Skip to content

Commit a1ecef0

Browse files
committed
Add Bilibili engagement enrichment + YouTube tags/category; concurrent fetch; GUI KPI band & per-creator data status
1 parent e7e14df commit a1ecef0

13 files changed

Lines changed: 368 additions & 56 deletions

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ python scripts/scan_signals.py # scan every dimension at once (try: scan_s
206206
python scripts/charts.py # draw the README charts
207207
python scripts/compare_meme.py # (opt-in, slow) cross-creator comment-engagement test
208208
python scripts/fetch_covers.py # (opt-in, slow) cover-image metrics
209+
python scripts/enrich_bilibili.py # (opt-in) Bilibili likes/coins/favorites → "triple rate" — the hardest engagement signal on the platform
209210
```
210211

211212
Don't know a Bilibili creator's UID? Run `python scripts/resolve_creators.py` — it searches by
@@ -272,6 +273,13 @@ rate-limit · small *n* is reported as *"weak signal,"* never dressed up as proo
272273
- [x] One-command front door — `python viralens.py` (just the data → CSV/JSON) · `--report` (data + full analysis + report)
273274
- [x] Self-contained interactive HTML report — `reports/index.html`
274275
- [x] Downloadable desktop app for Windows / macOS / Linux — no Python install needed ([releases](https://github.com/HarryXin0919/viralens/releases/latest))
276+
- [x] Concurrent fetching — YouTube channels in parallel while Bilibili paces itself politely (4× faster on mixed rosters)
277+
- [x] Bilibili "triple" enrichment — likes / coins / favorites → **triple-rate** dimension (opt-in `enrich_bilibili.py`); no other OSS tool computes this
278+
- [x] YouTube tags + category captured per video → tag-count dimension
279+
- [ ] Posting-time heatmap (weekday × hour vs plays) and golden-duration curve per zone
280+
- [ ] Incremental fetch — stop paging at the first already-seen video
281+
- [ ] Comment / danmaku layer — common vs signature vs divergent words across hit/flop groups
282+
- [ ] `pip install viralens` (PyPI) · demo GIF in README · hosted sample report
275283
- [ ] Per-creator (not keyword-based) signature-form definition
276284
- [ ] Opt-in LLM layer for qualitative "why this form works" summaries
277285

scripts/app.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import subprocess
2020
import sys
2121
import threading
22+
import time
2223
import webbrowser
2324
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
2425
from pathlib import Path
@@ -68,11 +69,12 @@ def write_keys(sessdata, youtube):
6869
cur_sd, cur_yt = _read_config()
6970
sd = sessdata.strip() or cur_sd
7071
yt = youtube.strip() or cur_yt
71-
proxy = "" # 保留用户可能手填的 PROXY(界面没这输入框,别冲掉)
72+
proxy = buvid3 = "" # 保留用户可能手填的 PROXY / BUVID3(界面没这输入框,别冲掉)
7273
try:
7374
import config_local as _c
7475
importlib.reload(_c)
7576
proxy = (getattr(_c, "PROXY", "") or "").strip()
77+
buvid3 = (getattr(_c, "BUVID3", "") or "").strip()
7678
except Exception:
7779
pass
7880
body = (
@@ -82,16 +84,31 @@ def write_keys(sessdata, youtube):
8284
)
8385
if proxy:
8486
body += f"PROXY = {json.dumps(proxy, ensure_ascii=False)}\n"
87+
if buvid3:
88+
body += f"BUVID3 = {json.dumps(buvid3, ensure_ascii=False)}\n"
8589
CONFIG.write_text(body, encoding="utf-8")
8690

8791

8892
# ————————————————————————————— 状态 —————————————————————————————
8993
def list_creators():
94+
"""清单 + 每人的抓取状态(几条数据、什么时候抓的),界面据此显示「未抓取」提示。"""
9095
try:
9196
import creators
9297
importlib.reload(creators)
93-
return [{"name": c["name"], "platform": c.get("platform", "bilibili"),
94-
"zone": c.get("zone", "")} for c in creators.CREATORS]
98+
out = []
99+
for c in creators.CREATORS:
100+
alias = c.get("alias") or ""
101+
count, updated = 0, ""
102+
p = DATA / f"{alias}_videos.json"
103+
if alias and p.exists():
104+
try:
105+
count = len(json.loads(p.read_text(encoding="utf-8")))
106+
updated = time.strftime("%m-%d", time.localtime(p.stat().st_mtime))
107+
except Exception:
108+
pass
109+
out.append({"name": c["name"], "platform": c.get("platform", "bilibili"),
110+
"zone": c.get("zone", ""), "count": count, "updated": updated})
111+
return out
95112
except Exception:
96113
return []
97114

@@ -282,7 +299,14 @@ def do_GET(self):
282299
"cover_url": v.get("cover_url") or "",
283300
"url": u,
284301
"published": (v.get("created_iso") or "")[:10]})
285-
return self._json(200, {"count": len(slim), "videos": slim[:300]})
302+
# KPI 摘要按全量算(卡片只回前 300 条,但统计不能只看样本)
303+
plays = sorted((v.get("play") or 0) for v in vids)
304+
n = len(plays)
305+
stats = {"videos": n,
306+
"creators": len({v.get("creator") for v in vids if v.get("creator")}),
307+
"play_med": (plays[n // 2] if n % 2 else (plays[n // 2 - 1] + plays[n // 2]) // 2) if n else 0,
308+
"play_max": plays[-1] if n else 0}
309+
return self._json(200, {"count": len(slim), "videos": slim[:300], "stats": stats})
286310
if path in ("/diagnose", "/diagnose.html"):
287311
dg = HERE / "diagnose.html"
288312
if not dg.exists():

scripts/config_local.example.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@
2424
SESSDATA = "" # Bilibili 登录 cookie(抓 B 站时用)
2525
YOUTUBE_API_KEY = "" # YouTube Data API v3 key(抓 YouTube 时用)
2626
PROXY = "" # 可选:下 YouTube 视频用的代理(国内填 http://127.0.0.1:10809)
27+
BUVID3 = "" # 可选:B 站 Cookie 里的 buvid3。一般不用填;抓取报 -352 时再补

scripts/enrich_bilibili.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""
2+
viralens · enrich_bilibili.py(可选步骤)—— B 站「三连」数据补全:赞 / 硬币 / 收藏 / 分享。
3+
4+
UP 主投稿列表接口(vlist)只给 播放/弹幕/评论;三连要逐条视频调 view 接口,
5+
N=40 条/人 × 节流 ≈ 半分钟/人,所以做成可选步骤,不进默认流水线:
6+
7+
python enrich_bilibili.py # 增量:只补还没有 coin 字段的视频
8+
python enrich_bilibili.py --force # 全量重取(刷新三连 + 播放/评论/弹幕)
9+
10+
为什么值得跑:投币/收藏是 B 站独有的「真金白银认可」。三连率 =(赞+币+藏)/万播放,
11+
比评论率更硬的内容质量信号 —— 商业数据平台的核心指标,开源工具里还没人算。
12+
跑完后无需其他操作:scan_signals.py / 报告会自动多出「三连率 / 投币率」维度
13+
(features.py 检测到 coin/favorite 字段就启用),export_data.py 也会把新字段带进 CSV。
14+
"""
15+
import asyncio
16+
import json
17+
import sys
18+
19+
if hasattr(sys.stdout, "reconfigure"):
20+
sys.stdout.reconfigure(encoding="utf-8")
21+
22+
from bilibili_api import video, Credential
23+
24+
from runtime import DATA
25+
from creators import CREATORS
26+
27+
try:
28+
from config_local import SESSDATA
29+
except ImportError:
30+
SESSDATA = ""
31+
try:
32+
from config_local import BUVID3
33+
except ImportError:
34+
BUVID3 = ""
35+
36+
PACE = 0.6 # 每条视频之间的节流(view 接口风控比 vlist 宽松,但仍要温和)
37+
38+
39+
def _cred():
40+
"""view 接口是公开的,不带登录态也能查;有 SESSDATA 更稳,有就带上。"""
41+
if SESSDATA and BUVID3:
42+
return Credential(sessdata=SESSDATA, buvid3=BUVID3)
43+
if SESSDATA:
44+
return Credential(sessdata=SESSDATA)
45+
return None
46+
47+
48+
async def enrich_one(v, cred, tries=3):
49+
"""给一条视频补 stat 字段。失败重试(退避),最终失败返回 False、不动原记录。"""
50+
bvid = v.get("bvid")
51+
for attempt in range(tries):
52+
try:
53+
info = await video.Video(bvid=bvid, credential=cred).get_info()
54+
st = info.get("stat") or {}
55+
v["like"] = st.get("like")
56+
v["coin"] = st.get("coin")
57+
v["favorite"] = st.get("favorite")
58+
v["share"] = st.get("share")
59+
# 顺手刷新基础数据(view 接口的数字比抓取时新;"--" 等占位符不覆盖)
60+
for src, dst in (("view", "play"), ("reply", "comment"), ("danmaku", "danmaku")):
61+
if isinstance(st.get(src), int):
62+
v[dst] = st[src]
63+
return True
64+
except Exception as e:
65+
if attempt < tries - 1:
66+
await asyncio.sleep(5 * (attempt + 1)) # 5s,10s 退避(风控/网络抖动)
67+
continue
68+
print(f" · 失败 {bvid}: {type(e).__name__}: {e}")
69+
return False
70+
return False
71+
72+
73+
async def main():
74+
force = "--force" in sys.argv
75+
cred = _cred()
76+
targets = [c for c in CREATORS if (c.get("platform") or "bilibili").lower() == "bilibili"]
77+
if not targets:
78+
print("creators.py 里没有 B 站创作者,无事可做")
79+
return
80+
81+
for c in targets:
82+
p = DATA / f"{c['alias']}_videos.json"
83+
if not p.exists():
84+
print(f" ✗ 缺 {p.name},先跑 fetch_multi.py")
85+
continue
86+
vids = json.loads(p.read_text(encoding="utf-8"))
87+
todo = [v for v in vids if v.get("bvid") and (force or v.get("coin") is None)]
88+
if not todo:
89+
print(f" ⏭ {c['name']:<16} 三连已齐,跳过(要刷新加 --force)")
90+
continue
91+
n_ok = 0
92+
for i, v in enumerate(todo):
93+
if i:
94+
await asyncio.sleep(PACE)
95+
n_ok += await enrich_one(v, cred)
96+
if n_ok and n_ok % 10 == 0: # 边取边存,中断不丢
97+
p.write_text(json.dumps(vids, ensure_ascii=False, indent=2), encoding="utf-8")
98+
p.write_text(json.dumps(vids, ensure_ascii=False, indent=2), encoding="utf-8")
99+
print(f" ✓ {c['name']:<16} 三连补全 {n_ok}/{len(todo)} 条")
100+
101+
print("\n✅ 完成。重跑 scan_signals.py / 报告,会自动出现「三连率 / 投币率」维度")
102+
103+
104+
if __name__ == "__main__":
105+
asyncio.run(main())

scripts/export_data.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@
2121
from schema import video_url # 单一数据源:链接拼法只在 schema.py 维护
2222

2323
# CSV 里放哪些列、按什么顺序(挑人看得懂、Excel 排序有用的;长描述留在 JSON 里不塞 CSV)
24+
# coin/favorite 只有跑过 enrich_bilibili.py 的 B 站数据才有,其余行留空
2425
CSV_COLS = ["creator", "platform", "zone", "title", "play", "comment", "like",
25-
"danmaku", "duration_sec", "length", "published", "url", "cover_url"]
26+
"coin", "favorite", "danmaku", "duration_sec", "length", "published", "url", "cover_url"]
2627

2728

2829
def hhmmss(sec):
@@ -71,6 +72,8 @@ def main():
7172
"play": v.get("play", "") if v.get("play") is not None else "",
7273
"comment": v.get("comment", "") if v.get("comment") is not None else "",
7374
"like": v.get("like", "") if v.get("like") is not None else "",
75+
"coin": v.get("coin", "") if v.get("coin") is not None else "",
76+
"favorite": v.get("favorite", "") if v.get("favorite") is not None else "",
7477
"danmaku": v.get("danmaku", "") if v.get("danmaku") is not None else "",
7578
"duration_sec": v.get("duration_sec", ""),
7679
"length": hhmmss(v.get("duration_sec")),

scripts/features.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def extract(v, now):
4343
dur = v.get("duration_sec") or 0
4444
comment = v.get("comment") or 0
4545
danmaku = v.get("danmaku") or 0
46+
like = v.get("like") or 0
4647
days = max((now - ts) / 86400, 1.0) if ts else 1.0
4748

4849
f = {
@@ -51,6 +52,8 @@ def extract(v, now):
5152
"play_per_day": play / days, # 控累积:每天涨多少播放
5253
"comment_per_10k": comment / (play / 1e4) if play else 0, # 互动率:每万播放多少评论
5354
"danmaku_per_min": danmaku / (dur / 60) if dur else 0, # 弹幕密度:每分钟多少弹幕
55+
"like_per_10k": like / (play / 1e4) if play else 0, # 点赞率(YT 原生;B站跑过 enrich 后也有)
56+
"danmaku_per_comment": danmaku / comment if comment else 0, # 陪伴型(弹幕多)vs 讨论型(评论多),B站特有指纹
5457
"days_since": days,
5558
# —— 数值型特征(算相关性) ——
5659
"duration_sec": dur,
@@ -81,6 +84,14 @@ def extract(v, now):
8184
else "午 12-18" if dt.hour < 18 else "晚 18-24")
8285
f["dur_bucket"] = ("短 <5min" if dur < 300 else "中 5-12min" if dur < 720 else "长 >12min")
8386

87+
# —— 平台增值字段(有才加,scan_signals 对缺字段的视频自动跳过) ——
88+
if v.get("tags") is not None: # YouTube:创作者自填的 SEO 标签
89+
f["n_tags"] = len(v.get("tags") or [])
90+
coin, fav = v.get("coin"), v.get("favorite")
91+
if coin is not None or fav is not None: # B站:跑过 enrich_bilibili.py 才有三连
92+
f["triple_per_10k"] = (like + (coin or 0) + (fav or 0)) / (play / 1e4) if play else 0
93+
f["coin_per_10k"] = (coin or 0) / (play / 1e4) if play else 0
94+
8495
# —— 封面图像特征(仅当 fetch_covers.py 算过、且已 merge 进 v 时才加) ——
8596
cov = v.get("cover") or {}
8697
if cov:
@@ -111,6 +122,10 @@ def extract(v, now):
111122
}
112123
CAT_LABELS = {"dur_bucket": "视频时长档", "daypart": "发布时段"}
113124
NUMERIC_LABELS = {"duration_sec": "时长(秒)", "title_len": "标题字数", "hour": "发布小时",
125+
"danmaku_per_comment": "弹幕/评论比(陪伴vs讨论)",
126+
"n_tags": "标签数(YouTube tags)",
127+
"triple_per_10k": "三连率(赞+币+藏/万播放)",
128+
"coin_per_10k": "投币率(币/万播放)",
114129
"cover_brightness": "封面亮度", "cover_saturation": "封面饱和度",
115130
"cover_contrast": "封面对比度", "cover_colorfulness": "封面色彩丰富度",
116131
"cover_edge": "封面繁简(边缘密度)", "cover_warm": "封面暖色占比"}
@@ -121,4 +136,5 @@ def extract(v, now):
121136
"play": "总播放",
122137
"comment_per_10k":"互动率(评论/万播放)",
123138
"danmaku_per_min":"弹幕密度(条/分钟)",
139+
"like_per_10k": "点赞率(赞/万播放)",
124140
}

scripts/fetch_bilibili.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,16 +68,17 @@ async def _get_page(u, ps, pn, name, tries):
6868
await asyncio.sleep(wait)
6969

7070

71-
async def fetch_creator(c, sessdata, num=40, tries=4) -> "list[VideoRecord]":
71+
async def fetch_creator(c, sessdata, num=40, tries=4, buvid3="") -> "list[VideoRecord]":
7272
"""标准适配器接口(异步):给一个 creators.py 条目 → 标准视频表。
73-
**分页**抓取直到取够 num 条或没有更多(B 站每页上限 50;旧版只取第一页,num>50 会漏数据)。"""
73+
**分页**抓取直到取够 num 条或没有更多(B 站每页上限 50;旧版只取第一页,num>50 会漏数据)。
74+
buvid3 可选:B 站 2025 年起部分接口要求该 Cookie,遇到 -352 时在 config_local.py 里补上。"""
7475
if not sessdata:
7576
raise BilibiliError("没读到 SESSDATA —— 在 config_local.py 里填上(浏览器 Cookie 里的 SESSDATA)")
7677
uid = c.get("uid")
7778
if not uid:
7879
raise BilibiliError(
7980
f"{c.get('name', '?')} 缺 uid —— 先跑 resolve_creators.py 查出 UP 主 ID 填进 creators.py")
80-
cred = Credential(sessdata=sessdata)
81+
cred = Credential(sessdata=sessdata, buvid3=buvid3) if buvid3 else Credential(sessdata=sessdata)
8182
u = user.User(uid=uid, credential=cred)
8283

8384
raw_videos: list[dict] = []
@@ -86,6 +87,8 @@ async def fetch_creator(c, sessdata, num=40, tries=4) -> "list[VideoRecord]":
8687
# (重复抓前面的 + 漏掉后面的)。宁可末页多拿几条,最后统一切片到 num。
8788
ps = min(PAGE_MAX, num)
8889
while len(raw_videos) < num:
90+
if pn > 1:
91+
await asyncio.sleep(0.8) # 成功路径也节流:把 412 防在前面,而不是事后退避救
8992
raw = await _get_page(u, ps, pn, c["name"], tries)
9093
page = (raw.get("list", {}) or {}).get("vlist", []) or []
9194
raw_videos.extend(page)

scripts/fetch_covers.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import io
2323
import time
2424
import urllib.request
25+
from concurrent.futures import ThreadPoolExecutor, as_completed
2526

2627
import numpy as np
2728
from PIL import Image
@@ -92,20 +93,30 @@ def main():
9293
cp = DATA / f"{c['alias']}_covers.json"
9394
cache = json.loads(cp.read_text(encoding="utf-8")) if cp.exists() else {}
9495

95-
n_new = n_fail = 0
96-
for v in vids:
97-
bvid, url = v.get("bvid"), v.get("cover_url")
98-
if not bvid or not url or bvid in cache:
99-
continue
96+
jobs = [(v.get("bvid"), v.get("cover_url")) for v in vids
97+
if v.get("bvid") and v.get("cover_url") and v.get("bvid") not in cache]
98+
99+
def work(bvid, url):
100100
try:
101-
cache[bvid] = metrics(fetch_img(url))
102-
n_new += 1
103-
time.sleep(0.15)
101+
return bvid, metrics(fetch_img(url)), None
104102
except Exception as e:
105-
n_fail += 1
106-
print(f" · 失败 {bvid}: {e}")
107-
if n_new and n_new % 10 == 0: # 边下边存,中断不丢
108-
cp.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8")
103+
return bvid, None, e
104+
105+
# 封面是公开 CDN(B站/YouTube 图床),6 路并发温和且无需登录态;指标计算在线程里顺便做了
106+
n_new = n_fail = 0
107+
if jobs:
108+
with ThreadPoolExecutor(max_workers=min(6, len(jobs))) as ex:
109+
futs = [ex.submit(work, bvid, url) for bvid, url in jobs]
110+
for fut in as_completed(futs):
111+
bvid, m, err = fut.result()
112+
if err is not None:
113+
n_fail += 1
114+
print(f" · 失败 {bvid}: {err}")
115+
continue
116+
cache[bvid] = m
117+
n_new += 1
118+
if n_new % 10 == 0: # 边下边存,中断不丢
119+
cp.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8")
109120
cp.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8")
110121
print(f" ✓ {c['name']}: 封面 {len(cache)}/{len(vids)} (新增 {n_new}, 失败 {n_fail})")
111122

0 commit comments

Comments
 (0)