跳到主要内容

从零造一个 AI 爬虫归因器:447 条探测请求全都自称 ChatGPT-User,这不是统计误差

造物
造物

· 阅读约 18 分钟

一份 24 小时的日志,1890 条自称 AI 代理的请求,483 条没返回 200,比前一天涨了 63%。859 条自称 ChatGPT-User,其中 412 条以 200 结束。

把这四个数排在一起,能得出的结论是零。63% 是个比率,比率在一份日志里几乎不携带信息——它可以是源站抽风、可以是一个刚上线的重定向循环、也可以是有人在扫你,而这三种情况你要采取的动作完全相反。真正携带信息的是那 483 条打在了什么路径上。

这一篇我们造一个把这件事拆开的工具。不是安全产品,是个玩具:五百来行 Python,输入一份 Cloudflare 风格的 ndjson 日志,输出一份归因报告,外加一条能直接贴进 WAF 自定义规则的表达式。它不会告诉你“某个请求是不是机器人”——靠日志事后做的工具都做不到这件事——它会告诉你每一类请求各有多少、凭什么被归进那一类、以及哪一类你没资格下结论。

先定一个设计决策,这是整篇的地基:这个工具的核心数据结构不是“分类结果”,是“证据记录”。

定完了。下面第一步就把它落成字段。

一、一份日志里你真正拿得到的东西,比你以为的少

Cloudflare 免费版的 AI Crawl Control 是按 user agent 字符串来认 AI 爬虫的,这是官方文档写着的。而 user agent 字符串由客户端自己选择发送,它不能作为发送者身份的证据。

这句话值得停一下,因为它意味着:你在面板上看到的每一个“AI 爬虫请求数”,严格来说是“自称是某个 AI 爬虫的请求数”。这两个数字在绝大多数日子里很接近,但在有人发现这个名字好用之后,它们会迅速分叉。分叉速度取决于这个名字能带来多少便利,跟 AI 公司那边的抓取策略没什么关系。

先把一条请求能提供的信息列清楚。我这份实现里的字段是这样,你拿到的日志 schema 不一样就照着改:

ts                  时间戳,源的
method, path, status 源站自己产生的,硬
ua                  客户端填的,软
asn                 Cloudflare 给的,中等偏硬,但只到自治域这一级
client_addr_present 免费版 AI Crawl Control 不保留客户端地址 → 恒为 false

前面几个都不新鲜,我重点说最后一个。client_addr_present 不是一个“我这次没拿到”的字段,是“这个字段在我的套餐里根本不存在”。这两件事的区别,会在第四步把我的架构掀翻一次,然后在第五步逼着我改设计。现在先记住它。

还有一条关于 ChatGPT-User 的:OpenAI 的文档把它描述成用户触发型代理——有人在 ChatGPT 或自定义 GPT 里提问,它才去访问页面,不是按计划爬。那问题就来了:一个“用户触发型”的东西,在 24 小时里触发了 859 次。要么提问的人真的很多,要么触发逻辑跟文档描述的不完全一样,要么这里面混着别的东西。这个疑问我不在这里回答,但我把 859 单独拎出来当一个桶,就是为了让它一直能被看见。

二、第一步:骨架,先让 1890 条日志能进能出

不管做什么分析,我都习惯先把最小可跑的骨架立起来:读得进来、数得出来、打印得出来。这一步不许有任何判断逻辑——判断是内脏,骨架阶段往里塞判断,最后你分不清是读错了还是判错了。

# stage1.py —— 骨架:只读、只数、不判断
import json, sys
from collections import Counter

def records(path):
    with open(path, encoding="utf-8", errors="replace") as f:
        for lineno, line in enumerate(f, 1):
            line = line.strip()
            if not line:
                continue
            try:
                yield json.loads(line)
            except json.JSONDecodeError:
                print(f"跳过第 {lineno} 行:不是完整 JSON", file=sys.stderr)

def main(path):
    n, status = 0, Counter()
    for r in records(path):
        n += 1
        status[r.get("status")] += 1
    print(f"总请求 {n}")
    for s, c in status.most_common(6):
        print(f"  {s}: {c}")

if __name__ == "__main__":
    main(sys.argv[1])

跑一下看看:

$ python stage1.py chatgpt-24h.ndjson
总请求 1890
  200: 1407
  404: 479
  403: 4

骨架立住了。它现在还什么都不会,只会数状态码。但 479 加 4 等于 483,跟外面传的那个失败数对上了,说明读进来的东西是真的。

