返回知识中心
部署

Headless 浏览器可靠截图工作流

从 profile viewport、页面就绪信号、原生整页截图到长图底部复核,建立稳定的 headless 浏览器截图流程,并为容器预留共享内存。

BotBrowser Team

文档中心

想直接看维护中的产品文档?

这篇文章对应的主题已经有文档中心页面。需要规范流程、当前参数和长期参考时,优先看 docs。

BotBrowser 支持在受控浏览器上下文中重复截图,但不证明不同主机、显示器、字体或应用状态具有视觉一致性。请用下面的流程测试自有页面,并记录实际核验的边界。

摘要

保持 profile viewport 不变,等待页面自己的就绪和结束标记,使用原生整页截图,复核长图下部,并按最长获批图片为容器预留内存。BotBrowser 提供可重复的受控截图,不提供普遍视觉等价保证。

内容导航

先固定 profile viewport

截图只有在呈现了浏览器实际要呈现的页面时才有意义。对于使用 profile 的 BotBrowser 会话,viewport 是渲染契约的起点。它会影响换行、响应式断点、 固定导航、图片尺寸,以及首屏能看到的内容范围。它不是截图脚本可以随手 替换的临时参数。

常见问题是让自动化库选择默认 viewport。默认 context 可能比选定 profile 更 窄,页面会在截图前就发生重新排版。另一个问题是为了做整页图而把 viewport 改成文档高度。这样会改变响应式布局,也会让输出不再代表这次浏览器会话。

如果 profile 应该提供尺寸,就不要在 context 中设置 viewport。Puppeteer 使用 defaultViewport: null,Playwright 创建 context 时省略 viewport 覆盖。如果 业务确实需要不同尺寸,应把它写入截图规格并与 profile 一起固定,在同类任务 中始终使用同一选择。

const browser = await chromium.launch({
  executablePath: process.env.BROWSER_BINARY,
  args: [`--bot-profile=${process.env.BROWSER_PROFILE}`],
});

const context = await browser.newContext();
const page = await context.newPage();

示例故意没有 viewport 设置。这个省略本身很重要。不要为了匹配文档高度而 调用 viewport resize。长文档应当在 profile viewport 中保持长文档状态。

截图流程从 profile viewport 和就绪信号开始,经过原生截图后进行复核。

截图证明了什么,页面又报告了什么

截图是对像素的证词,不是对页面计算结果值的断言。把两者放在一起看,才能让每个复核问题落到正确的产物上。

信号截图显示什么同一页面报告什么差距为何重要
viewport 宽度布局绘制时的宽度window.innerWidth 和 window.visualViewport.width调整尺寸后拍的图仍然看着合理,而报告的宽度能说明实际命中了哪个断点。
屏幕尺寸与模拟屏幕无关screen.width 和 screen.heightprofile 屏幕可能与上下文 viewport 不同,只有报告值能暴露这一点。
设备像素比输出图片的像素尺寸window.devicePixelRatio宽 3840 的图片可能来自比率为 2 的 1920 viewport,也可能来自比率为 1 的 3840 viewport。
已加载字体族事后看到的字形document.fonts.status 和 document.fonts.check()小图上备用字形看起来很接近,而报告的字体状态会点明实际使用的字体族。
动画状态冻结的某一帧element.getAnimations() 和过渡时间同一页面的两张图可能只是因为截图落在不同帧而不同。
元素几何元素被绘制的像素element.getBoundingClientRect()被截断的页脚在图片顶部看着正常,矩形会给出真实位置。

图片回答不了的问题,就用属性表。如果有人问是哪个断点、哪种字体或哪一帧,答案来自同一会话中读到的计算值,而不是再看一眼图片。

用页面自己的信号定义就绪

导航完成不等于画面就绪。页面可能已经完成初始导航,但应用仍在放置卡片、 加载图表、选择语言,或者把备用字体替换成正式字体。在这些步骤之间截图, 得到的可能是有效图片,却不是有效页面状态。

