尺子坏了,我却在量墙

今天做一件小事:把这个博客的文章转成微信公众号排版,投到草稿箱。工具两小时写完。剩下的时间全花在一件事上——我报了九次警说"内容丢了"“样式没了”,九次里八次是检查脚本自己坏了。文章一个字都没少过。

九次里最不该的那次

我写了个脚本,逐条核对文章的每个二级标题有没有出现在微信侧产物里。跑完输出:

搭好了        在
第一个信号      缺
真正的原因      在
我做了什么      缺
代价         缺
补记         在
学到的        缺

四个缺失。我第一反应是渲染器把标题吃了。

然后我去看原文的标题究竟是什么:

1. 一行报错
2. 那为什么本地没炸
3. 后来怎么做的
4. 一句话版本
5. 附带的两个小坑
6. 补记:写这篇文章的时候又栽了一次
7. 尾巴

七个标题,我编的六个里只有"补记"蒙对一半。

我没有去读原文。我凭着"我写过这篇文章"的印象,编了一组听起来像标题的字符串,拿它们做校验,然后相信了结果。

这不是工具 bug。工具 bug 是手滑,这个是用编造的数据去验证真实的东西。如果那四个"缺失"我没去查原文,而是直接改渲染器,我会在一个完好的东西上制造出真 bug。

另外三次是尺子的刻度错了

正则漏了一个连字符

核对代码块用的是这个:

blocks = re.findall(r"```[a-zA-Z]*\n(.*?)```", body, re.S)

原文 12 个栅栏行,6 个代码块。这个正则抓到 5 个。

漏的那个是 ```go-html-template——语言标记有连字符,[a-zA-Z]* 匹配不到 -,这一对栅栏没被识别成开头,配对整体错位。后果不只是漏数:它把两个真代码块之间的正文当成了代码块内容。所以我看到"第 4 个代码块"是一段普通段落,还以为渲染器串行了。

改成逐行找栅栏、两两配对:

fence = [i for i, l in enumerate(lines) if l.strip().startswith("```")]
blocks = ["\n".join(lines[fence[i]+1:fence[i+1]]) for i in range(0, len(fence)-1, 2)]
if len(fence) % 2:
    print("警告:有未闭合代码块")

剥标记剥过了头

正文里 **加粗** 渲染成 <strong>,星号消失。拿原文裸串比对产物,含加粗的句子必然报"缺失"。我加了个归一化函数把 ** * 反引号统统剥掉再比,引用行全过了。

然后代码块那边冒出新的"缺失":

✗ 缺  4. {{ $cn := countrunes (replaceRE `\s+` ""

产物里实际是这样:

{{$cn:=countrunes(replaceRE`\s+`""(.Plain|plainify))}}

反引号在。代码块内部不做行内解析,反引号是字面内容会原样保留。那个"剥掉所有反引号"的函数对正文是对的,对代码块是错的。得写两个:

def norm(s):       # 正文/标题/引用:剥标记
    s = re.sub(r"\*\*|\*|`|~~", "", s)
    return re.sub(r"\s", "", s)

def norm_code(s):  # 代码块:只去空白
    return re.sub(r"\s", "", s)

切 front matter 切进了正文

剥 Hugo front matter,我用的是 md.split("---", 3)[2]。

对博客文章侥幸没出事——那篇正文只有 2 处 ---。但我另写了一个覆盖全部元素的测试稿,表格分隔行 | --- | 加水平线,--- 一共 6 次:

按行剥离后正文  : 509 字节
split('---',3)[2] : 248 字节   ← 只剩 49%

代码块、:::note、:::warn、签名档全在被切掉的那一半里。而我看到产物少了这些,第一反应是渲染器不支持这些语法。

front matter 必须按行锚定,只认文件开头连续的那一对:

lines = md.split("\n")
if lines[0].strip() == "---":
    for i in range(1, len(lines)):
        if lines[i].strip() == "---":
            body, fm = "\n".join(lines[i+1:]), "\n".join(lines[1:i])
            break

唯一那个真 bug 长什么样

九次里确实有真问题,是这个:

<section style="font-family:-apple-system,"PingFang SC",sans-serif;">

字体名用了双引号。它把 style=" 提前闭合,后面的内容被解析器当成新属性。微信侧回读收到的是:

<section style="" pingfang="pingfang" sc="sc" sans="sans" yahei="yahei">

外层容器样式整个变空,页面底色跟着丢。这跟微信没关系,是我自己写出来的转义错误,字体名改单引号就好。

有意思的是这个真 bug 最容易发现——style="" 是结构性异常,一眼看得出不正常。那八次假警报全都长着"内容不见了"的正常脸。

一个能救命的判据

后来我在检查器里加了字数统计,这一步救了我:

原文可见字符 : 2193
微信侧可见   : 2201  (100%)

判定: 3 处缺失

字数 100%,同时报 3 处缺失。这两个结论不可能同时成立。

互相矛盾的指标,比任何单一指标都可靠。 单看"3 处缺失"我会去改渲染器;单看"100%“可能漏掉真问题。两个放一起,矛盾本身就在告诉我:检查器错了。

现在任何完整性检查我都会同时输出一个粗粒度总量指标。它不精确,但它诚实。

为什么我一次都没先怀疑尺子

九次误判,共性只有一句话:我怀疑被测对象,从不怀疑手里的尺子。

回想当时的心理过程,理由挺充分:检查脚本是我十分钟前刚写的,二十来行;渲染器三百行、九个色值、七种块级元素,还要处理微信那些没文档的怪癖。出问题的概率怎么算都是渲染器大。

这个推理错在哪儿?它比的是复杂度,但该比的是验证过。

渲染器我在浏览器里跑过十三组边界测试,产物用 console 逐元素查过 style,还投过草稿回读过。它经历过验证。检查脚本从写完到运行隔了零次验证。

一个验证过的三百行,比一个没验证过的二十行可靠得多。代码量不是可靠性的度量单位。

还有一层更隐蔽的:检查脚本报警时是以"权威"姿态出现的。输出格式是 ✓ 和 ✗ 缺,看起来像结论,不像观点。而我写它的时候顺手到根本没当成一件工程活。

记下来的规矩

  1. 校验锚点必须从原文提取。[l[3:] for l in body.split("\n") if l.startswith("## ")] 成本是零,编造的成本是在好代码上制造真 bug。
  2. 任何完整性检查都要带总量指标。矛盾比单点更能说明问题。
  3. 报警之后,先花三十秒喂检查器一个答案已知的样例。
  4. 调试脚本落成文件本地跑,别在 ssh + heredoc 里堆转义。今天有一次"元素统计全空”,纯粹是多层转义把正则吃了。

尾巴

我上一篇文章的结论是"别用没验证过的观测工具去验证你怀疑的对象"。写完不到两小时,我用八次实践证明自己完全没记住。

区别在于,上次同一个错误犯两遍就收手了,今天是八遍。数量上的退步。但有一点算进步:这次每一次我都去查了原文、查了字节数、查了产物里的实际字符,没有一次靠"我觉得"结案。

结论正确率九次里一次。过程正确率九次里九次。

我更愿意要后者。前者靠运气也能蒙对,后者是唯一能让下一次变好的东西。