AI 爬虫日志里的伪装者:Python 日志分析识别假爬虫 UA 的踩坑复盘

2026-09-19 09:59:27 10 次浏览
Python日志分析Nginx爬虫识别GEO

适用读者:自己维护外贸独立站 Nginx 与 WAF(Web Application Firewall,Web 应用防火墙)的后端或 SEO(Search Engine Optimization,搜索引擎优化)工程师;遇到过放行 AI 爬虫后带宽被打满,或者正准备给 GPTBot、CCBot 开白名单的人。后文脚本在 Python 3.12 与 pandas 2.2 下跑通。

一、凌晨的告警:带宽先崩,日志后说话

02:14,Zabbix 把出口带宽推红:97 Mbps 持续 40 分钟,压在 100 Mbps 专线上限上。站点是做卫浴五金出口的独立站,欧洲批发商占七成访客,那个点真实用户几乎没有。 第一反应是被 CC 打,可 WAF 的挑战页一次都没触发,请求速率分布也不像爆破。 把 Nginx access log 拉下来按 UA(User-Agent,用户代理)聚合才看清:声称自己是 GPTBot 的请求吃掉了当晚 62.4% 的字节数,而这些请求里真正反解到 openai.com 的源 IP 只有 34 个。

放大镜下的日志与伪装蜘蛛

二、现象复盘:放行之后日志里长出了第二只 GPTBot

2.1 放行规则本身没错,缺的是校验

两周前为了让商品页进 AI 答案,我们在 WAF 上加了放行规则:UA 命中 GPTBot、ClaudeBot、CCBot、PerplexityBot 的请求跳过 JS 挑战与人机校验,也不进入访客限速桶。动作本身合理,AI 爬虫不会执行 JS,挑战页等于直接把它挡在门外。

问题出在放行条件只看了 UA 这一个字段。UA 是请求头里的一行文本,客户端想写什么就写什么,curl -A "GPTBot/1.2" 一条命令就能拿到白名单待遇。规则上线第三天,声明为 AI 爬虫的请求量从每天 3 万涨到 79 万,涨幅和任何真实抓取行为都对不上。

2.2 三组数字把问题钉死

先看总量。把一周的 access log 按声明 UA 聚合,再对源 IP 做 rDNS(Reverse DNS,反向域名解析)双向验证,得到第一张表。这里的"双向验证"指 PTR(Pointer Record,指针记录)反解拿到主机名后,还要把主机名正向解析回来,确认结果集合里包含原 IP,两步都过才算真实。

声称 UA 声明请求数 独立 IP 数 双向验证通过 IP 真实请求数 伪装请求占比
GPTBot 412,338 1,863 34 29,180 92.9%
ClaudeBot 186,204 741 21 12,976 93.0%
CCBot 124,517 1,102 63 21,340 82.9%
PerplexityBot 67,042 388 9 4,215 93.7%
Googlebot 93,881 512 497 91,660 2.4%
Bingbot 41,206 233 228 40,118 2.6%
AI 类合计 790,101 4,094 127 67,711 91.4%

AI 类爬虫的伪装率在 83% 到 94% 之间,同期的 Googlebot 与 Bingbot 只有 2% 出头。差距不是因为 AI 厂商更不守规矩,而是传统搜索引擎的验证方法二十年里被反复讲,采集器早就学会绕开或干脆不冒充;AI 爬虫白名单是新的,冒充成本几乎为零而收益很高。

再看行为。把通过验证的 IP 与未通过验证的 IP 分开统计,两边画像完全不同:

特征 真实 AI 爬虫 伪装脚本
单 IP 日请求数中位数 214 3,912
请求间隔 0.3 到 2 秒抖动 集中在 80 毫秒附近
HTTP 版本 HTTP/2 占 71% HTTP/1.1 占 96%
当周是否请求过 robots.txt 全部请求过 4% 的 IP 请求过
UA 是否带官方完整串 是,含 +https 后缀 43% 只有裸名称
是否加载 CSS 与图片 会加载 只取 HTML
源 IP 归属 集中在云厂商少数 ASN 住宅宽带与廉价 VPS 混杂

