feat(mcp): 浏览器分步填报 P3——7 个 browser 工具 + 单线程 actor + 磁盘锁
填报从「一把梭」变成分步:Agent 可以开浏览器、登录、建单、按步填、看页面、关,每步有截图, 中途失败不必从头重来。提交不在本步——提交不可逆,留给 P4 的 submit_preview/submit_execute。 - src/infra/browser/actor.py(新):Playwright 单线程 actor(sync API 的 greenlet 线程亲和要求所有 调用回到创建线程)+ 会话注册表 + cases/<id>/.browser.lock(PID/started_at/heartbeat/url, 心跳 180 秒过期才允许接管;锁文件损坏按无锁处理;atexit 尽力关浏览器)。与 COM actor 分开。 - base.py:login_portal 拆成 submit_login + wait_for_portal(返回布尔不抛异常),新增 portal_reached/ human_required;_screenshot 支持 screenshot_dir 并按 001-步骤名.png 编号、返回路径、失败只告警 - src/mcp/tools/browser.py(新):browser_open/login/new_form/fill/steps/page_state/close,工具 17 → 24 · need_human 用 ok:true + logged_in:false 返回(扫码不是失败,避免宿主当错误重试或另开窗口) · 步骤顺序硬约束,跳步拒绝并用 next_action 指回该补的一步;步骤报错回传 detail.screenshot · browser_close 明确不提交不存草稿;travel 五步、normal 四步(无 subsidy) - tests/test_mcp_browser.py(新,17 例,FakeBot/FakePage 不起浏览器):锁四种情形、need_human 分支与 截图落点、重复登录幂等、未打开时各工具拒绝、类型推断与两 info 并存时拒猜、未登录拒建单、跳步拦截、 步骤推进与 remaining、未知步骤、报错截图、close 释放会话与锁、open 复用、steps 两种流程 - 文档:docs/MCP.md 工具段按 wire schema 重生成(24 工具);AGENTS/SKILL/操作指南/README/浏览器 README 同步分步流程、锁与心跳、有头默认、提交必须交人工;方案文档 §13 记录实现、取舍与未完成项 验证: - 联机跑通 open(3.1s 起有头 Chromium)→ 锁写入(pid/heartbeat)→ page_state(about:blank, 001-state.png 4721 字节)→ 重复 open 复用(already_open=true,不多开窗)→ close(锁释放、注册表清空) - 登录与填报的联机验证需要用户在场(SSO 可能扫码),本轮未做 - 门禁:ruff / format --check / mypy src / deptry / pytest 129 passed / mcp_smoke 24 工具 instructions 2109 字节。仍用 --no-verify 提交并手跑等价检查(原因见 AGENTS 提交纪律)。
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@ MCP 服务器的全部实现:工具层在上,确定性内核在下,infra
|
||||
|
||||
```
|
||||
src/
|
||||
├── mcp/ 工具与协议层(17 个工具,stdio)
|
||||
├── mcp/ 工具与协议层(24 个工具,stdio)
|
||||
│ ├── server.py INSTRUCTIONS + TOOLS 注册 + build_server()
|
||||
│ ├── tools/ materials 材料 / extraction 抽取交接 / analysis 匹配校验 / documents 产物
|
||||
│ ├── schemas.py 抽取字段定义单一真源 + 日期金额规范化
|
||||
|
||||
@@ -13,15 +13,32 @@ Playwright 操作财务报销系统:登录信息门户、进入报销系统、
|
||||
| `normal.py` | 普通填报:基本信息 → 总明细 → 支付方式 → 附件 |
|
||||
| `__init__.py` | `run_bot(config, travel_info, normal_info)`;`run_bot_web(config, work_dir)` 从会话缓存加载后执行 |
|
||||
|
||||
## 现状与计划
|
||||
## 两种用法
|
||||
|
||||
现有入口是**一次性跑完**的编排函数,适合手工验证,不适合 Agent 分步介入:中途出错只能整体重来,
|
||||
也没法在填到一半时先看一眼。P3 计划拆成 `browser_open / login / new_form / fill(step) / page_state / close`
|
||||
分步工具,过程截图进 `cases/<id>/screenshots/NNN-<step>.png`,并用 `.browser.lock`(PID + 心跳)防止多代进程
|
||||
抢同一个浏览器;P4 再加提交前预览与一次性 token 护栏(提交不可逆)。
|
||||
- **MCP 分步工具**(推荐):`browser_open → browser_login → browser_new_form → browser_fill(step) →
|
||||
`browser_page_state → browser_close`,实现在 `src/mcp/tools/browser.py`
|
||||
- `run_bot()` / `run_bot_web()`:一把梭编排,只留给手工验证与回归;新代码别用——中途失败只能整体重来
|
||||
|
||||
步骤函数(`fill_travel_info` / `add_travel_items` / `fill_normal_payment` 等)是两种用法共用的底座,
|
||||
**出错必须回抛**(`log.error` 之后要 `raise`)。吞异常会让上层以为填好了——本项目已经因为静默失败
|
||||
丢了 7 周的出库单产物(方案文档 §11.4),不要重犯。
|
||||
|
||||
## 线程与进程约束
|
||||
|
||||
- Playwright sync 的 browser/page 只能在创建它的线程里用;MCP 是多线程调用,所以一律经
|
||||
`actor.run_browser()` 提交到专属单线程执行(与 COM actor 分开,套间要求不同)
|
||||
- 浏览器归属写在 `cases/<id>/.browser.lock`(PID + 心跳;180 秒未更新可被别的进程接管)。
|
||||
宿主可能同时跑多代 MCP 子进程(重连窗口、subagent/teammate 各一条 transport),
|
||||
各开一个 Chromium 会让 Agent 看到的截图不是它正在操作的那个页面
|
||||
- `actor.py` 的 atexit 会尽力关浏览器并释放锁;被强杀时锁文件会留下,靠心跳过期接管
|
||||
|
||||
## 计划中的 P4
|
||||
|
||||
`submit_preview`(快照 + 摘要 + 一次性 token)与 `submit_execute`(token 一次性、摘要变更即失效)。
|
||||
在那之前,填报完成必须停下交人工核对提交。
|
||||
|
||||
## 注意
|
||||
|
||||
- 会打开 Chromium,自动化期间不要手动点页面
|
||||
- 调试截图落在 `images/`(已 gitignore,不进仓库)
|
||||
- 步骤函数最好返回「填了什么 / 什么失败」的结构,P3 可直接复用
|
||||
- 会打开 Chromium(默认有头),自动化期间不要手动点页面
|
||||
- 分步填报的截图写 `cases/<id>/screenshots/NNN-<步骤名>.png`;只有走 `run_bot()` 才写
|
||||
`images/debug_*.png`(已 gitignore)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""浏览器会话的常驻持有者与独占锁
|
||||
|
||||
两件事必须同时成立,填报才可信:
|
||||
|
||||
1. **线程亲和**:Playwright 同步 API 底层是 greenlet,browser/page 对象必须在创建它的那个线程里使用。
|
||||
MCP 服务器是多线程的(每个工具调用可能在不同线程),所以所有浏览器操作统一提交到这个单线程 actor。
|
||||
与 COM 的 actor 分开:COM 套间要求不同,混用会踩坑。
|
||||
2. **进程独占**:宿主可能同时存在多代 MCP 子进程(重连窗口、subagent/teammate 各一条 transport,
|
||||
实测 4 个 PID 并存)。每个进程各开一个 Chromium,Agent 看到的截图就不是它正在操作的那个页面。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ... import get_logger
|
||||
|
||||
log = get_logger("browser.actor")
|
||||
|
||||
LOCK_FILENAME = ".browser.lock"
|
||||
# 心跳超过这个秒数没更新就视为持有者已死,可被接管
|
||||
STALE_AFTER_SECONDS = 180
|
||||
|
||||
_executor: ThreadPoolExecutor | None = None
|
||||
_lock = threading.Lock()
|
||||
# case_id -> {"bot": BaseBot, "steps": [str, ...], "kind": str|None}
|
||||
_sessions: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _pool() -> ThreadPoolExecutor:
|
||||
global _executor
|
||||
if _executor is None:
|
||||
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="playwright")
|
||||
return _executor
|
||||
|
||||
|
||||
def run_browser[T](fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
||||
"""在专用 Playwright 线程上串行执行 fn 并等待结果。"""
|
||||
with _lock:
|
||||
return _pool().submit(fn, *args, **kwargs).result()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 会话注册表(只在 actor 线程里读写,不加锁也不竞争)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def session(case_id: str) -> dict[str, Any] | None:
|
||||
"""取会话的浏览器上下文;未打开返回 None。"""
|
||||
return _sessions.get(case_id)
|
||||
|
||||
|
||||
def put_session(case_id: str, bot: Any, kind: str | None = None) -> dict[str, Any]:
|
||||
ctx: dict[str, Any] = {"bot": bot, "steps": [], "kind": kind, "opened_at": time.time()}
|
||||
_sessions[case_id] = ctx
|
||||
return ctx
|
||||
|
||||
|
||||
def drop_session(case_id: str) -> dict[str, Any] | None:
|
||||
return _sessions.pop(case_id, None)
|
||||
|
||||
|
||||
def list_sessions() -> list[str]:
|
||||
return sorted(_sessions)
|
||||
|
||||
|
||||
def mark_step(case_id: str, step: str) -> None:
|
||||
ctx = _sessions.get(case_id)
|
||||
if ctx is not None and step not in ctx["steps"]:
|
||||
ctx["steps"].append(step)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 磁盘锁
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def lock_path(case_dir: Path) -> Path:
|
||||
return case_dir / LOCK_FILENAME
|
||||
|
||||
|
||||
def read_lock(case_dir: Path) -> dict[str, Any] | None:
|
||||
"""读锁文件;损坏或不存在返回 None(损坏不阻断,按无锁处理)。"""
|
||||
path = lock_path(case_dir)
|
||||
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, exc)
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _write_lock(case_dir: Path, url: str = "") -> dict[str, Any]:
|
||||
payload = {
|
||||
"pid": os.getpid(),
|
||||
"started_at": round(time.time(), 3),
|
||||
"heartbeat": round(time.time(), 3),
|
||||
"url": url,
|
||||
}
|
||||
lock_path(case_dir).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return payload
|
||||
|
||||
|
||||
def acquire(case_dir: Path, url: str = "") -> tuple[bool, dict[str, Any]]:
|
||||
"""尝试取得该会话的浏览器所有权。返回 (是否取得, 锁信息或占用者信息)。"""
|
||||
held = read_lock(case_dir)
|
||||
if held is not None:
|
||||
other_pid = int(held.get("pid", -1))
|
||||
age = time.time() - float(held.get("heartbeat", 0))
|
||||
if other_pid != os.getpid() and age < STALE_AFTER_SECONDS:
|
||||
return False, {**held, "age_seconds": round(age, 1), "stale": False}
|
||||
if other_pid != os.getpid():
|
||||
log.warning("接管过期浏览器锁(原持有 pid=%s,%.0fs 无心跳)", other_pid, age)
|
||||
return True, _write_lock(case_dir, url)
|
||||
|
||||
|
||||
def touch(case_dir: Path, url: str = "") -> None:
|
||||
"""刷心跳:每次浏览器工具调用都调一下。"""
|
||||
if read_lock(case_dir) is None:
|
||||
return
|
||||
_write_lock(case_dir, url)
|
||||
|
||||
|
||||
def release(case_dir: Path) -> None:
|
||||
held = read_lock(case_dir)
|
||||
if held is not None and int(held.get("pid", -1)) == os.getpid():
|
||||
try:
|
||||
lock_path(case_dir).unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 收尾
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def close_all() -> None:
|
||||
"""尽力关闭所有浏览器并释放锁(进程退出时调用)。"""
|
||||
for case_id in list(_sessions):
|
||||
ctx = _sessions.pop(case_id, None)
|
||||
if not ctx:
|
||||
continue
|
||||
bot = ctx.get("bot")
|
||||
work_dir = getattr(bot, "work_dir", None)
|
||||
try:
|
||||
if bot is not None:
|
||||
bot.close()
|
||||
except Exception as exc:
|
||||
log.warning("关闭浏览器失败 %s: %s", case_id, exc)
|
||||
if isinstance(work_dir, Path):
|
||||
release(work_dir)
|
||||
|
||||
|
||||
atexit.register(close_all)
|
||||
+63
-29
@@ -38,6 +38,9 @@ class BaseBot:
|
||||
self.config = config
|
||||
self.headless = headless
|
||||
self.work_dir: Path | None = None
|
||||
# 设了就把截图按 001-步骤名.png 编号写进这里;不设保持旧行为(src/images/debug_*.png)
|
||||
self.screenshot_dir: Path | None = None
|
||||
self._shot_seq = 0
|
||||
self.browser: Any = None
|
||||
self.context: Any = None
|
||||
self.page: Any = None
|
||||
@@ -74,12 +77,44 @@ class BaseBot:
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def login_portal(self) -> None:
|
||||
"""登录信息门户"""
|
||||
log.info("登录信息门户...")
|
||||
"""登录信息门户;一把梭版本,等价于 submit_login 后等门户出现。"""
|
||||
self.submit_login()
|
||||
if not self.wait_for_portal(30):
|
||||
self._screenshot("portal_timeout")
|
||||
raise TimeoutError("登录超时,未跳转到信息门户")
|
||||
|
||||
PORTAL_MARKERS = ("tyrz.fynu.edu.cn/zs-uip", "tyrz.fynu.edu.cn/oshall", "portal")
|
||||
|
||||
def portal_reached(self) -> bool:
|
||||
"""是否已落到信息门户;可反复调用判断登录是否完成。"""
|
||||
try:
|
||||
url = self.page.url or ""
|
||||
except Exception:
|
||||
return False
|
||||
return any(marker in url for marker in self.PORTAL_MARKERS)
|
||||
|
||||
def human_required(self) -> bool:
|
||||
"""页面是否卡在需要人工的地方(扫码、验证码、仍在 SSO 登录页)。"""
|
||||
try:
|
||||
if self.portal_reached():
|
||||
return False
|
||||
for selector in (
|
||||
'text="微信扫码登录"',
|
||||
'img[src*="qrcode"]',
|
||||
'img[src*="captcha"]',
|
||||
'input[placeholder*="验证码"]',
|
||||
):
|
||||
if self.page.query_selector(selector):
|
||||
return True
|
||||
return "login" in (self.page.url or "").lower()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def submit_login(self) -> None:
|
||||
"""填工号密码、勾同意、点登录;不等跳转。"""
|
||||
log.info("登录信息门户...")
|
||||
self.page.goto(self.config["sso_login_url"], wait_until="domcontentloaded")
|
||||
self._wait_for('text="微信扫码登录"', timeout=5000)
|
||||
|
||||
try:
|
||||
self.page.fill(
|
||||
'input[placeholder*="工号"], input[placeholder*="学号"]',
|
||||
@@ -88,41 +123,27 @@ class BaseBot:
|
||||
self.page.fill('input[placeholder*="密码"]', self.config["password"])
|
||||
except Exception:
|
||||
log.warning("未找到登录输入框,可能已登录")
|
||||
|
||||
try:
|
||||
checkbox = self.page.query_selector('input[type="checkbox"]')
|
||||
if checkbox and not checkbox.is_checked():
|
||||
checkbox.click()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for selector in ['button:has-text("登录")', 'input[value="登录"]', 'text="登录"']:
|
||||
for selector in ('button:has-text("登录")', 'input[value="登录"]', 'text="登录"'):
|
||||
try:
|
||||
self.page.click(selector, timeout=3000)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
self._wait_for_portal()
|
||||
|
||||
def _wait_for_portal(self) -> None:
|
||||
"""等待跳转到统一信息平台"""
|
||||
for _ in range(30):
|
||||
self.page.wait_for_timeout(1000)
|
||||
url = self.page.url
|
||||
if any(
|
||||
kw in url
|
||||
for kw in (
|
||||
"tyrz.fynu.edu.cn/zs-uip",
|
||||
"tyrz.fynu.edu.cn/oshall",
|
||||
"portal",
|
||||
)
|
||||
):
|
||||
def wait_for_portal(self, timeout: int = 30) -> bool:
|
||||
"""等待门户跳转;返回是否成功,不抛异常——分步填报靠它判 need_human。"""
|
||||
for _ in range(timeout):
|
||||
if self.portal_reached():
|
||||
self._screenshot("portal_loaded")
|
||||
return
|
||||
log.error("等待门户跳转超时")
|
||||
self._screenshot("portal_timeout")
|
||||
raise TimeoutError("登录超时,未跳转到信息门户")
|
||||
return True
|
||||
self.page.wait_for_timeout(1000)
|
||||
return self.portal_reached()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 导航
|
||||
@@ -197,7 +218,20 @@ class BaseBot:
|
||||
def _wait_for(self, selector: str, timeout: int | None = None) -> None:
|
||||
self.page.wait_for_selector(selector, timeout=timeout)
|
||||
|
||||
def _screenshot(self, name: str) -> None:
|
||||
img_dir = Path(__file__).parent.parent.parent / "images"
|
||||
img_dir.mkdir(exist_ok=True)
|
||||
self.page.screenshot(path=str(img_dir / f"debug_{name}.png"))
|
||||
def _screenshot(self, name: str) -> str:
|
||||
"""截图并返回落盘路径;截图失败只告警,别盖掉真正的填报错误。"""
|
||||
self._shot_seq += 1
|
||||
if self.screenshot_dir is not None:
|
||||
img_dir = Path(self.screenshot_dir)
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = img_dir / f"{self._shot_seq:03d}-{name}.png"
|
||||
else:
|
||||
img_dir = Path(__file__).parent.parent.parent / "images"
|
||||
img_dir.mkdir(exist_ok=True)
|
||||
target = img_dir / f"debug_{name}.png"
|
||||
try:
|
||||
self.page.screenshot(path=str(target))
|
||||
except Exception as exc:
|
||||
log.warning("截图失败 %s: %s", target.name, exc)
|
||||
return ""
|
||||
return str(target)
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ last_reviewed: 2026-10-19
|
||||
|
||||
# src/mcp — 工具与协议层
|
||||
|
||||
MCP 服务器的全部对外面。17 个工具的参数表在 `docs/MCP.md`(那份表由运行时 schema 生成,与代码同源)。
|
||||
MCP 服务器的全部对外面。24 个工具的参数表在 `docs/MCP.md`(那份表由运行时 schema 生成,与代码同源)。
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
|
||||
+19
-4
@@ -13,11 +13,11 @@ from typing import Any
|
||||
|
||||
from mcp.server import MCPServer
|
||||
|
||||
from .tools import analysis, documents, extraction, materials
|
||||
from .tools import analysis, browser, documents, extraction, materials
|
||||
|
||||
SERVER_NAME = "auto-reimbursement"
|
||||
|
||||
INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配 → 校验 → 文档 → 浏览器填报 → 提交)。
|
||||
INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配 → 校验 → 文档 → 浏览器填报;提交由人工完成)。
|
||||
|
||||
当前已实现(可直接调用):
|
||||
1. mcp__arb__case_create 建会话;mcp__arb__case_add_files 拷入发票/支付截图/申请单
|
||||
@@ -38,9 +38,17 @@ INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配
|
||||
人工核对后要改单张票据的某个字段,用 mcp__arb__case_patch_extraction(只传要改的键,会回显 from/to)。
|
||||
7. 每步之后用 mcp__arb__case_status 复查态势;阶段由产物推导,没有内部状态机。
|
||||
mcp__arb__case_reset(scope) 可清派生产物重跑;mcp__arb__case_list 看全部会话。
|
||||
8. 浏览器分步填报:mcp__arb__browser_open(默认有头 Chromium;同一会话重复调用会复用现有浏览器)→
|
||||
mcp__arb__browser_login(页面停在扫码或验证码时返回 need_human,请用户在窗口里完成后重调本工具)→
|
||||
mcp__arb__browser_new_form(kind 省略时按会话里的 info 推断;travel_info 与 normal_info 同时存在必须显式传)→
|
||||
按它返回的 steps 顺序逐个 mcp__arb__browser_fill(step=...)(travel: basic/details/payment/subsidy/attachments,
|
||||
normal 没有 subsidy)→ mcp__arb__browser_page_state 复查 → mcp__arb__browser_close(不提交也不存草稿)。
|
||||
浏览器归属写在 cases/<id>/.browser.lock(PID + 心跳):返回 owned_by_other_process 时不要另开窗口,
|
||||
让持有者 close 或等心跳超时 180 秒后自动接管。截图按 001-步骤名.png 落在 cases/<id>/screenshots/。
|
||||
|
||||
尚未实现(**现在不要调用,也不要在回答里承诺**):Word 出库单与 Excel 申购单、浏览器填报、
|
||||
提交预览与执行。需要这些步骤时告诉用户该环节还在改造中,可暂用旧的 Web 流程,不要自己拼 HTML 或猜接口。
|
||||
尚未实现(**现在不要调用,也不要在回答里承诺**):提交预览与提交执行(submit_preview / submit_execute)。
|
||||
填报完成后必须停下:把 browser_page_state 的截图与已填内容报给用户,由用户在财务系统里核对并亲自点提交。
|
||||
Word 出库单功能已下线(模板自 2026-08 起不在仓库),不要再提;Excel 申购单见第 6 条。
|
||||
|
||||
硬约束:不得回显密码;不得臆造财务数据,拿不准的字段标出来问用户;产物只写 cases/<case_id>/。
|
||||
"""
|
||||
@@ -63,6 +71,13 @@ TOOLS: list[Callable[..., dict[str, Any]]] = [
|
||||
analysis.rules_get,
|
||||
analysis.info_skeleton,
|
||||
documents.doc_requisition,
|
||||
browser.browser_open,
|
||||
browser.browser_login,
|
||||
browser.browser_new_form,
|
||||
browser.browser_fill,
|
||||
browser.browser_page_state,
|
||||
browser.browser_steps,
|
||||
browser.browser_close,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
"""分步浏览器填报工具
|
||||
|
||||
取代旧的「一把梭」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、标题、命中的表单标记、已完成步骤、最近截图。"""
|
||||
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 = ""
|
||||
return {
|
||||
"url": url_value,
|
||||
"title": title,
|
||||
"logged_in": bool(bot.portal_reached()),
|
||||
"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_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", [])),
|
||||
)
|
||||
Reference in New Issue
Block a user