使用 puppeteer 建立一個自動化導出 PDF 的服務

最近在基於 RAP2 作內網的一個 API 管理平臺,涉及到與外部人員進行協議交換,須要提供 PDF 文檔。
在設置完成 CSS 後已經能夠使用瀏覽器的打印功能實現導出 PDF,但全手動,老是以爲不爽,
因此嘗試使用了 PUPPETEER 實現 PDF 自動生成。javascript

PUPPETEER 功能介紹

puppeteer 是 chrome 提供的一個無頭瀏覽器,它是替代 phantomjs 的一個替代品,
多用於實現自動化測試。官方倉庫地址:https://github.com/GoogleChrome/puppeteer前端

它和傳統的 phantomjs、zombiejs 等主要區別在於:java

  • 基於 chromuim,頁面渲染徹底使用最新瀏覽器,保證和實際頁面徹底一致
  • 可進行有頭和無頭切換,調試更爲方便
  • 基本上等同於瀏覽器控制檯的操做,擴展功能強大

它其實是基於 chromium 實現的一個 Nodejs 引擎,因此想要運行 puppeteer 就必須可以運行 chromium。
對於 centos6 等低版本的系統就沒法安裝 chromium,就須要考慮使用其餘方式。node

使用它的主要流程爲:啓動瀏覽器 -> 打開tab -> 加載 url -> 加載完成後的操做 -> 關閉頁面 -> 關閉瀏覽器ios

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

導出服務的實現思路

鑑於公司內部的服務器是 centos6.9,也就意味着沒法安裝 chromuim,因此想要實現安裝就得使用容器技術。github

導出服務的要求:web

  • 單頁面,加載完成後直接導出
  • 多頁面,多用於相似頁面,加載完成後按照傳入順序導出PDF,併合併成一個 PDF 後返回
  • 以容器技術部署

單頁面

實現比較方便,能夠在頁面加載完成後執行chrome

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

各類配置請參考 https://github.com/GoogleChrome/puppeteer/blob/v1.8.0/docs/api.md#pagepdfoptionsdocker

多頁面

實現思路是相似的,先調用單頁面建立並寫入 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

相關文章
相關標籤/搜索