Hugo

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

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

Hugo 博客中的图片通常以本地文件形式随文章管理,结构清晰且编辑直观,但随着文章数量增加,本地图片会拉大仓库体积,在 CDN 加速、多端访问等场景下也不够灵活,此时可将图片迁移至云端图床。

图床迁移至云端
图床迁移至云端

下面介绍一种自动化迁移方案,针对 Hugo 的两种本地图片组织方式,即 Page Bundle(图片与 index.md 位于同一目录)与 assets 固定目录,分别提供批量上传脚本,并重写 Markdown 引用路径。

准备工作

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

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

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

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

创建脚本

Page Bundle

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

Pythonhugo_pagebundle_to_cloud.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
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
import os
import re
import shutil
import random
import string
import datetime
import subprocess
import time
import urllib.parse
import ssl
from pathlib import Path
from urllib.parse import urljoin  # 新增的导入

# 配置参数 - 请根据您的实际情况修改
BLOG_ROOT = r"D:\Hugo\blog"
POSTS_DIR = os.path.join(BLOG_ROOT, "content", "posts")
CDN_BASE = "https://raw.githubusercontent.com/<username>/<reponame>/main"  # 您的图床基础 URL
PICLIST_PATH = r"D:\Program Files\PicList\PicList.exe"
YEAR = datetime.datetime.now().strftime("%Y")
UPLOAD_DIR = os.path.join(BLOG_ROOT, "assets", "uploads", YEAR)  # 临时上传目录
IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg')  # 支持的图片格式

# 创建自定义 SSL 上下文以忽略证书错误
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE

def generate_safe_filename(original_name):
    """生成安全的唯一文件名(小写字母+数字)"""
    # 获取文件扩展名
    _, ext = os.path.splitext(original_name)
    ext = ext.lower()

    # 生成日期部分
    date_str = datetime.datetime.now().strftime("%Y%m%d")

    # 生成随机部分(确保只使用小写字母和数字)
    random_str = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))

    # 组合成新文件名
    return f"{date_str}_{random_str}{ext}"

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 process_posts():
    """处理所有文章:更新图片引用为图床URL,并收集图片"""
    file_mapping = {}
    folders_to_remove = []
    processed_folders = []

    print(f"扫描文章目录: {POSTS_DIR}")

    for folder_name in os.listdir(POSTS_DIR):
        folder_path = os.path.join(POSTS_DIR, folder_name)
        if not os.path.isdir(folder_path):
            continue

        index_md = os.path.join(folder_path, "index.md")
        if not os.path.exists(index_md):
            print(f"⚠️ 跳过无index.md的目录: {folder_name}")
            continue

        print(f"\n处理文章: {folder_name}")
        processed_folders.append(folder_name)

        # 读取文章内容
        with open(index_md, "r", encoding="utf-8", errors="replace") as f:
            content = f.read()

        # 备份原始内容
        original_content = content

        # 收集需要替换的图片
        replace_map = {}
        for file in os.listdir(folder_path):
            if file == "index.md":
                continue

            file_path = os.path.join(folder_path, file)
            if os.path.isfile(file_path) and file.lower().endswith(IMAGE_EXTENSIONS):
                print(f"  发现图片: {file}")

                # 生成安全的新文件名
                new_filename = generate_safe_filename(file)

                # 确保新文件名格式正确
                if not re.match(r"^\d{8}_[a-z0-9]{6}\.[a-z]{3,4}$", new_filename):
                    print(f"  ⚠️ 生成的文件名格式无效: {new_filename},使用备用方案")
                    new_filename = f"{datetime.datetime.now().strftime('%Y%m%d')}_{random_string(6)}{os.path.splitext(file)[1].lower()}"

                # 构建图床URL - 使用urljoin确保正确拼接
                cdn_url = urljoin(f"{CDN_BASE}/", f"{YEAR}/{urllib.parse.quote(new_filename)}")

                # 记录映射关系
                replace_map[file] = (file_path, new_filename, cdn_url)
                file_mapping[file_path] = new_filename

        # 逐行处理内容,保护代码块和行内代码
        if replace_map:
            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 and replace_map:
                    # 对每个图片进行替换
                    for old_name, (_, _, cdn_url) in replace_map.items():
                        # 精确替换图片引用 - 处理各种空格情况
                        pattern = r'!\[([^\]]*?)\]\(\s*' + re.escape(old_name) + r'\s*\)'
                        replacement = f'![\\1]({cdn_url} "\\1")'
                        protected_line = re.sub(pattern, replacement, protected_line)

                new_lines.append(protected_line)

            content = '\n'.join(new_lines)

        # 保存更新
        if content != original_content:
            with open(index_md, "w", encoding="utf-8") as f:
                f.write(content)
            print(f"  已更新图片链接")
        else:
            print("  未修改内容")

        # 移动文章
        new_md_name = f"{folder_name}.md"
        new_md_path = os.path.join(POSTS_DIR, new_md_name)
        shutil.move(index_md, new_md_path)
        print(f"  已移动文章: {folder_name} -> {new_md_name}")

        # 记录文件夹,稍后删除
        folders_to_remove.append(folder_path)

    return file_mapping, folders_to_remove, processed_folders

