使用Node和Puppeteer實現(xiàn)批量生成PDF
引言
本文檔旨在記錄項目組使用Node.js和Puppeteer庫將網(wǎng)頁內(nèi)容轉(zhuǎn)換為PDF文件的過程。該方案旨在提供一種高效、穩(wěn)定的方法,以實現(xiàn)自動化網(wǎng)頁內(nèi)容轉(zhuǎn)PDF的需求。
方案選型
- html2canvas + jsPDF
- Node Puppeteer + nb-fe-pdf(二次封裝的庫,支持前端渲染過程中對網(wǎng)頁進行切割,支持動態(tài)渲染多頁pdf)
技術(shù)選定
html2canvas+jsPDF的優(yōu)缺點
優(yōu)點
- 不依賴服務(wù)器,由前端獨立完成,定制化樣式強
- 性能高
- 支持局部內(nèi)容生成PDF
缺點
- 兼容性不夠好
- 不支持批量生成
- 導(dǎo)出為圖片,會出現(xiàn)模糊的問題
- pdf過大,關(guān)閉頁面會導(dǎo)致生成失敗
Node Puppeteer的優(yōu)缺點
優(yōu)點
- 兼容性好
- 支持批量生成
- PDF是矢量,放大縮小不會模糊,支持粘貼
- 服務(wù)端生成,關(guān)閉頁面不會導(dǎo)致生成PDF失敗
缺點
- 吞吐量限制,需要滿足用戶的批量生成
- 如果PDF過大,生成過程較長,需要通知前端生成階段和進度
- 學(xué)習(xí)成本較高,需要更復(fù)雜的編碼
基于兩種方案的優(yōu)缺點,怎么選擇呢
- 如果是支持局部頁面導(dǎo)出PDF,想快速通過當(dāng)前頁導(dǎo)出PDF,并且對清晰圖要求不那么高,不要求能直接粘貼,可以采用方案一
- 如果是對PDF要求比較高,要求高清并且支持文字粘貼,或者需要批量后臺生成,建議采用方案二
基于項目需求,需要在后臺批量生成PDF,所以最終選取方案二
項目實現(xiàn)
實現(xiàn)思路
- 前端通過頁面渲染成PDF預(yù)覽的樣子
- Node puppeteer通過模擬打開瀏覽器,并且生成PDF
- Node將生成的文件流上傳到CSP,并且返回前端一個csp的路徑,存儲到后端服務(wù)器
- 用戶點擊下載通過csp的路徑從CSP上直接下載
第一步: 前端頁面渲染,通過nb-fe-pdf第三庫進行頁面切割
基于第三方庫nb-fe-pdf二次改造,支持自定寬高,支持文字截斷功能,更好的class標志,同時解決底部空白太多等問題,實現(xiàn)更完善的分頁功能。
參考:GitHub - Reesejia/nb-fe-pdf-1: html page to pdf file
實現(xiàn)原理
- @irp/fe-nb-pdf算法實現(xiàn)是在頁面dom渲染完成之后,根據(jù)標記,將頁面分成一個一個小的模塊,然后通過計算木塊的高度,將這些小的模塊合理的放到PDF容器中
- 對于一個print-page-split-flag表示整個模塊需要放在同一頁中,如果需要將組件一拆分更細,可以單獨給組件一里面的內(nèi)容各自加上print-page-split-flag
- 對于表格分頁實現(xiàn),首先給容器添加一個print-table標志,然后再table上面添加print-table-wrapper的標志,表格是需要這兩個標志結(jié)合使用;對于表格的行class可以通過配置修改
- 對于文字截斷實現(xiàn),首先通過虛擬渲染,計算出特殊字符,英文字符以及中文字符的長度,然后再講文字遍歷,一行一行計算,然后再拼接每一行文字,給每一行文字添加print-page-split-flag,既可以實現(xiàn)文字分頁功能
第二步:Node puppeteer通過模擬打開瀏覽器,并且生成PDF
實現(xiàn)原理
- puppeteer通過pege.goto訪問指定頁面
- 然后等待頁面加載完成,可以通過監(jiān)聽全部請求是否加載完成或者通過監(jiān)聽頁面加載完成標志
- 監(jiān)聽結(jié)束后,通過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 瀏覽器的,瀏覽器為了打印節(jié)省油墨,默認是不導(dǎo)出背景圖及背景色的 // 坑點,必須加 printBackground: true // margin:{top:'2cm',right:'2cm',bottom:'2cm',left:'2cm'} })
Puppeteer痛點
什么時機開始生成PDF
page.goto是通過網(wǎng)絡(luò)頁面加載,響應(yīng)速度依賴頁面的資源加載和網(wǎng)絡(luò)狀態(tài),或者前端頁面有報錯,會導(dǎo)致失敗,那node服務(wù)怎么確定什么時候開始取生成PDF呢?
答: 頁面在渲染組件的過程,在每個組件渲染結(jié)束后通知最外層自己渲染結(jié)束,外層頁面在監(jiān)聽到所有組件渲染完畢,就添加一個‘loaded-finished’ className的標志。Puppetter在獲取到該className的再開始生成PDF
怎么實現(xiàn)批量生成PDF
一般就會想到循環(huán)遍歷就能實現(xiàn)批量,再深入想一點,就是通過類似于隊列的方式保證隊列中至少有多少個程序在同時生成PDF
答:有兩種方式,一種是通過隊列的方式實現(xiàn),另一種方式通過worker的思想實現(xiàn)
方案一:
const handlePool = (urls, max, handler) => { let i = 0 const ret = [] // 存儲所有的異步任務(wù) const executing = [] // 存儲正在執(zhí)行的異步任務(wù) const enqueue = function () { if (i === urls.length) { return Promise.resolve() } const item = urls[i++] // 獲取新的任務(wù)項 const p = Promise.resolve().then(() => handler(item, urls)) ret.push(p) let r = Promise.resolve() // 當(dāng)poolLimit值小于或等于總?cè)蝿?wù)個數(shù)時,進行并發(fā)控制 if (max <= urls.length) { // 當(dāng)任務(wù)完成后,從正在執(zhí)行的任務(wù)數(shù)組中移除已完成的任務(wù) const e = p.then(() => executing.splice(executing.indexOf(e), 1)).catch((error) => { }) executing.push(e) if (executing.length >= max) { r = Promise.race(executing) } } // 正在執(zhí)行任務(wù)列表 中較快的任務(wù)執(zhí)行完成之后,才會從array數(shù)組中獲取新的待辦任務(wù) return r.then(() => enqueue()) } return enqueue().then(() => Promise.all(ret)).catch(error => { console.error('handlePool', error) })}
方案二:
寫一個MainWorker類,控制任務(wù)(job)生成器,然后通過node的EventEmitter事件通知job開始和結(jié)束
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() // 隨機數(shù)ID this.pagePools.push({ jobId: jobId, instance, page, isIdle: true }) resolve() }) }) } async createJobs(){ for (let i = 0;i < this.jobCount;i++){ await this.createJob() } } // 調(diào)用開始就是調(diào)用job開始工作 async start() { this.pagePools.forEach(el => { this.send({ type: 'jobReady', jobId: el.jobId }) }) }}
線上node服務(wù)生成PDF會經(jīng)常失敗,在本地運行不會報錯
查了很久的原因,發(fā)現(xiàn)上述流程是啟動一個瀏覽器實例,多個tab頁(page)的時候,在k8s里面會經(jīng)常goto失敗,監(jiān)控內(nèi)存和cpu都顯示正常,但是經(jīng)常會失敗,導(dǎo)致PDF生成失??;
解決辦法:采取啟動多個瀏覽器,每一個瀏覽器只對應(yīng)一個tab,解決了這個問題。(雖然沒找到為什么,但是這樣解決了這個問題,如果大家沒遇到這樣的問題,就可以不用這樣處理了)
這里就用generic-pool在node服務(wù)啟動的時候,就生成多個Puppetter Instance池,等需要用的時候,就拿一個空閑的Puppetter Instance去使用
const puppeteer = require('puppeteer') const genericPool = require('generic-pool') /** * 初始化一個 Puppeteer 池 * @param {Object} [options={}] 創(chuàng)建池的配置配置 * @param {Number} [options.max=30] 最多產(chǎn)生多少個 puppeteer 實例 。如果你設(shè)置它,請確保 在引用關(guān)閉時調(diào)用清理池。 pool.drain().then(()=>pool.clear()) * @param {Number} [options.min=15] 保證池中最少有多少個實例存活 * @param {Number} [options.maxUses=2048] 每一個 實例 最大可重用次數(shù),超過后將重啟實例。0表示不檢驗 * @param {Number} [options.testOnBorrow=2048] 在將 實例 提供給用戶之前,池應(yīng)該驗證這些實例。 * @param {Boolean} [options.autostart=false] 是不是需要在 池 初始化時 初始化 實例 * @param {Number} [options.idleTimeoutMillis=3600000] 如果一個實例 60分鐘 都沒訪問就關(guān)掉他 * @param {Number} [options.evictionRunIntervalMillis=180000] 每 3分鐘 檢查一次 實例的訪問狀態(tài) * @param {Object} [options.puppeteerArgs={}] puppeteer.launch 啟動的參數(shù) * @param {Function} [options.validator=(instance)=>Promise.resolve(true))] 用戶自定義校驗 參數(shù)是 取到的一個實例 * @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') }) // 創(chuàng)建一個 puppeteer 實例 ,并且初始化使用次數(shù)為 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 => { // 執(zhí)行一次自定義校驗,并且校驗校驗 實例已使用次數(shù)。 當(dāng) 返回 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) // 重寫了原有池的消費實例的方法。添加一個實例使用次數(shù)的增加 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 => { // 不管業(yè)務(wù)方使用實例成功與后都表示一下實例消費完成 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實現(xiàn)批量生成PDF的詳細內(nèi)容,更多關(guān)于Node Puppeteer生成PDF的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Node.js Continuation Passing Style( CPS與
這篇文章主要介紹了Node.js Continuation Passing Style,將回調(diào)函數(shù)作為參數(shù)傳遞,這種書寫方式通常被稱為Continuation Passing Style(CPS),它的本質(zhì)仍然是一個高階函數(shù),CPS最初是各大語言中對排序算法的實現(xiàn)2022-06-06