likes
comments
collection
share

Puppeteer 前端生成海报新姿势

作者站长头像
站长
· 阅读数 17

原文参考我的公众号文章 前端生成海报新姿势 Puppeteer

以往都是怎么生成海报的?

在开发中经常会遇到「生成海报长图」的需求,一般都是这么做的:

  • 后端生成:引入会图库进行绘制
  • 前端生成:用原生canvas进行绘制、用一些js库(html-to-canvas)

这么用过来的体验就是:无论是谁生成,都会遇到海报上各个元素的定位困难、样式还原的困难、动态内容和动态海报尺寸不好把控等问题。

因此,经过一波探索,接触了puppeteer这个「高级货!」,用完之后,简直有种相见恨晚的感觉!

所以现在要想生成一个复杂的海报或者长图的流程变成了这样:

  • 编写海报承载web页面
  • 调用puppeteer截图服务,对web页面进行截图,并返回图片或者图片地址给调用者

先看看官方是怎么介绍的?

Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过 DevTools 协议控制 Chromium 或 Chrome。Puppeteer 默认以 headless 模式运行,但是可以通过修改配置文件运行“有头”模式。

你可以在浏览器中手动执行的绝大多数操作都可以使用 Puppeteer 来完成! 下面是一些示例:

  • 生成页面截图或PDF。
  • 抓取 SPA(单页应用)并生成预渲染内容(即“SSR”(服务器端渲染))。
  • 自动提交表单,进行 UI 测试,键盘输入等。
  • 创建一个时时更新的自动化测试环境。 使用最新的 JavaScript 和浏览器功能直接在最新版本的Chrome中执行测试。
  • 捕获网站的 timeline trace,用来帮助分析性能问题。
  • 测试浏览器扩展。

👍简直是「不明觉厉」!

主角登场

这篇文章的主角就是 page.screenshot,通过它,我们可以实现对目标网页(其实也就是承载海报内容的网页)的截图,然后开一个node接口服务,形成高可用的业务接口,将截图后的图片或者图片地址返回给调用者。

直接参考 screenshot文档 就能快速实现一个截图服务,下面直接上代码,然后再说一些期间遇到的小问题及解决思路。

编写截图核心 screenshotPuppeteer.js

const path = require("path");
const puppeteer = require("puppeteer");

function sleep(delay = 1000) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(1);
        }, delay);
    });
}

function loginfo(debug, info) {
    if (debug) {
        console.log(info);
    }
}

/**
 * 基于「puppeteer」的服务端截图工具
 * @param {*} options {...} 具体见下
   @param {*} options.debug: false 是否开启调试
   @param {*} options.pageUrl: "" 要截图的web地址
   @param {*} options.defaultViewport: {
      width: 390,
      height: 844,
      deviceScaleFactor: 3,
      isMobile: true,
    } 是否开启Viewport模拟器
    @param {*} options.headless: "" 是否有浏览器界面
    @param {*} options.fileName: "" 截图文件保存名称
    @param {*} options.fileSavePath: "datasource/poster/" 默认服务器代码写死
    @param {*} options.fileType: "jpeg" 截图保存格式 [jpeg, png, webm]
    @param {*} options.quality: 100 压缩率[0,100],fileType=jpeg时有效
    @param {*} options.fullPage: true 是否全屏,边滚动动边截图
    @param {*} options.clip: null 指定裁剪区域,fullPage为true时不可用,默认null,允许参数:{ x: 0, y: 0, width: 390, height: 844 }
    @param {Number} screenshotDelay: 500 页面load后,触发截图之前的【延迟时间】
    @param {*} options.closePage: true 截图完关闭页面(默认true关闭)
    @param {*} options.closeBrowser: true 截图完关闭浏览器(默认true关闭)
    @param {*} options.cb: null,
  };
 */