给每个截图路由定义一份小型就绪契约。页面拥有的标记通常比固定延时可靠, 因为页面可以在需要的内容出现后再设置标记。标记可以是带稳定属性的元素、 页面中呈现的状态,或只在最终布局完成后出现的路由组件。契约应描述读者要 看到的内容,不应依赖浏览器的私有事件。

document.readyState 在 load 事件之后才变成 'complete',这仍不能说明 最后一次布局已经结束。区域更新时设置 aria-busy="true"、结束时移除该属性, 就是一个标准可用的信号;document.fonts.status 会给出 'loading' 或 'loaded',反映当前字体集合的状态。

一个实用契约通常包含以下条件:

  • 主内容容器已经出现并可见。
  • 当前视图需要的数据已经渲染。
  • 数据到达后引起的布局变化已经结束。
  • 标题、标签和正文使用的字体已经准备好。
  • 截图范围内的图片已经加载,或已经进入明确的备用状态。
  • 整页截图有清楚的结束标记。

结束标记对渐进式页面尤其重要。第一个卡片出现,并不能说明报告下半部分 已经完成。等待页面自己的结束标记,才能为截图任务提供稳定边界。如果路由 没有自然结尾,就定义一个明确的截图范围,不要把持续增长的文档当成已经完成。

可复用的等待顺序

下面的顺序使用路由标记、字体状态和图片状态,不会无条件睡眠一段时间。请 按具体页面替换选择器,并把标记作为页面契约的一部分,不要把一个通用选择器 套在所有路由上。

async function waitForCaptureReady(page) {
  await page.waitForLoadState('domcontentloaded');
  await page.locator('[data-capture-ready]').waitFor({ state: 'visible' });
  await page.locator('[data-capture-end]').waitFor({ state: 'visible' });

  await page.evaluate(async () => {
    await document.fonts.ready;
    await Promise.all(
      [...document.images].map(image =>
        image.complete
          ? undefined
          : new Promise(resolve => {
              image.addEventListener('load', resolve, { once: true });
              image.addEventListener('error', resolve, { once: true });
            })
      )
    );
  });
}

图片分支把失败的可选缩略图当作已经进入备用状态,避免一张无关图片让整 个任务一直等待。如果页面要求每一张图片都必须存在,就让页面标记只在所需 资源成功后出现,并对缺失资源记录清楚的失败。

网络空闲可以作为辅助信号,但不应是唯一信号。统计脚本、实时更新、长连接 和轮询会让页面在可见内容完成后仍然保持忙碌。相反,客户端渲染也可能在没有 新的网络请求时完成。页面契约应表达读者真正需要看到的状态。

不要在导航后加入固定 sleep。快速页面会白白等待,慢页面仍可能过早截图, 同时隐藏页面没有就绪的真实原因。稳定标记能在路由变化时提供可复核的依据。

不拉伸 viewport,使用原生整页截图

需要首屏图时,使用普通截图选项捕获当前 viewport。需要文档图时,使用自动 化库提供的原生整页选项。这个选项能保留 profile viewport,同时包含折叠线 以下的内容,是整页请求应使用的边界。

await waitForCaptureReady(page);

await page.screenshot({
  path: outputPath,
  fullPage: true,
  type: 'png',
});

不要在调用前把 viewport 高度设成文档高度。这个捷径会改变响应式布局,可能 破坏依赖正常滚动的懒加载,也会让图片不再代表选定的 profile。它还可能创建 一个超出显示后端或容器内存能力的巨大渲染面。

一条确定性的截图顺序

顺序比任何单次等待都重要。按顺序执行这些步骤,每一步都回答上一步留下的问题。

  1. 用 profile 启动,不要覆盖 viewport,让 Puppeteer 的 defaultViewport: null 或 Playwright 中未改动的上下文报告 profile 尺寸。
  2. 用 waitUntil: 'domcontentloaded' 导航后停下。load 事件说明解析器结束,不说明布局最终确定。
  3. 等待页面自己的就绪标记([data-capture-ready]),让依赖数据的布局已经运行。没有它,快速运行会截到空容器。
  4. 等待页面自己的结束标记([data-capture-end]),让渐进内容完整。没有它,长报告会丢掉下半部分。
  5. 等待 document.fonts.ready,让文字用 profile 的字体包绘制,而不是用度量永远对不上的备用字体。
  6. 让截图范围内的每个 img 以 load 或 error 收尾,避免迟到的图片在两次运行之间移动布局。
  7. 用正常滚动预热懒加载区域,再回到顶部并重做第 3、4 步。滚动会加载内容,但不能替代原生截图。
  8. 在未改动的 viewport 上用 fullPage: true 和 type: 'png' 截图,让图片边界与实测布局一致。
  9. 把 window.innerWidth、window.devicePixelRatio 和解析后的字体状态记录在文件旁边,让后续对比点明变化的设置。

