Files
aramesh-backend/backend/app/api/public.py
T
aramesh-site deploy 1d0c1ae53d feat(seo): ایندکس‌پذیری کامل، محتوای سمت سرور برای خزنده‌های AI و رفع باگ ایندکس
سئوی سمت سرور (backend/app/seo_pages.py)
- متا/canonical/hreflang/JSON-LD برای همهٔ صفحات عمومی (شش صفحهٔ استاتیک فارسی که قبلاً پوستهٔ خالی بودند)
- canonical سمت سرور (قبلاً فقط با JS ست می‌شد)، ۴۰۴ واقعی برای مسیرهای ناموجود
- اسکیما: Organization با لوگوی ImageObject، FAQPage، LocalBusiness، BreadcrumbList،
  CreativeWork پروژه با همهٔ تصاویر به‌صورت ImageObject (نام/توضیح + موقعیت پروژه)
- hreflang فقط وقتی ترجمهٔ انگلیسی واقعاً وجود دارد؛ sitemap با URLهای درصد‌گذاری‌شده
- llms.txt و robots.txt تازه‌ساز از دیتابیس؛ سیاست صریح خزنده‌های AI + Allow: /api/site
- _site_content_html: محتوای واقعی سایت به‌صورت HTML خوانا در <noscript> —
  خزنده‌های بدون جاوااسکریپت (GPTBot/OAI-SearchBot/ClaudeBot/PerplexityBot) از ۲۴۰ نویسه
  به ۸٬۵۰۰+ نویسه محتوای معنایی می‌رسند
- Cache-Control: no-store روی HTML و llms.txt (جلوگیری از کش کهنهٔ CDN)، max-age روی sitemap

فرانت‌اند
- api.tsx: خواندن دادهٔ تزریق‌شدهٔ سرور (window.__SITE__) به‌عنوان حالت اولیه — رندر بدون XHR
- App.tsx: اسکلت بارگذاری به‌جای DOM خالی (رفع Soft 404 گوگل)
- index.html: فاوآیکون واقعی برند (قبلاً به vite.svg ناموجود اشاره می‌کرد) + preload فونت

زیرساخت
- Dockerfile: آینهٔ PyPI (Tsinghua/Aliyun) — PyPI برای IP ایران بلاک است
- Caddyfile: هر مسیر پسوند‌دار بدون فایل واقعی ۴۰۴ می‌گیرد (قبلاً HTML اسپا برمی‌گرداند)
- docker-compose.prod.yml: مسیرهای seo برای صفحات استاتیک + llms.txt + کلیدهای IndexNow

داده
- seed_data*.py: حذف دفاتر و معرفی‌های دمو (که در هر استقرار برمی‌گشتند)؛
  تنها دفتر واقعی: اصفهان، ملاصدرا شمالی

دارایی‌ها
- فاوآیکون/آیکون‌های برند از لوگوی رسمی + کلیدهای IndexNow
2026-10-09 23:29:26 +00:00

198 lines
8.4 KiB
Python

