koa+puppeteer爬虫实践

news2024/9/22 11:35:57

最近工作中遇到一个使用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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2051084.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Route路由 Vue2

1.路由的概念 2.路由的基本使用 1.安装 因为我们使用的是Vue2 所以使用的 router 是 3版本 当使用Vue3 的时候就使用 router4 npm i vue-router3 2.简单使用 /router/index.js //该文件专门创建整个应用的路由器import VueRouter from vue-router; //引入组件 import MyA…

谷粒商城实战笔记-179~183-商城业务-检索服务-SearchRequest和SearchResponse构建

文章目录 一&#xff0c;179-商城业务-检索服务-SearchRequest构建-检索1&#xff0c;Controller接口 二&#xff0c;180-商城业务-检索服务-SearchRequest构建-排序、分页、高亮&测试三&#xff0c;181-商城业务-检索服务-SearchRequest构建-聚合四&#xff0c;182-商城业…

x64汇编语言与逆向工程实战指南(一)

逆向程序demo网址&#xff1a;https://crackmes.one/&#xff0c;下载的压缩包密码均为.cracksme.one或cracksme.de 实例一&#xff1a;基本 网络钓鱼密码程序 破解 目录 1. DIE确定程序基本信息1.1 DIE程序与下载1.2 分析demo的架构 2. x64dbg调试获取密码2.1 功能初探2.2 调试…

C++基础——合集

1.C关键字&#xff08;C98&#xff09; C总计63个关键字&#xff0c;C语言32个关键字 2.命名空间 在C/C中&#xff0c;变量、函数和后面要学到的类都是大量存在的&#xff0c;这些变量、函数和类的名称将都存 在于全局作用域中&#xff0c;可能会导致很多冲突。使用命名空间的…

虚拟机可以玩Steam游戏吗?虚拟机怎么玩Steam Windows游戏 PD19虚拟机玩Steam

你有没有在苹果电脑上玩游戏的需求呢&#xff1f;很多人认为只有“双系统”才能实现Mac电脑运行Windows操作系统&#xff0c;其实不然&#xff0c;近些年来&#xff0c;虚拟机技术在不断发展&#xff0c;越来越多的苹果用户开始使用虚拟机在苹果设备上玩游戏。Steam是一个非常受…

【运维高级内容--KEEPALIVED高可用集群】

目录 1.简介 2.实现master/slave的 Keepalived 单主架构 3.vip通行 &#xff08;ping通: 4.启用日志功能 5.实现独立子配置文件 6.非抢占式模式 7.抢占延迟模式 8.单播配置 9.keepalived状态切换的通知脚本 10.双主结构&#xff1a;两个虚拟路由&#xff08;多主模式&…

精武杯的部分复现

标红的为答案 计算机手机部分 1、请综合分析计算机和⼿机检材&#xff0c;计算机最近⼀次登录的账户名是&#xff1f;admin 2.请综合分析计算机和⼿机检材&#xff0c;计算机最近⼀次插⼊的USB存储设备串号是?S3JKNX0JA05097Y 3.请综合分析计算机和⼿机检材&#xff0c;谢弘…

Xilinx FPGA:vivado关于以太网的零碎知识点

一、OSI七层模型 为了实现网络通信的标准化&#xff0c;普及网络应用&#xff0c;国际标准化组织&#xff08;ISO&#xff09;将整个以太网通信结构制定了OSI模型&#xff0c;即开放式系统互联。 OSI定义了网络互连的七层框架&#xff08;物理层、数据链路层、网络层、传输层、…

web前端之html弹窗面板的popover新属性

MENU 前言效果图htmlstyle 前言 1、代码段的功能是在网页上实现一个弹出框。当用户点击"Open Popup"按钮时&#xff0c;会显示一个中央定位的弹出框&#xff0c;弹出框里有"This is a popup"文本&#xff0c;以及两个按钮(“Close"和"confirm”)…

XXX【3】模板方法

一.GOF-23 模式分类 从目的来看&#xff1a; 创建型模式&#xff1a;解决对象创建的工作。结构型模式&#xff1a;解决需求变化为对象结构带来的冲击。行为型模式&#xff1a;解决多个类交互之间责任的划分问题。 从范围来看&#xff1a; 类模式处理类与子类的静态关系&…

