我把 GEO 做成了可回归的指标:一个 100 行的体检器,扫出官网只有 6/11 页面达标
为什么要自己写检查器
"让网站被大模型引用"这件事(现在叫 GEO,Generative Engine Optimization)大部分讨论停在"写好标题、加结构化数据、多写事实"这种建议层面。问题是这类建议无法回归:你改了一版,没人能回答"到底达标没有、下次加页面会不会又漏"。
我的判断是:只要能把要求写成可计算的量,它就能进工程流程——像 lint 一样跑,像测试一样看红绿。于是写了个 100 行出头的体检器,把官网的每个可索引页面过一遍。
跑出来的第一个结果挺打脸:全站 11 个可索引页面,只有 6 个达到"可抽取块 ≥3"。
先定义"什么叫可被抽取"
模型(以及搜索引擎的精选摘要)从页面里拿走的通常不是整篇文章,而是一个自足的小块:一段能独立回答一个问题的定义、一张对比表、一组数字、一段步骤、一条问答。我把它们归纳成五类,作为检查项:
BLOCKS = {
"定义": r"(定义|是什么)",
"对比": r"(对比|区别|vs|选型)",
"数字事实": r"(数字事实|事实|参数|规格)",
"操作步骤": r"(步骤|如何|快速开单|流程)",
"FAQ": r"(常见问题|FAQ|问答)",
}
匹配对象是页面里的 h1~h3 标题文本。用标题而不是正文,是因为标题是"内容块边界"的显式声明:一个以"常见问题"开头的 h2 下面大概率就是一个可抽取的问答块。
然后配上几个结构指标,阈值直接来自"抽取器友好"的经验值:
| 指标 | 目标 | 为什么 |
|---|---|---|
| H1 数量 | 恰好 1 | 多 H1 让页面主题被判为不明确 |
| H2 数量 | 6~10 | 太少说明没有分层,太多是层级噪声 |
| 可抽取块 | ≥3 | 单一块不足以覆盖不同提问角度 |
| 列表密度 | ≥0.35 | li 行数 / 段落行数;列表是机器最容易结构化复刻的形态 |
| 正文中文字数 | ≥1000 | 短页没有可引用的实体密度 |
| canonical + og | 齐 | 规范链接和分享语义 |
| 更新日期 | 有 | 时效性是生成式回答的排序偏好之一 |
体检器核心实现
去掉注释后不到 100 行,标准库就够:
import re, sys, glob, html
from pathlib import Path
HEADING = re.compile(r"<h([1-3])[^>]*>(.*?)</h\1>", re.S | re.I)
TAG = re.compile(r"<[^>]+>")
def text_of(raw):
# 关键:先剔除 script/style/noscript,再脱标签
raw = re.sub(r"(?is)<(script|style|noscript)[^>]*>.*?</\1>", " ", raw)
return html.unescape(TAG.sub(" ", raw))
def check(path):
raw = path.read_text(encoding="utf-8", errors="ignore")
body = text_of(raw)
cjk = len(re.findall(r"[一-鿿]", body)) # 只数中文字,别用 len(body)
heads = [(int(l), TAG.sub("", t).strip()) for l, t in HEADING.findall(raw)]
h1 = sum(1 for l, _ in heads if l == 1)
h2 = sum(1 for l, _ in heads if l == 2)
hit = {k: any(re.search(v, t, re.I) for _, t in heads) for k, v in BLOCKS.items()}
li = len(re.findall(r"(?i)<li\b", raw))
p = len(re.findall(r"(?i)<p\b", raw)) + max(0, h2 - 2)
return dict(cjk=cjk, h1=h1, h2=h2, blocks=sum(hit.values()),
density=round(li / p, 2) if p else 0.0,
missing="/".join([k for k, v in hit.items() if not v]) or "-",
canonical=("Y" if 'rel="canonical"' in raw else "N"),
updated=("Y" if re.search(r"(最后更新|更新日期)", body) else "N"))
三处实现细节,每一处都对应一个会骗你的数字。
第一,字数必须先把 <script> / <style> 剔掉,再脱标签,再只数中文字符。 现在页面里普遍内嵌 JSON-LD,那是一大段带中文的脚本。如果不剔除,一个"正文 200 字 + JSON-LD 800 字"的页面会直接通过 1000 字门槛——你会得到一个漂亮的假达标,然后困惑为什么模型不引用它。用 len(re.findall(r"[一-鿿]", body)) 而不是 len(body),也顺手规避了标签残留和空白灌水。
第二,列表密度的分母不是段落数。 li 只跟"正文承载量"比才有意义,所以我用 <p> 数加上"H2 减 2"作分母——小标题本身也是一处正文。这个指标是纯经验值,但它在实践里非常能区分"文章"和"资料页":资料页密度高(可抽取),叙事文密度低(读起来顺,机器抓不到块)。
第三,h1~h3 的选取要跟着你的页面语义走。 有的团队把正文小标题写到 h4,那检查器就会漏判。与其把正则放宽到六级,不如把规范定死:正文层级只允许到 h3。
分母由 sitemap 决定,而且 URL 到文件的映射不一定如你想
这是最容易做错的环节:体检的页面集合必须是"实际可索引集合",也就是 sitemap 里那些 URL,而不是"目录里所有 html"。
我第一版直接 glob("web/public/docs/*.html"),扫出 21 个页面,达标数 8。看起来是"做得不行"。但把 sitemap 拿过来一对,发现两种页面根本不该进分母:*-wechat.html(公众号排版稿)和 soft-copyright-*.html(软著申请材料),它们有 h1 但没有 canonical,也不在 sitemap 里——它们不需要 GEO。
更麻烦的是首页。仓库根有一个 web/index.html,它是 Vue 应用的入口壳:
page cjk h1 h2 blocks density canonical updated
index.html 1122 1 10 5 0.88 N Y ← 仓库根,SPA 壳
index.html 3204 1 14 5 1.03 Y Y ← docs/index.html,真正的官网首页
两个同名 index.html,指标完全不同。而 nginx 里 location = / 显式指向 docs/index.html:
location = / {
root /usr/share/nginx/html/docs;
try_files /index.html =404;
}
如果按文件路径去量"首页",量的其实是 SPA 壳。 所以检查器必须复刻部署层的 URL→文件映射,而不是假设目录结构就是站点结构:
def from_sitemap(sitemap, root="web"):
urls = re.findall(r"<loc>([^<]+)</loc>", Path(sitemap).read_text(encoding="utf-8"))
paths, routes = [], []
for u in urls:
p = re.sub(r"^https?://[^/]+", "", u)
if p == "/":
paths.append(f"{root}/public/docs/index.html") # 由 nginx 映射决定
elif p.endswith(".html"):
paths.append(f"{root}/public{p}")
else:
routes.append(p) # SPA 路由,无静态文件
print("SPA 路由(无静态文件,单列):", ", ".join(routes))
return paths
routes 那个列表别忽略——它是另一个坑。我的 sitemap 里有 /login,它没有对应静态文件,由前端路由渲染。这类 URL 必须单独验证抓到的 HTML 是不是空壳:服务端没配好时,直连域名会返回一个只有挂载点的空文档,正文一个字都没有,但 sitemap 告诉搜索引擎"这里有个页面值得收录"。我这边是靠给每个域名补一个 server 块、让它落到真实入口 HTML 上解决的。检查器管不到这一层,要单独抓一次渲染结果确认。
按 sitemap 重跑,真实结果
page cjk h1 h2 blocks missing density canonical og updated
index.html 3204 1 14 5 - 1.03 Y Y Y
company.html 3173 1 16 5 - 0.82 Y Y Y
product-intro.html 1411 1 11 5 - 0.28 Y Y Y
manual.html 3466 1 13 5 - 0.67 Y Y Y
changelog.html 6025 1 44 4 对比 1.33 Y Y Y
marketing.html 1210 1 9 4 对比 0.67 Y Y Y
brochure.html 467 1 5 1 定义/数字事实/操作步骤/FAQ 0.0 Y Y N
article.html 2088 1 4 1 对比/数字事实/操作步骤/FAQ 0.17 Y Y N
article-v2.html 2601 1 7 1 定义/数字事实/操作步骤/FAQ 0.13 Y Y N
article-2.html 2150 1 6 0 全部五类 0.12 Y Y N
article-3.html 1804 1 2 0 全部五类 0.42 Y Y N
H1 唯一 : 11/11 canonical+og 齐 : 11/11 字数 >=1000 : 10/11
可抽取块 >=3 : 6/11 列表密度 >=0.35 : 6/11 H2 在 6-10 : 3/11 带更新日期 : 6/11
这张表里有四个值得停下来看的信号。
核心页全绿,文章页全红。 产品/手册/公司这几页是 5/5 可抽取块,而四篇引流文章只有 0~1 篇达标。原因很直接:文章是叙事体裁,标题是"转机:朋友推荐了一个软件""变化一:开单再也没出过错"这种,机器划不出块。内容与可被引用的内容不是一回事——这也是 GEO 和传统内容营销最实质的分歧。
changelog.html 的 H2 = 44,是反例而不是模范。 每个版本条目都升成了一个 h2,页面变成 44 个平级块。抽取器面对这种结构,很难判断哪个块是"这个页面的主答案"。修法是给版本记录降一级(h3)或按大版本分节,而不是继续加标题。
brochure.html 只有 467 字,但它本来就不该是资料页。 它是给印刷准备的一页物料。检查器把它标红是对的,处置方式是接受红——指标要服务于目的,不是所有页面都要冲绿。这一条很重要,否则团队会开始为了达标往宣传页里塞 FAQ。
product-intro.html 的列表密度 0.28,是它唯一的短板,正文结构是长段落 + 卡片。卡片在视觉上是列表,在 DOM 里是 div。这类"语义上是列表、实现上不是"的差距,只能靠把内容真的写成 ul/li(或用 <dl>)来补。
检查器自己也有假阴性,得抽查
写工具最容易犯的错误是把工具输出当真理。我抽查了被判 blocks=0 的页面,抓到两种情况:
-
真阴性:
article-2.html的标题全是叙事句,确实一个可抽取块都没有。判对。 -
假阴性:
article-3.html有一个h2叫"这些问题怎么解决?"——语义上就是 FAQ,但关键词表里没有"怎么解决",判成 0。
所以结论是:关键词表是召回上限。两条改进路:把同义词表扩下去("怎么解决/怎么办/答疑/一问一答"),或者加问句式检测(标题以"?"结尾、或含"如何/为什么/是否"就计入潜在问答块)。但要留一手——扩表会带来假阳性,比如正文里的"如何选择"未必真有一个对比块。
我的处置是:检查器负责"绿的一定可信",红的一律人工抽查。工具用来发现漏,不用来发号施令。
让它进流程,而不是一次性运动
指标定完,价值在回归。落地是三件事:
-
发布前门槛:新增或改动可索引页时跑
python scripts/geo-check.py --sitemap web/public/sitemap.xml,看的是没有把别的页面改红(可抽取块数量只允许升不允许降)。 -
sitemap 与文件同步:sitemap 里出现不存在的文件、或存在 canonical 的页面不在 sitemap 里,都当成缺陷报出来。这一步我加了
missing_files检查,因为它同时能抓到"改了 URL 忘了改 sitemap"。 -
加页面时必须同步加块:新的文章页默认
blocks很低,所以要么在文末补一段定义/问答,要么接受它不进 GEO 目标——但不要指望"内容多了自然被引用"。
收获与边界
做完之后真正变化的不是流量数字(那需要时间且混杂因素太多),而是讨论方式:以前说"官网 SEO 优化了一下",现在能说"11 个可索引页里 6 个有 3 类以上可抽取块,四篇引流文章是短板,其中一篇是假阴性"。能被这样描述的事情,才能被改进。
也要说清边界。这套指标量的是"抽取友好度",不是"被引用率",更不是排名。它防的是明确的失误(空壳 URL、800 字的假达标、卡片冒充列表、层级过载),不能替代内容质量本身。真正决定引用的仍然是:你有没有给出别人给不出的具体事实。
不过反过来说,正因为"具体事实"是决定项,把它做成可检查、可回归的清单,才是工程该做的事。
