Hugo

Hugo 图床迁移:从云端到本地

警告
本文最后更新于 2026-01-16,文中内容可能已过时。

Hugo 博客使用云端图床可减轻仓库体积并提升访问速度,但在离线写作、图床迁移或服务不可用时,可将图片回迁至本地统一管理。

图床回迁本地
图床回迁本地

下面介绍一种自动化方案,通过脚本批量下载图片并重写 Markdown 引用路径,实现从云端图床到 Hugo 本地目录的回迁,减少人工干预。

准备工作

在开始之前,准备以下工具:

  • PicList:用于图床图片的上传与管理,需提前配置好目标图床。
  • Typora:Markdown 编辑器,用于编辑与预览文章内容。
  • Python:用于运行图片迁移脚本,安装时勾选 Add Python to PATH
  • Git(可选):用于博客内容的版本管理,便于迁移前后进行变更对比与回滚。
  • VS Code(可选):用于批量编辑 Markdown 文件或调整脚本内容。

图片迁移会直接修改 Markdown 文件,属于不可逆操作,需做好以下准备:

  • 备份博客目录:迁移前务必对整个博客目录进行完整 备份,以防止意外数据丢失。
  • PicList 配置检查:确认已正确配置图床,并暂时关闭 高级重命名,避免文件名与脚本生成规则冲突。
  • Typora 设置确认:插入图片时选择 上传图片,取消勾选 使用相对路径
  • 图片文件准备:从图床下载所有图片,并确保下载的文件名与 Markdown 文件中的图片引用一致。

创建脚本

创建以下文件(记得修改配置参数):

Pythonhugo_cloud_to_local.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
import os
import re
import subprocess
import time
import urllib.parse
from pathlib import Path

# 配置参数(请根据实际情况修改)
BLOG_ROOT = r"D:\Hugo\blog"
POSTS_DIR = os.path.join(BLOG_ROOT, "content", "posts")
UPLOADS_DIR = os.path.join(BLOG_ROOT, "assets", "uploads")  # 手动下载位置
IMAGE_DIR = os.path.join(BLOG_ROOT, "assets", "images")     # 最终存储位置
PICLIST_PATH = r"D:\Program Files\PicList\PicList.exe"
IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg')

def is_in_code_block(line, in_code_block):
    """检测是否在代码块中(基于代码块标记)"""
    if re.match(r'^\s*```', line):
        return not in_code_block
    return in_code_block

def contains_code_segment(line):
    """检测行内代码段(反引号包围的内容)"""
    # 找出所有行内代码段的位置
    code_segments = []
    pos = 0
    while pos < len(line):
        start = line.find('`', pos)
        if start == -1:
            break
        end = line.find('`', start + 1)
        if end == -1:  # 未闭合的反引号
            break
        code_segments.append((start, end))
        pos = end + 1
    return code_segments

def protect_code_content(line, in_code_block):
    """
    保护代码内容不被修改
    返回:处理后的行,以及新的代码块状态
    """
    # 如果在代码块中,完全保护整行
    if in_code_block:
        return line, in_code_block, True

    # 检测行内代码段
    code_segments = contains_code_segment(line)
    if not code_segments:
        return line, in_code_block, False

    # 保护行内代码段
    protected_line = ""
    last_pos = 0
    for start, end in code_segments:
        # 添加非代码内容
        protected_line += line[last_pos:start]
        # 添加受保护的代码段(原样保留)
        protected_line += line[start:end+1]
        last_pos = end + 1

    # 添加剩余内容
    protected_line += line[last_pos:]

    return protected_line, in_code_block, True

def upload_images():
    """使用PicList命令行批量上传图片"""
    print(f"开始上传图片到 {IMAGE_DIR}")

    # 确保目标目录存在
    os.makedirs(IMAGE_DIR, exist_ok=True)

    # 遍历上传目录中的所有图片
    uploaded_count = 0
    for root, _, files in os.walk(UPLOADS_DIR):
        for file in files:
            if file.lower().endswith(IMAGE_EXTENSIONS):
                file_path = os.path.join(root, file)

                # 构建PicList上传命令
                cmd = [PICLIST_PATH, "upload", file_path]

                try:
                    # 执行上传
                    result = subprocess.run(
                        cmd,
                        stdout=subprocess.PIPE,
                        stderr=subprocess.PIPE,
                        text=True,
                        timeout=30,
                        check=True
                    )

                    # 输出上传结果
                    print(f"✅ 上传成功: {file}")
                    uploaded_count += 1

                except subprocess.CalledProcessError as e:
                    print(f"❌ 上传失败: {file}")
                    print(f"错误: {e.stderr}")
                except subprocess.TimeoutExpired:
                    print(f"❌ 上传超时: {file}")
                except Exception as e:
                    print(f"❌ 未知错误: {file} - {str(e)}")

    print(f"\n✅ 上传完成! 成功上传 {uploaded_count} 张图片")
    return uploaded_count