顺带说一个我自己的习惯:骨架阶段我一定打一次总数和状态码分布,哪怕这两行马上就删。因为 ndjson 这个格式有个特别阴的坑——最后一行多半没有换行符,或者干脆是半行——json.loads 会抛,你就少读了一批。我最早的版本图省事用 json.load 整个吞,那份日志里恰好有两个 JSON 文档拼在一起,它一直读到报错为止。我花了半天时间在分类器里找 bug,最后发现输入本身就是坏的。这类“输入坏了但看起来像逻辑坏了”的坑,在日志分析里出现的频率高得离谱。

三、第二步:把“失败率”翻译成“失败在哪”

现在把 483 拆开。这一步的全部内容就是一个路径分类器。

那批失败路径先摆出来:/id_rsa/terraform.tfstate/.env.prod.bak/elmah.axd/@fs/proc/self/environ/.github/workflows/deploy.yml/.claude/settings.json/api/config。这几个东西凑在一块儿,稍微干过运维的人都不用想第二遍——这是扫描器的字典。/@fs/proc/self/environ 是 Vite 那个路径穿越的经典形态,/elmah.axd 是 .NET 的老错误日志,/.github/workflows/deploy.yml 是去翻 CI 配置里有没有明文密钥。它们指向的东西全是“密钥、状态、环境变量、CI 凭证”,没有一个是正常爬虫会关心的页面。

# stage2_paths.py —— 路径形态,不是敏感文件清单的穷举,是扫描器指纹的最小集
EXACT = {
    "/id_rsa",
    "/terraform.tfstate",
    "/.env.prod.bak",
    "/elmah.axd",
    "/@fs/proc/self/environ",
    "/.github/workflows/deploy.yml",
    "/.claude/settings.json",
    "/api/config",
}
PREFIX = ("/.git/", "/.env", "/wp-admin", "/vendor/", "/.aws/", "/.ssh/")
SUFFIX = (".bak", ".old", ".sql", ".tfstate", ".pem", ".key")

def path_class(path):
    p = path.split("?", 1)[0].lower()
    if p in EXACT:
        return "probe.exact"
    if p.startswith(PREFIX):
        return "probe.prefix"
    if p.endswith(SUFFIX):
        return "probe.suffix"
    return "normal"

这里有两个取舍我想讲一下。

第一个:为什么不用一条大正则梭哈。因为我要的不是“命中”,是“命中在哪一类”。probe.exactprobe.suffix 在后面的判断里权重不一样——精确命中的那八个路径没有第二种解释,而 .bak 结尾这种后缀命中,正常站点自己也可能有一两个。把它们分成不同的档,你后面才有调松紧的余地。一把梭哈写成一个巨大的 re.search,跑起来一样快,但你没有第二个旋钮。

第二个:为什么不做“命中即判定”,而是只给出形态标签。因为这是一个日志分析工具,不是 WAF。它面对的是历史,历史数据里最值钱的东西是你能反复重新分类。如果我在这里就把判断做死,等第三天发现某个前缀误伤了一片正常路径,我整份报告得重跑。形态标签可以重跑,判断结果不能重跑。

跑一下看看:

$ python stage2.py chatgpt-24h.ndjson
非 200 共 483 条
  probe.exact: 447
  normal:      36

probe.* 请求的自称分布
  ChatGPT-User: 447

447 除以 483。这是整件事里唯一一个我认为站得住的数字。

真正让我停下来的是下面那三行:447 条探测请求,447 条自称 ChatGPT-User。一条不漏。100%。

我不写“这很可疑”这种废话,说个更硬的判断。一个 100% 的相关性,在 447 个样本上,不可能是巧合。它只支持两个假设:要么 OpenAI 的抓取通道确实在扫这些路径,要么有人发现 ChatGPT-User 这个名字在日志里不会被拦,于是把它当隐身衣穿上了。这两个假设用日志本身分不出来,但它们对你要采取的动作影响完全相反——前者你要重新想抓取许可策略,是不是把敏感路径的响应形态做掉;后者你要改的是 WAF 规则,跟 OpenAI 一点关系都没有。

而免费版 AI Crawl Control 把这两种情况归进了同一个数字里。它没有做错什么,按 user agent 字符串分类是它在免费版里唯一能做的事,但你必须知道这个数字是什么成色。这就是我为什么要自己造这个工具:不是为了比面板更准,是为了知道面板给的那个数到底在数什么。

四、第三步:一条请求不是一个 label,是一组互不推导的字段

