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私有仓库,加快镜像生产速度。

相关推荐
yanyu-yaya6 分钟前
速学兼复习之vue3章节3
前端·javascript·vue.js·学习·前端框架
web小白成长日记9 分钟前
前端向架构突围系列模块化 [4 - 1]:思想-超越文件拆分的边界思维
前端·架构
tkevinjd10 分钟前
3-Vue&Ajax
前端·vue.js·ajax
林恒smileZAZ14 分钟前
前端拖拽,看似简单,其实处处是坑
前端·javascript·vue.js
Filotimo_24 分钟前
那在HTML中,action是什么
前端·okhttp·html
跟着珅聪学java29 分钟前
JavaScript中编写new Vue()实例的完整教程(Vue 2.x)
前端·javascript·vue.js
Pu_Nine_933 分钟前
Vue Router 企业级配置全攻略:打造专业级路由系统
前端·vue.js·typescript·vue-router·路由配置
Marshmallowc34 分钟前
React 合成事件失效?深度解析 stopPropagation 阻止冒泡无效的原因与 React 17+ 事件委派机制
前端·javascript·react.js·面试·合成事件
遗憾随她而去.1 小时前
前端浏览器缓存深度解析:从原理到实战
前端
万行2 小时前
企业级前后端认证方式
前端·windows