Obsidian 自动同步到 Hexo 仓库

前言

上一篇文章写了如何通过 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 插件的配置。

img

1. 实现文章迁移

首先,我们编写一个脚本,读取本地 Obsidian 笔记仓库的所有 Markdown 文件。使用 front-matter 库来解析每篇笔记的元数据,然后将这些信息转换为适合 Hexo 的格式。脚本会自动生成 Hexo 文章需要的 tagstitlecategories 信息。

通过这种方式,可以将 Obsidian 的笔记批量迁移到 Hexo,省去了手动整理的麻烦。

核心步骤:

  1. 遍历 Obsidian 笔记目录,排除不需要处理的文件和目录。
  2. 使用 front-matter 解析笔记的 YAML 元数据,获取标题、日期等信息。
  3. 自动生成 Hexo 所需的分类(categories)和标签(tags)。
  4. 转换后的 Markdown 文件会被保存到 Hexo 项目的 source/_posts 目录中。

2. 实现自动推送文章

Vercel 会自动监听 Git 仓库的变化,所以当我们完成文章迁移后,只需要将 Hexo 项目推送到远程仓库即可。这里,我们可以通过 Node.js 的 child_process 模块来调用 Git 命令,实现自动提交和推送操作。

核心步骤:

  1. 使用 spawn 调用 git 命令,拉取最新代码、提交修改,并推送到远程仓库。
  2. 配合 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');

/**
* 获取目录中所有文件的路径(递归)
* @param {string} dirPath - 要读取的目录路径
* @returns {Promise<string[]>} - 文件路径列表
*/
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 元数据

  1. 获取文章并解析

首先,我们获取全部文件路径,然后逐一解析 markdown 文件内容。这里采用 front-matter 解析 YAML 格式的元数据。相比 gray-matterfront-matter 更轻量,且直接生成类似于 Hexo 中的元数据格式。

1
2
3
4
5
6
7
8
const files = await getAllFiles(SOURCE_DIR);
for (const filePath of files) {
// 确保是 markdown 文件
if (extname(filePath) !== '.md') continue;
const content = await readFile(filePath, 'utf-8');
const parsedContent = fm(content);

}

通过调用 fm() 函数将元数据解析为一个包含 attributes 和 body 两个属性的对象,其中 attributes 是存储 markdown 元信息的对象,body 是除元信息之外的正文。

  1. 筛选同步文章

为了更好地区分哪些文章需要同步到 hexo,哪些不需要,我们可以提前手动在文章元数据中加入配置,例如添加 blog: true。在处理文章时,只需判断元数据中包含 blog: true 的文章才会同步到 hexo,否则不同步。当然,hexo 自带的 published 属性也可用于定义文章是否发布,可根据个人喜好进行配置。

1
if (!parsedContent.attributes.blog) continue
  1. 处理元数据

对于发布的文章,可以基于文件名生成标题,并通过路径层级生成分类。这里使用 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';

/**
* 处理文件元数据,将 Obsidian Markdown 的元数据转换为 Hexo 格式
* @param {string} content - 文件内容
* @param {string} filePath - 文件路径
* @returns {Promise<string>} - 转换后的文件内容
*/
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;

// 转换为 YAML 格式并拼接文件内容
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, '../../');

// Hexo 博客文章目标目录
const TARGET_DIR = '/Users/pangchaoyue/git-base/ziyang-blog/source/_posts';
// 源 Markdown 文件目录
const SOURCE_DIR = rootPath;

// 排除不需要处理的目录和文件
const EXCLUDED_ITEMS = [
'.git', '.git-hooks', '.obsidian', '.trash',
'.DS_Store', 'Excalidraw', '.gitignore'
];
const excludedPattern = new RegExp(`/(${EXCLUDED_ITEMS.join('|')})`, 'i');

/**
* 获取目录中所有文件的路径(递归)
* @param {string} dirPath - 要读取的目录路径
* @returns {Promise<string[]>} - 文件路径列表
*/
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;
}

/**
* 清空指定目录内容
* @param {string} dirPath - 需要清空的目录路径
*/
async function clearDirectory(dirPath) {
try {
await rm(dirPath, { recursive: true, force: true });
mkdirSync(dirPath, { recursive: true });
} catch (error) {
console.error(`清空目录失败: ${dirPath}`, error);
}
}

/**
* 确保目录存在,如不存在则递归创建
* @param {string} path - 需要检查/创建的路径
*/
async function ensureDirectoryExists(path) {
try {
await access(path);
} catch {
mkdirSync(path, { recursive: true });
}
}

/**
* 格式化文件名,将前缀数字及其后缀符号移除
* @param {string} filename - 文件名
* @returns {string} - 格式化后的文件名
*/
function formatFileName(filename) {
return filename.replace(/^\d+(\.|\-)\s*/, '');
}

/**
* 获取文件路径中包含的分类列表
* @param {string} filePath - 文件路径
* @returns {string[]} - 分类名称数组
*/
function getCategoriesList(filePath) {
return filePath.replace(SOURCE_DIR, '').split('/').filter(Boolean);
}

/**
* 处理文件元数据,将 Obsidian Markdown 的元数据转换为 Hexo 格式
* @param {string} content - 文件内容
* @param {string} filePath - 文件路径
* @returns {Promise<string>} - 转换后的文件内容
*/
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;

// 转换为 YAML 格式并拼接文件内容
const yamlMetadata = yaml.dump(parsedContent.attributes).trim();
return `---\n${yamlMetadata}\n---\n\n${parsedContent.body}`;
}

/**
* 将 Obsidian Markdown 文件转换为 Hexo 格式
*/
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)
}
}

/**
* 执行 git 命令
* @param {Array | String} command git 命令参数
* @returns {child_process.SpawnSyncReturns<string>} 命令执行结果
*/
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);
// 关闭进程,避免后面 git 进程被占用
controller.abort();
return response;
}

/**
* 代码提交、拉取操作异常
* @param {*} std
* @returns
*/
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 图表)等仍需进一步优化。我会在后续文章中分享这些改进的细节!


Obsidian 自动同步到 Hexo 仓库
https://blog.pangcy.cn/2024/10/25/前端编程相关/前端框架与库/hexo/Obsidian 自动同步到 Hexo 仓库/
作者
子洋
发布于
2024年10月25日
许可协议