Supabase 内容同步脚本一条龙:构建期落盘 JSON 快照 + routes manifest + assets 镜像(多语言齐全 gate)
Supabase 内容同步脚本一条龙:构建期落盘 JSON 快照 + routes manifest + assets 镜像(多语言齐全 gate)
你把内容放进 Headless CMS(或 Supabase 这类“内容+数据”平台)之后,通常会遇到一个很现实的问题: 内容不是“有没有”,而是“能不能稳定交付到前端应用里”。
如果你完全依赖运行时从远端拉取内容,那么 CMS 抖动、语言未补齐、资源链接失效,都会直接变成线上 500/404/软 404; 反过来,如果你把内容工程化成“构建产物”,就能获得三件关键能力:可追溯、可降级、可回滚。
这篇文章给一个足够落地的案例:用一个脚本把 Supabase 的内容表与 Storage 变成构建期产物, 输出本地 JSON 快照 + routes manifest + 静态资源镜像,并用“语言齐全 gate”把多语言事故挡在发布之前。
TL;DR(30 秒讲清楚)
- 内容落盘:分页拉取内容表,按
category与slug把内容写入本地 JSON(pages/components/common 三类)。 - 多语言 gate:只把“所有必需语言都齐全”的页面加入可发布集合;缺语言页面直接跳过(避免 sitemap 宣告 404)。
- 路由清单:从内容元信息生成
routes.json(包含slug与prefixPath),供 sitemap/路由治理复用。 - 资源镜像:递归枚举 Storage bucket,逐个下载到本地目录,保证页面离线也能渲染出图。
问题定义(Problem Statement)
设计一个构建期同步脚本:从 Supabase 内容表分页拉取多语言内容,按约定目录落盘为 JSON 快照; 通过语言齐全 gate 过滤不可发布页面;生成 routes.json 供 sitemap/路由治理复用; 同步镜像 Storage 里的静态资源到本地目录;并提供最小可验证与失败可回滚机制。
关键伪代码(读者可复现)
1) 入口:脚本封装(加载 .env → 执行下载)
实战里最容易踩坑的是“本地能跑、CI 跑不了”:原因往往不是代码,而是环境变量与工作目录不一致。 所以入口脚本最好做两件事:切到项目根目录 + 加载本地 env 文件(如果存在)。
伪代码:scripts/downloadLocales.sh(入口封装)
set -e
cd <repo-root>
if fileExists(".env.local"):
exportEnvFromFile(".env.local")
# 允许传 slug 过滤:只同步指定页面(但 common/components 始终同步)
run("npx tsx src/modules/landing/scripts/download.ts", args)
2) 分页拉取:offset/limit 直到没有更多
内容表动辄上千行,直接一次性拉全量不仅慢,还容易被网关/超时打断。最稳的方式是 offset + limit 分页循环,直到返回条数小于 pageSize。
伪代码:fetchAllPages()(分页拉取)
async function fetchAllRows() {
const all = [];
const pageSize = 1000;
let offset = 0;
while (true) {
const rows = await httpGetJson(`/rest/v1/marketing_pages?offset=${offset}&limit=${pageSize}`);
all.push(...rows);
if (rows.length < pageSize) break;
offset += pageSize;
}
return all;
}
3) 语言齐全 gate:按 slug 聚合,缺语言直接跳过
多语言内容最常见的事故是:英文先上线,其他语言还没补齐,但 sitemap/head 的 alternates 先对外宣告了; 最终爬虫抓到一堆 404/软 404,索引覆盖率下降。
解决思路很简单:把“发布集合”从行级(row)升级为slug 级(聚合),要求 slug 覆盖所有必需语言后才允许落盘/进入 manifest。
伪代码:completeSlugs gate(按 slug 聚合语言)
const REQUIRED_LOCALES = ['en', 'es', 'fr']; // 站点支持语言(示例)
const LANGUAGE_MAP = { 'zh-TW': 'zh-Hant', fil: 'tl' }; // DB -> FS 映射(示例)
function dbLangOf(fsLang) {
// 反向映射:用于“数据库是否齐全”的判断
for (const [dbLang, mapped] of Object.entries(LANGUAGE_MAP)) {
if (mapped === fsLang) return dbLang;
}
return fsLang;
}
function computeCompleteSlugs(rows) {
const bySlug = new Map(); // slug -> Set<dbLang>
for (const r of rows) {
if (r.category !== 'page') continue;
bySlug.getOrCreate(r.slug).add(r.language);
}
const complete = new Set();
for (const [slug, langs] of bySlug.entries()) {
const ok = REQUIRED_LOCALES.every((fsLang) => langs.has(dbLangOf(fsLang)));
if (ok) complete.add(slug);
}
return complete;
}
4) 落盘快照:按 category/slug/locale 写入固定目录
目录结构建议直接体现“用途边界”(pages/components/common),避免把一切塞进一个大 JSON: 这样你后面要做 diff、回滚、局部更新都会简单很多。
伪代码:getOutputPath()(目录约定)
function outputPathOf(row) {
const locale = (LANGUAGE_MAP[row.language] || row.language).trim();
const slug = row.slug.trim();
if (row.category === 'page') {
return `distStatic/locales/marketing/pages/${slug}/${locale}.json`;
}
if (row.category === 'component') {
return `distStatic/locales/marketing/components/${slug}/${locale}.json`;
}
if (row.category === 'common') {
return `distStatic/locales/common/${slug}/${locale}.json`;
}
throw new Error('unknown category');
}
5) 生成 routes.json:把内容元信息变成 sitemap/路由治理可复用的输入
routes manifest 的价值是把“内容系统”与“站点路由”解耦:内容只需要提供 slug 与必要元信息(例如 page type), 站点根据规则把它落到不同的前缀路径(例如 /{slug} 或 /features/{slug})。
这一步建议同时做两类防御:
- 默认值:遇到未知 page type 时给默认前缀(同时打印告警)。
- 发布隔离:对“未发布/未准备好”的 slug 做硬过滤(避免误入 sitemap)。
伪代码:generateRoutesManifest()(routes.json)
const PAGE_TYPE_PREFIX = {
'Tool': '/',
'Use Case': '/features'
};
function generateRoutesManifest(completeSlugs, pageTypeBySlug) {
const entries = [];
for (const slug of completeSlugs) {
const pageType = pageTypeBySlug.get(slug) || null;
const prefix = PAGE_TYPE_PREFIX[pageType] || '/features';
entries.push({ slug, prefixPath: prefix });
}
return {
version: 1,
generatedAt: new Date().toISOString(),
entries
};
}
示例输出:distStatic/locales/marketing/routes.json
{
"version": 1,
"generatedAt": "2026-03-11T10:00:00.000Z",
"entries": [
{ "slug": "example-tool", "prefixPath": "/" },
{ "slug": "example-feature", "prefixPath": "/features" }
]
}
6) 递归镜像 Storage:枚举所有对象,逐个下载到本地
同步 JSON 但不同步图片/插图,最终会得到“内容对了但页面缺图”。一个工程化的做法是: 在构建期把 Storage bucket 当成资源源,把它镜像到本地(或你自己的静态域名/CDN)。
对象存储的“目录”通常是虚拟概念:列表接口可能会返回文件与“文件夹占位符”。实践里可用一个规则: 如果列表项没有 id(或标识为 folder),就递归进入;否则当成文件下载。
伪代码:listStorageFiles() + downloadAllAssets()
async function listStorageFiles(prefix = '') {
const out = [];
let offset = 0;
const limit = 1000;
while (true) {
const items = await httpPostJson('/storage/v1/object/list/assets', { prefix, offset, limit });
for (const item of items) {
const fullPath = prefix ? `${prefix}/${item.name}` : item.name;
if (item.id == null) out.push(...await listStorageFiles(fullPath));
else out.push(fullPath);
}
if (items.length < limit) break;
offset += limit;
}
return out;
}
async function downloadAllAssets() {
const files = await listStorageFiles('');
let ok = 0, failed = 0;
for (const storagePath of files) {
try {
const bytes = await httpGetBinary(`/storage/v1/object/public/assets/${encodePath(storagePath)}`);
writeFile(`distStatic/locales/${storagePath}`, bytes);
ok++;
} catch {
failed++;
}
}
return { ok, failed };
}
真实链路:一次同步任务在构建里到底发生了什么?
- 你在本地或 CI 执行
sh scripts/downloadLocales.sh(可选传入 slug 参数做局部同步)。 - 入口脚本切到项目根目录,并尝试加载
.env.local(保证 Supabase URL/API Key 可用)。 - 下载脚本从内容表分页拉取所有记录(offset/limit),拿到多语言 rows。
- 脚本把
category=page的 rows 按slug聚合语言集合,计算哪些 slug 是“语言齐全”的。 - 脚本把 common/components 全量落盘;把 pages 里“不齐全/未发布”的 slug 跳过。
- 脚本从“齐全 slug 集合”生成
routes.json(用于 sitemap 与路由治理),并写入固定目录。 - 脚本调用 Storage list API 递归枚举 bucket 对象,逐个下载写入本地镜像目录。
- 脚本输出统计:JSON 文件数、manifest entries 数、assets 下载数、失败数;你按“最小验证清单”做回归。
工程化细节:容易被忽略但很关键的点
1) 语言映射:DB 语言码与站点语言码可能不一致
一些语言码在数据库与站点侧的表示不同(例如繁体的缩写、某些语言的旧写法)。 同步时你需要做两件事:
- 写入时映射:把 DB 语言码映射成站点期望的文件名(否则运行时加载器找不到文件)。
- 校验时反向映射:判断“语言是否齐全”时必须回到 DB 语言码,否则会误判缺失。
2) 未发布内容隔离:不要靠“没有 publishAt”这种隐式约定
真实项目里经常存在“内容已写但暂不上线”的状态。最稳的做法是有一个明确的黑名单/白名单机制: 在同步脚本里硬过滤,避免不小心进入 routes.json 与 sitemap。
更进一步,你可以把它升级为“环境差异策略”: 开发环境允许同步未发布页面用于预览;生产环境严格过滤(甚至要求所有语言齐全才可发布)。
3) 失败处理:assets 失败不一定阻断,但必须可观测
脚本里常见的取舍是:下载某些 assets 失败时不中断整体流程(避免一次小抖动导致构建完全失败)。 但要配套两个东西:
- 失败计数与样本日志:把失败数打出来,必要时列出前 N 个失败路径。
- 发布门禁:在 CI 上设置阈值(例如失败数 > 0 则阻断/告警),防止“构建成功但线上缺图”。
4) 可回滚:避免“覆盖式写入”造成无法回到上个快照
最推荐的方式是“写入到带时间戳的临时目录,然后原子切换”: 例如输出到 distStatic/locales.snapshots/2026-03-11T1000/...,校验通过后再把 current 指向新快照。 这样 CMS/网络失败时,你可以一键回滚到上一个快照,而不是靠重新同步“碰碰运气”。
指标与验证(最小闭环)
- 内容完整性:本次同步的 pages 数量、跳过的 slug 数量(缺语言/未发布)、缺失语言明细(按 slug)。
- manifest 正确性:
routes.jsonentries 数量是否与“齐全 slug 集合”一致;未知 page type 是否有告警。 - 资源完整性:assets 成功/失败数量;失败路径样本。
- 可回归:对比上一次快照的 diff(新增/删除/变更的 slug 与资源),确认变更符合预期。
通过标准(建议):
- 稳定性:CMS 不可用时可降级(页面与 sitemap 不 500)。
- 完整性:manifest/快照可追溯;生产环境语言齐全 gate 生效(缺语言不对外宣告)。
- 可验证:抽样 sitemap loc 200;无效内容/缺字段有统计与告警。
最小可复现检查清单(示例)
# 1) 全量同步(构建期/CI)
sh scripts/downloadLocales.sh
# 2) 只同步一个页面(排查某个 slug 的多语言齐全性/内容结构)
sh scripts/downloadLocales.sh example-tool
# 3) 检查 routes.json 是否生成
cat distStatic/locales/marketing/routes.json
# 4) 抽样检查某个 slug 的多语言文件是否齐全
ls distStatic/locales/marketing/pages/example-tool
预期与判定(建议):
- 同步可重复:脚本在本地/CI 都能稳定跑通;失败时不会把已有快照覆盖成“半成品”。
- gate 生效:缺语言 slug 被统计并跳过(不进入 routes manifest / sitemap 集合)。
- 产物可用:
routes.json与落盘目录结构一致;抽样 slug 的多语言文件齐全且能被渲染消费。
不通过先查:环境变量是否在 CI 与本地一致;分页拉取是否被超时/限流打断; assets 下载是否缺少重试与并发控制;以及快照目录是否支持“写入临时目录 → 校验通过 → 原子切换”的回滚路径。
FAQ
Q1:为什么要“语言齐全 gate”?会不会影响发布效率?
gate 的目标不是“卡你上线”,而是把 SEO 事故(sitemap 宣告 404/软 404、alternates 指向不存在)挡在发布之前。 你可以通过“环境差异策略”平衡效率:开发/预览不要求齐全,生产要求齐全。
Q2:能不能做增量同步,而不是每次全量?
可以。常见做法是按 updated_at 拉取增量,或在 manifest 里记录上次同步时间戳。 但要注意:增量同步最容易把“删除/下架”漏掉;所以仍建议定期跑全量(例如每天一次)。
Q3:assets 太多,下载很慢怎么办?
- 并发:做有限并发下载(例如 5–10 并发),避免单线程慢,也避免把 Storage 打爆。
- 缓存:对已存在且大小/hash 未变的文件跳过下载。
- 分桶:把“内容引用资源”与“历史遗留资源”拆分 bucket 或 prefix,减少需要同步的集合。
Q4:routes.json 里为什么只输出 slug + prefixPath,不输出完整 URL?
因为 baseUrl/locale 前缀/尾斜杠等属于“站点 URL 策略”,不应该被 CMS 绑死。 routes manifest 最适合作为中间层:它输出“可发布的内容集合”,由 sitemap/head 在不同环境下拼成最终 URL。
Q5:为什么 common/components 也要同步?只同步 pages 不行吗?
实战里 pages 往往引用 common(header/footer/global-content)与 components(CTA 模块、卡片组件)。 只同步 pages 会导致渲染时缺依赖;同步脚本把这两类作为“基础依赖”全量拉取,是为了让页面快照可独立复现。
Q6:下载过程中某个接口 500/超时怎么处理更稳?
建议增加:请求超时、指数退避重试、以及“失败时使用上一次快照”的降级策略。 这样即使 CMS 短暂抖动,也不会直接把构建打红或把线上内容清空。