سئوی سمت سرور (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
1249 lines
56 KiB
Python
1249 lines
56 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 urllib.parse import quote
|
|
|
|
from fastapi import APIRouter, Depends, Response
|
|
from fastapi.encoders import jsonable_encoder
|
|
from fastapi.responses import HTMLResponse
|
|
from sqlalchemy.orm import Session
|
|
|
|
from . import models
|
|
from .api.public import build_site_payload
|
|
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)
|
|
# کلید IndexNow — اثبات مالکیت برای اعلام فوری نشانیهای سایت به Bing/Copilot و
|
|
# موتورهای مشارکتکننده در پروتکل (فایل در /.well-known/indexnow-key.txt سرو میشود)
|
|
INDEXNOW_KEY = "daa4488a8e2905312b5efd02ad5fc614"
|
|
# خزندههای هوش مصنوعی جستوجو/ارجاع — سیاست صریح «مجاز» تا قالبهای پیشفرض
|
|
# «بستن همهچیز» اتفاقی آنها را نبندند (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 _url_escape(url: str) -> str:
|
|
"""درصدگذاری نویسههای غیر-ASCII (اسلاگهای فارسی) — استاندارد sitemap و IndexNow."""
|
|
return quote(url, safe=":/?#[]@!$&'()*+,;=")
|
|
|
|
|
|
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 _site_json(db: Session) -> str:
|
|
"""محتوای کامل سایت بهصورت JSON برای تزریق داخل HTML (window.__SITE__).
|
|
فرانتاند با همین داده بدون انتظار برای XHR رندر میشود — خزندههای رندرکننده
|
|
(گوگل) هیچوقت در پنجرهٔ «در حال بارگذاری» صفحهٔ نیمهخالی نمیبینند."""
|
|
payload = jsonable_encoder(build_site_payload(db))
|
|
data = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
# امنسازی درج در HTML: توالی بستن اسکریپت و کامنت HTML نباید در داده ظاهر شود
|
|
return data.replace("</", "<\\/").replace("<!--", "<\\u0021--")
|
|
|
|
|
|
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,
|
|
db: Session | None = None,
|
|
) -> 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>')
|
|
if db is not None:
|
|
# دادهٔ کامل سایت داخل HTML — اولین رندر بدون انتظار برای درخواست /api/site
|
|
tags.append(f"<script>window.__SITE__={_site_json(db)};</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,
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
|
|
# اسکیمای 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, headers={"Cache-Control": "no-store"})
|
|
|
|
|
|
# ---------- موجودیتهای ساختیافته (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:
|
|
# لوگوی رسمی بهصورت ImageObject — تا موتورها و دستیارهای AI آن را
|
|
# بهعنوان نشان برند بشناسند، نه یک تصویر عمومی بیهویت
|
|
org["logo"] = {
|
|
"@type": "ImageObject",
|
|
"url": _absolute(logo),
|
|
"contentUrl": _absolute(logo),
|
|
"name": f"{_brand_name(db, lang)} — {'لوگوی رسمی' if lang == 'fa' else 'official logo'}",
|
|
"caption": _brand_name(db, lang),
|
|
}
|
|
# تصویر شاخص برند بهعنوان تصویر معرفی سازمان
|
|
hero_img = (brand.hero or "").strip() if brand else ""
|
|
if hero_img:
|
|
org["image"] = {"@type": "ImageObject", "url": _absolute(hero_img), "caption": _brand_name(db, lang)}
|
|
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",
|
|
)
|
|
# Allow: /api/site — رندرر گوگل موقع رندر صفحه این XHR را هم واکشی میکند؛
|
|
# Disallow کلی /api باعث میشد «مسدود توسط robots.txt» در منابع صفحه ثبت شود
|
|
lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", "Allow: /api/site", ""]
|
|
# سیاست صریح خزندههای هوش مصنوعی — رباتهای جستوجو/ارجاع آزادند تا محتوا
|
|
# در پاسخهای 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",
|
|
headers={"Cache-Control": "public, max-age=300"},
|
|
)
|
|
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(_url_escape(href_))}" />'
|
|
for code, href_ in pairs
|
|
)
|
|
return f" <url><loc>{esc(_url_escape(_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",
|
|
headers={"Cache-Control": "public, max-age=300"},
|
|
)
|
|
|
|
|
|
@router.get("/.well-known/indexnow-key.txt", response_class=Response)
|
|
def indexnow_key_file():
|
|
"""فایل تأیید مالکیت IndexNow — موتورها با خواندن آن، ارسالهای IndexNow را میپذیرند."""
|
|
return Response(content=INDEXNOW_KEY, media_type="text/plain; charset=utf-8")
|
|
|
|
|
|
@router.get(f"/{INDEXNOW_KEY}.txt", response_class=Response)
|
|
def indexnow_key_root():
|
|
"""محل پیشفرض پروتکل IndexNow: /<key>.txt در ریشهٔ دامنه — برخی اعتبارسنجها فقط همین را میخوانند."""
|
|
return Response(content=INDEXNOW_KEY, media_type="text/plain; 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")
|
|
url = "/" if page_key == "home" else f"/en/{page_key}"
|
|
# توضیح متا کوتاه است (۱۵۰–۱۶۰ نویسه) — کامل میآید تا جمله نیمهبریده نشود
|
|
summary = " ".join((description or "").split())
|
|
lines.append(f"- [{label}]({_absolute(url)})" + (f": {summary}" if summary else ""))
|
|
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 = (p.summary_en or p.summary or "").strip()
|
|
if not summary:
|
|
place = (p.location_en or p.location or "").strip()
|
|
summary = f"Project in {place}" if place else ""
|
|
summary = _clip(summary, 140)
|
|
# بدون ترجمهٔ انگلیسی، همان نشانی فارسی اعلام میشود؛ توضیح خالی هم حذف میشود
|
|
path = f"/en/projects/{p.slug}" if _project_has_en(p) else f"/projects/{p.slug}"
|
|
lines.append(f"- [{title}]({_absolute(path)})" + (f": {summary}" if summary else ""))
|
|
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, 140)
|
|
path = f"/en/blog/{a.slug}" if _article_has_en(a) else f"/blog/{a.slug}"
|
|
lines.append(f"- [{title}]({_absolute(path)})" + (f": {summary}" if summary else ""))
|
|
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()
|
|
# اگر نشانی خودش نام شهر را دارد، تکرار نشود («اصفهان، ملاصدرا…» + «، اصفهان»)
|
|
detail = street if (city and city in street) else (street + ("، " + city if city and street else city))
|
|
if detail:
|
|
contact_lines.append(f"- Office: {_clip(detail, 120)}")
|
|
if contact_lines:
|
|
lines += ["", "## Contact", *contact_lines]
|
|
# llms.txt زنده از دیتابیس ساخته میشود — بدون کش تا همیشه تازه باشد
|
|
return Response(
|
|
content="\n".join(lines) + "\n",
|
|
media_type="text/plain; charset=utf-8",
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
|
|
|
|
# این مسیر باید بعد از همهٔ مسیرهای .txt مشخص (robots/llms/indexnow) تعریف شود
|
|
# تا هیچکدام را سایه نیندازد
|
|
@router.get("/{key}.txt", response_class=Response)
|
|
def indexnow_key_any(key: str):
|
|
"""هر کلید IndexNow معتبر (۳۲ نویسهٔ هگز) — فایل /{key}.txt همان کلید را برمیگرداند.
|
|
پنل Bing/دیگر موتورها هر بار کلید تازه میسازند؛ این مسیر یعنی هیچ کلیدی
|
|
نیاز به افزودن دستی فایل ندارد (فایلهای موجود در بیلد هم معتبر میمانند)."""
|
|
if re.fullmatch(r"[a-f0-9]{32}", key):
|
|
return Response(content=key, media_type="text/plain; charset=utf-8")
|
|
return Response(status_code=404)
|
|
|
|
|
|
# ---------- صفحات استاتیک فارسی ----------
|
|
|
|
|
|
def _site_content_html(db: Session, page_key: str, lang: str) -> str:
|
|
"""محتوای واقعی سایت بهصورت متن HTML خوانا — داخل <noscript>.
|
|
|
|
خزندههایی که جاوااسکریپت اجرا نمیکنند (اکثر رباتهای هوش مصنوعی: GPTBot،
|
|
OAI-SearchBot، ClaudeBot، PerplexityBot) فقط HTML اولیه را میخوانند؛ بدون این،
|
|
از سایت فقط عنوان و یک خط توضیح میدیدند. اینجا معرفی، خدمات، پروژهها، آمار،
|
|
دفاتر و پرسشوپاسخها به متن واقعی تبدیل میشوند. برای مرورگرهای معمولی نامرئی
|
|
است (noscript وقتی JS فعال است نمایش داده نمیشود)."""
|
|
def en(fa_value: str, en_value: str | None) -> str:
|
|
return (en_value or "").strip() if (lang == "en" and (en_value or "").strip()) else (fa_value or "").strip()
|
|
|
|
esc = html.escape
|
|
parts: list[str] = []
|
|
|
|
brand = _brand_row(db)
|
|
brand_name = _brand_name(db, lang)
|
|
hero = db.query(models.Hero).first()
|
|
if hero:
|
|
parts.append(f"<h2>{esc(en(hero.title, hero.title_en))}</h2>")
|
|
sub = en(hero.subtitle, hero.subtitle_en)
|
|
if sub:
|
|
parts.append(f"<p>{esc(sub)}</p>")
|
|
|
|
if page_key == "home":
|
|
story = db.query(models.StorySection).first()
|
|
if story:
|
|
parts.append(f"<h2>{esc(en(story.title, story.title_en))}</h2>")
|
|
# نسخهٔ انگلیسی متن داستان اگر ثبت شده باشد، جایگزین میشود
|
|
paras = story.paragraphs if isinstance(story.paragraphs, list) else []
|
|
if lang == "en" and isinstance(getattr(story, "paragraphs_en", None), list) and story.paragraphs_en:
|
|
paras = story.paragraphs_en
|
|
for para in paras:
|
|
if isinstance(para, str) and para.strip():
|
|
parts.append(f"<p>{esc(para.strip())}</p>")
|
|
|
|
stats = db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all()
|
|
if stats:
|
|
items = "".join(
|
|
f"<li>{esc(str(s.value))}{esc(en(s.suffix, s.suffix_en))} — {esc(en(s.label, s.label_en))}</li>"
|
|
for s in stats
|
|
)
|
|
parts.append(f"<h2>{'Key Numbers' if lang == 'en' else 'آمار کلیدی'}</h2><ul>{items}</ul>")
|
|
|
|
if page_key in ("home", "projects"):
|
|
projects = db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
|
|
if projects:
|
|
items = ""
|
|
for p in projects:
|
|
title = en(p.title, p.title_en)
|
|
loc = en(p.location, p.location_en)
|
|
year = en(p.year, p.year_en)
|
|
meta_bits = "، ".join(x for x in (loc, year) if x)
|
|
items += f"<li>{esc(title)}" + (f" — {esc(meta_bits)}" if meta_bits else "") + "</li>"
|
|
parts.append(f"<h2>{'Our Projects' if lang == 'en' else 'پروژههای ما'}</h2><ul>{items}</ul>")
|
|
|
|
if page_key in ("home", "services"):
|
|
services = db.query(models.Service).order_by(models.Service.position, models.Service.id).all()
|
|
if services:
|
|
items = "".join(
|
|
f"<li><strong>{esc(en(s.title, s.title_en))}</strong>"
|
|
+ (f": {esc(en(s.desc, s.desc_en))}" if en(s.desc, s.desc_en) else "")
|
|
+ "</li>"
|
|
for s in services
|
|
)
|
|
parts.append(f"<h2>{'Our Services' if lang == 'en' else 'خدمات ما'}</h2><ul>{items}</ul>")
|
|
|
|
if page_key == "investment":
|
|
models_list = db.query(models.InvestmentModel).order_by(models.InvestmentModel.position).all()
|
|
for m in models_list:
|
|
parts.append(f"<h3>{esc(en(m.title, m.title_en))}</h3>")
|
|
if en(m.desc, m.desc_en):
|
|
parts.append(f"<p>{esc(en(m.desc, m.desc_en))}</p>")
|
|
|
|
if page_key in ("home", "faq"):
|
|
faqs = db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all()
|
|
if faqs:
|
|
items = "".join(
|
|
f"<li><strong>{esc(en(f.question, f.question_en))}</strong> {esc(en(f.answer, f.answer_en))}</li>"
|
|
for f in faqs
|
|
)
|
|
parts.append(f"<h2>{'Frequently Asked Questions' if lang == 'en' else 'پرسشهای پرتکرار'}</h2><ul>{items}</ul>")
|
|
|
|
if page_key == "about":
|
|
team = db.query(models.TeamMember).order_by(models.TeamMember.position).all()
|
|
if team:
|
|
items = "".join(
|
|
f"<li><strong>{esc(en(m.name, m.name_en))}</strong> — {esc(en(m.role, m.role_en))}</li>" for m in team
|
|
)
|
|
parts.append(f"<h2>{'Our Team' if lang == 'en' else 'تیم ما'}</h2><ul>{items}</ul>")
|
|
|
|
if page_key in ("home", "contact"):
|
|
offices = db.query(models.Office).order_by(models.Office.position, models.Office.id).all()
|
|
contact_bits = []
|
|
if brand and brand.phone.strip():
|
|
contact_bits.append(f"<li>{'Phone' if lang == 'en' else 'تلفن'}: {esc(brand.phone.strip())}</li>")
|
|
if brand and brand.email.strip():
|
|
contact_bits.append(f"<li>{'Email' if lang == 'en' else 'ایمیل'}: {esc(brand.email.strip())}</li>")
|
|
if brand and brand.address.strip():
|
|
label = "Head office" if lang == "en" else "دفتر مرکزی"
|
|
contact_bits.append(f"<li>{label}: {esc(brand.address.strip())}</li>")
|
|
for o in offices:
|
|
city = en(o.city, o.city_en)
|
|
addr = en(o.address, o.address_en)
|
|
hours = en(o.hours, o.hours_en)
|
|
detail = addr if (city and city in addr) else f"{addr}، {city}" if addr and city else (addr or city)
|
|
contact_bits.append(f"<li>{esc(detail)}" + (f" — {esc(hours)}" if hours else "") + "</li>")
|
|
if contact_bits:
|
|
parts.append(f"<h2>{'Contact' if lang == 'en' else 'تماس'}</h2><ul>{''.join(contact_bits)}</ul>")
|
|
|
|
if not parts:
|
|
return ""
|
|
return "<h1>" + esc(brand_name if page_key == "home" else brand_name) + "</h1>" + "".join(parts)
|
|
|
|
|
|
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)
|
|
# noscript با محتوای واقعی — خزندههای بدون جاوااسکریپت (رباتهای هوش مصنوعی)
|
|
# هم عنوان، توضیح و کل محتوای معنایی صفحه را میبینند
|
|
noscript = f"<h1>{html.escape(title)}</h1>"
|
|
if description.strip():
|
|
noscript += f"<p>{html.escape(description)}</p>"
|
|
noscript += _site_content_html(db, page_key, lang)
|
|
return _shell(
|
|
db=db,
|
|
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),
|
|
noscript=noscript,
|
|
)
|
|
|
|
|
|
@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(
|
|
db=db,
|
|
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(
|
|
db=db,
|
|
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 _project_schema(project: models.Project, lang: str, db: Session) -> dict:
|
|
"""اسکیمای پروژه — همهٔ تصاویر با نام و توضیح به خود پروژه گره میخورند.
|
|
بدون این، سیستمهای هوش مصنوعی فقط یک فایل تصویر بدون هویت میبینند و
|
|
نمیتوانند تصویر را به پروژه نسبت دهند."""
|
|
brand = _brand_name(db, lang)
|
|
title = (project.title_en or project.title) if lang == "en" else project.title
|
|
schema: dict = {
|
|
"@context": "https://schema.org",
|
|
"@type": "CreativeWork",
|
|
"name": title,
|
|
"inLanguage": lang,
|
|
"creator": {"@type": "Organization", "name": brand},
|
|
}
|
|
location = (project.location_en or project.location) if lang == "en" else project.location
|
|
summary = (project.summary_en or project.summary) if lang == "en" else project.summary
|
|
body_text = _doc_text(project.body, 2000) if isinstance(project.body, dict) else ""
|
|
description = (summary or "").strip() or body_text
|
|
if description:
|
|
schema["description"] = description
|
|
if location:
|
|
schema["locationCreated"] = {"@type": "Place", "name": location.strip()}
|
|
# همهٔ تصاویر (شاخص + گالری) بهصورت ImageObject با نام — تا هر تصویر هویت داشته باشد
|
|
paths = [project.image] if (project.image or "").strip() else []
|
|
for g in (project.gallery or []):
|
|
if isinstance(g, str) and g.strip() and g not in paths:
|
|
paths.append(g)
|
|
if paths:
|
|
images = [
|
|
{
|
|
"@type": "ImageObject",
|
|
"url": _absolute(p),
|
|
"name": f"{title} — {'تصویر پروژه' if lang == 'fa' else 'project photo'}",
|
|
"caption": (f"{title}" + (f"، {location.strip()}" if location else "")),
|
|
"contentUrl": _absolute(p),
|
|
}
|
|
for p in paths[:12]
|
|
]
|
|
schema["image"] = images if len(images) > 1 else images[0]
|
|
return schema
|
|
|
|
|
|
def _project_content_html(project: models.Project, lang: str, brand: str) -> str:
|
|
"""محتوای خوانا صفحهٔ پروژه برای خزندههای بدون جاوااسکریپت + توضیح تصاویر."""
|
|
def en(fa_value: str, en_value: str | None) -> str:
|
|
return (en_value or "").strip() if (lang == "en" and (en_value or "").strip()) else (fa_value or "").strip()
|
|
|
|
esc = html.escape
|
|
title = en(project.title, project.title_en)
|
|
parts = [f"<h1>{esc(title)}</h1>"]
|
|
if en(project.summary, project.summary_en):
|
|
parts.append(f"<p>{esc(en(project.summary, project.summary_en))}</p>")
|
|
elif isinstance(project.body, dict):
|
|
text = _doc_text(project.body, 2000)
|
|
if text:
|
|
parts.append(f"<p>{esc(text)}</p>")
|
|
|
|
facts = []
|
|
for fa_v, en_v, label_fa, label_en in [
|
|
(project.location, project.location_en, "موقعیت", "Location"),
|
|
(project.year, project.year_en, "سال", "Year"),
|
|
(project.area, getattr(project, "area_en", ""), "زیربنا", "Area"),
|
|
(project.client, getattr(project, "client_en", ""), "کارفرما", "Client"),
|
|
]:
|
|
val = en(fa_v or "", en_v)
|
|
if val:
|
|
facts.append(f"<li>{label_en if lang == 'en' else label_fa}: {esc(val)}</li>")
|
|
if facts:
|
|
parts.append(f"<ul>{''.join(facts)}</ul>")
|
|
|
|
# نام و موقعیت هر تصویر — همان چیزی که سیستمهای هوش مصنوعی برای اعتبارسنجی لازم دارند
|
|
images = []
|
|
if (project.image or "").strip():
|
|
images.append(project.image)
|
|
for g in (project.gallery or []):
|
|
if isinstance(g, str) and g.strip() and g not in images:
|
|
images.append(g)
|
|
if images:
|
|
label = "Project photos" if lang == "en" else "تصاویر پروژه"
|
|
items = "".join(
|
|
f'<li><img src="{esc(p)}" alt="{esc(title)}'
|
|
+ (f' — {esc(en(project.location, project.location_en))}' if en(project.location, project.location_en) else "")
|
|
+ f'" /> {esc(title)}</li>'
|
|
for p in images[:12]
|
|
)
|
|
parts.append(f"<h2>{label}</h2><ul>{items}</ul>")
|
|
return "".join(parts)
|
|
|
|
|
|
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(
|
|
db=db,
|
|
title=_brand_name(db), description="", path=f"/projects/{slug}",
|
|
org_jsonld=_org_jsonld(db, "fa"), status_code=404,
|
|
)
|
|
|
|
brand = _brand_name(db)
|
|
jsonld = [
|
|
_project_schema(project, "fa", db),
|
|
_breadcrumb_jsonld([
|
|
(_nav_label(db, "/", "fa") or "خانه", "/"),
|
|
(_nav_label(db, "/projects", "fa") or "پروژهها", "/projects"),
|
|
(project.title, f"/projects/{project.slug}"),
|
|
]),
|
|
]
|
|
return _shell(
|
|
db=db,
|
|
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"),
|
|
noscript=_project_content_html(project, "fa", brand),
|
|
)
|
|
|
|
|
|
# ---------- صفحات انگلیسی (/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(
|
|
db=db,
|
|
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(
|
|
db=db,
|
|
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(
|
|
db=db,
|
|
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 = [
|
|
_project_schema(project, "en", db),
|
|
_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(
|
|
db=db,
|
|
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"),
|
|
noscript=_project_content_html(project, "en", brand),
|
|
)
|
|
|
|
|
|
@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(
|
|
db=db,
|
|
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")
|