Commit 2c946632b4fbc0e2f88bebb8a248730cb1b976eb

Authored by yibo
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>
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())