先在输出卷里用临时名称写入文件,写入关闭后再移动到目标位置,这样被取消的任务不会用半成品覆盖上一张已批准的图片。

如果页面有懒加载内容,可以先用正常滚动让目标范围进入加载流程,然后再调用 原生整页选项。滚动只是请求页面加载指定范围,不是整页截图的替代品。回到 需要的顶部位置,再次确认就绪契约,然后让原生截图选项收集整份文档。

无限滚动必须有明确边界。应事先决定截图在当前已加载内容、指定章节或路由 完成状态处结束。不要让任务在页面持续追加项目时无限滚动。边界固定的报告 更容易复核、存储和比较,图片高度也不会被时机左右。

固定元素和响应式布局

吸顶标题、同意面板、聊天按钮和浮动工具栏可能覆盖整页截图中的内容。如果 这些元素不属于报告,截图路由应提供打印或归档呈现方式。与其在图片生成后 删除元素,不如使用小型的截图专用样式。这个呈现选择应留在页面或路由配置 中,同时保持 profile viewport 不变。

同类任务使用同一个 viewport。宽度变化会改变换行和卡片位置,即使页面数据 完全相同。device pixel ratio 变化还会改变输出尺寸和图片组装期间所需的内存。 把这些值作为截图元数据记录,而不是让脚本临时决定。

单独检查长图下部

长图下半部分需要独立复核。图片顶部可能看起来正确,但页脚、最后一行、边 框或背景色可能被截断,底部也可能仍显示加载占位。只从左上角打开图片,很 容易漏掉这些问题。

给页面添加稳定的结束标记,并把它放进复核用的页面样本。标记可以是页脚标题、 报告最后一个章节,或读者能理解的完成区域。它应当出现在最终图片中,而不是 一个隐藏的浏览器实现标志。

长图可以按下面的顺序检查:

  1. 打开缩小预览,确认文档确实到达预期结尾。
  2. 以可读比例打开最后一段,检查最后一行、页脚、边框和背景填充。
  3. 将图片尺寸和文件大小与截图记录对照。突然变化可能表示布局移动、章节 缺失或资源加载失败。identify -format '%wx%h %b' shot.png 打印的正是 记录中应保存的两个值。

只截下部这一段,Playwright 用 page.screenshot({ clip: { x: 0, y: 800, width: 1280, height: 400 } }), 不必整页重拍再看局部。

自动质量检查应关注页面契约和生成的文件。确认结束标记在截图前可见,输出文 件存在,并且图片可以正常解码。保留少量有代表性的长页面样本用于视觉复核。 在受控回归套件中,逐像素比较可以提供帮助,但仍应结合人类可读的检查,因 为字体、平台渲染和合理的内容变化都可能影响单个像素。

不要只把文档高度当成成功条件。高度可能在内容绘制前就已经存在,也可能在图 片生成后继续增长。结束标记和产物复核回答了更有用的问题:预期内容是否真正 进入了最终图片。

为容器中的高图预留共享内存

容器尺寸属于服务器整体配置的一部分,相关参数和显示路径见无头服务器配置。长截图消耗的资源不只是磁盘上的 PNG 或 JPEG 大小。浏览器在组装页面时需要 工作空间,自动化层接收结果时也需要空间,编码器写文件时还需要临时空间。 高 DPI profile 会同时放大像素面。共享内存挂载过小的容器,往往只在最长页 面上失败,因此看起来像偶发的截图问题。

