AI爬虫真实身份验证实战:UA 说了不算,怎么知道谁在抓你的网站

2026-09-09 技术文章

服务器日志里每天都有几十条 GPTBot、ClaudeBot 的记录,但这些流量真的来自对应厂商吗?User-Agent 只是请求方自己填的一行文本,任何人都能伪造。本文给出可落地的三层验证方案:官方 IP 段匹配、FCrDNS 双向反查、行为指纹兜底,配套三个可运行的 Python 脚本,帮你把日志里"自称的爬虫"和"真实的爬虫"分开,顺便识别出那些挂羊头卖狗肉的伪装流量。在易科势腾做官网 AI 友好化改造的项目里,这套验证逻辑是数据分级与 robots 策略能落地的前提——规则写得再对,也得先知道来访者到底是谁。

服务器日志里每天都有几十条 GPTBot、ClaudeBot 的记录,但这些流量真的来自对应厂商吗?User-Agent 只是请求方自己填的一行文本,任何人都能伪造。本文给出可落地的三层验证方案:官方 IP 段匹配、FCrDNS 双向反查、行为指纹兜底,配套三个可运行的 Python 脚本,帮你把日志里"自称的爬虫"和"真实的爬虫"分开,顺便识别出那些挂羊头卖狗肉的伪装流量。在易科势腾做官网 AI 友好化改造的项目里,这套验证逻辑是数据分级与 robots 策略能落地的前提——规则写得再对,也得先知道来访者到底是谁。

本文要点:

  • 三层验证体系:L1 官方 IP 段匹配(强信号)→ L2 FCrDNS 双向反查(无段厂商)→ L3 行为指纹(兜底),验证结果分三分类:可信 / 矛盾 / 无法验证
  • 三个可运行脚本:IP 段匹配验证器、FCrDNS 验证器、UA 身份轮换检测器,全部纯标准库、零第三方依赖
  • 反例验证贯穿全文:自称 GPTBot 却来自公共 DNS 服务地址的 IP、一天内轮换四种身份的 IP、单向 PTR 伪造,每个反例都对应一个真实发生过的案例
  • 诚实交代边界:有的厂商(如 Bytespider)至今没有可独立验证的手段,“无法验证"不等于"伪造”,这是方案设计必须保留的分类

一、日志里全是 GPTBot,但你一个都不认识

先看一个我处理过的真实场景。某企业官网做了 AI 友好化改造后,运维同学从 Nginx 日志里统计"这个月 AI 爬虫来了多少次",得出一个看起来很漂亮的数字:GPTBot 3000 次、ClaudeBot 1200 次。然后把这份统计当成了 AI 渠道引流的依据。

问题在于,这个统计只验证了一件事:请求的 User-Agent 字符串里写着 GPTBot。而 User-Agent 是客户端随手填的,服务器没有任何机制校验它。等于有人敲门说"我是快递员",你就直接记了一笔快递进账——既不核对工牌,也不看车牌。

这不是理论风险。几个公开案例可以说明失真有多严重:

  1. 一个 IP 一天轮换四种身份。LumenGEO 在 2026 年 6 月对自家站点做过两周的服务器日志统计(数据来源:lumengeo.co 的 AI Crawler List 2026),3392 次自称 AI 爬虫的请求里,2.0% 的源 IP 完全不在对应厂商公布的网段内(实锤伪造),40% 根本无从验证。最极端的一条:同一个 IP 在同一天里先后自称 ChatGPT-User、GPTBot、OAI-SearchBot、PerplexityBot。真实爬虫不会这么干。
  2. 伪装成 Chrome 的 stealth 爬虫。Cloudflare 在 2025 年 8 月发布报告,指出某 AI 搜索厂商在被 robots.txt 和 WAF 拦截后,会改用普通的 Chrome UA、并轮换官方网段之外的 IP 和 ASN 继续抓取。这个案例的细节我们在第五节展开。
  3. 被冒充的老实人。Common Crawl 的 CCBot 因为名声好、网段公开,反而成了伪装的热门对象——连它都被第三方冒用过。

所以这里有个残酷的结论:User-Agent 只能告诉你"该用哪种验证方法去查",不能告诉你"来的是谁"。

顺带解释一个很多人问过的问题:为什么 Google Analytics 里的"AI 爬虫"数据也不准?因为 GA 落在浏览器端的统计脚本上,爬虫大多不执行 JS,GA 看到的只是"已声明自己身份的"那一小部分;而真正需要警惕的伪装流量和匿名抓取,GA 根本看不到。想统计谁在抓你的网站,只能回到服务器日志这一层,用 IP 做验证,而不是信 UA。

如果你看过系列里那篇《企业官网 AI 友好化改造中的数据安全合规》里配套的 check_robots.py,这里先厘清一个分工:那个脚本是"规则模拟器"——拿 UA 和路径去跑 robots 规则,验证你写的配置对不对;本篇要做的是"流量验证器"——拿真实访问日志里的 IP 去核对身份,验证来的到底是谁。前者管配置,后者管访客,是两码事。后文会看到,规则写得再严密,也拦不住一个不守规矩、甚至伪装身份的来访者。

二、三层验证体系:先把结果分成三种,而不是两种

做验证之前,先想清楚输出应该长什么样。常见的错误做法是二分类:验证通过=官方爬虫,验证失败=伪造。这个模型在现实里会栽跟头,因为存在第三种情况:厂商根本没给你验证手段。

举一个具体的例子。字节跳动的 Bytespider 是目前流量很大的训练爬虫之一,但它没有可用的官方验证口径——头条站长平台虽公布过 10 个 /24 网段,实测流量却大量来自这些段之外,FCrDNS 反查后缀也没公布。你日志里看到自称 Bytespider 的流量,是真的字节跳动在抓,还是某个第三方借用这个名字(这个名字因为广泛被拦,反而成了掩护)?目前没有办法从身份层区分。这种流量你不能判"伪造",只能判"无法验证"。

把"无法验证"单列出来很重要,原因有二:一是避免误伤——有些守规矩的爬虫只是没提供验证手段,把"无法验证"当"伪造"处理会误封;二是逼出对策——无法验证的流量,你要么接受它、要么用行为层观察它,而不是假装问题不存在。

三层验证体系的分工如下:

层级验证依据适用对象强度局限
L1官方公布的 IP 段OpenAI、Anthropic、Perplexity 等发布段表的厂商最强,直接判可信/矛盾段表会变,需定期刷新;只覆盖公布段的厂商
L2FCrDNS 双向反查Google、Bing、Apple、Common Crawl 等公布反查后缀的厂商强,能防单向伪造PTR 可伪造,必须配合 A 记录回查
L3行为指纹(UA 轮换、频率、路径规律)不公布任何验证手段的流量、以及伪装成普通浏览器的流量弱,只能标"疑似"无法定罪,只能作为限流/告警依据

