← 返回首页

一源多栖:双站内容发布系统的同源架构、可信同步与 SEO 治理

·阅读 0 次

一篇文章,只维护一次;两个网站,各自完整呈现;内容自动流动,个人站的上线节奏仍掌握在人手里。

问题不是“复制文件”,而是管理内容的所有权

很多人同时维护两个网站:一个是纯粹的内容站,只负责沉淀文章;另一个是更完整的个人网站,除了文章,还包含作品、项目、履历或其他长期模块。

最直觉的做法,是每写完一篇文章,就把 Markdown 文件复制到两个项目里。这个办法短期有效,长期一定会出现问题:

  • 修正错别字时只改了一边;
  • 两边的标题、摘要和发布日期逐渐不一致;
  • 同名文件互相覆盖,却无法判断哪一份才是最新版本;
  • 搜索引擎发现两份正文相同的页面,不知道应该收录哪一个;
  • 自动化做得越激进,误删和静默覆盖的风险越高。

因此,真正需要设计的不是一条复制命令,而是一套内容所有权模型:谁是源头,谁是副本,哪些内容允许流动,发生冲突时谁有权覆盖,以及两边的页面如何向搜索引擎表达关系。


一、先确定边界:一个内容源,两个展示端

这套方案的第一原则是:共享文章只能有一个权威来源。

可以把两个网站抽象成两个角色:

  • 内容源站:负责文章创作、修改、版本历史和内容发布声明;
  • 聚合展示站:拥有自己的信息架构,同时原生展示来自源站的共享文章。

这里的“原生展示”很重要。聚合站不是跳转到源站,也不是用 iframe 把另一个页面包进来,而是使用自己的路由、导航、主题和页面布局渲染同一份 Markdown 内容。

这样既保留了两个网站的独立体验,又避免形成两套需要人工维护的正文。

                    唯一内容源
                         │
                 Markdown + 元信息
                         │
              ┌──────────┴──────────┐
              │                     │
         内容源站原生渲染       受控同步到聚合站
                                    │
                             聚合站原生渲染

两个网站仍然是两个产品,只是共享同一套内容资产。共享的是文章,不是整个网站。


二、用发布声明代替目录猜测

仅靠“文件放在哪个目录”判断发布范围并不够清晰。更稳妥的做法,是让每篇文章在 frontmatter 中显式声明发布目标:

---
title: 示例文章
date: 2026-09-24
tags: [架构, 实践]
summary: 文章摘要
publishTo: [blog, personal]
---

发布规则可以保持非常简单:

声明含义
[blog, personal]共享文章,在两个网站展示
[blog]内容源站专属文章
[personal]聚合站专属文章,由聚合站自行维护

显式声明比约定文件名、目录前缀或标签更可靠,因为它把“这篇文章应该去哪里”变成了文章自身的一部分。

同步程序还应该严格校验这些字段:标题、日期、摘要、标签和发布范围缺失时立即失败,而不是带着不完整数据继续运行。自动化系统最危险的行为不是报错,而是悄悄接受一个模糊状态。


三、共享渲染能力,但不要把内容塞进共享包

两个网站要呈现一致的 Markdown 阅读体验,通常会共享这些能力:

  • GFM 表格、任务列表与删除线;
  • 代码块语法高亮;
  • 标题 ID 生成;
  • 目录抽取与滚动高亮;
  • 图片、引用、列表和表格样式;
  • 原始 HTML 与危险链接的安全边界。

这些能力适合抽成一个共享渲染包,但文章正文不应该打进这个包里。

原因是代码与内容具有完全不同的更新节奏:

  • 调整目录算法、代码高亮或正文排版时,更新共享包;
  • 发布、修改或下架文章时,只更新 Markdown 和资源文件;
  • 发一篇文章不应该触发共享组件版本升级。

这个拆分避免了“为了改一个标点,两个项目都升级依赖”的荒谬流程。

最终形成三层结构:

  1. 内容层:Markdown、frontmatter、文章图片;
  2. 渲染层:正文组件、目录、标题解析和作用域样式;
  3. 站点层:各自的导航、主题、首页、侧边栏和其他业务模块。

内容可以同步,渲染能力可以复用,站点体验仍然独立。


四、同步清单:让自动化知道自己拥有什么

跨项目同步最容易犯的错误,是把目标目录当作可以随意覆盖的镜像目录。

