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

博客时光机 2.0 后端篇:构建可视索引

# 记录博客时光机 2.0 的可视索引构建过程:如何从 IPFS 历史 CID 中筛出真正发生页面变化的版本。

author: Liudon date: 2026-09-27 words: 3677 read_time: 8m
$ less index.md

前言

上一篇介绍了博客时光机 2.0的更新,这篇来聊聊它背后的实现。

2.0 想做的事情其实很直观:

让博客从过去开始,一版一版走到今天。

原本以为,把历史页面截下来,按时间播放就行了。

真正做起来以后才发现,截图只是一个开始。

有 CID,直接截图不就行了?

在 1.0 版本中,已经记录了每一次 IPFS 发布对应的 CID 和部署时间,保存在 ipfs-history 分支里:

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

每条历史记录大概包含这些信息:

{
  "cid": "...",
  "source_commit": "...",
  "deployed_at": "..."
}

既然 CID 和时间都有了,那实现起来不就很简单了嘛。

读取 CID 历史
      ↓
访问对应 IPFS 页面
      ↓
Playwright 完整截图
      ↓
按照部署时间排序
      ↓
前端依次播放

方案上感觉是可行的,接下来先做个 Demo 来验证。

博客首页的变化最直观,所以第一版就把范围限定在首页。

实现上也很直接,先从 history/YYYY-MM.jsonl 里读取历史记录,拿到每个版本对应的 CID:

CID
 ↓
拼接 IPFS Gateway 地址
 ↓
https://liudon.xyz/ipfs/<CID>/

然后用 Playwright 依次访问这些历史页面,对首页进行完整页面截图:

await page.goto(`https://liudon.xyz/ipfs/${cid}/`);

await page.screenshot({
  path: `${cid}.png`,
  fullPage: true,
});

最后按照 deployed_at 排序,前端依次播放这些截图。

第一版的整个流程大概如下:

