侧边栏壁纸
  • 累计撰写 191 篇文章
  • 累计收到 2 条评论

为了体验把分页改成无限滚动加载,网站收录却断崖下跌?排查爬虫截断、Canonical错配与静态降级我踩过的坑

2026-10-12 / 0 评论 / 7 阅读
AI 摘要由 AI 生成

网站改为无限滚动加载后,抓取频次大幅下降,原因在于搜索引擎爬虫无法像用户一样滑动页面和触发异步请求。同时,使用HTML5 History API为分页创建独立URL时,Canonical标签错误地指向了首页,导致老文章无法被抓取。作者分享了应对方案,包括双轨改造,确保老文章被正确索引。

前阵子给自己的技术站点重构移动端交互,觉得传统底部的分页条太老气。在手机屏幕上点那些小小的“1、2、3、4、下一页”不仅容易误触,跳转时整页刷新还伴随着明显的白屏闪烁。看很多资讯类 App 和信息流网站全在用瀑布流,我顺手就换成了无限滚动加载:页面底部放个哨兵节点,用 IntersectionObserver 监听,用户一滑到底部就发个异步请求拉取下一页的数据,动态追加到列表末尾。

改完上线之后,自己拿着手机在各个浏览器里划拉了几圈,滑动顺畅,数据无缝拼接,体验确实比以前频繁点翻页按钮好得多。

结果安稳日子没过满一个月,翻开站长后台和搜索控制台,整站的抓取频次从每天几千次骤降到几百次。更要命的是,除了首页最新露脸的十几篇,以前排在第 2 页、第 3 页以后的老文章,蜘蛛几乎再也没来访问过。那些本来权重还算稳定的长尾技术解析,因为失去了抓取入口,收录量直接腰斩。

我当时第一反应是网站被降权了,排查了域名、SSL 证书和服务器网络都没毛病,最后拉出 Nginx 访问日志挨个分析爬虫的行为轨迹,才发现把分页改成纯前端无限滚动,在搜索引擎眼里相当于亲手把全站深层文章的入口给焊死了。

这里把我在这场翻车事故中踩过的几个核心坑,以及最终平稳落地的双轨改造方案记一下。


坑一:以为蜘蛛会替你滑屏,异步追加的内容在爬虫眼里全是一片空白

很多前端同学(包括我自己一开始)对现代搜索引擎爬虫的渲染能力抱有不切实际的幻想。常听人说 Googlebot 早就能运行 JavaScript 了,国内很多搜索引擎也陆续宣传支持动态渲染,就以为爬虫访问页面时会像真人一样把页面一路滑到底。

事实根本不是这么回事。

搜索引擎爬虫每天要调度上百亿个网页,服务器算力成本极其高昂。百度蜘蛛绝大部分请求依旧是轻量级的无状态 HTTP GET,拿到首屏返回的原始 HTML 文本,用正则或 DOM 解析器提取出所有的 href 链接就放进抓取队列了,压根不跑你的 JS 脚本。

就算遇上 Googlebot 的无头浏览器渲染队列(Web Rendering Service),它通常也只针对初次渲染执行主线程脚本,生成首屏快照用于评估内容。它绝对不会模拟用户手指在屏幕上匀速滑动、或者反复触发 window.onscroll 事件,更不会傻傻地等你那个哨兵元素进入视口再去触发 fetch 回调。

我当时写的前端逻辑长这样:

// 表面看起来很现代的无限滚动
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting && !loading) {
      loadNextPage(); // 异步发 fetch('/api/articles?page=' + nextPage)
    }
  });
});
observer.observe(document.querySelector('#scroll-sentinel'));

用 curl 伪装成百度蜘蛛去抓首页源码看一眼:

curl -s -A "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)" https://example.com/blog | grep -i "article-card"

终端打印出来的条数死死停在第 1 页的 10 条。10 条之后,页面底部就只剩下一个孤零零的 <div id="scroll-sentinel"></div>。

