给博客加上动态 OG 图片
之前每次在微信或者 Discord 里分享博客链接,预览卡片里永远是同一张占位图,看着总有点别扭。正好最近在折腾博客,就顺手把动态 OG 图片这件事一起解决了。
什么是 OG 图片
OG 是 Open Graph 的缩写,本质上就是一组 HTML meta 标签,告诉社交平台在渲染链接预览卡片时用哪张图、显示什么标题和描述。微博、Twitter、Telegram、Discord,基本上主流的平台都会读取这些标签。
<meta property="og:image" content="https://example.com/og/my-post.png" />
<meta property="twitter:image" content="https://example.com/og/my-post.png" />
静态的方式是给所有文章指定同一张图,简单但是没有区分度。动态的方式是在构建或请求时,根据文章标题实时生成一张带文字的图片,每篇文章都有自己独特的预览图。
技术选型
生成图片这件事,现在最主流的方案是 Satori,Vercel 出品。它的核心思路很简单:把一段类似 JSX 的 HTML 结构和 CSS 样式转换成 SVG,再配合 sharp 把 SVG 转成 PNG。
整个链路:文章标题 → Satori 渲染 SVG → sharp 转 PNG → 静态文件。
遇到的问题
这里有个坑值得记一下。我最初的思路是把 OG 图片做成 Astro 的 prerendered 静态路由,在 src/pages/og/[...slug].png.ts 里生成每篇文章对应的 PNG。这个方案理论上很干净,和博客内容的生命周期绑在一起。
但这个项目用的是 @astrojs/cloudflare 适配器,Astro 在执行 prerender 的时候会走 miniflare 环境,也就是 Cloudflare Workers 的本地模拟器。问题在于,我的生成代码里用了 node:fs 来读字体文件,而 miniflare 里默认没有 node:fs,构建直接报错:
Error: No such module "node:fs".
这个问题有几种解法:启用 nodejs_compat 兼容标志、把字体文件编码成 base64 内联到代码里、或者换一种完全不依赖 Node.js 文件系统的字体加载方式。但说实话,这些方案都有点绕。
我最后选择的方案是:把 OG 图片生成从 Astro 的构建流程里分离出来,做成一个独立的 prebuild 脚本,在调用 astro build 之前先跑一遍,生成好的图片直接放到 public/og/ 目录里。Astro 在构建时会把 public/ 里的文件原样复制到输出目录,所以这些图片最终会作为静态资源被部署出去。
这样做有几个好处:脚本跑在纯 Node.js 环境里,没有 Cloudflare Workers 的限制;字体文件可以直接用 fs.readFileSync 加载;逻辑也比较好测试。
改完之后 package.json 里的 build 命令变成了这样:
"build": "node scripts/generate-og-images.mjs && astro build"
中文字体的问题
Satori 生成图片时需要提供字体文件,而且必须是 ArrayBuffer 格式。博客里用的 Atkinson Hyperlegible 字体不包含中文字符,所以博客标题里的汉字如果直接渲染,要么是空白,要么是豆腐块。
解决方案是在检测到标题包含非 ASCII 字符时,额外从 Google Fonts 拉取 Noto Sans SC。Google Fonts 的接口有个好用的地方:可以通过 text 参数指定需要用到哪些字符,返回的字体文件只包含对应的字形子集,体积很小,通常只有几十 KB。
const url = `https://fonts.googleapis.com/css2?family=Noto+Sans+SC&text=${encodeURIComponent(text)}`;
这个请求发生在构建阶段,不会影响页面的加载性能。
效果
现在分享博客链接,预览卡片会显示对应文章的标题。设计上我做的比较克制:深色背景,上面一条紫色的色块做点缀,标题用白色大字,底部分割线下面是博客名称。没有多余的装饰,够用就好。
图片规格是标准的 1200x630,基本上所有平台都能正常显示。
一点想法
这件事做下来大概花了半个多小时,其中大部分时间是在处理 Cloudflare Workers 环境的兼容性问题。这也是我用这个技术栈的一个感受:Cloudflare Workers 作为 runtime 非常轻量,但正因为如此,很多 Node.js 里习以为常的东西都用不了,稍微复杂一点的 Node 生态工具就容易踩坑。
不过换个角度想,把生成逻辑从运行时移到构建时,其实是个更合理的设计。OG 图片的内容来自文章标题,只要文章不改,图片就不需要重新生成,放在静态资源里完全够用。