def replace_image_links_step1():
    """第一步:替换图床URL为Hugo兼容的绝对路径(使用正斜杠)"""
    print("\n第一步:替换图床URL为绝对路径...")
    updated_files = 0

    # 使用正斜杠路径 (Hugo兼容)
    abs_path_prefix = str(Path(IMAGE_DIR).as_posix()) + '/'

    for root, _, files in os.walk(POSTS_DIR):
        for file in files:
            if file.endswith('.md'):
                file_path = os.path.join(root, file)
                with open(file_path, 'r+', encoding='utf-8') as f:
                    content = f.read()
                    original_content = content

                    # 逐行处理,保护代码块和行内代码中的内容
                    lines = content.split('\n')
                    new_lines = []
                    in_code_block = False

                    for line in lines:
                        # 检测代码块状态
                        in_code_block = is_in_code_block(line, in_code_block)

                        # 保护代码内容
                        protected_line, in_code_block, is_protected = protect_code_content(line, in_code_block)

                        if not is_protected:
                            # 匹配常见的图床URL模式
                            pattern = r'!\[(.*?)\]\((https?://[^\s\)]+)\)'

                            # 使用函数处理每个匹配项
                            def replace_match(match):
                                alt_text = match.group(1)
                                url = match.group(2)

                                # 从URL中提取文件名
                                filename = os.path.basename(urllib.parse.urlparse(url).path)

                                # 构建Hugo兼容的绝对路径
                                return f'![{alt_text}]({abs_path_prefix}{filename} "{alt_text}")'

                            # 替换非保护内容中的图片链接
                            protected_line = re.sub(pattern, replace_match, protected_line)

                        new_lines.append(protected_line)

                    new_content = '\n'.join(new_lines)

                    # 保存更新
                    if new_content != original_content:
                        f.seek(0)
                        f.write(new_content)
                        f.truncate()
                        print(f"更新: {file}")
                        updated_files += 1

    print(f"✅ 第一步完成! 更新了 {updated_files} 个文件中的链接")
    return updated_files

def replace_image_links_step2():
    """第二步:替换绝对路径为相对路径(使用正斜杠)"""
    print("\n第二步:替换绝对路径为相对路径...")
    updated_files = 0

    # 使用与第一步相同的路径格式 (正斜杠)
    abs_path_prefix = str(Path(IMAGE_DIR).as_posix()) + '/'
    rel_path = "../../assets/images/"  # Hugo兼容的相对路径

    for root, _, files in os.walk(POSTS_DIR):
        for file in files:
            if file.endswith('.md'):
                file_path = os.path.join(root, file)
                with open(file_path, 'r+', encoding='utf-8') as f:
                    content = f.read()
                    original_content = content

                    lines = content.split('\n')
                    new_lines = []
                    in_code_block = False

                    for line in lines:
                        in_code_block = is_in_code_block(line, in_code_block)
                        protected_line, in_code_block, is_protected = protect_code_content(line, in_code_block)

                        if not is_protected:
                            # 调试输出
                            if abs_path_prefix in protected_line:
                                print(f"在 {file} 中找到需要替换的路径: {protected_line.strip()[:50]}...")

                            # 替换绝对路径为相对路径
                            protected_line = protected_line.replace(abs_path_prefix, rel_path)

                        new_lines.append(protected_line)

                    new_content = '\n'.join(new_lines)

                    if new_content != original_content:
                        f.seek(0)
                        f.write(new_content)
                        f.truncate()
                        print(f"更新: {file}")
                        updated_files += 1

    print(f"✅ 第二步完成! 更新了 {updated_files} 个文件中的链接")
    return updated_files

def main():
    print("=" * 60)
    print("Hugo 图片迁移回本地工具 - 智能代码保护版")
    print("=" * 60)

    # 步骤1: 上传图片
    uploaded_count = upload_images()

    if uploaded_count == 0:
        print("❌ 没有图片上传,请检查图片目录和配置")
        return

    # 步骤2: 第一步路径替换
    step1_updated = replace_image_links_step1()

    if step1_updated == 0:
        print("❌ 没有文件被更新,请检查图床URL模式")
        return

    # 关键提示1: 要求用户修改Typora设置并重启
    print("\n" + "=" * 60)
    print("⚠️ 重要操作: 请立即完成以下步骤")
    print("1. 打开Typora → 偏好设置 → 图像")
    print("   - 取消勾选'上传图片'")
    print("   - 勾选'优先使用相对路径'")
    print("2. 关闭并重启Typora")
    print("3. 打开任意文章验证图片显示(应使用绝对路径显示正常)")
    print("=" * 60)

    input("完成以上操作并验证后,按Enter键继续第二步替换...")

    # 步骤3: 第二步路径替换
    step2_updated = replace_image_links_step2()

    # 最终提示
    print("\n" + "=" * 60)
    print("✅ 所有操作已完成! 请手动完成以下步骤:")
    print("1. 关闭Typora")
    print("2. 重新打开Typora")
    print("3. 验证图片显示(现在应使用相对路径显示正常)")
    print("=" * 60)

    # 添加3秒延迟,确保用户看到提示
    time.sleep(3)