def random_string(length=6):
    """生成随机字符串(小写字母+数字)"""
    return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length))

def upload_images(file_mapping):
    """上传并验证所有图片"""
    # 确保上传目录存在
    os.makedirs(UPLOAD_DIR, exist_ok=True)
    print(f"\n创建上传目录: {UPLOAD_DIR}")

    # 移动图片到上传目录
    uploaded_files = []
    for src_path, new_filename in file_mapping.items():
        dest_path = os.path.join(UPLOAD_DIR, new_filename)

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

        # 移动文件
        shutil.move(src_path, dest_path)
        uploaded_files.append((dest_path, new_filename))
        print(f"移动图片: {os.path.basename(src_path)} -> {new_filename}")

    # 使用PicList命令行逐个上传图片
    print("\n开始使用PicList上传图片...")
    success_count = 0
    failed_files = []

    for i, (file_path, new_filename) in enumerate(uploaded_files):
        print(f"\n上传图片 {i+1}/{len(uploaded_files)}: {new_filename}")

        # 构建PicList命令
        cmd = [PICLIST_PATH, "upload", file_path]
        print(f"执行命令: {' '.join(cmd)}")

        try:
            # 执行上传
            result = subprocess.run(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                encoding='utf-8',
                timeout=120  # 2分钟超时
            )

            if result.returncode == 0:
                print("上传成功!")
                print("PicList输出:")
                print(result.stdout.strip())
                success_count += 1

                # 上传成功后删除本地图片
                try:
                    os.remove(file_path)
                    print(f"删除本地图片: {new_filename}")
                except Exception as e:
                    print(f"删除本地图片失败: {str(e)}")
            else:
                print(f"上传失败! 错误代码: {result.returncode}")
                print("错误输出:")
                print(result.stderr.strip())
                failed_files.append((file_path, new_filename))
        except subprocess.TimeoutExpired:
            print("上传超时! 跳过此文件")
            failed_files.append((file_path, new_filename))
        except Exception as e:
            print(f"上传错误: {str(e)}")
            failed_files.append((file_path, new_filename))

    # 处理失败的文件
    if failed_files:
        print("\n⚠️ 以下文件上传失败:")
        for file_path, filename in failed_files:
            print(f"  - {filename}")
        print("请手动处理这些文件")

    print(f"\n上传完成: {success_count}/{len(uploaded_files)} 成功")

    # 清理上传目录
    try:
        if os.path.exists(UPLOAD_DIR):
            # 检查目录是否为空
            if not os.listdir(UPLOAD_DIR):
                os.rmdir(UPLOAD_DIR)
                print(f"删除空目录: {UPLOAD_DIR}")
            else:
                print(f"目录中仍有文件,保留: {UPLOAD_DIR}")
    except Exception as e:
        print(f"目录清理错误: {str(e)}")

    return success_count

