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 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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user