站内搜索页把 AI 爬虫喂饱了:一次参数 URL 爆炸引发的抓取陷阱复盘

2026-09-17 11:41:02 14 次浏览
抓取陷阱noindexrobots.txt参数治理抓取预算

适用读者:运营内容站、课程站或商品库的运维与后端工程师。涉及日志分析、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 通配符、抓取预算、知识付费

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