Files
aramesh-backend/backend/app/seo_pages.py
T
aramesh-site deploy 04dc5facde fix(robots): مسدود نکردن /api/site — Allow داخل گروه Disallow قابل‌اعتماد نبود
گزارش منابع صفحهٔ GSC: «Googlebot blocked by robots.txt → /api/site».
robots.txt قاعدهٔ «آخرین تطابق» ندارد؛ افزودن Allow: /api/site داخل همان گروه
Disallow: /api توسط برخی خزنده‌ها نادیده گرفته می‌شد. حالا فقط /admin و
/api/admin مسدود می‌شوند و /api/site آزاد است.
2026-10-10 10:28:51 +00:00

1506 lines
71 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 hashlib
import html
import io
import json
import re
import urllib.parse
import zipfile
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",
)
# /api/site مسدود نمی‌شود: رندرر گوگل این XHR را واکشی می‌کند و Disallow کلی
# /api باعث می‌شد «مسدود توسط robots.txt» ثبت شود. افزودن Allow داخل همان گروه
# قابل‌اعتماد نبود (برخی خزنده‌ها گروه Disallow را ملاک می‌گیرند). حالا فقط
# مسیرهای حساس اداری و اندپوینت‌های مدیریتی مسدود می‌شوند — نه /api/site.
lines = [
"User-agent: *",
"Disallow: /admin",
"Disallow: /api/admin",
"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]
# راهنمای دستیارهای AI: تصاویر هر پروژه به‌صورت آرشیو ZIP با manifest قابل دریافت است
project_count = db.query(models.Project).count()
if project_count:
lines += [
"",
"## Project images (machine-readable)",
f"- [{project_count} projects with image archives]"
f"({_absolute('/projects-images.json')}): JSON index — page URL, image count,"
" area, year and a ZIP download link for every project.",
f"- Per project: {_absolute('/projects/{slug}/images.zip')} — ZIP of that project's"
" photos plus manifest.json (specs, per-image captions). Replace {slug} with the"
" URL-encoded project slug from the index above.",
]
# 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)
# متراژ و طبقات هم در فهرست می‌آید — بدون این، دستیارهای AI
# نتیجه می‌گیرند «برای پروژه‌ها متراژ ثبت نشده» (فقط صفحهٔ جزئیات داشت)
def num(fa_v: object, en_v: object) -> str:
return str(en_v).strip() if (lang == "en" and str(en_v or "").strip()) else str(fa_v or "").strip()
specs = []
if num(p.land_area, getattr(p, "land_area_en", "")):
specs.append(("Land" if lang == "en" else "زمین") + f" {num(p.land_area, getattr(p, 'land_area_en', ''))} m²")
if num(p.area, getattr(p, "area_en", "")):
specs.append(("Built" if lang == "en" else "زیربنا") + f" {num(p.area, getattr(p, 'area_en', ''))} m²")
if num(p.floors, getattr(p, "floors_en", "")):
specs.append(("Floors" if lang == "en" else "طبقات") + f" {num(p.floors, getattr(p, 'floors_en', ''))}")
if num(p.units_residential, getattr(p, "units_residential_en", "")):
specs.append(("Units" if lang == "en" else "واحد") + f" {num(p.units_residential, getattr(p, 'units_residential_en', ''))}")
bits = "، ".join(x for x in (loc, year) if x)
tail = "؛ ".join(specs)
# نام پروژه پیوند به صفحهٔ جزئیات — خزنده می‌تواند مشخصات کامل را دنبال کند
href = f"/en/projects/{p.slug}" if lang == "en" else f"/projects/{p.slug}"
line = f'<a href="{esc(_absolute(href))}">{esc(title)}</a>'
if bits:
line += f" — {esc(bits)}"
if tail:
line += f" [{esc(tail)}]"
# پیوند صریح به آرشیو تصاویر همان پروژه
zip_url = _absolute(f"/projects/{urllib.parse.quote(p.slug)}/images.zip")
zip_label = "images (ZIP)" if lang == "en" else "تصاویر (ZIP)"
line += f' — <a href="{esc(zip_url)}">{esc(zip_label)}</a>'
items += f"<li>{line}</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()}
# مشخصات فنی پروژه — دادهٔ ساخت‌یافته تا موتورها/دستیارها بتوانند به آن استناد کنند
def spec(fa_v: object, en_v: object, label: str) -> dict | None:
val = (str(en_v).strip() if (lang == "en" and str(en_v or "").strip()) else str(fa_v or "").strip())
return {"@type": "PropertyValue", "name": label, "value": val} if val else None
specs = [
spec(project.year, getattr(project, "year_en", ""), "Year" if lang == "en" else "سال"),
spec(project.location, getattr(project, "location_en", ""), "Location" if lang == "en" else "موقعیت"),
spec(project.land_area, getattr(project, "land_area_en", ""), "Land area" if lang == "en" else "مساحت زمین"),
spec(project.area, getattr(project, "area_en", ""), "Built area" if lang == "en" else "زیربنا"),
spec(project.floors, getattr(project, "floors_en", ""), "Floors" if lang == "en" else "طبقات"),
spec(project.units_residential, getattr(project, "units_residential_en", ""), "Residential units" if lang == "en" else "واحد مسکونی"),
spec(project.units_commercial, getattr(project, "units_commercial_en", ""), "Commercial units" if lang == "en" else "واحد تجاری"),
spec(project.units_office, getattr(project, "units_office_en", ""), "Office units" if lang == "en" else "واحد اداری"),
spec(project.client, getattr(project, "client_en", ""), "Client" if lang == "en" else "کارفرما"),
]
specs = [s for s in specs if s]
if specs:
schema["additionalProperty"] = specs
# همهٔ تصاویر (شاخص + گالری) به‌صورت 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 = []
# مشخصات فنی — همهٔ فیلدهای عدسی که تیم در پنل پر می‌کند.
# اعداد زبان‌مستقل‌اند؛ پس حتی وقتی ترجمهٔ انگلیسی نیست، مقدار فارسی
# (ارقام فارسی) هم منتشر می‌شود تا داده گم نشود.
def num(fa_v: object, en_v: object) -> str:
if lang == "en" and str(en_v or "").strip():
return str(en_v).strip()
return str(fa_v or "").strip()
for fa_v, en_v, label_fa, label_en in [
(project.location, getattr(project, "location_en", ""), "موقعیت", "Location"),
(project.year, getattr(project, "year_en", ""), "سال", "Year"),
(project.project_type, getattr(project, "project_type_en", ""), "نوع پروژه", "Project type"),
(project.usage, getattr(project, "usage_en", ""), "کاربری", "Usage"),
(project.land_area, getattr(project, "land_area_en", ""), "مساحت زمین (مترمربع)", "Land area (m²)"),
(project.area, getattr(project, "area_en", ""), "زیربنا (مترمربع)", "Built area (m²)"),
(project.floors, getattr(project, "floors_en", ""), "تعداد طبقات", "Floors"),
(project.pilot_floors, getattr(project, "pilot_floors_en", ""), "طبقات پیلوت", "Pilot floors"),
(project.units_residential, getattr(project, "units_residential_en", ""), "واحد مسکونی", "Residential units"),
(project.units_commercial, getattr(project, "units_commercial_en", ""), "واحد تجاری", "Commercial units"),
(project.units_office, getattr(project, "units_office_en", ""), "واحد اداری", "Office units"),
(project.client, getattr(project, "client_en", ""), "کارفرما", "Client"),
(project.status, "", "وضعیت", "Status"),
]:
val = num(fa_v, 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]
)
# پیوند واقعی به آرشیو ZIP — دستیارهای AI الگو نمی‌سازند؛ باید لینک صریح ببینند
zip_url = _absolute(f"/projects/{urllib.parse.quote(project.slug)}/images.zip")
archive_label = "Download all images (ZIP)" if lang == "en" else "دانلود همهٔ تصاویر (ZIP)"
parts.append(
f"<h2>{label}</h2>"
f'<p><a href="{esc(zip_url)}">{esc(archive_label)}</a></p>'
f"<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")
# ---------- آرشیو ZIP تصاویر پروژه — برای دستیارهای AI و ابزارهای بیرونی ----------
# خزنده‌ها/دستیارها نمی‌توانند دکمه‌های UI را کلیک کنند؛ این مسیر هر پروژه را
# به‌صورت یک فایل ZIP با «manifest.json» ارائه می‌دهد: هر تصویر + مشخصات پروژه.
# آدرس قابل حدس: /projects/{slug}/images.zip — و فهرست کامل در /projects-images.json
def _project_images(project: models.Project) -> list[str]:
paths: list[str] = []
if (project.image or "").strip():
paths.append(project.image.strip())
for g in (project.gallery or []):
if isinstance(g, str) and g.strip() and g.strip() not in paths:
paths.append(g.strip())
return paths
def _fetch_media(path: str) -> bytes | None:
"""خواندن فایل رسانه از MinIO با کلید S3 (کلید = همان مسیر /media/...).
توجه: نام فایل‌ها فارسی‌اند؛ unquote روی کلیدِ از قبل دیکد‌شده نویسه‌ها را
خراب می‌کند — پس هر دو شکل (خام و دیکد‌شده) امتحان می‌شود."""
import urllib.request
import urllib.parse
key = path.split("/media/", 1)[-1].lstrip("/") if "/media/" in path else path.lstrip("/")
candidates = [key]
decoded = urllib.parse.unquote(key)
if decoded != key:
candidates.append(decoded)
try:
import boto3
from botocore.config import Config as BotoConfig
s3 = boto3.client(
"s3",
endpoint_url=settings.s3_endpoint,
aws_access_key_id=settings.s3_access_key,
aws_secret_access_key=settings.s3_secret_key,
region_name="us-east-1",
config=BotoConfig(connect_timeout=5, read_timeout=20, retries={"max_attempts": 2}),
)
for candidate in candidates:
try:
obj = s3.get_object(Bucket=settings.s3_bucket, Key=candidate)
return obj["Body"].read()
except Exception:
continue
except Exception:
pass
# پشتیبان: واکشی از خود سایت عمومی (مسیر کامل encode می‌شود)
try:
url = _absolute(urllib.parse.quote(path, safe="/"))
req = urllib.request.Request(url, headers={"User-Agent": "ArameshArchive/1.0"})
return urllib.request.urlopen(req, timeout=25).read()
except Exception:
return None
def _project_manifest(project: models.Project, files: list[tuple[str, str]]) -> dict:
"""اطلاعات هر تصویر + مشخصات پروژه — تا AI بداند هر فایل چیست."""
title = project.title
location = (project.location or "").strip()
specs = {
"سال": project.year, "موقعیت": location, "کاربری": project.usage,
"مساحت زمین (مترمربع)": project.land_area, "زیربنا (مترمربع)": project.area,
"تعداد طبقات": project.floors, "طبقات پیلوت": project.pilot_floors,
"واحد مسکونی": project.units_residential, "واحد تجاری": project.units_commercial,
"واحد اداری": project.units_office, "کارفرما": project.client,
"وضعیت": project.status,
}
specs = {k: str(v).strip() for k, v in specs.items() if str(v or "").strip()}
return {
"project": title,
"project_en": (project.title_en or "").strip() or None,
"url": _absolute(f"/projects/{project.slug}"),
"slug": project.slug,
"specifications": specs,
"description": (project.summary or "").strip() or None,
"images": [
{
"file": fname,
"source_url": _absolute(src),
"caption": f"{title}" + (f"، {location}" if location else ""),
"alt": f"{title} — {'تصویر پروژه' if True else ''}".strip(" —"),
}
for fname, src in files
],
"license_note": "© گروه ساختمانی آرامش — تصاویر پروژههای واقعی این شرکت.",
}
@router.get("/projects-images.json", response_class=Response)
def projects_images_index(db: Session = Depends(get_db)):
"""فهرست ماشین‌خوان همهٔ پروژه‌ها با لینک ZIP تصاویرشان — نقطهٔ ورود برای دستیارهای AI."""
projects = db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
data = {
"@context": "https://schema.org",
"@type": "ItemList",
"name": f"{_brand_name(db, 'fa')} — فهرست پروژه‌ها و تصاویر",
"numberOfItems": len(projects),
"itemListElement": [
{
"position": i + 1,
"name": p.title,
"page_url": _absolute(f"/projects/{p.slug}"),
"images_zip": _absolute(f"/projects/{urllib.parse.quote(p.slug)}/images.zip"),
"image_count": len(_project_images(p)),
"location": (p.location or "").strip(),
"area_m2": str(p.area) if p.area else None,
"year": str(p.year) if p.year else None,
}
for i, p in enumerate(projects)
],
}
return Response(
content=json.dumps(data, ensure_ascii=False, indent=1),
media_type="application/json; charset=utf-8",
headers={"Cache-Control": "no-store"},
)
@router.get("/projects/{slug}/images.zip")
def project_images_zip(slug: str, db: Session = Depends(get_db)):
"""ZIP تصاویر یک پروژه (+ manifest.json) — برای دانلود و بررسی توسط ابزارها/AI."""
project = db.query(models.Project).filter(models.Project.slug == slug).first()
if project is None:
return Response(status_code=404)
paths = _project_images(project)
buf = io.BytesIO()
files: list[tuple[str, str]] = []
seen_hashes: set[str] = set()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
for src in paths:
data = _fetch_media(src)
if data is None:
continue
# تصویر تکراری (شاخص = اولین عکس گالری) دوباره در آرشیو نمی‌آید
digest = hashlib.md5(data).hexdigest()
if digest in seen_hashes:
continue
seen_hashes.add(digest)
ext = Path(urllib.parse.unquote(src)).suffix.lower() or ".webp"
fname = f"{len(files) + 1:02d}{ext}"
zf.writestr(fname, data)
files.append((fname, src))
manifest = _project_manifest(project, files)
zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
payload = buf.getvalue()
# نام فایل ASCII امن — اسلاگ فارسی در هدر Content-Disposition نمی‌آید
safe_name = re.sub(r"[^A-Za-z0-9._-]+", "-", project.slug).strip("-") or f"project-{project.id}"
if not safe_name or safe_name == "-":
safe_name = f"project-{project.id}"
return Response(
content=payload,
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{safe_name}-images.zip"',
"Cache-Control": "public, max-age=3600",
},
)