fix(blog): کلید زبان در صفحهٔ مقاله ۴۰۴ می‌داد + اسلاگ‌های انگلیسی نامعتبر

باگ کاربر: در پیش‌نمایش فارسی مقاله، دکمهٔ EN به '/en' + اسلاگ فارسی می‌رفت
(«مقاله‌ای وجود ندارد») چون اسلاگ دو زبان فرق دارد.
- LangToggle: نگاشت آگاه از اسلاگ مقاله (fa↔en از روی slug_en) — هم статье
  و هم فهرست وبلاگ
- seo.ts: مسیر alternate صریح (altFa/altEn)؛ پیش از این localeHref پیشوند ساده
  می‌ساخت و hreflang درست سرور را بعد از هیدریشن خراب می‌کرد
- backend: /en/blog/{fa-slug} → 301 به اسلاگ انگلیسی و /blog/{en-slug} → 301
  به نسخهٔ انگلیسی (جلوگیری از ۴۰۴ برای نشانی‌های اشتباه)
- داده: ۱۰ مقدار slug_en که «عنوان با فاصله» بودند به اسلاگ واقعی تبدیل شدند
  (مثل 'Principles of Commercial Building Design' → principles-of-commercial-...)؛
  URL با فاصله هرگز کار نمی‌کرد
