晓风博客

一片荒芜的地方

Obsidian 自动发布到 Hugo 博客

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

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

现有痛点

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

解决方案优势

系统架构

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

Loading Mermaid chart...

核心实现

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]] 或 ![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 宏脚本,这是用户界面的核心:

// 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. 确保脚本具有执行权限:

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

    {
      "scripts": {
        "convert": "node scripts/obsidian-to-hugo.js"
      }
    }
    

3. QuickAdd 配置

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

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

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

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

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

    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 格式:

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

转换后的 TOML 格式:

+++
title = "文章标题"
author = ["作者名"]
date = 2024-01-15
draft = false
+++

图片处理

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

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

图片查找顺序:

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

处理后的图片会:

进阶功能

命令行使用

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

# 基本用法
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: 确保图片位于以下位置之一:

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. 可扩展性: 可以根据需要添加更多功能和自定义处理逻辑

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