商品只被 AI 抓到前两页:分页链接写法与抓取深度差异的 30 天对照数据
适用读者:负责电商站、商品库或大型分类目录的前后端工程师。示例用 ASP.NET Core Razor 与 Python 日志脚本,分页可发现性的判断逻辑与语言无关。
客户问了一个很具体的问题:他想买的那台型号,站内搜索能搜到,让 AI 帮忙"找找某某型号的参数",AI 回了一句没找到。我们人工把型号输进商品列表翻到第 3 页就看到那个商品。SQLite 里一共 3120 个 SKU,分 156 页,每页 20 个。
问题跟"页面上有没有内容"无关,跟"爬虫能不能走到那一页"有关。第 3 页之后的商品,从来没被抓取过。
抓取深度靠什么决定
一个商品页要被 AI 引用,前提是它得先进索引。进索引的前提是被抓取,被抓取的前提是被抓取器发现。发现路径只有两条:页面里的链接,以及 sitemap。
我们那站的实际情况:分类列表页用的是无限滚动,滚到底触发一段 XHR 拿下一页数据,onclick 绑在按钮上。对人来说体验不错,对抓取器来说——页面上根本不存在指向第 3 页的链接。抓取器拿到第 1 页,看不到任何可跟进的 URL,这一支就断了。
flowchart TD
A[抓取器进入分类页] --> B{页面上有没有可跟进的链接}
B -->|有真实 a href| C[抓取第 2 页]
C --> D[继续发现第 3 页…第 N 页]
D --> E[抓取深层商品详情页]
B -->|只有 onclick 或 XHR| F[停在第一页]
B -->|sitemap 里有分页地址| G[从 sitemap 直接取分页 URL]
G --> E
F --> H[深层商品不进索引]
E --> I[商品有机会被 AI 引用]
我们的站两条路都断了:分页链接不存在,sitemap 只提交了商品详情页的 URL——听上去更直接,但抓取调度并不是只按数量发牌的。
原理剖析:抓取预算、URL 发现与分页信号
抓取器每天在单个站点上分配的请求数是有上限的,业内叫抓取预算(Crawl Budget)。预算怎么花,取决于它判断哪些 URL "值得抓"。它做这件事的流程大致是:
候选集构建。 从种子 URL 出发,沿页面里的链接遍历,形成待抓队列。sitemap 是补充来源,主要用于兜住那些没有入链的孤岛页面。这里有个容易被忽略的细节:从链接图上发现的分页 URL,与直接从 sitemap 提交的 URL,在抓取优先级上不一定相同。分页 URL 属于"列表的延伸",被视作同一主题的延续;而 sitemap 里孤立的详情页,如果没有任何入链,常被视作低价值页面,排在后面。
重要性打分。 入链数量、入链页面本身的权重、URL 层级深度、页面更新频率,都会参与打分。一个商品如果只出现在 sitemap 里,且全站没有其他页面链接到它,评分会很低。我们的 3120 个 SKU 恰好都处在这个状态。
配额分配与回访。 抓完一批后按变更频率决定回访间隔。分页 URL 的更新频率高(新品上架就变),因此天然更容易被反复访问;这也是为什么把分页链接做出来之后,深层页面的抓取量会连带上升。
关于分页信号还有一条需要说清楚的历史:rel="next" / rel="prev" 曾经是分页的标准标记方式,后来 Google 明确表示不再把这些链接用作索引信号,但对用户与抓取器仍然有用——它能让抓取器顺着链接走。也就是说,这两个属性今天的价值在于可发现性,而不是索引提示。真正的规范化要通过每页自指的 canonical 来做。
| 分页实现方式 | 抓取器能否发现后续页 | 用户体验 | 适用场景 |
|---|---|---|---|
服务端渲染真实链接 <a href="/list?page=2"> |
能,逐页遍历 | 有刷新,稍慢 | 主列表、需要被索引的页面 |
| JS 按钮 + onclick 请求 | 不能,除非另有链接或 sitemap | 流畅 | 仅用于辅助筛选 |
| 无限滚动 + XHR | 不能,页面无后续 URL | 流畅 | 仅用于用户的"逛",不能作为主要入口 |
| 无限滚动 + 同步输出分页链接(视觉隐藏) | 能 | 兼顾 | 常见折中方案 |
最后一行是我们最终采用的方案:视觉上保留无限滚动,DOM 里同步输出全部分页链接。用户可以滚,抓取器可以爬。
改造方案:四件一起做才有数据变化
只改分页链接,30 天后的数据不会明显变化;我们最后做了四件事,缺一件都打折。
第一件,列表页服务端输出分页链接。分页链接放在列表底部,同时提供第一页/上一页/下一页/末页四个锚点,href 全部是可直接访问的地址。
第二件,分页 URL 统一形态并自指 canonical。?page=2 这类参数 URL 与路径式 URL 混用会产生重复内容,选定一种,另一种 301 过去。第 2 页的 canonical 必须指向它自己,不能统一指向第一页——这是最常见的错误,等于告诉引擎"这些页面的内容都属于第一页"。
第三件,sitemap 分片。商品详情页按品类分片提交,每片不超过 5 万条;列表页按分页顺序单独提交一份,让抓取器即使不从链接进入也能知道有 156 页。
第四件,详情页反向链接。相关商品、同系列、同分类三个模块的锚文本改成商品名,而不是"查看详情"。这一步提升的是详情页的入链数。
@* 环境:ASP.NET Core 8 Razor Pages / MVC
作用:列表页底部输出真实分页链接,视觉上可隐藏,DOM 里必须有
注意:不要用 button + onclick 代替 a href,抓取器不执行点击
输出位置:列表容器之后、页脚之前,确保与列表内容同属主体区块 *@
@model ProductListViewModel
@* aria-label 写清楚:既方便读屏,也方便人工核对分页区是否存在 *@
<nav class="pager" aria-label="分页">
@* 上一页:仅在不是第一页时输出
第一页输出 rel=prev 会指到不存在的地址,属于无效链接 *@
@if (Model.PageNumber > 1)
{
<a rel="prev" href="@Model.BuildPageUrl(Model.PageNumber - 1)">上一页</a>
}
@* 中间页:只输出当前页附近的 5 个,控制单页链接数量
全量输出 156 个链接会让页面权重被稀释 *@
@foreach (var p in Model.NearbyPages())
{
<a href="@Model.BuildPageUrl(p)"
aria-current="@(p == Model.PageNumber ? "page" : null)">@p</a>
}
@* 下一页:分页最关键的链接,抓取器靠它逐页深入
rel=next 对索引不再是信号,但仍然是抓取器跟进的路径 *@
@if (Model.PageNumber < Model.TotalPages)
{
<a rel="next" href="@Model.BuildPageUrl(Model.PageNumber + 1)">下一页</a>
}
</nav>
@* 无限滚动的挂载点:滚动时只做客户端加载,链接已在上面输出
data 属性放在容器上,前端脚本读它决定下一次请求哪个地址 *@
<div id="feed"
data-next-url="@Model.BuildPageUrl(Model.PageNumber + 1)"
data-total-pages="@Model.TotalPages"></div>
对应的 URL 构造与 canonical 处理:
// ProductListViewModel 里的地址构造
// 统一分页形态:/list/{category}/page/{n},参数式地址一律 301 到它
// 地址形态一旦确定就不要改,改了会让已索引的分页地址全部失效
public string BuildPageUrl(int page) =>
page <= 1
? $"/list/{CategorySlug}" // 第一页不带页码,地址更短
: $"/list/{CategorySlug}/page/{page}"; // 第 2 页起带页码
// 页面里设置自指 canonical
// 关键点:第 N 页的 canonical 指向第 N 页自己,不能指向第一页
// 指向第一页会让后面所有页被判定为重复内容,等于把深层页从索引里删掉
// 这条规则在分类页、搜索页、标签页上都适用
// 校验方法:随便打开第 5 页,查看源代码里的 canonical 是否带 page/5
public string CanonicalUrl() => BuildPageUrl(PageNumber);
// 旧参数式地址:/list?cat=x&page=2 在老站里大量存在
// 用 301 一次性收敛到新形态,避免两套地址同时被索引
// 重定向链不要超过一跳,链式跳转会拖慢抓取并损失信号
public string? LegacyRedirectTarget(string? query) =>
query is null ? null : BuildPageUrl(ParsePage(query));
分片 sitemap 的生成逻辑不必写得很复杂,关键是让分页与详情页都有出口:
# 环境:Python 3.10+,仅标准库;按品类与分页生成 sitemap 索引与分片
# 说明:单个 sitemap 文件上限 5 万条 URL,超过必须分片,否则会被整体忽略
import gzip
from datetime import date
BASE = "https://www.example.com"
# 单个 sitemap 文件的 URL 条数上限,规范值为 50000,这里留少量余量
MAX_PER_FILE = 50000
def write_sitemap(path: str, urls: list) -> None:
"""写一个 gzip 压缩的 sitemap 分片。"""
# 参数说明:urls 为 (完整地址, 最后修改日期) 的列表
# lastmod 填真实修改时间,全填今天会降低引擎对该字段的信任
# 命名空间必须写对,缺了 xmlns 部分抓取器会直接丢弃整份文件
# 声明文件类型与编码,命名空间必须写对
head = ('<?xml version="1.0" encoding="UTF-8"?>'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">')
# 逐条拼装 url 节点,loc 用完整地址
body = []
for u, mod in urls:
body.append(f"<url><loc>{u}</loc><lastmod>{mod}</lastmod></url>")
# 拼装时不换行美化:体积优先,抓取器不关心格式
xml = head + "".join(body) + "</urlset>"
# 用 gzip 写出,文件名以 .gz 结尾
with gzip.open(path, "wt", encoding="utf-8") as f:
f.write(xml)
def main() -> None:
# 统一用当天日期作为 lastmod
today = date.today().isoformat()
# 第一步:分页 URL 单独一份,让抓取器即使不跟链接也能知道有多少页
# 页码范围从 1 到总页数,空品类页也提交,避免中间某个分类被漏掉
pager_urls = [
(f"{BASE}/list/gear/page/{p}", today) # 每页一条记录,地址形态与站内一致
for p in range(1, 157)
]
# 写出分页专用分片
write_sitemap("sitemap-pager.xml.gz", pager_urls)
# 第二步:详情页按片切分,每片不超过上限
# 片数别太多:整站的 sitemap 索引文件建议控制在几十份量级
# load_all_skus 读库拿全量 SKU,注意排序稳定,便于比对前后差异
detail_urls = [(f"{BASE}/product/{sku}", today) for sku in load_all_skus()]
# 按上限切片写出,文件名带序号便于排查
for i in range(0, len(detail_urls), MAX_PER_FILE):
write_sitemap(f"sitemap-detail-{i // MAX_PER_FILE + 1}.xml.gz",
detail_urls[i:i + MAX_PER_FILE])
if __name__ == "__main__":
main()
30 天对照数据
观测口径说明:索引覆盖用搜索引擎的收录数做近似,AI 侧用"能否在回答里出现该商品型号"做抽检(每 3 天一次,每次 20 个型号,覆盖不同分页区间)。改造在第 1 天上线,前 7 天为生效期。
| 观测项 | 改造前 30 天 | 改造后第 8–14 天 | 改造后第 22–30 天 |
|---|---|---|---|
| 被索引的商品数(收录近似值) | 约 640(全部在第 1–2 页) | 约 1580 | 约 2760 |
| 抽检型号能在 AI 回答中出现的比例 | 8/20 | 12/20 | 16/20 |
| 深层页(第 8 页以后)日均抓取次数 | 0 | 约 40 | 约 110 |
| 分页 URL 日均抓取次数 | 约 6 | 约 90 | 约 260 |
| 列表页平均出链数(抓取器视角) | 21 | 26 | 26 |
第三行的数据来自服务器日志,按 URL 里页码大于 7 聚合。它说明一件事:分页链接一出来,抓取预算自然往深层倾斜,不需要额外做什么。
第四行值得单独看。分页 URL 的抓取频次比深层详情页还高,因为列表页的更新频率高、每次访问都能发现新的详情页链接。这就是我们开头说的"两条路都断"的代价:sitemap 提交了详情页,但没人来按图索骥。
sequenceDiagram
participant C as 抓取器
participant L as 列表页
participant P as 商品详情页
C->>L: GET /list/gear
L-->>C: HTML(含 page/2..page/6 链接 + 自指 canonical)
C->>L: GET /list/gear/page/2
L-->>C: HTML(含 page/3..page/7 链接)
C->>P: GET /product/sku-1234(从第 4 页发现)
P-->>C: HTML(含属性表 + Product JSON-LD)
Note over C,P: 第 4 页之后的商品开始进入索引
五个判断错误,我们踩过三个
无限滚动用了就不管 DOM。 这是最初的问题。正确做法是把分页链接留在 DOM 里,视觉上用 CSS 收起,滚动逻辑照常。
把所有分页的 canonical 指向第一页。 我们在早期站点模板里就这么写的,理由是"避免重复内容"。结果是把 156 页的内容全声明成了第 1 页的副本。
分页 URL 用参数还是路径没统一。 ?page=2、&p=2、/page/2 三种混着用,抓取器会把它们当不同页面,既浪费预算又产生一堆低质副本。定一种,其余 301。
以为 sitemap 提交了就一定会被抓。 sitemap 保证的是"知道你存在",不代表会给配额。没有入链的孤岛页面优先级天然靠后。
把筛选参数页面也当成可索引页。 颜色、价格区间、排序方式的组合会产生组合爆炸。我们的做法是给筛选结果页加 noindex, follow——不索引,但保留链接传递能力,让抓取器顺着链接走到详情页。
往后怎么变
分页这件事的技术形态在变,判断逻辑没变。前端从服务端分页换成滚动加载、再换成流式渲染,抓取器的发现路径始终只有链接与 sitemap 两条。任何把"下一页"藏进 JS 的实现,都要额外补一条可发现路径。
真要做这件事,我的建议是先花半小时做一次抓取深度盘点:从分类首页出发,用脚本按链接图遍历三层,看能到达多少个商品页,再和商品库总数比一比。这个差值就是你在损失的量级,也决定要不要马上动手。盘点脚本不用写复杂的,能输出"从首页出发可达的详情页数量"和"实际商品总数"两个数字就够了。
如果你们的商品列表也是滚动加载,欢迎在评论区说说 DOM 里有没有留分页链接——这个细节决定了 AI 能不能看到第 2 页以后的东西。
参考与延伸
- Google 关于分页与 rel=next/prev 的说明:https://developers.google.com/search/blog/2019/03/rel-next-prev
- sitemap 协议与文件大小限制:https://www.sitemaps.org/protocol.html
- Google 抓取预算说明:https://developers.google.com/search/docs/crawling-indexing/large-site-managing-crawl-budget
- MDN 关于 rel=next / prev 的定义:https://developer.mozilla.org/docs/Web/HTML/Attributes/rel/next
- Schema.org 的 ItemList 类型(列表页结构化):https://schema.org/ItemList
关键词:GEO、AI优化AIO、抓取深度、分页 SEO、canonical 自指、sitemap 分片、Crawl Budget、零售电商