fix(seo): noindex برای صفحات /en بدون ترجمهٔ واقعی — پاسخ به گزارش GSC

گزارش Coverage Drilldown گوگل (۱۰ اکتبر): ۳۶ صفحه «Crawled - currently not indexed»
که غالباً /en/projects/... بودند — صفحهٔ انگلیسی با محتوای فارسی (۰/۶۵ پروژه ترجمه دارد)
یعنی نسخهٔ تکراری صفحهٔ فارسی، و گوگل درست ردش کرده بود.
اکنون صفحات /en که ترجمه ندارند متای noindex,follow می‌گیرند (کاربر عادی سالم
می‌بیند؛ با افزودن ترجمه خودکار برمی‌گردد). صفحه‌های فارسی و /en صفحه‌های اصلی
ترجمه‌دار دست‌نخورده و ایندکس‌پذیرند.
This commit is contained in:
aramesh-site deploy
2026-10-13 09:57:31 +00:00
parent 9b0533b7b2
commit 8de338bb1f
+92 -16
View File
@@ -25,7 +25,7 @@ import json
import re import re
import urllib.parse import urllib.parse
import zipfile import zipfile
from datetime import date, datetime from datetime import date, datetime, timezone
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
@@ -153,6 +153,14 @@ def _parse_site_date(value: str | None) -> str | None:
return None return None
def _iso_stamp(value: datetime | None) -> str | None:
"""زمان سیستمی رکورد → W3C Datetime (UTC) برای <lastmod> نقشه سایت.
None یعنی آخرین تغییر رکورد ثبت نشده و lastmod صادر نمی‌شود."""
if value is None:
return None
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00")
# ---------- alternates — فقط وقتی ترجمهٔ انگلیسی واقعاً وجود دارد ---------- # ---------- alternates — فقط وقتی ترجمهٔ انگلیسی واقعاً وجود دارد ----------
@@ -250,6 +258,7 @@ def _shell(
org_jsonld: dict | None = None, org_jsonld: dict | None = None,
noscript: str = "", noscript: str = "",
status_code: int = 200, status_code: int = 200,
noindex: bool = False,
db: Session | None = None, db: Session | None = None,
) -> HTMLResponse: ) -> HTMLResponse:
"""index.html فرانت‌اند با متای تزریقی؛ اگر بیلد در دسترس نبود، صفحه حداقلی.""" """index.html فرانت‌اند با متای تزریقی؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
@@ -264,7 +273,13 @@ def _shell(
image = _absolute(custom_image) if custom_image else _default_og() image = _absolute(custom_image) if custom_image else _default_og()
image_is_default = not custom_image and bool(_default_og()) image_is_default = not custom_image and bool(_default_og())
tags = [ tags = []
if noindex:
# صفحهٔ انگلیسی بدون ترجمهٔ واقعی — همان محتوای فارسی زیر نشانی /en است؛
# به گوگل صریح می‌گوییم ایندکس نکند (برای کاربر عادی سالم می‌ماند و
# به‌محض اضافه‌شدن ترجمه، خودکار برمی‌گردد)
tags.append('<meta name="robots" content="noindex, follow" />')
tags += [
f'<link rel="canonical" href="{esc(canonical)}" />', f'<link rel="canonical" href="{esc(canonical)}" />',
f'<meta name="description" content="{esc(description)}" />', f'<meta name="description" content="{esc(description)}" />',
f'<meta property="og:title" content="{esc(title)}" />', f'<meta property="og:title" content="{esc(title)}" />',
@@ -617,13 +632,15 @@ def sitemap(db: Session = Depends(get_db)):
p.slug: p for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all() 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: def entry(loc: str, lang: str, has_en: bool, lastmod: str | None = None) -> str:
pairs = _alternate_pairs(loc, lang, has_en) pairs = _alternate_pairs(loc, lang, has_en)
links = "\n".join( links = "\n".join(
f' <xhtml:link rel="alternate" hreflang="{code}" href="{esc(_url_escape(href_))}" />' f' <xhtml:link rel="alternate" hreflang="{code}" href="{esc(_url_escape(href_))}" />'
for code, href_ in pairs for code, href_ in pairs
) )
return f" <url><loc>{esc(_url_escape(_absolute(loc)))}</loc>\n{links}\n </url>" # lastmod فقط با زمان واقعی ثبت‌شده — تاریخ جعلی همگن خودش سیگنال منفی است
stamp = f"\n <lastmod>{esc(lastmod)}</lastmod>" if lastmod else ""
return f" <url><loc>{esc(_url_escape(_absolute(loc)))}</loc>{stamp}\n{links}\n </url>"
urls: list[str] = [] urls: list[str] = []
# صفحات استاتیک فارسی + معادل‌های انگلیسیِ ترجمه‌شده # صفحات استاتیک فارسی + معادل‌های انگلیسیِ ترجمه‌شده
@@ -637,15 +654,17 @@ def sitemap(db: Session = Depends(get_db)):
for slug, article in articles.items(): for slug, article in articles.items():
fa_path = f"/blog/{slug}" fa_path = f"/blog/{slug}"
has_en = _article_has_en(article) has_en = _article_has_en(article)
urls.append(entry(fa_path, "fa", has_en)) stamp = _iso_stamp(article.updated_at)
urls.append(entry(fa_path, "fa", has_en, stamp))
if has_en: if has_en:
urls.append(entry(_en_path(fa_path), "en", True)) urls.append(entry(_en_path(fa_path), "en", True, stamp))
for slug, project in projects.items(): for slug, project in projects.items():
fa_path = f"/projects/{slug}" fa_path = f"/projects/{slug}"
has_en = _project_has_en(project) has_en = _project_has_en(project)
urls.append(entry(fa_path, "fa", has_en)) stamp = _iso_stamp(project.updated_at)
urls.append(entry(fa_path, "fa", has_en, stamp))
if has_en: if has_en:
urls.append(entry(_en_path(fa_path), "en", True)) urls.append(entry(_en_path(fa_path), "en", True, stamp))
xml = ( xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n' '<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" ' '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" '
@@ -716,10 +735,12 @@ def llms_txt(db: Session = Depends(get_db)):
if featured_projects: if featured_projects:
lines += ["", "## Featured Projects"] lines += ["", "## Featured Projects"]
for p in featured_projects: for p in featured_projects:
title = (p.title_en or p.title).strip() title_en = (p.title_en or "").strip()
# خلاصهٔ ثبت‌شده؛ در نبودش، موقعیت و وضعیت پروژه به‌عنوان توضیح مفید # بدون ترجمهٔ انگلیسی، عنوان فارسی با نشان «(Persian)» می‌آید تا متن انگلیسی
summary = (p.summary_en or p.summary or "").strip() # مخلوط به نظر نرسد؛ خلاصه هم از نسخهٔ همان زبان عنوان می‌آید
if not summary: title = (title_en or p.title.strip()) + ("" if title_en else " (Persian)")
summary = ((p.summary_en or "").strip() if title_en else "") or (p.summary or "").strip()
if title_en and not (p.summary_en or "").strip():
place = (p.location_en or p.location or "").strip() place = (p.location_en or p.location or "").strip()
summary = f"Project in {place}" if place else "" summary = f"Project in {place}" if place else ""
summary = _clip(summary, 140) summary = _clip(summary, 140)
@@ -736,8 +757,10 @@ def llms_txt(db: Session = Depends(get_db)):
if latest: if latest:
lines += ["", "## Recent Articles"] lines += ["", "## Recent Articles"]
for a in latest: for a in latest:
title = (a.title_en or a.title).strip() title_en = (a.title_en or "").strip()
summary = _clip(a.excerpt_en or a.excerpt, 140) 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}" 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 "")) lines.append(f"- [{title}]({_absolute(path)})" + (f": {summary}" if summary else ""))
brand_row = _brand_row(db) brand_row = _brand_row(db)
@@ -806,7 +829,6 @@ def _site_content_html(db: Session, page_key: str, lang: str) -> str:
parts: list[str] = [] parts: list[str] = []
brand = _brand_row(db) brand = _brand_row(db)
brand_name = _brand_name(db, lang)
hero = db.query(models.Hero).first() hero = db.query(models.Hero).first()
if hero: if hero:
parts.append(f"<h2>{esc(en(hero.title, hero.title_en))}</h2>") parts.append(f"<h2>{esc(en(hero.title, hero.title_en))}</h2>")
@@ -928,7 +950,56 @@ def _site_content_html(db: Session, page_key: str, lang: str) -> str:
if not parts: if not parts:
return "" return ""
return "<h1>" + esc(brand_name if page_key == "home" else brand_name) + "</h1>" + "".join(parts) # h1 را پوستهٔ استاتیک خودش می‌گذارد (عنوان سئوی صفحه) — h1 دومی اینجا تکرار می‌شد
return "".join(parts)
def _list_jsonld(db: Session, page_key: str, lang: str) -> dict | None:
"""اسکیمای ItemList برای صفحه‌های فهرست — همان آیتم‌هایی که خود صفحه نشان می‌دهد.
مقاله‌ها و پروژه‌ها نشانی اختصاصی دارند؛ خدمات و مدل‌های سرمایه‌گذاری صفحهٔ
اختصاصی ندارند، پس فقط نامشان می‌آید (فقط دادهٔ واقعی)."""
def item(position: int, name: str, url: str = "") -> dict:
entry_: dict = {"@type": "ListItem", "position": position, "name": name}
if url:
entry_["url"] = _absolute(url)
return entry_
elements: list[dict] = []
if page_key == "blog":
articles = (
db.query(models.Article)
.filter(models.Article.status == "published")
.order_by(models.Article.position, models.Article.id)
.all()
)
prefix = "/en/blog" if lang == "en" else "/blog"
for i, a in enumerate(articles, 1):
title = (a.title_en or a.title) if lang == "en" else a.title
elements.append(item(i, title, f"{prefix}/{(a.slug_en or '').strip() or a.slug}" if lang == "en" else f"/blog/{a.slug}"))
elif page_key == "projects":
projects = db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
prefix = "/en/projects" if lang == "en" else "/projects"
for i, p in enumerate(projects, 1):
title = (p.title_en or p.title) if lang == "en" else p.title
elements.append(item(i, title, f"{prefix}/{p.slug}"))
elif page_key == "services":
services = db.query(models.Service).order_by(models.Service.position, models.Service.id).all()
for i, s in enumerate(services, 1):
title = (s.title_en or s.title) if lang == "en" else s.title
elements.append(item(i, title))
elif page_key == "investment":
rows = db.query(models.InvestmentModel).order_by(models.InvestmentModel.position).all()
for i, m in enumerate(rows, 1):
title = (m.title_en or m.title) if lang == "en" else m.title
elements.append(item(i, title))
if not elements:
return None
return {
"@context": "https://schema.org",
"@type": "ItemList",
"numberOfItems": len(elements),
"itemListElement": elements,
}
def _static_shell(db: Session, *, path: str, page_key: str, lang: str = "fa") -> HTMLResponse: def _static_shell(db: Session, *, path: str, page_key: str, lang: str = "fa") -> HTMLResponse:
@@ -948,6 +1019,9 @@ def _static_shell(db: Session, *, path: str, page_key: str, lang: str = "fa") ->
business = _localbusiness_jsonld(db, lang, path) business = _localbusiness_jsonld(db, lang, path)
if business: if business:
jsonld.append(business) jsonld.append(business)
listing = _list_jsonld(db, page_key, lang)
if listing:
jsonld.append(listing)
# noscript با محتوای واقعی — خزنده‌های بدون جاوااسکریپت (ربات‌های هوش مصنوعی) # noscript با محتوای واقعی — خزنده‌های بدون جاوااسکریپت (ربات‌های هوش مصنوعی)
# هم عنوان، توضیح و کل محتوای معنایی صفحه را می‌بینند # هم عنوان، توضیح و کل محتوای معنایی صفحه را می‌بینند
noscript = f"<h1>{html.escape(title)}</h1>" noscript = f"<h1>{html.escape(title)}</h1>"
@@ -1260,6 +1334,7 @@ def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
jsonld=jsonld, jsonld=jsonld,
org_jsonld=_org_jsonld(db, "en"), org_jsonld=_org_jsonld(db, "en"),
noscript=noscript, noscript=noscript,
noindex=not _article_has_en(article),
) )
@@ -1332,6 +1407,7 @@ def en_project_detail(slug: str, db: Session = Depends(get_db)):
jsonld=jsonld, jsonld=jsonld,
org_jsonld=_org_jsonld(db, "en"), org_jsonld=_org_jsonld(db, "en"),
noscript=_project_content_html(project, "en", brand), noscript=_project_content_html(project, "en", brand),
noindex=not _project_has_en(project),
) )