Skip to content

Commit e7e14df

Browse files
HarryXin0919claude
andcommitted
Fix Bilibili pagination window drift, isolate chart failures, consolidate shared helpers
Bugs: - fetch_bilibili.py: keep ps constant across pages. Shrinking ps on the last page shifts Bilibili's [(pn-1)*ps, pn*ps) window: num=120 re-fetched items 41-60 and never fetched 101-120. Overshoot is trimmed by the existing [:num] slice. The pagination test's mock computed offsets from constant PAGE_MAX, which masked exactly this -- it now mirrors the real window semantics and asserts all 120 vids are distinct (old code fails this test). - charts.py: run the three charts independently. cross_creator_meme.json is produced only by the opt-in compare_meme.py, so the standard --report run always died on chart 3 with FileNotFoundError and the whole charts step reported as failed. Missing input now skips just that chart with a note; empty cross_creator_form.json no longer crashes chart 1. Hardening: - compare_form.py: imported data with missing/null play or title no longer raises (KeyError/TypeError); empty creator files are skipped; bot5_med == 0 no longer writes JSON-invalid Infinity into cross_creator_form.json. - creator_profile.py: fmt_play crashed on play=None (n >= 1e8 TypeError); now uses the shared formatter with "-" fallback. - diagnose.py: private-dims failures were swallowed by a blanket except-pass after the user uploaded their CSV; now logged to stderr. - fetch_multi.py: the printed time span was taken from the play-sorted list (date of lowest-play video ~ date of highest-play video); now sorted by date. Consolidation (anti-drift, same outputs -- formatter outputs are test-pinned): - video_url was copy-pasted in app/build_report/export_data, fmt_play had four drifting variants (one with the None crash above); both now live in schema.py next to the record contract, charts keeps its short axis format via fmt_play(n, yi=1, wan=0). - off_tag matching loop unified in features.py (markers parameter); compare_form keeps only its YouTube-title-only policy wrapper. - Dead code: analyze_video._url (unused 4th URL builder), unused pathlib imports in 10 files, a branch in dim_title whose if/elif arms were identical, hand-rolled median replaced with statistics.median. Left alone deliberately: diagnose zh_num/en_num (bilingual pair, local by design), the two _worst() variants (different "na" semantics), and the mixed naive/UTC created_iso between the two fetchers (changing it would shift dates on already-fetched data; needs its own change). Verified: mypy clean (25 files), 23/23 tests pass, compileall clean, all touched modules import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent aaeca13 commit e7e14df

17 files changed

Lines changed: 105 additions & 128 deletions

scripts/analyze_video.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,6 @@ def _safe(s):
6666
return re.sub(r"[^0-9A-Za-z_.-]", "_", str(s))[:80]
6767

6868

69-
def _url(v):
70-
plat = (v.get("platform") or "").lower()
71-
if plat == "youtube":
72-
return f"https://www.youtube.com/watch?v={v.get('vid')}"
73-
bv = v.get("bvid") or v.get("vid")
74-
return f"https://www.bilibili.com/video/{bv}"
75-
76-
7769
# ————————————————————————— 下载(yt-dlp,只下开头) —————————————————————————
7870
def _sessdata():
7971
"""B 站 cookie。只在内存里用,绝不打印/落盘到 git。"""

scripts/app.py

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from urllib.parse import urlparse, parse_qs
2626

2727
import runtime # 收口「源码跑 vs 打包成 app 跑」的路径/子进程差异
28+
from schema import video_url # 单一数据源:链接拼法只在 schema.py 维护
2829

2930
if hasattr(sys.stdout, "reconfigure"):
3031
sys.stdout.reconfigure(encoding="utf-8")
@@ -107,17 +108,6 @@ def data_stats():
107108
return len(files), count
108109

109110

110-
def video_url(v):
111-
"""按平台拼出可点开的视频链接(给结果卡片用)。"""
112-
p = (v.get("platform") or "bilibili").lower()
113-
vid = v.get("bvid") or v.get("vid") or ""
114-
if not vid:
115-
return ""
116-
if p == "youtube":
117-
return f"https://www.youtube.com/watch?v={vid}"
118-
return f"https://www.bilibili.com/video/{vid}"
119-
120-
121111
# ——————————————————————— 诊断页:选择器数据 + 单视频诊断 ———————————————————————
122112
def diag_list():
123113
"""给诊断页的选择器:每个创作者 + 其视频(按播放降序)。"""

