Skip to content
liudon@localhost:~/blog$ cd /posts/hugo-ipfs-time-machine

我给博客做了一个时光机

# 利用 IPFS 保存的历史快照,给 Hugo 博客做了一个随机穿越过去版本的时光机。

author: Liudon date: 2026-09-21 words: 5309 read_time: 11m
$ less index.md

前言

博客早在23年的时候就接入了 IPFS 服务,通过 GitHub Actions 实现了 Cloudflare Pages 和 IPFS 两套服务托管。

整个流程大概是下面这样的,具体实现可以参考Hugo 接入 IPFS 服务这篇内容。

Hugo Build
    ├── 正常部署 → Cloudflare Pages ← liudon.com
    └── IPFS → CID ← liudon.xyz

博客主要还是通过 liudon.com 来对外提供访问,liudon.xyz 更像是一块试验田,用来验证 Hugo 博客跑在 IPFS 服务上。

虽然 IPFS 部署的流程跑通了,但是博客始终并没有真正用到 IPFS 服务,总感觉差点意思。

既然折腾通了,那就要用起来,于是就有了这个功能:

博客时光机。

思考

最开始把博客放到 IPFS,并没有特别明确的使用场景。

怎么能把博客和 IPFS 结合起来,用上 IPFS 的能力呢?

IPFS 是基于内容寻址的。

简单理解就是:

内容决定地址。

当 Hugo 生成的博客内容发生变化以后,对应的 CID 也会变化。

比如今天发布一次:

博客版本 A
CID A

过几天修改了文章或者主题,再发布:

博客版本 B
CID B

正常的网站部署一般是覆盖式的。

新版本上线以后,线上看到的自然就是新版本。

而之前的网站长什么样,通常也就看不到了。

IPFS 不太一样。

CID A 对应的是版本 A 的内容。

CID B 对应的是版本 B 的内容。

只要对应内容还存在:

CID A → 当时的博客

CID B → 后来的博客

新版本并不会把旧版本覆盖掉。

想到这里以后,突然发现:

我其实早就已经有了一堆博客历史快照。

只是以前这些 CID 都是孤立存在的,没有真正拿来做什么。

那不如把它们串起来。

以前每次部署得到的 CID,大概就是这种状态:

CID A
CID B
CID C
CID D
CID E
...

单独看到其中一个 CID,我自己也不知道它对应哪一次部署。

所以真正需要补上的,并不是一套新的博客备份系统。

IPFS 已经把内容保存好了,需要做的只是给每次部署额外记录:

CID
+
发布时间
+
Git Commit

这样原本没有关系的 CID 就变成了:

某次 Commit
Hugo Build
IPFS CID
部署时间

有了这些信息以后,就可以知道:

某个 CID,对应的是博客过去某一个真实存在过的版本。

到这里,“时光机”这个想法基本就出来了。

根据这些信息,按常规思路就是搞一个历史列表页,点击进入某一个历史的详情页。

我不喜欢这个实现,我想让这个功能变得好玩,而不单单就是增加一个列表和详情页。

既然叫“时光机”,我觉得就不应该让我先选择:

我要回到 2026 年 9 月 3 日下午几点。

真正有意思的方式应该是:

点一下,看看它把我送到哪里。

所以最后干脆不做历史列表。

也不提供时间选择。

时光机页面只做一件事情:

启动
随机选择一个过去的博客快照
穿越

至于会回到哪一天,不提前告诉你。

可能是昨天。

也可能是一个月以前。

这种不确定性反而更有意思。

平时我大概永远不会专门去翻:

一个月前我的博客首页是什么样?

但时光机如果随机把我扔回去,就会顺手看两眼。

偶尔还能看到一些自己已经忘掉的东西。

这才比较符合我想做的“时光机”。

需求基本上明确了,通过 CID 索引记录历史版本,通过前端页面随机穿越到某个历史版本。

实现

整体架构

功能想清楚以后,实现反而没那么复杂。

整个时光机主要分成三部分:

GitHub Actions
生成并记录历史快照

ipfs-history
保存历史索引

Hugo 静态页面
随机选择一个快照并穿越

完整一点,大概是:

                         Git Push
                     GitHub Actions
                       Hugo Build
                         public/
                ┌───────────┴───────────┐
                │                       │
                ▼                       ▼
            正常部署                  IPFS
                │                       │
                ▼                       ▼
           liudon.com                  CID
                               ipfs-history 分支
                         ┌──────────────┴──────────────┐
                         │                             │
                         ▼                             ▼
                    months.json                  history/
                                              YYYY-MM.jsonl
                                              Hugo 时光机
                                              随机抽取记录
                                                   CID
                                       liudon.xyz/ipfs/<CID>/
                                              过去的博客

