feat(blog): جدول — ستون‌ها قابل تغییر اندازه/افزودن و حذف؛ لینک کپشن تصویر؛ هدینگ‌های نسخه انگلیسی بزرگ‌تر

This commit is contained in:
aramesh-site deploy
2026-10-06 10:57:01 +00:00
parent 61a2e95278
commit 1b491357ab
45 changed files with 2341 additions and 81 deletions
+173 -15
View File
@@ -2,7 +2,12 @@
هر سند یک dict با کلیدهای text / type / title / url است. بدنه مقاله‌ها و
پروژه‌ها هم می‌تواند آرایه پاراگراف باشد و هم سند TipTap (JSON) — هر دو
پشتیبانی می‌شوند. مقاله‌های پیش‌نویس ایندکس نمی‌شوند.
پشتیبانی می‌شوند. مقاله‌های پیش‌نویس ایندکس نمی‌شوند؛ اما پرسش‌وپاسخ‌های
پایان مقاله‌های منتشرشده به‌عنوان سند faq ایندکس می‌شوند.
به‌جز جدول‌های محتوایی، متن‌های مدیریت محتوا (آمار، هیرو، هیرو صفحه‌ها،
تیتر بخش‌ها، فوتر، قبل/بعد) هم ایندکس می‌شوند — ادعاهای مرکزی سایت
(سال تجربه، تعداد پروژه و…) فقط آنجا ثبت شده‌اند.
"""
import logging
@@ -16,6 +21,19 @@ log = logging.getLogger(__name__)
_ARTICLE_URL = "/blog/{slug}"
_PROJECT_URL = "/projects/{slug}"
# بخش‌های صفحه اصلی و صفحات داخلی — برای نسبت دادن URL به تیتر بخش‌ها
_SECTION_URL = {
"services": "/services",
"featured_projects": "/projects",
"articles": "/blog",
"faq": "/faq",
"investment_models": "/investment",
"process": "/investment",
}
# سندهای کوتاه‌تر از این جای‌گاه‌خالی‌اند («خدمت جدید» و امثال آن) — ایندکس نمی‌شوند
_MIN_DOC_CHARS = 30
def _psycopg_dsn(database_url: str) -> str:
"""DATABASE_URL فرمت SQLAlchemy (postgresql+psycopg://) را به DSN psycopg3 تبدیل می‌کند."""
@@ -75,7 +93,9 @@ def body_to_text(body: Any) -> str:
def _doc(text: str, dtype: str, title: str, url: str) -> dict | None:
text = (text or "").strip()
if not text:
if len(text) < _MIN_DOC_CHARS:
if text:
log.info("Skipped tiny doc (%d chars, type=%s, title=%s)", len(text), dtype, title)
return None
return {"text": text, "type": dtype, "title": title, "url": url}
@@ -84,6 +104,19 @@ def _clean(value: Any) -> str:
return normalize(str(value or "")).strip()
def _format_stat_rows(rows: list[dict[str, Any]]) -> list[str]:
"""سطرهای جدول stats → «۲۳+ سال تجربه ساخت» (پسوند تک‌حرفی می‌چسبد، بقیه با فاصله)."""
parts: list[str] = []
for r in rows:
suffix = _clean(r.get("suffix"))
value = str(r.get("value") or "").strip()
if not value:
continue
num = f"{value}{suffix}" if len(suffix) == 1 else f"{value} {suffix}".strip()
parts.append(f"{num} {_clean(r.get('label'))}".strip())
return parts
def fetch_site_documents(database_url: str) -> list[dict]:
"""همه محتوای عمومی سایت را به سند تبدیل می‌کند (بدون وابستگی به ORM بک‌اند)."""
dsn = _psycopg_dsn(database_url)
@@ -105,6 +138,36 @@ def fetch_site_documents(database_url: str) -> list[dict]:
if d:
docs.append(d)
# --- آمار و افتخارات — اعداد مرکزی سایت فقط اینجا ثبت‌اند ---
stats = _format_stat_rows(_fetch_rows(dsn, "SELECT value, suffix, label FROM stats ORDER BY position"))
if stats:
d = _doc("آمار و افتخارات مجموعه: " + "؛ ".join(stats), "stats", "آمار و افتخارات", "/")
if d:
docs.append(d)
# --- هیرو صفحه اصلی (رکورد یگانه) ---
hero_rows = _fetch_rows(
dsn,
"SELECT kicker, title, title_highlight, subtitle, badge_value, badge_label FROM hero LIMIT 1",
)
if hero_rows:
h = hero_rows[0]
title = _clean(h["title"])
if _clean(h["title_highlight"]):
title = f"{title} (کلمهٔ برجسته: {_clean(h['title_highlight'])})"
lines = list(filter(None, [
f"شعار بالای تیتر: {_clean(h['kicker'])}" if _clean(h["kicker"]) else "",
f"تیتر اصلی: {title}" if title else "",
f"زیرتیتر: {_clean(h['subtitle'])}" if _clean(h["subtitle"]) else "",
(
f"نشان: {_clean(h['badge_value'])} {_clean(h['badge_label'])}"
if _clean(h["badge_value"]) or _clean(h["badge_label"]) else ""
),
]))
d = _doc("\n".join(lines), "hero", "معرفی صفحه اصلی", "/")
if d:
docs.append(d)
# --- دفاتر ---
for row in _fetch_rows(dsn, "SELECT city, address, hours FROM offices ORDER BY position"):
d = _doc(
@@ -132,29 +195,50 @@ def fetch_site_documents(database_url: str) -> list[dict]:
if d:
docs.append(d)
# --- پروژه‌ها ---
# --- پروژه‌ها (شامل پروژه‌های نقشهٔ صفحه پروژه‌ها — فقط مشخصات، بدون توضیح) ---
for row in _fetch_rows(
dsn,
"SELECT slug, title, location, year, status, area, floors, units, client, summary, body FROM projects ORDER BY position",
"""
SELECT slug, title, location, year, status, area, floors, land_area, pilot_floors,
units_residential, units_commercial, units_office, client,
project_type, usage, summary, body
FROM projects ORDER BY position
""",
):
units = "، ".join(
f"{v} واحد {label}"
for label, col in (("مسکونی", row["units_residential"]),
("تجاری", row["units_commercial"]),
("اداری", row["units_office"]))
if (v := _clean(col)) and v != "0"
)
meta = "\n".join(filter(None, [
f"مکان: {_clean(row['location'])}" if row["location"] else "",
f"سال: {_clean(row['year'])}" if row["year"] else "",
f"وضعیت: {'در حال ساخت' if row['status'] == 'progress' else 'تکمیل‌شده'}",
f"متراژ: {_clean(row['area'])}" if row["area"] else "",
f"نوع پروژه: {_clean(row['project_type'])}" if row["project_type"] else "",
f"کاربری: {_clean(row['usage'])}" if row["usage"] else "",
f"متراژ زیربنا: {_clean(row['area'])}" if row["area"] else "",
f"عرصه: {_clean(row['land_area'])}" if row["land_area"] else "",
f"طبقات: {_clean(row['floors'])}" if row["floors"] else "",
f"واحدها: {_clean(row['units'])}" if row["units"] else "",
f"طبقات پیلوت: {_clean(row['pilot_floors'])}" if row["pilot_floors"] else "",
f"واحدها: {units}" if units else "",
f"کارفرما: {_clean(row['client'])}" if row["client"] else "",
]))
text = "\n".join(filter(None, [meta, _clean(row["summary"]), body_to_text(row["body"])]))
text = "\n".join(filter(None, [
f"پروژه «{_clean(row['title'])}»",
meta,
_clean(row["summary"]),
body_to_text(row["body"]),
]))
d = _doc(text, "project", _clean(row["title"]), _PROJECT_URL.format(slug=row["slug"]))
if d:
docs.append(d)
# --- مقاله‌ها (فقط منتشرشده) ---
# --- مقاله‌ها (فقط منتشرشده) + پرسش‌وپاسخ‌های پایان مقاله ---
for row in _fetch_rows(
dsn,
"SELECT slug, title, date, category, excerpt, body FROM articles WHERE status = 'published' ORDER BY position",
"SELECT slug, title, date, category, excerpt, body, faqs FROM articles WHERE status = 'published' ORDER BY position",
):
text = "\n".join(filter(None, [
f"دسته‌بندی: {_clean(row['category'])}" if row["category"] else "",
@@ -165,6 +249,15 @@ def fetch_site_documents(database_url: str) -> list[dict]:
if d:
docs.append(d)
article_url = _ARTICLE_URL.format(slug=row["slug"])
for f in row["faqs"] or []:
if not isinstance(f, dict):
continue
q, a = _clean(f.get("question")), _clean(f.get("answer"))
d = _doc(f"پرسش: {q}\nپاسخ: {a}", "faq", q, article_url)
if d:
docs.append(d)
# --- مدل‌های سرمایه‌گذاری ---
for row in _fetch_rows(dsn, 'SELECT title, "desc", points FROM investment_models ORDER BY position'):
points = "؛ ".join(_clean(p) for p in (row["points"] or []))
@@ -187,7 +280,29 @@ def fetch_site_documents(database_url: str) -> list[dict]:
if d:
docs.append(d)
# --- چرا ما / تیم / نظرسنجی / تایم‌لاین / داستان ما ---
# --- تیتر و توضیح بخش‌ها (مدیریت محتوا) ---
for row in _fetch_rows(dsn, "SELECT key, title, lead FROM section_headings ORDER BY key"):
text = "\n".join(filter(None, [_clean(row["title"]), _clean(row["lead"])]))
d = _doc(text, "section", _clean(row["title"]), _SECTION_URL.get(row["key"], "/"))
if d:
docs.append(d)
# --- هیرو صفحه‌های داخلی (about/services/projects/… ) ---
for row in _fetch_rows(dsn, "SELECT key, title, lead FROM page_heroes ORDER BY key"):
text = "\n".join(filter(None, [_clean(row["title"]), _clean(row["lead"])]))
d = _doc(text, "page_hero", _clean(row["title"]), f"/{row['key']}")
if d:
docs.append(d)
# --- قبل/بعد (بخش صفحه اصلی) ---
ba_rows = _fetch_rows(dsn, "SELECT title, lead FROM before_after LIMIT 1")
if ba_rows:
text = "\n".join(filter(None, [_clean(ba_rows[0]["title"]), _clean(ba_rows[0]["lead"])]))
d = _doc(text, "before_after", _clean(ba_rows[0]["title"]) or "قبل و بعد", "/")
if d:
docs.append(d)
# --- چرا ما / تیم / نظرسنجی / تایم‌لاین / داستان ما / فوتر ---
for row in _fetch_rows(dsn, 'SELECT title, "desc" FROM why_us ORDER BY position'):
d = _doc(f"{_clean(row['title'])}: {_clean(row['desc'])}", "about", _clean(row["title"]), "/about")
if d:
@@ -201,13 +316,24 @@ def fetch_site_documents(database_url: str) -> list[dict]:
if d:
docs.append(d)
for row in _fetch_rows(dsn, "SELECT name, role, quote FROM testimonials ORDER BY position"):
testimonial_rows = _fetch_rows(dsn, "SELECT name, role, quote FROM testimonials ORDER BY position")
for row in testimonial_rows:
d = _doc(
f"نظر {_clean(row['name'])} ({_clean(row['role'])}): {_clean(row['quote'])}",
"testimonial", _clean(row["name"]), "/",
)
if d:
docs.append(d)
# سند تجمیعی — پرسش‌های طبیعی مثل «نظرات مشتریان چیست؟» به تک‌نفرها نمی‌رسند
# (بی‌ریشه‌سازی BM25: «نظرات» با «نظر» و «مشتریان» با «کارفرما» هیچ اشتراکی ندارند)
if len(testimonial_rows) >= 2:
agg = "؛ ".join(
f"{_clean(t['name'])} ({_clean(t['role'])}): {_clean(t['quote'])}"
for t in testimonial_rows
)
d = _doc(f"نظرات مشتریان و کارفرمایان پروژه‌ها:\n{agg}", "testimonial", "نظرات مشتریان", "/")
if d:
docs.append(d)
for row in _fetch_rows(dsn, 'SELECT year, title, "desc" FROM timeline ORDER BY position'):
d = _doc(
@@ -226,15 +352,31 @@ def fetch_site_documents(database_url: str) -> list[dict]:
if d:
docs.append(d)
footer_rows = _fetch_rows(dsn, "SELECT about_text FROM footer LIMIT 1")
if footer_rows and _clean(footer_rows[0]["about_text"]):
d = _doc(
_clean(footer_rows[0]["about_text"]),
"about", "درباره مجموعه (فوتر)", "/",
)
if d:
docs.append(d)
log.info("Fetched %d documents from site database", len(docs))
return docs
def _url_key(url: str) -> str:
"""URL → کلید یکتا برای doc_id («/projects/x» → «projects-x»)."""
return url.strip("/").replace("/", "-") or "home"
def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120) -> list[dict]:
"""متن‌های بلند (مقاله/پروژه) را به قطعات هم‌پوشان تقسیم می‌کند.
هر قطعه یک doc_id پایدار می‌گیرد («type:ترتیب در همان url:شماره قطعه») تا
هر قطعه یک doc_id پایدار می‌گیرد («type:url-key:ترتیب در همان url:شماره قطعه») تا
همگام‌سازی افزایشی pgvector بتواند فقط سطرهای تغییر یافته را امبد کند.
URL بخشی از doc_id است — بدون آن همه پروژه‌ها/مقاله‌ها که url یکتا دارند
روی همان id می‌افتند و در جدول بردار یکدیگر را بازنویسی می‌کنند.
"""
from langchain_text_splitters import RecursiveCharacterTextSplitter
@@ -257,9 +399,15 @@ def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120)
pieces = splitter.split_text(doc["text"]) if len(doc["text"]) > chunk_size * 1.3 else [doc["text"]]
for i, piece in enumerate(pieces, 1):
if len(pieces) == 1:
# تیتر باید در متن امبدشده باشد تا BM25/معنایی پیدایش کند —
# در سندهای تک‌قطعه‌ای که تیترشان در متن نیست، به ابتدای قطعه می‌چسبد
text = piece if not doc["title"] or doc["title"] in piece[:150] else f"{doc['title']}\n{piece}"
else:
text = f"[{doc['title']} — بخش {i} از {len(pieces)}]\n{piece}"
chunk = {
"doc_id": f"{doc['type']}:{occ}:{i}",
"text": piece if len(pieces) == 1 else f"[{doc['title']} — بخش {i} از {len(pieces)}]\n{piece}",
"doc_id": f"{doc['type']}:{_url_key(doc['url'])}:{occ}:{i}",
"text": text,
"type": doc["type"],
"title": doc["title"],
"url": doc["url"],
@@ -285,7 +433,7 @@ _site_context_cache: tuple[float, str] | None = None
def fetch_site_context(database_url: str, ttl: int = 120) -> str:
"""نام برند، تماس و فهرست خدمات — با کش کوتاه‌مدت تا هر پیام یک کوئری سبک بزند."""
"""نام برند، تماس، خدمات، آمار و دفاتر — با کش کوتاه‌مدت تا هر پیام یک کوئری سبک بزند."""
global _site_context_cache
import time as _time
@@ -296,6 +444,10 @@ def fetch_site_context(database_url: str, ttl: int = 120) -> str:
dsn = _psycopg_dsn(database_url)
brand = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
services = _fetch_rows(dsn, "SELECT title FROM services ORDER BY position")
stats = _format_stat_rows(
_fetch_rows(dsn, "SELECT value, suffix, label FROM stats ORDER BY position")
)
offices = _fetch_rows(dsn, "SELECT city, address FROM offices ORDER BY position")
lines: list[str] = []
if brand:
b = brand[0]
@@ -307,6 +459,12 @@ def fetch_site_context(database_url: str, ttl: int = 120) -> str:
]
if services:
lines.append("خدمات سایت: " + "، ".join(_clean(s["title"]) for s in services))
if stats:
lines.append("آمار: " + "؛ ".join(stats))
if offices:
lines.append("دفاتر: " + "؛ ".join(
f"{_clean(o['city'])} ({_clean(o['address'])})" for o in offices
))
ctx = "\n".join(lines)
_site_context_cache = (now, ctx)
return ctx