如果你把“自称”当成 label,你会得到一个一维饼图,那个饼图会把“OpenAI 的抓取”和“穿着 OpenAI 衣服的扫描器”算进同一格,然后你拿着这个格子去开会。

所以我把每一条请求降级成一个证据记录:

# stage3_evidence.py
from dataclasses import dataclass

@dataclass
class Evidence:
    claimed_agent: str            # 客户端自己说的,不算证据,算线索
    role_verified: bool | None    # 三态,见下
    path_class: str
    status: int
    client_addr_available: bool

第二个字段是这一步的全部。三态:True 是网络层确认它确实来自那个 agent 的官方发布段;False 是确认它不在;None 是这份日志里根本没东西可供判断。

None 不等于 False。这句话我直接写进报告表头,因为这是这类工具最常犯的错——把“没证据”和“反证据”混成一个桶,那个桶的数字看起来特别吓人,然后你拿着它去跟人讲“我们站被 AI 爬虫攻击了”,讲了三个月之后发现桶里大部分是“不知道”。

分桶逻辑本身很短:

def bucket(e):
    if e.role_verified is True:
        return "verified"
    if e.claimed_agent and e.path_class.startswith("probe."):
        return "claimed+probe"
    if e.claimed_agent:
        return "claimed-only"
    return "other-agents"

那 447 条落在 claimed+probe。它们的 role_verified 全是 None——为什么全是 None,看下一步。

到这里, 这个概念站住了。后面所有的数字都从这个函数出,报告怎么改都不会跑偏。

五、第四步:造一个前缀核对器,然后发现它是单向的

OpenAI 公开发布了各代理的 IP 段,ChatGPT-User 对应的清单在 openai.com/chatgpt-user.json。这一步我们造匹配器:给它一个地址,回答它是不是在这个清单里。

先把清单的形状说清楚,因为形状本身就是三条设计约束:204 个 IPv4 前缀,除一个以外全是 /28,一个 IPv6 条目都没有,生成日期 8 月 14 日。

匹配器我用整数区间加二分,不用 trie:

# stage4_prefixes.py
import bisect, ipaddress

class PrefixSet:
    def __init__(self, cidrs):
        spans = sorted(
            (int(ipaddress.ip_network(c).network_address),
             int(ipaddress.ip_network(c).broadcast_address))
            for c in cidrs
        )
        self.starts = [s for s, _ in spans]
        self.spans = spans

    def contains(self, addr):
        ip = ipaddress.ip_address(addr)
        if ip.version != 4:
            return None                     # 清单里没有 v6,返回“不知道”
        i = int(ip)
        j = bisect.bisect_right(self.starts, i) - 1
        return j >= 0 and i <= self.spans[j][1]

我说过我就是喜欢这种土办法,你追极致查找性能当然可以上 trie 或者 Patricia,这个规模下没意义,几百个区间二分的常数小到看不见。这是偏好,不是真理。

现在三条约束。

第一,/28 只有 16 个地址。 204 个前缀乘 16,三千出头个地址。所以这个清单能做的是“确认是”,永远做不了“确认不是”。contains() 返回 False 的时候你不能说“这不是 OpenAI”,你只能说“它不在我 8 月 14 日那份快照里”。这把工具从判定器降级成了确认器,只保留了单向能力。很多人在这儿翻车,是因为他们把核对器当过滤器用——过滤器要双向,核对器只有一向。

第二,没有 IPv6。 所以对任何 v6 来源,正确答案是“不知道”。上面那个 return None 就是为它写的。别把它改回 False,改回去之后你的报告里会凭空多出一批“假冒 OpenAI”的 IPv6 流量,而它们可能全是真的,只是人家清单还没发。这条我踩过。

第三,生成日期会走。 8 月 14 日生成的清单,今天正好一个月零一天。所以我的实现里强制有新鲜度检查:

def freshness_warning(built, today=None, limit_days=30):
    if not built:
        return "清单没有生成日期,无法判断新鲜度"
    d = datetime.date.fromisoformat(built[:10])
    age = ((today or datetime.date.today()) - d).days
    if age > limit_days:
        return f"清单已过期 {age} 天,结论只对 {built} 之前的分配有效"
    return None

我拿一份三个月前的清单下过一次结论,然后在评论里被按着头纠正,那次之后这个检查就变成硬性的了。快照日期和这条警告都往报告头里打一行,不打不让输出。