scripts/build_report.py

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import sys
1616
import time
1717
from runtime import DATA, REPORTS, IMG
18+
from schema import fmt_play, video_url # 单一数据源:链接拼法/播放量格式只在 schema.py 维护
1819

1920
if hasattr(sys.stdout, "reconfigure"):
2021
sys.stdout.reconfigure(encoding="utf-8")
@@ -36,19 +37,6 @@ def esc(s):
3637
return html.escape(str(s if s is not None else ""))
3738

3839

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-
5240
def fmt_dur(sec):
5341
try:
5442
sec = int(sec)
@@ -58,16 +46,6 @@ def fmt_dur(sec):
5846
return f"{m}:{s:02d}"
5947

6048

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-
7149
def img_data_uri(path):
7250
try:
7351
b = path.read_bytes()

scripts/charts.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
"""
99
import json
1010
import sys
11-
from pathlib import Path
1211

1312
import matplotlib
1413
matplotlib.use("Agg")
@@ -18,6 +17,7 @@
1817
sys.stdout.reconfigure(encoding="utf-8")
1918

2019
from creators import CREATORS
20+
from schema import fmt_play # 播放量格式化收口在 schema.py;轴标签传 yi=1, wan=0 用短格式
2121

2222
# 中文字体:按 Windows → macOS → Linux 顺序取第一个装了的,都没有才显示 □□□
2323
plt.rcParams["font.sans-serif"] = [
@@ -58,16 +58,11 @@ def anon(name):
5858
return ANON.get(name, "创作者")
5959

6060

61-
def fmt_play(n):
62-
if n >= 1e8:
63-
return f"{n/1e8:.1f}亿"
64-
if n >= 1e4:
65-
return f"{n/1e4:.0f}万"
66-
return f"{n:,.0f}"
67-
68-
6961
def chart_form_spread():
7062
rows = json.loads((DATA / "cross_creator_form.json").read_text(encoding="utf-8"))
63+
if not rows:
64+
print(" ⏭ cross_creator_form.json 是空的(还没有创作者数据)—— 跳过这张图")
65+
return
7166
rows.sort(key=lambda r: r["top5_med"]) # 从下往上递增,天花板最高的在顶部
7267
zone_of = {c["name"]: c.get("zone", "?") for c in CREATORS}
7368

@@ -79,7 +74,7 @@ def chart_form_spread():
7974
ax.scatter(hi, i, color=HIT, s=110, zorder=3, edgecolors="white", linewidths=1.2)
8075
ax.text(hi * 1.3, i, f"{r['ratio']:.0f}×" if r["ratio"] >= 10 else f"{r['ratio']:.1f}×",
8176
va="center", ha="left", fontsize=11, color="#333", fontweight="bold")
82-
ax.text(lo * 0.72, i, fmt_play(lo), va="center", ha="right", fontsize=8.5, color="#888")
77+
ax.text(lo * 0.72, i, fmt_play(lo, yi=1, wan=0), va="center", ha="right", fontsize=8.5, color="#888")
8378

8479
ax.set_xscale("log")
8580
ax.set_yticks(range(len(rows)))
@@ -225,10 +220,22 @@ def chart_2nd_person_falsified():
225220

226221

227222
def main():
228-
chart_form_spread()
229-
chart_2nd_person_falsified()
230-
chart_meme_falsified()
231-
print("\n✅ 图已生成到 reports/img/")
223+
# 三张图各自独立:缺哪个输入就跳过哪张,别让一张图(尤其是依赖可选步骤的)拖垮整个 charts 步骤
224+
jobs = [
225+
(chart_form_spread, "cross_creator_form.json(compare_form.py 生成)"),
226+
(chart_2nd_person_falsified, "signal_scan.json(scan_signals.py 生成)"),
227+
(chart_meme_falsified, "cross_creator_meme.json(可选步骤 compare_meme.py 才会生成)"),
228+
]
229+
n_ok = 0
230+
for fn, src in jobs:
231+
try:
232+
fn()
233+
n_ok += 1
234+
except FileNotFoundError:
235+
print(f" ⏭ 缺 {src} —— 跳过这张图")
236+
except Exception as e:
237+
print(f" ⚠ 这张图没画成({type(e).__name__}: {e})—— 跳过")
238+
print(f"\n{n_ok} 张图已生成到 reports/img/")
232239

233240

234241
if __name__ == "__main__":

scripts/compare_form.py

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@
88
"""
99
import json
1010
import sys
11-
from pathlib import Path
1211
from statistics import median
1312