这意味着:任何被顶到第 11 条以后的老文章,在整站的内链拓扑结构里全部成了没有父级入口的“孤岛页面”。只要新文章不断发布,老文章被挤下首页,蜘蛛就再也找不到通往它们的超链接通道。抓取频次断崖式下跌完全是必然结果。


坑二:为了让每页有独立地址加了 pushState,却把 Canonical 标签全指回了首页

发现抓取截断之后,我意识到不能让分页只停留在内存变量里,得让每个分页都有合法的独立 URL。

当时查了些前端资料,看到有人推荐用 HTML5 History API:当页面拉取到第 2 页的数据并渲染进列表时,调用一次 history.pushState(null, '', '/blog/page/2'),这样用户滑动到第 2 页时地址栏会自动变化,刷新或者复制链接也能对应到第 2 页。

我在服务端把 /blog/page/2 的路由配好了,服务端渲染(SSR)也会根据 URL 里的页码吐出对应的第 2 页 HTML。

但是上线后观察了一周,老文章依然没有恢复抓取,GSC 里反而跳出一大堆警告:“网页未编入索引:备用网页(有适当的规范标记)”。

问题出在模板头部的 Canonical 规范标签上。

当时公共布局模版的 Head 部分是这么写的:

<!-- 坑爹的全局模板配置 -->
<link rel="canonical" href="https://example.com/blog" />

很多单页应用或 CMS 主题在处理列表页时,为了防止带各种杂乱筛选参数(如 ?sort=desc&filter=tech)的 URL 分散权重,图省事直接把整个栏目的 Canonical 统一写死成栏目根路径。

这就直接引爆了逻辑炸弹:

Canonical 标签在搜索引擎眼里的含义是:“无论当前 URL 看起来是什么,它都是指向目标的副本,请以目标页面的内容为准。”

当爬虫顺着外链或者站点地图好不容易访问到 https://example.com/blog/page/2 时,一抬头看到里面赫然写着 Canonical 指向 https://example.com/blog。爬虫立刻得出结论:第 2 页是第 1 页的重复副本,根本不需要分配索引额度,顺带把第 2 页里包含的那些老文章内链权重大打折扣甚至直接忽略。

对于内容完全不同的列表分页,Canonical 必须是自引用(Self-Referencing)的:

  • 第 1 页的 Canonical 是 https://example.com/blog
  • 第 2 页的 Canonical 必须严格对应 https://example.com/blog/page/2
  • 第 3 页的 Canonical 必须是 https://example.com/blog/page/3

绝对不能让所有分页都把 Canonical 指向首页,否则就相当于亲手告诉搜索引擎把你的深层列表全部丢弃。


坑三:前端路由改得欢,Nginx 伪静态一查全在吐 404

用 history.pushState 改变 URL 时,浏览器内部只是更新了地址栏记录,并不会真正向服务端发网络请求。所以在页面里顺畅滑动时,一切看似岁月静好。

但搜索引擎蜘蛛是独立的 HTTP 客户端,它拿到一个形如 /blog/page/2 的地址,是直接发起一条原生的 GET 请求。

如果你的后端是基于 Nginx 做反代或者静态托管,路由配置稍有疏漏就会引发灾难。我最初的 Nginx 配置大致是这样写的:

location /blog {
    alias /www/dist/blog;
    try_files $uri $uri/ /blog/index.html;
}

这套针对纯前端 SPA 单页应用的经典配置在普通二级路径下能跑,但一旦遇上深层伪静态路径,比如实际后端服务或者 Node SSR 需要根据路径解析分页参数时,Nginx 直接匹配上了 try_files,把原本应该由服务端程序根据页码渲染的请求,直接丢回了前端的空白外壳或者触发了路径查找失败。

更常见的情况是,有些站点用的是独立博客程序或轻量级框架,伪静态规则只写了匹配文章别名:

# 容易遗漏分页规则的正则
location ~ ^/blog/([a-zA-Z0-9-]+)$ {
    proxy_pass http://127.0.0.1:3000;
}