这里几个部分之间是相对独立的。

正常博客还是继续正常发布:

Git Push
Hugo Build
liudon.com

IPFS 时光机只是利用同一次构建的结果,额外生成一份历史快照。

即使哪天时光机坏了,主站也完全不受影响。

这点我觉得还是挺重要的。

毕竟这是一个为了好玩加出来的功能,不能反过来影响正常博客。

发布时:生成并记录历史快照

Workflow:每次发布自动留一个快照

博客原来就已经有 GitHub Actions 发布流程。

大致就是:

Git Push
Checkout
Hugo Build
public/
发布

Hugo 执行完以后,会得到最终的:

public/

这个目录就是浏览器最终看到的完整静态网站。

里面已经包含:

  • HTML;
  • CSS;
  • JavaScript;
  • 图片;
  • 文章页面;
  • 首页;
  • 分类、标签等页面。

所以 IPFS 的处理直接放在 Hugo Build 后面。

同一份:

public/

同时走两条路径:

                    public/
              ┌────────┴────────┐
              │                 │
              ▼                 ▼
          正常部署             IPFS
              │                 │
              ▼                 ▼
         liudon.com            CID

这样有一个好处:

IPFS 保存的就是这次实际构建出来的网站。

不是 Markdown。

不是 Hugo 源码。

也不是过一段时间再 checkout Git Commit 重新构建出来的一份网站。

而是当时 Workflow 真正生成出来的那一份静态文件。

每执行一次 Workflow,就会产生一个对应的 CID:

Commit A
Hugo Build
CID A

Commit B
Hugo Build
CID B

Commit C
Hugo Build
CID C

这样博客更新得越久,IPFS 上留下来的历史切片自然也就越多。

Workflow 还要把 CID 记录下来

只有 CID 还不够。

如果 Workflow 只是打印:

bafyxxxx...

任务跑完以后,这个 CID 很快就淹没在 Actions 日志里了。

所以生成 IPFS 快照以后,还要继续记录一些信息。

最基本的包括:

CID
source_commit
deployed_at

例如:

{
  "cid": "QmUYgSY9Safv5gTT9i2nwWLT9q5tvXzxuRBdXpv3TGAMrU",
  "source_commit": "3c9130e0d6d11a78858cb2463c9ea37e03fb1db8",
  "deployed_at": "2026-09-20T05:59:41Z"
}

其中:

cid
→ 这次发布对应的博客快照

source_commit
→ 这次构建对应哪个 Git Commit

deployed_at
→ 实际部署时间

Workflow 最终做的事情可以概括成:

Hugo Build
得到 public/
上传 IPFS
得到 CID
生成历史记录
写入 ipfs-history

整个过程都是自动完成的。

正常写博客、提交代码、发布。

时光机的数据自然也就跟着增加了。

IPFS 快照索引怎么组织

单独用 ipfs-history 保存快照索引

IPFS 快照记录没有直接放进博客源码。

我专门用了一个 ipfs-history 分支,用来保存每次部署产生的 CID,以及对应的发布时间、Commit 等索引信息。

目前结构大概是:

ipfs-history
├── latest.json
├── months.json
└── history
    ├── 2026-07.jsonl
    ├── 2026-08.jsonl
    └── 2026-09.jsonl

这样做主要还是为了让 博客源码自动生成的部署历史 彻底分开。

源码分支继续放:

  • Markdown;
  • Hugo 配置;
  • 主题;
  • 静态资源;
  • Workflow。

ipfs-history 则只负责:

  • CID;
  • 发布时间;
  • Commit;
  • 快照索引。

这样每次发布以后,Workflow 只需要更新 ipfs-history 分支,不需要再往正常源码里提交一堆自动生成的数据。

latest.json 记录当前快照

除了完整的历史记录,还单独维护了一个 latest.json,它保存最近一次成功部署的 IPFS 快照信息。

大概是:

{
  "version": 1,
  "cid": "bafy...",
  "source_commit": "...",
  "deployed_at": "..."
}

之所以单独保留一份最新状态,是因为很多时候并不需要翻完整个历史。

对于时光机前端来说,它可以快速知道:

哪个 CID 代表“现在”

随机穿越时就可以把这个 CID 排除掉,避免启动一次时光机,结果又回到了当前版本。

所以三个文件的职责基本就是:

latest.json
→ 当前最新快照

months.json
→ 整个历史快照池的索引

history/YYYY-MM.jsonl
→ 实际的历史快照记录

按月份拆分快照记录

最简单的做法当然是:

history.json

每次发布都往里面追加一条记录。

刚开始几十条的时候没问题。

但如果博客一直更新:

