返回博客
指南
Mihnea-Octavian ManolacheLast updated on May 2, 20267 min read

Puppeteer 下载文件:适用于 Node.js 的 4 种方法

Puppeteer 下载文件:适用于 Node.js 的 4 种方法
简而言之:Puppeteer 的文件下载工作流主要有四种有效方案:点击按钮并让 Chrome 将文件写入你控制的文件夹;在页面内部运行 fetch() ,并将 base64 编码数据通过管道传回 Node,利用 Chrome DevTools Protocol 处理下载进度事件,或者跳过浏览器,直接使用从 Puppeteer 会话中获取的 Cookie 通过 Axios 拉取 URL。选择时需考虑文件大小、身份验证以及网站提供链接的方式。

引言

如果你曾尝试针对真实的生产环境网站编写 Puppeteer 文件下载流程脚本,你一定经历过那个关键时刻:脚本点击了下载按钮,无头 Chrome 实例报告成功,但磁盘却依然空空如也。这是因为 Chromium 在无头模式下默认会阻止自动化下载,而解决方法并不在 Puppeteer 的高级 API 中。它位于更底层,即 Chrome DevTools 协议中。

本指南面向中级 Node.js 开发者、质量保证工程师以及数据抓取从业者。这些读者已掌握启动浏览器、导航页面和选择元素的基本技能,现在需要获取实际的文件数据。我们将逐步讲解四种限定方法,每种方法都附有完整的代码,并会坦诚说明哪种方法适用于何种情况。

您会发现各处都复用了相同的基线框架:使用 fs.mkdirSync创建的下载文件夹、真实的 User-Agent、桌面视口,以及等待文件实际写入磁盘(而非仍在写入中)的检测逻辑。最终,您将掌握适用于点击触发下载、受身份验证限制的下载、大型二进制数据以及已知 URL 的 Puppeteer 文件下载方案,同时获得选择方案的决策指南和生产环境加固检查清单。

为何使用 Puppeteer 下载文件比看起来更棘手

当你在 page.click() 在带头部的 Chrome 中点击“下载 CSV”按钮时,文件会存入你的“下载”文件夹,而你则继续处理其他事务。若使用 headless: 'new' 则毫无反应。点击事件触发,网络请求发出,但文件系统却空空如也。这并非 Puppeteer 的 bug。Chromium 会刻意将自动化下载视为可疑行为,而解决之道在于 Chrome DevTools 协议,而非 Puppeteer 的表面 API。除非你手动开启该功能,否则任何 Puppeteer 文件下载流程都不会在磁盘上留下哪怕一个字节。

处理此问题并无唯一最佳方案。正确的方法取决于网站如何提供文件、其认证机制的严格程度、有效负载的大小,以及你对可靠性的需求。以下四种模式几乎涵盖了所有情况:

  1. 点击加 setDownloadBehavior通过 CDP 配置浏览器的下载目录,点击按钮,并轮询下载完成状态。最适合下载由 JavaScript 触发,且您无法获取或不愿追踪底层 URL 的情况。
  2. 页面内 fetch() 配合 base64。在页面内部运行 fetch() 内部 page.evaluate(),对响应进行编码,并以 base64 格式回传至 Node。最适用于单页应用(SPAs)、blob URL 以及受仅存在于浏览器上下文中的 Cookie 限制的下载。
  3. 纯 CDP 配合下载事件。打开一个 CDP 会话,调用 Browser.setDownloadBehavior,并监听 Browser.downloadWillBeginBrowser.downloadProgress。最适合需要实时进度、GUID 到文件名的映射,或精细错误检测的情况。
  4. 将 URL 传递给 Axios 或 https使用 Puppeteer 渲染页面并提取真实文件 URL,随后利用从 Puppeteer 会话中获取的 Cookie 和请求头,通过 Node.js 进行下载。最适合处理大文件、并行任务,以及任何浏览器会造成干扰的情况。

本指南的其余部分将按每种方法分章节介绍,并附有决策指南、安全加固检查清单,以及结尾处的 Puppeteer 与 Playwright 对比分析。

先决条件与项目设置

在深入探讨具体方法之前,我们需要一个供这四种方法共用的项目。此处的框架设计刻意保持简单:一个文件夹、一个 package.json、一个下载目录,以及一个 launch.js 文件,该文件将在每个示例中重复使用。保持测试框架的一致性,既能让你在不修改其余代码的情况下自由切换方法,也能在并排比较时清晰展现各方法之间的差异。

本文撰稿时,配置说明针对 Node.js 20 或更高版本;若您锁定的是旧版运行时,请查阅当前 Puppeteer 发布说明,因为 Puppeteer 每次重大版本发布都会调整其支持的最低 Node.js 版本。

安装 Puppeteer、Node.js 基础知识及文件夹结构

创建项目、初始化 npm 并安装 Puppeteer:

mkdir puppeteer-downloads
cd puppeteer-downloads
npm init -y
npm install puppeteer

打开 package.json 并添加 "type": "module" 以便在示例中使用 import 语法。顺便添加一些开发便利工具:

{
  "type": "module",
  "scripts": {
    "method1": "node method1.js",
    "method2": "node method2.js",
    "method3": "node method3.js",
    "method4": "node method4.js"
  }
}

Puppeteer 自带用于测试的 Chrome 浏览器,并在大多数平台安装时自动下载,这足以满足本指南中的所有需求。如果您在精简版容器中运行,请查阅您锁定的 Puppeteer 版本的发布说明以确认安装行为,因为随附的 Chrome 浏览器行为在不同版本中有所变化。

文件夹结构:

puppeteer-downloads/
  downloads/        # files end up here
  launch.js         # shared harness
  method1.js
  method2.js
  method3.js
  method4.js

现在创建 downloads/ 文件夹(mkdir downloads),或让启动脚本在首次运行时自动创建。

包含下载路径、User-Agent 和视口设置的基准启动脚本

本指南中的每种方法都基于相同的框架。将此内容放入 launch.js:

// launch.js
import puppeteer from 'puppeteer';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

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

export const DOWNLOAD_DIR = path.resolve(__dirname, 'downloads');

export async function launchBrowser({ headless = 'new' } = {}) {
  // setDownloadBehavior requires an absolute path. Relative paths silently fail.
  if (!fs.existsSync(DOWNLOAD_DIR)) {
    fs.mkdirSync(DOWNLOAD_DIR, { recursive: true });
  }

  const browser = await puppeteer.launch({
    headless,
    args: [
      '--no-sandbox',
      '--disable-dev-shm-usage',
      '--disable-blink-features=AutomationControlled',
    ],
  });

  return browser;
}

export async function newPage(browser) {
  const page = await browser.newPage();

  // Realistic desktop fingerprint. Some sites hide download buttons on mobile.
  await page.setUserAgent(
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
    '(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
  );
  await page.setViewport({ width: 1366, height: 900 });

  return page;
}

有三点需要注意。首先, setDownloadBehavior 需要绝对路径;若传入相对路径,Chrome 会静默忽略且不写入任何内容。其次,我们强制使用桌面版 User-Agent 和视口,因为某些网站会将下载链接隐藏在移动端布局中,而没有 User-Agent 的自动化客户端通常会被分配一个 Chrome 视为不可信的 User-Agent。第三,我们使用 headless: 'new' 而非 headless: 'shell'。下载行为在 shell 模式下有所不同,特别是涉及浏览器管理的下载时,因此我们坚持使用默认设置。

