• 作者:老汪软件技巧
  • 发表时间:2024-08-20 11:01
  • 浏览量:

最近工作中遇到一个使用nodejs实现爬虫程序的任务。需求背景是这样的:公司运营的一个老项目运营那边最近提了SEO优化的需求,但是项目本身并没有做SSR(服务端渲染),公司的要求是花费的人力成本最低,代价最小。在经过一番调研之后团队收集了几种备选方案,一番讨论之后最终选择了koa+puppeteer的方案。PS:欢迎关注作者微信公众号fever code,获取最新技术分享。

选型中的一些思考

调研的过程中团队是有收集几种技术方案供选择的,但每种技术方案都利弊兼有,适用的场景也各不相同。虽然最后只能取一种最适合我们需求的方案,但这个思考的过程我觉得还是很有价值的。

最初的爬虫版本

在确定方案之后就是开始动手码代码了,在一番哐哐哐顺畅的代码输入之后,就有了如下代码片段,使用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去操作浏览器行为的时候,在可视化界面鼠标一顿点不是更驾轻就熟!

项目源码

/mangoisgrea…