私聊新增 /candidate [页码](待决策列表)、/candi_del(标题按钮逐条删除,删后原地重建键盘、可翻页连续删)、/candi_clear(全部/待决策/取消)。重构 handle_callback 为前缀分派(cdel/cpage/cclr,保留 add/ign/blk/fav);repository 加 list_pending_candidates/delete_all_candidates;LibraryService 加 pending_candidates/delete_candidate/clear_candidates 并刷看板缓存。
55 KiB
咻咻满 Reaction 视频数据看板 —— 可行性分析与技术路线
状态:设计定稿;P0(数据底座)、P1(每日快照与粉丝追踪)、P2(只读本地看板)、P3(Telegram 发现与入库)已实施;第 9 轮加入时长效率榜、UP 粉丝快照与导航拆分;第 10 轮与 B 站收藏夹解耦并加入 Telegram 手动入库 最后更新:2026-11-01(第 10 轮:收藏夹解耦、手动入库、写鉴权仅用于重排序) B 站写操作仅保留
fav/resource/sort(由favsort.py触发,频率极低);fav/resource/deal已移除
说明:本文档记录项目完整设计与历史验证。第 10 轮起,收藏夹不再是库的真相源:入库改由「搜索候选确认」与「Telegram 私聊手动入库」承担,项目不再轮询收藏夹;下方 §4、§7 等章节中关于「收藏夹同步 / 一键加入」的描述为历史设计,已被第 10 轮取代(见 §15)。
0. 结论摘要
判定:可行,工程难度中等偏低。 核心功能点都有稳定可用的官方 Web 接口,且取数匿名即可完成。
| 功能点 | 主力接口 | 实测 |
|---|---|---|
| 视频发现 | wbi/search/type |
✅ code=0,匿名可用,单关键词上限 800~1000 条 |
| 手动入库(链接/BV/av/短链) | view?aid= / view?bvid=、b23.tv 跳转 |
✅ 只读 |
| 每日统计 | view?aid= |
✅ 7 项指标齐全,0.07~0.14s/条,无风控 |
| 收藏夹重排序(唯一写操作) | fav/resource/ids + infos + sort |
✅ favsort.py 已实现 |
| 看板 | 本地 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 数据来源的关系(第 10 轮更新)
搜索候选 + Telegram 点确认 ──┐
├──► video 表(status=active)──► 每日统计 ──► 看板
Telegram 私聊发链接/BV 号 ───┘
- 本地库是真相源:入库来自「搜索候选确认」与「Telegram 私聊手动入库」,只写本地
video(status='active'); - 收藏夹解耦:项目不再轮询收藏夹,也不会因取消收藏改变视频状态;收藏夹由人工维护,
favsort.py负责低频重排序; - 日更对象 = 库内
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,
"snapshot_at": "00:01", "candidate_ttl_days": 7 },
"discover": { "keywords": ["咻咻满"], "pages": 3, "window_days": 7,
"candidate_min_score": 4 },
"notify": { "min_score": 4, "max_items_per_message": 10 },
"classify": {
"require_title": "咻咻满",
"self_mid": 37754047,
"reaction": ["reaction", "反应"],
"charge": ["加急", "特急", "充电", "专场", "转场"],
"weak_positive": ["双人", "路人", "第一次", "首次"],
"negative": ["歌切", "切片", "纯享", "搬运", "完整版", "录播", "录像"],
"date_negative": true,
"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仅用于favsort.py收藏夹重排序(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 次拿全 | favsort.py 重排序 |
| 收藏夹批量详情 | GET x/v3/fav/resource/infos |
是 | ✅ 一次 400 条,0.2s;664 条返回 648(缺 16) | favsort.py 重排序 |
POST x/v3/fav/resource/deal |
— | — | 第 10 轮已移除 | |
| 收藏夹内排序 | 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 含 "咻咻满"(全半角/大小写归一化后),否则直接 0 分
up_mid 命中黑名单 → 直接排除(永不通知,连候选池都不进)
# 正向命中次数 n
pos = reaction|反应 命中次数 + count(加急|特急|充电|专场) + count(双人|路人|第一次|首次)
n = pos + (pos > 0 ? 1 : 0) # 标题含"咻咻满"仅在已有正向命中时计 1 次
score = n * n # 超线性:1→1、2→4、3→9、4→16(1+1>2、1+1+1>5)
+ 2 # 时长 > 60 分钟(与下一条不叠加,取大)
+ 1 # 时长 > 30 分钟
- 1 # 时长 < 10 分钟(切片/片段;时长未知=0 不扣)
- 2 * k # 每个负向词:歌切|切片|搬运|完整版|录播|录像
- k*(k-1) # 同时命中 2 个额外 -2、3 个额外 -6、4 个额外 -12
- 4 # 标题带日期(2026-10-28 / 2026/10/25 / 2026.10.26 / 2024年11月24日 / 10月25日)
- 3 # UP 为 咻咻满本人(mid=37754047)
候选线 = discover.candidate_min_score(默认 = notify.min_score)
通知线 = notify.min_score
score < 候选线 → 丢弃(只过标题关、无正向命中的不再进候选池)
候选线 <= score < 通知线 → 进看板"候选池"(7 天后删除),不通知
score >= 通知线 → 通知
几条设计依据:
- reaction 类用宽松
re但须同现:re|reaction|反应必须与"咻咻满"或"充电"同时出现才计命中——既避免通用词(任意含 "re" 的拉丁串)加分,又能兼容reactio/reation/recation等拼写; - 只过标题关不计分:标题仅含
require_title("咻咻满")而没有命中任何正向词时 0 分——"咻咻满"单独出现和 "reaction" 单独出现一样没有正向意义,必须"咻咻满 + 任一正向词"才计分; - 正向按命中次数超线性加权(n²):多个正向词同现(如"咻咻满 + reaction + 充电")时不能简单线性和,否则一个负向词就能把多信号真 reaction 打到线下;
1+1>2、1+1+1>5用平方实现; - 负向也非线性:每个负向词 -2,命中 2 个额外 -2、3 个额外 -6(
k*(k-1)),带日期再 -4;单一负向词不再一票否决多信号 reaction; - 时长 <10 分钟 -1:这类基本是切片/片段;时长未知(0)不扣;
- 候选线与通知线分离:
discover.candidate_min_score(默认取notify.min_score)控制是否进候选池——低于候选线的直接丢弃(只过标题关的不再入池),避免看板被无意义候选刷屏; - "听/看"等宽泛词已移除;
纯享(会误伤"纯享受")已从负向移除,转场("专场"错别字)已从正向移除; - 时长只在已命中正向词时加权:避免"完整录播/直播录像"这类既无 reaction 也无 charge 的长视频仅靠时长进池;
- 带日期的标题按强负向处理:
2026-10-28、10月25日这类通常对应某场直播的录播/回放/录像/自用收藏;实测对真 reaction 标题零误伤,代价是极少数"真 reaction 恰好写日期"会被漏掉; 录播/录像计负向:完整版覆盖不到完整录播;录像覆盖直播录像。裸回放不列入,因为【回放/咻咻满reaction】之类确为 reaction;- 时长直接取搜索结果自带的
duration字段(格式是分钟:秒,超过 60 分钟写作105:5),不需要额外请求; - 关键词用单个
咻咻满(discover.pages=3覆盖 7 天窗口),因此require_title是过滤非相关结果的关键兜底; - UP 黑名单在配置文件里维护,可热加载,后续从"误通知"记录里一键加入。
3.4 通知内容
每条候选一条消息,包含:
- 封面缩略图 + 标题 + UP 名 + 发布时间 + 时长 + 当前播放/点赞
- 三个动作按钮:
- 「收藏」:加入目标收藏夹并入库(见 §7)
- 「忽略」:该 aid 标
rejected,不再提醒(同一轮同 UP 的其他候选不受影响) - 「拉黑」:把该视频的 UP 拉入
up_blacklist,同时把该 UP 的其他待决策候选一并标记为rejected;已入库/收藏夹里的该 UP 视频不受影响
- 「打开视频」链接(
https://www.bilibili.com/video/<bvid>)
私聊管理命令(仅 telegram.chat_id 本人可用):
/candidate [页码]:纯文本列出待决策候选标题,每页 10 条;/candi_del:每页 10 个标题按钮(cdel:<page>:<aid>)+ 上一页/下一页;点击删除该候选后用editMessageReplyMarkup原地重建本页键盘,可连续删;删空本页则回退上一页;/candi_clear:[全部][待决策][取消](cclr:all|pending|cancel);「全部」清整张candidate表,「待决策」只清decision IS NULL。
黑名单语义(明确边界):
- 只过滤"未来发现"——命中黑名单的 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_csv.py从 B 站粉丝历史 CSV 回填;只写up_stat,默认跳过已有成功记录,--replace可用完整历史覆盖重建,缺失日保持断开。
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);
第 9 轮新增(schema v4,已迁移)
video.pages:分 P 数(来自infos的page),只用于详情展示与包装诊断,不参与排序;video.up_follower/up_follower_at/up_follower_backfilled:视频入库时的 UP 粉丝数快照;video.up_face(第 11 轮,schema v6):UP 头像 URL,来自view的owner.face;入库与每日快照都会刷新,UP 列表/详情据此显示头像;up_profile(mid, name, follower_first, following_first, first_captured_at, follower_latest, last_refreshed):每个 UP 的首次/最近粉丝数。follower_first不可被覆盖;- 采集:收藏夹同步结束时对仍缺粉丝数的 UP 调一次
relation/stat(匿名可用);first_seen >= 本次运行开始前 1 小时的视频记为实时采集,更早的记为回填; - 回填:
python app.py backfill-followers [--limit N] [--dry-run],只补up_follower IS NULL的追踪视频; - 基于粉丝数的榜单暂不实现,本轮只保证数据从现在起不再缺失。
要点
status是核心状态机(第 10 轮更新):active在库,参与日更unfaved历史遗留状态(迁移 v5 已归一为active),不再由系统产生removed视频失效(view返回-404/62002/62012),停止请求但保留历史dropped手动"移出库",停止发现与追踪;再次被候选确认或手动入库时会恢复active
candidate与video分表:候选可能永远不入库,避免污染主表;候选first_seen超过 7 天且decision IS NULL→ 删除 candidate 行,去重由seen_aid保证,不会重复通知;seen_aid只存(aid, first_seen, source),每条约 30 字节,按 5 条/天增长,10 年也不到 100KB;up_blacklist只影响发现,不影响已入库视频;folder_id与folder_resource保留为历史数据,不再由同步任务写入(第 10 轮);- 不预存"日增量",全部查询时算(历史数据修正后不会自相矛盾)。
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 天开始。
8.5 时长效率与 UP 内容效率(第 9 轮)
Reaction 视频时长差异极大(100~336 分钟),而播放是累计计数;若不加时长分母,长视频会天然占优,短视频/切片则天然吃亏。因此新增与时长相关的口径:
内容小时 = duration / 3600 # 多 P 的 duration 已是各 P 之和
内容效率 = (view + κ_v·μ₀) / (内容小时 + κ_v) # 榜单口径(收缩后)
原始效率 = view / 内容小时 # 详情页口径
互动效率 = (互动 + κ_v·μ₀ᵢ) / (内容小时 + κ_v)
UP 内容效率 = EB 收缩后的 Σview / Σ内容小时
- 分母只用总时长,不用分 P 数:B 站播放记在 bvid 上,
view / pages没有统计含义;pages仅用于详情展示与包装诊断; - 视频级也做 EB 收缩:
κ_v默认 60 分钟(配置analytics.video_shrink_minutes),μ₀为全库播放/小时。短时长视频的暴露量小、速率估计噪声大,收缩后不再虚高;2–3 小时的长视频几乎不受影响; - 效率榜与综合热度榜只统计播放 ≥
analytics.min_view_for_ranking(默认 2000) 的视频;播放过低的视频多为小样本噪声,不上榜; - 效率榜只统计
duration >= analytics.min_duration_seconds(默认 600 秒)且发布满analytics.maturity_days(默认 2 天)的视频;不足者标注"样本不足"; - 不用无界年龄项:reaction 视频通常在发布前两天完成数据积累、两周内接近平台期,
log(age)会系统性惩罚老视频,故采用"成熟窗口筛选"而非年龄回归; duration为空或 ≤ 0(通常只在失效视频出现)不进入效率榜,不插补;- UP 效率用 Gamma–Poisson 经验贝叶斯:
rate = (Σview + κ·μ₀) / (Σ内容分钟 + κ),μ₀为全库播放/分钟,κ自适应取全库 UP 总内容分钟中位数(配置analytics.shrink_minutes可覆盖,缺省回退 120 分钟); - 视频榜默认 Tab 为内容效率,原始播放榜保留;互动单项只保留播放/投币/收藏/评论,点赞、弹幕、转发并入"互动";
- UP 榜的"视频数"与"总时长"并列展示、分别可排序,不合成单一产出分(两者高度共线,加权求和的权重是任意的)。
9. 看板
9.1 页面
| 页面 | 内容 |
|---|---|
| 首页 | 最新动态优先:标题下显示「收录视频 N」;顶部四个数据卡片为「今日入库 / 播放增量 / 7 日粉丝变化 / 数据日期」(前三项可点击跳转);下方为最近发布、增长动态、热度速览三个区块(竖排卡片,移动端每行 2 个、PC 每行 3 个,每处前 6 条);采集/候选等运行期信息只在登录后的「系统」「候选」页可见 |
| 视频 | 排序下拉:内容效率(默认)/综合热度/发布时间/入库时间/播放/投币/收藏/评论/互动率/播放增量/播放增速;时间下拉:全部/7 天/30 天/90 天/1 年/年份;搜索框约 230px;不再按收藏夹过滤 |
| UP主 | 独立入口;排序下拉:内容效率/互动效率/内容量/总播放/视频数/平均播放/总互动/最近发布;时间下拉同上;不按视频数筛选 |
| 工具条 | 顺序为「排序」(带 label)→ 时间(无 label)→ 右侧自适应搜索框(最大 320px,放大镜按钮内嵌),gap 10px,PC 与移动均一行 |
| 数据说明 | 数据来源与更新节奏、三个时间概念、指标公式、时长/成熟窗口筛选、粉丝快照覆盖与已知局限;数值由 /api/about 动态读取当前配置 |
| 视频详情 | 元数据 + 分 P 数 + 七项指标 + 内容/互动效率 + 入库时粉丝数 + 单指标趋势 + 同 UP 对比 + 快照表 |
| UP 页 | 首次/最近观看时间、全部视频(默认按发布时间排序)、聚合趋势、单视频对比、总时长、播放/小时与互动/小时、入库时与当前粉丝数 |
| 粉丝页 | 咻咻满粉丝数曲线 + 每日/7 日净增 + 累计增长(标注起始日期)+ 最近 14 天记录;支持 7/30/90/全部与自选起止日期,范围不超过一年按日绘制,超过一年按每月峰值日期绘制;只画粉丝,不叠加视频数据 |
| 候选池 | 候选、得分、通知时间、剩余 TTL;只读展示,操作指向 Telegram/CLI |
| 系统状态 | 最近任务、采集成功率、下次任务、备份、鉴权、黑名单与访问统计(PV/UV) |
9.2 技术
- 单
index.html+ 原生 ES Module,无构建链; - History API 路径路由(
/rank、/video/{aid}、/up/{mid}),服务端对无扩展名的非静态路径回退index.html; - 排序、筛选和趋势指标切换用
replaceState更新 URL 并重新取数,保留滚动位置,不整页刷新; - 首页/榜单聚合结果缓存 1 小时,粉丝结果缓存 24 小时;新快照、收藏夹同步、发现、收藏操作和粉丝导入会通过
kv版本号立即失效对应缓存; - 图表本地 vendored ECharts(Apache-2.0,离线);
/api/*JSON,前端只渲染;- 移动端优先,底部主导航 5 项(首页/视频/UP主/粉丝/说明);桌面端左侧导航;「候选」「系统」入口放顶栏右侧(未登录为「登录」),避免移动端底部拥挤;
- 首页增长榜在不足 2 天快照时显示空态,不伪造 0。
9.3 只读边界与鉴权
- P2 Web 业务接口只读,写方法统一
405;数据管理继续通过 Telegram/CLI/数据库; - 首页、榜单、视频/UP、粉丝页面和接口对访客开放;
- 配置
web.auth_token后,“候选”和“系统”页面及其接口需要登录,启用登录页 + HttpOnly Cookie,或使用Authorization: Bearer <token>; - 未登录访问
/candidates//system会跳转/login?next=<原路径>; - 本地回环地址且未配置
web.auth_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) / snapshot(每日 00:01) / candidate_cleanup(每日)
web 线程:ThreadingHTTPServer :8080
telegram:getUpdates long polling(回调 + 私聊文本入库)
storage:SQLite WAL,单写线程
11. 风险清单(更新)
| 风险 | 影响 | 概率 | 缓解 |
|---|---|---|---|
| 搜索排序抖动 + 只取 2 页 | 漏抓个别新视频 | 中 | 每小时扫一次 + 候选池 + 私聊手动入库兜底;业务上可接受 |
| 搜索结果噪声 | 通知太吵 | 中 | 轻量规则降噪 + 人点确认;不达阈值进候选池 |
| 收藏夹与库不一致 | 库内视频不在收藏夹 | 低 | 收藏夹已解耦;人工维护收藏夹,favsort.py 只做重排序 |
| 收藏夹 1000 条上限 | 重排序目标夹过满 | 低 | 由人工新建收藏夹;favsort.py 仅排序,不自动加条 |
| 黑名单误伤 | 漏掉正常 reaction | 低 | 黑名单可随时移除;被排除的 UP 不写候选表,但可从日志审计 |
infos 静默丢条 |
重排序计划缺条 | 低 | favsort.py 用 请求 ids - 返回 ids 判断缺失 |
infos.reply 恒 0 / 缺 like/coin/share |
统计不全 | 确定 | 日更以 view 为唯一事实来源,infos 仅用于重排序 |
b23.tv 短链失效或改版 |
手动入库解析失败 | 低 | 解析失败会在 Telegram 回执提示;可直接改发 BV 号 |
| GET 链接被预览预取 | 无(已无写入型 GET) | — | 写操作走 Telegram 消息/回调,Web 全只读 |
sort 写失败 |
收藏夹顺序不一致 | 中 | favsort.py 回读校验、失败冷却重试、可 run 续跑 |
| 视频失效 | 日更报错 | 高 | view 返回 -404 / 62002 / 62012 → removed,不再请求 |
| 接口下线 | 功能中断 | 中 | 接口层抽象 + 降级(旧版 search);监控告警 |
风控 -352/-412 |
采集中断 | 低(实测未见) | 限速 + 抖动 + 指数退避 + 告警 |
| SQLite 并发 | 写冲突 | 低 | WAL + 单写线程 |
| cookie 过期 | 只读采集失败 | 中 | `python app.py auth --account read |
12. 待确认
无。 第 10 轮口径已确认:入库只写本地库;收藏夹解耦、不再追踪;手动入库支持视频链接 / BV 号 / av 号 / b23.tv 短链;bilibili.write 仅用于 favsort.py 重排序。
进入运行前只需要你提供运行期凭据(不建议写进本文档):
- Telegram
bot_token+ 私聊chat_id - B 站 cookie(
read必需;write仅重排序用;也可先用auth扫码生成) - 收藏夹
media_id(favsort.py重排序目标;已有 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 | 只读页面 | 首页/视频/UP主/视频详情/UP 详情/粉丝/数据说明/候选/系统页面可访问;390px 与 1280px 下无脚本错误 |
| 36 | P2 只读边界 | 业务 POST/PUT/PATCH/DELETE 均返回 405,请求前后数据库行数不变 |
| 37 | 粉丝历史回填(CSV) | 从 B 站粉丝历史 CSV 导入 2020-06-03~2026-10-31 共 1788 条,替换旧 91 条;155 个缺失日保持断开 |
| 38 | 迁移 v4 与时长效率榜 | 预览库升级到 schema 4;/api/videos?sort=efficiency、/api/ups?sort=efficiency 正确排序,min_duration_seconds=600 与 maturity_days=2 过滤生效,duration 缺失不参与 |
| 39 | 粉丝快照回填(真实性) | 预览库 488 个 UP、796/796 视频补齐 up_follower,0 失败;历史视频标记 backfilled=1 |
| 40 | 导航拆分与数据说明页 | /videos、/ups、/about 均返回 200;/api/about 返回 schema 4、当前阈值、权重与粉丝覆盖度 |
| 41 | 全量离线测试 | 32 个 tests/test_*.py 全部通过(含新增 test_up_profile.py 与迁移/数据库断言更新) |
| 42 | 内容效率噪声修正 | 预览库实测:视频级 EB 收缩(κ=60 分钟)+ 播放 ≥ 2000 后,效率榜 Top10 全为 ≥110 分钟视频;「什么哦吼吼」4 条不再入榜;综合热度榜同样应用 2000 门槛(637 条) |
| 43 | 榜单交互改版 | 排序/时间改为下拉;时间筛选实测 year=2026 640 条、year=2025 156 条(合计 796);sort=published 可用;/api/overview 返回 years [2026,2025];year=1800 返回 400 |
| 44 | 第 10 轮全量离线测试 | 32 个 tests/test_*.py 全部通过;新增 test_video_ref.py、test_library.py,重写 test_telegram.py,删除收藏夹同步相关测试 |
| 45 | 迁移 v5 | 历史 video.status='unfaved' 归一为 active;app-pre-v4-* 自动备份;folder / folder_resource 保留 |
未验证(需授权或外部配合):
- 真实 Telegram 私聊文本入库与按钮回调(长轮询与解析逻辑已离线测试);
- 真实
b23.tv短链跳转(客户端跟随重定向逻辑已离线测试); - 手机扫码
auth完整链路; - 长期(>72h)每小时扫描的风控表现;
favsort.py对真实收藏夹的重排序(写操作,需用户确认计划后手动执行)。
本轮有意不做:基于粉丝数的"破圈"榜单(只采集与回填数据,见 §14)。
14. 下一步
P0、P1、P2、P3、第 9 轮与第 10 轮已实施;P2 交付了只读本地看板与可重复生成的 7 天预览数据。
第 10 轮(2026-11-01)交付:
- 收藏夹解耦:删除
folder_sync、FavoriteActionService、app.py sync与调度同步任务;folder/folder_resource/folder_id仅保留历史; - 迁移 v5:历史
unfaved归一为active; - 入库:候选确认与 Telegram 私聊手动入库统一走
services/library.py,只写本地库并补当天快照; - 手动入库解析:
video_ref.extract_video_refs(BV/av/URL/b23.tv)+bilibili.client.view?bvid=/resolve_short_link; - Telegram:
allowed_updates增加message;按钮改为「入库 / 忽略 / 拉黑」(add:,兼容fav:); - 写鉴权收口:删除
write_client.deal,bilibili.write仅favsort.py重排序使用; - 配置移除
schedule.folder_sync_minutes/full_folder_refresh_hours; - 看板文案改为「历史收藏夹」,候选页改为「入库」。
仍待处理:
- 真实 Telegram 文本入库 / 按钮回调 /
b23.tv跳转的补充验证记录; - 基于粉丝数的"破圈"维度(播放/入库时粉丝数、粉丝分段、UP 中位数)——数据已就绪,榜单未实现;
- P4(容器化 + 反代)继续保持可选。
第 12 轮(2026-11-01)交付:基础访问统计(PV/UV):
- 迁移 v7 新增
visit(stat_date, ip, path, hits, first_at, last_at)聚合表; - 前端
sendBeacon埋点POST /api/visit;服务端内存缓冲、每分钟批量落库,关闭时 flush; - 来源 IP 兼容「域名直连公网 EIP / Cloudflare / EdgeOne」:nginx 用 realip 按 CDN 网段归一化到
X-Real-IP,应用仅在 socket 对端可信时采信(见deploy/nginx/); /system系统状态页展示近 30 天 PV/UV 与 Top 页面;Web 只读边界放宽为显式 POST 白名单;- 纯函数
client_ip.py与内存缓冲web/visits.py均有离线单元测试。
本地数据:第 9 轮起本地只保留可再生的预览库
data/preview/app.db(tools/seed_preview_db.py生成), 原data/app.db真实副本已删除;需要重新生成或恢复时,用--source data/backup/app-local-before-reseed-20261101.db。