"""سئوی سمت سرور — robots.txt، sitemap.xml و سرو HTML صفحات وبلاگ/پروژه با متای تزریق‌شده. فرانت‌اند یک SPA است؛ خزنده‌های اجتماعی (تلگرام، واتس‌اپ و...) جاوااسکریپت اجرا نمی‌کنند. این ماژول همان index.html بیلد فرانت‌اند را می‌خواند و پیش از ارسال، تگ‌های title / description / OG / canonical / JSON-LD را از دیتابیس داخل آن تزریق می‌کند — بازدیدکننده‌های عادی همان SPA همیشگی را می‌بینند و خزنده‌ها متای درست را می‌خوانند. """ import html import json import re from pathlib import Path from fastapi import APIRouter, Depends, Response from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from . import models from .config import settings from .database import get_db router = APIRouter() STATIC_PATHS = ["/", "/about", "/services", "/projects", "/investment", "/blog", "/faq", "/contact"] def _absolute(path: str) -> str: if not path: return "" if path.startswith(("http://", "https://")): return path site = settings.site_url.rstrip("/") return f"{site}{path}" if site else path def _read_index() -> str | None: try: return (Path(settings.frontend_dist) / "index.html").read_text(encoding="utf-8") except OSError: return None def _doc_text(doc: object, limit: int = 300) -> str: """متن ساده سند TipTap — برای noscript و پیش‌نمایش.""" words: list[str] = [] def walk(node: object) -> None: if not isinstance(node, dict): return if node.get("type") == "text" and isinstance(node.get("text"), str): words.append(node["text"]) for child in node.get("content") or []: walk(child) if isinstance(doc, dict): for block in doc.get("content") or []: walk(block) text = " ".join(words).strip() return text[:limit] + ("…" if len(text) > limit else "") def _shell( *, title: str, description: str, path: str, og_image: str = "", og_type: str = "website", jsonld: dict | None = None, noscript: str = "", ) -> HTMLResponse: """index.html فرانت‌اند با متای تزریق‌شده؛ اگر بیلد در دسترس نبود، صفحه حداقلی.""" title = title.strip() or "گروه ساختمانی آرامش" description = description.strip() canonical = _absolute(path) image = _absolute(og_image) esc = html.escape # تگ title جای تگ موجود index.html را می‌گیرد و در بلوک تکرار نمی‌شود tags = [ f'', f'', f'', f'', f'', f'', ] if image: tags.append(f'') if jsonld: payload = json.dumps(jsonld, ensure_ascii=False).replace("{payload}') block = "\n ".join(tags) raw = _read_index() if raw is None: # بیلد فرانت‌اند سوار نشده — صفحه حداقلی با خودِ متا تا خزنده‌ها دست خالی نمانند body = f"

{esc(title)}

{esc(description)}

" return HTMLResponse( f'' f"{esc(title)}{block}{body}" ) raw = re.sub(r".*?", f"{esc(title)}", raw, count=1, flags=re.S) raw = raw.replace("", f" {block}\n ", 1) if noscript: raw = raw.replace("", f"\n ", 1) return HTMLResponse(raw) def _brand_name(db: Session) -> str: brand = db.query(models.Brand).first() return brand.name if brand else "گروه ساختمانی آرامش" def _page_meta(db: Session, page_key: str) -> models.SeoMeta | None: return db.query(models.SeoMeta).filter(models.SeoMeta.page_key == page_key).first() # ---------- robots.txt و sitemap.xml ---------- @router.get("/robots.txt", response_class=Response) def robots(db: Session = Depends(get_db)): lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""] if settings.site_url: lines.append(f"Sitemap: {_absolute('/sitemap.xml')}") return Response(content="\n".join(lines), media_type="text/plain; charset=utf-8") @router.get("/sitemap.xml", response_class=Response) def sitemap(db: Session = Depends(get_db)): urls = list(STATIC_PATHS) urls += [ f"/blog/{a.slug}" for a in db.query(models.Article) .filter(models.Article.status == "published") .order_by(models.Article.position, models.Article.id) .all() ] urls += [ f"/projects/{p.slug}" for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all() ] items = "\n".join( f" {html.escape(_absolute(u))}" for u in urls ) xml = ( '\n' '\n' f"{items}\n\n" ) return Response(content=xml, media_type="application/xml; charset=utf-8") # ---------- صفحات وبلاگ ---------- @router.get("/blog", response_class=HTMLResponse) def blog_list(db: Session = Depends(get_db)): meta = _page_meta(db, "blog") return _shell( title=meta.title if meta and meta.title else "وبلاگ | " + _brand_name(db), description=meta.description if meta else "", path="/blog", og_image=meta.image if meta else "", ) @router.get("/blog/{slug}", response_class=HTMLResponse) def blog_detail(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: # مقاله ناموجود یا پیش‌نویس — پوسته ساده بدون متا؛ SPA پیام «یافت نشد» را نشان می‌دهد return _shell(title=_brand_name(db), description="", path=f"/blog/{slug}") brand = _brand_name(db) first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2]) jsonld = { "@context": "https://schema.org", "@type": "Article", "headline": article.title, "description": article.excerpt, "image": _absolute(article.image), "inLanguage": "fa", "author": {"@type": "Organization", "name": brand}, "publisher": {"@type": "Organization", "name": brand}, "mainEntityOfPage": _absolute(f"/blog/{article.slug}"), } noscript = f"

{html.escape(article.title)}

{html.escape(first_text)}

" return _shell( title=f"{article.title} | {brand}", description=article.excerpt, path=f"/blog/{article.slug}", og_image=article.image, og_type="article", jsonld=jsonld, noscript=noscript, ) # ---------- صفحات پروژه ---------- @router.get("/projects", response_class=HTMLResponse) def projects_list(db: Session = Depends(get_db)): meta = _page_meta(db, "projects") return _shell( title=meta.title if meta and meta.title else "پروژه‌ها | " + _brand_name(db), description=meta.description if meta else "", path="/projects", ) @router.get("/projects/{slug}", response_class=HTMLResponse) def project_detail(slug: str, db: Session = Depends(get_db)): project = db.query(models.Project).filter(models.Project.slug == slug).first() if project is None: return _shell(title=_brand_name(db), description="", path=f"/projects/{slug}") brand = _brand_name(db) return _shell( title=f"{project.title} | {brand}", description=project.summary, path=f"/projects/{project.slug}", og_image=project.image, jsonld={ "@context": "https://schema.org", "@type": "WebPage", "name": project.title, "description": project.summary, "image": _absolute(project.image), "inLanguage": "fa", }, )