晓风博客

一片荒芜的地方

Obsidian 自动发布到 Hugo 博客

作为一个重度 Obsidian 用户,我一直在寻找一种更高效的方式来将我的笔记发布到 Hugo 博客上。经过不断的探索和优化,我开发了一套完整的自动化解决方案,让你可以通过一键操作将 Obsidian 笔记转换并发布到 Hugo 博客。

为什么需要这个集成方案?

现有痛点

  1. 格式转换繁琐:Obsidian 使用 YAML 前置数据,Hugo 需要 TOML 格式
  2. 图片处理复杂:需要手动移动图片到正确的目录结构
  3. 发布流程冗长:复制粘贴、格式调整、图片处理等多个步骤
  4. 文件命名不一致:需要手动生成 SEO 友好的文件名

解决方案优势

系统架构

我们的解决方案包含三个核心组件:

Loading Mermaid chart...

核心实现

1. Node.js 转换脚本

首先,我们需要一个核心转换脚本来处理 Obsidian 笔记到 Hugo 文章的转换:

  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
#!/usr/bin/env node

const fs = require("fs");
const path = require("path");

class ObsidianToHugo {
  constructor(blogRoot, defaultAuthor = "你的名字") {
    this.blogRoot = blogRoot;
    this.defaultAuthor = defaultAuthor;
    this.postsDir = path.join(blogRoot, "content", "posts");
    this.imagesDir = path.join(blogRoot, "static", "images");
  }

  async convertFile(obsidianFilePath, vaultRoot = null) {
    try {
      console.log(`正在转换: ${obsidianFilePath}`);

      const content = fs.readFileSync(obsidianFilePath, "utf8");
      const { frontmatter, body } = this.parseFrontmatter(content);

      // 使用原始 Obsidian 文件名(已经是 slug 友好的)
      const originalFilename = path.basename(obsidianFilePath, ".md");
      const filename = this.cleanFilename(originalFilename);
      const targetPath = path.join(this.postsDir, `${filename}.md`);

      // 提取标题用于前置数据
      const extractedTitle = this.extractTitleFromContent(body);
      const humanizedFilename = this.humanizeFilename(originalFilename);
      const title = frontmatter.title || humanizedFilename || extractedTitle;

      // 处理文章中的图片
      const { processedBody, movedImages, featuredImage } =
        await this.processImages(body, obsidianFilePath, vaultRoot);

      // 生成 Hugo 前置数据
      const updatedFrontmatter = { ...frontmatter, title };
      const hugoFrontmatter = this.generateHugoFrontmatter(updatedFrontmatter);

      // 构建最终内容
      const finalContent = this.buildFinalContent(
        hugoFrontmatter,
        processedBody,
        featuredImage
      );

      // 写入文件
      fs.writeFileSync(targetPath, finalContent, "utf8");

      console.log(`✓ 转换完成: ${targetPath}`);
      if (movedImages.length > 0) {
        console.log(`✓ 移动了 ${movedImages.length} 张图片`);
      }

      return { targetPath, movedImages };
    } catch (error) {
      console.error(`转换 ${obsidianFilePath} 时出错:`, error.message);
      throw error;
    }
  }

  // 解析前置数据
  parseFrontmatter(content) {
    const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);