更安全的方式,是在聚合站维护一份同步清单。清单记录:

  • 当前共享内容版本;
  • 由同步程序管理的文章路径;
  • 每个文件的 SHA-256;
  • 由文章引用并受管理的静态资源;
  • 清单结构版本与内容来源标识。

示意结构如下:

{
  "schemaVersion": 1,
  "source": "content-source",
  "contentVersion": "...",
  "articles": [
    {
      "path": "content/shared/articles/example.md",
      "sha256": "..."
    }
  ],
  "assets": []
}

清单的作用不是记录“现在有哪些文件”,而是明确告诉同步程序:哪些文件是我创建的,因此以后只有这些文件允许由我更新或删除。

这使同步过程具备几个关键性质。

不覆盖未知文件

如果目标路径已经存在同名文件,但它不在旧清单中,而且内容也不完全一致,同步必须停止。

它不能猜测这个文件是历史副本、个人站文章,还是用户临时编辑的内容。拒绝覆盖比自动判断更安全。

不覆盖被人工修改的托管文件

如果某个文件原本由同步程序管理,但目标站中的当前哈希既不等于旧清单哈希,也不等于新内容哈希,说明它曾被人工修改。

此时同步同样停止,要求先处理冲突,而不是把修改静默抹掉。

只删除自己管理过的文件

文章从共享范围中移除后,同步程序只能删除旧清单中存在、但新清单中已经消失的文件。

聚合站自己的文章永远不在这份删除集合中。这样才能保证“同步下架”不会演变成“清空目标目录”。


五、把同步变成 PR,而不是直接修改生产分支

文章同步可以自动化,但不应该直接绕过验证并修改生产分支。

更稳妥的链路是:源站文章进入主分支后,由持续集成任务完成同步,并向聚合站创建或刷新一个专用分支上的 Pull Request。PR 先执行代码检查和生产构建;验证成功后,再由一个独立的受信任工作流复核来源分支、目标分支、已验证提交和文件白名单,全部一致才自动合并。

文章提交到源站主分支
          │
          ▼
校验发布范围与资源完整性
          │
          ▼
根据旧清单计算写入、更新与删除
          │
          ▼
更新聚合站专用同步分支
          │
          ▼
创建或刷新 Pull Request
          │
          ▼
Lint + 生产构建
          │
          ▼
复核来源、提交 SHA 与文件白名单
          │
     ┌────┴────┐
     │         │
   全部通过    任一不符
     │         │
自动合并       保留 PR
     │
     ▼
由维护者决定何时发布服务器

为什么不让同步任务直接推送聚合站主分支?

因为“文章内容可以自动流动”和“未经验证就修改生产分支”是两件不同的事。PR 提供了清晰的变化记录和自动检查入口;独立的后置工作流只在验证成功后运行,并且不执行 PR 分支中的代码,从而避免把合并权限暴露给待验证内容。

这是一种刻意保留的控制边界:

  • 源站可以自动部署静态页面;
  • 聚合站可以自动接收内容 PR;
  • 只有通过校验且完全符合白名单的同步 PR 才自动合并;
  • 普通功能 PR、来源异常的 PR 和夹带其他文件的 PR 不受影响;
  • 自有服务器何时发布,仍由人决定。

自动化负责减少重复劳动,但每一次合并仍然具备可审查记录和明确的安全条件。


六、凭据应遵循最小权限,而不是“能用就行”

跨项目创建分支和 PR,需要一份能够访问目标项目的凭据。它不应该使用拥有全部项目权限的长期令牌,而应满足最小权限原则:

  • 只授权目标项目;
  • 内容权限只开放读写;
  • Pull Request 权限只开放读写;
  • 不授予项目管理、发布环境、密钥管理等无关权限;
  • 令牌只保存在源站的自动化 Secret 中,不写入代码和日志。

工作流还应该在 Secret 缺失时安全跳过,并在任务摘要中说明原因,而不是因为空凭据执行一半后留下难以理解的失败。


七、重复内容 SEO:展示可以重复,权威必须唯一

两个网站原生展示同一篇文章后,会出现一个新的问题:搜索引擎看到两份正文几乎完全相同的页面,应该把哪一个视为权威版本?