你可以切换 headless 切换至 false 进行调试。在真实的 Chrome 浏览器中观察点击事件,通常是诊断 Puppeteer 文件下载流程为何无声失败的最快方法。一旦在带头模式下能正常工作而在无头模式下无法工作,你就知道问题出在下载策略上,而不是你的选择器。

在将此测试框架广泛复用之前,有两处小改动值得添加。首先,设置默认导航超时: page.setDefaultNavigationTimeout(60_000) 在缓存清空时能避免大量不稳定的 CI 运行。其次,安装一个基础的 consolepageerror 监听器,这样下载点击过程中发生的任何页面内错误都会显示在 Node 日志中,而不是被浏览器吞没。这两项都是单行代码,当部署在凌晨 2 点首次失败时,它们的价值就会显现出来。

此外,若您需要本文所假设的更全面的导航、选择器和等待模式,这里也是一个自然的地方,可以链接到更深入的 Puppeteer 抓取指南。

方法 1:点击下载按钮并等待文件

方法 1 最接近“人类会怎么做”。导航至页面,点击下载按钮,让 Chrome 将文件写入你选择的文件夹。关键在于,无头 Chrome 默认不会写入任何位置;你必须通过 Chrome DevTools Protocol 调用,明确告知它允许下载的位置以及文件应保存的目录。一旦配置完成,剩下的工作就是检测文件何时真正下载完成,因为 page.click() 返回结果的时间远早于数据写入磁盘。

在以下情况下,此方法是正确的选择:

  • 下载是由 JavaScript 触发的,而非普通 <a href> 链接,因此无法轻易提取 URL。
  • 您不需要实时进度(只需知道“是否已完成”)。
  • 文件足够小,磁盘缓冲即可满足需求(通常小于几百 MB)。

以下情况不适用此方法:

  • 网站需要复杂的身份验证和 Cookie,且这些信息仅在多次 SPA 交互后才会生成(方法 2 更简洁)。
  • 您需要进度事件或中断检测(请采用方法 3)。
  • 文件体积庞大,且您希望直接流式传输至 S3 或其他接收端(请采用方法 4)。

下面我们将设置下载文件夹,点击按钮,并使用 .crdownload sentinel 和稳定的文件大小检查来轮询完成状态,因此绝不会将部分写入的文件返回为已完成文件。

使用 setDownloadBehavior 配置下载文件夹

在实际应用中,你会看到两种 CDP 调用。旧版调用是 Page.setDownloadBehavior,其作用范围仅限于单个页面:

const client = await page.target().createCDPSession();
await client.send('Page.setDownloadBehavior', {
  behavior: 'allow',
  downloadPath: DOWNLOAD_DIR, // absolute path
});

此方法在许多配置中仍可正常工作,但已被官方废弃,且最新版本的 Chrome 已开始通过浏览器级别的 CDP 目标路由下载。此时,您的 Page.setDownloadBehavior 调用会返回成功,但文件仍会保存到 ~/Downloads (或根本无处可存),因为页面会话已不再负责下载操作。如果您曾花了一个下午盯着一个“原本正常”的脚本,却发现它在 Chrome 自动更新后突然停止写入文件,这通常就是原因所在。

向后兼容的调用是 Browser.setDownloadBehavior,作用域限定在浏览器层面:

const session = await browser.target().createCDPSession();
await session.send('Browser.setDownloadBehavior', {
  behavior: 'allow',
  downloadPath: DOWNLOAD_DIR,
  eventsEnabled: true, // required for Method 3 progress events
});

Browser.setDownloadBehavior 适用于浏览器中的所有页面,而不仅仅是你打开会话的那个页面,这正是多标签页下载工作流所需要的。它还允许你通过 eventsEnabled: true,方法 3 将大量使用该方法。Chrome DevTools 团队对这两个调用都进行了文档记录,当 Chrome 不同版本的行为发生变化时,Chrome DevTools 协议参考文档是权威来源。

实用建议:对于新代码,请优先使用 Browser.setDownloadBehavior 。仅将 Page.setDownloadBehavior 仅作为无法更新的极旧版 Chrome 的备用方案。并且务必传入绝对路径;相对路径不仅风险高,还会无声失败。

触发点击事件并轮询下载完成状态

调用 await page.click(selector) 会在点击事件触发时立即返回,这比数据实际写入的时间要早得多。要准确判断下载何时真正完成,我们需要一个辅助函数来监视下载文件夹并忽略 Chrome 的临时文件。Chrome 会在 something.pdf.crdownload ,待数据写入完成后再将文件重命名为最终名称。我们的辅助函数会同时等待重命名操作和文件大小稳定的时间窗口,以此防范低速网络连接或异常文件系统导致的文件不完整问题。

// waitForRealFile.js
import fs from 'fs/promises';
import path from 'path';

export async function waitForRealFile(dir, knownBefore, {
  timeoutMs = 90_000,
  stableChecks = 3,
  intervalMs = 250,
} = {}) {
  const deadline = Date.now() + timeoutMs;
  let lastSize = -1;
  let stable = 0;
  let candidate = null;

  while (Date.now() < deadline) {
    const entries = await fs.readdir(dir);
    const fresh = entries.filter(
      (n) => !knownBefore.has(n) && !n.endsWith('.crdownload'),
    );

    if (fresh.length) {
      candidate = path.join(dir, fresh[0]);
      const { size } = await fs.stat(candidate);
      if (size === lastSize && size > 0) {
        if (++stable >= stableChecks) return candidate;
      } else {
        stable = 0;
        lastSize = size;
      }
    }
    await new Promise((r) => setTimeout(r, intervalMs));
  }

  throw new Error(`Download did not finish within ${timeoutMs}ms`);
}

对于数十兆字节范围内的文件,默认的 90 秒超时、三次稳定大小检查以及 250 毫秒轮询间隔是一个合理的起点。对于更大的下载任务,请延长超时时间;对于希望快速失败的快速端点,则应缩短超时时间。

调用方的流程如下:

const before = new Set(await fs.readdir(DOWNLOAD_DIR));
await page.click('[data-testid="download-button"]');
const finalPath = await waitForRealFile(DOWNLOAD_DIR, before);
console.log('Downloaded:', finalPath);

关于完整性的说明: waitForRealFile 仅为启发式机制。在极少数情况下(尤其是在网络文件系统上),Chrome 可能在文件完全写入前就对其进行重命名。若需更强的保障,请将此辅助函数与方法 3 中的 CDP Browser.downloadProgress 事件结合使用,其中 state: 'completed' 信号更具权威性(尽管如后文所述,仍非绝对)。

方法 1 的完整脚本及常见故障模式

method1.js:

// method1.js
import fs from 'fs/promises';
import { launchBrowser, newPage, DOWNLOAD_DIR } from './launch.js';
import { waitForRealFile } from './waitForRealFile.js';

const TARGET_URL = 'https://example.com/reports';
const DOWNLOAD_SELECTOR = '[data-testid="download-report"]';

(async () => {
  const browser = await launchBrowser();
  const page = await newPage(browser);

  const session = await browser.target().createCDPSession();
  await session.send('Browser.setDownloadBehavior', {
    behavior: 'allow',
    downloadPath: DOWNLOAD_DIR,
    eventsEnabled: false,
  });

  await page.goto(TARGET_URL, { waitUntil: 'networkidle2' });

  const before = new Set(await fs.readdir(DOWNLOAD_DIR));
  await page.click(DOWNLOAD_SELECTOR);
  const finalPath = await waitForRealFile(DOWNLOAD_DIR, before);

  console.log('Saved to:', finalPath);
  await browser.close();
})();

