跳至内容

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

使用 JavaScript 和 Playwright 自动化网站截图。涵盖单个页面、整页、移动端视图,以及带重试的 URL 批量截图。

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

Playwright 是微软开发的浏览器自动化库。它可以从 Node.js、Python、Java 或 .NET 驱动 Chromium、Firefox 和 WebKit。本文使用 Node.js 包编写截图脚本,先从单个页面开始,最后实现一个能重试失败项的 URL 批量截图流程。

何时适合使用 Playwright

当截图是更大的技术流程的一部分时,编写脚本的投入是值得的:

  • 每次部署后在 CI 中运行截图任务
  • URL 列表来自站点地图、数据库或 API
  • 文件必须存放在固定的文件夹结构或存储桶中
  • 截图步骤与测试、数据抓取或 PDF 生成放在一起

如果需要图片的人不写代码,或者列表每周都在变化,无代码工具通常更快。这一权衡在文末说明。

本文的示例任务

示例围绕一个任务展开:每次部署后,按桌面和手机两种尺寸截取站点地图中列出的 200 个页面,并报告失败的页面。该任务包含了让截图脚本变难的所有因素:列表长、页面加载慢、图片懒加载、Cookie 横幅,以及偶发的超时。

该任务的数字均为估算值。实际耗时取决于网站、网络和机器性能。

示例任务的四项统计:200 个 URL,400 张截图(桌面和手机),4 个并行 worker,每次运行估计 10 到 20 分钟。
估算假设每个页面耗时 3 到 6 秒,使用 4 个 worker。

维护脚本的成本

脚本本身免费,但并非无需维护。网站改版可能会改变 Cookie 横幅、用作就绪信号的元素或滚动容器,每一处变化都会导致批量任务的一部分失败。

Node.js 还是其他语言

四种语言绑定中的截图 API 几乎相同。Python 中的 page.screenshot(path=..., full_page=True) 与 Node.js 中的 fullPage: true 作用相同。选择流水线中已在使用的语言,使截图步骤与周边代码位于同一个代码仓库和 CI 任务中。

需要为此预留时间。每次部署后运行的批量任务,维护成本很快就能收回。每年只运行两次的批量任务,常因为期间发生了变化,第一次运行就失败。

为截图脚本安装 Playwright

mkdir site-shots && cd site-shots
npm init -y
npm install -D playwright
npx playwright install chromium

最后一条命令会下载与已安装 Playwright 版本匹配的 Chromium 构建。下面的脚本使用 .mjs 扩展名,因此无需打包工具或额外配置即可使用顶层 await。

同时固定版本和浏览器

每个 Playwright 版本都依赖特定的浏览器构建。升级 playwright 包后,请重新运行 npx playwright install chromium,否则脚本会失败,并提示可执行文件不存在。

提交 package-lock.json,使每台机器安装的版本一致。在 Linux CI 运行器上,npx playwright install --with-deps chromium 还会安装浏览器所需的系统库。

命令列表:npx playwright install chromium 下载浏览器,install with-deps 添加 Linux 库,npx playwright screenshot 进行一次性截图,npx playwright codegen 录制定位器。

screenshot 命令适合在把单个 URL 加入批量任务之前进行检查。它还支持 --device 和 --wait-for-selector 参数,因此可以在终端中测试加载缓慢的页面。

截取单个网站的截图

// one.mjs
import { chromium } from 'playwright';

const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });

await page.goto('https://example.com');
await page.screenshot({ path: 'example.png' });

await browser.close();

运行方式为 node one.mjs。浏览器以无头模式启动,即在不打开窗口的情况下渲染页面。无头浏览器词条介绍了这一工作方式。

不设置其他选项时,生成的文件是 视口截图,尺寸为精确的 1920×1080 像素。

让截图可重复的选项

当页面没有变化时,同一页面两次运行应生成相同的图片。以下几个截图选项可以消除常见的噪声来源:动画截取到一半、闪烁的文本光标,以及不同机器上的像素密度差异。