1413
if hasattr(sys.stdout, "reconfigure"):
1514
sys.stdout.reconfigure(encoding="utf-8")
1615

1716
from runtime import DATA # 源码=仓库/data,打包成 app 时=用户数据目录
1817
from creators import CREATORS
18+
import features
1919

2020
# 偏离核心形式的关键词 —— 单一数据源在 shared_markers.py(对比报告用保守口径版)。
2121
from shared_markers import OFF_MARKERS_COMPARE as OFF_MARKERS
@@ -24,14 +24,9 @@
2424
def off_tag(v):
2525
# YouTube description 几乎必含 brand list / hashtag(podcast/interview 等),全军 false positive;
2626
# 只扫 title。B 站 description 短而精,继续扫 title + description。
27-
if (v.get("platform") or "bilibili") == "youtube":
28-
s = (v.get("title", "") or "").lower()
29-
else:
30-
s = (v.get("title", "") + " " + v.get("description", "")).lower()
31-
for label, ms in OFF_MARKERS.items():
32-
if any(m.lower() in s for m in ms):
33-
return label
34-
return ""
27+
# 匹配循环本身复用 features.off_tag,只是换成保守口径的标记表。
28+
desc = "" if (v.get("platform") or "bilibili") == "youtube" else (v.get("description") or "")
29+
return features.off_tag(v.get("title") or "", desc, OFF_MARKERS)
3530

3631

3732
def fmt(n):
@@ -46,32 +41,40 @@ def main():
4641
print(f" ✗ 缺 {p.name}")
4742
continue
4843
vids = json.loads(p.read_text(encoding="utf-8"))
44+
if not vids:
45+
print(f" ✗ {p.name} 是空的,跳过")
46+
continue
4947
for v in vids:
5048
v["off"] = off_tag(v)
51-
vids.sort(key=lambda x: -(x["play"] or 0))
49+
# 导入的数据可能缺 play/title 字段或为 null —— 全部走 .get + 兜底,别在分析里崩
50+
vids.sort(key=lambda x: -(x.get("play") or 0))
5251
top5, bot5 = vids[:5], vids[-5:]
53-
top5_med = median([v["play"] for v in top5])
54-
bot5_med = median([v["play"] for v in bot5])
55-
ratio = top5_med / bot5_med if bot5_med else float("inf")
52+
top5_med = median([v.get("play") or 0 for v in top5])
53+
bot5_med = median([v.get("play") or 0 for v in bot5])
54+
if not bot5_med:
55+
# 尾部播放全是 0/缺失:头尾倍数没意义,而且 Infinity 进不了 JSON
56+
print(f" ✗ {c['name']} 尾部播放全是 0/缺失,头尾对比无意义,跳过")
57+
continue
58+
ratio = top5_med / bot5_med
5659

5760
print("\n" + "=" * 74)
5861
print(f"■ {c['name']} top5中位 {fmt(top5_med)} / bot5中位 {fmt(bot5_med)} = {ratio:.0f}×")
5962
print(" 🔴 TOP5(爆款):")
6063
for v in top5:
6164
tag = f"[{v['off']}]" if v["off"] else ""
62-
print(f" {fmt(v['play']):>12} {tag}{v['title'][:34]}")
65+
print(f" {fmt(v.get('play')):>12} {tag}{(v.get('title') or '')[:34]}")
6366
print(" 🔵 BOTTOM5(翻车):")
6467
for v in bot5:
6568
tag = f"[{v['off']}]" if v["off"] else ""
66-
print(f" {fmt(v['play']):>12} {tag}{v['title'][:34]}")
69+
print(f" {fmt(v.get('play')):>12} {tag}{(v.get('title') or '')[:34]}")
6770