三层是叠加关系而不是互斥:L1、L2 是"身份验证",给出确定的答案;L3 是"行为观察",在身份验证给不出答案时兜底。对应到输出,就是下面三种状态,本文所有脚本都围绕这个三分类设计:

  • 可信(verified):源 IP 与自称身份匹配,可以作为统计和策略依据
  • 矛盾(mismatch):自称某厂商但源 IP 对不上,疑似伪装,需要人工复核
  • 无法验证(unverifiable):厂商没给手段,或 UA 压根没声称自己是爬虫

下面三节分别讲每一层怎么落地。

三、L1:官方 IP 段匹配

这是最直接也最可靠的一层。好消息是,主流 AI 厂商这几年陆续公布了自家爬虫的 IP 段,以 JSON 文件形式对外提供,方便站长做服务端校验。截至 2026 年 9 月,主流厂商的发布情况如下:

厂商爬虫官方 IP 段文件说明
OpenAIGPTBot(训练)https://openai.com/gptbot.json每个爬虫一个文件,互不干扰
OpenAIOAI-SearchBot(搜索)https://openai.com/searchbot.json出现在 ChatGPT 搜索结果里靠它
OpenAIChatGPT-User(用户触发抓取)https://openai.com/chatgpt-user.json用户点了链接才抓,非自动爬取
AnthropicClaudeBot / Claude-User / Claude-SearchBothttps://claude.com/crawling/bots.json三家共用一个文件
PerplexityPerplexityBot / Perplexity-Userhttps://www.perplexity.ai/perplexitybot.json 等各一个文件
GoogleGooglebot 及通用爬虫https://developers.google.com/static/crawling/ipranges/common-crawlers.json官方推荐优先用 FCrDNS 验证(见第四节)
MicrosoftBingbothttps://www.bing.com/toolbox/bingbot.json同时支持 FCrDNS
Common CrawlCCBothttps://index.commoncrawl.org/ccbot.json运行在 AWS 网段

一个容易踩的误区顺便说掉:Google-Extended、Applebot-Extended 不是爬虫。它们是 robots.txt 里控制"已抓取内容能否用于 AI 训练"的标记 token,没有独立的 UA、没有独立流量,所以你在日志里永远找不到"Google-Extended 访问记录"——这不是配置错了,是它本来就不存在。有人为了"禁止 AI 使用内容"而在日志里找这个 UA 找半天,属于白费功夫。

验证逻辑本身不复杂:请求的源 IP 落在"它自称的那个厂商"公布的网段内,就是可信的;落在别的厂商网段或任何厂商网段之外,就是矛盾的。下面这个脚本把这件事做成可以天天跑的工具。

动手前先说清一个边界:脚本比对的是 IPv4 官方段。日志里出现 IPv6 源地址(Google 的爬虫就有 IPv6 出口)时,它会诚实地标"无法验证",而不是因为"对不上"就误判"矛盾"——认不出就认不出,别把带 IPv6 出口的真爬虫冤枉成伪装。

# verify_crawler_ip.py —— AI 爬虫 IP 段匹配验证器(L1 层)
"""
把日志里"自称是 AI 爬虫"的请求按源 IP 与厂商网段比对,输出三分类:
  [OK] verified      源IP命中该厂商网段,身份可信
  [!!] mismatch      自称某厂商但源IP不在其网段内——疑似伪装,需人工复核
  [--] unverifiable  该厂商官方段表覆盖不全(如Bytespider),或当前模式未载入其网段,或UA未声称任何AI爬虫

注:本脚本只比对 IPv4 官方段;IPv6 源地址一律标 unverifiable(无法验证),
    不会因为"对不上"就误判 mismatch。官方 JSON 里的 IPv6 段也不做提取。

用法:
  python3 verify_crawler_ip.py                            # 示例模式:示意网段+示例日志
  python3 verify_crawler_ip.py --file 段表.txt --log 日志.tsv   # 用自己的段表和日志
  python3 verify_crawler_ip.py --fetch-openai --log 日志.tsv   # 在线拉取 OpenAI 三个爬虫的官方段

日志格式(--log):每行两列、Tab 分隔:源IP<TAB>完整User-Agent
从 nginx combined 格式日志生成该文件:
  awk '{for(i=12;i<=NF;i++)ua=ua" "$i; print $1"\t"ua; ua=""}' access.log
"""
import argparse
import ipaddress
import json
import re
import sys
import urllib.error
import urllib.request

# ============ 网段数据 ============

# 示例模式用的"示意网段":全部取自 RFC 5737 保留的文档测试段,
# 真实网络里不会出现这些 IP,只用来演示判定逻辑,切勿当真实网段使用。
# 生产环境请用 --file 传入自己维护的段表,或用 --fetch-openai 拉取官方 JSON。
DEMO_RANGES = {
    "GPTBot(OpenAI)": ["192.0.2.0/24"],
    "ClaudeBot(Anthropic)": ["198.51.100.0/24"],
    "PerplexityBot": ["203.0.113.0/24"],
}

# UA 标记 -> 期望的厂商 key(大小写不敏感,version 号忽略)
TOKEN_VENDOR = {
    "gptbot": "GPTBot(OpenAI)",
    "oai-searchbot": "OAI-SearchBot(OpenAI)",
    "chatgpt-user": "ChatGPT-User(OpenAI)",
    "claudebot": "ClaudeBot(Anthropic)",
    "claude-user": "Claude-User(Anthropic)",
    "claude-searchbot": "Claude-SearchBot(Anthropic)",
    "perplexitybot": "PerplexityBot",
    "perplexity-user": "Perplexity-User",
    "ccbot": "CCBot(Common Crawl)",      # 实际运行在AWS,建议走L2
    "googlebot": "Googlebot(Google)",     # 官方推荐走L2 FCrDNS
    "bingbot": "Bingbot(Microsoft)",      # 同时支持L2
    "bytespider": "Bytespider(ByteDance)",  # 官方段表覆盖不全 -> unverifiable
}

# ============ 判定逻辑 ============

# 官方段表覆盖不全、且无反查后缀的厂商(正文第七节口径):无法用 IP 段验证,只能标 unverifiable
NO_OFFICIAL_RANGES = {"Bytespider(ByteDance)"}

# 只匹配 IPv4;官方 JSON 里的 IPv6 段本脚本不比对(见顶部 docstring 注)
CIDR_RE = re.compile(r"\d{1,3}(?:\.\d{1,3}){3}/\d{1,2}")

