"""分步浏览器填报工具 取代旧的「一把梭」run_bot:Agent 可以开浏览器、登录、建单、按步填、看页面、关, 每步都有截图与明确状态,中途失败不必从头再来。提交不在这里——提交不可逆,属于 P4 的 submit_preview / submit_execute(带一次性 token)。 所有 Playwright 调用都在 src/infra/browser/actor.py 的单线程上执行;浏览器归属用 cases//.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", [])), )