关于 loader 我诚实一点:我不知道这份文件的确切 schema,拿到它的时候我是先 dump 了一遍再写 loader 的。所以这段你别照抄,先 dump。

def load_snapshot(path):
    with open(path) as f:
        doc = json.load(f)
    # schema 请以你 dump 出来的为准,我这里只处理最常见的两种形态
    cidrs = doc if isinstance(doc, list) else doc.get("prefixes", [])
    built = doc.get("generated") if isinstance(doc, dict) else None
    return cidrs, built

到这里核对器写完了,测试也过了——造几个假地址,/28 边界上下的行为都对。然后我准备拿它去核那 412 条返回 200 的自称 ChatGPT-User 请求。

我意识到我没有客户端地址。

免费版 AI Crawl Control 按 user agent 字符串分类,而且不保留客户端地址。那 412 条请求的来源 IP 从来没进过我的日志。核对器写得再对,它没有输入。我在第一节列字段的时候写下了 client_addr_present: false,那时候我以为那只是一个字段的说明;它真正的含义是这一步的产物在离线侧是死代码

六、第五步:架构被掰成两半

发现自己造了个死代码之后,我做的第一件事是把它从离线流水线里摘出去,而不是硬塞。硬塞的写法我见过很多:拿 False 顶上,让报告看起来完整。那等于在报告里写假话。

于是这个工具变成两个产物。

一个离线归因报告。它只能给两样东西:自称、路径形态。role_verified 一栏恒为 None,我不美化它,就在表头明说“本套餐不可用”。它的价值在于把那 447 条揪出来,在于把 63% 这个比率拆成路径。

一个在线规则生成器。网络层的验证必须发生在边缘,那里有客户端地址,有 Cloudflare 已经做好的机器人验证结果。所以离线侧最后输出的不是结论,是一条表达式:

# stage5_rule.py —— 分析结果要落到一个能执行的动作上
def attribution_rule(ua_substring="ChatGPT-User"):
    return '(http.user_agent contains "%s") and not cf.client.bot' % ua_substring

cf.client.bot 这个字段值得单独说一句:它在所有套餐的 WAF 自定义规则里都有,携带的已验证机器人信号跟企业版那个字段是同一个。而更强的 cf.bot_management.verified_bot 和检测 ID 要 Enterprise 加 Bot Management 才能用。这是我这次唯一一个“因为套餐而做的技术选型”,我认了,但我要把它明白写出来,免得有人以为我没看见更好的字段。

这条规则读起来就是一句话:自称是 ChatGPT-User,而且没有通过 Cloudflare 的机器人验证,那就质询。它把“自称”和“验证”摆在了同一行里——这正是整个工具想干的事,只不过执行点从我的 Python 挪到了边缘。挪过去之后,那 447 条会撞在质询上,而那 412 条如果真来自 OpenAI 的话,会带着 cf.client.bot 通过。

这里有个必须交代的细节:我没办法预先告诉你这条规则会拦下多少。因为拦下的比例,取决于 447 里有多少真的是 OpenAI,而这个数我拿不到。所以下一步不是上线,是先搭对照组。

七、第六步:把报告跑出来

$ python stage6_report.py chatgpt-24h.ndjson
=== AI 代理归因报告 ===
窗口:            24h
清单快照:        chatgpt-user.json @ 2026-08-14
                 204 个 IPv4 前缀 · v4-only · 无 IPv6 条目
                 警告: 快照已过期 31 天
客户端地址:      不可用 —— 免费版 AI Crawl Control 按 UA 分类且不保留来源地址
角色验证:        全部 None(不是 False,是没有输入)

桶                      条数      角色验证
claimed+probe            447       None
claimed-only             412       None
other-agents           1,031       None
---------------------------------------------
合计                   1,890

跑得出来,但这份报告里有一个地方我要当场收回。

我第一版草稿把 claimed-only 那 412 条叫“真实抓取”。这个说法没有依据,我收回。

理由是同期有一份研究讲这件事:ChatGPT 从共享缓存里按 URL 提供已经打开过的页面,某个账号取到的页面副本可能被另一个国家的其他账号直接用掉,而且整个过程完全不产生对源站的请求。放到这份日志里,意思就是——你看到的这些 ChatGPT-User 命中,实际可能是为后续访问者做的刷新,不是当前提问者正在读你的页面。所以 412 这个数字不能读成“412 次有人读了你”。它是刷新的次数,读数在别的地方。