共享内存要按实际工作负载预留。计算 profile viewport、device pixel ratio、 批准范围内的最长文档、图片格式,以及同一容器可能同时存在的截图数量,并留 出浏览器启动和普通页面渲染的余量。通过容器运行时显式设置 /dev/shm,或 使用 Compose 的等效设置,不要依赖运行时很小的默认值。

docker run --rm \
  --shm-size="${BROWSER_SHM_SIZE}" \
  -e BROWSER_PROFILE=/run/profiles/capture.enc \
  screenshot-worker

共享内存值应放在部署配置中,因为它取决于批准的页面集合。不要把短页面测试 使用的数值直接复制给长文档工作负载。页面集合改变后,应重新捕获最有代表性 的长页面,并同时查看输出和容器资源状态。

容器的其他资源也要一起规划。输出目录要能容纳完整图片和写入期间的临时文 件,挂载目录的所有权要与 worker 一致,浏览器 profile 存储要和最终产物分开。 失败写入不能用半张图片替换上一次已经批准的图片。可以先在同一个输出卷内 写入临时名称,关闭文件后再使用文件系统支持的原子操作完成替换。

如果批准的 Linux 配置使用 Xvfb,其显示面应覆盖 profile viewport,并使用已经 验证过的颜色配置。原生 headless 模式可能不需要 Xvfb。不要因为图片很高就 自动加入虚拟显示。选择一个支持的显示路径,在目标页面上验证,然后让开发和 生产保持一致。

让输出格式服务于用途

PNG 更适合文字、图表、界面和视觉回归,因为小标签和细边缘不会出现压缩伪影。 JPEG 更适合以照片为主、文件大小比边缘精度更重要的预览。把格式写入截图规格, 避免后续任务悄悄改变复核标准。

高 DPI 输出适合需要放大报告或保存归档记录的场景,但会增加内存、传输和存 储消耗。device pixel ratio 应由选定 profile 提供,不要为了让图片看起来更 锐利而在 context 中覆盖它。若消费者只需要小预览,应在批准的原图之后生成 派生图,而不是改变浏览器的渲染契约。

文件名应稳定记录路由、profile family、截图模式、格式和创建日期,同时不要 把页面私密数据放进文件名。生成派生图时保留原始产物,后续复核才能区分渲染 变化和缩略图处理变化。

一段清晰的生产流程

截图函数应让执行顺序一眼可见:创建使用 profile 的页面,导航,等待页面契约, 需要时调用原生整页选项,并在失败时关闭页面。

async function capture(page, url, outputPath, fullPage = false) {
  await page.goto(url, { waitUntil: 'domcontentloaded' });
  await waitForCaptureReady(page);
  await page.screenshot({
    path: outputPath,
    fullPage,
    type: 'png',
  });
}

如果多个路由使用同一份批准的会话策略,可以把浏览器创建放在函数外。页面 所有权要清楚,产物完成后关闭页面,错误要连同路由和截图模式一起交给任务 运行器。失败的截图不能静默地沿用旧文件。

批量任务中的每个页面都应走同一套就绪和产物检查。不要为后续页面增加跳过 结束标记的快速路径。第一张图片通常看起来健康,慢页面才会暴露共享内存、 晚到字体或不断增长的文档问题。统一契约能让这些差异更容易解释,样本本身则跟发布一起登记,见browser 版本验证。

常见现象和处理顺序

图片空白或大部分空白。 先确认导航进入预期路由,页面就绪标记出现, 并确认选定的显示路径可用。查看浏览器输出,找出页面在标记出现前是否已经 停止,而不是先加更长的 sleep。截图本身是一次 CDP 往返: Page.captureScreenshot 把整张图放在一个 base64 字符串里返回,传输 中断不会留下可比的半成品文件。

图片在最后章节前结束。 确认结束标记出现,懒加载范围已经通过正常滚动 准备好,并确认增量页面有明确边界。随后检查产物下部。

多次截图中的文字位置变化。 确认使用了同一个 profile viewport、device pixel ratio、字体、配色和页面数据。等待字体完成,并在截图前检查布局移动。

