做网站的人,多少都碰到过这种怪事:浏览器里打开明明完整无缺,标题、正文、配图、数据全渲染好了,可一看搜索引擎的抓取结果,只剩一个白底空壳。原因也不复杂——站点的HTML里只有框架,所有内容全靠JS动态加载,而爬虫在拿到原始HTML那一步就把你的页面“看完”了,根本不会等你的JS跑完。今天这篇,我就结合宝塔面板,聊一套在“HTML静态+JS动态加载”网站上实现24小时自动静态预渲染的完整方案。做完之后,爬虫拿到的是一份已经渲染好的完整HTML,用户访问速度也能顺带提升一个档次。
这套方案适合谁?自己用宝塔面板搭了个人站、内容站、导航站,页面数据靠JS从接口拉的朋友;想让文章页和列表页被搜索引擎更快收录的朋友;以及不想为了SEO去重写一套SSR、只想用最小成本解决问题的朋友。内容不涉及任何花里胡哨的框架改造,就是把预渲染脚本、定时任务、Nginx路由这三块拼起来,我尽量把每一个细节和坑都写清楚。
1. 为什么HTML+JS动态加载的网站在爬虫眼里是“空壳”
1.1 服务器吐出来的原始HTML,和你看到的根本不是同一份
很多人对“HTML静态网站”有个误解:以为文件是静态的,页面就一定是静态的。实际上,很多用原生JS或Vue、React这类框架写的页面,服务器上存放的HTML只是一个“壳子”。比如下面这份代码,就是很典型的动态加载结构:
html复制<!doctype html>
<html lang="zh-cn">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>我的资讯站</title>
</head>
<body>
<div id="app"></div>
<script src="/js/main.js"></script>
</body>
</html>
浏览器打开没问题,是因为浏览器把main.js完整执行了一遍:fetch接口、拼接DOM、绑定事件,最终把内容填进#app里。但搜索引擎爬虫拿到的是哪个版本?正是上面这份只有空div的骨架。爬虫在“抓取HTML”这一步就把页面内容定了性,后面你的JS跑得多欢快,它根本不在乎。
这就是“动态加载网站”做SEO最尴尬的地方:你提供的内容是真材实料的,可爬虫拿到的白纸一张。很多站点反馈“只收录了首页,内页一个也不进”,多半就是这个原因。
1.2 各家搜索引擎对JS渲染的真实态度
搜索引擎不是没有渲染JS的能力,但“能渲染”和“愿意为每个页面都花这个成本”是两码事。我根据自己的日常观察,把主流搜索引擎的JS处理能力整理成了一张表:
| 搜索引擎 | 对JS渲染的支持 | 现实中的表现 |
|---|---|---|
| 支持较好,但需要进入渲染队列 | 新页面经常要等几天甚至更久才收录,核心页面收录相对快 | |
| 百度 | 支持不稳定 | 很多站点只有首页被收录,内页URL长期不进索引 |
| Bing | 支持一般 | 依赖外链和站点地图,动态页面收录波动大 |
| 搜狗/360等 | 相对保守 | 对纯JS渲染的页面基本是“看到什么记录什么” |
你可能会说:Google不是早就支持JS渲染了吗?支持归支持,它给每个页面分配的渲染预算有限。我测试下来,一个完全靠JS渲染内容的新页面,从提交到被真正执行JS、再到索引,周期可能比纯静态HTML长一倍以上。对于小网站来说,这种延迟非常致命——内容明明发了,搜索引擎就是不收录。
1.3 预渲染、SSR、CSR到底有什么区别
要理解预渲染的定位,得把几种渲染方式放在一起看:
- CSR(客户端渲染):就是你现在网站的运行方式。服务器只给空壳,浏览器跑JS填充内容。优点是对服务器压力小、开发灵活;缺点是SEO不友好,首屏也受JS加载速度影响。
- SSR(服务端渲染):每次用户请求页面,服务器都实时执行一次渲染,再把完整HTML返回。SEO很好,但对服务器性能要求很高,而且需要改造项目结构,工作量大。
- 预渲染(Prerender):在构建阶段或定时任务阶段,提前把页面渲染成完整HTML,保存为静态文件。之后不管是爬虫还是用户,拿到的都是这份现成的HTML。它不需要改造站点代码,又解决了SEO问题,对“内容更新频率不高”的站点来说非常合适。
导航站、文章站、企业站、资源列表站,这些站的内容一天更新几次甚至几天更新一次,完全可以用“每半小时或每小时预渲染一遍”的方式来覆盖。既享受了静态HTML的秒开速度,又不丢SEO,还不需要动现有的JS代码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型:为什么Puppeteer绕不开,其他方案差在哪
2.1 为什么不用PhantomJS
早些年大家做预渲染,首选是PhantomJS。它当年确实是唯一一个能“在服务器上无头运行、拿到渲染后DOM”的工具。但问题也很明显:PhantomJS已经停止更新很多年了,内置的WebKit内核版本非常老,对ES6语法、async/await、Fetch API、新版CSS特性的支持都很差。你现在站点里的JS只要用到一个fetch或者箭头函数,扔进PhantomJS里可能就直接白屏。
Puppeteer刚出来的时候,我就把身边几个站点的预渲染脚本从PhantomJS迁了过去。怎么说呢,就像把一台十年的老爷车换成现在的自动挡——同一个操作,体验天差地别。
2.2 Puppeteer到底干了什么
Puppeteer是Chrome团队推出的Node.js库,它的本质是:在服务器上启动一个“无头Chrome”实例,然后通过Chrome DevTools Protocol协议,用代码控制这个浏览器。
拿预渲染来说,整个过程是:
puppeteer.launch()启动一个无头浏览器;browser.newPage()打开一个新标签页;page.goto(url)访问你的目标页面,这一步会真实执行页面里的所有JS;- 等到网络请求基本停止后,调用
page.content()拿到当前页面的完整HTML; - 把这个HTML写进静态文件。
因为执行这段渲染的就是真正的Chrome内核,所以它得到的DOM和你自己在浏览器里右键“查看源代码”几乎完全一致。这是PhantomJS那种老方案给不了的保真度。
2.3 在宝塔面板跑无头浏览器,需要什么基础条件
Puppeteer虽然好用,但它毕竟是启动了一个完整的Chrome,所以对服务器环境有要求,主要是两点:
- 需要有Node.js运行环境。在宝塔面板里,可以通过软件商店安装PM2管理器或Node.js版本管理器,也可以用命令行自行安装,后面我会讲到。
- 无头Chrome需要一些Linux系统依赖库。常见的是libnss3、libatk-1.0、libatk-bridge2.0、libcups、libdrm、libxkbcommon等。在CentOS和Ubuntu上,装依赖的方式略有不同,但绝大多数情况下,只要系统是干净的,缺哪个补哪个就行。
有一点要注意:无头Chrome默认在沙箱模式下运行,而很多服务器环境没有配置好沙箱所需的内核参数,启动时会报No usable sandbox错误。所以脚本里通常要加--no-sandbox参数。这个我在后面章节会给出可以直接用的完整示例。
3. 宝塔面板环境准备:Node.js运行栈与目录规划
3.1 安装Node.js运行环境
宝塔面板装Node.js,我一般推荐用软件商店里的“PM2管理器”或者“Node.js版本管理器”,装完等于环境、进程守护都有了。如果商店里找不到对应版本,也可以用命令行手动安装,以Ubuntu为例:
- Debian/Ubuntu系列:
bash复制curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
apt install -y nodejs
- CentOS/RHEL系列:
bash复制curl -fsSL https://rpm.nodesource.com/setup_18.x | bash -
yum install -y nodejs
装完验证一下:
bash复制node -v
npm -v
看到版本号输出了,环境就算OK。我个人更建议用宝塔软件商店里的PM2管理器来装,因为它会把Node、npm、PM2一起装好,并且之后可以用PM2来守护预渲染脚本。不过我们这个方案里,定时任务本身就承担了“触发”职责,脚本跑完就退出,不要求常驻,所以用不用PM2其实不是硬性要求。
3.2 目录结构怎么规划才不容易乱
预渲染方案里有一个很容易踩的坑:源站文件、预渲染输出文件、脚本文件三者混在一起,最后Nginx配置都分不清谁是谁。我建议从一开始就用清晰的目录结构,比如:
text复制/www/wwwroot/mysite/
├─ public/ # 源站静态文件(原来的网站文件)
│ ├─ index.html
│ ├─ css/
│ └─ js/
├─ prerender/ # 预渲染输出目录
│ ├─ index.html
│ ├─ about.html
│ └─ news.html
├─ renderer/ # 预渲染脚本目录
│ ├─ package.json
│ ├─ render.js
│ └─ logs/
└─ nginx.conf # 站点配置备份
为什么把预渲染输出单独放一层目录?因为这样可以做到“源站文件不动,预渲染结果独立切换”。如果直接覆盖原文件,一旦脚本出错或者页面渲染不完整,线上就变成坏页面了,而且想回滚都没有备份。独立目录的好处是:脚本先全部生成完,确认无误后,再通过Nginx把流量切到prerender目录,或者让爬虫只拿prerender目录的文件,风险小很多。
3.3 安装Puppeteer依赖
进入脚本目录,初始化npm项目并安装Puppeteer:
bash复制cd /www/wwwroot/mysite/renderer
npm init -y
npm install puppeteer
这里要提醒一句:npm install puppeteer会自动下载对应版本的Chromium,压缩包大概一百多MB,安装时间取决于服务器带宽。如果安装过程卡住或超时,可以设置淘宝镜像再试:
bash复制npm config set registry https://registry.npmmirror.com
npm install puppeteer
如果网络实在不行,也可以不下载内置Chromium,改用系统已有的Chrome,做法是在安装时设置PUPPETEER_SKIP_DOWNLOAD=true,然后在脚本里通过executablePath指定Chrome的二进制路径。但大多数情况下没必要这么麻烦,直接装内置版最省心。
4. 预渲染脚本核心实现:从URL列表到完整静态HTML
4.1 URL清单怎么维护
预渲染的第一步,是明确“要渲染哪些页面”。最简单的方式,是在脚本里维护一个URL数组。内容站通常也没几个核心页面,手动维护完全够用:
javascript复制const RENDER_URLS = [
'https://example.com/',
'https://example.com/about.html',
'https://example.com/news.html',
'https://example.com/tools.html'
];
如果页面数量多,也可以让脚本自动读sitemap.xml,把里面所有URL解析出来依次渲染。还有一种思路是只渲染“动态变化的列表页”和“新发布的内容页”,历史页面实际上不需要每次重新渲染。我自己的经验是:对大多数中小站点来说,全站URL一次渲染,并发控制好,一分钟内就能跑完,没必要做太复杂的增量逻辑。但渲染完的文件写入方式要讲究,这点后面章节会说。
4.2 一个可直接落地的Puppeteer渲染脚本
下面是我在宝塔面板上正在用的脚本骨架,去掉了敏感信息,可以直接参考:
javascript复制const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
const RENDER_URLS = [
'https://example.com/',
'https://example.com/news.html'
];
const OUTPUT_DIR = path.join(__dirname, '../prerender');
const CONCURRENCY = 3; // 并发标签页数量
const RETRY_TIMES = 2; // 单个页面失败重试次数
const TIMEOUT = 60000; // 页面加载超时时间
function getFileFromUrl(url) {
const urlObj = new URL(url);
let pathname = urlObj.pathname;
if (pathname === '/' || pathname === '') {
return 'index.html';
}
pathname = pathname.replace(/\/$/, '');
return pathname + '.html';
}
async function renderPage(browser, url) {
const page = await browser.newPage();
try {
// 用爬虫UA渲染,让页面返回爬虫视角的内容
await page.setUserAgent('Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)');
await page.setViewport({ width: 1280, height: 800 });
await page.goto(url, { waitUntil: 'networkidle2', timeout: TIMEOUT });
// 模拟滚动到底部,触发懒加载内容
await page.evaluate(async () => {
await new Promise(resolve => {
let totalHeight = 0;
const distance = 300;
const timer = setInterval(() => {
const scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= scrollHeight) {
clearInterval(timer);
resolve();
}
}, 100);
});
});
// 等待最后一帧稳定
await new Promise(resolve => setTimeout(resolve, 2000));
const html = await page.content();
const filePath = path.join(OUTPUT_DIR, getFileFromUrl(url));
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, html);
console.log('[OK] ' + url + ' -> ' + filePath);
} catch (e) {
console.error('[FAIL] ' + url + ' : ' + e.message);
throw e;
} finally {
await page.close();
}
}
async function renderWithRetry(browser, url) {
for (let i = 0; i <= RETRY_TIMES; i++) {
try {
await renderPage(browser, url);
return;
} catch (e) {
console.warn('retry ' + url + ' (' + (i + 1) + ')');
}
}
}
(async () => {
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu'
]
});
// 分批并发
for (let i = 0; i < RENDER_URLS.length; i += CONCURRENCY) {
const batch = RENDER_URLS.slice(i, i + CONCURRENCY);
await Promise.all(batch.map(url => renderWithRetry(browser, url)));
}
await browser.close();
console.log('render done');
})();
4.3 等待策略怎么选:networkidle2、domcontentloaded、自定义等待
这是预渲染脚本里最玄学也最关键的一部分。
waitUntil主要有这几个取值:
load:页面所有资源加载完成时返回。但很多站点的JS是异步加载的,load触发时接口数据可能还没回来。domcontentloaded:DOM解析完成就返回,往往不是你想要的结果,JS还没跑完。networkidle0:网络连接数降为0时返回。如果页面有轮询请求或websocket长连接,可能永远不会触发。networkidle2:网络连接数降到不超过2时返回,且持续500ms。这是我最常用的值,它给页面留出了接口请求、渲染的时间,又不会因为轮询而一直卡住。
但networkidle2不是万能的。有些页面接口很快返回,但JS渲染还需要几百毫秒,此时网络已经空闲了,DOM却还没拼接完。所以我通常会在goto之后加一个固定的等待时间,比如上面的脚本里setTimeout(resolve, 2000),给渲染留出余量。
更稳健的做法是使用page.waitForSelector()等待某个关键节点出现。比如你的列表页渲染完之后,#list .item这样的节点一定会出现,那就等它:
javascript复制await page.waitForSelector('#list .item', { timeout: 10000 });
但如果页面本身可能没有数据(比如空列表),这个等待就会抛错,需要catch处理。实际使用中,我建议“networkidle2 + 固定等待 + 超时兜底”三件套组合,既简单又不容易误判。
4.4 并发数控制:别把服务器内存打爆
无头Chrome每个标签页大概会占用80到150MB内存,如果并发开太多,1GB内存的服务器直接卡死。我通常把并发数控制在2到5之间。
脚本里用分批Promise.all的方式实现了一个极简并发池。每一批3个页面同时跑,跑完再开下一批。这样不会同时启动十几个Chrome标签页,也不会一个页面一个页面串行导致速度太慢。
如果你的页面数量特别多,想更精细地控制并发,可以引入puppeteer-cluster这个库,它支持队列、重试、并发配置,适合大型站点。但对于几十个页面的场景,自己写这个分批逻辑反而更轻量。
5. Nginx路由规则与静态HTML的切换策略
5.1 预渲染完成后,到底给谁看?
脚本跑完,prerender目录里就有了一份完整HTML。接下来要决定:这份HTML是给所有用户看,还是只给爬虫看?
我总结了两种方案,各有适用场景,你可以按实际情况选:
| 方案 | 做法 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 爬虫专用 | 通过User-Agent识别爬虫,只给爬虫返回prerender目录 | 用户行为不受影响,动态实时性不受影响 | 普通用户仍然要跑JS,首屏速度没有提升 | 站点内容实时性要求高,比如行情、订单状态 |
| 全局替换 | 网站根目录直接指向prerender目录 | 所有访客都享受静态HTML秒开,SEO和用户体验一起提升 | 预渲染周期内发布的新内容不会立刻出现 | 内容更新频率不高,如文章、导航、产品展示 |
对我来说,日常维护的内容站和导航站,我一般直接用“全局替换”。因为这类站点内容更新本身就有延迟,晚半小时展示完全可以接受。但如果你做的是工具类站点,页面里要实时显示余额、任务进度什么的,那还是用“爬虫专用”方案更稳妥,用户侧保持JS实时渲染。
5.2 爬虫专用方案的Nginx写法
在宝塔面板的站点设置里,找到“配置文件”,在server块中增加一段UA判断:
nginx复制location / {
if ($http_user_agent ~* "(Baiduspider|Googlebot|bingbot|Bytespider|360Spider|Sogou web spider|Sogou inst spider|YisouSpider)") {
rewrite ^/$ /prerender/index.html break;
rewrite ^/(.*)\.html$ /prerender/$1.html break;
}
try_files $uri $uri/ /index.html;
}
注意rewrite规则中的break,确保匹配到预渲染文件后不再继续执行后面的try_files。另外,如果页面URL是“无.html后缀”的伪静态形式,比如/news/123,那rewrite规则要改成匹配目录形式,比如:
nginx复制if ($http_user_agent ~* "Baiduspider|Googlebot") {
rewrite ^/(.*)$ /prerender/$1.html break;
}
但这条规则有个隐患:如果用户直接访问/js/main.js,UA又是爬虫,会被错误地重写到/prerender/js/main.js.html。所以更严谨的写法是,排除常见的静态资源目录:
nginx复制location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
expires 7d;
try_files $uri =404;
}
location / {
if ($http_user_agent ~* "(Baiduspider|Googlebot|bingbot|Bytespider)") {
rewrite ^/$ /prerender/index.html break;
rewrite ^/(.*)$ /prerender/$1.html break;
}
try_files $uri $uri/ /index.html;
}
这样静态资源请求先进第一个location,不会被UA判断拦截。
5.3 全局替换方案的Nginx写法
如果决定让所有用户都使用预渲染页面,操作更简单——直接把站点根目录指向prerender:
nginx复制server {
listen 80;
server_name example.com;
root /www/wwwroot/mysite/prerender;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源仍然从public目录读取
location /js/ {
alias /www/wwwroot/mysite/public/js/;
}
location /css/ {
alias /www/wwwroot/mysite/public/css/;
}
location /img/ {
alias /www/wwwroot/mysite/public/img/;
}
# 接口反向代理保持原样
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
这里有一个非常容易踩的坑:预渲染出来的HTML里,JS和CSS的引用路径必须是绝对路径,也就是以/开头,比如/js/main.js,而不是js/main.js或../js/main.js。因为prerender目录的层级和public目录不同,相对路径很容易就指向错误位置,导致预渲染页面虽然内容完整,但样式全丢、JS不执行。我吃过这个亏,排查了好久才发现是路径问题。
5.4 写入原子性:如何避免Nginx读到“半成品”文件
预渲染脚本在写HTML文件时,如果直接fs.writeFileSync覆盖旧文件,可能在写入中途被Nginx读到,造成页面短暂残缺。虽然概率不高,但一旦发生,爬虫正好抓取到残缺页面,就得不偿失了。
更稳妥的做法是“先写临时文件,再原子替换”:
javascript复制const tmpFile = filePath + '.tmp';
fs.writeFileSync(tmpFile, html);
fs.renameSync(tmpFile, filePath);
fs.renameSync在同一文件系统内是原子操作,Nginx无论如何都只会读到完整的旧文件或完整的新文件,不会卡在中间状态。这个习惯我建议一定要养成,成本几乎为零,但能规避一类非常隐蔽的线上故障。
6. 24小时自动执行:宝塔计划任务与更新节奏设计
6.1 宝塔计划任务的配置步骤
预渲染不是跑一次就完事,网站内容在持续更新,所以需要一个定时任务来反复执行。宝塔面板的“计划任务”功能非常成熟,配置也简单:
- 登录宝塔面板,左侧菜单找到“计划任务”;
- 点“添加任务”;
- 任务类型选择“Shell脚本”;
- 任务名称填
prerender之类; - 执行周期按需选择,或者选“自定义”填cron表达式;
- 脚本内容填下面这段:
bash复制cd /www/wwwroot/mysite/renderer
/usr/bin/node render.js >> logs/render.log 2>&1
这里/usr/bin/node是Node的绝对路径,如果你不确定,可以在服务器上执行which node查看。用绝对路径是为了避免计划任务的Shell环境与登录Shell环境不一致导致找不到命令。
6.2 Cron周期怎么定才合理
“24小时自动”不一定是每小时都跑,周期完全取决于站点内容更新速度。我列几个常见场景:
| 站点类型 | 推荐执行周期 | Cron表达式 |
|---|---|---|
| 文章站,一天更新1-3次 | 每6小时一次 | 0 */6 * * * |
| 导航站,频繁收录新资源 | 每30分钟一次 | */30 * * * * |
| 企业展示站,内容极少变动 | 每天凌晨一次 | 0 2 * * * |
| 聚合站/工具站,更新不确定 | 每小时一次 | 0 * * * * |
执行频率太高会让服务器一直忙于启动无头Chrome,白白消耗CPU;频率太低又会让新内容迟迟不进预渲染页面。我自己的平衡点是:内容站每6小时一次,导航站每1小时一次,如果哪天内容更新特别频繁,手动跑一次脚本补一下就行。
6.3 配合宝塔“任务日志”看执行结果
宝塔计划任务每执行一次,都会在任务列表里生成日志。如果脚本有输出,比如[OK] https://example.com/,可以在日志里看到。但要注意,console.log默认是标准输出,用>> logs/render.log 2>&1重定向到文件后,宝塔自带日志里只能看到执行状态码,看不到脚本细节。所以我习惯同时输出到文件,这样排查问题时可以直接看logs/render.log。
如果发现某次任务状态显示“执行失败”,第一步就是去服务器上手动执行一遍脚本,看完整报错。最常见的原因无非三种:Node路径不对、网络超时、服务器内存不足导致Chrome崩溃。
6.4 增量更新的进阶思路:内容变更触发式渲染
定时任务已经能满足“周期性更新”的需求,但如果你的站点每次只发布一两篇文章,全站重新渲染确实浪费。进阶一点的玩法是:内容发布接口里触发一次渲染脚本,只渲染新增和受影响的页面。
比如在发布文章的后端逻辑中,调用一个node render.js --only=/news/123.html,脚本内部解析参数,只渲染指定页面。这样既保证了新内容立即出现在预渲染HTML里,又避免了全站渲染的资源浪费。不过这个方案需要改造站点,工作量取决于你的发布逻辑。我建议先跑通定时任务方案,后续确实有“秒级收录”需求了再考虑。
7. 上线后的性能优化与踩坑实录
7.1 无头Chrome在宝塔服务器上的内存优化
如果你服务器内存只有1GB或512MB,跑无头Chrome会非常吃力。我踩过的坑是:原本并发开到5,结果任务跑到一半服务器直接OOM,SSH都连不上,只能重启。
优化方向有这么几个:
- 降低并发数:把
CONCURRENCY改成2或1,过程慢一点,但稳定。 - 开启
--disable-dev-shm-usage:这个参数很关键。Chrome默认会用/dev/shm共享内存,而容器环境里/dev/shm通常只有64MB,很容易爆掉。加上这个参数后,Chrome会改用磁盘临时文件,内存压力小很多。 - 限制Chrome的缓存占用:在启动参数中加
--disk-cache-size=1048576,把磁盘缓存限制在1MB以内,对预渲染这种一次性场景完全够用。 - 跑完必须
browser.close():如果脚本异常退出,Chrome进程会残留。建议配合宝塔的“系统防火墙”或者定期执行pkill -f chrome来做兜底清理。不过这个命令别放定时任务里乱跑,得确保不会误杀其他业务。
7.2 图表、懒加载导致渲染结果不完整
这是预渲染最典型的翻车场景。页面里有ECharts图表,或者图片用了懒加载,网络空闲时图表还没初始化完,page.content()抓到的HTML就缺了图表节点。解决思路有两个:
- 等待关键节点:在脚本里用
page.waitForSelector()等图表容器出现,或者用page.waitForFunction()轮询某个数据变量是否已赋值。 - 模拟滚动:很多懒加载效果要在滚动到视口内才触发。我脚本里的模拟滚动代码就是为了处理这个问题。懒加载组件一般会在图片进入视口后替换
data-src为真实src,滚动一遍之后,HTML里的图片地址就是最终状态了。
如果用了ECharts这种canvas绘制的图表,还要注意:canvas内容本身不在DOM里,抓HTML是抓不到图形的。这种场景要么在预渲染时隐藏canvas的容器、用静态占位图替代,要么放弃对图表区域的SEO覆盖。好在大数据图表对SEO来说本来也不是重点。
7.3 接口超时和慢接口引起的渲染失败
预渲染脚本挂在页面加载上,而页面加载又依赖接口响应。如果某个接口偶尔超时,整页渲染就会失败。我的做法是:
- 脚本里单页面重试2次,给接口一个恢复窗口;
goto超时设成60秒,超过就放弃;- 在站点JS里给fetch请求设置合理的超时,别让它无限等待。
另外,如果你的站点数据是从第三方接口拉的,预渲染时段第三方刚好挂了,那你生成出来的静态HTML会是一份空数据页面,比没预渲染还糟糕。针对这种情况,可以在脚本里加一个“内容有效性校验”,比如检查渲染后的HTML里是否包含文章列表的某个特定class节点,如果为空,就保持旧文件不覆盖。
7.4 验证爬虫看到的页面是否正确
配置好Nginx和定时任务后,不要直接干等搜索引擎来抓,先用命令行自己验证一遍。方法很简单:
bash复制curl -A "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)" https://example.com/
如果返回的HTML里包含完整的内容节点,说明UA判断和rewrite规则都生效了。再换一个普通UA请求一遍,确认普通用户访问是否正常。
我习惯把这条curl命令也加进预渲染脚本的最后一步,自动检查渲染结果。如果检测到内容为空或关键节点缺失,就让脚本返回非0退出码,宝塔计划任务日志会同时标记为失败,这样就能第一时间发现异常。
7.5 我跑这套方案半年后的几点体会
从我把自己的几个内容站切到预渲染方案到现在,最明显的变化是:百度从“只收录首页”变成正文页和列表页都开始进索引,Google那边新页面的收录速度也快了不少。期间因为偷懒停过一个月的定时任务,结果新发布的内容又变得“死活不收”,重新开启预渲染后一周左右才恢复。这也从侧面说明,定时任务不能断,一旦断了,搜索引擎会重新把网站当成“动态加载站点”来处理,索引恢复需要时间。
另一个心得是,别为了“预渲染”去动现有站点结构。这套方案的精髓就是“不改业务代码,只加一层静态兜底”。你把Puppeteer、Nginx、计划任务这三件事跑通,剩下的大头其实是维护URL清单和观察日志。等到哪天你发现日志里不再有[FAIL],某几页的HTML连续几次没有变化,你再考虑是不是该把定时周期拉长一点,此时整个系统就已经非常稳定了。
