
晚上十一点的地铁上我掏出手机用九键输入法在备忘录里记下了一段关于今天采访的观察。写完之后按下同步第二天早上这段文字自动变成了带标题、带标签、带待办清单的结构化记录还能被我的静态博客生成器直接消费。这套工作流我用了整整四年核心就两样东西一套我自己定义的CSD码和一条用正则表达式搭起来的解析流水线。CSD码全称叫 Custom Structured Document是我在四年前为了解决“手机端快速记录 → 桌面端结构化沉淀”这个矛盾而设计的一套轻量标记语法。它解决的核心问题是当你只能用手机键盘和备忘录软件记录时如何用最少的击键成本让一篇看似平平无奇的纯文本最终能被程序精确解析成干净的结构化数据。这篇内容就是把这四年里踩过的坑、改过的语法、写废的正则一一拆给你看里面所有解析代码都能直接复制去跑。如果你也在折腾个人知识库、博客数据迁移或者单纯想学正则到底怎么用在真实项目里这篇应该对你有用。1. CSD码的由来手机输入时代的一次“反效率”尝试1.1 为什么我不直接用现成的 Markdown很多朋友看到标题第一反应是手机上记笔记用 Markdown 不就行了标题用##列表用-加粗用**现成语法随手就能用何必自己造轮子四年前我也是这么想的。直到我在通勤路上用手机备忘录写稿发现几个非常现实的问题手机上打##这类标记需要从字母键盘切到符号键盘再切回来一次切换要一到两秒。如果一条笔记里有十个标题光切换键盘的时间就够喝一口水了。**加粗**这种成对闭合符号在手机上极容易漏打后面的星号。漏一个整篇渲染就乱掉还得回头找。富文本 App比如系统备忘录里直接调格式虽然方便但导出的数据格式乱得一团糟往博客、知识库迁移时特别痛苦。格式是人眼看的程序没法稳定消费。现成 Markdown 编辑器在手机端的体验也一般尤其是光标定位不好控制想在一个长段落中间补一个标记手指要点半天。当时我意识到问题的根源在于现成的标记语法是为“桌面全键盘 完整编辑器”设计的它从来不为“九键输入 纯文本备忘录”优化。我要的是一个只有自己用、但用起来极其顺手的东西——这才是 CSD 码最早的出发点。1.2 设计原则与手机键盘的输入路径CSD 码从第一天起就定了三条硬性原则单字符前缀优先。所有行级标记都用#加一两个字母开头不用闭合符号。标题标记就是#h2 这是标题待办标记就是#w 明天交房租。没有#h2 ... #/h2这种成对结构。行内标记数量压到最少。加粗、行内代码、链接锚点总共就这三类需要范围控制的行内标记而且全部用同一个结束符#x。只用手机上输入路径最短的符号。我专门打开手机备忘录数过按键路径九键键盘上#、*、-、数字都在符号面板第一屏拇指点一下就能到而反引号、方括号、尖括号要翻到第二三屏根本不考虑用。这也是为什么整套语法里看不到和[]的原因。你可能会问为什么不选*做前缀实测下来*在输入法里经常被自动纠错吃掉而且中文输入环境下*的语义容易和乘号混淆。#在输入法面板里位置稳定中文环境下默认也不会被自动替换所以最终全体系都用#开头。1.3 四年时间线从 v1 到 v3 的演进CSD 码不是一天设计出来的而是跟着使用习惯慢慢长出来的大致分三个阶段v1第一年只有标题、待办、分段。那时候需求就是写稿列提纲够用。v2第二年加入引用#q、元数据#m: 键值、标签#tag: 名称。因为这时候我开始把 CSD 解析出来的内容接到静态博客生成器上文章需要归档信息、所属专题必须在文本内部就能表达。v3第三年引入版本头#v3。这是一个重要的分水岭——语法升级后老文档和新语法发生冲突导致历史数据解析出问题具体过程我放在第三章专门讲。版本头之后旧文档不会因为新语法上线而被误解析这一步对整个体系的稳定性帮助巨大。2. CSD码语法详解一套为拇指设计的轻量规则2.1 行级标记标题、待办、引用、元数据、标签CSD 码把所有“控制一行结构”的标记统称为行级标记。这里的核心逻辑是一行开头的前几个字符决定了这一行在整个文档里的角色。程序拿到一段文本后逐行读过去一眼望到行首就能分类。我自己用的规则表如下标记含义示例#h1~#h6标题层级#h2 为什么用纯文本记笔记#w待办事项#w 给解析器写单元测试#q引用段落#q 数据自由的前提是格式可控#m:键值对元数据#m: date2024-03-14#tag:标签#tag: 正则#v版本头#v3-/*无序列表项- 手机备忘录输入某个标记后面跟什么内容每个文档统一用“空格分隔”的原则。比如#h2 标题中间必须有一个空格这样解析正则写起来很干净匹配到^#h([1-6])\s之后其余部分就是标题文本。行级标记之所以不设计闭合符是因为手机输入最大的痛点就是“光标移动和重复符号”。一条笔记里写五个标题如果每个标题都要手打一个结束标记那相当于多打十个符号。而把标题的范围定义为“从标记开始到本行结束”程序自然知道边界在哪完全不需要人工闭合。2.2 行内标记加粗、行内代码、链接锚点行内标记解决的是“一行文字中间某段需要特殊样式”的问题。这类标记必须有始有终但我在设计上做了两个妥协来降低手打成本用同一个结束符#x不管开始符是什么结束一律#x。#x里的x我解释为 exit退出行内状态。只记一个结束符比记三种结束符省脑力。开始符也尽量短加粗用#bbold行内代码用#ccode链接锚点用#l:link。一个典型的加粗写法是这周重点是#b 数据清洗#x这一步。解析出来之后数据清洗会被包成加粗节点前后文字是普通文本。链接锚点的写法稍微特殊一点我设计成#l: 显示文本|链接地址#x中间用竖线分隔。竖线在九键输入法的符号面板上虽然不是第一屏但排位也算靠前关键是它不会出现在普通中文文本里不会弄出歧义。2.3 一段真实的 CSD 原文与解析结果对照空谈规则没有感觉贴一段我去年写笔记时的原始文本做了脱敏#v3 #m: date2023-11-06 #m: place城市图书馆 #h2 关于笔记系统的再思考 今天在图书馆呆了一下午整理旧笔记的时候发现一个问题 #q 如果笔记软件的导出格式不开放你写的每一个字都会被平台绑架。 所以还是得回到纯文本。 #tag: 笔记系统 #tag: 数据自由 #w 把老的印象笔记导出文件转成Markdown #w 给CSD解析器增加一个 --dry-run 参数这段文本经过解析之后得到的核心结构大概是这样的{ type: doc, version: 3, meta: {date: 2023-11-06, place: 城市图书馆}, children: [ {type: headline, level: 2, title: 关于笔记系统的再思考}, {type: paragraph, text: 今天在图书馆呆了一下午整理旧笔记的时候发现一个问题}, {type: quote, text: 如果笔记软件的导出格式不开放你写的每一个字都会被平台绑架。}, {type: paragraph, text: 所以还是得回到纯文本。}, {type: tags, tags: [笔记系统, 数据自由]}, {type: todos, items: [ {content: 把老的印象笔记导出文件转成Markdown}, {content: 给CSD解析器增加一个 --dry-run 参数} ]} ] }注意源文本里的#tag和#w都是分散在各处的解析器要做的是把同类节点聚合起来。这一步不是正则单次匹配就能完成的需要走完整的三层流水线这正好是下一章的内容。3. 正则解析核心从散乱文本到结构化数据的关键一役3.1 解析的整体思路三层流水线CSD 解析器我前后重写过两次最后稳定下来的结构是三段式流水线断行分类 → 行内抽取 → 结构组装。你可能觉得一个自己用的解析器不需要这么复杂但四年用下来我发现如果没有清晰的分层一旦语法变化整个解析代码就得推倒重来。三层拆开之后改行级规则只动第一层改行内符号只动第二层底层结构组装完全不用管。打个比方这就像快递分拣中心。第一层根据运单上的城市码把包裹扔到不同传送带行级分类第二层对每个包裹再扫描有没有易碎品标识行内抽取第三层才把一个个快递装进运输车对应的格口结构组装。每一层干一件事清晰且好排查。3.2 行级判定的正则实现第一层的核心就是一堆“行首锚定”的正则。锚定用的是^它确保我只匹配行首不会把文本中间出现的#w也当成标记。下面这段 Python 代码是完整可运行的行级分类器import re LINE_PATTERNS [ # 标题^#h 开头后面跟1-6数字再接空格和标题内容 (re.compile(r^#h([1-6])\s(?Ptitle.)$, re.IGNORECASE), headline), # 待办^#w 开头 (re.compile(r^#w\s(?Ptodo.)$), todo), # 引用^#q 开头 (re.compile(r^#q\s(?Pquote.)$), quote), # 元数据^#m: 开头键值对 (re.compile(r^#m:\s*(?Pkey[\w-])(?Pvalue.)$), meta), # 标签^#tag: 开头 (re.compile(r^#tag:\s*(?Ptag[\w-])$), tag), # 版本头^#v3 这种 (re.compile(r^#v(?Pversion\d)$), version), # 无序列表- 或 * 开头 (re.compile(r^(?:[-*])\s(?Pitem.)$), bullet), ] def classify_line(line: str): line line.strip() for pattern, kind in LINE_PATTERNS: m pattern.match(line) if m: d m.groupdict() if kind headline: # 把1-6的等级数字单独取出来存好 d[level] int(m.group(1)) return kind, d # 都不是就是普通段落 return plain, {text: line}这里有一个实战经验行级规则的正则应该“从头到尾一次匹配完整”而不是全用search找片段。用match^锚定能天然规避掉大量误报这个细节省了我后面很多麻烦。3.3 行内标记抽取循环找出最早出现的标记行内抽取比行级分类复杂一些因为一行里面可能同时出现多个加粗、加粗和代码混排。我采用的策略是从头扫描文本每次找出位置最靠前的那个标记提取中间内容然后继续往后扫。这里用到的正则稍微讲究一点以加粗为例INLINE_PATTERNS [ # 加粗#b 开头#x 结束中间内容是非空的可见字符 (re.compile(r#b(?\S)(.?)(?\S)#x), bold), # 行内代码#c 开头#x 结束 (re.compile(r#c(?\S)(.?)(?\S)#x), code), ] def parse_inline(text: str): tokens [] pos 0 while pos len(text): best None for kind, pattern in INLINE_PATTERNS: m pattern.search(text, pos) if m is not None: if best is None or m.start() best[1].start(): best (kind, m) if best is None: tokens.append((text, text[pos:])) break kind, m best if m.start() pos: tokens.append((text, text[pos:m.start()])) tokens.append((kind, m.group(1))) pos m.end() return tokens解释几个关键点都是我踩过坑之后才理解为什么必须这么写的必须用.search不能用.match。行内标记可能出现在一行文字的任意位置match只匹配开头就废了。必须用非贪婪量词.?而不是.。如果用贪婪量词一行里如果有两个#b前一个会把中间所有内容一直吃到最后一个#x直接吞掉一片正常文字。这个坑几乎每个学正则的人都会踩一次。前后两端的(?\S)和(?\S)是边界断言意思是“标记符号后面必须紧跟一个非空字符结束符前面必须是非空字符”。这能防止出现#b #x这种空内容标记也能避免标记符号之间被连在一起判定。3.4 结构树组装把散节点串成文档树前两层拿到的还是“一节一节的平板信息”到了第三层才需要真正组装成树。这里我用一个栈来维护当前所在层级核心规则是遇到#h1清空栈回到根节点遇到#h2让当前节点降级到二级标题其余节点直接挂在当前标题下面。def build_tree(parsed_lines): # 根节点 root {type: doc, children: []} # 栈里存 (标题等级, 节点) stack [(0, root)] for kind, info in parsed_lines: if kind headline: level info[level] node {type: headline, level: level, title: info[title]} # 把等级高于当前栈顶的标题弹出去保证并排标题不嵌套 while stack and stack[-1][0] level: stack.pop() # 如果栈空了挂回根节点 if not stack: stack.append((0, root)) stack[-1][1][children].append(node) stack.append((level, node)) else: node {type: kind, **info} # 普通节点一律挂到当前栈顶节点下 stack[-1][1][children].append(node) return root这个实现并不复杂但结构树的价值很大解析完成后我只需要对这个 JSON 做任何下游加工——生成 HTML、生成 RSS、生成知识库索引全都水到渠成。你也可以用同样的思路把输出适配到你自己的博客系统里。4. 四年迭代里踩过的那些坑编码冲突与旧数据污染4.1 症状一历史文本里的“#5”全被误判成编号第一次大规模翻车发生在我给 CSD 码增加“自动编号”能力的时候。我原本的语法设计里有#h2.这种带点的标题后来为了兼容某种导出需求我在规则表里加了一条正则^#(\d)([\.、])\s表示“编号标题”。结果一夜之间我过去三年写的好几百篇旧文本大量被误判。当时排查的链路大概是这样的先写了一个统计脚本输出解析后所有节点的类型分布发现numbered_title类型的节点数暴涨到 1200 多个。按文档抽样看了十来条发现命中的文本全是这种“今天在地铁上想明白了 #5 的问题”“第三季度的 #7 号预案要重写”。这些#5、#7根本不是编号标题就是正文里的井号加数字。我再看正则发现问题很简单^#(\d)前面的^只锚定了行长但那段文本确实可能出现在行首比如列表项里我习惯写成- 今天想明白了#5 的问题。关键是行首的^对“列表项文本内部”的约束太弱了。最终我把“编号标题”这个功能直接砍掉了改用-列表加手动排序不再提供#数字开头的语法。对于已经误解析的数据我写了个一次性反转脚本把匹配到但不在合法标题结构里的节点转回普通段落。这个坑给我的教训是语法设计阶段宁可少一个功能也不要让一个符号承担太含混的语义。数字前加井号在中文文本里太容易被自然语言撞车了。4.2 症状二新语法#w让老文档“待办爆炸”另一个印象深刻的事故发生在 v2 升级到 v3 的时候。我在 v2 新增了#w待办标记结果同步上线后很多老文档的待办数量一夜之间从一两条涨到几十条。抽样一看老文档里大量出现“第w期”“W项目”“w40”这类写法——因为#w正则写的是^#w\s理论上要求#w后有空格但我为了兼容自己偶尔手滑写的#w把正则放宽成了^#w[]?\s*直接把 “w40” 这种也吞了进去。这次的排查过程让我下定决心引入版本头机制统计所有文档的版本头分布发现只有约三分之一的老文档带版本信息剩余的根本无法判断是 v1 还是 v2。我决定不再追责“哪条老文本为什么被误判”而是直接从源头切断新语法只在声明了#v3及以上的文档里生效。对老文档批量添加#v2版本头并保持 v2 的解析规则不变让它们继续按旧逻辑跑。具体到代码里解析器最开始先检查版本头VERSION_PATTERN re.compile(r^#v(?Pversion\d)$) def parse_document(text: str): lines text.splitlines() version 1 if lines and VERSION_PATTERN.match(lines[0].strip()): version int(VERSION_PATTERN.match(lines[0].strip()).group(version)) # 版本号不同使用不同的规则集 patterns RULE_SETS.get(version, RULE_SETS[1]) ...这个设计之后我再也没有遇到“版本升级污染旧数据”的问题。我的体会是凡是自用工具总要留一条后路让旧数据在旧规则里活着比硬推新规则更稳。4.3 症状三贪婪匹配让加粗无限延伸到段落末尾有一次用户其实就是我自己报 bug某篇长文解析出来后整个中间段落全部变成了加粗。我打开文本一看原因非常典型。原文大概是这样的然后是 #b 核心逻辑#x 部分这里说明一下为什么。 这里有一个 #b 边界条件#x 需要小心。第一层行级分类一切正常问题出在第二层。我当时加粗正则写的是#b(.)#x大家注意(.)贪婪匹配会把从第一个#b开始到最后一个#x之间的所有内容都吞进一个组里。结果就是“核心逻辑”和“边界条件”中间的一大段全部变成加粗节点。当时我的排查思路是先单独对问题行跑正则看匹配结果的 span 范围。用可视化工具把匹配到的内容打印出来一眼就看到了跨度太长。改成非贪婪.?同时用边界断言保证内容非空问题立刻消失。非贪婪量词.?的意思是“能少匹配就少匹配”遇到第一个#x就停。配合前面的(?\S)和(?\S)边界断言加粗解析在一般文本里就能稳定工作。如果你想彻底避免这种问题还可以限定加粗内容不允许包含#字符#b([^#])#x但有时代码文本里确实包含#所以我最后还是用了非贪婪加边界断言组合。4.4 症状四大小写混杂与历史手误第四个坑比较细早期我写标题标记时不统一有时是#h2有时是#H2有时又是#h2.。这些差异在 v1 时没有统一处理导致解析器必须同时匹配三种写法正则写得越来越长还不好读。后来我做了一次彻底清洗选择“小写 无尾点”作为唯一标准。清洗脚本的逻辑很简单遍历所有纯文本行。把行首的#H\d、#h\d.统一替换成#h\d。解析器里把re.IGNORECASE去掉强制区分大小写今后凡是语法字符一律小写。这个决定可能很多人不理解明明可以用IGNORECASE兼容为什么要强制我的理由是语法越宽松正则就越复杂边界情况就越多而这是我自己打出来的文本只要迁移一次成本以后不会再产生新的大小写问题。实测清洗完以后解析器的误报率直线下降通配性提升了很多。4.5 我的排查方法论统计优先、样本定界、回归测试四年的经验汇总成一条方法论改解析规则永远先从统计入手别直接改代码。具体来说我每次准备调整语法时会先做一个“行首模式统计”的小工具把全量文档文本按行首前 8 个字符分组统计每一类出现的次数。这样能立刻看到新增语法会不会和历史文本里的常见写法撞车。举个例子我想加#p表示“重点”时先跑统计发现老文本中“#p”开头的行有 30 多条全是“#p 值”或者“#p.s.”这种历史遗留表达那就得考虑改用#focus这类不会撞车的写法。其次我会维护一个固定的测试样本集。里面包含正常文档、待办爆炸的老文档、嵌套加粗的文档、带代码块的文档。每次改完正则跑一遍全量回归对比新旧输出 diff。这套流程看着很笨但对一个自用工具来说效果远胜在线上发现问题再回去修数据。5. 数据自由的最终形态现在的完整工作流与扩展思路5.1 当前从记录到落库的日常链路CSD 码设计出来不是给人参观的它最核心的价值在于能跑通一条从手机端到知识库的数据流水线。我现在每天的记录链路大概是这样的手机上任何能写纯文本的 App 都行——备忘录、纯纯写作、甚至微信文件传输助手。记录时全程用九键用 CSD 语法标记结构。文本通过坚果云的 WebDAV 同步到家里的服务器或者直接用 Git 仓库管理版本。服务器上跑一个定时任务我用 cron每 30 分钟一次把新增的.csd.txt文件丢给解析器。解析器输出 JSON再经过模板引擎渲染成 HTML 片段推送到博客的content/posts目录。最后博客生成器打包发布同时生成一个按标签聚合的索引页和按月归档的列表页。整体看下来手机端只是纯文本的采集器真正的处理和发布都在服务器端完成。这个链路里没有任何一步依赖某个特定 App 的私有格式所以任何一环出了问题替换成本都极低。5.2 “数据自由”到底指什么很多人以为数据自由是指“数据不丢失”其实不止。我理解的自由是三层含义平台自由手机备忘录也好、第三方写作 App 也好只要它允许导出纯文本CSD 码就能接住。哪天我对某款软件不满意直接换一个不会影响我已有内容的结构。格式自由CSD 文本可以输出成 JSON、HTML、Markdown也可以输出成 CSV 汇总表。博客用不上就转给知识库知识库不喜欢就再转回文本本质上是自己掌握了一层“中间格式”。程序自由解析后的结构化数据可以喂给任意脚本做统计。比如我统计过一年写了多少条待办、完成率多少也统计过不同标签的出现频率用来复盘注意力分配。这几件事在传统笔记软件里基本做不了或者说要付出极高的迁移成本才能做。CSD 码的价值不是语法本身有多优雅而是它把内容的所有权重新放回了我自己手里。5.3 后续可以扩展的方向如果你也想复刻这套思路后面可以自己加很多料给解析器增加语音输入入口。手机语音识别转文字之后再用脚本把“第几部分”“待办”这类关键词自动转成 CSD 标记能进一步减少手打成本。把 JSON 输出接入 Obsidian 或者 Logseq 这类基于本地纯文本的知识库工具实现双链笔记等于是给旧的 CSD 文档加了一道现代化导入通道。做一个简单的标签云或者知识图谱生成器输入是解析后的 JSON输出是可视化页面。我目前正在折腾这一步。回到最初的话题手机键盘加正则解析听起来像是两个不相干的领域但它们结合的产物——CSD 码和配套解析器——确确实实让我四年的文字沉淀变成了可以被搜索、被统计、被迁移的资产。如果你也经常在手机上记录碎片想法又希望这些想法不要烂在某个 App 的服务器里我建议你不妨从最小的一版开始一个#h2、一个#w、十行正则已经足够打开数据自由的门。