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