diff --git a/backend/Dockerfile b/backend/Dockerfile index 3b4dee2..e867e6a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,7 +7,11 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +# PyPI برای IP ایران بلاک است (connection timeout) — آینهٔ Tsinghua پیش‌فرض است و +# Aliyun به‌عنوان منبع دوم؛ با --build-arg قابل بازنویسی است (مثلاً روی CI خارج ایران) +ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple +ARG PIP_EXTRA_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/ +RUN pip install --no-cache-dir -i "$PIP_INDEX_URL" --extra-index-url "$PIP_EXTRA_INDEX_URL" -r requirements.txt # FFmpeg — نرمال‌سازی رسانه به WebP برای ورودی‌هایی که Pillow نمی‌خواند # (ویدیو → WebP متحرک، فرمت‌های تصویری خاص). مسیر Pillow به آن وابسته نیست. diff --git a/backend/app/api/public.py b/backend/app/api/public.py index 5a30799..82a1cb1 100644 --- a/backend/app/api/public.py +++ b/backend/app/api/public.py @@ -153,9 +153,11 @@ def get_before_after(db: Session = Depends(get_db)): return item -@router.get("/site") -def get_site(db: Session = Depends(get_db)): - """همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانت‌اند.""" +def build_site_payload(db: Session) -> dict: + """همه محتوای سایت در یک دیکشنری — هم برای GET /api/site و هم برای + تزریق مستقیم داخل HTML (window.__SITE__) در app/seo_pages.py. + تزریق داخل HTML یعنی فرانت‌اند بدون انتظار برای XHR رندر می‌شود — + خزنده‌های رندرکننده (گوگل) هیچ‌وقت صفحهٔ نیمه‌خالی نمی‌بینند.""" return { "brand": db.query(models.Brand).first(), "nav": db.query(models.NavItem).order_by(models.NavItem.position, models.NavItem.id).all(), @@ -187,3 +189,9 @@ def get_site(db: Session = Depends(get_db)): "footer": db.query(models.FooterContent).first(), "seo_meta": db.query(models.SeoMeta).order_by(models.SeoMeta.id).all(), } + + +@router.get("/site") +def get_site(db: Session = Depends(get_db)): + """همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانت‌اند.""" + return build_site_payload(db) diff --git a/backend/app/seed_data.py b/backend/app/seed_data.py index 803bf8d..a1b1f6c 100644 --- a/backend/app/seed_data.py +++ b/backend/app/seed_data.py @@ -178,11 +178,8 @@ WHY = [ {"title": "پایداری و انرژی", "desc": "طراحی پوسته و تأسیسات با هدف کاهش مصرف انرژی تا ۳۵ درصد."}, ] -TESTIMONIALS = [ - {"name": "مهندس سارا کیانی", "role": "سرمایه‌گذار پروژه پارسه", "quote": "گزارش‌های ماهانه گروه ساختمانی آرامش آن‌قدر دقیق بود که هیچ‌وقت نیاز نشد نگران سرمایه‌ام باشم. پروژه دو ماه زودتر از موعد به بهره‌برداری رسید."}, - {"name": "دکتر امیرحسین راد", "role": "ساکن برج الهیه", "quote": "کیفیت اجرا در جزئیاتی دیده می‌شود که معمولاً کسی به آن‌ها فکر نمی‌کند؛ از عایق صوتی پنجره‌ها تا نور غیرمستقیم راهروها."}, - {"name": "مریم دلاور", "role": "مدیر مجموعه ویلاهای دریاکنار", "quote": "همکاری با تیم طراحی، تجربه‌ای واقعاً حرفه‌ای بود. ایده‌های ما را شنیدند و به زبان معماری ترجمه کردند."}, -] +# معرفی مشتریان — خالی. (سه معرفی قبلی افراد ساختگی دمو بودند؛ با دادهٔ واقعی تیم پر می‌شود.) +TESTIMONIALS = [] TEAM = [ {"name": "کامران آرامش", "role": "بنیان‌گذار و مدیرعامل", "bio": "بیش از سه دهه تجربه در ساخت بلندمرتبه."}, @@ -388,10 +385,9 @@ PROCESS_STEPS = [ {"step": "۰۴", "title": "تحویل و پشتیبانی", "desc": "تحویل کلید، آموزش بهره‌برداری و پنج سال پشتیبانی فنی."}, ] +# دفتر مرکزی — تنها دفتر واقعی. (سه دفتر تهران/اصفهان/نوشهرِ قبلی دادهٔ دمو بودند و حذف شدند.) OFFICES = [ - {"city": "تهران", "address": "خیابان ولیعصر، برج آرامش، طبقه هجدهم", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۸"}, - {"city": "اصفهان", "address": "خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷"}, - {"city": "نوشهر", "address": "بلوار دریا، دفتر پروژه دریاکنار", "hours": "شنبه تا پنجشنبه، ۱۰ تا ۱۶"}, + {"city": "اصفهان", "address": "خیابان ملاصدرا شمالی، کوچه ۶، پلاک ۳۶", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷"}, ] GALLERY_IMAGES = [ diff --git a/backend/app/seed_data_en.py b/backend/app/seed_data_en.py index 9da7a97..83eb169 100644 --- a/backend/app/seed_data_en.py +++ b/backend/app/seed_data_en.py @@ -213,11 +213,8 @@ WHY = [ {"title": "پایداری و انرژی", "title_en": "Sustainability & Energy", "desc": "طراحی پوسته و تأسیسات با هدف کاهش مصرف انرژی تا ۳۵ درصد.", "desc_en": "Envelope and building systems designed to cut energy consumption by up to 35%."}, ] -TESTIMONIALS = [ - {"name": "مهندس سارا کیانی", "name_en": "Sara Kiani", "role": "سرمایه‌گذار پروژه پارسه", "role_en": "Investor, Parseh Mall project", "quote": "گزارش‌های ماهانه گروه ساختمانی آرامش آن‌قدر دقیق بود که هیچ‌وقت نیاز نشد نگران سرمایه‌ام باشم. پروژه دو ماه زودتر از موعد به بهره‌برداری رسید.", "quote_en": "Aramesh's monthly reports were so precise that I never had to worry about my investment. The project opened two months ahead of schedule."}, - {"name": "دکتر امیرحسین راد", "name_en": "Dr. Amirhossein Rad", "role": "ساکن برج الهیه", "role_en": "Resident, Elahieh Tower", "quote": "کیفیت اجرا در جزئیاتی دیده می‌شود که معمولاً کسی به آن‌ها فکر نمی‌کند؛ از عایق صوتی پنجره‌ها تا نور غیرمستقیم راهروها.", "quote_en": "The build quality shows in details nobody usually thinks about — from the acoustic insulation of the windows to the indirect lighting in the corridors."}, - {"name": "مریم دلاور", "name_en": "Maryam Delavar", "role": "مدیر مجموعه ویلاهای دریاکنار", "role_en": "Manager, Daryakenar Villas", "quote": "همکاری با تیم طراحی، تجربه‌ای واقعاً حرفه‌ای بود. ایده‌های ما را شنیدند و به زبان معماری ترجمه کردند.", "quote_en": "Working with the design team was a truly professional experience. They listened to our ideas and translated them into architecture."}, -] +# Customer testimonials — empty. (The previous three were fictional demo people.) +TESTIMONIALS = [] TEAM = [ {"name": "کامران آرامش", "name_en": "Kamran Aramesh", "role": "بنیان‌گذار و مدیرعامل", "role_en": "Founder & CEO", "bio": "بیش از سه دهه تجربه در ساخت بلندمرتبه.", "bio_en": "Three decades of high-rise construction experience."}, @@ -476,10 +473,9 @@ PROCESS_STEPS = [ {"step": "۰۴", "step_en": "04", "title": "تحویل و پشتیبانی", "title_en": "Handover & Support", "desc": "تحویل کلید، آموزش بهره‌برداری و پنج سال پشتیبانی فنی.", "desc_en": "Key handover, operating walkthrough and five years of technical support."}, ] +# Head office — the only real office. (The previous Tehran/Isfahan/Nowshahr entries were demo data.) OFFICES = [ - {"city": "تهران", "city_en": "Tehran", "address": "خیابان ولیعصر، برج آرامش، طبقه هجدهم", "address_en": "Valiasr St., Aramesh Tower, 18th Floor", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۸", "hours_en": "Saturday–Wednesday, 9 AM–6 PM"}, - {"city": "اصفهان", "city_en": "Isfahan", "address": "خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم", "address_en": "Chaharbagh Bala St., Parseh Complex, 6th Floor", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷", "hours_en": "Saturday–Wednesday, 9 AM–5 PM"}, - {"city": "نوشهر", "city_en": "Nowshahr", "address": "بلوار دریا، دفتر پروژه دریاکنار", "address_en": "Darya Blvd., Daryakenar Project Office", "hours": "شنبه تا پنجشنبه، ۱۰ تا ۱۶", "hours_en": "Saturday–Thursday, 10 AM–4 PM"}, + {"city": "اصفهان", "city_en": "Isfahan", "address": "خیابان ملاصدرا شمالی، کوچه ۶، پلاک ۳۶", "address_en": "Mollasadra Shomali St., Alley 6, No. 36, Isfahan", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷", "hours_en": "Saturday–Wednesday, 9 AM–5 PM"}, ] GALLERY_IMAGES = [ diff --git a/backend/app/seo_pages.py b/backend/app/seo_pages.py index 977db83..6ec12bc 100644 --- a/backend/app/seo_pages.py +++ b/backend/app/seo_pages.py @@ -24,12 +24,15 @@ import re from datetime import date, datetime from functools import lru_cache from pathlib import Path +from urllib.parse import quote from fastapi import APIRouter, Depends, Response +from fastapi.encoders import jsonable_encoder from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from . import models +from .api.public import build_site_payload from .config import settings from .database import get_db @@ -43,6 +46,9 @@ FALLBACK_EN_BRAND = "Aramesh Construction Group" # تصویر پیش‌فرض شبکه‌های اجتماعی — در frontend/public بیلد می‌شود DEFAULT_OG_IMAGE = "/og-default.jpg" DEFAULT_OG_SIZE = (1200, 630) +# کلید IndexNow — اثبات مالکیت برای اعلام فوری نشانی‌های سایت به Bing/Copilot و +# موتورهای مشارکت‌کننده در پروتکل (فایل در /.well-known/indexnow-key.txt سرو می‌شود) +INDEXNOW_KEY = "daa4488a8e2905312b5efd02ad5fc614" # خزنده‌های هوش مصنوعی جست‌وجو/ارجاع — سیاست صریح «مجاز» تا قالب‌های پیش‌فرض # «بستن همه‌چیز» اتفاقی آن‌ها را نبندند (GPTBot آموزش است، OAI-SearchBot ارجاع چت‌جی‌پی‌تی) AI_SEARCH_BOTS = [ @@ -79,6 +85,11 @@ def _fa_path(path: str) -> str: return path[3:] if path.startswith("/en/") else path +def _url_escape(url: str) -> str: + """درصدگذاری نویسه‌های غیر-ASCII (اسلاگ‌های فارسی) — استاندارد sitemap و IndexNow.""" + return quote(url, safe=":/?#[]@!$&'()*+,;=") + + def _clip(text: str, limit: int) -> str: """برش متن به طول مشخص برای توضیح‌های تک‌خطی (llms.txt).""" text = " ".join((text or "").split()) @@ -211,6 +222,16 @@ def _doc_text(doc: object, limit: int = 300) -> str: return text[:limit] + ("…" if len(text) > limit else "") +def _site_json(db: Session) -> str: + """محتوای کامل سایت به‌صورت JSON برای تزریق داخل HTML (window.__SITE__). + فرانت‌اند با همین داده بدون انتظار برای XHR رندر می‌شود — خزنده‌های رندرکننده + (گوگل) هیچ‌وقت در پنجرهٔ «در حال بارگذاری» صفحهٔ نیمه‌خالی نمی‌بینند.""" + payload = jsonable_encoder(build_site_payload(db)) + data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + # امن‌سازی درج در HTML: توالی بستن اسکریپت و کامنت HTML نباید در داده ظاهر شود + return data.replace(" HTMLResponse: """index.html فرانت‌اند با متای تزریقی؛ اگر بیلد در دسترس نبود، صفحه حداقلی.""" title = title.strip() or (FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND) @@ -265,6 +287,9 @@ def _shell( if org_jsonld: payload = json.dumps(org_jsonld, ensure_ascii=False).replace("{payload}') + if db is not None: + # دادهٔ کامل سایت داخل HTML — اولین رندر بدون انتظار برای درخواست /api/site + tags.append(f"") block = "\n ".join(tags) raw = _read_index() @@ -276,6 +301,7 @@ def _shell( f'' f"{esc(title)}{alternates}{block}{body}", status_code=status_code, + headers={"Cache-Control": "no-store"}, ) # اسکیمای Organization مینیمالِ پوستهٔ استاتیک حذف می‌شود تا با نسخهٔ غنی تزریقی تکراری نشود @@ -302,7 +328,7 @@ def _shell( raw = raw.replace("", f" {alternates}\n {block}\n ", 1) if noscript: raw = raw.replace("", f"\n ", 1) - return HTMLResponse(raw, status_code=status_code) + return HTMLResponse(raw, status_code=status_code, headers={"Cache-Control": "no-store"}) # ---------- موجودیت‌های ساخت‌یافته (JSON-LD) ---------- @@ -355,7 +381,19 @@ def _org_jsonld(db: Session, lang: str) -> dict: org["url"] = _absolute("/") logo = (brand.logo or "").strip() if brand else "" if logo: - org["logo"] = _absolute(logo) + # لوگوی رسمی به‌صورت ImageObject — تا موتورها و دستیارهای AI آن را + # به‌عنوان نشان برند بشناسند، نه یک تصویر عمومی بی‌هویت + org["logo"] = { + "@type": "ImageObject", + "url": _absolute(logo), + "contentUrl": _absolute(logo), + "name": f"{_brand_name(db, lang)} — {'لوگوی رسمی' if lang == 'fa' else 'official logo'}", + "caption": _brand_name(db, lang), + } + # تصویر شاخص برند به‌عنوان تصویر معرفی سازمان + hero_img = (brand.hero or "").strip() if brand else "" + if hero_img: + org["image"] = {"@type": "ImageObject", "url": _absolute(hero_img), "caption": _brand_name(db, lang)} footer = db.query(models.FooterContent).first() socials = [] if footer is not None: @@ -511,7 +549,9 @@ def robots(db: Session = Depends(get_db)): content="User-agent: *\nDisallow: /\n", media_type="text/plain; charset=utf-8", ) - lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""] + # Allow: /api/site — رندرر گوگل موقع رندر صفحه این XHR را هم واکشی می‌کند؛ + # Disallow کلی /api باعث می‌شد «مسدود توسط robots.txt» در منابع صفحه ثبت شود + lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", "Allow: /api/site", ""] # سیاست صریح خزنده‌های هوش مصنوعی — ربات‌های جست‌وجو/ارجاع آزادند تا محتوا # در پاسخ‌های ChatGPT/Claude/Perplexity/Gemini ارجاع شود for bot in AI_SEARCH_BOTS: @@ -540,7 +580,11 @@ def sitemap(db: Session = Depends(get_db)): '\n' '\n' ) - return Response(content=xml, media_type="application/xml; charset=utf-8") + return Response( + content=xml, + media_type="application/xml; charset=utf-8", + headers={"Cache-Control": "public, max-age=300"}, + ) esc = html.escape articles = { @@ -557,10 +601,10 @@ def sitemap(db: Session = Depends(get_db)): def entry(loc: str, lang: str, has_en: bool) -> str: pairs = _alternate_pairs(loc, lang, has_en) links = "\n".join( - f' ' + f' ' for code, href_ in pairs ) - return f" {esc(_absolute(loc))}\n{links}\n " + return f" {esc(_url_escape(_absolute(loc)))}\n{links}\n " urls: list[str] = [] # صفحات استاتیک فارسی + معادل‌های انگلیسیِ ترجمه‌شده @@ -589,7 +633,23 @@ def sitemap(db: Session = Depends(get_db)): 'xmlns:xhtml="http://www.w3.org/1999/xhtml">\n' f"{chr(10).join(urls)}\n\n" ) - return Response(content=xml, media_type="application/xml; charset=utf-8") + return Response( + content=xml, + media_type="application/xml; charset=utf-8", + headers={"Cache-Control": "public, max-age=300"}, + ) + + +@router.get("/.well-known/indexnow-key.txt", response_class=Response) +def indexnow_key_file(): + """فایل تأیید مالکیت IndexNow — موتورها با خواندن آن، ارسال‌های IndexNow را می‌پذیرند.""" + return Response(content=INDEXNOW_KEY, media_type="text/plain; charset=utf-8") + + +@router.get(f"/{INDEXNOW_KEY}.txt", response_class=Response) +def indexnow_key_root(): + """محل پیش‌فرض پروتکل IndexNow: /.txt در ریشهٔ دامنه — برخی اعتبارسنج‌ها فقط همین را می‌خوانند.""" + return Response(content=INDEXNOW_KEY, media_type="text/plain; charset=utf-8") @router.get("/llms.txt", response_class=Response) @@ -627,12 +687,10 @@ def llms_txt(db: Session = Depends(get_db)): ("projects", "Projects"), ("blog", "Blog"), ("faq", "FAQ"), ("contact", "Contact"), ]: title, description = _meta_for(db, page_key, "en") - if page_key == "home": - url = "/" - else: - url = f"/en/{page_key}" if page_key != "projects" else "/en/projects" - summary = _clip(description, 100) - lines.append(f"- [{label}]({_absolute(url)}): {summary or label}") + url = "/" if page_key == "home" else f"/en/{page_key}" + # توضیح متا کوتاه است (۱۵۰–۱۶۰ نویسه) — کامل می‌آید تا جمله نیمه‌بریده نشود + summary = " ".join((description or "").split()) + lines.append(f"- [{label}]({_absolute(url)})" + (f": {summary}" if summary else "")) featured_projects = ( db.query(models.Project).order_by(models.Project.position, models.Project.id).limit(6).all() ) @@ -640,8 +698,15 @@ def llms_txt(db: Session = Depends(get_db)): lines += ["", "## Featured Projects"] for p in featured_projects: title = (p.title_en or p.title).strip() - summary = _clip(p.summary_en or p.summary, 100) - lines.append(f"- [{title}]({_absolute('/en/projects/' + p.slug)}): {summary}") + # خلاصهٔ ثبت‌شده؛ در نبودش، موقعیت و وضعیت پروژه به‌عنوان توضیح مفید + summary = (p.summary_en or p.summary or "").strip() + if not summary: + place = (p.location_en or p.location or "").strip() + summary = f"Project in {place}" if place else "" + 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 "")) latest = ( db.query(models.Article) .filter(models.Article.status == "published") @@ -653,8 +718,9 @@ def llms_txt(db: Session = Depends(get_db)): lines += ["", "## Recent Articles"] for a in latest: title = (a.title_en or a.title).strip() - summary = _clip(a.excerpt_en or a.excerpt, 100) - lines.append(f"- [{title}]({_absolute('/en/blog/' + a.slug)}): {summary}") + summary = _clip(a.excerpt_en or a.excerpt, 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) contact_lines = [] if brand_row and (brand_row.phone or "").strip(): @@ -664,16 +730,149 @@ def llms_txt(db: Session = Depends(get_db)): for o in offices: city = (o.city_en or o.city).strip() street = ((o.address_en or o.address) or o.address).strip() - if city or street: - contact_lines.append(f"- Office: {_clip(street + ('، ' + city if city and street else city), 120)}") + # اگر نشانی خودش نام شهر را دارد، تکرار نشود («اصفهان، ملاصدرا…» + «، اصفهان») + 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)}") if contact_lines: lines += ["", "## Contact", *contact_lines] - return Response(content="\n".join(lines) + "\n", media_type="text/plain; charset=utf-8") + # llms.txt زنده از دیتابیس ساخته می‌شود — بدون کش تا همیشه تازه باشد + return Response( + content="\n".join(lines) + "\n", + media_type="text/plain; charset=utf-8", + headers={"Cache-Control": "no-store"}, + ) + + +# این مسیر باید بعد از همهٔ مسیرهای .txt مشخص (robots/llms/indexnow) تعریف شود +# تا هیچکدام را سایه نیندازد +@router.get("/{key}.txt", response_class=Response) +def indexnow_key_any(key: str): + """هر کلید IndexNow معتبر (۳۲ نویسهٔ هگز) — فایل /{key}.txt همان کلید را برمیگرداند. + پنل Bing/دیگر موتورها هر بار کلید تازه میسازند؛ این مسیر یعنی هیچ کلیدی + نیاز به افزودن دستی فایل ندارد (فایلهای موجود در بیلد هم معتبر میمانند).""" + if re.fullmatch(r"[a-f0-9]{32}", key): + return Response(content=key, media_type="text/plain; charset=utf-8") + return Response(status_code=404) # ---------- صفحات استاتیک فارسی ---------- +def _site_content_html(db: Session, page_key: str, lang: str) -> str: + """محتوای واقعی سایت به‌صورت متن HTML خوانا — داخل