Hugo 博客项目实战:从 Obsidian 批量导入到生产部署

记录 Hugo + hugo-theme-stack v3 博客项目的完整搭建、迁移和排障过程,涵盖配置迁移、导航修复、Obsidian 笔记批量导入、YAML 问题处理、敏感信息清理等实操经验。

Hugo 博客项目实战:从 Obsidian 批量导入到生产部署

本文基于 十指的世界 博客项目(/Users/docker/hugo/blog)的真实排障经历整理,覆盖从主题升级到内容迁移的完整流程。


一、项目背景

项目
框架 Hugo v0.164.0-ce (extended)
主题 hugo-theme-stack v3
系统 macOS 12.7
内容来源 Obsidian 笔记(iCloud 同步)
输出路径 public/

二、Hugo v0.158+ 废弃 API 迁移

2.1 问题现象

每次运行 hugo 都会输出大量 deprecated 警告:

1
2
3
4
WARN  deprecated: project config key languageCode was deprecated in Hugo v0.158.0 and will be removed in a future release. Use locale instead.
WARN  deprecated: .Site.LanguageCode was deprecated in Hugo v0.158.0 and will be removed in a future release. Use .Site.Language.Locale instead.
WARN  deprecated: .Language.LanguageDirection was deprecated in Hugo v0.158.0 and will be removed in a future release. Use .Language.Direction instead.
WARN  deprecated: .Site.Data was deprecated in Hugo v0.156.0 and will be removed in a future release. Use hugo.Data instead.

2.2 根因分析

Hugo 在 v0.158.0 起引入了新的语言配置体系,旧 API 虽然还能用但会被弃用。涉及两个层面:

  1. 配置文件:顶层 languageCode 已废弃,应移至 languages.<key>.locale
  2. 模板文件.Site.LanguageCode.Site.Language.Locale.Language.LanguageDirection.Language.Direction
  3. 数据访问.Site.Datahugo.Data

2.3 修复步骤

第一步:修改 config.yaml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# ❌ 旧写法
languageCode: zh-cn

# ✅ 新写法:删除顶层 languageCode,放入 languages 下
defaultContentLanguage: zh
hasCJKLanguage: true

languages:
  zh:
    label: 简体中文
    title: 十指的世界
    locale: zh-cn    # 替代 languageCode
    weight: 1

⚠️ 注意:locale 是放在 languages.zh 下面的子字段,不是顶层字段。

第二步:覆盖主题的 baseof.html

Hugo 的主题模板优先级规则:项目目录下的模板优先于主题目录。在项目根目录创建覆盖文件即可,无需修改主题源码。

1
mkdir -p layouts/_default

layouts/_default/baseof.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!DOCTYPE html>
<html lang="{{ .Site.Language.Locale }}" dir="{{ default `ltr` .Language.Direction }}">
    <head>
        {{- partial "head/head.html" . -}}
        {{- block "head" . -}}{{ end }}
    </head>
    <body class="{{ block `body-class` . }}{{ end }}">
        ...
    </body>
</html>

关键替换:

  • .Site.LanguageCode.Site.Language.Locale
  • .Language.LanguageDirection.Language.Direction

第三步:覆盖 external.html 模板

layouts/partials/helper/external.html

1
2
{{- $List := index hugo.Data.external .Namespace -}}
...

关键替换:.Context.Site.Datahugo.Data

2.4 验证

1
2
3
4
cd /Users/liwei/docker/hugo/blog
rm -rf public
hugo --cleanDestinationDir 2>&1 | grep -i warn
# 输出应为空(或仅剩非关键警告)

💡 经验教训:主题依赖 v3.34.2,但 Hugo 已升级到 v0.164.0。主题更新可能滞后于 Hugo 核心,因此模板覆盖是必要的维护手段。


三、导航菜单 404 问题

3.1 问题现象

点击左侧导航栏的「搜索」「关于」等链接时返回 404。

3.2 根因

Hugo 根据页面 title 自动生成 URL slug:

1
2
content/page/search/index.md  →  title: "搜索"  →  URL: /搜索/
content/page/about/index.md   →  title: "关于"  →  URL: /关于/

config.yaml 中菜单 URL 仍使用英文路径 /search//about/,导致 404。

3.3 修复

config.yaml 中的菜单配置必须与实际生成的 URL 一致:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
menu:
  main:
    - identifier: home
      name: 主页
      url: /
      weight: -100

    - identifier: search
      name: 搜索
      url: /搜索/          # ✅ 与页面 title 生成的 slug 一致
      weight: -60

    - identifier: archives
      name: 归档
      url: /archives/     # ✅ 使用 slug 而非中文 title
      weight: -70

    - identifier: about
      name: 关于
      url: /关于/          # ✅ 与页面 title 一致
      weight: -80

