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

给网站加了 PWA 和 Service Worker 离线缓存,搜索快照却全成了离线兜底页?排查爬虫渲染激活 Worker、CacheFirst 误锁 HTML 与紧急注销我踩过的几个坑

2026-10-16 / 0 评论 / 8 阅读
AI 摘要由 AI 生成

作者在进行网站PWA改造时,意外导致搜索引擎快照仅显示离线兜底页,通过排查发现,爬虫执行JS激活Service Worker和CacheFirst缓存策略错误是主要原因。作者详细分析了问题产生的原因和解决方案,强调了PWA改造中需注意的细节。

几个月前我给手头的内容站做了 PWA 改造。当时的想法很单纯:用 Workbox 给静态资源做离线预缓存,加一个简单的 navigateFallback 离线兜底页,让手机端弱网访问能秒开,即使断网也能看到友好的“网络连接中断,请稍后重试”提示。

上线前在手机 Chrome 和桌面浏览器里测试,离线拦截、资源更新都很顺畅。结果上线第三周,Google Search Console 里的有效网页索引开始大面积警告,百度搜索的抓取频次也掉了大半。去查搜索引擎快照的时候,整个人直接懵了:几十篇新发布的文章,搜索快照里既没有正文也没有图片,清一色全是那个居中的离线提示框“您当前处于离线状态”。

原本好端端的内容站,被自己亲手配的 Service Worker 直接洗成了空壳。排查和抢救折腾了整整两天才恢复正常,中间有几个隐蔽的坑点,记录一下完整的复盘过程。

爬虫也会执行 JS 并激活 Service Worker

过去很多人潜意识里觉得搜索引擎蜘蛛只是跑一个简单的 HTTP 客户端,抓了 raw HTML 就走,不会去管客户端脚本。但现代搜索引擎的抓取流程早就是混合模式了。Googlebot 使用的是接近最新版 Chromium 的渲染引擎,百度和搜狗等国内搜索引擎在对移动落地页进行反作弊与体验评估时,也会用无头浏览器跑完整的页面生命周期。

当时我的前端入口文件里写了这样一段通用的注册逻辑:

if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js').catch(err => {
      console.error('SW register failed:', err);
    });
  });
}

这段代码对普通访客完全没毛病,但当 Googlebot 的渲染沙箱带着特定的抓取策略跑进网页时,问题就来了。

渲染沙箱执行这段代码后,成功在沙箱环境里注册并激活了 /sw.js。一旦 Worker 激活,它就接管了后续同域下的每一个网络请求。很多爬虫在渲染页面时为了降低带宽开销和防死循环,会限制渲染沙箱的二次外联请求耗时,或者对异步资源的请求做拦截和限流。

这时候 Workbox 默认的离线回退机制就会触发。

navigateFallback 触发网络超时误判

在当时的 Workbox 配置文件里,我按照官方文档的常见示例配了离线回退路由:

import { registerRoute, NavigationRoute } from 'workbox-routing';
import { NetworkFirst } from 'workbox-strategies';

const navigationHandler = async (params) => {
  try {
    return await new NetworkFirst({
      networkTimeoutSeconds: 3,
    }).handle(params);
  } catch (error) {
    return caches.match('/offline.html');
  }
};

registerRoute(new NavigationRoute(navigationHandler));

这里的致命破绽在 networkTimeoutSeconds: 3 和 catch 逻辑。

爬虫沙箱在并发抓取或者网络调度拥堵时,首字节响应很可能超过 3 秒。对爬虫沙箱来说,一旦 3 秒没拿到完整网络流,Service Worker 立即宣告网络失败,把本地预缓存里的 /offline.html 调出来返回给调用方。

更要命的是返回状态码。通过 Cache API 吐出的 /offline.html,在浏览器沙箱眼里是一个标准的 HTTP 200 OK 响应。爬虫根本不知道这个响应其实是前端本地伪造的离线兜底,只看到这个 URL 返回了 200,内容就是一段离线文字,于是顺理成章地把这页内容当作正文存进了索引库。

原先权重很高的页面,快照在一周之内被这批 200 状态码的离线提示替换得干干净净。

HTML 文档绝不能用 CacheFirst

排查过程中还发现另一个隐蔽配置:部分详情页被配置成了 CacheFirst 缓存策略。

// 错误示范:千万别对 HTML 页面使用 CacheFirst
registerRoute(
  ({ request }) => request.destination === 'document',
  new CacheFirst({
    cacheName: 'html-pages-cache',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 7 * 24 * 60 * 60,
      }),
    ],
  })
);

把静态 JS、CSS 和图片做 CacheFirst 没问题,但如果把 document(HTML 文档)放进 CacheFirst,哪怕只给一天的有效期,也是灾难级的。