def parse_cidr_list(lines):
    """把 ['# 注释', '1.2.3.0/24', ...] 解析为 ipaddress 网络集合"""
    nets = []
    for line in lines:
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        m = CIDR_RE.search(line)
        if not m:
            print(f"[!] 跳过无法识别的行: {line}", file=sys.stderr)
            continue
        try:
            nets.append(ipaddress.ip_network(m.group(0), strict=False))
        except ValueError as e:
            print(f"[!] 无效网段 {line}: {e}", file=sys.stderr)
    return nets

def ip_in_any(ip, nets):
    return any(ip in net for net in nets)

def match_token(ua):
    """从 UA 里找 AI 爬虫标记,返回 token;找不到返回 None"""
    low = ua.lower()
    for token in TOKEN_VENDOR:
        # 用单词边界匹配,避免 gptbot 匹配到 xxxgptbotxxx 之类
        if re.search(r"(^|[^a-z])" + token + r"([^a-z]|$)", low):
            return token
    return None

def classify(ip_str, ua, ranges):
    """
    返回 (status, vendor, note)
    status: ok / mismatch / unverifiable / browser / ignore
    """
    try:
        ip = ipaddress.ip_address(ip_str)
    except ValueError:
        return ("ignore", "-", f"非法IP: {ip_str}")
    token = match_token(ua)
    if token is None:
        # UA 没有声称是任何 AI 爬虫;若 IP 落在某厂商网段内给一条提示
        for vendor, nets in ranges.items():
            if ip_in_any(ip, nets):
                label = "自定义段" if vendor == "_pool" else vendor
                return ("browser", label,
                        "UA无bot标记但IP在厂商网段内,可能是用户触发抓取或该网段其它服务,不作为伪装证据")
        return ("ignore", "-", "普通流量,非本脚本职责范围")
    vendor = TOKEN_VENDOR[token]
    if ip.version == 6:
        # 官方段表多为 IPv4、本脚本也只比对 IPv4。IPv6 判不了就诚实标 unverifiable,
        # 绝不能因为"对不上"就判 mismatch,冤枉带 IPv6 出口的真爬虫。
        return ("unverifiable", vendor, "IPv6 源地址不在本脚本 IPv4 比对范围——无法验证,勿当作 mismatch")
    nets = ranges.get(vendor)
    if nets is None:
        # 厂商映射里没有该厂商的段:查 _pool(--file 单段表模式)
        nets = ranges.get("_pool")
        if nets is None:
            if vendor in NO_OFFICIAL_RANGES:
                return ("unverifiable", vendor, "官方段表覆盖不全,无法用IP段验证(只能走L3行为观察)")
            return ("unverifiable", vendor,
                    "本模式未载入该厂商官方网段——用 --fetch-openai/--file 载入后重跑,或改走 L2 FCrDNS")
        if ip_in_any(ip, nets):
            return ("ok", vendor, "命中自定义段表——厂商归属请按段表自行核对")
        return ("mismatch", vendor, "源IP不在自定义段表内——疑似伪装")
    if ip_in_any(ip, nets):
        return ("ok", vendor, "源IP命中官方网段")
    # 没命中自称的厂商,看看是不是落在别的厂商网段里
    def _family(key):
        m = re.search(r"\(([^)]+)\)", key)
        return m.group(1) if m else None
    vfam = _family(vendor)
    for other, onets in ranges.items():
        if other != vendor and ip_in_any(ip, onets):
            if vfam and vfam == _family(other):
                return ("mismatch", vendor,
                        f"自称{vendor}但源IP落在{other}网段——同厂商不同爬虫,段表或有重叠,需人工复核")
            return ("mismatch", vendor,
                    f"自称{vendor}但源IP落在{other}网段——跨厂商冒充,高度疑似伪装")
    return ("mismatch", vendor, f"源IP不在{vendor}官方网段——疑似伪装或厂商网段已变更,需复核")

# ============ 示例数据 ============

DEMO_LOG = """192.0.2.10	Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.2; +https://openai.com/gptbot
114.114.114.114	Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)
203.0.113.55	Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)
198.51.100.5	Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ClaudeBot/1.0; +https://claude.com/bots
203.0.113.8	Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/bot)
203.0.113.9	Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36
60.8.151.23	Mozilla/5.0 (Linux; Android 5.0) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Safari/537.36 (compatible; Bytespider; spider-feedback@bytedance.com)
"""

def run_log(log_lines, ranges, out=sys.stdout):
    status_count = {}
    for line in log_lines:
        line = line.rstrip("\n")
        if not line.strip():
            continue
        parts = line.split("\t")
        if len(parts) < 2:
            print(f"[!] 跳过无法解析的行: {line}", file=sys.stderr)
            continue
        ip_str, ua = parts[0].strip(), "\t".join(parts[1:]).strip()
        status, vendor, note = classify(ip_str, ua, ranges)
        status_count[status] = status_count.get(status, 0) + 1
        if status == "ok":
            print(f"[OK] verified     | {vendor:<20} | {ip_str:<16} | {note}")
        elif status == "mismatch":
            print(f"[!!] mismatch     | {vendor:<20} | {ip_str:<16} | {note}")
        elif status == "unverifiable":
            print(f"[--] unverifiable | {vendor:<20} | {ip_str:<16} | {note}")
        elif status == "browser":
            print(f"[~]  browser-note | {vendor:<20} | {ip_str:<16} | {note}")
    print("-" * 90)
    print(f"汇总: {status_count}")

# ============ 在线拉取官方段(OpenAI / Google 格式) ============

def fetch_cidrs(url):
    """拉取官方 JSON 并提取其中所有 IPv4 网段字符串(IPv6 段不提取、不比对)。
    各厂商 JSON 结构不同,这里递归扫全字段,凡形如 x.x.x.x/n 的都收进来。"""
    req = urllib.request.Request(url, headers={"User-Agent": "crawler-verify/1.0"})
    with urllib.request.urlopen(req, timeout=20) as resp:
        data = json.load(resp)
    found = set()

    def walk(o):
        if isinstance(o, dict):
            for v in o.values():
                walk(v)
        elif isinstance(o, list):
            for v in o:
                walk(v)
        elif isinstance(o, str):
            found.update(CIDR_RE.findall(o))
    walk(data)
    return sorted(found)

# ============ 入口 ============

