Files
aramesh-backend/backend/app/seo_pages.py
T

957 lines
40 KiB
Python

"""سئوی سمت سرور — robots.txt، sitemap.xml، llms.txt و سرو همهٔ صفحات با متای تزریقی.
فرانت‌اند یک SPA است؛ خزنده‌های جست‌وجو و شبکه‌های اجتماعی جاوااسکریپت اجرا نمی‌کنند.
این ماژول همان index.html بیلد فرانت‌اند را می‌خواند و پیش از ارسال، تگ‌های
title / description / canonical / OG / hreflang / JSON-LD را از دیتابیس داخل آن تزریق می‌کند —
بازدیدکننده‌های عادی همان SPA همیشگی را می‌بینند و خزنده‌ها متای درست را در HTML اولیه می‌خوانند.
(اسکیمای صفحه داخل <script id="seo-jsonld"> تزریق می‌شود؛ همان اسکریپتی که سمت مرورگر
بعد از هیدریشن بازنویسی می‌شود — پس نسخهٔ تکراری ساخته نمی‌شود. اسکیمای Organization
جداگانه و پایدار تزریق می‌شود و سمت مرورگر دست نمی‌خورد.)
دو زبانه بودن: نسخه فارسی روی مسیرهای اصلی و نسخه انگلیسی روی /en/... سرو می‌شود.
لینک‌های alternate (هم در head و هم در sitemap) فقط وقتی اعلام می‌شوند که ترجمهٔ
انگلیسی واقعاً وجود داشته باشد — وگرنه گوگل محتوای فارسی را با برچسب «en» می‌بیند
و مجموعهٔ hreflang ناسازگار می‌شود. صفحهٔ انگلیسیِ ترجمه‌نشده برای بازدیدکننده سرو
می‌شود ولی هیچ ادعای hreflang ندارد.
حالت پیش‌نمایش (PREVIEW_MODE=on): robots.txt همه‌چیز را می‌بندد، sitemap خالی است
و llms.txt با کد ۴۰۴ پاسخ می‌دهد — هیچ نشانی‌ای از سایت خصوصی بیرون درز نمی‌کند.
"""
import html
import json
import re
from datetime import date, datetime
from functools import lru_cache
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"
# تصویر پیش‌فرض شبکه‌های اجتماعی — در frontend/public بیلد می‌شود
DEFAULT_OG_IMAGE = "/og-default.jpg"
DEFAULT_OG_SIZE = (1200, 630)
# خزنده‌های هوش مصنوعی جست‌وجو/ارجاع — سیاست صریح «مجاز» تا قالب‌های پیش‌فرض
# «بستن همه‌چیز» اتفاقی آن‌ها را نبندند (GPTBot آموزش است، OAI-SearchBot ارجاع چت‌جی‌پی‌تی)
AI_SEARCH_BOTS = [
"GPTBot",
"OAI-SearchBot",
"ChatGPT-User",
"ClaudeBot",
"Claude-SearchBot",
"PerplexityBot",
"Perplexity-User",
"Google-Extended",
"Applebot-Extended",
]
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 _clip(text: str, limit: int) -> str:
"""برش متن به طول مشخص برای توضیح‌های تک‌خطی (llms.txt)."""
text = " ".join((text or "").split())
return text[:limit] + ("…" if len(text) > limit else "")
# ---------- تاریخ مقاله — ISO میلادی یا شمسی دست‌نویس → ISO برای datePublished ----------
_FA_DIGITS = str.maketrans("۰۱۲۳۴۵۶۷۸۹", "0123456789")
_FA_MONTHS = {
"فروردین": 1, "اردیبهشت": 2, "خرداد": 3, "تیر": 4, "مرداد": 5, "شهریور": 6,
"مهر": 7, "آبان": 8, "آذر": 9, "دی": 10, "بهمن": 11, "اسفند": 12,
}
def _jalali_to_gregorian(jy: int, jm: int, jd: int) -> tuple[int, int, int]:
"""تبدیل تاریخ جلالی به میلادی — الگوریتم استاندارد jalaali."""
jy += 1595
days = -355668 + (365 * jy) + ((jy // 33) * 8) + (((jy % 33) + 3) // 4) + jd
days += (jm - 1) * 31 if jm < 7 else ((jm - 7) * 30) + 186
gy = 400 * (days // 146097)
days %= 146097
if days > 36524:
days -= 1
gy += 100 * (days // 36524)
days %= 36524
if days >= 365:
days += 1
gy += 4 * (days // 1461)
days %= 1461
if days > 365:
gy += (days - 1) // 365
days = (days - 1) % 365
gd = days + 1
leap = (gy % 4 == 0 and gy % 100 != 0) or (gy % 400 == 0)
for gm, md in enumerate([31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31], start=1):
if gd <= md:
return gy, gm, gd
gd -= md
return gy, 12, 31
def _parse_site_date(value: str | None) -> str | None:
"""تاریخ مقاله → ISO (YYYY-MM-DD) برای datePublished اسکیما؛ تاریخ نامفهوم → None.
هم ISO میلادی (خروجی انتخابگر تاریخ پنل) و هم شمسی دست‌نویس («۲۴ مرداد ۱۴۰۳») را می‌فهمد."""
raw = (value or "").strip().translate(_FA_DIGITS)
if not raw:
return None
try:
return datetime.fromisoformat(raw).date().isoformat()
except ValueError:
pass
m = re.fullmatch(r"(\d{1,2})\s*([\u0600-\u06FF]+)\s*(\d{4})", raw)
if m and m.group(2) in _FA_MONTHS:
gy, gm, gd = _jalali_to_gregorian(int(m.group(3)), _FA_MONTHS[m.group(2)], int(m.group(1)))
return date(gy, gm, gd).isoformat()
return None
# ---------- alternates — فقط وقتی ترجمهٔ انگلیسی واقعاً وجود دارد ----------
def _alternate_pairs(path: str, lang: str, has_en: bool) -> list[tuple[str, str]]:
"""جفت‌های (hreflang، href) یک صفحه — مبنای مشترک head و sitemap.
صفحهٔ انگلیسیِ ترجمه‌نشده هیچ ادعایی ندارد (لیست خالی)."""
fa = path if lang == "fa" else _fa_path(path)
if lang == "en" and not has_en:
return []
pairs = [("fa", _absolute(fa)), ("x-default", _absolute(fa))]
if has_en:
en = _en_path(path) if lang == "fa" else path
pairs.insert(1, ("en", _absolute(en)))
return pairs
def _alternates(path: str, lang: str, has_en: bool = True) -> str:
"""تگ‌های link alternate برای head — از همان جفت‌های sitemap."""
pairs = _alternate_pairs(path, lang, has_en)
return "\n ".join(
f'<link rel="alternate" hreflang="{lang_code}" href="{html.escape(href_)}" />'
for lang_code, href_ in pairs
)
def _article_has_en(article: models.Article) -> bool:
"""آیا ترجمهٔ انگلیسی واقعی برای مقاله ثبت شده است؟"""
if (article.title_en or "").strip() or (article.excerpt_en or "").strip():
return True
return bool(isinstance(article.body_en, dict) and (article.body_en.get("content") or []))
def _project_has_en(project: models.Project) -> bool:
return bool((project.title_en or "").strip() or (project.summary_en or "").strip())
# ---------- پوستهٔ HTML با متای تزریقی ----------
def _read_index() -> str | None:
try:
return (Path(settings.frontend_dist) / "index.html").read_text(encoding="utf-8")
except OSError:
return None
@lru_cache(maxsize=1)
def _default_og() -> str:
"""تصویر پیش‌فرض OG — فقط وقتی فایل واقعاً در بیلد فرانت‌اند باشد."""
if (Path(settings.frontend_dist) / DEFAULT_OG_IMAGE.lstrip("/")).is_file():
return _absolute(DEFAULT_OG_IMAGE)
return ""
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",
has_en: bool = True,
og_image: str = "",
og_image_alt: str = "",
og_type: str = "website",
jsonld: dict | list | None = None,
org_jsonld: dict | None = None,
noscript: str = "",
status_code: int = 200,
) -> HTMLResponse:
"""index.html فرانت‌اند با متای تزریقی؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
title = title.strip() or (FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND)
description = description.strip()
canonical = _absolute(path)
esc = html.escape
alternates = _alternates(path, lang, has_en)
# تصویر OG — تصویر اختصاصی صفحه یا تصویر پیش‌فرض برند
custom_image = (og_image or "").strip()
image = _absolute(custom_image) if custom_image else _default_og()
image_is_default = not custom_image and bool(_default_og())
tags = [
f'<link rel="canonical" href="{esc(canonical)}" />',
f'<meta name="description" content="{esc(description)}" />',
f'<meta property="og:title" content="{esc(title)}" />',
f'<meta property="og:description" content="{esc(description)}" />',
f'<meta property="og:type" content="{esc(og_type)}" />',
f'<meta property="og:url" content="{esc(canonical)}" />',
f'<meta property="og:locale" content="{"en_US" if lang == "en" else "fa_IR"}" />',
f'<meta property="og:locale:alternate" content="{"fa_IR" if lang == "en" else "en_US"}" />',
f'<meta name="twitter:card" content="summary_large_image" />',
f'<meta name="twitter:title" content="{esc(title)}" />',
f'<meta name="twitter:description" content="{esc(description)}" />',
]
if image:
tags.append(f'<meta property="og:image" content="{esc(image)}" />')
tags.append(f'<meta name="twitter:image" content="{esc(image)}" />')
tags.append(f'<meta property="og:image:alt" content="{esc(og_image_alt.strip() or title)}" />')
if image_is_default:
tags.append(f'<meta property="og:image:width" content="{DEFAULT_OG_SIZE[0]}" />')
tags.append(f'<meta property="og:image:height" content="{DEFAULT_OG_SIZE[1]}" />')
if jsonld:
payload = json.dumps(jsonld, ensure_ascii=False).replace("</", "<\\/")
# id="seo-jsonld" — سمت مرورگر همان اسکریپت بازنویسی می‌شود، نه اینکه تکراری ساخته شود
tags.append(f'<script type="application/ld+json" id="seo-jsonld">{payload}</script>')
if org_jsonld:
payload = json.dumps(org_jsonld, ensure_ascii=False).replace("</", "<\\/")
tags.append(f'<script type="application/ld+json" id="seo-org-jsonld">{payload}</script>')
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"<h1>{esc(title)}</h1><p>{esc(description)}</p>"
return HTMLResponse(
f'<!doctype html><html {lang_attr}><head><meta charset="utf-8">'
f"<title>{esc(title)}</title>{alternates}{block}</head><body>{body}</body></html>",
status_code=status_code,
)
# اسکیمای Organization مینیمالِ پوستهٔ استاتیک حذف می‌شود تا با نسخهٔ غنی تزریقی تکراری نشود
if org_jsonld:
raw = re.sub(r'<script type="application/ld\+json">[^<]*</script>', "", raw, count=1)
if lang == "en":
# پوسته بیلدشده فارسی است — برای نسخه انگلیسی جهت، زبان و برند سند عوض می‌شود
raw = re.sub(r'<html[^>]*>', '<html lang="en" dir="ltr">', raw, count=1)
raw = raw.replace(FALLBACK_FA_BRAND, FALLBACK_EN_BRAND)
# description استاتیک حذف می‌شود تا با متای تزریقی تکراری نشود
raw = re.sub(r'<meta name="description"[^>]*/>\s*', '', raw, count=1)
# og:locale — در پوسته استاتیک به‌روز می‌شود و از بلوک تزریقی حذف تا دوباره نوشته نشود
og_locale = "en_US" if lang == "en" else "fa_IR"
if re.search(r'<meta property="og:locale"', raw):
raw = re.sub(
r'(<meta property="og:locale" content=")[^"]*(")',
lambda m: f'{m.group(1)}{og_locale}{m.group(2)}',
raw,
count=1,
)
tags = [t for t in tags if 'og:locale' not in t]
block = "\n ".join(tags)
raw = re.sub(r"<title>.*?</title>", f"<title>{esc(title)}</title>", raw, count=1, flags=re.S)
raw = raw.replace("</head>", f" {alternates}\n {block}\n </head>", 1)
if noscript:
raw = raw.replace("</body>", f"<noscript>{noscript}</noscript>\n </body>", 1)
return HTMLResponse(raw, status_code=status_code)
# ---------- موجودیت‌های ساخت‌یافته (JSON-LD) ----------
def _brand_row(db: Session) -> models.Brand | None:
return db.query(models.Brand).first()
def _brand_name(db: Session, lang: str = "fa") -> str:
brand = _brand_row(db)
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 ""
def _meta_desc_for(article: models.Article, lang: str) -> str:
"""توضیح سئوی مقاله به زبان درخواستی — توضیح اختصاصی در اولویت، چکیده در برگشت."""
if lang == "en":
return (article.meta_description_en or article.meta_description or article.excerpt_en or article.excerpt or "").strip()
return (article.meta_description or article.excerpt or "").strip()
def _org_jsonld(db: Session, lang: str) -> dict:
"""اسکیمای Organization — روی همهٔ صفحات تزریق می‌شود (موجودیت برند سایت).
فقط داده‌های واقعی: لوگو، شبکه‌های اجتماعی فوتر و تماس ثبت‌شده در پنل."""
brand = _brand_row(db)
org: dict = {
"@context": "https://schema.org",
"@type": "Organization",
"name": _brand_name(db, lang),
}
if settings.site_url:
org["url"] = _absolute("/")
logo = (brand.logo or "").strip() if brand else ""
if logo:
org["logo"] = _absolute(logo)
footer = db.query(models.FooterContent).first()
socials = []
if footer is not None:
socials = (footer.socials_en if lang == "en" and footer.socials_en else footer.socials) or []
same_as: list[str] = []
for item in socials if isinstance(socials, list) else []:
if isinstance(item, dict):
href_ = (item.get("href") or "").strip()
if href_.startswith("http") and href_ not in same_as:
same_as.append(href_)
if same_as:
org["sameAs"] = same_as
phone = (brand.phone or "").strip() if brand else ""
if phone:
org["contactPoint"] = [
{"@type": "ContactPoint", "contactType": "customer service", "telephone": phone}
]
email = (brand.email or "").strip() if brand else ""
if email:
org["email"] = email
return org
def _localbusiness_jsonld(db: Session, lang: str, path: str) -> dict | None:
"""اسکیمای GeneralContractor از دفاتر ثبت‌شده — برای صفحه‌های تماس و درباره."""
offices = db.query(models.Office).order_by(models.Office.position, models.Office.id).all()
if not offices:
return None
brand = _brand_row(db)
business: dict = {
"@context": "https://schema.org",
"@type": "GeneralContractor",
"name": _brand_name(db, lang),
"url": _absolute(path),
}
phone = (brand.phone or "").strip() if brand else ""
if phone:
business["telephone"] = phone
email = (brand.email or "").strip() if brand else ""
if email:
business["email"] = email
addresses: list[dict] = []
areas: list[str] = []
for office in offices:
city = ((office.city_en or office.city) if lang == "en" else office.city).strip()
street = ((office.address_en or office.address) if lang == "en" else office.address).strip()
address: dict = {"@type": "PostalAddress", "addressCountry": "IR"}
if street:
address["streetAddress"] = street
if city:
address["addressLocality"] = city
areas.append(city)
addresses.append(address)
business["address"] = addresses[0] if len(addresses) == 1 else addresses
if areas:
business["areaServed"] = sorted(set(areas))
return business
def _breadcrumb_jsonld(items: list[tuple[str, str]]) -> dict:
"""اسکیمای BreadcrumbList — متناظر با مسیر بطری بصری صفحه."""
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": i + 1, "name": name, "item": _absolute(url)}
for i, (name, url) in enumerate(items)
],
}
def _faq_entities(faqs: object, lang: str = "fa", faqs_en: object = None) -> list[dict]:
"""ساخت mainEntity اسکیمای FAQPage از ردیف‌های [{question, answer}]."""
rows = faqs if isinstance(faqs, list) else []
if lang == "en":
rows_en = faqs_en if isinstance(faqs_en, list) else None
if rows_en:
rows = rows_en
entities = []
for item in rows:
if isinstance(item, dict):
question = (item.get("question") or "").strip()
answer = (item.get("answer") or "").strip()
if question and answer:
entities.append(
{"@type": "Question", "name": question,
"acceptedAnswer": {"@type": "Answer", "text": answer}}
)
return entities
def _faq_page_jsonld(db: Session, lang: str) -> dict | None:
"""اسکیمای FAQPage صفحهٔ پرسش‌وپاسخ — از موجودیت faqs.
(گوگل از اردیبهشت ۱۴۰۵ نتیجهٔ غنی FAQ برای همهٔ سایت‌ها برداشت؛ این اسکیما برای
موتورهای هوش مصنوعی است که پرسش‌وپاسخ ساخت‌یافته را مستقیم می‌خوانند.)"""
rows = db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all()
entities = _faq_entities(
[{"question": f.question, "answer": f.answer} for f in rows],
lang,
[{"question": f.question_en, "answer": f.answer_en} for f in rows],
)
if not entities:
return None
return {"@context": "https://schema.org", "@type": "FAQPage", "mainEntity": entities}
def _article_jsonld(article: models.Article, lang: str, db: Session) -> dict:
"""اسکیمای Article — با نویسنده (Person در صورت ثبت نام)، تاریخ انتشار و ناشر."""
brand = _brand_name(db, lang)
title = (article.title_en or article.title) if lang == "en" else article.title
en_slug = ((article.slug_en or "").strip() or article.slug) if lang == "en" else article.slug
author_name = ((article.author_en or "").strip() or (article.author or "").strip()) if lang == "en" else (article.author or "").strip()
publisher: dict = {"@type": "Organization", "name": brand}
brand_row = _brand_row(db)
logo = (brand_row.logo or "").strip() if brand_row else ""
if logo:
publisher["logo"] = {"@type": "ImageObject", "url": _absolute(logo)}
schema: dict = {
"@context": "https://schema.org",
"@type": "Article",
"headline": title,
"description": _meta_desc_for(article, lang),
"inLanguage": lang,
"author": {"@type": "Person", "name": author_name} if author_name else {"@type": "Organization", "name": brand},
"publisher": publisher,
"mainEntityOfPage": _absolute(f"/blog/{article.slug}" if lang == "fa" else f"/en/blog/{en_slug}"),
}
image = _absolute(article.image) if (article.image or "").strip() else ""
if image:
schema["image"] = image
published = _parse_site_date(article.date_en if lang == "en" else article.date) or _parse_site_date(article.date)
if published:
schema["datePublished"] = published
return schema
def _nav_label(db: Session, href: str, lang: str) -> str:
"""برچسب فارسی/انگلیسی یک مسیر از ناوبری — برای پیش‌فرض عنوان و بردکرامب."""
nav = db.query(models.NavItem).filter(models.NavItem.href == href).first()
if nav is None:
return ""
return ((nav.label_en or nav.label) if lang == "en" else nav.label or "").strip()
# ---------- robots.txt و sitemap.xml و llms.txt ----------
@router.get("/robots.txt", response_class=Response)
def robots(db: Session = Depends(get_db)):
# پیش‌نمایش خصوصی — هیچ خزنده‌ای نباید چیزی ذخیره کند
if settings.preview_mode:
return Response(
content="User-agent: *\nDisallow: /\n",
media_type="text/plain; charset=utf-8",
)
lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""]
# سیاست صریح خزنده‌های هوش مصنوعی — ربات‌های جست‌وجو/ارجاع آزادند تا محتوا
# در پاسخ‌های ChatGPT/Claude/Perplexity/Gemini ارجاع شود
for bot in AI_SEARCH_BOTS:
lines += [f"User-agent: {bot}", "Allow: /", ""]
# خزندهٔ تهاجمی با سود کم
lines += ["User-agent: Bytespider", "Disallow: /", ""]
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 _static_has_en(db: Session, page_key: str) -> bool:
"""آیا صفحهٔ استاتیک ترجمهٔ انگلیسی واقعی دارد؟ (متای سئوی انگلیسی ثبت شده باشد)"""
meta = _page_meta(db, page_key)
return bool(
meta
and ((meta.title_en or "").strip() or (meta.description_en or "").strip())
)
@router.get("/sitemap.xml", response_class=Response)
def sitemap(db: Session = Depends(get_db)):
# پیش‌نمایش خصوصی — نقشه سایت خالی تا هیچ نشانی‌ای اعلام نشود
if settings.preview_mode:
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>\n'
)
return Response(content=xml, media_type="application/xml; charset=utf-8")
esc = html.escape
articles = {
a.slug: a
for a in db.query(models.Article)
.filter(models.Article.status == "published")
.order_by(models.Article.position, models.Article.id)
.all()
}
projects = {
p.slug: p for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
}
def entry(loc: str, lang: str, has_en: bool) -> str:
pairs = _alternate_pairs(loc, lang, has_en)
links = "\n".join(
f' <xhtml:link rel="alternate" hreflang="{code}" href="{esc(href_)}" />'
for code, href_ in pairs
)
return f" <url><loc>{esc(_absolute(loc))}</loc>\n{links}\n </url>"
urls: list[str] = []
# صفحات استاتیک فارسی + معادل‌های انگلیسیِ ترجمه‌شده
for path in STATIC_PATHS:
page_key = "home" if path == "/" else path.strip("/")
has_en = _static_has_en(db, page_key)
urls.append(entry(path, "fa", has_en))
if has_en:
urls.append(entry(_en_path(path), "en", True))
# مقاله‌ها و پروژه‌ها — alternate انگلیسی فقط با وجود ترجمهٔ واقعی
for slug, article in articles.items():
fa_path = f"/blog/{slug}"
has_en = _article_has_en(article)
urls.append(entry(fa_path, "fa", has_en))
if has_en:
urls.append(entry(_en_path(fa_path), "en", True))
for slug, project in projects.items():
fa_path = f"/projects/{slug}"
has_en = _project_has_en(project)
urls.append(entry(fa_path, "fa", has_en))
if has_en:
urls.append(entry(_en_path(fa_path), "en", True))
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" '
'xmlns:xhtml="http://www.w3.org/1999/xhtml">\n'
f"{chr(10).join(urls)}\n</urlset>\n"
)
return Response(content=xml, media_type="application/xml; charset=utf-8")
@router.get("/llms.txt", response_class=Response)
def llms_txt(db: Session = Depends(get_db)):
"""راهنمای سایت برای دستیارهای هوش مصنوعی — برند، واقعیت‌های کلیدی و صفحه‌های اصلی.
در حالت پیش‌نمایش خصوصی ۴۰۴ می‌دهد تا ساختار سایت بیرون درز نکند."""
if settings.preview_mode:
return Response(status_code=404)
brand = _brand_name(db, "en")
_, home_desc = _meta_for(db, "home", "en")
lines = [
f"# {brand}",
f"> {_clip(home_desc or 'Construction group — engineering, contracting and property investment.', 200)}",
"",
]
services = [
(s.title_en or s.title).strip()
for s in db.query(models.Service).order_by(models.Service.position, models.Service.id).all()
if (s.title_en or s.title).strip()
]
offices = db.query(models.Office).order_by(models.Office.position, models.Office.id).all()
cities = [
(o.city_en or o.city).strip() for o in offices if (o.city_en or o.city).strip()
]
facts = []
if services:
facts.append(f"- Services: {', '.join(services)}")
if cities:
facts.append(f"- Offices: {', '.join(sorted(set(cities)))}")
facts.append("- Languages: Persian at / and English at /en")
lines += ["## Key Facts", *facts, ""]
lines.append("## Pages")
for page_key, label in [
("home", "Home"), ("about", "About"), ("services", "Services"),
("projects", "Projects"), ("blog", "Blog"), ("faq", "FAQ"), ("contact", "Contact"),
]:
title, description = _meta_for(db, page_key, "en")
if page_key == "home":
url = "/"
else:
url = f"/en/{page_key}" if page_key != "projects" else "/en/projects"
summary = _clip(description, 100)
lines.append(f"- [{label}]({_absolute(url)}): {summary or label}")
featured_projects = (
db.query(models.Project).order_by(models.Project.position, models.Project.id).limit(6).all()
)
if featured_projects:
lines += ["", "## Featured Projects"]
for p in featured_projects:
title = (p.title_en or p.title).strip()
summary = _clip(p.summary_en or p.summary, 100)
lines.append(f"- [{title}]({_absolute('/en/projects/' + p.slug)}): {summary}")
latest = (
db.query(models.Article)
.filter(models.Article.status == "published")
.order_by(models.Article.position, models.Article.id)
.limit(6)
.all()
)
if latest:
lines += ["", "## Recent Articles"]
for a in latest:
title = (a.title_en or a.title).strip()
summary = _clip(a.excerpt_en or a.excerpt, 100)
lines.append(f"- [{title}]({_absolute('/en/blog/' + a.slug)}): {summary}")
brand_row = _brand_row(db)
contact_lines = []
if brand_row and (brand_row.phone or "").strip():
contact_lines.append(f"- Phone: {brand_row.phone.strip()}")
if brand_row and (brand_row.email or "").strip():
contact_lines.append(f"- Email: {brand_row.email.strip()}")
for o in offices:
city = (o.city_en or o.city).strip()
street = ((o.address_en or o.address) or o.address).strip()
if city or street:
contact_lines.append(f"- Office: {_clip(street + ('، ' + city if city and street else city), 120)}")
if contact_lines:
lines += ["", "## Contact", *contact_lines]
return Response(content="\n".join(lines) + "\n", media_type="text/plain; charset=utf-8")
# ---------- صفحات استاتیک فارسی ----------
def _static_shell(db: Session, *, path: str, page_key: str, lang: str = "fa") -> HTMLResponse:
"""پوستهٔ صفحات استاتیک — متا از seo_meta، اسکیمای اختصاصی هر صفحه."""
title, description = _meta_for(db, page_key, lang)
brand = _brand_name(db, lang)
if not title:
# عنوان پیش‌فرض از برچسب ناوبری همان صفحه
label = _nav_label(db, f"/{page_key}", lang)
title = f"{label} | {brand}" if label else brand
jsonld: list[dict] = []
if page_key == "faq":
faq_schema = _faq_page_jsonld(db, lang)
if faq_schema:
jsonld.append(faq_schema)
elif page_key in ("contact", "about"):
business = _localbusiness_jsonld(db, lang, path)
if business:
jsonld.append(business)
return _shell(
title=title,
description=description,
path=path,
lang=lang,
has_en=_static_has_en(db, page_key),
jsonld=jsonld or None,
org_jsonld=_org_jsonld(db, lang),
)
@router.get("/", response_class=HTMLResponse)
def home(db: Session = Depends(get_db)):
return _static_shell(db, path="/", page_key="home")
@router.get("/about", response_class=HTMLResponse)
def about_page(db: Session = Depends(get_db)):
return _static_shell(db, path="/about", page_key="about")
@router.get("/services", response_class=HTMLResponse)
def services_page(db: Session = Depends(get_db)):
return _static_shell(db, path="/services", page_key="services")
@router.get("/investment", response_class=HTMLResponse)
def investment_page(db: Session = Depends(get_db)):
return _static_shell(db, path="/investment", page_key="investment")
@router.get("/faq", response_class=HTMLResponse)
def faq_page(db: Session = Depends(get_db)):
return _static_shell(db, path="/faq", page_key="faq")
@router.get("/contact", response_class=HTMLResponse)
def contact_page(db: Session = Depends(get_db)):
return _static_shell(db, path="/contact", page_key="contact")
# ---------- صفحات وبلاگ ----------
@router.get("/blog", response_class=HTMLResponse)
def blog_list(db: Session = Depends(get_db)):
return _static_shell(db, path="/blog", page_key="blog")
@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}",
org_jsonld=_org_jsonld(db, "fa"), status_code=404,
)
brand = _brand_name(db)
title = article.title
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
faq_schema = _faq_jsonld(article, "fa")
breadcrumb = _breadcrumb_jsonld([
(_nav_label(db, "/", "fa") or "خانه", "/"),
(_nav_label(db, "/blog", "fa") or "وبلاگ", "/blog"),
(title, f"/blog/{article.slug}"),
])
jsonld: list[dict] = [_article_jsonld(article, "fa", db), breadcrumb]
if faq_schema:
jsonld.append(faq_schema)
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
return _shell(
title=f"{title} | {brand}",
description=_meta_desc_for(article, "fa"),
path=f"/blog/{article.slug}",
has_en=_article_has_en(article),
og_image=article.image,
og_image_alt=article.image_alt or title,
og_type="article",
jsonld=jsonld,
org_jsonld=_org_jsonld(db, "fa"),
noscript=noscript,
)
def _faq_jsonld(article: models.Article, lang: str) -> dict | None:
"""اسکیمای FAQPage از پرسش‌وپاسخ‌های پایان مقاله."""
entities = _faq_entities(article.faqs, lang, article.faqs_en)
if not entities:
return None
return {"@context": "https://schema.org", "@type": "FAQPage", "mainEntity": entities}
# ---------- صفحات پروژه ----------
@router.get("/projects", response_class=HTMLResponse)
def projects_list(db: Session = Depends(get_db)):
return _static_shell(db, path="/projects", page_key="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}",
org_jsonld=_org_jsonld(db, "fa"), status_code=404,
)
brand = _brand_name(db)
jsonld = [
{
"@context": "https://schema.org",
"@type": "WebPage",
"name": project.title,
"description": project.summary,
"image": _absolute(project.image),
"inLanguage": "fa",
},
_breadcrumb_jsonld([
(_nav_label(db, "/", "fa") or "خانه", "/"),
(_nav_label(db, "/projects", "fa") or "پروژه‌ها", "/projects"),
(project.title, f"/projects/{project.slug}"),
]),
]
return _shell(
title=f"{project.title} | {brand}",
description=project.summary,
path=f"/projects/{project.slug}",
has_en=_project_has_en(project),
og_image=project.image,
og_image_alt=project.title,
jsonld=jsonld,
org_jsonld=_org_jsonld(db, "fa"),
)
# ---------- صفحات انگلیسی (/en/...) ----------
def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
brand = _brand_name(db, "en")
title = article.title_en or article.title
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])
faq_schema = _faq_jsonld(article, "en")
breadcrumb = _breadcrumb_jsonld([
(_nav_label(db, "/", "en") or "Home", "/"),
(_nav_label(db, "/blog", "en") or "Blog", "/en/blog"),
(title, f"/en/blog/{(article.slug_en or '').strip() or article.slug}"),
])
jsonld: list[dict] = [_article_jsonld(article, "en", db), breadcrumb]
if faq_schema:
jsonld.append(faq_schema)
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
return _shell(
title=f"{title} | {brand}",
description=_meta_desc_for(article, "en"),
path=f"/en/blog/{(article.slug_en or '').strip() or article.slug}",
lang="en",
has_en=_article_has_en(article),
og_image=article.image,
og_image_alt=article.image_alt or title,
og_type="article",
jsonld=jsonld,
org_jsonld=_org_jsonld(db, "en"),
noscript=noscript,
)
@router.get("/en", response_class=HTMLResponse)
@router.get("/en/", response_class=HTMLResponse)
def en_home(db: Session = Depends(get_db)):
return _static_shell(db, path="/en", page_key="home", lang="en")
@router.get("/en/blog", response_class=HTMLResponse)
def en_blog_list(db: Session = Depends(get_db)):
return _static_shell(db, path="/en/blog", page_key="blog", lang="en")
@router.get("/en/blog/{slug}", response_class=HTMLResponse)
def en_blog_detail_route(slug: str, db: Session = Depends(get_db)):
article = (
db.query(models.Article)
.filter(
(models.Article.slug_en == slug)
| ((models.Article.slug_en == "") & (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",
org_jsonld=_org_jsonld(db, "en"), status_code=404,
)
return _en_blog_detail(article, db)
@router.get("/en/projects", response_class=HTMLResponse)
def en_projects_list(db: Session = Depends(get_db)):
return _static_shell(db, path="/en/projects", page_key="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",
org_jsonld=_org_jsonld(db, "en"), status_code=404,
)
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",
},
_breadcrumb_jsonld([
(_nav_label(db, "/", "en") or "Home", "/"),
(_nav_label(db, "/projects", "en") or "Projects", "/en/projects"),
(title, f"/en/projects/{project.slug}"),
]),
]
return _shell(
title=f"{title} | {brand}",
description=summary,
path=f"/en/projects/{project.slug}",
lang="en",
has_en=_project_has_en(project),
og_image=project.image,
og_image_alt=title,
jsonld=jsonld,
org_jsonld=_org_jsonld(db, "en"),
)
@router.get("/en/{path:path}", response_class=HTMLResponse)
def en_page(path: str, db: Session = Depends(get_db)):
"""بقیه مسیرهای انگلیسی (about، services، ...) — متا از seo_meta به انگلیسی.
مسیرهای ناشناخته ۴۰۴ می‌گیرند — پوستهٔ ۲۰۰ با عنوان برند، خطای soft-404 است."""
page_key = path.split("/")[0] if path else ""
if page_key not in META_KEYS:
return _shell(
title=_brand_name(db, "en"), description="", path=f"/en/{path}".rstrip("/"), lang="en",
org_jsonld=_org_jsonld(db, "en"), status_code=404,
)
return _static_shell(db, path=f"/en/{path}".rstrip("/"), page_key=page_key, lang="en")