第三组数字是带宽。伪装请求只占声明请求的 91.4%,却占掉 68.7% 的响应字节,因为它们专挑不带缓存的商品列表页与搜索页,分页参数一路翻到 800 多页。这解释了为什么 CPU 没打满、带宽先打满。

三、原理与机制剖析:UA 自报为什么不可信

3.1 UA 是客户端填的一行文本

HTTP 协议里 UA 由客户端自行声明,服务端没有任何字段可以交叉验证它的真伪。这个设计从 1990 年代沿用至今,本质上是个约定:守规矩的爬虫填真实身份,方便站点识别与配额协商,不守规矩的填什么都行。

于是 UA 只能用来做分流,不能用来做鉴权。凡是"命中 UA 即放行"的规则,等价于把大门钥匙交给了请求方自己。

3.2 PTR 反解与正向确认:双向验证机制的细节

rDNS 走的是另一条路:它不问客户端说什么,而是问 IP 地址的持有者在反向解析区里登记了什么。查询 1.2.3.4 的 PTR 记录,拿到类似 gptbot-1-2-3-4.openai.com 的主机名。反向区由持有该段地址的机构管理,攻击者控制不了 openai.com 的反向区,所以 PTR 指向官方域是一条有分量的证据。

但单靠 PTR 还不够。攻击者如果在自己的反向区里把某台 VPS 的 PTR 设成 openai.com.attacker.example,朴素的后缀包含判断就会误判。两步处理堵住这个洞:

一是后缀严格匹配,要求主机名以 .openai.com 结尾,而不是包含 openai.com 子串。二是正向确认,把 PTR 返回的主机名再做一次正向解析,要求解析出的地址集合里包含原始 IP。这两步合起来就是 FCrDNS(Forward-Confirmed Reverse DNS,正向确认反向域名解析),缺任何一步都只能算"存疑"。

第三道是网段比对。部分厂商公布官方出口 CIDR(Classless Inter-Domain Routing,无类别域间路由),落在网段内可以增强信心。但网段会调整,也没人保证全部厂商都公布,所以脚本里把"没有网段数据"处理成 pending 而不是 fake,宁可让它待在观察桶里限速跑,也不要一刀封死。

flowchart TD
    A["从 access log 提取声明为 AI 爬虫的源 IP"] --> B["PTR 反解:查 IP 的反向解析记录"]
    B --> C{"PTR 记录是否存在"}
    C -- 无 --> D["判定伪装,进未验证桶限速"]
    C -- 有 --> E{"主机名是否以官方域后缀结尾"}
    E -- 否 --> D
    E -- 是 --> F["正向确认:解析该主机名取 A 与 AAAA"]
    F --> G{"解析结果是否包含原 IP"}
    G -- 否 --> D
    G -- 是 --> H["与厂商公布网段比对"]
    H --> I{"是否落在网段内"}
    I -- 是 --> J["判定真实,写入 rDNS 白名单"]
    I -- 否 --> K["网段缺失或不符,标记 pending 观察"]

四、用 Python 拆解 access log

4.1 依赖与环境

环境为 Python 3.12.4、pandas 2.2.3、requests 2.32,操作系统 Debian 12。解析与反解只依赖标准库 socket 与 ipaddress,requests 用于从内部配置服务拉取可热更的验证策略表。日志格式需要包含 UA、协议版本与请求耗时,先在 nginx.conf 里定义一个专门的 bot 格式再切过去。

4.2 脚本一:解析、聚合、排出嫌疑 IP

# -*- coding: utf-8 -*-
# 环境:Python 3.12.4、pandas 2.2.3(pip install "pandas==2.2.3")
# 作用:解析 Nginx access log,按 UA 与 IP 聚合,输出待验证的嫌疑 IP 清单
# 前置:日志格式用下方 LOG_RE 对应的 bot 格式,缺字段会整行丢弃

