ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python正则解析混合文本:将‘3death‘和‘dc*2‘拆成Token

Python正则解析混合文本:将‘3death‘和‘dc*2‘拆成Token 拿到abd 3deathq3*1 dc*2这样的原始文本时第一个直觉通常是把它直接拼成字符串交给下游但实际工程里这样做会在同一套数据换了一组写法后立刻出问题。这类文本既不像 JSON 有明确的键值边界也不像固定 ID 有严格规则它更像业务同学或配置文件中留下来的短码携带了符号、重复次数和分组信息。如果要做统计、批量生成或自动执行第一步不是猜语义而是先把结构拆出来。这篇内容会从一个只含有少量信息的待解析样本出发写一个不依赖第三方库的 Python 解析器。最终能把abd、3death、q3*1、dc*2这类片段解析成结构化 Token再根据规则还原成“按顺序输出哪些符号、每个符号出现多少次”的结果。整个过程会覆盖概念假设、环境准备、代码实现、运行验证、边界测试、异常排查和生产化建议适合作为文本解析类小项目的起步模板。1. 先理解这种混合文本需要表达什么1.1 原始串里的三类信息abd 3deathq3*1 dc*2不是一个标准的配置文件也不是数据库里常见的字段值。它是由多段短码拼在一起的自然文本常见于临时备注、测试用例说明、批处理参数或任务描述里。在没有明确协议的情况下绝不能假设别人已经定义了字段格式但可以先用工程经验做最小结构假设。把原始串拆开看可以分成三类片段原始片段外形特点常见表达含义解析后建议abd纯英文字母一个裸符号没有明显重复标记重复次数按 1 处理3death数字在前单词在后某个符号重复 3 次symboldeathrepeat3q3*1符号加*1q3这个符号重复 1 次symbolq3repeat1dc*2符号加*2dc这个符号重复 2 次symboldcrepeat2...中文括号一段分组或备注区域括号本身不表示特定优先级只作为分组边界不进入最终符号按这组假设目标解析结果就是四个 Tokenabd(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(frozenTrue) class Token: symbol: str repeat: int source: str这里把 dataclass 设成frozenTrue好处是 Token 一旦创建就不能被误改。如果后续发现解析结果错误应该回源头修正解析规则而不是在业务代码里偷偷修改 Token。对不可变对象做缓存、集合比较或日志输出也更安全。注意symbol和source是有区别的。source是原始片段比如3deathsymbol是去掉重复次数后的符号比如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^(?Pcount\d)(?Psymbol[A-Za-z_][A-Za-z0-9_-]*)$ ) _TOKEN_NUMBER_SUFFIX re.compile( r^(?Psymbol[A-Za-z_][A-Za-z0-9_-]*)\*(?Pcount\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(funknown fragment: {frag}) return Token(frag, 1, frag)第一个正则处理数字在前的情况如3death会解析成 symboldeath、repeat3。第二个正则处理*数字在结尾的情况如dc*2会解析成 symboldc、repeat2。这里特意用fullmatch而不是match。match只要开头匹配就会成功fullmatch要求整段完全匹配能避免把3death_extra这种畸形片段错误解析成death重复 3 次。对于完全未知的片段提供两种策略非严格模式保留原始文本作为 symbolrepeat 按 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 对后续生成任务产生副作用。如果你的业务里 repeat0 表示“这条记录仍然要有但不参与展开”当前实现也符合预期。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 3deathq3*1 dc*2 的符号序列 ) parser.add_argument( --text, defaultabd 3deathq3*1 dc*2, help待解析文本, ) parser.add_argument( --expand, actionstore_true, help输出展开后的序列, ) parser.add_argument( --strict, actionstore_true, help遇到未知片段时报错, ) args parser.parse_args() tokens parse_sequence(args.text, strictargs.strict) result { input: args.text, tokens: tokens_to_dicts(tokens), } if args.expand: result[expanded] expand_sequence(tokens) print(json.dumps(result, ensure_asciiFalse, indent2)) if __name__ __main__: main()ensure_asciiFalse很重要否则 JSON 里的中文括号和汉字会被转成\u开头的内容虽然机器能识别但人看日志时很难受。4. 运行验证和最小回归测试4.1 直接运行主程序在sequence_parser目录下执行python main.py默认会解析样例abd 3deathq3*1 dc*2预期输出类似下面这样{ input: abd 3deathq3*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是否被正确拆成 symboldeath、repeat3。如果这里输出成 symbol3death、repeat1说明前缀计数正则没有命中要去检查_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 3deathq3*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, strictTrue) 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 3deathq3*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 即可不需要再经历一次展开。如果执行任务时只想跳过 repeat0 的项也只需要在生成层判断。展开列表适合“明确要按顺序执行 N 次”的任务Token 列表适合“保留结构并支持灵活统计”的任务。两者互补不应该二选一。这也是为什么主解析函数返回 Token而--expand只作为可选输出。5.3 四种设计取舍速查设计点可选做法当前选择理由Token 是否保留原始片段只存 symbol/repeat同时保留 source日志和问题追踪需要回看原文未知片段处理丢弃/抛错/按原样保留非严格保留严格抛错保证宽进严出便于逐步收紧括号语义忽略/内部优先/仅分组仅分组保留顺序样例没有说明更高语义不能乱猜展开 repeat0跳过/报错/原样输出展开时跳过避免异常值影响执行层6. 常见异常和排查顺序6.1 把异常现象和原因先对应起来解析类程序报错通常不是复杂问题更多是字符、正则或顺序出问题。下面把这些高频坑整理成表。问题现象常见原因检查方式处理建议3death没有被拆分没有调用normalize_text或前缀正则写成了普通match打印清洗后文本单独测试_TOKEN_NUMBER_PREFIX.fullmatch(3death)使用fullmatch并在解析前统一字符中文括号内容没有被识别正则只支持半角括号查看normalize_text是否包含到(的映射补齐全角符号映射dc*2没被解析为 repeat2输入里是中文星号不是半角*对输入做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 后已经具备进入真实流程的基础。实际使用中最值得保留的技术判断是不要因为输入像一段短文本就只做字符串处理先把规则显式化再用结构和测试兜住后续变化。下一个任务出现另一种写法时你只要在规则层增加映射而不是重写所有处理逻辑。
返回列表