437 lines
17 KiB
Python
437 lines
17 KiB
Python
"""سئوی سمت سرور — robots.txt، sitemap.xml و سرو HTML صفحات وبلاگ/پروژه با متای تزریقشده.
|
|
|
|
فرانتاند یک SPA است؛ خزندههای اجتماعی (تلگرام، واتساپ و...) جاوااسکریپت اجرا نمیکنند.
|
|
این ماژول همان index.html بیلد فرانتاند را میخواند و پیش از ارسال، تگهای
|
|
title / description / OG / canonical / hreflang / JSON-LD را از دیتابیس داخل آن تزریق میکند —
|
|
بازدیدکنندههای عادی همان SPA همیشگی را میبینند و خزندهها متای درست را میخوانند.
|
|
|
|
دو زبانه بودن: نسخه فارسی روی مسیرهای اصلی و نسخه انگلیسی روی /en/... سرو میشود؛
|
|
هر دو در sitemap با hreflang متقابل و در head هر صفحه با link alternate معرفی میشوند.
|
|
"""
|
|
|
|
import html
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, Response
|
|
from fastapi.responses import HTMLResponse
|
|
from sqlalchemy.orm import Session
|
|
|
|
from . import models
|
|
from .config import settings
|
|
from .database import get_db
|
|
|
|
router = APIRouter()
|
|
|
|
STATIC_PATHS = ["/", "/about", "/services", "/projects", "/investment", "/blog", "/faq", "/contact"]
|
|
# صفحاتی که متای آنها از seo_meta خوانده میشود — کلید همان page_key است
|
|
META_KEYS = ["home", "about", "services", "projects", "investment", "blog", "faq", "contact"]
|
|
FALLBACK_FA_BRAND = "گروه ساختمانی آرامش"
|
|
FALLBACK_EN_BRAND = "Aramesh Construction Group"
|
|
|
|
|
|
def _absolute(path: str) -> str:
|
|
if not path:
|
|
return ""
|
|
if path.startswith(("http://", "https://")):
|
|
return path
|
|
site = settings.site_url.rstrip("/")
|
|
return f"{site}{path}" if site else path
|
|
|
|
|
|
def _en_path(path: str) -> str:
|
|
"""مسیر انگلیسی معادل یک مسیر فارسی — / و /en یکدیگرند."""
|
|
return "/en" if path == "/" else f"/en{path}"
|
|
|
|
|
|
def _fa_path(path: str) -> str:
|
|
"""مسیر فارسی معادل یک مسیر انگلیسی."""
|
|
if path == "/en":
|
|
return "/"
|
|
return path[3:] if path.startswith("/en/") else path
|
|
|
|
|
|
def _alternates(path: str, lang: str) -> str:
|
|
"""تگهای hreflang متقابل فارسی/انگلیسی برای head."""
|
|
fa = path if lang == "fa" else _fa_path(path)
|
|
en = _en_path(path) if lang == "fa" else path
|
|
return (
|
|
f'<link rel="alternate" hreflang="fa" href="{html.escape(_absolute(fa))}" />\n'
|
|
f' <link rel="alternate" hreflang="en" href="{html.escape(_absolute(en))}" />\n'
|
|
f' <link rel="alternate" hreflang="x-default" href="{html.escape(_absolute(fa))}" />'
|
|
)
|
|
|
|
|
|
def _read_index() -> str | None:
|
|
try:
|
|
return (Path(settings.frontend_dist) / "index.html").read_text(encoding="utf-8")
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _doc_text(doc: object, limit: int = 300) -> str:
|
|
"""متن ساده سند TipTap — برای noscript و پیشنمایش."""
|
|
words: list[str] = []
|
|
|
|
def walk(node: object) -> None:
|
|
if not isinstance(node, dict):
|
|
return
|
|
if node.get("type") == "text" and isinstance(node.get("text"), str):
|
|
words.append(node["text"])
|
|
for child in node.get("content") or []:
|
|
walk(child)
|
|
|
|
if isinstance(doc, dict):
|
|
for block in doc.get("content") or []:
|
|
walk(block)
|
|
text = " ".join(words).strip()
|
|
return text[:limit] + ("…" if len(text) > limit else "")
|
|
|
|
|
|
def _shell(
|
|
*,
|
|
title: str,
|
|
description: str,
|
|
path: str,
|
|
lang: str = "fa",
|
|
og_image: str = "",
|
|
og_type: str = "website",
|
|
jsonld: dict | None = None,
|
|
noscript: str = "",
|
|
) -> HTMLResponse:
|
|
"""index.html فرانتاند با متای تزریقشده؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
|
|
title = title.strip() or (FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND)
|
|
description = description.strip()
|
|
canonical = _absolute(path)
|
|
image = _absolute(og_image)
|
|
esc = html.escape
|
|
alternates = _alternates(path, lang)
|
|
|
|
# تگ title جای تگ موجود index.html را میگیرد و در بلوک تکرار نمیشود
|
|
tags = [
|
|
f'<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 name="twitter:card" content="summary_large_image" />',
|
|
]
|
|
if image:
|
|
tags.append(f'<meta property="og:image" content="{esc(image)}" />')
|
|
if jsonld:
|
|
payload = json.dumps(jsonld, ensure_ascii=False).replace("</", "<\\/")
|
|
tags.append(f'<script type="application/ld+json">{payload}</script>')
|
|
block = "\n ".join(tags)
|
|
|
|
raw = _read_index()
|
|
if raw is None:
|
|
# بیلد فرانتاند سوار نشده — صفحه حداقلی با خودِ متا تا خزندهها دست خالی نمانند
|
|
lang_attr = 'lang="en" dir="ltr"' if lang == "en" else 'lang="fa" dir="rtl"'
|
|
body = f"<h1>{esc(title)}</h1><p>{esc(description)}</p>"
|
|
return HTMLResponse(
|
|
f'<!doctype html><html {lang_attr}><head><meta charset="utf-8">'
|
|
f"<title>{esc(title)}</title>{alternates}{block}</head><body>{body}</body></html>"
|
|
)
|
|
|
|
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)
|
|
raw = raw.replace('"inLanguage":"fa"', '"inLanguage":"en"')
|
|
# 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)
|
|
|
|
|
|
def _brand_name(db: Session, lang: str = "fa") -> str:
|
|
brand = db.query(models.Brand).first()
|
|
if brand is None:
|
|
return FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND
|
|
if lang == "en":
|
|
return brand.name_en or brand.name or FALLBACK_EN_BRAND
|
|
return brand.name or FALLBACK_FA_BRAND
|
|
|
|
|
|
def _page_meta(db: Session, page_key: str) -> models.SeoMeta | None:
|
|
return db.query(models.SeoMeta).filter(models.SeoMeta.page_key == page_key).first()
|
|
|
|
|
|
def _meta_for(db: Session, page_key: str, lang: str) -> tuple[str, str]:
|
|
"""عنوان و توضیح سئوی یک صفحه به زبان درخواستی — با برگشت به فارسی."""
|
|
meta = _page_meta(db, page_key)
|
|
if meta is None:
|
|
return "", ""
|
|
if lang == "en":
|
|
return meta.title_en or meta.title or "", meta.description_en or meta.description or ""
|
|
return meta.title or "", meta.description or ""
|
|
|
|
|
|
# ---------- robots.txt و sitemap.xml ----------
|
|
|
|
|
|
@router.get("/robots.txt", response_class=Response)
|
|
def robots(db: Session = Depends(get_db)):
|
|
lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""]
|
|
if settings.site_url:
|
|
lines.append(f"Sitemap: {_absolute('/sitemap.xml')}")
|
|
return Response(content="\n".join(lines), media_type="text/plain; charset=utf-8")
|
|
|
|
|
|
def _sitemap_entry(loc: str, fa: str, en: str) -> str:
|
|
esc = html.escape
|
|
return (
|
|
f" <url><loc>{esc(_absolute(loc))}</loc>\n"
|
|
f' <xhtml:link rel="alternate" hreflang="fa" href="{esc(_absolute(fa))}" />\n'
|
|
f' <xhtml:link rel="alternate" hreflang="en" href="{esc(_absolute(en))}" />\n'
|
|
f" </url>"
|
|
)
|
|
|
|
|
|
@router.get("/sitemap.xml", response_class=Response)
|
|
def sitemap(db: Session = Depends(get_db)):
|
|
fa_urls = list(STATIC_PATHS)
|
|
fa_urls += [
|
|
f"/blog/{a.slug}"
|
|
for a in db.query(models.Article)
|
|
.filter(models.Article.status == "published")
|
|
.order_by(models.Article.position, models.Article.id)
|
|
.all()
|
|
]
|
|
fa_urls += [
|
|
f"/projects/{p.slug}"
|
|
for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
|
|
]
|
|
items = "\n".join(_sitemap_entry(u, u, _en_path(u)) for u in fa_urls)
|
|
xml = (
|
|
'<?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"{items}\n</urlset>\n"
|
|
)
|
|
return Response(content=xml, media_type="application/xml; charset=utf-8")
|
|
|
|
|
|
# ---------- صفحات وبلاگ ----------
|
|
|
|
|
|
@router.get("/blog", response_class=HTMLResponse)
|
|
def blog_list(db: Session = Depends(get_db)):
|
|
meta = _page_meta(db, "blog")
|
|
return _shell(
|
|
title=meta.title if meta and meta.title else "وبلاگ | " + _brand_name(db),
|
|
description=meta.description if meta else "",
|
|
path="/blog",
|
|
)
|
|
|
|
|
|
def _article_pair(article: models.Article, lang: str) -> tuple[str, str]:
|
|
"""عنوان و چکیده مقاله به زبان درخواستی — با برگشت به فارسی."""
|
|
if lang == "en":
|
|
return article.title_en or article.title, article.excerpt_en or article.excerpt
|
|
return article.title, article.excerpt
|
|
|
|
|
|
@router.get("/blog/{slug}", response_class=HTMLResponse)
|
|
def blog_detail(slug: str, db: Session = Depends(get_db)):
|
|
article = (
|
|
db.query(models.Article)
|
|
.filter(models.Article.slug == slug, models.Article.status == "published")
|
|
.first()
|
|
)
|
|
if article is None:
|
|
# مقاله ناموجود یا پیشنویس — پوسته ساده بدون متا؛ SPA پیام «یافت نشد» را نشان میدهد
|
|
return _shell(title=_brand_name(db), description="", path=f"/blog/{slug}")
|
|
|
|
brand = _brand_name(db)
|
|
title, excerpt = _article_pair(article, "fa")
|
|
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
|
|
jsonld = {
|
|
"@context": "https://schema.org",
|
|
"@type": "Article",
|
|
"headline": title,
|
|
"description": excerpt,
|
|
"image": _absolute(article.image),
|
|
"inLanguage": "fa",
|
|
"author": {"@type": "Organization", "name": brand},
|
|
"publisher": {"@type": "Organization", "name": brand},
|
|
"mainEntityOfPage": _absolute(f"/blog/{article.slug}"),
|
|
}
|
|
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
|
return _shell(
|
|
title=f"{title} | {brand}",
|
|
description=excerpt,
|
|
path=f"/blog/{article.slug}",
|
|
og_image=article.image,
|
|
og_type="article",
|
|
jsonld=jsonld,
|
|
noscript=noscript,
|
|
)
|
|
|
|
|
|
# ---------- صفحات پروژه ----------
|
|
|
|
|
|
@router.get("/projects", response_class=HTMLResponse)
|
|
def projects_list(db: Session = Depends(get_db)):
|
|
meta = _page_meta(db, "projects")
|
|
return _shell(
|
|
title=meta.title if meta and meta.title else "پروژهها | " + _brand_name(db),
|
|
description=meta.description if meta else "",
|
|
path="/projects",
|
|
)
|
|
|
|
|
|
@router.get("/projects/{slug}", response_class=HTMLResponse)
|
|
def project_detail(slug: str, db: Session = Depends(get_db)):
|
|
project = db.query(models.Project).filter(models.Project.slug == slug).first()
|
|
if project is None:
|
|
return _shell(title=_brand_name(db), description="", path=f"/projects/{slug}")
|
|
|
|
brand = _brand_name(db)
|
|
jsonld = {
|
|
"@context": "https://schema.org",
|
|
"@type": "WebPage",
|
|
"name": project.title,
|
|
"description": project.summary,
|
|
"image": _absolute(project.image),
|
|
"inLanguage": "fa",
|
|
}
|
|
return _shell(
|
|
title=f"{project.title} | {brand}",
|
|
description=project.summary,
|
|
path=f"/projects/{project.slug}",
|
|
og_image=project.image,
|
|
jsonld=jsonld,
|
|
)
|
|
|
|
|
|
# ---------- صفحات انگلیسی (/en/...) ----------
|
|
|
|
|
|
def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
|
|
brand = _brand_name(db, "en")
|
|
title, excerpt = _article_pair(article, "en")
|
|
body = article.body_en if isinstance(article.body_en, dict) else article.body
|
|
first_text = _doc_text(body) if not isinstance(body, list) else " ".join(body[:2])
|
|
jsonld = {
|
|
"@context": "https://schema.org",
|
|
"@type": "Article",
|
|
"headline": title,
|
|
"description": excerpt,
|
|
"image": _absolute(article.image),
|
|
"inLanguage": "en",
|
|
"author": {"@type": "Organization", "name": brand},
|
|
"publisher": {"@type": "Organization", "name": brand},
|
|
"mainEntityOfPage": _absolute(f"/en/blog/{article.slug}"),
|
|
}
|
|
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
|
return _shell(
|
|
title=f"{title} | {brand}",
|
|
description=excerpt,
|
|
path=f"/en/blog/{article.slug}",
|
|
lang="en",
|
|
og_image=article.image,
|
|
og_type="article",
|
|
jsonld=jsonld,
|
|
noscript=noscript,
|
|
)
|
|
|
|
|
|
@router.get("/en", response_class=HTMLResponse)
|
|
@router.get("/en/", response_class=HTMLResponse)
|
|
def en_home(db: Session = Depends(get_db)):
|
|
title, description = _meta_for(db, "home", "en")
|
|
if not title:
|
|
title = _brand_name(db, "en")
|
|
return _shell(title=title, description=description, path="/en", lang="en")
|
|
|
|
|
|
@router.get("/en/blog", response_class=HTMLResponse)
|
|
def en_blog_list(db: Session = Depends(get_db)):
|
|
meta = _page_meta(db, "blog")
|
|
title = meta.title_en or meta.title if meta else ""
|
|
description = meta.description_en or meta.description if meta else ""
|
|
if not title:
|
|
title = "Blog | " + _brand_name(db, "en")
|
|
return _shell(title=title, description=description, path="/en/blog", lang="en")
|
|
|
|
|
|
@router.get("/en/blog/{slug}", response_class=HTMLResponse)
|
|
def en_blog_detail(slug: str, db: Session = Depends(get_db)):
|
|
article = (
|
|
db.query(models.Article)
|
|
.filter(models.Article.slug == slug, models.Article.status == "published")
|
|
.first()
|
|
)
|
|
if article is None:
|
|
return _shell(title=_brand_name(db, "en"), description="", path=f"/en/blog/{slug}", lang="en")
|
|
return _en_blog_detail(article, db)
|
|
|
|
|
|
@router.get("/en/projects", response_class=HTMLResponse)
|
|
def en_projects_list(db: Session = Depends(get_db)):
|
|
meta = _page_meta(db, "projects")
|
|
title = meta.title_en or meta.title if meta else ""
|
|
description = meta.description_en or meta.description if meta else ""
|
|
if not title:
|
|
title = "Projects | " + _brand_name(db, "en")
|
|
return _shell(title=title, description=description, path="/en/projects", lang="en")
|
|
|
|
|
|
@router.get("/en/projects/{slug}", response_class=HTMLResponse)
|
|
def en_project_detail(slug: str, db: Session = Depends(get_db)):
|
|
project = db.query(models.Project).filter(models.Project.slug == slug).first()
|
|
if project is None:
|
|
return _shell(title=_brand_name(db, "en"), description="", path=f"/en/projects/{slug}", lang="en")
|
|
|
|
brand = _brand_name(db, "en")
|
|
title = project.title_en or project.title
|
|
summary = project.summary_en or project.summary
|
|
jsonld = {
|
|
"@context": "https://schema.org",
|
|
"@type": "WebPage",
|
|
"name": title,
|
|
"description": summary,
|
|
"image": _absolute(project.image),
|
|
"inLanguage": "en",
|
|
}
|
|
return _shell(
|
|
title=f"{title} | {brand}",
|
|
description=summary,
|
|
path=f"/en/projects/{project.slug}",
|
|
lang="en",
|
|
og_image=project.image,
|
|
jsonld=jsonld,
|
|
)
|
|
|
|
|
|
@router.get("/en/{path:path}", response_class=HTMLResponse)
|
|
def en_page(path: str, db: Session = Depends(get_db)):
|
|
"""بقیه مسیرهای انگلیسی (about، services، ...) — متا از seo_meta به انگلیسی."""
|
|
page_key = path.split("/")[0] if path else ""
|
|
title, description = _meta_for(db, page_key, "en") if page_key in META_KEYS else ("", "")
|
|
brand = _brand_name(db, "en")
|
|
if not title:
|
|
# عنوان پیشفرض از برچسب ناوبری انگلیسی همان صفحه
|
|
nav = db.query(models.NavItem).filter(models.NavItem.href == f"/{page_key}").first() if page_key else None
|
|
label = (nav.label_en or nav.label) if nav else ""
|
|
title = f"{label} | {brand}" if label else brand
|
|
return _shell(title=title, description=description, path=f"/en/{path}".rstrip("/"), lang="en")
|