# 咻咻满 Reaction 视频数据看板 —— 可行性分析与技术路线 > 状态:**设计定稿;P0(数据底座与收藏夹同步)、P1(每日快照与粉丝追踪)已实施** > 最后更新:2026-10-31(第 6 轮:P1 落地) > 全部探测均为只读(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]`),但看板默认只突出咻咻满; - 粉丝日增 = 今日 - 昨日,缺失日不插值,曲线断开。 --- ## 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); ``` **要点** - `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 增长榜 ``` 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 页面 | 页面 | 内容 | |---|---| | 总览 | 库内视频数、活跃 UP 数、今日新增、今日总播放/增量、**收藏夹容量条**(664/1000)、昨日采集成功率 | | 榜单 | Tab:综合热度/播放/点赞/硬币/收藏/转发/评论/弹幕/增长最快/互动率;筛选 UP、搜标题、导出 CSV | | 视频详情 | 元数据 + 7 项趋势 + 同 UP 对比 + 快照表(含失败标记)+ 「加入收藏夹」按钮 | | UP 页 | 该 UP 全部视频、聚合趋势、平均互动率 | | **粉丝页** | 咻咻满粉丝数曲线 + 每日/7 日净增 + 累计增长(只画粉丝,不叠加视频数据) | | **候选池** | 未达通知阈值 / 未决策的候选(7 天后自动删除),支持收藏/忽略/拉黑 | | **系统状态** | 最近扫描、采集成功率、收藏夹同步时间、下次任务时间、错误日志 | ### 9.2 技术 - 单 `index.html` + 原生 JS,无构建链; - 图表 **vendored ECharts**(Apache-2.0,离线); - `/api/*` JSON,前端只渲染; - 移动端优先(通知点进来多为手机)。 ### 9.3 鉴权 - 公网部署,默认开启:`Authorization: Bearer ` 或一次性登录换 HttpOnly cookie; - 写操作(加入/忽略)同样校验,CSRF 用签名 token; - 不做多用户(YAGNI)。 --- ## 10. 部署 ```dockerfile FROM python:3.14-slim WORKDIR /app COPY . /app ENV TZ=Asia/Shanghai PYTHONUNBUFFERED=1 VOLUME ["/data"] EXPOSE 8080 HEALTHCHECK CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8080/api/health')" CMD ["python", "app.py"] ``` - 单容器跑「调度 + 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 与增量同步** | 真实库升级到 schema 2,自动生成 `app-pre-v1-*.db`;同步 167+664 条无新增/移除,主归属无矛盾 | **未验证(需授权或外部配合)**: - `fav/resource/deal` 实际加入 + `sort` 定位(会写收藏夹); - 手机扫码 `auth` 完整链路; - 各通知渠道的按钮/链接实际表现; - 长期(>72h)每小时扫描的风控表现; - 收藏夹真实上限是否 1000。 --- ## 14. 下一步 所有决策点已确认(§12 无遗留)。下一步是**方案定稿**,产出: 1. 最终 DDL + 迁移脚本设计(含 `up_stat`、`up_blacklist`); 2. 四个任务的调度/限速/重试/幂等伪代码(发现 / 收藏夹同步 / 快照 / 粉丝); 3. 分类规则 v1(含时长权重、UP 黑名单)+ 用 831 条收藏回测方案; 4. 热度算法参数表与榜单口径; 5. 前端线框 + API 契约(含粉丝页、候选池页); 6. 一键加入时序图(Telegram callback 与 签名链接 两条入口); 7. 分阶段实施计划: - P0 收藏夹同步 + 导入 + 黑名单种子(先把库建起来) - P1 每日快照 + 粉丝追踪(开始积累数据) - P2 看板(榜单/详情/粉丝页) - P3 Telegram 通知 + 一键加入 - P4 容器化 + 反代 + 备份 > P0 已实施;P1 起继续按上述阶段推进。