3.4 通用规则

Hugo 生成 URL 的规则:页面路径的最后一个目录名或 title 会被转换为 URL slug。中文会保留为 UTF-8 编码。如果 title 包含特殊字符,Hugo 会做清理处理。


四、Obsidian 笔记批量导入 Hugo

4.1 场景

用户有大量 Obsidian Markdown 笔记,需要批量转换为 Hugo 文章。

源目录/Users/liwei/Library/Mobile Documents/iCloud~md~obsidian/Documents/liwei笔记/自媒体/ 目标目录/Users/liwei/docker/hugo/blog/content/post/

4.2 批量转换脚本

  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
import os
import re

SRC = "/Users/liwei/Library/Mobile Documents/iCloud~md~obsidian/Documents/liwei笔记/自媒体"
DST = "/Users/liwei/docker/hugo/blog/content/post"

def get_category(rel):
    """根据文件名和内容分类"""
    if '文案' in rel:
        return '自媒体文案'
    elif 'python' in rel.lower() or 'Python' in rel:
        return 'Python技术'
    elif '主机' in rel or '硬件' in rel or 'TOPS' in rel:
        return '硬件评测'
    elif '旅行' in rel or '旅游' in rel or '攀枝花' in rel or '甘南' in rel or '新疆' in rel:
        return '旅行生活'
    elif 'macOS' in rel or 'office' in rel or 'Excel' in rel:
        return '办公技巧'
    else:
        return '综合'

def clean_sensitive(content):
    """清理敏感信息"""
    content = re.sub(r'wx:\s*\w+', 'wx: ***', content)
    content = re.sub(r'微信:\s*\w+', '微信: ***', content)
    content = re.sub(r'VX:\s*\w+', 'VX: ***', content)
    content = re.sub(r'微信号:\s*\w+', '微信号:***', content)
    content = re.sub(r'1[3-9]\d{9}', '***', content)
    return content

def should_skip(rel, content):
    """跳过不适合发布的内容"""
    skip_patterns = ['找接单群', 'ai+头条', '找文案方式', '一键三连', '未命名 1.md']
    if any(kw in rel for kw in skip_patterns):
        return True
    if len(content.strip()) < 100:
        return True
    return False

files = []
for root, dirs, fnames in os.walk(SRC):
    for f in fnames:
        if f.endswith('.md') and f != '.DS_Store':
            full = os.path.join(root, f)
            rel = os.path.relpath(full, SRC)
            with open(full, 'r', encoding='utf-8') as fh:
                content = fh.read()
            files.append((rel, content))

created = 0
skipped = 0

for rel, content in sorted(files):
    if should_skip(rel, content):
        skipped += 1
        continue

    content = clean_sensitive(content)

    # 提取日期
    m = re.match(r'^(\d{4})-(\d{2})-(\d{2})', rel)
    if m:
        date = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
    else:
        date = "2026-07-21"

    basename = os.path.basename(rel).replace('.md', '')
    category = get_category(rel)
    tags = set()

    if 'python' in rel.lower():
        tags.update(['Python', '编程'])
    if 'uv' in rel.lower() or 'poetry' in rel.lower() or 'PDM' in rel.lower():
        tags.add('Python工具')
    if 'FPV' in rel:
        tags.add('FPV')
    if '电商' in rel:
        tags.add('电商')
    if '旅行' in rel or '旅游' in rel:
        tags.add('旅行')
    tags = list(tags)[:5]

    # 文案类内容放到子目录
    if '文案/' in rel:
        subpath = f"自媒体文案/{basename}"
    else:
        subpath = basename

    dest_dir = os.path.join(DST, subpath)
    os.makedirs(dest_dir, exist_ok=True)

    tags_str = '\n'.join([f'  - {t}' for t in tags])
    desc = content[:100].replace('\n', ' ').strip()

    md_content = f'''---
title: "{basename}"
date: {date}T00:00:00+08:00
description: "{desc}"
tags:
{tags_str}
categories:
  - {category}
draft: false
---

{content.strip()}
'''

    with open(os.path.join(dest_dir, 'index.md'), 'w', encoding='utf-8') as f:
        f.write(md_content)

    created += 1

print(f"Created: {created}, Skipped: {skipped}")

4.3 处理结果

指标 数量
源文件总数 55
成功导入 48
跳过 7

跳过的文件类型:

  • 找接单群.md — 询问消息,非正式文章
  • ai+头条.md — 链接+指令说明
  • 找文案方式.md — 个人笔记
  • 未命名 1.md — 原始命令记录
  • 一键三连模板.md — CTA 模板
  • 2个重复/过短文件