Playwright 截图选项设置面板:fullPage 为 true,type 为 jpeg,quality 为 80,animations 为禁用,caret 为隐藏,scale 为 css,mask 为定位器列表,timeout 为 30000。
await page.screenshot({
  path: 'example.jpg',
  type: 'jpeg',
  quality: 80,
  animations: 'disabled',
  caret: 'hide',
  scale: 'css',
  mask: [page.locator('.ad-slot'), page.locator('.live-chat')],
});

mask 会在每个匹配元素上覆盖一个方框。这是避免轮播广告、实时时间戳和聊天组件导致每次运行结果都不同的最简单方法。

排查空白或错误的截图

截图为空白、显示错误页面或显示机器人验证页面时,需要查看浏览器实际看到的内容。使用 headless: false 和 slowMo 选项启动浏览器,逐步观察;或录制 trace,并在 trace viewer 中打开。

五个编号步骤:使用 headless false 重新运行该 URL,记录 response.status(),录制包含截图和快照的 trace,用 npx playwright show-trace 打开,然后修复等待条件或选择器。

trace 会显示页面的胶片条,以及每个操作对应的网络请求。如果 trace 中页面已完整渲染,而截图却是空白的,通常是截图在渐入动画结束之前执行。设置 animations: 'disabled' 可以解决这个问题。

截取整页截图

加上 fullPage: true 即可截取整个可滚动文档。

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

宽度保持为视口宽度,高度会随页面增长。非常长的页面会生成很高的图片,部分图片查看器会大幅缩小显示。带固定页眉的页面,页眉只会出现在顶部一次,因为 Playwright 是一次性截取整个文档,而不是拼接滚动后的视图。

若只需截取某个元素,可以对 locator 调用 screenshot:page.locator('main').screenshot({ path: 'main.png' })。

截取固定区域

clip 用于截取页面中的一个矩形区域,以 CSS 像素为单位,参数为 x、y、width 和 height。它适用于只需要页面前 1,200 像素的情况,例如落地页首屏加第一段内容。

await page.screenshot({ path: 'hero.png', clip: { x: 0, y: 0, width: 1920, height: 1200 } });

如果 clip 超出了短页面的底部,会在页面边缘被截断。若要求所有页面的 clip 完全一致,需要检查图片高度。

把目标 URL 放入批量脚本

把 URL 保存在文本文件中,这样修改列表时无需改动代码。

// batch.mjs
import { readFile, mkdir } from 'node:fs/promises';
import { chromium } from 'playwright';

const urls = (await readFile('urls.txt', 'utf8'))
  .split('\n').map((l) => l.trim()).filter(Boolean);

await mkdir('shots', { recursive: true });
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });

for (const [i, url] of urls.entries()) {
  const name = `shots/${String(i + 1).padStart(3, '0')}.png`;
  await page.goto(url, { waitUntil: 'load', timeout: 30000 });
  await page.screenshot({ path: name, fullPage: true });
  console.log('saved', url, name);
}

await browser.close();

以零填充的文件名(如 001.png)可以让 Finder 中的文件保持列表顺序。

从站点地图生成列表

在示例任务中,列表来自 sitemap.xml。在运行开始时获取一次,提取所有 loc 条目,这样新增的页面无需编辑 urls.txt 即可被截取。

const xml = await (await fetch('https://example.com/sitemap.xml')).text();
const urls = [...xml.matchAll(/<loc>(.*?)<\/loc>/g)].map((m) => m[1].trim());

按页面命名文件,而不只按位置

001.png 这类索引式名称会在站点地图新增页面时失效,因为之后的每个文件都会顺延一位。根据 URL 路径生成的名称在多次运行之间保持稳定:/pricing/teams 会变为 pricing-teams.png。只有当列表顺序对审核有意义时,才把索引作为前缀保留。

两个 URL 可能映射到同一个名称,例如带和不带末尾斜杠的路径。运行前检查是否存在重复名称,必要时添加索引加以区分。

大型网站会把站点地图拆分为一个索引文件,该文件再指向更多的站点地图。需要向下跟随这些链接一层,并过滤掉 PDF 等文件 URL,因为这些文件不会被渲染为页面。

由六个方框和箭头连接的流程:抓取 sitemap.xml,构建 URL 队列,worker 打开页面并等待就绪元素,滚动以加载懒加载图片,用补零名称保存截图,写入 failed.txt 和 JSON 报告。

