From 2c946632b4fbc0e2f88bebb8a248730cb1b976eb Mon Sep 17 00:00:00 2001 From: yibo Date: Sat, 17 Oct 2026 22:40:21 +0800 Subject: [PATCH] feat(device): 快照 stage_digest_media.py(日报媒体同源暂存) --- device/pixelphone/digest/wechat-group-digest/scripts/stage_digest_media.py | 170 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+), 0 deletions(-) create mode 100644 device/pixelphone/digest/wechat-group-digest/scripts/stage_digest_media.py diff --git a/device/pixelphone/digest/wechat-group-digest/scripts/stage_digest_media.py b/device/pixelphone/digest/wechat-group-digest/scripts/stage_digest_media.py new file mode 100644 index 0000000..0cbde13 --- /dev/null +++ b/device/pixelphone/digest/wechat-group-digest/scripts/stage_digest_media.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""日报媒体同源暂存(2026-10-17)——修复日报 HTML 图片/视频不可点预览。 + +背景: + collect_media_oss.py --upload 只上传「本地已有原图」,微信通常只落了缩略图 + (|thumb),原图未下载 ⇒ 全部被跳过 ⇒ oss_url 全空 ⇒ gen_digest_html 的 + build_media_section 渲染零条目,日报只剩文字计数行(10-15 起塌缩)。 + +本脚本: + 1. 读 ~/media_digest_<组>_<日期>.json; + 2. 把每个媒体文件(缩略图即可)用 `su cp` 批量暂存到 termux 可读位置; + 3. 按内容 md5 去重(同图/同视频被 8 群转发只存一份,合并群名); + 4. 落位 ~/wechat-viewer/digests/media/<组>/<日期>/., + 回填 digest_url=/digest-assets/media/<组>/<日期>/.(同源、公开路由); + 5. 清单原地写回,供 gen_digest_html 渲染。 + +用法: + python3 stage_digest_media.py --date 2026-10-16 --group tgo [--limit N] + +幂等:已有 digest_url 且目标文件存在的条目直接跳过。不依赖 OSS。 +""" +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tempfile + +HOME = os.path.expanduser("~") +DIGEST_MEDIA_DIR = os.path.join(HOME, "wechat-viewer", "digests", "media") +SUDO_SH = os.environ.get("PREFIX", "/data/data/com.termux/files/usr") + "/tmp" + + +def media_json_path(group: str, date: str) -> str: + return os.path.join(HOME, f"media_digest_{group}_{date}.json") + + +def batch_su_copy(pairs: list[tuple[str, str]]) -> set[str]: + """一条 su 批量 cp+chmod。返回成功暂存的 dst 集合。""" + if not pairs: + return set() + batch = tempfile.mkstemp(prefix="stage_copy_", suffix=".sh", dir=SUDO_SH)[1] + with open(batch, "w") as f: + f.write("#!/system/bin/sh\n") + for src, dst in pairs: + s = src.replace("'", "'\\''") + d = dst.replace("'", "'\\''") + f.write(f"cp '{s}' '{d}' 2>/dev/null && chmod 644 '{d}'\n") + os.chmod(batch, 0o644) + try: + subprocess.run(["su", "-c", f"sh '{batch}'"], capture_output=True, timeout=300) + finally: + try: + os.unlink(batch) + except OSError: + pass + return {dst for _, dst in pairs if os.path.exists(dst)} + + +def sniff_ext(head: bytes) -> str: + if head.startswith(b"\xff\xd8"): + return ".jpg" + if head.startswith(b"\x89PNG"): + return ".png" + if head[:4] == b"\x1aE\xdf\xa3" or b"ftyp" in head[:12]: + return ".mp4" + return ".jpg" + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--date", required=True) + ap.add_argument("--group", default="tgo") + ap.add_argument("--limit", type=int, default=0) + args = ap.parse_args() + + mpath = media_json_path(args.group, args.date) + if not os.path.exists(mpath): + print(json.dumps({"ok": False, "error": f"清单不存在: {mpath}"}), file=sys.stderr) + return 1 + with open(mpath, encoding="utf-8") as f: + data = json.load(f) + media = data.get("media", []) + + out_dir = os.path.join(DIGEST_MEDIA_DIR, args.group, args.date) + os.makedirs(out_dir, exist_ok=True) + url_prefix = f"/digest-assets/media/{args.group}/{args.date}" + + # 1. 待暂存条目:本地路径解析 + 幂等跳过 + todo = [] # (idx, src_path) + skipped_done = missing = 0 + for i, m in enumerate(media): + if m.get("digest_url") and os.path.exists( + os.path.join(HOME, "wechat-viewer", "digests", + m["digest_url"].removeprefix("/digest-assets/"))): + skipped_done += 1 + continue + lp = (m.get("local_path") or "").split("|")[0] + if not lp or lp.startswith("THUMBNAIL_DIRPATH://"): + missing += 1 + continue + if args.limit and len(todo) >= args.limit: + break + todo.append((i, lp)) + + # 2. su 批量暂存到 termux tmp + tmp_dir = tempfile.mkdtemp(prefix=f"stage_media_{args.group}_{args.date}_", dir=SUDO_SH) + pairs = [(lp, os.path.join(tmp_dir, f"{i}")) for i, lp in todo] + staged = batch_su_copy(pairs) + + # 3. 内容 md5 去重 + 落位 + by_md5: dict[str, dict] = {} # md5 -> media item(保留首条,合并群名) + md5_of: dict[int, str] = {} # idx -> md5 + staged_cnt = 0 + for i, lp in todo: + dst_tmp = os.path.join(tmp_dir, f"{i}") + if dst_tmp not in staged: + missing += 1 + continue + with open(dst_tmp, "rb") as fh: + content = fh.read() + md5 = hashlib.md5(content).hexdigest() + m = media[i] + if md5 in by_md5: + first = by_md5[md5] + for g in (m.get("group") or "").split(","): + g = g.strip() + if g and g not in first["group"]: + first["group"] = f"{first['group']}, {g}" if first["group"] else g + md5_of[i] = md5 + continue + ext = sniff_ext(content[:16]) + final = os.path.join(out_dir, md5 + ext) + if not os.path.exists(final): + shutil.copyfile(dst_tmp, final) + os.chmod(final, 0o644) + m["digest_url"] = f"{url_prefix}/{md5}{ext}" + by_md5[md5] = m + md5_of[i] = md5 + staged_cnt += 1 + shutil.rmtree(tmp_dir, ignore_errors=True) + + # 4. 重复条目指向同一 URL(群名已并入首条,渲染前去重由生成器按 md5 键处理: + # 这里直接把重复条目剔除,首条带合并群名,清单更干净) + kept, seen = [], set() + for i, m in enumerate(media): + h = md5_of.get(i) + if h is None: + kept.append(m) + elif h not in seen: + seen.add(h) + kept.append(m) + data["media"] = kept + data["staged"] = {"ok": True, "staged_new": staged_cnt, "unique": len(by_md5), + "skipped_done": skipped_done, "missing_local": missing, + "total_before": len(media), "total_after": len(kept)} + with open(mpath, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=1, default=str) + + print(json.dumps({"ok": True, "group": args.group, "date": args.date, + "staged_new": staged_cnt, "unique": len(by_md5), + "skipped_done": skipped_done, "missing_local": missing, + "total": len(kept), "dir": out_dir}, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) -- libgit2 0.21.4