0%

从第三方图床到仓库自托管:一次 Hexo 博客图片失效的排查与迁移

问题现象

博客上几篇旧文章的图片无法显示,浏览器里是一个个「裂图」图标。奇怪的是,并不是所有文章都出问题:有的文章图片一切正常,有的整篇几乎全是空白占位。

这些文章都是几年前写的,正文里的图片一直是用外链的方式插入的,托管在第三方免费图床上。既然大部分页面还能打开、文字也在,问题就只可能出在这些外链图片本身。

根因分析

把出问题的图片地址单独拿出来,用浏览器直接打开是能看到的;但只要从博客页面的上下文去请求,就失败。这种「换一个来源就失败」的特征,是典型的 Referer 防盗链。

用 curl 手动带上博客的 Referer 复现一下就很清楚了:

1
2
3
4
5
# 模拟浏览器从博客页面发起图片请求
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Referer: https://<博客域名>/" \
"https://<图床地址>/xxx.jpg"
# 输出:403

再对比不带 Referer 的请求:

1
2
curl -s -o /dev/null -w "%{http_code}\n" "https://<图床地址>/xxx.jpg"
# 输出:200

结论很明确:

  • 出问题的那批图来自微博图床,该 CDN 会校验 Referer,来源不是它白名单内的域名时直接返回 403。
  • 另一批图来自 SM.MS 图床,当时没有做这种限制,所以还能显示。

也就是说,「有的正常、有的裂图」并不是随机的,而是取决于图片挂在哪个图床。根本问题则是同一个:把博客的可视内容托付给了随时会变的第三方免费服务。免费图床限流、防盗链、甚至关停,站点就会大面积「塌方」。

顺带排查了一下:所有外链图片都只出现在文章正文(Markdown 里),没有任何配置文件或模板引用它们,涉及 8 篇文章、共 91 张唯一图片。

修复方案

核心思路只有一句话:把图片收回来,放进博客仓库自托管。仓库本身就是通过静态托管发布出去的,图片和页面同源,天然没有防盗链问题,也不再依赖任何第三方。

1. 图片落库并校验

先按「不带 Referer」的方式把图片全部下载下来,然后逐张校验,确保拿到的确实是合法图片,而不是图床返回的错误页或占位图:

1
2
3
4
curl -sL -o "$dest" "$url"
# 校验 MIME 类型与真实尺寸
file -b --mime-type "$dest" # 期望 image/*
sips -g pixelWidth -g pixelHeight "$dest" # 期望输出像素尺寸

这里有一个小发现:其中一张远端以 .png 命名的图,真实内容是 WebP。像 <img> 这种场景浏览器通常会做内容嗅探,勉强能显示,但仍然统一按真实格式改名保存,避免以后被某个环节的 MIME 校验坑到。

目录按文章 slug 组织,一篇一个文件夹,文件名按正文出现顺序编号,既清晰又不会因为多篇文章日期相同而冲突:

1
2
3
4
5
source/images/
├─ <post-slug-a>/01.jpg
├─ <post-slug-a>/02.jpg
├─ <post-slug-b>/01.png
└─ ...

同一张图在正文里被引用多次时,多个位置指向同一个本地文件,不做重复下载。

2. 改写正文链接

把每篇文章里的图片地址批量替换为根相对路径,只改链接本身,保留原有的 alt 文本和 Markdown 语法:

1
2
3
4
5
<!-- 之前 -->
![截图](https://<图床地址>/xxx.png)

<!-- 之后 -->
![截图](/images/<post-slug>/01.png)

用根相对路径(以 / 开头)而不是相对路径,好处是不受文章 URL 层级影响,无论页面被放在哪一级目录都能正确解析。

3. 顺手修正站点 URL 配置

排查时发现站点的 _config.yml 里 url 还停留在模板默认的占位值:

1
2
3
4
# 修正前
url: http://example.com
# 修正后
url: https://<站点正式域名>

这个字段虽然不影响页面里的相对路径,但它决定了 canonical、og:url、og:image 等元信息。值不对的话,社交平台分享卡片又会回到「图片不显示」的老问题,所以一并改正。

4. 两个构建环境的坑

真正动手构建时才暴露出两个更隐蔽的问题,值得记下来。

其一,主题目录是一个空的 Git 子模块。仓库里记录了子模块的指针,却没有对应的 .gitmodules,于是在一台新机器上克隆后,主题目录是空的。此时构建不会报错,只是安静地生成一堆 0 字节的 HTML——看起来「构建成功」,实际上整个站点是空白的。解决办法是把主题还原到仓库记录的那个提交,并补回两处未提交的主题定制(一套配色方案,以及页脚里追加的一段访问统计脚本),让本地构建结果与线上一致。

其二,有一个资源只存在于发布分支。发布出去的站点里有一张头像图片,但它的源文件从未提交到源码分支。如果直接重新构建部署,它就会被删掉,导致头像 404。这颗「定时炸弹」平时看不见,只有在下一次部署时才会爆炸。既然发现了,就把它补回源码目录,让它进入正常的构建流程。

验证方法

「看起来没问题」远远不够,这次把验证做扎实了:

  1. 图片数量核对:逐篇统计生成的 <img> 标签数量,与预期张数一一对齐。
  2. 无遗漏检查:确认生成结果里不再有任何指向旧图床的外链(全文搜索图床域名,必须为零)。
  3. 逐张探测:对每一张自托管图片,都带上博客 Referer 发一次请求,确认返回 200。这一步很关键——要验证的正是「在被防盗链拦截的上下文里也能加载」。
1
2
3
4
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" \
-H "Referer: https://<站点正式域名>/" \
"https://<站点正式域名>/images/<post-slug>/01.jpg"
# 输出:200 image/jpeg
  1. 产物比对:把新生成的静态产物与线上正在服务的版本做整体比对,忽略换行符差异、每次构建都会随机的装饰色、以及时间戳,确认除了本次预期改动之外没有其它意外变化。这一步是防止「改 A 处、坏 B 处」的安全网。

部署流程

本博客使用 Hexo,源码在一个分支、生成产物用另一个分支托管。标准流程是:先提交源码,再构建并部署(部署会把静态产物推送到托管分支):

1
2
3
4
5
6
7
# 1. 提交并推送源码
git add source/_posts source/images _config.yml
git commit -m "..."
git push origin <源码分支>

# 2. 构建并部署(生成产物 → 推送托管分支)
hexo clean && hexo generate && hexo deploy

部署完成后,静态托管通常需要几十秒才刷新。等一会儿再线上抽查几张图,确认返回 200,就真正收工了。

小结

  • 博客正文里的图片,尽量不要外链第三方免费图床。它们限流、防盗链、乃至关停时,你的站点会在大面积「塌方」后才被发现。
  • 图片跟着仓库一起走是最省心的方案:同源、无防盗链、随站点一起版本化。
  • 验证要覆盖「失败上下文」:这次的关键一步,就是带着博客 Referer 去请求每一张图,而不是只验证图片「本身能打开」。
  • 构建环境里的空子模块和只存在于发布分支的资源是两类典型的隐形陷阱,新机器上首次构建、以及重新部署之前,都值得多留一个心眼。