藏经阁
首页文章项目关于
首页文章项目关于

Supabase 内容同步脚本一条龙:构建期落盘 JSON 快照 + routes manifest + assets 镜像(多语言齐全 gate)

2228
2026-02-28 17:21
16 小时前
标签
Nextjs全栈
返回博客列表

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、回滚、局部更新都会简单很多。

构建期落盘产物目录树(示意图)
图:把“内容/路由/资源”拆成可追溯的目录产物,天然支持 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 };
}

真实链路:一次同步任务在构建里到底发生了什么?

  1. 你在本地或 CI 执行 sh scripts/downloadLocales.sh(可选传入 slug 参数做局部同步)。
  2. 入口脚本切到项目根目录,并尝试加载 .env.local(保证 Supabase URL/API Key 可用)。
  3. 下载脚本从内容表分页拉取所有记录(offset/limit),拿到多语言 rows。
  4. 脚本把 category=page 的 rows 按 slug 聚合语言集合,计算哪些 slug 是“语言齐全”的。
  5. 脚本把 common/components 全量落盘;把 pages 里“不齐全/未发布”的 slug 跳过。
  6. 脚本从“齐全 slug 集合”生成 routes.json(用于 sitemap 与路由治理),并写入固定目录。
  7. 脚本调用 Storage list API 递归枚举 bucket 对象,逐个下载写入本地镜像目录。
  8. 脚本输出统计: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.json entries 数量是否与“齐全 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 短暂抖动,也不会直接把构建打红或把线上内容清空。

友情链接
© 2024
Workmn
|
踏实的走好每一步
粤ICP备15107978号