关于小程序查学位房功能的总结

前提

最近接到的一个需求,就是小程序通过调用其他网站的接口获取数据,一开始是想通过后端去爬数据,但存在跨域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性能优化与执行速度提升 | 我是大熊

Last updated

Was this helpful?