    if (frontmatterMatch) {
      // YAML 前置数据
      const yamlFrontmatter = frontmatterMatch[1];
      const body = frontmatterMatch[2];

      const frontmatter = {};
      yamlFrontmatter.split("\n").forEach((line) => {
        const match = line.match(/^(.+?):\s*(.+)$/);
        if (match) {
          const key = match[1].trim();
          let value = match[2].trim();

          // 移除引号
          if (
            (value.startsWith('"') && value.endsWith('"')) ||
            (value.startsWith("'") && value.endsWith("'"))
          ) {
            value = value.slice(1, -1);
          }

          frontmatter[key] = value;
        }
      });

      return { frontmatter, body };
    } else {
      // 没有前置数据,返回空对象让主逻辑处理
      return {
        frontmatter: {},
        body: content,
      };
    }
  }

  // 生成 Hugo TOML 前置数据
  generateHugoFrontmatter(obsidianFrontmatter) {
    const now = new Date();
    const dateStr = now.toISOString().split("T")[0];

    const title = obsidianFrontmatter.title || "无标题文章";
    const author = obsidianFrontmatter.author || this.defaultAuthor;
    const date = obsidianFrontmatter.date || dateStr;
    const draft =
      obsidianFrontmatter.draft === "true" || obsidianFrontmatter.draft === true
        ? "false"
        : "false";

    return `+++
title = "${title}"
author = ["${author}"]
date = ${date}
draft = ${draft}
+++`;
  }

  // 处理图片
  async processImages(body, sourceFilePath, vaultRoot = null) {
    const sourceDir = path.dirname(sourceFilePath);
    const now = new Date();
    const year = now.getFullYear();
    const month = String(now.getMonth() + 1).padStart(2, "0");
    const day = String(now.getDate()).padStart(2, "0");

    const targetImagesDir = path.join(this.imagesDir, String(year), month, day);
    fs.mkdirSync(targetImagesDir, { recursive: true });

    const movedImages = [];
    let featuredImage = null;
    let isFirstImage = true;

    // 匹配 Obsidian 图片引用:![[image.jpg]] 或 ![alt](image.jpg)
    const imagePattern = /!\[\[([^\]]+)\]\]|!\[([^\]]*)\]\(([^)]+)\)/g;

    const processedBody = body.replace(
      imagePattern,
      (match, obsidianImg, altText, markdownImg) => {
        const imageName = obsidianImg || markdownImg;
        const alt = altText || "";

        if (!imageName) return match;

        // 查找图片的实际路径
        let sourceImagePath = this.findImagePath(
          imageName,
          sourceDir,
          vaultRoot
        );

        if (!sourceImagePath || !fs.existsSync(sourceImagePath)) {
          console.warn(`⚠ 图片未找到: ${imageName}`);
          return match;
        }

        try {
          const ext = path.extname(imageName);
          const baseName = path.basename(imageName, ext);
          const targetImageName = `${baseName}${ext}`;
          const targetImagePath = path.join(targetImagesDir, targetImageName);

          // 复制图片到目标位置
          fs.copyFileSync(sourceImagePath, targetImagePath);

          const webPath = `/images/${year}/${month}/${day}/${targetImageName}`;
          movedImages.push(webPath);

          // 创建 AMP 图片标签
          const ampImage = `





    
    
        
    








`;

          // 第一张图片作为特色图片
          if (isFirstImage) {
            featuredImage = ampImage;
            isFirstImage = false;
            return ""; // 从正文中移除
          }

          return ampImage;
        } catch (error) {
          console.error(`处理图片 ${imageName} 时出错:`, error.message);
          return match;
        }
      }
    );

    return { processedBody, movedImages, featuredImage };
  }

  // 查找图片路径
  findImagePath(imageName, sourceDir, vaultRoot) {
    if (path.isAbsolute(imageName)) {
      return imageName;
    }

    if (imageName.includes("/")) {
      return path.resolve(sourceDir, imageName);
    }

    // 检测 vault 根目录
    let detectedVaultRoot = vaultRoot || sourceDir;

    if (!vaultRoot) {
      while (detectedVaultRoot !== path.dirname(detectedVaultRoot)) {
        if (fs.existsSync(path.join(detectedVaultRoot, ".obsidian"))) {
          break;
        }
        const parentDir = path.dirname(detectedVaultRoot);
        if (parentDir === detectedVaultRoot) break;
        detectedVaultRoot = parentDir;
      }
    }

    const possiblePaths = [
      // 首先检查 vault 根目录(Obsidian ![[image]] 语法最常见)
      path.join(detectedVaultRoot, imageName),
      // 然后检查相对于当前文件
      path.join(sourceDir, imageName),
      // 检查常见的附件文件夹
      path.join(detectedVaultRoot, "attachments", imageName),
      path.join(detectedVaultRoot, "assets", imageName),
      path.join(detectedVaultRoot, "_resources", imageName),
      path.join(sourceDir, "attachments", imageName),
      path.join(sourceDir, "assets", imageName),
    ];

    return possiblePaths.find((p) => fs.existsSync(p));
  }
}

2. QuickAdd 宏脚本

接下来是 Obsidian QuickAdd 宏脚本,这是用户界面的核心:

  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
