puppeteer生成PDF实践

为了解决html2canvas再生成PDF过程中遇到的自动分页、生成的PDF样式不一致等问题,这里学习使puppeteer来生成PDF。

项目初始化

首先我们创建一个nodejs项目,结构如下:

markdown 复制代码
puppeteer-report-server/
├── .dockerignore
├── Dockerfile
├── export.js
├── server.js	
└── package.json

项目依赖

json 复制代码
{
  "name": "puppeteer-report-server",
  "type": "module",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^4.21.0",
    "puppeteer": "^24.26.1"
  }
}

express服务

javascript 复制代码
//server.js
import express from "express";
import path from "path";
import { fileURLToPath } from "url";
import { generatePDF } from "./export.js";
import fs from "fs";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();

// 静态文件目录
app.use(express.static(path.join(__dirname, "public")));

// 首页(报告页面)
app.get("/", (req, res) => {
  res.sendFile(path.join(__dirname, "public/index.html"));
});

app.get("/export-pdf", async (req, res) => {
  const rawUrl = req.query.url;
  if (!rawUrl) {
    return res.status(400).send("缺少 url 参数,例如: /export-pdf?url=https%3A%2F%2Fexample.com");
  }

  const url = decodeURIComponent(String(rawUrl));
  if (!/^https?:\/\//i.test(url)) {
    return res.status(400).send("url 必须为以 http:// 或 https:// 开头的完整地址");
  }

  const reportPath = path.join(__dirname, `report-${Date.now()}.pdf`);

  try {
    await generatePDF(url, reportPath);
  } catch (err) {
    res.status(500).send("网页生成 PDF 失败,失败原因:" + err.toString());
  }

  try {
    res.download(reportPath, "report.pdf", (err) => {
      if (!err && fs.existsSync(reportPath)) fs.unlinkSync(reportPath); // 下载后删除临时文件
    });
  } catch (error) {
    if (fs.existsSync(reportPath)) fs.unlinkSync(reportPath);
    console.error("导出 PDF 失败:", err);
    res.status(500).send("导出 PDF 失败");
  }
});

app.listen(3001, () => {
  console.log("🚀 服务器已启动:http://localhost:3001");
  console.log("📄 导出 PDF 接口:http://localhost:3001/export-pdf");
});

puppeteer导出pdf逻辑

javascript 复制代码
//export.js
import puppeteer from "puppeteer";

export async function generatePDF(url, outputPath = "report.pdf") {
  // 动态获取 Chrome 路径 类似 /home/pptruser/.cache/puppeteer/chrome/linux-xxx/chrome-linux64/chrome
  const chromePath = puppeteer.executablePath();

  const browser = await puppeteer.launch({
    // executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
    // executablePath: '/app/puppeteer/chrome/linux-131.0.6778.204/chrome-linux64/chrome'  //linux docker镜像中使用
    headless: true,
    args: ["--no-sandbox", "--disable-setuid-sandbox"],
    executablePath: chromePath, // 直接使用动态获取的路径
  });
  //A4:  595px × 842px
  const page = await browser.newPage();
  await page.setViewport({
    width: 1190,   // 扩宽纸张代替 scale
    height: 1684,
  });
  try {
    await page.goto(url, { waitUntil: "networkidle0", timeout: 3000 });
  } catch (error) {
    console.error("导航到页面失败", error);
    throw new Error("导航到页面失败");
  }

  // 等待自定义条件满足(例如:数据加载状态为"complete")
  try {
    await page.waitForFunction(() => {
    // 这里是在浏览器环境中执行的代码
    return window.pdfReady === true; // 假设页面定义了全局变量标记状态
  }, { timeout: 5000 });
  } catch (error) {
    console.error("pdfReady标志获取失败", error);
  }

  await page.pdf({
    path: outputPath,
    // format: "A4",
    // scale: 0.8,
    width: 1190,   // 扩宽纸张代替 scale
    height: 1684,
    printBackground: true,
    displayHeaderFooter: true,
    // headerTemplate: `
    //   <div style="width:100%; font-size:10px; text-align:center; padding:5px; border-bottom:1px solid #ccc;">
    //     这是页眉 - 2025
    //   </div>`,
    // footerTemplate: `
    //   <div style="width:100%; font-size:10px; text-align:center; padding:5px; border-top:1px solid #ccc;">
    //     第 <span class="pageNumber"></span> / <span class="totalPages"></span> 页
    //   </div>`,
    margin: {
      // top: '12mm',
      // bottom: '12mm',
      top: '0mm',
      bottom: '0mm',
      left: '0mm',
      right: '0mm',
    },
  });

  await browser.close();
  console.log("✅ PDF 已生成:" + outputPath);
}

