该文章探讨了使用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 做爬虫定向转发,可以在保护服务器负载的同时,以很小的改动成本解决搜索引擎抓取不全的难题。
文章标题:单页应用爬虫抓不到内容?我用 Nginx 加 Prerender 自建预渲染服务,4个关键坑复盘
文章链接:https://www.llbbs.cn/jishujaocheng/217.html
本站文章均为原创,未经授权请勿用于任何商业用途
评论一下?