解决方法不是隐藏聚合站页面,而是建立明确的 canonical 规则:

  • 共享文章在两个网站上的 canonical 都指向内容源站;
  • 聚合站专属文章 canonical 指向聚合站自身;
  • 共享文章不进入聚合站 sitemap;
  • 共享文章在聚合站仍然可以访问、分享和站内导航;
  • Open Graph URL 与 JSON-LD 中的文章 URL 使用同一权威地址;
  • JSON-LD 的 mainEntityOfPage 与 canonical 保持一致。

这套规则表达的是:聚合站拥有展示页面,但源站拥有内容权威。

需要特别注意,canonical 不是跳转。读者仍然留在当前网站阅读,只是搜索引擎知道应把两个页面的信号归并到哪一个地址。


八、不要只测试“复制成功”,还要测试拒绝路径

同步系统的价值主要体现在异常情况下,因此测试重点不应该只有“文件成功复制”。至少要覆盖:

  1. 共享文章可以被复制并写入清单;
  2. 相同内容的历史文件可以被安全接管;
  3. 聚合站专属文章不会被删除;
  4. 未托管的同名冲突会拒绝覆盖;
  5. 托管文件被人工修改后会拒绝覆盖;
  6. 下架时只删除旧清单管理的文件;
  7. Markdown 引用的共享资源必须真实存在;
  8. 重复标题、Setext 标题和代码块伪标题不会破坏目录;
  9. 原始 HTML 与危险 URL 不会被执行;
  10. 两个网站的生产构建都能通过。

此外,还应直接检查最终 HTML,而不是只看 TypeScript 是否编译成功:

  • <link rel="canonical"> 是否符合文章来源;
  • og:url 是否与 canonical 一致;
  • JSON-LD 是否指向同一个权威页面;
  • sitemap 是否包含了应该收录的页面,并排除了共享副本。

构建成功只能证明代码能运行,不能证明搜索引擎看到的语义正确。


九、一次完整的文章发布会发生什么

当作者新增一篇共享文章并推送后,完整链路应该是:

  1. 源站读取 frontmatter,确认文章同时发布到两个站点;
  2. 源站完成测试和静态构建,并部署自己的文章页面;
  3. 同步任务解析文章及其引用资源;
  4. 同步任务读取目标站旧清单,进行冲突检查;
  5. 新文章被写入目标站的共享内容目录;
  6. 清单重新计算内容版本与文件哈希;
  7. 自动化更新专用分支,并创建或刷新 PR;
  8. 目标站在 PR 中运行定向检查和生产构建;
  9. 后置工作流复核来源、提交 SHA 和文件白名单,通过后自动合并;
  10. 维护者按自己的节奏发布目标站服务器。

从作者视角看,只发布了一次文章;从工程视角看,每个阶段都有自己的边界、证据和失败方式。


十、从写作到双站上线:日常应该怎么操作

架构设计最终要落到一条足够简单的日常路径。对于这套双站系统,作者不需要同时操作两个内容目录,也不需要逐次进入聚合站点击合并。

第一步:只在内容源项目中写文章

共享文章始终创建或修改在内容源项目的文章目录中,并显式声明发布到两个站点:

---
title: 文章标题
summary: 文章摘要
date: 2026-09-24
pinned: false
tags: [标签一, 标签二]
publishTo: [blog, personal]
---

如果文章只属于内容源站,则将发布范围改为 [blog]。聚合站专属文章则在聚合站自己的文章目录中维护,不进入共享同步链路。

第二步:在推送前做同步预演和生产构建

提交前至少执行两类检查:

npm run sync:articles:check
npm run build

同步预演应明确列出本次准备写入和删除的文件,但不真正修改聚合站。作者需要确认:

  • 本次只更新预期文章和资源;
  • 没有意外删除;
  • 没有同名冲突或人工修改冲突;
  • 文章 frontmatter 和图片引用完整;
  • 内容源站能够完成生产构建。

如果本次还修改了 Markdown 渲染、目录算法或同步程序,则应额外运行共享渲染测试和同步安全测试。

第三步:提交内容源站主分支

验证通过后,正常提交文章并推送主分支:

git add content/articles/<article>.md
git commit -m "docs: add article"
git push origin main

共享图片也应与文章一起提交到约定的文章资源目录。推送完成后,维护者不再手工复制 Markdown。

第四步:等待两条自动流水线完成

