四年前,我还在用一部旧安卓手机写博客。没有电脑,没有编辑器,所有文章都从九宫格键盘一个个字敲出来,保存在备忘录里,定时同步到 CSDN。那时候我完全没想过数据归属这件事,直到有一天发现平台编辑器的老文章排版一塌糊涂、导出功能形同虚设,我才开始正视一个问题:我写的东西,到底归谁管?
这四年里,我断断续续把近两百篇 CSDN 博文从“平台账号里的文章”变成了“自己硬盘上可以随时解析和检索的数据”。整个过程绕不开两个关键词:手机键盘和正则解析。这篇文章不是教程合集,而是把我从数据权限、文本清洗、正则匹配到归档检索的全过程完整拆开,附上我实际在用的源码,讲清楚每个环节为什么这样设计、踩过哪些坑、有哪些代码可以直接抄走。无论你是天天用手机码字的写作者,还是想把平台文章备份下来的开发者,这篇文章应该都能给你几条能落地的思路。
1. 项目缘起:我为什么跟自己的博客数据死磕了四年
1.1 从手机键盘开始的写作习惯
我的写作场景非常固定:通勤、午休、睡前,用手机备忘录敲一个个段落。当时的习惯是每篇文章存成一个 txt 文件,用一段特殊的标题格式开头,比如“【2021-04-12】从零搭建你的第一个爬虫”,后面的正文按“段落之间空一行”的方式组织。这个习惯看上去很原始,但恰恰是它决定了后来整个解析方案的方向:所有原始素材都以纯文本形式存在,没有富文本,没有格式干扰,只有文字本身。
你可能会问,为什么不在手机上直接用 CSDN 编辑器写?我当时在用的是第三方输入法和系统备忘录,原因是 CSDN 的移动端编辑器在弱网环境下保存经常失败,加上微信等应用频繁切换后台,内容丢过两三次之后就彻底放弃了。现在回看,这个“被迫的”选择反而是整件事能顺利推进的最大前提:纯文本是所有数据结构里最容易被解析的,没有之一。
1.2 “数据自由”到底在解决什么问题
文章写了两年之后,平台积累的内容越来越多,我慢慢意识到三个具体痛点:
- 平台没有一键导出全部文章的完整功能,只有逐篇文章复制粘贴的操作方式。
- 老文章经常因为编辑器版本升级导致格式错乱,代码块变成普通文本,列表层级丢失。
- 我想建立一个属于自己的本地知识库,把博文、草稿、微信聊天里记录的灵感碎片统一检索,但平台的数据是“孤岛”,没法直接接入本地工具。
所以“数据自由”这个词,对我来说不是口号,而是一个很具体的工程目标:不管平台还在不在,我都能把曾经发布的所有内容拉回到本地,并且用统一的结构化方式重新解析、索引、搜索。我需要的是“数据的可迁移性”和“数据的可解析性”,这两点缺一不可。
1.3 四年里我踩过的第一个坑:平台导出的无力感
最开始我尝试了当时所有能找到的导出方案。平台自带的导出功能会生成一份 PDF 或 HTML 文件,然而 PDF 里的代码块和中文标点经常错乱,HTML 文件又夹杂了大量样式标签和广告脚本。我试着把 HTML 直接另存为 Markdown,结果每个文件都残留几十行无用代码,手动清理一个文件就要十分钟。
这之后我才意识到,所谓“数据自由”并不是有一个现成的按钮让你一键下载,而是你必须自己写一层清洗和解析逻辑,把平台给你的一堆“半成品”数据重新加工成真正属于自己的东西。这就是为什么项目标题里把“手机键盘”和“正则解析”放在一起:前者是我的内容生产端,后者是内容加工端,两者之间隔着整整一层数据清洗的脏活累活。
2. 整体设计思路:先把“数据自由”拆成四个模块
2.1 数据采集层:手机端怎么低成本沉淀内容
数据自由的前提是“数据先能集中到一个地方”。我最开始的做法是靠手动同步,每写完一篇就复制到 CSDN 后台再粘贴发布,但这个流程对草稿、灵感、半成品非常不友好。后来我调整了方案,在手机端用同步盘 App 建立一个“博客草稿箱”目录,每篇文章一个 txt 文件,文件名就是日期加标题。这样草稿会自动同步到电脑端,我再在电脑上做后续处理。
这个方案没有引入任何复杂的工具,核心思路就是“文件名即元数据”。文件名里带了日期和标题,意味着哪怕文件内容完全混乱,我也能通过文件名恢复基本的时间线和主题信息。这是低成本数据采集的关键,也是很多做个人知识库的人容易忽略的地方:采集阶段的结构设计,决定了后续解析阶段能省多少事。
2.2 传输与落盘层:从碎片文本到结构化文件
每次从手机同步过来的 txt 文件,都只能算是“原始素材”,还不能直接进知识库。我在电脑端做了第一次整理,把所有文件分成三个目录:publish(已发布)、draft(半成品)、source(引用资料)。整理规则很简单:发布过的文章进 publish,没写完的进 draft,参考的外部资料单独放。
传输过程遇到过一个问题:手机备忘录导出的文本经常在句末带上“来自 XXX 手机”的签名,还有一些输入法的全角括号被替换成半角括号。我必须在传输后加一道清洗脚本,统一做三件事:去掉签名行,把半角括号统一为全角,把多个连续空行压缩成一个。这道工序我用 Python 脚本跑批处理,本质上是逐文件做正则替换,后续所有解析工作才不会被这些无关字符干扰。
2.3 解析层:为什么最终选定了正则表达式
很多人一听到“解析”就想到用 BeautifulSoup 或 lxml 去解析 HTML。但我的场景里有大量纯文本文件、少量 HTML 备份,以及从剪贴板复制过来的零散段落,没有一个统一的格式。BeautifulSoup 擅长处理 HTML,但遇到纯文本和半结构化的 Markdown 反而杀鸡用牛刀。我需要一个不依赖数据格式、只依赖内容模式的解析工具,最终我选择了正则表达式。
正则表达式的优势不在于它能解析一切,而在于它能把“文本里的模式”直接转换成“程序认识的字段”。比如一段文章,只要我知道标题固定以日期开头、正文以“正文开始”标记起始、标签放在文章末尾,那么用三个正则表达式就能把元数据提取干净。代价是正则表达式容易写得脆,边界条件一多就会断裂,但这可以通过在输入侧设定严格的书写规范来弥补。
2.4 归档与查询层:让旧文章能再被翻出来
解析完成之后,所有文章统一转成本地 Markdown 文件,并且用一个 metadata 头记录发布日期、修改日期、原文链接、标签列表。归档目录按年份拆分,每年一个文件夹,每篇文章一个 .md 文件,文件名格式是“YYYY-MM-DD-短横线标题.md”。
查询层我用的是 Windows 自带搜索加上一个简单的 Python 命令行工具。命令行工具做的事情很朴素:输入一个关键词,遍历所有 Markdown 文件,用正则匹配标题和正文,输出命中文件列表。后来我干脆把解析结果导入 SQLite,这样就能支持更复杂的组合查询,比如“查所有 2022 年发布且带‘正则’标签的文章”。这一步让我真正体会到:解析的价值不是把数据拆成字段,而是拆完之后还能合起来用。
3. 核心实现:手把手拆解源码里的每个关键环节
3.1 手机键盘侧的输入规范设计
要让正则解析稳定工作,输入侧必须制定一套“简陋但严格”的书写规范。我最终固定的格式是这样:
【2021-04-12】从零搭建你的第一个爬虫 正文开始 这里是第一段内容,可以包含多个句子。 如果出现代码块,我使用四个空格缩进表示,像这样: import requests print('hello') 正文结束 标签:爬虫, Python, 入门这套规范的要点是:日期格式固定为 YYYY-MM-DD,标题前面有“【】”符号,正文用“正文开始”和“正文结束”包裹,标签统一放在末尾,用“标签:”开头。这看起来非常简单,甚至有点死板,但绝对的统一格式换来了极高的解析稳定性,后面的正则表达式几乎不用处理例外情况。如果你想复现这个项目,我强烈建议先把书写规范固定下来,否则你会在解析阶段不断遇到“又一个特例”。
3.2 用正则表达式清洗原始博文
拿到一个 txt 文件后,我首先做的操作不是解析,而是清洗。清洗分三个步骤,每个步骤都用一组正则表达式完成,核心代码如下:
import re def clean_raw_text(raw: str) -> str: # 1. 去掉手机签名行,比如“来自X米手机” text = re.sub(r'\n来自.{2,8}手机', '', raw) # 2. 压缩多个连续空行为单个空行 text = re.sub(r'\n{3,}', '\n\n', text) # 3. 把半角括号统一成全角括号(避免中文环境下显示错乱) text = text.replace('(', '(').replace(')', ')') # 4. 去掉行尾多余空格 text = re.sub(r'[ \t]+$', '', text, flags=re.MULTILINE) return text清洗操作的顺序是有讲究的:必须先处理签名行,再压缩空行,最后才处理标点和行尾空格。如果先压缩空行,签名行前后的换行会被一起压缩,后续正则替换签名时可能误删正文第一段。这类细节就是实操和理论之间差出来的经验,我一开始就吃过顺序颠倒的亏。
3.3 从 HTML 页面批量抽取 CSDN 文章数据
由于早期有些文章没有在手机端留底稿,只有 CSDN 页面本身,我还写了一个 HTML 解析器,用来从文章页面源码里抽取标题、正文和发布时间。CSDN 的页面结构不同年代差别很大,但总有规律可循,比如标题通常出现在<h1 class="title-article">标签中,正文在一个id="article_content"的 div 里。我的抽取函数如下:
def extract_article_from_html(html: str) -> dict: title = re.search(r'<h1[^>]*class="title-article"[^>]*>(.*?)</h1>', html, re.S) content = re.search(r'<div[^>]*id="article_content"[^>]*>(.*?)</div>\s*</div>', html, re.S) date = re.search(r'<span[^>]*class="time"[^>]*>(.*?)</span>', html, re.S) result = {} if title: result['title'] = re.sub(r'<[^>]+>', '', title.group(1)).strip() if content: result['content'] = clean_html_content(content.group(1)) if date: result['date'] = date.group(1).strip() return result def clean_html_content(raw_html: str) -> str: # 去掉脚本和样式 raw_html = re.sub(r'<script.*?</script>', '', raw_html, flags=re.S) raw_html = re.sub(r'<style.*?</style>', '', raw_html, flags=re.S) # 代码块替换:pre 标签内容保留换行 raw_html = re.sub(r'<pre[^>]*>(.*?)</pre>', lambda m: '\n ' + re.sub(r'<[^>]+>', '', m.group(1)) + '\n', raw_html, flags=re.S) # 段落标签转换为空行分隔 raw_html = re.sub(r'</p>', '\n\n', raw_html) # 剩余标签全部去除 text = re.sub(r'<[^>]+>', '', raw_html) # 还原常见 HTML 实体 text = text.replace(' ', ' ').replace('&', '&').replace('<', '<').replace('>', '>') return text这里需要特别注意正则表达式中非贪婪匹配的使用,即.*?。如果你写成贪婪的.*,当页面里有多个<div>标签时,匹配结果会一路延伸到整个页面末尾,把无关内容全部吞进来。我最初就犯过这个错误,提取出来的“正文”里塞满了侧边栏推荐内容和底部广告,后来把所有跨行匹配都改成了.*?加re.S标志,问题才彻底解决。
3.4 完整解析流程:从原始文件到结构化 Markdown
上面的清洗和抽取都是局部函数,真正落地的核心是一个parse_article_file函数,把整个文件从原始文本转换为最终的结构化 Markdown:
def parse_article_file(filepath: str) -> dict: with open(filepath, encoding='utf-8') as f: raw = f.read() # 先清洗 text = clean_raw_text(raw) # 提取日期和标题:兼容【2021-04-12】标题 和 2021-04-12-标题 两种格式 meta = { 'date': None, 'title': None, 'tags': [], 'content': '' } date_title = re.search(r'【(\d{4}-\d{2}-\d{2})】(.+)', text) if not date_title: date_title = re.search(r'(\d{4}-\d{2}-\d{2})[- ](.+)', text) if date_title: meta['date'] = date_title.group(1) meta['title'] = date_title.group(2).strip() # 提取正文 content_match = re.search(r'正文开始\s*(.*?)\s*正文结束', text, re.S) if content_match: meta['content'] = content_match.group(1).strip() # 提取标签 tag_match = re.search(r'标签[::](.+)', text) if tag_match: meta['tags'] = [t.strip() for t in tag_match.group(1).split(',')] # 组装为 Markdown 文件内容 md_lines = [] md_lines.append(f"# {meta['title']}") md_lines.append(f"发布日期:{meta['date']}") if meta['tags']: md_lines.append('标签:' + ', '.join(meta['tags'])) md_lines.append('') md_lines.append(meta['content']) return { 'meta': meta, 'markdown': '\n'.join(md_lines) }这个函数把前面所有模块串在一起:清洗层负责把脏数据变干净,正则层负责从干净文本里抽字段,最后的 Markdown 组装层则是生成本地归档文件。整个项目的核心逻辑其实只有这三层,代码量也就是百来行,没有用到任何重型框架。
4. 实操过程记录:从采集到归档的完整流程
4.1 一次完整的博文导入流程
我做了一个批处理脚本,把所有待处理的 txt 文件统一跑一遍,生成 Markdown 文件并按日期归档到对应年份目录。完整流程分五步:
- 把手机草稿箱里的文件同步到电脑本地目录。
- 运行清洗和解析脚本,生成 Markdown 文件和一个 JSON 索引文件。
- 人工抽查 5 到 10 篇,重点看标题有没有提取错、正文有没有被截断。
- 把 Markdown 文件移动到按年份归档的目录。
- 更新 SQLite 数据库,把新文章的元数据和全文索引插入进去。
脚本的核心调度逻辑很简单,用一个for循环遍历目录文件,逐个调用解析函数。为了不覆盖已有文件,我加了一个判断:如果目标 Markdown 文件已存在,就自动重命名为“原名_重复.md”。这一步看着不起眼,但避免了重复导入导致的数据混乱,是我在实际运行中吃过亏之后补上的。
4.2 参数与边界条件的处理:日期、标签、代码块
日期是解析中最容易出问题的字段。我最初只支持【YYYY-MM-DD】一种格式,后来发现很多草稿文件用的是2021年4月12日这种写法,于是加了一组兼容正则。日期解析的优先级必须固定:先匹配标准格式,再匹配中文格式,如果都没有,就默认取文件的修改时间。标签字段也有类似的问题,既有全角冒号“标签:”又有半角冒号“标签:”,解析时候必须两个都匹配。
代码块是另一个高频坑。手机端输入代码块时,无法像电脑端一样方便地切换代码块标记,所以我的规范是“四个空格缩进代表代码块”。解析时,我需要把所有四空格缩进的行统一替换成 Markdown 的围栏代码块格式。这个过程用一个简单正则就能做到:
def convert_indented_code_to_fence(md_text: str) -> str: lines = md_text.split('\n') in_code = False new_lines = [] for line in lines: if line.startswith(' '): if not in_code: new_lines.append('```') in_code = True new_lines.append(line[4:]) else: if in_code: new_lines.append('```') in_code = False new_lines.append(line) if in_code: new_lines.append('```') return '\n'.join(new_lines)这个函数不需要正则,但它解决的是正则也容易出错的场景:逐行状态机比正则更适合处理跨多行的结构性转换。经验就是:能逐行处理用逐行处理,不要把简单问题复杂化。
4.3 跨设备同步的小技巧
手机和电脑之间的文件同步,我在不同时期用过不同方案,体验差别很大。最初用数据线手动复制,最大的问题是忘记同步导致两边文件不一致。后来改用局域网文件共享和自动同步工具,终于解决了实时同步问题,但随之而来的是同步冲突:同一个 txt 文件在手机端和电脑端都被改动,生成了“文件名(冲突)”这样的副本。
我的解决办法是在手机端固定只做“写入”,在电脑端固定只做“读取与归档”。手机端的文件一旦写完并同步出去,就不再修改,只增不改。电脑端归档完成后,把已处理文件移动到archived子目录,避免脚本重复处理。数据流向是单向的,冲突自然就消失了。这是个人项目里最容易被忽略但收益极大的设计原则。
5. 常见问题与排查技巧实录
5.1 正则匹配不到内容:先查转义和换行
我用这套方案跑了半年后,发现一个很诡异的现象:个别文章的正文提取出来只有第一段,后面全部丢失。排查后发现问题出在手机端写正文时用了两个连续换行,而我的正则写的却是\s*。理论上\s应该能匹配换行,但在某些场景配合re.S标志使用时,如果文件末尾有\r\n和\n混在一起,就会在正文结束这个标记处提前终止匹配。
我的排查思路是分步定位:先打印原始文本里“正文结束”附近的字符码,确认到底是\n还是\r\n,再调整正则。最终我把所有文本统一做了换行符标准化,把\r\n全部替换成\n,正则解析的稳定性瞬间提高了一个台阶。后来我形成了一条经验:凡是正则匹配不到内容,先别急着改表达式,先去检查文本里的不可见字符。
5.2 手机端换行符与电脑端格式不兼容
这个问题和上面相关,值得单独拿出来说一下。安卓手机上的很多文本编辑器默认使用\n,但有些第三方输入法会在粘贴时插入\r\n。当这些文件被传回 Windows 电脑,我用记事本打开时一切正常,但用 Python 读取时,\r会残留在每行末尾,导致正则里的$锚点经常失效。
解决办法是在脚本开头加一行标准化:
text = raw.replace('\r\n', '\n').replace('\r', '\n')这行代码看着简单,但如果没有它,后面所有正则都会像踩在棉花上一样,看起来匹配了,实际提取出来的文本里全是隐形符号。我在项目文档第一版里甚至专门用红色字体标注了这句话:先标准化换行符,再做任何正则解析。
5.3 文章乱序和重复导入怎么处理
因为同步冲突,同一个文件可能出现多个副本,导入后就会产生重复文章。我的处理方案是建立“文章指纹”,用标题加日期的组合作为唯一标识,如果 SQLite 里已经存在相同标识,就跳过导入。如果标题和日期都相同但正文不同,说明有两个版本,我会生成一个_v2后缀文件,而不是覆盖旧文件。
乱序问题则涉及文章编号。有些文章发布时没有固定顺序,但在本地归档时,我希望文件能按时间排序。处理方式是文件名统一用日期开头,脚本自动按日期排序归档,月份顺序自然就理顺了。这套逻辑也让我意识到:数据自由不仅是能拿到数据,还包括数据能按照自己的规则被重新组织和排序。
5.4 常见问题速查表
为了方便你直接对照排查,我把四年里遇到的高频问题整理成了一张速查表:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 正则匹配不到标题 | 标题里有全角空格或特殊符号 | 先调用re.sub(r'\s+', ' ', text)做归一化 |
| 正文只提取到一半 | 换行符不一致导致匹配中断 | 统一把\r\n替换为\n |
| 代码块丢失缩进 | HTML 解析时 pre 标签没处理 | 单独补一层pre标签转换逻辑 |
| 标签提取为空 | 使用了全角冒号但正则只匹配了半角 | 同时匹配[::] |
| 重复导入 | 文件同步产生副本 | 用标题+日期做唯一指纹校验 |
| 文件名乱码 | 手机端编码非 UTF-8 | 统一在手机端使用 UTF-8 编码保存 |
我把这张表打印出来贴在了电脑显示器侧面,后来每次解析出问题时都会先对着表检查一遍,大部分问题在五分钟内就能定位。
6. 四年实践下来的几点心得
6.1 正则不是万能的,但它是性价比最高的
如果你的数据本身就是从手机键盘一个一个敲出来的纯文本,那正则解析就是最合适的工具。它不需要你维护一个庞大的解析框架,也不依赖网络,哪怕十年后打开当年的脚本,依然能运行。相比之下,那些依赖在线 API 的数据导出工具反而不稳定,API 升级一次,你的代码就要跟着改一次,这种“依赖别人的系统拿到自己数据”的方式,本身就是对数据自由的一种背离。
当然,正则也有它的天花板。遇到嵌套结构的 HTML 或者层级复杂的 Markdown,正则写起来会很痛苦,这时候适当配合逐行处理和 DOM 解析会更好。我的原则是:能用简单正则解决的,绝不上复杂框架;但也不要神化正则,该妥协的地方果断妥协。
6.2 数据自由的真义:不是把数据搬走,而是随时能再解析
做了四年这个项目,我最大的体会是:数据自由不是把文章从 CSDN 复制到本地硬盘就算完成,而是你能不能在自己掌握的本地系统里,对这批数据做任意形式的解析、重组、重新导出。复制只是搬运,解析才是拥有。现在哪怕 CSDN 明天改版、甚至关闭,我的每一篇文章都已经以标准 Markdown 格式保存在自己的文件系统里,可以用本地工具搜索、转换、发布到任意平台。我不用依赖任何人的导出按钮。
6.3 给后来者的三个建议
第一,务必从第一天就规范输入格式。书写规范越严格,后续解析越省力,不要在源头偷懒,否则解析阶段会用十倍的时间还回来。第二,先写清洗脚本,再写解析脚本。把原始文本中所有可见和不可见的脏字符都处理干净,正则解析才能真正发挥作用。第三,建立一套自己的唯一标识规则。无论是文章标题加日期,还是文件名前缀,都要保证同一篇文章不会重复入库,这是长期运行数据系统的基本底线。
如果你也有大量散落在平台和手机里的文章数据,我建议你从今天开始做一个小实验:挑五篇文章,用最简单的正则表达式提取标题和正文,转成本地 Markdown。你会发现一旦迈出这一步,后面的事情会越来越顺手,最终你收获的不只是数据备份,而是一套完全属于你自己的内容管理基础设施。