使用 Obsidian 管理我的博客

我大概在今年年初的时候,把所有以前分散在各个平台上的文章、知识库个人帖子,以及老的通过 Emacs Org Mode 管理的内容,全部迁移到了 Obsidian 下,一直有在想,有没有比较好的办法把博客上的文章也放到一起做汇总,这样也有写新文章的动力。后来经过了一段时间的尝试,算是做了一个最简单的无附件(图片之类的)的文章的管理方案,目前这个博客上的所有文章都是从我的 Obsidian 库中直接提取出来发布的。本文章抛砖引玉,介绍一个使用 Obsidian 进行博文编写、管理的解决方案。

额外说明

目前没有实现图片、附件的解析、拆解和同步功能,但是理论上这块的内容是可以通过 tokenizer 来实现的,可以在后面考虑进来,这个文章只是做了抛砖引玉,倒是可以提一提解决方案,但是不会考虑说明具体的实现。

主要流程

目前我的文章是存放在一个 Obsidian 库中,我们这里假设它是 obsidian-vault 目录,我们需要编写一个程序(或者脚本),遍历所有库中的文章,根据 Markdown 的元数据来过滤出需要发布的文章,然后收集到 Hexo 的仓库中,这里我们认为收集到的目的地是 hexo-blog/source/_posts/obsidian

元数据的定义如下:

1
2
3
id: 7b89a1b1-b4f6-442a-8002-32e046f525e0
date: 2023-11-05 13:56
categories: development

这里各个字段的含义如下:

键值 类型 含义
id 字符串 文章的 ID
date 日期 文章创建的时间,在博客中只会显示它的日期
categories 类别 文章的类别

而在 Obsidian 中,我们可能有自己额外的元数据,并且有发布的标记,我们需要对这些元数据进行过滤、映射,来满足我们发布的要求。为了方便处理,我在 Obsidian 的文件头,添加了一个叫 published 的选项,当它是 true 的时候,它将被脚本处理,过滤完发布到对应的仓库中。

总结一下,整个流水线的流程是:通过 Obsidian 编写文章,打上 published 标记,然后通过脚本进行处理,映射、过滤以及清洗元数据,并处理文章的标签,最后把处理好的文件存放在 hexo-blog 对应的目标文件夹中。

处理脚本的实现

我选择 rust 作为脚本的实现语言,这个其实无所谓,甚至其它的语言有更好的 Markdown 文件元数据处理框架,我没有去折腾了,这里怎么简单怎么来。

提示

如果说要考虑未来图片及相关附件的复制,以及解析,选择一个成熟的 Markdown 解析器是非常重要的。

Markdown 文件结构拆分

首先我们要做的事是把 Markdown 文件的元数据和内容拆分区分出来,在处理 Markdown 文件过程中,我们使用一个 Phase 枚举,来表示我们处理到了哪个阶段:

1
2
3
4
5
6
#[derive(PartialEq)]
enum Phase {
START,
METADATA,
CONTENT,
}

为了简化问题,我们认为 Markdown 文件一定是由元数据、正文的形式组成,并且元数据一定是由 --- 分隔。如果文件的第一行不是 ---,我们认为它没有元数据。假设我们已经读取到了文件的原始内容,我们记它为 raw,以下的代码片断对整个文件进行了解析:

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
let mut phase = Phase::START;

let mut metadata = String::new();
let mut content = String::new();

for line in raw.lines() {
if phase == Phase::CONTENT {
content += &format!("{line}\n");
continue;
} else if line.trim() == "---" {
match phase {
Phase::START => phase = Phase::METADATA,
Phase::METADATA => {
phase = Phase::CONTENT;
migrate_metadata(metadata)
}
_ => {}
}
continue;
} else if phase == Phase::START {
return None;
}

metadata += &format!("{line}\n");
}

在上面的代码片段中的第 21 行,如果文章一开始不是 ---,说明它没有元数据,而没有元数据的文章我们认为是不会用来发布的(没有 published 元数据),故这里我们直接返回,直接跳过这个文件即可。

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
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
let metadata_mappings = HashMap::from([
("id", "id"),
("date_created", "date"),
("categories", "categories"),
]);

