C09【博客备份】把Hexo的文章连图片一起搬到本地
把Hexo的文章连图片一起搬到本地
博客跑了两年,47 篇文章,200 张图,全部托管在 Cloudflare Pages 图床。
免费额度,说停就停。真停了,我手里剩下的就是 47 个 md 文件,里面躺着 200 条 。一张张下载再改路径,200 次,想想就放下了。
拖了半年,还是写了这个工具。
一、要什么
三条:
- 一篇文章一个文件夹,它引用的图片收进旁边的
assets/ - 正文里的远程地址换成本地相对路径
- 结果不依赖 Hexo,用 Typora 或 Obsidian 直接打开就能读
第三条是重点。只备份 md 的话 git clone 私有仓库就够了,用不着专门做个软件。工具的价值全在把资源一起搬下来。
二、技术选择
Python + tkinter,PyInstaller 打成单文件 exe,11 MB。
不用 Electron,一个备份工具占 150 MB 不划算。tkinter 的界面谈不上好看,但 Windows 自带,打包出来目标机器不用装任何东西。
核心逻辑单独放在 core.py,可以命令行跑;app.py 只管界面。调 bug 的时候不用反复点按钮。
三、输出长什么样
1 | 备份目录/ |
图片名保留图床原名。名字确实难看,但去重和增量备份靠它才可靠——改名之后,同一个文件在两篇文章里会被存成两个不同的名字。
四、几个坑
写这个工具一半时间在写功能,另一半在填坑。挑六个有代表性的。
4.1 图床文件名带中文
现象:一部分图下载失败,日志里是 UnicodeEncodeError: 'ascii' codec can't encode characters in position 24-26。
原因:图床里有一批文件叫 聊天室260630.png、搬屎腌入味_结果.jpg。urllib 构造 HTTP 请求行时按 ascii 编码,遇到中文直接抛异常。
修法:
1 | urllib.parse.quote(url, safe=":/?#[]@!$&'()*+,;=%~") |
% 必须放进 safe。少写它,已经编码过的 %E4%B8%AD 会被二次编码成 %25E4%25B8%25AD,请求 404。
4.2 换成连接复用之后,失败率反而涨了
现象:把 urlopen 换成 http.client.HTTPSConnection 想复用连接,失败率从 20% 涨到 60%,报的全是 SSL 握手超时。
原因:urlopen 会自动读 http_proxy / https_proxy 环境变量,http.client 不会。这台机器挂着代理,http.client 直连 Cloudflare,大面积超时。
修法:换回 urlopen。192 次握手减到 8 次省下的时间,远不如走代理重要。
这个坑的教训是:调网络问题之前先看环境变量。我当时对着代码改了三轮,env | grep -i proxy 一敲就明白了。
4.3 post_asset_folder 的项目一张图都认不出来
现象:拿构造的项目测,post_asset_folder: true 那种,识别到的图片引用是 0 处。
原因:Hexo 打开这个选项后,hexo new post foo 会同时建 _posts/foo.md 和 _posts/foo/ 目录。图片放进目录,正文只写 ,渲染时才替换成 /foo/图片.png。我原来只找 _posts/图片.png 和 source/图片.png,两边都落空。
修法:候选路径加上 md_path.parent / md_path.stem / clean,并且优先级最高。不设最高的话,_posts/x.png 和 _posts/x/x.png 同时存在时会认错。
这个坑我自己项目碰不到——我的 post_asset_folder 是关的。是后来考虑”别人能不能用”时测出来的,也是这几个坑里唯一会导致整篇丢图的。
4.4 GBK 编码的老文件
现象:正文乱码,文件夹名变成 f_F_���ı���。
原因:老项目里 GBK 的 md 不少见。按 utf-8 解码整篇变乱码。文件夹名乱码是另一个 bug——读标题的代码没走同一个解码函数。
修法:三档解码,utf-8-sig → gb18030 → 有损兜底。gb18030 几乎能解开任意字节流,所以必须放最后,放前面会把正常的 UTF-8 文件也吞进去。
4.5 DOI 链接被当成文件下载
现象:日志里出现 HTTP 403 https://www.tandfonline.com/doi/full/10.1080/15502783.2026.2707252#abstract。
原因:我一开始用扩展名白名单,后来嫌维护麻烦,改成”有扩展名且不是网页”就下载。但 10.1080/15502783.2026.2707252 用 splitext 拆出来的扩展名是 .2707252,看着挺像文件。
修法:扩展名限制在 1–6 位字母数字,纯数字的不超过 3 位。.001 这种分卷保留,7 位数字的论文编号排除。
4.6 Windows 换行让 front-matter 扫不到
现象:加 front-matter 图片字段支持,测试识别 0 处,但代码看着没问题。
原因:write_text 在 Windows 上把 \n 转成 \r\n。我用 ^---[ \t]*$ 找 front-matter 的闭合标记,re.M 的 $ 只认 \n 之前的位置,而 --- 后面跟着的是 \r,匹配不上,整块 front-matter 就被判定为不存在。
修法:^---[ \t]*\r?$。
这个坑花了半小时。单看代码看不出任何问题,得把实际字节打出来才发现。
五、下载策略
图床的实测情况:单张 1.5 到 30 秒不等,速度 5–10 KB/s,偶尔整块挂起,25 秒一个字节都不来。
第一版 8 线程、25 秒超时,192 张图跑了 7 分钟,还失败 40 张。
改完是这样:
- 4 线程,30 秒 socket 超时
- 首轮失败的用 2 线程、45 秒超时补一轮
- 下载过的缓存到
%LOCALAPPDATA%\HexoBackup\asset-cache,30 天内重跑直接复用
超时用的是 socket 读超时,只要数据还在流动就不会触发,所以大图慢一点也能下完。
第二轮补下载救回 15 个里的 14 个,剩 1 个下次跑的时候补齐了。
缓存是这里最值钱的东西。第一次 7 分半,第二次 0.5 秒。中途关掉重来也不白费。
六、front-matter 的处理
原始字段是这样的:
1 |
|
转完是这样:
1 |
|
三处改动:
categories折进tags。Hexo 的分类是带层级的数组,Obsidian 没有这个概念。- 日期从空格分隔改成 ISO 8601 的
T分隔。Obsidian 的日期属性按 ISO 8601 识别,空格分隔的会被当纯文本,Dataview 里按时间排序就失效。 note、cover(空值)、swiper_index这些主题和站点控制字段删掉。
有值的 cover 会保留,值已经改成本地路径。空值的字段直接删,留着只是一堆空壳。
代价是丢了 Hexo 专有的元数据,想导回 Hexo 得自己补回来。所以这个转换默认是关的,勾上才生效。
七、用法
- 双击
HexoBackup.exe - Hexo 项目选到含
_config.yml的那一层 - 备份到选一个目录,默认填成项目旁边的
博客备份_年月日_时分 - 需要 Obsidian 风格就勾上
front-matter 转成 Obsidian 风格 - 点开始备份
选好项目可以先点扫描预览,只统计不下载,确认认到的文章数和图片数对不对。
下载失败的图片不会从正文里删掉,原始 URL 原样保留,同时列进 _backup-report.md。重跑一次通常能补齐。
八、兼容性
测了九种布局:
| 情况 | 支持 |
|---|---|
图片放 source/images/,正文写 /images/x.png |
✅ |
正文写 ../images/x.png |
✅ |
| 图片和文章同目录散放 | ✅ |
post_asset_folder: true |
✅ |
{% asset_img %} / {% asset_link %} |
✅ |
中文文件名写成 %E4%B8%AD%E6%96%87.png |
✅ |
自定义 source_dir |
✅ |
草稿 _drafts/ |
✅ |
多语言子站 source/zh-cn/_posts/ |
✅ |
| GBK / GB18030 编码 | ✅ |
front-matter 的 cover / banner / top_img |
✅ |
用不了的:纯在线面板(本地没源码可选)、文章存数据库的 Hexo 变体、图片用 permalink 指向 public/ 的路径(源码里没有对应文件)。
判断标准很简单——本地能选到一个含 _config.yml 和 source/ 的文件夹,就能用。
九、留下的问题
工具现在只做一件事:把内容搬出来。
反方向没做。备份出来的目录想再导回 Hexo,得自己把 assets/ 里的图挪回 source/images/,再把相对路径改回远程地址或者站点绝对路径。理论上可以写个反向工具,但需求不明确——真要换博客系统,大概也不会换回 Hexo。
另一个没做的:source/ 下那些没被任何文章引用的静态资源(css/、img/background.png)不在备份范围内。它们属于站点而不是内容,混进”一篇一文件夹”的结构里会破坏整洁。真要完整迁移,git clone 更省事。
工具目前只在自己机器上跑,没发布出去。
