最近独立开发者的圈子里“rea”这个词出现得越来越频繁。最初它只是某个小项目给自己起的代号后来慢慢变成一种隐喻看到一条消息、一个表单、一段重复提问立刻用脚本生成一句合适的回复然后归档走人。说白了就是给重复劳动按个快进键。我花了一个周末照着这个思路从零写了一个最小可用的 rea 工具。不依赖任何第三方库一个配置文件加一个脚本放在本机定时跑。它解决的事情很简单把每天都会收到的同类消息、同类问题、同类请求自动生成回复草稿或者自动归档让我只需要在关键位置点一下确认。这不是一个面向所有人的产品更适合内容运营、社群管理员、文档维护者以及所有每天被“同一句话反复解释”折磨的人。这篇文章我会完整讲清楚它的设计思路、核心机制、可运行的代码还有这半年我踩过的坑。想直接动手的朋友照着第三章的代码和规则文件抄一遍十分钟就能跑起来。1. 项目定位与设计思路拆解为什么需要“rea”“rea”本质上不是一个框架也不是什么新技术。它是我给自己的一类重复劳动找的解决方案。动手之前我先花了不少时间梳理自己每天到底在重复做什么结论是三类一类是回复重复问题的客服劳动一类是搬运信息的归档劳动还有一类是照模板填内容的填空劳动。1.1 天天在做的三类重复劳动以及各自的痛点先说客服劳动。社群和管理后台每天都有新人问同样的问题报销单格式去哪下载文档链接为什么打不开某个功能入口在哪。我手动回复过上百遍每次都复制粘贴同一段话偶尔改个名字。这类劳动的问题是内容高度模板化但消息来源混杂必须逐条判断之后再回不能无脑自动。再说归档劳动。每天要在多个来源收集消息、截图、表格然后整理成固定格式的文档存进资料库。手动做步骤本身不难但容易遗漏而且整理完后还要再核对一遍“是不是真放进去了”。最后是填空劳动。把某个客户信息、订单号、日期从一个系统复制到另一个模板里生成一份看起来像定制的通知或记录。技术含量不高但错一个数字就要返工。这三类劳动有个共同点规则是确定的输入是半结构的唯一需要人判断的部分在于“什么消息走什么规则”。只要把这段判断规则化剩下的事情完全可以让脚本完成。这就是“rea”诞生时的核心假设把确定性的流程交给脚本把不确定的判断留给人。1.2 三条设计原则轻量、可组合、留痕动手写之前我给自己定了三条非常朴素的原则。第一是轻量。个人工具最大的敌人是维护成本。如果这个工具需要装几十个依赖、配置一个数据库、起一个服务那它很快就会因为“太麻烦”被弃用。所以“rea”被设计成一个 Python 脚本加一个 JSON 规则文件目录结构就是整个项目的全部。想跑起来只要本机有 Python 3.9 以上版本不需要任何第三方库。第二是可组合。规则和动作是两个独立的概念。规则负责回答“什么消息需要处理”动作负责回答“处理成什么样”。一条规则可以只有一个动作也可以组合多个动作比如“生成回复、追加归档、写日志”。这种设计背后其实是“组合优于堆叠”的思路功能不往代码里堆而是靠数据文件去组合改起来不用动脚本试错成本很低。第三是留痕。所有自动执行的动作都必须留下记录。这事听起来像常识但很多个人工具都不做。没有日志哪天规则误触了你根本不知道发生了什么。我把日志当成自动化的“行车记录仪”平时它没什么存在感一旦出问题它是唯一能还原现场的东西。为什么这三条对个人工具尤其重要因为个人工具没有专职开发维护。半年后再打开这个脚本的大概率还是你自己。如果设计时不留余地到时候你可能完全看不懂自己当时写了什么。1.3 对比现成方案为什么最终选择自己写做之前我认真评估过几种现成方案。按键精灵和通用 RPA 工具擅长模拟鼠标键盘操作适合操作图形界面但我的场景是纯文本处理用它们属于大材小用而且脚本换个电脑就要重新调试环境。办公软件自带的宏只能处理表格或文档内部的信息流转覆盖不到网页消息和即时通聊天记录。直接用大模型提示词做自动化效果确实灵活但同样的消息每次生成的结果会飘模板里带个订单号偶尔会被改写作为一个“要稳定输出固定格式”的工具不太合适而且本来只想跑个离线小脚本没必要为它架一堆接口。把几种方案放在一起对比会更清楚方案擅长场景最大痛点“rea”的差异按键精灵 / RPA图形界面自动化依赖屏幕坐标环境变了就要重调只处理文本输出可复制可验证办公宏表格、文档内部处理出了办公软件就抓瞎纯文件输入输出适配任意渠道导出的文本大模型提示词开放问答、内容改写输出不稳定无法精确控制格式规则引擎确定性输出模板变量精确替换“rea”脚本半结构化文本归类处理不适合开放式创作轻量、离线、稳定、可审计结论很清楚我要的不是“聪明”而是“可靠”。重复劳动对应的恰恰是确定性场景用一条条写死的规则比用大模型更合适。规则引擎让我对每一步都有控制权——我知道哪条规则会命中、会生成什么内容、会写到哪个文件里。2. 核心模块与关键机制解析规则引擎怎么设计才可控“rea”的整个核心只有四个部分规则文件、匹配器、执行器和日志模块。任何一个自动化工具说到底都是在回答两个问题什么时候触发触发之后做什么。规则引擎就是用来保证这两个问题的答案都是确定的。2.1 规则文件让非程序员也能看懂的条件与动作“rea”的所有逻辑都写在 rules.json 里。每条规则的结构非常简单核心就两部分怎么匹配、怎么处理。{ id: r001, priority: 1, match: contains, pattern: 报销单, actions: [ { type: reply, content: 报销单模板在文件中心的“财务”目录下取件码 2025-01。 }, { type: append, path: output/replies.md, content: 已回复报销单相关问题。 } ] }这里“match”决定匹配方式我实现了三种足够覆盖绝大多数场景contains消息中包含某个关键词适合日常最常见的情况prefix消息以某个固定前缀开头适合“求问”“紧急”“报销”这类带标识的入口regex按正则表达式匹配适合提取订单号、手机号、日期这类结构化信息。“priority”是优先级数字越小越先执行。规则被命中后默认整条消息就不再往下匹配其他规则。如果确实希望一条消息同时命中和触发多条规则可以在规则里加上 continue: true让它继续找下一条。这个设计是为了避免重复回复——如果关键词重叠很容易一条消息被回两次。动作部分目前支持两种reply 只是打印一段回复文本让使用者决定下一步怎么处理append 会把指定内容追加到某个文件里用于归档。两种动作都可以自由组合模板里的 {date}、{time} 这类占位符会被自动替换成当前时间。我特意没有把规则设计成一套更复杂的 DSL。原因很简单规则文件是要经常改的改的人可能是不写代码的运营同事。如果规则语言太复杂最终结果就是没人敢改所有更新都堆到我这里来那就违背了“轻量”的初衷。用最朴素的 JSON 加三种匹配方式已经解决了 90% 的需求。2.2 匹配器与变量替换从“命中”到“生成内容”的中间层匹配器本身不复杂但“变量替换”是个值得展开的细节。很多情况下自动回复不是一句固定模板而是要把用户消息里的某个动态片段提取出来再拼到模板里。最典型的场景是订单号。我在规则里设计了“capture”字段配合正则表达式的分组来提取动态内容。{ id: r002, priority: 2, match: regex, pattern: 订单\\s*([A-Z0-9]), capture: [order_no], actions: [ { type: reply, content: 收到的订单号是 {order_no}已记录。 }, { type: append, path: output/replies.md, content: 订单 {order_no} 状态待查询。 } ] }当消息是“麻烦查下 订单 AB12345 物流”时正则匹配到 AB12345捕获组的值被放进 context 对象里模板渲染阶段用真实值替换占位符最后生成“收到的订单号是 AB12345已记录。”这个机制理解起来就是“填空”模板是印刷好的表单捕获组就是往空格里填写的值。每次生成的文本不仅格式统一而且关键信息不会漏掉。为了防止替换时把模板里的占位符漏掉我写了一个 render_template 函数遍历上下文里的所有键值逐个替换。如果规则写得有问题渲染结果里会留下明显的“未替换占位符”一眼就能看出来。2.3 执行器与安全阀自动化的前提是可控执行器是真正干活的模块它读取动作列表执行 reply 或 append。相比逻辑本身更值得说的是我给执行器加的三道安全措施。第一道是 dry-run 模式。执行脚本时加 --dry-run 参数脚本只输出“将要做什么”不会真的写入文件。这个设计对应了自动化领域一个古老但有效的习惯先演练再实弹。我刚写完规则时几乎每次都先跑一遍 dry-run 看命中情况确认没有误伤才正式执行。第二道是动作限额 MAX_ACTIONS。单次运行最多只执行 50 个动作。这个限制的来历是因为我遇到过一种情况某条规则匹配得太宽一次导入几千条历史消息如果全部执行会把归档文件写得乱七八糟。有了上限脚本会在达到上限后直接停止相当于给失控状态拉了一个急停闸。第三道是延迟控制。每个动作可以配置 delay让实际执行时先停零点几秒再继续。对于本地文件操作延迟不是必需但如果后续扩展成调用外部接口这个延迟就是最简陋的限流器防止一把梭把对方服务打爆。我在代码里统一用 time.sleep 实现现在看是过度设计但在加“调用外部系统”这类动作时会非常省心。2.4 留痕设计append-only 日志与事后追溯日志模块是“rea”里看起来最简单、实际上最救命的部分。每执行一个动作脚本就往 run.log 里追加一行包含时间戳、命中规则 ID、动作类型和内容摘要。日志只追加不覆盖避免“上一次运行把上次日志清了”这种低级事故。我用的是最朴素的自定义函数没有引入 logging 库的复杂配置因为需求就那么点记录、追加、按行读。日志文件名可以在脚本头部改成按日期拆分比如 run-2025-01-20.log后续清理也更方便。留痕的意义在于当有人问你“这条自动回复到底是不是 rea 发的”时你可以翻出日志精确告诉他对应哪条规则、哪条消息、什么时间。我自己就有一次差点把所有规则删掉重写后来靠日志发现只是一条旧规则的优先级没调被另一条规则抢了先。如果没有日志那天就是另一个故事了。3. 从零实现一个最小可用版本十分钟跑起来的实操流程这一章给出可以直接抄的完整实现。我没有用任何第三方库复制文件到本机就能跑核心脚本大约 150 行。3.1 环境准备与目录结构先准备环境。本机有 Python 3.9 以上版本即可不需要安装任何东西。我平时在 macOS 和 Windows 上都跑过行为一致唯一要注意的是 Windows 控制台编码问题后面第四节详细说。目录按下面这种方式组织rea/ ├── rea.py # 主脚本 ├── rules.json # 规则文件 ├── messages/ │ └── sample.txt # 待处理消息一行一条 ├── output/ │ └── replies.md # 归档输出文件 └── logs/ └── run.log # 运行日志目录结构就用相对路径脚本启动时以自身所在目录为基准找文件不管你在哪个路径下执行都不会出现找不到文件的尴尬。这也是个人小工具实用性的一部分。3.2 主脚本 rea.py完整代码把下面这段代码保存为 rea.py#!/usr/bin/env python3 # -*- coding: utf-8 -*- rea - 轻量级文本规则处理器 用法: python rea.py --dry-run # 只展示将要执行的动作 python rea.py # 正常执行 python rea.py --input messages/xxx.txt # 指定待处理消息文件 import argparse import json import re import time from datetime import datetime from pathlib import Path BASE_DIR Path(__file__).resolve().parent RULES_FILE BASE_DIR / rules.json INPUT_FILE BASE_DIR / messages / sample.txt OUTPUT_FILE BASE_DIR / output / replies.md LOG_FILE BASE_DIR / logs / run.log MAX_ACTIONS 50 # 单次运行最多执行的动作数 def load_rules(path): with open(path, r, encodingutf-8) as f: return json.load(f) def load_messages(path): if not path.exists(): return [] return [ line.strip() for line in path.read_text(encodingutf-8).splitlines() if line.strip() ] def match_rule(rule, text): match_type rule.get(match, contains) pattern rule.get(pattern, ) if match_type contains: return pattern in text if match_type prefix: return text.startswith(pattern) if match_type regex: return re.search(pattern, text) is not None return False def render_template(template, ctx): result template for key, value in ctx.items(): result result.replace({ key }, str(value)) return result def build_context(rule, text): ctx { date: datetime.now().strftime(%Y-%m-%d), time: datetime.now().strftime(%H:%M), } match_type rule.get(match, contains) if match_type regex and capture in rule: m re.search(rule[pattern], text) if m and m.groups(): for idx, group_name in enumerate(rule[capture], start1): ctx[group_name] m.group(idx) return ctx def run_action(action, action_index): action_type action.get(type, reply) content action.get(content, ) if action_type reply: return f- {content} elif action_type append: target BASE_DIR / action.get(path, output/replies.md) target.parent.mkdir(parentsTrue, exist_okTrue) with open(target, a, encodingutf-8) as f: f.write(content.rstrip() \n) return f已追加到 {target} return 未知动作 def append_log(line): LOG_FILE.parent.mkdir(parentsTrue, exist_okTrue) with open(LOG_FILE, a, encodingutf-8) as f: f.write(line \n) def main(): parser argparse.ArgumentParser(descriptionrea - 轻量级文本规则处理器) parser.add_argument(--dry-run, actionstore_true, help只展示将要执行的动作) parser.add_argument(--input, typestr, help待处理消息文件路径) args parser.parse_args() rules load_rules(RULES_FILE) rules.sort(keylambda r: r.get(priority, 100)) input_path Path(args.input) if args.input else INPUT_FILE messages load_messages(input_path) if not messages: print(没有消息需要处理。) return action_count 0 print( rea 运行开始 ) for msg in messages: for rule in rules: if not match_rule(rule, msg): continue ctx build_context(rule, msg) actions rule.get(actions, []) print(f[{datetime.now():%H:%M:%S}] 规则 {rule[id]} 命中 - {msg[:60]}) for action in actions: if action_count MAX_ACTIONS: print(达到单次动作上限停止。) append_log(WARN 超过 MAX_ACTIONS停止执行) return rendered render_template(action.get(content, ), ctx) display_action { type: action.get(type), content: rendered, } if args.dry_run: print( [dry-run], run_action(display_action, action_count)) else: time.sleep(action.get(delay, 0)) result run_action(display_action, action_count) print( , result) append_log( fOK 规则{rule[id]} 动作{action.get(type)} 内容{rendered[:80]} ) action_count 1 if not rule.get(continue, False): break print( rea 运行结束 ) if __name__ __main__: main()代码风格刻意保持极简每个函数只做一件事没有乱七八糟的抽象。原因还是那个个人工具的代码要经得起半年后重新阅读。3.3 规则文件示例覆盖三种匹配方式对应的 rules.json 示例文件[ { id: r001, priority: 1, match: prefix, pattern: 求问, actions: [ { type: reply, content: 你好这里是常见问题说明请先查看帮助中心或者描述一下具体问题。 }, { type: append, path: output/replies.md, content: ## {date} 收到一条待跟进问题 } ] }, { id: r002, priority: 2, match: regex, pattern: 订单\\s*([A-Z0-9]), capture: [order_no], actions: [ { type: reply, content: 订单 {order_no} 已记录稍后会有专人回复。 }, { type: append, path: output/replies.md, content: 订单 {order_no} 状态待查询 } ] } ]注意JSON 文件里不能写注释所以我把说明写在文末。如果需要字段说明建议单独维护一个 README或者用规则 ID 本身做注释比如 r001 表示“通用求助问题”r002 表示“订单查询”。3.4 运行效果与日志输出在 messages/sample.txt 里放几条测试消息求问新人入职要带什么材料 求问报销单格式哪里下载 麻烦查下 订单 AB12345 物流 下午开会的会议室定了吗第一次先跑 dry-run只看不执行python rea.py --dry-run输出结果 rea 运行开始 [10:23:41] 规则 r001 命中 - 求问新人入职要带什么材料 [dry-run] - 你好这里是常见问题说明请先查看帮助中心或者描述一下具体问题。 [dry-run] 已追加到 /path/to/rea/output/replies.md [10:23:41] 规则 r001 命中 - 求问报销单格式哪里下载 [dry-run] - 你好这里是常见问题说明请先查看帮助中心或者描述一下具体问题。 [dry-run] 已追加到 /path/to/rea/output/replies.md [10:23:41] 规则 r002 命中 - 麻烦查下 订单 AB12345 物流 [dry-run] - 订单 AB12345 已记录稍后会有专人回复。 [dry-run] 已追加到 /path/to/rea/output/replies.md rea 运行结束 第四条消息“下午开会的会议室定了吗”没命中任何规则保持原样由人工处理。这一行为非常关键rea 只负责减轻工作量不试图替你做决定。确认无误后去掉 --dry-run 再跑一遍真实写入回复和归档。3.5 为什么规则用 JSON 不用其他格式选 JSON 是刻意的。首先Python 标准库直接支持不用为解析 YAML 引入额外依赖其次JSON 是文本格式记事本就能改给不写代码的同事做培训也容易最后JSON 结构严谨少个逗号立刻报错不会出现“格式看着对但解析出来是空”的模糊问题。相比之下 YAML 虽然更易读但对缩进极其敏感个人工具里反而容易埋坑。4. 实际落地中的常见问题与排查技巧实录这一章是真实踩坑记录。“rea”从写完到稳定运行中间经历了各种幺蛾子。整理成四个最有代表性的问题每个都给出现象、原因和处理方法。4.1 规则匹配过宽误伤正常消息第一次尝试用 contains 匹配关键词“地址”结果凡是消息里带“地址”二字的全部被自动回复包括“你的收货地址有误”这种不应该自动处理的反馈。原因很简单关键词粒度过粗没有限定消息的来源场景。解决办法是把规则条件变严格。比如限定前缀、用正则加边界词或者像下面这样加来源判断{ id: r003, priority: 5, match: regex, pattern: ^(售后|客服渠道) 地址, actions: [ { type: reply, content: 地址问题请查看订单详情页的地址管理。 } ] }我后来养成了一个习惯任何新增规则都会先拿最近一两个月的消息样本跑一遍 dry-run统计命中数量。如果命中率超过日常预期的两倍就说明关键词太宽得收一收。放到生产环境再发现误伤代价就大了。4.2 Windows 控制台的编码乱码问题在 Windows 上运行第一次 print 中文就乱码了输出文件内容也是乱码。原因是 Windows 控制台默认编码不是 UTF-8Python 的 stdout 和文件读写编码不一致。这个问题在老版本 Windows 上尤其烦人。解决办法分三层脚本内所有文件读写都显式指定 encodingutf-8运行前在控制台执行 chcp 65001 切换代码页如果重定向输出到文件再加环境变量 PYTHONIOENCODINGutf-8。值得说明的是三层里第一层最重要文件编码写对了数据就不会坏。输出到控制台乱码只是难看文件里乱码才是真的灾难。4.3 日志文件越来越大的烦恼“rea”跑了几个月后run.log 到了两百多兆打开都卡。原因有两个每动作都写一行量累积起来自然不小日志里还存了完整模板内容冗余严重。我的处理是双管齐下。一方面裁剪日志内容只记前 80 个字符的内容摘要不存完整消息正文另一方面改成按天拆日志文件名带日期每天一个文件保留最近 7 天。个人工具不需要企业级日志平台这样朴素的做法足够。如果想更省事直接用 Python logging 模块的 TimedRotatingFileHandler 也行但我没引入因为加一个第三方模块就多一个维护点。4.4 动作偶尔没执行的排查思路最诡异的一次是某条消息明明命中了规则打印信息也正常但 output/replies.md 里就是没有对应内容。排查了一圈最后发现是 append 动作里写的相对路径有误脚本把文件写到了别的目录而打印信息里显示的是目标路径不仔细看根本发现不了。这个问题的排查思路值得记下来先看 run.log有执行记录就说明规则和动作都触发了问题出在输出侧没有执行记录就说明匹配阶段出了问题回过去检查规则条件。检查顺序永远是“日志 → 输入消息 → 规则字段 → 输出路径”别一上来就怀疑代码逻辑。代码逻辑在 dry-run 阶段就该验证过真正上线后的 bug 大多数出在数据和路径上。这四类问题我做成了一张速查表贴在脚本目录里现象可能原因排查/解决命中过多误回消息关键词粒度过粗加前缀/正则边界跑历史样本验证中文输出乱码编码不一致读写强制 UTF-8chcp 65001日志文件膨胀无轮转、内容冗余按天拆分只记摘要动作没写入目标文件路径不对或目录未建先看日志再检查输出路径5. 进阶扩展方向与我的实操心得“rea”目前的形态足够解决我 80% 的需求但它的架构留了很自然的扩展口。如果你也想做一个类似的小工具可以从这几个方向继续发展。5.1 四个值得做的扩展方向第一个方向是接入定时任务。在 Windows 计划任务或系统 cron 里加一条命令每天固定时间跑一次 rea把 messages 目录下的新文件处理完。定时跑的价值在于消息文件可以靠其他脚本自动汇总rea 自动消费整条链路就自动化了。第二个方向是接入 webhook。当某个渠道有新消息时直接把消息文本 POST 到本机一个轻量接口接口收到后调用 rea 的处理函数。这一步会让工具从“批处理”变成“实时处理”但前提是先把本地批处理跑稳。第三个方向是扩展动作类型。目前只支持 reply 和 append如果需要在动作里发请求、改文件、调外部 API只需要在 run_action 函数里加一个分支。扩展点的设计原则是先让新动作支持 dry-run再接入真实副作用千万不能在自动化工具里加一个无法演练的动作类型。第四个方向是和个人知识库联动。append 动作的目标从本地 markdown 改成笔记软件支持的导入目录让每次归档都自动沉淀到知识库里。配合日期变量半年后翻知识库时能直接看到完整的处理轨迹。5.2 我从这个项目里收获的三条实操心得第一规则不是一次性写好的而是跟着真实消息迭代出来的。我一开始只写了三条规则跑了大概两周后根据真实消息不断增补和调整才长成现在这套。设计之初不要追求规则覆盖全面先在最小可用版本上跑起来再逐步加。第二自动化之前先把手动流程完整跑通一遍。这听起来像废话但我见过很多自动化项目翻车就是因为连手动流程都没理顺就开始写脚本。手动流程中的每一个分支都应该在规则里有一个明确对应没有对应到的地方就是将来误判的隐患。第三一定要留一个手动开关。我的 dry-run 参数就是那个开关它在关键时刻救了我很多次。遇到大批量导入消息、规则刚修改完、操作对象是很重要的文件目录时我都会先开 dry-run 看一眼。自动化最大的风险不是慢而是失控控制失控的最简单手段就是让执行前多一道人工确认。最后再分享一个小技巧我在 rules.json 的后面加了一个固定不用的占位规则专门用来记录规则文件的版本号和更新时间。每次改规则会顺手把版本号更新一下同时日志头部会记录“本次使用的规则版本”。这样半年后如果发现某天的处理结果有问题我能直接定位到那个阶段用的是什么规则不用靠记忆硬猜。这个习惯看着不起眼排查历史问题的时候是真香。