你刚把 robots.txt 里五家爬虫全改成了 Disallow: /------字节的 Bytespider、百度的 Baiduspider、360、搜狗、神马,一个没留。改完顺手跑一次 GEO 体检,想看看分数会掉成什么样。
74 分。跟改之前一模一样,一个数字都没动。
我第一反应是自己改错文件了。回去 cat 了一遍,五条 Disallow 好好地躺在里面。那就只剩一个可能:体检脚本压根没读懂这几行。
31 行里出问题的只有一条正则
tri-geo 的爬虫判定全在 scripts/site_signal.py 的 judge_ua(401--431 行)。它逐行扫 robots.txt,把对当前 UA 生效的行收进 applicable,再遍历这个列表下结论:
python
# scripts/site_signal.py:420-425
for line in applicable:
if re.match(r"(?i)^disallow\s*:\s*/?\s*$", line):
return True
if re.match(r"(?i)^disallow\s*:", line):
return False
return True
第一条正则本意是处理 Disallow: 后面跟空值的情况------按 robots 规范空值等于什么都不禁,返回 True 没毛病。可 \s*/?\s*$ 里那个 /? 让 Disallow: / 也匹配上了,而 Disallow: / 恰恰是"禁止全站"的标准写法。
于是就有了开头那一幕:封得越彻底越放行,只封一个子目录反倒被判禁止。
同一个 UA,六种写法跑下来是这样:
| robots.txt 片段 | 真实语义 | judge_ua |
|---|---|---|
Disallow: / |
禁止全站 | True(允许) |
Disallow: /admin |
禁止 /admin | False(禁止) |
Disallow: |
空值,允许全部 | True(允许) |
Allow: / + Disallow: / |
禁止全站 | True(允许) |
Disallow: / + Allow: /public |
除 /public 外全禁 | True(允许) |
| 无该 UA 声明 | 未声明即允许 | True(允许) |
六行里翻了两行,翻的还都是"全站封禁"这两种最常见的写法。
还有个细节值得单独说:applicable 这个列表不分 UA 组。User-agent: * 下面的 Allow: /,会和后面 User-agent: Bytespider 的 Disallow: / 收进同一个列表,遍历时先撞上哪条就按哪条返回,跟这行究竟属于哪个组没关系。"先放行全站、再单独封某一家"是真实 robots.txt 里最常见的写法,这一版实现认不出来------实测一律返回 True。
连带后果:那道封顶门永远够不着
这条正则不只是让分数偏高。site_signal.py:463 还挂了一道一票否决:
python
# scripts/site_signal.py:463-465
if crawl_detail.get("blocked_verified_crawlers") == sum(1 for _, _, v in CRAWLERS if v):
veto.append({"code": "CRAWLER_BLOCKED_ALL", "type": "cap60",
"message": "全部已核验 AI/搜索爬虫被 robots.txt 禁止,评分封顶 60"})
触发条件是被封的已核验爬虫数等于 5。而 blocked 是 judge_ua 累加出来的------Disallow: / 判 True,永远加不上去。
同一份 good-page 夹具,我换了三种 robots 喂进去:
| 场景 | rc | 总分 | 封顶 | crawl_llms | 被封 | veto |
|---|---|---|---|---|---|---|
| 不喂 robots / llms.txt | 0 | 66 | --- | 0(status=missing) | --- | 无 |
| 正常 robots + llms.txt | 0 | 74 | --- | 85 | 0 | 无 |
5 家全 Disallow: /(真封全站) |
0 | 74 | --- | 85 | 0 | 无 |
5 家全 Disallow: /private(只封一个目录) |
1 | 60 | 60 | 15 | 5 | CRAWLER_BLOCKED_ALL |
| 只封 Bytespider(/private 写法) | 0 | 73 | --- | 71 | 1 | 无 |
第三行就是开头那次。全站封死,跟完全开放时一个字节都不差。第四行封的是个站点上从没存在过的 /private,反倒被封顶 60。

