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

单页应用爬虫抓不到内容?我用 Nginx 加 Prerender 自建预渲染服务,4个关键坑复盘

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

该文章探讨了使用Vue 3开发的单页应用在搜索引擎抓取时遇到的问题,并介绍了作者通过Nginx和Prerender自建预渲染服务解决的方法。文章详细分析了四个关键问题,包括Nginx转发配置问题、404错误处理、SEO优化等,提供了解决方案和代码示例。

几个月前我把一个用 Vue 3 写的工具类网站推上线,域名和基础内容都备齐了。跑了两个星期,去百度搜索资源平台和搜狗站长后台看抓取诊断。测试结果一出来,抓取到的页面源码里只有一个 <div id="app"></div> 节点,里面挂着两行打包出来的 JavaScript 脚本引用,页面正文半个汉字都没留下。虽然 Google 爬虫具备执行部分 JavaScript 的能力,但在国内搜索引擎的环境下,空骨架直接导致爬虫无法提取正文文本,内链也无法被发现,收录自然直接见底。

当时的第一想法是直接重构,改成 Nuxt 或者 Next.js 的服务端渲染(SSR)。但项目里已经写了四五十个页面,业务组件里散落着大量的 window 对象访问、localStorage 读写,还混用了一些没有做服务端适配的第三方绘图库。真要改写成 SSR,光是处理水合不一致和生命周期报错就得折腾两周,改造成本太高。

权衡之后我选了折中路线:保留原本打包好的 SPA 静态资源,在服务器后台用 Node.js 跑一个基于无头浏览器的 Prerender 预渲染服务。前面通过 Nginx 拦截搜索引擎的爬虫请求,只在爬虫访问时把请求反向代理到 Prerender 服务,由它渲染出包含完整 DOM 和文字的 HTML 快照返回;普通真实用户访问时依然直接读取静态文件。

整套思路很清晰,但在云服务器实际部署上线时,先后踩了四个不大不小的深坑。这里把当时的排查过程和稳定配置记录下来。

坑一:Nginx 判定爬虫时把静态资源也转走了,陷入请求死循环

这是部署初期最隐蔽的翻车点。Prerender 服务收到页面的渲染请求后,内部会启动 Headless Chrome 去加载目标网址。这时候 Chrome 本身也需要向 Nginx 发起请求,把页面依赖的 CSS、JS 以及图片资源拉取下来才能完成渲染。

如果在 Nginx 配置里单纯根据 User-Agent 判断爬虫就直接做重写转发,而且没有排除静态资源后缀,就会出问题。预渲染服务启动的 Chrome 去拉取 app.js 时,这个请求因为透传了相同的头部或者匹配了全局规则,又被 Nginx 转发回了 Prerender 服务。结果静态脚本一直加载不到,预渲染服务直接卡住超时,爬虫拿到的是一片空白。

正确的做法是在 Nginx 中分两层处理。先用 map 指令定义常见的搜索引擎爬虫标识,再通过正则表达式严格排除静态文件,确保只有页面路由才会走预渲染代理:

# 识别常见搜索引擎爬虫
map $http_user_agent $is_crawler {
    default 0;
    ~*(Baiduspider|Googlebot|bingbot|Sogou|Bytespider|360Spider|YisouSpider) 1;
}

server {
    listen 80;
    server_name example.com;
    root /www/web/dist;
    index index.html;

    location / {
        # 如果是爬虫,且请求的不是静态资源,就转发到预渲染服务
        if ($is_crawler = 1) {
            set $prerender 1;
        }

        # 遇到静态资源后缀,强制不走预渲染
        if ($uri ~* "\.(js|css|xml|less|png|jpg|jpeg|gif|pdf|doc|txt|ico|rss|zip|mp3|rar|exe|wmv|doc|avi|ppt|mpg|mpeg|tif|wav|mov|psd|ai|xls|mp4|m4a|swf|dat|dmg|rom|iso|flv|m4v|torrent|woff|ttf|svg|eot)") {
            set $prerender 0;
        }

        if ($prerender = 1) {
            # 这里的 3000 端口为本地自建的 prerender 服务
            rewrite .* /http://$host$request_uri? break;
            proxy_pass http://127.0.0.1:3000;
        }

        try_files $uri $uri/ /index.html;
    }
}

这段配置把静态文件后缀拦截在第一线,Chrome 内部请求资源时能走本地 Nginx 静态文件响应,避免了请求递归和超时堵塞。

坑二:SPA 页面返回 404 时 HTTP 状态码依然是 200,导致软收录受损

单页应用的所有路由都是前端路由接管的。当爬虫爬到一个已经失效或者原本就不存在的文章路径(比如 /article/99999)时,前端路由匹配到了未定义路由,在界面上展示了 404 错误页。

但在 HTTP 传输层上,Nginx 返回给爬虫的响应状态码依然是读取 index.html 产生的 200 OK。搜索引擎看到返回的是 200,就会把这个提示"页面不存在"的内容当作正文收录进去。如果站点有大量失效链接,搜索引擎后台会判定存在大量的软 404 页面,严重影响整个域名的权威度和爬虫抓取配额。

解决办法是利用 Prerender 约定的状态码捕获协议。Prerender 支持在页面解析期间识别特定的 meta 标签,并将其转换为实际的 HTTP 响应头。

在前端的全局 404 页面组件中,页面加载完成后向文档头部注入特定的 meta 标签:

