跳至内容

如何使用 Puppeteer 自动化网站截图

使用 JavaScript 和 Puppeteer 自动化网站截图。涵盖单个页面、整页截图、URL 批量处理和移动端视口,并附可复用的脚本。

9 分钟阅读网站截图作者:Temposhot Team

Puppeteer 是由 Chrome 团队维护的 Node.js 库。它通过 JavaScript API 控制 Chrome 或 Firefox,是编写脚本化网站截图时的常见选择。下面的示例从一个五行的截图脚本开始,逐步扩展为可复用的运行器。该运行器从文件读取 URL,并行处理页面,最后生成报告。

最后一节展示同一个批量任务在 Mac 上以 Temposhot Website Capture 运行的方式,无需维护脚本。两种方式生成的文件可以比较,选择取决于任务在哪里运行。

何时适合使用 Puppeteer

当截图应当放在已有的代码中运行时,Puppeteer 最合适:

  • 渲染预览图或社交分享图的 Node.js 服务
  • 部署后截取页面的 cron 任务或 CI 步骤
  • 同时需要可视化记录的抓取任务
  • 已经在使用 Chrome DevTools Protocol 功能的团队

对于不维护 Node.js 项目的人员偶尔执行的一次性任务,它并不适合。

包含六项信号的清单,说明何时适合使用 Puppeteer:团队已有 Node.js 项目,截图按计划或在 CI 中运行,输出文件供其他程序使用,团队像审查其他代码一样审查脚本变更,服务器或 CI 运行器没有桌面应用所需的显示环境,任务需要超出普通 URL 访问的浏览器步骤。

为截图脚本安装 Puppeteer

mkdir site-shots && cd site-shots
npm init -y
npm install puppeteer

puppeteer 包在安装时会下载兼容的 Chrome 构建。更轻量的 puppeteer-core 包跳过下载,改为需要指定已安装浏览器的路径。下面的脚本使用 .mjs 文件,因此可以直接使用顶层 await。

选择 puppeteer 还是 puppeteer-core

在允许下载的开发者机器上使用 puppeteer。在已经安装 Chrome 的 CI 镜像中,或在封锁浏览器下载的网络环境中,使用 puppeteer-core。两个包的 API 相同,因此脚本无需修改。只有当浏览器位于自定义路径时,启动调用才需要设置 executablePath。

site-shots 项目的文件树:包含 package 文件、one 和 run 脚本、urls 文本文件,以及 shots 文件夹,其中每次运行对应一个带日期的子文件夹。

截取单个网站的截图

// one.mjs
import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
const page = await browser.newPage();

await page.setViewport({ width: 1440, height: 900 });
await page.goto('https://example.com', { waitUntil: 'networkidle0' });
await page.screenshot({ path: 'example.png' });

await browser.close();

page.setViewport 设置宽度和高度。在同一个调用中加入 deviceScaleFactor: 2,可生成像素密度为两倍的图片,效果接近 Retina 显示屏。

设置产生的结果

上面的脚本加入 deviceScaleFactor: 2 之后,输出尺寸是固定的。具体数值由视口和缩放比例计算得出。

单页脚本的设置:视口为 1440 乘 900 CSS 像素,设备缩放比例为 2,等待网络空闲且连接数为 0,输出 PNG 格式,输出尺寸为 2880 乘 1800 像素。

输出图片在每个方向上都是视口的两倍,因为缩放比例增加的是像素数,CSS 尺寸保持不变。缩放比例为 1 时,1440 乘 900 的视口生成 1440 乘 900 的图片,适合用于网页预览。缩放比例用于在高密度屏幕上生成清晰的图片,代价是文件体积随之翻倍。

截取整页截图

await page.screenshot({ path: 'example-full.png', fullPage: true });

加上 fullPage: true 后,图片覆盖整个可滚动文档。若要减小文件体积,可保存为 JPEG 或 WebP:

await page.screenshot({ path: 'example.jpg', type: 'jpeg', quality: 80, fullPage: true });

quality 取值为 0 到 100,仅适用于 JPEG 和 WebP。长页面的整页截图高度可达数万像素,因此 JPEG 通常能将文件体积控制在合理范围内。

长页面的尺寸

图片宽度保持为视口宽度,高度随文档变化。长文章会生成非常高的图片,下面的数字展示了变化的幅度。

以 1440 像素宽度整页截取长文章的四项统计:高 12000 像素,图片共 1730 万像素,约为 4K 画面面积的 2.1 倍,以及 JPEG 质量 80 的设置。

