# 咻咻满 Reaction 视频数据看板 —— 可行性分析与技术路线 > 状态:**设计定稿;P0(数据底座与收藏夹同步)、P1(每日快照与粉丝追踪)、P2(只读本地看板)、P3(Telegram 与一键加入)已实施;第 9 轮加入时长效率榜、UP 粉丝快照与导航拆分** > 最后更新:2026-11-01(第 9 轮:时长效率榜、粉丝快照采集、视频/UP 导航拆分与数据说明页) > 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. 搜索频率 1 次/小时,**只取前 2 页,允许遗漏**——搜索是"代替人工发现 + 即时提醒",人工加收藏兜底; 2. 搜索结果的**分类靠人工点击确认**(通知即确认),不追求自动判定; 3. **不建 UP 白名单**(reaction UP 多为一次性投稿,收益低),改为**可配置的 UP 黑名单**(专发直播回放/切片的号直接排除,**仅作用于发现,不影响库内已有视频**); 4. 收藏夹**按年分夹**,但**新建夹由人工完成**,系统只读配置;新视频优先入"同 UP 最早视频所在夹"; 5. **通知渠道先做 Telegram**(支持按钮;开发阶段用 long polling,不需要公网 URL); 6. 收藏夹是"新增视频来源"的兜底,**日更对象是库内已入库视频**,与年度夹解耦; 7. 用户取消收藏的视频**继续日更**,但允许打标记"移出库"后停止发现与追踪; 8. 新增追踪指标:**咻咻满粉丝数**(只画粉丝曲线,不叠加视频数据); 9. 所有凭据(Telegram token/chat_id、收藏夹 id、cookie)**统一放配置文件**; 10. 通知提供**收藏 / 忽略 / 拉黑**三个动作,拉黑只作用于"未来发现"。 **三个仍需注意的硬约束**: - `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` 加进配置。 **入哪个夹的判定(按优先级)**: 1. 该 UP 在库中已有视频 → **入"同 UP 最早视频所在夹"**。 例:某 UP 在 2026-12-25 发过视频、在 2026 夹,那么他 2027-01-02 的新视频**仍入 2026 夹**。 2. 该 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` 排除): ```jsonc { "bilibili": { "read": { "cookie": "SESSDATA=...; bili_jct=...; buvid3=...; ...", "csrf": "", "mid": 28802351 }, "write": { "cookie": "SESSDATA=...; bili_jct=...; buvid3=...; ...", "csrf": "", "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~~ | `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 条分块更稳)。 **因此日更拆成两段**: 1. **元数据 + 3 项指标**(play/favorite/danmaku):`infos` 批量,2 次请求拿全库 → 秒级; 2. **其余 4 项**(like/coin/share/reply):`view` 逐条,831 条约 17 分钟。 > 注意:第一段不能只跑一次就当作日更,因为两张表口径必须一致;实际做法是**以 `view` 为唯一事实来源**,`infos` 只用于收藏夹同步/导入/校验。若某天 `view` 大面积失败,可用 `infos` 的三项先兜底并标记"部分指标缺失"。 ### 2.2 `infos` 的两个坑(实测) 1. **静默丢条**:请求 664 条只返回 648 条,缺失的 16 条是"账号已注销/视频已删"。做差集时**不能用返回条数判断成功**,要 `请求 ids - 返回 ids` 得到缺失集合。 2. **失效标记**:`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 | 第 1~2 页覆盖 20~30 天,而扫描间隔只有 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/`) **黑名单语义(明确边界)**: 1. 只过滤"未来发现"——命中黑名单的 UP 新视频不进候选池、不发通知; 2. **不做回溯清理**:库内已有的、收藏夹里已有的该 UP 视频照常日更、照常上榜; 3. **不从收藏夹反推黑名单**:人工加进收藏夹的行为不会修改黑名单; 4. 可在看板里移除黑名单条目(误拉黑可恢复)。 > 同一轮多条候选**合并成一条消息**(避免刷屏),单条消息最多 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:', 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. 数据模型(定稿草案) ```sql -- 视频主表 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 粉丝数快照; - `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` 是核心状态机: - `pending` 搜索候选,等确认(只在 `candidate` 表,不进 `video`) - `active` 在库,参与日更 - `unfaved` 用户取消收藏,**仍继续日更**,看板可筛 - `removed` 视频失效(`-404` / `attr=9` / `infos` 缺失),停止请求但保留历史 - `dropped` 用户手动"移出库",**停止发现与追踪**;若之后又出现在收藏夹,同步任务会自动改回 `active` - `rejected` 用户点过"忽略",不再通知 - `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 用 `getUpdates` long polling 收 `callback_query`,本机没有公网 IP 也能联调; - `bot_token` / `chat_id` 从 `config.json` 读(见 §1.5),不写死在代码里; - **按私聊设计**:`chat_id` 是用户 id(正数),Bot 只服务你一个人;回调 `callback_query` 直接用 `answerCallbackQuery` 回执; - 上线后服务有域名 + TLS,再补"签名链接"路径给企业微信/邮件/Webhook; - 两种路径共用同一个 `fav_action` 状态机,只是入口不同(`callback_data` vs `POST /a//confirm`)。 ### 7.2 链接与防误触 ``` 通知链接: https:///a/ token = base64url(aid.folder.exp.nonce) + "." + HMAC-SHA256(secret, payload) ``` - `GET /a/`:**只读**,校验签名/过期/未使用 → 渲染确认页(封面、标题、UP、当前数据、加入后位置预览)→ 页面按钮 `POST /a//confirm`; - `POST`:再次校验 → 调 `deal` → 调 `sort` 定位 → 写 `fav_action` → upsert `video(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=":2::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 / 内容小时 互动效率 = (like + coin + favorite) / 内容小时 UP 内容效率 = EB 收缩后的 Σview / Σ内容小时 ``` - 分母只用总时长,**不用分 P 数**:B 站播放记在 bvid 上,`view / pages` 没有统计含义;`pages` 仅用于详情展示与包装诊断; - 效率榜只统计 `duration >= analytics.min_duration_seconds`(默认 600 秒)且发布满 `analytics.maturity_days`(默认 14 天)的视频;不足者标注"样本不足"; - **不用无界年龄项**:reaction 视频约两周进入平台期,`log(age)` 会系统性惩罚老视频,故采用"成熟窗口筛选"而非年龄回归; - `duration` 为空或 ≤ 0(通常只在失效视频出现)不进入效率榜,不插补; - UP 效率用 Gamma–Poisson 经验贝叶斯:`rate = (Σview + κ·μ₀) / (Σ内容分钟 + κ)`,`μ₀` 为全库播放/分钟,`κ` 自适应取全库 UP 总内容分钟中位数(配置 `analytics.shrink_minutes` 可覆盖,缺省回退 120 分钟); - 视频榜默认 Tab 为内容效率,原始播放榜保留;互动单项只保留播放/投币/收藏/评论,点赞、弹幕、转发并入"互动"; - **UP 榜的"视频数"与"总时长"并列展示、分别可排序,不合成单一产出分**(两者高度共线,加权求和的权重是任意的)。 --- ## 9. 看板 ### 9.1 页面 | 页面 | 内容 | |---|---| | 首页 | 最新动态优先:今日增量、最新入库、最近发布、增长动态、热度速览、收藏夹容量、采集状态 | | **视频** | Tab:内容效率/综合热度/播放/投币/收藏/评论/互动率/播放增量/播放增速;筛选与搜索 | | **UP主** | 独立入口;列:视频数、总时长、总播放、播放·小时、互动·小时等,可分别排序 | | 数据说明 | 数据来源与更新节奏、三个时间概念、指标公式、时长/成熟窗口筛选、粉丝快照覆盖与已知局限;数值由 `/api/about` 动态读取当前配置 | | 视频详情 | 元数据 + 分 P 数 + 七项指标 + 内容/互动效率 + 入库时粉丝数 + 单指标趋势 + 同 UP 对比 + 快照表 | | UP 页 | 该 UP 全部视频、聚合趋势、单视频对比、总时长/播放·小时、入库时与当前粉丝数 | | **粉丝页** | 咻咻满粉丝数曲线 + 每日/7 日净增 + 累计增长(标注起始日期)+ 最近 14 天记录;支持 7/30/90/全部与自选起止日期,范围不超过一年按日绘制,超过一年按每月峰值日期绘制;只画粉丝,不叠加视频数据 | | **候选池** | 候选、得分、通知时间、剩余 TTL;只读展示,操作指向 Telegram/CLI | | **系统状态** | 最近任务、采集成功率、收藏夹同步时间、下次任务、备份、鉴权、黑名单 | ### 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 项导航;桌面端左侧导航; - 首页增长榜在不足 2 天快照时显示空态,不伪造 0。 ### 9.3 只读边界与鉴权 - P2 Web 业务接口只读,写方法统一 `405`;数据管理继续通过 Telegram/CLI/数据库; - 首页、榜单、视频/UP、粉丝页面和接口对访客开放; - 配置 `web.auth_token` 后,“候选”和“系统”页面及其接口需要登录,启用登录页 + HttpOnly Cookie,或使用 `Authorization: Bearer `; - 未登录访问 `/candidates` / `/system` 会跳转 `/login?next=<原路径>`; - 本地回环地址且未配置 `web.auth_token` 时视为无鉴权,全部页面可见; - 非回环地址且未配置 token 时拒绝启动; - 不做多用户(YAGNI)。 --- ## 10. 部署 仓库已提供: ```text 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|write` 扫码续期;`view`/搜索匿名可用作降级 | --- ## 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 | **粉丝历史回填(CSV)** | 从 B 站粉丝历史 CSV 导入 2020-06-03~2026-10-31 共 1788 条,替换旧 91 条;155 个缺失日保持断开 | **未验证(需授权或外部配合)**: - `fav/resource/deal` 实际加入 + `sort` 定位(会写收藏夹;需用户点击 Telegram 按钮或授权测试视频); - Telegram 按钮真实回调(长轮询代码与授权逻辑已离线测试); - 手机扫码 `auth` 完整链路; - 各通知渠道的按钮/链接实际表现; - 长期(>72h)每小时扫描的风控表现; - 收藏夹真实上限是否 1000。 --- ## 14. 下一步 P0、P1、P2、P3 与第 9 轮已实施;P2 交付了只读本地看板与可重复生成的 7 天预览数据。 第 9 轮(2026-11-01)交付: 1. schema v4:`video.pages`、`video.up_follower*`、`up_profile`; 2. 入库时采集 UP 粉丝快照 + `python app.py backfill-followers` 历史回填(基于粉丝数的榜单暂不做); 3. 时长效率榜(内容效率/互动效率)与 UP 内容效率(EB 收缩),过滤 < 600 秒与未满 14 天的视频; 4. 主导航拆分:「榜单」→「视频」+「UP主」,两者独立页面;新增「数据说明」页(`/about` + `/api/about`); 5. 列表精简:卡片只保留主指标 + 互动率,七项明细与分 P、粉丝快照移到详情页。 仍待处理: 1. 真实 `deal` / `sort` 与 Telegram 按钮回调的补充验证记录; 2. 基于粉丝数的"破圈"维度(播放/入库时粉丝数、粉丝分段、UP 中位数)——数据已就绪,榜单未实现; 3. P4(容器化 + 反代)继续保持可选。 > 本地数据:第 9 轮起本地只保留可再生的预览库 `data/preview/app.db`(`tools/seed_preview_db.py` 生成), > 原 `data/app.db` 真实副本已删除;需要重新生成或恢复时,用 `--source data/backup/app-local-before-reseed-20261101.db`。