fix(seo): noindex برای صفحات /en بدون ترجمهٔ واقعی — پاسخ به گزارش GSC
گزارش Coverage Drilldown گوگل (۱۰ اکتبر): ۳۶ صفحه «Crawled - currently not indexed» که غالباً /en/projects/... بودند — صفحهٔ انگلیسی با محتوای فارسی (۰/۶۵ پروژه ترجمه دارد) یعنی نسخهٔ تکراری صفحهٔ فارسی، و گوگل درست ردش کرده بود. اکنون صفحات /en که ترجمه ندارند متای noindex,follow میگیرند (کاربر عادی سالم میبیند؛ با افزودن ترجمه خودکار برمیگردد). صفحههای فارسی و /en صفحههای اصلی ترجمهدار دستنخورده و ایندکسپذیرند.
This commit is contained in:
+92
-16
@@ -25,7 +25,7 @@ import json
|
||||
import re
|
||||
import urllib.parse
|
||||
import zipfile
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timezone
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
@@ -153,6 +153,14 @@ def _parse_site_date(value: str | None) -> str | 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 — فقط وقتی ترجمهٔ انگلیسی واقعاً وجود دارد ----------
|
||||
|
||||
|
||||
@@ -250,6 +258,7 @@ def _shell(
|
||||
org_jsonld: dict | None = None,
|
||||
noscript: str = "",
|
||||
status_code: int = 200,
|
||||
noindex: bool = False,
|
||||
db: Session | None = None,
|
||||
) -> HTMLResponse:
|
||||
"""index.html فرانتاند با متای تزریقی؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
|
||||
@@ -264,7 +273,13 @@ def _shell(
|
||||
image = _absolute(custom_image) if custom_image else _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'<meta name="description" content="{esc(description)}" />',
|
||||
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()
|
||||
}
|
||||
|
||||
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)
|
||||
links = "\n".join(
|
||||
f' <xhtml:link rel="alternate" hreflang="{code}" href="{esc(_url_escape(href_))}" />'
|
||||
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] = []
|
||||
# صفحات استاتیک فارسی + معادلهای انگلیسیِ ترجمهشده
|
||||
@@ -637,15 +654,17 @@ def sitemap(db: Session = Depends(get_db)):
|
||||
for slug, article in articles.items():
|
||||
fa_path = f"/blog/{slug}"
|
||||
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:
|
||||
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():
|
||||
fa_path = f"/projects/{slug}"
|
||||
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:
|
||||
urls.append(entry(_en_path(fa_path), "en", True))
|
||||
urls.append(entry(_en_path(fa_path), "en", True, stamp))
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<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:
|
||||
lines += ["", "## Featured Projects"]
|
||||
for p in featured_projects:
|
||||
title = (p.title_en or p.title).strip()
|
||||
# خلاصهٔ ثبتشده؛ در نبودش، موقعیت و وضعیت پروژه بهعنوان توضیح مفید
|
||||
summary = (p.summary_en or p.summary or "").strip()
|
||||
if not summary:
|
||||
title_en = (p.title_en or "").strip()
|
||||
# بدون ترجمهٔ انگلیسی، عنوان فارسی با نشان «(Persian)» میآید تا متن انگلیسی
|
||||
# مخلوط به نظر نرسد؛ خلاصه هم از نسخهٔ همان زبان عنوان میآید
|
||||
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()
|
||||
summary = f"Project in {place}" if place else ""
|
||||
summary = _clip(summary, 140)
|
||||
@@ -736,8 +757,10 @@ def llms_txt(db: Session = Depends(get_db)):
|
||||
if latest:
|
||||
lines += ["", "## Recent Articles"]
|
||||
for a in latest:
|
||||
title = (a.title_en or a.title).strip()
|
||||
summary = _clip(a.excerpt_en or a.excerpt, 140)
|
||||
title_en = (a.title_en or "").strip()
|
||||
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 ""))
|
||||
brand_row = _brand_row(db)
|
||||
@@ -806,7 +829,6 @@ def _site_content_html(db: Session, page_key: str, lang: str) -> str:
|
||||
parts: list[str] = []
|
||||
|
||||
brand = _brand_row(db)
|
||||
brand_name = _brand_name(db, lang)
|
||||
hero = db.query(models.Hero).first()
|
||||
if hero:
|
||||
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:
|
||||
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:
|
||||
@@ -948,6 +1019,9 @@ def _static_shell(db: Session, *, path: str, page_key: str, lang: str = "fa") ->
|
||||
business = _localbusiness_jsonld(db, lang, path)
|
||||
if business:
|
||||
jsonld.append(business)
|
||||
listing = _list_jsonld(db, page_key, lang)
|
||||
if listing:
|
||||
jsonld.append(listing)
|
||||
# noscript با محتوای واقعی — خزندههای بدون جاوااسکریپت (رباتهای هوش مصنوعی)
|
||||
# هم عنوان، توضیح و کل محتوای معنایی صفحه را میبینند
|
||||
noscript = f"<h1>{html.escape(title)}</h1>"
|
||||
@@ -1260,6 +1334,7 @@ def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
|
||||
jsonld=jsonld,
|
||||
org_jsonld=_org_jsonld(db, "en"),
|
||||
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,
|
||||
org_jsonld=_org_jsonld(db, "en"),
|
||||
noscript=_project_content_html(project, "en", brand),
|
||||
noindex=not _project_has_en(project),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user