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