def main():
    ap = argparse.ArgumentParser(description="AI 爬虫 IP 段匹配验证器 (L1)")
    ap.add_argument("--file", help="本地网段文件,每行一个 CIDR,支持 # 注释")
    ap.add_argument("--fetch-openai", action="store_true",
                    help="在线拉取 OpenAI 三个爬虫的官方网段(gptbot/searchbot/chatgpt-user.json)")
    ap.add_argument("--log", help="日志文件:每行 源IP<TAB>UA;缺省用内置示例日志")
    args = ap.parse_args()

    if args.file:
        with open(args.file, encoding="utf-8") as f:
            lines = [l for l in f]
        nets = parse_cidr_list(lines)
        # 单文件段表无法区分厂商:以 _pool 兜底,命中即 OK,厂商归属自行核对
        ranges = {"_pool": nets}
        print(f"# 本地段表模式:共 {len(nets)} 个网段,命中即OK,厂商归属请按段表核对")
    elif args.fetch_openai:
        openai_urls = {
            "GPTBot(OpenAI)":       "https://openai.com/gptbot.json",
            "OAI-SearchBot(OpenAI)": "https://openai.com/searchbot.json",
            "ChatGPT-User(OpenAI)":  "https://openai.com/chatgpt-user.json",
        }
        ranges, ok = {}, True
        for vendor, url in openai_urls.items():
            print(f"# 正在拉取 {url} ...")
            try:
                cidrs = fetch_cidrs(url)
            except (urllib.error.URLError, OSError, ValueError) as e:
                print(f"[!] 拉取失败:{e}", file=sys.stderr)
                print("[!] 网络/证书/格式异常时,请手动下载官方 JSON 并转成'每行一个 CIDR',用 --file 传入。",
                      file=sys.stderr)
                ok = False
                break
            ranges[vendor] = [ipaddress.ip_network(c, strict=False) for c in cidrs]
            print(f"#   {vendor}: {len(cidrs)} 个网段")
        if not ok:
            sys.exit(1)
    else:
        print("# 示例模式:使用 RFC5737 示意网段,仅演示判定逻辑,生产请 --file / --fetch-openai")
        ranges = {k: parse_cidr_list(v) for k, v in DEMO_RANGES.items()}

    log_lines = []
    if args.log:
        with open(args.log, encoding="utf-8") as f:
            log_lines = f.readlines()
    else:
        log_lines = DEMO_LOG.splitlines(keepends=True)

    run_log(log_lines, ranges)

if __name__ == "__main__":
    main()

这段代码的核心在 classify 函数,逻辑可以对着示例日志走一遍:

  • 192.0.2.10 自称 GPTBot,而示例网段把 GPTBot 划在 192.0.2.0/24 里 → 命中,输出 [OK] verified
  • 114.114.114.114 自称 GPTBot,但该 IP 是公共 DNS 服务地址,不在任何厂商网段 → 输出 [!!] mismatch,这条就是伪装样本
  • 203.0.113.55 自称 GPTBot,却落在 Perplexity 的示意网段里 → 输出 [!!] mismatch 且附注"跨厂商冒充",这是比上一条更强烈的伪装信号
  • 60.8.151.23 自称 Bytespider,但它的官方段表覆盖不全 → 输出 [--] unverifiable,脚本明确告诉你"没法用 IP 段验证",而不是假装它能判断

三条 mismatch/unverifiable 的反例分别对应真实世界里的三类情况:冒充者、厂商网段变更、无验证手段厂商。设计这些反例的目的不是让脚本"看起来能跑",而是确认它在最容易被误判的输入上不会给出误导结论——如果示例里全是真爬虫,脚本跑得再绿也说明不了问题。

实际使用时有两点提醒。一是段表会变。Anthropic 官方文档里自己都写了:IP 段会调整,单纯靠 IP 封锁不可靠,robots.txt 才是它认可的主控方式。所以脚本拉回来的段表要定期刷新(建议加进 crontab 每周拉一次),否则迟早把真爬虫的旧段当成"未命中"误报。二是 --file 模式属于"自定义段表",如果你只有一个网段文件,脚本无法区分它属于哪个厂商,判定会退化成"命中即 OK",这部分逻辑在生产里要按自己的段表结构调整。

跑一遍示例模式,输出大致是这样(行号仅示意):

$ python3 verify_crawler_ip.py
# 示例模式:使用 RFC5737 示意网段,仅演示判定逻辑,生产请 --file / --fetch-openai
[OK] verified     | GPTBot(OpenAI)       | 192.0.2.10       | 源IP命中官方网段
[!!] mismatch     | GPTBot(OpenAI)       | 114.114.114.114  | 源IP不在GPTBot(OpenAI)官方网段——疑似伪装或厂商网段已变更,需复核
[!!] mismatch     | GPTBot(OpenAI)       | 203.0.113.55     | 自称GPTBot(OpenAI)但源IP落在PerplexityBot网段——跨厂商冒充,高度疑似伪装
[OK] verified     | ClaudeBot(Anthropic) | 198.51.100.5     | 源IP命中官方网段
[OK] verified     | PerplexityBot        | 203.0.113.8      | 源IP命中官方网段
[~]  browser-note | PerplexityBot        | 203.0.113.9      | UA无bot标记但IP在厂商网段内,可能是用户触发抓取或该网段其它服务,不作为伪装证据
[--] unverifiable | Bytespider(ByteDance) | 60.8.151.23      | 官方段表覆盖不全,无法用IP段验证(只能走L3行为观察)
------------------------------------------------------------------------------------------
汇总: {'ok': 3, 'mismatch': 2, 'browser': 1, 'unverifiable': 1}

注意最后两行的处理。203.0.113.9 是"IP 在厂商网段内但 UA 是普通 Chrome"——脚本只给一条提示,不判伪装。因为 Perplexity 这类厂商的网段里可能跑着用户触发式抓取(Perplexity-User 本来就是用户问问题时服务端去取网页),也可能是该网段上的其它服务。真实案例里 Cloudflare 抓到的 stealth 行为恰恰用的是官方网段之外的 IP(第五节细说),所以"段内 + 浏览器 UA"并不能作为伪装证据,宁可放过、不可误杀。

四、L2:FCrDNS 双向验证

L1 能覆盖发布网段的厂商,但还有一类厂商(Google、Bing、Apple、Common Crawl)走的是另一条路:公布反向 DNS 后缀。Google 的做法是:你看到的 IP 反查(PTR)出来的主机名必须落在 googlebot.com / google.com / googleusercontent.com 这几个域里,并且这个主机名再正向解析(A 记录)必须指回同一个 IP。这个方法 Google 用了二十年,是验证 Googlebot 的标准姿势。

很多人只做了前半段(反查域名后缀匹配),这不够。单向 PTR 是可以伪造的:只要控制一段 IP 的反向 DNS 区域,任何人都能把某个 IP 的 PTR 记录指到 crawl-xxx.googlebot.com 这种名字上。真正能锁死身份的,是后半段的正向回查——googlebot.com 域名的解析权在 Google 手里,一个主机名能解析回哪个 IP 由 Google 说了算,第三方不可能让不属于 Google 的 IP 出现在 Google 自己域名的 A 记录里。前半段验证"这 IP 自称叫什么",后半段验证"这个名字认不认识这个 IP",两个都过了,身份才闭合。

