From 365e4b9417eb0c9b65509fdd91459cd0778e5f22 Mon Sep 17 00:00:00 2001 From: aramesh-site deploy Date: Fri, 9 Oct 2026 23:45:35 +0000 Subject: [PATCH] =?UTF-8?q?feat(seo):=20=D8=A2=D8=B1=D8=B4=DB=8C=D9=88=20Z?= =?UTF-8?q?IP=20=D8=AA=D8=B5=D8=A7=D9=88=DB=8C=D8=B1=20=D9=BE=D8=B1=D9=88?= =?UTF-8?q?=DA=98=D9=87=20=D8=A8=D8=B1=D8=A7=DB=8C=20=D8=AF=D8=B3=D8=AA?= =?UTF-8?q?=DB=8C=D8=A7=D8=B1=D9=87=D8=A7=DB=8C=20AI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit دستیارها نمی‌توانند دکمه‌های UI را کلیک کنند؛ برای همین نمی‌توانستند تصاویر یک پروژه را بررسی کنند. اکنون: - /projects/{slug}/images.zip — آرشیو تصاویر هر پروژه + manifest.json (مشخصات کامل پروژه: زمین/زیربنا/طبقات/واحدها/کاربری/سال + برای هر تصویر: نام، caption با موقعیت پروژه، و لینک منبع) - /projects-images.json — فهرست ماشین‌خوان هر ۶۵ پروژه با لینک ZIP (ItemList) - هر دو مسیر در قاعدهٔ seo ترویک (prod + override) اضافه شدند رفع باگ‌ها در مسیر ساخت آرشیو: - _fetch_media: unquote روی کلیدِ از قبل دیکد‌شده، نام فایل‌های فارسی را خراب می‌کرد (حالا هر دو شکل امتحان می‌شود + سقوط به واکشی از سایت) - UnboundLocalError: import محلی urllib.request نام urllib را محلی می‌کرد - تصاویر تکراری (تصویر شاخص = اولین عکس گالری) با هش MD5 حذف می‌شوند — ۱۷ از ۱۹ پروژهٔ نمونه یک تصویر تکراری داشتند --- backend/app/seo_pages.py | 167 +++++++++++++++++++++++++++++++++++++++ docker-compose.prod.yml | 2 +- 2 files changed, 168 insertions(+), 1 deletion(-) diff --git a/backend/app/seo_pages.py b/backend/app/seo_pages.py index 0420882..4666c26 100644 --- a/backend/app/seo_pages.py +++ b/backend/app/seo_pages.py @@ -18,9 +18,13 @@ title / description / canonical / OG / hreflang / JSON-LD را از دیتابی و llms.txt با کد ۴۰۴ پاسخ می‌دهد — هیچ نشانی‌ای از سایت خصوصی بیرون درز نمی‌کند. """ +import hashlib import html +import io import json import re +import urllib.parse +import zipfile from datetime import date, datetime from functools import lru_cache from pathlib import Path @@ -1282,3 +1286,166 @@ def en_page(path: str, db: Session = Depends(get_db)): org_jsonld=_org_jsonld(db, "en"), status_code=404, ) return _static_shell(db, path=f"/en/{path}".rstrip("/"), page_key=page_key, lang="en") + + +# ---------- آرشیو ZIP تصاویر پروژه — برای دستیارهای AI و ابزارهای بیرونی ---------- +# خزنده‌ها/دستیارها نمی‌توانند دکمه‌های UI را کلیک کنند؛ این مسیر هر پروژه را +# به‌صورت یک فایل ZIP با «manifest.json» ارائه می‌دهد: هر تصویر + مشخصات پروژه. +# آدرس قابل حدس: /projects/{slug}/images.zip — و فهرست کامل در /projects-images.json + + +def _project_images(project: models.Project) -> list[str]: + paths: list[str] = [] + if (project.image or "").strip(): + paths.append(project.image.strip()) + for g in (project.gallery or []): + if isinstance(g, str) and g.strip() and g.strip() not in paths: + paths.append(g.strip()) + return paths + + +def _fetch_media(path: str) -> bytes | None: + """خواندن فایل رسانه از MinIO با کلید S3 (کلید = همان مسیر /media/...). + توجه: نام فایل‌ها فارسی‌اند؛ unquote روی کلیدِ از قبل دیکد‌شده نویسه‌ها را + خراب می‌کند — پس هر دو شکل (خام و دیکد‌شده) امتحان می‌شود.""" + import urllib.request + import urllib.parse + + key = path.split("/media/", 1)[-1].lstrip("/") if "/media/" in path else path.lstrip("/") + candidates = [key] + decoded = urllib.parse.unquote(key) + if decoded != key: + candidates.append(decoded) + try: + import boto3 + from botocore.config import Config as BotoConfig + + s3 = boto3.client( + "s3", + endpoint_url=settings.s3_endpoint, + aws_access_key_id=settings.s3_access_key, + aws_secret_access_key=settings.s3_secret_key, + region_name="us-east-1", + config=BotoConfig(connect_timeout=5, read_timeout=20, retries={"max_attempts": 2}), + ) + for candidate in candidates: + try: + obj = s3.get_object(Bucket=settings.s3_bucket, Key=candidate) + return obj["Body"].read() + except Exception: + continue + except Exception: + pass + # پشتیبان: واکشی از خود سایت عمومی (مسیر کامل encode می‌شود) + try: + url = _absolute(urllib.parse.quote(path, safe="/")) + req = urllib.request.Request(url, headers={"User-Agent": "ArameshArchive/1.0"}) + return urllib.request.urlopen(req, timeout=25).read() + except Exception: + return None + + +def _project_manifest(project: models.Project, files: list[tuple[str, str]]) -> dict: + """اطلاعات هر تصویر + مشخصات پروژه — تا AI بداند هر فایل چیست.""" + title = project.title + location = (project.location or "").strip() + specs = { + "سال": project.year, "موقعیت": location, "کاربری": project.usage, + "مساحت زمین (مترمربع)": project.land_area, "زیربنا (مترمربع)": project.area, + "تعداد طبقات": project.floors, "طبقات پیلوت": project.pilot_floors, + "واحد مسکونی": project.units_residential, "واحد تجاری": project.units_commercial, + "واحد اداری": project.units_office, "کارفرما": project.client, + "وضعیت": project.status, + } + specs = {k: str(v).strip() for k, v in specs.items() if str(v or "").strip()} + return { + "project": title, + "project_en": (project.title_en or "").strip() or None, + "url": _absolute(f"/projects/{project.slug}"), + "slug": project.slug, + "specifications": specs, + "description": (project.summary or "").strip() or None, + "images": [ + { + "file": fname, + "source_url": _absolute(src), + "caption": f"{title}" + (f"، {location}" if location else ""), + "alt": f"{title} — {'تصویر پروژه' if True else ''}".strip(" —"), + } + for fname, src in files + ], + "license_note": "© گروه ساختمانی آرامش — تصاویر پروژههای واقعی این شرکت.", + } + + +@router.get("/projects-images.json", response_class=Response) +def projects_images_index(db: Session = Depends(get_db)): + """فهرست ماشین‌خوان همهٔ پروژه‌ها با لینک ZIP تصاویرشان — نقطهٔ ورود برای دستیارهای AI.""" + projects = db.query(models.Project).order_by(models.Project.position, models.Project.id).all() + data = { + "@context": "https://schema.org", + "@type": "ItemList", + "name": f"{_brand_name(db, 'fa')} — فهرست پروژه‌ها و تصاویر", + "numberOfItems": len(projects), + "itemListElement": [ + { + "position": i + 1, + "name": p.title, + "page_url": _absolute(f"/projects/{p.slug}"), + "images_zip": _absolute(f"/projects/{urllib.parse.quote(p.slug)}/images.zip"), + "image_count": len(_project_images(p)), + "location": (p.location or "").strip(), + "area_m2": str(p.area) if p.area else None, + "year": str(p.year) if p.year else None, + } + for i, p in enumerate(projects) + ], + } + return Response( + content=json.dumps(data, ensure_ascii=False, indent=1), + media_type="application/json; charset=utf-8", + headers={"Cache-Control": "no-store"}, + ) + + +@router.get("/projects/{slug}/images.zip") +def project_images_zip(slug: str, db: Session = Depends(get_db)): + """ZIP تصاویر یک پروژه (+ manifest.json) — برای دانلود و بررسی توسط ابزارها/AI.""" + project = db.query(models.Project).filter(models.Project.slug == slug).first() + if project is None: + return Response(status_code=404) + + paths = _project_images(project) + buf = io.BytesIO() + files: list[tuple[str, str]] = [] + seen_hashes: set[str] = set() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf: + for src in paths: + data = _fetch_media(src) + if data is None: + continue + # تصویر تکراری (شاخص = اولین عکس گالری) دوباره در آرشیو نمی‌آید + digest = hashlib.md5(data).hexdigest() + if digest in seen_hashes: + continue + seen_hashes.add(digest) + ext = Path(urllib.parse.unquote(src)).suffix.lower() or ".webp" + fname = f"{len(files) + 1:02d}{ext}" + zf.writestr(fname, data) + files.append((fname, src)) + manifest = _project_manifest(project, files) + zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2)) + + payload = buf.getvalue() + # نام فایل ASCII امن — اسلاگ فارسی در هدر Content-Disposition نمی‌آید + safe_name = re.sub(r"[^A-Za-z0-9._-]+", "-", project.slug).strip("-") or f"project-{project.id}" + if not safe_name or safe_name == "-": + safe_name = f"project-{project.id}" + return Response( + content=payload, + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{safe_name}-images.zip"', + "Cache-Control": "public, max-age=3600", + }, + ) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 69b6c0c..cf844d4 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -73,7 +73,7 @@ services: - traefik.http.routers.api.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers # سئو: همهٔ صفحات عمومی از بک‌اند سرو می‌شوند تا متا در HTML اولیه تزریق شود — # مسیرهای استاتیک دقیق‌اند (Path) تا assets/ فونت‌ها از Caddy سرو شوند - - traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (Path(`/`) || Path(`/about`) || Path(`/services`) || Path(`/investment`) || Path(`/faq`) || Path(`/contact`) || PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/llms.txt`) || Path(`/.well-known/indexnow-key.txt`) || Path(`/daa4488a8e2905312b5efd02ad5fc614.txt`) || PathRegexp(`^/[a-f0-9]{32}\.txt$`)) + - traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (Path(`/`) || Path(`/about`) || Path(`/services`) || Path(`/investment`) || Path(`/faq`) || Path(`/contact`) || PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/llms.txt`) || Path(`/.well-known/indexnow-key.txt`) || Path(`/daa4488a8e2905312b5efd02ad5fc614.txt`) || PathRegexp(`^/[a-f0-9]{32}\.txt$`) || Path(`/projects-images.json`) || PathRegexp(`^/projects/.+/images\.zip$`)) - traefik.http.routers.seo.entrypoints=websecure - traefik.http.routers.seo.tls.certresolver=le - traefik.http.routers.seo.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers