网页截图和 OG Image 生成这类 API,很多做内容平台、CMS、链接预览和自动化测试的人迟早会碰到。它解决的问题很直接:用一个 HTTP 接口,输入 URL 或标题文案,返回一张能用的图片。你可能已经看过不少现成服务,但自己搭一个仍然值得,因为这样可以控制缓存、并发、安全和成本。下面按实际落地顺序拆一遍,重点讲清楚它解决什么问题、适合谁、怎么从零跑通,以及最容易踩的坑。
1. 先搞清楚网页截图和 OG Image 到底是什么 API 场景
1.1 网页截图:不是简单调浏览器“打印页面”
网页截图能力理解起来很直观:输入 URL,输出图片。但实际落地时会发现,真正的难点不是“能不能截”,而是“截出来的图是不是用户想要的样子”。
默认情况下,无头浏览器打开一个页面后立刻截图,得到的结果往往不完整。图片可能还没加载完,懒加载内容还在等滚动,字体没有生效,尺寸也不是预期大小。所以接口里不能只收一个 url,还需要 viewport、fullPage、delay、device_scale_factor 这类参数。它们决定的是截什么、截多宽、截多清晰。
真实项目里,网页截图最常见的几个用途:
- 链接预览卡片。用户在 IM、后台或浏览器里粘贴链接,系统后台截图生成缩略图。
- 自动化测试视觉回归。对同一页面跑不同版本,靠截图对比 UI 变化。
- 内容审核与留证。把某个页面在特定时间点的状态保存下来,方便追溯。
- 订单、账单、确认单场景。把网页版单据转成图片,嵌入邮件或消息里。
这些场景都不要求“实时截图一定最快”,但都要求“这张图至少是完整、可读、尺寸正确的”。这一点先想清楚,后面调参数就不会那么慌。
1.2 OG Image:社交分享时的那张卡片图
OG Image 对应 Open Graph 协议里的 og:image 标签。很多社交平台抓取链接时,会读取这个标签里的图片地址,然后生成分享卡片。卡片通常有固定尺寸,常见的是 1200x630,约等于 1.91:1 比例。
和网页截图不一样,OG 图不是“把页面原样拍下来”,而是“按模板画一张图”。图上有标题、描述、站点名称、Logo 等文字信息。每篇文章不同,分享卡也应该不同。动态生成 OG 图的价值就在这里:人工不需要每篇文章做图,只需要通过一个带参数的接口 URL,实时生成一张和当前内容匹配的分享卡。
实现路线通常有两条:
- HTML 模板 + 无头浏览器,渲染后截图。CSS 能力强,排版灵活,缺点是要依赖浏览器,资源开销高。
- SVG 或 Canvas 先绘制矢量模板,再转成位图。轻量、速度快,但复杂布局要自己处理。
我一般建议新手先走 HTML 模板路线。调试成本低,样式改起来直观。等流量上来了,再把高频的 OG 图缓存住,或者换成更轻的 SVG 方案。
1.3 为什么要把两个能力放进同一个 API
从技术角度,网页截图和 OG 图生成可以做成两个独立服务。放在一起,最大的好处是调用方只需要学一套鉴权、一套参数风格、一套返回结构,运维也不需要部署两套服务。
从工程角度看,两者共享了不少基础设施:
- 无头浏览器实例,同时做截图和 HTML 模板渲染时,可以复用。
- 缓存策略类似,基本都按请求参数作为 key。
- 存储、CDN、日志、限流可以共用一条链路。
所以这个标题拆开看是两个能力,合在一起更像一个“静态图片渲染服务”。理解了这层关系,下面选型和实现就顺了。
2. 从零搭建一个可用的截图与 OG 图 API,关键选型先想清楚
2.1 技术选型:无头浏览器 + 图片渲染
网页截图绕不开无头浏览器。现在常见的选择是 Puppeteer 和 Playwright。Puppeteer 是 Node.js 生态里操作 Chrome 很常用的库,Playwright 支持多浏览器、多语言,等待和录制工具也更完善。如果团队用 Java 或 Python,也可以找对应语言的浏览器绑定库。原理一样:启动一个 Chromium 实例,打开页面,等待就绪,截屏。
OG 图生成需要分开评估。我的建议是:HTML 模板 + 无头浏览器截图适合需要复杂视觉效果、和 Web 页面保持一致的场景;SVG/Canvas + 位图转换适合固定模板、高并发的场景。
如果是个人项目,Node.js + Puppeteer + Sharp 是比较常见的组合。Puppeteer 负责截图,Sharp 负责缩放、压缩、格式转换。如果只做简单 OG 图,Sharp 配合 SVG 模板也能直接输出 PNG,省掉浏览器开销。
我自己的经验是:技术选型不要先看谁更高级,先看团队维护成本。你们平时已经用 Node.js,那 Puppeteer 组合最快。团队偏 Python,用 Playwright Python 或 pyppeteer 也可以。每个方案都有沙箱、字体、系统库依赖的坑,但没有一个方案是“完全不用配置”的。
这里补一句边界:不是所有页面都能被无头浏览器完美渲染。需要登录、有强验证码、依赖 WebSocket 推送的页面,截图服务往往只能拿到部分内容。如果页面里有反爬逻辑,接口也需要额外处理 Cookie、UA 或等待条件,但这本身又是一个复杂度来源。
2.2 运行环境和资源条件
这类服务对 GPU 没有硬要求,主要看 CPU、内存和磁盘。常见的最小部署条件可以参考:
| 资源 | 建议 |
|---|---|
| CPU | 2 核起,截图时 CPU 会明显升高 |
| 内存 | 2GB 起步,建议 4GB |
| 磁盘 | 至少 5GB,需要放系统、依赖、临时截图和缓存 |
| 网络 | 必须能访问目标站点,DNS 要稳定 |
| 系统 | Linux 服务器最常见,Docker 部署更可控 |
每个无头浏览器进程可能占用几百 MB 内存,并发多时会快速上涨。低配置机器能跑,但大概率只适合低并发。不要拿小内存去扛全天候批量任务,页面如果包含大量动态内容、高清图片或视频,资源占用会再上一个台阶。
这里给的是通用判断,实际参数要以你的页面和服务端环境为准。第一次部署时,建议通过 top、free、df 观察一下资源占用曲线,再决定并发上限。
2.3 安全和权限:为什么不能把接口裸奔
把截图和 OG 图服务做成 API 后,最容易忽略的是权限和安全边界。只要是暴露在公网的 HTTP 接口,理论上别人就能用它访问任意 URL,这会带来两个常见风险:
- 滥用。被刷接口,消耗服务器资源和流量。
- 内网探测。如果服务部署在可访问内网的机器上,又允许任意 URL,攻击者可能通过它访问内网地址,间接探测内部资源。
建议至少做这五件事:
- 请求带 Token 或签名,不要裸奔。
- 对 URL 做协议和域名过滤,只允许 http/https,必要时维护白名单。
- 限制单 IP、单 Token 的调用频率。
- 设置浏览器访问超时,比如 15 秒没加载完就返回错误,不要无限等待。
- 不要允许调用方传入 shell 参数或覆盖浏览器二进制路径。
这些不是多余动作。内部接口可能裸奔也能跑,一旦面向多个团队或外部调用方,安全边界就是上线前必须补的功课。
3. API 接口设计:从单张截图到自定义尺寸和参数
3.1 网页截图接口
一个常见的网页截图接口可以设计成POST /v1/screenshot,请求体使用 JSON。核心参数可以这样定:
| 参数 | 类型 | 说明 |
|---|---|---|
| url | string | 要截图的完整地址,必须带协议 |
| viewport_width | integer | 视口宽度,常用 1280 |
| viewport_height | integer | 视口高度,常用 800 |
| full_page | boolean | 是否截整页,true 时忽略视口高度限制 |
| delay | integer | 加载完成后等待的毫秒数 |
| device_scale_factor | number | 设备像素比,2 适合高清屏截图 |
| format | string | png 或 jpeg,默认 png |
| quality | integer | jpeg 质量,1 到 100 |
响应可以直接返回图片二进制,也可以返回 JSON 包含 base64 或文件 URL。我建议第一版直接返回图片二进制,Content-Type设置成image/png,调用方拿到即用。需要保存或回调时再加file_url。
示例请求:
POST /v1/screenshot { "url": "https://example.com", "viewport_width": 1280, "viewport_height": 800, "full_page": false, "delay": 1000, "format": "png" }为什么 delay 和 full_page 重要?因为很多页面在首屏加载完之前会动态插入内容。没有 delay,截出来可能是白屏或半加载状态。不理解 full_page 的语义,想截整页却始终只截首屏,很容易误判是接口坏了。
接口返回图片时,建议在响应头里带上Cache-Control。这样 CDN 或浏览器可以帮忙缓存,避免每次重复触发无头浏览器。
3.2 OG Image 接口
OG 图接口建议设计成GET /v1/og-image,参数通过 query string 传入。这样做的好处是方便把完整地址直接放到<meta property="og:image" content="...">里,社交平台抓取时会自动带参数请求。
参数可以这样设计:
| 参数 | 类型 | 说明 |
|---|---|---|
| title | string | 标题,建议 30 字以内 |
| description | string | 描述,建议 80 字以内 |
| site_name | string | 站点名称,显示在卡片底部 |
| logo_url | string | Logo 图片地址 |
| background_color | string | 背景色,需要 URL 编码 |
输出建议固定 1200x630,这个尺寸在主流平台分享卡片里通用度最高。用 GET 的另一个好处是方便调试,浏览器里直接打开 URL 就能看到效果。坏处是 URL 长度有限,文案过长时要改用 POST,或者把参数做短。
示例请求:
GET /v1/og-image?title=Hello%20World&description=OG%20Image%20API如果缺少主要字段,不要硬生成。返回 400,而不是一张没有标题的图。接口设计里很容易忽略这一点:宁可拒绝,也不要生成一张“看起来完成但信息缺失”的图。
3.3 错误码和返回格式
统一错误结构对调用方非常友好。可以约定成这样:
{ "code": "invalid_param", "message": "url is required", "request_id": "xxxx" }常见状态码可以这样规划:
| 状态码 | 含义 |
|---|---|
| 400 | 参数缺失、URL 格式错误 |
| 401 / 403 | 鉴权失败或没有权限 |
| 404 | 目标页面不存在 |
| 408 | 浏览器加载超时 |
| 429 | 调用过于频繁,触发限流 |
| 502 | 目标站点无法连接 |
| 503 / 529 | 服务过载,通常是临时的 |
这里特别说一下 529。这个词在很多 API 平台里表示“服务端过载”,对应英文描述基本是 overloaded、server-side issue、usually temporary。自建服务不一定用 529,但错误信息里要明确“这是服务端临时压力,不是调用方参数问题”,否则调用方会反复重试,反而把服务压得更狠。重试策略建议用退避重试,而不是立即重发。
4. 本地运行与单条验证:先把最小可用链路跑通
4.1 最小启动步骤
我建议第一次做这个服务时,不要先写完整接口,先跑通一个最小脚本。以 Node.js 为例,最小流程通常是:
- 创建项目并初始化 npm。
- 安装 puppeteer 或 playwright。
- 写一个函数,打开 Chromium,访问 URL,等待几秒,截屏保存。
- 确认截图文件生成且能正常打开。
示例代码:
const puppeteer = require('puppeteer'); (async () => { const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] }); const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800 }); await page.goto('https://example.com', { waitUntil: 'networkidle2', timeout: 15000 }); await page.screenshot({ path: 'output.png' }); await browser.close(); console.log('done'); })();这里的--no-sandbox要注意。在 Docker 或 CI 环境里经常需要它,但直接在不可信环境关闭沙箱会降低浏览器安全性。生产部署建议使用 Docker 容器隔离,并创建非 root 用户跑服务。
安装 puppeteer 时会下载对应版本的 Chromium,这个过程比较依赖网络。建议先确认服务器能正常访问 npm 源,避免下载中断导致依赖不完整。Windows 本地调试可以跑,但生产环境我更推荐 Linux 或 Docker。
注意:不要一上来就跑并发。先让一条任务跑通,确认浏览器能起来、页面能打开、文件能写进去。这一步稳定了,后面才会少出问题。
4.2 用 curl 验证单条任务
接口写好之后,先用 curl 验证,不要直接上 Web 页面。
curl -X POST http://localhost:8080/v1/screenshot \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <你的token>" \ -d '{"url":"https://example.com","viewport_width":1280,"viewport_height":800,"format":"png"}' \ -o test.png然后看几个指标:
- HTTP 状态码是不是 200。
Content-Type是不是image/png。- 文件大小是不是合理。1280x800 的 PNG 通常在几百 KB 到 2MB 之间。太小可能说明是空白图,太大可能说明页面渲染内容非常多。
- 用图片查看器打开,确认页面内容完整、文字清晰、没有白屏。
Windows 本地调试时,路径和权限问题会更明显,比如临时目录、浏览器下载目录权限。建议在 Linux 或 Docker 环境做第一次完整验证。
4.3 成功结果长什么样,失败时看什么
成功的状态很好判断:接口返回 200,图片能打开,尺寸和参数一致,页面内容完整。
失败时不要直接改参数,先按顺序看:
- 接口返回了什么状态码和错误 message。
- 服务日志里有没有打印目标 URL、加载耗时、浏览器进程状态。
- 进程还活着吗?有没有残留的 Chromium 进程占住内存。
- 输出目录有没有写权限,磁盘是否满。
- 目标站点是不是本身不可达,或者对方有反爬限制。
第一次跑最常见的问题往往不是代码逻辑,而是环境:缺系统依赖、缺字体、目录权限不对、沙箱配置冲突。如果你 curl 之后什么日志都没有,先确认服务进程绑定的端口、防火墙和工作目录,不要一头扎进参数调整里。
5. 批量生成、性能优化和缓存策略
5.1 批量任务不能只看能不能跑
很多人在本地跑通单条截图后,第二步就直接开一个 for 循环批量生成,结果服务卡死、文件命名乱、部分请求超时,然后以为是接口不稳定。
批量任务要考虑这几点:
- 任务队列。把 URL 列表按队列消费,不要一次性全部塞进并发池。
- 并发限制。第一次建议并发数 2 到 4,观察 CPU 和内存,再逐步增加。
- 超时控制。单条任务必须有硬超时,避免一个页面拖死全部任务。
- 失败重试。网络抖动和目标站不可达是常态,可以设计重试 2 到 3 次,但要加退避,不要无脑重发。
- 输出命名。批量任务里的文件名最好带上 URL 的 hash 或任务 ID,避免名称冲突。
- 幂等性。同一任务重复执行应该产生可替换的结果,方便重跑。
如果是多条任务,我建议先跑一个小集合并人工看几张图,确认结果没问题,再放量跑全量。批量任务最怕的不是慢,而是“批量产出大量错误数据”还没被发现。
5.2 缓存是这类 API 的生命线
网页截图和 OG 图生成,本质上都是“同一个 URL 反复被访问时,返回相同图”的场景。如果不做缓存,会有两个问题:资源消耗大,响应时间慢。
缓存策略可以很直接:
- OG 图接口按 title、description、site_name、background_color 拼接的字符串做缓存 key。参数不变,结果就不变,缓存命中率极高。
- 网页截图接口按 url + viewport + full_page 组成 key。内容型页面有更新频率,TTL 不宜太长,比如 10 分钟到几小时。
- 缓存可以放本地磁盘、Redis 或对象存储。第一版用磁盘文件缓存最简单,命中时直接把文件流返回。
- 接入 CDN 时,响应头里输出
Cache-Control和ETag,让 CDN 也参与缓存。
注意:缓存键如果包含 delay 时间,同一页面不同延迟会缓存成多份,磁盘可能膨胀。可以把 delay 从缓存键里去掉,或者只保留 0 秒和 N 秒两档。
5.3 性能优化:浏览器复用、模板静态化和日志
单接口能跑还不够,批量或对外提供服务后,性能就会决定体验。常见优化有这几个:
- 浏览器实例复用。每请求都启动新浏览器非常慢,建议进程内维护一个浏览器池,多个 Tab 并发处理。
- 合理控制并发数。并发不是越大越好,太大容易把 CPU 打满,单图耗时反而上升,错误率也上升。
- HTML 模板静态化。OG 图如果走 HTML 模板,把模板文件提前写好,请求时只替换变量,不要动态拼接代码。
- 图片压缩。PNG 文件偏大,不要求透明通道时可以用 JPEG 或 WebP。Sharp 这类工具可以直接把 buffer 压缩后返回。
- 日志记录关键指标。每次请求的耗时、缓存命中、成功或失败都要记录,方便定位瓶颈。
我会把“成功率”和“缓存命中率”列成核心指标,而不是只看 QPS。一个截图 API 如果成功率很低、缓存命中率也很低,QPS 再高也说明架构不合理。
6. 常见报错和排查顺序:从依赖、权限、资源占用开始查
6.1 常见错误清单
下面这些是自建网页截图和 OG 图服务时最常见的报错和原因。
| 现象 | 常见原因 | 建议处理 |
|---|---|---|
| 浏览器启动失败 | 缺少系统依赖、沙箱配置不对 | 查看浏览器进程日志,考虑 Docker 环境,必要时加 --no-sandbox |
| 截图全白或半加载 | delay 太短、waitUntil 条件不对、页面懒加载 | 加大 delay,改用 networkidle2,检查页面是否有懒加载 |
| 返回 502 | 目标站点无法连接、DNS 解析失败 | 先在服务器上 curl 目标 URL |
| 返回 408 超时 | 目标站太慢、网络问题、超时设置太短 | 调大超时,但不要无限大 |
| 输出图片文字乱码或缺字 | 缺少中文字体 | 安装字体,比如 fonts-noto-cjk |
| 权限 denied | 输出目录、临时目录权限不对 | 检查运行用户和工作目录权限 |
| 磁盘满 | 缓存和临时文件太多 | 清理缓存,设置文件保留策略 |
| 529 overloaded | 服务过载,临时性问题 | 调用方退避重试,服务方扩容或降并发 |
| 接口 401 | Token 没传或失效 | 检查鉴权头 |
6.2 排查顺序
遇到问题,我一般按这个顺序查,不跳步:
- 先看现象。是报错、卡住、无输出,还是图片异常。
- 再看输入。URL 是否带协议,参数类型对不对,请求体是否合法。
- 再看服务日志。错误信息、请求参数、浏览器调用记录都在日志里。
- 再查环境。依赖版本、系统库、字体、目录权限、磁盘空间、端口占用。
- 再查资源占用。top 看 CPU,free 看内存,df 看磁盘。
- 最后才调参数。并发、超时、delay、waitUntil。
这套顺序有它的道理。很多看起来像“API 故障”的问题,最后定位出来都是输入格式不对、服务器磁盘满了、或者某次部署把依赖装到了错误环境。先看最简单、最容易定位的,再动复杂参数,能少走很多弯路。
长期提供服务时,不要把日志只留在控制台。建议输出到文件或日志平台,按 request_id 检索。排查问题的时候,有一份完整日志比临时加打印快得多。
这类 API 真正的难点永远不是第一张图能不能生成,而是批量任务里能不能稳定产出、缓存能不能扛住重复请求、错误信息能不能让调用方快速理解。如果你也是要做链接预览、分享卡片或自动化留证,我建议先把单条任务跑稳,再考虑并发和缓存。一次能跑通不代表可以上线,但连一次都跑不通,后面所有优化都无从谈起。踩过几次之后你会认同这个判断:网页截图和 OG 图服务的坑,大多不是功能不支持,而是环境、权限、输入格式和服务过载这几个老问题。