def delete_empty_folders(folders_to_remove):
    """最后删除空文件夹"""
    print("\n开始清理空文件夹...")
    deleted_count = 0
    retained_count = 0

    for folder_path in folders_to_remove:
        try:
            # 检查文件夹是否存在
            if not os.path.exists(folder_path):
                print(f"文件夹不存在: {os.path.basename(folder_path)}")
                retained_count += 1
                continue

            # 检查是否为空
            if os.path.isdir(folder_path):
                contents = os.listdir(folder_path)
                if not contents:
                    os.rmdir(folder_path)
                    print(f"已删除空文件夹: {os.path.basename(folder_path)}")
                    deleted_count += 1
                else:
                    print(f"文件夹非空,保留 ({len(contents)} 个文件): {os.path.basename(folder_path)}")
                    retained_count += 1
            else:
                print(f"不是文件夹,保留: {os.path.basename(folder_path)}")
                retained_count += 1
        except Exception as e:
            print(f"删除错误: {os.path.basename(folder_path)} - {e}")
            retained_count += 1

    return deleted_count, retained_count

def main():
    print("="*50)
    print("Hugo博客图片迁移到云端工具 - 智能代码保护版")
    print(f"博客根目录: {BLOG_ROOT}")
    print(f"文章目录: {POSTS_DIR}")
    print(f"图床URL: {CDN_BASE}/{YEAR}/")
    print(f"PicList路径: {PICLIST_PATH}")
    print("="*50)

    start_time = time.time()

    # 处理文章并记录需要删除的文件夹
    file_mapping, folders_to_remove, processed_folders = process_posts()

    if not file_mapping:
        print("\n没有发现需要处理的图片,退出程序")
        return

    # 上传图片
    success_count = upload_images(file_mapping)

    # 最后删除空文件夹
    deleted_count, retained_count = delete_empty_folders(folders_to_remove)

    # 显示摘要
    duration = time.time() - start_time
    print("\n" + "="*50)
    print("迁移摘要:")
    print(f"处理文章: {len(processed_folders)}")
    print(f"处理图片: {len(file_mapping)}")
    print(f"成功上传: {success_count}/{len(file_mapping)}")
    print(f"删除空文件夹: {deleted_count}")
    print(f"保留文件夹: {retained_count}")
    print(f"耗时: {duration:.2f} 秒")
    print("="*50)

    if success_count == len(file_mapping):
        print(f"✅ 所有图片成功上传到图床!")
        print(f"图床链接示例: {CDN_BASE}/{YEAR}/YYYYMMDD_xxxxxx.png")
    else:
        print(f"⚠️ 部分图片上传失败 ({len(file_mapping)-success_count}个),请检查日志")

    print("\n操作完成! 请使用Typora打开Markdown文件验证图片显示")

if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        print(f"\n❌ 发生未预期错误: {str(e)}")
        import traceback
        traceback.print_exc()

脚本运行逻辑说明

  1. 扫描文章:遍历 content/posts/ 下的目录,找到每个目录中的 index.md 及图片文件。
  2. 生成新文件名:日期(YYYYMMDD)+ 随机小写字母数字 + 扩展名。
  3. 更新文章:将图片引用替换为图床 URL(CDN_BASE/年份/新文件名)。
  4. 移动文章:将 index.md 移到上级目录并重命名为 目录名.md,记录待删除的文件夹。
  5. 上传图片:复制图片到临时目录,调用 PicList 上传(保留新文件名),成功后删除本地文件。
  6. 清理空文件夹:删除已处理的原 Page Bundle 空目录。
  7. 输出摘要:显示处理文章数、上传成功数、删除文件夹数等。