// QuickAdd 宏:发布到 Hugo 博客
module.exports = async (params) => {
  const { quickAddApi: QuickAdd, app, obsidian } = params;

  // 配置 - 请更新为你的实际路径
  const BLOG_ROOT = "/path/to/your/hugo/blog";
  const SCRIPT_PATH = "/path/to/obsidian-to-hugo.js";

  try {
    // 获取当前活动文件
    const activeFile = app.workspace.getActiveFile();

    if (!activeFile) {
      new obsidian.Notice("未找到活动文件,请先打开一个笔记");
      return;
    }

    if (activeFile.extension !== "md") {
      new obsidian.Notice("请打开一个 Markdown 文件");
      return;
    }

    // 获取绝对文件路径和 vault 根目录
    let filePath;
    let vaultPath;
    try {
      vaultPath = app.vault.adapter.basePath;
      if (vaultPath) {
        filePath = require("path").join(vaultPath, activeFile.path);
      } else {
        // 备选方案:创建临时文件
        const content = await app.vault.read(activeFile);
        const tempPath = require("path").join(
          require("os").tmpdir(),
          `temp-${Date.now()}-${activeFile.basename}`
        );
        require("fs").writeFileSync(tempPath, content, "utf8");
        filePath = tempPath;
        console.log("使用临时文件(未检测到 vault 路径)");
      }
    } catch (error) {
      console.error("获取文件路径时出错:", error);
      new obsidian.Notice(`获取文件路径时出错: ${error.message}`, 5000);
      return;
    }

    // 显示确认对话框
    const shouldPublish = await QuickAdd.yesNoPrompt(
      `发布 "${activeFile.basename}" 到 Hugo 博客?`,
      `这将转换并发布当前笔记到你的 Hugo 博客。\n\n文件: ${activeFile.path}`
    );

    if (!shouldPublish) {
      return;
    }

    new obsidian.Notice("正在发布到 Hugo 博客...");

    // 执行转换脚本
    const { exec } = require("child_process");
    const util = require("util");
    const execPromise = util.promisify(exec);

    let isTemporaryFile = filePath.includes(require("os").tmpdir());

    try {
      // 包含 vault 路径(如果可用)
      const command = vaultPath
        ? `node "${SCRIPT_PATH}" "${filePath}" "${BLOG_ROOT}" "你的名字" "${vaultPath}"`
        : `node "${SCRIPT_PATH}" "${filePath}" "${BLOG_ROOT}"`;

      console.log("执行命令:", command);

      const { stdout, stderr } = await execPromise(command);

      if (stderr && !stderr.includes("Warning")) {
        console.error("脚本错误:", stderr);
        throw new Error(`脚本错误: ${stderr}`);
      }

      console.log("脚本输出:", stdout);

      // 解析输出获取目标路径
      const targetMatch = stdout.match(/Blog post: (.+)/);
      const targetPath = targetMatch ? targetMatch[1] : "未知";

      new obsidian.Notice(
        `✅ 成功发布到 Hugo 博客!\n位置: ${targetPath}`,
        8000
      );

      // 询问是否预览网站
      const shouldPreview = await QuickAdd.yesNoPrompt(
        "启动本地预览?",
        "是否启动 Hugo 开发服务器来预览你的更改?"
      );

      if (shouldPreview) {
        // 启动 Hugo 服务器
        const serverCommand = `cd "${BLOG_ROOT}" && pnpm server`;
        exec(serverCommand, (error, stdout, stderr) => {
          if (error) {
            new obsidian.Notice(`启动服务器时出错: ${error.message}`, 5000);
            return;
          }
          new obsidian.Notice(
            "Hugo 服务器启动中... 访问 http://localhost:1313",
            3000
          );
        });
      }
    } catch (scriptError) {
      console.error("转换错误:", scriptError);
      new obsidian.Notice(`❌ 转换失败: ${scriptError.message}`, 8000);
      throw scriptError;
    } finally {
      // 清理临时文件
      if (isTemporaryFile) {
        try {
          require("fs").unlinkSync(filePath);
          console.log("清理临时文件:", filePath);
        } catch (cleanupError) {
          console.warn("无法清理临时文件:", cleanupError);
        }
      }
    }
  } catch (error) {
    console.error("宏错误:", error);
    new obsidian.Notice(`❌ 错误: ${error.message}`, 5000);
  }
};

安装和配置

1. 准备工作

首先确保你已经安装了以下软件:

2. 脚本配置

  1. 将转换脚本保存为 obsidian-to-hugo.js

  2. 确保脚本具有执行权限:

    1
    
    chmod +x obsidian-to-hugo.js
    
  3. 在你的 package.json 中添加转换命令:

    1
    2
    3
    4
    5
    
    {
      "scripts": {
        "convert": "node scripts/obsidian-to-hugo.js"
      }
    }
    

