站内搜索页把 AI 爬虫喂饱了:一次参数 URL 爆炸引发的抓取陷阱复盘
适用读者:运营内容站、课程站或商品库的运维与后端工程师。涉及日志分析、robots.txt 通配符、noindex 与参数治理,示例用 Nginx + Python。
去年冬天有个课程站客户找过来,说 CDN 流量涨了四倍,咨询量却没动。我们先看日请求量,从 40 万涨到 170 万;再看请求分布,八成集中在 /search 路径下,参数长得离谱:/search?q=python&sort=price&page=9&filter=level-2。
站内搜索页被当成内容页抓了。更要命的是,这些页面里还会列出"相关搜索",只要关键词略有差别,就生成一批新 URL。抓取器和这个循环较了两周劲,索引里堆了十二万个搜索页,真正的课程详情页反而抓得比以前少。
陷阱是怎么长出来的
站内搜索页本身没问题,问题在三个条件同时成立。
第一个条件是URL 空间无上限。搜索页的地址由查询词决定,q= 后面可以是任何字符串,任何字符串都能生成一个 200 状态码的页面。第二个条件是页面里能发现新 URL。搜索结果列表里有意向不到的连带:相似课程推荐、相关搜索、分页、排序切换,每个都是一个新地址。第三个条件是这些页面没有禁止索引。
三个条件凑齐,抓取器就进入了一个自增长的循环:抓第 1 页 → 发现 12 个新地址 → 抓这 12 个 → 每个又发现 12 个。指数的形式不夸张。
flowchart TD
A[抓取器进入 /search?q=x] --> B[页面输出结果列表]
B --> C[相关搜索与排序链接生成新参数地址]
C --> D[抓取器抓取新地址]
D --> E[每个页面再次输出新参数地址]
E --> C
C --> F[参数 URL 累积到十几万]
F --> G[抓取预算被吃光]
G --> H[课程详情页与课程列表页抓取量下降]
最后一行的后果是最容易被忽略的:被挤掉的是真正的内容页。抓取预算在站点内是有分配的,搜索页多了,课程页就少了。
原理剖析:为什么 AI 时代这类陷阱更常见
抓取器发现 URL 的方式是遍历链接图,这一点没变。变的是它遍历的目的:搜索引擎的抓取器更在意"这页值不值得进索引",而 AI 引擎的抓取器还要补全语义——它倾向于把内容周边的上下文一起抓回来,才能判断这段内容该在什么问题上被引用。
这个差异放大了陷阱。传统爬虫遇到搜索页,看到内容单薄、重复度高,可能抓几次就走了;而以语义补全为目标的抓取器会把"相似的问题页面"当作上下文的一部分继续展开,抓取量自然更大。
具体到参数组合,我们的站当时的组合空间是这样的:
| 参数 | 取值空间 | 是否可被索引(问题现状) | 处理方式 |
|---|---|---|---|
| q(关键词) | 无上限,任何字符串 | 是,最大的问题源 | 禁止索引 + robots 屏蔽 |
| page(分页) | 1–50 | 是,第 3 页起内容重复度高 | 禁止索引,保留链接 |
| sort(排序) | 4 种(价格/销量/评分/上新) | 是,同一结果不同顺序 | 禁止索引,规范到默认排序 |
| filter(筛选) | 6 类 × 3 档 | 是,组合爆炸 | 禁止索引,保留链接 |
按当时统计,光前三个维度就能排列出六万多种组合,加上筛选维度,理论上限是七位数。实际被索引的十二万,只占其中一小部分——但这已经足够把预算吃掉八成。
顺便说一个容易混的概念:这类页面返回的是 200,内容是真实存在的搜索结果,所以不是软 404(Soft 404,指返回 200 但内容为空或近似无内容的页面)。它的危害方式不同:软 404 浪费单次抓取,参数空间则持续产生新目标,属于结构性问题。
修复:三件事,顺序不能反
很多人第一反应是在 robots.txt 里加一条 Disallow: /search,然后就以为解决了。我们试过,两周后发现问题缓解但没有消失,原因在顺序上。
# robots.txt 片段:先收敛参数,再声明屏蔽
# 说明:robots.txt 只是"请求"合规抓取器不要抓,不等于技术拦截
User-agent: *
# 1) 搜索路径整段屏蔽,含参数与不带参数两种形态
Disallow: /search
Disallow: /search?
# 2) 分页与筛选参数:只屏蔽参数形态,路径形态的列表页要保留
# 通配符 * 匹配任意字符串,$ 表示结尾,两条一起用才严谨
Disallow: /*?*page=
Disallow: /*?*sort=
Disallow: /*?*filter=
# 3) 明确放行需要被抓的路径,写在后面便于人工核对
Allow: /course/
Allow: /list/
Allow: /article/
# sitemap 位置声明,方便抓取器找到分片
Sitemap: https://www.example.com/sitemap.xml
第二步是页面层的技术收紧,这是真正解决问题的部分。robots.txt 管不住不讲规矩的采集器,也不影响已经建立的索引——已经被索引的页面必须靠页面自身的信号退出。
<!-- 搜索页模板的 head 部分:加 noindex,但保留 follow -->
<!-- 关键点一:noindex 的前提是页面可被抓取,否则标签永远读不到 -->
<!-- 关键点二:follow 保留,让抓取器顺着结果链接走到课程页 -->
<meta name="robots" content="noindex, follow">
<!-- 排序与筛选参数页面规范到默认排序,避免同一结果被当成多页 -->
<link rel="canonical" href="https://www.example.com/search?q=python">
第三步是链接层的改造:搜索结果页里的"相关搜索"和排序切换,改成不产生可抓取 URL 的形式。相关搜索用前端渲染的按钮、排序用表单提交,不再输出 <a href>。
这三步的先后顺序有讲究。先 robots 后 noindex 的话,抓取器读不到 noindex 标签,已索引的页面会长期挂在索引里;反过来先加 noindex 再补 robots,退出的过程会平滑得多。
上线后的验证也别只看状态码。我们当时用三种方式交叉确认:一是抓一批搜索页的响应头,确认 X-Robots-Tag: noindex, follow 已下发;二是用站内搜索页的 URL 再抓一次,确认页面里那行 meta 标签的位置没被模板覆盖;三是在日志里盯 /search 路径的抓取频次曲线,看它是否在两周内进入下降通道。三条都过了,才认为这一步完成。
从日志定位陷阱:两个判据
同类问题的排查不必一开始就看全站数据,盯两个判据就够:路径请求占比和参数组合的去重数量。
# 环境:Python 3.10+,仅标准库;分析 Nginx access log
# 用途:判断是否存在参数 URL 膨胀(抓取陷阱的典型特征)
# 判据一:单一路径占比超过 30%
# 判据二:同一路径下的参数组合数在持续增长
import re
from collections import Counter
from urllib.parse import urlparse, parse_qs
LOG = re.compile(r'"(?P<method>\S+) (?P<path>\S+) [^"]*" (?P<status>\d{3})')
# 只关心这些参数:它们决定了组合空间的大小
PARAM_KEYS = ("q", "page", "sort", "filter")
def analyze(path_log: str) -> None:
path_hits = Counter() # 路径维度:谁在吃预算
param_combos = Counter() # 参数维度:组合有多少种
crawler_hits = Counter() # 抓取器维度:分布是否异常集中
with open(path_log, encoding="utf-8", errors="ignore") as f:
for line in f:
m = LOG.search(line)
if not m:
continue # 非标准行跳过,别让脏行影响判断
raw = m.group("path")
parsed = urlparse(raw)
path_hits[parsed.path] += 1 # 路径本身单独计数
# 参数组合用键排序后拼接,避免顺序不同被当成两种组合
qs = parse_qs(parsed.query)
combo = "&".join(
f"{k}={qs[k][0]}" for k in sorted(qs) if k in PARAM_KEYS
)
if combo:
param_combos[f"{parsed.path}?{combo}"] += 1
# 日志里带 bot 关键字的归为抓取器,用于对比人类流量
# 这个归类只是近似:无 UA 的采集器会被算进人类流量里
if re.search(r"bot|crawler|spider", line, re.I):
crawler_hits[parsed.path] += 1
total = sum(path_hits.values()) or 1
# 输出占比前 10 的路径:超过 30% 就要警惕
print("路径请求占比(前 10)")
for p, n in path_hits.most_common(10):
print(f" {n:>8} {n / total:>6.1%} {p}")
# 输出参数组合最多的路径:增长中的组合数就是陷阱在扩张
print("\n参数组合去重数量(前 10)")
combo_by_path = Counter(k.split("?")[0] for k in param_combos)
for p, n in combo_by_path.most_common(10):
print(f" {n:>8} {p}")
# 抓取器占比过高时,说明预算主要花在非内容页上
print("\n抓取器请求分布(前 5)")
for p, n in crawler_hits.most_common(5):
print(f" {n:>8} {p}")
if __name__ == "__main__":
analyze("/var/log/nginx/access.log")
跑完之后我们看到的数字是:/search 占 78% 的请求,参数组合去重后有 11.4 万种,抓取器请求里 9 成落在搜索路径。三个数字都指向同一个结论。
补充一条实操心得上:把这段脚本挂在定时任务里,每天凌晨跑一次,只要参数组合数比昨天涨超过 20% 就告警。这类问题的早期信号就是"组合数上涨",等到索引量上涨时,退出成本已经很高了。
还有一个细节值得记一笔:搜索页当初是怎么被索引进去的。我们回查了历史,最早进来的几个地址是用户把搜索结果的链接分享到了外部平台,抓取器顺着外链抓了第一批;之后站内的"相关搜索"模块把它们和站内链接图连了起来,索引就开始自我扩张。也就是说,只要有一个可达入口,参数空间就会被打开,这跟"站内有没有主动链接到搜索页"关系不大。修复时我们顺手把"相关搜索"改成前端渲染,等于把这个自我扩张的闭环拆掉了一半。
sequenceDiagram
participant C as AI 抓取器
participant S as 站点
participant L as 日志与巡检
C->>S: GET /search?q=a
S-->>C: 200 搜索结果(noindex, follow)
C->>S: GET /course/python-basics(顺着结果链接)
S-->>C: 200 课程页(可索引)
Note over C,S: 链接仍可跟进,但搜索页不再进索引
C->>L: 请求记录
L->>L: 每日统计参数组合数
L-->>S: 组合数异常上涨即告警
修复前后的 30 天数据
| 观测项 | 修复前 30 天 | 第 8–14 天 | 第 22–30 天 |
|---|---|---|---|
| 被索引的搜索页数量 | 约 120000 | 约 41000 | 约 2600 |
| /search 路径请求占比 | 78% | 41% | 9% |
| 课程详情页日均抓取次数 | 约 1200 | 约 3100 | 约 4600 |
| CDN 日均请求量 | 约 170 万 | 约 96 万 | 约 58 万 |
| 抽检课程名能否在 AI 回答中命中 | 6/20 | 11/20 | 14/20 |
第三行是这次修复真正想拿到的结果。预算不是省下来了,是挪回内容页了。第四行的成本变化属于附带收益:请求量降回三分之一的水平,CDN 账单随之回落。
最后一行的提升没有前几项那么陡,原因也清楚:搜索页退出索引需要时间,而 AI 侧的回答更新更慢。这类结构性问题的收益周期按月算,不要按周看。
四个容易做错的处理
直接在服务端 404 搜索页。 用户会从外部链接点进来,抓到 404 是体验事故。要退出索引应该用 noindex,不是让地址失效。
只加 robots.txt 不改页面。 前面已经说过,robots 管不住违规采集,也管不了已建立的索引。它和 noindex 是配合关系,不是替代关系。
以为 nofollow 能解决问题。 nofollow 现在更多是提示性质,各家实现不一致。想彻底切断发现路径,得从"页面上不输出可抓取的链接"入手,而不是指望属性生效。
保留排序与筛选的可索引页面。 有些同行认为排序页有搜索价值。实际情况是同一批课程换个顺序,对 AI 的回答毫无增量,只会把预算分散掉。真要保留,就规范到默认排序那一个地址。
往后要盯的是什么
抓取陷阱不是新问题,但 AI 时代它的形态会变。传统形态是参数爆炸,下一阶段可能出现在两个地方:一是站内问答与评论区的自我引用(用户提问生成页面,页面又推荐相关问题);二是内容镜像与多语言副本(同一篇内容多个地址版本)。这两类的共同特征都是"可无限生成地址"。
我的做法是把它当成一个固定的巡检项:每月看一次参数组合数量的增长曲线,看一次"抓取量最大的 20 个路径"里有没有非内容页。两个动作加起来不到半小时,能挡住绝大多数同类问题。
如果你们的日志里也出现过某个路径占比异常高、参数长得像废话一样的请求,欢迎在评论区贴出来,这类特征通常一眼就能认出来。
参考与延伸
- Google 关于抓取陷阱与无限空间的说明:https://developers.google.com/search/docs/crawling-indexing/troubleshoot-crawling-errors
- robots.txt 规范与通配符支持(RFC 9309):https://www.rfc-editor.org/rfc/rfc9309
- Google 关于 noindex 与索引退出的说明:https://developers.google.com/search/docs/crawling-indexing/block-indexing
- sitemap 协议:https://www.sitemaps.org/protocol.html
关键词:GEO、AI优化AIO、抓取陷阱、参数 URL 治理、noindex、robots.txt 通配符、抓取预算、知识付费