let mut metadata = String::new();
let mut current: Option<String> = None;
let metadata_key_prefix = Regex::new(r"^(\w+):(.*)").unwrap();
let tags_line = Regex::new(r"^[]*-[ ]*(.*)").unwrap();
let mut published = false;
let mut id: Option<String> = None;
let mut last_key = "";
for line in self.metadata.lines() {
if let Some(caps) = metadata_key_prefix.captures(line) {
if let Some(current) = &current {
metadata += current;
}

let (key, value) = (caps.get(1).unwrap().as_str(), caps.get(2).unwrap().as_str());
last_key = key;

if key == "published" {
if value.trim() == "true" {
published = true
} else {
return Err(exit_codes::OK);
}
} else if key == "id" {
id = Some(value.trim().to_string());
}

if let Some(key) = metadata_mappings.get(&key) {
current = Some(format!("{key}:{value}\n"))
} else {
current = None;
}
} else if let Some(value) = current {
current = Some(value + &format!("{line}\n"));
} else if last_key == "tags" {
if let Some(caps) = tags_line.captures(line) {
let tag = caps.get(1).unwrap().as_str();
self.tags.push(tag.to_string())
}
}
}
self.metadata = format!("title: \"{title}\"\n{metadata}");

if published && id != "" {
match id {
Some(id) => Ok(id),
None => Err(ID_ABSENT_AND_SKIP),
}
} else {
Err(NOT_PUBLISHED_AND_SKIP)
}

该代码片段中,我们处理了三个特殊的元数据,一个是 id,表示文章的 ID,如果它的值不存在,我们认为它不应该被发布;另外一个元数据是 tags,由于 Obsidian 我使用了 Obsidian Prettifier 插件,它会直接把 tags 强制转换成数组类型,所以这里我直接认为 tags 一定是空定义一行,然后后面若干行的 - 打头的数组的形式;最后,需要单独找出来 published 的值,如果是 false 或者不存在,表示不发布,退出即可。

注意

这里的处理逻辑,不同的人习惯不一样,而且和 Obsidian 的文章的元数据关联性极大,需要按自己的习惯进行处理。比如说,如果你的 tags 可以是单个(一行)或者多个的情况,那得自己特殊处理。另外由于 rust 没有比较好的 Makrdown 文件的库,为了简化需求,这里我直接用正则强行搞了,没有考虑扩展性。

内容标签处理

由于个人的习惯,我会在文章的开关标记这个文章的标签,而不一定会放在元数据中,故我做了一下内容这边的处理,比如说我的文章长这个样子:

1
2
3
4
5
6
7
8
---
metadata: key
---


#blog #obsidian

内容正文

这里,我直接把文章开头的标签抠出来,放到元数据的标签中进行合并(注意,老的文章有可能已经有一些 tags 了,所以这里是做合并),然后把它们从正文中去掉,当遇到不是空行也不是标签定义行的时候,就不做任何处理。

此外,我们需要把 Obsidian Callouts[1] 转义成 Bootstrap Callouts,这里也只是简单做了处理。具体的对比如下:

1
2
3
> [!tag-kind|indent] 提示
>
> 内容
1
2
3
4
5
{% note tag-kind %}
<p style="text-decoration: underline;"> 提示 </p>

内容
{% endnote %}

大概的实现片断如下:

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
let mut content = String::new();
let mut beginning = true;
let mut in_note_block = false;

let tags_line = Regex::new("^[]*#([^ ]+)(.*)").unwrap();
let note = Regex::new(
r"^>[]*\[(!(default|primary|success|info|warning|danger))(\|\w+)?][ ]*(\w+)",
)
.unwrap();
let quote = Regex::new(r"^>[ ]*(.*)").unwrap();
for line in self.content.lines() {
if beginning && line.trim().starts_with("#") {
let mut line = line;
let mut ok = true;
while ok {
ok = false;
if let Some(caps) = tags_line.captures(line) {
if let Some(tag) = caps.get(1) {
self.tags.push(tag.as_str().to_string());
line = caps.get(2).unwrap().as_str();
ok = true
}
}
}

continue;
} else if line.trim() != "" {
beginning = false;
}

if !in_note_block {
if let Some(caps) = note.captures(line) {
let (tag, title) =
(caps.get(2).unwrap().as_str(), caps.get(4).unwrap().as_str());
in_note_block = true;
content += &format!("{{% note {tag} %}}\n<p style=\"text-decoration: underline;\">{title}</p>\n")
} else {
content += &format!("{line}\n");
}
continue;
}

if let Some(caps) = quote.captures(line) {
let line = caps.get(1).unwrap().as_str();
content += &format!("{line}\n");
} else {
in_note_block = false;
content += &format!("{{% endnote %}}\n");
content += &format!("{line}\n");
}
}

if in_note_block {
content += &format!("{{% endnote %}}\n");
}
self.content = content;

自动化

如果有必要,可以考虑用 CI/CD 来进行部署,不过由于我的博客是直接用 hexo-deployer-git 的,故我的部署和发布脚本是放在一个叫 deploy.zsh 的脚本中,这个脚本第一步做同步的工作,第二步进行 hexo deploy


  1. https://help.obsidian.md/Editing+and+formatting/Callouts ↩︎