zoukankan      html  css  js  c++  java
  • 使用 puppeteer 创建一个自动化导出 PDF 的服务

    最近在基于 RAP2 做内网的一个 API 管理平台,涉及到与外部人员进行协议交换,需要提供 PDF 文档。
    在设置完成 CSS 后已经可以使用浏览器的打印功能实现导出 PDF,但全手动,总是觉得不爽,
    所以尝试使用了 PUPPETEER 实现 PDF 自动生成。

    PUPPETEER 功能介绍

    puppeteer 是 chrome 提供的一个无头浏览器,它是替代 phantomjs 的一个替代品,
    多用于实现自动化测试。官方仓库地址:https://github.com/GoogleChrome/puppeteer

    它和传统的 phantomjs、zombiejs 等主要区别在于:

    • 基于 chromuim,页面渲染完全使用最新浏览器,保证和实际页面完全一致
    • 可进行有头和无头切换,调试更为方便
    • 基本上等同于浏览器控制台的操作,扩展功能强大

    它实际上是基于 chromium 实现的一个 Nodejs 引擎,所以想要运行 puppeteer 就必须能够运行 chromium。
    对于 centos6 等低版本的系统就无法安装 chromium,就需要考虑使用其他方式。

    使用它的主要流程为:启动浏览器 -> 打开tab -> 加载 url -> 加载完成后的操作 -> 关闭页面 -> 关闭浏览器

    API 地址是:https://github.com/GoogleChrome/puppeteer/blob/v1.8.0/docs/api.md#

    导出服务的实现思路

    鉴于公司内部的服务器是 centos6.9,也就意味着无法安装 chromuim,所以想要实现安装就得使用容器技术。

    导出服务的要求:

    • 单页面,加载完成后直接导出
    • 多页面,多用于类似页面,加载完成后按照传入顺序导出PDF,并合并成一个 PDF 后返回
    • 以容器技术部署

    单页面

    实现比较方便,可以在页面加载完成后执行

    await page.pdf({path: 'page.pdf'});
    

    各种配置请参考 https://github.com/GoogleChrome/puppeteer/blob/v1.8.0/docs/api.md#pagepdfoptions

    多页面

    实现思路是类似的,先调用单页面创建并写入 PDF 至临时目录中(不要写入任意目录,在 docker 中未必有权限),
    然后合并 PDF 即可。Nodejs 目前没有原生合并 PDF,只能使用现成的库实现。PDFTK 是目前一个首选,nodejs 中也有相关集成的包。
    调用方式为:

    pdf.merge([file1,file2])
    

    注意: PDFtk 包中创建完成 PDF 会删除临时文件,所以我们单页面创建的也需要最终删除文件,不然到最后你的磁盘会直接爆掉。

    部署

    使用 docker 创建 image,涉及的依赖有:puppeteer(chromuim),pdftk,nodejs。

    代码实现

    puppeteer 封装

    为了方便使用,对 puppeteer 进行封装

    'use strict'
    const puppeteer = require('puppeteer')
    
    class Browser {
      constructor (option) {
        this.option = {
          args: ['--no-sandbox', '--disable-setuid-sandbox'],
          ignoreHTTPSErrors: true,
          executablePath: process.env.CHROME_PUPPETEER_PATH || undefined,
          dumpio: false,
          ...option
        }
      }
      async start () {
        if (!this.browser) {
          this.browser = await puppeteer.launch(this.option)
          this.browser.once('disconnected', () => {
            this.browser = undefined
          })
        }
        return this.browser
      }
      async exit () {
        if (!this.browser) {
          return
        }
        await this.browser.close()
      }
      async open (url, { cookie }) {
        await this.start()
        const page = await this.browser.newPage()
        // 缓存状态下多页面可能不正常
        await page.setCacheEnabled(false)
        if (cookie) {
          const cookies = Array.isArray(cookie) ? cookie : [cookie]
          await page.setCookie(...cookies)
        }
    
        await page.goto(url, {
          waitUntil: 'networkidle0'
        })
        return page
      }
    }
    
    const browser = new Browser({
      headless: true
    })
    
    // 退出时结束浏览器,防止内存泄漏
    process.on('exit', () => {
      browser.exit()
    })
    
    module.exports = browser
    

    由于我们要在 docker 镜像中使用,设置 puppeterr 的参数为:--no-sandbox --disable-setuid-sandbox
    这里面的执行路径使用全局的环境变量,主要目的是避免 chromuim 重复下载,导出包的体积过大。

    实现请求服务

    由于浏览器的特性,GET 请求可下载文件, POST 请求无法下载文件,所以我们单页面以 GET 方式实现,多页面以 POST 方式实现。

    router.post('/pdf/create/files', async (ctx, next) => {
      const { cookie, pdfOptions, list = [] } = ctx.request.body
      const filename = encodeURIComponent(ctx.request.body.filename || 'collectionofpdf')
      const queryList = list.map((item) => {
        const hostname = nodeUrl.parse(item.url).hostname
        return [
          item.url,
          {
            cookie: findCookie(ctx, hostname, item.cookie || cookie || '') || [],
            pdfOptions: item.pdfOptions || pdfOptions
          }
        ]
      })
      const pdfBuffer = await createPdfFileMergedBuffer(queryList)
      ctx.set({
        'Content-Type': 'application/pdf',
        'Content-Disposition': `attachment;filename="${filename}.pdf"`,
        'Content-Length': `${pdfBuffer.length}`
      })
      ctx.body = pdfBuffer
    })
    
    router.get('/pdf/create/download', async (ctx, next) => {
      const { url, cookie, pdfOptions } = ctx.request.query
      const filename = encodeURIComponent(ctx.request.query.filename || 'newpdf')
      const hostname = nodeUrl.parse(url).hostname
      const pdfBuffer = await createPdfBuffer(url, {
        cookie: findCookie(ctx, hostname, cookie),
        pdfOptions
      })
    
      ctx.set({
        'Content-Type': 'application/pdf',
        'Content-Disposition': `attachment;filename="${filename}.pdf"`,
        'Content-Length': `${pdfBuffer.length}`
      })
      ctx.body = pdfBuffer
    })
    
    

    创建 PDF:

    /**
     * create pdf with file path return
     * @param {String} url a web page url to fetch
     * @param {Object}
     *  @param {Array} cookie A array with cookie Object
     *  @param {Object} pdfOptions options for puppeteer pdf options, cover the default pdf setting
     */
    async function createPdfFile (url, { cookie, pdfOptions = {} }) {
      const options = Object.assign({}, defaultPdfOptions, pdfOptions)
    
      const page = await browser.open(url, {
        cookie
      })
      // const filename = path.join(__dirname, '../../static/', getUniqueFilename() + '.pdf')
      const filename = shellescape([tmp.tmpNameSync()])
      await page.pdf({ path: filename, ...options })
      await page.close()
      return filename
    }
    
    async function queueCreatePdfFile (list = []) {
      const result = await queueExecAsyncFunc(createPdfFile, list, { maxLen: MAX_QUEUE_LEN })
      return result
    }
    
    async function createPdfFileMergedBuffer (list) {
      const files = await queueCreatePdfFile(list)
      return pdfMerge(files)
        .then((buffer) => {
          return Promise.all(files.map((file) => {
            return new Promise((resolve) => {
              fs.unlink(file, resolve)
            })
          })).then(() => {
            return buffer
          })
        })
    }
    

    环境部署

    DockerFile

    FROM wenlonghuo/puppeteer-pdf-base:1.0.0
    
    # COPY package.json /app/package.json
    COPY . /app
    
    USER root
    
    ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD="TRUE"
    
    RUN rm -rf ./node_modules/ && rm -rf ./example/node_modules/ 
      && npm install --production && npm cache clean --force
    
    USER pptruser
    # Default to port 80 for node, and 5858 or 9229 for debug
    ARG PORT=19898
    ENV PORT $PORT
    EXPOSE $PORT 5858 9229
    
    CMD ["node", "app/index.js"]
    

    使用已经完成的 docker 进行部署的方法是:

    docker run -i -t -p 19898:19898 --restart=always --privileged=true wenlonghuo/puppeteer-pdf
    

    然后服务调用接口即可。如果没有其他服务,也可以前端调用,效果会差很多,比如使用 axios 实现调用接口并下载:

    axios.post('/pdf/create/files', {
      list: multi.list.split(',').map(item => ({ url: item })),
      cookie: multi.cookie,
      pdfOptions: multi.pdfOptions
    }, {
      responseType: 'arraybuffer'
    }).then(res => {
      createDownload(res.data)
    })
    
    function createDownload (text, filename = '导出') {
      /* eslint-disable no-undef */
      const blob = new Blob([text], { type: 'application/pdf' })
      const elink = document.createElement('a')
      elink.download = filename + '.pdf'
      elink.style.display = 'none'
      elink.href = URL.createObjectURL(blob)
      document.body.appendChild(elink)
      elink.click()
      URL.revokeObjectURL(elink.href) // 释放URL 对象
      document.body.removeChild(elink)
    }
    

    这种方式的主要问题在于下载完成文件后才会弹出窗口,会让人感觉很慢,服务中应该使用 stream 方式进行处理

    总结

    虽然服务搭建好了,但由于公司的服务器没有 root 权限,无法搭建 docker 环境,最后还是白折腾一场,只能搭在自己的 vps 上进行当作小实验了。

    服务存在的问题:

    • 无流式实现,感觉等待时间有点久
    • 多页面导出页脚的统一设置需要提供统一函数
    • 部分页面导出后会将文字切割分成两页,是 puppeteer 的问题
    • 服务稳定性还有待提高

    附:
    demo 地址:https://pdf-maker3.eff.red/#/ https://pdf-maker.eff.red/#/
    仓库地址:https://github.com/wenlonghuo/puppeteer-pdf

  • 相关阅读:
    「七天自制PHP框架」第四天:模型关联
    「七天自制PHP框架」第三天:PHP实现的设计模式
    「七天自制PHP框架」第二天:模型与数据库
    一个例子简要说明include和require的区别
    解读Laravel,看PHP如何实现Facade?
    Laravel是怎么实现autoload的?
    Laravel表单提交
    Laravel的console使用方法
    PHP控制反转(IOC)和依赖注入(DI)
    PHP解耦的三重境界(浅谈服务容器)
  • 原文地址:https://www.cnblogs.com/dreamless/p/9673792.html
Copyright © 2011-2022 走看看