该脚本做对了以下几点,而大多数教程却忽略了:

  • 它使用了 networkidle2 ,因此下载按钮在点击前已存在于 DOM 中并完成绑定。若点击过早,会在处理该事件的 JavaScript 加载前触发点击。
  • 它在点击前对目录进行快照,因此前次运行留下的文件不会被报告为新下载。
  • 它会显式关闭浏览器;否则 Node 进程可能会因 Chrome 仍处于打开状态而卡死。

常见故障及排查方法:

  • 完全无法下载。请确认 Browser.setDownloadBehavior 是否在跳转前已运行,并且 downloadPath 是绝对路径。相对路径是导致无声失败的最常见原因。
  • 选择器点击后无反应。所谓的“下载”可能只是跳转而非实际下载。请在监视模式下观察页面;如果 URL 发生变化而非触发保存对话框,请切换至方法 2 或方法 4 直接抓取数据。
  • 下载在 .crdownload处卡住。可能是服务器挂起、超时设置过短,或是页面在下载完成前关闭。请增加 timeoutMs 并确保不要调用 browser.close() 直到 waitForRealFile 解析完成前。
  • 无头模式在本地运行正常,但在 CI 环境中却不行。容器版 Chrome 有时在下载路径上没有写入权限,或者采用了更严格的沙箱策略。请预先创建该文件夹,并仅在理解其安全影响后才传递 --no-sandbox 参数,

还有一个容易被忽略的失败原因:方法 1 的脚本首次运行成功,但第二次运行失败,因为前一次运行在文件夹中留下了 report.pdf.crdownload ,导致新点击操作被阻塞,或者文件被重命名为 report (1).pdf。请在每次运行开始时清空 *.crdownload 以及任何残留的输出文件,确保在每次点击前目录快照保持干净。 beforewaitForRealFile 仅能防范快照生成时已存在的文件,无法应对 Chrome 生成的、具有您未预料到的去重文件名的文件。

方法 2:在页面内获取文件并将其传递给 Node.js

只要 Chrome 愿意为你驱动下载,方法 1 就能奏效。但有些网站并不那么“客气”。它们会在 JavaScript 中生成文件 URL,将其隐藏在仅在多步骤 SPA 登录后才存在的 Cookie 之后,或者给你一个 blob: Chrome 自身生成的 URL,而外部 HTTP 客户端无法解析该 URL。在所有这些情况下,唯一能获取文件的地方就是页面本身,因为页面已经拥有正确的会话。

方法 2 运行 fetch()page.evaluate(),在浏览器内部读取响应正文,并通过 Puppeteer 的序列化层将字节流回传给 Node。由于 page.evaluate() 只能返回可序列化为 JSON 的值,二进制数据必须进行编码,而通用的解决方案是 base64。Node 对其进行解码,将 Buffer 写入磁盘,您便获得了文件。

此方法特别适用于:

  • 需要身份验证的单页应用(SPAs),因为在页面内部“借用”cookie和请求头比收集并重放要容易得多。
  • 通过 blob URL、object URL 或内存生成提供的文件(JavaScript 生成的 PDF 报告就是经典示例)。
  • 支持 CORS 的端点,且页面本身被允许下载文件。

该方法不适用于:

  • 超大文件,因为 base64 会使有效载荷膨胀约 33%,且通过 V8 进行往返传输会消耗大量 CPU 和内存。
  • 非CORS端点(页面不被允许获取,浏览器规则依然适用)。

下面我们将首先介绍适用于中小型文件的方案,随后介绍一种分块变体,该方案可在不导致 Node 进程崩溃的情况下处理数百 MB 的文件。

使用 page.evaluate 配合 fetch 将响应读取为 Blob

page.evaluate(), fetch() 的行为与普通浏览器 fetch 完全一致。它包含同源请求的 Cookie、支持重定向,并遵守 CORS 规则。这正是其强大的地方:如果页面能访问该文件,你的脚本也能访问。

const base64 = await page.evaluate(async (fileUrl) => {
  const res = await fetch(fileUrl, { credentials: 'include' });
  if (!res.ok) {
    throw new Error(`Fetch failed: ${res.status} ${res.statusText}`);
  }
  const buf = await res.arrayBuffer();

  // Convert ArrayBuffer to base64 inside the browser.
  let binary = '';
  const bytes = new Uint8Array(buf);
  const chunkSize = 0x8000; // 32 KB stride to avoid stack issues
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode.apply(
      null,
      bytes.subarray(i, i + chunkSize),
    );
  }
  return btoa(binary);
}, fileUrl);

有两个值得了解的实现细节。首先, String.fromCharCode.apply(null, bigArray) 如果一次性传递数十兆字节的数据,会导致调用栈溢出,这就是为什么我们在调用 btoa之前,我们以 32 KB 为单位遍历缓冲区。其次, credentials: 'include' 正是这一机制构成了“Puppeteer 获取下载”模式的核心;若缺少它,会丢失会话 Cookie,导致请求失去认证。

对于在单页应用(SPA)中动态构建 URL 的 Puppeteer 下载 PDF 用例,你可以采用相同的模式:从按钮的 data- 属性或 JS 回调中提取 URL,将其传递给 page.evaluate(),并让页面执行 fetch 操作。返回的字节流仅是原始数据;源格式对 Node 而言并不重要。

如果 fetch() 因 CORS 错误失败,说明浏览器告知你该页面无权读取响应正文。你有两种选择:切换到方法 1 并让 Chrome 驱动下载(CORS 不适用于导航或下载),或者切换到方法 4 并从 Node 重放请求,因为那里不适用同源策略。

将 base64 数据返回给 Node 并写入磁盘

一旦 base64 返回 Node,剩下的就简单了。 Buffer.from(base64, 'base64') 解码它, fs.writeFile 将其写入磁盘, Buffer.byteLength 并允许您根据 Content-Length

import fs from 'fs/promises';
import path from 'path';
import { launchBrowser, newPage, DOWNLOAD_DIR } from './launch.js';

const TARGET_URL = 'https://example.com/report-page';
const FILE_URL_SELECTOR = 'a#download-link';

(async () => {
  const browser = await launchBrowser();
  const page = await newPage(browser);

  await page.goto(TARGET_URL, { waitUntil: 'networkidle2' });
  const fileUrl = await page.$eval(FILE_URL_SELECTOR, (a) => a.href);

  const base64 = await page.evaluate(async (url) => {
    const res = await fetch(url, { credentials: 'include' });
    const buf = await res.arrayBuffer();
    let binary = '';
    const bytes = new Uint8Array(buf);
    for (let i = 0; i < bytes.length; i += 0x8000) {
      binary += String.fromCharCode.apply(
        null,
        bytes.subarray(i, i + 0x8000),
      );
    }
    return btoa(binary);
  }, fileUrl);

  const buffer = Buffer.from(base64, 'base64');
  console.log('Bytes from page.evaluate:', buffer.byteLength);

  const outPath = path.join(DOWNLOAD_DIR, 'report.pdf');
  await fs.writeFile(outPath, buffer);

  console.log('Saved to:', outPath);
  await browser.close();
})();

