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

2,421 阅读6分钟


前几天在掘金看到一篇讲利用 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 和 爬虫 的一次实践。完整的接口代码在这里:
接口源码地址 。如有更好见解,还请不吝指出,万分感谢。