最近工作中遇到一个使用nodejs实现爬虫程序的任务。需求背景是这样的:公司运营的一个老项目运营那边最近提了SEO优化的需求,但是项目本身并没有做SSR(服务端渲染),公司的要求是花费的人力成本最低,代价最小。在经过一番调研之后团队收集了几种备选方案,一番讨论之后最终选择了koa+puppeteer的方案。PS:欢迎关注作者微信公众号fever code
,获取最新技术分享。
选型中的一些思考
调研的过程中团队是有收集几种技术方案供选择的,但每种技术方案都利弊兼有,适用的场景也各不相同。虽然最后只能取一种最适合我们需求的方案,但这个思考的过程我觉得还是很有价值的。
- 新项目剥离部分代码的方案:将项目中对SEO需求较高的几个页面的代码剥离出来,另起一个Nuxt项目盛放这几个页面,在老项目中使用window.open的方式进行新老项目的互通。这种方式的优点就是尽可能改动少的代码来实现目前的需求,但其实缺点也很明显。在实施落地的过程中会存在新页面打开新项目体验较差、两个项目之间数据不互通、迭代升级的时候两个项目来回切换不方便开发、多一个代码仓库维护不方便,CI/CD很麻烦、服务器多启一个项目且技术栈不同也抬高了运营维护成本,加重了服务器的负担,造成不必要的服务器资源浪费。综上所述,这是一个缺点大于优点的方案,不到万不得已是不会采纳的,所以最终被我们抛弃。
- 新项目剥离部分代码结合微前端的方案:这种方案在前一种方案的基础上做了升级改造,最大的不同之处在于两个项目的交互方式上,相比使用上割裂感极强的window.open的方式,微前端方案保留了SPA(单页应用)无刷新方式的流畅体验,且能方便的实现多个项目之间的数据互通,对项目全局状态的维护毫无压力。但是这种方案需要维护三个项目:微前端主应用(对两个子项目进行聚合)、老项目(子应用)、新的剥离出来的Nuxt项目(子项目)。虽然这种方案比前一个方案用户使用体验上会好很多,但前一个方案由多个项目带来的成本提升及开发维护麻烦的问题只会更甚,基于此,我们还是决定不采纳这种方案。
- koa+puppeteer爬虫方案:使用koa+puppeteer启动一个爬虫服务,并且将这个服务开放给搜索引擎爬虫,供搜索引擎爬取内容收录我们的站点。在部署实践的过程中,可以在nginx服务器中根据请求头判断是爬虫程序还是用户正常访问而进行请求的分发,用户访问时正常访问我们的项目,爬虫访问则通过我们的爬虫程序访问我们的站点并获得经puppeteer无头浏览器解析渲染好的SEO友好的静态资源文件。这种方案需要维护两个项目,一个服务端爬虫服务,一个原本的客户端服务程序,且两个项目是完全解耦的,只要爬虫程序部署好之后无需改动原有前端项目任何代码,所以基于成本的考量这种方案性价比是非常高的。
- 项目重构:使用Nuxt项目重构整个项目成本极高,直接放弃。
最初的爬虫版本
在确定方案之后就是开始动手码代码了,在一番哐哐哐顺畅的代码输入之后,就有了如下代码片段,使用postman测试,结果符合预期,团队大喜过望!以为轻轻松松就搞定了这个看起来并不简单的问题。上线之后没几天访问的爬虫比想象中多很多,而且puppeteer对资源的消耗也非常恐怖,再就是存在内存泄漏问题,服务器频繁报警,只能继续优化打磨这个方案。
const puppeteer = require('puppeteer')
const express = require('express')
var app = express();
app.get('*', async (req, res) => {
let url = "https://www.demo.com" + req.originalUrl; // 目标网站URL
const browser = await puppeteer.launch(); // 启动Puppeteer浏览器
const page = await browser.newPage(); // 创建一个新页面
await page.goto(url, { waitUntil: 'networkidle2' }); // 跳转到目标网站并等待页面完全加载 const html = await page.content(); // 获取页面HTML代码
await browser.close(); // 关闭浏览器
res.send(html);
});
app.listen(3000, () => {
console.log('服务已启动在3000端口...');
});
经过打磨后的爬虫程序
service.js(程序入口)
完善了日志记录功能,对每个请求和报错信息都进行了详细的记录,方便分析和排查问题。对程序被访问次数进行了累加统计。
const Koa = require("koa");
const Router = require("koa-router");
const minify = require('html-minifier').minify;
const spider = require("./spider.js");
const requestLog = require("./request-log.js");
const app = new Koa();
const router = new Router();
// 记录请求次数
let reqTimes = 0
// 正则表达式 /.*/ 用于匹配所有请求
router.get(/.*/, async (ctx) => {
reqTimes += 1
// 记录请求日志
requestLog(ctx.request, 'request.log', reqTimes)
const url = "https://www.demo.com" + ctx.originalUrl; // 目标网站URL
try {
let content = ''
const { html = '获取html内容失败', contentType } = await spider(url)
// 通过minify库压缩代码,减少搜索引擎爬虫爬取到静态资源文件的网络时延
content = minify(html, { removeComments: true, collapseWhitespace: true, minifyJS: true, minifyCSS: true });
if (ctx.request.url.indexOf(".") < 0) {
ctx.set("Content-Type", "text/html");
} else {
ctx.set("Content-Type", contentType);
}
ctx.body = content; // 将HTML代码返回给前端
} catch (error) {
const errorObject = {
message: error.message,
name: error.name,
stack: error.stack
};
// 记录错误日志
requestLog(errorObject, 'error.log')
ctx.body = '获取html内容失败';
}
})
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(3000, () => {
console.log("服务已启动在3000端口...");
});
spider.js(爬虫功能实现)
每次访问从puppeteer无头浏览器实例池中取出浏览器实例列表随机访问某个浏览器,从而实现负载均衡策略,降低服务器的负担。为防止某个浏览器报错影响后续搜索引擎爬虫的正常访问,当服务累计被访问到5000次时,则自动销毁浏览器实例列表并重新创建。
const puppeteer = require('puppeteer');
const { createWSEList, resetWSEList, fetchWSEList } = require('./puppeteer-pool.js');
const requestLog = require("./request-log.js");
// 访问次数阈值
const reqLimit = 5000
// 访问次数
let reqTimes = 0
const spider = async (url) => {
// 标记请求次数+1
reqTimes += 1
// 响应头:content-type
let contentType = "";
// 请求次数等于请求阈值则清空浏览器实例
if (reqTimes >= reqLimit) {
await resetWSEList()
// 重置请求次数
reqTimes = 0
}
// 获取浏览器实例列表
let WSE_LIST = fetchWSEList()
if (WSE_LIST.length === 0) {
await createWSEList()
WSE_LIST = fetchWSEList()
}
let tmp = Math.floor(Math.random() * WSE_LIST.length);
//随机获取浏览器
let browserWSEndpoint = WSE_LIST[tmp];
//连接
const browser = await puppeteer.connect({
browserWSEndpoint
});
//打开一个标签页
const page = await browser.newPage();
// Intercept network requests.
await page.setRequestInterception(true);
page.on('request', async (request) => {
if (request.isInterceptResolutionHandled()) return;
if (
request.url().indexOf(".png") > 0 ||
request.url().indexOf(".jpg") > 0 ||
request.url().indexOf(".gif") > 0 ||
request.url().indexOf(".svg") > 0 ||
request.url().indexOf(".mp4") > 0
) {
await request.abort();
} else {
await request.continue();
}
});
page.on("response", async (response) => {
await (contentType = response.headers()["content-type"]);
});
//打开网页
try {
await page.goto(url, {
timeout: 60000, //连接超时时间,单位ms
waitUntil: 'networkidle0' //网络空闲说明已加载完毕
});
} catch (err) {
try {
await page.goto(url, {
timeout: 120000, //连接超时时间,单位ms
waitUntil: 'networkidle0' //网络空闲说明已加载完毕
});
} catch (error) {
const errorObject = {
message: error.message,
name: error.name,
stack: error.stack
};
// 记录错误日志
requestLog(errorObject, 'error.log')
// 获取当前浏览器打开的tab数
const pages = await browser.pages();
if (pages.length > 1) {
// 当前tab数大于1则关闭当前页面(一个浏览器至少要留一个页面,不然browser就关闭了,后面newPage就卡死了)
await page.close();
}
return {
html: '请求超时',
contentType: 'text/html'
};
}
}
//获取渲染好的页面源码。不建议使用await page.content();获取页面,因为在我测试中发现,页面还没有完全加载。就获取到了。页面源码不完整。也就是动态路由没有加载。vue路由也配置了history模式
const html = await page.evaluate(() => {
return document.documentElement.outerHTML
});
// 获取当前浏览器打开的tab数
const pages = await browser.pages();
if (pages.length > 1) {
// 当前tab数大于1则关闭当前页面(一个浏览器至少要留一个页面,不然browser就关闭了,后面newPage就卡死了)
await page.close();
}
return {
html: html || '获取html内容失败',
contentType
};
}
module.exports = spider;
puppeteer-pool.js(浏览器实例池)
创建4个浏览器实例存在浏览器实例池中,供实现负载均衡策略时调用
const puppeteer = require("puppeteer");
let WSE_LIST = []; //存储browserWSEndpoint列表
let BROWSER_LIST = []; //存储browserWSEndpoint列表
const MAX_WSE = 4;
//负载均衡
const createWSEList = async () => {
for (let i = 0; i < MAX_WSE; i++) {
const browser = await puppeteer.launch({
headless: "shell",
//参数(浏览器性能优化策略)
args: [
'--disable-gpu',
'--disable-dev-shm-usage',
// 禁用沙箱
'--disable-setuid-sandbox',
// --no-first-run 配置建议关闭,允许创建浏览器的时候每个浏览器保留一个空页面,防止浏览器因为没有页面而关闭
// '--no-first-run',
'--no-sandbox',
'--no-zygote',
// 单线程
'--single-process'
],
//一般不需要配置这条,除非启动一直报错找不到谷歌浏览器
//executablePath:'chrome.exe在你本机上的路径,例如C:/Program Files/Google/chrome.exe'
});
let browserWSEndpoint = await browser.wsEndpoint();
BROWSER_LIST.push(browser);
WSE_LIST.push(browserWSEndpoint);
}
}
const resetWSEList = () => {
try {
BROWSER_LIST.forEach(async browser => {
await browser?.close()
})
WSE_LIST = []
BROWSER_LIST = []
} catch (error) {
WSE_LIST = []
BROWSER_LIST = []
}
}
const fetchWSEList = () => WSE_LIST
module.exports = {
createWSEList,
resetWSEList,
fetchWSEList
}
request-log.js(日志记录功能)
不用任何第三方库,自己手写实现的日志记录功能,我个人觉得简单好用
const fs = require('fs')
const path = require('path')
// 格式化时间戳
const formateDate = (date, rule = '') => {
let fmt = rule || 'yyyy-MM-dd hh:mm:ss'
// /(y+)/.test(fmt) 判断是否有多个y
if (/(y+)/.test(fmt)) {
// RegExp.$1 获取上下文,子正则表达式 /(y+)/.test(fmt)
fmt = fmt.replace(RegExp.$1, date.getFullYear())
}
const o = {
'M+': date.getMonth() + 1,
'd+': date.getDate(),
'h+': date.getHours(),
'm+': date.getMinutes(),
's+': date.getSeconds()
}
for (let k in o) {
if (new RegExp(`(${k})`).test(fmt)) {
const val = o[k] + ''
fmt = fmt.replace(RegExp.$1, RegExp.$1.length == 1 ? val : ('00' + val).substr(val.length))
}
}
return fmt
}
const writeLog = (logData, fileName, times) => {
const writeFilePath = path.join(__dirname, `logs/${fileName}`);
let content = formateDate(new Date())
if (times) {
content += `(${times})`
}
content += ':\r' + JSON.stringify(logData) + '\n';
fs.appendFileSync(writeFilePath, content);
}
module.exports = writeLog
package.json(包管理)
{
"name": "puppeteer",
"version": "1.0.0",
"main": "service.js",
"scripts": {
"dev": "nodemon service.js",
"serve": "pm2 start ecosystem.config.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"html-minifier": "^4.0.0",
"koa": "^2.15.3",
"koa-router": "^12.0.1",
"pm2": "^5.4.1",
"puppeteer": "^19.8.0"
},
"devDependencies": {
"nodemon": "^3.1.4"
}
}
ecosystem.config.cjs
pm2配置文件,使用pm2实现项目部署,不使用cjs结尾在有些window环境下启动时会报错
module.exports = {
apps: [{
name: 'puppeteer',
script: 'service.js',
log_date_format: "YYYY-MM-DD HH:mm:ss",
instances: "max", // 进程实例的数量,"max" 表示根据 CPU 核心数自动设置
exec_mode: "cluster", // 运行模式,"cluster" 表示使用集群模式
max_memory_restart: "3G", // 在内存达到指定大小时自动重启应用程序
error_file: './logs/pm2_error_file.log',
out_file: './logs/out_file.log'
}],
};
写在最后
在实践SEO的过程中puppeteer是一种偏冷门的方案,但是却也有自己的用武之地,但是目前关于puppeteer的技术方案沉淀还比较少,所以遇到问题很难找到相关的资料,得自己慢慢去摸索,而且这种方案要求对服务器相关知识有储备,对于传统前端开发人员来说是有难度的。但是在实践之后对于浏览器的底层原理会有更加深刻的认知,毕竟puppeteer就是个浏览器内核程序,当你熟悉了使用浏览器内核程序开放的API去操作浏览器行为的时候,在可视化界面鼠标一顿点不是更驾轻就熟!
项目源码
https://gitee.com/mangoisgreat/puppeteer