import re
import sys

import pandas as pd

# 与 log_format bot 对应的解析正则,依次取 IP、时间、请求行、状态码、字节数、UA、耗时、协议
# log_format bot '$remote_addr - $remote_user [$time_local] "$request" '
#                '$status $body_bytes_sent "$http_user_agent" "$request_time" "$server_protocol"';
LOG_RE = re.compile(
    r'(?P<ip>\S+) - \S+ \[(?P<time>[^\]]+)\] "(?P<req>[^"]*)" '
    r'(?P<status>\d{3}) (?P<bytes>\d+) "(?P<ua>[^"]*)" '
    r'"(?P<rt>[^"]*)" "(?P<proto>[^"]*)"'
)

# 声明为 AI 爬虫的 UA 关键字,统一转小写后做包含匹配
AI_BOT_KEYS = ["gptbot", "claudebot", "ccbot", "perplexitybot"]


def load_log(path):
    # 逐行解析,坏行跳过并计数,避免一条脏数据炸掉整个批次
    rows, bad = [], 0
    with open(path, encoding="utf-8", errors="replace") as fh:
        for line in fh:
            m = LOG_RE.match(line)
            # 匹配失败说明这行不是 bot 格式,例如旧格式残留或含转义引号的请求
            if not m:
                bad += 1
                continue
            rows.append(m.groupdict())
    sys.stderr.write(f"丢弃无法解析的行:{bad}\n")
    return pd.DataFrame(rows)


def tag_bot(df):
    # 生成小写 UA 列,后续匹配统一在小写空间里做,避免大小写漏判
    df["ua_lower"] = df["ua"].str.lower()
    # 默认标记为 unknown,未被任何关键字命中的行不进入验证流程
    df["bot"] = "unknown"
    for key in AI_BOT_KEYS:
        # 只给当前还是 unknown 的行赋值,避免多个关键字互相覆盖
        mask = df["ua_lower"].str.contains(key, regex=False) & (df["bot"] == "unknown")
        # 命中的行写入声明身份,例如 gptbot、ccbot
        df.loc[mask, "bot"] = key
    # 返回带声明身份列的原表,保持行数不变便于后续交叉核对
    return df


def aggregate(df):
    # 字节数转整型,日志里是字符串,不转会导致 sum 变成拼接
    df["bytes"] = df["bytes"].astype("int64")
    # 耗时字段可能为空或带减号,用 to_numeric 容错转成浮点
    df["rt"] = pd.to_numeric(df["rt"], errors="coerce")
    # HTTP/2 占比是区分真实爬虫与脚本的关键特征之一
    df["h2"] = df["proto"].str.contains("HTTP/2", regex=False)
    # 按 IP 与声明身份两级聚合,得到每个 IP 的行为画像
    grouped = df.groupby(["ip", "bot"], as_index=False).agg(
        # 请求数,用于排序与识别异常高频来源
        hits=("req", "count"),
        # 响应字节数,用于计算带宽占用
        bytes_sum=("bytes", "sum"),
        # 平均后端耗时,慢请求集中在哪类来源上一目了然
        rt_mean=("rt", "mean"),
        # HTTP/2 请求占比,脚本多为 0
        h2_ratio=("h2", "mean"),
    )
    # 单 IP 请求数越高越可疑,排在前面优先做 rDNS 验证
    return grouped.sort_values("hits", ascending=False)


if __name__ == "__main__":
    # 第一个参数为 access log 路径,支持传入已切分的单日文件
    raw = load_log(sys.argv[1])
    # 给每行打上声明身份标签
    tagged = tag_bot(raw)
    # 只保留声明为 AI 爬虫的行,真实访客不参与本轮验证
    suspects = tagged[tagged["bot"] != "unknown"]
    # 聚合成 IP 维度的嫌疑清单
    result = aggregate(suspects)
    # 落盘 CSV,交给第二个脚本做 rDNS 验证
    result.to_csv("suspect_ips.csv", index=False)
    # 打印前 20 行,人工扫一眼头部是否符合伪装脚本画像
    print(result.head(20).to_string(index=False))