Dockerfile

javascript 复制代码
# 使用官方 Puppeteer 镜像(内含 Node.js + Chromium + 所有系统依赖)
FROM ghcr.io/puppeteer/puppeteer:latest

# 设置工作目录
WORKDIR /app

# 切换为 root 用户以便安装依赖
USER root

# 设置 pnpm 淘宝镜像并安装 pnpm
RUN npm install -g pnpm \
    && pnpm config set registry https://registry.npmmirror.com/ \
    && pnpm config set fetch-retries 5 \
    && pnpm config set fetch-retry-factor 2 \
    && pnpm config set fetch-timeout 60000

# 复制依赖文件并安装生产依赖(利用缓存)
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod --no-optional

# 复制项目源码(并设置权限)
COPY --chown=pptruser:pptruser . . 

# 将 /app 目录的所有权更改为 pptruser 便于读写pdf
RUN chown -R pptruser:pptruser /app

# 切回 Puppeteer 默认用户
USER pptruser

# 暴露端口
EXPOSE 3001

# 启动应用
CMD ["node", "server.js"]

.dockerignore

javascript 复制代码
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
.env
puppeteer

项目部署

一般情况下,windows中只要executablePath路径设置的没问题,就可以正常导出PDF。

部署到Linux的Docker容器中时,问题比较多。

首先是官方镜像ghcr.io/puppeteer/puppeteer:latest下载很慢,这个视网络情况而定,我这边下载花了很久。

其次是启动后puppeteer容易报找不到chrome路径,我的方案是将Linux已安装的chrome复制到docker容器中。

首先到项目目录下执行:

shell 复制代码
npx puppeteer browsers install chrome

如果已经安装会输出:

shell 复制代码
chrome@131.0.6778.204 /root/.cache/puppeteer/chrome/linux-131.0.6778.204/chrome-linux64/chrome

~~/root/.cache/puppeteer~~目录整个复制到项目目录下,后续我们使用该~~puppeteer~~目录作为运行chrome的~~executablePath~~地址。

上面的问题已经解决,使用puppeteer.executablePath()获取动态路径,类似/home/pptruser/.cache/puppeteer/chrome/linux-xxx/chrome-linux64/chrome需要注意的是这里的chrome版本号是否一致

Docker命令

shell 复制代码
## docker打包

docker build -t my-puppeteer-app .

## docker运行

docker run -d -p 3001:3001 my-puppeteer-app

## 进入docker容器

docker exec -it [容器ID] /bin/bash

## docker查看容器日志

docker stop -f [容器ID]

可能的问题

找不到chrome

Error: Could not find Chrome (ver. 131.0.6778.204). This can occur if either

  1. you did not perform an installation before running the script (e.g. npx puppeteer browsers install chrome) or

  2. your cache path is incorrectly configured (which is: C:\Users\GL.cache\puppeteer)

遇到这个错误,是因为 Puppeteer 没有找到指定版本的 Chrome 浏览器。

Windows、Linux物理机、Docker容器中Chrome路径可能都是不一样的,需要看情况处理。

我这边的方案是Windows用本机Chrome,Linux和Docker容器用项目文件夹下的chrome,直接把能用的chrome复制到项目文件夹中。

