دستیار AI گفت «تصاویر سایت را نتوانستم دریافت کنم» — آرشیو ZIP وجود داشت و از چهار کشور ۲۰۰ میداد، اما هیچ پیوندی به آن در صفحات نبود: llms.txt فقط الگو داده بود و مدلها الگو را جایگذاری نمیکنند. اکنون: - صفحهٔ هر پروژه: <a href=.../images.zip>دانلود همهٔ تصاویر (ZIP)</a> - فهرست /projects: هر ردیف پیوند «تصاویر (ZIP)» همان پروژه را دارد (۶۵ پیوند) تا خزندهها با دنبالکردن پیوند واقعی به آرشیو برسند.
1498 lines
70 KiB
Python
1498 lines
70 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",
|
|
)
|
|
# 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]
|
|
# راهنمای دستیارهای 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",
|
|
},
|
|
)
|