← 返回博客

用 Astro + GitHub Pages 免费建站

从 Gitee 停运开始,到一个静态站真正跑起来。部署只花了一下午,剩下的时间都在填坑。

2026-09-01 约 7 分钟读完

本文目录 跳到想读的章节
  1. 为什么换平台
  2. 三步上线
  3. 项目页还是用户页
  4. 一、子路径会击穿一切硬编码的斜杠
  5. 二、三种长得一模一样的抓取失败
  6. 三、抓不到数据不能让整站挂掉
  7. 四、图片:防盗链、SSRF、和「支持」的两种含义
  8. 五、时间:构建机在 UTC
  9. 六、序列化必须是精确的逆运算
  10. 七、删东西会留下坏账
  11. 八、「看起来没问题」是最贵的
  12. 最后

这个站从零到现在六十来个提交。部署本身只花了一下午,剩下的时间全在填坑。

先把能一次说清的部分说完,后面是那些让我真正卡住过的事。

为什么换平台

本来打算用 Gitee Pages,一搜才发现 Gitee Pages 已于 2025 年 6 月正式下线,官方入口都撤了。于是迁到 GitHub Pages。

三步上线

  1. 用 Astro 写内容,Markdown 放在 src/content/
  2. npm run build 生成静态文件到 dist/
  3. 推到 GitHub,仓库 Settings → Pages → Source 选 GitHub Actions

配好 .github/workflows/deploy.yml,之后每次 git pushmain 都会自动构建上线。

项目页还是用户页

  • 项目页:仓库名 blog-demo → 访问 https://<用户名>.github.io/blog-demo/,构建要带 SITE_BASE=/blog-demo/
  • 用户页:仓库名 <用户名>.github.io → 直接访问根路径,SITE_BASE=/

免费额度对个人博客绰绰有余:1GB 存储、100GB/月流量。绑自己的域名要在域名商配 CNAME,用 .github.io 子域名则免备案直接可用。


到这里为止都很顺。下面才是这个站真正花掉时间的地方。

一、子路径会击穿一切硬编码的斜杠

项目页那个 /blog-demo/ 前缀是所有麻烦的开始。

冒烟测试里有一句「等 preview 起来」,做法是去请求 /。本地 SITE_BASE=/,一请求就通;CI 上站挂在 /blog-demo/ 下,请求 / 永远 404 —— 于是本地永远绿,CI 永远红,而且报的是「preview 30 秒内没起来」,指向完全错误的方向。

顺带学到一件事:「端口上有东西在应答」和「我们的站能打开」是两个判断,不能共用一个函数。前者 404 也算,后者必须 2xx。混在一起就会像上面那样,把一个路径问题报成一个启动问题。

二、三种长得一模一样的抓取失败

股票页的行情是构建时抓的。抓不到的时候,undici 的 fetch 抛出来的 e.message 永远fetch failed —— 真正的原因埋在 e.cause 链里。把链打出来之后,才看清是三种完全不同的病:

现象真实原因
UND_ERR_SOCKET other side closed被限流。实测连续 171 秒不通,任何重试策略都跨不过去
HTTP 502对方 WAF 挡了 GitHub runner 的整个出口 IP 段。同一个接口,我本地 200,runner 上恒 502
HTTP 200,body 是 []接口静默死掉了。返回成功,就是没有数据

第三种最阴。它不报错、不超时、不触发任何重试逻辑,页面只是安静地变空。一个只在失败时打日志的系统,看不见这一种。

限流那次的教训是:既然重试跨不过 171 秒,就别再往重试上加代码了,改成一次批量请求(ulist.np)绕开被限流的那个分页接口。方向错了的时候,重试次数加到多少都没用。

三、抓不到数据不能让整站挂掉

行情抓失败,博客、关于页、照片时间线不该跟着一起下线。所以降级要分区:指数拿到了、板块没拿到,就只降板块那一块。

这里我自己制造了一个更糟的 bug:降级时沿用上一次的数据,提示语写「沿用上一次的(03:36:52)」,而 03:36这次构建的时间戳 —— 页面在一本正经地说谎。

修法是让每一块数据各记各的时间:indicesAtbreadthAt 分开存,页面显示哪一块就取哪一块的时间。一个数据源一个时间戳,别共用一个「更新于」。

还有一个更隐蔽的:占位数据被当成了历史。CI 每次都是全新 checkout,仓库里那份 source: 'sample' 的占位文件被「沿用上一次」的逻辑当成了真数据,于是这个降级永远自愈不了。

四、图片:防盗链、SSRF、和「支持」的两种含义

编辑器要能按书名检索封面,于是碰上三件事。

豆瓣的图有防盗链。 不带 Referer 直接返回 418。而跨站 Referer 是浏览器伪造不了的 —— 只能由服务端取回来转一手。

那个转发接口就是一个 SSRF 洞。 ?u=<任意 URL> 意味着谁都能拿它去请求 127.0.0.1、内网地址、或者云厂商的元数据端点 —— 那些恰恰是浏览器够不到、而服务器够得到的地方。必须上白名单,而且要按 hostname 精确匹配:用 includes('doubanio.com') 判断的话,img9.doubanio.com.evil.com 会直接放行。

