使用Node和Puppeteer实现批量生成PDF
作者:简恬
引言
本文档旨在记录项目组使用Node.js和Puppeteer库将网页内容转换为PDF文件的过程。该方案旨在提供一种高效、稳定的方法,以实现自动化网页内容转PDF的需求。
方案选型
- html2canvas + jsPDF
- Node Puppeteer + nb-fe-pdf(二次封装的库,支持前端渲染过程中对网页进行切割,支持动态渲染多页pdf)
技术选定
html2canvas+jsPDF的优缺点
优点
- 不依赖服务器,由前端独立完成,定制化样式强
- 性能高
- 支持局部内容生成PDF
缺点
- 兼容性不够好
- 不支持批量生成
- 导出为图片,会出现模糊的问题
- pdf过大,关闭页面会导致生成失败
Node Puppeteer的优缺点
优点
- 兼容性好
- 支持批量生成
- PDF是矢量,放大缩小不会模糊,支持粘贴
- 服务端生成,关闭页面不会导致生成PDF失败
缺点
- 吞吐量限制,需要满足用户的批量生成
- 如果PDF过大,生成过程较长,需要通知前端生成阶段和进度
- 学习成本较高,需要更复杂的编码
基于两种方案的优缺点,怎么选择呢
- 如果是支持局部页面导出PDF,想快速通过当前页导出PDF,并且对清晰图要求不那么高,不要求能直接粘贴,可以采用方案一
- 如果是对PDF要求比较高,要求高清并且支持文字粘贴,或者需要批量后台生成,建议采用方案二
基于项目需求,需要在后台批量生成PDF,所以最终选取方案二
项目实现
实现思路
- 前端通过页面渲染成PDF预览的样子
- Node puppeteer通过模拟打开浏览器,并且生成PDF
- Node将生成的文件流上传到CSP,并且返回前端一个csp的路径,存储到后端服务器
- 用户点击下载通过csp的路径从CSP上直接下载
第一步: 前端页面渲染,通过nb-fe-pdf第三库进行页面切割
基于第三方库nb-fe-pdf二次改造,支持自定宽高,支持文字截断功能,更好的class标志,同时解决底部空白太多等问题,实现更完善的分页功能。
参考:GitHub - Reesejia/nb-fe-pdf-1: html page to pdf file
实现原理
- @irp/fe-nb-pdf算法实现是在页面dom渲染完成之后,根据标记,将页面分成一个一个小的模块,然后通过计算木块的高度,将这些小的模块合理的放到PDF容器中
- 对于一个print-page-split-flag表示整个模块需要放在同一页中,如果需要将组件一拆分更细,可以单独给组件一里面的内容各自加上print-page-split-flag
- 对于表格分页实现,首先给容器添加一个print-table标志,然后再table上面添加print-table-wrapper的标志,表格是需要这两个标志结合使用;对于表格的行class可以通过配置修改
- 对于文字截断实现,首先通过虚拟渲染,计算出特殊字符,英文字符以及中文字符的长度,然后再讲文字遍历,一行一行计算,然后再拼接每一行文字,给每一行文字添加print-page-split-flag,既可以实现文字分页功能
第二步:Node puppeteer通过模拟打开浏览器,并且生成PDF
实现原理
- puppeteer通过pege.goto访问指定页面
- 然后等待页面加载完成,可以通过监听全部请求是否加载完成或者通过监听页面加载完成标志
- 监听结束后,通过page.pdf来生成PDF buffer文件流
- 将buffer流返回到前端或者直接上传到CSP
eg:
const browser = await puppeteer.launch({ executablePath: 'google-chrome-stable', headless: true, args: ['--disable-setuid-sandbox', '--no-sandbox'] }) // 打开浏览器 const context = await browser.createIncognitoBrowserContext() // 开启无痕模式 const page = await context.newPage() // 打开一个空白页 await page.goto('url', { timeout: 3000 }) await page.waitForSelector('.report-pages.load-finished', { timeout: 60000 })// 等待页面加载完成 const bufferStr = await page.pdf({ scale: 1, width: ctx.request.body.width, height: ctx.request.body.height + 1, // 加1,解决多生成一个空白页 // CSS preferCSSPageSize: true, // 开启渲染背景色,因为 puppeteer 是基于 chrome 浏览器的,浏览器为了打印节省油墨,默认是不导出背景图及背景色的 // 坑点,必须加 printBackground: true // margin:{top:'2cm',right:'2cm',bottom:'2cm',left:'2cm'} })
Puppeteer痛点
什么时机开始生成PDF
page.goto是通过网络页面加载,响应速度依赖页面的资源加载和网络状态,或者前端页面有报错,会导致失败,那node服务怎么确定什么时候开始取生成PDF呢?
答: 页面在渲染组件的过程,在每个组件渲染结束后通知最外层自己渲染结束,外层页面在监听到所有组件渲染完毕,就添加一个‘loaded-finished’ className的标志。Puppetter在获取到该className的再开始生成PDF
怎么实现批量生成PDF
一般就会想到循环遍历就能实现批量,再深入想一点,就是通过类似于队列的方式保证队列中至少有多少个程序在同时生成PDF
答:有两种方式,一种是通过队列的方式实现,另一种方式通过worker的思想实现
方案一:
const handlePool = (urls, max, handler) => { let i = 0 const ret = [] // 存储所有的异步任务 const executing = [] // 存储正在执行的异步任务 const enqueue = function () { if (i === urls.length) { return Promise.resolve() } const item = urls[i++] // 获取新的任务项 const p = Promise.resolve().then(() => handler(item, urls)) ret.push(p) let r = Promise.resolve() // 当poolLimit值小于或等于总任务个数时,进行并发控制 if (max <= urls.length) { // 当任务完成后,从正在执行的任务数组中移除已完成的任务 const e = p.then(() => executing.splice(executing.indexOf(e), 1)).catch((error) => { }) executing.push(e) if (executing.length >= max) { r = Promise.race(executing) } } // 正在执行任务列表 中较快的任务执行完成之后,才会从array数组中获取新的待办任务 return r.then(() => enqueue()) } return enqueue().then(() => Promise.all(ret)).catch(error => { console.error('handlePool', error) })}
方案二:
写一个MainWorker类,控制任务(job)生成器,然后通过node的EventEmitter事件通知job开始和结束
class MainWorker extends EventEmitter { constructor(ctx, jobCount) { super() this.ctx = ctx this.pagePools = [] // 记录每个job的信息 this.jobCount = jobCount || 6 this.instance = null // 生成jobCount个job,用来后面 this.createJobs() } createJob(){ return new Promise((resolve, reject) => { this.ctx.pool.use(async instance => { // instance为浏览器实例,写处理逻辑的handler const page = await instance.newPage() const jobId = Util.token() // 随机数ID this.pagePools.push({ jobId: jobId, instance, page, isIdle: true }) resolve() }) }) } async createJobs(){ for (let i = 0;i < this.jobCount;i++){ await this.createJob() } } // 调用开始就是调用job开始工作 async start() { this.pagePools.forEach(el => { this.send({ type: 'jobReady', jobId: el.jobId }) }) }}
线上node服务生成PDF会经常失败,在本地运行不会报错
查了很久的原因,发现上述流程是启动一个浏览器实例,多个tab页(page)的时候,在k8s里面会经常goto失败,监控内存和cpu都显示正常,但是经常会失败,导致PDF生成失败;
解决办法:采取启动多个浏览器,每一个浏览器只对应一个tab,解决了这个问题。(虽然没找到为什么,但是这样解决了这个问题,如果大家没遇到这样的问题,就可以不用这样处理了)
这里就用generic-pool在node服务启动的时候,就生成多个Puppetter Instance池,等需要用的时候,就拿一个空闲的Puppetter Instance去使用
const puppeteer = require('puppeteer') const genericPool = require('generic-pool') /** * 初始化一个 Puppeteer 池 * @param {Object} [options={}] 创建池的配置配置 * @param {Number} [options.max=30] 最多产生多少个 puppeteer 实例 。如果你设置它,请确保 在引用关闭时调用清理池。 pool.drain().then(()=>pool.clear()) * @param {Number} [options.min=15] 保证池中最少有多少个实例存活 * @param {Number} [options.maxUses=2048] 每一个 实例 最大可重用次数,超过后将重启实例。0表示不检验 * @param {Number} [options.testOnBorrow=2048] 在将 实例 提供给用户之前,池应该验证这些实例。 * @param {Boolean} [options.autostart=false] 是不是需要在 池 初始化时 初始化 实例 * @param {Number} [options.idleTimeoutMillis=3600000] 如果一个实例 60分钟 都没访问就关掉他 * @param {Number} [options.evictionRunIntervalMillis=180000] 每 3分钟 检查一次 实例的访问状态 * @param {Object} [options.puppeteerArgs={}] puppeteer.launch 启动的参数 * @param {Function} [options.validator=(instance)=>Promise.resolve(true))] 用户自定义校验 参数是 取到的一个实例 * @return {Object} pool */ const initPuppeteerPool = (options = { otherConfig: {} }) => { const { max = 20, min = 10, maxUses = 2048, testOnBorrow = true, autostart = false, idleTimeoutMillis = 3600000, evictionRunIntervalMillis = 180000, puppeteerArgs = { executablePath: 'google-chrome-stable', // executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', // executablePath: 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', // 宝丹me路径 // executablePath: 'google-chrome-stable', headless: true, devtools: false, defaultViewport: { width: 1920, height: 1080 }, slowMo: 200, args: [ '--no-sandbox', '--unlimited-storage', '--full-memory-crash-report', '--disable-gpu', '--disable-gpu-sandbox', '--disable-gl-drawing-for-tests', '--ignore-certificate-errors', '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--lang=en-US;q=0.9,en;q=0.8', '--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36' ] }, validator = () => Promise.resolve(true) } = options const factory = { create: () => puppeteer.launch(puppeteerArgs).then(async(browser) => { browser.on('targetdestroyed', () => { console.log('1111 page closed') }) browser.on('disconnected', () => { console.log('disconnected') }) // 创建一个 puppeteer 实例 ,并且初始化使用次数为 0 const instance = await browser.createIncognitoBrowserContext() // 开启无痕模式 instance.useCount = 0 return instance }), destroy:async (instance) => { const pages = await instance.pages() console.log('实例 close',pages) for(let i=0,l=pages.length;i<l;i++){ await pages[i].close() } await instance.close() await browser.close() }, validate: instance => { // 执行一次自定义校验,并且校验校验 实例已使用次数。 当 返回 reject 时 表示实例不可用 return validator(instance).then(valid => Promise.resolve(valid && (maxUses <= 0 || instance.useCount < maxUses))) } } const config = { max, min, testOnBorrow, autostart, idleTimeoutMillis, evictionRunIntervalMillis } const pool = genericPool.createPool(factory, config) const genericAcquire = pool.acquire.bind(pool) // pool.drain = pool.acquire.bind(pool) // 重写了原有池的消费实例的方法。添加一个实例使用次数的增加 pool.acquire = () => genericAcquire().then(instance => { instance.useCount += 1 return instance }) // pool.drain = () =>{} pool.use = fn => { let resource // let page return pool .acquire() .then(r => { resource = r // page = resource.newPage() return resource }) .then(fn) .then( result => { // 不管业务方使用实例成功与后都表示一下实例消费完成 pool.release(resource) return result }, err => { pool.release(resource) throw err } ).catch(err => { pool.release(resource) throw err }) } return pool } module.exports = initPuppeteerPool
以上就是使用Node和Puppeteer实现批量生成PDF的详细内容,更多关于Node Puppeteer生成PDF的资料请关注脚本之家其它相关文章!