AI 爬虫日志里的伪装者:Python 日志分析识别假爬虫 UA 的踩坑复盘
适用读者:自己维护外贸独立站 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_module 与 ngx_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 低得多,单独用它做判定等于没做。
参考与延伸
- OpenAI 官方爬虫文档(GPTBot 与验证方式说明):https://platform.openai.com/docs/bot
- RFC 9309 Robots Exclusion Protocol:https://www.rfc-editor.org/rfc/rfc9309.html
- Google 官方 Googlebot 验证文档:https://developers.google.com/search/docs/crawling-indexing/verifying-googlebot
- Cloudflare 已验证爬虫(Verified Bots)说明:https://developers.cloudflare.com/bots/
这套判定链路目前还留了一个未解决的口子:pending 桶里的 IP 如果一直拿不到 PTR,会长期卡在 2 r/s,其中可能混着新上线、还没来得及登记反向记录的真实抓取节点。如果你有更好的收敛办法,或者有厂商公布过完整的官方网段清单,欢迎在评论区贴出来一起核对。
外贸独立站出海, AI 爬虫识别, GPTBot 反解验证, Python 日志分析, Nginx 限速分层, 假 UA 伪装, GEO, AI优化AIO