Skip to content

Commit 9063387

Browse files
HarryXin0919claude
andcommitted
Add self-contained HTML report generator (build_report.py)
--report now produces reports/index.html: a single self-contained file (charts inlined as base64; headline conclusion, per-creator profiles, signal-lever table, and a sortable video table) that opens offline and can be handed to any creator -- delivering the interactive report the project advertised but never generated (the /report 404 gap). Wired into the viralens.py --report pipeline after export_data, and added to the PyInstaller spec hiddenimports. Verified rendering in both source and frozen (packaged app) modes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2fce5e8 commit 9063387

3 files changed

Lines changed: 360 additions & 1 deletion

File tree

packaging/viralens.spec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ hiddenimports += ["matplotlib.backends.backend_agg", "numpy", "PIL"]
3737
hiddenimports += [
3838
"runtime", "app", "viralens",
3939
"fetch_multi", "fetch_bilibili", "fetch_youtube",
40-
"compare_form", "creator_profile", "scan_signals", "charts", "export_data",
40+
"compare_form", "creator_profile", "scan_signals", "charts", "export_data", "build_report",
4141
"diagnose", "analyze_video", "import_private",
4242
"creators", "features", "benchmarks",
4343
"classify_and_stats", "comments", "compare_meme", "fetch_covers",

scripts/build_report.py

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
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()

scripts/viralens.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ def main():
129129
run("scan_signals.py") # 多维信号扫描(哪些杠杆通用、哪些因人而异)
130130
run("charts.py", optional=True) # README 配图(要 matplotlib;缺了不致命)
131131
run("export_data.py") # 顺手也整理一份干净数据出来
132+
run("build_report.py") # 汇总成单个自包含 reports/index.html(可离线打开/转发)
132133
print("\n" + "=" * 60)
133134
print("✅ 全跑完了。")
134135
print(f" 交互报告 → {REPORT}")

0 commit comments

Comments
 (0)