async function screenshotCore(config = {}) {
    let options = {
        debug: false,
        headless: true, //默认无头
        pageUrl: "", //要截图的web地址
        defaultViewport: {
            width: 390,
            height: 667,
            deviceScaleFactor: 3,
            isMobile: true,
        }, //是否开启Viewport模拟器
        fileName: "", //截图文件保存名称
        fileSavePath: "datasource/poster/", //默认服务器代码写死
        fileType: "jpeg", //截图保存格式 [jpeg, png, webm]
        quality: 100, //压缩率[0,100],fileType=jpeg时有效
        fullPage: true, //是否全屏,边滚动动边截图
        clip: null, //指定裁剪区域,fullPage为true时不可用,默认null,允许参数:{ x: 0, y: 0, width: 390, height: 844 }
        screenshotDelay: 500, //页面load后,触发截图之前的【延迟时间】
        closePage: true, //截图完关闭页面(默认true关闭)
        closeBrowser: true, //截图完关闭浏览器(默认true关闭)
        cb: null,

        ...config,
    };

    loginfo(
        options.debug,
        `screenshotCore options: ${JSON.stringify(options, " ", "\t")}`
    );

    let webpagePath = options.pageUrl || null;
    let saveType = options.fileType || "jpeg";
    let file = `${options.fileName}.${saveType}`;
    let relativePath = `${options.fileSavePath}${file}`;
    let savePath = path.join(__dirname, "..", relativePath);
    let saveQuality = options.quality || 100;
    let isFullPage = options.fullPage || true;
    let clipArea = options.clip || null;
    let browser = null;
    let page = null;

    const errHandler = (msg, err) => {
        return {
            error: 1,
            msg,
            errMsg: err.message || err.msg,
            err,
        };
    };

    const successHandler = (msg, data = {}) => {
        return {
            error: 0,
            msg,
            data,
        };
    };

    const closeAll = async() => {
        if (options.closePage) {
            await page.close();
        }
        if (options.closeBrowser) {
            await browser.close();
        }
    };

    return new Promise(async(resolve, reject) => {
        try {
            // 启动浏览器
            browser = await puppeteer.launch({
                args: ["--no-sandbox", "--disable-setuid-sandbox"], //如果报“No usable sandbox!”
                // slowMo: 100, //放慢浏览器执行速度,方便测试观察
                headless: options.headless, //是否为无界面访问浏览器
                defaultViewport: options.defaultViewport || null,
            });
        } catch (err) {
            closeAll();
            return reject(errHandler("POSTER应用启动失败", err.msg || err));
        }

        try {
            // 新建页面
            page = await browser.newPage();
            page.setDefaultNavigationTimeout(60000); //超时报错timeout时间,默认30s,0表示无限制
        } catch (err) {
            closeAll();
            return reject(errHandler("puppeteer打开新页面失败", err));
        }

        // page.on("load", async () => {
        //   loginfo(options.debug, "Page loaded!");
        //   await sleep(options.screenshotDelay);
        //   do screenshot ...
        // });

        // let reqNum = 0;
        // page.on("request", async (req) => {
        //   let method = req.method();
        //   let url = req.url();
        //   console.log("request:", ++reqNum);
        // });

        // let repNum = 0;
        // page.on("response", async (rep) => {
        //   let url = rep.url();
        //   let status = rep.status();
        //   console.log("response:", ++repNum);
        // });

        try {
            // 打开目标页面 { waitUntil: "networkidle0"} 表示当前页面500ms内无http请求,再返回
            await page.goto(webpagePath, { waitUntil: "networkidle0" });
        } catch (err) {
            closeAll();
            return reject(errHandler("puppeteer打开目标网页失败", err));
        }

        try {
            // 开始截图
            await sleep(options.screenshotDelay);
            await page.screenshot({
                path: savePath, //在服务器上的存储位置
                type: saveType,
                quality: saveQuality,
                fullPage: isFullPage,
                clip: clipArea,
            });

            loginfo(options.debug, `截图文件存储在了: ${savePath}`);

            let retData = {
                file,
                type: saveType,
                quality: saveQuality,
            };
            resolve(successHandler("海报生成成功", retData));
            options.cb && options.cb(page);
        } catch (err) {
            reject(errHandler("puppeteer截图失败", err));
        }

        closeAll();
    });
}

module.exports = {
    screenshotCore,
};

调用示例

const { screenshotCore } = require("./screenshotPuppeteer");
screenshotCore({
        pageUrl: "www.baidu.com",
        fileName: `capture_${+new Date()}`
    })
    .then((res) => {
        console.log("success:", res);
    })
    .catch((err) => {
        console.log("failed:", err);
    });

总结一些遇到的问题

puppeteer.launch启动报错

比如在本地开发没问题,部署到Linux服务器之后,遇到No usable sandbox!...setuid... ,需要在启动配置中增加 args: ["--no-sandbox","--disable-setuid-sandbox"]

// 启动浏览器
browser = await puppeteer.launch({
    args: ["--no-sandbox", "--disable-setuid-sandbox"],
    // slowMo: 100, //放慢浏览器执行速度,方便测试观察
});
截图出现中文乱码?

这是由于服务器上没有中文字体的缘故,只需在Linux服务器的 usr/local/share/fonts 目录下添加中文字体包即可。如果要完美还原目标页面的字体样式,还需要安装对应的字体文件。

截图不够清晰?

可以设置 page.setViewport(viewport),其中viewportdeviceScaleFactor代表着定义设备缩放, (类似于 dpr)。 默认 1。我们可以根据想要的效果设置为2或者3(这是根据iPhone的屏幕dpr来的),当然这个值越高,图片体积也就回越大,所以要做好权衡。一般最多设置为3。

截图内容空白或不完整?

有时候发现,虽然已经是在page实例的page.on('load')里才开始截图,但是依然会出现空白内容。这是因为大部分情况,我们的海报页面并不是纯静态的,也会有接口请求,然后再渲染一些动态内容。因此,需要做到「在接口请求结束,且短暂延迟后」再触发截图操作。可以这么做:

// networkidle0表示当前页面500ms内无http请求,再返回
await page.goto(webpagePath, { waitUntil: "networkidle0" });
针对某个dom进行截图
 //对页面某个元素截图
let element = await page.$x('#target_dom');
await element.screenshot({
    ...
});