拿到abd 3death(q3*1 dc*2)这样的原始文本时,第一个直觉通常是把它直接拼成字符串交给下游,但实际工程里这样做会在同一套数据换了一组写法后立刻出问题。这类文本既不像 JSON 有明确的键值边界,也不像固定 ID 有严格规则,它更像业务同学或配置文件中留下来的短码,携带了符号、重复次数和分组信息。如果要做统计、批量生成或自动执行,第一步不是猜语义,而是先把结构拆出来。
这篇内容会从一个只含有少量信息的待解析样本出发,写一个不依赖第三方库的 Python 解析器。最终能把abd、3death、q3*1、dc*2这类片段解析成结构化 Token,再根据规则还原成“按顺序输出哪些符号、每个符号出现多少次”的结果。整个过程会覆盖概念假设、环境准备、代码实现、运行验证、边界测试、异常排查和生产化建议,适合作为文本解析类小项目的起步模板。
1. 先理解这种混合文本需要表达什么
1.1 原始串里的三类信息
abd 3death(q3*1 dc*2)不是一个标准的配置文件,也不是数据库里常见的字段值。它是由多段短码拼在一起的自然文本,常见于临时备注、测试用例说明、批处理参数或任务描述里。在没有明确协议的情况下,绝不能假设别人已经定义了字段格式,但可以先用工程经验做最小结构假设。
把原始串拆开看,可以分成三类片段:
| 原始片段 | 外形特点 | 常见表达含义 | 解析后建议 |
|---|---|---|---|
abd | 纯英文字母 | 一个裸符号,没有明显重复标记 | 重复次数按 1 处理 |
3death | 数字在前,单词在后 | 某个符号重复 3 次 | symbol=death,repeat=3 |
q3*1 | 符号加*1 | q3这个符号重复 1 次 | symbol=q3,repeat=1 |
dc*2 | 符号加*2 | dc这个符号重复 2 次 | symbol=dc,repeat=2 |
(...) | 中文括号 | 一段分组或备注区域,括号本身不表示特定优先级 | 只作为分组边界,不进入最终符号 |
按这组假设,目标解析结果就是四个 Token:abd(1)、death(3)、q3(1)、dc(2)。如果继续展开,顺序列表就是["abd", "death", "death", "death", "q3", "dc", "dc"]。
这里要强调一句:这种结构假设只是为了把样例变成可运行程序。真实项目中,如果上游没有给出规则文档,先要把规则书面化并和需求方确认。直接按猜测写解析器,后续所有统计结果都可能建立在错误规则上。
1.2 解析后要支撑什么下游工作
解析成结构化数据不是为了输出一个漂亮 JSON,而是为了满足下游不同使用方式。
第一种下游场景是统计。比如要统计每个符号在整段序列里出现多少次,直接拿到展开后的列表再统计就行。
第二种下游场景是生成执行任务。如果每个符号代表一种操作,那么展开后的顺序就代表要依次执行的任务序列。
第三种下游场景是回显和追踪。原始写法3death和dc*2保留了书写习惯,但机器处理时需要统一成symbol + repeat结构,所以每个 Token 还应该保留source字段,便于出现问题之后快速对比“原始文本和解析结果”。
如果只做字符串拼接,这些能力全部要重复开发。用 Token 列表承载信息,后续所有功能都只需要围绕列表做二次加工。
2. 环境准备和项目文件拆分
2.1 Python 版本和依赖
实现这个解析器使用 Python 3.9 及以上版本即可,代码只用标准库,不额外安装第三方依赖。
| 项目 | 要求 |
|---|---|
| 操作系统 | Windows、Linux、macOS 均可 |
| Python 版本 | 3.9 及以上 |
| 第三方依赖 | 无 |
| 命令行工具 | 能执行python或python3 |
| 建议开发方式 | 在项目目录中使用虚拟环境执行 |
如果原始环境只有 Python 3.6,那么代码里的 dataclass 也仍可用,但在使用一些新类型语法时需要回退。实际项目里建议先执行python --version确认版本,避免后面因为版本问题误判代码错误。
代码中的模块只使用标准库,原因有两个。第一,文本解析规则变化很快,第三方库可能带来额外的学习成本;第二,这个场景的输入规模通常不大,标准库正则和字符串处理已经够用,没有必要为了一个简单解析器引入大型解析框架。
2.2 项目目录与文件职责
为了后续测试和维护,建议把代码拆成三个文件:
sequence_parser/ ├── seq_parser.py ├── main.py └── test_quick.pyseq_parser.py放核心解析逻辑,包含字符清洗、括号拆分、片段解析、Token 定义和序列展开函数。main.py提供命令行入口,方便用--text传入不同字符串直接验证。test_quick.py放最小回归测试,每次改了规则后先跑一遍,确保基础案例没有被破坏。
在学习环境里,即使是单文件也能跑通,但拆开文件会让调试变得更清楚。尤其当解析规则变多之后,把纯函数和命令行交互混在同一个文件里,容易出现“想验证解析结果却要先处理参数”的困境。
3. 实现一个最小但可扩展的解析器
3.1 用数据类定义 Token 结构
Token 是解析器里最重要的中间结构。它至少需要保存三个信息:解析出来的符号名、重复次数、原始片段。
from dataclasses import dataclass @dataclass(frozen=True) class Token: symbol: str repeat: int source: str这里把 dataclass 设成frozen=True,好处是 Token 一旦创建就不能被误改。如果后续发现解析结果错误,应该回源头修正解析规则,而不是在业务代码里偷偷修改 Token。对不可变对象做缓存、集合比较或日志输出也更安全。
注意symbol和source是有区别的。source是原始片段,比如3death;symbol是去掉重复次数后的符号,比如death。如果不区分这两个字段,一个片段可能同时既当原始文本又当解析结果,会给调试造成混乱。
3.2 先做字符清洗:把全角括号和乘号统一成半角
样例里写的是中文括号(),还可能出现中文乘号×。直接写正则匹配半角括号会漏掉这些内容,所以第一步要先清洗输入,把可能存在的全角符号映射成半角。
import re _WIDE_MAP = str.maketrans({ "(": "(", ")": ")", "×": "*", "*": "*", ",": ",", ":": ":", ";": ";", }) def normalize_text(raw_text: str) -> str: if not raw_text: return "" text = raw_text.translate(_WIDE_MAP) text = text.strip().replace("\u3000", " ") text = re.sub(r"\s+", " ", text) return textstr.translate会把字符串里所有匹配到的字符替换掉,比一条条str.replace更高效,也更容易维护。后面需要新增映射时,往_WIDE_MAP字典里加一项即可。
这里只处理符号类的全角字符,不会把全角汉字转成半角,因为汉字本身就是业务文本的一部分。常见的空格问题也需要处理,比如字符串中间混入全角空格或连续空格,统一用re.sub(r"\s+", " ", text)压缩成单个半角空格。
注意:字符清洗必须在解析前完成。已经写好的解析正则可以默认输入已经是半角格式,后续代码看起来会清爽很多。调试时如果解析结果不符,第一件事先打印
normalize_text(text)后的中间结果。
3.3 拆分顶层括号片段和普通片段
样例里出现了(q3*1 dc*2),括号内部整体作为一组。但解析结果又要保留顺序,不能让括号内的内容跑到最后,因此不能用text.split()一次性全拆掉,因为3death(q3*1 dc*2)这个前后紧邻的关系会丢失。
可以用re.split同时抓出括号与非括号片段:
def split_grouped_fragments(text: str): part_list = re.split(r"(\([^()]*\))", text) for part in part_list: part = part.strip() if not part: continue if part.startswith("(") and part.endswith(")"): yield ("group", part[1:-1]) else: for piece in part.split(): yield ("plain", piece)re.split的正则用捕获括号包起来,所以匹配到的括号片段也会出现在返回值里。这样abd 3death(q3*1 dc*2)会被拆成三块:abd 3death、(q3*1 dc*2)、空字符串。
普通片段继续用空格切,得到abd和3death。括号片段去掉左右括号后用空格切,得到q3*1和dc*2。这种做法的好处是,只要括号不嵌套,后续即使增加新的分组标记也能沿用同一套顺序逻辑。
这个函数故意不解析嵌套括号。如果文本真正复杂到a(b(c))这种深度嵌套,就需要换用完整的递归下降或栈式解析器,而不是继续堆正则。
3.4 把单个片段解析成 Token
单个片段可能长这样:abd、3death、q3*1、dc*2、dc*0,还可能出现未知写法。需要逐类匹配。
_TOKEN_NUMBER_PREFIX = re.compile( r"^(?P<count>\d+)(?P<symbol>[A-Za-z_][A-Za-z0-9_-]*)$" ) _TOKEN_NUMBER_SUFFIX = re.compile( r"^(?P<symbol>[A-Za-z_][A-Za-z0-9_-]*)\*(?P<count>\d+)$" ) def _parse_fragment(fragment: str, strict: bool) -> Token: frag = fragment if not frag: if strict: raise ValueError("empty fragment") return Token(frag, 1, frag) match = _TOKEN_NUMBER_PREFIX.fullmatch(frag) if match: return Token(match.group("symbol"), int(match.group("count")), frag) match = _TOKEN_NUMBER_SUFFIX.fullmatch(frag) if match: return Token(match.group("symbol"), int(match.group("count")), frag) if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_-]*", frag): return Token(frag, 1, frag) if strict: raise ValueError(f"unknown fragment: {frag}") return Token(frag, 1, frag)第一个正则处理数字在前的情况,如3death会解析成 symbol=death、repeat=3。第二个正则处理*数字在结尾的情况,如dc*2会解析成 symbol=dc、repeat=2。
这里特意用fullmatch而不是match。match只要开头匹配就会成功,fullmatch要求整段完全匹配,能避免把3death_extra这种畸形片段错误解析成death重复 3 次。
对于完全未知的片段,提供两种策略:
- 非严格模式:保留原始文本作为 symbol,repeat 按 1 处理。
- 严格模式:直接抛
ValueError。
学习阶段建议先开非严格模式观察输出,进入生产环境后再使用严格模式,避免脏数据静默通过。
3.5 组装主解析函数并支持展开
有了片段拆分和单片段解析,主解析函数就是把它们组合起来。
from typing import List def parse_sequence(raw_text: str, strict: bool = False) -> List[Token]: tokens: List[Token] = [] text = normalize_text(raw_text) for kind, inner in split_grouped_fragments(text): if kind == "group": for frag in inner.split(): tokens.append(_parse_fragment(frag, strict)) else: tokens.append(_parse_fragment(inner, strict)) return tokensparse_sequence返回的是 Token 列表,而不是展开后的字符串列表,这样保留的信息更多。比如下游要统计每个符号出现次数,不需要再把5death这种原始写法重新拆一遍,直接读取repeat字段即可。
如果要获得顺序执行列表,再单独写一个展开函数:
def expand_sequence(tokens: List[Token]) -> List[str]: expanded: List[str] = [] for token in tokens: if token.repeat > 0: expanded.extend([token.symbol] * token.repeat) return expanded展开函数对 repeat 做大于 0 的判断,避免负数和 0 对后续生成任务产生副作用。如果你的业务里 repeat=0 表示“这条记录仍然要有,但不参与展开”,当前实现也符合预期。
3.6 给命令行入口准备 JSON 输出
把核心逻辑接到main.py,让程序能直接用参数运行。
import argparse import json from seq_parser import parse_sequence, expand_sequence, tokens_to_dicts def tokens_to_dicts(tokens): return [ { "symbol": token.symbol, "repeat": token.repeat, "source": token.source, } for token in tokens ] def main(): parser = argparse.ArgumentParser( description="解析类似 abd 3death(q3*1 dc*2) 的符号序列" ) parser.add_argument( "--text", default="abd 3death(q3*1 dc*2)", help="待解析文本", ) parser.add_argument( "--expand", action="store_true", help="输出展开后的序列", ) parser.add_argument( "--strict", action="store_true", help="遇到未知片段时报错", ) args = parser.parse_args() tokens = parse_sequence(args.text, strict=args.strict) result = { "input": args.text, "tokens": tokens_to_dicts(tokens), } if args.expand: result["expanded"] = expand_sequence(tokens) print(json.dumps(result, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()ensure_ascii=False很重要,否则 JSON 里的中文括号和汉字会被转成\u开头的内容,虽然机器能识别,但人看日志时很难受。
4. 运行验证和最小回归测试
4.1 直接运行主程序
在sequence_parser目录下执行:
python main.py默认会解析样例abd 3death(q3*1 dc*2),预期输出类似下面这样:
{ "input": "abd 3death(q3*1 dc*2)", "tokens": [ { "symbol": "abd", "repeat": 1, "source": "abd" }, { "symbol": "death", "repeat": 3, "source": "3death" }, { "symbol": "q3", "repeat": 1, "source": "q3*1" }, { "symbol": "dc", "repeat": 2, "source": "dc*2" } ] }关键校验点不是“程序能跑”,而是3death是否被正确拆成 symbol=death、repeat=3。如果这里输出成 symbol=3death、repeat=1,说明前缀计数正则没有命中,要去检查_TOKEN_NUMBER_PREFIX的写法或是否在解析前做了字符清洗。
4.2 验证展开顺序
加上--expand参数:
python main.py --expandJSON 结果末尾会多出一段:
{ "tokens": [ { "symbol": "abd", "repeat": 1, "source": "abd" }, { "symbol": "death", "repeat": 3, "source": "3death" }, { "symbol": "q3", "repeat": 1, "source": "q3*1" }, { "symbol": "dc", "repeat": 2, "source": "dc*2" } ], "expanded": [ "abd", "death", "death", "death", "q3", "dc", "dc" ] }展开顺序表示的是解析后的顺序,而不是单纯字符串从左到右的顺序。如果原始括号前后有特殊语义,仍然需要在业务层单独处理。
4.3 写一组最小回归测试
文本解析最怕后续加规则时把已有行为改坏。建议写一个test_quick.py,至少保存一个正常案例、一个严格模式案例和一个边界案例。
import unittest from seq_parser import parse_sequence, expand_sequence class SeqParserTest(unittest.TestCase): def test_main_case(self): tokens = parse_sequence("abd 3death(q3*1 dc*2)") self.assertEqual( [token.symbol for token in tokens], ["abd", "death", "q3", "dc"], ) self.assertEqual( [token.repeat for token in tokens], [1, 3, 1, 2], ) self.assertEqual( expand_sequence(tokens), ["abd", "death", "death", "death", "q3", "dc", "dc"], ) def test_empty_input(self): self.assertEqual(parse_sequence(""), []) def test_strict_unknown_fragment(self): with self.assertRaises(ValueError): parse_sequence("abd bad^sym dc*2", strict=True) if __name__ == "__main__": unittest.main()执行:
python test_quick.py如果三个测试全部通过,说明解析器在当前规则下表现稳定。test_empty_input看似简单,却能在你修改normalize_text和split_grouped_fragments时快速发现空串被误处理成含一个空 Token 的问题。
4.4 用命令行做多组输入对比
在开发阶段,可以准备几条不同类型的数据反复验证:
python main.py --text "abd 3death(q3*1 dc*2)" python main.py --text "alpha beta*3 gamma" --expand python main.py --text "a*0 b" --expand遇到中文空格、全角乘号等情况也不用改代码,直接把原始字符串传给--text即可。因为main.py内部先执行normalize_text,外部输入仍保持原始样子更接近真实场景。
5. 为什么拆成 Token 而不是直接替换字符串
5.1 直接字符串替换的典型事故
如果需求简单,可能有人想直接在原始串里把3death替换成三个death,把dc*2替换成两个dc。这种思路在样例上碰巧可用,但一旦规则复杂就会出问题。
假设原始文本是3deathx。直接做replace("death", "death death death"),原始片段被拆坏;如果先提取数字再按次数复制单词,又需要处理后续单词边界、下划线、连字符等情况。更麻烦的是,如果一个符号既出现3death,又出现death,直接替换很难区分两类规则。
用re.sub也能写,但正则表达式会变得越来越复杂,而且缺少结构化中间层。到最后输入只要换个写法,正则就要跟着修。
5.2 Token 方式更利于处理重复和统计
Token 方式把“重复次数”从字符串语法中剥离出来。比如统计每个符号的总次数,直接遍历 Token 累加 repeat 即可,不需要再经历一次展开。如果执行任务时只想跳过 repeat=0 的项,也只需要在生成层判断。
展开列表适合“明确要按顺序执行 N 次”的任务,Token 列表适合“保留结构并支持灵活统计”的任务。两者互补,不应该二选一。这也是为什么主解析函数返回 Token,而--expand只作为可选输出。
5.3 四种设计取舍速查
| 设计点 | 可选做法 | 当前选择 | 理由 |
|---|---|---|---|
| Token 是否保留原始片段 | 只存 symbol/repeat | 同时保留 source | 日志和问题追踪需要回看原文 |
| 未知片段处理 | 丢弃/抛错/按原样保留 | 非严格保留,严格抛错 | 保证宽进严出,便于逐步收紧 |
| 括号语义 | 忽略/内部优先/仅分组 | 仅分组,保留顺序 | 样例没有说明更高语义,不能乱猜 |
| 展开 repeat=0 | 跳过/报错/原样输出 | 展开时跳过 | 避免异常值影响执行层 |
6. 常见异常和排查顺序
6.1 把异常现象和原因先对应起来
解析类程序报错通常不是复杂问题,更多是字符、正则或顺序出问题。下面把这些高频坑整理成表。
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
3death没有被拆分 | 没有调用normalize_text,或前缀正则写成了普通match | 打印清洗后文本,单独测试_TOKEN_NUMBER_PREFIX.fullmatch("3death") | 使用fullmatch,并在解析前统一字符 |
| 中文括号内容没有被识别 | 正则只支持半角括号 | 查看normalize_text是否包含(到(的映射 | 补齐全角符号映射 |
dc*2没被解析为 repeat=2 | 输入里是中文星号*,不是半角* | 对输入做repr()查看字符编码 | 增加*: *映射 |
| 输出顺序不符合预期 | 拆括号时把分组内容追加到了最后 | 打印split_grouped_fragments结果 | 保持原顺序遍历每个片段 |
| 空字符串产生了一个空 Token | _parse_fragment("")没有提前处理 | 查看 parse_sequence 空输入路径 | 在 normalize 或 parse 开头直接返回空列表 |
| 未知片段在严格模式下不报错 | strict 参数没有传递到_parse_fragment | 检查调用链 | 严格模式必须向下传递,否则静默吞掉脏数据 |
6.2 推荐按这条链路排查
当输出和预期不同,不要先翻正则。按下面顺序排查通常更快。
先检查输入在进入解析器前是否被正确清洗。打印normalize_text(raw_text),看中文括号是否变成半角,连续空格是否被压缩。很多时候问题不在主逻辑,而在最开头的字符映射。
再检查拆分结果。打印list(split_grouped_fragments(text)),确认括号片段和普通片段是否被完整还原。如果这一步顺序已经不对,后面的 Token 排列大概率也不对。
接着检查单个片段解析。对每个有问题的片段单独执行_parse_fragment,看能否得到预期的 symbol 和 repeat。这能把问题缩小到“正则匹配规则”而不是“整个解析函数”。
最后检查调用参数。确认 strict 参数是否按预期生效,确认--text传入的原始字符串没有被终端转义破坏。
6.3 最常见的三个踩坑点
第一个坑是字符清洗不完整。样例中的中文括号很容易被忽略,一旦用户手动改成半角括号就正常,但这并不代表所有数据都正常。正确做法是把清洗集中在一个函数里,不让业务代码依赖“外部输入恰好是半角”。
第二个坑是match与fullmatch混用。用re.match(r"\d+...", "3death_2")时会误判,导致看起来“解析成功”但实际得到错误的 symbol 或 repeat。推荐所有片段规则都使用fullmatch。
第三个坑是 unknown 片段被 quietly 保留后,没有留下任何日志。非严格模式设计了兜底,但在生产环境里需要额外记录一条告警,帮助发现新格式。只“不报错”还不够,要让异常情况可见。
7. 从样例走向生产:参数设计、规则配置和检查清单
7.1 把规则参数化而不是散落在代码里
当前解析器把正则写死在seq_parser.py里。对学习项目来说可以接受,但进入生产后,建议把规则配置外置,方便不同业务方在不清代码的前提下修改格式。
可以维护一个最小配置结构:
RULES = { "normalize_wide_signs": True, "max_repeat": 100000, "allow_repeat_zero": True, "strict_unknown": True, }max_repeat用来防止999999999999death这种输入导致展开列表过大。展开前应该先校验 repeat 是否超过业务上限,避免内存被打满。allow_repeat_zero决定是否是合法输入,如果某些下游不接受 0 次任务,就应该在解析阶段直接拒绝,而不是等展开后再悄悄跳过。
参数对实际代码的影响很大,设计时要回答清楚:重复次数允许为 0 吗?允许最大是多少?未知写法是容忍还是报错?同一符号重复出现在不同位置时,是累计还是分开存储?这些决策需要写进文档。
7.2 生产环境需要额外补的防护
如果要把这个解析器接到真实任务系统,至少还要考虑下面几项。
第一,日志要同时保留原始输入和解析结果。原始输入用于审计,解析结果用于排错。不要把原始输入只放在临时的 print 里,否则线上出现脏数据时缺少对照。
第二,未知字段要告警。非严格模式适合前期探索,不建议直接在生产长时间开启。生产环境里新增一种不认识的写法,通常意味着上游规则发生了变化,应该通过日志或监控暴露出来。
第三,所有外部输入都要做长度限制。比如单条文本超过 64KB 就不再继续解析,避免异常数据占用太多 CPU 和内存。
第四,解析函数要保持无副作用。不要在函数内部读写数据库、请求远程接口或改全局状态。解析函数只做输入到输出的转换,后续调用者才能自由决定是批量处理还是逐条处理。
7.3 这个解析器之后可以怎么扩展
如果输入从“一层括号”变成“多层括号”,建议不要继续加大正则,换成手写递归解析或引入成熟的解析库。常见的可选方案包括 Python 标准库的ast来做规则语法分析、pyparsing定义更复杂的语法规则,或者参考 shlex 的拆分思路。
如果还希望支持嵌套语义,比如2(q3*1 dc*2)代表整组重复两次,那么当前 Token 结构就不够用,需要定义 GroupToken。主解析函数也需要从遍历片段改成递归下降,按左括号切入、右括号返回。
如果业务方想用中文逗号分隔并列项,可以在split_grouped_fragments里增加半角/全角逗号的分隔逻辑。注意增加分隔逻辑前,先确认逗号在真实数据里不会出现在符号内部,否则会误切。
7.4 文本解析类项目上线前检查清单
每次准备把这类解析器发到测试或生产环境前,可以对照下面清单做快速检查:
| 检查项 | 检查内容 |
|---|---|
| 编码统一 | 是否处理 UTF-8 中的全角符号、空格、换行符 |
| 空输入 | 空字符串、纯空格、只有括号的输入是否返回空列表 |
| 重复次数边界 | 0、1、大数、非法字符分别如何表现 |
| 未知片段策略 | strict 和 non-strict 是否都符合预期 |
| 顺序保持 | 普通片段和括号片段在解析后仍保持原始顺序 |
| 展开安全 | 展开列表长度是否有限制,是否可能撑爆内存 |
| 日志内容 | 是否记录原始文本、清洗后文本、解析结果或错误原因 |
| 回归测试 | 至少有一个样例测试、一个边界测试、一个失败分支测试 |
这个样例看起来短,但把它拆成 Token 后,已经具备进入真实流程的基础。实际使用中,最值得保留的技术判断是:不要因为输入像一段短文本就只做字符串处理,先把规则显式化,再用结构和测试兜住后续变化。下一个任务出现另一种写法时,你只要在规则层增加映射,而不是重写所有处理逻辑。