3. QuickAdd 配置

  1. 在 Obsidian 中打开设置 → 社区插件 → QuickAdd

  2. 点击"管理宏" → 创建新宏

  3. 名称: “发布到 Hugo 博客”

  4. 脚本: 粘贴上面的宏脚本代码

  5. 更新脚本中的路径配置:

    1
    2
    
    const BLOG_ROOT = "/path/to/your/hugo/blog"; // 你的博客目录
    const SCRIPT_PATH = "/path/to/obsidian-to-hugo.js"; // 脚本路径
    
  6. 返回 QuickAdd 设置,添加新选择:

    • 类型: 宏
    • 名称: “📝 发布到博客”
    • : 选择刚创建的宏
  7. 启用选择并分配快捷键(推荐:Ctrl+Shift+P

使用方法

发布文章

  1. 打开笔记: 在 Obsidian 中打开要发布的笔记
  2. 触发宏: 使用以下方式之一:
    • 按下分配的快捷键(例如 Ctrl+Shift+P
    • 打开命令面板(Ctrl+P)输入 “QuickAdd: 📝 发布到博客”
    • 点击 QuickAdd 工具栏按钮
  3. 确认发布: 在弹出的对话框中确认发布
  4. 等待完成: 脚本会自动处理转换和图片同步
  5. 可选预览: 选择是否启动 Hugo 开发服务器预览

支持的前置数据字段

脚本支持以下 YAML 前置数据字段,并会自动转换为 TOML 格式:

1
2
3
4
5
6
7
8
---
title: "文章标题"
author: "作者名"
date: 2024-01-15
draft: false
tags: ["标签1", "标签2"]
categories: ["分类"]
---

转换后的 TOML 格式:

1
2
3
4
5
6
+++
title = "文章标题"
author = ["作者名"]
date = 2024-01-15
draft = false
+++

图片处理

脚本会自动处理以下格式的图片引用:

  1. Obsidian 格式: ![[图片.jpg]]
  2. 标准 Markdown 格式: ![描述](图片.jpg)

图片查找顺序:

  1. Vault 根目录(推荐 Obsidian 图片存放位置)
  2. 当前文件相对路径
  3. 常见附件文件夹(attachments、assets、_resources)

处理后的图片会:

进阶功能

命令行使用

你也可以直接使用命令行来转换文件:

1
2
3
4
5
6
7
8
# 基本用法
node obsidian-to-hugo.js "/path/to/obsidian/note.md"

# 指定博客根目录和作者
node obsidian-to-hugo.js "/path/to/note.md" "/path/to/blog" "作者名"

# 指定 vault 根目录(用于图片查找)
node obsidian-to-hugo.js "/path/to/note.md" "/path/to/blog" "作者名" "/path/to/vault"

批量处理脚本

创建一个 Shell 脚本来批量处理多个文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#!/bin/bash

# 批量发布脚本
BLOG_ROOT="/path/to/your/blog"
VAULT_ROOT="/path/to/your/obsidian/vault"
AUTHOR="你的名字"

for file in "$VAULT_ROOT"/*.md; do
    if [ -f "$file" ]; then
        echo "处理: $file"
        node obsidian-to-hugo.js "$file" "$BLOG_ROOT" "$AUTHOR" "$VAULT_ROOT"
    fi
done

echo "批量处理完成!"

自定义内容处理

你可以扩展脚本来处理特定的内容格式,例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 处理 Obsidian 特定语法
processObsidianSyntax(body) {
  return body
    // 处理双链接 [[链接]]
    .replace(/\[\[([^\]]+)\]\]/g, '[$1]($1)')
    // 处理标签 #标签
    .replace(/#([^\s#]+)/g, '**#$1**')
    // 处理高亮 ==高亮==
    .replace(/==([^=]+)==/g, '`$1`');
}

常见问题

Q: 图片无法找到怎么办?

A: 确保图片位于以下位置之一:

Q: 转换后的标题是 “无标题文章”?

A: 脚本会按以下优先级提取标题:

  1. YAML 前置数据中的 title
  2. 文件名(人性化处理)
  3. 文章内容中的第一个标题

Q: 如何自定义图片尺寸?

A: 在脚本中修改 AMP 图片标签的 width 和 height 属性,或者集成 image-size 库自动获取尺寸。

Q: 支持其他静态网站生成器吗?

A: 当前脚本专为 Hugo 优化,但可以很容易地修改以支持 Jekyll、Gatsby 等其他生成器。

总结

这套 Obsidian QuickAdd 与 Hugo 博客的集成解决方案极大地简化了从笔记到博客的发布流程。通过自动化的格式转换、图片处理和文件管理,让你能够专注于内容创作,而无需为技术细节分心。

关键优势包括:

  1. 无缝集成: 直接在 Obsidian 中完成发布,无需切换工具
  2. 智能处理: 自动处理格式转换、图片同步、文件命名等繁琐任务
  3. 灵活配置: 支持自定义路径、作者信息、图片处理规则等
  4. 可扩展性: 可以根据需要添加更多功能和自定义处理逻辑

希望这个方案能够帮助你构建更高效的写作和发布工作流!如果你有任何问题或改进建议,欢迎交流讨论。