我个人最在意的不是分数高了几分,是这道封顶门从设计到实现都挺像样,唯独触发路径被上游堵死了。一个从来不会响的警报比没装警报更坑人------它会让你以为自己有兜底。
顺带撞见的第二件事:自己给自己当来源
拆完 robots 我顺手翻了 citation_check.py。它有两份词表:UNSOURCED_CLAIM_RE 抓疑似捏造句式,SOURCE_HINT_RE 判断这段话有没有声明来源。判定是"触发了数据/研究类表述 且 没有来源声明"才记 risky。
问题是有两个词同时躺在两份表里。
text
研究表明 UNSOURCED_CLAIM_RE 命中 SOURCE_HINT_RE 命中 → 永不告警
研究显示 UNSOURCED_CLAIM_RE 命中 SOURCE_HINT_RE 未命中
数据显示 UNSOURCED_CLAIM_RE 命中 SOURCE_HINT_RE 命中 → 永不告警
统计显示 UNSOURCED_CLAIM_RE 命中 SOURCE_HINT_RE 未命中
权威机构 UNSOURCED_CLAIM_RE 命中 SOURCE_HINT_RE 未命中
专家表示 UNSOURCED_CLAIM_RE 命中 SOURCE_HINT_RE 未命中
两句本质一样的空头断言,实测待遇相反:
bash
$ python -c "import citation_check as c; print(len(c.detect_unsourced('研究表明,采用结构化数据的页面被引擎引用的概率明显更高。')))"
0 # 放行
$ python -c "import citation_check as c; print(len(c.detect_unsourced('权威机构指出,该指标将持续走高。')))"
1 # 拦截
同一份"疑似捏造句式"词表,两个最高频的词拿到了豁免,剩下四个没有。你要是写稿习惯用"研究表明"开头,这条检测对你等于不存在。
旁边那个 DATA_CLAIM_RE 也有类似的取舍。它只认 %、万、亿、元、倍和四位年份,"提升了 34 个百分点"实测不触发数据主张检测,改写成"提升 34%"才触发。把"7 天试用""3 家客户"这类泛指量词排除掉是刻意为之(脚本注释里写了,怕误报),方向我认,可"个百分点"和中文数字也一并被排除了。
这里插句题外话。我另一边在做产品雷达鸭,它攒竞品资料时会给每条结论标出处,标不出来的宁可整条丢掉,也不写"研究显示"。所以看到这种"自己给自己发通行证"的判定我会格外敏感------可能反应过度了三秒,但方向我觉得没偏。
我怎么改的
按组解析 robots,别把不同 UA 组的规则混进一个列表;Disallow: / 归禁止,空值归允许:
python
def judge_ua_fix(robots: str, ua: str) -> bool:
groups, names, lines = [], [], []
for raw in robots.splitlines():
line = raw.split("#", 1)[0].strip()
if not line:
continue
if line.lower().startswith("user-agent:"):
if names:
groups.append((names, lines))
names, lines = [line.split(":", 1)[1].strip().lower()], []
elif names:
lines.append(line)
if names:
groups.append((names, lines))
target, best, best_len = ua.lower(), None, -1
for g_names, g_lines in groups: # 取最长匹配的那组,别串组
for n in g_names:
if n == "*" or target.startswith(n):
if len(n) > best_len:
best, best_len = g_lines, len(n)
if best is None:
return True
for line in best:
if line.lower().startswith("disallow:"):
v = line.split(":", 1)[1].strip()
return False if v else True # "/" 封全站,"" 允许全部
return True
六种写法回测,翻面的三处全部归位;五家 Disallow: / 时 blocked=5,封顶门正常触发,rc=1、总分 60。
重来一次我大概会直接用标准库的 urllib.robotparser,而不是自己撸这 31 行。评分脚本里造轮子真不划算------Allow 优先、最长匹配、通配符这些边界,自己写一遍基本等于把前人踩过的坑重新踩一遍。
还有一处对不上
crawl_llms 在不喂 robots 时记 0 分并标 status=missing,但这个 0 照样按 10% 权重进总分。同一份页面,喂不喂 robots 差了 8 分(66 → 74)。脚本头注释第 16 行写着"未提供的输入维度不计分也不猜测",实现里是计了 0 分。这两处得改一个,现在谁也说不清 66 分到底代表"没查"还是"很差"。
llms.txt 那一档我也顺手摸了下:分档是 0/30/50/70/90,只看标题数和链接数,没标题没链接给 30,有 1 个标题且链接 ≤2 给 50,标题 ≥2 且链接 ≥3 才 70。我丢了个只有 1 行标题、1 条链接的文件进去拿到 50 分------比不写多一半。这套分档我理解是鼓励先有再好,可一旦进了评分,它就容易被当成"llms.txt 已经做了"的证据,而实际内容可能就一句话。
我到现在也没想明白,一个标着一票否决的封顶门是怎么在验收里活下来的------T4/T5 覆盖的是捏造和广告法红线,爬虫封顶这一档没有对应用例。下个版本我打算先拿这五种 robots 写法做成固定夹具塞进 tests/fixtures,不然修完还会再漂。
我是老三,10+ 年软件开发经验,软件设计师、注册人工智能工程师;专注鸿蒙 ArkTS 北向开发与 Web 前端,也在折腾 AI 自动化,不定期在 CSDN 写点实战踩坑。
本文遵循 MIT 协议,转载请注明出处。
安装:skillhub install tri-geo