240 lines
8.7 KiB
Python
240 lines
8.7 KiB
Python
"""سئوی سمت سرور — robots.txt، sitemap.xml و سرو HTML صفحات وبلاگ/پروژه با متای تزریقشده.
|
|
|
|
فرانتاند یک SPA است؛ خزندههای اجتماعی (تلگرام، واتساپ و...) جاوااسکریپت اجرا نمیکنند.
|
|
این ماژول همان index.html بیلد فرانتاند را میخواند و پیش از ارسال، تگهای
|
|
title / description / OG / canonical / JSON-LD را از دیتابیس داخل آن تزریق میکند —
|
|
بازدیدکنندههای عادی همان SPA همیشگی را میبینند و خزندهها متای درست را میخوانند.
|
|
"""
|
|
|
|
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"]
|
|
|
|
|
|
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 _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,
|
|
og_image: str = "",
|
|
og_type: str = "website",
|
|
jsonld: dict | None = None,
|
|
noscript: str = "",
|
|
) -> HTMLResponse:
|
|
"""index.html فرانتاند با متای تزریقشده؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
|
|
title = title.strip() or "گروه ساختمانی آرامش"
|
|
description = description.strip()
|
|
canonical = _absolute(path)
|
|
image = _absolute(og_image)
|
|
esc = html.escape
|
|
|
|
# تگ 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 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:
|
|
# بیلد فرانتاند سوار نشده — صفحه حداقلی با خودِ متا تا خزندهها دست خالی نمانند
|
|
body = f"<h1>{esc(title)}</h1><p>{esc(description)}</p>"
|
|
return HTMLResponse(
|
|
f'<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8">'
|
|
f"<title>{esc(title)}</title>{block}</head><body>{body}</body></html>"
|
|
)
|
|
|
|
raw = re.sub(r"<title>.*?</title>", f"<title>{esc(title)}</title>", raw, count=1, flags=re.S)
|
|
raw = raw.replace("</head>", f" {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) -> str:
|
|
brand = db.query(models.Brand).first()
|
|
return brand.name if brand else "گروه ساختمانی آرامش"
|
|
|
|
|
|
def _page_meta(db: Session, page_key: str) -> models.SeoMeta | None:
|
|
return db.query(models.SeoMeta).filter(models.SeoMeta.page_key == page_key).first()
|
|
|
|
|
|
# ---------- 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")
|
|
|
|
|
|
@router.get("/sitemap.xml", response_class=Response)
|
|
def sitemap(db: Session = Depends(get_db)):
|
|
urls = list(STATIC_PATHS)
|
|
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()
|
|
]
|
|
urls += [
|
|
f"/projects/{p.slug}"
|
|
for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
|
|
]
|
|
items = "\n".join(
|
|
f" <url><loc>{html.escape(_absolute(u))}</loc></url>" for u in urls
|
|
)
|
|
xml = (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\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",
|
|
og_image=meta.image if meta else "",
|
|
)
|
|
|
|
|
|
@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)
|
|
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": article.title,
|
|
"description": article.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(article.title)}</h1><p>{html.escape(first_text)}</p>"
|
|
return _shell(
|
|
title=f"{article.title} | {brand}",
|
|
description=article.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)
|
|
return _shell(
|
|
title=f"{project.title} | {brand}",
|
|
description=project.summary,
|
|
path=f"/projects/{project.slug}",
|
|
og_image=project.image,
|
|
jsonld={
|
|
"@context": "https://schema.org",
|
|
"@type": "WebPage",
|
|
"name": project.title,
|
|
"description": project.summary,
|
|
"image": _absolute(project.image),
|
|
"inLanguage": "fa",
|
|
},
|
|
)
|