6871
rows.append({
6972
"creator": c["name"],
7073
"top5_med": top5_med, "bot5_med": bot5_med, "ratio": round(ratio, 1),
7174
"off_in_top5": sum(1 for v in top5 if v["off"]),
7275
"off_in_bot5": sum(1 for v in bot5 if v["off"]),
73-
"top5": [{"play": v["play"], "off": v["off"], "title": v["title"]} for v in top5],
74-
"bot5": [{"play": v["play"], "off": v["off"], "title": v["title"]} for v in bot5],
76+
"top5": [{"play": v.get("play"), "off": v["off"], "title": v.get("title") or ""} for v in top5],
77+
"bot5": [{"play": v.get("play"), "off": v["off"], "title": v.get("title") or ""} for v in bot5],
7578
})
7679

7780
(DATA / "cross_creator_form.json").write_text(

scripts/compare_meme.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
import sys
1818
import re
1919
import time
20-
from pathlib import Path
2120
from collections import Counter
2221

2322
from bilibili_api import comment, Credential

scripts/creator_profile.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
import json
2222
import sys
2323
import time
24-
from pathlib import Path
2524
from statistics import median
2625

2726
if hasattr(sys.stdout, "reconfigure"):
@@ -31,6 +30,7 @@
3130
from creators import CREATORS
3231
from features import extract
3332
from scan_signals import spearman # 复用秩相关,避免重复实现
33+
from schema import fmt_play # 播放量格式化收口在 schema.py(旧本地版遇 None 会崩)
3434

3535
NOW = time.time()
3636
MATURE_DAYS = 30 # 发布满 30 天才算"播放稳定",用于趋势
@@ -111,14 +111,6 @@ def trend(vids):
111111
"gap_early": gap_early, "gap_recent": gap_recent, "cadence": cad}
112112

113113

114-
def fmt_play(n):
115-
if n >= 1e8:
116-
return f"{n/1e8:.2f}亿"
117-
if n >= 1e4:
118-
return f"{n/1e4:.1f}万"
119-
return str(n)
120-
121-
122114
def fmt_dur(sec):
123115
return f"{sec//60}{sec%60:02d}秒"
124116

scripts/diagnose.py

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,16 @@
1818
import json
1919
import re
2020
import sys
21-
from pathlib import Path
21+
from statistics import median
22+
from typing import Any
2223

2324
if hasattr(sys.stdout, "reconfigure"):
2425
sys.stdout.reconfigure(encoding="utf-8")
2526

2627
from runtime import DATA # 源码=仓库/data,打包成 app 时=用户数据目录
2728

2829
# —— 懒加载 + 缓存:同一进程里只读一次盘 ——
29-
_CACHE = {}
30+
_CACHE: dict[str, Any] = {}
3031

3132

3233
def _load(name):
@@ -100,12 +101,9 @@ def _subtitle_of(vid):
100101

101102
# ————————————————————————— 小工具 —————————————————————————
102103
def _median(xs):
103-
xs = sorted(x for x in xs if x is not None)
104-
n = len(xs)
105-
if not n:
106-
return None
107-
m = n // 2
108-
return xs[m] if n % 2 else (xs[m - 1] + xs[m]) / 2
104+
"""statistics.median + 本项目约定:先滤掉 None,空列表返回 None 而不是抛异常。"""
105+
xs = [x for x in xs if x is not None]
106+
return median(xs) if xs else None
109107

110108

111109
def zh_num(n):
@@ -246,13 +244,8 @@ def dim_title(alias, v):
246244
hit_len = _median([len(h.get("title") or "") for h in vids[:n_hit]]) or tlen
247245

