|
| 1 | +""" |
| 2 | +viralens · build_report.py —— 把 --report 跑出来的分析数据汇总成**一个自包含的 HTML 报告**。 |
| 3 | +
|
| 4 | +读 data/ 里各步骤产物(cross_creator_form / creator_profile / signal_scan / all_videos) |
| 5 | ++ 把 reports/img/*.png 以 base64 内联进来 → 写出单个 reports/index.html。 |
| 6 | +这个文件不依赖本地服务器、不依赖网络:双击就能开,也能直接发给任何创作者看。 |
| 7 | +
|
| 8 | +被 viralens.py 的 --report 模式调用,也能单独跑: |
| 9 | + python build_report.py |
| 10 | +纯标准库(仅可选 import features 取人类可读的维度名)。 |
| 11 | +""" |
| 12 | +import base64 |
| 13 | +import html |
| 14 | +import json |
| 15 | +import sys |
| 16 | +import time |
| 17 | +from runtime import DATA, REPORTS, IMG |
| 18 | + |
| 19 | +if hasattr(sys.stdout, "reconfigure"): |
| 20 | + sys.stdout.reconfigure(encoding="utf-8") |
| 21 | + |
| 22 | +ACCENT = "#61C4E3" # 项目主色(与 charts.py 一致) |
| 23 | +HIT = "#E5484D" # 爆款红 |
| 24 | + |
| 25 | + |
| 26 | +# ————————————————————————— 小工具 ————————————————————————— |
| 27 | +def load(name): |
| 28 | + p = DATA / name |
| 29 | + try: |
| 30 | + return json.loads(p.read_text(encoding="utf-8")) |
| 31 | + except Exception: |
| 32 | + return None |
| 33 | + |
| 34 | + |
| 35 | +def esc(s): |
| 36 | + return html.escape(str(s if s is not None else "")) |
| 37 | + |
| 38 | + |
| 39 | +def fmt_play(n): |
| 40 | + """中文友好的播放量:1.2亿 / 814万 / 9532。""" |
| 41 | + try: |
| 42 | + n = float(n) |
| 43 | + except (TypeError, ValueError): |
| 44 | + return "-" |
| 45 | + if n >= 1e8: |
| 46 | + return f"{n/1e8:.2f}亿" |
| 47 | + if n >= 1e4: |
| 48 | + return f"{n/1e4:.1f}万" |
| 49 | + return f"{n:,.0f}" |
| 50 | + |
| 51 | + |
| 52 | +def fmt_dur(sec): |
| 53 | + try: |
| 54 | + sec = int(sec) |
| 55 | + except (TypeError, ValueError): |
| 56 | + return "-" |
| 57 | + m, s = divmod(sec, 60) |
| 58 | + return f"{m}:{s:02d}" |
| 59 | + |
| 60 | + |
| 61 | +def video_url(v): |
| 62 | + plat = (v.get("platform") or "bilibili").lower() |
| 63 | + vid = v.get("bvid") or v.get("vid") or "" |
| 64 | + if not vid: |
| 65 | + return "" |
| 66 | + if plat == "youtube": |
| 67 | + return f"https://www.youtube.com/watch?v={vid}" |
| 68 | + return f"https://www.bilibili.com/video/{vid}" |
| 69 | + |
| 70 | + |
| 71 | +def img_data_uri(path): |
| 72 | + try: |
| 73 | + b = path.read_bytes() |
| 74 | + except Exception: |
| 75 | + return None |
| 76 | + return "data:image/png;base64," + base64.b64encode(b).decode("ascii") |
| 77 | + |
| 78 | + |
| 79 | +# ————————————————————————— 各板块 ————————————————————————— |
| 80 | +def section_headline(form): |
| 81 | + """顶部三句话结论卡:形式决定天花板(头尾播放差)。""" |
| 82 | + if not form: |
| 83 | + return "" |
| 84 | + ratios = [r["ratio"] for r in form if isinstance(r.get("ratio"), (int, float))] |
| 85 | + if not ratios: |
| 86 | + return "" |
| 87 | + lo, hi = min(ratios), max(ratios) |
| 88 | + chips = "".join( |
| 89 | + f'<span class="chip"><b>{esc(r["creator"])}</b> {r["ratio"]:.0f}×</span>' |
| 90 | + for r in sorted(form, key=lambda x: -(x.get("ratio") or 0)) |
| 91 | + ) |
| 92 | + return f""" |
| 93 | +<section class="card hero"> |
| 94 | + <div class="kicker">站得住的结论</div> |
| 95 | + <h2>同一个创作者,头部 5 条 vs 尾部 5 条播放差 <span class="big">{lo:.0f}×–{hi:.0f}×</span></h2> |
| 96 | + <p>差距不来自选题或运气,而来自<b>视频是否在 TA 的招牌形式里</b>。偏离核心形式(商单 / vlog / 访谈 / 直播回放)就容易翻车。</p> |
| 97 | + <div class="chips">{chips}</div> |
| 98 | +</section>""" |
| 99 | + |
| 100 | + |
| 101 | +def section_charts(): |
| 102 | + """内联 reports/img 下生成的图表 PNG。""" |
| 103 | + candidates = [ |
| 104 | + ("form_spread.png", "形式决定天花板 —— 各创作者头尾播放差"), |
| 105 | + ("second_person_falsified.png", "被我们自己证伪的「通用杠杆」"), |
| 106 | + ("meme_falsified.png", "玩梗 / 社群参与度 跨创作者检验"), |
| 107 | + ] |
| 108 | + blocks = [] |
| 109 | + for fn, cap in candidates: |
| 110 | + uri = img_data_uri(IMG / fn) |
| 111 | + if uri: |
| 112 | + blocks.append( |
| 113 | + f'<figure><img alt="{esc(cap)}" src="{uri}"><figcaption>{esc(cap)}</figcaption></figure>' |
| 114 | + ) |
| 115 | + if not blocks: |
| 116 | + return "" |
| 117 | + return f'<section class="card"><h3>图表</h3><div class="charts">{"".join(blocks)}</div></section>' |
| 118 | + |
| 119 | + |
| 120 | +def _pct(x): |
| 121 | + try: |
| 122 | + return f"{float(x)*100:.0f}%" |
| 123 | + except (TypeError, ValueError): |
| 124 | + return "-" |
| 125 | + |
| 126 | + |
| 127 | +def section_creators(profile, form): |
| 128 | + """每位创作者一张卡:同区基准对位 + 趋势 + 头/尾代表作。""" |
| 129 | + form_by = {r["creator"]: r for r in (form or [])} |
| 130 | + cards = [] |
| 131 | + zones = (profile or {}).get("zones", {}) if isinstance(profile, dict) else {} |
| 132 | + seen = set() |
| 133 | + for zone, zd in zones.items(): |
| 134 | + bench = zd.get("benchmark", {}) or {} |
| 135 | + for pr in zd.get("creators", []): |
| 136 | + name = pr.get("name", "?") |
| 137 | + seen.add(name) |
| 138 | + s = pr.get("stat", {}) or {} |
| 139 | + t = pr.get("trend", {}) or {} |
| 140 | + fr = form_by.get(name) |
| 141 | + bench_line = ( |
| 142 | + f'<div class="kv"><span>体量(播放中位)</span><b>{fmt_play(s.get("play_med"))}</b>' |
| 143 | + f'<span class="ref">区典型 {fmt_play(bench.get("play_med"))}</span></div>' |
| 144 | + f'<div class="kv"><span>招牌形式命中率</span><b>{_pct(s.get("in_format_rate"))}</b>' |
| 145 | + f'<span class="ref">区典型 {_pct(bench.get("in_format_rate"))}</span></div>' |
| 146 | + f'<div class="kv"><span>典型时长</span><b>{fmt_dur(s.get("dur_med"))}</b>' |
| 147 | + f'<span class="ref">区典型 {fmt_dur(bench.get("dur_med"))}</span></div>' |
| 148 | + f'<div class="kv"><span>每万播放评论</span><b>{esc(s.get("comment_per_10k"))}</b>' |
| 149 | + f'<span class="ref">区典型 {esc(bench.get("comment_per_10k"))}</span></div>' |
| 150 | + ) |
| 151 | + if t.get("verdict") and t.get("verdict") != "样本不足": |
| 152 | + trend_line = (f'<div class="trend">趋势:<b>{esc(t.get("verdict"))}</b> · ' |
| 153 | + f'近/早 {esc(t.get("ratio"))}×({fmt_play(t.get("early_med"))} → {fmt_play(t.get("recent_med"))},' |
| 154 | + f' {esc(t.get("n_mature"))} 条成熟视频)</div>') |
| 155 | + else: |
| 156 | + trend_line = f'<div class="trend dim">趋势:{esc(t.get("note") or "样本不足")}</div>' |
| 157 | + reps = "" |
| 158 | + if fr: |
| 159 | + def li(v, kind): |
| 160 | + tag = f'<span class="off">[{esc(v["off"])}]</span> ' if v.get("off") else "" |
| 161 | + return f'<li><span class="p {kind}">{fmt_play(v.get("play"))}</span> {tag}{esc(v.get("title"))[:40]}</li>' |
| 162 | + tops = "".join(li(v, "hit") for v in (fr.get("top5") or [])[:3]) |
| 163 | + bots = "".join(li(v, "flop") for v in (fr.get("bot5") or [])[:3]) |
| 164 | + reps = (f'<div class="reps"><div><div class="rep-h">🔴 头部代表作</div><ul>{tops}</ul></div>' |
| 165 | + f'<div><div class="rep-h">🔵 尾部代表作</div><ul>{bots}</ul></div></div>') |
| 166 | + ratio_badge = f'<span class="ratio">头尾 {fr["ratio"]:.0f}×</span>' if fr and fr.get("ratio") else "" |
| 167 | + cards.append(f""" |
| 168 | + <div class="creator"> |
| 169 | + <div class="ch"><h4>{esc(name)}</h4><span class="zone">{esc(zone)}</span>{ratio_badge}</div> |
| 170 | + <div class="kvs">{bench_line}</div> |
| 171 | + {trend_line} |
| 172 | + {reps} |
| 173 | + </div>""") |
| 174 | + # creator_profile 没覆盖、但 form 里有的,也补一张精简卡 |
| 175 | + for name, fr in form_by.items(): |
| 176 | + if name in seen: |
| 177 | + continue |
| 178 | + ratio_badge = f'<span class="ratio">头尾 {fr["ratio"]:.0f}×</span>' if fr.get("ratio") else "" |
| 179 | + cards.append(f'<div class="creator"><div class="ch"><h4>{esc(name)}</h4>{ratio_badge}</div></div>') |
| 180 | + if not cards: |
| 181 | + return "" |
| 182 | + return f'<section class="card"><h3>各创作者画像</h3><div class="creators">{"".join(cards)}</div></section>' |
| 183 | + |
| 184 | + |
| 185 | +def section_signals(scan, binlab, numlab): |
| 186 | + if not isinstance(scan, dict): |
| 187 | + return "" |
| 188 | + metric = scan.get("metric", "") |
| 189 | + rows = [] |
| 190 | + for g in scan.get("generalization", []): |
| 191 | + label = binlab.get(g["key"], g["key"]) |
| 192 | + same = f'{g.get("pos",0)}↑ / {g.get("neg",0)}↓ / 共{g.get("n",0)}' |
| 193 | + rows.append(f'<tr><td>{esc(label)}</td><td>{esc(same)}</td>' |
| 194 | + f'<td>{esc(g.get("geo_ratio"))}×</td><td>{esc(g.get("verdict"))}</td></tr>') |
| 195 | + for g in scan.get("generalization_numeric", []): |
| 196 | + label = numlab.get(g["key"], g["key"]) |
| 197 | + same = f'{g.get("pos",0)}+ / {g.get("neg",0)}- / 共{g.get("n",0)}' |
| 198 | + rows.append(f'<tr><td>{esc(label)}</td><td>{esc(same)}</td>' |
| 199 | + f'<td>ρ {g.get("mean_rho"):+.2f}</td><td>{esc(g.get("verdict"))}</td></tr>') |
| 200 | + if not rows: |
| 201 | + return "" |
| 202 | + return f""" |
| 203 | +<section class="card"> |
| 204 | + <h3>哪些杠杆通用,哪些因人而异 <span class="dim">(指标:{esc(metric)})</span></h3> |
| 205 | + <table class="grid"><thead><tr><th>维度</th><th>同向</th><th>平均倍数 / ρ</th><th>判决</th></tr></thead> |
| 206 | + <tbody>{"".join(rows)}</tbody></table> |
| 207 | +</section>""" |
| 208 | + |
| 209 | + |
| 210 | +def section_table(videos): |
| 211 | + if not videos: |
| 212 | + return "" |
| 213 | + rows = [] |
| 214 | + shown = videos[:500] |
| 215 | + for v in shown: |
| 216 | + u = video_url(v) |
| 217 | + title = esc(v.get("title"))[:60] |
| 218 | + title_html = f'<a href="{u}" target="_blank" rel="noopener">{title}</a>' if u else title |
| 219 | + rows.append( |
| 220 | + f'<tr>' |
| 221 | + f'<td>{esc(v.get("creator"))}</td>' |
| 222 | + f'<td>{esc(v.get("platform"))}</td>' |
| 223 | + f'<td>{esc(v.get("zone"))}</td>' |
| 224 | + f'<td class="t">{title_html}</td>' |
| 225 | + f'<td data-v="{v.get("play") or 0}">{fmt_play(v.get("play"))}</td>' |
| 226 | + f'<td data-v="{v.get("comment") or 0}">{esc(v.get("comment"))}</td>' |
| 227 | + f'<td data-v="{v.get("danmaku") or v.get("like") or 0}">{esc(v.get("danmaku") if v.get("danmaku") is not None else v.get("like"))}</td>' |
| 228 | + f'<td data-v="{v.get("duration_sec") or 0}">{fmt_dur(v.get("duration_sec"))}</td>' |
| 229 | + f'<td>{esc((v.get("created_iso") or "")[:10])}</td>' |
| 230 | + f'</tr>' |
| 231 | + ) |
| 232 | + note = f'<p class="dim">共 {len(videos)} 条,显示前 {len(shown)} 条。点表头排序。</p>' if len(videos) > len(shown) else '<p class="dim">点表头排序。</p>' |
| 233 | + return f""" |
| 234 | +<section class="card"> |
| 235 | + <h3>视频明细</h3> |
| 236 | + {note} |
| 237 | + <table class="grid sortable" id="vtab"><thead><tr> |
| 238 | + <th>创作者</th><th>平台</th><th>赛道</th><th>标题</th> |
| 239 | + <th data-num>播放</th><th data-num>评论</th><th data-num>弹幕/赞</th><th data-num>时长</th><th>发布</th> |
| 240 | + </tr></thead><tbody>{"".join(rows)}</tbody></table> |
| 241 | +</section>""" |
| 242 | + |
| 243 | + |
| 244 | +HEAD = """<!doctype html> |
| 245 | +<html lang="zh-CN"><head><meta charset="utf-8"> |
| 246 | +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| 247 | +<title>viralens 报告</title> |
| 248 | +<style> |
| 249 | + :root{ --accent:#61C4E3; --hit:#E5484D; --ink:#1d1d1f; --sub:#6e6e73; --line:#e6e6e9; --bg:#f5f5f7; --card:#fff; } |
| 250 | + *{box-sizing:border-box} |
| 251 | + body{margin:0;background:var(--bg);color:var(--ink); |
| 252 | + font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif} |
| 253 | + .wrap{max-width:980px;margin:0 auto;padding:32px 20px 80px} |
| 254 | + header.top{text-align:center;margin:18px 0 26px} |
| 255 | + header.top h1{font-size:34px;margin:0;letter-spacing:-.5px} |
| 256 | + header.top .meta{color:var(--sub);margin-top:6px;font-size:13px} |
| 257 | + .card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:22px 24px;margin:16px 0; |
| 258 | + box-shadow:0 1px 2px rgba(0,0,0,.03)} |
| 259 | + .card h2{font-size:22px;margin:.2em 0 .4em;letter-spacing:-.3px} |
| 260 | + .card h3{font-size:18px;margin:0 0 14px} |
| 261 | + .hero{background:linear-gradient(180deg,#fff, #fbfdff);border-color:#dCEFF6} |
| 262 | + .kicker{color:var(--accent);font-weight:700;font-size:13px;letter-spacing:.04em;text-transform:uppercase} |
| 263 | + .big{color:var(--hit);white-space:nowrap} |
| 264 | + .chips{margin-top:14px;display:flex;flex-wrap:wrap;gap:8px} |
| 265 | + .chip{background:#eef7fb;border:1px solid #d6ecf4;border-radius:999px;padding:4px 11px;font-size:13px} |
| 266 | + .chip b{font-weight:600} |
| 267 | + .charts{display:grid;grid-template-columns:1fr;gap:18px} |
| 268 | + figure{margin:0} |
| 269 | + figure img{width:100%;border:1px solid var(--line);border-radius:12px;display:block} |
| 270 | + figcaption{color:var(--sub);font-size:13px;margin-top:6px;text-align:center} |
| 271 | + .creators{display:grid;grid-template-columns:1fr 1fr;gap:16px} |
| 272 | + @media(max-width:720px){.creators{grid-template-columns:1fr}} |
| 273 | + .creator{border:1px solid var(--line);border-radius:12px;padding:14px 16px} |
| 274 | + .ch{display:flex;align-items:center;gap:8px;margin-bottom:8px} |
| 275 | + .ch h4{margin:0;font-size:16px} |
| 276 | + .zone{font-size:12px;color:var(--sub);background:#f0f0f3;border-radius:6px;padding:1px 7px} |
| 277 | + .ratio{margin-left:auto;font-size:12px;color:#fff;background:var(--hit);border-radius:6px;padding:2px 8px} |
| 278 | + .kvs{display:flex;flex-direction:column;gap:3px;margin:8px 0} |
| 279 | + .kv{display:flex;align-items:baseline;gap:8px;font-size:13px} |
| 280 | + .kv span:first-child{color:var(--sub);min-width:108px} |
| 281 | + .kv b{font-weight:600} |
| 282 | + .kv .ref{color:#9a9aa0;font-size:12px;margin-left:auto} |
| 283 | + .trend{font-size:13px;margin-top:6px} |
| 284 | + .trend.dim,.dim{color:var(--sub)} |
| 285 | + .reps{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px} |
| 286 | + .rep-h{font-size:12px;color:var(--sub);margin-bottom:3px} |
| 287 | + .reps ul{margin:0;padding-left:2px;list-style:none;font-size:12.5px} |
| 288 | + .reps li{margin:2px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} |
| 289 | + .p{font-variant-numeric:tabular-nums;font-weight:600;margin-right:5px} |
| 290 | + .p.hit{color:var(--hit)} .p.flop{color:#8a8a8f} |
| 291 | + .off{color:var(--accent);font-size:11px} |
| 292 | + table.grid{width:100%;border-collapse:collapse;font-size:13px} |
| 293 | + table.grid th,table.grid td{padding:7px 9px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top} |
| 294 | + table.grid th{color:var(--sub);font-weight:600;position:sticky;top:0;background:var(--card)} |
| 295 | + table.sortable th{cursor:pointer;user-select:none} |
| 296 | + table.sortable th[data-num]{text-align:right} |
| 297 | + table.grid td[data-v]{text-align:right;font-variant-numeric:tabular-nums} |
| 298 | + td.t{max-width:360px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} |
| 299 | + td.t a{color:inherit;text-decoration:none;border-bottom:1px solid var(--line)} |
| 300 | + footer{text-align:center;color:var(--sub);font-size:12px;margin-top:30px} |
| 301 | + a.gh{color:var(--accent);text-decoration:none} |
| 302 | +</style></head><body><div class="wrap">""" |
| 303 | + |
| 304 | +FOOT_TMPL = """<footer>由 <a class="gh" href="https://github.com/HarryXin0919/viralens">viralens</a> 生成 · 数据与分析全部在本机完成,未上传 · __TS__</footer> |
| 305 | +<script> |
| 306 | +document.querySelectorAll('table.sortable').forEach(function(tb){ |
| 307 | + tb.querySelectorAll('th').forEach(function(th,ci){ |
| 308 | + th.addEventListener('click',function(){ |
| 309 | + var body=tb.tBodies[0], rows=[].slice.call(body.rows); |
| 310 | + var num=th.hasAttribute('data-num'); |
| 311 | + var dir=th.dataset.dir==='asc'?-1:1; th.dataset.dir=dir===1?'asc':'desc'; |
| 312 | + rows.sort(function(a,b){ |
| 313 | + var x=a.cells[ci], y=b.cells[ci]; |
| 314 | + if(num){return (parseFloat(x.dataset.v||x.textContent)-parseFloat(y.dataset.v||y.textContent))*dir;} |
| 315 | + return x.textContent.localeCompare(y.textContent,'zh')*dir; |
| 316 | + }); |
| 317 | + rows.forEach(function(r){body.appendChild(r);}); |
| 318 | + }); |
| 319 | + }); |
| 320 | +}); |
| 321 | +</script></div></body></html>""" |
| 322 | + |
| 323 | + |
| 324 | +def main(): |
| 325 | + form = load("cross_creator_form.json") |
| 326 | + profile = load("creator_profile.json") |
| 327 | + scan = load("signal_scan.json") |
| 328 | + videos = load("all_videos.json") or [] |
| 329 | + try: |
| 330 | + from features import BINARY_LABELS, NUMERIC_LABELS |
| 331 | + except Exception: |
| 332 | + BINARY_LABELS, NUMERIC_LABELS = {}, {} |
| 333 | + |
| 334 | + n_creators = len({v.get("creator") for v in videos}) if videos else len(form or []) |
| 335 | + parts = [HEAD] |
| 336 | + parts.append( |
| 337 | + f'<header class="top"><h1>viralens 报告</h1>' |
| 338 | + f'<div class="meta">{n_creators} 位创作者 · {len(videos)} 条视频 · ' |
| 339 | + f'{time.strftime("%Y-%m-%d %H:%M")}</div></header>' |
| 340 | + ) |
| 341 | + body = [section_headline(form), section_charts(), |
| 342 | + section_creators(profile, form), section_signals(scan, BINARY_LABELS, NUMERIC_LABELS), |
| 343 | + section_table(videos)] |
| 344 | + body = [b for b in body if b] |
| 345 | + if not body: |
| 346 | + body = ['<section class="card"><h3>还没有数据</h3>' |
| 347 | + '<p class="dim">先在界面里「抓取并分析」,或命令行跑 <code>python viralens.py --report</code>,再生成报告。</p></section>'] |
| 348 | + parts.extend(body) |
| 349 | + parts.append(FOOT_TMPL.replace("__TS__", time.strftime("%Y-%m-%d %H:%M"))) |
| 350 | + |
| 351 | + REPORTS.mkdir(parents=True, exist_ok=True) |
| 352 | + out = REPORTS / "index.html" |
| 353 | + out.write_text("\n".join(parts), encoding="utf-8") |
| 354 | + print(f"✅ 已写交互报告 → {out}") |
| 355 | + |
| 356 | + |
| 357 | +if __name__ == "__main__": |
| 358 | + main() |
0 commit comments