从 Tesseract 到 RapidOCR:微信收款监控自动化折腾全记录
一次从”能跑就行”到”稳定后台运行”的完整踩坑史。如果你也在做微信收款监听、OCR 识别、Windows 托盘、PyInstaller 打包,这篇文章能帮你少走几天弯路。
一、项目背景
需求很简单:监听微信电脑版”微信收款助手”的到账弹窗,识别金额,自动推送到 V免签服务端。
V免签是一个开源的聚合支付系统,个人收款码可以用它的 appPush 接口来自动标记订单已支付——顾客扫码付款后,监控程序识别到账,POST 一个金额给服务端,服务端匹配订单,顾客端自动跳转”支付成功”。
听上去逻辑清晰,但从零到稳定上线,前后踩了十几个坑。整理如下。
二、技术选型与架构
最终架构
┌──────────────────────────────────────────────────┐
│ 主线程: infi.systray 托盘消息循环 │
├──────────────────────────────────────────────────┤
│ 子线程: heartbeat_loop(每 10 秒心跳) │
├──────────────────────────────────────────────────┤
│ 子线程: monitor_loop(监控主循环) │
│ ├── 截图微信收款助手窗口 │
│ ├── pHash 检测窗口变化 │
│ ├── RapidOCR 识别金额/时间/序号 │
│ ├── 时间+序号双重过滤 │
│ └── 推送到 N 个 V免签服务端 │
└──────────────────────────────────────────────────┘
关键选型
| 模块 | 方案 | 理由 |
|---|---|---|
| OCR | RapidOCR (ONNX) | 纯 Python,脱离 Tesseract,打包简单 |
| 变化检测 | pHash 感知哈希 | 忽略鼠标/光标/抗锯齿差异 |
| 托盘 | infi.systray | 比 pystray 在 --noconsole 下更稳 |
| 打包 | PyInstaller | --onefile --noconsole 单文件无窗口 |
| 日志 | logging + 文件 | 无窗口模式下唯一排障手段 |
三、踩坑全记录
坑 1:Tesseract 打包难题
问题:pytesseract 只是调用壳,真正干活的是外部的 tesseract.exe。PyInstaller 不会自动把它塞进去。
最初尝试:把整个 tesseract.exe + tessdata 文件夹打包进 exe。
问题:
- exe 体积从 30MB 暴涨到 100MB+
- 运行时还要处理
sys._MEIPASS临时目录路径 tessdata语言包还要额外指定--tessdata-dir- 在没装 VC++ 运行库的机器上,Tesseract 本身也起不来
最终方案:改用 RapidOCR(rapidocr_onnxruntime)。
# 旧
import pytesseract
pytesseract.pytesseract.tesseract_cmd = r'C:\Program Files\Tesseract-OCR\tesseract.exe'
text = pytesseract.image_to_string(crop, lang='chi_sim+eng', config='--psm 6')
# 新
from rapidocr_onnxruntime import RapidOCR
engine = RapidOCR()
result, _ = engine(img_array)
texts = [item[1] for item in result if len(item) >= 2]
raw_text = "\n".join(texts)
收益:
- 打包体积降到 60~90MB
- 无外部依赖,不需要 VC++ 运行库
- 中文识别精度比 Tesseract 好
- 打包命令只需要
--collect-all rapidocr_onnxruntime
坑 2:--noconsole 后程序”假死”
问题:--noconsole 打包后,双击 exe 看不到任何窗口,也看不到 print 输出。程序崩了你都不知道。
解决:把所有 print 改成 logging,写文件 + 控制台双写。
LOG_PATH = os.path.join(get_base_dir(), "vmq.log")
logger = logging.getLogger("vmq")
logger.setLevel(logging.INFO)
_file_handler = logging.FileHandler(LOG_PATH, encoding='utf-8')
_file_handler.setFormatter(logging.Formatter(
'%(asctime)s [%(levelname)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
))
logger.addHandler(_file_handler)
# 有控制台时同时输出(开发模式)
if sys.stdout is not None and hasattr(sys.stdout, 'write'):
_console_handler = logging.StreamHandler(sys.stdout)
logger.addHandler(_console_handler)
def log(msg, level='info'):
getattr(logger, level, logger.info)(msg)
关键点:get_base_dir() 在打包后要取 sys.executable 目录,保证 vmq.log 和 exe 在一起。
def get_base_dir():
if getattr(sys, 'frozen', False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
坑 3:pystray 在 --noconsole 下不显示图标
问题:pystray 日志显示 on_setup 回调已触发,但右下角就是没有图标。
排查过程:
- 任务管理器确认进程在跑 ✓
- 日志显示托盘对象创建成功 ✓
- 展开
∧折叠区也找不到 ✗ - 怀疑是 pystray 在
--onefile临时目录下的兼容问题
最终方案:改用 infi.systray。
import infi.systray
def tray_toggle_pause(sysTrayIcon):
global paused
paused = not paused
...
def build_tray():
menu_options = (
("暂停 / 继续监控", None, tray_toggle_pause),
("查看日志", None, tray_open_log),
("打开所在目录", None, tray_open_dir),
("退出", None, tray_quit),
)
tray = infi.systray.SysTrayIcon(
get_icon_path(),
"微信收款监控(运行中)",
menu_options,
on_quit=tray_quit,
)
return tray
def main():
global _tray_instance
...
_tray_instance = build_tray()
_tray_instance.start() # 阻塞
关键坑:tray 对象必须存全局变量,否则会被 GC 回收,消息循环退出:
_tray_instance = None # 模块级全局
def main():
global _tray_instance
_tray_instance = build_tray()
坑 4:托盘正常了,心跳却”离线”
这是最隐蔽的一个坑。
现象:换 infi.systray 后托盘显示正常,但 V免签后台显示”监控端离线”。
日志证据:
13:32:55 [心跳] 心跳线程已启动
13:32:55 [托盘] 已启动
13:32:57 [预热] 完成
(然后就没有然后了)
根因:infi.systray.start() 在主线程跑消息循环时,会长时间持有 GIL,把子线程的心跳饿死。
解决:心跳独立线程 + stop_event.wait() 代替 time.sleep()。
HEARTBEAT_INTERVAL = 10
def heartbeat_loop():
log(f"[心跳] 心跳线程已启动,间隔 {HEARTBEAT_INTERVAL} 秒")
while not stop_event.is_set():
try:
send_heartbeat()
except Exception as e:
log(f"[心跳] 线程内异常: {e}", 'warning')
stop_event.wait(HEARTBEAT_INTERVAL) # ★ 关键
log("[心跳] 心跳线程已停止")
def main():
threading.Thread(target=heartbeat_loop, daemon=True).start()
...
为什么用 stop_event.wait() 而不是 time.sleep():wait() 能立即响应 stop_event.set(),退出更干净。
坑 5:主进程 1 秒后自己退出
现象:日志显示
[托盘] 已启动,右键图标可操作
[预热] 正在读取当前屏幕...
[退出] 程序已完全退出 ← 1秒后
根因:infi.systray.start() 在 Server 2012 上可能提前返回,导致 main() 后面的 stop_event.set() 立即执行,整个进程退出。
解决:守护循环兜底。
try:
_tray_instance.start()
log("[托盘] tray.start() 返回,进入守护循环")
except Exception as e:
log(f"[托盘] start() 异常: {e}", 'error')
# ★ 守护循环
while not stop_event.is_set():
time.sleep(1)
stop_event.set()
monitor_thread.join(timeout=5)
坑 6:RapidOCR 打包后报 DLL load failed while importing cv2
现象:打包后运行报错:
ImportError: DLL load failed while importing cv2: 找不到指定的模块。
根因:rapidocr_onnxruntime 依赖 cv2(OpenCV),而 OpenCV 的 .pyd 依赖 Visual C++ 运行库。PyInstaller 默认不会自动收集。
解决:打包命令加 --collect-all cv2:
python -m PyInstaller --onefile --noconsole ^
--collect-all rapidocr_onnxruntime ^
--collect-all onnxruntime ^
--collect-all cv2 ^
--add-data "icon.ico;." ^
--hidden-import numpy ^
--hidden-import PIL ^
--hidden-import pygetwindow ^
--hidden-import win32gui ^
--hidden-import infi.systray ^
vmq.py
如果是 Windows Server 2012,还需要在服务器上开启”桌面体验”功能(包含媒体基础组件)。
坑 7:旧弹窗被反复推送,没付款的订单也跳转
现象:顾客没付款,订单状态却变成”已支付”。
根因链:
- 高频 OCR 版每 0.5 秒都 OCR,旧弹窗只要在屏幕上就一直被识别、一直推送
- V免签的匹配规则是”按金额找最近的未支付订单”,没有时间校验
- 于是”旧弹窗的推送”匹配到了”刚创建的未付款订单”
解决:回到 pHash 变化触发 —— 只有弹窗真的变了才 OCR。
if current_hash == last_image_hash:
# 窗口没变,跳过
stop_event.wait(SCAN_INTERVAL)
continue
# 窗口变了才 OCR
amount, dedup_key = extract_amount(img)
教训:“更快”不总是更好。高频采样会把”静态界面”也当成”事件”处理。
坑 8:弹窗延迟 9 分钟,被”5 分钟时间过滤”误杀
现象:14:38 的收款,弹窗到 14:47 才刷新,但 5 分钟过滤规则直接把 14:38 视为”旧消息”,漏推。
根因:微信电脑版收款助手的弹窗刷新延迟可能超过 5 分钟(尤其是有”1条新消息”折叠气泡时,主区域不刷新)。
解决:时间过滤 + 序号过滤双管齐下。
# 时间过滤放宽到 15 分钟
TIME_FILTER_MINUTES = 15
# 序号过滤:不依赖时间,只依赖"今日第 N 笔"递增
max_seq_seen = 0
# 在 extract_amount 里
seq_match = re.search(r'今日第(\d+)笔', clean_text)
seq = int(seq_match.group(1)) if seq_match else 0
if seq > 0 and seq <= max_seq_seen:
log(f"[过滤] 序号第{seq}笔 <= 已处理最大序号{max_seq_seen},跳过")
return None, None
if seq > max_seq_seen:
max_seq_seen = seq
预热时读当前序号,避免程序重启后误推旧弹窗:
# 预热阶段
init_seq = extract_seq_only(img) # 不带过滤的 OCR
if init_seq:
max_seq_seen = init_seq
log(f"[预热] 当前已处理到第 {max_seq_seen} 笔,后续只推送更大的序号")
坑 9:多个服务端 + 同一收款通道 = 必然错配
现象:同时对接 Workers 版和 PHP 版两个 V免签服务端,结果Workers 版的未付款订单被标记为已支付。
根因:
- 微信收款助手只有一个收款通道
- 顾客在哪个服务端下单,钱进的是同一个微信
- 监控程序识别到”1.00 到账”,不知道它对应哪个服务端的订单
- 盲推给两边 → 一方匹配对,另一方匹配错
结论:这是 V免签服务端”按金额匹配”的架构缺陷,本地代码无法根治。
可选方案:
| 方案 | 说明 | 可行性 |
|—-|—-|—-|
| 单服务端 | 只保留一个 | ★★★★★ |
| 金额区间隔离 | PHP 用 0.01~0.50,Workers 用 0.51~1.00 | ★★ |
| 两个微信账号 | 各自对应一个服务端 | ★(需要多开微信) |
最终选:单服务端。
坑 10:pHash 检测不到”1条新消息”气泡
现象:弹窗右上角显示”∧ 1条新消息”,但主区域(时间、金额)不刷新,pHash 一直认为”窗口未变化”,永远识别不到新收款。
根因:pHash 裁剪区只覆盖 20%~52%(时间+金额区),气泡在 5%~15% 的顶部,在裁剪区之外。
解决:
- 扩大 pHash 裁剪区到顶部:
HASH_CROP_TOP_RATIO = 0.05 - OCR 检测到”条新消息”时主动激活窗口,逼微信展开主区域
if '条新消息' in clean_text or '新消息' in clean_text:
log("[刷新] 检测到新消息气泡,主动激活窗口强制刷新")
activate_wechat_window()
refresh_pending = True
return None, None
def activate_wechat_window():
global last_hwnd
if not last_hwnd:
return
try:
if win32gui.IsIconic(last_hwnd):
win32gui.ShowWindow(last_hwnd, win32con.SW_RESTORE)
win32gui.SetForegroundWindow(last_hwnd)
except Exception as e:
log(f"[刷新] 激活窗口失败: {e}", 'warning')
四、最终代码架构
核心模块清单
# ========== 配置区 ==========
SERVERS = [...] # 多服务端配置(含 timeout)
WINDOW_TITLE = "微信收款助手"
SCAN_INTERVAL = 0.3 # pHash 检测间隔
HEARTBEAT_INTERVAL = 10 # 心跳间隔
TIME_FILTER_MINUTES = 15 # 时间过滤
HASH_CROP_* = ... # pHash 裁剪区(含顶部气泡)
OCR_CROP_* = ... # OCR 裁剪区
# ========== 全局状态 ==========
stop_event = threading.Event() # 退出信号
paused = False # 暂停标志
max_seq_seen = 0 # 序号过滤
pushed_records = deque(maxlen=200) # 去重
_tray_instance = None # 托盘全局引用
# ========== 线程 ==========
heartbeat_loop() # 心跳线程
monitor_loop() # 监控线程
# ========== 托盘 ==========
build_tray() # 构建托盘
tray_toggle_pause() # 暂停/继续
tray_open_log() # 查看日志
tray_open_dir() # 打开目录
tray_quit() # 退出
# ========== 主循环 ==========
main() # 启动所有线程 + 守护循环
关键设计模式
stop_event统一退出信号:所有线程的循环都用while not stop_event.is_set(),time.sleep换成stop_event.wait(),退出时瞬间响应。_tray_instance全局引用:防止托盘对象被 GC 回收。refresh_pending标志:激活窗口后等 0.5 秒再截图,让微信刷新。- 双线程 + 守护循环:心跳独立、监控独立、托盘主线程 + 守护循环防提前退出。
五、打包命令速查
依赖安装
python -m pip install rapidocr_onnxruntime onnxruntime opencv-python numpy pillow requests pygetwindow pywin32 infi.systray -i https://pypi.tuna.tsinghua.edu.cn/simple
python -m pip install pyinstaller -i https://pypi.tuna.tsinghua.edu.cn/simple
打包(单行)
python -m PyInstaller --onefile --noconsole --name 微信收款监控 --collect-all rapidocr_onnxruntime --collect-all onnxruntime --collect-all cv2 --add-data "icon.ico;." --hidden-import numpy --hidden-import PIL --hidden-import pygetwindow --hidden-import win32gui --hidden-import infi.systray vmq.py
打包(多行)
python -m PyInstaller --onefile --noconsole ^
--name 微信收款监控 ^
--collect-all rapidocr_onnxruntime ^
--collect-all onnxruntime ^
--collect-all cv2 ^
--add-data "icon.ico;." ^
--hidden-import numpy ^
--hidden-import PIL ^
--hidden-import pygetwindow ^
--hidden-import win32gui ^
--hidden-import infi.systray ^
vmq.py
产物
dist\微信收款监控.exe # 单文件无窗口,带托盘
六、经验总结
该记住的原则
- 无窗口模式下,日志是唯一的排障手段。宁可有冗余日志,也不要”崩了没线索”。
- 心跳、监控、托盘必须解耦到不同线程。
infi.systray.start()会占 GIL,别把关键任务放主线程。 - “更快”不总是更好。高频 OCR 会把静态界面当成事件,导致误推。
- 过滤规则要双轨。时间过滤会被弹窗延迟击穿,序号过滤是更可靠的兜底。
- 多服务端 + 同一收款通道 = 必然错配。V免签的”按金额匹配”没有时间校验,无法根治。
- 打包是最后一关,也是最容易翻车的一关。
--collect-all是关键,缺一个就运行时崩。 - 主进程退出会带走所有 daemon 线程。托盘对象要全局引用,主线程要用守护循环顶住。
该避免的坑
| ❌ 错误做法 | ✅ 正确做法 |
|---|---|
用 print 输出 |
用 logging 落文件 |
| 心跳在主循环里按次数发 | 独立线程 + stop_event.wait() |
| 托盘对象是局部变量 | 全局引用,防 GC |
icon.run() 后直接退出 |
加守护循环 |
| 只靠时间过滤 | 时间 + 序号双重过滤 |
| 多服务端盲推 | 单服务端或金额隔离 |
七、待办与扩展方向
- 单实例锁:防止重复双击启动多个进程
- 开机自启:写注册表
Run项 - 图标动态切换:暂停时换灰色图标(需要两个 ico)
- 邮件/Server酱告警:监控异常时通知
- 配置外部化:把
SERVERS、裁剪比例抽到config.ini
八、结语
这个项目从”能跑”到”稳定后台”,大概花了 3 天,踩了 10 个坑。大多数问题不是代码写错了,而是”没考虑到 Windows 桌面应用的运行时特性”:
- GIL 和消息循环的交互
- PyInstaller 的依赖收集
- 微信电脑版的弹窗刷新机制
- V免签服务端的匹配逻辑
这些都不是”看一眼文档就能知道”的,只有真的跑过、崩过、翻过日志,才能摸清。
希望这篇记录能帮到后面遇到同样问题的人。
(完)

💬 评论区 0