248246
metrics, levels = [], []
249-
# 长度:跟自己爆款比
250-
if hit_len and (0.7 * hit_len <= tlen <= 1.4 * hit_len):
251-
lvl = "good"
252-
elif hit_len and tlen > 1.4 * hit_len:
253-
lvl = "ok"
254-
else:
255-
lvl = "ok"
247+
# 长度:跟自己爆款比 —— 在爆款长度 0.7~1.4 倍区间内算 good,出区间只是 ok(不警告)
248+
lvl = "good" if hit_len and 0.7 * hit_len <= tlen <= 1.4 * hit_len else "ok"
256249
levels.append(lvl)
257250
metrics.append({"name": {"zh": "长度", "en": "Length"},
258251
"value": f"{tlen}", "ref": {"zh": f"你爆款约 {hit_len:.0f} 字", "en": f"your hits ~{hit_len:.0f} chars"},
@@ -418,14 +411,15 @@ def diagnose_video(alias, vid):
418411
dims.append(sub)
419412
dims = [d for d in dims if d]
420413

421-
# 私有后台数据(用户上传 CSV 后才有):完播率 / 点击率 —— 有就插进来一起算
414+
# 私有后台数据(用户上传 CSV 后才有):完播率 / 点击率 —— 有就插进来一起算。
415+
# 失败不挡公开维度,但要在控制台留痕:别让用户刚上传的 CSV 无声消失。
422416
try:
423417
import import_private
424418
priv = import_private.load_private(alias).get(v.get("vid") or v.get("bvid"))
425419
if priv:
426420
dims += import_private.private_dims(v, priv.get("metrics", {}))
427-
except Exception:
428-
pass
421+
except Exception as e:
422+
print(f" ⚠ 私有数据维度没加上(alias={alias}):{type(e).__name__}: {e}", file=sys.stderr)
429423

430424
real = [d for d in dims if d["level"] in ("good", "ok", "warn")]
431425
n_good = sum(1 for d in real if d["level"] == "good")

scripts/export_data.py

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,29 +13,18 @@
1313
import csv
1414
import json
1515
import sys
16-
from pathlib import Path
1716

1817
if hasattr(sys.stdout, "reconfigure"):
1918
sys.stdout.reconfigure(encoding="utf-8")
2019

2120
from runtime import DATA # 源码=仓库/data,打包成 app 时=用户数据目录
21+
from schema import video_url # 单一数据源:链接拼法只在 schema.py 维护
2222

2323
# CSV 里放哪些列、按什么顺序(挑人看得懂、Excel 排序有用的;长描述留在 JSON 里不塞 CSV)
2424
CSV_COLS = ["creator", "platform", "zone", "title", "play", "comment", "like",
2525
"danmaku", "duration_sec", "length", "published", "url", "cover_url"]
2626

2727

28-
def video_url(v):
29-
"""按平台拼出可点开的视频链接。"""
30-
p = (v.get("platform") or "bilibili").lower()
31-
vid = v.get("bvid") or v.get("vid") or ""
32-
if not vid:
33-
return ""
34-
if p == "youtube":
35-
return f"https://www.youtube.com/watch?v={vid}"
36-
return f"https://www.bilibili.com/video/{vid}"
37-
38-
3928
def hhmmss(sec):
4029
"""秒 → 人看的 时长(M:SS 或 H:MM:SS)。"""
4130
sec = int(sec or 0)

scripts/features.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@
2424
"测评", "评测", "实测", "上手", "教你"] # 教程/实用
2525

2626

27-
def off_tag(title, desc=""):
28-
"""命中任一偏离标记就返回标签,否则空串。宁紧勿松,靠人眼复核标题。"""
27+
def off_tag(title, desc="", markers=OFF_MARKERS):
28+
"""命中任一偏离标记就返回标签,否则空串。宁紧勿松,靠人眼复核标题。
29+
markers 可换口径(compare_form.py 传保守版 OFF_MARKERS_COMPARE,匹配循环只写这一处)。"""
2930
s = (title + " " + desc).lower()
30-
for label, ms in OFF_MARKERS.items():
31+
for label, ms in markers.items():
3132
if any(m.lower() in s for m in ms):
3233
return label
3334
return ""

0 commit comments

Comments
 (0)