在实际处理一个小PDF文件时,该脚本会输出类似以下内容 Bytes from page.evaluate: 3672808 随后仅用一次 fs.writeFile。字节计数是一个有用的预警指标:如果你预期是 5 MB 却只得到 80 KB,那几乎可以肯定你收到的是一张 HTML 错误页面而非 PDF,此时你应该在保存前检查缓冲区的头几个字节以确认。

这种模式适用于大约 50 MB 以内的文件。超过这个大小,Base64 字符串本身就会开始占用 Node 的堆内存(在 V8 中每个字符占用两个字节),你将开始看到 JavaScript heap out of memory 失败。这就是下一小节要解决的问题。

使用分块 Base64 流式传输大文件

对于数百MB的文件,直接通过 page.evaluate() 将导致内存不足崩溃。解决方法是在浏览器端将响应作为流读取,将其切分成约 1 MB 的块,将每个块编码为 base64,然后逐个发回给 Node。在 Node 端,将每个块解码为 Buffer 并追加到写入流中,这样整个文件就永远不会驻留在内存中。

该方案利用 expose function 为浏览器提供回调 Node 的途径,并配合 ReadableStream.getReader() 来逐块遍历响应正文:

import fs from 'fs';
import path from 'path';
import { launchBrowser, newPage, DOWNLOAD_DIR } from './launch.js';

const FILE_URL = 'https://example.com/big-archive.zip';
const OUT_PATH = path.join(DOWNLOAD_DIR, 'big-archive.zip');

(async () => {
  const browser = await launchBrowser();
  const page = await newPage(browser);

  const out = fs.createWriteStream(OUT_PATH);
  let written = 0;

  await page.exposeFunction('onChunk', async (b64) => {
    const buf = Buffer.from(b64, 'base64');
    written += buf.byteLength;
    if (!out.write(buf)) {
      // Apply backpressure if the write stream is saturated.
      await new Promise((r) => out.once('drain', r));
    }
  });

  await page.exposeFunction('onDone', () => {
    out.end();
    console.log('Total bytes:', written);
  });

  await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });

  await page.evaluate(async (url) => {
    const res = await fetch(url, { credentials: 'include' });
    const reader = res.body.getReader();
    const CHUNK = 1 << 20; // 1 MB target
    let pending = new Uint8Array(0);

    const flush = (bytes) => {
      let binary = '';
      for (let i = 0; i < bytes.length; i += 0x8000) {
        binary += String.fromCharCode.apply(
          null,
          bytes.subarray(i, i + 0x8000),
        );
      }
      return window.onChunk(btoa(binary));
    };

    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      const merged = new Uint8Array(pending.length + value.length);
      merged.set(pending, 0);
      merged.set(value, pending.length);
      pending = merged;
      while (pending.length >= CHUNK) {
        await flush(pending.subarray(0, CHUNK));
        pending = pending.subarray(CHUNK);
      }
    }
    if (pending.length) await flush(pending);
    await window.onDone();
  }, FILE_URL);

  await browser.close();
})();

有几点需要理解。 page.exposeFunction 在页面上添加了一个全局函数,调用时会等待 Node 端的处理程序。我们利用它将 Base64 数据块直接推入写入流,从而避免字节在 V8 内存中堆积。我们还遵循反压机制:如果 out.write() 返回 false,我们会等待 'drain' 。若不如此,即使网络速度快而磁盘速度慢,Node 最终仍会缓冲整个文件,这便违背了初衷。

1 MB 的分块大小是权衡的结果。较小的分块意味着页面与 Node 之间需要更多往返,且每次调用产生的 Base64 开销更大。较大的分块虽能减轻开销,但会占用浏览器更多内存。1 MB 是一个合理的起点;请根据您的工作负载进行调整。

何时应采用页面内加载(认证、SPA、blob URL)

当文件仅“存在”于浏览器会话中,且方法 1 因以下三种原因之一无法访问时,方法 2 才是正确答案。

第一种情况是基于 Cookie 或令牌的防重放认证。某些网站会将会话与指纹(User-Agent 加上 IP 地址,外加存储在非 Cookie 存储中的 CSRF 令牌)绑定,而这种组合在浏览器外部难以复现。页面内获取完全规避了这一问题,因为请求来自拥有该会话的页面本身。

其次是单页应用(SPA)生成的下载。点击按钮会执行 JavaScript 代码,该代码构建一个 Blob,将其传递给 URL.createObjectURL,并通过模拟的 <a download> 点击。URL 通常类似于 blob:https://app.example.com/abc-123 ,且只有源页面才能解析该地址。如果 setDownloadBehavior 已启用,但方法 2 更具确定性:自行重现相同的 fetch 请求,对结果进行编码,并完全绕过 Chrome 的下载流程。

第三种是动态导出端点。对于那些接收 JSON 有效载荷、即时生成 CSV 或 PDF 并内联返回的 API,使用 page.evaluate() 进行脚本化处理,因为你可以 JSON.stringify 获取有效载荷、发送 POST 请求,并将响应作为流读取。

何时不应使用页面内获取:超大文件(如上所述)、受CORS限制且页面无权读取的文件,以及任何情况下,直接从Node发起普通Axios请求即可正常工作的情况。请使用能获取数据字节的最简单工具。

方法 3:利用 Chrome DevTools 协议驱动下载

方法 1 虽在后台使用了 CDP,但将其视为配置步骤。方法 3 则让 CDP 成为主角。当您需要实时进度、运行并行下载并需将每个下载映射回触发它的点击事件,或希望尽早检测中断时,您需要浏览器级别的 CDP 事件: Browser.downloadWillBegin 以及 Browser.downloadProgress。它们为每次下载提供一个 GUID、建议的文件名、已知的总字节数、当前已接收的字节数,以及一个状态机: inProgress, completed,以及 canceled.

这与 Chrome 自带的开发者工具面板所使用的协议相同,且比 Puppeteer 原生暴露的任何功能都更接近“真正的”下载 API。但问题在于,它位于 page.click(),因此你需要显式地进行配置,并在 CDP 会话上监听事件,而不是等待 Puppeteer 的 Promise。

何时选择方法 3:

  • 您需要向用户显示进度,或将其推送到任务队列。
  • 您正在运行并行的 Puppeteer 文件下载任务,并且需要将文件名映射到上下文。
  • 您希望获得明确的“此下载已被取消”信号,而非通过文件系统推测。
  • 你希望实现一个可靠的 Puppeteer 无头下载方案,且不依赖于旧版 Page.setDownloadBehavior.

何时应跳过此方法:

  • 您每次只需处理一个文件,方法 1 已足够。
  • 您可以获取 URL 并使用 Axios;在这种情况下,CDP 的底层实现通常不值得为此增加复杂度。

使用 page.createCDPSession 打开 CDP 会话

Puppeteer 提供两种 CDP 会话供选择:页面级(page-scoped)和浏览器级(browser-scoped)。对于方法 3,我们需要使用浏览器级会话,因为下载事件是在浏览器层级触发的, Browser.setDownloadBehavior 而 `download` 属于浏览器级别的操作。

const session = await browser.target().createCDPSession();

相比之下, await page.createCDPSession(),后者属于页面范围。页面会话仍适用于导航、网络和运行时调用(这些调用仅限于单个页面),但如果 Chrome 将其路由到浏览器目标(这是近期版本的趋势),它们将无法捕获浏览器级别的下载事件。

