• 如果您觉得本站非常有看点,那么赶紧使用Ctrl+D 收藏吧

Nodejs puppeteer + events 实现简易物流爬虫

开发技术 开发技术 2周前 (05-12) 16次浏览

功能在线预览 接口调用地址 接口源码地址

前几天在掘金看到一篇讲利用 puppeteer 进行页面测试的文章,瞬间对这个无头浏览器来了兴趣。一通了解之后,爱不释手。

Puppeteer(中文翻译”操纵木偶的人”) 是 Google Chrome 团队官方的无界面(Headless)Chrome 工具,它是一个Node库,提供了一个高级的 API 来控制DevTools协议上的无头版 Chrome 。也可以配置为使用完整(非无头)的 Chrome。Chrome素来在浏览器界稳执牛耳,因此,Chrome Headless 必将成为 web 应用自动化测试的行业标杆。使用Puppeteer,相当于同时具有 Linux 和 Chrome 双端的操作能力,应用场景可谓非常之多。如:

  • 生成页面的截图和PDF。

  • 抓取SPA并生成预先呈现的内容(即“SSR”)。

  • 从网站抓取你需要的内容。

  • 自动表单提交,UI测试,键盘输入等

  • 创建一个最新的自动化测试环境。使用最新的JavaScript和浏览器功能,直接在最新版本的Chrome中运行测试。

  • 捕获您的网站的时间线跟踪,以帮助诊断性能问题。

而我也做了一个爬取物流状态的小 demo, 地址: github.com/yinchengnuo… 。

puppeteer 虽然很强大,安装使用却很简单。但是因为要 down 一个浏览器到 package 里。所以安装 puppeteer推荐使用 cnpm,完整的安装指令如下:

npm i bufferutil utf-8-validate cnpm && npx cnpm puppeteer复制代码

安装完成后的使用也很简单,官方给的文档很详细,基本上有什么需求,看着文档就能捣鼓出来。上面的 小demo 就是一个简单的根据物流单号获取物流状态的小工具:

const puppeteer = require('puppeteer'); (async () => { // 生成浏览器实例 const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'] }) // 生成一个页面实例 const page = await browser.newPage() // 让此页面去访问 快递100 官网 await page.goto('https://www.kuaidi100.com/', { timeout: 0, waitUntil: 'networkidle2' }) // 557006432812950 // 找到 快递100 官网的输入框 const input = await page.$('#postid') // 把物流单号输入到输入框里 await input.type(process.argv.slice(2)[0] || '557006432812950') // 找到 快递100 官网的输入框后面的搜索按钮 const query = await page.$('#query') // 拦截网路响应 page.on('response', async res => { if (res._url.includes('/query')) { // 判断指定 url console.log(JSON.parse(await res.text())) // 获取到数据 await page.close() await browser.close() } }) // 点击搜索按钮 await query.click() })();复制代码

使用起来只需要:

node index.js '物流单号'复制代码

去掉注释只有不到 20 行代码,太强大了。

而逻辑更是简直不要太简单,就是和人的操作一样:

打开浏览器 => 打开Tab => 输入URL回车 => 找到输入框并输入 => 点击搜索 => 获取数据复制代码

这应该就是 puppeteer 最简单的使用了。

但是仅仅这个是没有办法实现需求的,比如我现在需要一个接口,带着物流单号 Get 一下就能得到物流动态数据。仅仅这样的话,单单是速度就会让人抓狂:

await puppeteer.launch() await browser.newPage() await page.goto()复制代码

这三步走下来就至少需要 2 s。当然这个不同的设备都有所不同。但是显然我们不能将这三步放在接口逻辑里,而是要提前打开 puppeteer,等接收到请求直接执行:

const input = await page.$('#postid') await input.type('xxxxxxxxxx') const query = await page.$('#query') await query.click()复制代码

就可以了。

但是这样会有问题,问题就是当存在并发请求时候。所有的请求操作的都是同一个页面,同一个 input ,同一个 button。这种情况下是没有办法保证,当 click() 触发时,input 里的value 是不是当前接口请求时带来的参数。

这种情况下就要考虑加锁了,还需要一个执行队列在在并发量大时来放置等待中的物流单号,同时我们也需要多个 tab 来增强处理能力,以及一个分发函数,将不同的请求分发至不同 tab。

大概的流程是这样的:

  • 服务器启动,启动 puppteer 并打开一定数量的页面(这里是3个),并跳转至指定页面等待。在此同时,将 3 个页面实例并一些其他状态保存至 pageList:

const URL = require('url') const events = require('events'); const puppeteer = require('puppeteer'); const company = require('./util/exoresscom') const pageNum = 3 // 无头浏览器 tab 数量 let nowPageNum = 0 // 可用 page 个数 const pageList = [] // page 实例列表 const requestList = [] // 待处理单号 const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'] }) for(let i = 0; i < pageNum; i ++) { // 初始化指定数量的 page 配置 const page = await browser.newPage() // 生成 page 实例 page.goto('https://www.kuaidi100.com/', { timeout: 0, waitUntil: 'domcontentloaded' }).then(() => { nowPageNum ++ }) // 页面加载完成后标记可用页面个数 page.on('response', async res => { // 监听网页网络请求响应数据 if (res._url.includes('/query?')) { // 监听指定 url const result = JSON.parse(await res.text()) // 取到数据 result.com ? result.comInfo = company.find(e => e.number == result.com) : '' // 查询到结果后绑定快递公司名称 !result.nu ? result.nu = URL.parse(res.url(), true).query.postid : '' // 查询无无结果时将物流单号赋值给 nu 便于从队列删除 event.emit('REQUEST_OK', result) // 将数据发送到全局 } }) pageList.push({ page, // page 实例 requesting: false, // 状态是否在请求中 async request(order_num) { // 请求方法 this.requesting = true // 将状态标记为请求中 const input = await this.page.$('#postid') // 获取输入框 await input.type(order_num) // 填入物流单号 const query = await this.page.$('#query') // 获取按钮 await query.click() // 触发按钮点击 this.requesting = false // 将状态标记为空闲 } }) }复制代码
  • 当接收到请求,获取到物流单号,执行分发函数

router.get("/express", async (ctx) => { // 物流单号查询 if (ctx.request.query.num) { // 检查物流单号 if (nowPageNum) { // 爬虫页面是否开启 distribute(ctx.request.query.num) // 分发请求 try { ctx.body = await new Promise(resolve => event.on('REQUEST_OK', data => data.nu == ctx.request.query.num && resolve(data))) // 等待请求成功响应请求 } catch (error) { ctx.body = { msg: '爬取失败' } } } else { ctx.body = { msg: '爬虫启动中' } } } else { ctx.body = { msg: '订单号不合法' } } })复制代码
  • 分发函数拿到物流单号,判断当前是否有空闲 tab 。有就执行爬取,没有就把物流单号 push 进一个等待队列

const distribute = order_num => { // 根据订单号分发请求 if (!requestList.includes(order_num)) { const free = pageList.find(e => !e.requesting) // 获取空闲的 page free ? free.request(order_num) : requestList.push(order_num) // 有空闲 page 就执行爬取,否则推入 requestList 等待 } }复制代码
  • 最后,又回到了第一步,在注册 page 的时候就给 page 添加了拦截相应事件。当拦截到数据后,通过 event 发射到全局:

page.on('response', async res => { // 监听网页网络请求响应数据 if (res._url.includes('/query?')) { // 监听指定 url const result = JSON.parse(await res.text()) // 取到数据 result.com ? result.comInfo = company.find(e => e.number == result.com) : '' // 查询到结果后绑定快递公司名称 !result.nu ? result.nu = URL.parse(res.url(), true).query.postid : '' // 查询无无结果时将物流单号赋值给 nu 便于从队列删除 event.emit('REQUEST_OK', result) // 将数据发送到全局 } })复制代码

此时,会有两处能够接收到响应完成的数据,分别是全局的:

event.on('REQUEST_OK', () => requestList.length && distribute(requestList.splice(0, 1)[0])) // 当有订单爬取成功且 requestList 有等待订单,重新分发)复制代码

和路由内的:

ctx.body = await new Promise(resolve => event.on('REQUEST_OK', data => data.nu == ctx.request.query.num && resolve(data))) // 等待请求成功响应请求复制代码

至此,所有的逻辑就形成了一个闭环。

一个简单的爬虫就做好了。

当然,这只是最理想情况下的逻辑处理流程,实际中的项目要考虑的情况要比这多太多了。所以这仅仅是我学习 nodejs 过程中对 events 和 爬虫 的一次实践。完整的接口代码在这里:

接口源码地址 。如有更好见解,还请不吝指出,万分感谢。


喜欢 (0)