likes
comments
collection
share

纵横四海 -- Puppeteer全能应用一文通

作者站长头像
站长
· 阅读数 14

Puppeteer是什么?

Puppeteer翻译过来的意思是"操纵木偶的人", 实质是一个Headless/无头浏览器, 尤其在 Node.js 中的应用极为广泛.

Puppeteer 介绍和应用场景

介绍

Puppeteer 是一个 Node 库,它提供了一整套高级 API,通过 DevTools 协议控制 Chromium 或 Chrome。正如其翻译为“操纵木偶的人”一样,你可以通过 Puppeteer 提供的 API 直接控制 Chrome,模拟大部分用户操作,进行 UI 测试或者作为爬虫访问页面来收集数据。

应用场景

  • 为网页生成页面 PDF 或者截取图片;

  • 抓取 SPA(单页应用)并生成预渲染内容;

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

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

  • 捕获网站的timeline trace,用来帮助分析性能问题;

  • 测试浏览器扩展。

  • 数据爬虫

Puppeteer 在 SSR 中的应用

区别于next/nuxt等SSR框架的设计方案,基于Puppeteer的SSR方案最大的好处是不需要对项目代码进行任何调整,却能获取到 SSR 应用的收益。当然,相比同构渲染,基于 Puppeteer 技术的 SSR 在灵活性和扩展性上都有所局限; 甚至在 Node.js 端渲染的性能成本也较高; 不过该技术也逐渐落地,并在很多场景发挥了重要价值。

在 Node.js 端使用 Puppeteer 渲染时,我们可以实现ssr.mjs,完成渲染任务,如下代码:

// ssr.mjs
import puppeteer from 'puppeteer';

// 将已经渲染过的页面,缓存在内存中
const RENDER_CACHE = new Map();

// 重复使用 Chrome 实例
let browserWSEndpoint = null
let browser

async function ssr(url) {
	// 命中缓存
  if (RENDER_CACHE.has(url)) {
    return {html: RENDER_CACHE.get(url), ttRenderMs: 0};
  }
  const start = Date.now();
  // 使用 Puppeteer launch 一个无头浏览器
 
  if (!browserWSEndpoint) {
    // 一下两行代码不必随着渲染重复执行
    browser = await puppeteer.launch()
    browserWSEndpoint = await browser.wsEndpoint()
  }
  
  const page = await browser.newPage();
  try {
    // 访问页面地址直到页面网络状态为 idle
    await page.goto(url, {waitUntil: 'networkidle0'});
    // 确保 #posts 节点已经存在
    // await page.waitForSelector('#app');
    await page.waitForSelector('#posts');
  } catch (err) {
    console.error('err>>>', err);
    throw new Error('page.goto/waitForSelector timed out.');
  }
	// 获取 html 内容
  const html = await page.content(); 
  // 关闭无头浏览器
  await browser.close();
  const ttRenderMs = Date.now() - start;
  console.info(`Headless rendered page in: ${ttRenderMs}ms`);

	// 进行缓存存储
  RENDER_CACHE.set(url, html);
  return {html, ttRenderMs};
}

export {ssr as default};
 