先厘清哪些厂商适用 FCrDNS,因为网上大量教程在这里教错了:

厂商反查后缀是否适合 FCrDNS
Google*.googlebot.com、*.google.com、*.googleusercontent.com是,官方推荐方式
Microsoft Bing*.search.msn.com是
Apple*.applebot.apple.com是
Common Crawl*.crawl.commoncrawl.org(运行在 AWS)是
OpenAI(GPTBot 等)未公布稳定反查后缀否,网上"反查 GPTBot 到 openai.com"的教程基本不可信,请用 L1
Anthropic未公布否,用 L1
Perplexity未公布否,用 L1

看到 OpenAI/Anthropic 那一行的结论了吗?这也是本文第一节说"UA 只决定用哪种验证方法"的实例——你没法用同一套方法验证所有厂商。如果你的运维同学从某篇老教程里学会了"反查 IP 域名含 openai.com 就是 GPTBot",那基本是在拿伪造友好的单点信号当验证,建议把这篇文章转给他。

下面脚本实现完整的 FCrDNS,演示模式下用内置案例(不依赖网络),--live 模式对单个 IP 做真实查询。

# fcdns_verify.py —— FCrDNS 双向验证器(L2 层)
"""
对给定 IP 做"前向确认的反向 DNS"验证:
  1) PTR 反查:IP -> 主机名
  2) 域名校验:主机名必须落在允许的后缀列表里(如 googlebot.com)
  3) A 记录回查:该主机名正向解析出的地址必须包含原 IP
三步全过才算 verified。单向 PTR 可伪造,回查是防伪的关键。

用法:
  python3 fcdns_verify.py              # 演示模式:内置案例,不联网
  python3 fcdns_verify.py --live 66.249.66.1 Google   # 真实查询单个IP(厂商传 ALLOWED_SUFFIX 的 key)
"""
import argparse
import socket
import sys

# ============ 允许的反查后缀(按厂商) ============
ALLOWED_SUFFIX = {
    "Google":   ("googlebot.com", "google.com", "googleusercontent.com"),
    "Bing":     ("search.msn.com",),
    "Apple":    ("applebot.apple.com",),
    "CommonCrawl": ("crawl.commoncrawl.org",),
}

# ============ DNS 查询抽象(演示模式可注入假数据,无需联网) ============

def real_ptr(ip):
    """真实 PTR 反查:返回主机名或 None"""
    try:
        return socket.gethostbyaddr(ip)[0]
    except (socket.herror, socket.gaierror, OSError):
        return None

def real_a(host):
    """真实 A/AAAA 回查:返回解析出的地址集合"""
    try:
        infos = socket.getaddrinfo(host, None)
    except socket.gaierror:
        return set()
    addrs = set()
    for info in infos:
        addrs.add(info[4][0])
    return addrs

def verify_fcdns(ip_str, suffix_set, ptr_fn=real_ptr, a_fn=real_a):
    """
    返回 (status, host, detail)
    status: pass / fail_ptrsuffix / fail_forward / unknown / not_crawler
    """
    host = ptr_fn(ip_str)
    if not host:
        return ("unknown", None, "无反查记录,无法验证")
    low = host.lower()
    # 归一化:去掉结尾句点
    if low.endswith("."):
        low = low[:-1]
    if not any(low == s or low.endswith("." + s) for s in suffix_set):
        return ("not_crawler", host, f"PTR为{host},不在允许后缀内——不是目标厂商爬虫")
    # 域名校验通过,做正向回查
    addrs = a_fn(low)
    if ip_str in addrs:
        return ("pass", host, f"PTR {host} 回查命中 {ip_str},双向闭合")
    # 兼容 IPv4-mapped IPv6 形式
    if any(a == "::ffff:" + ip_str for a in addrs):
        return ("pass", host, f"PTR {host} 回查命中(IPv4-mapped) {ip_str}")
    return ("fail_forward", host,
            f"PTR 声称 {host},但该主机名解析到 {sorted(addrs)[:3]},不含 {ip_str}——PTR 可能被伪造")

# ============ 演示数据(假 DNS,仅展示逻辑) ============

class FakeResolver:
    """演示模式用的假 DNS 表(注意:方法名 ptr/a 与数据表用不同属性名,避免覆盖)"""
    def __init__(self):
        self.ptr_map = {
            "66.249.66.1":   "crawl-66-249-66-1.googlebot.com",
            "203.0.113.55":  "crawl-203-0-113-55.googlebot.com",  # 伪造 PTR!
            "198.51.100.20": None,
            "8.8.8.8":       "dns.google",
        }
        self.a_map = {
            "crawl-66-249-66-1.googlebot.com": {"66.249.66.1"},
            "crawl-203-0-113-55.googlebot.com": {"198.51.100.99"},  # 回查露馅
            "dns.google": {"8.8.8.8", "8.8.4.4"},
        }
    def ptr(self, ip):
        return self.ptr_map.get(ip)
    def a(self, host):
        return self.a_map.get(host, set())

def run_demo():
    print("# 演示模式:内置假 DNS 数据,不联网。验证 Google 爬虫身份")
    fake = FakeResolver()
    cases = [
        ("66.249.66.1",   "Google", "真实 Googlebot:PTR 后缀对、回查命中"),
        ("203.0.113.55",  "Google", "伪装案例:PTR 被指向 googlebot.com,但回查不命中"),
        ("198.51.100.20", "Google", "普通 IP:无反查记录"),
        ("8.8.8.8",       "Google", "非 Google 爬虫:PTR 是 dns.google 而非 googlebot.com"),
    ]
    for ip_str, vendor, note in cases:
        suffix_set = ALLOWED_SUFFIX[vendor]
        status, host, detail = verify_fcdns(
            ip_str, suffix_set, ptr_fn=fake.ptr, a_fn=fake.a)
        tag = {"pass": "[OK]", "fail_forward": "[!!]",
               "fail_ptrsuffix": "[!!]", "unknown": "[--]", "not_crawler": "[--]"}[status]
        print(f"{tag} {status:<14} | {ip_str:<16} | {detail}   # {note}")
    print("-" * 90)
    print("要点:第2行 PTR 看着像 Googlebot,但正向回查不命中——单看 PTR 会误判为真爬虫。")

def main():
    ap = argparse.ArgumentParser(description="FCrDNS 双向验证器 (L2)")
    ap.add_argument("--live", nargs=2, metavar=("IP", "厂商"),
                    help="真实查询:python3 fcdns_verify.py --live 66.249.66.1 Google")
    args = ap.parse_args()
    if args.live:
        ip_str, vendor = args.live
        if vendor not in ALLOWED_SUFFIX:
            print(f"未知厂商 {vendor},可选: {list(ALLOWED_SUFFIX)}", file=sys.stderr)
            sys.exit(1)
        status, host, detail = verify_fcdns(
            ip_str, ALLOWED_SUFFIX[vendor], ptr_fn=real_ptr, a_fn=real_a)
        print(f"{status}: {detail}")
    else:
        run_demo()

