build_report.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  1. #!/usr/bin/env python3
  2. """build_report.py — 生成拉片 HTML 报告(暗色主题/手机自适应/相对路径).
  3. 用法:
  4. python3 build_report.py --workdir /tmp/lapian --title "拉片分析:听show丽江酒吧"
  5. 前置(可缺失, 缺了自动降级):
  6. meta.json / transcript.json / frames.json / comments.json
  7. 产物: workdir/report.html + workdir/analysis.json
  8. 特性:
  9. - 分段: 优先 LLM(编导方法论点评), 失败回退规则启发式, 永不中断
  10. - 词频/情绪: 纯本地 n-gram + 情绪词典(无第三方依赖)
  11. - 模板渲染: 内置 mini-Jinja(stdlib), 无需安装 jinja2
  12. """
  13. import argparse
  14. import datetime
  15. import json
  16. import os
  17. import re
  18. import sys
  19. import urllib.request
  20. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
  21. from common import e as esc, fmt_ts, get_api_token # noqa: E402
  22. ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  23. TEMPLATE = os.path.join(ROOT, "templates", "report.html.j2")
  24. TEXT_MODEL = os.environ.get("FEME_TEXT_MODEL", "deepseek-v4-flash")
  25. API_BASE = os.environ.get("FEME_API_BASE", "https://api.fmode.cn/v1")
  26. TAG_CLS = {"薛辉": "tag-blue", "郑经说": "tag-purple", "三把刀": "tag-green",
  27. "小希": "tag-yellow", "南门": "tag-yellow", "志楠": "tag-green"}
  28. POS_KW = ("同感 真实 说得对 认同 共鸣 喜欢 爱 赞 通透 治愈 舒服 自由 幸福 羡慕 "
  29. "向往 懂生活 回家 回云南 想去 泪目 豁达 乐观").split()
  30. NEG_KW = ("不认同 杠 胡说 假的 喷 讨厌 反对 无语 尬 难看 翻车 骗 贩卖焦虑 内卷 累 苦"
  31. ).split()
  32. SENT_STOP = "的了是在和就有不人我都这也你们他们啊吧吗呢把被将还又再便而或如果所以因为但是"
  33. LLM_PROMPT = """你是短视频编导顾问(方法体系: 薛辉·观点前置/排比/画面感/收束升华; 郑经说·人群分层/功利型内容; 三把刀·流量文案vs变现文案; 编导小希·前三秒画面钩子)。对下面这条口播视频逐段拉片。
  34. 视频数据: 时长{dur}s, 点赞{digg}, 评论{cmt}, 收藏{coll}, 分享{share}
  35. 文案全文: {fulltext}
  36. 逐段台词(带时间戳):
  37. {segment_lines}
  38. 已抽帧画面参考:
  39. {frame_lines}
  40. 严格输出 JSON(不要markdown围栏):
  41. {{"segments":[{{"i":0,"func":"起/承/转/合/收 之一","techniques":["导师·技法名"],"comment":"60-90字点评: 句式/节奏/画面匹配/情绪价值, 点名用了什么技法","reuse":"可直接套用的句式模板, 含{{占位符}}"}}],
  42. "suggestions":[{{"title":"建议标题","body":"40字内可执行动作"}}]}}
  43. 要求: segments 数量与输入逐段台词一一对应; techniques 每段1-3个, 用"导师·技法"格式; suggestions 3条。"""
  44. # ----------------------------------------------------------- mini-Jinja ----
  45. _TOKEN = re.compile(r"({{.*?}}|{%.*?%})", re.S)
  46. def _lookup(val, key):
  47. if isinstance(val, dict):
  48. return val.get(key, "")
  49. if isinstance(val, (list, tuple)):
  50. if key == "length":
  51. return len(val)
  52. try:
  53. return val[int(key)]
  54. except (ValueError, IndexError):
  55. return ""
  56. return getattr(val, key, "")
  57. def _resolve(expr, ctx):
  58. expr = expr.strip()
  59. if re.fullmatch(r"-?\d+(\.\d+)?", expr):
  60. return float(expr)
  61. if expr in ("true", "True"):
  62. return True
  63. if expr in ("false", "False", "none", "None"):
  64. return False
  65. parts = expr.split(".")
  66. val = ctx.get(parts[0], "")
  67. for p in parts[1:]:
  68. val = _lookup(val, p)
  69. return val
  70. def _parse(tokens, i):
  71. nodes = []
  72. while i < len(tokens):
  73. t = tokens[i]
  74. if t.startswith("{%"):
  75. stmt = t[2:-2].strip()
  76. kw, _, rest = stmt.partition(" ")
  77. if kw in ("endif", "endfor", "else"):
  78. return nodes, i
  79. if kw == "if":
  80. branches, cond = [], rest.strip()
  81. while True:
  82. body, i = _parse(tokens, i + 1)
  83. branches.append((cond, body))
  84. tk = tokens[i]
  85. tstmt = tk[2:-2].strip()
  86. tkw, _, trest = tstmt.partition(" ")
  87. if tkw == "elif":
  88. cond = trest.strip()
  89. continue
  90. if tkw == "else":
  91. body, i = _parse(tokens, i + 1)
  92. branches.append((None, body))
  93. i += 1 # consume endif
  94. break
  95. nodes.append(("if", branches))
  96. elif kw == "for":
  97. var, itexpr = rest.strip().split(" in ", 1)
  98. body, i = _parse(tokens, i + 1)
  99. i += 1 # consume endfor
  100. nodes.append(("for", var.strip(), itexpr.strip(), body))
  101. else:
  102. i += 1
  103. elif t.startswith("{{"):
  104. nodes.append(("expr", t[2:-2].strip()))
  105. i += 1
  106. else:
  107. nodes.append(("text", t))
  108. i += 1
  109. return nodes, i
  110. def _exec(nodes, ctx, out):
  111. for node in nodes:
  112. if node[0] == "text":
  113. out.append(node[1])
  114. elif node[0] == "expr":
  115. v = _resolve(node[1], ctx)
  116. out.append("" if v is None else str(v))
  117. elif node[0] == "if":
  118. for cond, body in node[1]:
  119. if cond is None or _resolve(cond, ctx):
  120. _exec(body, ctx, out)
  121. break
  122. elif node[0] == "for":
  123. _, var, itexpr, body = node
  124. it = _resolve(itexpr, ctx)
  125. items = it if isinstance(it, (list, tuple)) else ([it] if it else [])
  126. n = len(items)
  127. for idx, item in enumerate(items, 1):
  128. sub = dict(ctx)
  129. sub[var] = item
  130. sub["loop"] = {"index": idx, "first": idx == 1, "last": idx == n}
  131. _exec(body, sub, out)
  132. def render_template(template: str, ctx: dict) -> str:
  133. tokens = _TOKEN.split(template)
  134. nodes, _ = _parse(tokens, 0)
  135. out = []
  136. _exec(nodes, ctx, out)
  137. return "".join(out)
  138. # ------------------------------------------------------------- analysis ----
  139. def load_json(path):
  140. if os.path.exists(path):
  141. try:
  142. return json.load(open(path, encoding="utf-8"))
  143. except Exception:
  144. return None
  145. return None
  146. def call_llm(prompt: str):
  147. """调文本模型。注意: 账号内 deepseek-v4-flash 实际路由到 reasoning 模型
  148. (实测 z-ai/glm-5.3-flash), reasoning 会吃 token 预算 → max_tokens 给足,
  149. 否则 content 为空。返回 (content, 实际路由模型名)。"""
  150. payload = {"model": TEXT_MODEL, "temperature": 0.3, "max_tokens": 12000,
  151. "messages": [{"role": "user", "content": prompt}]}
  152. req = urllib.request.Request(
  153. f"{API_BASE}/chat/completions", data=json.dumps(payload).encode(),
  154. headers={"Content-Type": "application/json",
  155. "Authorization": f"Bearer {get_api_token()}"})
  156. with urllib.request.urlopen(req, timeout=300) as r:
  157. resp = json.loads(r.read())
  158. content = (resp["choices"][0]["message"].get("content") or "").strip()
  159. if not content:
  160. raise ValueError("模型返回空content(reasoning吃满token预算)")
  161. return content, resp.get("model") or TEXT_MODEL
  162. def parse_llm_json(text: str) -> dict:
  163. text = text.strip()
  164. if text.startswith("```"):
  165. text = text.strip("`").lstrip("json").strip()
  166. try:
  167. return json.loads(text)
  168. except Exception:
  169. pass
  170. if "{" in text and "}" in text:
  171. try:
  172. return json.loads(text[text.index("{"): text.rindex("}") + 1])
  173. except Exception:
  174. pass
  175. return {}
  176. def tag_cls(label: str) -> str:
  177. for k, cls in TAG_CLS.items():
  178. if k in label:
  179. return cls
  180. return "tag-blue"
  181. def heuristic_techniques(i: int, n: int, text: str) -> list:
  182. tags = []
  183. if i == 0:
  184. tags.append("薛辉·观点前置")
  185. tags.append("小希·前三秒钩子")
  186. if re.search(r"(既不|又不|也不|不急着|不是[^。,]*,?不是)", text):
  187. tags.append("薛辉·排比")
  188. if re.search(r"(有人说|不是.*而是|别人.*我们|他们.*我们)", text):
  189. tags.append("薛辉·反击句式/对比")
  190. if re.search(r"(块钱|免费|不要钱|吃|喝|买|菜市场|院子)", text):
  191. tags.append("薛辉·画面感细节")
  192. if i == n - 1:
  193. tags.append("薛辉·收束升华")
  194. return tags or ["志楠·节奏控制"]
  195. def fallback_comment(i, n, text) -> str:
  196. if i == 0:
  197. return "开场即抛出反常识观点, 承担前三秒留人任务; 句式短促, 信息密度高。"
  198. if i == n - 1:
  199. return "结尾回扣开头形成闭环, 用更高维度的定义完成价值升华。"
  200. return "中段延续主线, 用具体细节支撑观点, 保持节奏与信息密度。"
  201. def chi_words(text: str, top: int = 8) -> list:
  202. """无分词词典 → 2/3-gram 近似, 去停用字边, 去包含重复。"""
  203. text = re.sub(r"[^\u4e00-\u9fff]", " ", text)
  204. grams = {}
  205. for w in text.split():
  206. for n in (2, 3):
  207. for k in range(len(w) - n + 1):
  208. g = w[k:k + n]
  209. grams[g] = grams.get(g, 0) + 1
  210. cands = [(g, c) for g, c in grams.items()
  211. if c >= 2 and g[0] not in SENT_STOP and g[-1] not in SENT_STOP]
  212. cands.sort(key=lambda x: -x[1])
  213. kept = []
  214. for g, c in cands:
  215. if not any(g in k for k, _ in kept):
  216. kept.append((g, c))
  217. if len(kept) >= top:
  218. break
  219. mx = kept[0][1] if kept else 1
  220. return [{"word": g, "count": c, "pct": max(6, round(c / mx * 100))}
  221. for g, c in kept]
  222. def sentiment_split(text: str) -> list:
  223. sents = [s for s in re.split(r"[。!?;\n]", text) if s.strip()]
  224. pos = sum(1 for s in sents if any(k in s for k in POS_KW))
  225. neg = sum(1 for s in sents if any(k in s for k in NEG_KW))
  226. neu = max(len(sents) - pos - neg, 0)
  227. tot = max(pos + neg + neu, 1)
  228. rows = [("正面", pos, "var(--green)"), ("中性", neu, "var(--yellow)"),
  229. ("负面", neg, "var(--red)")]
  230. out, acc = [], 0
  231. for i, (label, v, color) in enumerate(rows):
  232. pct = round(v / tot * 100) if i < 2 else max(0, 100 - acc)
  233. acc += pct
  234. out.append({"label": label, "pct": pct, "color": color})
  235. return out
  236. def comment_insight(text: str) -> str:
  237. if any(k in text for k in ("回去", "回云南", "回家", "社保")):
  238. return "在外游子的身份共鸣——最能带动转发"
  239. if any(k in text for k in ("同感", "真实", "说得对", "就是这样")):
  240. return "强认同型评论, 印证文案戳中人群"
  241. if any(k in text for k in ("不是", "而是", "——")):
  242. return "评论本身有文案意识, 可沉淀为选题素材"
  243. if any(k in text for k in ("但", "不过", "其实")):
  244. return "补充视角, 可作为下一条视频的回应点"
  245. return "氛围型互动"
  246. def classify_comment(text: str) -> str:
  247. if any(k in text for k in NEG_KW):
  248. return "neg"
  249. if any(k in text for k in POS_KW):
  250. return "pos"
  251. return "neu"
  252. def main():
  253. ap = argparse.ArgumentParser()
  254. ap.add_argument("--workdir", required=True)
  255. ap.add_argument("--title", default="")
  256. args = ap.parse_args()
  257. wd = args.workdir
  258. meta = load_json(os.path.join(wd, "meta.json")) or {}
  259. tr = load_json(os.path.join(wd, "transcript.json")) or {}
  260. frames = load_json(os.path.join(wd, "frames.json")) or []
  261. comments = load_json(os.path.join(wd, "comments.json")) or []
  262. data = tr.get("data") or {}
  263. segs = data.get("segments") or []
  264. fulltext = data.get("text") or "".join(s.get("text", "") for s in segs)
  265. if not segs and fulltext: # 无时间戳转写 → 整体一段
  266. segs = [{"bg": 0, "ed": int(meta.get("duration_s", 60) * 1000),
  267. "speaker": "0", "text": fulltext}]
  268. if not segs:
  269. raise SystemExit("[report] 没有转写内容, 先跑 transcribe.py")
  270. duration_s = meta.get("duration_s") or (segs[-1]["ed"] / 1000)
  271. # --- LLM 逐段点评(失败回退启发式) ---
  272. llm_engine, llm_out = "规则启发式", {}
  273. manual = load_json(os.path.join(wd, "llm_out.json"))
  274. if manual and manual.get("segments"):
  275. llm_out, llm_engine = manual, "手动点评(llm_out.json)"
  276. print(f"[report] 使用手动点评 {len(llm_out['segments'])} 段 (llm_out.json)")
  277. else:
  278. try:
  279. seg_lines = "\n".join(
  280. f"[{fmt_ts(s['bg'])}-{fmt_ts(s['ed'])}] {s.get('text','')}" for s in segs)
  281. frame_lines = "\n".join(
  282. f"{f['t_label']}: {f.get('caption','')}" for f in frames) or "(无)"
  283. st = meta.get("stats") or {}
  284. prompt = LLM_PROMPT.format(
  285. dur=round(duration_s), digg=st.get("digg", "?"), cmt=st.get("comment", "?"),
  286. coll=st.get("collect", "?"), share=st.get("share", "?"),
  287. fulltext=fulltext[:1200], segment_lines=seg_lines, frame_lines=frame_lines)
  288. raw, routed = call_llm(prompt)
  289. llm_out = parse_llm_json(raw)
  290. if llm_out.get("segments"):
  291. llm_engine = f"{TEXT_MODEL}→{routed}"
  292. print(f"[report] LLM 点评 {len(llm_out['segments'])} 段 ({llm_engine})")
  293. else:
  294. print("[report] LLM 输出无法解析, 回退规则启发式")
  295. except Exception as ex:
  296. print(f"[report] LLM 调用失败({ex}), 回退规则启发式")
  297. llm_segs = {int(s.get("i", i)): s for i, s in enumerate(llm_out.get("segments") or [])}
  298. # --- 组装分析段 ---
  299. n = len(segs)
  300. analysis_segs = []
  301. for i, s in enumerate(segs):
  302. t0, t1 = int(s.get("bg", 0)), int(s.get("ed", 0))
  303. text = s.get("text", "")
  304. L = llm_segs.get(i) or {}
  305. techniques = [t for t in (L.get("techniques") or
  306. heuristic_techniques(i, n, text)) if t]
  307. frame = min(frames, key=lambda f: abs(f["t_ms"] - t0),
  308. default=None) if frames else None
  309. if frame and abs(frame["t_ms"] - t0) > 8000:
  310. frame = None
  311. analysis_segs.append({
  312. "i": i, "bg": t0, "ed": t1,
  313. "t_start": fmt_ts(t0), "t_end": fmt_ts(t1), "t_start_s": t0 // 1000,
  314. "text": esc(text), "preview": esc(text[:24] + ("…" if len(text) > 24 else "")),
  315. "techniques": [{"label": esc(t), "cls": tag_cls(t)} for t in techniques],
  316. "func": esc(L.get("func") or ""),
  317. "comment_html": esc(L.get("comment") or fallback_comment(i, n, text)),
  318. "reuse": esc(L.get("reuse") or ""),
  319. "frame": frame,
  320. })
  321. # --- 整体结构表 ---
  322. stage_names = ["起", "承", "转", "合", "收"]
  323. structure_rows = []
  324. for idx, sg in enumerate(analysis_segs):
  325. stage = sg["func"] or stage_names[min(int(idx / n * len(stage_names)),
  326. len(stage_names) - 1)]
  327. structure_rows.append({
  328. "stage": esc(stage), "time": f"{sg['t_start']}—{sg['t_end']}",
  329. "func": esc(re.sub(r"^.{0,3}[::]", "", sg["func"]) if sg["func"]
  330. else ("开场留人" if idx == 0 else
  331. ("收束升华" if idx == n - 1 else "承接展开"))),
  332. "techniques": " ".join(t["label"] for t in sg["techniques"]) or "—",
  333. })
  334. summary_html = esc(llm_out.get("structure_summary") or
  335. f"{n}段完成全片, 各段技法如上; 逐段细节见上方可折叠卡片。")
  336. # --- 词频/情绪 ---
  337. wf = chi_words(fulltext)
  338. sentiment = sentiment_split(fulltext)
  339. word_count = len(re.sub(r"\s", "", fulltext))
  340. wpm = round(word_count / max(duration_s / 60, 0.1))
  341. # --- 评论区 ---
  342. comment_stats, top_comments, comment_keywords, comment_total = [], [], [], 0
  343. if comments:
  344. cls_counts = {"pos": 0, "neu": 0, "neg": 0}
  345. for c in comments:
  346. cls_counts[classify_comment(c.get("text", ""))] += 1
  347. comment_total = len(comments)
  348. rows = [("正面认同", cls_counts["pos"], "var(--green)"),
  349. ("中性补充", cls_counts["neu"], "var(--yellow)"),
  350. ("争议", cls_counts["neg"], "var(--red)")]
  351. acc = 0
  352. for i, (label, v, color) in enumerate(rows):
  353. pct = round(v / comment_total * 100) if i < 2 else max(0, 100 - acc)
  354. acc += pct
  355. comment_stats.append({"label": label, "pct": pct, "color": color})
  356. top_comments = [{"nickname": esc(c.get("nickname", "匿名")),
  357. "likes": c.get("likes", 0), "text": esc(c.get("text", "")),
  358. "insight": esc(comment_insight(c.get("text", "")))}
  359. for c in comments[:5]]
  360. comment_keywords = chi_words(" ".join(c.get("text", "") for c in comments), top=6)
  361. # --- 数据卡片 ---
  362. st = meta.get("stats") or {}
  363. stats_cards = [{"num": f"{duration_s:.0f}s", "label": "总时长"}]
  364. if st.get("digg"):
  365. stats_cards += [{"num": f"{st['digg']:,}", "label": "👍 点赞"},
  366. {"num": f"{st['comment']:,}", "label": "💬 评论"},
  367. {"num": f"{st['collect']:,}", "label": "🔖 收藏"},
  368. {"num": f"{st['share']:,}", "label": "↗ 分享"},
  369. {"num": f"{st['digg'] / max(st['comment'], 1):.0f}:1",
  370. "label": "赞评比"}]
  371. else:
  372. stats_cards += [{"num": len(comments), "label": "评论采集"},
  373. {"num": len(frames), "label": "抽帧"},
  374. {"num": n, "label": "分段"},
  375. {"num": word_count, "label": "总字数"},
  376. {"num": wpm, "label": "字/分"}]
  377. # --- 建议 ---
  378. suggestions = [{"title": esc(s.get("title", f"建议{ i + 1 }")),
  379. "body": esc(s.get("body", ""))}
  380. for i, s in enumerate(llm_out.get("suggestions") or [])]
  381. if not suggestions:
  382. hw = wf[0]["word"] if wf else "核心词"
  383. pos_pct = next((r["pct"] for r in (comment_stats or sentiment)
  384. if r["label"] in ("正面认同", "正面")), 0)
  385. suggestions = [
  386. {"title": "强化记忆锚点", "body": f"高频词「{hw}」贯穿全片, 封面/标题/评论区置顶都应重复它, 形成账号记忆点。"},
  387. {"title": "延续情绪线", "body": f"正面情绪约{pos_pct}%, 下一条保持同一情绪基调并升级一个具体场景细节。"},
  388. {"title": "补强行动指令", "body": "结尾闭环后加一句轻互动引导(如「你家乡呢?」), 把共鸣转化为评论量。"},
  389. ]
  390. # --- 汇总上下文(所有动态值已转义) ---
  391. author = meta.get("author") or "本地视频"
  392. title = args.title or f"拉片分析:{author}"
  393. today = datetime.date.today().strftime("%Y%m%d")
  394. aweme_id = str(meta.get("aweme_id") or "local")
  395. ctx = {
  396. "meta": {
  397. "title": esc(title),
  398. "subtitle": esc(f"{(meta.get('desc') or '')[:42]} · {duration_s:.0f}秒 · "
  399. f"{n}段 · 方法论对照拆解"),
  400. "author": esc(author), "author_signature": esc(meta.get("author_signature", "")),
  401. "tags": [esc(t) for t in (meta.get("tags") or [])],
  402. "publish_time": esc(meta.get("create_time", "")),
  403. "aweme_id": esc(aweme_id), "platform": esc(meta.get("platform", "douyin")),
  404. "generated_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
  405. "llm_engine": esc(llm_engine),
  406. "report_id": esc(f"lapian-{meta.get('platform','douyin')}-{today}-{aweme_id[:10]}"),
  407. "word_count": word_count, "wpm": wpm,
  408. "sentence_count": len([s for s in re.split(r"[。!?]", fulltext) if s.strip()]),
  409. },
  410. "stats_cards": stats_cards,
  411. "segments": analysis_segs,
  412. "structure_rows": structure_rows,
  413. "summary_html": summary_html,
  414. "wordfreq": wf,
  415. "sentiment": sentiment,
  416. "comments": bool(comments),
  417. "comment_total": comment_total,
  418. "comment_stats": comment_stats,
  419. "top_comments": top_comments,
  420. "comment_keywords": comment_keywords,
  421. "suggestions": suggestions,
  422. }
  423. html = render_template(open(TEMPLATE, encoding="utf-8").read(), ctx)
  424. out_html = os.path.join(wd, "report.html")
  425. open(out_html, "w", encoding="utf-8").write(html)
  426. json.dump({"meta": {k: v for k, v in ctx["meta"].items()},
  427. "segments": [{k: (v if k != "text" else v) for k, v in sg.items()
  428. if k not in ("frame",)} for sg in analysis_segs],
  429. "suggestions": suggestions,
  430. "llm_engine": llm_engine},
  431. open(os.path.join(wd, "analysis.json"), "w", encoding="utf-8"),
  432. ensure_ascii=False, indent=2, default=str)
  433. print(f"[report] {out_html} ({os.path.getsize(out_html)/1024:.0f} KB)")
  434. if __name__ == "__main__":
  435. main()