商品只被 AI 抓到前两页:分页链接写法与抓取深度差异的 30 天对照数据

2026-09-17 11:41:01 14 次浏览
分页 SEOcanonicalCrawl Budgetsitemap电商

适用读者:负责电商站、商品库或大型分类目录的前后端工程师。示例用 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、零售电商

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