使用 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 的問題
- 服務穩定性還有待提高