if __name__ == "__main__":
    main()

演示模式里最关键的是第二行那个案例:203.0.113.55 的 PTR 记录被指到了 crawl-203-0-113-55.googlebot.com——只看反查结果,后缀完全合规,像极了真 Googlebot。但正向回查时,这个主机名解析回的是 198.51.100.99,和源 IP 对不上,于是判定 fail_forward。这就是伪造 PTR 的现场:反查结果你控制得了,Google 域名的 A 记录你控制不了,双向验证就是靠这个不对称锁死伪造。

真实使用把厂商换成对应的反查后缀即可:

python3 fcdns_verify.py --live 66.249.66.1 Google
python3 fcdns_verify.py --live 207.46.13.99 Bing      # Bingbot 常见段

注意一点:--live 模式依赖本机 DNS,网络策略严格的环境里可能查不到结果,输出 unknown 不代表对方是伪造的,只是当前环境查不了。

五、L3:行为层兜底——伪装成浏览器的流量怎么抓

L1 和 L2 能解决"自报家门"的流量,但还有一类流量根本不报家门:UA 是普通 Chrome,看不出任何爬虫特征。这一层只能靠行为特征去观察。

最有名的案例是 Cloudflare 2025 年 8 月那份报告。当时 Cloudflare 收到大量站长投诉:robots.txt 明确禁止、WAF 也拦了 PerplexityBot 和 Perplexity-User,但内容还是持续被抓。Cloudflare 的工程师做了蜜罐实验——新注册一批从未被索引过的域名,robots.txt 设为全部禁止,然后去问那家 AI 搜索产品,结果它对蜜罐内容了如指掌。进一步排查发现,被拦之后,抓取方换成了伪装成 macOS Chrome 的 UA,并且轮换使用官方公布网段之外的 IP 和多个 ASN,请求还保留了 cookie 和 referrer 来伪装成连续的人类会话。Cloudflare 据此把该厂商移出了它的已验证爬虫名单。

这里要客观记一笔:被点名的厂商随后发文反驳,称 Cloudflare 把第三方浏览器自动化服务(BrowserBase)的流量错误归因到了它头上。双方各执一词,谁对谁错不是本文要裁定的——这个案例对我们真正的价值是两点:第一,伪装成浏览器的抓取是真实存在的,而且用的是官方网段外的 IP,所以 L1 的"段内浏览器 UA 不判伪装"和"段外自称爬虫要警惕"是配套的;第二,厂商的官方网段列表只是"它承认的爬虫出口",不代表它没有别的出口,段表命中是强证据,但不是铁证。

行为层能用的信号里,最容易落地、也最接近"实锤"的一条是 UA 身份轮换:同一个 IP 短时间内先后自称多个不同的官方爬虫。真实爬虫的 UA 是固定的,一个 IP 今天自称 GPTBot、明天自称 ClaudeBot,只有两种可能——要么这 IP 是共享出口(比如某个云厂商的代理池),要么有人在换马甲。LumenGEO 抓到的那条"一天四身份"的 IP 就是典型。

下面脚本专门检测这类轮换行为,顺带把高频无标记的流量也标出来:

# detect_ua_rotation.py —— UA 身份轮换检测器(L3 层,简化行为指纹)
"""
按源 IP 聚合日志,检测三类行为:
  [!!] rotation   同一IP声称多个不同的官方AI爬虫身份——换马甲,伪装高嫌疑
  [~]  anonymous  高请求量但UA没有任何AI标记——疑似stealth匿名抓取
  [OK] steady     单一官方身份,建议再用L1/L2验证IP(适用于有验证手段的厂商)
  [--] steady*    单一身份但厂商无L1/L2手段(如Bytespider),身份仍属unverifiable

用法:
  python3 detect_ua_rotation.py                      # 示例日志
  python3 detect_ua_rotation.py --log 日志.tsv --min-req 50
日志格式同 verify_crawler_ip.py:每行 源IP<TAB>完整User-Agent
"""
import argparse
import collections
import re
import sys

# 与 verify_crawler_ip.py 保持一致的官方爬虫标记表
AI_TOKENS = ("gptbot", "oai-searchbot", "chatgpt-user", "claudebot",
             "claude-user", "claude-searchbot", "perplexitybot",
             "perplexity-user", "googlebot", "bingbot", "ccbot",
             "bytespider", "tiktokspider", "amazonbot", "meta-externalagent")

# 官方段表覆盖不全、也无反查后缀的厂商:L3 只能看到"身份稳定",但身份永远无法用 L1/L2 证实
NO_L12_TOKENS = {"bytespider"}

def token_in_ua(ua):
    low = ua.lower()
    for tok in AI_TOKENS:
        if re.search(r"(^|[^a-z])" + tok + r"([^a-z]|$)", low):
            return tok
    return None

def analyze(lines, min_req):
    """返回 (report_rows, summary)"""
    # ip -> {token: count, "total": n, "no_token": n}
    agg = collections.defaultdict(lambda: {"total": 0, "no_token": 0, "tokens": {}})
    for line in lines:
        line = line.rstrip("\n")
        if not line.strip():
            continue
        parts = line.split("\t")
        if len(parts) < 2:
            continue
        ip_str = parts[0].strip()
        ua = "\t".join(parts[1:]).strip()
        a = agg[ip_str]
        a["total"] += 1
        tok = token_in_ua(ua)
        if tok:
            a["tokens"][tok] = a["tokens"].get(tok, 0) + 1
        else:
            a["no_token"] += 1

    rows = []
    for ip_str, a in agg.items():
        if a["total"] < min_req:
            continue
        n_tokens = len(a["tokens"])
        if n_tokens >= 2:
            claim = ", ".join(f"{t}x{c}" for t, c in
                              sorted(a["tokens"].items(), key=lambda kv: -kv[1]))
            rows.append(("rotation", ip_str, a["total"],
                         f"同一IP声称{n_tokens}种官方身份: {claim}"))
        elif n_tokens == 1:
            tok = next(iter(a["tokens"]))
            if tok in NO_L12_TOKENS:
                rows.append(("steady*", ip_str, a["total"],
                             f"仅声称 {tok}——UA稳定但该厂商无 L1/L2 验证手段,身份仍属 unverifiable"))
            else:
                rows.append(("steady", ip_str, a["total"],
                             f"仅声称 {tok},建议用L1/L2校验其IP是否真实"))
        elif a["no_token"] == a["total"]:
            rows.append(("anonymous", ip_str, a["total"],
                         f"{a['total']}次请求UA均无AI标记——高频无标记,可能是stealth抓取或正常用户出口"))
    rows.sort(key=lambda r: -r[2])
    return rows