4.4 分类统计

分类 数量 示例
Python技术 17 uv、Poetry、PyInstaller、NiceGUI
自媒体文案 16 抖音文案、旅行攻略、情感类
硬件评测 2 AI主机、小主机推荐
电商运营 3 外贸数据、电商数据分析
办公技巧 3 macOS小红点、Excel固定行
旅行生活 5 攀枝花、甘南、新疆巴里坤
综合 2 高考、电池电压表

五、YAML Front Matter 常见错误

5.1 多行 description 导致构建失败

错误信息

1
2
ERROR error building site: assemble: failed to create page from pageMetaSource
[3:85] value is not allowed in this context. map key-value is pre-defined

原因description 字段的值中包含换行符,YAML 字符串无法跨行解析。

问题文件示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
---
title: "20260330 FPV 视频旁白"
date: 2026-03-30T00:00:00+08:00
description: "我来为你重新创作一段更贴合你内容的5分钟FPV飞行视频旁白:
  ---
  ## 🎬 FPV飞行视频旁白
  **【开场 - 40秒】**
  \"新手FPV玩家,都有一个共同的痛——找个好飞场,太难了。\""
tags:
  - FPV
---

修复方法:将 description 压缩为单行,去除 Markdown 标记,截断到合理长度:

1
description: "我来为你重新创作一段更贴合你内容的5分钟FPV飞行视频旁白,涵盖开场、飞行技巧与场景建议"

5.2 批量检测与修复脚本

 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
import os, re

post_dir = "/Users/liwei/docker/hugo/blog/content/post"

for root, dirs, files in os.walk(post_dir):
    if 'index.md' in files:
        path = os.path.join(root, 'index.md')
        with open(path, 'r', encoding='utf-8') as f:
            content = f.read()

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

        for i, line in enumerate(lines):
            if not found_desc and line.strip().startswith('description:'):
                found_desc = True
                match = re.match(r'(description:\s*")(.*)(")', line)
                if match:
                    desc_text = match.group(2)
                    # 清理换行和 Markdown 标记
                    desc_text = desc_text.replace('\\n', ' ').replace('\n', ' ')
                    desc_text = re.sub(r'\s+---\s+', ' ', desc_text)
                    desc_text = re.sub(r'#{1,6}\s*', '', desc_text)
                    desc_text = re.sub(r'\*\*', '', desc_text)
                    desc_text = re.sub(r'`', '', desc_text)
                    desc_text = re.sub(r'\s+', ' ', desc_text).strip()
                    if len(desc_text) > 120:
                        desc_text = desc_text[:117] + "..."
                    new_lines.append(f'description: "{desc_text}"')
                else:
                    new_lines.append(line)
            else:
                new_lines.append(line)

        with open(path, 'w', encoding='utf-8') as f:
            f.write('\n'.join(new_lines))

5.3 YAML 字符串最佳实践

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# ✅ 推荐:单行引号包裹
description: "这是一段简短的描述文字"

# ✅ 推荐:使用管道符处理长文本
description: |
  这是第一段。
  这是第二段。

# ❌ 避免:在引号内嵌入换行
description: "第一行
第二行"

# ❌ 避免:描述中包含未转义的双引号
description: "他说"你好""
# 应改为:
description: "他说\"你好\""

六、文章日期与可见性

6.1 问题现象

某篇文章在 content/post/ 下正确放置,draft: false,但 Hugo 构建后不显示。

6.2 根因

文章的 date 字段设置为未来时间:

1
date: 2026-07-21T16:00:00+08:00

而系统当前时间是 2026-07-21 15:48。Hugo 默认只发布 date <= now 的文章,未来的文章会被当作 scheduled content 处理。

6.3 解决方案

方案一:调整 date 为已过去的时间

1
date: 2026-07-21T14:00:00+08:00  # 改为当前时间之前

方案二:使用 publishDatedate 分离

1
2
date: 2026-07-21T16:00:00+08:00
publishDate: 2026-07-21T00:00:00+08:00  # 发布日期设为当天凌晨

⚠️ 注意:publishDate 的语义是「何时发布」,date 是「内容关联日期」。两者可以同时存在,Hugo 按 publishDate 决定可见性。

方案三:构建时使用 --buildFuture

1
hugo --buildFuture

这会强制发布所有未来日期的文章,但不推荐用于生产环境。


七、Hugo 文章目录结构规范

7.1 正确结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
content/post/
├── python-pip-替代工具-uv-poetry-pdm比较/
│   └── index.md
├── 自媒体文案/
│   ├── 20260421-告诉你一个残酷的真相/
│   │   └── index.md
│   └── 20260422-四大谎言/
│       └── index.md
└── A股市场分析报告_20260721/
    └── index.md