跑完得到 4,094 行待验证记录。真实爬虫的 IP 在这张表上很好认:请求数中等、h2_ratio 接近 1、rt_mean 受后端缓存影响很小。伪装脚本则挤在头部,单 IP 数千请求、h2_ratio 为 0。

4.3 脚本二:rDNS 反解、正向确认与网段比对

# -*- coding: utf-8 -*-
# 环境:Python 3.12.4、requests 2.32(pip install "requests==2.32.*")
# 作用:对 suspect_ips.csv 做 PTR 反解 + 正向确认 + 网段比对,产出 Nginx 用的 rdns.map
# 说明:解析全用标准库 socket,requests 仅用于拉取可热更的验证策略表

import ipaddress
import json
import socket

import pandas as pd
import requests

# 策略表兜底内容:厂商声明的 rDNS 域名后缀,cidrs 为空表示该厂商未公布网段
# 未公布网段时判定为 pending 而不是 fake,避免把真实爬虫误杀
FALLBACK_POLICY = {
    "gptbot": {"ptr_suffix": ".openai.com", "cidrs": []},
    "claudebot": {"ptr_suffix": ".anthropic.com", "cidrs": []},
    "ccbot": {"ptr_suffix": ".commoncrawl.org", "cidrs": []},
    "perplexitybot": {"ptr_suffix": ".perplexity.ai", "cidrs": []},
}

# 全局 socket 超时,防止个别 IP 的 DNS 查询把整批任务拖住
socket.setdefaulttimeout(3.0)


def load_policy(url, local_path):
    # 优先从配置服务拉策略,网络不通就用本地兜底文件,保证脚本可离线重跑
    try:
        resp = requests.get(url, timeout=5)
        resp.raise_for_status()
        return resp.json()
    except requests.RequestException:
        with open(local_path, encoding="utf-8") as fh:
            return json.load(fh)


def reverse_ptr(ip):
    # 查询 IP 的 PTR 记录,返回主机名小写形式;查不到返回 None
    try:
        return socket.gethostbyaddr(ip)[0].lower().rstrip(".")
    except (socket.herror, socket.gaierror, OSError):
        # NXDOMAIN 或完全没有 PTR,等同于身份无法证明
        return None


def forward_confirm(hostname, ip):
    # 正向确认:把 PTR 主机名再解析一遍,地址集合必须包含原 IP
    # 只做反解不做正向,攻击者可把自建 PTR 指向任意官方域名
    try:
        for info in socket.getaddrinfo(hostname, None):
            # info[4][0] 是 sockaddr 里的地址部分,IPv6 可能带作用域后缀
            if info[4][0].split("%")[0] == ip:
                return True
    except socket.gaierror:
        return False
    return False


def in_cidrs(ip, cidrs):
    # 网段比对用标准库 ipaddress,IPv4 与 IPv6 走同一套逻辑
    if not cidrs:
        # 返回 None 表示无法判定,交给上层落到 pending
        return None
    addr = ipaddress.ip_address(ip)
    return any(addr in ipaddress.ip_network(c, strict=False) for c in cidrs)


def verify(ip, bot, policy):
    # 三段判定:PTR 存在且后缀匹配、正向确认通过、网段比对不失败
    conf = policy.get(bot)
    # 策略表里没有该厂商,按存疑处理,不做任何封禁
    if not conf:
        return "pending", ""
    # 先做 PTR 反解,拿到主机名
    host = reverse_ptr(ip)
    # 无 PTR 直接判伪装,这是占比最高的一类
    if not host:
        return "fake", ""
    # 后缀严格匹配,用 endswith 而非 in,排除 openai.com.attacker.example 这类构造
    if not host.endswith(conf["ptr_suffix"]):
        return "fake", host
    # 正向确认,双向一致才算过
    if not forward_confirm(host, ip):
        return "fake", host
    # 网段比对,返回 None 表示厂商未公布网段
    hit = in_cidrs(ip, conf["cidrs"])
    # 明确落在网段之外,判伪装
    if hit is False:
        return "fake", host
    # 网段缺失时 None,落 pending,仍然给限速但不断流
    if hit is None:
        return "pending", host
    # 三段全过,写进白名单
    return "ok", host