DEMO_LOG = """192.0.2.10	Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)
192.0.2.10	Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)
192.0.2.10	Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)
203.0.113.55	Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)
203.0.113.55	Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/bot)
203.0.113.55	Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OAI-SearchBot/1.3; +https://openai.com/searchbot
203.0.113.55	Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36
198.51.100.5	Mozilla/5.0 (compatible; ClaudeBot/1.0; +https://claude.com/bots)
198.51.100.5	Mozilla/5.0 (compatible; ClaudeBot/1.0; +https://claude.com/bots)
198.51.100.5	Mozilla/5.0 (compatible; ClaudeBot/1.0; +https://claude.com/bots)
60.8.151.23	Mozilla/5.0 (Linux; Android 5.0) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Safari/537.36 (compatible; Bytespider; spider-feedback@bytedance.com)
60.8.151.23	Mozilla/5.0 (Linux; Android 5.0) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Safari/537.36 (compatible; Bytespider; spider-feedback@bytedance.com)
60.8.151.23	Mozilla/5.0 (Linux; Android 5.0) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Safari/537.36 (compatible; Bytespider; spider-feedback@bytedance.com)
114.114.114.114	Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)
114.114.114.114	Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)
114.114.114.114	Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)
"""

def main():
    ap = argparse.ArgumentParser(description="UA 身份轮换检测器 (L3)")
    ap.add_argument("--log", help="日志文件:每行 源IP<TAB>UA;缺省用内置示例日志")
    ap.add_argument("--min-req", type=int, default=3,
                    help="参与判断的最少请求数(过滤零星流量,默认3)")
    args = ap.parse_args()

    if args.log:
        with open(args.log, encoding="utf-8") as f:
            lines = f.readlines()
    else:
        lines = DEMO_LOG.splitlines(keepends=True)

    rows = analyze(lines, args.min_req)
    if not rows:
        print("没有命中任何行为的IP(请求数都低于阈值?)")
        return
    for status, ip_str, total, note in rows:
        tag = {"rotation": "[!!]", "steady": "[OK]", "steady*": "[--]", "anonymous": "[~]"}[status]
        print(f"{tag} {status:<10} | {ip_str:<16} | {total:>3}次 | {note}")
    print("-" * 90)
    print("判定口径:rotation 是强信号;anonymous 只是提示,公共出口/NAT下的正常用户也可能长这样。")
    print("steady 只代表'身份一致',须经 L1/L2 复核才算 verified;steady* 的厂商(如 Bytespider)")
    print("没有 L1/L2 验证手段,单一身份仍属 unverifiable,不要当可信统计。")

if __name__ == "__main__":
    main()

示例日志里埋了三条典型样本:203.0.113.55 这个 IP 在 4 次请求里分别自称 GPTBot、PerplexityBot、OAI-SearchBot 和普通 Chrome(复刻 LumenGEO 那个"一天四身份"的案例),脚本会把它标成 rotation;192.0.2.10 稳定自称 GPTBot,标 steady,提示配合 L1 验证;114.114.114.114 稳定自称 GPTBot 但请求量不低——它在示例里同样标 steady,但如果你把这份日志交给 L1 脚本跑一遍,就会发现它的 IP 根本不在 OpenAI 段里,是个"身份稳定但身份是假的"伪装者。这就是三个脚本配合的价值:L3 负责找"可疑对象",L1/L2 负责给"最终结论"。

说清楚行为层的边界:它只能标"疑似自动化/疑似伪装",不能定罪。一个高请求量、无 AI 标记的 IP,可能是 stealth 爬虫,也可能是一个公司办公室的 NAT 出口(几百号人共用一个公网 IP 访问你的网站)。所以 L3 的输出适合做两件事——触发告警让人工复核,或者作为限流(而不是直接封禁)的依据。真要封,先拿 L1/L2 的结果说话。

六、把三层接到一起:一条日志的完整处置流程

分开讲了三层,实际使用是串起来的。这套流程在易科势腾服务过的官网改造项目里,已经作为每周定时任务跑了一段时间,处置逻辑如下:

原始日志(nginx/CLB)
      │
      ├─ 提取 源IP + UA(awk 一行命令,见 verify_crawler_ip.py 的 docstring)
      ▼
① 按 UA 分流:UA 里有 AI 爬虫标记吗?
      ├─ 没有 → 交给 L3 行为层观察(高请求量无标记 → 人工复核)
      └─ 有 ───────────────▼
② L1/L2 身份验证:这个厂商公布网段还是反查后缀?
      ├─ 公布网段(OpenAI/Anthropic/Perplexity)→ verify_crawler_ip.py
      │     ├─ verified    → 计入"真实AI爬虫"统计,与 robots 策略对照
      │     ├─ mismatch    → 告警 + 记录样本,人工复核(可能厂商段变更)
      │     └─ unverifiable→ 进 L3
      ├─ 公布反查后缀(Google/Bing/Apple/CCBot)→ fcdns_verify.py
      │     ├─ pass        → 计入真实统计
      │     └─ fail/unknown→ 告警 + 进 L3
      └─ 无可验证口径(Bytespider:段表覆盖不全、无反查后缀)→ 直接进 L3
                ▼
③ L3 行为观察:detect_ua_rotation.py + 请求频率统计
      ├─ rotation        → 高嫌疑,加入观察名单,必要时按 IP 限流
      ├─ anonymous 高频   → 提示级,人工判断
      ├─ steady          → 回到 ② 复核结论存档
      └─ steady*         → 归 unverifiable(厂商无 L1/L2 手段,如 Bytespider),单独统计

整个流程落到处置动作上,就是一张决策表:

验证结果处置建议
verified(官方)计入 AI 流量统计;对照 robots 策略确认是否该放行;作为 AI 渠道归因的数据源
mismatch(自称官方但 IP 不符)不直接封(可能是段变更);记告警 + 抽样人工复核;连续多日同一来源的,按 IP 段限流
unverifiable(无法验证,如 Bytespider)单独分类统计,不并入"官方 AI 爬虫"数;交给行为层持续观察
browser-note(段内但 UA 无标记)记录即可,不判伪;量大时单独告警观察
L3 rotation观察名单 + 限流候选,等 L1/L2 或更多证据再定性

