我给自己的站点做了一次结构体检,想量化一个模糊的直觉:这些文章是不是长得太像了。
第一次量出来 92%,我当场写进了结论。后来发现那个数字是错的——错误来源是我把主题模板算进了”文章结构”。修正之后是 12%。
两个数字差了将近八倍,而中间只隔着一行提取代码。这篇讲这次测量本身:怎么量、我怎么量错的、以及为什么第二个数字也不是终点。
为什么要量结构而不是字数
内容质量出问题时,第一反应通常是查字数。我先查了:正文中位数三千多字符,最短的也有一千五,没有一篇是凑数的短文。字数不解释问题。
那就换个角度。如果一批文章的形状高度一致——同样的小节编排、同样位置的对比表格、同样的结尾——读者和审核者都会感觉到”这是套模板生成的”,哪怕每一篇的文字都是原创。形状是可以量化的,只要把”形状”定义成一组可检测的结构特征。
我定义了九个二值特征:有没有代码块、有没有表格、有没有列表、有没有三级标题、有没有引用块、有没有图、有没有编号小节、有没有”步骤”字样、有没有 FAQ。每篇文章因此对应一个特征集合,比如 code+list+table。然后统计这些集合的分布。
第一次量错在哪
我最初直接读仓库里的源文件统计,得到一个荒谬的结果:所有文章都没有“相关阅读”这一项。
原因很直接:那些区块不在源文件里。站点的一个插件在渲染时往每篇文章外面套了一层壳——阅读进度条、难度标签、自动生成的目录、文末的相关阅读。源文件里只有正文,读者和爬虫看到的却是套完壳的页面。
所以我改成抓线上渲染后的 HTML。这一步是对的。错的是接下来那一步:我用 <article> 标签界定”文章”。
结果算出 figure 出现率 100%——每一篇都有图。这个数字当时看着很合理,技术文章配图很正常,我就接受了它,并把它算进了”共享同一骨架”的判定,得到 92%。
直到我加了三篇正文里一张图都没有的新文章,重新测,figure 仍然是 100%。
这才回头去看那个 <img> 到底是什么:
<img alt='' src='.../personal-site-community/avatars/user-1/default-v3.png'>
是作者头像。主题在每篇文章底部渲染作者信息,头像在 <article> 里面。我一直在把它当成文章内容的一部分。
修正
正确的边界不是 <article>,而是那个只包含作者原始 markup 的容器。这个站点上它带一个 data-article-primary 属性;换个主题会是别的名字,但原则一样:找到”模板注入到此为止、作者内容从此开始”的那条线。
换了边界之后重测,同一批 56 篇文章:最大单一骨架从 92% 掉到 12%,figure 从 100% 掉到 36%。
差别全部来自那层壳。而壳是每个 WordPress 站点都有的东西——用它来判断内容是否模板化,等于用”这个站有没有页脚”来判断内容质量。
测量脚本长什么样
整个测量不到八十行,核心只有三个函数。抓取部分要处理一个现实问题:链路不稳,返回的 HTML 经常被截断,而截断的响应看起来和正常响应没有区别。
判据是页面必须以 </html> 结尾——不完整的响应拿去统计,会得到”这篇文章没有表格”这种假结论:
def fetch(slug, tries=4):
for _ in range(tries):
r = subprocess.run(["curl", "-s", "--max-time", "100",
f"{BASE}/{slug}/?nc={random.randint(1, 10**9)}"],
capture_output=True, text=True)
if "</html>" in r.stdout: # 只接受完整响应
return r.stdout
return None
那个随机查询参数是为了绕过 CDN 缓存。不加的话,改完站点再测,拿回来的还是旧页面——这个坑我踩过一次,表现是”改了配置但指标纹丝不动”,和真正的指标失灵一模一样。
特征提取就是一组正则,每个返回布尔值:
def skeleton(h):
f = set()
if '<table' in h: f.add('table')
if re.search(r'<pre|<code', h): f.add('code')
if re.search(r'<(ul|ol)', h): f.add('list')
if re.search(r'<blockquote', h): f.add('quote')
if re.search(r'常见问题|FAQ', h): f.add('faq')
...
return sorted(f)
正则很粗糙,但这里不需要精确——要的是同一把尺子量所有文章,系统性误差会在比较中抵消掉。
正文边界怎么找
这是整件事唯一需要动脑的地方,也是我出错的地方。方法是先看,再写代码:随便挑一篇文章,把提取出来的片段直接打印出来,从头到尾读一遍。
要确认的东西很具体:里面有没有导航链接、有没有作者信息、有没有”相关文章”列表、有没有页脚。有任何一样,边界就画错了。
我这次的终止标记最后取了三个位置里最靠前的那个:
ends = [x.start() for x in [
re.search(r'related|site-experience__next', b),
re.search(r'<footer', b),
re.search(r'</article>', b),
] if x]
body = b[:min(ends)] if ends else b
一开始我只截到”相关阅读”那一处。结果是:关掉了相关阅读的文章没有这个标记,正文一路延伸到页脚,长度报出来是真实值的六倍。修正之后,同一篇文章从 22234 字符变成 3407。
这个错误和前面那个头像的错误是同一类:用一个”通常成立”的标记去界定边界,而不是用一组必然成立的。
第二个数字也不是终点
12% 这个”最大单一骨架占比”看着很健康,但它掩盖了另一件事。换个统计口径:
代码块出现在 96% 的文章里,列表 95%,表格 91%。同时含这三样的占 88%。
29 种不同的骨架组合,结构熵 4.44 bits(上限 5.81)。所以文章之间确实有差异,但那些差异都发生在”有没有 FAQ””有没有引用块”这类次要特征上,主干是稳定的:一段说明、一块代码、一个列表、一张对比表。
“最大单一组合占比”这个指标对此不敏感,因为它把 code+list+table 和 code+list+table+quote 算成两种不同的骨架。从分类学上没错,从阅读体验上它们是同一个东西。
所以一个指标不够。至少要同时看三个:最大单一组合的占比、高频元素各自的出现率、以及这些高频元素的共现比例。第三个才是真正暴露问题的那个。
怎么在自己的站上做一遍
整件事的核心只有三步,都不需要什么工具。
第一步,抓渲染后的页面,不要读源文件。源文件里没有模板注入的部分,而那部分恰恰是最容易同质化的。
第二步,把作者内容和主题外壳分开。先随便挑一篇文章,把提取出来的片段打印出来肉眼看一遍,确认里面没有导航、没有作者信息、没有相关文章列表。这一步我跳过了,代价是一个错了八倍的结论。
第三步,用共现率而不是组合分布做判断。先看哪几个元素的出现率超过九成,再算它们同时出现的比例。这个数字如果很高,说明主干是固定的。
顺带一个副产品:跑完之后你会得到一份”结构上的异类”名单——那些不符合主流骨架的文章。我原以为它们是需要修的,实际正好相反,它们是需要更多的。
第三次:查重指标对中文一直是失效的
这篇写完之后,同一套自我测量里又暴露了一个错误,而且它已经静默运行了很久,值得补进来——因为它的失败方式和前两次完全不同。
除了结构同质度,我还有一个跨文重复检测:把每篇文章切成 5-token 的滑动窗口,两两算 Jaccard 相似度。它一直报「全站最高 0.059」,看起来很健康。
破绽是有一次我给四篇新文章跑这个检测,其中两篇是英文,报出来最相似的却是同一篇中文文章。英文和中文最相似,这不可能。
根因在分词。脚本用 re.findall(r'\w+', text) 切词——这在英文里是对的(空格天然分隔),在中文里则完全不成立:中文没有空格,一整串连续的汉字会被切成一个 token。
t = "更糟的是它会冻结同一个循环上所有正在流式输出的对话"
re.findall(r'\w+', t)
# → ['更糟的是它会冻结同一个循环上所有正在流式输出的对话'] 一个 token,25 个字
于是「5-token 的窗口」实际横跨了五个完整分句。两篇文章要产生一次重合,得有连续五个分句逐字相同——这几乎不可能发生。所以这个指标对任意两篇中文文章都返回接近 0,它从来没有在检测重复,只是在稳定地输出「没问题」。
正确的口径是按语言分别切分:中文用字符级 n-gram,拉丁文字用词级 n-gram,两者取并集。
def shingles(t, n=5):
out = set()
for run in re.findall(r'[一-鿿]+', t): # 中文按字符
for i in range(len(run) - n + 1):
out.add(run[i:i+n])
lat = re.findall(r'[A-Za-z0-9_]+', t.lower()) # 拉丁按词
for i in range(len(lat) - n + 1):
out.add(tuple(lat[i:i+n]))
return out
换成这个口径重测全站,真实的最高相似度是 0.0488,出现在同一个系列的两篇部署文章之间——这个结果合理,而且指标终于能区分「相关」和「不相关」了。之前那个 0.059 其实是英文文章对贡献的,中文那一半根本没参与。
结论没有变——站内确实没有重复内容。但在此之前,这个结论是没有依据的。
还有一个佐证:我确实写过一篇重复的文章,是在写完之后偶然发现封面文件重名才察觉的,而当时这个查重指标报的是通过。一个能被文件名重名捡漏、自己却漏掉的检测,本来就该被怀疑。
三次错误的共同点
把三次放在一起看,它们的失败方式各不相同,但有一个共同点:指标每次都给出了一个看起来合理的数字,没有任何一次报错。
- 量源文件时,得到「0% 含相关阅读」——一个精确的、错误的数字。
- 量整个
<article>时,得到 92%——一个精确的、把主题外壳算进去的数字。 - 用
\w+切中文时,得到 0.059——一个精确的、只反映英文的数字。
所以「指标能跑通、结果在合理范围内」提供的信息量非常有限。真正拆穿它们的,三次都是某个不该出现的巧合:加了三篇无图文章而指标纹丝不动;`figure` 出现率恰好 100%;英文文章的最相似项是中文文章。
可以带走的做法是:给测量脚本设计一个「已知答案」的对照输入。比如故意放一篇和已有文章完全重复的文本进去,检测必须报出接近 1 的相似度;再放一篇完全无关的,必须接近 0。这两个端点通过之后,中间的数字才值得相信。这个成本很低,而它能一次性挡住上面三类错误。
关于自我测量
这次最值得记的不是方法,是那个错误持续了多久。
92% 这个数字看起来完全合理:它符合我的直觉、能解释我观察到的现象、还给了我一个明确的行动方向。它唯一的问题是错的,而我没有做任何检验就用了它。
推翻它的不是复查,是一次意外的对照——我加了三篇没有图的文章,指标却纹丝不动。如果我当时加的是三篇有配图的文章,这个错误会继续留在结论里。
所以在自我测量这件事上,最有价值的动作是主动制造一个你确知答案的样本,看指标会不会动。指标不动,就说明它量的不是你以为的东西。