Assets 目录

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

Pythonhugo_local_to_cloud.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
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
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")
IMAGES_DIR = os.path.join(BLOG_ROOT, "assets", "images")  # 图片存储目录
CDN_BASE = "https://example.com/"  # 您的图床基础URL
PICLIST_PATH = r"D:\Program Files\PicList\PicList.exe"
IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg')  # 支持的图片格式
USE_YEAR_FOLDER = True  # 是否按年分文件夹存储图片
# ===========================================

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 convert_relative_paths_to_absolute():
    """将文章中的图片相对路径转换为绝对路径"""
    print("\n将相对路径转换为绝对路径...")
    updated_files = 0

    # 遍历所有Markdown文件
    for md_file in os.listdir(POSTS_DIR):
        if not md_file.endswith('.md'):
            continue

        file_path = os.path.join(POSTS_DIR, md_file)
        print(f"处理文章: {md_file}")

        # 读取文章内容
        with open(file_path, "r", encoding="utf-8", errors="replace") as f:
            content = f.read()

        original_content = content

        # 逐行处理内容
        lines = content.split('\n')
        new_lines = []
        in_code_block = False
        changes_made = 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:
                # 匹配所有非HTTP开头的图片路径
                pattern = r'!\[([^\]]*?)\]\(\s*([^http][^\s\)]+)\s*\)'
                matches = re.findall(pattern, protected_line)

                for alt_text, img_path in matches:
                    # 跳过已经是绝对路径的图片
                    if img_path.startswith('http') or os.path.isabs(img_path):
                        continue

                    # 构建相对于文章目录的绝对路径
                    article_dir = os.path.dirname(file_path)
                    abs_img_path = os.path.normpath(os.path.join(article_dir, img_path))

                    # 替换为绝对路径(使用原始字符串避免转义问题)
                    # 使用re.escape处理特殊字符,但避免Windows路径中的反斜杠问题
                    escaped_img_path = re.escape(img_path)

                    # 使用原始字符串替换
                    new_line = re.sub(
                        r'!\[\s*' + re.escape(alt_text) + r'\s*\]\(\s*' + escaped_img_path + r'\s*\)',
                        r'![' + alt_text + r'](' + abs_img_path.replace('\\', r'\\' "' + alt_text + r'") + r')',
                        protected_line
                    )

                    if new_line != protected_line:
                        protected_line = new_line
                        print(f"  转换路径: {img_path}{abs_img_path}")
                        changes_made = True

            new_lines.append(protected_line)

        # 保存更新
        if changes_made:
            new_content = '\n'.join(new_lines)
            with open(file_path, "w", encoding="utf-8") as f:
                f.write(new_content)
            updated_files += 1

    print(f"✅ 完成路径转换! 更新了 {updated_files} 个文件")
    return updated_files

