43 KiB
咻咻满 Reaction 视频数据看板 —— 可行性分析与技术路线
状态:设计定稿;P0(数据底座与收藏夹同步)、P1(每日快照与粉丝追踪)、P2(只读本地看板)、P3(Telegram 与一键加入)已实施 最后更新:2026-10-31(第 8 轮:P2 只读看板与预览数据落地) B 站探测均为只读(GET),尚未调用
fav/resource/deal、fav/resource/sort
0. 结论摘要
判定:可行,工程难度中等偏低。 四个功能点都有稳定可用的官方 Web 接口,且匿名即可取数。
| 功能点 | 主力接口 | 实测 |
|---|---|---|
| 视频发现 | wbi/search/type |
✅ code=0,匿名可用,单关键词上限 800~1000 条 |
| 收藏夹同步(库的真相源) | fav/resource/ids + fav/resource/infos |
✅ infos 一次 400 条 / 0.2s,664 条全量 2 次请求 |
| 每日统计 | view?aid= |
✅ 7 项指标齐全,0.07~0.14s/条,无风控 |
| 一键加入 | fav/resource/deal + fav/resource/sort |
排序语义前作已验证;加入待授权实测 |
| 看板 | 本地 SQLite | ✅ 无外部依赖 |
本轮确认的设计前提(来自你的两轮答复):
- 搜索频率 1 次/小时,只取前 2 页,允许遗漏——搜索是"代替人工发现 + 即时提醒",人工加收藏兜底;
- 搜索结果的分类靠人工点击确认(通知即确认),不追求自动判定;
- 不建 UP 白名单(reaction UP 多为一次性投稿,收益低),改为可配置的 UP 黑名单(专发直播回放/切片的号直接排除,仅作用于发现,不影响库内已有视频);
- 收藏夹按年分夹,但新建夹由人工完成,系统只读配置;新视频优先入"同 UP 最早视频所在夹";
- 通知渠道先做 Telegram(支持按钮;开发阶段用 long polling,不需要公网 URL);
- 收藏夹是"新增视频来源"的兜底,日更对象是库内已入库视频,与年度夹解耦;
- 用户取消收藏的视频继续日更,但允许打标记"移出库"后停止发现与追踪;
- 新增追踪指标:咻咻满粉丝数(只画粉丝曲线,不叠加视频数据);
- 所有凭据(Telegram token/chat_id、收藏夹 id、cookie)统一放配置文件;
- 通知提供收藏 / 忽略 / 拉黑三个动作,拉黑只作用于"未来发现"。
三个仍需注意的硬约束:
fav/resource/infos只能替代 7 项里的 3 项(play/favorite/danmaku),reply字段恒为 0,且没有like/coin/share→ 日更仍必须逐条view;- 搜索
order=pubdate排序不稳定(page 20 会跳到 3 月),不能"翻到旧视频就停"; - 收藏夹约 1000 条/夹,2026 夹已 664 条,年末约 740 条 → 容量安全但需预警。
1. 已确认的需求口径
1.1 收录范围
- 只收「他人 reaction 咻咻满」:别的 UP 主观看/试听/点评咻咻满的直播、唱歌、充电内容。
- 不收:咻咻满本人投稿、纯直播歌切、粉丝切片(除非人工明确加入收藏夹)、搬运、纯翻唱。
- 判定责任在人:搜索只负责"可能相关"的候选,用户点确认才入库。
1.2 "充电"关键词的真实含义
- 不是"充电才能观看的付费视频";
- 而是很多 reaction UP 把"充电用户点播"的专场投稿标题写成
【加急充电-咻咻满专场】、【咻咻满充电专场①】,标题里没有 reaction 字样; - 所以
咻咻满 充电是为了命中这批漏网的 reaction 视频,实测 537 条结果、近 48h 新增 5 条,确有必要。 - 实测这些视频
view正常返回 stat,is_upower_exclusive=False,不存在取不到数据的问题。
1.3 收藏夹按年(人工建夹 + 同 UP 归属)
4451053851=咻咻满-reaction-2025(167 条)3700023351=咻咻满 reaction 合集(664 条,实为 2026 年)- 年度夹只作为配置项,系统不自动创建;年末人工建夹后把
media_id加进配置。
入哪个夹的判定(按优先级):
- 该 UP 在库中已有视频 → 入"同 UP 最早视频所在夹"。 例:某 UP 在 2026-12-25 发过视频、在 2026 夹,那么他 2027-01-02 的新视频仍入 2026 夹。
- 该 UP 在库中无视频 → 入当前自然年对应的夹(找不到则用配置里的最新年度夹,并提示"建议新建 2027 夹")。
这样同一个 UP 的视频永远在同一个夹里,配合 §7.3 的"同 UP 连续"规则,收藏夹不会因为跨年把一个 UP 拆成两段。 容量处理(不做自动改夹):目标夹满了就让它失败——
deal返回错误时记fav_action.state='failed'并通过通知渠道告警(含"请新建年度夹并更新配置")。可在接近上限(如media_count >= 950)时提前发一次预警,但系统绝不擅自把视频塞进另一个夹。 注意:这只是"收藏夹归属",snapshot与看板不按年分表,全部视频统一查询、统一出榜。
1.4 两个数据来源的关系
人工加收藏(兜底、可信) ──┐
├──► video 表(status=active)──► 每日统计 ──► 看板
搜索候选 + 点击确认(补充) ──┘
- 收藏夹是真相源:用户手工加进去的,默认就是"符合条件",系统自动入库;
- 搜索是补漏:只发通知,点确认才入库,同时顺手加进目标收藏夹;
- 日更对象 = 库内
active视频(跨年度夹累计),与收藏夹当前内容解耦; - 黑名单只挡"发现":收藏夹里已有的黑名单 UP 视频一律当普通视频处理,不判断、不排除、也不反向更新黑名单。
1.5 配置与凭据(统一放配置文件)
所有可变项与敏感信息都在一个配置文件里(建议 config.json,chmod 600,.gitignore 排除):
{
"bilibili": {
"read": {
"cookie": "SESSDATA=...; bili_jct=...; buvid3=...; ...",
"csrf": "<read bili_jct>",
"mid": 28802351
},
"write": {
"cookie": "SESSDATA=...; bili_jct=...; buvid3=...; ...",
"csrf": "<write bili_jct>",
"mid": 0
}
},
"telegram": {
"bot_token": "123456:ABC...",
"chat_id": "-1001234567890"
},
"folders": [
{ "year": 2025, "media_id": 4451053851, "title": "咻咻满-reaction-2025" },
{ "year": 2026, "media_id": 3700023351, "title": "咻咻满 reaction 合集" }
],
"track_fans": [37754047],
"up_blacklist_seed": [
261052419, 1908537643, 1701121088, 542262, 376316958,
145178393, 66389997, 293976264, 3026109
],
"schedule": { "discover_minutes": 60, "folder_sync_minutes": 60,
"snapshot_at": "00:01", "candidate_ttl_days": 7 },
"notify": { "min_score": 2, "max_items_per_message": 10 },
"classify": {
"require_title": "咻咻满",
"positive": ["reaction", "re", "反应", "看", "听", "初听", "首次", "点评", "锐评", "路人", "震惊", "双人"],
"charge": ["充电", "加急", "专场"],
"negative": ["歌切", "切片", "纯享", "搬运", "完整版"],
"duration_boost": { "gt_30min": 1, "gt_60min": 2 }
},
"heat_weights": { "view": 0.30, "like": 0.20, "coin": 0.20,
"favorite": 0.15, "share": 0.10, "reply": 0.03, "danmaku": 0.02 }
}
- 支持热加载(每 60 秒检查 mtime),改收藏夹/黑名单不用重启;
schedule中的周期与每日时间需重启生效; - 黑名单本身放数据库(
up_blacklist表),因为要从通知里动态拉黑;配置文件只做初始种子; - 日志里永不打印 token / cookie,启动时只打印"已配置/未配置";
- 容器里把配置目录挂成可写卷(
-v ./config:/config,权限 600),因为auth扫码续期后要回写 cookie; python app.py auth --account read|write对指定账号扫码续期,只替换该账号身份字段、保留该账号设备指纹;read只用于年度收藏夹同步、快照和查询;write只用于目标收藏夹、deal和sort写操作;不支持顶层扁平凭据;- 可选的
ENV覆盖(TG_BOT_TOKEN等)留给 CI/临时调试,不作为主路径。
1.6 初始黑名单(已核对昵称,实测 2026-10-31)
| mid | 昵称 | 粉丝 | 类型(人工判断) |
|---|---|---|---|
| 261052419 | TL二十度灰 | 1,099 | 直播歌切 |
| 1908537643 | 咻咻满在发光 | 7,289 | 粉丝切片号 |
| 1701121088 | TTsssolo | 2,393 | 切片/搬运 |
| 542262 | 咻咻满的表情包 | 16,436 | 切片号 |
| 376316958 | 好心的王二哈 | 2,779 | 切片/直播相关 |
| 145178393 | 雨落柃云 | 1,545 | 切片/搬运 |
| 66389997 | 至尊煎饼 | 216 | 切片/搬运 |
| 293976264 | 宁牙XML | 158 | 切片/搬运 |
| 3026109 | 切片不切丝QAQ | 7,471 | 切片号 |
- 这 9 个作为
up_blacklist的初始种子,首次启动时写入数据库(INSERT OR IGNORE); - 之后通过通知里的「拉黑」动态追加;
- 已入库/收藏夹里的这些 UP 的视频不受影响(§1.4、§3.4);
- 误拉黑可在看板移除,或删库里的行。
2. 接口能力矩阵(2026-10-31 实测)
| 能力 | 接口 | 需登录 | 实测结果 | 用途 |
|---|---|---|---|---|
| 关键词搜索 | GET x/web-interface/wbi/search/type |
否 | ✅ 800~1000 条上限 | 发现 |
| 旧版搜索(降级) | GET x/web-interface/search/type |
否 | ✅ code=0,结果略少 |
兜底 |
| UP 主搜索 | 同上 search_type=bili_user |
否 | ✅ 找到 咻咻满 mid=37754047 | 仅元数据 |
| 空间投稿 | GET x/space/wbi/arc/search |
否(需 WBI) | ✅ code=0 |
本方案不使用 |
| 视频完整统计 | GET x/web-interface/view?aid=/bvid= |
否 | ✅ 7 项齐全,0.1s | 日更主力 |
| 收藏夹 ID 列表 | GET x/v3/fav/resource/ids |
是 | ✅ 664 条 1 次拿全 | 收藏夹监控 |
| 收藏夹批量详情 | GET x/v3/fav/resource/infos |
是 | ✅ 一次 400 条,0.2s;664 条返回 648(缺 16) | 元数据同步/导入 |
| 加入收藏夹 | POST x/v3/fav/resource/deal |
是 + csrf | 文档明确,待实测 | 一键加入 |
| 收藏夹内排序 | POST x/v3/fav/resource/sort |
是 + csrf | ✅ 已验证 A:2:B:2 |
定位 |
| 收藏夹列表 | GET x/v3/fav/folder/created/list-all |
是 | ✅ 返回 media_count | 容量监控 |
| 粉丝数 | GET x/relation/stat?vmid= |
否 | ✅ code=0,匿名可用 |
粉丝追踪 |
x/web-interface/archive/stat |
— | ❌ HTTP 404,已下线 | 不可用 | |
view?aid=1,2 |
— | ❌ -400 |
不可用 |
2.1 infos 到底能替代什么(本轮重点实测)
逐条与 view 对照(同一批 6 条视频):
| 指标 | infos | view | 是否一致 |
|---|---|---|---|
播放 play / view |
✅ | ✅ | 完全一致(746/746、1316/1316…) |
收藏 collect / favorite |
✅ | ✅ | 完全一致(43/43、66/66…) |
弹幕 danmaku |
✅ | ✅ | 完全一致(7/7、26/26…) |
评论 reply |
❌ 恒为 0 | ✅ 16/24/7/38/37/109 | 字段已失效,不能用 |
点赞 like |
❌ 无此字段 | ✅ | 缺失 |
投币 coin |
❌ 无此字段 | ✅ | 缺失 |
转发 share |
❌ 无此字段 | ✅ | 缺失 |
结论:infos 不能替代 archive/stat 做"7 项日更"。
但它是极好的批处理通道,有不可替代的价值:
- 单次 400 条 / 0.2 秒(664 条 URL 约 12KB,一次请求 0.2s 返回 648 条);
- 返回
upper.mid/name、bvid、title、pubtime、ctime、duration、attr(失效标记); - 664 条收藏的全量元数据同步只需 2 次请求(按 200 条分块更稳)。
因此日更拆成两段:
- 元数据 + 3 项指标(play/favorite/danmaku):
infos批量,2 次请求拿全库 → 秒级; - 其余 4 项(like/coin/share/reply):
view逐条,831 条约 17 分钟。
注意:第一段不能只跑一次就当作日更,因为两张表口径必须一致;实际做法是以
view为唯一事实来源,infos只用于收藏夹同步/导入/校验。若某天view大面积失败,可用infos的三项先兜底并标记"部分指标缺失"。
2.2 infos 的两个坑(实测)
- 静默丢条:请求 664 条只返回 648 条,缺失的 16 条是"账号已注销/视频已删"。做差集时不能用返回条数判断成功,要
请求 ids - 返回 ids得到缺失集合。 - 失效标记:
attr=9表示"已失效视频",664 条里有 20 条(3.0%);加上静默缺失的 16 条,共 36 条 / 664 = 5.4% 已不可用。日更必须跳过并把状态置为removed,否则每天 36 次无效请求 + 报错。
3. 发现环节设计(每小时,前 2 页)
3.1 为什么"前 2 页就够"
实测(order=pubdate):
| 关键词 | 第 1 页时间跨度 | 第 2 页覆盖到 | 近 48h 新增 |
|---|---|---|---|
咻咻满 reaction |
10-31 → 10-23 | 10-08 | 6 |
咻咻满 充电 |
10-31 → 10-17 | 10-02 | 5 |
第 12 页覆盖 2030 天,而扫描间隔只有 1 小时,理论冗余 500 倍。即使排序抖动导致某条新视频落到第 3 页,下一轮也会被捞到(抖动主要是"旧视频位置漂移",不是"新视频沉底")。
代价可接受:漏掉的由人工加收藏兜底,且收藏夹监控会自动发现。
3.2 扫描流程
每 60 分钟(整点 ± 抖动):
for kw in ["咻咻满 reaction", "咻咻满 充电"]: # 2 个关键词
for page in (1, 2): # 每词 2 页
r = wbi_search(kw, page, order=pubdate) # 共 4 次请求
for item in r.result:
if item.aid in seen_aid or item.aid in video: continue # 永久去重
if now - item.pubdate > 14 天: continue # 时间窗,防历史回填
insert(seen_aid, item.aid)
record(candidate, score=classify(item)) # 进候选池
if score >= NOTIFY_THRESHOLD: enqueue_notify(item)
sleep(1.5~2.5s jitter)
# 收藏夹监控(独立任务,见 §4),发现人工新增会立即入库
请求量:每小时 4 次搜索,一天 96 次。远低于风控阈值(实测 1.5s 间隔连续 15 次无 -352)。
3.3 轻量降噪规则(只决定"要不要发通知",不决定"要不要入库")
# 硬门槛
title 含 "咻咻满"(全半角/大小写归一化后),否则直接丢弃
up_mid 命中黑名单 → 直接排除(永不通知,连候选池都不进)
score = 0
+2 标题含 reaction|re|反应|看|听|初听|首次|点评|锐评|路人|震惊|双人
+2 标题含 充电|加急|专场
+2 时长 > 60 分钟
+1 时长 > 30 分钟(与上一条不叠加,取大)
-5 标题含 歌切|切片|纯享|搬运|完整版
-3 UP 为 咻咻满本人(mid=37754047)
通知阈值 = 2
0 <= score < 2 且含"咻咻满" → 进看板"候选池"(7 天后删除),不通知
score < 0 → 丢弃
几条设计依据:
- 对现有 664 条收藏回测,92.6% 的标题含 reaction 类词,规则召回足够;误判代价只是"多发/少发一条通知",最终由人点击决定;
- "直播回放"不再算负向词:确实有人先直播看、再发直播回放,这类仍是 reaction;改由"UP 黑名单"处理专发回放/切片的号;
- 时长是强正向信号:实测这批 reaction 视频时长普遍 100~336 分钟(
105:05、158:24、336:31),30/60 分钟阈值能把大多数切片(十几分钟)区分开; - 时长直接取搜索结果自带的
duration字段(格式是分钟:秒,超过 60 分钟写作105:5),不需要额外请求; - UP 黑名单在配置文件里维护,可热加载,后续从"误通知"记录里一键加入。
3.4 通知内容
每条候选一条消息,包含:
- 封面缩略图 + 标题 + UP 名 + 发布时间 + 时长 + 当前播放/点赞
- 三个动作按钮:
- 「收藏」:加入目标收藏夹并入库(见 §7)
- 「忽略」:该 aid 标
rejected,不再提醒(同一轮同 UP 的其他候选不受影响) - 「拉黑」:把该视频的 UP 拉入
up_blacklist,同时把该 UP 的其他待决策候选一并标记为rejected;已入库/收藏夹里的该 UP 视频不受影响
- 「打开视频」链接(
https://www.bilibili.com/video/<bvid>)
黑名单语义(明确边界):
- 只过滤"未来发现"——命中黑名单的 UP 新视频不进候选池、不发通知;
- 不做回溯清理:库内已有的、收藏夹里已有的该 UP 视频照常日更、照常上榜;
- 不从收藏夹反推黑名单:人工加进收藏夹的行为不会修改黑名单;
- 可在看板里移除黑名单条目(误拉黑可恢复)。
同一轮多条候选合并成一条消息(避免刷屏),单条消息最多 10 个按钮。
零额外请求:搜索结果的 item 里已经带 play / like / favorites / danmaku / review / duration / cover / pubdate / uname / mid,发通知不需要额外调用 view。注意这些是索引值,可能比实时值滞后几分钟到几小时,通知里标注"(索引值)"即可。
4. 收藏夹监控(库的真相源)
4.1 为什么必须有
- 人工加收藏是最可靠的"确认"动作,系统必须能感知;
- 同时解决"标题/UP 改名、视频失效、用户移出收藏夹"的状态同步。
4.2 流程(每小时,与搜索同频)
for folder in 配置的年度夹(2025、2026、…):
ids = get(ids?media_id=folder) # 1 次,返回全部 aid
diff_add = ids - db.known_ids(folder)
diff_del = db.known_ids(folder) - ids
if diff_add:
for chunk in chunks(diff_add, 200): # 通常 1 次
infos = get(infos?resources=...)
for it in infos:
upsert(video, source='fav:<folder>', status='active')
enqueue_snapshot_today(it.id) # 当天补一次快照
notify("收藏夹发现 N 条新视频,已自动入库")
if diff_del:
for aid in diff_del:
mark(video.status='removed' or 'unfaved')
# 元数据刷新:每 6 小时用 infos 全量刷一次标题/UP/封面/失效状态
- 请求量:2 个夹 × (1 次 ids + 0~1 次 infos)/小时,基本可忽略;
infos返回attr=9或缺失 → 标记removed,保留历史快照;- 用户移出收藏夹(
diff_del且视频仍可访问)→ 标记unfaved,数据继续跟踪(库不因取消收藏而丢历史),只是看板可筛"仅在库/在收藏夹"。 - 被用户手动"移出库"(
dropped)的视频若又出现在收藏夹(diff_add)→ 自动恢复为active,并补当天快照;这是唯一会"复活"的状态。 - 监控的是所有配置的年度夹(2025、2026…),所以人工把新视频加进旧夹也能被发现;
video.folder_id以"实际所在夹"为准,若同一 aid 同时出现在多个夹(极少见),取最新加入的那个并告警。- 上述
enqueue_snapshot_today已实现为pending_snapshot_aids()检查:完成标记为 1 时仍会为新入库/恢复视频补采当天快照。
4.3 首次导入
infos按 200 条分块,664 条 = 4 次请求,秒级完成;- 两个夹共 831 条,一次性导入
video表; - 历史快照无法回填:B 站不提供任意日期历史数据,只能从部署当天开始积累;
- 开发联调阶段产生的数据一律保留(不清库、不重建),正式上线直接续用同一个
app.db; - 新发现/新入库的视频从"入库当天"开始积累快照,第一天没有增量是正常的,看板对"仅 1 个数据点"的视频标注"样本不足"。
5. 每日数据追踪
5.1 任务
每天 00:01:00(Asia/Shanghai):
videos = db.where(status='active') # 跨所有年度夹
for v in videos:
if exists(snapshot[today, v.aid]) and ok: continue # 断点续跑
r = view(aid=v.aid) # 匿名 + 设备指纹
if code == 0: upsert(snapshot, today, r.stat)
elif code == -404: v.status='removed'
else: retry(3, backoff); snapshot(ok=0, err=...)
sleep(1.0~1.6s jitter)
发送完成摘要(总数/成功/失败/耗时)
5.2 容量与耗时
| 库规模 | 请求数 | 预估耗时(1.2s/条 + 网络) |
|---|---|---|
| 831(当前) | 831 | 约 17 分钟 |
| 1600(2027 年末) | 1600 | 约 33 分钟 |
| 3000(2029 年) | 3000 | 约 60 分钟 |
- 00:01 启动,最晚 01:00 前完成,不影响白天使用;
- 若单条 0.1s + sleep 1.2s ≈ 1.3s,实际按 1.2s 估算偏保守。
5.3 幂等与口径
- 唯一键
(stat_date, aid),INSERT ... ON CONFLICT DO UPDATE; stat_date用本地日期,不用 UTC(避免 00:01 落到前一天);- 失败重试 3 次后写
ok=0占位,不写假数据;看板显示"缺失"而不是 0; - 完成标记之外还要检查当天待采集合:新入库/恢复视频立即补采,
ok=0项在续跑时重试; - 强制重采开始前先写未完成标记,中断后仍可续跑;
- 日增量查询时用
LAG()跳过缺失日,不插值。
5.4 粉丝数追踪(新增)
每天 00:01 与视频快照同一个任务里,追加 1 次请求:
r = GET x/relation/stat?vmid=37754047 # 匿名可用,实测 0.1s
upsert(up_stat, today, mid=37754047, follower=r.follower, following=r.following)
- 接口实测:
{"mid":37754047,"following":95,"follower":2767264},匿名code=0,无需登录; - 唯一键
(stat_date, mid),同样幂等; - 续跑时已成功的
(stat_date, mid)会跳过,不会因后续失败覆盖为 NULL; - 只加 1 次请求/天,成本可忽略;
- 支持配置多个 mid(
track_fans: [37754047]),但看板默认只突出咻咻满; - 粉丝日增 = 今日 - 昨日,缺失日不插值,曲线断开;
- 历史数据可用
tools/import_fans_history.py从小满虫之家公开data.js回填最近 90 天(当前快照锚点 2026-10-24);只写up_stat,默认跳过已有成功记录,缺失日保持断开。
6. 数据模型(定稿草案)
-- 视频主表
CREATE TABLE video (
aid INTEGER PRIMARY KEY,
bvid TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
up_mid INTEGER,
up_name TEXT,
pubdate INTEGER NOT NULL,
duration INTEGER,
cover TEXT,
folder_id INTEGER, -- 来源年度夹(可空=纯搜索确认)
fav_time INTEGER, -- 加入收藏夹时间
status TEXT NOT NULL, -- active|unfaved|removed|dropped|rejected
source TEXT, -- 'fav:3700023351' | 'search:咻咻满 reaction'
first_seen INTEGER NOT NULL,
last_sync INTEGER,
attr INTEGER
);
CREATE INDEX idx_video_status ON video(status, pubdate DESC);
CREATE INDEX idx_video_up ON video(up_mid, pubdate DESC);
-- 每日快照(唯一键 = 日期 + 视频)
CREATE TABLE snapshot (
stat_date TEXT NOT NULL, -- 'YYYY-MM-DD'
aid INTEGER NOT NULL,
view INTEGER, like INTEGER, favorite INTEGER, coin INTEGER,
share INTEGER, reply INTEGER, danmaku INTEGER,
captured_at INTEGER NOT NULL,
ok INTEGER NOT NULL DEFAULT 1,
err TEXT,
src TEXT DEFAULT 'view', -- view | infos(降级时标记)
PRIMARY KEY (stat_date, aid)
);
CREATE INDEX idx_snap_aid ON snapshot(aid, stat_date);
-- 收藏夹监控状态
CREATE TABLE folder (
media_id INTEGER PRIMARY KEY,
year INTEGER NOT NULL,
title TEXT,
media_count INTEGER,
last_ids_at INTEGER,
last_full_at INTEGER,
enabled INTEGER DEFAULT 1
);
-- UP 黑名单(专发直播回放/切片等,直接排除,不进候选)
CREATE TABLE up_blacklist (
mid INTEGER PRIMARY KEY,
name TEXT,
reason TEXT,
created_at INTEGER NOT NULL
);
-- UP 粉丝/关注数快照(每天一条)
CREATE TABLE up_stat (
stat_date TEXT NOT NULL,
mid INTEGER NOT NULL,
follower INTEGER,
following INTEGER,
captured_at INTEGER NOT NULL,
ok INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (stat_date, mid)
);
-- 候选与扫描流水(漏抓分析 + 去重 + 候选池)
CREATE TABLE candidate (
aid INTEGER PRIMARY KEY,
bvid TEXT,
title TEXT,
up_mid INTEGER,
up_name TEXT,
pubdate INTEGER,
cover TEXT,
score REAL,
channel TEXT, -- 'search:咻咻满 reaction#p1'
first_seen INTEGER NOT NULL,
notified_at INTEGER,
decision TEXT, -- accepted | rejected | NULL(待定)
decided_at INTEGER
);
-- 永久去重标记:candidate 删除后仍能防重复通知
CREATE TABLE seen_aid (
aid INTEGER PRIMARY KEY,
first_seen INTEGER NOT NULL,
source TEXT
);
-- 通知与一键加入
CREATE TABLE notify (
aid INTEGER NOT NULL,
channel TEXT NOT NULL,
batch_id TEXT,
sent_at INTEGER NOT NULL,
ok INTEGER NOT NULL,
err TEXT,
PRIMARY KEY (aid, channel)
);
CREATE TABLE fav_action (
token TEXT PRIMARY KEY, -- HMAC 签名串
aid INTEGER NOT NULL,
folder_id INTEGER NOT NULL,
state TEXT NOT NULL, -- pending | done | failed | expired
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
done_at INTEGER,
position INTEGER,
err TEXT
);
CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT);
要点
status是核心状态机:pending搜索候选,等确认(只在candidate表,不进video)active在库,参与日更unfaved用户取消收藏,仍继续日更,看板可筛removed视频失效(-404/attr=9/infos缺失),停止请求但保留历史dropped用户手动"移出库",停止发现与追踪;若之后又出现在收藏夹,同步任务会自动改回activerejected用户点过"忽略",不再通知
candidate与video分表:候选可能永远不入库,避免污染主表;候选first_seen超过 7 天且decision IS NULL→ 直接删除 candidate 行(按你的偏好),去重由seen_aid保证,不会重复通知;seen_aid只存(aid, first_seen, source),每条约 30 字节,按 5 条/天增长,10 年也不到 100KB;up_blacklist只影响发现:folder_sync不查这张表,收藏夹里黑名单 UP 的视频按普通视频入库;folder_id的取值遵循 §1.3:优先同 UP 最早视频所在夹,其次当前自然年夹;- 不预存"日增量",全部查询时算(历史数据修正后不会自相矛盾)。
7. 通知与「一键加入」
7.1 渠道抽象
统一接口 notify.send(candidates, actions) -> ok,实现:
| 优先级 | 渠道 | 按钮能力 | 备注 |
|---|---|---|---|
| P0 | Telegram Bot | ✅ Inline Keyboard | callback_data 直接回调,long polling 即可,不需要公网 URL |
| P1 | 企业微信机器人 | ⚠️ 仅 markdown 链接 | 不支持按钮,只能点链接 |
| P1 | 邮件 | ⚠️ HTML 链接 | 兼容性最好 |
| P1 | 通用 Webhook | ✅ 自定义 JSON | 给用户自己接(Bark/ntfy/钉钉) |
开发阶段只做 Telegram:
- Bot 用
getUpdateslong polling 收callback_query,本机没有公网 IP 也能联调; bot_token/chat_id从config.json读(见 §1.5),不写死在代码里;- 按私聊设计:
chat_id是用户 id(正数),Bot 只服务你一个人;回调callback_query直接用answerCallbackQuery回执; - 上线后服务有域名 + TLS,再补"签名链接"路径给企业微信/邮件/Webhook;
- 两种路径共用同一个
fav_action状态机,只是入口不同(callback_datavsPOST /a/<token>/confirm)。
7.2 链接与防误触
通知链接: https://<domain>/a/<token>
token = base64url(aid.folder.exp.nonce) + "." + HMAC-SHA256(secret, payload)
GET /a/<token>:只读,校验签名/过期/未使用 → 渲染确认页(封面、标题、UP、当前数据、加入后位置预览)→ 页面按钮POST /a/<token>/confirm;POST:再次校验 → 调deal→ 调sort定位 → 写fav_action→ upsertvideo(status='active')→ 删除 candidate 行 → 返回"已加入,位置 #N";- 任一步失败(夹满 / csrf 失效 / 风控 / 网络):
fav_action.state='failed'、保留 candidate、把错误原文通过通知渠道发回,用户可再点一次; - 忽略:
candidate.decision='rejected',7 天后随候选一起删除; - 拉黑:写
up_blacklist+ 该 UP 所有待决策候选rejected,不触碰已有video; - token 一次性(成功后作废)、24 小时过期、
nonce防重放; - GET 绝不产生写副作用,从根上避免 Telegram/微信/钉钉的链接预览预取误写;
- 若某渠道不支持按钮又想要"一步到位":确认页可用 JS 在加载后自动 POST(预览机器人不执行 JS),但仍保留手动按钮兜底。
7.3 加入后的位置计算
沿用 bili-fav 已验证的规则(同一 UP 连续、组内发布时间倒序、组间按该 UP 最早视频时间倒序):
target_folder =
同 UP 在库视频中最早一条的 folder_id # 优先,见 §1.3
若没有 → 当前自然年的夹
若该年未配置 → 最新年度夹 + 提示"建议新建新夹"
if 已在夹内: return "已在收藏夹,位置 #N"
anchor = 该 UP 组内比它新的最后一条
- 若同 UP 组存在:插到"该 UP 最新的那条"之前(即组头)
- 若不存在:新组,按组间规则插到"最早视频时间比它小的第一个组"之前
调用 deal(add_media_ids=target_folder, rid=aid, type=2, csrf=...)
调用 sort(sort="<prev_aid>:2:<aid>:2") # 把 aid 插到 prev 之后
校验 ids 序列里 aid 的位置
举例:某 UP 的旧视频在 2026 夹,他 2027-01-02 的新视频仍进 2026 夹,并插在该 UP 组头——这样收藏夹里同一 UP 永远是连续一段,不会跨年断裂。
失败处理:
deal报错(夹满/权限/风控)→fav_action.state='failed'+ 原样记录错误信息 → 通过通知渠道告知用户 → 视频保持candidate状态,仍可重试。不自动换夹、不自动丢弃。
幂等:加入前先查 ids 是否已在夹内;并发/重复点击用 fav_action.state 锁。
7.4 年度夹切换
- 配置
folders: [{year: 2025, media_id: 4451053851}, {year: 2026, media_id: 3700023351}]; - 系统不建夹,年末人工建好后把
media_id加进配置(可热加载,无需重启); - 选夹规则见 §1.3(同 UP 最早视频所在夹优先);
- 12 月中旬开始在看板/通知里提醒"距年末 N 天,请准备下一年度收藏夹",避免跨年时新 UP 的视频无处可放。
8. 热度算法
8.1 综合热度(存量)
x_i = log1p(metric_i)
z_i = (x_i - median(x)) / (IQR(x)/1.349) # 稳健标准化
score = Σ w_i * z_i
w = {view:0.30, like:0.20, coin:0.20, favorite:0.15,
share:0.10, reply:0.03, danmaku:0.02}
- 权重可配置,看板可切预设(均衡/互动向/传播向);
- 用中位数 + IQR 而非 max-min,避免单个爆款把其他视频压平;
- 硬币权重高:B 站硬币有每日限额,是强意愿信号;
- 另设"互动率榜":
(like+coin+fav)/view,识别小而精。
8.2 增长榜
看板 UI 中“增长”标注为播放增量(区间内绝对增量),“增速”标注为播放增速(增量 / 基线播放)。两者基线都取不早于
最新快照 - N 天的最近一条成功快照。
d7 = (view_today - view_{today-7}) / 实际间隔天数
r7 = (view_today - view_{today-7}) / max(view_{today-7}, 1)
| 榜 | 口径 | 基数门槛 |
|---|---|---|
| 累计热度 | §8.1 | — |
| 近 7 天播放增量 | d7(view) |
— |
| 近 7 天增速 | r7(view) |
view >= 200 |
| 互动率 | (like+coin+fav)/view |
view >= 100 |
| 单项榜 | 7 项直接排序 | — |
| UP 榜 | 按 up_mid 聚合 Σ/AVG |
— |
冷启动:不足 2 天数据的视频用"发布后 24h 增量",并标注"样本不足";缺失日不插值。
8.3 趋势
- 单视频:7 项折线,缺失日期断开;
- 按 UP:该 UP 全部视频的多线/堆叠;
- 区间:7/30/90/全部。
8.4 粉丝维度(独立,不参与热度计算)
fan_delta_1d(d) = follower(d) - follower(d-1)
fan_delta_7d(d) = follower(d) - follower(d-7)
fan_delta_cum = follower(d) - follower(首日)
- 单独一条曲线(默认近 90 天,可切 7/30/全部);
- 只画粉丝数,不叠加 reaction 视频数/播放量(两者与咻咻满本人涨粉没有稳定因果关系);
- 缺失日不插值,曲线断开并标注;
- 首日没有前值,第一条增量从第 2 天开始。
9. 看板
9.1 页面
| 页面 | 内容 |
|---|---|
| 首页 | 最新动态优先:今日增量、最新入库、最近发布、增长动态、热度速览、收藏夹容量、采集状态 |
| 榜单 | Tab:综合热度/播放/点赞/投币/收藏/转发/评论/弹幕/互动率/播放增量/播放增速;视频与 UP 范围切换;筛选与搜索 |
| 视频详情 | 元数据 + 七项指标 + 单指标趋势(7/30/90/全部)+ 同 UP 对比 + 快照表(含失败标记) |
| UP 页 | 该 UP 全部视频、聚合趋势、单视频对比、平均互动率 |
| 粉丝页 | 咻咻满粉丝数曲线 + 每日/7 日净增 + 累计增长(标注起始日期)+ 最近 14 天记录;只画粉丝,不叠加视频数据 |
| 候选池 | 候选、得分、通知时间、剩余 TTL;只读展示,操作指向 Telegram/CLI |
| 系统状态 | 最近任务、采集成功率、收藏夹同步时间、下次任务、备份、鉴权、黑名单 |
9.2 技术
- 单
index.html+ 原生 ES Module,无构建链; - History API 路径路由(
/rank、/video/{aid}、/up/{mid}),服务端对无扩展名的非静态路径回退index.html; - 排序、筛选和趋势指标切换用
replaceState更新 URL 并重新取数,保留滚动位置,不整页刷新; - 图表本地 vendored ECharts(Apache-2.0,离线);
/api/*JSON,前端只渲染;- 移动端优先,底部 5 项导航;桌面端左侧导航;
- 首页增长榜在不足 2 天快照时显示空态,不伪造 0。
9.3 只读边界与鉴权
- P2 Web 业务接口只读,写方法统一
405;数据管理继续通过 Telegram/CLI/数据库; - 本地回环地址且未配置
web.auth_token时免登录; - 配置 token 后启用登录页 + HttpOnly Cookie,或使用
Authorization: Bearer <token>; - 非回环地址且未配置 token 时拒绝启动;
- 不做多用户(YAGNI)。
10. 部署
仓库已提供:
Dockerfile 单容器镜像(只安装 httpx 0.28.1)
compose.example.yml 挂载 ./config.json → /config、./data → /data(复制为 compose.yml 后使用)
.dockerignore 排除凭据、数据、测试与本地环境
config.docker.example.json 服务器配置样例(0.0.0.0 + /data 绝对路径)
- 单容器跑「调度 + Web」,数据卷
/data/app.db; - 不拆多容器、不依赖宿主 cron:运营量级是"每小时几次请求 + 每天千次请求",单体进程完全够;调度用内置线程,避免宿主机差异;
- 镜像预计 120~150MB,多架构 amd64/arm64;
- 公网:Nginx/Caddy 反代 → TLS(Caddy 自动证书最省事);
- 日志 stdout(JSON 行);
- 备份:每日
sqlite3 /data/app.db ".backup /data/backup/app-YYYYMMDD.db",保留 14 天。
进程结构
scheduler 线程:discovery(每 60min) / folder_sync(每 60min) / snapshot(每日 00:01)
web 线程:ThreadingHTTPServer :8080
storage:SQLite WAL,单写线程
11. 风险清单(更新)
| 风险 | 影响 | 概率 | 缓解 |
|---|---|---|---|
| 搜索排序抖动 + 只取 2 页 | 漏抓个别新视频 | 中 | 每小时扫一次 + 收藏夹监控兜底 + 候选池;业务上可接受 |
| 搜索结果噪声 | 通知太吵 | 中 | 轻量规则降噪 + 人点确认;不达阈值进候选池 |
| 收藏夹 1000 条上限 | 夹满后无法加入 | 中 | 接近 950 时预警;夹满则 deal 失败 → 报错并通知(不自动改夹),由人工新建年度夹并更新配置 |
| 同 UP 归属导致旧夹持续增长 | 2026 夹被 2027 视频撑爆 | 高 | 与上一条同一处置:预警 + 失败告警 + 人工建新夹;不加自动改夹逻辑 |
| 黑名单误伤 | 漏掉正常 reaction | 低 | 黑名单可随时移除;被排除的 UP 不写候选表,但可从日志审计 |
infos 静默丢条 |
误判同步成功 | 高 | 用 请求 ids - 返回 ids 判断缺失;缺失即 removed |
infos.reply 恒 0 / 缺 like/coin/share |
统计不全 | 确定 | 日更以 view 为唯一事实来源,infos 只做同步/降级 |
| GET 链接被预览预取 | 误加入收藏夹 | 中 | GET 只渲染页面,写操作走 POST |
deal / sort 写失败 |
收藏夹状态不一致 | 中 | 先查 ids 幂等、失败重试、fav_action 记录、页面展示失败原因 |
| 视频失效(当前 5.4%) | 日更报错 | 高 | -404 / attr=9 / 缺失 → removed,不再请求 |
接口下线(archive/stat 已 404) |
功能中断 | 中 | 接口层抽象 + 降级(旧版 search / infos 三项)+ 监控告警 |
风控 -352/-412 |
采集中断 | 低(实测未见) | 限速 1.2s + 抖动 + 指数退避 + 告警 |
| 跨年切换遗漏 | 新视频进错夹 | 低 | 按 pubdate 选夹 + 1 月 1 日提醒 |
| SQLite 并发 | 写冲突 | 低 | WAL + 单写线程 |
| cookie 过期 | 收藏夹相关接口失败 | 中 | 使用 `python app.py auth --account read |
12. 待确认
无。 三轮问答已覆盖:收录口径、发现策略、收藏夹归属与容量、通知渠道与动作、黑名单语义、候选池生命周期、粉丝追踪、部署形态。
进入定稿/实施前只需要你提供运行期凭据(不建议写进本文档):
- Telegram
bot_token+ 私聊chat_id - B 站 cookie(也可以先用
auth扫码生成) - 年度收藏夹
media_id(已有 4451053851 / 3700023351)
13. 验证记录
环境:Windows / Python 3.14 / 现有 bili-fav/config.json(mid=28802351,SESSDATA 有效至 2027-04-29)。
| # | 验证项 | 结果 |
|---|---|---|
| 1 | WBI 关键词搜索 × 4 | 全部 code=0 |
| 2 | WBI 搜索匿名 | code=0 可用 |
| 3 | 旧版 search/type |
code=0,可降级 |
| 4 | UP 主搜索 | 咻咻满 mid=37754047(276.7 万粉) |
| 5 | 空间投稿 | Reaction厂 mid=230020975,251 投稿 |
| 6 | view 取 7 项 stat |
完整 |
| 7 | view 匿名 / 仅设备指纹 |
均 code=0 |
| 8 | archive/stat |
HTTP 404 已下线 |
| 9 | 批量 view?aid=1,2 |
-400 不支持 |
| 10 | 连续 12 次 view(0.6s 间隔) |
无风控,0.07~0.14s/条 |
| 11 | 搜索分页深度 | 上限 ~1000(50 页),排序非严格时间序 |
| 12 | 收藏夹容量 | 3700023351=664、4451053851=167 |
| 13 | 充电专场视频 stat | 正常,非付费墙 |
| 14 | 664 条收藏 UP 分布 | 392 个 UP;269 个只投 1 条;Top100 覆盖 52.6% |
| 15 | 664 条标题规则回测 | 92.6% 含 reaction 类词;4.7% 含切片类词 |
| 16 | infos 批量上限 |
400 条/次 OK(0.2s);664 条一次请求 URL 12KB,返回 648 条 |
| 17 | infos vs view 对照 |
play/favorite/danmaku 完全一致;reply 恒 0;无 like/coin/share |
| 18 | 失效判定 | attr=9 20 条 + 静默缺失 16 条 = 36/664(5.4%) |
| 19 | infos 200 条分块 |
code=0,稳定 |
| 20 | 粉丝数接口 x/relation/stat |
code=0,匿名可用,follower=2767264 |
| 21 | 搜索 item 的 duration 格式 | 分钟:秒(105:5、336:31),超 60 分钟不转小时 |
| 22 | 搜索 item 自带统计字段 | play/like/favorites/danmaku/review/duration/cover/pubdate/mid/uname 齐全,通知可用索引值 |
| 23 | 初始黑名单 9 个 mid 核对 | x/web-interface/card 全部 code=0,昵称/粉丝数已记录(§1.6) |
| 24 | x/web-interface/view 七项 stat |
code=0,view/like/favorite/coin/share/reply/danmaku 齐全 |
| 25 | view 不可访问返回码 |
实测为 62012;代码同时兼容 -404、62002、HTTP 404 |
| 26 | x/relation/stat 粉丝数 |
code=0,匿名可用,实测 follower=2767268 |
| 27 | x/web-interface/nav 登录态 |
code=0、isLogin=true、mid=28802351 |
| 28 | P1 真实全量快照 | 795 条全部 ok=1、0 失败、0 移除;耗时 1124s;粉丝 follower=2767270;日备份 app-20261031.db 含 795 条快照 |
| 29 | 空收藏夹 ids 契约 |
4413147139 返回 {"code":0,"data":[]},data 键存在且为数组;缺失/null 按协议异常处理 |
| 30 | 迁移 v2/v3 与增量同步 | 真实库升级到 schema 3,自动生成 app-pre-v1-* / app-pre-v2-*;同步 167+664 条无新增/移除,主归属无矛盾 |
| 31 | P3 真实发现 | 4 次 WBI 搜索、168 条结果、161 已入库、6 条超时间窗、1 条候选(score=6) |
| 32 | P3 Telegram 通知 | 真实 Bot 发送成功;发现重复结果后已修复按 aid 去重,离线测试覆盖 |
| 33 | P3 同步联动 | 候选被人工加入收藏夹后,同步识别 1 条新增并当天补采快照;候选行自动删除 |
| 34 | P2 预览数据 | 复制真实库回填 7 天:796 条视频、4,606 条合成快照、3 条失败、4 条候选、1 条预览黑名单 |
| 35 | P2 只读页面 | 首页/榜单/视频/UP/粉丝/候选/系统 7 个页面在 390px 与 1280px 下无脚本错误 |
| 36 | P2 只读边界 | 业务 POST/PUT/PATCH/DELETE 均返回 405,请求前后数据库行数不变 |
| 37 | 粉丝历史回填 | 从小满虫之家 data.js 回填 90 天(2026-07-27 |
未验证(需授权或外部配合):
fav/resource/deal实际加入 +sort定位(会写收藏夹;需用户点击 Telegram 按钮或授权测试视频);- Telegram 按钮真实回调(长轮询代码与授权逻辑已离线测试);
- 手机扫码
auth完整链路; - 各通知渠道的按钮/链接实际表现;
- 长期(>72h)每小时扫描的风控表现;
- 收藏夹真实上限是否 1000。
14. 下一步
P0、P1、P2、P3 已实施。P2 交付了只读本地看板与可重复生成的 7 天预览数据:
- 首页、榜单、视频/UP 详情、粉丝曲线、候选池和系统状态页面;
- 原生 ES Module + 本地 ECharts,本机默认绑定
127.0.0.1:8080; - 查询、热度、增长和互动率全部在服务端计算,前端只渲染;
- Web 不提供业务写操作,数据管理继续通过 Telegram/CLI/数据库;
- P3 的真实
deal/sort待用户点击 Telegram 按钮或授权测试视频后补充验证记录。
P4(容器化 + 反代)继续保持可选。