feat(seo): آرشیو ZIP تصاویر پروژه برای دستیارهای AI

دستیارها نمی‌توانند دکمه‌های 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 حذف می‌شوند —
  ۱۷ از ۱۹ پروژهٔ نمونه یک تصویر تکراری داشتند
This commit is contained in:
aramesh-site deploy
2026-10-09 23:45:35 +00:00
parent b26207769f
commit 365e4b9417
2 changed files with 168 additions and 1 deletions
+167
View File
@@ -18,9 +18,13 @@ title / description / canonical / OG / hreflang / JSON-LD را از دیتابی
و llms.txt با کد ۴۰۴ پاسخ می‌دهد — هیچ نشانی‌ای از سایت خصوصی بیرون درز نمی‌کند. و llms.txt با کد ۴۰۴ پاسخ می‌دهد — هیچ نشانی‌ای از سایت خصوصی بیرون درز نمی‌کند.
""" """
import hashlib
import html import html
import io
import json import json
import re import re
import urllib.parse
import zipfile
from datetime import date, datetime from datetime import date, datetime
from functools import lru_cache from functools import lru_cache
from pathlib import Path 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, org_jsonld=_org_jsonld(db, "en"), status_code=404,
) )
return _static_shell(db, path=f"/en/{path}".rstrip("/"), page_key=page_key, lang="en") 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",
},
)
+1 -1
View File
@@ -73,7 +73,7 @@ services:
- traefik.http.routers.api.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers - traefik.http.routers.api.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers
# سئو: همهٔ صفحات عمومی از بک‌اند سرو می‌شوند تا متا در HTML اولیه تزریق شود — # سئو: همهٔ صفحات عمومی از بک‌اند سرو می‌شوند تا متا در HTML اولیه تزریق شود —
# مسیرهای استاتیک دقیق‌اند (Path) تا assets/ فونت‌ها از Caddy سرو شوند # مسیرهای استاتیک دقیق‌اند (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.entrypoints=websecure
- traefik.http.routers.seo.tls.certresolver=le - traefik.http.routers.seo.tls.certresolver=le
- traefik.http.routers.seo.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers - traefik.http.routers.seo.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers