老图床关停后,旧博客的历史配图一夜全裂:我的批量抢救全过程

1 阅读6分钟

最近我被读者的几条留言拉回了一个快被我遗忘的旧博客:有人说"文章里的图全裂了"。我点进去一看,横跨好几年的历史文章,配图大面积失效——当年图省事,把图片统一托管在一个公共图床上,如今那个域名已经打不开。更棘手的是,这些图散落在几十上百篇文章里,原始本地文件又经过几次换机、基本找不回来。这篇把我整个周末的批量抢救过程记下来,核心是一套"抽链接 → 探活 → 抓取转存 → 精确替换 → 验证"的流程,跟具体用哪个工具关系不大。

一、先判断:还能不能救

手动一张张重传显然不现实,我得先想清楚图片还在不在。旧文章里的图片链接虽然裂了,但链接本身还写在页面里,于是有三种可能:

  • 旧图床只是前端页面关了,图片直链暂时还能访问:这部分可以顺着旧链接把图抓回来。
  • 直链返回 403:可能是防盗链或限流,换个 Referer、换个网络环境还有机会。
  • 域名彻底失效、返回 404 或无法解析:线上已经没有这张图,只能找归档或本地备份,找不到就只能补图。

所以抢救的第一步不是上传,而是把所有图片链接收集起来、逐条探测死活,先把"还能救"和"彻底没了"分开。

二、把家底摸清:抽链接、去重、探活

1. 抽出全部图片链接。 我手里还有旧博客的文章源(一批 Markdown),直接递归扫描;如果只剩线上页面,就从 sitemap 拿文章 URL、抓 HTML 再提取 img:

import pathlib, re

imgs = set()
for f in pathlib.Path("posts").rglob("*.md"):
    text = f.read_text(encoding="utf-8")
    for u in re.findall(r'!\[.*?\]\((.*?)\)', text):
        imgs.add(u)

# 只有线上 HTML 时,抓回来用这个:
# html_imgs = re.findall(r'<img[^>]+src=["\'](.*?)["\']', html)

抽链接、按映射替换的正则写法,我在之前一篇笔记导出迁移里详细记过,这里不展开:juejin.cn/post/768974… 。

2. 去重、归一化。 同一张图可能在不同文章里、或带不同的尺寸参数,先按完整 URL 去重,再人工扫一眼有没有明显是同图不同写法的,避免重复转存。

3. 并发探活,给每条链接定性。

import concurrent.futures, requests

def probe(u):
    try:
        r = requests.get(u, timeout=10, stream=True)
        return u, r.status_code
    except requests.RequestException:
        return u, "ERR"

result = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as ex:
    for u, code in ex.map(probe, imgs):
        result[u] = code

探测结果我按下面这张表分类处理:

旧链接状态含义处理方式
200直链仍可访问进入批量抓取转存
403防盗链 / 限流带 Referer 或换网络重试,仍不行则按丢失处理
404 / 无法解析图已彻底删除查网页归档、本地备份,找不到就补图
超时网络抖动或对本机限流隔段时间重试几次再定性

如果 403 怀疑是 Referer 防盗链在作怪,可以对照我之前那篇防盗链排查的文章看:juejin.cn/post/768676… 。那些彻底抓不回的图,我单独列了一张清单,后面统一处理,绝不混进自动流程里假装救回来了。

三、关键一步:让服务端逐条抓取转存

真正要把"还能访问的旧链接"变成稳定新链接时,我没有在本机一张张下载再上传——几百张图,要处理失败重试、还要占本地带宽,而且旧图床对我这个 IP 还时不时限流。我的做法是把探活通过的旧 URL 整理成列表,交给图床小镇的 URL 批量上传,由服务端逐条去抓取、转存,最后拿回一组"旧链接 → 新链接"的对照结果。

这么做有两个实际好处:一是下载、重试、存储都在服务端完成,本机网络环境的限流影响不到它;二是返回结果天然就是一份映射表,后面替换直接用。批量之前我先挑了一小批试跑,确认抓取成功率和新链接格式没问题,才把整批交出去;返回里个别失败的条目,我单独重试或转人工,没有蒙混过去。

存放路径上我特意选了多级目录加随机路径。旧博客里重名图特别多(大量都叫 image.png、截图、photo 之类),让每张图各占独立路径,就不会出现后传的把前传的顶掉这种事故。至于"同名覆盖、链接不变"这个能力,它更适合以后某张图要反复改版的场景,并不适合现在这种重名扎堆的抢救。

四、按映射精确替换,并写回文章源

拿到映射表后,在文章源里批量替换。这里最容易出问题的是子串误伤和漏换,所以我一律用"完整旧链接 + 它在原文里的边界符号"整条替换:

import pathlib

for f in pathlib.Path("posts").rglob("*.md"):
    md = f.read_text(encoding="utf-8")
    for old, new in mapping.items():
        md = md.replace(f"({old})", f"({new})")   # HTML 源则替换 src="old"
    f.write_text(md, encoding="utf-8")

替换前我先把整个 posts 目录备份了一份;替换时顺手统计每条旧链接实际被替换的次数,次数为 0 的说明这条链接没在文章里出现(可能是探测时的冗余项),替换完还额外全局搜了一遍旧域名,确认没有残留。

五、灰度验证,留好回滚

我没有一次性全部上线,而是先挑一两篇替换、发布,用无痕窗口加手机热点(换个网络环境)打开,确认图片全部走新链接、状态 200;没问题再批量铺开。批量后又全站检查了一遍旧域名残留和页面渲染。原始备份和那张映射表我都留着——万一某个新链接出问题,可以按映射精确回滚,而不是再重来一遍。

那些确认彻底丢失、救不回来的图,我统一换成带说明文字的占位图并补好 alt,或者重新截图补回去,至少不让页面留着一堆刺眼的裂图图标。

六、抢救自检清单

  • 已从文章源或 sitemap 抽全图片链接,并完成去重
  • 已并发探活,给每条链接定性、可救与不可救分开
  • 不可救的已查归档 / 备份,或列入补图清单
  • 可救的已先小批试跑、再整批服务端抓取转存,拿到映射表
  • 新链接用多级目录 / 随机路径,无同名覆盖
  • 替换前已备份,按完整链接精确替换、替换次数已核对
  • 无痕 + 换网络回读,全站无旧域名残留、图片全部正常
  • 备份与映射表已留存,可按表回滚

写在最后

这次最大的教训是:图片是博客的资产,把它放在自己不可控、随时可能关停的免费托管上,等于给未来埋雷。 真正花时间的不是上传,而是先把链接收集、探活,再批量转存和精确替换——这套流程跟选哪个工具无关,能服务端抓取转存、能给稳定外链的方案都可以做。经历这次之后,我新写的配图都放在自己能长期掌控的地方,也养成了定期离线备份的习惯。官网:imgbed.cn 。