3840 乘 2160 的画面约有 830 万像素,因此示例图片约为 4K 画面的两倍大。图片过高时难以整体查看。如果图片需要在屏幕或文档中展示,请对很长的页面使用 clip 分段截取。

截取一个区域

使用 clip 选项保存一个区域。坐标以 CSS 像素为单位,从页面顶部计算,而不是从视口计算:

await page.screenshot({
  path: 'example-pricing.png',
  clip: { x: 0, y: 2400, width: 1440, height: 900 },
});

在同一次调用中只能使用 fullPage 或 clip 之一。若要将长页面切分为多个文件,先通过 document.body.scrollHeight 读取页面高度,然后为每个切片设置一个 clip。一个 12000 像素高的页面按 900 像素切片,会生成 14 个文件,最后一个文件只有 300 像素。

构建可复用的 URL 运行器

// run.mjs
import { readFile, mkdir, writeFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';

const urls = (await readFile('urls.txt', 'utf8'))
  .split('\n').map((l) => l.trim()).filter(Boolean);
const outDir = `shots/${new Date().toISOString().slice(0, 10)}`;
await mkdir(outDir, { recursive: true });

const browser = await puppeteer.launch();

async function shoot(url, index) {
  const page = await browser.newPage();
  try {
    await page.setViewport({ width: 1440, height: 900 });
    await page.goto(url, { waitUntil: 'networkidle2', timeout: 45000 });
    const path = `${outDir}/${String(index + 1).padStart(3, '0')}.jpg`;
    await page.screenshot({ path, type: 'jpeg', quality: 80, fullPage: true });
    return { url, path, ok: true };
  } finally {
    await page.close();
  }
}

每个 URL 使用一个新页面,因此一个站点崩溃不会影响下一个。带日期的输出文件夹会保留之前运行的结果。

运行器分为五个阶段。下图按顺序列出各阶段,以及每个阶段读写的文件或进程。

由五个方框组成的流程:读取 urls.txt 并去掉空行,在 shots 下创建带日期的文件夹,为整个运行启动一个浏览器,在各自的页面上截取每个 URL,最后写入包含所有结果的 report.json。

清理 URL 列表

空行、注释和重复的 URL 会浪费浏览器时间,重复的 URL 还会生成同一页面的第二个文件。下面的版本会跳过以井号开头的行,去除完全重复的 URL,并丢弃不是 HTTP 或 HTTPS 地址的内容。raw 为上一步读取的文件内容。

const lines = raw.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
const urls = [...new Set(lines)].filter((l) => {
  try {
    const { protocol } = new URL(l);
    return protocol === 'http:' || protocol === 'https:';
  } catch {
    return false;
  }
});

Set 按字符串精确比较,因此 https://example.com 和 https://example.com/ 会被视为两个 URL。也要记录被拒绝的行。像 htps://example.com 这样的拼写错误会在运行开始时出现在日志中,而不是 45 秒后以超时的形式出现。

大批量的编号

脚本将序号补齐为三位数,因此排序在 999 个文件以内是正确的。序号 1000 会变为 1000.jpg,按文本排序时会排在 100.jpg 和 101.jpg 之间。应改为按批量大小补齐位数:

const width = String(urls.length).length;
const path = `${outDir}/${String(index + 1).padStart(width, '0')}.jpg`;

1250 个 URL 的批量补齐位数为 4,因此第一个文件为 0001.jpg。应在 worker 启动之前计算一次位数。

为大批量任务增加并发

const LIMIT = 4;
const queue = [...urls.entries()];
const results = [];

async function worker() {
  while (queue.length) {
    const [i, url] = queue.shift();
    results.push(await shootWithRetry(url, i));
  }
}

await Promise.all(Array.from({ length: LIMIT }, worker));
await browser.close();

每个 worker 从共享队列中取下一个 URL。从 4 开始,只有在确认内存占用后才提高上限,因为每个打开的页面都会占用一个独立的渲染进程。

脚本从同一个浏览器中打开所有页面。页面共享浏览器缓存,默认也共享 Cookie。对于一批互不相关的公开网站,这没有问题。若运行过程中一个站点的状态不能影响下一个站点,请使用 browser.createBrowserContext() 为每个 URL 创建一个上下文,并在截图后关闭它。每个上下文都会占用内存,因此这一选择应与并发上限一并考虑。

选择上限

上限决定同时渲染的页面数。上限低时内存占用稳定,但运行时间更长;上限高则相反。下表展示了 200 个 URL 批量任务的权衡,假设每个页面平均耗时约 6 秒。

并发上限 1 和 10 的对比:处理 200 个 URL、每页约 6 秒时,每次只处理一个页面约需 20 分钟,同时处理十个页面约需 2 分钟,但需承担十个渲染进程的内存开销。

理想耗时假设没有网络等待,也没有失败。实际耗时因网站而异,超时和重试还会增加时间。请在目标机器上用 20 个 URL 分别测试两种上限,然后选择在保持 Mac 响应流畅的前提下最高的上限。

截取移动端截图

Puppeteer 通过 KnownDevices 提供设备描述符。

import puppeteer, { KnownDevices } from 'puppeteer';

const page = await browser.newPage();
await page.emulate(KnownDevices['iPhone 13']);
await page.goto('https://example.com');

page.emulate 在一次调用中设置视口、像素比、触摸支持和 User Agent。若只需要普通的移动端宽度而不修改 User Agent,请调用 setViewport 并设置 isMobile: true 和 hasTouch: true。

两种移动端设置的对比:emulate 搭配设备描述符会改变视口、像素比、触摸支持和 User Agent,而带 isMobile 的 setViewport 只改变视口和触摸行为。

当网站会根据 User Agent 返回不同的标记时使用 emulate;只关心布局宽度时使用 setViewport。两个调用都只作用于调用时所在的页面,之后打开的第二个页面需要单独设置。

桌面和移动端混合的批量任务,需要为每组输出分别设置视口,而不是为每个脚本设置一次。将移动端图片保存在独立的带日期文件夹中,例如 shots/2026-04-10-mobile,这样两组文件的编号不会冲突。Temposhot Website Capture 也为同样的目的提供 Mobile 尺寸 390 乘 844,并且可以在同一次运行中为每一行单独设置。

确认模拟的视口

截图前,在页面中检查布局宽度。错误的描述符会在移动端尺寸的图片中生成桌面布局。下面的代码输出页面所见的宽度和像素比:

const seen = await page.evaluate(() => ({
  width: window.innerWidth,
  pixelRatio: window.devicePixelRatio,
}));
console.log(seen);

对于较新版本 Puppeteer 中的 iPhone 13 描述符,宽度应为 390。像素比同样来自该描述符。请检查所安装版本中的数值,因为描述符数据会随版本变化。

处理懒加载内容

滚动后才加载的图片,在导航完成后立即截取时会显示为空白。先滚动整个页面:

await page.evaluate(async () => {
  for (let y = 0; y < document.body.scrollHeight; y += window.innerHeight) {
    window.scrollTo(0, y);
    await new Promise((r) => setTimeout(r, 200));
  }
  window.scrollTo(0, 0);
});

懒加载词条解释了这种模式。

滚动需要多长时间

循环每一步等待 200 毫秒,每一步滚动一个视口的高度。下面的示例是视口高 900 像素、页面高 8000 像素的情况。

8000 像素页面滚动过程的时间线:0.0 秒滚动到 0 像素,图片开始加载;0.2 秒滚动到 900 像素;1.6 秒滚动到 7200 像素,即最后一步;1.8 秒回到 0 像素,然后截图。

页面共有九步,因此滚动过程会在截图前增加 1.8 秒。如果页面加载图片较慢,每一步需要更长的停顿。无限滚动的页面永远不会到达固定的末尾。对于无限滚动,应在固定的步数后停止,而不是以文档高度为终点。

在容器内滚动

某些布局滚动的是内部元素,而不是窗口。上面的循环检测不到任何移动,页面下半部分会保持空白。此时应以内部元素自身的 scrollHeight 和 clientHeight 作为上下限,通过 scrollTop 滚动该元素。运行结束后,比较第一帧和最后一帧。两帧相同表示页面没有滚动,问题很可能出在容器上。

选择合适的等待策略

选项返回时机
loadload 事件触发。这是默认值。
domcontentloadedHTML 解析完成。
networkidle0至少 500 毫秒内没有网络连接。
networkidle2至少 500 毫秒内连接数不超过 2 个。

networkidle0 在轮询或持续推送数据的页面上可能超时。networkidle2 可以容忍这些后台流量。当页面有明确的就绪信号时,对该元素使用 page.waitForSelector 是最可靠的选择。默认导航超时为 30 秒。

仪表盘页面的就绪信号清单:主图表容器已存在于 DOM 中,加载动画已移除,最后一行数据可见,网页字体已加载完成,同意横幅已关闭或隐藏。

只选一个仅在页面内容就绪时才由页面设置的信号。在每次加载中都存在的元素(例如页眉)并不能说明下方的数据已经就绪。

选择等待条件前先测量

在决定等待策略之前,先测量每个 URL 的导航时间。下面的值是从导航开始到 load 事件触发的时间:

const started = Date.now();
await page.goto(url, { waitUntil: 'load', timeout: 45000 });
const loadMs = Date.now() - started;

将 loadMs 与每个 URL 一起写入报告。如果大多数页面在几秒内加载完成,默认等待即可。如果一组页面接近超时,则将这些 URL 切换为 domcontentloaded 并等待某个选择器,然后保留报告,用于确认这一变化的效果。

添加重试和报告

async function shootWithRetry(url, i, tries = 3) {
  for (let attempt = 1; attempt <= tries; attempt++) {
    try {
      return await shoot(url, i);
    } catch (error) {
      if (attempt === tries) return { url, ok: false, error: error.message };
    }
  }
}

// after Promise.all in run.mjs
await writeFile(`${outDir}/report.json`, JSON.stringify(results, null, 2));

报告列出每个 URL 的文件路径或错误信息。将失败的条目重新写入 urls.txt,即可进行第二轮处理。

从报告生成重试列表

有了报告,第二轮处理只需一个过滤步骤,无需手动复制。下面的 jq 命令将失败的 URL 写入新文件:

jq -r '.[] | select(.ok == false) | .url' shots/2026-04-10/report.json > urls-retry.txt

将脚本指向 urls-retry.txt 即可进行第二轮处理。脚本以日期命名输出文件夹,因此同一天的第二次运行会写入同一个文件夹,编号相同的文件会覆盖第一次运行的图片。在第二轮之前为 outDir 添加 -retry 之类的后缀,可使两次运行的结果都保留在文件夹中。

最坏情况下的运行时间

慢速网站可能会用完所有重试次数。超时和重试次数决定了单个 URL 的最坏耗时,而 worker 数量会将这部分成本分摊到各个 worker 上。下图使用脚本中的数值。

最坏情况的四项统计:每个 URL 3 次尝试,每次尝试 45 秒超时,每个 URL 全部超时时为 135 秒,使用 4 个 worker 处理 100 个 URL 且全部超时时约 56 分钟。

最坏情况在健康的批量任务中不太可能出现,但它设定了耗时上限。应安排任务,使即使出现大量超时的运行,也能在下一次运行开始之前结束。

只重试可能改变结果的错误

导航超时或连接中断在第二次尝试时可能成功。页面已完整加载却缺少预期的选择器,通常会以同样的方式再次失败,返回 404 状态的页面也是如此。应重试第一类错误,并将第二类错误只记录一次。上面的 shootWithRetry 函数会重试所有错误,因此一个固定失败的页面会花费三次完整超时的时间,才会出现在报告中。

实用的文件夹结构

site-shots/
  run.mjs
  urls.txt
  shots/
    2026-04-10/
      001.jpg
      002.jpg
      report.json

每个运行日期对应一个文件夹,每个文件夹对应一份报告。这样的结构已足以在之后比较各次运行,无需数据库。

常见错误

  1. 在所有站点上都使用 networkidle0。 带有分析脚本或聊天组件的页面永远不会进入空闲状态。
  2. 不关闭页面。 内存持续增长,直到进程变慢或崩溃。
  3. 同时运行过多页面。 笔记本电脑上的并发上限设为 20,通常会导致更多超时,而不是更快的运行速度。
  4. 截图中包含同意浮层。 通过选择器点击拒绝按钮,或使用 page.addStyleTag 隐藏横幅。
  5. 忽略网站条款。 自动化截图会发送真实请求。应检查每个网站的条款,并控制请求频率。

按症状排查

症状可能原因解决方法
整页图片中出现空白区域懒加载内容从未滚动到可视范围在截图前执行滚动过程
goto 超时页面从未达到等待事件等待某个选择器,或切换为 domcontentloaded
每个 URL 的图片都相同截图在导航完成前执行对每个页面等待 goto,并为每个 URL 打开新页面
图片尺寸为预期的两倍deviceScaleFactor 为 2去掉该缩放比例,或将其设为 1
长时间运行时内存增长截图后页面仍保持打开在 finally 块中关闭每个页面,并限制并发数
图片中出现同意横幅未处理横幅通过选择器点击拒绝按钮,或隐藏横幅
截图脚本的做与不做清单:关闭每个页面,限制并发数,等待页面自身设置的就绪信号,并保留每个 URL 一条记录的报告;不要在所有页面上使用 networkidle0,也不要在多次运行之间复用同一个输出文件夹。

Puppeteer 截图脚本的替代方案

  • Playwright 的 API 与之相似,还可以驱动 WebKit。参见如何使用 Playwright 自动化网站截图。
  • 托管截图 API 由服务端运行浏览器,并通过 HTTP 返回图片。
  • 浏览器扩展,例如 Temposhot 扩展,可保存当前标签页的一张整页 PNG。
  • Temposhot Website Capture 无需代码即可在 Mac 上运行粘贴的 URL 列表,支持 Desktop 1920×1080、Tablet 768×1024 或 Mobile 390×844 尺寸,每行可设置 0 到 60 秒的延迟。

何时不值得编写脚本

脚本的长期成本往往超过第一个版本所显示的。每次网站改版、Node.js 升级或 Chrome 更新都可能需要修复。对于每月执行、列表经常变化的任务,使用 Website Capture 成本更低,因为 URL 列表和每一行的设置都位于同一个窗口中。当截图需要供构建步骤使用、必须由 CI 流水线运行,或者操作超出单纯访问和截图时,则应保留脚本。

同一批任务在 Temposhot Website Capture 中的做法

Temposhot Website Capture 无需脚本即可完成本文中的批量任务。下方设置与脚本在 1440 乘 900 整页截图时的配置相对应,并包含 Temposhot 为每一行提供的等待和拦截选项。

Temposhot Website Capture 批量任务的设置:Desktop 设备,Full page 区域,Network idle 等待,2 秒延迟,Reject banners 处理 Cookie 横幅,Block ads and trackers 已开启,JPEG 质量 80,以及 4 个 Concurrent browsers。

设备尺寸与脚本有意不同:Temposhot 中的 Desktop 为 1920 乘 1080,而脚本使用 1440 乘 900。如果两者的输出需要一致,请为两者设置相同的视口。Temposhot 运行结束后,结果栏中会显示 ZIP、Images 或 PDF,用于导出所选的帧。

在 Temposhot Website Capture 中运行 URL 批量任务的五个步骤:使用 Bulk insert 粘贴列表,在 Run Settings 中设置并发数,开始截图,对失败的行重试,最后在结果栏选择 ZIP 导出截图。

每一行在批量任务运行期间都会显示其状态,例如 Capturing 或 Capture failed。失败的行可以单独重试,无需重新开始整个批量任务。

行状态对比:Capture pending 表示等待空闲的浏览器槽位,Capturing 表示正在加载页面,Capture complete 表示截图已保存,Capture failed 表示未生成截图。

常见问题

Puppeteer 支持 Firefox 吗?

支持。较新版本既可以启动 Chrome,也可以启动 Firefox。Chrome 仍然是使用最广泛的选项。

Puppeteer 能保存 WebP 截图吗?

能。在截图选项中设置 type: 'webp'。quality 值对 WebP 的作用与对 JPEG 相同。

为什么 page.goto 在某些网站上超时?

页面在 30 秒内未达到所选的等待事件。可以切换为 networkidle2 或 load,提高 timeout,或改为等待某个选择器。

为什么整页图片的一部分是空白的?

内容在滚动进入视野时才加载。在截图前运行懒加载章节中的滚动过程,并等待最后一行出现。

一次运行可以处理多少个 URL?

上限取决于内存和时间,而不是代码。用计划使用的并发数测量 20 个 URL 的批量任务,观察内存占用,然后据此扩展。Temposhot Website Capture 的 Concurrent browsers 可设置为 1 到 10,默认值为 4。

截图输出应该纳入 Git 吗?

不应。图片和报告都是随每次运行增长的构建产物。应将 urls.txt 和脚本纳入版本控制,并将输出文件夹存放在能容纳其体积的位置。

参考资料

需要无代码的网站截图自动化?

Temposhot Website Capture 在本地 Mac 上运行同类的 URL 批量任务,每行可单独设置,无需维护脚本。参见网站截图自动化,或捕获选项指南。

puppeteerwebsite-captureautomationnodejs

本文使用的术语: full page screenshot, headless browser, playwright, puppeteer, screenshot api, screenshot automation, viewport screenshot

把重复性截图整理为成品

Temposhot 可按定时或每次点击进行截图,并将结果导出为 PDF、图片、ZIP 或 MP4。

相关文章