为大批量任务增加并发

对于数百个 URL,逐个页面处理速度太慢。可以并行运行几个页面,每个页面使用各自的 page 对象,并限制并发数以控制内存占用。

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

async function worker() {
  const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
  while (queue.length) {
    const [i, url] = queue.shift();
    await page.goto(url);
    await page.screenshot({ path: `shots/${i + 1}.png`, fullPage: true });
  }
  await page.close();
}

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

在笔记本电脑上,4 个 worker 是合理的起点。只有在检查内存占用之后才应提高上限;对于并非自己拥有的网站,应保持较低的并发数,以控制请求频率。

worker 数量如何影响运行时间

在机器或网站成为瓶颈之前,运行时间几乎与 worker 数量成反比下降。按每页估算 4 秒计算,200 个页面使用 1 个 worker 约需 13 分钟,使用 4 个 worker 约需 3.5 分钟,使用 8 个 worker 则会再少一些。

超过这一点后,增加的 worker 主要带来内存压力和服务器负载。如果增加数量后截图开始超时,说明网站或机器已经饱和,应将数量调低。

控制请求频率

每次页面加载还会加载脚本、图片和字体,因此 4 个 worker 可能意味着对同一服务器每秒数十个请求。对于并非自己拥有的网站,应将 worker 数量保持在 1 或 2,并在页面之间加入 1 到 2 秒的暂停。大批量任务应安排在网站的非高峰时段运行,当响应开始返回 429 Too Many Requests 时停止。

页面还是上下文

上面的 worker 共享同一个浏览器上下文,因此所有页面共享 Cookie。若某个页面设置了同意 Cookie,之后的页面就会跳过横幅,截图结果会因 worker 的执行顺序而不同。如果要求每次截图都从干净状态开始,请为每个 worker 使用 browser.newContext() 创建独立的上下文。

截取移动端或平板截图

Playwright 提供了设备描述符,可以同时设置视口、User-Agent、触摸支持和像素比。

import { chromium, devices } from 'playwright';

const browser = await chromium.launch();
const context = await browser.newContext({ ...devices['iPhone 13'] });
const page = await context.newPage();

每个设备使用一个上下文,使 Cookie 和存储相互独立。对于平板,可从同一个 devices 列表中选择 iPad 条目,或直接设置普通视口,例如 768×1024。

像素比与文件大小

手机描述符会把 deviceScaleFactor 设为 2 或 3。宽 390 像素的手机视口,若比例为 3,会保存宽 1,170 像素的图片;较长的页面高度可达数万像素。这一像素数是比例为 1 时同一页面的 9 倍。

在截图选项中设置 scale: 'css',可使图片每个像素对应一个 CSS 像素,适合布局审查。只有当图片需要在手机屏幕上清晰显示时,才保留设备缩放比例。

确认移动端布局已加载

部分网站在服务器端根据 User-Agent 选择布局,另一些则根据 CSS 中的视口宽度选择。设备描述符会同时设置两者,而单纯修改 viewport 只会改变宽度。如果手机截图显示的是被压缩的桌面布局,请从单独的视口改为完整的设备描述符。

使用 WebKit 获得接近 Safari 的渲染

大多数脚本默认使用 Chromium,但在真实设备上,iPhone 的布局由 WebKit 渲染。需要检查 Safari 特有的渲染效果时,使用 npx playwright install webkit 安装,并通过 webkit.launch() 启动。

对比表,列为 chromium 和 webkit,行包括安装命令、与 iPhone 上 Safari 的接近程度、page.pdf() 支持情况,以及在截图批量任务中的典型用途。

处理懒加载内容

许多页面只在图片滚动进入视野时才加载图片。页面加载完成后立即进行的整页截图,下方可能显示为空白方框。截图前先将页面完整滚动一遍。

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);
});

懒加载词条解释了这种现象的原因。

等待图片解码完成

滚动会触发图片请求,但截图仍可能在图片到达之前执行。滚动之后,等待页面上每张图片都报告加载完成。

await page.waitForFunction(() =>
  [...document.images].every((img) => img.complete && img.naturalWidth > 0)
);

缺失的图片 URL 永远不会完成加载,因此在存在缺失文件的页面上,此等待可能会超时。捕获超时并继续截图,然后在报告中标记该页面。

