前提
最近接到的一个需求,就是小程序通过调用其他网站的接口获取数据,一开始是想通过后端去爬数据,但存在跨域cookie验证不通过,token无法拿到等问题。于是有了这个备用方案,通过node搭一个中间件去获取数据。这里用到eggjs+Puppeteer。
Puppeteer的介绍
Puppeteer是谷歌官方出品的一个通过DevTools协议控制headless Chrome的Node库。可以通过Puppeteer的提供的api直接控制Chrome模拟大部分用户操作来进行UI Test或者作为爬虫访问页面来收集数据。本文大部分依赖page提供的接口
如何与eggjs结合
第一个需求是从深圳居住证平台 房屋编码查询 - 深圳经济特区居住证服务平台 -查询房屋编码。从提交的请求可知,每一次打开生成的session是与验证码绑定的。
因而我们在每次打开小程序时,需要先调用eggjs的一个接口去打开上述的页面,进而返回页面对应的cookie。
async getCookie() {
const { ctx, app } = this
await app.pool.use(async instance => {
try {
const page = await instance.newPage()
await page.goto("https://szjzz.ga.sz.gov.cn/index/queryHouseCode", { timeout: 120000 })
const cookie = await page.cookies()
if (!cookie) {
ctx.body = {
code: 104,
data: '获取cookie失败'
}
} else {
ctx.body = {
code: 200,
data: cookie
}
}
} catch {
ctx.body = {
code: 500,
data: '网络错误,请稍后再试'
}
}
return true
})
}
第二个需求是从各个区的房屋编码查询网站中获取数据,我们进入这个网站可知,每一次打开时都会生成一个session,而请求所用到的token是与session绑定的,而请求所用到的参数FWTYPE和FWBM都是页面上的type和code通过编码加密得到的。那么我们只要获取到这个token,然后在小程序中对对应的参数加密提交,就可以解决了。
所以我们可以用page.evaluate,该函数可以让我们使用内置的 DOM 选择器。而正好每次要用到的token都会存在页面的js中。
代码如下:
const token = await page.evaluate(() => {
return window.KWZ.token
});
然而这些网站都对请求做了拦截,不可以直接调用。
那么我们只能退而求其次,我们通过分析网站的代码可知,网站只会显示房屋地址和锁定结果。所以我们可以通过模拟运行来获取页面元素,进而获取到锁定结果并返回,具体代码如下:
async getHouseResult() {
const { app, ctx } = this;
const code = ctx.query.code || ''
const region = ctx.query.region
const pages = {
440303: 'http://lhjjk.sz.edu.cn/visitlhgbxyxqfcx',
440304: 'http://ftjyk.sz.edu.cn/visitftgbxyxqfcx',
440305: 'http://nsjyk.sz.edu.cn/visitnsgbxyxqfcx',
440306: 'http://bajjk.sz.edu.cn/visitbagbxyxqfcx',
440307: 'http://lgjjk.sz.edu.cn/visitlggbxyxqfcx',
440308: 'https://ytjjk.sz.edu.cn/visitytgbxyxqfcx',
440309: 'http://gmjjk.sz.edu.cn/visitgmgbxyxqfcx',
440317: 'https://psjybm.sz.edu.cn/visitpsgbxyxqfcx',
440326: 'https://zsjz.szlhq.gov.cn:8090/visitlhuagbxyxqfcx',
440327: 'https://dpjyw.sz.edu.cn/visitdpgbxyxqfcx',
}
await app.pool.use(async instance => {
try {
const page = await instance.newPage()
await page.goto(pages[region], { timeout: 120000 })
const data = await getSpan(page, code)
ctx.body = {
code: 200,
data
}
} catch {
ctx.body = {
code: 500,
data: '网络错误,请稍后再试'
}
}
return true
})
}
async function getSpan(page, code) {
return new Promise(async resolve => {
await page.type('input[id=fwxx]', code, {delay: 20})
await page.click('#check')
await page.waitFor(300) // 等待遮盖层渲染完
await page.click('.btn-primary[data-dismiss=modal]')
await page.waitFor(1000) // 等待结果
const status = await page.$('#_xqfdzlock_searchxx_span')
const statusText = await page.evaluate(node => node.innerText, status)
resolve(statusText)
})
}
关于并发的解决方案
这样的实现,每一次请求都去产生一个 puppeteer 实例。来一个请求就打开一个chrome。这本身就是一个非常消耗性能的行为。当运行一定数量的 puppeteer 实例之后 ,就会报EventEmitter的错。那么怎么解决呢? 从google查询可知,可以用 generic-pool 来解决。 这是一个基于 Promise 的通用链接池库。有了他之后我们就可以将 puppeteer 实例放在我们的链接池中,如果有请求进来,那么就去池子里面去取一个实例。我们可以设置实例的上限,和常驻池中的实例数量。
'use strict'
const puppeteer = require('puppeteer')
const genericPool = require('generic-pool')
/**
* 初始化一个 Puppeteer 池
* @param {Object} [options={}] 创建池的配置配置
* @param {Number} [options.max=10] 最多产生多少个 puppeteer 实例 。如果你设置它,请确保 在引用关闭时调用清理池。 pool.drain().then(()=>pool.clear())
* @param {Number} [options.min=1] 保证池中最少有多少个实例存活
* @param {Number} [options.maxUses=2048] 每一个 实例 最大可重用次数,超过后将重启实例。0表示不检验
* @param {Number} [options.testOnBorrow=2048] 在将 实例 提供给用户之前,池应该验证这些实例。
* @param {Boolean} [options.autostart=false] 是不是需要在 池 初始化时 初始化 实例
* @param {Number} [options.idleTimeoutMillis=3600000] 如果一个实例 60分钟 都没访问就关掉他
* @param {Number} [options.evictionRunIntervalMillis=180000] 每 3分钟 检查一次 实例的访问状态
* @param {Object} [options.puppeteerArgs={}] puppeteer.launch 启动的参数
* @param {Function} [options.validator=(instance)=>Promise.resolve(true))] 用户自定义校验 参数是 取到的一个实例
* @param {Object} [options.otherConfig={}] 剩余的其他参数 // For all opts, see opts at https://github.com/coopernurse/node-pool#createpool
* @return {Object} pool
*/
const initPuppeteerPool = (options = {}) => {
const {
max = 10,
min = 2,
maxUses = 2048,
testOnBorrow = true,
autostart = false,
idleTimeoutMillis = 3600000,
evictionRunIntervalMillis = 180000,
puppeteerArgs = {},
validator = () => Promise.resolve(true),
...otherConfig
} = options
const factory = {
create: () =>
puppeteer.launch(puppeteerArgs).then(instance => {
// 创建一个 puppeteer 实例 ,并且初始化使用次数为 0
instance.useCount = 0
return instance
}),
destroy: instance => {
instance.close()
},
validate: instance => {
// 执行一次自定义校验,并且校验校验 实例已使用次数。 当 返回 reject 时 表示实例不可用
return validator(instance).then(valid => Promise.resolve(valid && (maxUses <= 0 || instance.useCount < maxUses)))
}
}
const config = {
max,
min,
testOnBorrow,
autostart,
idleTimeoutMillis,
evictionRunIntervalMillis,
...otherConfig
}
const pool = genericPool.createPool(factory, config)
const genericAcquire = pool.acquire.bind(pool)
// 重写了原有池的消费实例的方法。添加一个实例使用次数的增加
pool.acquire = () =>
genericAcquire().then(instance => {
instance.useCount += 1
return instance
})
pool.use = fn => {
let resource
return pool
.acquire()
.then(r => {
resource = r
return resource
})
.then(fn)
.then(
result => {
// 不管业务方使用实例成功与后都表示一下实例消费完成
pool.release(resource)
return result
},
err => {
pool.release(resource)
throw err
}
)
}
return pool
}
在eggjs中增加入口文件app.js
const initPuppeteerPool = require('./util/puppeteer-pool')
const { EventEmitter } = require('events')
EventEmitter.defaultMaxListeners = 30
class AppBootHook {
constructor(app) {
this.app = app
}
async didLoad() {
// 所有的配置已经加载完毕
// 可以用来加载应用自定义的文件,启动自定义的服务
this.app.pool = initPuppeteerPool()
}
async beforeClose() {
if (this.app.pool.drain) {
await this.app.pool.drain().then(() => this.app.pool.clear())
}
}
}
module.exports = AppBootHook
PS: 结合 egg.js 使用时,需要手动指定 workers 数量为 1: egg-scripts start —daemon —workers=1 不然会启动 pool.max * workers 数量的 Puppeteer 实例。
关于部署
Puppeteer在centos上需做一些配置才能部署成功
1. 在cnpm安装后可运行查看是否缺少哪些依赖(版本号可能不同)
ldd node_modules/puppeteer/.local-chromium/linux-706915/chrome-linux/chrome
2. 安装缺失的依赖库
yum install -y alsa-lib.x86_64 atk.x86_64 cups-libs.x86_64 GConf2.x86_64 gtk3.x86_64 ipa-gothic-fonts libXcomposite.x86_64 libXcursor.x86_64 libXdamage.x86_64 libXext.x86_64 libXi.x86_64 libXrandr.x86_64 libXScrnSaver.x86_64 libXtst.x86_64 pango.x86_64 wqy-unibit-fonts.noarch wqy-zenhei-fonts.noarch xorg-x11-fonts-100dpi xorg-x11-fonts-75dpi xorg-x11-fonts-cyrillic xorg-x11-fonts-misc xorg-x11-fonts-Type1 xorg-x11-utils
3. puppeteer.launch需要增加以下配置
puppeteer.launch({
headless: true,
args: [
"--disable-gpu",
"--disable-dev-shm-usage",
'--no-sandbox',
'–no-zygote',
'–single-process',
"--disable-setuid-sandbox"
]
})
总结
这是本人第一次把Puppeteer用于项目中,其中还是有很大的优化空间的,比如Puppeteer的启动项、执行流程、接口的完善等。 Puppeteer除了模拟获取数据外,本身还是一个强大的自动化测试工具,未来可以在小程序中加入自动化测试用例,生成测试报告,并入到小程序的工程化中。
参考文章
Puppeteer 入门与实战 - 云+社区 - 腾讯云
使用 generic-pool 优化 puppeteer 并发问题 | 三省吾身丶丶
Puppeteer性能优化与执行速度提升 | 我是大熊