1. 报销单这件事,为什么值得用 Docker 跑自动化
每个月总有那么几天,我要对着 OA 系统里那张报销单发呆。日期、地点、金额、事由、发票号、附件上传,一条一条填,填完还要核对有没有串行。出差三次,光报销就得搭进去小半天。后来我琢磨,这事能不能让 AI 替我干?答案是可以,而且比想象中简单——核心思路就是:在 Docker 容器里搭一个浏览器自动化环境,让 AI 按固定流程读取票据信息,再回填到报销单页面。
浏览器自动化本身不新鲜,Selenium、Puppeteer 都是老面孔。但传统脚本的问题是写死了每一步,OA 系统改个按钮位置,脚本就得重写。AI 驱动的浏览器自动化不一样,它靠截图理解页面,自己找按钮、填表单,页面小改版也能自适应。Claude Computer Use 和智谱 AutoGLM 是两条主流路线,前者适合自部署、可控性强,后者开箱即用但适配内部系统偏弱。这篇不讲虚的,直接给你一套可复制的 Dockerfile 和 config.toml 骨架,再配一次本地表单回填的验证动作,目标是跑通从截图识别到字段写入的闭环。
适合谁看?有 Docker 基础、想把手头重复性网页操作交给 AI 的后端或运维同学。不需要你懂深度学习,会写配置文件、能看懂报错就行。整篇的节奏是:先讲清楚场景和坑,再给配置,最后验证和排障。你跟着做,大概率能在一小时内看到 AI 自己把表单填完。
2. TaoToken 前置:统一 Key 接入,别在多个平台之间来回切
在动手之前,先把模型接入这件事理顺。浏览器自动化里,AI 需要频繁调用视觉理解能力——看截图、识别表单字段、判断按钮位置。如果你同时试 Claude 和别的模型,每个平台一套 Key、一套计费、一套限流,管理成本很高。我的做法是用 TaoToken 做统一接入层,一个 Key 走通多个模型,配置里只改模型名就行。
TaoToken 的定位是 AI 模型 API 的统一入口,官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api 。它不替代你的编辑器,也不碰你的生产数据库,就是帮你把模型调用这层收拢。对于浏览器自动化这种需要反复试模型、调 prompt 的场景,统一 Key 能省掉大量切换成本。
具体操作上,你需要先去控制台创建一个 API Key。控制台入口在这里:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 。创建完 Key 之后,在 API Keys 页面可以查看和管理:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。如果你对某个模型的对话能力没把握,可以先用模型对话页面快速试一下:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有各语言的调用示例。
注意:Key 只存在服务端环境变量里,别写进 Dockerfile,也别提交到 Git。容器里通过
-e或env_file注入。
如果你打算长期跑编码类或 Agent 类任务,可以了解下 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。它更适合高频调用的场景,报销单这种每月几次的任务,按量付费就够了。
3. 可复制配置:Dockerfile 与 config.toml 骨架
这一节是核心,直接给能跑的配置。整体架构分三层:基础镜像层负责浏览器和桌面环境,应用层负责自动化逻辑,配置层用 config.toml 管理模型和流程参数。我试过把浏览器、Python 运行时、自动化脚本打包进一个镜像,启动后通过 API 接收指令,AI 截图分析后执行点击和输入。
先看 Dockerfile。基础镜像选带桌面环境的,因为浏览器自动化需要真实的渲染上下文,无头模式在某些 OA 系统上会被识别。这里用 Debian 加 Xvfb 虚拟显示,再装 Chromium 和 Python 依赖。
FROM debian:bookworm-slim ENV DEBIAN_FRONTEND=noninteractive ENV DISPLAY=:99 ENV PYTHONUNBUFFERED=1 RUN apt-get update && apt-get install -y \ xvfb \ chromium \ chromium-driver \ python3 \ python3-pip \ python3-venv \ fonts-wqy-zenhei \ fonts-wqy-microhei \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY requirements.txt . RUN pip3 install --no-cache-dir -r requirements.txt COPY . . RUN chmod +x /app/entrypoint.sh EXPOSE 8080 ENTRYPOINT ["/app/entrypoint.sh"]requirements.txt 里放自动化相关的库:
playwright==1.44.0 requests==2.32.0 tomli==2.0.1 pillow==10.3.0entrypoint.sh 负责启动虚拟显示和自动化服务:
#!/bin/bash set -e Xvfb :99 -screen 0 1920x1080x24 & sleep 2 export DISPLAY=:99 python3 -m playwright install chromium python3 /app/agent.py接下来是 config.toml,这是整个流程的骨架。它定义模型接入、浏览器参数、报销单字段映射三块。模型这块走 TaoToken 的统一入口,base_url 指向 API 地址,model 字段按需切换。
[model] provider = "taotoken" base_url = "https://taotoken.net/api" api_key_env = "TAOTOKEN_API_KEY" model = "claude-3-5-sonnet" max_tokens = 4096 temperature = 0.1 [browser] headless = false viewport_width = 1920 viewport_height = 1080 user_agent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" screenshot_dir = "/app/screenshots" action_delay_ms = 800 [expense] login_url = "https://oa.example.com/login" form_url = "https://oa.example.com/expense/new" username_env = "OA_USERNAME" password_env = "OA_PASSWORD" [expense.fields] date = "input[name='expenseDate']" location = "input[name='expenseLocation']" amount = "input[name='expenseAmount']" category = "select[name='expenseCategory']" reason = "textarea[name='expenseReason']" receipt_upload = "input[type='file'][name='receipt']" [expense.flow] steps = [ "open_login", "fill_credentials", "wait_dashboard", "open_form", "fill_fields", "upload_receipt", "screenshot_confirm" ]字段映射这块,我建议先用浏览器开发者工具把 OA 表单的 selector 抓出来,填进 config.toml。AI 执行时优先用文字描述定位,比如「点击写着新建报销单的按钮」,selector 作为兜底。这样页面小改版时,AI 还能靠视觉理解找到目标。
agent.py 的核心逻辑是:读取 config.toml,启动浏览器,按 steps 顺序执行,每步截图发给模型判断下一步动作。这里给一个简化版骨架:
import os import tomli import requests from playwright.sync_api import sync_playwright def load_config(path="/app/config.toml"): with open(path, "rb") as f: return tomli.load(f) def call_model(cfg, prompt, image_b64=None): headers = { "Authorization": f"Bearer {os.environ[cfg['model']['api_key_env']]}", "Content-Type": "application/json" } payload = { "model": cfg["model"]["model"], "max_tokens": cfg["model"]["max_tokens"], "temperature": cfg["model"]["temperature"], "messages": [{"role": "user", "content": prompt}] } resp = requests.post( f"{cfg['model']['base_url']}/v1/messages", headers=headers, json=payload, timeout=120 ) resp.raise_for_status() return resp.json() def run_flow(cfg): with sync_playwright() as p: browser = p.chromium.launch(headless=cfg["browser"]["headless"]) page = browser.new_page( viewport={ "width": cfg["browser"]["viewport_width"], "height": cfg["browser"]["viewport_height"] } ) page.goto(cfg["expense"]["login_url"]) page.fill("input[name='username']", os.environ[cfg["expense"]["username_env"]]) page.fill("input[name='password']", os.environ[cfg["expense"]["password_env"]]) page.click("button[type='submit']") page.wait_for_url("**/dashboard") page.goto(cfg["expense"]["form_url"]) page.screenshot(path=f"{cfg['browser']['screenshot_dir']}/form.png") browser.close() if __name__ == "__main__": config = load_config() run_flow(config)启动容器时注入环境变量:
docker build -t expense-agent:latest . docker run -d \ --name expense-agent \ -p 8080:8080 \ -e TAOTOKEN_API_KEY=你的Key \ -e OA_USERNAME=你的账号 \ -e OA_PASSWORD=你的密码 \ -v $(pwd)/screenshots:/app/screenshots \ expense-agent:latest提示:第一次跑建议把 headless 设为 false,通过 VNC 或截图看 AI 的操作过程。确认流程稳定后再考虑无头模式。
4. 验证请求:一次本地表单回填的闭环
配置搭好之后,别急着上真实 OA 系统。先在一个本地测试页面上验证闭环,确认 AI 能看懂截图、能定位字段、能写入内容。我准备了一个简单的 HTML 表单,字段和报销单一致,放在容器里用 Python 起个静态服务。
测试页面 test_form.html:
<!DOCTYPE html> <html> <head><meta charset="utf-8"><title>报销单测试</title></head> <body> <h2>费用报销单</h2> <form> <label>日期 <input name="expenseDate" type="text"></label><br> <label>地点 <input name="expenseLocation" type="text"></label><br> <label>金额 <input name="expenseAmount" type="text"></label><br> <label>类别 <select name="expenseCategory"> <option value="">请选择</option> <option value="交通">交通</option> <option value="住宿">住宿</option> </select> </label><br> <label>事由 <textarea name="expenseReason"></textarea></label><br> <label>附件 <input name="receipt" type="file"></label><br> <button type="button" onclick="alert('已保存')">保存</button> </form> </body> </html>在容器里起服务:
python3 -m http.server 8000 --directory /app/test然后写一个验证脚本,让 AI 读取一张模拟票据的截图,提取字段,回填到测试页面。这里的关键是 prompt 设计:把截图和字段映射一起发给模型,让它输出 JSON 格式的填写指令。
import base64 import json import os import tomli import requests from playwright.sync_api import sync_playwright def encode_image(path): with open(path, "rb") as f: return base64.b64encode(f.read()).decode("utf-8") def extract_fields(cfg, image_path): image_b64 = encode_image(image_path) prompt = """你是一个报销单填写助手。请从这张票据截图中提取以下字段, 以 JSON 格式返回,不要输出其他内容: {"date": "", "location": "", "amount": "", "category": "", "reason": ""} 类别只能是交通或住宿。""" headers = { "Authorization": f"Bearer {os.environ[cfg['model']['api_key_env']]}", "Content-Type": "application/json" } payload = { "model": cfg["model"]["model"], "max_tokens": 1024, "temperature": 0.1, "messages": [{ "role": "user", "content": [ {"type": "text", "text": prompt}, {"type": "image", "source": { "type": "base64", "media_type": "image/png", "data": image_b64 }} ] }] } resp = requests.post( f"{cfg['model']['base_url']}/v1/messages", headers=headers, json=payload, timeout=120 ) resp.raise_for_status() text = resp.json()["content"][0]["text"] return json.loads(text) def fill_form(cfg, fields): with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.goto("http://localhost:8000/test_form.html") page.fill(cfg["expense"]["fields"]["date"], fields["date"]) page.fill(cfg["expense"]["fields"]["location"], fields["location"]) page.fill(cfg["expense"]["fields"]["amount"], fields["amount"]) page.select_option(cfg["expense"]["fields"]["category"], fields["category"]) page.fill(cfg["expense"]["fields"]["reason"], fields["reason"]) page.screenshot(path="/app/screenshots/filled.png") browser.close() if __name__ == "__main__": with open("/app/config.toml", "rb") as f: config = tomli.load(f) result = extract_fields(config, "/app/test/receipt_sample.png") print("提取结果:", result) fill_form(config, result) print("回填完成,截图见 /app/screenshots/filled.png")跑完之后,打开 filled.png,你应该能看到表单里已经填好了日期、地点、金额、类别和事由。这一步跑通,说明从截图识别到字段写入的闭环成立了。接下来把测试页面的 URL 换成真实 OA 表单地址,selector 换成真实字段,流程就能迁移过去。
注意:真实 OA 系统通常有登录态和 CSRF token,建议在 fill_form 之前先复用登录后的 cookie,或者把登录步骤也纳入流程。别把账号密码硬编码在脚本里,走环境变量。
5. 本篇常见错排查
跑这套东西,报错基本集中在几个地方。我按出现频率排一下,你对照着看。
容器启动后浏览器起不来,报错Failed to launch chromium。多半是缺少系统依赖或者 DISPLAY 没设对。检查 entrypoint.sh 里 Xvfb 是否在后台跑起来了,ps aux | grep Xvfb看一眼。另外 Debian slim 镜像缺一些库,可以在 Dockerfile 里补上libnss3 libatk-bridge2.0-0 libdrm2 libxkbcommon0这些。
调用模型返回 401 或 403。先确认TAOTOKEN_API_KEY环境变量在容器里能读到,docker exec -it expense-agent env | grep TAOTOKEN查一下。如果 Key 没问题,检查 base_url 是不是写成了https://taotoken.net/api,别多加斜杠或者路径。接入文档里有完整的请求示例,对照一下 header 格式。
截图发给模型后返回的内容不是 JSON,解析报错。这是 prompt 不够约束导致的。在 prompt 里明确写「只输出 JSON,不要 markdown 代码块,不要解释」,并且把 temperature 调到 0.1 以下。如果模型还是输出多余内容,可以在解析前用正则提取第一个{到最后一个}之间的部分。
表单填了但没生效,截图里字段还是空的。常见原因是 selector 匹配到了多个元素,或者页面还没加载完就执行了 fill。在 fill 之前加page.wait_for_selector(selector),确保元素可见。另外有些 OA 系统用 iframe 嵌套表单,需要先page.frame_locator()切进去。
上传附件失败。Playwright 的set_input_files需要文件在容器内可访问。如果你把票据放在宿主机,记得用-v挂载进容器,config.toml 里的路径要写容器内的路径,不是宿主机的。
AI 点错了按钮,流程跑偏。这是视觉理解的固有风险。解决办法是在 prompt 里用文字描述目标,比如「点击页面上文字为『新建报销单』的按钮」,而不是给坐标。同时把 action_delay_ms 调大一点,给页面渲染留时间。如果某个步骤反复出错,可以在 config.toml 的 steps 里插入一个screenshot_confirm,人工确认后再继续。
6. 下一步:把闭环跑稳,再谈扩展
这套骨架跑通之后,你会发现真正花时间的不是写代码,而是调 prompt 和抓 selector。我的建议是先把一个固定流程跑稳,比如就填交通费这一种,跑上十次不出错,再考虑加字段、加类别。别一上来就追求全自动,人工确认那一步在初期很有价值。
如果你在接入模型时遇到 Key 管理或者调用报错的问题,可以先去 API Keys 页面检查一下 Key 状态:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。接入细节看文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。想先试试模型对截图的理解能力,用模型对话页面传张票据图进去问它:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 。长期跑编码或 Agent 任务的话,Coding Plan 会更划算:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。
最后说个实际经验:报销单自动化最大的坑不是技术,是 OA 系统的登录态过期。我现在的做法是容器里保留一个持久化的 browser context,登录一次能用好几天,过期了再手动扫码。这样 AI 接手的时候直接就是登录态,省掉验证码那一步。你可以试试在 Playwright 里用launch_persistent_context,把用户数据目录挂载到宿主机,登录态就能跨容器重启保留。