上述代码在渲染页面时,对 Chrome 实例进行了重复利用,减少了额外的性能损耗。我们还可以从其它方面进行优化:

  • 改造浏览器端代码,防止重复请求接口(比如基于axios的请求库做二次封装的时候,使用axios的拦截器与防抖节流相结合, 同时也需要考虑界面交互操作的防抖/节流。可以参考Vue + Axios全局接口防抖、节流封装实现,让你前端开发更高效
  • 在 Node.js 端 / browser端,abort 掉不必要的请求,以得到更快的服务端渲染响应速度
  • 将关键资源内联进 HTML
  • 自动压缩静态资源

我们简单进行下调用:

// server.mjs
import express from "express"
import ssr from "./ssr.mjs"

const app = express()

app.get("/", async (req, res, next) => {
  // 调用 SSR 方法渲染页面
  // const {html, ttRenderMs} = await ssr(`https://www.jd.com/?cu=true&utm_source=hao.360.com&utm_medium=tuiguang&utm_campaign=t_1000003625_360mz&utm_term=6e74a90b77bc4ac3a5165b0c8364b511`);
  const { html, ttRenderMs } = await ssr(`http://192.168.1.110:8000/ssr.html`)
  res.set(
    "Server-Timing",
    `Prerender;dur=${ttRenderMs};desc="Headless render time (ms)"`
  )
  console.log(res.get("Server-Timing"))
  return res.status(200).send(html)
})

app.listen(8080, () => console.log("Server started. Press Ctrl+C to quit"))

在这里我们安装了http-server全局模块, 然后在html文件夹中做了一个静态代理,使用端口8000,如下:

npm i http-server -g
cd html
http-server -p 8000

纵横四海 -- Puppeteer全能应用一文通

纵横四海 -- Puppeteer全能应用一文通

纵横四海 -- Puppeteer全能应用一文通

纵横四海 -- Puppeteer全能应用一文通

或者也可以直接使用一个网址例如360官网(如上,打开注释即可)做一个渲染。

纵横四海 -- Puppeteer全能应用一文通

对应的ssr.cjs的代码要做对应的修改,将id由posts改为doc如下:

try {
    // 访问页面地址直到页面网络状态为 idle
    await page.goto(url, {waitUntil: 'networkidle0'});
    // 确保 #doc 节点已经存在
    await page.waitForSelector('#doc');
    // await page.waitForSelector('#posts');
  } catch (err) {
    console.error('err>>>', err);
    throw new Error('page.goto/waitForSelector timed out.');
  }

纵横四海 -- Puppeteer全能应用一文通

纵横四海 -- Puppeteer全能应用一文通

整个目录结构如下所示:

纵横四海 -- Puppeteer全能应用一文通

至此,我们从原理和代码层面分析了 Puppeteer 在 SSR 中的应用。

Puppeteer 在 UI 测试中的应用

Puppeteer 在 UI 测试(即端到端测试)中也可以大显身手,比如和 Jest 结合,通过断言能力实现一个完备的端到端测试系统/UI测试系统。如下:

const puppeteer = require("puppeteer")

// 测试页面 title 符合预期
test("baidu title is correct", async () => {
  // 启动一个无头浏览器
  const browser = await puppeteer.launch()
  // 通过无头浏览器访问页面
  const page = await browser.newPage()
  await page.goto("https://www.baidu.com/")
  // 获取页面 title
  还可以通过page.url()获取当前地址, 通过page.content()获取ssr内容
  const title = await page.title()
  // 使用 Jest 的 test 和 expect 两个全局函数进行断言
  expect(title).toBe("百度一下,你就知道")
  console.log("true")
  await browser.close()
})

纵横四海 -- Puppeteer全能应用一文通

实际上,现在流行的主流端到端测试框架,比如 Cypress 原理都如上代码所示。

Puppeteer 结合 Lighthouse 应用场景

Puppeteer 和 Lighthouse 结合, 就是一个简单的性能守卫系统的雏形。如下:

// lighthouse.js
const chromeLauncher = require("chrome-launcher")
const puppeteer = require("puppeteer")
const lighthouse = require("lighthouse")
const config = require("lighthouse/lighthouse-core/config/lr-desktop-config.js")
const reportGenerator = require("lighthouse/lighthouse-core/report/report-generator")
const request = require("request")
const util = require("util")
const fs = require("fs")

;(async () => {
  // 默认配置
  const opts = {
    logLevel: "info",
    output: "json",
    disableDeviceEmulation: true,
    defaultViewport: {
      width: 1200,
      height: 900,
    },
    chromeFlags: ["--disable-mobile-emulation"],
  }

  // 使用 chromeLauncher 启动一个 chrome 实例
  const chrome = await chromeLauncher.launch(opts)
  opts.port = chrome.port
  console.log("port>>>", opts.port)

  // 使用 puppeteer.connect 连接 chrome 实例
  const resp = await util.promisify(request)(
    `http://localhost:${opts.port}/json/version`
  )
  console.log("resp>>>", resp)

  const { webSocketDebuggerUrl } = JSON.parse(resp.body)
  console.log("webSocketDebuggerUrl", webSocketDebuggerUrl)

  const browser = await puppeteer.connect({
    browserWSEndpoint: webSocketDebuggerUrl,
  })

  //   访问逻辑
  page = (await browser.pages())[0]
  await page.setViewport({ width: 1200, height: 900 })
  const url = page.url()
  console.log("url", url)

  // 使用 lighthouse 产出报告
  const report = await lighthouse('https://www.baidu.com', opts, config)
  // console.log("report>>>", report)

  const html = reportGenerator.generateReport(report.lhr, "html")
  const json = reportGenerator.generateReport(report.lhr, "json")
  await browser.disconnect()
  // await chrome.kill()

  // 将报告写入文件系统
  fs.writeFile("report.html", html, (err) => {
    if (err) {
      console.error(err)
    }
  })
  fs.writeFile("report.json", json, (err) => {
    if (err) {
      console.error(err)
    }
  })
})()

纵横四海 -- Puppeteer全能应用一文通

最后会在项目目录下生成report.html和report.json这两个不同格式的性能报告文件,供我们进行具体的性能分析和处理(图表可视化展示等)

接下来我们重点讲下如何利用 Puppeteer 实现海报 Node.js 服务

Puppeteer 实现海报 Node.js 服务

生成海报的应用场景很多,比如文稿中划线,进行“金句分享”,如下图所示:

纵横四海 -- Puppeteer全能应用一文通

一般来说,生成海报可以使用html2canvas这样的类库基于浏览器api完成,这里面的技术难点主要有跨域处理、分页处理、页面截图时机处理等。整体来说,并不难实现,但是稳定性一般。另一种生成海报的方式就是使用 Puppeteer,构建一个 Node.js 服务来做页面截图,性能较高且稳定性强。

整个海报服务的整体技术链路如下:

纵横四海 -- Puppeteer全能应用一文通

核心技术是使用 Puppeteer,访问页面并截图。

纵横四海 -- Puppeteer全能应用一文通

下面我们来模拟简单实现下:

第一步,创建一个通用连接池,以供使用

依赖generic-pool库,这个库提供了 Promise 风格的通用池,可以用来对一些高消耗、高成本资源的调用实现防抖或拒绝服务能力,一个典型场景是对数据库的连接。这里我们把它用于 Puppeteer 实例的创建,如下代码所示:

// createPuppeteerPool.js
const puppeteer = require('puppeteer')
const genericPool = require('generic-pool')

const createPuppeteerPool = ({
  // pool 的最大容量
  max = 10,
  // pool 的最小容量
  min = 2,
  // 连接在池中保持空闲而不被回收的最小时间值
  idleTimeoutMillis = 30000,
  // 最大使用数
  maxUses = 50,
  // 在连接池交付实例前是否先经过 factory.validate 测试
  testOnBorrow = true,
  puppeteerArgs = {},
  validator = () => Promise.resolve(true),
  ...otherConfig
} = {}) => { 
  const factory = {
    // 创建实例
    create: () =>
      puppeteer.launch(puppeteerArgs).then(instance => {
        instance.useCount = 0
        return instance
      }),
    // 销毁实例
    destroy: instance => {
      instance.close()
    },
    // 验证实例可用性
    validate: instance => {
      return validator(instance).then(valid =>
        // maxUses 小于 0 或者 instance 使用计数小于 maxUses 时可用
        Promise.resolve(valid && (maxUses <= 0 || instance.useCount < maxUses))
      )
    }
  }
  const config = {
    max,
    min,
    idleTimeoutMillis,
    testOnBorrow,
    ...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 r
      })
      .then(fn)
      .then(
        result => {
          // 释放资源
          pool.release(resource)
          return result
        },
        err => {
          pool.release(resource)
          throw err
        }
      )
  }

  return pool
}

