
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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content。具体操作上你需要先拿到一个 API Key。进入控制台创建 Key 的页面在这里https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite。创建完之后复制 Key待会儿写进.env文件。如果你主要是做长期编码和 Agent 任务可以看一下 Coding Plan 的说明https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite。接入文档在https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite里面有完整的请求示例和参数说明。注意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_URLhttps://taotoken.net/api TAOTOKEN_API_KEY你的TaoToken_Key TAOTOKEN_MODELgpt-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( levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s, handlers[ logging.FileHandler(os.getenv(LOG_FILE, ./logs/invoice_organizer.log), encodingutf-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_okTrue) 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(fOCR 失败 {path}: {res.get(error_msg)}) return return \n.join([w[words] for w in res.get(words_result, [])]) except Exception as e: logger.error(fOCR 异常 {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: fBearer {self.taotoken_key}}, json{model: self.model, messages: [{role: user, content: prompt}], temperature: 0}, timeout30 ) 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_okTrue) 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_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite你可以在网页上直接试。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-utilsMac 上brew install poppler。装完再跑。6. 把 Skill 接进你的日常工作流跑通之后你可以把这个 Skill 挂到 Codex 的定时任务里每周五下午自动扫一遍invoices_source/。也可以扩展成监听文件夹变化有新文件丢进来就自动处理。如果你后面要做更复杂的 Agent 任务比如自动生成报销单、对接财务系统建议看一下 Coding Plan 的额度方案https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite。接入文档里有流式输出、函数调用这些高级用法的示例https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite。API Key 管理页面记得定期轮换 Keyhttps://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite。我一般三个月换一次避免泄露风险。最后说一个我踩过的坑百度 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, quality95) with open(/tmp/preprocessed.jpg, rb) as f: img_bytes f.read()这样处理完再调basicGeneral对手机拍的发票效果会好很多。