使用 Puppeteer 導出聲享 PPT

現狀

聲享是一個基於 ThinkJS 開發的在線製做 PPT 平臺。聲享製做的 PPT 支持代碼高亮、圖片上傳、神奇效果等功能,同時你能夠在聲享收藏本身喜歡的 PPT 、對本身的 PPT 進行分類管理。其中有一個 PDF 導出的功能,能夠將本身製做的 PPT 導出成 PDF 保存到本地。前端

功能實現比較簡單,只是提供了一個頁面,用戶須要手動去打印成 PDF。這個方案存在一些問題:node

  1. 因爲使用了 iframe 懶加載致使未加載的 iframe 沒法正常顯示。
  2. 該種方案只能打印全部頁面的初始狀態。若是頁面中存在切換動畫,可能會丟失部分 PPT 信息。
  3. 須要用戶手動操做,提升了使用難度。

若是是前端來生成 PDF,這些問題基本能夠獲得解決,可是開發量比較大並且存在一個效率問題。若是 PPT 頁面存在多個 iframe,PDF 的生成時間過長會讓用戶長時間等待,明顯不太合適。最終仍是決定服務端來生成 PDF,纔有了後來 Puppeteer 的嘗試。linux

Puppeteer

t0141d1aa3573ceb40e.png

什麼是Puppeteer呢?官方給的解釋是:git

Puppeteer is a Node library which provides a high-level API to control Chrome or Chromium over the DevTools Protocol. Puppeteer runs headless by default, but can be configured to run full (non-headless) Chrome or Chromium.github

簡而言之,這貨是一個提供高級 API 的 node 庫,可以經過 devtool 控制 headless 模式的 Chrome 或者 Chromium,它能夠在 headless 模式下模擬任何的人爲操做。經過它咱們能夠實現:web

  1. 生成頁面的截圖或者 PDF
  2. 抓取 SPA(單頁應用)並生成預渲染內容(即「SSR」(服務器端渲染))。
  3. 自動提交表單,進行 UI 測試,鍵盤輸入等。 ...

經過 Puppeteer,咱們能夠直接使用 Chrome 把咱們須要的內容導出爲 PDF。對比之前的實現方式有如下優勢:chrome

  1. 不須要用戶手動操做,服務端生成 PDF 後直接以郵件的方式發送給用戶。
  2. PPT 中的動畫能夠模擬用戶翻頁的動做觸發,而後以初始、結束兩張 PDF 的方式展現,不會丟失 PPT 內容。
  3. 不須要考慮圖片/ iframe 跨域等問題。

能夠說 Puppeteer 完美的解決來咱們一期 PDF 導出存在的問題。跨域

解決方案

咱們基本的實現思路是:瀏覽器

  1. 打開一個正常的 PPT 播放頁,獲取須要打印的 DOM 元素並翻頁 。
  2. 重複第一步操做直至到最後一頁 。
  3. 清空頁面內容並將前兩步得到的頁面內容依次填充到當前頁面(爲何要依次填充會在後面解釋)。

對應上述方案實現的部分代碼以下:緩存

  1. 經過 Puppeteer 打開指定的頁面
// 測試時建議headless設置爲false,以即可以直觀看到頁面效果
this.browser = await puppeteer.launch({headless: this.isDebug});
this.page = await this.browser.newPage();
await this.page.goto('https://xxxxx.com', { waitUntil:'networkidle2' });
複製代碼
  1. 打開頁面後能夠經過 Puppeteer 模擬用戶翻頁操做,每次翻頁後緩存須要打印的 DOM 元素字符串。
let canNext;
let i = 0;
const content = {};
do {
    canNext = await this.page.$('.navigate-right.enabled');
    const iframes = await this.page.$$('.PluginPage.present iframe').length;
    content[i++] = {
        iframe: iframes,
        domStr: await this.page.$eval('.RevealViewPort', el => el.outerHTML)
    }
    if (canNext) {
        await this.page.click('.navigate-right');
        // 等待翻頁動畫
        await this.page.waitFor(1000);
    }
} while (canNext);
複製代碼
  1. 獲取到要打印的全部頁面 DOM 後,替換掉原來的頁面內容。由於 $evaluate 方法中不支持調用外部變量因此只能以傳參的方式使用。
this.page.evaluate(domStr => document.body.innerHTML = domStr, content);
複製代碼
  1. 調用生成 PDF 的 API
this.page.pdf({
    path: path.join(think.ROOT_PATH, 'runtime/xxx.pdf'),
    format: 'A4',
    landscape: true,
    printBackground: true //若是要顯示背景,此屬性要設置爲true
})
複製代碼
  1. 使用 nodemailer 發送郵件給用戶。這一步若是想使用本地的 SMTP 服務請用 nodemailer 的 2.7.5 的版本,此版本後這項功能被刪除了。
let transporter = nodemailer.createTransport({
    host: 'smtp.ym.163.com',
    port: 994,
    secure: true,
    auth: {
        user: 'xxx@xxx.com',
        pass: 'xxx'
    }
});
transporter.sendMail({
    from: 'xxx@xxx.com',
    to: 'xxx@xxx.com',,
    subject: '【聲享】xxx',
    attachments: [{
        filename: 'xxx.pdf',
        path: path.join(think.ROOT_PATH, 'runtime/xxx.pdf'),
        contentType: 'application/pdf'
    }]
})
複製代碼

開發中須要注意的問題

  1. 用戶登陸 使用 Puppeteer 打開頁面至關於你新啓動了一個瀏覽器實例,頁面中的 seession 和 cookie 是空的。而打印所用的頁面須要用到用戶信息,因此咱們登陸了一個超管賬號來執行打印操做。在 ThinkJS 中能夠經過中間件來實現這項功能。在訪問頁面的時候經過參數校驗判斷是不是打印而打開的頁面,若是是則登陸超管賬號。
// 打開指定頁面時經過校驗後面參數判斷是否以超管登陸
module.exports = options => {
    return async (ctx, next) => {
        const { token, ctime } = ctx.query;
        const md5Str = tockenGenerator();
        if (md5Str === token) {
            await ctx.session('userInfo', adminUser);
        }
        return next();
    };
};

複製代碼
  1. Puppeteer 啓動

若是服務端是運行在 root 權限下,在啓動 Puppeteer 時要添加 --no-sandbox 參數,不然 Chrome/Chromium 會啓動失敗。詳情見 Running as root without — no-sandbox is not supported。這個權限問題在linux以root用戶使用 Chrome 的時候一樣適用。

this.browser = await puppeteer.launch({args:['--no-sandbox']});


複製代碼
  1. iframe 沒法加載

聲享支持頁面內嵌入 iframe,在打印的時候碰到一個問題。若是同時在頁面上插入 iframe 過多,後面的 iframe 會直接卡住再也不加載。因此 iframe 最好分批插入或者一個一個插入,同時設定10秒來加載iframe。 若是想精確控制 iframe 也可使用 API 等待 iframe 徹底加載再執行後續操做。

for (let i = 0; i < pages.length; i++) {
    const page = pages[i];
    await this.page.$evaluate(content => {
        const divDom = document.createElement('div');
        divDom.innerHTML = content;
        document.body.appendChild(divDom.childNodes[0])
    }, page.domStr);
    if (page.iframe) await this.page.waitFor(10000 * page.iframe);
}

複製代碼
相關文章
相關標籤/搜索