javascript 复制代码
const browser = await puppeteer.launch({
  // executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',  //windows 中使用本地安装的chrome
  executablePath: '/app/puppeteer/chrome/linux-131.0.6778.204/chrome-linux64/chrome'  //linux docker镜像中使用
});

首先可以确定的是,如果用上面的dockerfile生成的容器,在/home/pptruser/.cache/puppeteer/chrome/linux-xxx/chrome-linux64/chrome是能找到chrome,现在需要解决的是如何指定到该路径。

在 Docker 环境中,若希望动态获取 Puppeteer 自动安装的 Chrome 路径(避免写死版本号),可以利用 Puppeteer 内置的 API 或文件系统路径规则来动态拼接路径,具体方法如下:

Puppeteer 提供了 puppeteer.executablePath() 方法,会自动返回当前环境中 Puppeteer 对应的浏览器可执行文件路径(无论版本如何变化,都会指向正确路径),无需手动拼接版本号。

javascript 复制代码
// 动态获取 Chrome 路径
const chromePath = puppeteer.executablePath();
console.log('Chrome 路径:', chromePath); // 会输出类似 /home/pptruser/.cache/puppeteer/chrome/linux-xxx/chrome-linux64/chrome

const browser = await puppeteer.launch({
  executablePath: chromePath, // 直接使用动态获取的路径
});

需要注意的是官方镜像 把chrome下载到/home/pptruser/.cache/puppeteer/chrome/linux-xxx/chrome-linux64/chrome,用ls命令是看不到.cache目录的。另外用户要切换pptruser,否则就会去找/root/.cache,这样也是找不到的。

找不到NSS_3.31

/root/.cache/puppeteer/chrome/linux-131.0.6778.204/chrome-linux64/chrome: /lib64/libnss3.so: version `NSS_3.31' not found (required by /root/.cache/puppeteer/chrome/linux-131.0.6778.204/chrome-linux64/chrome)

这个错误是由于系统中安装的 libnss3 库版本过低,无法满足 Chrome 131 版本的需求(需要 NSS_3.31 及以上版本)。这是我在Linux物理机中运行报的错,我没有解决,直接使用docker容器了。

Failed to launch the browser process

Failed to launch the browser process! spawn /app/puppeteer/chrome/linux-131.0.6778.204/chrome-linux64/chrome EACCES TROUBLESHOOTING: https://pptr.dev/troubleshooting

这个错误说明 Puppeteer 在启动 Chrome 可执行文件时没有执行权限(EACCES)

需要给/app/puppeteer文件夹提升权限:

dockerfile 复制代码
# dockerfile
# 解压压缩包到当前工作目录(/app),并删除原压缩包以减小镜像体积
RUN unzip puppeteer.zip -d /app \
    && chmod -R 755 /app/puppeteer \
    && rm -f puppeteer.zip

可优化点

chrome优化(已优化)

需要解决找不到chrome的问题,减小镜像大小。

ghcr.io/puppeteer/puppeteer:latest优化

可以将已下载的官方镜像,上传到docker私有仓库,加快镜像生产速度。

相关推荐
冲刺逆向5 小时前
【js逆向案例二】瑞数6 深圳大学某医院
前端·javascript·vue.js
啃火龙果的兔子5 小时前
Promise.all和Promise.race的区别
前端
马达加斯加D5 小时前
Web身份认证 --- OAuth授权机制
前端
2401_837088505 小时前
Error:Failed to load resource: the server responded with a status of 401 ()
开发语言·前端·javascript
全栈师5 小时前
LigerUI下frm与grid的交互
java·前端·数据库
叫我詹躲躲5 小时前
被前端存储坑到崩溃?IndexedDB 高效用法帮你少走 90% 弯路
前端·indexeddb
无尽夏_5 小时前
CSS3(前端基础)
前端·css·1024程序员节
温宇飞5 小时前
Next.js 简述 - React 全栈框架
前端
百花~5 小时前
前端三剑客之一 CSS~
前端·css