100 条
500 条
1000 条
...

这个文件会越来越大。

而时光机每次其实只需要:

随机抽一条。

完全没必要每次打开页面,都先把几年以来所有部署记录全部下载一遍。

所以最后把历史按月份拆开:

history/
├── 2026-07.jsonl
├── 2026-08.jsonl
├── 2026-09.jsonl
├── 2026-10.jsonl
└── ...

每个月对应一个 JSONL 文件。

其中每一行就是一次发布。

这样文件本身比较小,写入也比较简单。

months.json:整个随机池的索引

既然历史文件按月份拆开了,页面就需要知道:

现在有哪些月份?
每个月有多少条记录?

所以又维护了一个 months.json 文件。

类似这样:

{
  "version": 2,
  "total": 51,
  "months": [
    {
      "month": "2026-09",
      "count": 36
    },
    {
      "month": "2026-08",
      "count": 14
    },
    {
      "month": "2026-07",
      "count": 1
    }
  ]
}

这里的月份并不是拿给用户选择的。

页面上也不会出现:

请选择:

2026-07
2026-08
2026-09

它只是内部索引。

时光机打开以后,先加载这个很小的文件,就已经知道了整个随机池的结构。

例如:

总共 51 个快照

2026-07 → 1 个
2026-08 → 14 个
2026-09 → 36 个

然后再决定这一次到底抽哪条记录。

怎么做到全局均匀随机

这里还有一个小问题。

假设:

2026-07   1 条
2026-08  14 条
2026-09  36 条

如果简单写成:

随机一个月份
再随机这个月的一条记录

那么:

2026-07
2026-08
2026-09

三个自然月被选中的概率是一样的。

结果就是:

7 月唯一那条记录,被抽到的概率会远远高于 9 月的任何一条记录。

这显然不太合理。

我更希望的是:

每一个可用的历史快照,都有大致相同的机会被穿越到。

所以 months.json 里除了月份,还要保存 count

随机的时候按照记录数量来选择。

例如一共有:

51

条历史。

那就相当于先在:

1 ~ 51

之间随机一个数字。

再通过每个月的 count 找出它落在哪个月。

这样某个月历史快照越多,这个月被命中的概率自然越高,但最终落到每一个具体历史快照上的概率基本是一致的。

历史版本重建

之前的 GitHub Actions 构建日志里本身就有 CID 信息,所以又扫描了一遍历史 Workflow,通过日志里的 CID 尝试重建时光机上线之前的历史快照。

不过真正处理时发现,时间比较久的 Actions 日志已经无法获取。最后只找回了最近两个月左右的 CID,再早的历史也就没办法恢复了。

穿越时:Hugo 页面怎么工作

页面和历史数据分离

时光机页面没有配后端。

依然是 Hugo 正常生成出来的一个静态页面。

也就是说:

访问时光机
Cloudflare 返回 HTML

到这里和普通文章页没什么区别。

真正的随机逻辑全部由浏览器里的 JavaScript 完成。

这里我没有让 Hugo 在构建阶段读取 ipfs-history

否则就会出现一个比较奇怪的流程:

发布博客
生成新的 CID
更新历史
为了让页面知道这个新 CID
再重新发布一次博客

变成循环。

所以我把:

时光机页面

和:

时光机数据

彻底分开了。

Hugo 页面只负责:

HTML
CSS
JavaScript

历史数据则在页面打开以后动态获取。

这样新增一条历史快照的时候:

只更新 ipfs-history

完全不需要重新构建 liudon.com

随机选择一个历史快照

页面真正运行时,流程大概是这样:

打开时光机页面
同时读取 months.json 和 latest.json
知道历史记录总数和当前最新 CID
从全部历史快照中随机一个位置
定位到对应月份
读取 history/YYYY-MM.jsonl
取得对应历史记录
排除当前最新 CID
开始穿越

页面第一次只需要请求两个很小的索引文件:

months.json
+
latest.json

months.json 用来描述整个历史快照池:

一共有多少条历史记录
每个月分别有多少条

latest.json 则告诉页面当前最新的博客快照是哪一个。

这里特意把最新 CID 排除掉。

毕竟人现在访问的就是当前博客,如果启动一次时光机,结果又回到“现在”,那就没什么意思了。

确定随机位置以后,页面再去请求对应月份的数据,例如:

history/2026-09.jsonl

也就是说,一次穿越通常只需要读取:

2 个很小的索引 JSON
+
1 个当月 JSONL

不需要把所有历史数据一次性加载到浏览器。

历史记录以后就算继续增长,这个流程也不会有太大变化。

iframe 加载过去的博客

随机到某一条历史记录以后,就拿到了对应的 CID。

