把Hexo的文章连图片一起搬到本地

博客跑了两年,47 篇文章,200 张图,全部托管在 Cloudflare Pages 图床。

免费额度,说停就停。真停了,我手里剩下的就是 47 个 md 文件,里面躺着 200 条 ![](https://cloudflare-imgbed-etc.pages.dev/file/xxx.png)。一张张下载再改路径,200 次,想想就放下了。

拖了半年,还是写了这个工具。

一、要什么

三条:

  1. 一篇文章一个文件夹,它引用的图片收进旁边的 assets/
  2. 正文里的远程地址换成本地相对路径
  3. 结果不依赖 Hexo,用 Typora 或 Obsidian 直接打开就能读

第三条是重点。只备份 md 的话 git clone 私有仓库就够了,用不着专门做个软件。工具的价值全在把资源一起搬下来。

二、技术选择

Python + tkinter,PyInstaller 打成单文件 exe,11 MB。

不用 Electron,一个备份工具占 150 MB 不划算。tkinter 的界面谈不上好看,但 Windows 自带,打包出来目标机器不用装任何东西。

核心逻辑单独放在 core.py,可以命令行跑;app.py 只管界面。调 bug 的时候不用反复点按钮。

三、输出长什么样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
备份目录/
├── _posts/
│ └── A01【科普】网络运维工具的介绍与使用技巧/
│ ├── A01【科普】网络运维工具的介绍与使用技巧.md
│ └── assets/
│ ├── 1792086663731_43b11ab78fad3a2f9e812.png
│ └── ...
├── _pages/
│ └── about/
│ ├── about.md
│ └── assets/
├── _meta/
│ ├── _config.yml
│ └── _config.butterfly.yml
├── _backup-report.md
└── _backup-report.json

图片名保留图床原名。名字确实难看,但去重和增量备份靠它才可靠——改名之后,同一个文件在两篇文章里会被存成两个不同的名字。

四、几个坑

写这个工具一半时间在写功能,另一半在填坑。挑六个有代表性的。

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/ 目录。图片放进目录,正文只写 ![](图片.png),渲染时才替换成 /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
2
3
4
5
6
7
8
9
10
11
12
13
---
title: B02【TCP/IP概述】常见协议:HTTP、DNS、TCP、UDP等
categories:
- 计算机网络
tags:
- 网络
date: 2024-08-22 10:55:33
updated: 2024-08-22 10:55:33
description: 暂不详细介绍,就当作一个索引目录吧。
note: 暂不填坑
cover:
swiper_index:
---

转完是这样:

1
2
3
4
5
6
7
8
9
---
title: B02【TCP/IP概述】常见协议:HTTP、DNS、TCP、UDP等
date: 2024-08-22T10:55:33
updated: 2024-08-22T10:55:33
description: 暂不详细介绍,就当作一个索引目录吧。
tags:
- 网络
- 计算机网络
---

三处改动:

  1. categories 折进 tags。Hexo 的分类是带层级的数组,Obsidian 没有这个概念。
  2. 日期从空格分隔改成 ISO 8601 的 T 分隔。Obsidian 的日期属性按 ISO 8601 识别,空格分隔的会被当纯文本,Dataview 里按时间排序就失效。
  3. note、cover(空值)、swiper_index 这些主题和站点控制字段删掉。

有值的 cover 会保留,值已经改成本地路径。空值的字段直接删,留着只是一堆空壳。

代价是丢了 Hexo 专有的元数据,想导回 Hexo 得自己补回来。所以这个转换默认是关的,勾上才生效。

七、用法

  1. 双击 HexoBackup.exe
  2. Hexo 项目选到含 _config.yml 的那一层
  3. 备份到选一个目录,默认填成项目旁边的 博客备份_年月日_时分
  4. 需要 Obsidian 风格就勾上 front-matter 转成 Obsidian 风格
  5. 点开始备份

选好项目可以先点扫描预览,只统计不下载,确认认到的文章数和图片数对不对。

下载失败的图片不会从正文里删掉,原始 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 更省事。

工具目前只在自己机器上跑,没发布出去。