三个落地提醒:

  1. 段表每周刷新。各家网段都在变(Anthropic 自己都承认 IP 封锁不可靠),--fetch-openai 拉回来的段表加个 crontab 每周更新一次,别让验证器用上个月的旧地图。
  2. mismatch 别急着封。厂商调整网段是常态,今天不在段内的 IP 下周可能就进来了。脚本输出的 mismatch 是"需要复核"的信号,不是"立即封禁"的指令——把它和"连续 N 天、同一来源段"这类条件组合起来再动手。
  3. 统计口径要分开。"日志里自称 GPTBot 的次数"和"验证后确实是 OpenAI 的次数"是两个数。对外汇报 AI 渠道流量时用 verified 的口径,否则你报的数字里混着别人的伪装流量,归因结论就是错的——这一点在 ROI 评估里尤其致命,误差会直接传导到投入产出数字上。

七、诚实交代:这套方案的边界

写到这里,把方案的边界说透,避免读者踩我们踩过的坑。

Bytespider 至今没有可独立验证的手段。 字节跳动没有公布 FCrDNS 反查后缀;头条站长平台虽然公布过 10 个 /24 网段,但实测流量大量来自这些段之外,也没有配套的官方验证协议。拿这些段当验证依据会在真实流量上翻车——段外的大量真实 Bytespider 流量会被误判成伪装。所以脚本对它的处理是诚实的:标 unverifiable,不假装能验证。如果你确实要管控它,现实的做法是行为层观察加按 UA 模式限流,同时注意 Bytespider 的移动端 UA 嵌着 2014 年的 Android/iOS 版本号,按浏览器版本过滤日志会把它误分类成古董设备。

IP 段命中是强证据,但不是铁证。 一方面,官方网段里可能跑着用户触发抓取或该网段的其它服务(所以"段内 + 浏览器 UA"我们只提示不判伪);另一方面,伪装流量恰恰常在官方网段之外(Cloudflare 抓到的 stealth 行为用的就是官方段外的 IP 和 ASN)。段表能证明"这个请求大概率来自某厂商",证明不了"该厂商所有请求都从这个段出来"。

这是一场军备竞赛。 Cloudflare 公布检测方法后,被抓的抓取方必然调整手法;反过来,伪装者也在研究各家段表的更新节奏。所以验证规则要定期校准——建议把"验证覆盖率"(可验证厂商的流量里 verified 占比)当成一个指标跟踪,覆盖率突然下降,先怀疑段表过期,再怀疑来了新手法。

八、展望:把身份从"自报"变成"签名"

L1-L3 本质上还是在跟"自报家门"的死磕:厂商说一个段,你去对;厂商不说,你就只能猜。更彻底的解法是让请求方用密码学签名证明身份——RFC 9421(HTTP Message Signatures)定义的就是这个:请求方用私钥对请求签名,站长用公钥验证,身份不再依赖 UA 文本,而是数学上可验证的。

这个方向的现实进度要如实说:它仍处于标准落地早期,主流厂商和 CDN 做过一些支持性探索(比如"Web Bot Auth"的讨论),但距离"打开日志就能看到带签名的爬虫请求"还有距离。而且它有一个绕不开的局限——签名需要请求方主动配合部署,对那类连 robots.txt 都不理的抓取方,你没法指望它跑来给你签个名。所以它的价值是把"守规矩的厂商"身份做实,把验证成本降下来;对不守规矩的,该用的还是 L3 行为层。

眼下能做的,就是把 L1-L3 这套跑起来:段表每周刷新,mismatch 告警有人看,行为层持续观察。身份验证是概率工程,做不到百分百,但至少能把"可验证的"和"不可验证的"分开——这本身就是质变。

九、FAQ

Q1:GA 或者百度统计里能看到 AI 爬虫的详细数据吗?

看不到。统计脚本靠浏览器执行 JavaScript 来采集,爬虫大多不执行 JS,能看到的只有"自报家门"的那一小部分;伪装流量和匿名抓取在统计后台里根本不出现。想统计谁在抓你,回服务器日志层,用 IP 验证而不是信 UA。

Q2:verify_crawler_ip.py 在线拉取官方 JSON,厂商改格式了会不会跑不了?

--fetch-openai 会分别拉取 OpenAI 三个爬虫(GPTBot / OAI-SearchBot / ChatGPT-User)的官方 JSON,实现是递归遍历 JSON 所有字段、把形如 x.x.x.x/n 的字符串全部提取出来,对结构变化有一定容忍度。万一拉取失败或格式大改,手动把网段下载下来转成"每行一个 CIDR"的文本文件,用 --file 传入即可,不影响判定逻辑。

Q3:IP 在官方段内,就一定是官方爬虫吗?

“自称某爬虫 + IP 在对应官方段内”,这个组合可信度很高,可以作为统计依据。但官方段不是官方独享——段内可能跑着用户触发抓取或其它服务。另外段表会变,务必定期刷新,否则把旧段当"未命中"会误报一堆 mismatch。

Q4:检测到 mismatch 的伪装流量,直接封掉不行吗?

先别急着封。mismatch 可能是厂商网段刚调整造成的误伤,也可能是第三方抓取服务(伪装流量很多来自这类服务,它们会不断换 IP)。建议的处置是:告警 + 记录样本 + 连续多日同一来源再按段限流。封禁是最后手段,不是第一反应。

Q5:三个脚本能直接处理我服务器的 Nginx 日志吗?

脚本输入是"源IP + UA"两列 TSV。从 nginx combined 格式转过去用一条 awk 命令(见 verify_crawler_ip.py 的 docstring),腾讯云 CLB、阿里云 SLB 的默认日志格式大同小异,先导一小段样本试跑确认字段位置对得上,再全量跑。

Q6:这套方案能识别出所有 AI 爬虫吗?

不能,说清楚这点很重要。Bytespider 这类没有官方验证手段的,只能标"无法验证";伪装成普通浏览器的流量,行为层只能给"疑似"级别。但方案的价值不在于识别所有,而在于把日志分成"可信、矛盾、无法验证"三堆——后面两类不再混进你的 AI 流量统计里。对外汇报数字时用 verified 口径,这比追求"全识别"实在得多。


把这三个脚本放进 crontab 之前,值得再确认一遍你拿到的 UA 统计里混了多少水分。我们习惯把方法论写成能直接跑的脚本,也是因为这套验证流程在客户现场反复被验证过——身份验证这种活儿,光讲道理留不住,代码跑一遍,日志里谁是真是假,一眼就分出来了。这也是易科势腾给企业做 AI 友好化改造时坚持的做法:改造之前先搞清楚谁在访问你,改造之后才知道效果算在谁头上。

如果这篇文章对你有帮助,欢迎点赞收藏。系列前一篇《企业官网 AI 友好化改造中的数据安全合规》讲了怎么配置 robots 和分级,本篇补上了"怎么验证配置是否被执行"这一环;下一篇可以聊聊把 AI 引用监测从单次脚本升级成持续流水线,让改造效果变成能天天看的趋势。