公司动态
动态OG图片生成实战:用Node.js和satori打造非AI名片
在这个 AI 能写文章、能画海报、能写代码的时代一个很微妙的问题开始出现当你发布一条内容时读者第一反应可能不是“这条内容是否有价值”而是“这到底是不是 AI 生成的”。与其抱怨这个现象不如把它当成一个技术练习题。这个系列叫《Going out of my way to prove Im not an AI》第一期我选择了一个很小、但特别适合展示“人味”的切面Open Graph Images简称 OG Images也就是社交媒体分享卡片。这篇文章会从 OG Images 是什么讲起然后带你搭建一个可用的动态 OG 图片生成服务用的技术栈是 Node.js Express satori resvg-js。整个过程不是简单贴代码而是把每一步背后的原理、坑点和工程化建议都一起讲清楚。无论你是博客作者、前端开发者还是对“AI 时代如何保留手工痕迹”这个话题感兴趣的人这篇都能给你一些可落地的思路。1. 背景当 AI 能写一切你如何保留“人味”先聊一个比较现实的问题。现在的 AI 可以几秒钟写出一篇结构完整的文章、生成一张风格统一的海报、甚至帮你把代码注释补得整整齐齐。但也正因为这种“太整齐”大量 AI 生成内容开始变得同质化标题结构相似、排版布局雷同、措辞习惯统一。读者刷到一条内容时甚至会下意识去判断——这是人写的还是模型生成的我个人的态度是不去纠结“证明自己不是 AI”而是主动在内容里留下一些只有人在乎的细节。这些细节通常不是宏大的创意而是像手写签名、个人偏好、某个不完美的角落一样的东西。OG Images 就是很典型的一个场景。为什么因为绝大多数博客、工具站、开源项目社交分享图要么不用要么用一张万年不变的静态图。稍微讲究一点的会用 Canva、Figma 或 Photoshop 手动做几张模板图再根据每篇文章手动改文字。但这样做有两个问题无法做到“每篇文章一张专属卡片”因为手动改图是重复劳动。手动改图虽然花时间但模板一旦固定下来内容依然会很“模板化”缺少那种“这篇是特别的”感觉。动态 OG Image 能解决第一个问题而第二个问题其实取决于你如何设计模板。如果你在模板里加入自己的标识、签名风格、甚至一些微小的随机变量生成出来的卡片就会带着一种“程序化的手工感”——这本身就是一种有趣的反差。2. OG Images 是什么它和“人味”有什么关系Open Graph 协议最早由 Facebook 提出用来控制一个网页在社交平台上被分享时展示成什么样子。后来 Twitter、微信、Slack、Discord 等平台也陆续支持了类似机制。一张链接分享卡片通常由三部分组成标题og:title描述og:description图片og:image对应的 HTML meta 标签大致长这样head meta propertyog:title content这是一篇关于 Rust 的文章 / meta propertyog:description content从零开始构建一个命令行工具并分享一些编译期优化经验。 / meta propertyog:image contenthttps://your-domain.com/covers/2025-rust-cli.png / meta propertyog:type contentarticle / /head当有人把链接粘贴到社交平台时平台服务器会去抓取这些标签。特别是og:image如果图片地址可以访问并且尺寸符合平台要求最常见的推荐尺寸是 1200 × 630 像素就会自动生成一张包含图片、标题、描述的分享卡片。那这跟“证明不是 AI”有什么关系因为og:image可以是一个“动态接口”不是一张静态图片。只要这个接口能够根据 URL 参数实时生成图片你就可以做到每一篇文章、每一个页面都有一张独立的、带有你个人风格标识的分享图。这张图不是从某个素材库复制粘贴的而是你的技术栈、你的设计偏好、你的内容标题共同作用的产物。这种“每页一张定制卡片”的能力很多高级博客系统也有但多数是依赖现成服务很少自己从头搭建。自己动手搭建一遍你会对这个链路有完全不一样的理解。3. 技术选型为什么选择 satori resvg-js生成动态图片的技术方案其实不少我梳理一下主流几条路线方便你理解为什么最终选了 satori resvg-js。3.1 手动设计模板 工具改图用 Canva、Figma、PS 做一张底图然后每篇文章手动改标题。这是最原始的方式优点是设计上限高缺点是无法规模化不适合作为自动化的“接口”存在。3.2 Puppeteer / Playwright 截图用无头浏览器打开一个 HTML 页面再截图输出 PNG。这条路的好处是“可以写 HTML/CSS自由度极高”坏处是头一次启动浏览器实例非常慢在服务器端跑起来内存占用也高如果访问量稍大很容易把 Node 进程内存打满。3.3 Canvas 服务端绘制用 node-canvas 在服务端画图依赖原生库编译环境问题比较多而且绘制复杂布局时代码可读性很差。3.4 satori resvg-js本文选择satori 是 Vercel 开源的一个库它能用类似 HTML/CSS 的语法描述 UI然后在服务端把它转成 SVG 矢量图。resvg-js 则是一个 Rust 编写的 SVG 渲染库的 Node 绑定可以把 SVG 快速转成 PNG。这两个库组合起来的优势非常明显不需要启动浏览器轻量、速度快。布局语法接近 Web 开发前端开发者上手成本极低。支持 flexbox 布局、渐变、边框、圆角等常用样式能力。中文字体只要以二进制形式传入即可不依赖服务器系统安装字体。整个过程不涉及浏览器渲染资源占用小。要注意satori 不是全量 CSS 实现它只支持布局相关的子集比如 flexbox、margin、padding、position、font-size 等。像box-shadow、filter这类能力支持不全这个我在后面“常见问题”部分还会再提。4. 环境准备初始化 Node.js 项目与字体正式开始写代码前先把环境准备好。4.1 基础环境本文示例环境如下Node.js 18 及以上版本npm 或 pnpm 作为包管理器任意 Linux / macOS / Windows 系统均可但字体路径会略有不同如果你还不确定自己的 Node 版本可以执行node -v建议确保在 18因为示例代码中会用到 ESM 语法以及比较新的fetch能力本文暂时没用到 fetch但 18 更稳。4.2 初始化项目创建一个目录并初始化 package.jsonmkdir heartbeat-og-server cd heartbeat-og-server npm init -y然后安装依赖npm install express satori resvg-js安装完成后package.json的 dependencies 部分大约像这样{ dependencies: { express: ^4.19.2, resvg-js: ^2.6.2, satori: ^0.10.13 } }这里版本号是示例实际以你安装时的版本为准。satori 的 API 在 0.x 阶段陆续有调整如果后续大版本更新请留意官方文档。因为 satori 是 ESM only 的包所以我在项目里使用 ESM 模块规范。修改package.json加上{ type: module }4.3 准备中文字体这是一个很容易被忽略但一旦踩坑就要花很久解决的环节。satori 本身不读取系统字体它需要你把字体文件以 Buffer 形式传进去。如果字体不包含中文字形生成出来的中文内容就会变成空白方块。所以我们要准备一份开源可商用的中文字体。推荐使用 Google 的 Noto Sans SC思源黑体的 Google 版它是 SIL Open Font License 授权个人和商业项目都能用。你可以从 Google Fonts 或 GitHub 下载一份NotoSansSC-Regular.otf然后放到项目的fonts目录下heartbeat-og-server/ ├── fonts/ │ └── NotoSansSC-Regular.otf ├── src/ ├── package.json └── index.js如果手头暂时没有 Noto Sans SC也可以先用系统自带的字体但要注意版权。比如 Windows 系统自带的微软雅黑、macOS 自带的苹方在个人测试、学习环境中作为本地示例问题不大但生产环境商用请谨慎最好换用开源字体。5. 完整实战搭建动态 OG Image 生成服务下面进入核心部分。我们一起来搭一个能在浏览器里直接访问的 OG 图片生成服务。最终效果是访问类似下面的地址会返回一张 1200 × 630 的 PNG 图片http://localhost:3000/og?title你好世界authorzhangshantagTECHdate2025-01-205.1 项目结构先规划一下项目结构保持清晰heartbeat-og-server/ ├── fonts/ │ └── NotoSansSC-Regular.otf ├── index.js # Express 服务入口 ├── template.js # 卡片布局模板 └── package.jsonindex.js负责加载字体、接收 HTTP 请求、调用 satori 和 resvg 生成图片。template.js定义卡片长什么样相当于前端的“模板组件”。5.2 编写页面模板模板是整个卡片视觉的关键。我设计了一个比较简洁的“博客分享卡片”布局包含四个区域顶部左侧显示一个栏目标签比如HUMAN PROOF // TECH。顶部右侧一个绿色描边的NOT AI徽章呼应主题。中间大号标题文字。底部左侧显示作者右侧显示日期和期数。satori 的模板结构不是 JSX而是对象树。每个节点包含type和propsprops里用style描述样式用children描述子节点。来写template.js// 文件路径template.js export function buildPoster({ title, author, tag, date }) { return { type: div, props: { style: { width: 100%, height: 100%, display: flex, flexDirection: column, justifyContent: space-between, padding: 64px 72px, background: linear-gradient(135deg, #0f172a 0%, #334155 100%), color: #f8fafc }, children: [ // 顶部信息区左侧标签 右侧 NOT AI 徽章 { type: div, props: { style: { display: flex, justifyContent: space-between, alignItems: center }, children: [ { type: div, props: { style: { fontSize: 28px, fontWeight: 700, letterSpacing: 2px, color: #94a3b8 }, children: HUMAN PROOF // ${tag} } }, { type: div, props: { style: { display: flex, alignItems: center, borderRadius: 999px, border: 2px solid #34d399, color: #34d399, padding: 8px 20px, fontSize: 24px, fontWeight: 700, letterSpacing: 1px }, children: NOT AI } } ] } }, // 中间标题区 { type: div, props: { style: { display: flex, flexDirection: column, marginTop: 32px, marginBottom: 32px }, children: [ { type: div, props: { style: { fontSize: 88px, fontWeight: 800, lineHeight: 1.25, maxWidth: 900px }, children: title } } ] } }, // 底部信息区作者 日期 { type: div, props: { style: { display: flex, justifyContent: space-between, alignItems: center, borderTop: 2px solid rgba(148,163,184,0.3), paddingTop: 24px }, children: [ { type: div, props: { style: { fontSize: 28px, fontWeight: 600, color: #cbd5e1 }, children: author } }, { type: div, props: { style: { fontSize: 28px, color: #94a3b8 }, children: Ep. 1 // ${date} } } ] } } ] } }; }这段代码看起来有点长但逻辑很直接。你可能已经发现了这不是字符串拼接模板而是嵌套的 JavaScript 对象。satori 会把这棵对象树渲染成 SVG 的内容。这里有几个注意事项maxWidth用来限制标题区域宽度避免标题过长时把布局撑乱。中文字体不要选择太细的字重否则在大字号、深色背景上辨识度会下降。borderRadius: 999px用于把右侧徽章做成圆角胶囊形状。5.3 实现主服务与图片生成接下来写index.js。这部分要做的事情是启动 Express 服务定义/og路由从 URL query 中获取标题、作者、标签、日期等参数调用模板函数得到对象树然后依次交给 satori 和 resvg 处理。// 文件路径index.js import express from express; import fs from node:fs; import path from node:path; import { fileURLToPath } from node:url; import satori from satori; import { Resvg } from resvg-js; import { buildPoster } from ./template.js; const __dirname path.dirname(fileURLToPath(import.meta.url)); const app express(); const PORT process.env.PORT || 3000; // 输出图片尺寸 const WIDTH 1200; const HEIGHT 630; // 优先加载项目内置字体找不到就尝试常见系统字体路径 const FONT_CANDIDATES [ path.join(__dirname, fonts, NotoSansSC-Regular.otf), path.join(__dirname, fonts, NotoSansSC-Regular.ttf), /System/Library/Fonts/PingFang.ttc, C:/Windows/Fonts/msyh.ttc, C:/Windows/Fonts/simhei.ttf ]; let fontData; for (const fontPath of FONT_CANDIDATES) { try { fontData fs.readFileSync(fontPath); console.log(使用字体:, fontPath); break; } catch (_) { // 尝试下一个路径 } } if (!fontData) { console.error(未找到可用字体请下载一个中文字体放入 fonts/ 目录。); process.exit(1); } app.get(/og, async (req, res) { try { // 从 query 取参数并做长度限制防止恶意超长内容 const title String(req.query.title || 我的独立博客).slice(0, 30); const author String(req.query.author || Handmade Human).slice(0, 20); const tag String(req.query.tag || TECH).slice(0, 12); const date String(req.query.date || new Date().toISOString().slice(0, 10)).slice(0, 10); // 1. 构建模板对象树 const element buildPoster({ title, author, tag, date }); // 2. satori 将对象树渲染为 SVG 字符串 const svg await satori(element, { width: WIDTH, height: HEIGHT, fonts: [ { name: Noto Sans SC, data: fontData, weight: 400, style: normal } ] }); // 3. resvg 将 SVG 渲染为 PNG const resvg new Resvg(svg, { fitTo: { mode: width, value: WIDTH } }); const pngData resvg.render(); const pngBuffer pngData.asPng(); // 4. 输出图片并设置缓存头 res.setHeader(Content-Type, image/png); res.setHeader(Cache-Control, public, max-age600); res.send(pngBuffer); } catch (error) { console.error(生成 OG Image 失败:, error); res.status(500).send(生成失败: error.message); } }); app.listen(PORT, () { console.log(OG Image 服务已启动: http://localhost:${PORT}/og); });这段代码里有几个容易被新手忽略的点我单独解释一下第一字体加载逻辑。我写了一个FONT_CANDIDATES数组优先读项目内fonts目录下的字体文件读取失败时再去系统常见路径里找。这样既方便你下载开源字体放入项目也能在缺少字体文件时自动用系统字体兜底。第二参数要做长度限制。req.query.title来自用户输入数据源不可控。如果不截断攻击者可以传一个超长字符串导致图片渲染时布局错乱甚至撑爆内存。这里统一用.slice(0, 30)限长是一种简单的前置防护。第三satori()的返回值是字符串不能直接作为图片返回。必须先交给new Resvg(svg).render().asPng()得到 Buffer 后才能真正通过res.send()发送给浏览器。第四Cache-Control头不要设置太长。动态图片如果内容经常变化缓存太久会导致新图不生效如果完全不缓存每次访问都会现场渲染性能又差。600 秒10 分钟是一个比较均衡的初始值后面可以再按场景调整。5.4 启动服务并验证在项目根目录执行node index.js看到类似输出使用字体: /path/to/heartbeat-og-server/fonts/NotoSansSC-Regular.otf OG Image 服务已启动: http://localhost:3000/og然后打开浏览器访问http://localhost:3000/og?title亲手搭建%20OG%20Image%20服务authorzhangshantagTECHdate2025-01-20记得在 URL 中中文内容需要 URL 编码。比如上面的亲手搭建%20OG%20Image%20服务其中%20代表空格。如果一切正常你会在浏览器里看到一张深色背景、白色标题、右下角有NOT AI绿色徽章的 1200 × 630 卡片。5.5 扩展在模板中加入随机扰动既然这期的主题是“证明我不是 AI”我们可以给模板加一点有趣的小细节每次生成的卡片右上角的徽章位置可以在一个很小的范围内随机偏移。为什么这么做因为 AI 生成的图片往往过于稳定、过于对齐、过于“完美”。而人类手工制作的东西哪怕是程序生成的也常常带有一些刻意保留的随机性。在template.js中可以通过额外传入一个noise参数来控制偏移量。这个偏移可以作用在徽章的marginRight或marginTop上。注意 satori 对样式的支持是有限的所以这里不要做太复杂的位移简单加几像素偏移就够了。示例调整如下export function buildPoster({ title, author, tag, date, noise 0 }) { const offsetX (noise % 5) - 2; // 生成 -2 到 2 之间的偏移 // ... // 在 NOT AI 徽章的 style 中加入 // marginRight: ${offsetX}px }然后在index.js中获取noise参数const noise Number(req.query.noise || 0);这个功能本身很轻量但它让每次生成的图片都有轻微的、肉眼几乎察觉不到的差异。每次分享出去的卡片都是“这一秒”生成出来的版本。这种概念层面的趣味性其实比单纯把图片做得漂亮更贴合主题。6. 接入博客与社交平台服务搭建好了接下来就是把动态 OG 图片接入你的真实页面。假设你的博客页面是 HTML你只需要在head中把og:image指向这个动态服务并带上当前文章的参数meta propertyog:title content亲手搭建 OG Image 服务 / meta propertyog:description content本文从零开始实现一个动态 Open Graph 图片生成服务。 / meta propertyog:image contenthttps://your-domain.com/og?title亲手搭建%20OG%20Image%20服务authorzhangshantagTECHdate2025-01-20v20250120 / meta propertyog:type contentarticle /如果你用的是 Hexo、VitePress、Next.js 这类框架可以在主题模板里把og:image拼成动态 URL。以 VitePress 为例你可以在head配置或者在布局组件里动态生成。有一点要特别注意og:image的 URL 必须是可以被社交平台服务器公开访问的地址。localhost无效必须是公网可访问的域名。如果你在本地做实验可以用ngrok之类的工具把本地服务临时暴露到公网但公网暴露时要注意加防滥用措施这个我在后面“工程化建议”部分会讲。另外URL 中的v参数很有用。社交平台一般都会缓存第一次抓取到的 og 信息如果你更新了图片或标题平台可能还在用旧缓存。这时只要你修改 URL 中的v参数就是一个全新的 URL平台会把它当作新图片重新抓取。7. 常见问题与排查思路自己实现动态 OG 图片服务最常见的坑集中在字体、布局、缓存和性能四个方面。我整理成一张表方便你直接对照排查。问题现象常见原因解决思路图片生成成功但中文显示为空白方块satori 没有拿到包含中文字形的字体数据确认fonts目录下有中文字体文件并且启动日志中显示“使用字体”生成过程报错且提示 CSS 属性不支持satori 只实现 CSS 布局子集部分复杂样式不支持改用 flexbox、简单背景、border、borderRadius 等基础属性避免box-shadow等特殊效果图片内容不是预期的 1200 × 630satori 宽高参数与 resvg 输出尺寸不一致统一用WIDTH 1200、HEIGHT 630resvg 用fitTo.width保持输出尺寸每次访问都很慢响应经常超过 2 秒没有加缓存每次请求都完整走一遍渲染流程加内存缓存或 CDN 缓存用 query 参数作为缓存 key社交平台分享时仍显示旧图平台缓存了旧的 og:image 地址修改 URL 中的v参数强制生成新 URL同时在平台的分享调试工具中重新抓取部署到服务器后访问 500 错误服务器系统缺少可用字体或字体文件路径不对检查启动日志中“使用字体”是否打到了项目内字体路径确保字体文件随项目一起部署有人恶意刷接口服务器资源被耗尽接口没有做访问控制和限流增加签名参数、IP 限流或部署前限制可访问的域名/请求来源这里单独说一下“分享调试工具”。Facebook 有 Sharing Debugger可以输入 URL 后强制重新抓取。Twitter/X 有 Card Validator可以预览卡片效果。微信没有公开的调试工具很多开发者反映微信对 og 标签的解析和缓存策略跟海外平台不同而且更新很慢。如果你的目标主要是微信生态建议把图片地址的版本参数做明显比如v微信更新日期便于手动刷新。8. 工程化建议与最佳实践服务能跑通之后如果你想把它真正用到生产环境还有几个工程层面的问题需要提前考虑。8.1 加一层缓存动态生成图片的本质是把“文本数据”渲染成“图片文件”这个过程并不廉价。如果访问量稍大每次都现场渲染会白白消耗 CPU。最简单有效的方案是在 Node 进程内维护一个 LRU 缓存把 query 字符串作为 key把生成的 PNG Buffer 作为 value。命中缓存时直接返回不命中时才走渲染链路。示例思路如下const cache new Map(); const MAX_CACHE 200; function getCacheKey(query) { return JSON.stringify(query); } function setCache(key, buffer) { if (cache.size MAX_CACHE) { const oldestKey cache.keys().next().value; cache.delete(oldestKey); } cache.set(key, buffer); }更精细的做法是把生成的图片推到 CDN让边缘节点承担缓存。CDN 的缓存命中率远高于单机内存缓存而且能降低源站压力。8.2 参数签名与防滥用一个公开的动态生成接口如果没有任何防护很容易被刷。因为生成图片比返回普通 HTML 更消耗资源。常见做法是给 URL 加一个签名参数https://your-domain.com/og?titlexxxauthorxxxtagxxxsignmd5(titleauthorsecret)服务端用相同的secret算出签名再和请求参数中的sign对比不匹配就拒绝渲染。这样只有你知道秘钥别人无法随意发起海量请求。注意签名参数只能防“非授权调用”不能防“拿到合法 URL 后反复请求”。所以最好再配合 IP 限流、每分钟请求次数限制等策略。8.3 模板版本管理模板视觉总会有迭代。如果你改版了设计但线上还有旧文章怎么办建议在 URL 中显式带一个v版本参数例如v20250120。模板变化时版本号跟着变。这样旧文章仍然可以用旧版本号渲染旧风格新文章用新版本号渲染新风格两边互不干扰。如果你真的希望整个站点统一换新只需所有页面生成og:image时统一升级v参数即可。这里的核心思想是把“图片内容”和“模板版本”解耦。8.4 字体版权与性能生产环境尽量使用 OFL 或 Apache 2.0 授权的开源字体比如 Noto Sans SC、思源黑体、阿里巴巴普惠体。不要直接拷贝微软雅黑、苹方这些商用授权不明确的字体尤其是部署在企业项目中时。字体文件的体积也会影响内存。一个完整的中文字体文件可能 10MB 甚至更大如果每次请求都从磁盘读取一次会浪费 IO。建议在服务启动时读取一次字体 Buffer 并复用这也是本文示例代码中的做法。8.5 长文本与安全边界标题超长是动态图片最容易出现的视觉问题。除了代码里做.slice(0, 30)还应该支持按字数调整字号或者在模板中用 CSS 的文本省略能力。但要注意satori 对text-overflow: ellipsis支持并不稳定所以最稳妥的方式还是在上游控制标题长度或者按长度分档设置字号。另外用户传入的标题、作者、标签等内容最终会出现在图片里。虽然渲染成 SVG 后不会执行脚本但如果生产环境允许外部传参仍然建议对内容做 HTML 转义避免出现异常的 SVG 解析结果。9. 小结与下一步这一期我们从“怎么在 AI 时代保留一点手工痕迹”这个略显抽象的问题出发落地成一个非常具体的技术方案一个基于 Node.js、satori、resvg-js 的动态 OG Image 生成服务。你现在应该已经掌握OG Images 在网页分享链路中的作用satori resvg-js 的技术原理与组装方式如何设计一个可复用的卡片模板中文字体处理、参数限制、缓存与防滥用等工程细节接入博客和社交平台时需要留意的版本与缓存问题。下一步你可以把这个/og接口接入自己的博客主题让每一篇文章的分享卡片都自动生成。如果还想继续深入可以研究一下vercel/og这个更上层的封装它内部其实就是 satori resvg 的组合只是包装成了更易用的 Next.js 接口。理解了底层实现之后再看那些封装也就不再觉得“黑盒”了。如果你也在自己的项目里做过类似“保留人类痕迹”的小设计欢迎在评论区聊聊下一期我打算继续这个系列折腾一点更适合证明“我是真人”的小内容。