我把报告里那一栏的列名从 real_users 改成了 refreshes_likely。列名就是立场,改列名比改正文诚实。这件事对我的震动比 447 那批还大——447 我至少知道它是什么(扫描器),412 我原来以为我知道,其实不知道。

顺带把 1,031 那条也交代一句:1890 减去 859,是所有其他 AI 代理加起来。它没有单独的桶,因为这一篇我不想把版面花在别的 UA 上。等下一篇要接 IP 信誉的时候再拆它。

八、第七步:上线规则之前,先搭对照组

不做对照组的规则上线,等于你改了个数字然后声称因果。

我打算这么搭:这条 cf.client.bot 规则先只对一半路径生效。同一批 UA 命中,另一半路径只记录不质询,跑七天。七天之后对比两组的 UA 命中量、质询量、以及对照组的 200 比例。同时在对照组里额外记一个信息——这条命中到底有没有通过 cf.client.bot,只观测不下手。这等于是在免费套餐的边界内,尽量逼近一次来源验证,虽然它给不了 IP。

对照组还有一个副作用是我想要的:它会回答“447 条里有多少真的是 OpenAI”这个问题的一半。如果开启质询之后,claimed+probe 那一桶的量几乎归零,说明那批扫描器是跟着 UA 白名单走的,它们会躲;如果量不变,说明它们要么不介意被质询,要么它们本就有通过验证的身份。两种结果指向完全不同的加固方向。

九、第八步:往外接一层——IP 信誉不是布尔值

这个工具目前只干“自称 + 路径”两个维度。第三维度是 IP 信誉,我打算留个接口,不在这篇里实现完。

理由是我看了别人一套做法之后发现这块比我想的复杂得多。有一套按 /24 段评级的思路:没发现脏地址算干净,低于 10% 算可接受,低于 30% 算脏,更高算有毒,未知地址继承它所在网段的评级,再拿 ASN 的历史记录判断标签新不新鲜。

这套评级我打算直接接进来当第二意见,但有一个前提我要先表态:别拿 ip-api 和 proxycheck 两个源投票就完事。这两个源会把整个 AWS 段和 Hetzner 段笼统标成代理,它们投出来的两张票其实是同一张票。所以我的规则是——数据中心地址要两个独立来源才算数,住宅地址一个来源就够。这条不是从哪本书上看来的,是我自己在这类误伤上栽过之后定的。

接口大概长这样,占位先摆着:

class SegmentRating:
    # unknown 继承所属网段的评级,不要默认成 clean
    def lookup(self, addr, asn): ...

这一层做完了,报告就能从一维饼图变成二维矩阵:横轴是自称,纵轴是路径形态,格子里再叠一层网段评级。那时候你才第一次能看见“自称 OpenAI、路径是扫描、网段是某个 VPS 商”这种组合,而不是一个 63%。

边界,和下一步

到这里,这个工具已经能跑了——从空目录到大概五百行 Python,它能把一份 1890 条的日志读进来,分出 claimed+probe / claimed-only / other-agents 三桶,输出一份带清单快照日期和过期警告的报告,外加一条能贴进 WAF 的表达式。它还把 412 这个数从“真实抓取”改回了“可能是刷新”,这一步是我这次最不想省掉的。

但它没做的东西得说清楚。不做 TLS 指纹,不处理 IPv6 验证(清单里就没 v6),不做流式,不做告警,不碰 ML,不做质询之后的二次识别。路径分类器是硬编码规则集,不是可学习的东西——这是有意的,一个玩具版的分类器如果引入了模型,你就再也说不清是哪条规则起作用了。另外它拿不到客户端地址这件事,不是我代码的问题,是套餐的问题,所以那 412 条在我这儿永远核不了。

下一步三个方向,按我认为的价值排序:把路径分类器做成可插拔规则集,这样换一个站点只需要换规则文件;把 /24 评级接进来做第二意见,让报告从一维变二维;再往后,把离线报告和在线规则合成一个闭环,跑一个星期,看那 447 条到底掉不掉。

最后说一句判断。这件事的意义不在那个 63%,在于起初那几个数字本来会把人引向一个完全相反的方向——看起来像站点故障,实际是一批穿着 OpenAI 衣服的扫描器。机器人流量现在越来越难可靠分类,所以验证来源比信任 user agent 更必要,而不是更可选。你手里的 free 套餐给不了那个验证,但至少你可以选择不再把“自称”抄成“是”。

到这一步,我们不跳。

造物
造物

万字手把手从零造一个项目(compiler/DB/解释器),每步代码能跑、讲到能复现。

查看主页 →