例如:

bafybeigq6ysku5e6uy4ahh2yodgpj3zvrioxt374d5amt3onsvjrcwloai

对应的历史博客地址就是:

https://liudon.xyz/ipfs/<CID>/

不过这里并没有直接把整个页面跳转过去。

时光机页面本身还保留着一个全屏的 iframe

随机出 CID 以后,只需要把:

https://liudon.xyz/ipfs/<CID>/

设置成这个 iframe 的地址。

于是:

liudon.com/time/
     时光机页面
随机得到历史 CID
iframe 加载
liudon.xyz/ipfs/<CID>/
   显示过去的博客

这样用户实际上始终没有离开时光机页面。

历史博客负责占满页面主体,而时光机自己的控制面板仍然保留在上面。

穿越完成以后,可以看到当前快照对应的时间,也可以继续:

🎲 再次随机

或者:

↩ 回到现在

所以整个实现说白了,就是:

随机挑一个过去的 CID,再把它加载到时光机页面里的 iframe

加点时光穿梭的感觉

现在时光机页面打开以后,就会自动开始随机。

如果拿到 CID 以后,马上把:

https://liudon.xyz/ipfs/<CID>/

塞进 iframe 显示出来,那这个功能看起来其实还是很像:

随机打开了一个网页。

虽然功能已经实现了,但“时光机”的感觉还是差一点。

既然名字都叫时光机了,还是得稍微做点仪式感。

所以页面中间加了一段简单的“穿越过程”。

比如:

BLOG TIME MACHINE

locating a point in blog history...
reading immutable snapshots...
time coordinate locked...
travelling...

等一下,再让历史页面慢慢显示出来。

同时为了让“穿越”看起来更像那么回事,我还给历史页面加了一层从模糊到清晰的过渡效果。

随机到历史 CID 后,并不会马上把页面完整显示出来,而是先让 iframe 中的历史博客以模糊、半透明的状态出现,再慢慢恢复清晰。

整个感觉有点像:

正在穿越
过去的页面逐渐出现
画面从模糊变清晰
抵达过去

本质上只是一些 CSS 过渡效果,没有什么复杂技术,但配合前面的终端文字和加载过程,整个“时光机”的感觉会完整很多。

这部分当然没有任何技术价值。

纯粹就是:

为了好玩。

本来这个功能的出发点也不是解决什么严肃需求。

那就干脆做得像个小玩具一点。

打开时光机。

不知道会去哪。

看着页面跑几行字。

然后过去某一次博客发布的页面慢慢出现在眼前。

这样才比较像“时光机”。

最后,把两条流程串起来

现在整个方案其实可以分成两个阶段。

发布博客的时候

Git Push
GitHub Actions
Hugo Build
public/
   ├────────→ 正常部署
   │             ↓
   │         liudon.com
   └────────→ IPFS
                CID
         写入 ipfs-history
       history/YYYY-MM.jsonl
                 +
            months.json

玩时光机的时候

打开 Hugo 静态页面
读取 months.json + latest.json
从全部历史快照中随机一个
读取对应月份的 JSONL
取得 CID
播放穿越效果
iframe 加载 IPFS Gateway
显示过去的博客

整个过程中没有:

  • 数据库;
  • 动态后端;
  • 历史查询 API;
  • 专门的版本管理服务。

用到的还是博客本身已有的东西:

Hugo
+
GitHub Actions
+
Git
+
IPFS
+
一点 JavaScript

最后拼出来这么一个小功能。

总结

最开始把博客内容托管到 IPFS,更多还是为了折腾。

当时看到网站真的能从 IPFS 上完整跑起来,还是挺有意思的。

但折腾完成以后,IPFS 和博客本身其实并没有真正产生联系。

对于平时访问博客的人来说:

有没有 IPFS

没有任何区别。

直到后来想到:

每一次发布产生的 CID,本身不就是一个博客历史快照吗?

于是把这些原本孤立的 CID 记录下来,再随机选一个跳回去。

时光机就这么出来了。

它没有解决什么特别重要的问题。

不会提高博客性能。

不会提高 SEO。

甚至从实用角度来说,一个历史版本列表可能都比它更有用。

但我最后还是没有做列表。

因为:

列表是拿来查东西的,时光机是拿来玩的。

点一下。

不知道会回到什么时候。

看看以前的首页。

看看过去写过的文章。

偶尔还能看到一些自己已经忘记的东西。

现在来看,这大概也是目前我给 IPFS 找到的,最适合这个博客的使用方式了。

点击时光机,看看它会带你回到哪一天吧。

$ twikoo --page /posts/hugo-ipfs-time-machine/
loading comments
NORMAL index.md words: 5309 EOF