从零构建永久存证的区块链博客:Polygon + Hexo + GitHub Actions 实战
·(已编辑)· / , , , · 阅读0辅助 · 标题
普通静态博客难以证明内容未被修改及版本时间。本文方案结合 Hexo 生成网站、GitHub Actions 自动构建、Polygon PoS 保存数据、GitHub Pages 提供访问。核心是通过两个智能合约分别存档首页和规范化文章数据,实现链上公开可验证的发布记录。文章需经换行统一、gzip 压缩、Base64 编码等确定性处理,确保跨平台构建一致。合约使用 CREATE2 固定地址,发布脚本通过比对链上哈希避免重复交易。首页 HTML 需移除动态区域以稳定内容哈希。工作流采用双构建流程,先上链获取区块信息,再二次构建展示。文章还探讨了图片永久存证、历史版本查询及测试网限制等实践问题,最终在保持网站速度的同时,提供了不依赖服务器的内容证明。
普通静态博客解决了「如何发布」的问题,却没有完全解决「如何证明」的问题:文章是否被修改过、某个版本究竟何时出现、网站关闭后还能不能验证原始内容,都依赖站长和托管平台。
方案:Hexo 负责生成网站,GitHub Actions 负责自动构建,Polygon PoS 负责保存首页和文章数据,GitHub Pages 负责提供正常的网页访问体验。
最终架构
整个发布过程可以概括为:
这里有两个相互独立的合约:
网页仍然从 CDN 或 GitHub Pages 加载,因此访问速度和普通 Hexo 博客没有区别。区块链负责提供一份公开、可验证、不能悄悄改写的发布记录。
准备环境
本地需要 Node.js、pnpm、Git 和一个 Polygon 钱包:
安装依赖并验证 Hexo:
生产发布使用 Polygon 主网,链 ID 是 137,Gas 代币是 POL。建议单独创建一个只负责发布的钱包,不要直接使用存放主要资产的钱包。
先设计文章的链上格式
文章不能直接把 Hexo 生成后的 HTML 原样塞进合约。HTML 会混入主题结构、构建元数据和样式细节,主题稍微调整就会让全部文章产生新版本。
我最终使用下面的规范化 JSON:
content 不是普通 Markdown,而是经过以下处理后的结果:
确定性非常重要。同一篇文章无论在 Windows 还是 Linux 构建,都必须得到相同的字节序列。否则 GitHub Actions 每运行一次,就可能误判为新内容并支付一次 Gas。
文章内容哈希没有直接计算压缩后的 Base64,而是对包含规范化 Markdown 的语义 JSON 计算 keccak256。这样哈希表达的是文章内容本身,而不是某个压缩器版本的输出细节。
图片哈希
本地图片可以读取真实二进制并计算哈希,但远程图片存在一个经常被忽略的问题:构建机未必能稳定下载它们,远端还可能有防盗链、超时或内容动态变化。
当前实现对外链图片计算的是「图片 URL 字符串的 MD5」,它只能标识文章引用了哪个地址,不能证明该地址返回的图片内容,也不能保证图片永久可用。
如果要做严格的永久图片归档,可以选择:
因此,「文章 Markdown 已上链」和「文章引用的所有资源都已永久保存」是两件不同的事。
Solidity 合约设计
文章合约的核心结构并不复杂:
写入时不直接使用字符串 slug,而是先计算:
合约的 publish 函数需要完成几项检查:
我把单篇文章限制为 24,576 字节。这里限制的是最终 JSON 的 UTF-8 字节数,不是 Markdown 文件大小。gzip 会缩小正文,但 Base64 又会增加大约三分之一体积,所以一定要在发交易前检查最终 payload。
合约同时保存两个哈希:
「永久」到底指什么
当前结构每次更新会覆盖 publications[slugHash].payload,因此合约读取接口只能直接返回最新版本。旧版本仍然存在于区块链历史、交易 calldata 和事件中,但不能通过当前 mapping 直接查询。
如果需要让每个历史版本都能从合约方法直接读取,应改成:
代价是持续增长的合约存储和更高的 Gas。对个人博客而言,保留最新可读版本,同时依靠交易历史证明旧版本,是一个更实际的折中。
使用 CREATE2 固定合约地址
CI 不能依赖某个本地保存的部署结果,因此我使用 CREATE2 预先计算合约地址:
initCode 包含合约字节码和构造参数。只要部署器、salt、编译结果和 owner 完全一致,最终地址就一致。发布脚本会先调用 eth_getCode:如果地址上没有代码就部署,有代码就直接复用。
这里的坑是,更换发布钱包也会改变合约地址,因为 owner 是构造参数的一部分。升级 Solidity 版本、修改优化参数、调整合约代码或 salt,同样会得到新地址。不要在没有迁移计划时随意修改这些值。
发布脚本如何避免重复交易
文章发布脚本会扫描 source/_posts,并要求文件名与数字 slug 一致:
数字 slug 必须是正整数且不能重复。它既是网页永久链接的一部分,也是链上文章的逻辑主键。发布过后不要把原文章换到另一个 slug,否则链上会被视为一篇全新的文章。
对于每篇文章,脚本先读取链上记录:
这意味着每次 CI 都可以扫描全部文章,但只有新增或真正修改的文章会发送交易。没有变化的文章会根据发布区块查询历史事件,恢复交易哈希并生成展示元数据。
发送前还要执行 estimateGas,并预留 20% 的 gas limit 缓冲:
余额检查必须使用网络返回的 maxFeePerGas 或 gasPrice。合约首次部署通常比后续发布贵,而保存完整 HTML 或 JSON 的成本会随着字节数明显增加。
首页为什么需要单独规范化
首页保存的是 Hexo 生成后的完整 HTML,但页面底部包含构建哈希和构建时间,页面中还会显示上一次发布的区块高度与交易哈希。如果直接对 HTML 求哈希,会形成一个循环:
解决方法是在计算 contentHash 前,移除两个动态区域:
替换逻辑应该要求每个区域恰好出现一次。静默忽略模板结构变化很危险,因为某次主题修改可能让动态字段重新进入哈希,造成每次构建都重复付费。
需要注意:合约实际保存的是当次构建的完整 HTML,规范化只用于计算内容哈希和去重。只改变构建时间时不会覆盖链上的旧 HTML,这是预期行为。
GitHub Actions 双构建流程
工作流只监听 main 分支,并授予 contents: write 权限:
完整步骤如下:
核心命令可以写成:
.onchain/articles.json 只服务于同一次工作流的第二次构建,因此应该加入 .gitignore。真正可信的数据源仍然是 Polygon,JSON 文件只是生成页面时的缓存。
Secrets 与权限配置
仓库的 Settings -> Secrets and variables -> Actions 中需要配置:
类型
名称
用途
Secret
`POLYGON_PRIVATE_KEY`
发布钱包私钥
Variable
`POLYGON_RPC_URL`
可选的 Polygon 主网 RPC
POLYGON_PRIVATE_KEY 是钱包的 32 字节私钥,通常表现为 64 个十六进制字符。脚本可以兼容有无 0x 前缀,但不能填写钱包地址、助记词名称或 API Key。
私钥必须放在 Secret,不能放在仓库文件、Actions Variable、构建日志或前端 JavaScript 中。GITHUB_TOKEN 由 GitHub Actions 自动提供,不需要手动创建。
建议给发布钱包设置:
上链前后的验证
本地先验证构建和 payload:
也可以只模拟合约编译、地址计算和文章体积:
发布完成后,可以从合约读取文章并解压:
验证时不要只看网页上的文字。至少要在 Polygonscan 确认:
实际踩过的坑
1. 测试网Polygon不等于无限免费
Amoy 测试网适合验证流程,但水龙头通常有领取频率和数量限制。完整 HTML 或文章 payload 的 calldata 与存储都很贵,首次部署加首次写入很容易超过当天领取的测试币。
测试时应先缩短 payload,分开验证「部署」和「发布」,并在发送交易前打印 gas estimate。切换主网时必须同时检查 chain ID、RPC、区块浏览器地址和 Gas 代币,不能只换一个 RPC URL。
2. 构建时间让首页无限重复上链
只要 HTML 里包含当前时间、Git commit、实时区块高度或随机值,原始 HTML 哈希就不稳定。必须先定义哪些字段代表内容,哪些只是展示元数据,再进行严格规范化。
3. RPC 不稳定导致 CI 偶发失败
公共 RPC 可能限流、超时或不支持批量请求。文章发布器应关闭不必要的 JSON-RPC batching,设置明确网络,并在日志中区分「RPC 失败」「余额不足」「交易回滚」和「等待确认超时」。
不要在失败后无条件重发。先读取链上 contentHash,因为上一笔交易可能已经广播成功,只是 CI 在等待回执时中断了。
3. 外链图片仍然可以消失
文章正文永久存在,不代表 OSS 链接永久存在。只保存 URL 的 MD5 也不能证明远端文件内容。需要完整存证时,应把图片迁移到内容寻址存储,并将 CID 纳入文章内容哈希。、
参考链接
总结
Hexo 提供简单可靠的静态内容模型,GitHub Actions 把构建和发布变成可重复流程,Polygon 则为每个版本提供公开时间线和可验证字节。三者组合后,网页依然快速、易读、易维护,同时多了一份不依赖博客服务器自身信用的内容证明。