浏览器中使用AI开发好的插件实现自动报销

属于 #创作灵感# 系列:记录工作实践、项目复盘、写技术笔记巩固知识要点、发表职场感悟心得


每月报销,程序员的"至暗时刻"

不知道有多少人和我一样,每个月最痛苦的事不是写代码,而是——报销

出差一趟回来,攒了一堆PDF票据:高铁票、打车行程单、酒店发票、餐饮发票……然后打开蓝凌OA,开始手动填表。一张高铁票要填出发地、到达地、日期、舱位、金额,一个行程对应多笔费用,一行一行地敲,一个下拉框一个下拉框地选。遇到那种搜索式下拉框,还得先输入关键字、等加载、再点选——手指头都快敲出茧子了。

最崩溃的是,蓝凌OA用的是自研的LUI框架,表单控件各种花式封装:有隐藏的input、有iframe里嵌套的浮层、有MarcoPolo自动补全、还有那种readonly还得上传触发搜索的下拉框……每次填完一张报销单,我感觉自己就像个人肉RPA。

有一次月末赶着报销,十几张票据填了将近40分钟,中间还因为选错了下拉框选项被退回重填。那一刻我盯着屏幕想:我是写代码的啊,这事儿凭什么让我手动干?

于是,一个大胆的想法诞生了——用AI帮我写一个浏览器插件,让它替我填表。


从"想法"到"方案":用AI搭起整套架构

说实话,一开始我只想写个简单的脚本,后来跟Cursor(AI编程助手)聊了几轮需求,它帮我理清了一个关键问题:这件事不只是"填表",而是一条完整的链路——从票据识别到表单填写,中间涉及PDF解析、数据结构化、DOM操作,不是一个脚本搞得定的。

最终在AI的辅助下,我设计了一套三层解耦架构:

plaintext


浏览器插件(Chrome/Edge/Firefox, MV3规范)

↕ HTTP通信(端口8088)

本地解析服务(Flask)

↕ 本地调用

客户端GUI(Python tkinter)

为什么是三层?

  • 浏览器插件负责"看"和"填"——监听OA页面、注入表单、操作DOM
  • 本地解析服务负责"想"——PDF解析、票据分类、数据提取,这些重计算不适合放在浏览器里
  • 客户端GUI负责"管"——给非技术同事一个可视化的操作界面,选文件、看结果、改配置

三层通过HTTP通信解耦,插件和服务各自独立部署,任何一层升级都不影响其他层。这个架构不是我一上来就想明白的,是在AI的引导下逐步迭代出来的——v1.0的时候还只有Python客户端+Selenium自动化,后来才演化出浏览器插件模式。


核心难点一:PDF票据智能识别,比想象中难多了

多类型分类:文件名快判 + 内容精判

我一开始天真地以为,PDF解析嘛,正则表达式一把梭。结果发现票据种类五花八门——火车票、打车行程单、打车发票、酒店发票、餐饮发票,格式各不相同。光是区分"打车行程单"和"打车发票"就够喝一壶了。

最终实现的分类策略是两层过滤

python


# 第一层:文件名快速预判

def classify_by_filename(filename):

if "火车" in filename or "高铁" in filename:

return "train_ticket"

if "行程单" in filename:

return "ride_itinerary"

# ...

# 第二层:内容精确判断

def classify_by_content(text):

if re.search(r'车次|座位号|二等座', text):

return "train_ticket"

if re.search(r'行程单|起步价|里程', text):

return "ride_itinerary"

# ...

文件名快判能处理80%的情况,剩下20%靠内容精判兜底。这种分层策略是AI建议的——我本来只想做一层,AI提示说文件名判断速度快、可提前分流,避免每个文件都走全文解析,性能好很多。实测下来确实如此。

高铁票解析:站名翻译与座位等级

高铁票的解析坑点在于站名。12306的PDF里,站名有时是英文缩写(比如"Beijing South"),有时带"站"字("北京南站"),还有时候站名换行被拆开。我的处理策略:

  • 英文站名 → 维护一个映射表翻译成中文
  • 去掉"站"字统一格式
  • 乘客姓名提取采用多策略(位置定位、关键词匹配、模式匹配组合)

数字粘连:OCR的"幽灵Bug"

这是最让我头疼的问题。PDF文本提取时,相邻的数字偶尔会粘连在一起——比如"单价30数量2金额60"可能被提取成"单价302金额60",30和2粘成了302。

我写了一个暴力枚举+验证的拆分算法:

python


def split_stuck_numbers(text, expected_total):

"""尝试所有可能的拆分点,验证 单价×数量≈金额"""

for split_pos in range(1, len(text)):

unit_price = int(text[:split_pos])

quantity = int(text[split_pos:])

if abs(unit_price * quantity - expected_total) < 1:

