作为一个重度 Obsidian 用户,我一直在寻找一种更高效的方式来将我的笔记发布到 Hugo 博客上。经过不断的探索和优化,我开发了一套完整的自动化解决方案,让你可以通过一键操作将 Obsidian 笔记转换并发布到 Hugo 博客。
为什么需要这个集成方案?
现有痛点
- 格式转换繁琐:Obsidian 使用 YAML 前置数据,Hugo 需要 TOML 格式
- 图片处理复杂:需要手动移动图片到正确的目录结构
- 发布流程冗长:复制粘贴、格式调整、图片处理等多个步骤
- 文件命名不一致:需要手动生成 SEO 友好的文件名
解决方案优势
- ✅ 一键发布:通过快捷键或命令面板直接发布
- ✅ 自动格式转换:YAML → TOML 前置数据自动转换
- ✅ 智能图片处理:自动查找、移动并转换为 Hugo AMP 格式
- ✅ 文件名优化:基于 Obsidian 文件名生成合适的博客文件名
- ✅ 预览集成:发布后可直接启动本地 Hugo 服务器预览
系统架构
我们的解决方案包含三个核心组件:
核心实现
1. Node.js 转换脚本
首先,我们需要一个核心转换脚本来处理 Obsidian 笔记到 Hugo 文章的转换:
#!/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]] 或 
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 宏脚本,这是用户界面的核心:
// 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. 准备工作
首先确保你已经安装了以下软件:
- Node.js (v14 或更高版本)
- Hugo 静态网站生成器
- Obsidian 和 QuickAdd 插件
2. 脚本配置
-
将转换脚本保存为
obsidian-to-hugo.js
-
确保脚本具有执行权限:
chmod +x obsidian-to-hugo.js
-
在你的
package.json
中添加转换命令:{ "scripts": { "convert": "node scripts/obsidian-to-hugo.js" } }
3. QuickAdd 配置
-
在 Obsidian 中打开设置 → 社区插件 → QuickAdd
-
点击"管理宏" → 创建新宏
-
名称: “发布到 Hugo 博客”
-
脚本: 粘贴上面的宏脚本代码
-
更新脚本中的路径配置:
const BLOG_ROOT = "/path/to/your/hugo/blog"; // 你的博客目录 const SCRIPT_PATH = "/path/to/obsidian-to-hugo.js"; // 脚本路径
-
返回 QuickAdd 设置,添加新选择:
- 类型: 宏
- 名称: “📝 发布到博客”
- 宏: 选择刚创建的宏
-
启用选择并分配快捷键(推荐:
Ctrl+Shift+P
)
使用方法
发布文章
- 打开笔记: 在 Obsidian 中打开要发布的笔记
- 触发宏: 使用以下方式之一:
- 按下分配的快捷键(例如
Ctrl+Shift+P
) - 打开命令面板(
Ctrl+P
)输入 “QuickAdd: 📝 发布到博客” - 点击 QuickAdd 工具栏按钮
- 按下分配的快捷键(例如
- 确认发布: 在弹出的对话框中确认发布
- 等待完成: 脚本会自动处理转换和图片同步
- 可选预览: 选择是否启动 Hugo 开发服务器预览
支持的前置数据字段
脚本支持以下 YAML 前置数据字段,并会自动转换为 TOML 格式:
---
title: "文章标题"
author: "作者名"
date: 2024-01-15
draft: false
tags: ["标签1", "标签2"]
categories: ["分类"]
---
转换后的 TOML 格式:
+++
title = "文章标题"
author = ["作者名"]
date = 2024-01-15
draft = false
+++
图片处理
脚本会自动处理以下格式的图片引用:
- Obsidian 格式:
![[图片.jpg]]
- 标准 Markdown 格式:

图片查找顺序:
- Vault 根目录(推荐 Obsidian 图片存放位置)
- 当前文件相对路径
- 常见附件文件夹(attachments、assets、_resources)
处理后的图片会:
- 自动移动到
static/images/YYYY/MM/DD/
目录结构 - 转换为 Hugo AMP 图片格式
- 第一张图片作为特色图片显示在文章开头
进阶功能
命令行使用
你也可以直接使用命令行来转换文件:
# 基本用法
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 脚本来批量处理多个文件:
#!/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 "批量处理完成!"
自定义内容处理
你可以扩展脚本来处理特定的内容格式,例如:
// 处理 Obsidian 特定语法
processObsidianSyntax(body) {
return body
// 处理双链接 [[链接]]
.replace(/\[\[([^\]]+)\]\]/g, '[$1]($1)')
// 处理标签 #标签
.replace(/#([^\s#]+)/g, '**#$1**')
// 处理高亮 ==高亮==
.replace(/==([^=]+)==/g, '`$1`');
}
常见问题
Q: 图片无法找到怎么办?
A: 确保图片位于以下位置之一:
- Vault 根目录
- 当前文件的相对路径
- attachments、assets 或 _resources 文件夹
Q: 转换后的标题是 “无标题文章”?
A: 脚本会按以下优先级提取标题:
- YAML 前置数据中的 title
- 文件名(人性化处理)
- 文章内容中的第一个标题
Q: 如何自定义图片尺寸?
A: 在脚本中修改 AMP 图片标签的 width 和 height 属性,或者集成 image-size
库自动获取尺寸。
Q: 支持其他静态网站生成器吗?
A: 当前脚本专为 Hugo 优化,但可以很容易地修改以支持 Jekyll、Gatsby 等其他生成器。
总结
这套 Obsidian QuickAdd 与 Hugo 博客的集成解决方案极大地简化了从笔记到博客的发布流程。通过自动化的格式转换、图片处理和文件管理,让你能够专注于内容创作,而无需为技术细节分心。
关键优势包括:
- 无缝集成: 直接在 Obsidian 中完成发布,无需切换工具
- 智能处理: 自动处理格式转换、图片同步、文件命名等繁琐任务
- 灵活配置: 支持自定义路径、作者信息、图片处理规则等
- 可扩展性: 可以根据需要添加更多功能和自定义处理逻辑
希望这个方案能够帮助你构建更高效的写作和发布工作流!如果你有任何问题或改进建议,欢迎交流讨论。