1. 财务票据处理的真实痛点与 Codex Skills 的切入点
每个月月底,我都要面对一个文件夹,里面躺着几十上百张发票截图:有滴滴行程单、有餐饮小票、有京东采购发票,格式五花八门,命名全是IMG_20240512_143022.jpg这种。手工做法就是一张张点开看金额、看日期,然后在 Excel 里敲进去,再按月份建文件夹拖来拖去。100 张发票大概要花掉一个下午,而且眼睛看花了之后,6 和 8、3 和 5 特别容易敲错。
Codex Skills 的价值在于,它把「扫描文件夹 → OCR 识别 → 提取金额和日期 → 按月份归类」这一整条链路封装成一个可复用的 Agent 技能。你不需要每次重新写脚本,只需要在 Codex 里调用这个 Skill,它就会自动遍历目录、调用 OCR、解析文本、移动文件。适合谁?财务专员、需要报销的开发者、以及任何想用 Python + OCR 把重复劳动干掉的人。
这篇文章我会交付一套可复制的 Skills 配置骨架、完整的 OCR 调用脚本、目录结构设计,以及批量运行后的结果校验方法。你跟着做,半小时内能跑通第一版。
2. TaoToken 前置:给 Codex Skills 接上模型能力
Codex Skills 本身是一个执行框架,但它在解析 OCR 返回的非结构化文本、判断字段语义、处理异常情况时,需要调用大模型来做推理。比如 OCR 把「价税合计」识别成了「价税合汁」,正则匹配不到,这时候就需要模型根据上下文判断这其实是金额字段。
我用的接入方式是通过 TaoToken 统一管理模型调用。它的 API 地址是https://taotoken.net/api,兼容 OpenAI 的接口格式,所以你在 Codex Skills 里配置base_url的时候直接填这个就行。模型对话、Coding Plan、API Keys 管理这些入口都在官网上能找到,地址是https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。
具体操作上,你需要先拿到一个 API Key。进入控制台创建 Key 的页面在这里:https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite。创建完之后复制 Key,待会儿写进.env文件。
如果你主要是做长期编码和 Agent 任务,可以看一下 Coding Plan 的说明:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite。接入文档在https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite,里面有完整的请求示例和参数说明。
注意:API Key 不要硬编码在脚本里,用
.env文件管理,后面我会给出具体写法。
3. 可复制配置:目录结构、Skills 骨架与 OCR 脚本
3.1 目录结构设计
先把你项目文件夹建好,结构如下:
invoice_agent/ ├── .env # 存放 API Key 和路径配置 ├── skills/ │ └── invoice_organizer.py # Codex Skill 主逻辑 ├── invoices_source/ # 待处理的发票图片放这里 ├── invoices_organized/ # 归类后的输出目录(脚本自动创建) ├── logs/ │ └── invoice_organizer.log └── requirements.txtinvoices_source里你可以放.jpg、.png、.pdf格式的发票文件,脚本会递归扫描子目录。
3.2 环境依赖安装
打开终端,执行:
pip install baidu-aip python-dotenv requests pillow pdf2image这里说明一下每个包的作用:baidu-aip是百度 OCR 的官方 SDK,用来调通用文字识别接口;python-dotenv读取.env配置;requests用于调用 TaoToken 的模型接口做文本纠错;pillow处理图片格式转换;pdf2image把 PDF 发票转成图片再送 OCR。
3.3 .env 配置文件
在项目根目录创建.env:
# 百度 OCR 凭证 BAIDU_OCR_APP_ID=你的APP_ID BAIDU_OCR_API_KEY=你的API_KEY BAIDU_OCR_SECRET_KEY=你的SECRET_KEY # TaoToken 模型接口 TAOTOKEN_BASE_URL=https://taotoken.net/api TAOTOKEN_API_KEY=你的TaoToken_Key TAOTOKEN_MODEL=gpt-4o-mini # 路径配置 SOURCE_FOLDER=./invoices_source TARGET_ROOT=./invoices_organized LOG_FILE=./logs/invoice_organizer.log百度 OCR 的 Key 去百度 AI 开放平台申请,每月有免费额度。TaoToken 的 Key 就是刚才在控制台创建的那个。
3.4 Codex Skill 骨架
Codex Skills 的核心是一个可被 Agent 调用的函数入口。我把它写成类方法,方便扩展:
# skills/invoice_organizer.py import os import re import shutil import logging from datetime import datetime from pathlib import Path from dotenv import load_dotenv from aip import AipOcr import requests load_dotenv() logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(os.getenv('LOG_FILE', './logs/invoice_organizer.log'), encoding='utf-8'), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) class InvoiceOrganizerSkill: """Codex Skill: 批量扫描发票图片,OCR 提取金额,按月份归类""" def __init__(self): self.ocr_client = AipOcr( os.getenv('BAIDU_OCR_APP_ID'), os.getenv('BAIDU_OCR_API_KEY'), os.getenv('BAIDU_OCR_SECRET_KEY') ) self.source = os.getenv('SOURCE_FOLDER', './invoices_source') self.target_root = os.getenv('TARGET_ROOT', './invoices_organized') self.taotoken_url = os.getenv('TAOTOKEN_BASE_URL', 'https://taotoken.net/api') self.taotoken_key = os.getenv('TAOTOKEN_API_KEY') self.model = os.getenv('TAOTOKEN_MODEL', 'gpt-4o-mini') os.makedirs(self.target_root, exist_ok=True) def scan_files(self): exts = {'.jpg', '.jpeg', '.png', '.bmp', '.pdf'} files = [] for root, _, names in os.walk(self.source): for n in names: if Path(n).suffix.lower() in exts: files.append(os.path.join(root, n)) logger.info(f"扫描到 {len(files)} 个待处理文件") return files def ocr_image(self, path): try: with open(path, 'rb') as f: img = f.read() res = self.ocr_client.basicGeneral(img) if res.get('error_code', 0) != 0: logger.error(f"OCR 失败 {path}: {res.get('error_msg')}") return '' return '\n'.join([w['words'] for w in res.get('words_result', [])]) except Exception as e: logger.error(f"OCR 异常 {path}: {e}") return '' def extract_fields(self, text): info = {'amount': '', 'date': '', 'code': ''} if not text: return info code_m = re.search(r'\b\d{10,12}\b', text) if code_m: info['code'] = code_m.group() amount_m = re.search(r'(?:金额|合计|价税合计)[::\s]*[¥¥]?\s*(\d+(?:\.\d{1,2})?)', text) if amount_m: info['amount'] = amount_m.group(1) date_m = re.search(r'(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})', text) if date_m: y, m, d = date_m.groups() info['date'] = f"{y}-{int(m):02d}-{int(d):02d}" return info def llm_fix(self, text, info): """当正则提取失败时,调用 TaoToken 模型做语义纠错""" if info['amount'] and info['date']: return info if not self.taotoken_key: return info prompt = f"从以下发票OCR文本中提取金额和开票日期,只返回JSON格式:{{\"amount\":\"\",\"date\":\"YYYY-MM-DD\"}}\n\n文本:\n{text[:800]}" try: resp = requests.post( f"{self.taotoken_url}/v1/chat/completions", headers={'Authorization': f'Bearer {self.taotoken_key}'}, json={'model': self.model, 'messages': [{'role': 'user', 'content': prompt}], 'temperature': 0}, timeout=30 ) content = resp.json()['choices'][0]['message']['content'] import json fixed = json.loads(re.search(r'\{.*\}', content, re.S).group()) if not info['amount'] and fixed.get('amount'): info['amount'] = fixed['amount'] if not info['date'] and fixed.get('date'): info['date'] = fixed['date'] except Exception as e: logger.warning(f"模型纠错失败: {e}") return info def organize(self, path, info): date_str = info.get('date', '') if date_str: try: dt = datetime.strptime(date_str, '%Y-%m-%d') folder = dt.strftime('%Y-%m') except ValueError: folder = 'unclassified' else: folder = 'unclassified' target_dir = os.path.join(self.target_root, folder) os.makedirs(target_dir, exist_ok=True) fname = os.path.basename(path) target_path = os.path.join(target_dir, fname) if os.path.exists(target_path): stem, ext = os.path.splitext(fname) target_path = os.path.join(target_dir, f"{stem}_{datetime.now().strftime('%H%M%S')}{ext}") shutil.move(path, target_path) logger.info(f"归类 {fname} -> {folder}/ | 金额={info.get('amount','N/A')} 日期={info.get('date','N/A')}") def run(self): files = self.scan_files() ok, fail = 0, 0 for i, f in enumerate(files, 1): logger.info(f"[{i}/{len(files)}] 处理 {os.path.basename(f)}") text = self.ocr_image(f) info = self.extract_fields(text) info = self.llm_fix(text, info) try: self.organize(f, info) ok += 1 except Exception as e: logger.error(f"归类失败 {f}: {e}") fail += 1 logger.info(f"完成。成功 {ok},失败 {fail}") if __name__ == '__main__': InvoiceOrganizerSkill().run()这个骨架里,scan_files负责递归遍历,ocr_image调百度接口,extract_fields用正则抓金额和日期,llm_fix在正则失败时走 TaoToken 模型兜底,organize做文件移动。整个流程就是 Codex Skill 的「感知-决策-执行」闭环。
4. 验证请求与成功结果
4.1 单张发票测试
先放一张发票到invoices_source/,然后跑:
python skills/invoice_organizer.py终端会输出类似:
2025-06-10 14:32:01 - INFO - 扫描到 1 个待处理文件 2025-06-10 14:32:01 - INFO - [1/1] 处理 invoice_test.jpg 2025-06-10 14:32:03 - INFO - 归类 invoice_test.jpg -> 2025-05/ | 金额=1280.00 日期=2025-05-18 2025-06-10 14:32:03 - INFO - 完成。成功 1,失败 0去invoices_organized/下面看,应该出现了2025-05/文件夹,里面躺着那张发票。
4.2 批量运行与结果校验
把几十张发票全丢进invoices_source/,重新跑脚本。跑完之后做三件事校验:
第一,检查目录结构。执行tree invoices_organized/,看月份文件夹是否和发票日期对得上。如果出现unclassified/,说明那几张的日期没提取到,需要单独看。
第二,核对金额。脚本会在日志里打印每张发票的金额,你可以用grep "金额=" logs/invoice_organizer.log把所有金额拉出来,和原始发票对一遍。
第三,写一个简单的校验脚本,统计每个月的发票数量和总金额:
import os, re from collections import defaultdict root = './invoices_organized' summary = defaultdict(lambda: {'count': 0, 'total': 0.0}) for month in os.listdir(root): month_dir = os.path.join(root, month) if not os.path.isdir(month_dir): continue for f in os.listdir(month_dir): summary[month]['count'] += 1 for m, v in sorted(summary.items()): print(f"{m}: {v['count']} 张")这个脚本只统计数量,金额需要从日志里关联,因为文件名里没带金额。如果你想让金额也进文件名,可以在organize里把fname改成f"{info['date']}_{info['amount']}_{fname}",这样后续对账更方便。
4.3 模型对话验证
如果你想单独测试 TaoToken 的模型接口是否通,可以用这个最小请求:
curl https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"回复OK"}]}'返回里有choices[0].message.content就说明通了。模型对话的入口在https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite,你可以在网页上直接试。
5. 本篇常见错排查
报错ModuleNotFoundError: No module named 'aip'包名是baidu-aip,但导入名是aip。如果你装的是baidu-aio那不对,卸载重装pip install baidu-aip。
OCR 返回error_code: 17或Open api qps request limit reached百度免费版有 QPS 限制,通常是 2 次/秒。在循环里加time.sleep(0.6),或者升级配额。
金额提取为空先看 OCR 原文里金额前面是什么词。有的发票写「价税合计」,有的写「小写」,正则里要覆盖这些前缀。我上面的正则已经加了价税合计,如果还不行,把 OCR 文本打印出来,手动补前缀。
日期提取成了发票代码里的数字比如发票代码123456789012里包含2025这种片段。解决办法是给日期正则加边界,或者优先匹配带年/月/日或-分隔符的格式。我上面的正则要求\d{4}[-/年]\d{1,2}[-/月]\d{1,2},已经能过滤掉大部分纯数字串。
文件移动后原目录空了但目标目录没东西检查TARGET_ROOT路径是不是相对路径,脚本运行时的工作目录和你以为的不一样。建议在.env里写绝对路径,或者用os.path.abspath转一下。
TaoToken 接口返回 401Key 没填对,或者.env没被加载。确认load_dotenv()在读取环境变量之前执行,且.env在项目根目录。
PDF 发票识别乱码pdf2image需要系统装poppler。Ubuntu 上apt install poppler-utils,Mac 上brew install poppler。装完再跑。
6. 把 Skill 接进你的日常工作流
跑通之后,你可以把这个 Skill 挂到 Codex 的定时任务里,每周五下午自动扫一遍invoices_source/。也可以扩展成监听文件夹变化,有新文件丢进来就自动处理。
如果你后面要做更复杂的 Agent 任务,比如自动生成报销单、对接财务系统,建议看一下 Coding Plan 的额度方案:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite。接入文档里有流式输出、函数调用这些高级用法的示例:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite。
API Key 管理页面记得定期轮换 Key:https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite。我一般三个月换一次,避免泄露风险。
最后说一个我踩过的坑:百度 OCR 对褶皱、反光、倾斜的发票识别率会明显下降。如果你的发票是手机拍的,建议先用pillow做一次灰度化和二值化再送 OCR,准确率能提升不少。具体做法是在ocr_image里加一段预处理:
from PIL import Image, ImageOps img = Image.open(path).convert('L') img = ImageOps.autocontrast(img) img.save('/tmp/preprocessed.jpg', quality=95) with open('/tmp/preprocessed.jpg', 'rb') as f: img_bytes = f.read()这样处理完再调basicGeneral,对手机拍的发票效果会好很多。