def process_posts():
    """处理所有文章:更新图片引用为图床URL,并收集图片"""
    image_mapping = {}  # 存储图片路径到文件名的映射
    updated_files = 0  # 记录更新的文件数量

    print(f"\n扫描文章目录: {POSTS_DIR}")

    # 获取当前年份(如果启用年份目录)
    current_year = time.strftime("%Y") if USE_YEAR_FOLDER else ""

    # 遍历所有Markdown文件
    for md_file in os.listdir(POSTS_DIR):
        if not md_file.endswith('.md'):
            continue

        file_path = os.path.join(POSTS_DIR, md_file)
        print(f"\n处理文章: {md_file}")

        # 读取文章内容
        with open(file_path, "r", encoding="utf-8", errors="replace") as f:
            content = f.read()

        # 备份原始内容
        original_content = content

        # 查找文章中的所有本地图片引用
        local_images = set()
        pattern = r'!\[[^\]]*?\]\(([^\s\)]+)\)'
        for match in re.findall(pattern, content):
            # 跳过HTTP链接
            if match.startswith('http'):
                continue

            # 获取图片路径
            img_path = os.path.normpath(match)

            # 检查图片文件是否存在
            if os.path.exists(img_path):
                local_images.add(img_path)
            else:
                print(f"  ⚠️ 图片文件不存在: {img_path}")

        if not local_images:
            print("  未找到有效的本地图片引用")
            continue

        # 收集需要处理的图片
        for img_path in local_images:
            # 获取原始文件名(不重命名)
            img_name = os.path.basename(img_path)

            # 构建图床URL - 根据配置添加年份目录
            if USE_YEAR_FOLDER:
                # 使用URL安全的拼接方式
                cdn_url = urllib.parse.urljoin(CDN_BASE, f"{current_year}/")
                cdn_url = urllib.parse.urljoin(cdn_url, urllib.parse.quote(img_name))
            else:
                cdn_url = urllib.parse.urljoin(CDN_BASE, urllib.parse.quote(img_name))

            # 记录映射关系
            image_mapping[img_path] = (img_name, current_year) if USE_YEAR_FOLDER else img_name
            print(f"  发现图片: {img_name} ({img_path})")
            print(f"  图床URL: {cdn_url}")

        # 逐行处理内容,保护代码块和行内代码
        lines = content.split('\n')
        new_lines = []
        in_code_block = False
        changes_made = 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 and local_images:
                # 对每个图片进行替换
                for img_path in local_images:
                    # 使用原始字符串避免转义问题
                    escaped_img_path = re.escape(img_path)

                    # 尝试匹配原始路径
                    if re.search(escaped_img_path, protected_line):
                        img_name = os.path.basename(img_path)

                        # 构建正确的CDN URL
                        if USE_YEAR_FOLDER:
                            cdn_url = urllib.parse.urljoin(CDN_BASE, f"{current_year}/")
                            cdn_url = urllib.parse.urljoin(cdn_url, urllib.parse.quote(img_name))
                        else:
                            cdn_url = urllib.parse.urljoin(CDN_BASE, urllib.parse.quote(img_name))

                        # 使用原始字符串替换
                        protected_line = re.sub(
                            r'!\[([^\]]*?)\]\(\s*' + escaped_img_path + r'\s*\)',
                            r'![\1](' + cdn_url + r' "\1")',
                            protected_line
                        )
                        changes_made = True

            new_lines.append(protected_line)

        # 保存更新
        if changes_made:
            new_content = '\n'.join(new_lines)
            with open(file_path, "w", encoding="utf-8") as f:
                f.write(new_content)
            print(f"  已更新图片链接")
            updated_files += 1
        else:
            print("  未修改内容")

    return image_mapping, updated_files

def upload_images(image_mapping):
    """上传所有图片到图床(保留原始文件名)"""
    print("\n开始使用PicList上传图片...")
    success_count = 0
    failed_files = []

    # 获取当前年份(如果启用年份目录)
    current_year = time.strftime("%Y") if USE_YEAR_FOLDER else ""

    # 直接上传原始图片文件
    for i, (img_path, img_info) in enumerate(image_mapping.items()):
        # 解析图片信息
        if USE_YEAR_FOLDER:
            img_name, img_year = img_info
            remote_path = img_year  # 使用年份作为远程目录
        else:
            img_name = img_info
            remote_path = ""  # 不使用额外目录

        print(f"\n上传图片 {i+1}/{len(image_mapping)}: {img_name}")
        print(f"图片路径: {img_path}")
        if USE_YEAR_FOLDER:
            print(f"远程目录: {remote_path}/")

        # 检查文件是否存在
        if not os.path.exists(img_path):
            print(f"  ⚠️ 文件不存在,跳过上传")
            failed_files.append((img_path, img_name))
            continue

        # 构建PicList命令
        cmd = [PICLIST_PATH, "upload", img_path]

        # 如果启用年份目录,添加远程路径参数
        if USE_YEAR_FOLDER and remote_path:
            cmd.extend(["-p", remote_path])

        print(f"执行命令: {' '.join(cmd)}")

        try:
            # 执行上传
            result = subprocess.run(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                encoding='utf-8',
                timeout=120  # 2分钟超时
            )

            if result.returncode == 0:
                print("上传成功!")
                success_count += 1
            else:
                print(f"上传失败! 错误代码: {result.returncode}")
                print("错误输出:", result.stderr.strip())
                failed_files.append((img_path, img_name))
        except subprocess.TimeoutExpired:
            print("上传超时! 跳过此文件")
            failed_files.append((img_path, img_name))
        except Exception as e:
            print(f"上传错误: {str(e)}")
            failed_files.append((img_path, img_name))

    # 处理失败的文件
    if failed_files:
        print("\n⚠️ 以下文件上传失败:")
        for img_path, img_name in failed_files:
            print(f"  - {img_name} ({img_path})")
        print("请手动处理这些文件")

    print(f"\n上传完成: {success_count}/{len(image_mapping)} 成功")
    return success_count

