Files
Auto-Finance/src/mcp/tools/browser.py
T
wandering ceefe3c4eb feat(mcp): P3 联机验证通过,新增 browser_open_form 断点续填,修 4 个联机才暴露的真 bug
联机跑通 open→login→new_form→fill(basic)→page_state→close:财务系统真的落库(单据编号 202610020850,
basic 页点「下一步」即保存)。据此补上续填能力并把测试草稿清理干净(删除前后截图 + 复查 0 命中)。

- 新增第 25 个工具 browser_open_form:列表页 #seacher 定位 → 唯一命中 → 行内单选 → #update 修改,
  读回 #EXPENEXPLAIN 证明数据还在;preview_only 只定位不点开并回显 bill_no/state/amount;
  命中多张直接拒绝并回 candidates,不替用户猜单据
- 修 4 个真 bug(都是单元测试看不见的):
  1) navigate_to_reimburse 单点硬等 5 秒偶发超时 → 全程轮询 + 失败带页面文字与截图;
     并查明直达报销 URL 不建立会话(会被踢「长时间未操作,请重新登录!」),门户点击链是必经
  2) actor.close_all 用 atexit 注册无效:concurrent.futures.thread 的钩子走 threading._register_atexit,
     比 atexit 早跑,executor 已 shutdown → close 提交不进去。改为注册到同一列表,实测 close
     在 playwright_0 线程执行,退出不再漏浏览器
  3) wait_rows_loaded 只认 <a>,真实「查看」是 <button>,每次白等满 20 秒(也拖慢了测试套件)
  4) 空结果「没有找到匹配的记录」被当成加载中,搜不到时再白等 20 秒
- browser_page_state 语义修正:logged_in 判门户 URL,进报销系统后必然 false 会误导 → 拆成
  at_portal / in_reimburse_system / on_form_page
- case_status 补齐 info_both / screenshots / browser(锁持有者与心跳年龄)/ warnings(混合 info、
  陈旧抽取、他人持锁);.browser.lock 文件名由 cases.py 单一定义,actor 反向引用
- 列表页结构实测写进 AGENTS:btSelectItem 单选、#insert/#update/#delete、行内「查看」是 gzlc 流程图、
  可见搜索框是 #seacher 而 #expenexplain 在收起面板(fill 会卡 30 秒)
- 测试:FakeBot 绑 BaseBot 真实行逻辑,新增 5 例续填用例(唯一命中/歧义拒绝/未命中/preview/空关键词),
  134 passed 0.36s;文档 docs/MCP.md 工具参考按 wire schema 重生成(25 工具),SKILL 增「断了不要重来,
  用 browser_open_form 续填」与破坏性操作纪律;方案文档 §13.5 记录联机结果、4 个 bug 与未完成项

验证:mcp_smoke 25 工具 / ruff / format / mypy / deptry / pytest 134 passed 全绿;
联机时间线见方案文档 §13.5。仍用 --no-verify 提交并手跑等价检查(原因见 AGENTS 提交纪律)。
2026-10-20 10:44:57 +08:00

534 lines
21 KiB
Python

