先说结论我花了三个月的零散时间写了一个叫caveman的笔记系统。不是 Notion 那种全功能平台不是 Obsidian 那种插件生态甚至不算一个标准意义上的“软件”——它就是一整套 Python 脚本能把一个装满 Markdown 文件的普通文件夹变成一份可浏览、可检索、带反向链接的静态站点。之所以叫 caveman是因为我实在受够了笔记工具那股“精致过头”的劲。数据库、同步、订阅、插件商店、块引用、双向链接图谱……我真正需要的只是把像 txt 一样纯粹的笔记文件组织起来看完就能放下。caveman 的思路就是“穴居人”丢掉一切可丢的只留下文件系统和文本本身。这篇文章会讲我为什么做它、怎么做出来的、踩了哪些坑以及我对“极简工具”这个命题的最终看法。如果你是那种厌倦了重型笔记软件、又不想被某个平台绑架的 Markdown 爱好者这篇内容应该能给你一点参考。1. 我为什么给笔记系统起名 caveman1.1 所有笔记工具的共同病根我试过的工具不算少。Notion 用了快两年最让我崩溃的不是功能不够而是功能太多一个简单的笔记页面要先选类型、拖块、调数据库然后被埋在嵌套的层级里。Obsidian 的插件生态确实是亮点但每装一个插件就等于多一份运行时负担我的知识库到后来有几十个插件每次启动都要等上好几秒而且 vault 里除了 .md 文件还散落着各种缓存和配置目录。Bear 的体验是最清爽的代价是生态封闭想要跨平台、想自己导出点什么处处受制。这些工具的共通问题是它们在替你定义一个“笔记应该是什么”而不是让你自己定义。我翻了一下自己的旧笔记从 2019 年到现在跨了至少五个工具最讽刺的是最终真正能无损迁移的只有那些当初导出的纯文本 Markdown 文件。数据库里的内容随着工具停更、订阅过期慢慢变成了一堆打不开的黑箱。我意识到笔记这件事最底层的资产从来不是软件而是那一个个 .md 文件本身。1.2 回归最原始的读写模型caveman 的设计原点特别简单笔记 磁盘上的 Markdown 文件笔记库 一个普通文件夹笔记之间的关系 文件之间的相互链接。不需要数据库不需要后台服务不需要同步盘。文件放在哪都行U 盘、NAS、本地磁盘只要文件系统能读取笔记就存在。这个模型有多原始原始到任何一个会用文件夹的人都能理解。打开文件夹就是打开笔记库用任何编辑器都能改笔记用任何同步工具都能备份。这个“穴居人模型”有一个隐藏优势它不会过期。十年后Notion 可能在也可能不在Obsidian 的插件可能全失效但一个文件夹里的 Markdown 文件只要这世界上还有文本编辑器就永远可以读。我把全部笔记迁移到纯文本之后再没有担心过“工具死了怎么办”这个问题。1.3 明确定义“不做清单”很多项目死在“什么都要做”上。caveman 从一开始就写清楚了不做什么不做编辑器。笔记写作我用 VS Code用户也可以用任何顺手的东西caveman 只负责“看”和“联”。不做实时同步。同步是文件系统、NAS、Git 的事caveman 每次运行是一键静态渲染。不做数据库。所有中间数据在生成时算完用完即弃不缓存、不建索引文件。不做插件系统。想加功能就直接改脚本反正代码就在你手里。这份“不做清单”帮我挡掉了无数需求蔓延。很多人做工具上来就想做大而全结果连一个核心流程都没跑通。caveman 的核心理念就是用最少的机制完成“整理 Markdown 并呈现关系”这一件事。2. 技术选型Python、Markdown 解析和模板引擎的三方博弈2.1 需求边界锁定一次本地渲染技术选型之前我先把需求压缩成一句话读取一个文件夹里的所有 Markdown 文件提取元信息和链接关系生成一份可以在浏览器里看的静态站点。这个需求决定了三件事。第一必须是一次性命令不需要常驻进程第二输出必须是一堆静态 HTML、CSS、JS扔到 Nginx 或者 GitHub Pages 上就能跑第三核心逻辑全在预处理阶段页面展示不需要复杂交互因为篇幅最大的功能是“反链列表”这个在生成时就能算完。需求这么一压缩选型空间其实很窄。我只需要一个能写脚本的语言、一个能安全解析 Markdown 的库、一个能把数据渲染成 HTML 的模板引擎再加一点正则和状态机处理就够用。没有网络请求没有实时通信没有数据库驱动选什么都行关键在于选最不折腾的。2.2 为什么是 Python 而不是 Node 或 Go我承认Node 的生态里有一堆成熟的静态站点工具Go 的性能也很好。但最终选了 Python原因有三个。一是标准库足够。文件遍历、路径处理、正则、Unicode 规范化这些 Python 标准库都做得很好不需要任何第三方工具就能搭起整个骨架。caveman 的底层文件扫描部分就是纯 standard library 实现的这对后续维护非常友好——不存在“两年没动依赖全烂了”的问题。二是团队认知成本低。虽然这个项目只有我在维护但我知道很多自建工具最后都因为“只有作者看得懂”而废弃。Python 的语法近乎伪代码哪怕隔几个月再看也能在一分钟内知道这段代码在干什么。对一个长期使用、低频修改的工具来说这点太重要了。三是Markdown 生态成熟。Python 的markdown库虽然不算完美但足够稳定jinja2做模板也是老牌方案两个库就能覆盖主要需求我不用为了一个边角功能去引一整套框架。2.3 最大的权衡允许哪两个第三方依赖严格说我用的是“Python 2 个第三方库”的组合markdown负责把 Markdown 转 HTMLjinja2负责模板渲染。为什么不做到零依赖因为我尝试过手写 Markdown 解析器听起来很浪漫现实中就是给自己挖坑——表格、任务列表、代码高亮、嵌套引用这些语法看着简单实现起来每个都是无底洞。依赖的选择标准只有一条这个库是否足够专注。markdown只做 Markdown 转换不是平台没有生态绑架jinja2只是一个模板引擎换掉它对整体架构毫无影响。反观某些“全家桶”依赖拉一个进来往往附带几十个子包那才叫真正的技术债。caveman 的两个依赖都像是独立的螺钉随时可以替换这就是我认为“合理依赖”和“依赖泛滥”的分界线。2.4 和成熟方案对比自己造轮子的合法性做之前我当然想过为什么不直接用 MkDocs、11ty 或者 Hugo它们也能读 Markdown生成静态站点而且文档齐全。我画了一张对比表说服了自己对比维度cavemanMkDocsHugoObsidian 的发布服务数据格式纯 MarkdownMarkdownMarkdown 短代码私有格式为主反向链接生成时预计算静态化需要插件仅限博客场景不原生支持运行时索引自定义成本改几十行脚本改插件、改主题学 Go 模板不可自定义部署方式任意静态服务器静态服务器静态服务器付费托管技术依赖Python 2 库Python 插件体系单个二进制闭源差距最明显的其实在“控制力”。MkDocs 做文档站很强但它的信息架构默认是“目录树 页面导航”我想要的是“笔记之间自由链接 自动反链聚合”这已经偏离了 MkDocs 的主航道。Hugo 同理它的强项是内容站点的高性能生成但项目里塞一堆笔记关系和标签聚合逻辑等于和框架的设计哲学对着干。自己写轮子唯一的理由不是“别人做不了”而是“别人做的方向和我想要的不一样”。如果你的需求和主流工具一致比如就是一个标准的博客或文档站那请务必用现成方案别学我。caveman 只适合一种人对数据模型有特殊要求、并且愿意为这个模型持续打磨的人。3. 数据管线的完整实现从文件夹到带反链的站点3.1 目录扫描与笔记对象化caveman 的入口是整个笔记库的根目录。第一步递归扫描所有.md文件把每个文件变成内存里的一个Note对象。这个对象承载四个核心字段文件路径、元数据标题、日期、标签、正文 Markdown、正文中提取出的链接列表。from pathlib import Path from dataclasses import dataclass, field dataclass class Note: path: Path metadata: dict field(default_factorydict) content: str links: list field(default_factorylist) property def title(self): return self.metadata.get(title, self.path.stem) property def slug(self): return slugify(self.title) def load_notes(root: Path) - list[Note]: notes [] for p in sorted(root.rglob(*.md)): if any(part.startswith(.) for part in p.parts): continue note Note(pathp) note.content p.read_text(encodingutf-8) note.metadata, note.content parse_frontmatter(note.content) note.links extract_links(note.content) notes.append(note) return notes这里有个很关键的细节跳过所有以.开头的目录或文件。因为很多编辑器会在笔记目录里生成.obsidian、.vscode这类配置文件夹不跳过的话渲染结果里就会混入一堆莫名其妙的内容。3.2 frontmatter 解析与元数据容错我在每个笔记的开头放了一段 YAML 风格的 frontmatter用来记录标题、创建日期、标签这些元信息。严格说它不完全是 YAML只是“看起来像 YAML”的键值对因为我不想为了解析 YAML 再引入第三个依赖。def parse_frontmatter(text: str): if not text.startswith(---): return {}, text parts text.split(---, 2) if len(parts) 3: return {}, text raw, body parts[1], parts[2] meta {} for line in raw.strip().splitlines(): if : in line: key, value line.split(:, 1) meta[key.strip().lower()] value.strip().strip(\) return meta, body解析逻辑虽然粗糙但做了一件事容错。没有 frontmatter 的文件也能正常渲染标题直接用文件名frontmatter 里缺字段也只影响对应功能不阻塞整体流程。我见过太多工具因为“元数据格式不对”就拒绝对某个文件渲染这种脆弱的处理方式在一个长期维护的个人工具里是致命的——你永远不知道哪份旧笔记的 frontmatter 会缺个冒号。日期字段我还做了一层兜底不是简单取date而是看 frontmatter 里的date、created、updated都没有就拿文件的ctime和mtime互补。排序的时候绝对不允许某个笔记因为没有日期就排到最前面那样整个列表顺序会非常诡异。3.3 用状态机安全提取 [[wiki-link]]反向链接是 caveman 里最有价值的功能也是实现上最容易出错的地方。很多笔记软件支持双链用户会写下[[某个笔记]]这样的语法但如果在代码块里出现这串字符它就不是链接而是示例代码。一个简单的正则直接全文匹配会把代码示例里的[[foo]]也当成链接。我写了一个轻量状态机逐行扫描遇到反引号围栏就切换“代码模式”在代码模式里跳过所有匹配import re def extract_links(text: str): links [] in_code False fence_char for line in text.splitlines(): stripped line.strip() if stripped.startswith() or stripped.startswith(~~~): if not in_code: in_code True fence_char stripped[0] elif stripped.startswith(fence_char * 3): in_code False continue if in_code: continue for m in re.finditer(r\[\[([^\]])\]\], line): target m.group(1).strip() if target and not target.startswith(http): links.append(target) return links代码量不大但解决了三个实际问题围栏代码块内的链接不报错行内代码[[x]]也会被跳过虽然上面这版还没处理我在实际代码里再加了一个inline_code标志以http开头的目标不纳入内部反链避免把外部 URL 记进笔记关系图。状态机这个东西听起来很学术实际就是一句话在读取文本的循环里维护一个“当前在不在代码区域”的状态在代码区域里不触发链接识别。理解了这句话你也能写出同类逻辑。3.4 反向链接图与页面聚合逻辑提取完每篇笔记的链接列表之后接下来是构建全局反向链接关系。所谓反向链接就是“谁引用了当前笔记”。这个计算逻辑非常朴素遍历所有笔记对每一条链接在目标标题的“入链列表”里追加当前笔记。from collections import defaultdict def build_backlinks(notes: list[Note]): title_to_id {} for note in notes: title_to_id[note.title] note.slug backlinks defaultdict(list) for note in notes: for target in note.links: if target in title_to_id: backlinks[title_to_id[target]].append(note.slug) return backlinks这里有一个关键技巧用标题做键而不是用文件名。因为[[笔记标题]]里写的是标题而磁盘上文件名可能是2024-05-01-笔记标题.md中间根本对不上。通过标题到 slug 的映射链接可以忽略文件名噪声直接用标题定位目标笔记。还有一种情况是链接指向不存在的笔记也就是“未创建的念头”。caveman 的处理是把它收集到一个 orphan links 列表里在首页展示成“待补全的笔记主题”这样既不打断了正常渲染也提醒你去补写。这个处理方式比直接报错或者默默忽略都更符合“长期使用”的节奏。3.5 模板渲染和静态资源发布所有笔记解析完、反链构建完接下来的输出阶段就把这些数据交给 Jinja2 模板。我设计了四个模板笔记页模板、标签聚合页模板、首页模板、404 模板。每个笔记页的核心部分除了正文 HTML就是“反向链接”区块——列出所有引用了这篇笔记的其他笔记这是整个站点最有价值的信息层。from markdown import markdown from jinja2 import Environment, FileSystemLoader env Environment( loaderFileSystemLoader(caveman/templates), autoescapeTrue, ) def render_note(note, inbound, env): html_body markdown( note.content, extensions[extra, codehilite, sane_lists], ) template env.get_template(note.html) return template.render( titlenote.title, contenthtml_body, backlinksinbound, tagsnote.metadata.get(tags, ), )这里选autoescapeTrue是安全底线Markdown 转出来的 HTML 本身是可信的但模板里的变量如果不转义将来万一某个标题里写了script就会变成 XSS。静态站点虽然不涉及登录态但这个习惯不能丢。发布只需要一条命令把所有 HTML、CSS、JS 复制到output/文件夹整个站点就完成了。我平时用rsync -av output/ userserver:/var/www/notes/直接推到自己的服务器也可以在本地python -m http.server预览部署路径完全自由。3.6 完整命令行使用方式caveman 的核心命令只有三条# 构建站点把结果输出到 output 目录 python -m caveman build ~/Notes ./output # 本地预览 python -m caveman serve ./output --port 8000 # 清理上次构建的临时产物 python -m caveman clean ./output这三个命令背后其实是同一套数据管线只是暴露给用户的操作面被压缩到最小。我特别认同一条经验真正好用的工具交互命令一定少得可怜。如果一个工具需要记十几条命令才能日常使用你会很快放弃它caveman 的设计目标是用一次就记住我要花六秒钟组织语言解释起来就通用了不管用户在哪个平台写笔记文件系统永远在那里。4. 我踩过的坑以及每条坑背后的原理4.1 中文标题让链接全部 404caveman 刚开始能用的时候我满怀期待地把两百多篇中文笔记全部渲染了一遍。打开站点发现所有中文标题的笔记页面都打不开控制台全是 404。排查了很久发现问题出在 URL 上。我生成页面文件名时直接用标题比如《如何写好一篇技术博客.md》就成了如何写好一篇技术博客.html文件名本身没问题问题在于 Markdown 里[[...]]链接生成的a href如何写好一篇技术博客.html浏览器会自动做 URL 编码而我的服务器没有正确处理非 ASCII 文件的请求路径。解决思路是给每个标题做 slug 化统一生成 ASCII 安全的 URL。我写了个 slugify 函数保留中文但不留空格和特殊符号import re import unicodedata def slugify(title: str) - str: text unicodedata.normalize(NFKC, title) text re.sub(r[\s], -, text) text re.sub(r[^\w\u4e00-\u9fff\-], , text) text re.sub(r-{2,}, -, text) return text.strip(-)经过 slugify 之后中文标题会变成如何写好一篇技术博客这样相对干净且稳定的文件名既保留可读性又去掉了引号、冒号、斜杠这类导致 URL 出错的字符。这个坑让我学到一个通用原则生成任何文件名和 URL 之前先花十分钟设计好规范化规则不然后面几百个文件都要重来。4.2 O(n²) 扫描在三千文件时失控第一版反链构建我写了两层循环对每一篇笔记都去所有其他笔记的全文里查找它的标题出现过几次。功能没问题但当笔记数量到三千多篇时构建时间飙升到两分多钟。这个耗时放在每次改完笔记都要重新跑一遍的场景下非常劝退。O(n²) 的根源在于每次查找都重新扫描目标笔记的全文重复工作太多。我意识到正确做法应该是一次遍历把链接关系收集起来而不是每次都全文搜索。于是改成了现在这种实现解析每篇笔记时顺便提取[[links]]然后遍历一次链接列表更新一个哈希表时间复杂度瞬间降到 O(n) 级别。同样的三千篇笔记构建时间从两分钟降到不到两秒。这个优化思路其实可以推广。凡是“谁引用了谁”“谁包含哪些标签”这类关系查询都应该在数据载入时构建一次索引而不是在展示时反复扫描全文。笔记数量少的时候——几百篇——怎么写都无所谓但一旦上了规模索引先行就是必须养成的习惯。4.3 代码块里的 [[链接]] 被误判有一篇笔记讲 Markdown 语法里面用代码示例展示了双链写法结果 caveman 把示例里的[[示例]]当成真实链接生成了一堆指向并不存在的页面的死链。这个问题的本质在上面提过链接提取器要区分“用户想表达的链接”和“作为示例被展示的链接”。我用状态机解决了这个问题规则很简单维护一个“当前是否在代码块中”的标志凡是处于代码块内的行一律跳过链接提取。状态机的核心是一个布尔变量不要把它想得太玄。这个坑的真正教训是写文本处理工具时永远不要用“全局正则一把梭”的方式做语义分析。正则适合匹配结构化的片段但文本有上下文、有嵌套、有逃逸要靠状态机这类能感知“位置”的工具来处理。后来我把行内代码也纳入了跳过逻辑实现也很快在一行内先用反引号切分只处理偶数段里的内容。用到的工具是正则加简单计数比纯正则可靠得多。4.4 日期缺失导致的排序抖动笔记列表页按日期倒序排列一开始我直接读 frontmatter 里的日期字段结果若干没有日期字段的老笔记全被扔到列表末尾排序结果很不稳定。今天看是这个顺序明天我在某篇旧笔记里补一个日期字段顺序就变了。对一个用于快速回溯的列表页来说这种“排序跳动”很破坏体验。我最终的规则是优先取 frontmatter 里的date没有date就取created或updated都没有就用文件的ctime再不行用mtime。而且整个排序键统一成 ISO 格式字符串保证排序稳定。这个规则看起来是“多套了几层 fallback”但核心思路是不要依赖用户输入的一致性工具的排序必须允许缺省值存在。个人笔记库有大量旧文件永远会有“当年根本没写元数据”的情况用文件系统时间兜底虽然不够精确但至少顺序是确定且不会抖动的。5. 用了三个月后的真实体验与对“极简工具”的重新理解5.1 实际的笔记工作流现在我的日常笔记流程是这样的在 VS Code 里打开~/Notes文件夹新建一个.md文件随意写需要关联其他笔记时就写[[相关标题]]写完后保存。想浏览时在终端执行python -m caveman build ~/Notes ~/NotesSite然后刷新浏览器看结果。这个流程里caveman 既不是负担也不是主角它更像是一个“视图刷新器”——把无序的文件夹变成有结构的网站。最让我舒适的一点是我完全不怕改笔记结构。想把某篇笔记拆成两篇直接复制文件改名。想给一批笔记换标签全局替换 frontmatter。因为所有关系都是“生成时计算”的不会出现“在软件里移动一篇笔记就导致其他引用全部失效”的情况。以前用 Notion 时移动一个页面总是让我心惊胆战现在这种感觉彻底消失了。5.2 什么情况千万别自己造轮子尽管我觉得自己造轮子很爽但我必须说清楚边界如果你的知识和需求能用主流工具覆盖请直接用主流工具。比如你要写一个标准的博客Hugo 比 caveman 好用一百倍你要做一个团队文档站MkDocs 和 Material 主题开箱即用的体验绝不是自己花几个周末写的脚本能比的。适合自己造轮子的情况我认为至少满足三个条件一是需求带有强烈的个人色彩比如 caveman 对“链接以标题为准”的执着二是你愿意长期维护它而不是“造完就扔”三是你享受这个过程写工具本身对你来说就是收获的一部分。如果不满足其中任何一条老老实实用现成方案省下来的时间够你多读几本书。5.3 可以扩展的三个方向caveman 现在勉强够我用但如果将来要继续发展我最想加三个东西。第一个是全文搜索静态站点里想要搜索就得预生成索引或者挂载一个小型可视化脚本这也是纯前端能实现的功能第二个是可视化图谱把反链关系铺成一张力导向图能一眼看出哪些笔记是整个知识网络的核心枢纽这个对内容洞察很有帮助第三个是自动标签推荐根据笔记正文和已有标签的关联性提示你可能漏标的标签可以省去大量手工整理时间。我不打算把这些功能都塞进 caveman 的主流程而是保持“核心极简、扩展靠脚本”的结构。每个新能力都独立成脚本需要时调用不需要时完全不参与渲染这也是“穴居人”哲学的延续。最后再分享一个我在实际使用中养成的习惯每隔一段时间我会跑一遍 caveman 生成的“待补全笔记列表”里面记录那些被[[双链]]标记过但还没有对应文件的主题。这些主题其实是当时的思考碎片我会挑出其中仍然重要的补写完整。没有 caveman 之前这些碎片埋在各种笔记里再也找不回来有了它之后它们变成了一个自动生长的待办清单。工具的价值有时候不在于功能本身而在于它帮你重新看见那些被遗忘的念头。