选择合适的等待策略

waitUntil 选项决定 goto 何时返回。

值返回时机
loadload 事件触发。这是默认值。
domcontentloadedHTML 解析完成,图片尚未加载完毕。
networkidle至少 500 毫秒内没有网络请求。

Playwright 官方文档不建议在测试中使用 networkidle,因为带有轮询或分析脚本的页面可能永远不会进入静默状态。更可靠的做法是等待一个标记页面已就绪的元素,例如 await page.locator('main img').first().waitFor()。

单个页面的示例时间线:0.0 秒开始 page.goto,约 1.8 秒触发 load 事件,约 2.1 秒就绪元素可见,约 3.4 秒滚动结束且图片加载完成,约 3.9 秒 page.screenshot 写入文件,30 秒为任一步骤挂起时的超时上限。
示例值,并非实测数据。
做与不做两组对比:等待标记页面就绪的定位器;将 goto 的 waitUntil 保持为 load 并加上定位器等待;为 goto 和截图设置一个超时,例如 30000 毫秒。不要为每个页面添加 5 秒固定的 waitForTimeout;不要在带有分析或聊天组件的页面上使用 networkidle;不要在网页字体加载之前截图。

固定等待 5 秒,200 个页面就会给单个 worker 增加约 17 分钟的空闲时间,而且仍会在需要 6 秒的那一个页面上失败。定位器等待会在页面就绪后立即返回。await page.evaluate(() => document.fonts.ready) 可以处理字体的问题。

添加重试

网络会出错,某些页面会超时一次,之后又能正常加载。将截图操作包装在一个小型重试函数中。

async function capture(page, url, path, tries = 3) {
  for (let attempt = 1; attempt <= tries; attempt++) {
    try {
      await page.goto(url, { timeout: 30000 });
      await page.screenshot({ path, fullPage: true });
      return true;
    } catch (error) {
      console.warn(`attempt ${attempt} failed for ${url}: ${error.message}`);
    }
  }
  return false;
}

将返回 false 的 URL 收集起来写入 failed.txt,以便单独重新运行。

不要重试不会改变的情况

重试对超时和连接中断有帮助,但对返回 404 或 500 的页面无效。对 20 个缺失页面进行三次尝试会浪费数分钟。page.goto 返回一个 response 对象,因此应检查 response.status(),状态码为 400 或更高时跳过重试。

在报告中将状态码与 URL 一并记录。站点地图中的 404 列表本身就有用,因为它指出了过期的站点地图条目。

从崩溃的页面中恢复

页面在负载较重的网站上可能崩溃,此后该 page 对象上的每次 goto 都会抛出异常。在重试函数内部,发生错误后关闭该页面,并为下一次尝试打开一个新页面。worker 会继续运行,单个出错的 URL 不再导致其队列中的其余任务失败。

实用的文件夹结构

site-shots/
  urls.txt
  batch.mjs
  shots/
    2026-03-24/
      desktop/001.png
      mobile/001.png
  failed.txt

每次运行使用一个带日期的文件夹,可以保留较早的截图。按设备划分的子文件夹使并排审查更简单。

CI 运行应上传的内容

在 CI 中,运行器在任务结束后会被删除,因此截图必须作为构建产物上传。上传带日期的文件夹、失败列表,以及一个小型 JSON 报告,报告中包含每个页面的 URL、状态、文件名和耗时。

CI 产物的文件树:一个带日期的运行文件夹,内含桌面和手机图片文件夹、每个 URL 对应一条记录的 report.json 文件,以及 failed.txt 列表。
包含六项的检查清单:Playwright 版本已在 package-lock.json 中固定;浏览器已通过 install --with-deps 安装;与参考机器相同的字体已安装在运行器上;上下文中已设置 locale 和 timezoneId;运行文件夹已作为构建产物上传;failed.txt 非空时任务标记为失败。

将本次运行与上一次运行进行比较

两个带日期的文件夹只有在有人查看差异时才有用。对于少量页面,可在 Compare Images 中打开对应的文件,该工具提供滑块和像素差异视图。对于数百个页面,Playwright Test 的 toHaveScreenshot 断言会将每张截图与已保存的基线进行比较,差异超过阈值时测试失败。

