豆备 DOUBAK
← 开发日志

游戏评分成了访问者的:条目边界、缺席权威,与抓取中途的覆盖率

在公开或未登录归档中,游戏列表把访问者的「修改」按钮错当成了主人的评分;公开观测中缺失标签绝不能草率判定为用户删除了标签;而抓取中途的覆盖率面板,更不应因当前任务尚未收尾就扣下所有历史账号的成绩单。

随着多账号支持、主页输入以及未登录抢救机制在主线铺开,越来越多的真实边界被激荡了出来。

在纯粹的「单账号、本人登录态」时代,许多潜在的代码暗病被掩盖在「操作者恰好等于数据主人」的假象下。 一旦跨入「公开归档」与「并发状态共存」的真实场景,我们遭遇了三个极富启发性的技术隐患:

  1. 游戏标记的「李代桃僵」:为什么公开归档时,目标游戏评分竟然被读成了访问者的?
  2. 数据契约的严谨性:当公开页面缺失标签等字段时,为什么绝不能授予它「缺席权威」(Absence Authority)?
  3. 抓取中途看覆盖率:当前任务正在抓取尚未收尾,怎么能把所有已归档账号的成绩单全都扣下?

案发一:游戏标记的「李代桃僵」与结构解析陷阱

豆瓣各个媒介(书影音、游戏、舞台剧)的前端结构并非一套统一的标准体系,而是十余年历史地质层堆叠的结果。 书影音在列表页的星级评分,统一带有语义清晰的类名(如 class="rating4-t"); 但游戏列表(douban.com/game/...)却沿用了一套完全独立的遗留结构:

  • 条目主人的星级写在:<div class="rating-info"><span class="rating-star allstar40"></span></div>;
  • 条目主人的短评在一个没有任何 class 的裸 <div> 中;
  • 而在登录用户访问时,条目底部还会多出一行属于当前访问者的操作栏:<div class="user-operation"><a data-rating="4">修改</a></div>。

早先解析器(extract.js)在提取游戏标记时,走了一条自以为聪明的捷径: 它直接去读 data-rating="(\d)",并用正则把「user-operation 前面那个 div」判定为短评。

当用户登录自己的账号备份自己时: 操作者就是主人,user-operation 里显示的就是自己的评分,解析结果碰巧完全正确,隐患被天衣无缝地掩盖了。

但当开启未登录归档或公开抓取时,灾难降临了:
1. 如果是纯匿名公开页面,页面根本没有 user-operation 这个操作栏!解析器找不到 data-rating,导致全部游戏评分直接报废清零;
2. 如果用户在浏览器里登录着小号,user-operation 展现的是小号访问者对该游戏的操作控件!解析器不仅没读到目标账号的真实评分,反而把小号访问者的打分甚至单字短评塞进了目标档案中——发生了荒诞的「李代桃僵」;
3. 末项越界:如果最后一项原本没有短评,正则一路往后贪婪扫描,甚至能把页面底部的访问者表单、推荐栏强行当成最后一项的内容补上。

在刚刚发布的 doubak-data-parser/0.15.1 中,我们彻底重构了游戏标记的抽取基石:

  • 正文星级才是真身:严格从 class="rating-info" 下的 allstar([1-5])0 提取目标主人的真实评分;
  • 裸 div 短评脱钩:直接提取 common-item 内唯一的独立正文 <div>,彻底废除对 user-operation 操作栏的依赖;
  • 条目闭合边界守卫(gameItem):引入基于 <div> 嵌套深度的闭合截断,确保提取逻辑严格锁死在当前 common-item 内,绝不向后贪婪借取,不完整的页面宁可不解释。

案发二:公开观测的「缺席权威」(Absence Authority)

解决了解析器的字段提取之后,一个更深层次的数据哲学问题浮出了水面: 在公开页面上「没有看到 tags 标签」,到底意味着「用户没打标签」,还是「豆瓣没展示标签」?

答案显而易见:豆瓣出于反爬与界面简洁考量,在外部公开列表页上直接裁剪掉了用户的个人私密标签(tags)与备忘信息。

在规范体系(doubak-data-specs #5)中,我们必须界定这种「视界缺失」的权威性:

观测类型适用场景缺席权威(Absence Authority)合并(Merge)策略
本人登录态观测 用户登录目标账号本人抓取 full(完全权威) 页面无 tags,确证为用户已删除或从未添加 tags,下游覆盖同步清空。
公开/未登录观测 抢救被封账号、归档他人主页 none(无缺席权威) 页面无 tags,视为「不可观测」,绝不能覆盖抹除历史上由登录态记录下的私密标签。

如果合并引擎(mergeRecords)不加区分,一次被封账号的公开紧急抢救,就会成为一块残酷的橡皮擦, 把用户多年来在登录态下沉淀的私密标签全部洗劫一空。 明确了 absence_authority: 'none' 后,公开归档即便在不完整的视野下推进,也能确保既有高权限档案的数据安全。

案发三:抓取中途看覆盖率:不能因当前未收尾,就扣押全部账号的成绩单

在扩展层面,随着大家开始尝试备份多个不同账号,有用户提出了一个看似微小却令人窒息的体验问题:

「后台开着一个账号的抓取,我随手切到『覆盖率』Tab 想看看之前备份完的其他账号的覆盖率。 结果页面直接显示『备份来源待确认 / 暂无已收尾的档案』,而且账号下拉框直接不见了! 连我早就已经备份好的账号的覆盖率也全看不了了?这符合设计吗?」

这当然不符合设计,而是一个典型的实现层短路:

面板在加载覆盖率时,默认读取 OPFS 磁盘上按时间戳排序的最新目录。 而当前正在进行的抓取任务,恰好就是磁盘上最新的目录! 但这个任务正在热跑中,只存在实时增量的 checkpoint.json 与 WARC 数据块, 根本还没有经过最终的双向对账,没有生成 manifest.json (覆盖率是收尾阶段生成的权威连续性证据)。

旧的覆盖率前端代码一旦发现最新的目录 hasManifest: false,便仓促判定「整台机器没有任何有效档案」, 隐藏了账号切换下拉框,直接给用户贴上了「暂无已收尾档案」的冷板凳。

在刚刚合并入主线的 PR #24 中,我们将覆盖率展现层做了彻底解耦:

  1. 收尾驱动的选择器:账号与来源选择框只由已收尾且具备 manifest 的有效档案驱动(items.filter(e => e.manifest)),未收尾的进行中目录不参与扰动选择项;
  2. 随时自由审查历史:无论当前后台是否正在热火朝天地抓取,用户随时可以切换到任何历史账号,完整查阅其覆盖率与归档链;
  3. 进行中状态友好告知:如果用户选中的恰好是当前正在抓取的账号,面板给出明确提示: 「本次抓取正在进行中……覆盖率基于上一份已收尾档案计算」,若全盘仅有这一份未收尾档案,则提示「本次抓取尚未收尾——覆盖率证据在收尾时生成,目前尚不可用」。

从解析器的字符边界,到规范层的观测权威,再到面板上的多任务状态共存—— 每一次深水区的交汇,都在让豆备离真正的稳健与可靠更近一步。

← 更新:当好友注销了账号:从「页面不存在」到 WARC 里复活的高中同桌 更早:脚本里的验证码不是风控:假阳性、降速与恢复原速 →