一个有用的思维模型:CDP会话是连接到目标的类型化WebSocket。 browser.target() 是浏览器目标, page.target() 是页面目标,它们各自接收不同的事件。在方法 3 中,混淆二者是导致“我的监听器从未触发”这类 bug 的常见原因。如果你的 Browser.downloadProgress 监听器无响应,请仔细检查是否是在 browser.target()上建立会话,而非在页面上。

你可以同时打开多个 CDP 会话,包括每个页面一个以及浏览器层面的一个。对于下载操作,单个浏览器级别的会话就足够了。

Browser.setDownloadBehavior 及监听 downloadWillBegin / downloadProgress

获取浏览器会话后,配置下载行为并订阅事件:

const downloads = new Map(); // guid -> { filename, totalBytes, received, state }

await session.send('Browser.setDownloadBehavior', {
  behavior: 'allow',
  downloadPath: DOWNLOAD_DIR,
  eventsEnabled: true, // turn on downloadWillBegin / downloadProgress
});

session.on('Browser.downloadWillBegin', (event) => {
  // event: { guid, url, suggestedFilename, frameId }
  downloads.set(event.guid, {
    filename: event.suggestedFilename,
    received: 0,
    totalBytes: 0,
    state: 'inProgress',
  });
  console.log(`Starting download: ${event.suggestedFilename}`);
});

session.on('Browser.downloadProgress', (event) => {
  // event: { guid, totalBytes, receivedBytes, state }
  const entry = downloads.get(event.guid);
  if (!entry) return;

  entry.totalBytes = event.totalBytes;
  entry.received = event.receivedBytes;
  entry.state = event.state;

  if (event.totalBytes > 0) {
    const pct = ((event.receivedBytes / event.totalBytes) * 100).toFixed(1);
    process.stdout.write(`  ${entry.filename}: ${pct}%\r`);
  }

  if (event.state === 'completed') {
    console.log(`\nFinished: ${entry.filename}`);
  } else if (event.state === 'canceled') {
    console.warn(`\nCanceled: ${entry.filename}`);
  }
});

值得掌握的几种模式:

  • guid 字段是您用于追踪并行下载的键。Chrome 会为每次下载分配一个新的 GUID,而 suggestedFilename 是文件在磁盘上的命名(若发生冲突,Chrome 会追加 (1), (2)等后缀)。
  • totalBytes 可能 0 如果服务器未发送 Content-Length。这种情况下无法显示百分比进度,只能显示累积字节数。请据此规划您的用户界面。
  • state: 'completed' 是下载完成的强烈信号,但不能绝对保证文件已完全写入磁盘。Chrome 可能会在重命名或最终写入之前稍早报告完成,因此除了该事件外,进行一次简短的文件大小检查仍是明智之举。
  • state: 'canceled' 包括用户取消的下载(无头模式下很少见)和中断的下载(网络故障、服务器挂起)。将两者视为同等处理:重试或明确报错。

若未设置 eventsEnabled: true,虽然能获取下载文件但不会触发事件,这会让你退回到方法 1 的轮询模式。请务必选择方法 3。

若需更严格的“文件确实已写入磁盘”验证,请将 'completed' 事件与一个简短的 waitForFileStable 辅助函数,类似方法1中的实现但更严格(超时30秒,三次稳定性检查):

