项目代号就叫 rea,全称我私下取的是 Rapid Extraction Assistant,一个用来从一堆乱七八糟的半结构化文本里快速抽字段、转成结构化数据的命令行小工具。平时要分析日志、整理导出报告、或者从长聊天记录里批量捞信息的时候,手动复制粘贴能让人干到怀疑人生,rea 就是想解决这个重复劳动问题。这东西很适合经常跟文本数据打交道的人,不管是运维、数据分析、测试,还是日常办公里偶尔需要做文本清洗的普通用户,都能拿来直接用。
下面我会把这个项目的思路、核心实现、实际踩坑和常用排查方法完整写出来,代码都是从实际跑过的版本里抽出来的,不搞花里胡哨,能直接照着实践。
1. 项目从哪来:rea 到底要解决什么
1.1 核心痛点:半结构化文本里的信息提取真的是个重复体力活
先举一个实际场景。假设你手上有几万行如下的日志,每行都长这样:
[2024-05-11 10:22:33] INFO 用户ID=u10086 操作=登录 result=成功 耗时=45ms [2024-05-11 10:23:01] WARN 用户ID=u10087 操作=支付 result=失败 耗时=1203ms这种日志看着整齐,但并不是严格的 CSV 或 JSON,没法直接导入表格。你想统计每个操作的成功率、平均耗时,就要先把"用户ID""操作""result""耗时"这些字段从字符串里挖出来。如果只有十几条,肉眼看不出来什么,但几万条呢?用 Excel 手动分列可以做,但碰到格式稍微变一下的行就废了;用现成的日志分析平台又觉得杀鸡用牛刀。我一开始也是写一次性 Python 脚本,每换一种格式就改一次正则,改完还要处理编码、文件遍历、输出结构,真正要提取的数据反而被这些杂事淹没了。
rea 的核心主张就是:把"提取规则"和"处理逻辑"分开。你只需要写一份规则文件,剩下的文件读取、编码识别、逐行扫描、批量输出这些事情,全部交给工具完成。
1.2 方案选型:为什么没有直接用现成的工具或者大数据平台
在动手之前,我确实考虑过几条路。
第一是直接用 grep、awk 做。说实话,对于简单场景这俩是完全够用的,尤其是 awk,处理列式文本非常利落。但是一旦字段数量变多,或者一个字段需要从跨行内容里提取,awk 脚本的可读性和维护成本会迅速上升。而且 awk 的输出结果要再加工才能变成干净的 JSON 或 CSV,交互流程是断开的。
第二是引入重量级的日志收集系统。可以做到功能强大,但配置、部署和学习成本对于一个小场景来说太不划算。为了抽几个字段,搭一套完整的采集、存储、查询链路,时间和资源都受不了。
第三是自己写一次性脚本。这个是我之前最常用的做法,但问题在于每个脚本的读取逻辑、编码处理、输出格式都是重复造的轮子。这周写一个处理设备日志的,下周写一个处理业务报表的,真正通用的部分从来没人沉淀下来。
所以 rea 的定位很清晰:做一个轻量的、本地的、规则驱动的文本抽取工具。它不强求实时处理海量数据,也不做复杂的数据可视化,只在"把杂乱的文本变成整洁的结构化数据"这一件事上做到顺手。
1.3 目标与边界:rea 做什么,又坚决不做什么
做工具最怕贪多。我一开始也想加各种功能,比如自动识别日志格式、内置几十种解析器、生成统计图表,后来全部砍掉了。rea 最终只做三件事:
- 读取指定的文件或目录,支持递归遍历。
- 按照用户提供的规则,从每一行或者每一条记录里提取命名分组。
- 把提取结果输出为 JSON、CSV 或者带格式的表格。
不做什么也很重要:rea 不做模糊猜测,规则写什么它就匹配什么;不做实时监听,不是 tail -f 那种工具;不做数据库导入,只负责产出标准结构化数据。这样做的原因是,猜测和自动识别这种东西听起来智能,实际上遇到真实数据的风格差异时,结果反而不可控。明确规则带来的确定性,在文本处理里是一种极大的安全感。
2. 核心设计与实现思路:规则、扫描、输出三层拆分
2.1 整体架构:为什么我坚持把配置和数据逻辑隔离
rea 的代码结构很朴素,内部划分为三层:规则层、扫描层、输出层。
规则层负责加载和编译用户的提取规则。在 rea 里,规则是用 YAML 写的文件,里面定义了这条规则叫什么名字、用什么正则模式、提取哪些命名分组、要不要做前置过滤。扫描层拿到编译后的规则,逐行读取输入文件,先用一个非常快速的关键字预判断跳过大部分无关行,再对候选行做完整的正则匹配。输出层负责把匹配结果收集起来,编码成用户指定的格式。
为什么坚持分层?因为实际使用中,这三部分变化的频率完全不同。输出格式可能今天要 JSON,明天要 CSV;输入文件从 GBK 编码变成 UTF-8 编码;规则更是随时要调。如果这些逻辑糊在一个脚本里,每改一个需求就要动一遍主流程代码,迟早会改出 bug。分开之后,主流程代码几乎可以稳定不动,改配置就能应对大部分变化。
2.2 规则引擎:那些正则之外的细节
规则文件大概是这个样子的:
rules: - name: login_record title: 登录记录 pre_filter: "INFO" pattern: > \[(?P<time>.*?)\] INFO 用户ID=(?P<user>\w+) 操作=(?P<action>\S+) result=(?P<result>\S+) 耗时=(?P<cost>\d+)ms skip_if_absent: [time, user, action, result, cost]每个字段的意义:
- name:规则名,最终输出的数据里会带上,方便知道这条数据来自哪个规则。
- title:可选的显示名,输出表格时作为标题。
- pre_filter:前置过滤关键字。如果设置了这个值,只有包含该关键字的行才会进入正则匹配阶段。在几万行的日志里,如果只有三分之一是目标行,这个前置过滤能省下大量无谓的正则尝试。
- pattern:真正的正则表达式。这里要求必须使用命名分组,因为输出字段名直接取命名分组名。
- skip_if_absent:一个列表,列出必须存在的字段名。如果某一行正则匹配上了,但其中某些字段为空,则认为这条记录无效,跳过。
为什么还要一个 skip_if_absent?因为真实日志里经常出现字段缺失的情况。比如有些行有"耗时"字段,有些接口异常时没有耗时。如果只靠正则匹配,匹配到但不完整的行也会进入结果集,后续统计数据时就会混入脏数据。有了这个约束,我们可以直接把不完整的记录挡在外面。
2.3 命令行交互:参数设计是最容易被低估的地方
命令行工具的参数设计非常影响日常使用体感。rea 的主要参数如下:
rea extract -f input.log -r rules.yaml -o result.json rea extract -d ./logs -r rules.yaml -o result.csv --recursive rea extract -f input.log -r rules.yaml -o output/ --split-by date几个关键参数的设计逻辑:
-f, --file 指定单个输入文件。 -d, --dir 指定输入目录,配合 --recursive 递归扫描所有文件。 -r, --rules 指定规则文件路径。 -o, --output 指定输出文件路径,如果指向一个目录,则由 rea 根据时间戳自动生成文件名。 --encoding 手动指定输入文件编码,默认 auto 表示自动探测。 --line-numbers 输出结果中附带原始行号,方便回溯。 --split-by 按某个字段值拆分输出文件,比如按日期把结果切分成多个 CSV。2.4 输出格式:JSON、CSV 与表格,什么时候用哪个
rea 支持三种输出格式,由文件扩展名自动决定:.json输出 JSON 数组,.csv输出 CSV 表格,.txt输出对齐的纯文本表格。
JSON 是默认推荐格式,因为它的嵌套结构和字段缺失表达能力最强。比如一条记录里,某个字段可能存在也可能不存在,JSON 可以自然地表达这种差异。CSV 适合直接放进 Excel 或导入数据库,但字段是扁平的,某些字段值为空时只能用空字符串占位。纯文本表格适合终端下肉眼查看,快速确认提取效果。
实际使用中我的习惯是:先用.txt跑一条小日志,肉眼确认规则匹配正确;确认无误后,再正式跑全量并输出成 JSON 或 CSV。这个流程可以少踩很多坑。
3. 核心环节实操:从零搭一个能用的 rea
3.1 环境准备与项目结构
rea 用 Python 3.9+ 实现,除了标准库之外只额外依赖 PyYAML。选择 YAML 而不是 JSON 做规则文件,主要是 YAML 支持注释,在规则复杂时可以写说明,这一点在实际维护中太重要了。
项目结构非常简单:
rea/ rea_cli.py rules/ demo_rules.yaml tests/ sample.log入口脚本是rea_cli.py,我用 Click 库来做命令行参数解析。如果你不熟悉 Click,用 argparse 也行,但我个人更喜欢 Click,因为它对子命令、参数默认值、帮助信息的处理更省心。
安装依赖:
pip install click pyyaml3.2 实现文本读取与编码处理
这是整个项目里最琐碎的部分,也是最容易踩坑的部分。输入文件的编码如果和读取时用的编码不一致,轻则乱码,重则直接抛 UnicodeDecodeError。
我的做法如下:
import chardet def detect_encoding(path): with open(path, "rb") as f: raw = f.read(4096) result = chardet.detect(raw) return result.get("encoding") or "utf-8"chardet库可以根据字节内容判断文件编码。虽然它不完全可靠,但大多数情况下能给出一个可用的结果。如果检测出来是GB2312或者gbk,就统一映射成gb18030,因为gb18030是 GBK 的超集,覆盖中文字符更全。
检测到编码后,用流式方式逐行读取:
def iter_lines(path, encoding): with open(path, "r", encoding=encoding, errors="replace") as f: for line in f: yield line.rstrip("\r\n")注意这里用了errors="replace",意味着如果某一行有个别非法字节,不会中断整个任务,而是用替换字符代替。这在处理脏数据时是保命选项,宁可让某一行内容出现一个乱码字符,也不能让整个任务跑到一半挂掉。
3.3 实现可配置提取规则
规则的加载和编译逻辑如下:
import re import yaml class Rule: def __init__(self, raw_rule): self.name = raw_rule["name"] self.title = raw_rule.get("title", self.name) self.pre_filter = raw_rule.get("pre_filter", "") self.pattern = raw_rule["pattern"] self.skip_if_absent = raw_rule.get("skip_if_absent", []) self.regex = re.compile(self.pattern) def load_rules(path): with open(path, "r", encoding="utf-8") as f: data = yaml.safe_load(f) rules = [Rule(raw) for raw in data.get("rules", [])] return rules这里有一个容易忽略的细节:YAML 里的pattern字段使用了>多行写法,这会自动把换行变成空格。如果你把正则写在多行里,最终拼接后的字符串中间会多出空格,从而破坏正则。前面示例中我把pattern写成两行,是因为正则本身允许在空白处灵活匹配,但这不是所有正则都能接受的。如果你的正则严格依赖字符序列,请务必把pattern写成单行字符串,或者用|显式控制换行。
# 推荐:单行,避免 YAML 折行带来的意外空格 pattern: '\[(?P<time>.*?)\] INFO 用户ID=(?P<user>\w+) 操作=(?P<action>\S+) result=(?P<result>\S+) 耗时=(?P<cost>\d+)ms'3.4 实现批处理与统计
核心扫描函数长这样:
def extract_from_file(file_path, rules, want_line_numbers=False): encoding = detect_encoding(file_path) collected = [] for line_no, line in enumerate(iter_lines(file_path, encoding), start=1): for rule in rules: if rule.pre_filter and rule.pre_filter not in line: continue match = rule.regex.search(line) if not match: continue record = {"_rule": rule.name, "_file": file_path, "_line": line_no} valid = True for group_name in rule.regex.groupindex.keys(): value = match.group(group_name) if rule.skip_if_absent and group_name in rule.skip_if_absent and not value: valid = False break record[group_name] = value.strip() if value else "" if valid: if not want_line_numbers: record.pop("_line", None) collected.append(record) return collected这里有一个小设计:无论用户要不要行号,我在内部都会先记录_line,最后再决定是否留在输出里。这是为了调试方便,一旦后续处理需要回溯到原始日志,我们仍然有线索。实际跑数据那天,我靠这个行号字段定位了至少三处规则写错的地方。
批量处理目录时,用os.walk递归遍历文件,并对每个文件调用上述函数,最后合并结果。如果文件数量极大,可以按文件维度用多进程并行加速,但要注意输出顺序稳定性。我的经验是先不用并行,等确实慢到不能接受再考虑,才能避免过早优化带来的复杂度。
3.5 输出与打包
输出 JSON 很简单,关键是处理中文字符。默认的json.dump会把中文转成\uXXXX转义序列,虽然是合法 JSON,但人看起来非常难受。务必设置:
import json with open(output_path, "w", encoding="utf-8") as f: json.dump(results, f, ensure_ascii=False, indent=2)如果输出 CSV,用 Python 内置的csv模块,并设置newline="",否则在 Windows 上会多出空行。所有字段名需要从结果记录里动态合并,因为不同规则提取出的字段可能不一样。
最后,我用 PyInstaller 把脚本打包成单个可执行文件,这样目标机器上不需要安装 Python 环境。虽然 PyInstaller 打出来的包体积有点大,但部署起来非常省事。
4. 真实使用中的常见问题与排查实录
4.1 中文编码乱码问题
这是遇到最多的一个问题。明明文件用记事本打开看得好好的,程序处理完输出 JSON 却出现大量乱码。根源通常是文件本身不是 UTF-8,可能是 GBK 或者其他编码,而chardet在某些情况下会误判。
我的排查步骤是:
- 先用
chardet检测结果看一下。 - 用
errors="replace"跑一遍,如果乱码集中在某些字符,手动指定编码重跑一次。 - 常见的中文日志文件,优先尝试
gb18030,因为它能兼容 GBK 和 GB2312。 - 如果文件开头带 BOM,读取时记得用
utf-8-sig编码,否则第一行会多出一个\ufeff字符。
这里给一个小建议:规则文件本身永远用 UTF-8 保存。这样即使输入文件编码各不一样,规则的中文匹配不会出问题。
4.2 正则写对了却匹配不到内容
有几次我看规则文件觉得完全没问题,正则在测试网站上也匹配成功,但实际跑出来就是 0 条结果。排查后发现,问题出在pre_filter。如果pre_filter设置为"INFO",而实际日志行里 INFO 是全大写没问题,但有的行是INFO#12345这种带后缀,或者INFO与后面内容之间不是空格而是制表符,这些都不影响包含关系。真正影响的是大小写差异,比如输出日志里用的是Info,那"INFO" not in line就直接跳过了所有目标行。
建议是把pre_filter设置成完全确定会出现的子串,哪怕多写几个字都行,为了速度和准确性,不要图省事写一个模糊词。
还有一次是用.匹配不了换行导致跨行日志提取失败。这时可以在正则里加re.DOTALL标志。但要注意,开了 DOTALL 后,.会把整个文件都可能匹配进去,必须配合非贪婪量词.*?使用,并且尽可能添加结束锚点。比如:
pattern: '开始时间(?P<start>.*?)结束时间'这样即使中间有换行,也不会无限吞掉后面的内容。
4.3 大量文件处理太慢怎么办
rea 最初跑一个 20MB 的日志大概要 20 秒,这包括编码探测、逐行读取和正则匹配。如果文件变成几百 MB,时间就会线性增长。我做的第一个优化是调整pre_filter,让大量无关行根本不进入正则阶段。这个优化效果最明显,因为正则匹配是相对昂贵的操作,而一次str in line查询是 C 级别的快速扫描。
第二个优化是使用并行处理。Python 的多线程在这种场景下没什么用,因为 GIL 会限制 CPU 密集型任务;用multiprocessing按文件拆分任务,每个进程处理若干文件。我的经验是 4 进程时对机械硬盘并不友好,可能导致磁头频繁跳动,反而不如单进程;但配合 SSD,4 到 8 进程的加速比还是比较理想的。
4.4 输出结果对不上原始行号
如果你开启了--line-numbers,但输出的行号和日志里实际对不上,第一个检查点是文件读取时是否保留了空行。日志里经常有空行,如果一个空行没有被跳过,行号偏移就会出现。rea 默认不跳过空行,因为空行在某些自定义格式里也可能是有效记录。如果需要忽略空行,建议在规则层面通过pre_filter排除,或者接受行号对应的是"程序读取到的行号"而不是"日志编辑器显示的行号"。
另外,Windows 的行尾是\r\n,我在迭代器里已经用rstrip("\r\n")处理掉了,所以这一块不会影响行号。但如果有人用其他脚本读取 rea 输出的 CSV,在 Excel 里遇到行尾不一致导致串行,那通常是 CSV 自身的换行问题,不是行号问题。
4.5 常见问题速查表
| 症状 | 可能原因 | 解决建议 |
|---|---|---|
| 中文乱码 | 输入文件编码非 UTF-8,chardet 误判 | 手动指定--encoding gb18030重试 |
| 一条都没匹配到 | pre_filter 大小写不匹配或含义不清 | 检查目标行实际样子,改用精确子串 |
| 字段缺失或为空 | 正则里命名分组的可选分支没匹配 | 使用skip_if_absent丢弃不完整记录 |
| 跨行文本只取到第一行 | 默认.不匹配换行 | 给 pattern 增加 DOTALL 并确保非贪婪 |
| 输出 JSON 中中文被转义 | 缺少ensure_ascii=False | 在 json.dump 中显式设置 |
| CSV 在 Excel 中多空行 | 写入时未指定newline="" | 使用open(..., newline="") |
| 处理大文件内存暴涨 | 结果集全部加载到内存 | 按需改写成流式写文件,边扫边写 |
| 行号偏了 N 行 | 有空行被计入 | 明确是否跳过空行,统一预期 |
5. 一些没写进代码的经验:使用习惯与扩展余地
说实话,rea 的核心代码量不大,真正让我觉得这个工具有价值的是使用习惯的沉淀。我在实际使用中发现,规则文件本身就是最好的文档。以前处理完一批日志,过两周想复盘当时怎么做的,还需要翻聊天记录;现在只要看一眼rules/demo_rules.yaml,整个处理思路清清楚楚。因此每次建立新规则时,我会在 YAML 加注释说明这个规则的适用场景和数据样例,这个习惯带来的收益远超预期。
rea 后面的扩展方向,我目前有两条路:一是把规则改成支持多文件多规则的组合管线,比如某些文件用规则 A 提取,提取结果作为上下文去匹配规则 B;二是给输出层加一个 SQLite 支持,把提取结果直接写入表,方便后续查询。对随手工具来说,这两条路哪条先用,取决于下次需要分析的数据形态。
如果你也想做一个类似的本地文本处理工具,我的建议是:不要一开始就想着做一个平台,先想办法减少自己下一次重复劳动的时间。哪怕只有三条规则,只要跑得顺手,就是赚到的。