7.2 错误结构

1
2
3
content/post/
├── python-pip-替代工具-uv-poetry-pdm比较.md    # ❌ 裸 .md 文件
└── A股市场分析报告_20260721.md                  # ❌ 裸 .md 文件

Hugo 要求每篇文章必须在独立目录下,且主文件命名为 index.md

7.3 从裸文件迁移

1
2
3
4
# 将裸 .md 文件移入同名目录
mkdir -p "content/post/A股市场分析报告_20260721"
mv "content/post/A股市场分析报告_20260721.md" \
   "content/post/A股市场分析报告_20260721/index.md"

8.1 配置

1
2
3
permalinks:
  post: /p/:slug/
  page: /:slug/

8.2 URL 生成逻辑

文章路径 标题 最终 URL
content/post/a股7月3日/index.md A股7月3日(周五)行情复盘 /p/a股7月3日周五行情复盘/
content/post/A股市场分析报告_20260721/index.md A股市场分析报告(2026年7月21日) /p/a股市场分析报告2026年7月21日/

Hugo 的 :slug: 变量会自动:

  • 移除特殊字符(括号、逗号、连字符等)
  • 保留中文字符(UTF-8)
  • 将空格替换为 -

8.3 菜单 URL 必须匹配

1
2
3
4
5
6
7
# ❌ 错误:菜单指向 /search/,但实际页面生成 /搜索/
- identifier: search
  url: /search/

# ✅ 正确:菜单指向实际生成的 URL
- identifier: search
  url: /搜索/

九、敏感信息自动清理

9.1 需要清理的信息类型

类型 正则模式 替换为
微信号 wx:\s*\w+ wx: ***
微信 微信:\s*\w+ 微信: ***
VX VX:\s*\w+ VX: ***
手机号 1[3-9]\d{9} ***

9.2 注意事项

  • 代码示例中的 your_passwordusername 等占位符不需要清理
  • 教程中提到的"输入密码"等文字说明不需要清理
  • 仅清理真实的个人联系方式

十、构建验证清单

每次修改后执行以下检查:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
cd /Users/liwei/docker/hugo/blog

# 1. 清理并重新构建
rm -rf public
hugo --cleanDestinationDir

# 2. 检查是否有警告
hugo --cleanDestinationDir 2>&1 | grep -i warn

# 3. 检查文章是否被识别
hugo list all | grep "A股市场"

# 4. 检查输出文件
find public -name "index.html" | wc -l
find public/p -type d | wc -l

# 5. 本地预览
hugo server -D

十一、常见问题速查

问题 原因 解决
languageCode 警告 Hugo v0.158+ 废弃该配置项 改用 languages.zh.locale
.Site.LanguageCode 警告 模板使用旧 API 覆盖 baseof.html 使用 .Site.Language.Locale
.Site.Data 警告 Hugo v0.156+ 废弃 改用 hugo.Data
导航链接 404 菜单 URL 与页面实际 slug 不一致 检查 config.yamlurl 字段
文章不显示 date 是未来时间 调整 date 或设置 publishDate
description 构建报错 YAML 多行字符串未正确处理 压缩为单行或使用管道符
.md 文件不被识别 Hugo 要求文章在独立目录 移入 index.md 子目录

十二、完整项目结构

 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
/Users/liwei/docker/hugo/blog/
├── config.yaml              # 项目配置
├── go.mod                   # Hugo 模块管理
├── content/
│   ├── page/
│   │   ├── about/           # 关于页
│   │   ├── archives/        # 归档页
│   │   └── search/          # 搜索页
│   └── post/                # 文章目录
│       ├── Python技术/
│       ├── 自媒体文案/
│       ├── 硬件评测/
│       ├── 电商运营/
│       ├── 办公技巧/
│       └── 旅行生活/
├── layouts/
│   ├── _default/
│   │   └── baseof.html      # 覆盖主题的 baseof
│   └── partials/
│       └── helper/
│           └── external.html # 覆盖主题的外部资源加载器
├── static/
│   └── img/
│       └── avatar.png
├── themes/
│   └── hugo-theme-stack/    # 主题目录
└── public/                  # 构建输出

附录:关键命令参考

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 构建
hugo --cleanDestinationDir

# 本地预览(包含 draft 文章)
hugo server -D

# 列出所有内容
hugo list all

# 列出草稿
hugo list drafts

# 调试模式(查看每篇文章的 permalink)
hugo --logLevel debug

# 构建未来文章
hugo --buildFuture

# 指定目标目录
hugo --destination /var/www/html
使用 Hugo 构建
主题 StackJimmy 设计