async function waitForFileStable(filePath, {
  timeoutMs = 30_000,
  stableChecks = 3,
  intervalMs = 200,
} = {}) {
  const deadline = Date.now() + timeoutMs;
  let last = -1, stable = 0;
  while (Date.now() < deadline) {
    try {
      const { size } = await fs.stat(filePath);
      if (size === last && size > 0) {
        if (++stable >= stableChecks) return size;
      } else {
        stable = 0; last = size;
      }
    } catch {}
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  throw new Error(`File never stabilized: ${filePath}`);
}

现在你同时获得了两个信号:CDP 表示“完成”,且文件系统也确认了这一点。

带进度日志的完整方法 3 脚本

// method3.js
import path from 'path';
import { launchBrowser, newPage, DOWNLOAD_DIR } from './launch.js';
import { waitForFileStable } from './waitForFileStable.js';

const TARGET_URL = 'https://example.com/reports';
const SELECTOR = '[data-testid="download-report"]';

(async () => {
  const browser = await launchBrowser();
  const page = await newPage(browser);
  const session = await browser.target().createCDPSession();

  await session.send('Browser.setDownloadBehavior', {
    behavior: 'allow',
    downloadPath: DOWNLOAD_DIR,
    eventsEnabled: true,
  });

  let resolveDone, rejectDone;
  const done = new Promise((r, j) => { resolveDone = r; rejectDone = j; });
  let lastFilename = null;

  session.on('Browser.downloadWillBegin', (e) => {
    lastFilename = e.suggestedFilename;
    console.log('Begin:', e.guid, '->', e.suggestedFilename);
  });

  session.on('Browser.downloadProgress', async (e) => {
    if (e.state === 'completed') {
      const finalPath = path.join(DOWNLOAD_DIR, lastFilename);
      try {
        await waitForFileStable(finalPath);
        resolveDone(finalPath);
      } catch (err) { rejectDone(err); }
    } else if (e.state === 'canceled') {
      rejectDone(new Error('Download canceled'));
    }
  });

  await page.goto(TARGET_URL, { waitUntil: 'networkidle2' });
  await page.click(SELECTOR);

  const finalPath = await done;
  console.log('Saved to:', finalPath);
  await browser.close();
})();

与方法 1 相比,此脚本的优势在于:确定性完成(通过事件精确知晓下载的开始和结束时间,而非靠猜测)、实时进度( downloadProgress 处理程序每几百KB触发一次),以及明确的取消处理。它还能轻松扩展至N个并行下载:维护一个 Map<guid, Promise>,在处理程序内部解决每个 Promise,并 Promise.all 所有这些。

在生产环境中,通常建议将 done 设置超时,以免卡住的下载任务让你的 worker 永远停滞。对于典型文件,5 到 10 分钟的上限是合理的。如果超过了,记录 GUID,终止该页面,并重试。CDP 提供了做出该决策的可见性;仅靠文件系统则无法做到。

方法 3 中值得了解的第二个模式:按下载任务创建的 Promise。与其使用单个 done promise,而是维护一个 Map<guid, { resolve, reject }> ,并在 Browser.downloadWillBeginBrowser.downloadProgress 处理程序随后调用 resolvereject ,针对与事件 guid。这样一来,你可以连续触发 N 次点击,收集 N 个 Promise,并 Promise.all 解析它们。无论处理一个文件还是五十个文件,该处理程序代码均可适用,且能获得清晰的按文件分类的错误报告,而非仅显示一个隐藏了具体下载失败原因的全局超时错误。

方法 4:跳过浏览器,将 URL 传递给 Axios 或 https

有时,Puppeteer 下载文件的最佳策略几乎是不用 Puppeteer。如果网站暴露了文件的真实、稳定 URL(即使你需要渲染页面并点击探索才能发现),你可以仅用 Puppeteer 渲染足够长的时间来提取该 URL 及认证状态,然后使用 axios 或 Node 的内置 https进行下载。这种方法比方法 1 更快,比方法 2 更节省内存,而且可以轻松实现并行化,这是运行 N 个 Chrome 窗口无法做到的。

这也是最“无聊”的方法——当然是褒义的。一旦拿到 URL,下载就只是一个 HTTP GET 请求。无需追踪无头模式的回归问题,无需担心 CDP 版本漂移,也无需 .crdownload 哨兵需要轮询。你只需将 URL 和几个头部信息交给 Axios,将响应管道传输到写入流,文件便会存入磁盘。

在以下情况下选择方法 4:

  • 目标文件位于一个稳定的 URL 上,你可以从 DOM、网络响应或 JS 变量中提取该 URL。
  • 文件体积较大,且希望实现真正的流式写入磁盘,避免通过 V8 进行缓冲。
  • 你需要并发执行大量下载任务。Axios 请求池的开销远低于无头 Chrome 进程池。

在以下情况下请跳过方法 4:

  • 下载 URL 仅限单次使用、经过签名,或以无法重放的方式与浏览器会话绑定。
  • 网站强制执行 JavaScript 验证或指纹检查,而 Axios 无法在不进行大量额外工作的情况下通过这些检查。

当第二种情况发生时,通常会将 Axios 替换为能处理这些验证的请求层,但脚本的结构不会改变。

混合流程的核心在于继承 Puppeteer 的会话。您先驱动 SPA 登录或网站要求的其他操作,然后将 Cookie 和几个关键头部信息传递给 Axios。

async function buildAxiosHeaders(page) {
  const cookies = await page.cookies(); // current page's cookies
  const cookieHeader = cookies.map((c) => `${c.name}=${c.value}`).join('; ');

  const userAgent = await page.evaluate(() => navigator.userAgent);
  const referer = page.url();

  return {
    Cookie: cookieHeader,
    'User-Agent': userAgent,
    Referer: referer,
    Accept: '*/*',
    'Accept-Language': 'en-US,en;q=0.9',
  };
}

上述四个头部字段足以应对绝大多数 CDN 和 WAF 验证。 Cookie 携带会话, User-Agent 与页面已验证的信息相匹配, Referer 与浏览器点击下载链接时发送的内容一致, Accept-Language 并能作为真实浏览器刚刚访问过的微小线索。如果网站检查 Sec-Ch-Ua 或其他客户端提示,请一并复制这些内容,使用 page.evaluate(() => navigator.userAgentData).

有两个注意事项。首先, page.cookies() 默认会返回当前 URL 的 Cookie。如果文件托管在不同的子域名上,请显式传递该 URL: page.cookies(fileUrl)。否则,您发送的 Cookie 将不会被发送。其次,某些网站会设置 HttpOnlySecure 标志,Axios 能很好地处理这些,但路径范围的 Cookie(Path=/api)会被忽略,除非你在构建请求头时保留它们。最简单的解决方法是获取你将访问的精确源的 Cookie,并仅合并那些 path 是文件 URL 路径的前缀。

若想避免手动实现,已有成熟的 axios-cookiejar 适配器,它们可接收 Puppeteer 的 Cookie 并让 Axios 按请求进行管理。对于常见情况,一行代码 Cookie 。若需深入了解如何增强 Axios 调用以抵御检测,可参考与本节内容相辅相成的内部 axios-headers 指南。

使用 axios responseType: stream 流式处理响应

使用 responseType: 'stream'。Axios会将响应主体作为Node流返回,你可以将其管道传输到写入流中。整个文件从未驻留在内存中:

import axios from 'axios';
import fs from 'fs';
import { pipeline } from 'stream/promises';

async function downloadToFile(url, outPath, headers) {
  const res = await axios.get(url, {
    headers,
    responseType: 'stream',
    timeout: 30_000,
    maxRedirects: 5,
    validateStatus: (s) => s >= 200 && s < 400,
  });

  await pipeline(res.data, fs.createWriteStream(outPath));
}

stream.pipeline (或此处使用的 Promise 版本)是正确的原始操作,因为它能从任一端传播错误,并在失败时正确清理流。一个简单的 res.data.pipe(write) 会吞噬写入流的错误,这将导致文件写入不完整且不抛出异常。

几个生产级别的配置选项:

  • 超时设置。 timeout: 30_000 是建立连接的超时。对于长时间下载,还应将管道包裹在看门狗中,以免因数据流缓慢而导致程序无限期挂起。
  • 重试。将调用封装在一个带指数退避的小型重试助手中,并限制为三次尝试。大多数瞬时故障(如 504、ECONNRESET)可通过重试解决。
  • 避免对同一路径进行并发写入。两个并行任务覆盖 report.pdf 会导致隐蔽的数据损坏。建议使用临时文件名配合重命名操作,或为每个任务分配唯一文件名。

关于并行处理,较小的任务池是最安全的默认设置。三到五个并行 Axios 下载是合理的上限,若不确定服务器端的速率限制,则顺序 for...of await 循环是安全基线。若不确定服务器端速率限制,超过五个并发任务时应通过实际监测而非凭经验估算。

不使用 Puppeteer 的纯 URL 下载

一旦确定了 URL 模式,通常可以完全舍弃 Puppeteer。典型的混合运行方式是:使用 Puppeteer 抓取搜索结果网格,为每个结果提取一个详情页 URL,然后要么访问每个详情页获取文件 URL,要么(如果 URL 模式可预测)直接从列表中推导出该 URL。

以下是一个下载五个图片文件的典型端到端流程示例:

import axios from 'axios';
import fs from 'fs';
import path from 'path';

async function downloadAll(items, headers, outDir) {
  for (let i = 0; i < items.length; i++) {
    const url = items[i].downloadUrl;
    const out = path.join(outDir, `image-${String(i + 1).padStart(3, '0')}.jpg`);
    await downloadToFile(url, out, headers);
    console.log('Saved', out);
  }
}

将该流程应用于五个已提取的 URL 列表,即可得到 image-001.jpg 通过 image-005.jpg ,且实际传输过程中不涉及任何 Chrome 进程。如果这些 URL 是公开的且未受签名保护,后续运行时可以完全跳过 Puppeteer,直接访问 URL。对于已知数据集的日常刷新,这通常是正确的选择;你只需在首次发现 URL 结构时承担 Puppeteer 的成本。

更重要的启示是:将 Puppeteer 视为发现和身份验证工具,而非下载工具。浏览器的职责是定位数据存储位置并验证会话有效性;而下载操作本身几乎总能由更轻量、更快速的客户端来完成。

有两种操作模式可以进一步扩展这一思路。首先,将发现的 URL 模式缓存到以网站为键的小型 JSON 文件或数据库中,仅当 Axios 请求开始返回 404 错误或意外的 HTML 时,才重新运行 Puppeteer 的发现步骤。大多数网站的文件 URL 都遵循一个稳定的模板(/exports/{id}/{filename}.csv),一旦掌握该模板,日常刷新便完全无需浏览器。其次,当 URL 经过签名但签名逻辑可重现时(例如基于请求负载的 HMAC),只需对签名进行一次逆向工程,即可永久跳过该目标的 Puppeteer 操作。Puppeteer 下载文件的方法在首次接触时发挥其价值;此后的一切操作均为普通 HTTP。

选择合适的 Puppeteer 下载文件方法:决策指南

四种方法已超出搜索结果页面通常显示的范围,而这正是关键所在:每种方法都有其独特定位。以下是一个决策指南,通过几个是/否问题将您引导至合适的方法,此外还附有一张对比表,供您在阅读本指南时随时参考。

首先请回答以下问题:

  1. 您是否拥有一个稳定且可重放的文件 URL?如果是,请跳至问题 2。如果不是(URL 仅限单次使用、由 JS 生成,或仅在页面会话内有效),则您应采用方法 1 或方法 2。
  2. 该文件是否位于浏览器外部仍可存活的认证机制之后?如果你能导出 Cookie 并重放请求,方法 4 几乎总是最佳选择。如果认证机制与浏览器绑定(CSRF 令牌存储在 JS 内存中,或基于会话指纹),请使用方法 2。
  3. 文件是否非常大(超过 ~100 MB)或需要并行处理多个文件?方法 4 胜出。流式传输 Axios 的开销比运行 N 个 Chrome 更低,且方法 2 中 base64 的往返传输无法扩展。
  4. 是否需要进度事件或明确的取消信号?只有方法 3 能直接从 Chrome 获取这两者。
  5. 下载是否由点击触发,且无法轻松检查其 URL?方法 1 是最简单的解决方案,通常已足够。

方法

最适合

应避免

内存分析

身份验证模型

  1. 点击 + setDownloadBehavior

JS 触发的下载、未知 URL

超大文件,进度 UI

低(Chrome 流式传输至磁盘)

点击所见即下载

  1. 页面内 fetch + base64

单页应用(SPAs)、二进制大对象(blob)URL、浏览器绑定认证

数百MB的文件

未分块时数据量巨大

浏览器 Cookie、自动

  1. CDP 配合 Browser.downloadProgress

并行任务、进度、取消

一次性小文件

低(Chrome 流式传输至磁盘)

点击所见即所得

  1. Axios 配合 Puppeteer cookie

大文件、并行管道、已知 URL

一次性签名 URL

低(真正的流式传输)

重放 Cookie + 头部

通用原则:优先选择在仍能正常工作的情况下使用最少 Puppeteer 功能的方法。若 URL 已知,则默认采用方法 4;若 URL 未知,则默认采用方法 1。当需要并行处理或进度反馈时,方法 3 才是方法 1 本应具备的形态。方法 2 是应对其他所有情况的应急方案。

如有疑问,请先尝试方法 4。如果成功,你会庆幸自己没有为每个文件都运行一个 Chrome 实例。如果失败,你将在几分钟内知道问题出在认证(方法 2)还是 URL(方法 1)。

生产环境强化:超时、重试与完整性检查

一个在笔记本电脑上运行正常、但在生产环境中崩溃的 Puppeteer 文件下载脚本,其崩溃原因几乎总是归结于以下四点之一:忘记设置超时、忘记编写重试逻辑、 .crdownload 忘记清理的哨兵,或是将不完整的文件误判为已下载完成。以下是我们脚本上线前必须经过的检查清单。

在每个层级设置超时。设置 timeout on page.goto (默认值为 30 秒,对于冷缓存而言通常过于紧迫),并在您的 waitForRealFile 中显式设置超时,为方法 4 配置 Axios timeout 用于方法 4,以及对整个任务设置的实际时间上限。CI 卡死通常是由于缺少上述措施之一,而非存在真正的 bug。

带退避机制的重试。将涉及网络的调用封装在重试助手中,采用指数退避机制,最多尝试三次,最后强制失败。仅在 ECONNRESET, ETIMEDOUT、5xx响应以及任何疑似临时性错误的情况进行重试。对于401、403或404错误不要重试,这些是代码中存在缺陷的信号。

清理 .crdownload 文件。当下载被取消或进程提前退出时,Chrome 会留下这些文件。如果你重新运行脚本,你的 waitForRealFile 可能会拾取过期的哨兵文件,并将错误的文件报告为新文件。请在每次运行开始时清扫 .crdownload, .tmp以及您自己的工作文件。

验证完整性,而不仅仅是存在性。对于重要有效载荷,进行三层检查是合理的:文件是否存在、文件大小是否与预期 Content-Length (当服务器提供时),以及源文件发布校验和时的校验和验证。使用 crypto.createHash('sha256') 对数GB级文件而言速度很快,且能检测出单纯存在性检查无法察觉的截断问题。

限制并发,不要仅仅并行化。三到五个并发下载是一个合理的默认值;超过这个数量,你就会开始与自己争夺磁盘和带宽,而且许多网站会收紧速率限制。一个 p-limit 这种风格的连接池配合按主机设置的并发限制,只需少量代码就能避免大量故障报告。

记录 GUID 到文件名的映射(方法 3)或 URL 到输出文件的映射(方法 4)。当凌晨 3 点发生故障时,一份结构化的日志(记录“该 URL 生成了该文件,字节数为 X,状态为 Y”)将拯救你。务必保留日志。

隔离不完整的文件。如果下载中途失败,这些不完整的字节就像放射性物质一样危险。将它们移至一个 partial/ 目录中,切勿将其留在管道下一阶段可能误判为完整文件的路径上。看似完整的部分文件是下载自动化中最代价高昂的错误类型。

避免自动化下载过程中的阻塞

即使你的 Puppeteer 文件下载流程在文件处理层上万无一失,请求本身也可能在产生任何数据之前就被阻断。无论你是抓取 HTML 还是下载 200 MB 的 CSV 文件,CDN、WAF 和反机器人服务商都会分析相同的指纹特征,因此相同的防御策略同样适用。

最经济且最有效的加固措施在于三个请求头和一个 IP 决策:

  • 真实的 User-Agent。使用与捆绑版 Chrome for Testing 版本匹配的最新 Chrome 桌面版 User-Agent,而非 Puppeteer 的默认值。某些主机一看到默认 User-Agent 就会直接封锁。
  • 匹配视口。1366x900的视口与真实的桌面会话相符。800x600的视口则会昭示“自动化”。
  • Referer。Referer 指向链接到该文件的页面。WAF(Web应用防火墙)通常会对没有Referer的直接访问资产返回403错误,尤其是针对PDF和图片。
  • 合理的 IP 地址。主流云服务商的数据中心 IP 通常已被大多数反机器人供应商预先标记。如果您的下载在真实浏览器上收到 403 错误,但通过 VPN 连接到家庭网络时却能通过,那么问题出在 IP 地址上,而非脚本本身。

针对顽固情况,可采取以下额外措施:增加一小段 slowMo (50 至 200 毫秒)延迟以拉长点击间隔。使用 page.waitForTimeoutgoto ,让基于JavaScript的机器人检测机制有时间处理。将多文件任务错开执行,避免在同一秒内产生N次请求。

若已尝试上述所有方法,网站仍将您封禁,正确的做法是委托请求层处理,而非继续调整请求头。 像我们专为爬虫优化的住宅代理网络,或是 WebScrapingAPI 上的 Scraper API 接口这类工具,能在一项请求背后自动处理代理轮换、IP 信誉评估以及更复杂的指纹识别检测,从而让您的 Puppeteer 代码专注于驱动页面。如果您需要针对特定国家的下载,或者必须在验证页面后进行爬取,这也是值得考虑的解决方案。

此时也是思考是否真的需要完整无头浏览器的绝佳时机。如果您仍在权衡是使用自建的 Puppeteer 框架还是托管式替代方案,建议阅读本站其他位置链接的无头浏览器概述。

Puppeteer 与 Playwright 在文件下载方面的对比

坦率地说:Playwright 的下载 API 更优雅,Puppeteer 则能更直接地访问 Chrome 的内部机制,但在生产环境中使用二者皆无不可。

Playwright 提供了 page.waitForEvent('download'),该方法返回一个 Download 对象,其中包含诸如 download.path(), download.saveAs(path)download.suggestedFilename()。在基本场景下,您无需直接操作 CDP。这种写法确实比等效的 Puppeteer 配置更简洁,且在 Chromium、Firefox 和 WebKit 浏览器中行为一致,这对跨浏览器测试套件而言是更大的优势。如果您正在从零开始构建,且技术栈尚未依赖 Puppeteer,使用 Playwright 的下载工作流所需的代码量大约只有一半。

Puppeteer的优势在于它更贴近Chrome DevTools Protocol。如果你需要原始的CDP事件、自定义协议调用,或是尚未被封装到更高层API中的行为,Puppeteer能通过少一层间接调用实现。本指南中的方法3就是很好的例子。 Playwright 中的相同模式同样可行(Playwright 暴露了一个 CDP 会话),但 Puppeteer 的惯用语法感觉更像原生功能,因为整个库都是围绕 CDP 构建的。

对于已经投入运行的 Puppeteer 文件下载管道,以上这些都不是迁移的理由。方法 1 加上 Browser.setDownloadBehavior 的功能几乎与 Playwright waitForEvent('download') 的功能几乎完全一致;你只需多写几行代码。当跨浏览器兼容性是真正的优势时,才迁移到 Playwright,而非仅因下载功能。若需全面对比,本站另有更详尽的 Playwright 网页抓取指南

关键要点

  • 不存在唯一最佳的 Puppeteer 下载文件方法。应根据最棘手的限制条件选择相应方法:未知 URL(方法 1)、绑定浏览器的认证(方法 2)、带进度跟踪的并行任务(方法 3),或已知 URL 且 Cookie 可重放(方法 4)。
  • setDownloadBehavior 这一点不可妥协。无头Chrome默认会阻止下载。请使用带绝对路径的浏览器级 Browser.setDownloadBehavior 并指定绝对路径;页面级调用已被弃用且可能出现不可预测的故障。
  • 等待实际文件,而非点击事件。对下载文件夹进行快照,忽略 .crdownload,并在文件大小保持稳定一段时间后才报告成功。
  • 尽可能跳过浏览器。与运行 N 个 Chrome 进行并行下载相比,Puppeteer 与 Axios 的混合方案更快、更轻量且更易于扩展。
  • 将请求层与脚本分离并加强防护。使用真实的 User-Agent、匹配的视口、referer、住宅 IP 以及限制并发数,可防止大多数“神秘的 403”错误。

常见问题

在每个 Puppeteer 文件下载项目中,总会出现几个常见问题,通常是在第一个脚本以 headed 模式勉强运行后,却在 CI 环境中崩溃时。 下文的解答将跳过对四种方法的回顾(相关内容已在上文阐述),重点聚焦于操作决策:当无法对四种方法全部进行原型测试时如何快速选型;当文件下载无法完成时该如何处理;实践中更简洁的非浏览器路径是什么样子的;在下载场景中 Playwright 相对于 Puppeteer 的定位;以及如何处理会话绑定的认证,而无需为此耗费整个周末。

如何选择使用 Puppeteer 下载文件的最佳方法?

从候选方案中逐步筛选。如果能提取稳定的 URL 且认证信息可复用,请使用 Axios 并利用从 Puppeteer 会话中提取的 Cookie。如果 URL 由 JavaScript 生成或仅在页面内部有效,请运行 fetch()page.evaluate() 并返回 base64 编码结果。若仅有点击目标且只需基本完成,配置 Browser.setDownloadBehavior 并点击。若需进度显示或并行安全保障,请通过 CDP 事件驱动所有操作。根据最棘手的限制条件选择相应方法。

为什么我的 Puppeteer 下载卡在 .crdownload 文件上或永远无法完成?

最常见的原因是脚本在 Chrome 刷新文件前就退出了,因此请务必在轮询助手确认最终文件名存在且大小稳定后,再关闭浏览器。其他可能原因包括:相对路径 downloadPath (必须是绝对路径)、点击触发了导航而非下载,或是服务器卡死导致 Chrome 报告下载取消。在带头模式下观察一次运行过程,原因通常几秒内就会显而易见。

我完全不启动 Chrome 也能下载文件吗?

可以,而且这通常是正确的选择。如果文件 URL 是公开的,或者获取文件所需的 Cookie 和头部信息可以通过 HTTP 客户端重放,请跳过浏览器,直接使用 axios 或 Node 的内置 https 配合流式写入。仅在以下情况下才需要浏览器:JavaScript 动态生成 URL、认证与浏览器会话绑定且无法复现,或者该 URL 的反机器人检测层专门阻止非浏览器客户端。

在文件下载方面,Puppeteer 与 Playwright 相比如何?

Playwright 将下载封装在了一个高级事件 API 中(page.waitForEvent('download')),该 API 会返回一个 Download 对象,其中包含 saveAs()path() 辅助方法,其代码长度比等效的 Puppeteer 加 CDP 配置更简洁。Puppeteer 要求你连接 Browser.setDownloadBehavior ,并需轮询文件系统或监听 CDP 事件。这两种方式在生产环境中均可靠。选择时应基于技术栈已使用的库,而非仅考虑下载 API 本身。

有两种简洁的方案。要么在 Puppeteer 中完成登录,使用 page.cookies(),然后通过 Cookie 头部以及匹配的 User-AgentReferer。或者在 page.evaluate() 中执行文件获取操作,使请求自动继承会话。第一种方法速度更快且更易于扩展;第二种方法在认证依赖于内存令牌或指纹(这些信息无法经受重放测试)时更为稳健。

总结与后续步骤

可靠的 Puppeteer 文件下载工作流,与其说是关于 Puppeteer 本身,不如说是关于选择数据实际传输的路径。 当仅需执行单击操作时,请使用方法 1。当页面会话是唯一能获取文件的方式时,请采用方法 2。若需要进度跟踪、并行处理或明确的取消信号,请依赖方法 3。一旦能够重放 URL,请默认采用方法 4,并将 Puppeteer 视为探索工具而非下载工具。

为每个脚本添加生产环境加固的基础措施:绝对下载路径、分层超时、带退避机制的重试、超越简单存在性验证的完整性检查,以及并发数限制。检测 .crdownload 哨兵,在每次运行之间清理它们,并绝不允许不完整的文件像完整文件一样流向下游。

如果您的下载被阻断而非失败,问题已不再出在脚本本身,而在于请求层。这正是托管式爬取基础设施大显身手之处。 WebScrapingAPI 浏览器 API 为您提供完全托管的云浏览器,您可以使用相同的 Puppeteer(或 Playwright)代码进行驱动,此外还配备了住宅代理网络和针对高难度目标的内置解锁功能,因此您可以保留上述四种方法的方案,只需更换请求的来源即可。在此基础上,扩展 Puppeteer 文件下载管道只需更改配置,而非重构架构。

为当前文件选择合适的方法,进行一次优化,然后继续前进。

关于作者
Mihnea-Octavian Manolache, 全栈开发工程师 @ WebScrapingAPI
Mihnea-Octavian Manolache全栈开发工程师

Mihnea-Octavian Manolache 是 WebScrapingAPI 的全栈及 DevOps 工程师,负责开发产品功能并维护确保平台平稳运行的基础设施。

开始构建

准备好扩展您的数据收集规模了吗?

加入2,000多家企业,使用WebScrapingAPI在无需任何基础设施开销的情况下,以企业级规模提取网络数据。