sharp 的 format 里有 heif,但它解不了 HEIC。 实际调用抛的是 Support for this compression format has not been built in —— 预编译包带了容器格式的支持,没带 HEVC 解码插件。容器格式支持 ≠ 编解码器支持。 我一开始看 sharp.format 就下了结论,是错的,拿一张真的 HEIC 试过才知道。iPhone 默认拍出来就是这个格式,所以这条一定会被撞上,得给一句人话提示(「去关掉相机里的高效率格式」),而不是把编解码器的报错原样甩到脸上。

五、时间:构建机在 UTC

照片的拍摄时间存在 EXIF 里,而 EXIF 的时间不带时区OffsetTimeOriginal 才带 —— 手机会写,相机通常不写。不去读它,一张在日本拍的照片会整整偏一小时。

更普遍的一条:GitHub runner 跑在 UTC。

new Date('2025-01-01T00:00:00+08:00').getFullYear()
// 本地(东八区)→ 2025
// runner(UTC)  → 2024

年度归档页因此会把元旦那条记录扔进上一年。凡是从 Date 里取年月日、或者格式化日期,都必须显式指定时区,不能靠机器环境。这个 bug 在本地一辈子也复现不出来。

六、序列化必须是精确的逆运算

内容存成 Markdown,编辑器要读进来、改完再写回去。写和读这两半必须严格互逆,而且读的时候要单遍扫描

我这里出过一次典型事故:写的时候把换行转义成 \n 两个字符、引号转义成 \";读的时候只还原了 \"。结果是:

  • 多行文本每编辑保存一次,就多长出一层反斜杠
  • tags: ['a', 'b'] 被整个当成一个字符串 —— 标签静默消失,不报任何错

后来把解析挪到了服务端、和写入放进同一个模块,再用一组往返测试锁住:「写出来再读回来,必须和原来一模一样」。这类 bug 的特征是每次只坏一点点,单看一次改动完全正常。

顺带两条同源的:

  • Markdown 会把单个换行并成空格。 诗、清单、引文的断行是作者的语气,得靠 white-space: pre-wrap 保住。
  • reference() 在构建期就报错,这是 Astro 的好设计。但也意味着:删掉一篇被引用的长文,报错要等到下一次构建才冒出来 —— 那时人早就不在编辑器前面了。所以删除必须当场拒绝,并且点名是谁在引用它。

七、删东西会留下坏账

编辑器加删除功能的时候,真正的难点不是删文件,是别留下坏账:

  • 收藏条目的封面图和 md 在同一个目录,只删 md 就攒下一张没人引用的孤儿图
  • 换封面不删旧的,同理 —— 而且它们往往长得一模一样,事后根本分不清哪张还在用(真攒出过三张)
  • 一条照片动态是一个目录,得整个删

还有一条是给我自己的:清理脚本只能删这次明确创建的路径。 我写过 readdirSync(dir).filter(f => f.endsWith('.jpg') && !f.startsWith('example')) 这种「反向白名单」,一次删掉了三张真实的封面 —— 它保护的是我知道的文件,保护不了我不知道的。那次是 git 救了场。

八、「看起来没问题」是最贵的

这一节的四件事有同一个特征:没有任何东西报错。

类名撞车。 全局 CSS 里已经有一个 .spark(首页那排股票迷你柱,display: flex + 固定 height: 44px)。Astro 的作用域样式只是给选择器加了个属性,我没设的属性照样从全局继承下来 —— 于是新页面里每一条被压成 44px 高,多行的直接叠在下一条上面。页面照样返回 200,测试照样全绿。

截图会骗人。 页面有个入场动画:.reveal 靠 IntersectionObserver 加类才显形。而 fullPage 截图里,首屏之外的元素从没进过视口,永远停在 opacity: 0。我对着截图判断「布局塌了」,动手量了才发现 scrollWidth 根本没溢出 —— 白改了一轮。

绿色不等于测过了。 有一条新写的断言被我放在了目标元素数量为 0 的页面上。它一直是绿的,也一直什么都没测。写完一条断言,最好先确认它在 bug 存在时会红

断言别钉死在示例内容上。 「中文能搜到」这一条写死了必须命中 /blog/weekend/。那是脚手架自带的示例文章,删掉之后这条测试就红了 —— 而搜索本身好好的。查询词改成从站上现有内容里现取,才不会因为「删掉了一篇示例」而误报。

最后

静态站最大的好处不是省钱,是故障面小:没有服务器可以宕机,没有数据库可以损坏,最坏的情况也就是构建失败、线上停在上一个版本。

代价是它把复杂度整个推到了构建期。回头看,上面这些坑几乎全都发生在两个时刻 —— 「构建的时候」,和「我以为构建对了的时候」。

而最贵的那几个 bug,没有一个是报错的:静默丢掉的标签、静默返回的空数组、静默叠在一起的布局、静默什么都没测的测试。它们的共同点是输出看起来完全正常

所以真正学到的东西可能只有一句:别相信「看起来没问题」,去量。

读到这里,谢谢你的时间。

如果还想继续读下去,
新的文章和闪念会送进 RSS。

订阅这本档案 ↗