"""انتهای عمومی — خواندن محتوای سایت بدون نیاز به احراز هویت."""
import boto3
from botocore.config import Config as BotoConfig
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from sqlalchemy import text
from sqlalchemy.orm import Session
from ..config import settings
from ..database import get_db
from .. import models
from ..schemas import (
ArticleOut,
BeforeAfterOut,
BrandOut,
FaqOut,
ProjectOut,
ServiceOut,
StatOut,
TeamMemberOut,
TestimonialOut,
TimelineItemOut,
WhyUsItemOut,
)
router = APIRouter()
@router.get("/live")
def live():
"""زنده‌بودن پروسه — بدون وابستگی؛ برای restart gate استفاده نمی‌شود."""
return {"status": "ok"}
@router.get("/health")
def health(db: Session = Depends(get_db)):
"""بررسی عمق سلامت — اتصال DB و MinIO واقعاً آزموده می‌شود؛
در صورت خرابی هر وابستگی، 503 با جزئیات برمی‌گردد."""
checks: dict[str, str] = {}
try:
db.execute(text("SELECT 1"))
checks["database"] = "ok"
except Exception:
checks["database"] = "error"
try:
s3 = boto3.client(
"s3",
endpoint_url=settings.s3_endpoint,
aws_access_key_id=settings.s3_access_key,
aws_secret_access_key=settings.s3_secret_key,
region_name="us-east-1",
config=BotoConfig(connect_timeout=2, read_timeout=2, retries={"max_attempts": 1}),
)
s3.head_bucket(Bucket=settings.s3_bucket)
checks["storage"] = "ok"
except Exception:
checks["storage"] = "error"
if any(state != "ok" for state in checks.values()):
return JSONResponse(
status_code=503,
content={"status": "degraded", "checks": checks},
)
return {"status": "ok", "app": "Aramesh Building Group API", "checks": checks}
@router.get("/brand", response_model=BrandOut)
def get_brand(db: Session = Depends(get_db)):
brand = db.query(models.Brand).first()
if brand is None:
raise HTTPException(status_code=404, detail="اطلاعات برند یافت نشد")
return brand
@router.get("/services", response_model=list[ServiceOut])
def get_services(db: Session = Depends(get_db)):
return db.query(models.Service).order_by(models.Service.position, models.Service.id).all()
@router.get("/projects", response_model=list[ProjectOut])
def get_projects(db: Session = Depends(get_db)):
return db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
@router.get("/projects/{slug}", response_model=ProjectOut)
def get_project(slug: str, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.slug == slug).first()
if project is None:
raise HTTPException(status_code=404, detail="پروژه یافت نشد")
return project
@router.get("/articles", response_model=list[ArticleOut])
def get_articles(db: Session = Depends(get_db)):
return (
db.query(models.Article)
.filter(models.Article.status == "published")
.order_by(models.Article.position, models.Article.id)
.all()
)
@router.get("/articles/{slug}", response_model=ArticleOut)
def get_article(slug: str, db: Session = Depends(get_db)):
article = (
db.query(models.Article)
.filter(models.Article.slug == slug, models.Article.status == "published")
.first()
)
if article is None:
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
return article
@router.get("/testimonials", response_model=list[TestimonialOut])
def get_testimonials(db: Session = Depends(get_db)):
return db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all()
@router.get("/faqs", response_model=list[FaqOut])
def get_faqs(db: Session = Depends(get_db)):
return db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all()
@router.get("/team", response_model=list[TeamMemberOut])
def get_team(db: Session = Depends(get_db)):
return db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all()
@router.get("/timeline", response_model=list[TimelineItemOut])
def get_timeline(db: Session = Depends(get_db)):
return db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all()
@router.get("/stats", response_model=list[StatOut])
def get_stats(db: Session = Depends(get_db)):
return db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all()
@router.get("/why-us", response_model=list[WhyUsItemOut])
def get_why_us(db: Session = Depends(get_db)):
return db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all()
@router.get("/before-after", response_model=BeforeAfterOut)
def get_before_after(db: Session = Depends(get_db)):
item = db.query(models.BeforeAfter).first()
if item is None:
raise HTTPException(status_code=404, detail="بخش قبل/بعد یافت نشد")
return item
def build_site_payload(db: Session) -> dict:
"""همه محتوای سایت در یک دیکشنری — هم برای GET /api/site و هم برای
تزریق مستقیم داخل HTML (window.__SITE__) در app/seo_pages.py.
تزریق داخل HTML یعنی فرانت‌اند بدون انتظار برای XHR رندر می‌شود —
خزنده‌های رندرکننده (گوگل) هیچ‌وقت صفحهٔ نیمه‌خالی نمی‌بینند."""
return {
"brand": db.query(models.Brand).first(),
"nav": db.query(models.NavItem).order_by(models.NavItem.position, models.NavItem.id).all(),
"hero": db.query(models.Hero).first(),
"story": db.query(models.StorySection).first(),
"section_headings": db.query(models.SectionHeadingContent).order_by(models.SectionHeadingContent.id).all(),
"page_heroes": db.query(models.PageHeroContent).order_by(models.PageHeroContent.id).all(),
"services": db.query(models.Service).order_by(models.Service.position, models.Service.id).all(),
"projects": db.query(models.Project).order_by(models.Project.position, models.Project.id).all(),
# فقط مقاله‌های منتشرشده — پیش‌نویس‌ها از انتهای مدیریتی خوانده می‌شوند
"articles": db.query(models.Article)
.filter(models.Article.status == "published")
.order_by(models.Article.position, models.Article.id)
.all(),
"testimonials": db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all(),
"faqs": db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all(),
"team": db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all(),
"timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all(),
"stats": db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all(),
"why_us": db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all(),
"before_after": db.query(models.BeforeAfter).first(),
"investment_models": db.query(models.InvestmentModel).order_by(models.InvestmentModel.position, models.InvestmentModel.id).all(),
"process_steps": db.query(models.ProcessStep).order_by(models.ProcessStep.position, models.ProcessStep.id).all(),
"offices": db.query(models.Office).order_by(models.Office.position, models.Office.id).all(),
"gallery": db.query(models.GalleryImage).order_by(models.GalleryImage.position, models.GalleryImage.id).all(),
"status_labels": db.query(models.StatusLabel).order_by(models.StatusLabel.id).all(),
"contact_section": db.query(models.ContactSectionContent).first(),
"newsletter": db.query(models.NewsletterSectionContent).first(),
"footer": db.query(models.FooterContent).first(),
"seo_meta": db.query(models.SeoMeta).order_by(models.SeoMeta.id).all(),
}
@router.get("/site")
def get_site(db: Session = Depends(get_db)):
"""همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانت‌اند."""
return build_site_payload(db)