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 提交纪律)。
This commit is contained in:
wandering
2026-10-20 10:44:57 +08:00
parent 1725244faf
commit ceefe3c4eb
15 changed files with 753 additions and 85 deletions
+39 -11
View File
@@ -22,26 +22,51 @@ from pathlib import Path
from typing import Any
from ... import get_logger
from ...cases import BROWSER_LOCK_FILENAME
log = get_logger("browser.actor")
LOCK_FILENAME = ".browser.lock"
# 文件名归 cases.py 定义(会话目录布局的唯一真相),这里只是沿用
LOCK_FILENAME = BROWSER_LOCK_FILENAME
# 心跳超过这个秒数没更新就视为持有者已死,可被接管
STALE_AFTER_SECONDS = 180
_executor: ThreadPoolExecutor | None = None
_lock = threading.Lock()
_atexit_registered = False
# case_id -> {"bot": BaseBot, "steps": [str, ...], "kind": str|None}
_sessions: dict[str, dict[str, Any]] = {}
def _pool() -> ThreadPoolExecutor:
global _executor
global _executor, _atexit_registered
if _executor is None:
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="playwright")
_register_shutdown_hook()
return _executor
def _register_shutdown_hook() -> None:
"""把 close_all 挂到 executor 自己的退出机制之前。
踩过的坑:钩子写在模块底部用 atexit.register 是无效的——
concurrent.futures.thread 用的是 threading._register_atexit,它由
threading._shutdown() 触发,而解释器收尾顺序是先 threading._shutdown() 再跑 atexit,
等 close_all 轮到执行时 executor 已经 shutdown,close() 提交不进去,浏览器留成孤儿。
所以要注册到同一个列表里(后注册者先跑),并在这里注册而不是模块导入时
——那时 executor 还没建(线程也还没起),提前注册反而更乱。
"""
global _atexit_registered
if _atexit_registered:
return
hook = getattr(threading, "_register_atexit", None)
if callable(hook):
hook(close_all)
else: # 兜底:非 CPython 或该私有 API 变了,至少把锁放掉
atexit.register(close_all)
_atexit_registered = True
def run_browser[T](fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
"""在专用 Playwright 线程上串行执行 fn 并等待结果。"""
with _lock:
@@ -146,20 +171,23 @@ def release(case_dir: Path) -> None:
def close_all() -> None:
"""尽力关闭所有浏览器并释放锁(进程退出时调用)。"""
"""尽力关闭所有浏览器并释放锁(进程退出时调用)。
close() 必须在 actor 线程里跑——Playwright sync 的对象有 greenlet 线程亲和,
从主线程(atexit 就是主线程)直接关会抛 Cannot switch to a different thread,
浏览器就留成孤儿进程了。actor 线程可能已经在退出流程里,所以带超时并兜底放锁。
"""
global _executor
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 bot is not None and _executor is not None:
try:
_executor.submit(bot.close).result(timeout=20)
except Exception as exc:
log.warning("关闭浏览器失败 %s: %s(Chromium 可能需要手动结束)", case_id, exc)
if isinstance(work_dir, Path):
release(work_dir)
atexit.register(close_all)
+292 -36
View File
@@ -3,6 +3,8 @@
提供浏览器生命周期管理、登录、导航、截图等公共操作。
"""
import re
import time
from pathlib import Path
from typing import Any
@@ -149,50 +151,304 @@ class BaseBot:
# 导航
# ---------------------------------------------------------------
def navigate_to_reimburse(self, page_key: str = "reimburse_page") -> None:
"""从统一信息平台进入报销系统"""
log.info("进入报销系统...")
self._wait_for('text="快捷入口"', timeout=5000)
# ---------------------------------------------------------------
# 进入报销系统
#
# 门户 → 财务系统(dddl) → 网络报销(wlbx 链接) → 具体页面。每一跳都是异步加载,
# 单点硬等待(老代码的 timeout=5000)实测会偶发超时:同一份代码第一次过、第二次挂。
# 所以这里全部改成轮询,且失败时截图 + 抛人话(工具层会转成 fail 信封给宿主看)。
# ---------------------------------------------------------------
try:
self.page.click('text="财务系统"', timeout=5000)
except Exception:
log.warning("未找到财务系统入口")
def reimburse_host(self) -> str:
return str(self.config.get("reimburse_url", "")).split("//")[-1].split("/")[0].strip()
new_tab = None
for _ in range(15):
self.page.wait_for_timeout(1000)
for p in self.context.pages:
if "dddl" in p.url or "210.45.32.214" in p.url:
new_tab = p
break
if new_tab:
break
def _on_reimburse(self) -> bool:
host = self.reimburse_host()
return bool(host) and host in (self.page.url or "")
if new_tab:
self.page = new_tab
self._wait_for('text="网络报销"', timeout=5000)
else:
log.warning(f"未找到单点登录页面,当前 URL: {self.page.url}")
for p in self.context.pages[:-1]:
def _wait_any(self, selectors: list[str], timeout_ms: int = 20000, poll_ms: int = 500) -> str | None:
"""轮询多个选择器,返回第一个命中的;超时返回 None(不抛异常)。"""
deadline = time.monotonic() + timeout_ms / 1000.0
while time.monotonic() < deadline:
for selector in selectors:
try:
if self.page.query_selector(selector) is not None:
return selector
except Exception:
pass
try:
p.close()
self.page.wait_for_timeout(poll_ms)
except Exception:
return None
return None
def _page_hint(self, limit: int = 80) -> str:
"""页面上第一句可见文字,用来把「长时间未操作,请重新登录!」这类提示带进错误信息。"""
try:
text = " ".join((self.page.inner_text("body", timeout=3000) or "").split())
except Exception:
return ""
return text[:limit]
def _fail_navigation(self, what: str, shot: str) -> RuntimeError:
hint = self._page_hint()
detail = f"{what};当前 URL: {self.page.url}"
if hint:
detail += f";页面文字: {hint}"
if "重新登录" in hint or "超时" in hint:
detail += "(财务系统会话已过期:重新调 mcp__arb__browser_login 走一遍门户)"
if shot:
detail += f";截图: {shot}"
log.error(detail)
return RuntimeError(detail)
def _find_reimburse_tab(self, timeout_ms: int = 25000) -> Any:
"""找门户点「财务系统」后弹出的 tab(dddl 或直接是报销系统 IP)。"""
host = self.reimburse_host()
deadline = time.monotonic() + timeout_ms / 1000.0
while time.monotonic() < deadline:
for p in self.context.pages:
url = ""
try:
url = p.url or ""
except Exception:
continue
if "dddl" in url or (host and host in url):
return p
self.page.wait_for_timeout(700)
return None
def _wlbx_href(self, timeout_ms: int = 20000) -> str:
"""读「网络报销」图标的链接,读到为止;读不到返回空串。"""
deadline = time.monotonic() + timeout_ms / 1000.0
while time.monotonic() < deadline:
try:
link = self.page.query_selector('a:has(img[src*="wlbx"])')
if link is not None:
href = link.get_attribute("href") or ""
if href:
return href
except Exception:
pass
self.page.wait_for_timeout(700)
return ""
def navigate_to_reimburse(self, page_key: str = "reimburse_page") -> None:
"""从信息门户进入报销系统的具体页面;每一步都可能被门户拖慢,所以能跳过就跳过。"""
log.info("进入报销系统...")
target = str(self.config.get("reimburse_url", "")).rstrip("/") + str(self.config.get(page_key, ""))
# 已经在报销系统页面就不要再走一遍门户:分步填报中途重连时,
# 重新导航会把填了一半的单据冲掉。
if self._on_reimburse() and self._wait_any(['text="单据状态:"', "#table", "#EXPENEXPLAIN"], 2000):
log.info("已在报销系统页面,跳过门户跳转: %s", self.page.url)
return
self._wait_any(['text="快捷入口"', 'text="财务系统"'], 15000)
try:
self.page.click('text="财务系统"', timeout=8000)
except Exception:
shot = self._screenshot("no_finance_entry")
if not self._on_reimburse():
raise self._fail_navigation("门户里没找到「财务系统」入口", shot) from None
tab = self._find_reimburse_tab()
if tab is not None:
self.page = tab
try:
self.page.bring_to_front()
except Exception:
pass
self._wait_any(['text="网络报销"', 'a:has(img[src*="wlbx"])'], 15000)
for p in list(self.context.pages):
if p is self.page:
continue
try:
p.close()
except Exception:
pass
else:
log.warning("没等到财务系统 tab,继续用当前页试试: %s", self.page.url)
href = self._wlbx_href()
if href:
try:
self.page.goto(href, wait_until="domcontentloaded", timeout=20000)
except Exception:
pass
try:
link = self.page.query_selector('a:has(img[src*="wlbx"])')
if link:
reimburse_url = link.get_attribute("href")
self.page.goto(reimburse_url, wait_until="domcontentloaded", timeout=15000)
except Exception:
pass
hit = self._wait_any(['text="报销录入"', 'text="单据状态:"', "#table"], 15000)
if hit is None:
shot = self._screenshot("not_in_reimburse")
raise self._fail_navigation("点了网络报销但没进报销系统", shot)
self._wait_for('text="报销录入"', timeout=5000)
common_url = self.config["reimburse_url"] + self.config[page_key]
self.page.goto(common_url, wait_until="domcontentloaded", timeout=15000)
self._wait_for('text="单据状态:"', timeout=5000)
if page_key not in (self.page.url or ""):
try:
self.page.goto(target, wait_until="domcontentloaded", timeout=20000)
except Exception:
pass
done = self._wait_any(['text="单据状态:"', "#table", "#EXPENEXPLAIN", "#insertDetail"], 25000)
if done is None:
shot = self._screenshot("reimburse_page_not_ready")
raise self._fail_navigation("报销页面没加载出来", shot)
log.info("已进入报销系统: %s", self.page.url)
def _on_form_page(self) -> bool:
try:
return self.page.query_selector("#EXPENEXPLAIN") is not None
except Exception:
return False
# ---------------------------------------------------------------
# 从列表页找回已有单据(分步填报中途断了要能接上)
#
# 列表页表格是 AJAX 后加载的,工具栏右上角的关键字框 #seacher 是可见的,
# 而 #expenexplain 那套高级查询在收起来的面板里(元素存在但不可见,别去 fill 它)。
# ---------------------------------------------------------------
def _rows(self) -> list[Any]:
return [tr for tr in self.page.query_selector_all("#table tr") if (tr.inner_text() or "").strip()]
def _table_text(self) -> str:
try:
return self.page.inner_text("#table", timeout=3000) or ""
except Exception:
return ""
def _rows_loading(self) -> bool:
"""表格是否还在转圈。空结果(没有找到匹配的记录)是加载完成,不是加载中——
否则每次搜不到都要白等 20 秒。
"""
text = self._table_text()
if "没有找到匹配" in text or "暂无数据" in text:
return False
return "加载数据" in text
# 行里可点的元素:真实列表页的「查看」是 <button onclick="gzlc(...)">,不是 <a>;
# 行选择是 input[radio]。只认 <a> 会让这里每次都等满超时(联机实测浪费 20 秒)。
ROW_ACTIONABLE = ("button", "a", "input[type=radio]", "input[type=checkbox]", "[onclick]")
def row_is_ready(self, tr: Any) -> bool:
return any(tr.query_selector(sel) is not None for sel in self.ROW_ACTIONABLE)
def wait_rows_loaded(self, timeout_ms: int = 20000) -> list[Any]:
"""等表格出数据:「正在加载」消失且数据行里有可点元素才算好。"""
deadline = time.monotonic() + timeout_ms / 1000.0
rows = self._rows()
while time.monotonic() < deadline:
rows = self._rows()
data = rows[1:] if rows else []
if data and not self._rows_loading() and any(self.row_is_ready(tr) for tr in data):
return rows
self.page.wait_for_timeout(500)
if rows:
log.warning("等表格加载超时(%sms),按当前 %s 行继续", timeout_ms, max(0, len(rows) - 1))
return rows
def search_forms(self, keyword: str = "") -> int:
"""用列表页右上角的关键字框过滤,返回非表头行数。"""
page = self.page
if keyword:
box = page.query_selector("#seacher")
if box is None:
raise RuntimeError("列表页找不到关键字搜索框 #seacher(可能不在列表页)")
box.click()
box.fill(keyword)
clicked = False
for sel in ('a:has-text("搜索")', 'button:has-text("搜索")', ".search-btn"):
try:
page.click(sel, timeout=4000)
clicked = True
break
except Exception:
continue
if not clicked:
try:
box.press("Enter")
except Exception:
pass
rows = self.wait_rows_loaded()
self._screenshot(f"list_{(keyword or 'all')[:12]}")
return max(0, len(rows) - 1)
def list_forms(self, limit: int = 12) -> list[dict[str, str]]:
"""把当前列表页的行读成结构化文本,给宿主判断是哪一条。"""
out: list[dict[str, str]] = []
for tr in self._rows()[1:]:
cells = [(td.inner_text() or "").strip() for td in tr.query_selector_all("td")]
out.append({"cells": " | ".join(cells)[:220]})
if len(out) >= limit:
break
return out
def row_cells(self, tr: Any) -> list[str]:
return [(td.inner_text() or "").strip() for td in tr.query_selector_all("td")]
def row_summary(self, tr: Any) -> dict[str, Any]:
"""从行里提出可引用的事实:单据编号、状态、金额、说明。"""
joined = " ".join(self.row_cells(tr))
digits = [c for c in self.row_cells(tr) if c.isdigit() and len(c) >= 8]
# 金额只认 0.00 这种两位小数:单据编号也是纯数字,用 float() 会认错
money = next((c for c in self.row_cells(tr) if re.fullmatch(r"\d+\.\d{2}", c)), "")
return {
"bill_no": digits[0] if digits else "",
"state": "未提交" if "未提交" in joined else ("已提交" if "已提交" in joined else ""),
"amount": money,
"text": joined[:200],
}
def find_rows(self, keyword: str) -> list[Any]:
return [tr for i, tr in enumerate(self._rows()) if i > 0 and keyword in (tr.inner_text() or "")]
def open_form(self, keyword: str, dry_run: bool = False) -> dict[str, Any]:
"""选中关键字唯一命中的行并点「修改」把单据打开继续填。
列表里的「查看」按钮是 gzlc()(工作流程图),不是打开单据,别点它。
行选择是 input[name=btSelectItem] 的单选框,工具栏 #update 修改 / #delete 删除
都作用于选中的那一行——所以唯一命中是硬要求,命中多张一律不选。
"""
hits = self.find_rows(keyword)
if not hits:
return {"found": 0, "rows": max(0, len(self._rows()) - 1), "keyword": keyword}
summaries = [self.row_summary(tr) for tr in hits]
if len(hits) > 1:
return {"found": len(hits), "keyword": keyword, "candidates": summaries[:6], "ambiguous": True}
info = dict(summaries[0])
info["found"] = 1
if dry_run:
info["clicked"] = False
return info
radio = hits[0].query_selector("input[name=btSelectItem]") or hits[0].query_selector(
"input[type=radio], input[type=checkbox]"
)
if radio is None:
raise RuntimeError("命中的行里没有可选中的选择框(btSelectItem)")
try:
radio.click()
except Exception:
hits[0].click()
update = self.page.query_selector("#update")
if update is None:
shot = self._screenshot("no_update_button")
raise RuntimeError(f"列表页没有 #update 修改按钮,当前 URL: {self.page.url};截图: {shot}")
update.click()
hit = self._wait_any(["#EXPENEXPLAIN", "#insertDetail", "#receiptmoney2"], 20000)
el = self.page.query_selector("#EXPENEXPLAIN")
info.update(
{
"clicked": True,
"opened_marker": hit or "",
"url": self.page.url,
"description": (el.input_value() if el is not None else None),
"form_markers": {
"基本信息": self.page.query_selector("#EXPENEXPLAIN") is not None,
"明细": self.page.query_selector("#insertDetail") is not None,
"支付": self.page.query_selector("#insertPay") is not None,
"附件": self.page.query_selector("#insertAcc") is not None,
},
"screenshot": self._screenshot("opened_form"),
}
)
return info
def create_new_form(self) -> None:
"""点击「新增」创建新单据"""