最近在基于 RAP2 作内网的一个 API 管理平台,涉及到与外部人员进行协议交换,须要提供 PDF 文档。
在设置完成 CSS 后已经能够使用浏览器的打印功能实现导出 PDF,但全手动,老是以为不爽,
因此尝试使用了 PUPPETEER 实现 PDF 自动生成。javascript
puppeteer 是 chrome 提供的一个无头浏览器,它是替代 phantomjs 的一个替代品,
多用于实现自动化测试。官方仓库地址:https://github.com/GoogleChrome/puppeteer前端
它和传统的 phantomjs、zombiejs 等主要区别在于:java
它其实是基于 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
实现比较方便,能够在页面加载完成后执行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 进行封装
'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 上进行看成小实验了。
服务存在的问题:
附:
demo 地址:https://pdf-maker3.eff.red/#/ https://pdf-maker.eff.red/#/
仓库地址:https://github.com/wenlonghuo/puppeteer-pdf