def main():
    print("="*50)
    print("Markdown图片迁移到云端工具")
    print(f"博客根目录: {BLOG_ROOT}")
    print(f"文章目录: {POSTS_DIR}")
    print(f"图片目录: {IMAGES_DIR}")
    print(f"图床URL: {CDN_BASE}")
    print(f"PicList路径: {PICLIST_PATH}")
    print(f"按年分文件夹: {'是' if USE_YEAR_FOLDER else '否'}")
    print("="*50)
    print("注意: 图片将保留原始文件名上传,如需重命名请在PicList中启用高级重命名功能")
    print("="*50)

    start_time = time.time()

    # 步骤1: 将相对路径转换为绝对路径
    convert_relative_paths_to_absolute()

    # 步骤2: 处理文章并收集图片
    image_mapping, updated_files = process_posts()

    if not image_mapping:
        print("\n没有发现需要处理的图片,退出程序")
        return

    # 步骤3: 上传图片
    success_count = upload_images(image_mapping)

    # 显示摘要
    duration = time.time() - start_time
    print("\n" + "="*50)
    print("迁移摘要:")
    print(f"更新文章: {updated_files}")
    print(f"处理图片: {len(image_mapping)}")
    print(f"成功上传: {success_count}/{len(image_mapping)}")
    print(f"耗时: {duration:.2f} 秒")
    print("="*50)

    if success_count == len(image_mapping):
        print(f"✅ 所有图片成功上传到图床!")
        if USE_YEAR_FOLDER:
            current_year = time.strftime("%Y")
            print(f"图床链接示例: {CDN_BASE}{current_year}/图片名称.png")
        else:
            print(f"图床链接示例: {CDN_BASE}图片名称.png")
    else:
        print(f"⚠️ 部分图片上传失败 ({len(image_mapping)-success_count}个),请检查日志")

    print("\n操作完成! 请验证文章中的图片链接")

if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        print(f"\n❌ 发生未预期错误: {str(e)}")
        import traceback
        traceback.print_exc()

脚本运行逻辑说明

  1. 路径转换:将文章中的相对图片路径转换为绝对路径。
  2. 扫描文章:查找所有本地图片引用(非 HTTP),收集图片路径。
  3. 生成图床 URL:保留原文件名,可选按年份分目录。
  4. 更新文章:将图片链接替换为图床 URL(保护代码块 / 行内代码)。
  5. 上传图片:调用 PicList 上传所有图片(保留原文件名)。
  6. 输出摘要:显示更新文章数、上传成功数、耗时等。

运行脚本

运行以下命令(根据脚本文件名称选择),执行脚本:

Bash
1
2
python hugo_pagebundle_to_cloud.py
python hugo_local_to_cloud.py

运行时会显示进度,如果上传失败或路径不匹配,根据提示操作,避免未确认就中断。

验证迁移

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

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