history/*.jsonl
      ↓
     CID
      ↓
IPFS Gateway
      ↓
  Playwright
      ↓
Full Page Screenshot
      ↓
按时间顺序播放

实现并不复杂,把需求交给 AI,很快就做出了第一版 Demo,看起来方案完全可行。

但真正把历史版本跑起来以后,第一个问题马上就出现了。

不同的 CID,截图却一样

每个被记录下来的 CID,都对应着一次网站内容发生变化后的构建结果。

很多 CID 明明不一样,截出来的首页却一模一样。

有时候只是修改了一篇旧文章正文,或者调整了一个不会出现在首页上的内容,也会产生一次新的构建和新的 CID。

但首页可能什么都没有发生。

例如:

部署记录:A → B → C → D
首页状态:①   ①   ①   ②

如果完全按照 CID 播放,看到的就是:

A
↓
还是 A
↓
还是 A
↓
终于变成 B

日期一直在变化,画面却没有变化。

IPFS 保存的是完整历史,但时光机真正需要展示的,并不是每一次部署,而是那些真正发生变化的时间点。

问题也随之变成了:怎么判断两个历史版本到底有没有变化?

那就比较截图

既然不同 CID 的首页可能没有变化,那就直接比较截图。

第一版采用的是 dHash,把完整截图缩成很小的灰度图,计算图片 Hash,再通过 Hamming Distance 判断两张截图是否足够相似。

截图 A
  ↓
dHash
  ↓
Hamming Distance
  ↑
dHash
  ↑
截图 B

足够接近 → 跳过
差异明显 → 保留

比较方式也很直接,按照 CID 的时间顺序逐个比较:

A → B
B → C
C → D

拿全量 CID 索引跑了一遍,当时一共有 63 个历史 CID,经过图片比较去重后,剩下了 31 个可视版本。

看起来效果不错,正在我高兴的时候,又有了新问题。

截图一样的问题仍然存在

打开 Demo 一张张往后看,发现里面仍然存在一些肉眼看起来几乎一样的截图。

第一反应当然还是算法的问题,于是又把 dHash 换成了一版更直接的像素相似度比较。

因为首页是一张很长的截图,如果整张图直接缩小后再比较,局部变化很容易被稀释,所以这一版同时比较:

完整页面
+
页面顶部区域

再根据像素差异和阈值判断是否相同。

调整了几轮,问题依旧存在,而且定位起来非常麻烦耗时。

为了快速验证,我一开始把截图的变化筛选和展示逻辑都塞进了 Demo 前端,想着验证完后在线上实施时再进行拆分。

当某个版本看起来不对时,很难马上知道问题究竟出在哪:

历史页面真的没变?
      ↓
截图有问题?
      ↓
比较算法误判?
      ↓
CID 和截图对应错了?
      ↓
前端过滤错了?
      ↓
还是最终展示错图了?

每次都要从头到尾排查一遍,非常耗时间。

因此决定,在不影响线上 1.0 版本的前提下,提前做拆分的方案:

  1. 后端提前生成 Visual Index 可视索引
  2. 前端只读取 Visual Index 进行展示

这样两部分角色清晰,实现也变得简单了。

整个的流程就变成了:

历史页面
   ↓
截图
   ↓
可视变化比较
   ↓
Visual Index
   │
   ├── 有变化 → 保留
   └── 无变化 → 跳过

Visual Index + 截图
        ↓
   前端时间线展示

让可视比较稳定下来

按新方案调整后,Visual Index 里仍然保留了一些 CID 不同、但肉眼看起来几乎一样的截图。

这样问题范围一下就缩小了:不用再怀疑前端展示,只需要继续检查截图和比较逻辑。

逻辑拆分的好处一下子就感受到了。

接下来要解决的,就是怎么让这份索引足够稳定、足够可信。

1. 使用无损图片进行比较

新方案实现时,考虑到前端需要连续加载很多张历史截图,所以截图会先 resize,再转换成 WebP。

给用户看的图片,并不一定适合拿来做像素比较。

resize、WebP 有损编码,本身就会改变像素,这会导致算法上的误判。

因此决定增加无损截图进行比较:

历史页面
   ↓
无损截图
   ↓
可视变化比较
   ↓
生成 Visual Index
  │
  ├── 有变化 → 保留
  └── 无变化 → 跳过

同时生成
   ↓
有损 WebP + Visual Index
         ↓
    前端时间线展示

比较始终使用无损截图,前端使用另外生成体积更小的 WebP。

这样至少先排除了 resize 和有损编码给比较带来的干扰。

2. 固定截图环境

去掉有损图片后,重新跑索引,又发现了新问题。

重建索引的过程中,发现同一个页面,重新截图,像素结果发生了变化。

继续排查,发现影响截图结果的东西比想象中多:

  • Chromium 版本
  • 字体和字体抗锯齿
  • 广告
  • 动画
  • Lazy Load
  • 图片和 Web Font 是否加载完成
  • 页面布局是否已经稳定

也就是说:

页面内容没变,不代表两次截图一定逐像素相同。

最后把这些条件统一成了 Capture Profile,固定截图的环境:

固定 Chromium
固定字体
固定 viewport / DPR
固定 locale / timezone
固定 light mode

阻断广告
删除广告容器

等待 stylesheet
等待 image
等待 font

滚动触发 lazy load
再回顶部

持续检查文字和布局是否稳定

最开始还想过一个最简单的方案,等三秒再截图。

后来发现这种方式还是太玄学,加载快的时候白等,加载慢的时候三秒又未必够。

最终改成了持续采集页面文字和元素布局,连续几次结果都不再变化,才认为页面稳定。

截图前后还会各检查一次,如果截图过程中页面又动了,这次 Capture 直接作废重试。

到这里,比较输入算是相对稳定了。

3. 增加文字与局部比较

截图环境稳定以后,再回头检查之前的比较结果,又发现了另一个问题。

QmYHSNJDhtn3dyqmt5SRra8yWiiSdi82PBs5CuYJLHFsC6
↓
QmTvL4HCt2mqJ7pPrGT9YCpHAea8kzdEAqqgnpZzj2orui

首页里有一处很小的文字变化:

57.6K → 58.7K

对于用户来说,这已经是一次真实变化。

但把它放到整张首页长截图里,只占非常小的一部分。

这时候如果继续只依赖图片相似度,就会陷入一个比较尴尬的问题。

阈值放宽一点,可能把这种真实的小变化吞掉。

阈值收紧一点,又容易把字体抗锯齿、浏览器渲染之类的细碎像素差异留下来。

既然文字本身就能直接判断,那就没必要再让像素算法去猜。

因此比较逻辑增加了一层:优先比较用户真正能看到的文字。

截图时同时提取页面 Visible Text,合并连续空格和换行以后进行比较。

候选页面
   ↓
Visible Text 是否变化?
   ├── 是 → 直接保留
   └── 否 → 再比较图片

这样,只要用户能看到的文字发生了变化,就直接认为产生了新的视觉版本。

不过 Visible Text 只能解决文字变化问题。

如果文字完全一样,但页面里的图片、布局、间距或者颜色发生了变化,还是得继续比较截图。

而首页又是一张很长的图片,如果只计算一个整页差异值,某个很小的局部变化,还是有可能被整张长图平均掉。

整页变化很小 ≠ 局部没有明显变化

所以图片比较又增加了局部 Tile:将缩小后的截图划分成一个个小区域,除了整体变化以外,再额外观察局部区域的变化程度。

最终的流程变成了:

先比较 Visible Text
        ↓
文字变化
   └── 直接保留

文字没有变化
        ↓
比较无损图片
        ↓
整体差异 + Tile 局部差异
        ↓
判断保留 / 跳过

Visible Text 负责判断文字变化;Tile 则用来补充判断图片、布局等局部的像素变化。

4. 调整比较基准

算法继续检查下去以后,又发现比较对象本身也有问题。

第一版一直比较相邻版本:

A → B
B → C
C → D

如果每一次只有一点点变化:

A → B:没超过阈值
B → C:没超过阈值
C → D:没超过阈值

每一步可能都被认为相同。

但这些微小变化不断累积以后,A ↔ D 可能已经形成了比较明显的差异。

相邻部署比较,存在把小变化一点点漂掉的风险。

这种连续的小差异逐步累积,可以理解为一种基准漂移(Baseline Drift)。

于是 Baseline 也做了调整:不再和上一条 CID 比,而是始终和最近一个已经被保留下来的视觉版本进行比较。

A ← Baseline
│
├── B vs A → same
├── C vs A → same
└── D vs A → changed
                ↓
             保留 D
                ↓
          D 成为新 Baseline

这样真正判断的是:

当前页面相比最近一次确认过的视觉状态,有没有形成一个新的版本。

到这里,整套比较逻辑才逐渐稳定下来。

History
   ↓
Stable Capture
   ↓
Candidate vs Retained Baseline
   ↓
Visible Text
   ├── 变化 ──────────────┐
   │                      │
   └── 不变               │
        ↓                 │
   Lossless Pixel Diff    │
        ↓                 │
    Overall + Tile        │
        ↓                 │
      有变化 ─────────────┤
        │                 │
      无变化              ↓
        ↓             保留 Candidate
       跳过                ↓
                    更新 Baseline
                          ↓
                     Visual Index

如何验收?

算法调到这里,剩下的问题就是:

怎么确认它真的判断对了?

最开始的办法很原始,在 Demo 里一张一张往后点,再自己用肉眼找变化。

次数少的时候还能看,几十张以后就开始怀疑人生了。

所以又做了一轮改动:

增加 visual-diffs.jsonl 文件,把每一次比较的详细信息都记下来:

baseline CID
candidate CID
same / changed
reason
overall diff
max tile diff
mean delta
changed bounds
visible text before / after

然后又单独做了一个 Diff 核对 Demo。

Diff Demo 效果页

它不负责展示效果,只回答一个问题:

这一帧为什么被保留?

第一版图片去重,从 63 个历史 CID 中筛出了 31 个可视版本。

经过截图环境、比较算法和 Baseline 几轮调整后,最终进一步收敛到了 22 个。

经过人工逐个核对后,确认都能找到对应的页面变化。

第一步的预生成索引工作,到这里总算是搞定了!

总结

到这里,原来那条 CID 时间线,终于变成了一条真正能够“看见变化”的时间线。

从中午的一个点子,到晚上方案可行性验证通过。

太激动了,真的就像回到以前第一次做产品的感觉。

兴奋地一晚上没有睡好,一心想着下一步前端的展示要怎么做。

这就是这个版本的后端实现了,下一篇我们来讲讲前端的事情,敬请期待。

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