"""分步浏览器填报工具
取代旧的「一把梭」run_bot:Agent 可以开浏览器、登录、建单、按步填、看页面、关,
每步都有截图与明确状态,中途失败不必从头再来。提交不在这里——提交不可逆,属于 P4 的
submit_preview / submit_execute(带一次性 token)。
所有 Playwright 调用都在 src/infra/browser/actor.py 的单线程上执行;浏览器归属用
cases/<id>/.browser.lock 记录(PID + 心跳),防止多代 MCP 子进程各开一个 Chromium。
"""
from __future__ import annotations
import time
from pathlib import Path
from typing import Any
from ... import get_logger
from ...cases import CACHE_DIR_NAME, CaseStore
from ...config import load_config
from ...infra.browser import actor
from ..envelope import fail, ok
log = get_logger("mcp.browser")
_store = CaseStore()
# 每一步对应 infra/browser 里的一个步骤函数;顺序不能跳(页面状态是连续的)
STEP_ORDER: dict[str, list[str]] = {
"travel": ["basic", "details", "payment", "subsidy", "attachments"],
"normal": ["basic", "details", "payment", "attachments"],
}
STEP_LABEL: dict[str, str] = {
"basic": "基本信息",
"details": "报销明细",
"payment": "支付方式",
"subsidy": "补助清单",
"attachments": "附件上传",
}
PAGE_KEY = {"travel": "travel_page", "normal": "reimburse_page"}
INFO_FILE = {"travel": "travel_info.json", "normal": "normal_info.json"}
def _case_dir(case_id: str) -> Path | None:
return _store.resolve(case_id)
def _require_open(case_id: str) -> dict[str, Any] | None:
return actor.session(case_id)
def _load_info(d: Path, kind: str) -> dict[str, Any] | None:
import json
path = d / CACHE_DIR_NAME / INFO_FILE[kind]
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
log.warning("读取 %s 失败: %s", path.name, exc)
return None
return data if isinstance(data, dict) else None
def _infer_kind(d: Path) -> tuple[str | None, bool]:
"""按会话里已有的 info 推断单据类型;返回 (类型, 是否两种都有)。"""
has_travel = (d / CACHE_DIR_NAME / INFO_FILE["travel"]).exists()
has_normal = (d / CACHE_DIR_NAME / INFO_FILE["normal"]).exists()
if has_travel and has_normal:
return None, True
if has_travel:
return "travel", False
if has_normal:
return "normal", False
return None, False
def browser_open(case_id: str, headless: bool = False) -> dict[str, Any]:
"""打开(或复用)该会话的浏览器。
默认有头:提交不可逆,要看得见在点什么。同一会话重复调用直接复用现有浏览器。
被别的进程占用时返回 owned_by_other_process,别绕过去另开一个浏览器。
"""
d = _case_dir(case_id)
if d is None:
return fail(f"会话不存在: {case_id}")
got, info = actor.acquire(d)
if not got:
return fail(
"浏览器被其他进程占用",
next_action=f"让持有者调 mcp__arb__browser_close;心跳超过 {actor.STALE_AFTER_SECONDS} 秒未更新即可自动接管",
owned_by={"pid": info.get("pid"), "age_seconds": info.get("age_seconds")},
)
existing = _require_open(case_id)
if existing is not None:
actor.touch(d)
return ok(case_id=case_id, already_open=True, headless=bool(getattr(existing["bot"], "headless", False)))
def _open() -> None:
from ...infra.browser.base import BaseBot
bot = BaseBot(load_config(), headless=headless)
bot.work_dir = d
bot.screenshot_dir = d / "screenshots"
bot.launch()
actor.put_session(case_id, bot)
try:
actor.run_browser(_open)
except Exception as exc:
actor.release(d)
return fail(f"启动浏览器失败: {exc}", next_action="先执行 uv run playwright install chromium")
return ok(
case_id=case_id,
already_open=False,
headless=headless,
screenshots_dir=str(d / "screenshots"),
next_action="mcp__arb__browser_login",
)
def browser_login(case_id: str, wait_seconds: int = 25) -> dict[str, Any]:
"""登录信息门户。遇到扫码或验证码不要死等:返回 need_human,人工完成后再次调用。"""
d = _case_dir(case_id)
ctx = _require_open(case_id)
if d is None or ctx is None:
return fail("浏览器还没打开", next_action="mcp__arb__browser_open")
def _login() -> dict[str, Any]:
bot = ctx["bot"]
if bot.portal_reached():
return {"logged_in": True, "already": True}
bot.submit_login()
if bot.wait_for_portal(wait_seconds):
return {"logged_in": True, "already": False}
if bot.human_required():
return {"logged_in": False, "need_human": True, "screenshot": bot._screenshot("need_human")}
return {"logged_in": False, "timeout": True, "screenshot": bot._screenshot("login_timeout")}
try:
result = actor.run_browser(_login)
except Exception as exc:
return fail(f"登录出错: {exc}", next_action="mcp__arb__browser_page_state 看当前页面再决定")
actor.touch(d)
if result.get("need_human"):
return ok(
logged_in=False,
need_human=True,
reason="页面停在扫码或验证码,自动流程不能替你完成",
next_action="请在打开的浏览器窗口里完成扫码/验证码,然后再次调用 mcp__arb__browser_login",
screenshot=result.get("screenshot", ""),
)
if not result.get("logged_in"):
return fail(
"登录未完成",
next_action="mcp__arb__browser_page_state 确认页面;必要时 browser_close 后重开",
screenshot=result.get("screenshot", ""),
)
return ok(logged_in=True, already=result.get("already", False), next_action="mcp__arb__browser_new_form")
def browser_new_form(case_id: str, kind: str | None = None) -> dict[str, Any]:
"""进入报销系统并新建单据。kind 省略时按会话里已有的 info 推断。"""
d = _case_dir(case_id)
ctx = _require_open(case_id)
if d is None or ctx is None:
return fail("浏览器还没打开", next_action="mcp__arb__browser_open")
if kind is None:
kind, both = _infer_kind(d)
if both:
return fail(
"会话里同时有 travel_info 与 normal_info,无法判断单据类型",
next_action="显式传 kind='travel' 或 'normal'(混合发票只能走普通报销)",
)
if kind is None:
return fail(
"会话里还没有 travel_info.json / normal_info.json",
next_action="先 mcp__arb__save_info 落盘报销信息",
)
if kind not in STEP_ORDER:
return fail(f"kind 只能是 travel 或 normal,收到 {kind}")
if ctx.get("kind") and ctx["kind"] != kind:
return fail(
f"当前浏览器已经建了 {ctx['kind']} 单据",
next_action="要换类型先 mcp__arb__browser_close 再 browser_open",
)
def _new() -> dict[str, Any]:
bot = ctx["bot"]
if not bot.portal_reached():
raise RuntimeError("尚未登录信息门户")
bot.navigate_to_reimburse(page_key=PAGE_KEY[kind])
bot.create_new_form()
return {"url": bot.page.url, "screenshot": bot._screenshot(f"new_form_{kind}")}
try:
result = actor.run_browser(_new)
except RuntimeError as exc:
return fail(str(exc), next_action="mcp__arb__browser_login")
except Exception as exc:
return fail(f"新建单据失败: {exc}", next_action="mcp__arb__browser_page_state 看页面卡在哪一步")
ctx["kind"] = kind
ctx["steps"] = []
actor.touch(d, result.get("url", ""))
return ok(
kind=kind,
url=result.get("url", ""),
screenshot=result.get("screenshot", ""),
steps=STEP_ORDER[kind],
next_action="mcp__arb__browser_fill(step='basic')",
)
def browser_fill(case_id: str, step: str) -> dict[str, Any]:
"""执行一步填报。step 按 browser_new_form 返回的 steps 顺序,不能跳步。"""
d = _case_dir(case_id)
ctx = _require_open(case_id)
if d is None or ctx is None:
return fail("浏览器还没打开", next_action="mcp__arb__browser_open")
kind = ctx.get("kind")
if not kind:
return fail("还没建单据", next_action="mcp__arb__browser_new_form")
if step not in STEP_ORDER[kind]:
return fail(f"{kind} 流程没有这一步: {step}", steps=STEP_ORDER[kind])
done = list(ctx.get("steps", []))
expected = STEP_ORDER[kind]
missing = [s for s in expected[: expected.index(step)] if s not in done]
if missing:
return fail(
f"缺少前置步骤: {'、'.join(missing)}",
next_action=f"mcp__arb__browser_fill(step='{missing[0]}')",
done=done,
)
info = _load_info(d, kind)
if info is None:
return fail(f"读不到 {INFO_FILE[kind]}", next_action="先 mcp__arb__save_info 落盘报销信息")
def _fill() -> dict[str, Any]:
bot = ctx["bot"]
filled = _apply_step(bot, kind, step, info)
return {"filled": filled, "screenshot": bot._screenshot(f"{step}_done"), "url": bot.page.url}
try:
result = actor.run_browser(_fill)
except Exception as exc:
def _snap() -> str:
bot = ctx.get("bot")
return str(bot._screenshot(f"{step}_error")) if bot is not None else ""
shot = ""
try:
shot = str(actor.run_browser(_snap))
except Exception:
shot = ""
return fail(
f"{STEP_LABEL[step]}填写失败: {exc}",
next_action="mcp__arb__browser_page_state 看当前页;修正数据后用同一 step 重填",
step=step,
screenshot=shot,
)
actor.mark_step(case_id, step)
actor.touch(d, str(result.get("url", "")))
done_after = list(ctx.get("steps", []))
remaining = [s for s in expected if s not in done_after]
return ok(
step=step,
label=STEP_LABEL[step],
filled=result.get("filled"),
screenshot=result.get("screenshot", ""),
done=done_after,
remaining=remaining,
next_action=(f"mcp__arb__browser_fill(step='{remaining[0]}')" if remaining else "mcp__arb__browser_page_state"),
)
def _apply_step(bot: Any, kind: str, step: str, info: dict[str, Any]) -> dict[str, Any]:
"""把 step 映射到 infra/browser 的步骤函数,返回「填了多少」的描述。"""
from ...infra.browser import normal as normal_flow
from ...infra.browser import travel as travel_flow
if kind == "travel":
if step == "basic":
basic = info.get("basic_info", {})
travel_flow.fill_travel_info(bot, basic)
return {"字段数": len(basic)}
if step == "details":
details = info.get("reimbursement_details", {})
travel_flow.add_travel_items(bot, details)
counts = {k: len(v) for k, v in details.items() if isinstance(v, list)}
return counts if counts else {"明细": "见 travel_info"}
if step == "payment":
payments = info.get("payment_methods", [])
travel_flow.fill_travel_payment(bot, payments)
return {"支付笔数": len(payments)}
if step == "subsidy":
subsidy = info.get("subsidy_list", [])
travel_flow.fill_travel_subsidy(bot, subsidy)
return {"补助条目": len(subsidy)}
attachments = info.get("attachments", [])
travel_flow.upload_travel_attachments(bot, attachments)
return {"附件数": len(attachments)}
if step == "basic":
desc = str(info.get("basic_info", {}).get("reimbursement_description", "元器件采购报销"))
normal_flow.fill_basic_info(bot, desc)
return {"报销说明": desc}
if step == "details":
details = info.get("reimbursement_details", {})
total_invoices = int(details.get("total_invoices", 0) or 0)
total_amount = float(details.get("total_amount", 0) or 0)
normal_flow.add_normal_item(bot, total_invoices, total_amount)
return {"单据数": total_invoices, "总金额": total_amount}
if step == "payment":
payments = info.get("payment_methods", [])
normal_flow.fill_normal_payment(bot, payments)
return {"支付笔数": len(payments)}
attachments = info.get("attachments", [])
normal_flow.upload_normal_attachments(bot, attachments)
return {"附件数": len(attachments)}
def browser_page_state(case_id: str, screenshot: bool = True) -> dict[str, Any]:
"""看当前页面:URL、标题、命中的表单标记、已完成步骤、最近截图。
at_portal 只表示在信息门户;进了报销系统要用 in_reimburse_system 与 on_form_page 判断。
"""
d = _case_dir(case_id)
ctx = _require_open(case_id)
if d is None or ctx is None:
return fail("浏览器还没打开", next_action="mcp__arb__browser_open")
def _state() -> dict[str, Any]:
bot = ctx["bot"]
markers = {
"基本信息页": "#EXPENEXPLAIN",
"明细页": "#insertDetail",
"支付页": "#insertPay",
"附件页": "#insertAcc",
"提交按钮": "#submit",
}
hit: dict[str, bool] = {}
for label, selector in markers.items():
try:
hit[label] = bot.page.query_selector(selector) is not None
except Exception:
hit[label] = False
shot = ""
if screenshot:
try:
shot = str(bot._screenshot("state"))
except Exception:
shot = ""
try:
title = str(bot.page.title())
except Exception:
title = ""
url_value = ""
try:
url_value = str(bot.page.url)
except Exception:
url_value = ""
# 进了报销系统后 URL 就不再带门户标记,所以分开报,别再用一个 logged_in 混起来
reimburse = str(getattr(bot, "config", {}).get("reimburse_url", "") or "")
host = reimburse.split("//")[-1].split("/")[0]
return {
"url": url_value,
"title": title,
"at_portal": bool(bot.portal_reached()),
"in_reimburse_system": bool(host) and host in url_value,
"on_form_page": any(hit.values()),
"human_required": bool(bot.human_required()),
"markers": hit,
"screenshot": shot,
}
try:
state = actor.run_browser(_state)
except Exception as exc:
return fail(f"读取页面状态失败: {exc}", next_action="mcp__arb__browser_close 后重开")
actor.touch(d, str(state.get("url", "")))
shots_dir = d / "screenshots"
shots = sorted(shots_dir.glob("*.png")) if shots_dir.exists() else []
return ok(
kind=ctx.get("kind"),
steps_done=list(ctx.get("steps", [])),
recent_screenshots=[str(s) for s in shots[-5:]],
**state,
)
def browser_close(case_id: str) -> dict[str, Any]:
"""关闭浏览器并释放锁。不会提交也不会存草稿,填了一半的内容会丢。"""
d = _case_dir(case_id)
if d is None:
return fail(f"会话不存在: {case_id}")
ctx = _require_open(case_id)
if ctx is None:
actor.release(d)
return ok(closed=False, note="该会话本来就没有浏览器")
elapsed = round(time.time() - float(ctx.get("opened_at", time.time())), 1)
steps = list(ctx.get("steps", []))
kind = ctx.get("kind")
def _close() -> None:
bot = ctx.get("bot")
if bot is not None:
bot.close()
try:
actor.run_browser(_close)
except Exception as exc:
log.warning("关闭浏览器时出错 %s: %s", case_id, exc)
actor.drop_session(case_id)
actor.release(d)
return ok(
closed=True,
kind=kind,
steps_done=steps,
open_seconds=elapsed,
next_action="提交还没实现(P4);要回看填了什么用 browser_page_state",
)
def browser_open_form(
case_id: str,
match: str,
kind: str | None = None,
preview_only: bool = False,
) -> dict[str, Any]:
"""从列表页找回已存在的单据接着填(中途断了不必重来)。
match 用报销说明里的关键词,必须在当前列表页唯一命中;命中多张直接失败,
让宿主把关键词收紧——财务系统里点错别人的单据是不能接受的后果。preview_only=true
只搜索定位、不点开单据,用来先确认是哪一张。
"""
d = _case_dir(case_id)
ctx = _require_open(case_id)
if d is None or ctx is None:
return fail("浏览器还没打开", next_action="mcp__arb__browser_open")
if not match or not match.strip():
return fail("match 不能为空", next_action="传报销说明里的一段关键词,要能唯一命中")
if kind is None:
kind, both = _infer_kind(d)
if both or kind is None:
if ctx.get("kind"):
kind = ctx["kind"]
else:
return fail(
"无法确定单据类型",
next_action="显式传 kind='travel' 或 'normal'",
)
if kind not in STEP_ORDER:
return fail(f"kind 只能是 travel 或 normal,收到 {kind}")
def _resume() -> dict[str, Any]:
bot = ctx["bot"]
if not bot.portal_reached() and "expen" not in (bot.page.url or ""):
raise RuntimeError("尚未登录信息门户")
bot.navigate_to_reimburse(page_key=PAGE_KEY[kind])
rows = bot.search_forms(match)
opened: dict[str, Any] = bot.open_form(match, dry_run=preview_only)
opened["rows_after_search"] = rows
opened["sample"] = bot.list_forms(4)
return opened
try:
result = actor.run_browser(_resume)
except RuntimeError as exc:
return fail(str(exc), next_action="mcp__arb__browser_login")
except Exception as exc:
return fail(f"找回单据失败: {exc}", next_action="mcp__arb__browser_page_state 看页面现在在哪")
actor.touch(d, str(result.get("url", "")))
if not result.get("found"):
return fail(
f"列表页没有匹配「{match}」的单据(搜索后 {result.get('rows_after_search')} 行)",
next_action="收紧或更换 match;单据区间下拉默认只看近三个月,更早的单据要先把区间改掉",
sample=result.get("sample", []),
)
if result.get("ambiguous"):
return fail(
f"关键字「{match}」命中 {result.get('found')} 张单据,不替你选",
next_action="把 match 收紧到能唯一命中(报销说明全文或单据编号)",
candidates=result.get("candidates", []),
)
if preview_only:
return ok(
preview=True,
kind=kind,
bill_no=result.get("bill_no", ""),
state=result.get("state", ""),
amount=result.get("amount", ""),
text=result.get("text", ""),
next_action="确认单据编号无误后去掉 preview_only 再调一次",
)
ctx["kind"] = kind
ctx["resumed"] = True
return ok(
kind=kind,
resumed=True,
bill_no=result.get("bill_no", ""),
state=result.get("state", ""),
amount=result.get("amount", ""),
url=result.get("url", ""),
description=result.get("description"),
form_markers=result.get("form_markers", {}),
screenshot=result.get("screenshot", ""),
steps=STEP_ORDER[kind],
next_action="mcp__arb__browser_page_state 看已填到哪一步,再从该步继续 browser_fill(重填同一字段是覆盖)",
)
def browser_steps(case_id: str) -> dict[str, Any]:
"""列出该会话单据类型的步骤顺序与含义(不要求浏览器已打开)。"""
d = _case_dir(case_id)
if d is None:
return fail(f"会话不存在: {case_id}")
kind, both = _infer_kind(d)
ctx = _require_open(case_id)
if ctx is not None and ctx.get("kind"):
kind = ctx["kind"]
if both and not (ctx is not None and ctx.get("kind")):
return ok(kinds=list(STEP_ORDER), note="会话里 travel_info 与 normal_info 同时存在,建单时要显式传 kind")
if kind is None:
return ok(
kinds=list(STEP_ORDER),
note="还没落 info,无法确定类型;两种流程的步骤如下",
steps={k: [{"step": s, "label": STEP_LABEL[s]} for s in v] for k, v in STEP_ORDER.items()},
)
return ok(
kind=kind,
steps=[{"step": s, "label": STEP_LABEL[s]} for s in STEP_ORDER[kind]],
done=list((ctx or {}).get("steps", [])),
)