// 在 404 页面组件挂载时执行
export default {
  mounted() {
    let meta = document.createElement('meta');
    meta.name = 'prerender-status-code';
    meta.content = '404';
    document.head.appendChild(meta);
  }
}

如果文章发生了路径迁移需要做 301 重定向,也可以采用同样的机制:

// 重定向跳转组件
export default {
  mounted() {
    const metaCode = document.createElement('meta');
    metaCode.name = 'prerender-status-code';
    metaCode.content = '301';
    document.head.appendChild(metaCode);

    const metaHeader = document.createElement('meta');
    metaHeader.name = 'prerender-header';
    metaHeader.content = 'Location: https://example.com/new-path';
    document.head.appendChild(metaHeader);
  }
}

Prerender 抓取到带这类标记的页面后,会直接将 HTTP 状态码设置成 404 或 301 返回给爬虫,彻底杜绝了软 404 带来的负面影响。

坑三:异步接口还没返回,HTML 快照就已经提前生成

预渲染服务内部的 Chrome 何时判定页面已经加载完毕?默认情况下,Prerender 会监听页面的网络请求,当发现 500 毫秒内没有新的网络请求时,就认为页面加载完毕并截取当前 DOM 生成 HTML。

在真实业务场景中,很多页面的核心文章内容是通过 Axios 异步接口拉取的。有时后端接口响应稍微慢了一点,或者首屏有多层依赖请求,Chrome 就会误以为网络空闲,提前抓出了一份只有骨架屏或 Loading 图标的半成品 HTML。

要确保快照里包含完整内容,必须把渲染完成的控制权交给前端代码自己掌握。Prerender 提供了一个全局标志 window.prerenderReady。

在 SPA 的 index.html 的 <head> 最前面声明这个变量,默认置为 false:

<script>
  window.prerenderReady = false;
</script>

然后在业务页面的数据请求彻底完成,且 DOM 渲染完毕后再将其置为 true:

// 页面组件中获取文章数据
async function loadArticleData() {
  try {
    const res = await fetchArticleDetail(route.params.id);
    article.value = res.data;

    // 等待 DOM 更新完毕后再通知预渲染服务
    await nextTick();
    window.prerenderReady = true;
  } catch (err) {
    // 接口报错时也要置为 true,配合前面的 404 逻辑输出状态码
    window.prerenderReady = true;
  }
}

在预渲染服务的启动参数中加上对应开关,Chrome 就会一直等待该变量变为 true 才会导出 HTML(配合设置的全局超时兜底)。这样生成的快照能确保每一行正文文字、图片标签以及页面内链都完整呈现。

坑四:Headless Chrome 内存泄漏与僵尸进程拖死小内存服务器

自建预渲染服务通常跑在轻量云服务器上,比如常见的 2 核 4G 机器。Headless Chrome 是吃内存的大户,如果遇到百度或搜狗爬虫突发密集抓取,瞬时并发十几只爬虫请求,每个请求开一个标签页,内存占用会直接飙升。

更要命的是,Node.js 调用的 Chrome 子进程偶尔会出现异常挂起,无法正常退出释放内存。随着时间推移,后台积攒的孤儿进程会把系统内存吃满,触发 Linux 系统的 OOM Killer 机制,不仅预渲染服务被杀掉,服务器上的 Nginx 和其他应用也会跟着崩溃。

解决这个问题必须做多层防线限制:

第一层,使用 Docker 容器化运行 Prerender,严格限制单容器的内存和 CPU 资源配额。

第二层,在 Prerender 的环境变量里开启定期重启策略,比如设置单个 Chrome 实例在处理完 300 次渲染请求后自动销毁并重建,防止内部内存无法释放。

第三层,设置严格的单次页面渲染超时,比如 8 秒内未完成必须强制返回当前 DOM,防止因某个外链挂起而永久卡住进程。

下面是经过压力测试验证的 docker-compose.yml 编排配置:

version: '3.8'

services:
  prerender:
    image: prerender/prerender:latest
    container_name: prerender-service
    restart: always
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      - PORT=3000
      - CHROME_FLAGS=--no-sandbox,--disable-setuid-sandbox,--disable-dev-shm-usage,--disable-gpu
      - NUM_WORKERS=2
      - NUM_REQUESTS_BEFORE_RESTART=300
      - PAGE_DONE_CHECK_INTERVAL=200
      - WAIT_AFTER_LAST_REQUEST=500
      - PRERENDER_TIMEOUT=8000
    deploy:
      resources:
        limits:
          cpus: '1.5'
          memory: 1536M

加上了内存硬上限和实例滚动重置后,服务器的内存占用率在长时间抓取压力下基本维持在 60% 以下,没有再出现过进程假死或系统 OOM 的情况。

方案验证与效果

这套配置跑了三个月,抓取耗时稳定在 700 毫秒到 1.5 秒之间。去站长平台做抓取测试,抓取到的源码不仅包含完整的 H 标签和段落正文,内链也能被爬虫正常顺藤摸瓜提取出来。新发布的内容在提交给搜索引擎接口后,通常能在三到五天内看到稳定的收录展现。

对于存在历史代码袱包的单页应用,不需要大动干戈去推倒重做服务端渲染,利用 Nginx 结合自建 Prerender 做爬虫定向转发,可以在保护服务器负载的同时,以很小的改动成本解决搜索引擎抓取不全的难题。

评论一下?

OωO
取消