推送会同时触发:

  1. 内容源站部署:安装依赖、生产构建并发布静态页面;
  2. 聚合站内容同步:校验发布范围,更新共享文章与资源,重新生成 manifest,并创建或刷新同步 PR。

聚合站随后执行 ESLint 和生产构建。只有验证成功,并且来源分支、目标分支、提交 SHA 和文件白名单全部匹配,PR 才会被自动 squash merge;任何条件不满足都会保留 PR,等待人工处理。

第五步:按需发布聚合站服务器

自动合并只代表共享内容已经进入聚合站主分支,并不代表自有服务器已经更新。维护者先同步聚合站本地代码:

git pull --ff-only origin main

如果本次只有文章、manifest 或文章资源变化,可以使用项目提供的内容发布模式;如果还包含应用代码、依赖或共享渲染包变化,则执行完整发布。

因此,正常发布一篇共享文章时,人工动作最终只剩五步:

  1. 在内容源项目写文章;
  2. 运行同步预演和生产构建;
  3. 提交并推送主分支;
  4. 等待部署、同步、验证和自动合并;
  5. 需要更新聚合站线上内容时,手动发布服务器。

修改、下架和失败处理

修改共享文章时,仍然只修改内容源中的原文件。只想从聚合站下架时,将发布范围改为 [blog];两个站点都不再展示时,删除源文章。

出现异常时按照流水线位置定位:

现象优先检查
内容源站没有更新静态站部署任务
没有创建同步 PR跨项目同步任务与访问凭据
PR 检查失败聚合站的代码检查和生产构建
PR 没有自动合并来源、目标、提交 SHA 与文件白名单门禁
聚合站代码已更新但线上没有变化自有服务器是否完成手动发布

这条路径的关键不是“所有事情都自动”,而是把重复且可验证的步骤自动化,把服务器上线这种具有环境风险的动作保留为显式决策。


十一、为什么不选 iframe、跳转或运行时拉取

iframe

iframe 实现快,但会带来主题割裂、移动端高度处理、滚动嵌套、可访问性、分享语义和 SEO 等问题。它复用了整个页面,却没有复用内容模型。

直接跳转

直接跳转最简单,但聚合站的文章模块会退化成外链目录,无法形成完整的信息架构,也无法保留自己的阅读体验。

浏览器运行时拉取 Markdown

运行时从远端请求 Markdown 看起来更“实时”,但它把内容可用性绑定到另一个服务,增加跨域、缓存、失败降级和首屏性能问题。对于构建型网站,发布时同步通常更确定。

复制粘贴

复制粘贴没有系统成本,却把所有成本推迟到了未来:版本漂移、遗漏修改、冲突和重复劳动都会随着文章数量增长。

相比之下,“单一来源 + 构建时同步 + 原生渲染 + PR 审核”并不是代码最少的方案,却是长期维护成本更低、边界更清晰的方案。


十二、后续如何演进

这套架构已经能稳定支持个人规模的双站发布。只有出现明确需求时,才值得继续升级:

  • 文章数量显著增加:增加增量同步报告和更细粒度的变更摘要;
  • 出现第三个展示端:把发布目标扩展为可配置渠道,而不是复制第二套脚本;
  • 多人共同写作:为文章增加作者、审核状态和发布日期门禁;
  • 图片数量增加:加入资源去重、尺寸校验和失效引用检查;
  • 更新频率很高:在 PR 中生成文章预览地址,缩短审查路径;
  • 服务器发布足够稳定:再考虑把人工发布升级为带环境保护的自动部署。

没有这些触发条件时,不必急着引入 CMS、消息队列或复杂的内容平台。好的架构不是预先实现所有可能性,而是让下一次变化仍然有清晰的落点。


总结

双站文章发布的核心,不是把一个文件从 A 搬到 B,而是同时解决四个问题:

  • 内容所有权:共享文章只在一个地方维护;
  • 展示独立性:两个网站都使用自己的页面体系原生渲染;
  • 同步可信度:清单、哈希和冲突保护阻止静默覆盖;
  • 搜索权威性:canonical、结构化数据和 sitemap 对同一篇文章给出一致答案。

最终得到的不是一段更聪明的复制脚本,而是一条可审查、可回滚、可解释的内容供应链。

当自动化只做它能证明安全的事情,并在不确定时主动停止,发布一次、两站可见才真正从“方便”变成了“可靠”。