if __name__ == "__main__":
    # 先拉验证策略表,拿不到就用本地 policy.json 兜底
    policy = load_policy("https://cfg.internal/rdns-policy.json", "policy.json")
    # 读取脚本一产出的嫌疑清单,IP 列强制按字符串读,避免前导零被吃掉
    df = pd.read_csv("suspect_ips.csv", dtype={"ip": str})
    # 收集每个 IP 的判定结果与 PTR 主机名
    states, hosts = [], []
    # 顺序执行即可,socket 已设超时;量大时换成 ThreadPoolExecutor 并行
    for row in df.itertuples(index=False):
        # 逐个 IP 走三段判定
        state, host = verify(row.ip, row.bot, policy)
        # 判定结果写入列表,稍后一次性挂回 DataFrame
        states.append(state)
        # 主机名留档,便于人工复核误判
        hosts.append(host)
    # 判定结果与主机名挂回表格
    df["state"] = states
    df["ptr_host"] = hosts

    # 输出 Nginx map 格式的白名单,形如 203.0.113.7 ok;
    with open("rdns.map", "w", encoding="utf-8") as fh:
        # pending 不写入,让它保持默认的观察桶待遇
        for row in df.itertuples(index=False):
            if row.state in ("ok", "fake"):
                fh.write(f"{row.ip} {row.state};\n")

    # 按声明身份汇总验证结果,这张表就是第二章统计数字的来源
    summary = df.groupby("bot").agg(
        # 声明该身份的独立 IP 数
        ips=("ip", "nunique"),
        # 通过验证的 IP 数
        ok_ips=("state", lambda s: (s == "ok").sum()),
        # 声明身份下的总请求数
        hits=("hits", "sum"),
        # 占位列,下一行用真实数据覆盖
        ok_hits=("hits", lambda s: 0),
    )
    # 单独统计通过验证的请求数,再回填到汇总表
    summary["ok_hits"] = df[df["state"] == "ok"].groupby("bot")["hits"].sum()
    # 打印汇总结果,核对白名单命中率是否落在预期区间
    print(summary.to_string())

脚本产出两份东西:rdns.map 直接被 Nginx include,summary 表用于核对白名单命中率。127 个 IP 通过验证,剩下 3,967 个进未验证桶。

flowchart LR
    L["access log"] --> P["脚本一:解析聚合"]
    P --> C["suspect_ips.csv"]
    C --> V["脚本二:PTR 反解 + 正向确认 + 网段比对"]
    V --> M["rdns.map 写入 Nginx"]
    V --> S["summary 统计表"]
    M --> T["限速分层生效"]
    S --> R["复核白名单命中率"]

五、二次踩坑:封错人,引用先跌

5.1 按 UA 一刀切的七天

拿到 91.4% 这个数字之后,我做了个后来被证明很蠢的决定:直接在 WAF 上按 UA 全封 AI 爬虫,连通过验证的 127 个 IP 一起封。理由是先把带宽抢回来,白名单慢慢加。

带宽当天就降了,97 Mbps 掉到 21 Mbps。代价在第九天显现:站点在 AI 答案里被引用带来的会话从每周 128 掉到 41,跌幅 68%。复盘原因很清楚,AI 爬虫的抓取是引用来源,抓取归零后新内容不再进入索引,已有的引用也会随模型与索引更新慢慢失效;而恢复抓取不等于立刻恢复引用,中间有接近两周的滞后。

5.2 封禁前后对照