只有长页面让容器失败。 同时检查共享内存挂载、临时空间、输出卷和显示 面。短页面不能证明最长图片一定放得下。调整资源后重新捕获代表性长页面,并 把部署设置与工作负载记录放在一起。--disable-dev-shm-usage 把浏览器的 共享内存文件移到 /tmp,改变的是压力出现的位置,不是 profile viewport 和 像素表面能否装下。

浮动控件覆盖报告。 使用路由的归档呈现,或者截取有意义的内容元素,不 要在图片生成后修改像素。确认该呈现仍在 profile viewport 中渲染。

图片中有加载占位。 让页面就绪标记等待所需资源,或明确声明备用状态是 有意的。通用网络状态无法判断占位是否可以被读者接受。

同一页面的两种文件大小

作为假设对比,同一路由、同一 profile、同一脚本可能在一次运行得到约 218 KB 的 PNG,另一次约 402 KB。两张图都可能正常打开;应逐层隔离原因,不要把这些示例大小当作实测结果。

  1. 先用 identify -format '%wx%h %b' shot.png 比较像素尺寸。尺寸相同而文件大小不同,指向内容、文字或压缩;尺寸不同,指向 viewport。
  2. 排除 viewport。在每次运行中读取 window.innerWidth 和 window.devicePixelRatio。比率从 2 变成 1 会让同一 viewport 的像素数变为四分之一,因此可能明显改变编码后的大小;实际差异仍取决于图像内容和压缩方式。
  3. 排除字体加载。截图前读取 document.fonts.status 和 document.fonts.check('16px "Your Heading Family"') 的结果。在 status === 'loading' 时截下的运行绘制的是备用字形,它们的压缩方式不同,并把行高挪开。
  4. 排除动画。调用 document.getAnimations(),确认长过渡或加载指示器处于 playState === 'finished'。在过渡中途截到的帧会改变抗锯齿和背景填充,编码器因此输出不同的码流。
  5. 最后才比较内容。对比两个文件的文字区域;如果文字不同,说明页面数据变了,两次截图都没有错。
  6. 把修正放回就绪契约:等待字体族和结束标记,并对截图路由禁用或等待非必要动画。

先定位是哪一层,再改脚本。仅凭文件大小差异只能说明输出变了,不能说明这三个输入中哪个移动了。每次运行测两个值,报告 viewport 和字体状态,就能一次把它们分开。

建立发布前复核样本

保留一小组有代表性的路由用于发布复核,包括短页面、长报告、使用网页字体的 页面、带懒加载媒体的页面,以及使用选定 profile 的响应式页面。重点不是收 集所有页面,而是覆盖会改变图片边界或资源需求的状态。

每条路由记录 profile 引用、viewport 来源、截图模式、格式、显示路径、容器内 存策略、就绪标记和结束标记。检查首屏和下部,确认产物可解码、方向正确,并 写入预定位置。合理变化发生时,也要像维护页面一样更新截图记录。

可靠截图是渲染工作流,而不是最后一次按钮点击。profile 提供稳定 viewport, 页面提供有意义的就绪状态,原生整页选项在不改变 viewport 的前提下抵达折叠 线以下,复核则专门检查最容易丢失的长图下部。容器共享内存和输出空间补齐 长页面与高 DPI 文档的资源契约,让截图可以在不同环境中用于视觉复核、归档 和经过授权的质量检查。

BotBrowser 支持在受控上下文中重复截图;它不能保证不同主机、显示器、字体或应用状态的视觉一致性。

实用结论

把截图当作渲染合同:固定 viewport 和输入,等待可见就绪状态,使用原生整页截图,检查长图下部,并保留回退路径。记录 BotBrowser 观察到的结果,不承诺所有主机、显示器、字体或应用状态都一致。分享或归档图片前,遮盖凭据和个人数据,并确认其中的第三方内容已获授权。

来源

另见浏览器版本验证和 WebDriver BiDi 事件。

#Screenshot#无头模式#部署#Reliability#Playwright

本文属于指南: 浏览器部署与发布:运维决策地图

让 BotBrowser 从研究走向生产

先用这些指南理解模型,再进入跨平台验证、隔离上下文和面向规模化的浏览器部署。