module.exports = createPuppeteerPool

在这里我们做了一系列性能优化,如下:

  • 对pool.use方法的一个链式调用实现一个服务资源的及时销毁
  • 通过重写pool.acquire()方法和计数器的设计, 实现每一次调用pool.acquire()之后计数一次, 以实现最大连接数限制
  • idleTimeoutMillis设置连接在池中保持空闲而不被回收的最小时间值
  • 设置pool 的最大容量

使用连接池的方式如下:

// pool.js
const createPuppeteerPool = require("./createPuppeteerPool.js")

const pool = createPuppeteerPool({
  // puppeteerArgs: {
  //   args: config.browserArgs
  // }
})

module.exports = pool
第二步,实现具体的生成海报逻辑

如下:

// render.js
// 获取连接池
const pool = require("./pool.js")

const render = (
  request,
  handleFetchPicoImageError = (error) => {
    console.error(error)
  }
) =>
  // 使用连接池资源
  pool.use(async (browser) => {
    // browser也就是pool.use方法中的resource, 也就是factory.create()的返回值
    // ctx为请求的context, 其中, body为请求体参数, query为查询参数(在url中拼接的参数)
    const { body = "", query } = request
    // 打开新的页面
    const page = await browser.newPage()
    // 服务支持直接传递 HTML 字符串内容
    let html = body
    // 从请求服务的 query 获取默认参数
    // 一个比较好的思路是浏览器在访问当前需要截屏的页面的时候, 把当前设备/浏览器的宽度(例如window.innerWidth)传给服务端, 结合页面的固定宽高比算出高度, 然后智能截图适配
    let {
      width = 1280,
      height = 2000,
      ratio: deviceScaleFactor = 2,
      type = "png",
      filename = "poster",
      waitUntil = ["networkidle0", "load", "domcontentloaded"],
      quality = 100,
      omitBackground = "true",
      fullPage = "true",
      url,
    } = query
    let image
    try {
      if (html.length > 1.25e6) {
        throw new Error("image size out of limits, at most 1 MB")
      }

      console.log("width>>>", width, height)
      // 设置浏览器视口
      await page.setViewport({
        width: Number(width),
        height: Number(height),
        deviceScaleFactor: Number(deviceScaleFactor),
      })

      // waitUntil=["networkidle0", "load", "domcontentloaded"], 其中networkidle0代表"网络空闲"

      // 访问 URL 页面
      await page.goto(url || `data:text/html,${html}`, {
        waitUntil,
      })

      // 进行截图
      type = type === "jpg" ? "jpeg" : type
      image = await page.screenshot({
        type,
        quality: type === "png" ? undefined : Number(quality),
        omitBackground: omitBackground === "true",
        fullPage: fullPage === "true",
        path: `${filename}.${type}`,
      })
    } catch (error) {
      handleFetchPicoImageError(error)
      throw error
    }

    await page.close()
    return { image, type, filename }
  })

