做了这么多年 Web 自动化,我经常遇到这种需求:产品经理甩给我一份 500 个 URL 的 Excel,说“帮我把每个页面截个图,我要拿去评审”。刚入行那会儿,我老老实实地打开浏览器,F12 切到移动端模式,一个一个截,截到 50 个就想把电脑扔了。后来痛定思痛,干脆做了一个“网页在线批量截屏”的小服务,把整个流程自动化。这篇文章分享的,就是我从零到一搭出这套工具的全过程,包括技术选型、核心代码、上线后踩过的各种坑,适合所有需要做整站截图留档、页面回归巡检、电商商品页批量采集的朋友参考。
1. 为什么需要“网页在线批量截屏”:先搞清楚你究竟在解决什么问题
1.1 批量截图真实业务场景
很多人觉得截图是个小事,但一旦带上“批量”两个字,性质就变了。我归类下来,最常见的是下面这几种场景:
- 站点改版之前的全站快照:改版前把线上所有页面截下来,作为历史版本依据,方便后续对比是否出现样式丢失、功能回退。
- 商品或内容页批量留档:电商运营需要定期把爆款详情页、活动页面保存成图片,用于素材归档、竞品分析。一次可能就是几百上千个链接。
- 页面自动化巡检:每天晚上跑一轮批量截图,检查全站页面是否白屏、是否挂了、关键模块是否渲染,截图就是最直观的“日志”。
- PDF/报告生成类产品:把用户填写的表单、图表生成网页报告,再转成图片用于邮件或者审批流。这里面对的不是几个页面,而是动态生成的 URL。
这些场景都有一个共同特征:数量大、不能靠人工、必须无人值守地跑。本地手动截图方案顶多能撑 20 个页面,而且你没法保证每张图的视口宽度、缩放比例、等待时间完全一致。一旦截图参数不统一,后面对比分析就全乱套。
1.2 为什么本地手动方案撑不住
手动截图的问题不只是费时间,更致命的是“状态不一致”。同一个页面,你等 1 秒截图和等 5 秒截图,图片内容完全可能不同。懒加载图没出来、动画没播完、字体还没加载完,都会导致截图残废。
更麻烦的是,多标签页并行截图在你本地基本行不通。浏览器同时开十个标签页,内存占用瞬间拉满,风扇狂转不说,页面之间还会互相抢占资源,截图出来的时间点完全不可控。人肉 F12 这种方式,适合临时来一张两张,撑不起“批量”这个词。
1.3 在线化服务的边界在哪里
“网页在线批量截屏”说白了就是把这个过程搬到服务端:你提供一个 URL 列表,服务端自动用无头浏览器挨个打开页面、等页面渲染稳定、截图、存好,最后把图片地址返回给你。它解决的不只是“截得慢”,而是“可重复、可调度、可监控”。
不过我也要泼一盆冷水:并不是所有截图需求都适合服务端做。如果你只是想截自己当前浏览器里登录态下的页面,那服务端拿不到你的 Cookie 和 Session,就很麻烦。所以在线批量截图服务通常配合以下手段来解决:
- 通过参数传递带有时效性的鉴权 Token,服务端再注入到浏览器的 Cookie 或 LocalStorage。
- 只能在公网/IP 白名单内访问的页面,把服务所在机器加入白名单。
- 只能登录后可见的页面,在截图前用账号密码自动执行一次登录流程。
想清楚自己要解决的是“量的问题”还是“权限的问题”,再决定要不要搭这套东西。接下来我假定你的目标是:给一堆不需要复杂登录或者能用 Token 解决的 URL 做批量截图,这是最典型也最容易跑通的形态。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型:为什么最后选了无头浏览器而不是截图库
2.1 桌面截图库和渲染引擎的差距
市面上有不少“网页转图片”的开源库,比如纯前端的 html2canvas、dom-to-image,后台常用的 wkhtmltoimage,Python 生态的 imgkit 等等。我早期图省事,直接用 html2canvas 在浏览器端把 DOM 画到 Canvas 上,再导出图片。结果被现实狠狠教育了一顿:
- 跨域图片会把 Canvas 污染,导出直接报错。
- 现代 CSS 特性(比如 oklch 颜色、容器查询、某些滤镜)支持不完整,截出来的图跟真实浏览器渲染差异很大。
- 页面一旦复杂,DOM 节点一多,生成一张图可能要好几秒,而且内存占用令人窒息。
- 关键是 html2canvas 只能截“当前 DOM 的可视状态”,它不能替你等待网络请求完成、不能处理懒加载。
本质上,这类库是在“模拟浏览器渲染”,而不是“真的让浏览器渲染一遍”。真实需求是“让浏览器渲染完,然后截屏”,所以正确的选择只能是调用真实浏览器内核。
2.2 无头浏览器三巨头怎么选
真实浏览器内核里,最主流的是 Chromium 系的无头浏览器方案。我用过一个对比表格,方便你直接参考:
| 方案 | 底层驱动 | 批量截图契合度 | 自动等待能力 | 多浏览器支持 | 上手成本 |
|---|---|---|---|---|---|
| Puppeteer | Chrome DevTools Protocol | 高,生态最成熟 | 强,手动控制精细 | 仅 Chrome/Chromium | 较低 |
| Playwright | CDP + 自有协议 | 高,内置自动等待 | 很强,动作自动重试 | Chrome/Firefox/WebKit | 中低 |
| Selenium | WebDriver | 中,偏测试场景 | 弱,基本靠手动轮询 | 广 | 中 |
Puppeteer 是我个人最常用的,原因很朴素:它就是为“用代码操作无头 Chrome”而生的。Playwright 在测试领域的自动等待确实很强,但如果只是做批量截图,Puppeteer 的 API 更直接,文档和社区的例子也更多。Selenium 则更偏浏览器自动化测试,拿来做批处理截图不是不可以,但有点“杀鸡用牛刀”,而且对页面加载状态的把控反而更弱。
2.3 核心原理:Chrome DevTools Protocol
Puppeteer 背后的核心协议是 Chrome DevTools Protocol,简称 CDP。你可以把它理解成 Chrome 给开发者留了一整套控制接口:打开页面、模拟点击、执行 JavaScript、监听网络事件,甚至直接调用 Page.captureScreenshot 来截屏。
批量截图服务说白了就是两件事:
- 通过 CDP 让 Chromium 按指定 URL、指定视口大小去加载页面。
- 等待页面满足“可截图的时机”后,再调用 CDP 的截图接口把渲染结果保存成图片。
CDP 还有个好处是能拿到浏览器内部的真实渲染数据,比如网络空闲时间、字体加载状态、页面布局是否稳定。这些信息是 html2canvas 这类库永远拿不到的,也是“在线批量截屏”能够“截得准”的基础。
2.4 语言选型:Node.js 还是 Python
如果你只是自己写个脚本用,Python 生态里有 pyppeteer、playwright-python,都能做。但我最终选了 Node.js,原因是:
- Puppeteer 原生就是 Node.js 库,安装、调用、类型提示都最顺。
- 批量任务天然适合异步并发模型,Node.js 的事件循环在处理大量 IO 等待(等页面加载、等网络请求)时非常合适,写起来也直观。
- 后面如果要包一层 HTTP API,Node.js 的生态一样顺手。
当然,语言没有绝对优劣。如果你团队全是 Python,用 playwright-python 也完全没问题。核心思路不变:用真实浏览器内核渲染 + 驱动协议控制 + 异步任务调度。
3. 批量任务的核心实现:URL列表、队列调度与页面等待条件
3.1 拆分需求:你截的是长图还是视口图
编码之前先把截图类型定下来,否则后面全是返工。我把截图需求分为三种:
- 完整页面截图:
fullPage: true,把整个页面高度都截出来,适合商品详情页、文章页。 - 视口截图:只截当前窗口大小范围内的内容,适合后台仪表盘、需要固定比例做汇报的页面。
- 元素截图:只截页面里某个选择器对应的区域,比如只截某个图表、某个表格,适合做数据监控。
一个批量任务里,这三种截图类型很可能混着来。所以我设计任务的时候,会给每个 URL 带一个 type 字段,代码里再按类型选择不同的截图参数。
3.2 任务队列与并发控制的朴素实现
批量截图最忌讳“一个页面加载完了再开下一个”,那样 1000 个 URL 要跑到天荒地老。但也不能无限并发,因为每个无头标签页都要吃内存。比较稳妥的做法是:同一个浏览器实例,在里面并发开多个 Page 标签页,把并发控制在一个合理区间。
下面是一个可以直接改着用的核心脚本骨架:
javascript复制const puppeteer = require('puppeteer');
const fs = require('fs/promises');
const path = require('path');
const OUTPUT_DIR = './shots';
const CONCURRENCY = 3;
const TASKS = [
{ url: 'https://example.com/pages/1', name: 'page-1', type: 'fullpage', waitSelector: '.content' },
{ url: 'https://example.com/pages/2', name: 'page-2', type: 'viewport' },
// 批量任务从这里读:可以从 JSON/Excel/数据库查出来之后填入
];
async function captureOne(browser, task) {
const page = await browser.newPage();
try {
await page.setViewport({
width: 1440,
height: 900,
deviceScaleFactor: 2,
});
// 等待网络基本空闲,超时给 60 秒
await page.goto(task.url, {
waitUntil: 'networkidle2',
timeout: 60000,
});
// 页面自定义元素出现后再截,比 sleep 可靠得多
if (task.waitSelector) {
await page.waitForSelector(task.waitSelector, { timeout: 15000 });
}
// 等待字体加载完成,避免中文变豆腐块
await page.evaluate(() => document.fonts.ready);
const filePath = path.join(OUTPUT_DIR, `${task.name}.png`);
if (task.type === 'fullpage') {
await page.screenshot({ path: filePath, fullPage: true });
} else if (task.type === 'element' && task.selector) {
const el = await page.$(task.selector);
await el.screenshot({ path: filePath });
} else {
await page.screenshot({ path: filePath });
}
console.log(`[成功] ${task.url} -> ${filePath}`);
} finally {
await page.close();
}
}
async function main() {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const queue = [...TASKS];
const workers = Array.from({ length: CONCURRENCY }, async () => {
while (queue.length) {
const task = queue.shift();
try {
await captureOne(browser, task);
} catch (err) {
console.error(`[失败] ${task.url}: ${err.message}`);
}
}
});
await Promise.all(workers);
await browser.close();
console.log('全部任务处理完成');
}
main();
这段代码的核心思想是:一个浏览器进程 + 若干个并发 Worker + 一个共享任务队列。每个 Worker 从队列里拿任务,执行完换下一个。用 Promise.all 等所有 Worker 退出。
3.3 为什么 waitUntil: 'networkidle2' 比 sleep 可靠
新手最容易犯的错是 page.goto 之后直接 sleep(3000),然后截图。这有三层问题:
- 图片、埋点上报、广告脚本随时可能在发请求。3 秒后网络未必空闲,页面未必渲染完。
- 3 秒可能太长,浪费大量时间;也可能太短,截图时机不统一。
- 等待是基于“猜”,不是基于“事实”。
所以我强烈建议用 waitUntil 配合“关键元素出现”的条件来判断。networkidle2 的含义是:500 毫秒内网络请求数量不超过 2 个,就认为页面基本加载完了。这个策略比 networkidle0 更宽容,因为大多数页面会有定时上报请求,严格空闲反而容易超时。
更稳妥的组合是:domcontentloaded 先快速拿到页面骨架,再用 waitForSelector 等到真正需要渲染的内容出现。比如详情页里等 .detail-content,数据大屏里等 #chart-container。这样能保证“该出现的东西出现了”再去截图,而不是“网络听起来安静了”就去截图。
3.4 异常不能影响整批任务
批量截图的另一条铁律是:单个页面挂了,绝不能拖垮整批任务。
实现上就是我在 main 函数里加 try/catch 的做法。每个 Worker 都捕获异常,记录到日志以后继续处理下一个任务。如果某个 URL 连续失败,可以做一个简单的重试计数,比如失败 3 次就不再折磨它,把错误单独输出到一个 failed.log,回头人工排查。
4. 实测最容易翻车的五个细节:懒加载、字体、视口、超时与存储格式
4.1 滚轮触发懒加载
真实页面里图片十有八九是懒加载的。默认 fullPage 截图时,Puppeteer 会把视口高度设为整个页面高度再截图,但问题是:很多懒加载机制监听的是滚动事件,如果没触发过滚动,屏幕下方的图片根本不会加载。
我的解决办法是在页面加载完成后,先模拟一次从顶部到最底部的滚动,给每个懒加载容器一个触发机会:
javascript复制async function scrollToBottom(page) {
const scrollHeight = await page.evaluate(() => document.body.scrollHeight);
let y = 0;
const step = Math.max(300, Math.floor(scrollHeight / 10));
while (y < scrollHeight) {
y += step;
await page.evaluate((pos) => window.scrollTo(0, pos), y);
await new Promise((r) => setTimeout(r, 200));
}
await page.evaluate(() => window.scrollTo(0, 0));
await new Promise((r) => setTimeout(r, 500));
}
这里分 10 步滚到底,每步停 200 毫秒,目的是让懒加载有充分时间触发。滚完以后必须回到顶部,否则截图时页面初始位置不对,一些吸顶导航会把画面遮住。如果你面对的是无限滚动的列表(比如商品瀑布流),你还得在业务层判断“列表项数量是否还在增长”,滚动次数可能远不止 10 次。
4.2 Web 字体的加载时机
中文字体文件可以到 1MB 甚至更大,网络慢的时候,截图时机稍早,文字就会变成方块或默认宋体。最直接的方法就是我在脚本里写的:
javascript复制await page.evaluate(() => document.fonts.ready);
document.fonts.ready 返回一个 Promise,等它 resolve,说明页面内声明的字体已经全部加载完成(或加载失败)。这个等待是精确的、有依据的。
还有一个小坑:如果页面用了 font-display: swap,浏览器会先用回退字体渲染,字体文件到了再替换。字体加载完成前截图,截出来的就是普通字体;加载完成后截图,才是期望效果。所以等待字体这一步一定不能省。
4.3 跨域 iframe 和第三方图片
跨域 iframe 内部的内容能不能截到,取决于 iframe 是否允许被加载。默认情况下 page.screenshot 截的是整个页面合成后的画面,只要 iframe 的渲染画面出现在页面上,就能截进去——这跟访问 DOM 是两回事。但如果页面里有广告位,而广告内容加载超时,截图里可能是一块白色占位区。这种问题没法从代码层面完全解决,只能接受“这是广告位的真实状态”或者把广告位 URL 加白名单提前加载。
第三方图片也有个类似问题的变体:如果图片服务器响应慢,图片区域可能一直是灰块。这种情况我建议把 networkidle2 的超时时间放宽到 60 秒再观察,如果还是空块,就要考虑目标站点是否对无头浏览器做了限制。
4.4 视口尺寸和 deviceScaleFactor
如果你截出来的图在 4K 屏上模糊,大概率是没设置 deviceScaleFactor。它相当于模拟物理像素比,设成 2 后,截出来的图片分辨率是逻辑宽度的两倍,清晰度会明显提升。代价是图片体积成倍上涨,全页面长图尤其明显。
我的建议是:
| 业务场景 | 推荐视口 | deviceScaleFactor |
|---|---|---|
| 普通 PC 页面留档 | 1440 x 900 | 2 |
| 移动端页面巡检 | 375 x 667 | 3 |
| 数据大屏/图表 | 1920 x 1080 | 1 或 2 |
另外注意,fullPage 截图时,deviceScaleFactor 一高,生成的 PNG 尺寸会非常夸张。一个超长商城首页,宽度 2880 像素、高度 30000 多像素,单张图能到 50MB 以上。这时必须考虑网络传输成本,或者干脆按业务场景降低到 1 倍再截。
4.5 截图格式和质量参数
Puppeteer 默认输出 PNG,无损但体积大。如果对清晰度要求不是“像素级”那么高,JPEG 其实是更务实的选择:
javascript复制await page.screenshot({
path: filePath,
fullPage: true,
type: 'jpeg',
quality: 85,
});
JPEG quality 85 在视觉上和 PNG 差距很小,但体积往往只有 PNG 的十分之一。还有 WebP 格式,文件更小,但部分老系统不支持直接预览。我个人在内部系统里用 PNG 保底,在外送图片的接口里用 JPEG quality 85。
存储命名上,我也踩过坑。不要只用 page-1.png 这种名字,否则第二次跑任务直接覆盖,历史数据全没了。推荐格式:
text复制{任务ID}_{页面标识}_{时间戳}_{随机串}.png
如果是固定周期截图,至少要把日期放进目录名,比如 /shots/2025-04-12/task_order_page_001.png。
5. 从本地跑通到线上部署:并发压测、失败重试与成本估算
5.1 并发上限到底怎么定
很多人在本地跑通了脚本,部署到服务器却发现 Chrome 频繁崩溃。绝大多数原因是并发太高,内存耗尽。我实测下来,一个普通 Chromium 标签页在加载复杂页面时,内存占用约 80-200MB。所以不是“想开多少开多少”,而是要先看机器内存:
| 服务器内存 | 推荐并发标签页数 | 备注 |
|---|---|---|
| 2GB | 1-2 | 只适合轻量页面 |
| 4GB | 3-4 | 适合常规内容页 |
| 8GB | 6-8 | 可以跑复杂后台页面 |
| 16GB | 10-12 | 注意 CPU 也会成为瓶颈 |
这只是经验值,实际以压测为准。好的做法是写个小的压测脚本,用同一个 URL 列表分别跑并发 3、5、8,观察系统负载和单张截图耗时曲线。当并发翻倍但耗时没有明显下降时,说明瓶颈已经到了。
另外部署在容器里的朋友,记得加启动参数 --disable-dev-shm-usage,因为 Docker 默认的 /dev/shm 只有 64MB,Chrome 很容易直接崩掉。这个参数的作用是让 Chromium 使用 /tmp 而不是共享内存。
5.2 失败重试与任务断点续跑
线上跑 1000 个 URL,不可能全成功。常见失败原因:目标站点临时超时、页面 JS 报错导致白屏、资源被反爬拦截。我的策略是:
- 每个任务允许重试 2 次,重试前等待 3 秒。
- 重试还失败,记录到失败表。
- 任务表里保存每个 URL 的执行状态,下次启动脚本时只跑
pending和failed状态的任务。
这样一个简单的状态机就把“断点续跑”做出来了。任务表字段大概长这样:
text复制id | url | status | retry_count | error_msg | created_at | finished_at
批量任务真正到几千上万条时,你不可能忍受从头再来一遍。这个设计看起来基础,但价值极大。
5.3 包一层 HTTP API 的扩展思路
脚本模式够用以后,你会发现团队里的人也想用这个能力。这时候顺手用 Express 或 Fastify 包一层 HTTP 接口就很自然:
POST /api/screenshot/tasks:提交一批 URL,返回taskId。GET /api/screenshot/tasks/{taskId}:查询任务进度,返回成功/失败列表。GET /api/screenshot/files/{filename}:下载成品图。
这样一来,前端同事可以拖一个 Excel 上来,后端把 URL 解析成任务丢进队列,截图服务完成后通过回调或者轮询通知前端。整个“网页在线批量截屏”就从个人脚本变成了团队内部工具。
5.4 成本估算
最后说说成本,我遇到不少人在意这个。以 1000 个 URL、每个页面平均耗时 8 秒(包含等待网络空闲和滚动)为例:
- 并发 3:总耗时 ≈ 1000 × 8 / 3 ≈ 2667 秒 ≈ 45 分钟。
- 并发 6:总耗时 ≈ 1000 × 8 / 6 ≈ 1334 秒 ≈ 22 分钟。
- 如果单台 8GB 云服务器并发 6 就能跑,按按量付费的盒子算,一次任务成本通常在小几块钱以内,远低于人工截图的人力成本。
注意,某些页面会异常慢,比如有大量视频流、持续 websocket 推送的页面,networkidle2 可能永远等不到。对这类 URL,要给单独的 “最长加载时间” 上限,比如 30 秒到了就强制截图,长图总比超时失败好。
6. 一些只有跑过千次截图才会注意到的经验
写到最后,分享几个调试时特别容易忽略的细节。
第一,无头模式调试很痛苦。最有效的办法是先用 headless: false 在本地把有头浏览器打开,观察页面加载到哪一步出了问题。如果服务器没有桌面环境,可以加一行 page.on('console', msg => console.log(msg.text())) 和 page.on('pageerror', err => console.error(err)),把页面内部的报错透传出来,能省很多排查时间。
第二,保存截图前先思考文件系统。当截图数量过万,单个目录存 1 万个文件会让文件系统读取越来越慢。我习惯按 年/月/日/任务ID 分目录,每个目录控制在几百个文件以内。后续对接云端存储时,这个树状结构也天然适配对象存储的 Key 前缀。
第三,页面如果有动态图表,记得让图表渲染完再截。很多图表库(如 ECharts)加载数据和动画渲染都需要时间,document.fonts.ready 只解决字体,不解决这些。通用的做法是在 waitForSelector 之后,多等一个“关键 canvas 或 svg 节点出现”的条件,必要时再提供一个 extraWaitMs 参数,允许业务方自定义额外等待时间。虽然我用词很克制,但说实话,对特殊页面,“多等两秒再截”往往就是最直接的解法。
第四,批量截图服务要留日志。每张图截完,记一行结构化日志:URL、耗时、文件大小、重试次数。出了纠纷,比如客户说“这张图怎么是空白”,你能通过日志还原当时的加载时长和网络状态,而不是靠猜。
这套“网页在线批量截屏”方案,我从临时脚本一路迭代到内部工具,前后改了几轮,核心骨架一直没变:无头浏览器渲染、条件等待、并发调度、失败隔离。你如果也需要这个东西,直接从文章里的脚本开始跑,先解决 100 个 URL 的批量截图,再根据实际需求慢慢加队列、加接口、加存储,一定能落地成真正好用的工具。
