搜索框能搜到「时间戳」「子网」「大小写」这些词,靠的不是页面上的可见文字,而是 search-index.json 里每条记录的 kw 字段。这个字段是「把工具说明的第一句、常见别名、相关中文词都塞进去」的产物,一条记录最多 240 字符。
这些别名是手写补充的:直接叫「base64」的人多,但真正想找它的人可能输入「编码」「发邮件粘贴」「图片转字符」。所以索引构建脚本里有一张别名表,约两百个条目。
事故现场
收口脚本 finalize_site.py 里原本的顺序是:
refresh_stats.py # 重算体积与数量
seo_round.py # 重建 search-index.json 与 sitemap.xml
enrich_search.py # 给索引补关键词
某次我调整了顺序,想「先生成索引再算体积」,把 seo_round.py 挪到了最后。于是 enrich_search.py 先跑完,写好了关键词,紧接着 seo_round.py 把整个 search-index.json 重新生成了一遍 —— 干净、结构正确、没有关键词。
体积从 73885 字节掉到 42110 字节,索引条数还是 220 条。所有页面都正常,HTML 校验、链接校验、死链检查、JSON 解析全部通过。因为没有一个校验在问「关键词还在不在」。
怎么发现的
是在手动试搜索时发现的:搜「图片压缩」应该命中工具站的压缩工具,结果只命中了页面标题里恰好含「压缩」的几篇文档。搜「存了什么」应该命中新的数据台账,什么都没出来。
人肉发现是运气。所以我给收口脚本加了两条断言,让这件事变成机械可查:
# 关键词必须存在且足够长
longest = max(len(i.get("kw", "")) for i in items)
assert longest >= 120, "关键词疑似丢失,最长只有 %d" % longest
# 抽样:这些词必须能命中
for q in ("时间戳", "子网", "图片压缩", "存了什么"):
assert any(q in i.get("kw", "") for i in items), "索引缺关键词:%s" % q
同一类错误的另一个变体
这不是第一次:第三轮曾经有过 page() 函数既写盘又返回路径,调用方又拿返回值当 HTML 写了一次,把 45KB 的目录页覆盖成 47 字节的路径字符串。那次也是所有校验通过 —— 直到有人真的点开页面。
两次的共性:生成器彼此之间有隐含的顺序依赖,而顺序没有写在任何地方,也没有被断言保护。
现在的规矩
- 生成器只做一件事,且幂等(重复跑结果一致);
- 写盘之后立刻回读断言(长度、关键串),不相信返回值;
- 有顺序依赖的调用集中在
finalize_site.py一处,顺序写死并注释原因; - 「数据完整性」单独断言:条数、最长字段、抽样命中。
这类错误的成本不对称:发现要碰运气,修复只要一分钟。所以值得花的力气不在修,而在把「运气」换成断言。