前言
上一篇文章写了如何通过 Hexo + GitHub + Vercel + Cloudflare 实现低成本搭建博客站点,在那之后我开始准备将我以前的笔记和文章迁移到这个新搭建的博客。因为我一直使用 Obsidian 维护知识库,直接用 Hexo 来管理这些笔记并不现实,因此我需要一种可以自动同步 Obsidian 笔记到 Hexo 的方案。
在这篇文章中,我将详细介绍我是如何实现这个同步的,文章将从一个极简的思路出发,逐步深入到具体的实现,包括文章迁移、自动推送和插件配置,最终实现 Obsidian 笔记与 Hexo 博客的自动同步。
极简思路
在介绍我的实现方案之前,先提出一个极简思路:将 Obsidian 笔记库中内置 Hexo 项目和图床,简单实现同步策略。具体做法就是将 Hexo 项目放在 Obsidian 仓库内,所有需要发布的文章直接写在 Hexo 项目的目录下,然后在 Vercel 中指定部署目录。
这个方法的优点是零成本且实现简单,不需要额外的同步工具。只需要在 Obsidian 笔记库中创建 Hexo 项目目录,将要发布的文章放入该目录,Vercel 会自动处理部署。
然而,它的缺点也很明显。首先,Obsidian 和 Hexo 的语法并不完全兼容,比如 Obsidian 的独有块语法,如果不手动处理,在 Hexo 中会被解析为普通引用块。另外,Hexo 不支持 == 高亮标记等特定语法,这些需求也无法通过这种方式解决。
其次,这种方法依赖图床,因为 Hexo 的静态文件是放在 /source/
目录下的,而 Obsidian 的笔记使用相对路径,如果直接引用图片会导致路径找不到。因此,文章中的图片必须通过图床引用,用 http
协议链接。
最后,个人感觉这种方式最大的问题在于,直接将一个前端项目放在 Obsidian 文档仓库里有些不优雅,这让我总觉得不太舒服。
同步思路
基于上述局限性,我决定采用更灵活且可定制的方案,通过 Node.js 编写脚本来实现 Obsidian 笔记与 Hexo 博客的自动同步。具体思路包括三步:文章迁移、自动推送、以及 Obsidian Git 插件的配置。
1. 实现文章迁移
首先,我们编写一个脚本,读取本地 Obsidian 笔记仓库的所有 Markdown 文件。使用 front-matter
库来解析每篇笔记的元数据,然后将这些信息转换为适合 Hexo 的格式。脚本会自动生成 Hexo 文章需要的 tags
、title
和 categories
信息。
通过这种方式,可以将 Obsidian 的笔记批量迁移到 Hexo,省去了手动整理的麻烦。
核心步骤:
- 遍历 Obsidian 笔记目录,排除不需要处理的文件和目录。
- 使用
front-matter
解析笔记的 YAML 元数据,获取标题、日期等信息。
- 自动生成 Hexo 所需的分类(
categories
)和标签(tags
)。
- 转换后的 Markdown 文件会被保存到 Hexo 项目的
source/_posts
目录中。
2. 实现自动推送文章
Vercel 会自动监听 Git 仓库的变化,所以当我们完成文章迁移后,只需要将 Hexo 项目推送到远程仓库即可。这里,我们可以通过 Node.js 的 child_process
模块来调用 Git 命令,实现自动提交和推送操作。
核心步骤:
- 使用
spawn
调用 git
命令,拉取最新代码、提交修改,并推送到远程仓库。
- 配合 Vercel 的自动化部署功能,当 Git 仓库发生变化时,Vercel 会自动重新构建并发布更新。
3. 配置 Obsidian-Git 插件
对于 Obsidian 用户来说,Git 是管理笔记的常用工具。我们可以借助 Obsidian-Git 插件实现笔记的自动同步。这个插件会定时将 Obsidian 笔记同步到 Git 仓库,或在 Obsidian 打开时触发同步。
通过配置 Git 钩子(Git Hook),在 Obsidian 笔记同步的同时触发我们之前编写的同步脚本,就可以实现 Obsidian 笔记与 Hexo 博客的自动同步啦。
源码实现
1. 实现文章迁移
1.1 遍历笔记目录
为了筛选和转换所需的 Obsidian 笔记文件,我们首先要遍历根目录下的所有文件,排除不需要的项目。这可以通过使用 readdir
读取目录项,并利用条件过滤掉 .git
、.obsidian
等不需要处理的目录和文件。
我们声明一个 EXCLUDED_ITEMS
数组,列出所有需要忽略的文件夹和文件,然后生成一个正则表达式 excludedPattern
用于匹配。在遍历过程中,excludedPattern.test(entryPath)
会对每个文件或目录进行过滤,确保只保留目标文件。
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
| const EXCLUDED_ITEMS = [ '.git', '.git-hooks', '.obsidian', '.trash', '.DS_Store', 'Excalidraw', '.gitignore' ]; const excludedPattern = new RegExp(`/(${EXCLUDED_ITEMS.join('|')})`, 'i');
async function getAllFiles(dirPath) { const filesList = []; const entries = await readdir(dirPath, { withFileTypes: true });
for (const entry of entries) { const entryPath = join(entry.path, entry.name); if(excludedPattern.test(filePath)) return; if (entry.isDirectory()) { filesList.push(...await getAllFiles(entryPath)); } else { filesList.push(entryPath); } }
return filesList; }
|
通过上述代码,我们可以得到一个完整的文件路径列表,为后续的元数据解析奠定基础。
1.2 解析 yaml 元数据
- 获取文章并解析
首先,我们获取全部文件路径,然后逐一解析 markdown 文件内容。这里采用 front-matter
解析 YAML 格式的元数据。相比 gray-matter
,front-matter
更轻量,且直接生成类似于 Hexo 中的元数据格式。
1 2 3 4 5 6 7 8
| const files = await getAllFiles(SOURCE_DIR); for (const filePath of files) { if (extname(filePath) !== '.md') continue; const content = await readFile(filePath, 'utf-8'); const parsedContent = fm(content);
}
|
通过调用 fm()
函数将元数据解析为一个包含 attributes
和 body
两个属性的对象,其中 attributes
是存储 markdown 元信息的对象,body
是除元信息之外的正文。
- 筛选同步文章
为了更好地区分哪些文章需要同步到 hexo,哪些不需要,我们可以提前手动在文章元数据中加入配置,例如添加 blog: true
。在处理文章时,只需判断元数据中包含 blog: true
的文章才会同步到 hexo,否则不同步。当然,hexo 自带的 published
属性也可用于定义文章是否发布,可根据个人喜好进行配置。
1
| if (!parsedContent.attributes.blog) continue
|
- 处理元数据
对于发布的文章,可以基于文件名生成标题,并通过路径层级生成分类。这里使用 yaml.dump
将 attributes
对象转换为 YAML 格式的字符串,并拼接正文形成 Hexo 所需的 markdown 文件内容格式。
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
| import fm from 'front-matter'; import yaml from 'js-yaml';
async function processFileMetadata(parsedContent, filePath) { const fileStats = await stat(filePath);
const filename = formatFileName(basename(filePath)); parsedContent.attributes.title = filename.replace(extname(filePath), '');
parsedContent.attributes.date = parsedContent.attributes['日期'] || parsedContent.attributes['date'] || fileStats.birthtime;
const relativePath = filePath.replace(SOURCE_DIR, '').replace(basename(filePath), '').split('/').map(formatFileName).join('/'); const categories = getCategoriesList(relativePath); if (categories.length) parsedContent.attributes.categories = categories;
const yamlMetadata = yaml.dump(parsedContent.attributes).trim(); return `---\n${yamlMetadata}\n---\n\n${parsedContent.body}`; }
|
这里没有提供生成 tags 的方法。原因在于后来发现原本所写的生成方法虽有效,但由于未过滤掉代码块中的 CSS 样式,例如 #2f2f2f
(这是一个颜色值)也会被录入,导致生成结果存在问题。因此,后来干脆手动将 tags 写入每个文件的元数据中。后续我们会讲解通过 ast 实现扫描的方法。
1 2 3 4
| const tags = parsedContent.body.match(/\s#[\w\d\u4e00-\u9fa5]+/g) if (tags) { parsedContent.attributes.tags = tags }
|
1.3 将文章复制到 hexo 项目
当文件内容转换完毕后,我们按照原始目录结构将其复制到 Hexo 项目的 /source/_posts
目录下。这里使用 writeFile
写入文件前会通过 ensureDirectoryExists
确保目标目录已存在。该函数也可用于自动生成多级目录,确保文件复制时不会发生路径缺失错误。
1 2 3 4 5 6 7 8 9 10 11
| const finalContent = await processFileMetadata(parsedContent, filePath);
const relativePath = filePath.replace(SOURCE_DIR, '').split('/').join('/') const targetFilePath = join(TARGET_DIR, relativePath);
await ensureDirectoryExists(targetFilePath.replace(basename(targetFilePath), ''));
await writeFile(targetFilePath, finalContent, { encoding: 'utf8' });
|
这一步完成后,所有文件的目录结构和元数据格式都已适配 Hexo 项目,后续即可直接渲染成静态博客页面。
2. 实现自动推送
同步后的文章需要自动推送至远程仓库。我们借助 child_process
的 spawnSync
方法调用 git
命令,将同步到的内容推送到仓库,保证 Hexo 项目内容与远程仓库同步。
1 2 3
| child_process.spawnSync('git', ['add', '.'], { cwd: TARGET_DIR }); child_process.spawnSync('git', ['commit', '-m', '同步 Obsidian 笔记'], { cwd: TARGET_DIR }); child_process.spawnSync('git', ['push'], { cwd: TARGET_DIR });
|
这样,通过在脚本末尾执行 executeGitCommand
,就可以自动完成文件同步和推送啦。
3. 配置 Obsidian-Git 插件
虽然我们完成了基本的脚本编写,但还没有完全实现自动化推送。这时我们就需要依赖 Obsidian-Git 插件的自动推送能力。
我们在 Obsidian 仓库的根目录建一个 .git-hooks
目录,然后将当前的脚本命名为 commit-msg
存放到 .git-hooks
目录下,然后执行挂载 git hook 命令:
1
| git config core.hooksPath .git-hooks
|
这样当 Obsidian-Git 插件触发自动同步时,会被 Git Hook 拦截,然后执行我们的脚本,这样就真正的实现了自动同步。
但是此时会发现,当 Obsidian-Git 触发自动同步时,脚本执行会报错:env:node:No such file or directory
。`

我们需要在 Git 插件中配置一下 Node 的环境变量,我们先通过 which node
找一下当前 node 的位置

然后打开 Obsidian 的配置面板插到 Git 插件面板,然后在高级中找到 Additional PATH environment variable paths
,在后面的输入框中输入刚才获得的地址,在点击 reload
刷新环境变量,在去触发 Git 插件的推送后就正常了。

4. 完整源码
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
| import child_process from 'node:child_process'; import { mkdirSync, readFileSync } from 'node:fs'; import { access, readdir, readFile, rm, stat, writeFile, } from 'node:fs/promises'; import { basename, extname, join, resolve } from 'node:path';
import fm from 'front-matter'; import yaml from 'js-yaml';
const [, currentPath, msgFilePath] = process.argv; const rootPath = resolve(currentPath, '../../');
const TARGET_DIR = '/Users/pangchaoyue/git-base/ziyang-blog/source/_posts';
const SOURCE_DIR = rootPath;
const EXCLUDED_ITEMS = [ '.git', '.git-hooks', '.obsidian', '.trash', '.DS_Store', 'Excalidraw', '.gitignore' ]; const excludedPattern = new RegExp(`/(${EXCLUDED_ITEMS.join('|')})`, 'i');
async function getAllFiles(dirPath) { const filesList = []; const entries = await readdir(dirPath, { withFileTypes: true });
for (const entry of entries) { const entryPath = join(entry.path, entry.name); if(excludedPattern.test(filePath)) return; if (entry.isDirectory()) { filesList.push(...await getAllFiles(entryPath)); } else { filesList.push(entryPath); } }
return filesList; }
async function clearDirectory(dirPath) { try { await rm(dirPath, { recursive: true, force: true }); mkdirSync(dirPath, { recursive: true }); } catch (error) { console.error(`清空目录失败: ${dirPath}`, error); } }
async function ensureDirectoryExists(path) { try { await access(path); } catch { mkdirSync(path, { recursive: true }); } }
function formatFileName(filename) { return filename.replace(/^\d+(\.|\-)\s*/, ''); }
function getCategoriesList(filePath) { return filePath.replace(SOURCE_DIR, '').split('/').filter(Boolean); }
async function processFileMetadata(parsedContent, filePath) { const fileStats = await stat(filePath);
const filename = formatFileName(basename(filePath)); parsedContent.attributes.title = filename.replace(extname(filePath), '');
parsedContent.attributes.date = parsedContent.attributes['日期'] || parsedContent.attributes['date'] || fileStats.birthtime;
const relativePath = filePath.replace(SOURCE_DIR, '').replace(basename(filePath), '').split('/').map(formatFileName).join('/'); const categories = getCategoriesList(relativePath); if (categories.length) parsedContent.attributes.categories = categories;
const yamlMetadata = yaml.dump(parsedContent.attributes).trim(); return `---\n${yamlMetadata}\n---\n\n${parsedContent.body}`; }
async function main() { const files = await getAllFiles(SOURCE_DIR); await clearDirectory(TARGET_DIR);
for (const filePath of files) { if (extname(filePath) !== '.md') continue;
try { const content = await readFile(filePath, 'utf-8'); const parsedContent = fm(content); if (!parsedContent.attributes.blog) continue const finalContent = await processFileMetadata(parsedContent, filePath);
const relativePath = filePath.replace(SOURCE_DIR, '').split('/').map(formatFileName).join('/') const targetFilePath = join(TARGET_DIR, relativePath);
await ensureDirectoryExists(targetFilePath.replace(basename(targetFilePath), ''));
await writeFile(targetFilePath, finalContent, { encoding: 'utf8' }); } catch (error) { console.error(`处理文件失败: ${filePath}`, error); } } }
function assert(condition, error) { if (condition) { console.error(error) process.exit(1) } }
function runGitCommand(command) { assert( typeof command !== 'string' && !Array.isArray(command), `git 命令必须为字符串或数组,当前类型为:${typeof command}` ); const commandArr = typeof command === 'string' ? command.split(' ') : command; const controller = new AbortController(); const { signal } = controller; const shellConfig = { cwd: resolve(TARGET_DIR, '../../'), encoding: 'utf8', signal } const response = child_process.spawnSync('git', commandArr, shellConfig); controller.abort(); return response; }
function throwGitPullOrPushError(std) { assert(std.status !== 0, std.stderr || std.stdout, std.stderr) }
console.log('开始转换 hexo 博客文章') main().then(() => { console.log('hexo 博客文章转换完成,准备推送文章') const pullStd = runGitCommand(['pull']); throwGitPullOrPushError(pullStd) runGitCommand('add .'); console.log(`正在将代码添加至缓存区.`) const msg = readFileSync(msgFilePath, { encoding: 'utf8' }) runGitCommand(['commit', '-m', msg]); console.log(`正在推送代码.`) const pushStd = runGitCommand(['push']); throwGitPullOrPushError(pushStd) console.log(`博客代码推送成功!`); }).catch((error) => { console.error('发生错误:', error); process.exit(1) });
|
结语
本文我们完成了 Obsidian 笔记向 Hexo 博客的自动同步,虽然初步解决了文章的迁移与同步问题,但仍有许多待完善的地方。比如,图片迁移问题、语法兼容性问题(如 Excalidraw 图表)等仍需进一步优化。我会在后续文章中分享这些改进的细节!