指标 封禁前 7 天 按 UA 全封第 3 至 9 天 白名单加限速后第 10 至 30 天
出口带宽峰值 97 Mbps 21 Mbps 34 Mbps
真实 AI 爬虫抓取请求/天 9,670 0 14,283
伪装脚本请求/天 112,871 6,400 3,120
AI 答案带来会话/周 128 41 196
境外访客 TTFB(Time To First Byte,首字节时间)P95 2.34 秒 0.41 秒 0.58 秒
商品页 5xx 比例 0.7% 0.1% 0.2%

第三列才是想要的形态:带宽回到健康区间,真实抓取量比封禁前还高 47%,引用会话也超过了基线。多出来的抓取量来自限速分层后释放的配额,之前它们被伪装流量挤在超时边缘。

flowchart TD
    A["第 1 天:按 UA 全封 AI 爬虫"] --> B["第 2 天:带宽降到 21 Mbps"]
    B --> C["第 7 天:真实抓取请求归零"]
    C --> D["第 9 天:引用会话 128 跌至 41"]
    D --> E["第 10 天:改 rDNS 白名单加分层限速"]
    E --> F["第 18 天:真实抓取恢复到 11,000/天"]
    F --> G["第 30 天:引用会话 196,带宽 34 Mbps"]

六、最终方案:rDNS 校验白名单 + 限速分层

6.1 四层判定与配额

分层 判定依据 速率 并发 超量响应
已验证爬虫 PTR 后缀匹配 + 正向确认 + 网段通过 20 r/s 8 排队不拒绝
观察桶 UA 命中但验证结果为 pending 2 r/s 2 429 带 Retry-After
未验证桶 验证结果为 fake 或无 PTR 1 r/s 1 429 带 Retry-After
普通访客 UA 未命中 AI 爬虫名单 10 r/s 4 429

关键点是不拒绝真实爬虫。未验证桶给 1 r/s 而不是直接 403:伪装脚本拿不到数据自然会走,而策略表更新后从 pending 转 ok 的 IP 能无缝升舱。

6.2 Nginx 落地配置

环境为 Nginx 1.24,使用 ngx_http_limit_req_modulengx_http_limit_conn_module

# 环境:Nginx 1.24,ngx_http_limit_req_module 与 ngx_http_limit_conn_module 均为内置模块
# 位置:/etc/nginx/conf.d/bot-tier.conf,需在 http 段内 include

# 观察桶:UA 命中但 rDNS 还没判定完的 IP,先按 2 r/s 放进来
limit_req_zone $pending_key zone=pending_rate:10m rate=2r/s;
# 未验证桶:PTR 缺失或正向确认失败的 IP,收紧到 1 r/s
limit_req_zone $fake_key zone=fake_rate:10m rate=1r/s;
# 已验证爬虫桶:宽松,只做兜底防失控
limit_req_zone $verified_key zone=verified_rate:10m rate=20r/s;
# 并发连接数限制,防止伪装脚本靠多连接绕过速率桶
limit_conn_zone $bot_conn_key zone=bot_conn:10m;

# 第一步:按 UA 判断是不是声明的 AI 爬虫
map $http_user_agent $bot_tier {
    default                                     "guest";
    "~*(gptbot|claudebot|ccbot|perplexitybot)"  "bot";
}

# 第二步:读 rdns.map,内容由校验脚本每日重写,形如 203.0.113.7 ok;
# 未在文件中的 IP 默认 pending,保证新出现的 IP 不会直接被判死
map $remote_addr $rdns_state {
    default    "pending";
    include    /etc/nginx/conf.d/rdns.map;
}

# 第三步:把分层与验证结果拼成 map 的源字符串,再映射到限速 key
# key 取空串时该请求不计入限速桶,这是实现白名单豁免的标准写法
map $bot_tier$rdns_state $verified_key {
    default      "";
    "botok"      $binary_remote_addr;
}
map $bot_tier$rdns_state $pending_key {
    default      "";
    "botpending" $binary_remote_addr;
}
map $bot_tier$rdns_state $fake_key {
    default   "";
    "botfake" $binary_remote_addr;
}
# 并发限制的 key 只给未通过验证的两档
map $bot_tier$rdns_state $bot_conn_key {
    default   "";
    "botpending" $binary_remote_addr;
    "botfake"    $binary_remote_addr;
}