if __name__ == "__main__":
    main()

脚本运行逻辑说明

  1. 上传图片:扫描 assets/uploads/ 目录,调用 PicList 批量上传图片到图床。
  2. 替换链接(第一步):将文章中图床 URL 替换为本地绝对路径(assets/images/)。
  3. 提示用户:修改 Typora 设置(取消上传、启用相对路径),重启 Typora。
  4. 替换链接(第二步):将本地绝对路径替换为 Hugo 相对路径(../../assets/images/)。
  5. 完成:提示验证图片显示。

运行脚本

运行以下命令,执行脚本:

Bash
1
python hugo_cloud_to_local.py

脚本将按流程完成图片上传和 Markdown 路径替换,并在中途提示修改 Typora 配置。

路径修正

回迁完成后,Typora 中的图片路径(如 ../../assets/images/...)能正常预览,但 Hugo 渲染时 无法正确解析,因为 assets 目录不参与 Markdown 路径解析,需通过 Hugo Render Hook 修正路径。

创建以下文件:

HTMLlayouts/_markup/render-image.html
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
{{- /* 覆盖来源:FixIt/layouts/_markup/render-image.html */ -}}

{{- /*
  Markdown image render hook.

  Components of a Markdown image:
    ![white kitten](/images/kitten.jpg "A kitten!")
    ------------  ------------------  ---------
    description      destination        title
    ------------  ------------------  ---------
    .Text            .Destination       .Title
*/ -}}
{{- $params := .Page.Params | merge site.Params.page -}}

{{- /* ======================== 修改点 1:开始 ======================== */ -}}
{{- /* 路径转换:解决 Typora 等编辑器生成的相对路径问题 */ -}}
{{- $fixedDestination := .Destination -}}

{{- /* 检测并转换 Typora 特有的 assets 相对路径格式 */ -}}
{{- if hasPrefix .Destination "../../../assets" -}}
  {{- $fixedDestination = replace .Destination "../../../assets" "" -}}
{{- else if hasPrefix .Destination "../assets" -}}
  {{- $fixedDestination = replace .Destination "../../assets" "" -}}
{{- end -}}

{{- /* 图片优化配置:响应式尺寸及 WebP 格式,质量 75% */ -}}
{{- $optim := slice
  (dict "Process" "resize 800x webp q75" "descriptor" "800w")
  (dict "Process" "resize 1200x webp q75" "descriptor" "1200w")
  (dict "Process" "resize 1600x webp q75" "descriptor" "1600w")
-}}

{{- /* 情况 1:图片同时包含标题(title)和说明(text),生成带 figcaption 的 figure 元素 */ -}}
{{- if .Title | and .Text -}}
  {{- $linked := ne $params.lightgallery false -}}
  <figure>
    {{- /* 使用转换后的路径调用图片组件 */ -}}
    {{- dict "Src" $fixedDestination "Alt" .PlainText "Title" .Title "Caption" .Text "Linked" $linked "Loading" "lazy" "Resources" .Page.Resources "OptimConfig" $optim | partial "plugin/image.html" -}}
    <figcaption class="image-caption">
      {{- .Title | safeHTML -}}
    </figcaption>
  </figure>

{{- /* 情况 2:普通图片(无标题或无说明),只生成 img 标签(可能支持 lightgallery) */ -}}
{{- else -}}
  {{- $linked := (eq $params.lightgallery "force") | or ($params.lightgallery | and (ne .Title "")) -}}
  {{- /* 使用转换后的路径调用图片组件 */ -}}
  {{- dict "Src" $fixedDestination "Alt" .PlainText "Title" .Title "Linked" $linked "Loading" "lazy" "Resources" .Page.Resources "OptimConfig" $optim | partial "plugin/image.html" -}}
{{- end -}}
{{- /* ======================== 修改点 1:结束 ======================== */ -}}

验证迁移

迁移完成后,请依次完成以下检查,确保图片迁移与路径修正均已生效:

  • 打开任意 Markdown 文件,确认图片路径已从图床地址更新为本地路径。
  • 运行 hugo server,在本地预览中检查图片是否能够正常渲染。
  • 在 VS Code 中使用全局搜索(Ctrl+Shift+F),确认项目中已不存在旧的图床链接。
留言交流