Commit 2c946632b4fbc0e2f88bebb8a248730cb1b976eb
1 parent
e4ac1cff
feat(device): 快照 stage_digest_media.py(日报媒体同源暂存)
collect_media_oss.py 只上传本地已有原图,微信多半只落缩略图 → oss_url 全空 → HTML 媒体墙塌缩(10-15 起)。本脚本把缩略图暂存到同源公开路由并按 md5 去重, 供 gen_digest_html 渲染。手机上新增,此处仅同步快照。 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Showing
1 changed file
with
170 additions
and
0 deletions
device/pixelphone/digest/wechat-group-digest/scripts/stage_digest_media.py
0 → 100644
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +"""日报媒体同源暂存(2026-10-17)——修复日报 HTML 图片/视频不可点预览。 | ||
| 3 | + | ||
| 4 | +背景: | ||
| 5 | + collect_media_oss.py --upload 只上传「本地已有原图」,微信通常只落了缩略图 | ||
| 6 | + (|thumb),原图未下载 ⇒ 全部被跳过 ⇒ oss_url 全空 ⇒ gen_digest_html 的 | ||
| 7 | + build_media_section 渲染零条目,日报只剩文字计数行(10-15 起塌缩)。 | ||
| 8 | + | ||
| 9 | +本脚本: | ||
| 10 | + 1. 读 ~/media_digest_<组>_<日期>.json; | ||
| 11 | + 2. 把每个媒体文件(缩略图即可)用 `su cp` 批量暂存到 termux 可读位置; | ||
| 12 | + 3. 按内容 md5 去重(同图/同视频被 8 群转发只存一份,合并群名); | ||
| 13 | + 4. 落位 ~/wechat-viewer/digests/media/<组>/<日期>/<md5>.<ext>, | ||
| 14 | + 回填 digest_url=/digest-assets/media/<组>/<日期>/<md5>.<ext>(同源、公开路由); | ||
| 15 | + 5. 清单原地写回,供 gen_digest_html 渲染。 | ||
| 16 | + | ||
| 17 | +用法: | ||
| 18 | + python3 stage_digest_media.py --date 2026-10-16 --group tgo [--limit N] | ||
| 19 | + | ||
| 20 | +幂等:已有 digest_url 且目标文件存在的条目直接跳过。不依赖 OSS。 | ||
| 21 | +""" | ||
| 22 | +import argparse | ||
| 23 | +import hashlib | ||
| 24 | +import json | ||
| 25 | +import os | ||
| 26 | +import shutil | ||
| 27 | +import subprocess | ||
| 28 | +import sys | ||
| 29 | +import tempfile | ||
| 30 | + | ||
| 31 | +HOME = os.path.expanduser("~") | ||
| 32 | +DIGEST_MEDIA_DIR = os.path.join(HOME, "wechat-viewer", "digests", "media") | ||
| 33 | +SUDO_SH = os.environ.get("PREFIX", "/data/data/com.termux/files/usr") + "/tmp" | ||
| 34 | + | ||
| 35 | + | ||
| 36 | +def media_json_path(group: str, date: str) -> str: | ||
| 37 | + return os.path.join(HOME, f"media_digest_{group}_{date}.json") | ||
| 38 | + | ||
| 39 | + | ||
| 40 | +def batch_su_copy(pairs: list[tuple[str, str]]) -> set[str]: | ||
| 41 | + """一条 su 批量 cp+chmod。返回成功暂存的 dst 集合。""" | ||
| 42 | + if not pairs: | ||
| 43 | + return set() | ||
| 44 | + batch = tempfile.mkstemp(prefix="stage_copy_", suffix=".sh", dir=SUDO_SH)[1] | ||
| 45 | + with open(batch, "w") as f: | ||
| 46 | + f.write("#!/system/bin/sh\n") | ||
| 47 | + for src, dst in pairs: | ||
| 48 | + s = src.replace("'", "'\\''") | ||
| 49 | + d = dst.replace("'", "'\\''") | ||
| 50 | + f.write(f"cp '{s}' '{d}' 2>/dev/null && chmod 644 '{d}'\n") | ||
| 51 | + os.chmod(batch, 0o644) | ||
| 52 | + try: | ||
| 53 | + subprocess.run(["su", "-c", f"sh '{batch}'"], capture_output=True, timeout=300) | ||
| 54 | + finally: | ||
| 55 | + try: | ||
| 56 | + os.unlink(batch) | ||
| 57 | + except OSError: | ||
| 58 | + pass | ||
| 59 | + return {dst for _, dst in pairs if os.path.exists(dst)} | ||
| 60 | + | ||
| 61 | + | ||
| 62 | +def sniff_ext(head: bytes) -> str: | ||
| 63 | + if head.startswith(b"\xff\xd8"): | ||
| 64 | + return ".jpg" | ||
| 65 | + if head.startswith(b"\x89PNG"): | ||
| 66 | + return ".png" | ||
| 67 | + if head[:4] == b"\x1aE\xdf\xa3" or b"ftyp" in head[:12]: | ||
| 68 | + return ".mp4" | ||
| 69 | + return ".jpg" | ||
| 70 | + | ||
| 71 | + | ||
| 72 | +def main(): | ||
| 73 | + ap = argparse.ArgumentParser() | ||
| 74 | + ap.add_argument("--date", required=True) | ||
| 75 | + ap.add_argument("--group", default="tgo") | ||
| 76 | + ap.add_argument("--limit", type=int, default=0) | ||
| 77 | + args = ap.parse_args() | ||
| 78 | + | ||
| 79 | + mpath = media_json_path(args.group, args.date) | ||
| 80 | + if not os.path.exists(mpath): | ||
| 81 | + print(json.dumps({"ok": False, "error": f"清单不存在: {mpath}"}), file=sys.stderr) | ||
| 82 | + return 1 | ||
| 83 | + with open(mpath, encoding="utf-8") as f: | ||
| 84 | + data = json.load(f) | ||
| 85 | + media = data.get("media", []) | ||
| 86 | + | ||
| 87 | + out_dir = os.path.join(DIGEST_MEDIA_DIR, args.group, args.date) | ||
| 88 | + os.makedirs(out_dir, exist_ok=True) | ||
| 89 | + url_prefix = f"/digest-assets/media/{args.group}/{args.date}" | ||
| 90 | + | ||
| 91 | + # 1. 待暂存条目:本地路径解析 + 幂等跳过 | ||
| 92 | + todo = [] # (idx, src_path) | ||
| 93 | + skipped_done = missing = 0 | ||
| 94 | + for i, m in enumerate(media): | ||
| 95 | + if m.get("digest_url") and os.path.exists( | ||
| 96 | + os.path.join(HOME, "wechat-viewer", "digests", | ||
| 97 | + m["digest_url"].removeprefix("/digest-assets/"))): | ||
| 98 | + skipped_done += 1 | ||
| 99 | + continue | ||
| 100 | + lp = (m.get("local_path") or "").split("|")[0] | ||
| 101 | + if not lp or lp.startswith("THUMBNAIL_DIRPATH://"): | ||
| 102 | + missing += 1 | ||
| 103 | + continue | ||
| 104 | + if args.limit and len(todo) >= args.limit: | ||
| 105 | + break | ||
| 106 | + todo.append((i, lp)) | ||
| 107 | + | ||
| 108 | + # 2. su 批量暂存到 termux tmp | ||
| 109 | + tmp_dir = tempfile.mkdtemp(prefix=f"stage_media_{args.group}_{args.date}_", dir=SUDO_SH) | ||
| 110 | + pairs = [(lp, os.path.join(tmp_dir, f"{i}")) for i, lp in todo] | ||
| 111 | + staged = batch_su_copy(pairs) | ||
| 112 | + | ||
| 113 | + # 3. 内容 md5 去重 + 落位 | ||
| 114 | + by_md5: dict[str, dict] = {} # md5 -> media item(保留首条,合并群名) | ||
| 115 | + md5_of: dict[int, str] = {} # idx -> md5 | ||
| 116 | + staged_cnt = 0 | ||
| 117 | + for i, lp in todo: | ||
| 118 | + dst_tmp = os.path.join(tmp_dir, f"{i}") | ||
| 119 | + if dst_tmp not in staged: | ||
| 120 | + missing += 1 | ||
| 121 | + continue | ||
| 122 | + with open(dst_tmp, "rb") as fh: | ||
| 123 | + content = fh.read() | ||
| 124 | + md5 = hashlib.md5(content).hexdigest() | ||
| 125 | + m = media[i] | ||
| 126 | + if md5 in by_md5: | ||
| 127 | + first = by_md5[md5] | ||
| 128 | + for g in (m.get("group") or "").split(","): | ||
| 129 | + g = g.strip() | ||
| 130 | + if g and g not in first["group"]: | ||
| 131 | + first["group"] = f"{first['group']}, {g}" if first["group"] else g | ||
| 132 | + md5_of[i] = md5 | ||
| 133 | + continue | ||
| 134 | + ext = sniff_ext(content[:16]) | ||
| 135 | + final = os.path.join(out_dir, md5 + ext) | ||
| 136 | + if not os.path.exists(final): | ||
| 137 | + shutil.copyfile(dst_tmp, final) | ||
| 138 | + os.chmod(final, 0o644) | ||
| 139 | + m["digest_url"] = f"{url_prefix}/{md5}{ext}" | ||
| 140 | + by_md5[md5] = m | ||
| 141 | + md5_of[i] = md5 | ||
| 142 | + staged_cnt += 1 | ||
| 143 | + shutil.rmtree(tmp_dir, ignore_errors=True) | ||
| 144 | + | ||
| 145 | + # 4. 重复条目指向同一 URL(群名已并入首条,渲染前去重由生成器按 md5 键处理: | ||
| 146 | + # 这里直接把重复条目剔除,首条带合并群名,清单更干净) | ||
| 147 | + kept, seen = [], set() | ||
| 148 | + for i, m in enumerate(media): | ||
| 149 | + h = md5_of.get(i) | ||
| 150 | + if h is None: | ||
| 151 | + kept.append(m) | ||
| 152 | + elif h not in seen: | ||
| 153 | + seen.add(h) | ||
| 154 | + kept.append(m) | ||
| 155 | + data["media"] = kept | ||
| 156 | + data["staged"] = {"ok": True, "staged_new": staged_cnt, "unique": len(by_md5), | ||
| 157 | + "skipped_done": skipped_done, "missing_local": missing, | ||
| 158 | + "total_before": len(media), "total_after": len(kept)} | ||
| 159 | + with open(mpath, "w", encoding="utf-8") as f: | ||
| 160 | + json.dump(data, f, ensure_ascii=False, indent=1, default=str) | ||
| 161 | + | ||
| 162 | + print(json.dumps({"ok": True, "group": args.group, "date": args.date, | ||
| 163 | + "staged_new": staged_cnt, "unique": len(by_md5), | ||
| 164 | + "skipped_done": skipped_done, "missing_local": missing, | ||
| 165 | + "total": len(kept), "dir": out_dir}, ensure_ascii=False)) | ||
| 166 | + return 0 | ||
| 167 | + | ||
| 168 | + | ||
| 169 | +if __name__ == "__main__": | ||
| 170 | + sys.exit(main()) |