当爬虫访问 /blog/page/2 时,直接跌落到默认规则,返回了 404 Not Found。由于用户在前端页面里划屏时从来不会触发真实的整页刷新,站长自己在浏览器里根本察觉不到这个隐蔽的报错,直到在蜘蛛抓取失败日志里看到一片扎眼的 404 状态码才反应过来。


坑四:误把废弃的 rel="prev/next" 当成救命稻草,忽视了真正的锚文本链接

意识到分页 URL 必须对蜘蛛可达之后,很多人会在网上搜索“无限滚动 SEO 最佳实践”。

你大概率会搜出大量五六年前甚至更早的老教程,煞有介事地教你在 HTML 的 <head> 里塞入这两行标签:

<link rel="prev" href="https://example.com/blog/page/1" />
<link rel="next" href="https://example.com/blog/page/3" />

如果是在很多年前,这套标注确实能帮搜索引擎串联分页。但早在 2019 年,Google 官方就明确宣布早已废弃对 rel="prev/next" 的支持,不再将其作为分页索引信号。而百度等国内引擎也早已把注意力转向更实际的抓取行为:只看页面里有没有货真价实可见的超链接。

不仅如此,有些人在改造无限滚动时,把底部的“加载更多”做成了这种形式:

<!-- 蜘蛛完全看不懂的假链接 -->
<div class="load-more-btn" onclick="fetchNext()">点击加载更多</div>
<button id="btn-next" data-page="2">下一页</button>

在爬虫的抓取机制里,带 onclick 的 div 或者带事件监听的 button 根本不是超链接。爬虫抓取器在提取网页内链时,核心依赖的是带 href 属性的标准 <a> 标签。没有标准的 a 标签,再怎么在 head 里写 meta 注解也是徒劳。


解决办法:采用渐进增强的双轨方案(对人滑动无感,对蜘蛛透明静态)

踩完上面这一圈坑,逻辑其实就很清晰了:

我们要的是两全其美——在具备现代浏览器的真实访客面前,保持顺滑自然的无缝滚动体验;而在搜索引擎蜘蛛面前,页面必须退化为结构清晰、层层递进的传统分页 HTML。

这套方案不需要复杂的架构重构,核心思路分为三个部分。

1. 服务端直出带静态 a 标签的降级分页条

不管客户端以后执不执行 JS,服务端在首屏吐出 HTML 时,列表底部必须原生附带一个标准的分页导航结构。

例如服务端模板输出:

<div class="article-list" id="article-list">
  <!-- 服务端渲染出来的当前页 10 篇文章 -->
  <article class="article-card">
    <h2><a href="/article/nginx-cache.html">Nginx 缓存踩坑记录</a></h2>
  </article>
  <!-- ...其他文章... -->
</div>

<!-- 专供搜索引擎与 JS 降级的静态翻页导航 -->
<nav class="pagination-nav" id="pagination-nav">
  <a href="/blog/page/1" class="prev-page">上一页</a>
  <span class="current-page">第 2 页</span>
  <a href="/blog/page/3" class="next-page" id="next-page-link">下一页</a>
</nav>

无论百度蜘蛛还是任何无头抓取器来请求,一拿到这串 HTML,立刻就能从 <a href="/blog/page/3" class="next-page"> 提取出下一页的真实物理路径。蜘蛛沿着这个链接爬到第 3 页,第 3 页又包含通往第 4 页的 a 标签,整站无论沉淀了多少万篇老文章,内链链路始终是环环相扣的,绝不会发生截断。

2. 客户端 JS 动态接管:无感接缝与静默更新

当页面在普通访客的浏览器中运行时,JS 脚本加载完毕后,由前端逻辑自动接管这个静态分页条:

document.addEventListener('DOMContentLoaded', () => {
  const nextLink = document.getElementById('next-page-link');
  const paginationNav = document.getElementById('pagination-nav');
  const listContainer = document.getElementById('article-list');

  // 如果没有下一页了,直接退出
  if (!nextLink) return;

  // 1. 隐藏传统的静态分页按钮,换成无感滚动的哨兵节点
  paginationNav.style.display = 'none';

  const sentinel = document.createElement('div');
  sentinel.id = 'scroll-sentinel';
  sentinel.style.height = '10px';
  paginationNav.parentNode.insertBefore(sentinel, paginationNav);

  let nextPageUrl = nextLink.href;
  let isLoading = false;

  const loadMore = async () => {
    if (isLoading || !nextPageUrl) return;
    isLoading = true;

    try {
      const resp = await fetch(nextPageUrl);
      const htmlText = await resp.text();

      // 用 DOMParser 解析返回的新页面 HTML
      const parser = new DOMParser();
      const doc = parser.parseFromString(htmlText, 'text/html');

      // 提取新页面中的文章节点并追加
      const newArticles = doc.querySelectorAll('.article-card');
      newArticles.forEach(card => listContainer.appendChild(card));

      // 提取新页面里的下一页链接
      const newNextLink = doc.getElementById('next-page-link');
      if (newNextLink) {
        nextPageUrl = newNextLink.href;
      } else {
        nextPageUrl = null;
        observer.disconnect();
      }

      // 静默同步当前视口的地址栏 URL,方便用户分享与刷新
      window.history.replaceState(null, '', nextPageUrl || window.location.pathname);
    } catch (err) {
      // 网络异常时恢复显示传统分页条,允许用户手动点击重试
      paginationNav.style.display = 'block';
    } finally {
      isLoading = false;
    }
  };

  const observer = new IntersectionObserver((entries) => {
    if (entries[0].isIntersecting) {
      loadMore();
    }
  }, { rootMargin: '200px' });

  observer.observe(sentinel);
});

这个写法的巧妙之处在于:

  • 前端甚至不需要单独维护一套专供无限滚动的 /api/v1/articles JSON 接口。它直接拿 HTML 里的真实链接发请求,用 DOMParser 剥离出文章卡片追加进来。
  • 如果用户网络不好、或者脚本执行出错,catch 里直接把原本的 pagination-nav 显示出来,降级成传统的点击翻页,容错率极高。
  • 地址栏在滚动时通过 replaceState 悄悄与真实页码对齐,用户复制链接发给别人,对方点开就是那一个确定页码的内容。

3. 每个独立分页配置严格自引用的 Canonical 与正确的 HTTP 头

最后一步,确保每个分页 URL 都能独立在服务器端渲染,并且头部的 Canonical 必须严丝合缝地自引用:

以 /blog/page/2 为例:

<title>技术文章列表 - 第 2 页 | 我的博客</title>
<!-- 必须严格指向当前分页自身,杜绝指回首页 -->
<link rel="canonical" href="https://example.com/blog/page/2" />

并且在 Nginx 层面,确保这类分页路由直接交给后端处理,同时配置好静态资源的缓存头:

# 保证分页路径直达后端渲染程序,返回 200 状态码
location ~ ^/blog/page/([0-9]+)$ {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

改造上线后,我用包含 Baiduspider 和 Googlebot User-Agent 的终端脚本持续跑了几轮抓取测试,并在百度搜索资源平台提交了分页的更新探测。

大概过了两周左右,爬虫日志里关于 /blog/page/2、/blog/page/3 的请求密度开始明显回升。以前那些被挤到深层列表里的旧文章,蜘蛛顺着每个页面的静态 a 标签一条条重新爬了回去,收录曲线终于止跌回升,慢慢拉回到了改版前的健康水平。

移动端想要丝滑流畅的交互体验完全没有错,但做技术改版时千万别把搜索引擎爬虫当成真正的现代浏览器。给爬虫留好老老实实的静态 HTML 路径和自引用规范,永远是守住网站收录底线最稳妥的办法。

评论一下?

OωO
取消