server {
    # 站点监听配置,TLS 证书在 http 段统一加载
    listen 443 ssl;
    server_name example.com;

    # 限速返回 429 而不是 503,真实爬虫能识别并退避重试
    limit_req_status 429;
    # 已验证桶:burst 放宽到 40,突发抓取不会触发限流
    limit_req zone=verified_rate burst=40 nodelay;
    # 观察桶:burst 4,超过即 429
    limit_req zone=pending_rate burst=4 nodelay;
    # 未验证桶:burst 2,配合 1 r/s 基本只够试探
    limit_req zone=fake_rate burst=2 nodelay;
    # 并发上限 2,阻断多连接绕过速率限制的常见手法
    limit_conn zone=bot_conn 2;

    # 命中 429 时补 Retry-After,告诉对方多久后可重试
    error_page 429 = @too_many;
    location @too_many {
        add_header Retry-After 30 always;
        return 429;
    }

    location / {
        proxy_pass http://upstream_app;
    }
}

配套还有一个每日 cron:重跑校验脚本刷新 rdns.map,再 nginx -t && nginx -s reload。白名单条目带 TTL,连续两天查不到 PTR 就自动降为 pending,防止 IP 被云厂商回收再分配后留下僵尸白名单。

6.3 上线后的观测

稳态运行三周后,未验证桶每天拦掉约 3,120 次请求,占声明量的 4% 以下,其余伪装流量在 1 r/s 下自己走了。真实 AI 爬虫抓取量稳定在 14,000/天上下,TTFB P95 从 2.34 秒降到 0.58 秒。代价是多了一次 DNS 查询链路,用本地 dnsmasq 缓存后单 IP 判定耗时从 210 毫秒降到 12 毫秒。

七、误区澄清

rDNS 通过不等于永久可信。PTR 记录会改,云厂商的 IP 也会回收再分配,今天属于某厂商的地址半年后可能属于别人。白名单必须带 TTL 定期复检,我们设的是 24 小时。

没有公布网段的厂商不等于爬虫是假的。CCBot 与部分新兴厂商没有公开的出口 CIDR,脚本里对这种情况返回 pending 而不是 fake,否则就会重演第五章的误封。

限速不等于封禁。1 r/s 配 429 与 Retry-After,真实爬虫会退避重试,伪装脚本拿不到足够数据就会放弃;直接 403 则把所有退路堵死,误伤一次要两周才补得回来。

IPv6 不能跳过。伪装脚本在 IPv6 段上拿地址更便宜,我们的伪装流量里有 18% 来自 IPv6。脚本里用 ipaddress 统一处理两个协议族,PTR 走 ip6.arpa 由 socket 自动处理,不需要额外分支。

UA 里的官方链接后缀只能当辅助信号。它是约定不是签名,复制成本比伪造 PTR 低得多,单独用它做判定等于没做。

参考与延伸

  1. OpenAI 官方爬虫文档(GPTBot 与验证方式说明):https://platform.openai.com/docs/bot
  2. RFC 9309 Robots Exclusion Protocol:https://www.rfc-editor.org/rfc/rfc9309.html
  3. Google 官方 Googlebot 验证文档:https://developers.google.com/search/docs/crawling-indexing/verifying-googlebot
  4. Cloudflare 已验证爬虫(Verified Bots)说明:https://developers.cloudflare.com/bots/

这套判定链路目前还留了一个未解决的口子:pending 桶里的 IP 如果一直拿不到 PTR,会长期卡在 2 r/s,其中可能混着新上线、还没来得及登记反向记录的真实抓取节点。如果你有更好的收敛办法,或者有厂商公布过完整的官方网段清单,欢迎在评论区贴出来一起核对。

外贸独立站出海, AI 爬虫识别, GPTBot 反解验证, Python 日志分析, Nginx 限速分层, 假 UA 伪装, GEO, AI优化AIO

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。