timing derate失效,cppr为0原因分析

我正在「拾陆楼」和朋友们讨论有趣的话题&#xff0c;你⼀起来吧&#xff1f; 拾陆楼知识星球入口 timing derate失效&#xff0c;crpr结果为0&#xff0c;可能是错误的timing derate设置引起的&#xff0c;以下图为例&#xff1a; setup violation path的cppr为0&#xff0c;…

汇编语言指令 jmp: jmp word ptr、jmp dword ptr、jmp 寄存器

1. 转移地址在内存中的jmp指令有2种形式 1.1 jmp word ptr 内存单元地址 jmp word ptr 内存单元地址是段内转移指令&#xff0c;也就是说该指令只修改IP值&#xff0c;其功能是控制CPU下一条执行的指令是一个字&#xff08;2个字节&#xff09;内存中存放的偏移地址所指向的指…

集合的知识点

一、集合的简介 1.1 什么是集合 集合(Collection)&#xff0c;也是一个数据容器&#xff0c;类似于数组&#xff0c;但是和数组是不一样的。集合是一个可变的容器&#xff0c;可以随时向集合集合中添加元素&#xff0c;也可以随时从集合中删除元素。另外&#xff0c;集合还提…

在线图片编辑网站推荐(图片压缩)

&#x1f525;发现神器&#xff01;「可乐改图」——一站式在线图片编辑平台&#xff0c;让工作更高效&#xff01;&#x1f680; 大家好&#xff01;今天我要给大家安利一个我最近发现的宝藏工具——「可乐改图」&#xff0c;一个集多功能于一身的在线图片编辑平台&#xff0…

前端(Vue)动态换肤的通用解决方案及原理分析(2)

文章目录 动态换肤的主题解决方案总结处理 第三方( element-plus )主题变更原理与步骤分析**实现原理**实现步骤处理 element-plus 主题变更补充 > 步骤 2&#xff1a;获取当前 element-plus 的默认样式表&#xff0c;并且把需要进行替换的色值打上标记补充>步骤 3&#…

Android 手机恢复出厂设置后,还能恢复其中数据吗?

天津鸿萌科贸发展有限公司从事数据安全服务二十余年&#xff0c;致力于为各领域客户提供专业的数据恢复、数据备份、网络及终端数据安全等解决方案与服务。 同时&#xff0c;鸿萌是众多国际主流数据恢复软件的授权代理商&#xff0c;为专业用户提供正版的数据恢复软件。 对于 A…

网络版计算器(理解协议与序列化与反序列化)

一、理解协议 在网络层面&#xff0c;协议&#xff08;Protocol&#xff09;是一组规则、标准或约定&#xff0c;它们定义了在网络环境中&#xff0c;计算机、服务器、路由器、交换机等网络设备之间如何相互通信和交换信息。这些规则涵盖了数据格式、数据交换的顺序、速度、以及…

调研-音视频

音视频 基础概念主要内容音频基础概念音频量化过程音频压缩技术视频基础概念视频bug视频编码H264视频像素格式YUVRGB参考文献基础概念 ● 实时音视频应用环节 ○ 采集、编码、前后处理、传输、解码、缓冲、渲染等很多环节。 主要内容 音频 基础概念 三要素:音调(音频)、…

阿里云注册、认证、短信资质、签名、模板申请过程

一、帐号注册 输入“帐号密码注册”中的相关信息即可。 手机号是必须的&#xff0c;先确定好手机号。 正常的可以直接注册成功的。 二、实名认证 注册成功之后&#xff0c;就可以点击上述的“快速实名认证”。 这次选择的是“企业认证”。 有几种方式&#xff0c;如下&#x…

学习嵌入式第二十八天

有名管道 在C语言中&#xff0c;有名管道&#xff08;Named Pipe&#xff09;是一种特殊的文件类型&#xff0c;它允许进程间通信。有名管道与匿名管道&#xff08;Anonymous Pipe&#xff09;不同&#xff0c;它在文件系统中有一个路径名&#xff0c;因此可以被多个进程访问。…