"""Inspector-owned Playwright browser session (dev-time page dump). All Playwright sync API calls run on one dedicated worker thread (Playwright sync is not cross-thread safe). Dump / screenshot / recorder target the **currently visible** page (document.visibilityState === 'visible'), not a sticky pages[0]. This keeps list ↔ detail tab switching aligned with what the user sees. Uses jiangchang_skill_core.rpa stealth launch parts + dump_page_sync. Profile lives under ui_inspector/_browser_profile (not account-manager). """ from __future__ import annotations import atexit import queue import threading import uuid from pathlib import Path from typing import Any, Callable, Optional _HERE = Path(__file__).resolve().parent _DEFAULT_PROFILE = _HERE / "_browser_profile" _DEFAULT_START_URL = "about:blank" _cmd_q: queue.Queue = queue.Queue() _worker: Optional[threading.Thread] = None _worker_lock = threading.Lock() # Owned exclusively by worker thread: _pw = None _context = None _page = None _started = False _bridge_installed = False _bridge_sink: Optional[Callable[[dict[str, Any]], None]] = None _bridge_lock = threading.Lock() _BRIDGE_NAME = "__openclawUiInspectorEmit" _page_ids: dict[int, str] = {} _page_refs: dict[int, Any] = {} _pages_by_id: dict[str, Any] = {} _frame_ids: dict[int, str] = {} _frame_refs: dict[int, Any] = {} def _page_id_for(page: Any) -> str: if not _page_is_open(page): return "" key = id(page) page_id = _page_ids.get(key) if _page_refs.get(key) is page else None if not page_id: page_id = f"page_{uuid.uuid4().hex[:12]}" _page_ids[key] = page_id _page_refs[key] = page _pages_by_id[page_id] = page return page_id def _frame_id_for(frame: Any, *, page_id: str = "") -> str: if frame is None: return "" key = id(frame) frame_id = _frame_ids.get(key) if _frame_refs.get(key) is frame else None if not frame_id: prefix = page_id or "page" frame_id = f"{prefix}:frame_{uuid.uuid4().hex[:10]}" _frame_ids[key] = frame_id _frame_refs[key] = frame return frame_id def _page_for_id(page_id: str) -> Any: page = _pages_by_id.get(str(page_id or "")) if _page_is_open(page): return page if page_id: _pages_by_id.pop(str(page_id), None) return None def kit_page_dump_available() -> bool: try: from jiangchang_skill_core.rpa.page_dump import dump_page_sync # noqa: F401 return True except ImportError: return False def profile_dir() -> Path: return _DEFAULT_PROFILE def _ensure_worker() -> None: global _worker with _worker_lock: if _worker is not None and _worker.is_alive(): return def loop() -> None: while True: item = _cmd_q.get() if item is None: break fn, args, kwargs, out_q = item try: out_q.put(("ok", fn(*args, **kwargs))) except BaseException as exc: out_q.put(("err", exc)) _worker = threading.Thread(target=loop, name="ui-inspector-pw", daemon=True) _worker.start() atexit.register(shutdown) def _call_on_worker( fn: Callable[..., Any], *args: Any, timeout_sec: float = 30.0, **kwargs: Any, ) -> Any: _ensure_worker() out_q: queue.Queue = queue.Queue() _cmd_q.put((fn, args, kwargs, out_q)) try: status, payload = out_q.get(timeout=max(0.1, float(timeout_sec))) except queue.Empty as exc: raise RuntimeError( f"ERROR:BROWSER_WORKER_TIMEOUT Playwright 命令超过 {timeout_sec:g} 秒未返回。" ) from exc if status == "err": raise payload return payload def _teardown_unlocked() -> None: global _pw, _context, _page, _started, _bridge_installed, _bridge_sink try: if _context is not None: _context.close() except Exception: pass try: if _pw is not None: _pw.stop() except Exception: pass _pw = None _context = None _page = None _started = False _bridge_installed = False with _bridge_lock: _bridge_sink = None _page_ids.clear() _page_refs.clear() _pages_by_id.clear() _frame_ids.clear() _frame_refs.clear() def _goto_safe(page: Any, start_url: str) -> dict[str, Any]: url = (start_url or "").strip() if not url or url == "about:blank": return {"ok": True, "requested_url": url, "actual_url": page.url or ""} try: page.goto(url, wait_until="domcontentloaded", timeout=60_000) except Exception as exc: actual = "" try: actual = str(page.url or "") except Exception: pass # Some sites keep loading after domcontentloaded timeout. A real HTTP # page is usable, but the warning must remain visible to callers. if actual.startswith(("http://", "https://")): return { "ok": True, "requested_url": url, "actual_url": actual, "warning": str(exc), } raise RuntimeError( f"ERROR:NAVIGATION_FAILED requested={url!r} actual={actual!r}: {exc}" ) from exc actual = str(page.url or "") if not actual or actual == "about:blank" or actual.startswith("about:"): raise RuntimeError( f"ERROR:NAVIGATION_FAILED requested={url!r} actual={actual!r}" ) return {"ok": True, "requested_url": url, "actual_url": actual} def _page_is_open(page: Any) -> bool: try: return page is not None and not page.is_closed() except Exception: return False def _open_pages(context: Any) -> list[Any]: if context is None: return [] out: list[Any] = [] try: pages = list(context.pages or []) except Exception: return [] for p in pages: if _page_is_open(p): _page_id_for(p) out.append(p) return out def _page_visibility(page: Any) -> str: """Return document.visibilityState or '' on failure.""" if not _page_is_open(page): return "" try: state = page.evaluate("() => document.visibilityState") return str(state or "").strip().lower() except Exception: return "" def resolve_active_page( context: Any, *, preferred: Any = None, ) -> tuple[Any, dict[str, Any]]: """Pick the page the user is looking at (visible tab). Returns (page, meta). Prefer visibilityState===visible; fall back to preferred if still open, else first open page. """ pages = _open_pages(context) meta: dict[str, Any] = { "resolve": "none", "page_count": len(pages), "visible_count": 0, } if not pages: raise RuntimeError("ERROR:NO_OPEN_PAGE Playwright 会话中没有可用标签页。") visible: list[Any] = [] for p in pages: if _page_visibility(p) == "visible": visible.append(p) meta["visible_count"] = len(visible) if len(visible) == 1: meta["resolve"] = "visibility" return visible[0], meta if len(visible) > 1: # Multiple windows each with a visible tab — prefer preferred if among them if preferred is not None and preferred in visible: meta["resolve"] = "visibility_preferred" return preferred, meta meta["resolve"] = "visibility_first" return visible[0], meta if preferred is not None and _page_is_open(preferred) and preferred in pages: meta["resolve"] = "preferred_fallback" return preferred, meta meta["resolve"] = "first_open" return pages[0], meta def _sync_active_page(*, update_global: bool = True) -> tuple[Any, dict[str, Any]]: """Resolve visible page; optionally assign to module ``_page``.""" global _page if _context is None: raise RuntimeError("ERROR:NO_BROWSER_SESSION Playwright 会话未启动。") page, meta = resolve_active_page(_context, preferred=_page) if update_global: _page = page try: meta["url"] = page.url or "" meta["title"] = page.title() or "" except Exception: meta["url"] = "" meta["title"] = "" meta["page_id"] = _page_id_for(page) return page, meta def _resolve_capture_page(page_id: str = "") -> tuple[Any, dict[str, Any]]: global _page requested = str(page_id or "") if requested: page = _page_for_id(requested) if page is not None: _page = page meta = { "resolve": "event_page_id", "page_id": requested, "page_count": len(_open_pages(_context)), "visible_count": None, } try: meta["url"] = page.url or "" meta["title"] = page.title() or "" except Exception: meta["url"] = "" meta["title"] = "" return page, meta raise RuntimeError(f"ERROR:EVENT_PAGE_CLOSED page_id={requested}") return _sync_active_page(update_global=True) def _ensure_session_worker( *, start_url: str = _DEFAULT_START_URL, force_goto: bool = False, ) -> dict[str, Any]: global _pw, _context, _page, _started want = (start_url or "").strip() or _DEFAULT_START_URL if _started and _context is not None: try: navigation = None pages = _open_pages(_context) if not pages: _teardown_unlocked() else: # Start-flow navigation: prefer first open tab (stable home), # not whatever detail tab happens to be visible. if force_goto and want and want != "about:blank": home = pages[0] cur = "" try: cur = home.url or "" except Exception: cur = "" if cur.rstrip("/") != want.rstrip("/"): navigation = _goto_safe(home, want) _page = home else: _page, _meta = _sync_active_page(update_global=True) if want and want != "about:blank" and ( (_page.url or "") in {"", "about:blank"} or str(_page.url or "").startswith("about:") ): navigation = _goto_safe(_page, want) _page, _meta = _sync_active_page(update_global=True) try: live_url = _page.url or "" except Exception: live_url = "" return { "ok": True, "reused": True, "url": live_url, "profile_dir": str(profile_dir()), "page_count": len(_open_pages(_context)), "navigation": navigation, } except Exception: _teardown_unlocked() if not kit_page_dump_available(): raise RuntimeError( "ERROR:KIT_PAGE_DUMP_MISSING 需要 jiangchang-platform-kit>=1.2.11 " "(jiangchang_skill_core.rpa.page_dump)。请升级 kit 后重试。" ) try: from playwright.sync_api import sync_playwright except ImportError as exc: raise RuntimeError( "ERROR:MISSING_PLAYWRIGHT 未安装 playwright。" "请在与技能相同的 Python 环境中安装后重试。" ) from exc from jiangchang_skill_core.runtime_env import find_chrome_executable from jiangchang_skill_core.rpa.stealth import ( STEALTH_INIT_SCRIPT, persistent_context_launch_parts, stealth_enabled, ) chrome = find_chrome_executable() if not chrome: raise RuntimeError( "ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge,请安装后重试。" ) prof = profile_dir() prof.mkdir(parents=True, exist_ok=True) args, ignore = persistent_context_launch_parts(extra_args=["--start-maximized"]) launch_kwargs: dict[str, Any] = dict( user_data_dir=str(prof), headless=False, executable_path=chrome, locale="zh-CN", no_viewport=True, args=args, ) if ignore is not None: launch_kwargs["ignore_default_args"] = ignore _pw = sync_playwright().start() try: _context = _pw.chromium.launch_persistent_context(**launch_kwargs) if stealth_enabled(): _context.add_init_script(STEALTH_INIT_SCRIPT) if _context.pages: _page = _context.pages[0] else: _page = _context.new_page() navigation = None if want and want != "about:blank": navigation = _goto_safe(_page, want) elif want and ( _page.url in {"", "about:blank"} or str(_page.url).startswith("about:") ): navigation = _goto_safe(_page, want) _started = True return { "ok": True, "reused": False, "url": _page.url, "profile_dir": str(prof), "page_count": len(_open_pages(_context)), "navigation": navigation, } except Exception: _teardown_unlocked() raise def _dump_worker(*, max_nodes: int, locatable_limit: int, page_id: str = "") -> dict[str, Any]: from jiangchang_skill_core.rpa.page_dump import dump_page_sync _ensure_session_worker() page, meta = _resolve_capture_page(page_id) result = dump_page_sync(page, max_nodes=max_nodes, locatable_limit=locatable_limit) stats = dict(result.get("stats") or {}) stats["active_page"] = meta result["stats"] = stats return result def _screenshot_worker(*, page_id: str = "") -> bytes: _ensure_session_worker() page, _meta = _resolve_capture_page(page_id) return page.screenshot(type="png", full_page=False) def _session_info_worker() -> dict[str, Any]: if not _started or _context is None: return {"active": False} try: page, meta = _sync_active_page(update_global=True) return { "active": True, "url": page.url, "title": page.title(), "profile_dir": str(profile_dir()), "page_count": meta.get("page_count"), "resolve": meta.get("resolve"), "page_id": meta.get("page_id"), } except Exception as exc: return {"active": False, "error": str(exc)} def _dispatch_bridge_event(source: Any, payload: Any) -> None: if not isinstance(payload, dict): return item = dict(payload) try: page = source.get("page") if isinstance(source, dict) else None frame = source.get("frame") if isinstance(source, dict) else None page_id = _page_id_for(page) item["page_id"] = page_id item["frame_id"] = _frame_id_for(frame, page_id=page_id) except Exception: pass with _bridge_lock: sink = _bridge_sink if sink is not None: try: sink(item) except Exception: pass def _install_event_bridge_worker( *, init_script: str, sink: Callable[[dict[str, Any]], None] ) -> dict[str, Any]: global _bridge_installed, _bridge_sink _ensure_session_worker() assert _context is not None with _bridge_lock: _bridge_sink = sink if not _bridge_installed: _context.expose_binding(_BRIDGE_NAME, _dispatch_bridge_event) _bridge_installed = True _context.add_init_script(init_script) installed = 0 for page in _open_pages(_context): try: page.evaluate(init_script) installed += 1 except Exception: pass return {"ok": True, "binding": _BRIDGE_NAME, "installed_pages": installed} def install_event_bridge( init_script: str, sink: Callable[[dict[str, Any]], None] ) -> dict[str, Any]: """Install a context-level recorder bridge before user actions begin.""" return _call_on_worker( _install_event_bridge_worker, init_script=init_script, sink=sink, timeout_sec=15.0, ) def clear_event_bridge() -> None: global _bridge_sink with _bridge_lock: _bridge_sink = None def ensure_session( *, start_url: str = _DEFAULT_START_URL, force_goto: bool = False, ) -> dict[str, Any]: """Start or reuse headed persistent context; return status dict. force_goto=True: navigate to start_url even when session is reused (used when「开始流程」带起始 URL). """ return _call_on_worker( _ensure_session_worker, start_url=start_url, force_goto=force_goto, timeout_sec=75.0, ) def session_info() -> dict[str, Any]: return _call_on_worker(_session_info_worker, timeout_sec=10.0) def dump_current_page( *, max_nodes: int = 8000, locatable_limit: int = 300, page_id: str = "" ) -> dict[str, Any]: """Dump an event-origin page, or resolve the active page as fallback.""" return _call_on_worker( _dump_worker, max_nodes=max_nodes, locatable_limit=locatable_limit, page_id=page_id, timeout_sec=30.0, ) def screenshot_png(*, page_id: str = "") -> bytes: return _call_on_worker(_screenshot_worker, page_id=page_id, timeout_sec=20.0) def page_id_for(page: Any) -> str: """Return the stable id for a Page while already on the browser worker.""" return _page_id_for(page) def _validate_element_worker( *, page_id: str, element: dict[str, Any], frame: Optional[dict[str, Any]] = None ) -> dict[str, int]: page, _meta = _resolve_capture_page(page_id) scope: Any = page frame_meta = dict(frame or {}) frame_url = str(frame_meta.get("url") or "") frame_name = str(frame_meta.get("name") or "") if frame_url or frame_name: try: for candidate in page.frames: if frame_name and candidate.name == frame_name: scope = candidate break if frame_url and candidate.url == frame_url: scope = candidate break except Exception: pass el = dict(element or {}) role = str(el.get("role") or "").strip() name = str(el.get("name") or "").strip() test_id = str(el.get("test_id") or "").strip() label = str(el.get("label") or "").strip() placeholder = str(el.get("placeholder") or "").strip() name_attr = str(el.get("name_attr") or "").strip() href = str(el.get("href") or "").strip() text_value = str(el.get("text") or "").strip() css_id = str(el.get("id") or "").strip() css_path = str(el.get("css") or el.get("css_path") or "").strip() stable_css = str(el.get("stable_css") or "").strip() semantic_role = str(el.get("semantic_role") or "").strip() semantic_name = str(el.get("semantic_name") or "").strip() counts: dict[str, int] = {} def count(strategy: str, build: Callable[[], Any]) -> None: try: counts[strategy] = int(build().count()) except Exception: pass def count_actionable(strategy: str, build: Callable[[], Any]) -> None: try: locator = build() total = min(int(locator.count()), 50) actionable = 0 for index in range(total): item = locator.nth(index) if item.is_visible() and item.is_enabled(): actionable += 1 counts[strategy] = actionable except Exception: pass if role and name: # Playwright accessible names normalize whitespace (SVG/icon buttons are # a common example), while DOM innerText may not. Non-exact still # yields a trustworthy uniqueness count and mirrors getByRole defaults. count("role", lambda: scope.get_by_role(role, name=name, exact=False)) elif role and bool(el.get("contenteditable")): count("role", lambda: scope.get_by_role(role)) if semantic_role and semantic_name: count_actionable( "semantic_role", lambda: scope.get_by_role( semantic_role, name=semantic_name, exact=False ), ) if test_id: count("test_id", lambda: scope.get_by_test_id(test_id)) if label: count("label", lambda: scope.get_by_label(label, exact=True)) if placeholder: count("placeholder", lambda: scope.get_by_placeholder(placeholder, exact=True)) if name_attr: escaped_name = name_attr.replace("\\", "\\\\").replace('"', '\\"') count("name_attr", lambda: scope.locator(f'[name="{escaped_name}"]')) if href: escaped_href = href.replace("\\", "\\\\").replace('"', '\\"') count("href", lambda: scope.locator(f'a[href="{escaped_href}"]')) if text_value and len(text_value) <= 80: count("text", lambda: scope.get_by_text(text_value, exact=True)) if css_id and not css_id[0].isdigit(): escaped = css_id.replace("\\", "\\\\").replace('"', '\\"') count("css_id", lambda: scope.locator(f'[id="{escaped}"]')) if css_path: count("css", lambda: scope.locator(css_path)) if stable_css: count("stable_css", lambda: scope.locator(stable_css)) return counts def validate_element_on_page( *, page_id: str, element: dict[str, Any], frame: Optional[dict[str, Any]] = None ) -> dict[str, int]: """Count locator candidates against the exact live event-origin page.""" if not page_id: return {} return _call_on_worker( _validate_element_worker, page_id=page_id, element=element, frame=frame, timeout_sec=10.0, ) def call_on_page(fn: Callable[[Any], Any]) -> Any: """Run ``fn(page)`` on the **visible** Playwright page (worker thread).""" def _run() -> Any: _ensure_session_worker() page, _meta = _sync_active_page(update_global=True) return fn(page) return _call_on_worker(_run, timeout_sec=30.0) def call_on_each_open_page(fn: Callable[[Any], Any]) -> list[Any]: """Run ``fn(page)`` for every open tab; returns list of per-page results.""" def _run() -> list[Any]: _ensure_session_worker() assert _context is not None pages = _open_pages(_context) # Keep _page synced to visible for dump consistency try: _sync_active_page(update_global=True) except Exception: pass results: list[Any] = [] for p in pages: try: results.append(fn(p)) except Exception as exc: results.append({"error": str(exc)}) return results return _call_on_worker(_run, timeout_sec=20.0) def shutdown() -> None: def _stop() -> None: _teardown_unlocked() try: _call_on_worker(_stop, timeout_sec=5.0) except Exception: pass try: _cmd_q.put(None) except Exception: pass