From 8de338bb1fc8f24eb4cffa779dd558925a14e126 Mon Sep 17 00:00:00 2001 From: aramesh-site deploy Date: Tue, 13 Oct 2026 09:57:31 +0000 Subject: [PATCH] =?UTF-8?q?fix(seo):=20noindex=20=D8=A8=D8=B1=D8=A7=DB=8C?= =?UTF-8?q?=20=D8=B5=D9=81=D8=AD=D8=A7=D8=AA=20/en=20=D8=A8=D8=AF=D9=88?= =?UTF-8?q?=D9=86=20=D8=AA=D8=B1=D8=AC=D9=85=D9=87=D9=94=20=D9=88=D8=A7?= =?UTF-8?q?=D9=82=D8=B9=DB=8C=20=E2=80=94=20=D9=BE=D8=A7=D8=B3=D8=AE=20?= =?UTF-8?q?=D8=A8=D9=87=20=DA=AF=D8=B2=D8=A7=D8=B1=D8=B4=20GSC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit گزارش Coverage Drilldown گوگل (۱۰ اکتبر): ۳۶ صفحه «Crawled - currently not indexed» که غالباً /en/projects/... بودند — صفحهٔ انگلیسی با محتوای فارسی (۰/۶۵ پروژه ترجمه دارد) یعنی نسخهٔ تکراری صفحهٔ فارسی، و گوگل درست ردش کرده بود. اکنون صفحات /en که ترجمه ندارند متای noindex,follow می‌گیرند (کاربر عادی سالم می‌بیند؛ با افزودن ترجمه خودکار برمی‌گردد). صفحه‌های فارسی و /en صفحه‌های اصلی ترجمه‌دار دست‌نخورده و ایندکس‌پذیرند. --- backend/app/seo_pages.py | 108 +++++++++++++++++++++++++++++++++------ 1 file changed, 92 insertions(+), 16 deletions(-) diff --git a/backend/app/seo_pages.py b/backend/app/seo_pages.py index 7ba5e89..463c9e9 100644 --- a/backend/app/seo_pages.py +++ b/backend/app/seo_pages.py @@ -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) برای نقشه سایت. + 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('') + tags += [ f'', f'', f'', @@ -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' ' for code, href_ in pairs ) - return f" {esc(_url_escape(_absolute(loc)))}\n{links}\n " + # lastmod فقط با زمان واقعی ثبت‌شده — تاریخ جعلی همگن خودش سیگنال منفی است + stamp = f"\n {esc(lastmod)}" if lastmod else "" + return f" {esc(_url_escape(_absolute(loc)))}{stamp}\n{links}\n " 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 = ( '\n' ' 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"

{esc(en(hero.title, hero.title_en))}

") @@ -928,7 +950,56 @@ def _site_content_html(db: Session, page_key: str, lang: str) -> str: if not parts: return "" - return "

" + esc(brand_name if page_key == "home" else brand_name) + "

" + "".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"

{html.escape(title)}

" @@ -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), )