This commit is contained in:
aramesh-site deploy
2026-10-25 19:51:50 +00:00
parent a0a8985984
commit aa13d41b6d
4 changed files with 182 additions and 36 deletions
+129 -28
View File
@@ -32,7 +32,7 @@ from urllib.parse import quote
from fastapi import APIRouter, Depends, Response
from fastapi.encoders import jsonable_encoder
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from . import models
@@ -50,6 +50,11 @@ FALLBACK_EN_BRAND = "Aramesh Construction Group"
# تصویر پیش‌فرض شبکه‌های اجتماعی — در frontend/public بیلد می‌شود
DEFAULT_OG_IMAGE = "/og-default.jpg"
DEFAULT_OG_SIZE = (1200, 630)
# مختصات دفتر مرکزی (اصفهان، ملاصدرا شمالی) — همان مقدار فرانت‌اند
# (frontend/src/data/mapProjects.ts: OFFICE_LOCATION)؛ در اسکیمای LocalBusiness
# به geo و hasMap تبدیل می‌شود. دو مقدار باید همیشه یکی بمانند.
OFFICE_GEO = (32.61764688216492, 51.666306342927214)
OFFICE_MAPS_URL = f"https://www.google.com/maps/search/?api=1&query={OFFICE_GEO[0]},{OFFICE_GEO[1]}"
# کلید IndexNow — اثبات مالکیت برای اعلام فوری نشانی‌های سایت به Bing/Copilot و
# موتورهای مشارکت‌کننده در پروتکل (فایل در /.well-known/indexnow-key.txt سرو می‌شود)
INDEXNOW_KEY = "daa4488a8e2905312b5efd02ad5fc614"
@@ -90,8 +95,10 @@ def _fa_path(path: str) -> str:
def _url_escape(url: str) -> str:
"""درصدگذاری نویسه‌های غیر-ASCII (اسلاگ‌های فارسی) — استاندارد sitemap و IndexNow."""
return quote(url, safe=":/?#[]@!$&'()*+,;=")
"""درصدگذاری نویسه‌های غیر-ASCII و ناامن (اسلاگ‌های فارسی، فاصله و «?» در
اسلاگ انگلیسی) — استاندارد sitemap و IndexNow. ? و # عمداً «امن» نیستند:
در اسلاگِ خام، شروع query/fragment می‌شوند و URL را می‌شکنند."""
return quote(url, safe=":/[]@!$&'()*+,;=")
def _clip(text: str, limit: int) -> str:
@@ -164,24 +171,33 @@ def _iso_stamp(value: datetime | None) -> str | None:
# ---------- alternates — فقط وقتی ترجمهٔ انگلیسی واقعاً وجود دارد ----------
def _alternate_pairs(path: str, lang: str, has_en: bool) -> list[tuple[str, str]]:
def _alternate_pairs(
path: str,
lang: str,
has_en: bool,
fa_override: str = "",
en_override: str = "",
) -> list[tuple[str, str]]:
"""جفت‌های (hreflang، href) یک صفحه — مبنای مشترک head و sitemap.
صفحهٔ انگلیسیِ ترجمه‌نشده هیچ ادعایی ندارد (لیست خالی)."""
fa = path if lang == "fa" else _fa_path(path)
صفحهٔ انگلیسیِ ترجمه‌نشده هیچ ادعایی ندارد (لیست خالی). مقاله‌ها می‌توانند
در دو زبان اسلاگ متفاوت داشته باشند؛ overrideها همان نشانی واقعی‌اند."""
fa = fa_override or (path if lang == "fa" else _fa_path(path))
if lang == "en" and not has_en:
return []
# نشانی‌ها خام برمی‌گردند — درصدگذاری URL با مصرف‌کننده است
# (_alternates برای head و url_entry برای sitemap)
pairs = [("fa", _absolute(fa)), ("x-default", _absolute(fa))]
if has_en:
en = _en_path(path) if lang == "fa" else path
en = en_override or (_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)
def _alternates(path: str, lang: str, has_en: bool = True, fa_override: str = "", en_override: str = "") -> str:
"""تگ‌های link alternate برای head — از همان جفت‌های sitemap (درصدگذاری اینجا)."""
pairs = _alternate_pairs(path, lang, has_en, fa_override, en_override)
return "\n ".join(
f'<link rel="alternate" hreflang="{lang_code}" href="{html.escape(href_)}" />'
f'<link rel="alternate" hreflang="{lang_code}" href="{html.escape(_url_escape(href_))}" />'
for lang_code, href_ in pairs
)
@@ -251,6 +267,10 @@ def _shell(
path: str,
lang: str = "fa",
has_en: bool = True,
# نشانی‌های override برای مقاله‌هایی که دو زبان اسلاگ متفاوت دارند —
# پیش‌فرض همان مسیر با پیشوند /en است (اسلاگ مشترک)
alt_fa: str = "",
alt_en: str = "",
og_image: str = "",
og_image_alt: str = "",
og_type: str = "website",
@@ -264,9 +284,9 @@ def _shell(
"""index.html فرانت‌اند با متای تزریقی؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
title = title.strip() or (FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND)
description = description.strip()
canonical = _absolute(path)
canonical = _url_escape(_absolute(path))
esc = html.escape
alternates = _alternates(path, lang, has_en)
alternates = _alternates(path, lang, has_en, alt_fa, alt_en)
# تصویر OG — تصویر اختصاصی صفحه یا تصویر پیش‌فرض برند
custom_image = (og_image or "").strip()
@@ -476,6 +496,14 @@ def _localbusiness_jsonld(db: Session, lang: str, path: str) -> dict | None:
business["address"] = addresses[0] if len(addresses) == 1 else addresses
if areas:
business["areaServed"] = sorted(set(areas))
# دادهٔ مکانی دقیق دفتر — سیگنال مستقیم برای گوگل و دستیارهای AI؛
# hasMap همان قالب رسمی Maps URLs بدون کلید API است که فرانت‌اند هم می‌سازد
business["geo"] = {
"@type": "GeoCoordinates",
"latitude": OFFICE_GEO[0],
"longitude": OFFICE_GEO[1],
}
business["hasMap"] = OFFICE_MAPS_URL
return business
@@ -485,7 +513,7 @@ def _breadcrumb_jsonld(items: list[tuple[str, str]]) -> dict:
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": i + 1, "name": name, "item": _absolute(url)}
{"@type": "ListItem", "position": i + 1, "name": name, "item": _url_escape(_absolute(url))}
for i, (name, url) in enumerate(items)
],
}
@@ -633,7 +661,12 @@ def sitemap(db: Session = Depends(get_db)):
}
def entry(loc: str, lang: str, has_en: bool, lastmod: str | None = None) -> str:
pairs = _alternate_pairs(loc, lang, has_en)
"""ورودی sitemap با جفت‌های alternateِ هم‌اسلاگ (صفحات استاتیک/پروژه‌ها)."""
return url_entry(loc, _alternate_pairs(loc, lang, has_en), lastmod)
def url_entry(loc: str, pairs: list[tuple[str, str]], lastmod: str | None = None) -> str:
"""ورودی sitemap با جفت‌های alternate صریح — برای مقاله‌هایی که دو زبان
اسلاگ جدا دارند."""
links = "\n".join(
f' <xhtml:link rel="alternate" hreflang="{code}" href="{esc(_url_escape(href_))}" />'
for code, href_ in pairs
@@ -650,14 +683,22 @@ def sitemap(db: Session = Depends(get_db)):
urls.append(entry(path, "fa", has_en))
if has_en:
urls.append(entry(_en_path(path), "en", True))
# مقاله‌ها و پروژه‌ها — alternate انگلیسی فقط با وجود ترجمهٔ واقعی
# مقاله‌ها — نشانی انگلیسی، اسلاگ انگلیسیِ خودش را دارد (اگر ثبت شده)؛
# جفت‌های alternate صریح‌اند چون دو زبان می‌توانند اسلاگ متفاوت داشته باشند
for slug, article in articles.items():
fa_path = f"/blog/{slug}"
has_en = _article_has_en(article)
stamp = _iso_stamp(article.updated_at)
urls.append(entry(fa_path, "fa", has_en, stamp))
if has_en:
urls.append(entry(_en_path(fa_path), "en", True, stamp))
en_slug = (article.slug_en or "").strip() or slug
en_path = f"/en/blog/{en_slug}"
# مسیرهای خام — url_entry خودش درصدگذاری می‌کند (یک‌بار، نه دوبار)
pairs = [("fa", fa_path), ("en", en_path), ("x-default", fa_path)]
urls.append(url_entry(fa_path, pairs, stamp))
urls.append(url_entry(en_path, pairs, stamp))
else:
urls.append(entry(fa_path, "fa", False, stamp))
# پروژه‌ها — اسلاگ مشترک دو زبان است
for slug, project in projects.items():
fa_path = f"/projects/{slug}"
has_en = _project_has_en(project)
@@ -746,7 +787,7 @@ def llms_txt(db: Session = Depends(get_db)):
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 ""))
lines.append(f"- [{title}]({_url_escape(_absolute(path))})" + (f": {summary}" if summary else ""))
latest = (
db.query(models.Article)
.filter(models.Article.status == "published")
@@ -761,8 +802,12 @@ def llms_txt(db: Session = Depends(get_db)):
title = (title_en or a.title.strip()) + ("" if title_en else " (Persian)")
summary = ((a.excerpt_en or "").strip() if title_en else "") or (a.excerpt or "").strip()
summary = _clip(summary, 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 ""))
# نشانی انگلیسی اسلاگ انگلیسی خودش را دارد (اگر ثبت شده باشد)
if _article_has_en(a):
path = f"/en/blog/{(a.slug_en or '').strip() or a.slug}"
else:
path = f"/blog/{a.slug}"
lines.append(f"- [{title}]({_url_escape(_absolute(path))})" + (f": {summary}" if summary else ""))
brand_row = _brand_row(db)
contact_lines = []
if brand_row and (brand_row.phone or "").strip():
@@ -776,6 +821,8 @@ def llms_txt(db: Session = Depends(get_db)):
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)}")
# مختصات و پیوند نقشهٔ دفتر مرکزی — دادهٔ مکانی مستقیم برای دستیارهای AI
contact_lines.append(f"- Head office on map: {OFFICE_MAPS_URL}")
if contact_lines:
lines += ["", "## Contact", *contact_lines]
# راهنمای دستیارهای AI: تصاویر هر پروژه به‌صورت آرشیو ZIP با manifest قابل دریافت است
@@ -894,6 +941,31 @@ def _site_content_html(db: Session, page_key: str, lang: str) -> str:
items += f"<li>{line}</li>"
parts.append(f"<h2>{'Our Projects' if lang == 'en' else 'پروژه‌های ما'}</h2><ul>{items}</ul>")
# مقاله‌های منتشرشده — فهرست وبلاگ برای خزنده‌های بدون جاوااسکریپت (ربات‌های AI)؛
# نشانی انگلیسی همان اسلاگ انگلیسی مقاله است (اگر ثبت شده باشد)
if page_key in ("home", "blog"):
blog_items = (
db.query(models.Article)
.filter(models.Article.status == "published")
.order_by(models.Article.position, models.Article.id)
.limit(6)
.all()
)
if blog_items:
items = ""
for a in blog_items:
title = en(a.title, a.title_en)
if lang == "en" and _article_has_en(a):
href = f"/en/blog/{quote((a.slug_en or '').strip() or a.slug, safe='')}"
else:
href = f"/blog/{quote(a.slug, safe='')}"
items += f'<li><a href="{esc(_absolute(href))}">{esc(title)}</a>'
summary = en(a.excerpt, a.excerpt_en)
if summary:
items += f": {esc(_clip(summary, 160))}"
items += "</li>"
parts.append(f"<h2>{'Blog' 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:
@@ -921,13 +993,15 @@ def _site_content_html(db: Session, page_key: str, lang: str) -> str:
)
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>")
# بخش «تیم ما» موقتاً از خروجی حذف شده — همراه صفحهٔ درباره در فرانت‌اند غیرفعال شد
# تا خزنده‌ها و دستیارهای AI هم چیزی از آن نبینند؛ برای بازگردانی این بلوک را دوباره فعال کنید
# 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()
@@ -945,6 +1019,11 @@ def _site_content_html(db: Session, page_key: str, lang: str) -> str:
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>")
# موقعیت روی نقشه — خزنده‌های بدون جاوااسکریپت (ربات‌های AI) هم مختصات و پیوند را می‌بینند
map_label = "Location on map" if lang == "en" else "موقعیت روی نقشه"
contact_bits.append(
f'<li>{map_label}: <a href="{OFFICE_MAPS_URL}">{OFFICE_GEO[0]}, {OFFICE_GEO[1]}</a></li>'
)
if contact_bits:
parts.append(f"<h2>{'Contact' if lang == 'en' else 'تماس'}</h2><ul>{''.join(contact_bits)}</ul>")
@@ -1087,6 +1166,14 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
.first()
)
if article is None:
# اسلاگ انگلیسی روی مسیر فارسی → نشانی canonical انگلیسی همان مقاله
by_en = (
db.query(models.Article)
.filter(models.Article.slug_en == slug, models.Article.status == "published")
.first()
)
if by_en is not None:
return RedirectResponse(url=f"/en/blog/{by_en.slug_en}", status_code=301)
# مقاله ناموجود یا پیش‌نویس — کد وضعیت ۴۰۴ تا خزنده صفحه را ایندکس نکند؛
# SPA همان پیام «یافت نشد» را نشان می‌دهد
return _shell(
@@ -1114,6 +1201,9 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
description=_meta_desc_for(article, "fa"),
path=f"/blog/{article.slug}",
has_en=_article_has_en(article),
# alternate انگلیسی با اسلاگ انگلیسی خودش — نه اسلاگ فارسی زیر /en
# (خام پاس می‌شود؛ _url_escape خودش یک‌بار درصدگذاری می‌کند)
alt_en=f"/en/blog/{(article.slug_en or '').strip() or article.slug}",
og_image=article.image,
og_image_alt=article.image_alt or title,
og_type="article",
@@ -1328,6 +1418,9 @@ def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
path=f"/en/blog/{(article.slug_en or '').strip() or article.slug}",
lang="en",
has_en=_article_has_en(article),
# alternate فارسی با اسلاگ فارسی خودش — نه حذف پیشوند /en از مسیر انگلیسی
# (خام پاس می‌شود؛ _url_escape خودش یک‌بار درصدگذاری می‌کند)
alt_fa=f"/blog/{article.slug}",
og_image=article.image,
og_image_alt=article.image_alt or title,
og_type="article",
@@ -1361,6 +1454,14 @@ def en_blog_detail_route(slug: str, db: Session = Depends(get_db)):
.first()
)
if article is None:
# اسلاگ فارسی روی مسیر انگلیسی → مقالهٔ همان اسلاگ با slug_en → ۳۰۱ به canonical
by_fa = (
db.query(models.Article)
.filter(models.Article.slug == slug, models.Article.status == "published")
.first()
)
if by_fa is not None and (by_fa.slug_en or "").strip():
return RedirectResponse(url=f"/en/blog/{by_fa.slug_en}", status_code=301)
return _shell(
db=db,
title=_brand_name(db, "en"), description="", path=f"/en/blog/{slug}", lang="en",