- 迁移器与新增 photos_ingest.py 压缩时显式 exif= 传参(PIL 重存默认 剥离——此前 21 张已全部丢 EXIF,图库参数条构建期数据源被抽空) - exif=None 会使 PIL JPEG 保存 len(None) 崩,改条件传参 - photos_ingest.py:幂等入库处理器(长边 2400/q86,>1.5M 才动), 堵住 gitea/本地上传原图直入仓的缺口(196M 事故成因) - 重跑迁移:19/21 带 EXIF(2 张原图本无),体积 9.7M 验证: pnpm astro build 11 页 exit 0;EXIF 保留计数 19/21 Spec: none (Phase 2 归档后的独立小修:EXIF 修复 + ingest 补缺) Co-authored-by: GLM-5.3
83 lines
2.9 KiB
Python
83 lines
2.9 KiB
Python
#!/usr/bin/env python3
|
||
"""图库迁移(Phase 2 T3):Halo photos → content/photos/{jpg,json sidecar}
|
||
|
||
EXIF 不在迁移时提——构建期 photos loader(src/lib/exif.ts)会补全;
|
||
sidecar 里 exif 留空对象。分组:Halo displayName 含山/雪/land/snow →
|
||
landscape,否则 street(当前仅一组 snow mountain → landscape)。
|
||
"""
|
||
import json
|
||
import re
|
||
import urllib.request
|
||
from pathlib import Path
|
||
from urllib.parse import unquote
|
||
|
||
BASE = "http://127.0.0.1:18090"
|
||
OUT = Path("content/photos")
|
||
|
||
|
||
def slugify(name: str) -> str:
|
||
stem = re.sub(r"\.(jpg|jpeg|png|webp)$", "", name, flags=re.I)
|
||
stem = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff-]+", "-", stem).strip("-")
|
||
return stem or "photo"
|
||
|
||
|
||
def group_of(display: str) -> str:
|
||
low = display.lower()
|
||
return "landscape" if any(k in low for k in ("山", "雪", "snow", "land", "mountain")) else "street"
|
||
|
||
|
||
def compress(data: bytes) -> bytes:
|
||
from io import BytesIO
|
||
|
||
from PIL import Image
|
||
|
||
img = Image.open(BytesIO(data))
|
||
# EXIF 必须显式带上:PIL 重存默认剥离,而构建期 exifr 正是从文件提 EXIF
|
||
exif = img.info.get("exif")
|
||
if img.mode not in ("RGB", "L"):
|
||
img = img.convert("RGB")
|
||
img.thumbnail((2400, 2400))
|
||
buf = BytesIO()
|
||
# PIL 的 JPEG 保存不接受 exif=None(len(None) 崩),仅在确有 EXIF 时传参
|
||
kwargs = {"exif": exif} if exif else {}
|
||
img.save(buf, "JPEG", quality=86, optimize=True, **kwargs)
|
||
return buf.getvalue()
|
||
|
||
|
||
def main() -> int:
|
||
# 真实照片取代样例:先清样例(jpg/json 成对;仅清本脚本管辖的产物)
|
||
for f in OUT.glob("*"):
|
||
if f.suffix in (".jpg", ".json"):
|
||
f.unlink()
|
||
|
||
photos = json.load(open(".migration/photos.json", encoding="utf-8"))
|
||
n = 0
|
||
for p in photos:
|
||
spec = p["spec"]
|
||
url = spec.get("url") or spec.get("cover") or ""
|
||
if not url.startswith("/upload/"):
|
||
print(f" skip 非本站图: {url[:60]}")
|
||
continue
|
||
fname = unquote(url.rsplit("/", 1)[1])
|
||
slug = slugify(spec.get("displayName") or fname)
|
||
data = urllib.request.urlopen(BASE + url, timeout=60).read()
|
||
# 入仓前压到长边 2400/q86:原图总计 196M(单张最大 28M)会永久膨胀
|
||
# 公开仓历史并撞死 nginx 100M 推送上限;原图已随 Phase 0 备份归档,
|
||
# 站内展示由 sharp 出 srcset,2400px 足够 lightbox 全屏
|
||
(OUT / f"{slug}.jpg").write_bytes(compress(data))
|
||
|
||
(OUT / f"{slug}.json").write_text(json.dumps({
|
||
"title": spec.get("displayName", fname).rsplit(".", 1)[0],
|
||
"image": f"./{slug}.jpg",
|
||
"group": group_of(spec.get("groupName", "")),
|
||
"alt": spec.get("description", ""),
|
||
"exif": {},
|
||
}, ensure_ascii=False, indent=1))
|
||
n += 1
|
||
print(f"migrated {n} photos")
|
||
return 0 if n else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|