写一个零依赖的 Markdown 失效链接检查器:锚点、代码块和 HEAD 请求的三个细节
摘要:文档越写越多,文件改名、标题改字、外链过期都会让链接悄悄失效。我写了一个只用 Python 标准库的小工具 mdlinkcheck 来扫这些问题。本文复盘实现中三个容易踩坑的细节:标题锚点怎么算、怎么避免把代码块里的“链接”当真、在线检查为什么不能只发 HEAD。文中的输出都是我在本机跑代码得到的,没有接入任何云服务。
一、背景
写技术文档时最常见的小事故有三类:
- 文件改名了,旁边文档里的相对链接没跟着改;
- 标题改了几个字,指向它的
#锚点就失效了; - 外链过几个月 404。
这些问题肉眼很难发现,放进 CI 里自动查最合适。mdlinkcheck 的目标很克制:纯标准库、几百行代码、本地和 CI 都能用,退出码 0 表示没问题、1 表示有失效链接、2 表示参数错误。代码分成解析(parser.py)、检查(checker.py)、在线请求(online.py)三层。
二、细节一:锚点要自己“复刻”渲染规则
检查 [配置](guide.md#配置) 时,要知道目标文件里有哪些锚点,而锚点是渲染器根据标题生成的。我按 GitHub 的规则实现 slugify:转小写,空格变连字符,保留各语言的字母、数字和下划线,其他标点和符号丢弃。实际跑出来的结果是:
| 标题 | 锚点 |
|---|---|
快速开始 |
快速开始 |
Hello, World! |
hello-world |
API & SDK 说明 |
api--sdk-说明 |
🚀 Features |
-features |
第三行很容易让人意外:& 被丢弃后,两侧的空格各变成一个连字符,所以出现了双连字符。第四行同理,emoji 被丢掉,前面的空格留下了一个开头的连字符。如果工具把这些“修正”得好看一点,反而会和真实渲染结果对不上。
重复标题也要处理:同名标题第二个起要加 -1、-2 后缀。我用一个计数字典实现,对三个都叫“重复”的标题,得到的锚点集合是 重复、重复-1、重复-2。另外,HTML 里手写的 <a id="x"> 或 name="x" 也要算作锚点,所以解析时对每一行都用正则额外扫一遍。
需要说明的是,这是我按规则做的“够用”实现,不是完整的 CommonMark 解析器,一些极端写法没有覆盖。
三、细节二:先把代码块“抹掉”,再找链接
文档里经常有示例:
```
[x](no.md)
```
示例里的链接不是真链接,检查它只会产生误报。我的做法是在提取链接之前,先把围栏代码块(``` 或 ~~~)和行内代码替换成空行或空格,并且保持行数不变,这样后面报出来的行号仍然对得上原文件。
我用一段混合文本验证:三个重复标题、一个代码块里的 [x](no.md)、一段行内代码里的 [y](no2.md)、一个正常链接 [ok](a.md),以及一个没有定义的引用 [引用][nope]。解析结果是:只提取到 a.md(第 8 行),未定义的引用 nope 被单独报出(第 9 行),代码里的两个“链接”都被忽略了。
有个小技巧值得记下:围栏的关闭条件要同时满足“字符相同”和“长度不小于开头”,否则用四个反引号包住三个反引号示例的写法就会提前结束。
四、细节三:在线检查不能只发 HEAD
外链检查最直觉的写法是发 HEAD,省流量。但现实里有两种相反的问题:有的服务器不支持 HEAD(返回 405 等),有的服务器 HEAD 和 GET 行为不一致。
我的策略是:先 HEAD;如果出错或返回 400、403、404、405、429、5xx 这类“HEAD 上常被错误返回”的状态码,就回退成 GET 再确认;遇到网络错误、429、5xx 时按指数退避重试,默认重试 2 次。
为了验证,我在本机起了一个小 HTTP 服务模拟五种情况,实测请求序列如下:
| 路径 | 模拟的行为 | 实际请求 | 判定 |
|---|---|---|---|
/ok |
一切正常 | HEAD | 通过(200) |
/nohead |
HEAD 返回 405,GET 正常 | HEAD、GET | 通过(200) |
/flaky |
前两次 GET 返回 503,之后正常 | HEAD、GET、HEAD、GET | 重试后通过 |
/gone |
一直 404 | HEAD、GET | 失败(404) |
/headlies |
HEAD 返回 200,GET 返回 404 | HEAD | 通过(200) |
最后一行是这个策略的已知盲区:如果服务器的 HEAD 谎报 200,工具只发了 HEAD 就信了,漏掉了真实的 404。这种服务器不多,但确实存在。要堵这个洞只能每个链接都发 GET,代价是流量和耗时翻倍,我选择了默认不堵,把它写进了局限里。
五、可复用的结论
- 检查锚点就要复刻渲染规则,别“美化”。把标点、emoji、重复标题的边界用例写进测试。
- 解析前先抹掉代码,并保持行号,误报会少一大截,报告也更好定位。
- 外链检查是“概率性”的:HEAD 优先、GET 兜底、退避重试,仍然会有漏判,要在文档里写明。
- 用本地小服务模拟各种异常响应,比直接去打真实网站稳定得多,也不会给别人的服务器添麻烦。
六、局限与下一步
目前它只检查链接本身,不检查链接内容是否相关;相对路径的大小写在不同系统上行为也不同,这一点我还没系统测试。下一步打算补上更多真实仓库的文档做回归,看看误报率到底如何。如果你也在维护 AI Agent 项目的文档,不妨把这类检查放进 CI,毕竟用户第一次接触项目,往往就是从点开一个链接开始的。
- 点赞
- 收藏
- 关注作者
评论(0)