return unit_price, quantity

return None

暴力是暴力了点,但数字字符串通常不长,枚举开销可以接受。关键是验证逻辑——单价×数量≈金额,这个约束条件有效过滤掉了绝大多数错误的拆分方式。

电子发票:城市推导

酒店发票和餐饮发票里没有直接的"城市"字段,但报销需要填城市。我的解法是从销售方名称智能推导——"XX市XX酒店"→提取"XX市"。这个看似简单的正则,调试了不下十版,因为销售方名称的格式太随意了。最终还是在AI的帮助下,覆盖了十几种常见的命名模式。


核心难点二:OA表单自动填写,跟LUI框架斗智斗勇

如果说PDF解析是"脑力活",那OA表单填写就是"体力活+技术活"。蓝凌OA的LUI框架把原生HTML控件封装得面目全非,每个控件都有它自己的"脾气"。

MarcoPolo自动补全:逐字输入的艺术

蓝凌OA里有些字段用的是MarcoPolo控件——你输入一个字,它弹出候选列表,你得用鼠标点选。Selenium的send_keys()直接填完整文本是不行的,因为它不会触发搜索。

我的解决方案是逐字输入 + mousedown选择

python


def fill_marcopolo(element, text):

for char in text:

element.send_keys(char)

time.sleep(0.1) # 等待候选列表刷新

# 找到候选项,用mousedown而非click触发

candidate = wait_for_candidate(text)

dispatch_mouse_event(candidate, "mousedown")

注意这里用的是mousedown而不是click——因为MarcoPolo控件监听的是mousedown事件,用click反而选不上。这个坑我踩了整整一个下午,最后是AI帮我分析了DOM事件绑定才搞明白的。

搜索式下拉框:移除readonly的"黑客"操作

还有一种下拉框,表面看是<input>,但设了readonly属性,你直接输入会被拦截。我的做法简单粗暴——先用JavaScript移除readonly,再输入,再触发搜索

python


driver.execute_script(

"arguments[0].removeAttribute('readonly')", input_element

)

input_element.clear()

input_element.send_keys(keyword)

而且这种下拉框还经常加载慢,需要多次重试。我设了3次重试 + 5秒超时的容错机制。

跨iframe通信:你以为你在操作页面,其实你在操作iframe

蓝凌OA的某些表单区域是嵌在iframe里的,直接用Selenium定位根本找不到元素。浏览器插件模式下更麻烦——Content Script默认只能访问顶层页面,iframe里的内容需要通过chrome.webNavigationAPI来注入。

跨iframe协调的逻辑大致是:

  1. 主Content Script监听文件上传事件
  2. 通过chrome.runtime.sendMessage将解析结果发给后台Service Worker
  3. Service Worker通过chrome.tabs.sendMessage转发给iframe的Content Script
  4. iframe的Content Script执行具体的DOM操作

这个消息链路调试起来极其痛苦,因为Chrome DevTools对Service Worker的调试支持本身就有限。最后还是靠着AI帮我写了详细的日志输出,才把消息流转的时序问题排查清楚。

填写节奏控制

填表不是越快越好——太快了OA的LUI框架会来不及响应,导致数据丢失。我摸索出的节奏是:

  • 行程行:每行1.5秒间隔
  • 费用行:200ms基础间隔 + 5秒超时重试

这些数值是反复测试调出来的,AI帮我做了个参数化的配置,方便后续微调。


核心难点三:浏览器插件开发,MV3的"坑"你没商量

Service Worker的生命周期

Chrome MV3规范要求后台脚本改为Service Worker,而Service Worker有个让人抓狂的特性——它会休眠。如果你用setInterval做定时任务,一休眠就丢了。

解决方案是使用chrome.alarmsAPI替代setInterval

javascript


// 健康检查:每30秒

chrome.alarms.create("healthCheck", { periodInMinutes: 0.5 });

// 版本检测:每30分钟

chrome.alarms.create("versionCheck", { periodInMinutes: 30 });

chrome.alarms.onAlarm.addListener((alarm) => {

if (alarm.name === "healthCheck") checkServiceHealth();

if (alarm.name === "versionCheck") checkExtensionUpdate();

});

chrome.alarms的最小间隔是1分钟(开发模式可以更短),对于30秒健康检查这种需求,我用了periodInMinutes: 0.5的写法——虽然文档说最小是1分钟,但实测在已发布的扩展中0.5分钟也是生效的(可能是Chrome的一个"特性")。

Content Script注入时机

另一个坑是Content Script的注入时机。蓝凌OA是SPA(单页应用),页面路由切换不会触发完整的页面加载,所以manifest.json里的content_scripts匹配规则有时不会重新注入。

我的做法是在background.js里用chrome.webNavigation.onHistoryStateUpdated监听路由变化,主动重新注入Content Script:

javascript


chrome.webNavigation.onHistoryStateUpdated.addListener(

(details) => {

if (details.url.includes("/oa/")) {

chrome.scripting.executeScript({

target: { tabId: details.tabId },

files: ["content_script.js"]

});

}

}

);

这个坑也是AI帮我发现的——我一开始以为Content Script只需要在manifest.json里声明就够了,结果发现SPA场景下必须手动处理。


实际效果:从40分钟到40秒

现在我的报销流程是这样的:

  1. 登录蓝凌OA → 插件自动注入,图标亮起绿灯🟢
  2. 上传PDF附件 → 插件通过MutationObserver监听到文件上传,自动发送给本地解析服务
  3. 解析完成 → 弹出确认对话框,展示票据数量、合计金额、类型统计
  4. 确认无误 → 点击确认,插件自动填写所有表单字段
  5. 人工检查 → 快速扫一遍,确认提交 ✅

十几张票据,从上传到填写完成,全程不到1分钟。 而且因为数据是从PDF直接提取的,人为出错率大幅降低——以前手动填经常填错金额或日期,现在基本不会了。

本地解析服务也很稳定,日志按天轮转保留30天,每个请求带trace ID方便排查。Flask虽然不是性能最强的框架,但这个场景下单机并发足够了。


AI辅助开发感悟:哪些擅长,哪些还需人工

整个项目从v1.0到v2.3,我深度使用了Cursor和Copilot等AI编程工具,有一些真实的感受想分享:

AI擅长的

  • 架构设计讨论:三层解耦架构就是在和AI的对话中逐步明确的。AI擅长从需求中提取关键约束,帮你理清模块边界
  • 正则表达式编写:票据解析里大量正则,AI写正则的速度和准确率远超我手写。特别是那些边界case——AI能想到你容易忽略的变体
  • DOM操作方案:面对LUI框架的各种奇葩控件,AI能快速给出多种尝试方案。比如MarcoPolo的mousedown问题,AI在第三轮对话就提到了事件类型的差异
  • 代码脚手架:Flask服务的路由框架、Chrome插件的manifest配置、消息通信模板……这些套路化的代码AI几秒钟就能生成

AI不太擅长的

  • 业务细节调参:填表延迟1.5秒还是2秒?搜索下拉框重试几次?这些需要反复实测,AI给不出标准答案
  • DOM调试:蓝凌OA的DOM结构非常深、动态变化多,AI无法直接"看到"页面,你得上截图或HTML片段才能让它帮你分析
  • 跨系统联调:插件和服务之间的通信问题,往往是时序或状态不一致导致的,AI很难仅凭代码推断运行时的交互顺序
  • 创新性解决方案:数字粘连拆分那个暴力枚举的思路是我想出来的,AI最初给的是基于规则匹配的方案,对这种"反直觉"的问题不太在行

总结一句话:AI是超强辅助,但不是自动驾驶。 它能帮你省下80%的编码时间,但剩下20%的关键调优,还是得靠自己的判断力和耐心。


版本演进:一个项目的成长史

回顾一下版本历程,能清晰看到项目是如何一步步演化的:

  • v1.0:只能解析高铁票 + Selenium自动填写——验证了可行性,但只能自己用
  • v1.1:加入自定义文件夹浏览、多选功能——开始考虑易用性
  • v1.2:统一PDF浏览对话框——UI规范化
  • v1.3:酒店/餐饮发票解析——覆盖更多票据类型,同事开始问能不能用
  • v2.3:浏览器插件模式、自动更新、日志轮转、链路追踪——正式成为可交付的工具

每一次版本升级,背后都是真实使用中遇到的问题。v1.3的时候同事说"能不能也支持酒店发票",v2.3的时候同事说"我不想装Python环境,能不能直接用浏览器"——好的产品不是设计出来的,是用出来的。


写在最后

现在每个月报销,我只需要把PDF往OA里一拖,等几秒钟确认一下,搞定。同事看到我这么快就提交了报销单,跑来问我用了什么"黑科技",我就把插件给他们装上。

说实话,做这个项目最大的收获不是省了多少时间,而是验证了一个想法:AI时代,一个人也能做出完整的产品级工具。从前端插件到后端服务到解析引擎,每一层我都不是专家,但AI补齐了我的短板,让我能在每个领域都写出可用的代码。

当然,AI写的代码不是万能的——那些深夜调试MarcoPolo控件的崩溃时刻,那些PDF解析返回空结果的抓狂瞬间,都不是AI能替你扛的。但至少,你不再需要一个人从零开始写每一行代码了

如果你也有类似的重复性工作困扰,不妨试试用AI帮你造个轮子。也许一开始只是一个小脚本,但谁知道呢,说不定它会长成一套完整的工具链 🚀

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