当文章内容在后台修改、纠错或者补充了代码示例后,搜索引擎爬虫再次发起抓取,Service Worker 拦截到请求后直接从 Cache Storage 里返回了旧版的静态快照,根本不会去向源站发起新请求。更糟糕的是,如果爬虫第一次抓取时因为接口抖动拉到了半截骨架屏并被存入缓存,后续所有的重试抓取都会命中这个残缺的本地缓存,源站发布了什么它都视而不见。

对于内容站的 HTML 导航请求,要么用 NetworkOnly,要么用纯净的 NetworkFirst,并且绝对不能给爬虫环境开启离线兜底。

sw.js 自身被 Nginx 强缓存

修好前端代码后,我把更新后的 sw.js 重新推上了服务器,并在后台点了重新收录。过了一整天再去观察,爬虫抓回来的快照依然是离线页。

用 curl 检查了一下静态资源的 HTTP 响应头才发现问题所在:

curl -I https://www.llbbs.cn/sw.js

返回的头部赫然写着:

HTTP/2 200
server: nginx
content-type: application/javascript
cache-control: max-age=2592000

因为之前的 Nginx 配置文件里写了一条通用的静态资源缓存规则:

# 踩坑配置:把所有 .js 文件一视同仁缓存了 30 天
location ~* \.(js|css|png|jpg|jpeg|gif|ico|woff2)$ {
    expires 30d;
    add_header Cache-Control "public, no-transform";
}

这条正则把根目录下的 sw.js 也包了进去。虽然浏览器规范要求 Service Worker 脚本每隔 24 小时必须强制做一次 HTTP 检查,但爬虫的沙箱环境可能生命周期更短,或者严格尊崇 HTTP 缓存头部。只要 sw.js 带着 max-age=2592000,客户端和代理节点就不会主动拉取新版本的 Worker 代码。

解决办法是在 Nginx 里给 sw.js 设定优先级更高的单独规则,彻底封死缓存:

location = /sw.js {
    expires -1;
    add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
    add_header Service-Worker-Allowed "/";
}

前端注册入口彻底隔离爬虫

最稳妥的防线是在最源头的前端注册逻辑里,把所有已知的搜索引擎爬虫彻底排除掉。爬虫来访问网站是为了索引内容,不是为了离线离网使用,让爬虫去加载和执行 Service Worker 本身就是没有必要的负担。

在注册 Service Worker 前,先做一道 User-Agent 和环境特征识别:

function isSearchBot() {
  const ua = navigator.userAgent.toLowerCase();
  const botKeywords = [
    'baiduspider',
    'googlebot',
    'bingbot',
    'sogou',
    'bytespider',
    'yisouspider',
    '360spider',
    'slurp',
    'duckduckbot',
    'facebookexternalhit',
    'twitterbot',
  ];

  if (botKeywords.some(bot => ua.includes(bot))) {
    return true;
  }

  // 部分无头爬虫会开启 webdriver 标志
  if (navigator.webdriver) {
    return true;
  }

  return false;
}

if ('serviceWorker' in navigator && !isSearchBot()) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js');
  });
}

加上这段判断后,所有主流搜索引擎蜘蛛在抓取页面时,看到的都只是普通的 DOM 结构,不会再去触发 Worker 的下载、安装与请求拦截。

坏 Worker 蔓延时的紧急注销方案

如果线上已经被爬虫或者大量真实用户缓存了错误的 Service Worker,仅仅在服务器上删除 sw.js 是行不通的。浏览器请求 sw.js 遇到 404 时,旧的 Worker 仍会继续接管页面,直到超时被系统丢弃。

当时为了让所有客户端和可能残留的爬虫沙箱快速自愈,我写了一个只有几行代码的自毁版 sw.js 替换原文件:

// 紧急解毒版 sw.js
self.addEventListener('install', () => {
  self.skipWaiting();
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) => {
      return Promise.all(keys.map(key => caches.delete(key)));
    }).then(() => {
      return self.registration.unregister();
    }).then(() => {
      return self.clients.matchAll();
    }).then((clients) => {
      clients.forEach(client => {
        if (client.url && 'navigate' in client) {
          client.navigate(client.url);
        }
      });
    })
  );
});

这个自毁脚本做的事情很简单:
只要客户端拉到它,立即跳过等待接管控制权;然后在激活瞬间把本地 caches 里的所有静态快照清空,注销当前的 Service Worker 注册信息,并刷新当前页面。

同时在主站 HTML 的顶部加了一段临时的内联兜底脚本:

<script>
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.getRegistrations().then(function(registrations) {
    for (let registration of registrations) {
      registration.unregister();
    }
  });
}
</script>

挂上自毁脚本并重新在百度搜索资源平台和 Google Search Console 提交 URL 重新检测,大概过了 36 小时,快照里的离线提示框陆续消失,正常正文快照重新回到了索引库里。

离线缓存确实能让页面秒开,但对搜索引擎而言,每一条请求都必须是实打实的网络往返。把给真人弱网准备的兜底方案无差别塞给爬虫,稍不留神就会把整站收录拖进沟里。

评论一下?

OωO
取消