字体是笔记本电脑运行与 CI 运行之间最常见的差异来源。在没有安装网站回退字体的 Linux 运行器上,文本宽度会不同,因此换行和页面高度都会改变。

常见错误

  1. 遗漏 await。 脚本会在文件写入之前退出。
  2. 崩溃后继续使用同一个页面。 当 goto 抛出 target-closed 错误时,应打开新页面。
  3. 所有页面都依赖 networkidle。 部分页面永远不会进入该状态,每一行都会超时。
  4. 截图中留下 Cookie 横幅。 使用定位器点击同意按钮,或通过 page.addStyleTag 隐藏横幅。
  5. 忽略网站条款。 对于并非自己拥有的网站,应检查其服务条款和 robots 规则,并控制请求频率。

横幅因网站而异,因此单一选择器很难适用于整个列表。可以依次尝试一组已知的同意按钮,每个设置 2 秒超时;若均未找到,则使用 CSS 隐藏横幅容器作为回退方案。

const consent = page.getByRole('button', { name: /accept|agree|reject all/i });
if (await consent.first().isVisible({ timeout: 2000 }).catch(() => false)) {
  await consent.first().click();
}

点击同意按钮会改变该上下文中的 Cookie,之后的页面都会受影响。每个 worker 使用独立上下文时这没有问题;若多个 worker 共享上下文,这又是截图结果因执行顺序而不同的另一个原因。

Playwright 截图脚本的替代方案

  • Puppeteer 为 Chrome 和 Firefox 提供类似的 API。参见 如何使用 Puppeteer 自动化网站截图。
  • Playwright Test 会将截图与基线进行比较。参见 Playwright 截图测试指南。
  • 托管截图 API 由服务端运行浏览器,并通过 HTTP 返回图片。
  • Temposhot Website Capture 可在 Mac 上本地运行 URL 列表,无需编写代码。支持 Desktop 1920×1080、Tablet 768×1024 或 Mobile 390×844,支持 Viewport 或 Full page,最多同时运行 10 个浏览器,每一行都有 Retry。

脚本选项与无代码对应设置

本文中的大多数选项在 Website Capture 中都有直接对应的设置。区别在于由谁做出选择:开发者在代码中设置,还是任何能粘贴列表的人设置。Website Capture 没有调度器,也不支持登录,因此 CI 运行和需要登录的页面仍需使用脚本。

对比表,列为 Playwright 脚本和 Website Capture,行包括视口、整页、等待条件、固定延迟、图片类型与质量,以及 worker 数量。

常见问题

Playwright 需要显示浏览器窗口吗?

不需要。浏览器默认以无头模式启动。调试时若想观察页面,可向 chromium.launch 传入 headless: false。

Playwright 能保存 JPEG 而不是 PNG 吗?

可以。在截图选项中设置 type: 'jpeg',并将 quality 设为 0 到 100 之间的值,或使用 .jpg 文件名。

为什么整页截图被截断了?

部分页面在内部元素中滚动,而不是在文档中滚动。可以对该元素使用定位器截图,或在截图前修改其 CSS 高度。

Playwright 能截取需要登录的页面吗?

可以。登录一次,使用 context.storageState({ path: 'auth.json' }) 保存会话,之后创建的上下文使用 storageState: 'auth.json'。该文件包含会话 Cookie,应排除在版本控制之外。

如何让广告和时间戳不在每次截图时变化?

将它们的定位器传入 mask 选项。Playwright 会在每个元素上覆盖一个纯色方框,这样多次运行之间就不会产生虚假差异。

为什么 CI 中的截图与我的笔记本电脑不同?

通常是字体、像素比和语言区域设置不同。在运行器上安装相同的字体,设置 scale: 'css',并在上下文中固定 locale 和 timezoneId,使日期和数字格式保持一致。

参考资料

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

Temposhot Website Capture 接收粘贴的 URL 列表,在你的 Mac 上截取每个页面,无需维护脚本。参见 网站截图自动化,或从 Website Capture 快速开始 入门。

playwrightwebsite-captureautomationjavascript

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

把重复性截图整理为成品

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

相关文章