module.exports = render

这里的render方法对应了pool.use(fn => {})中的fn, 这里的fn可以是任意的nodejs平台服务

第三步,起一个服务,调用render方法生成海报

如下:

// index.js
const render = require("./render.js")
const express = require("express")

const app = express()

app.get("/", async (req, res, next) => {
  // const urls = [{name: '搜狐快照', value: 'https://www.sohu.com/'}, {name: '百度快照', value: 'https://www.baidu.com/'}]
  // const ctx = { request: { query: { url:  urls[1].value } }, res }
  const {query: {url, width, height}} = req
  console.log('width', width, height)
  const request = { query: { url, width, height  } }
  const { image, type, filename } = await render(request)
  res.set("Content-Type", `image/${type}`)
  res.set("Content-Disposition", `inline; filename=${filename}.${type}`)
  return res.status(200).send(image)
})

app.listen(8080, () => console.log("Server started. Press Ctrl+C to quit"))

node index.js

纵横四海 -- Puppeteer全能应用一文通

打开浏览器,使用京东官网测试一下:

纵横四海 -- Puppeteer全能应用一文通

点击图片,进行放大并打开谷歌开发者工具,可以看到:

纵横四海 -- Puppeteer全能应用一文通

其中,参数如下:

http://localhost:8080/?url=https://www.jd.com/?cu=true&utm_source=hao.360.com&utm_medium=tuiguang&utm_campaign=t_1000003625_360mz&utm_term=30e2b8a8dc064fc29eb9b8d0d4b05b6e&width=1600&height=3800

同时,可以看到我们的项目目录下多了一个poster.png的图片:

纵横四海 -- Puppeteer全能应用一文通

对应这一段代码:

纵横四海 -- Puppeteer全能应用一文通

也可以改变参数进行截图,比如对百度官网进行截图:

纵横四海 -- Puppeteer全能应用一文通

至此,我们的海报生成nodejs服务就实现了,浏览器端可以使用仿造a标签点击事件的方式进行自动下载。更进一步,也可以在浏览器端调用海报服务接口的时候,将整个站点的所有url和其它参数通过request body入参传递过来,由node代码遍历全部url,下载对应图片到当前目录下面,生成在线PDF相册。

我们也可以生成各种语言的 SDK 客户端,调用该海报服务。比如一个简单的 Python 版 SDK 客户端实现如下代码:

import requests
class PosterGenerator(object):
    // ...
    def generate(self, **kwargs):
        """
        生成海报图片,返回二进制海报数据
        :param kwargs: 渲染时需要传递的参数字典
        :return: 二进制图片数据
        """
        html_content = render(self._syntax, self._template_content, **kwargs)
        url = POSTER_MAN_HA_PROXIES[self._api_env.value]
        try:
        		// post 请求海报服务
            resp = requests.post(
                url,
                data=html_content.encode('utf8'),
                headers={
                    'Content-Type': 'text/plain'
                },
                timeout=60,
                params=self.config
            )
        except RequestException as err:
            raise GenerateFailed(err.message)
        else:
            if not resp:
                raise GenerateFailed(u"Failed to generate poster, got NOTHING from poster-man")
            try:
                resp.raise_for_status()
            except requests.HTTPError as err:
                raise GenerateFailed(err.message)
            else:
                return resp.content

总结

我们介绍了 Puppeteer 的各种应用场景,通过Puppeteer实现SSR服务,基于Puppeteer的性能守卫系统实现, 基于Puppeteer的UI测试/端到端测试框架实现, 并重点介绍了一个基于 Puppeteer 设计实现的海报服务系统。