AI 爬虫被 CDN 拦在门外:Cloudflare 与云 WAF 的 UA 放行规范,以及 403 排查清单
适用读者:负责外贸独立站或海外版官网的运维、后端与 SEO 工程师。涉及 CDN / WAF 配置、日志分析与 robots.txt 授权策略,平台以 Cloudflare、阿里云国际站与 Nginx 自建为例。
一个做了六周的外贸站,页面对比测评都写好了,sitemap 交了,robots.txt 里 User-agent: * 全部 Allow。客户问我们:为什么在 AI 里搜品牌名,出来的还是三年前的旧描述?我们翻访问日志,六周内来自 AI 抓取器的请求一共 3 条,其余全是被 403 挡回的记录。
问题不在内容,在门口。CDN 的机器人管理默认开着,把不认识的 UA 当恶意流量处理了。
三层拦截面:先搞清楚是谁在挡
外网站的"可抓取性"不是一道门,是三道。日志里只看到 403,很容易归因错。
| 拦截面 | 判定依据 | 被拦时的表现 | 排查入口 |
|---|---|---|---|
| 网络与边界层 | 源站防火墙、区域封锁、恶意 IP 库 | 连接超时或 444,日志里常常什么都没有 | 云厂商安全组、fail2ban、WAF 全量日志 |
| CDN 与机器人管理层 | UA 黑白名单、TLS 指纹(JA3/JA4)、速率与行为评分 | 403 / 429,返回 CDN 自带拦截页 | CDN 后台的 Bot 管理、安全事件视图 |
| 应用与协议层 | 应用防火墙规则、robots.txt、登录墙、参数校验 |
403 / 401 / 200 但内容为空 | 应用日志、robots.txt、渲染结果 |
我们这次的 403 落在第二层。第一层没问题,第三层也没有——robots.txt 是放行的,恰恰因为只看了这一层,前面六周没人往 CDN 后台看。
flowchart TD
A[AI 抓取器发起请求] --> B{边界层放行}
B -->|超时/444| X[请求根本进不来]
B -->|通过| C{CDN 机器人管理判定}
C -->|UA 不在白名单| Y[403 CDN 拦截页]
C -->|速率触发阈值| Z[429 或挑战页]
C -->|通过| D{应用与协议层}
D -->|robots 禁止| E[被合规爬虫主动放弃]
D -->|登录墙/参数校验| F[401 或空内容]
D -->|全部通过| G[返回 HTML 供抽取]
原理剖析:CDN 凭什么认定"你是机器人"
要把这事调对,得知道判定是怎么做出来的。主流 CDN 的机器人识别通常叠几个信号,任何一个都可能把合法抓取器判成坏人。
TLS 指纹。 客户端建立 TLS 时协商的密码套件顺序、扩展字段组合,构成一个指纹(JA3,较新的是 JA4)。同一个 UA 声称自己是某厂家的抓取器,但 TLS 指纹是一个普通 Python 脚本,两者对不上,评分就会掉。
IP 归属验证。 正规厂商会在官方文档里公布自己的出口 IP 段。CDN 可以拿这个名单反查:UA 写着 GPTBot、IP 却来自某个共享机房,就按"伪造者"处理。这一步最容易误伤的是自建在云服务器上的抓取器,以及通过企业代理出口的合法抓取。
UA 一致性。 UA 缺失、UA 里版本号与其他请求矛盾(同一 IP 上一秒是手机浏览器,下一秒是抓取器),都会触发降分。
行为评分。 单位时间的请求速率、是否只取少量 URL 却请求频繁、是否命中已知的攻击路径特征。外贸站有个天然劣势:站点总 URL 数少,抓取器一轮就把站刷完了,速率阈值很容易被踩到。
sequenceDiagram
participant C as AI 抓取器
participant W as CDN 边缘
participant O as 源站
C->>W: TLS 握手(JA3 指纹)
C->>W: GET /product/gear-x,UA 自称 GPTBot
W->>W: 指纹评分 + UA 解析 + IP 段比对
alt 评分低于阈值
W-->>C: 403 CDN 拦截页
else 评分通过
W->>O: 转发请求
O-->>W: 200 HTML
W-->>C: 200(可能被缓存)
end
Note over W,C: 被拦记录只出现在 CDN 日志,源站看不到
这解释了一个反直觉的现象:源站日志越干净,越可能有问题。如果源站访问日志里几乎看不到抓取器,同时 CDN 侧有大量 403,那就是被挡在门外,而不是"没人来抓"。
规范解读:robots.txt 是授权声明,不是通行证
robots.txt 的规范是 RFC 9309,它约束的是爬取方——合规的抓取器读到 Disallow 会主动放弃。它不约束服务端,也不能让任何请求穿过后面的技术拦截层。
于是有了四种组合,只有第一种是想要的状态:
| robots.txt | CDN / WAF | 实际结果 | 处理方向 |
|---|---|---|---|
| Allow | 放行 | 正常抓取与索引 | 保持,并把变更通知渠道接上 |
| Allow | 拦截 | 授权了却进不来,最隐蔽的一种 | 打开 CDN 机器人管理,按需放行 |
| Disallow | 放行 | 合规抓取器不来,违规采集照样来 | 补 robots 声明,并在边缘层做速率限制 |
| Disallow | 拦截 | 预期内,但要确认没误伤自家监控 | 复核白名单 |
注意第一行和第三行的对比:想拦住的是违规采集,而 robots.txt 只能约束守规矩的那批。真正的防采集要靠速率限制和行为判定,不该靠给 AI 抓取器一刀切。
在 CDN 上放行时,规则写法的粒度也值得留意。用 UA 包含匹配虽然简单,但 UA 是客户端自报的,伪造成本为零——所以放行规则应该和"官方 IP 段"或"已验证机器人"机制配合使用,而不是单独用 UA 做信任判断。生产环境的折中做法是:对匹配官方 IP 段的请求走高信任通道,其余走限速通道。
落地:Cloudflare 与 Nginx 的放行写法
环境:Cloudflare 免费版以上(Bot Fight Mode 与 WAF 自定义规则可用),源站 Nginx 1.24,测试用 curl 7.x。
先在 CDN 侧加一条"跳过"规则,让已知 AI 抓取器不被机器人管理处理:
# Cloudflare WAF 自定义规则(表达式写法)
# 命中条件:UA 命中已知 AI 抓取器之一,且请求方法为 GET/HEAD
# 只放 GET/HEAD:抓取器不应带 POST,带上就该被拦
(http.user_agent contains "GPTBot" or
http.user_agent contains "ClaudeBot" or
http.user_agent contains "PerplexityBot" or
http.user_agent contains "Google-Extended")
and http.request.method in {"GET" "HEAD"}
# 动作:Skip —— 跳过下列安全功能(不要选 Block / Managed Challenge)
# 1) Bot Fight Mode
# 2) 速率限制规则中的"挑战"动作
# 注意:Skip 只是不拦,不会绕过源站自身的鉴权
源站这侧再加一层,把 CDN 已经判定过的身份透传下来,避免重复判定:
# nginx.conf 片段:只允许来自 CDN 回源段的请求直连,其余拒绝
# 说明:CDN 回源段取自厂商官方文档,务必定期更新,写死会随厂商扩容失效
geo $from_cdn {
# 默认 0 表示非 CDN 来源;下面的网段必须与厂商文档保持同步
default 0;
173.245.48.0/20 1; # 示例网段,实际以 Cloudflare 官方 IP 列表为准
103.21.244.0/22 1; # 示例网段,实际以 Cloudflare 官方 IP 列表为准
}
server {
# 只开 443;80 端口交给 CDN 做跳转,源站不再直接服务明文请求
listen 443 ssl http2;
# 非 CDN 来源的直接访问一律拒绝:防止有人绕过 CDN 打源站
if ($from_cdn = 0) {
return 403;
}
# 透传真实客户端 IP,日志里才能看到抓取器的真实地址
real_ip_header CF-Connecting-IP;
set_real_ip_from 173.245.48.0/20; # 与上面的回源段保持一致
location / {
# 静态资源交回 Nginx,正文交给上游应用渲染
# 抓取器走正常渲染,不做 UA 层限速
# 限速针对的是采集特征,不是身份:这里只对高频无 UA 请求限流
limit_req zone=no_ua burst=20 nodelay;
proxy_pass http://app_upstream;
}
}
放行之后要验证,别只看规则界面显示"已生效"。用 curl 直接带 UA 打一次,跟不带 UA 的做对照:
# 环境:curl 7.x、站点已挂 CDN
# 1) 带官方抓取器 UA 请求,看是否还是 403 拦截页
curl -s -o /dev/null -w 'with_ua=%{http_code}\n' \
-A "GPTBot/1.0 (+https://example.com/gptbot)" https://www.example.com/product/gear-x
# 2) 不带 UA 请求,作为对照组;这一步返回 403 属于预期行为
curl -s -o /dev/null -w 'no_ua=%{http_code}\n' https://www.example.com/product/gear-x
# 3) 看响应体是不是 CDN 的拦截页:拿到 HTML 才说明真正回源了
curl -s -A "GPTBot/1.0" https://www.example.com/product/gear-x | head -5
第三步是关键。只看状态码不够:部分 CDN 的挑战页会返回 200,但内容是脚本跳转页,抓取器拿不到正文。
从日志反推拦截:一份可复用的排查顺序
排查顺序很重要,从上层往下走,避免在源站里空找。我们的顺序是:CDN 安全事件视图 → CDN 边缘日志 → 源站访问日志 → 应用日志 → 渲染结果。
下面这段脚本用来把 CDN 日志里的抓取器请求和被拦请求分开统计:
# 环境:Python 3.10+,仅标准库;输入为 CDN 边缘日志导出文件(JSON Lines)
# 作用:区分"被拦"与"正常抓取",并统计被拦最多的 UA
# 输出:四类计数 + 被拦 UA / 路径的 Top 榜,直接贴群里就能对齐认知
import json
from collections import Counter
# 官方文档公布的出口 IP 段(示例占位,实际请从厂商文档拉取最新列表)
TRUSTED_PREFIXES = ("20.42.", "172.203.", "52.230.")
# 已知 AI 抓取器的 UA 关键字:只做归类,不用来判定信任
AI_UA_HINTS = ("GPTBot", "ClaudeBot", "PerplexityBot", "Google-Extended", "CCBot")
def classify(rec: dict) -> str:
ua = rec.get("ClientRequestUserAgent", "") or ""
ip = rec.get("ClientIP", "") or ""
status = int(rec.get("EdgeResponseStatus", 0) or 0)
# UA 命中已知抓取器:只说明它自称是谁,不代表可信
hit_ai = any(h in ua for h in AI_UA_HINTS)
# IP 落在官方公布的出口段内:这一条才是信任的来源
ip_trusted = ip.startswith(TRUSTED_PREFIXES)
if hit_ai and not ip_trusted:
return "ua_only_pending_verify" # UA 像抓取器但 IP 不在名单,需要人工看
if hit_ai and ip_trusted and status in (403, 429, 503):
return "blocked_official_crawler" # 真正要处理的一类:官方抓取器被拦
# 正常抓取:UA 与 IP 都符合,状态码 200
if hit_ai and status == 200:
return "ok"
# 其余归为 other:包含普通用户流量与无 UA 采集,先不细分
return "other"
def main(path_log: str) -> None:
buckets = Counter()
blocked_ua = Counter() # 被拦请求的 UA 分布
blocked_url = Counter() # 被拦最多的路径,通常指向 cdn-cgi 或站点首页
# errors="ignore":CDN 日志里偶发截断行,交给下面的 try 兜住即可
with open(path_log, encoding="utf-8", errors="ignore") as f:
for line in f:
try:
rec = json.loads(line)
except Exception:
continue # 非 JSON 行直接跳过,别让坏行中断统计
tag = classify(rec)
buckets[tag] += 1
if tag == "blocked_official_crawler":
blocked_ua[(rec.get("ClientRequestUserAgent") or "")[:40]] += 1
blocked_url[(rec.get("ClientRequestURI") or "")[:60]] += 1
# 先看总量分布,再看具体是谁被拦
for k, v in buckets.most_common():
print(f"{v:>7} {k}")
print("\n被拦 UA 分布")
for ua, v in blocked_ua.most_common(10):
print(f"{v:>7} {ua}")
print("\n被拦路径分布")
for u, v in blocked_url.most_common(10):
print(f"{v:>7} {u}")
if __name__ == "__main__":
main("cdn_edge_log.jsonl")
这段代码里有个判断值得强调:ua_only_pending_verify 这一类不要自动加白。UA 可以随便写,把 UA 当成身份凭证等于给伪造者开门。这一类应该走人工核对,或者接入厂商的已验证机器人机制。
修复前后我们观察到的变化
改动本身很小:一条跳过规则、一段回源段配置、一次 nginx reload。观测窗口四周。
| 观测项 | 处理前(连续 4 周) | 处理第 1 周 | 处理第 4 周 |
|---|---|---|---|
| CDN 边缘日志中 AI 抓取器请求 | 3 次 | 87 次 | 每周 300 次上下 |
| 其中被拦(403/429)占比 | 约 100% | 12% | 低于 3% |
| 源站日志可见的抓取器请求 | 0 | 开始出现 | 与 CDN 侧数量基本对齐 |
| 站点被 AI 回答引用时描述与官网一致 | 仍引用旧描述 | 开始出现新页描述 | 抽检 20 个问题,15 个与官网一致 |
第三行是这次改动的直接证据:改动前源站日志是空的,因为请求根本没到源站。第一周被拦比例还有 12%,原因是速率限制规则还在挑战高频请求,我们把抓取器从速率挑战里也排除了之后才降下来。
三个常见判断错误
以为 robots.txt 放行就够了。 前面那张四象限表里,第二行是最容易踩的:文件里写着 Allow,读的人也以为没问题,实际请求在 CDN 就被 403 掉了,而且不会有任何报错。
以为开了机器人管理更安全。 对纯展示型官网,机器人管理带来的收益是挡掉一些采集和无意义请求;代价是把 AI 引擎的抓取一起挡掉。两件事要分开决策:采集要不要管,和 AI 抓取要不要放,是两条独立策略。
以为 403 一定是爬虫问题。 也见过反向案例:站点用了接口鉴权,页面 HTML 里数据靠带 token 的 XHR 拿,抓取器拿到的是空壳。这类问题的表现同样是"AI 看不到内容",但归属第三层,得从渲染方式下手,跟 CDN 规则无关。
往后会怎么变
可以预见的两个方向。一是抓取器身份会往"可验证"走:厂商公布出口 IP 段、并在 robots.txt 之外引入签名或凭证机制,服务端才知道眼前这个自称 GPTBot 的请求到底是不是本尊。二是放行策略会从"按 UA 一刀切"变成"按身份分级":已验证抓取器走无挑战通道,来源不明的走限速通道,违规的才拒绝。
在此之前,我的建议是每次动 CDN 安全策略都留一份对照记录:改之前抓一次带 UA 的请求、改之后抓一次,把状态码和响应体头几行存下来。这份记录在半年后排查"AI 怎么突然不引用了"时,比任何配置界面截图都有用。
评论区欢迎贴你们 CDN 里的抓取器放行规则表达式,尤其是云厂商那套"已验证机器人"的写法,各家差别挺大。
参考与延伸
- robots.txt 规范(RFC 9309):https://www.rfc-editor.org/rfc/rfc9309
- OpenAI 爬虫与出口 IP 段说明:https://platform.openai.com/docs/bots
- Anthropic 爬虫说明(ClaudeBot):https://support.anthropic.com/en/articles/8896518
- Cloudflare 机器人管理与 WAF 自定义规则文档:https://developers.cloudflare.com/waf/
- Nginx 的 real_ip 与限流模块文档:https://nginx.org/en/docs/http/ngx_http_realip_module.html
关键词:GEO、AI优化AIO、AI 爬虫、Cloudflare Bot 管理、WAF 放行、robots.txt、RFC 9309、外贸独立站