在做内容分享类业务时,很多团队都会遇到这样的需求:把某个网页变成一张清晰截图,或者为文章动态生成一张适合发到微信、Twitter、Facebook 的分享卡片图。市面上的网页截图 API 和 OG Image API 并不少,但按调用量付费、返回格式固定、定制能力有限,数据敏感一点的项目还不敢直接调用外部服务。这个项目Another Webpage Screenshot and OG Image Generation API的定位就是“自建一套”:自己控制无头浏览器,自己定义卡片样式,自己决定鉴权和缓存策略。本文会从原理讲起,完整搭建一个基于 Playwright + Express 的截图与 OG 图片生成服务,并覆盖 Docker 部署、并发控制、常见报错排查和工程化建议。如果你已经在使用 Puppeteer、Playwright 这类无头浏览器,可以直接跳到第 4 节看完整实现;如果是第一次接触,建议从头按顺序阅读。
1. 为什么需要自建网页截图与 OG 图片生成 API
1.1 网页截图 API 能做什么
网页截图 API 的核心能力很简单:传入一个 URL,服务端通过无头浏览器加载页面,等待资源渲染完成后截取整页或视口区域,再以图片二进制形式返回给调用方。它在业务中常见的用途包括:
- 生成网页预览缩略图,比如分享链接时展示目标网站的视觉快照。
- 定时巡检页面样式,记录线上页面是否出现布局错乱。
- 生成周报、日报中的可视化图表图片,将内部报表页面转成图片后贴到文档。
- 为移动端 H5 制作分享长图,把活动页、邀请页截成一张便于传播的图片。
- 作为 Web 自动化测试的辅助能力,将失败用例的关键页面截图留存证据。
这些场景有一个共同特点:页面是在浏览器里渲染的动态内容,不是简单抓一下 HTML 就能得到。服务端获取图片必须依赖一个“真实浏览器内核”,也就是无头浏览器。无头浏览器没有界面窗口,但拥有完整的渲染、脚本执行和网络请求能力,因此可以拿到和用户看到一模一样的页面。
1.2 OG Image 到底是什么
OG Image 中的 OG 是 Open Graph 协议的缩写。它最早由 Facebook 提出,后来被 Twitter、微信、LinkedIn 等平台广泛支持。它的作用是在网页 URL 被分享到社交平台时,告诉平台“你应该用哪张图、哪个标题、哪段描述来展示这个链接”。
我们平时在群里看到带大图的链接卡片,本质就是平台后台请求了网页的 HTML,读取<meta property="og:image">等标签后渲染出来的结果。
一个标准的 Open Graph 标签集合如下:
<meta property="og:title" content="从零搭建网页截图与 OG 图片生成 API" /> <meta property="og:description" content="基于 Playwright 的无头浏览器截图服务实战" /> <meta property="og:image" content="https://example.com/og/cover.png" /> <meta property="og:type" content="article" /> <meta property="og:url" content="https://example.com/post/1001" /> <meta name="twitter:card" content="summary_large_image" />其中og:image是分享卡片的核心素材,推荐尺寸是 1200×630 像素,宽高比约为 1.91:1。很多内容平台在生成分享卡片时会压缩图片,所以图片内文字不能太靠边缘,重要信息应当集中在中部区域。
动态 OG Image 是指:根据每篇文章的标题、描述、作者、分类等元数据,实时渲染出一张 1200×630 的图片。相比手工设计一张通用封面图,动态生成能做到“每篇文章一张专属封面”,而且不需要设计师介入,整个流程可以完全自动化。
1.3 自建 API 的优势与技术选型对比
自建而不是采购第三方 SaaS,主要考虑三点:一是成本,无头浏览器服务按调用量计费时,量大之后费用并不低;二是定制,第三方服务往往只能提供有限模板,无法满足企业内部的视觉规范;三是数据安全,内网报表、未发布内容如果经过外部截图服务,会带来泄露风险。
在技术选型上,目前主流方案大致有三类:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Playwright / Puppeteer 无头浏览器 | 启动 Chromium,加载页面后截图 | 支持复杂布局、完整 CSS 渲染 | 内存占用高,启动有一定开销 |
| SVG 转 PNG | 先用代码画出 SVG,再用 sharp/resvg 转换 | 轻量快速,适合纯版式卡片 | 对复杂排版支持有限 |
| Canvas 服务端绘制 | node-canvas 等库直接绘制位图 | 可控性强,无浏览器依赖 | 文字换行、字体适配需要自己处理 |
对于“网页截图 + 简单 OG 卡片”这种组合需求,无头浏览器是最均衡的方案。同一个浏览器实例既能加载任意网页截图,又能渲染自己写的 HTML 模板来生成 OG 图,代码路径统一,维护成本低。本项目采用 Playwright,原因是它的现代 API 设计、自动等待机制和跨浏览器支持都更友好。
2. 环境准备与项目结构设计
2.1 运行环境与依赖说明
搭建这个项目需要以下基础环境:
- 操作系统:macOS、Linux 或 Windows 均可,生产环境推荐 Debian/Ubuntu 系 Linux。
- Node.js:建议使用 16 及以上版本。版本需要根据你的项目实际情况调整,文章示例重点演示配置思路。
- 包管理器:npm 或 yarn,本文使用 npm。
- 浏览器内核:Playwright 需要单独下载 Chromium,不会自动复用系统浏览器。
核心依赖只有两个:
npm install express playwrightExpress 用来提供 REST API,Playwright 用来驱动 Chromium。项目本身不依赖数据库,缓存可以先用文件系统,生产环境可以替换为 Redis 或对象存储。
2.2 项目目录结构
为了让代码职责清晰,我们按模块拆分项目:
screenshot-og-api/ ├── package.json ├── .env.example ├── Dockerfile ├── docker-compose.yml ├── src/ │ ├── server.js │ ├── browser.js │ ├── limiter.js │ ├── auth.js │ ├── screenshot.js │ ├── og-image.js │ └── templates.jsserver.js:HTTP 服务入口,定义路由、鉴权、并发控制。browser.js:管理全局浏览器实例,避免每个请求都重新启动 Chromium。limiter.js:简单的并发限制器,防止无头浏览器占用过多内存。auth.js:接口鉴权中间件。screenshot.js:网页截图核心逻辑和处理函数。og-image.js:OG 图片生成核心逻辑和处理函数。templates.js:OG 图片的 HTML/CSS 模板。
2.3 初始化 Node 项目
创建目录并初始化:
mkdir screenshot-og-api && cd screenshot-og-api npm init -ypackage.json中我们需要声明启动脚本和安装脚本。依赖版本以你安装时的最新稳定版为准,下面给出示例:
{ "name": "screenshot-og-api", "version": "1.0.0", "description": "Webpage Screenshot and OG Image Generation API", "main": "src/server.js", "scripts": { "start": "node src/server.js", "install:browser": "playwright install chromium" }, "dependencies": { "express": "^4.18.2", "playwright": "^1.42.0" } }安装依赖并下载 Chromium:
npm install npx playwright install chromium这里要提醒一点:如果服务器网络环境对下载安装包有限制,npx playwright install chromium可能会失败。此时可以先执行npx playwright install-deps chromium安装系统依赖,再单独下载浏览器核心,或者配置 Playwright 的镜像源。
3. 核心原理拆解
3.1 无头浏览器的截图流程
无头浏览器截图看起来只是“打开页面,拍张照”,但实际流程中每一步都值得关注:
- 启动浏览器实例:
chromium.launch()。这一步开销最大,因此生产环境应当复用一个全局实例,而不是每个请求都重新启动。 - 创建上下文:
browser.newContext()。BrowserContext 相当于一个独立的浏览器会话,cookie、缓存、localStorage 相互隔离,适合作为每个请求的最小隔离单位。 - 打开新页面:
context.newPage()。 - 跳转 URL:
page.goto(url, { waitUntil: 'networkidle' })。networkidle表示页面在 500ms 内没有任何网络请求后认为加载完成。这个策略比load更可靠,可以等到大部分异步接口返回。 - 截图:
page.screenshot()。 - 释放资源:
context.close()。页面和上下文必须释放,否则长时间运行后内存会持续上涨。
这段流程中,waitUntil的选择决定了截图成功率。对于接口较慢、包含轮询请求的页面,networkidle可能一直等不到空闲,此时可以退化为load再加固定延时。更精细的做法是监听页面上的关键元素出现后再截图。
3.2 OG 图片的尺寸与渲染模板
社交平台对 OG 图的尺寸有强约束。1200×630 是事实标准,Twitter 的summary_large_image也推荐这个尺寸。为什么不是 800×800 这种正方形?因为信息流卡片在多数平台上都是横向排版,宽图能占据更大的视觉区域,同时不会被裁剪得太多。
用无头浏览器生成 OG 图的核心思路是:把卡片设计成一张 1200×630 的 HTML 页面,然后让 Chromium 按精确视口尺寸打开并截图。这种做法的好处是可以用 CSS 完成所有排版,渐变、圆角、文字截断都很容易实现。相比用 SVG 手写坐标,CSS 的调试成本和可维护性都更好。
需要注意的细节是:给 Chromium 的视口必须精确设置成 1200×630,否则截图比例不对。另外,字体渲染受操作系统影响,生产环境必须预装中文字体,这个在 6.2 节会展开说明。
3.3 REST API 接口设计
本项目的 API 设计遵循 RESTful 风格,只暴露两个核心接口:
| 方法 | 路径 | 功能 | 请求体关键参数 |
|---|---|---|---|
| POST | /api/screenshot | 截取指定网页 | url, width, height, fullPage, format, timeout |
| POST | /api/og-image | 生成自定义 OG 卡片 | title, description, siteName, theme, accent |
| GET | /health | 健康检查 | 无 |
接口都用 POST,因为请求体是结构化 JSON,比 query string 更适合传递截图参数。返回的不是 JSON,而是图片二进制,Content-Type设为image/png或image/jpeg。这样调用方直接把响应体当作图片文件保存即可,前端也可以用<img src="接口地址">直接预览。
考虑到权限,两个业务接口统一挂在/api前缀下,由鉴权中间件统一保护。实际部署时,还可以把写操作改为异步任务:调用方提交任务后立即拿到任务 ID,服务端生成完成后通过 Webhook 通知。这个优化适合生成图片耗时长的场景,但会让代码复杂度明显上升,文章先把同步方案做完整。
4. 完整实战:实现截图与 OG 图接口
4.1 浏览器实例管理与并发控制
浏览器实例是整个服务的核心资源。一个全局 Chromium 实例可以服务多个请求,每个请求通过 BrowserContext 获得独立会话。下面是browser.js的实现:
// 文件路径:src/browser.js const { chromium } = require('playwright'); let browserPromise = null; function getBrowser() { if (!browserPromise) { browserPromise = chromium.launch({ headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage' ] }); } return browserPromise; } async function closeBrowser() { if (browserPromise) { const browser = await browserPromise; await browser.close(); browserPromise = null; } } module.exports = { getBrowser, closeBrowser };--no-sandbox在容器环境通常是必须的,否则 Chromium 会因沙箱权限不足拒绝启动。生产环境如果对安全要求较高,可以通过单独的用户命名空间或更细粒度的 seccomp 配置来替代直接关闭沙箱。
无头浏览器属于“吃内存大户”,并发太高会导致 OOM。我们需要一个简单的并发限制器。这里实现一个基于 Promise 的信号量:
// 文件路径:src/limiter.js class ConcurrencyLimiter { constructor(max = 5) { this.max = max; this.running = 0; this.queue = []; } async run(task) { if (this.running >= this.max) { await new Promise((resolve) => this.queue.push(resolve)); } this.running++; try { return await task(); } finally { this.running--; const next = this.queue.shift(); if (next) next(); } } } module.exports = { ConcurrencyLimiter };这个限制器允许我们同时在页面加载阶段最多运行 5 个任务,剩下的请求排队等待。并发数可以通过环境变量调整,内存较小的机器建议控制在 3 以内,内存充足的机器可以适度提高到 8 到 10。
4.2 实现网页截图接口
截图模块需要处理 URL 合法性校验、页面加载、滚动触发懒加载、最终截图几个环节。完整代码如下:
// 文件路径:src/screenshot.js const { getBrowser } = require('./browser'); function validateUrl(rawUrl) { if (typeof rawUrl !== 'string' || rawUrl.length === 0) { return null; } let parsed; try { parsed = new URL(rawUrl); } catch (_) { return null; } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { return null; } return parsed.href; } async function autoScroll(page) { await page.evaluate(async () => { await new Promise((resolve) => { let totalHeight = 0; const distance = 800; const timer = setInterval(() => { const scrollHeight = document.body.scrollHeight; window.scrollBy(0, distance); totalHeight += distance; if (totalHeight >= scrollHeight) { clearInterval(timer); resolve(); } }, 100); }); }); await page.waitForTimeout(500); } async function captureScreenshot({ url, width = 1920, height = 1080, fullPage = false, format = 'png', timeout = 30000 }) { const browser = await getBrowser(); const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 2 }); const page = await context.newPage(); try { await page.goto(url, { waitUntil: 'networkidle', timeout }); if (fullPage) { await autoScroll(page); } const buffer = await page.screenshot({ type: format, fullPage: !!fullPage }); return buffer; } finally { await context.close(); } } async function screenshotHandler(req, res) { const body = req.body || {}; const { url, width, height, fullPage, format, timeout } = body; const targetUrl = validateUrl(url); if (!targetUrl) { return res.status(400).json({ error: 'url 参数不能为空,且必须是以 http:// 或 https:// 开头的合法地址' }); } const imageFormat = format === 'jpeg' ? 'jpeg' : 'png'; try { const imageBuffer = await captureScreenshot({ url: targetUrl, width: Number(width) || 1920, height: Number(height) || 1080, fullPage: fullPage === true, format: imageFormat, timeout: Number(timeout) || 30000 }); res.setHeader('Content-Type', imageFormat === 'jpeg' ? 'image/jpeg' : 'image/png'); res.setHeader('Cache-Control', 'public, max-age=86400'); res.send(imageBuffer); } catch (err) { console.error('[screenshot] capture failed:', err); res.status(502).json({ error: '截图失败:' + err.message }); } } module.exports = { captureScreenshot, screenshotHandler };这里有两个容易忽略的参数。deviceScaleFactor: 2表示用 2 倍像素密度渲染,这样截出的图片在高分屏上依然清晰,否则网页截图很容易有“糊”的感觉。autoScroll是为了处理懒加载。很多页面的图片和列表要在滚动到视口附近才开始请求,直接截图只能得到首屏内容,滚动加载后再截图才能拿到完整页面。
`