"""سئوی سمت سرور — robots.txt، sitemap.xml و سرو HTML صفحات وبلاگ/پروژه با متای تزریق‌شده. فرانت‌اند یک SPA است؛ خزنده‌های اجتماعی (تلگرام، واتس‌اپ و...) جاوااسکریپت اجرا نمی‌کنند. این ماژول همان index.html بیلد فرانت‌اند را می‌خواند و پیش از ارسال، تگ‌های title / description / OG / canonical / hreflang / JSON-LD را از دیتابیس داخل آن تزریق می‌کند — بازدیدکننده‌های عادی همان SPA همیشگی را می‌بینند و خزنده‌ها متای درست را می‌خوانند. دو زبانه بودن: نسخه فارسی روی مسیرهای اصلی و نسخه انگلیسی روی /en/... سرو می‌شود؛ هر دو در sitemap با hreflang متقابل و در head هر صفحه با link alternate معرفی می‌شوند. """ 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"] # صفحاتی که متای آن‌ها از seo_meta خوانده می‌شود — کلید همان page_key است META_KEYS = ["home", "about", "services", "projects", "investment", "blog", "faq", "contact"] FALLBACK_FA_BRAND = "گروه ساختمانی آرامش" FALLBACK_EN_BRAND = "Aramesh Construction Group" 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 _en_path(path: str) -> str: """مسیر انگلیسی معادل یک مسیر فارسی — / و /en یکدیگرند.""" return "/en" if path == "/" else f"/en{path}" def _fa_path(path: str) -> str: """مسیر فارسی معادل یک مسیر انگلیسی.""" if path == "/en": return "/" return path[3:] if path.startswith("/en/") else path def _alternates(path: str, lang: str) -> str: """تگ‌های hreflang متقابل فارسی/انگلیسی برای head.""" fa = path if lang == "fa" else _fa_path(path) en = _en_path(path) if lang == "fa" else path return ( f'\n' f' \n' f' ' ) 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, lang: str = "fa", og_image: str = "", og_type: str = "website", jsonld: dict | None = None, noscript: str = "", ) -> HTMLResponse: """index.html فرانت‌اند با متای تزریق‌شده؛ اگر بیلد در دسترس نبود، صفحه حداقلی.""" title = title.strip() or (FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND) description = description.strip() canonical = _absolute(path) image = _absolute(og_image) esc = html.escape alternates = _alternates(path, lang) # تگ title جای تگ موجود index.html را می‌گیرد و در بلوک تکرار نمی‌شود tags = [ f'', 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: # بیلد فرانت‌اند سوار نشده — صفحه حداقلی با خودِ متا تا خزنده‌ها دست خالی نمانند lang_attr = 'lang="en" dir="ltr"' if lang == "en" else 'lang="fa" dir="rtl"' body = f"

{esc(title)}

{esc(description)}

" return HTMLResponse( f'' f"{esc(title)}{alternates}{block}{body}" ) if lang == "en": # پوسته بیلدشده فارسی است — برای نسخه انگلیسی جهت، زبان و برند سند عوض می‌شود raw = re.sub(r']*>', '', raw, count=1) raw = raw.replace(FALLBACK_FA_BRAND, FALLBACK_EN_BRAND) raw = raw.replace('"inLanguage":"fa"', '"inLanguage":"en"') # description استاتیک حذف می‌شود تا با متای تزریقی تکراری نشود raw = re.sub(r']*/>\s*', '', raw, count=1) # og:locale — در پوسته استاتیک به‌روز می‌شود و از بلوک تزریقی حذف تا دوباره نوشته نشود og_locale = "en_US" if lang == "en" else "fa_IR" if re.search(r'.*?", f"{esc(title)}", raw, count=1, flags=re.S) raw = raw.replace("", f" {alternates}\n {block}\n ", 1) if noscript: raw = raw.replace("", f"\n ", 1) return HTMLResponse(raw) def _brand_name(db: Session, lang: str = "fa") -> str: brand = db.query(models.Brand).first() if brand is None: return FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND if lang == "en": return brand.name_en or brand.name or FALLBACK_EN_BRAND return brand.name or FALLBACK_FA_BRAND def _page_meta(db: Session, page_key: str) -> models.SeoMeta | None: return db.query(models.SeoMeta).filter(models.SeoMeta.page_key == page_key).first() def _meta_for(db: Session, page_key: str, lang: str) -> tuple[str, str]: """عنوان و توضیح سئوی یک صفحه به زبان درخواستی — با برگشت به فارسی.""" meta = _page_meta(db, page_key) if meta is None: return "", "" if lang == "en": return meta.title_en or meta.title or "", meta.description_en or meta.description or "" return meta.title or "", meta.description or "" # ---------- 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") def _sitemap_entry(loc: str, fa: str, en: str) -> str: esc = html.escape return ( f" {esc(_absolute(loc))}\n" f' \n' f' \n' f" " ) @router.get("/sitemap.xml", response_class=Response) def sitemap(db: Session = Depends(get_db)): fa_urls = list(STATIC_PATHS) fa_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() ] fa_urls += [ f"/projects/{p.slug}" for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all() ] items = "\n".join(_sitemap_entry(u, u, _en_path(u)) for u in fa_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", ) def _article_pair(article: models.Article, lang: str) -> tuple[str, str]: """عنوان و چکیده مقاله به زبان درخواستی — با برگشت به فارسی.""" if lang == "en": return article.title_en or article.title, article.excerpt_en or article.excerpt return article.title, article.excerpt @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) title, excerpt = _article_pair(article, "fa") 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": title, "description": 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(title)}

{html.escape(first_text)}

" return _shell( title=f"{title} | {brand}", description=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) jsonld = { "@context": "https://schema.org", "@type": "WebPage", "name": project.title, "description": project.summary, "image": _absolute(project.image), "inLanguage": "fa", } return _shell( title=f"{project.title} | {brand}", description=project.summary, path=f"/projects/{project.slug}", og_image=project.image, jsonld=jsonld, ) # ---------- صفحات انگلیسی (/en/...) ---------- def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse: brand = _brand_name(db, "en") title, excerpt = _article_pair(article, "en") body = article.body_en if isinstance(article.body_en, dict) else article.body first_text = _doc_text(body) if not isinstance(body, list) else " ".join(body[:2]) jsonld = { "@context": "https://schema.org", "@type": "Article", "headline": title, "description": excerpt, "image": _absolute(article.image), "inLanguage": "en", "author": {"@type": "Organization", "name": brand}, "publisher": {"@type": "Organization", "name": brand}, "mainEntityOfPage": _absolute(f"/en/blog/{article.slug}"), } noscript = f"

{html.escape(title)}

{html.escape(first_text)}

" return _shell( title=f"{title} | {brand}", description=excerpt, path=f"/en/blog/{article.slug}", lang="en", og_image=article.image, og_type="article", jsonld=jsonld, noscript=noscript, ) @router.get("/en", response_class=HTMLResponse) @router.get("/en/", response_class=HTMLResponse) def en_home(db: Session = Depends(get_db)): title, description = _meta_for(db, "home", "en") if not title: title = _brand_name(db, "en") return _shell(title=title, description=description, path="/en", lang="en") @router.get("/en/blog", response_class=HTMLResponse) def en_blog_list(db: Session = Depends(get_db)): meta = _page_meta(db, "blog") title = meta.title_en or meta.title if meta else "" description = meta.description_en or meta.description if meta else "" if not title: title = "Blog | " + _brand_name(db, "en") return _shell(title=title, description=description, path="/en/blog", lang="en") @router.get("/en/blog/{slug}", response_class=HTMLResponse) def en_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: return _shell(title=_brand_name(db, "en"), description="", path=f"/en/blog/{slug}", lang="en") return _en_blog_detail(article, db) @router.get("/en/projects", response_class=HTMLResponse) def en_projects_list(db: Session = Depends(get_db)): meta = _page_meta(db, "projects") title = meta.title_en or meta.title if meta else "" description = meta.description_en or meta.description if meta else "" if not title: title = "Projects | " + _brand_name(db, "en") return _shell(title=title, description=description, path="/en/projects", lang="en") @router.get("/en/projects/{slug}", response_class=HTMLResponse) def en_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, "en"), description="", path=f"/en/projects/{slug}", lang="en") brand = _brand_name(db, "en") title = project.title_en or project.title summary = project.summary_en or project.summary jsonld = { "@context": "https://schema.org", "@type": "WebPage", "name": title, "description": summary, "image": _absolute(project.image), "inLanguage": "en", } return _shell( title=f"{title} | {brand}", description=summary, path=f"/en/projects/{project.slug}", lang="en", og_image=project.image, jsonld=jsonld, ) @router.get("/en/{path:path}", response_class=HTMLResponse) def en_page(path: str, db: Session = Depends(get_db)): """بقیه مسیرهای انگلیسی (about، services، ...) — متا از seo_meta به انگلیسی.""" page_key = path.split("/")[0] if path else "" title, description = _meta_for(db, page_key, "en") if page_key in META_KEYS else ("", "") brand = _brand_name(db, "en") if not title: # عنوان پیش‌فرض از برچسب ناوبری انگلیسی همان صفحه nav = db.query(models.NavItem).filter(models.NavItem.href == f"/{page_key}").first() if page_key else None label = (nav.label_en or nav.label) if nav else "" title = f"{label} | {brand}" if label else brand return _shell(title=title, description=description, path=f"/en/{path}".rstrip("/"), lang="en")