rpa-core-runtime 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rpa_core/__init__.py +3 -0
- rpa_core/bsk_client.py +126 -0
- rpa_core/capture/__init__.py +13 -0
- rpa_core/capture/browser.py +300 -0
- rpa_core/capture/browser_bsk.py +253 -0
- rpa_core/capture/desktop.py +107 -0
- rpa_core/capture/desktop_agent.py +656 -0
- rpa_core/capture/extension.py +55 -0
- rpa_core/capture/hybrid.py +92 -0
- rpa_core/catalog/__init__.py +3 -0
- rpa_core/catalog/loader.py +48 -0
- rpa_core/cli.py +381 -0
- rpa_core/commands/browser/click.json +17 -0
- rpa_core/commands/browser/close.json +17 -0
- rpa_core/commands/browser/getText.json +17 -0
- rpa_core/commands/browser/input.json +17 -0
- rpa_core/commands/browser/launch.json +35 -0
- rpa_core/commands/browser/navigate.json +17 -0
- rpa_core/commands/browser/queryAll.json +17 -0
- rpa_core/commands/browser/waitFor.json +17 -0
- rpa_core/commands/data/format.json +30 -0
- rpa_core/commands/data/limit.json +33 -0
- rpa_core/commands/data/writeJson.json +17 -0
- rpa_core/commands/data/writeText.json +31 -0
- rpa_core/commands/desktop/attachWindow.json +17 -0
- rpa_core/commands/desktop/click.json +17 -0
- rpa_core/commands/desktop/closeSession.json +17 -0
- rpa_core/commands/desktop/findElement.json +17 -0
- rpa_core/commands/desktop/getText.json +17 -0
- rpa_core/commands/desktop/input.json +17 -0
- rpa_core/commands/desktop_win32/attachWindow.json +17 -0
- rpa_core/commands/desktop_win32/click.json +17 -0
- rpa_core/commands/desktop_win32/closeSession.json +17 -0
- rpa_core/commands/desktop_win32/findElement.json +17 -0
- rpa_core/commands/desktop_win32/getText.json +17 -0
- rpa_core/commands/desktop_win32/hotkey.json +17 -0
- rpa_core/commands/desktop_win32/input.json +17 -0
- rpa_core/commands/desktop_win32/menuSelect.json +17 -0
- rpa_core/compiler/__init__.py +4 -0
- rpa_core/compiler/compiler.py +125 -0
- rpa_core/compiler/plan.py +11 -0
- rpa_core/devserver/__init__.py +5 -0
- rpa_core/devserver/app.py +415 -0
- rpa_core/devserver/server.py +282 -0
- rpa_core/devserver/static/app.js +1773 -0
- rpa_core/devserver/static/i18n.js +120 -0
- rpa_core/devserver/static/icons.js +24 -0
- rpa_core/devserver/static/index.html +82 -0
- rpa_core/devserver/static/styles.css +145 -0
- rpa_core/devserver/store.py +126 -0
- rpa_core/executors/__init__.py +15 -0
- rpa_core/executors/base.py +15 -0
- rpa_core/executors/browser.py +429 -0
- rpa_core/executors/browser_bsk.py +98 -0
- rpa_core/executors/desktop.py +326 -0
- rpa_core/executors/desktop_win32.py +329 -0
- rpa_core/executors/python_worker.py +84 -0
- rpa_core/executors/registry.py +18 -0
- rpa_core/importer/__init__.py +13 -0
- rpa_core/importer/legacy_elements.py +240 -0
- rpa_core/model/__init__.py +15 -0
- rpa_core/model/capture.py +76 -0
- rpa_core/model/command.py +212 -0
- rpa_core/model/desktop.py +32 -0
- rpa_core/model/errors.py +36 -0
- rpa_core/model/runtime.py +37 -0
- rpa_core/model/workflow.py +82 -0
- rpa_core/runtime/__init__.py +3 -0
- rpa_core/runtime/checkpoint.py +91 -0
- rpa_core/runtime/events.py +54 -0
- rpa_core/runtime/orchestrator.py +787 -0
- rpa_core/runtime/resolver.py +54 -0
- rpa_core/workers/__init__.py +1 -0
- rpa_core/workers/python_worker.py +128 -0
- rpa_core_runtime-0.1.0.dist-info/METADATA +213 -0
- rpa_core_runtime-0.1.0.dist-info/RECORD +78 -0
- rpa_core_runtime-0.1.0.dist-info/WHEEL +4 -0
- rpa_core_runtime-0.1.0.dist-info/entry_points.txt +2 -0
rpa_core/__init__.py
ADDED
rpa_core/bsk_client.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""bsk CLI 子进程客户端(能力层,M14)。
|
|
2
|
+
|
|
3
|
+
`bsk` = BrowserSkill(Tencent/BrowserSkill)Rust CLI/daemon。本模块是 rpa_core
|
|
4
|
+
对 bsk 的唯一子进程入口:devserver 捕获会话与运行期执行器共用,避免协议分叉。
|
|
5
|
+
|
|
6
|
+
协议:`bsk --json <cmd> ...` → stdout JSON;失败形态 `{"code":..., "message":...}`
|
|
7
|
+
或非零退出码 + stderr。`runner` 可注入(测试无真实浏览器)。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import subprocess
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
DEFAULT_BSK_BINARY = str(Path.home() / ".local" / "bin" / "bsk.exe")
|
|
16
|
+
|
|
17
|
+
Runner = Callable[..., dict]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BskError(RuntimeError):
|
|
21
|
+
"""bsk 子协议失败(携带 daemon 返回的 code/message)。"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, code: str, message: str):
|
|
24
|
+
super().__init__(f"{code}: {message}")
|
|
25
|
+
self.code = code
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BskSessionGoneError(BskError):
|
|
29
|
+
"""bsk session 已失效(浏览器被关 / session 被 stop / 浏览器掉线)。"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_GONE_CODES = {"not_found", "session_not_found"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BskClient:
|
|
36
|
+
def __init__(self, *, bsk_binary: str | None = None, runner: Runner | None = None):
|
|
37
|
+
self._bsk_binary = bsk_binary or DEFAULT_BSK_BINARY
|
|
38
|
+
self._runner = runner
|
|
39
|
+
|
|
40
|
+
def run(self, *args: str) -> dict:
|
|
41
|
+
if self._runner is not None:
|
|
42
|
+
payload = self._runner(*args)
|
|
43
|
+
self._raise_for_error(payload, exit_code=0)
|
|
44
|
+
return payload
|
|
45
|
+
proc = subprocess.run(
|
|
46
|
+
[self._bsk_binary, "--json", *args],
|
|
47
|
+
capture_output=True, text=True, timeout=60, encoding="utf-8",
|
|
48
|
+
)
|
|
49
|
+
out = proc.stdout.strip()
|
|
50
|
+
try:
|
|
51
|
+
payload = json.loads(out) if out else {}
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
raise BskError("protocol_error", proc.stderr.strip() or out) from None
|
|
54
|
+
self._raise_for_error(
|
|
55
|
+
payload, exit_code=proc.returncode, stderr=proc.stderr.strip(), raw=out
|
|
56
|
+
)
|
|
57
|
+
return payload
|
|
58
|
+
|
|
59
|
+
def _raise_for_error(
|
|
60
|
+
self, payload: dict, *, exit_code: int, stderr: str = "", raw: str = ""
|
|
61
|
+
) -> None:
|
|
62
|
+
if not isinstance(payload, dict):
|
|
63
|
+
return # 数组载荷(如 tab list)无错误通道
|
|
64
|
+
if exit_code == 0 and not payload.get("code"):
|
|
65
|
+
return
|
|
66
|
+
code = str(payload.get("code") or f"exit_{exit_code}")
|
|
67
|
+
message = str(payload.get("message") or stderr or raw)
|
|
68
|
+
if code in _GONE_CODES:
|
|
69
|
+
raise BskSessionGoneError(code, message)
|
|
70
|
+
raise BskError(code, message)
|
|
71
|
+
|
|
72
|
+
# -- 常用命令封装 --------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def session_start(self, browser_instance_id: str | None = None) -> str:
|
|
75
|
+
args = ["session", "start"]
|
|
76
|
+
if browser_instance_id:
|
|
77
|
+
args += ["--browser", browser_instance_id]
|
|
78
|
+
payload = self.run(*args)
|
|
79
|
+
session_id = payload.get("session_id")
|
|
80
|
+
if not session_id:
|
|
81
|
+
raise BskError("protocol_error", f"no session_id in {payload}")
|
|
82
|
+
return str(session_id)
|
|
83
|
+
|
|
84
|
+
def session_stop(self, session_id: str) -> None:
|
|
85
|
+
self.run("session", "stop", session_id)
|
|
86
|
+
|
|
87
|
+
def evaluate(self, session_id: str, expression: str, *, timeout: str = "30s",
|
|
88
|
+
tab_id: str | None = None):
|
|
89
|
+
args = ["evaluate", "--session", session_id, "--timeout", timeout]
|
|
90
|
+
if tab_id:
|
|
91
|
+
args += ["--tab-id", str(tab_id)]
|
|
92
|
+
payload = self.run(*args, expression)
|
|
93
|
+
return payload.get("value")
|
|
94
|
+
|
|
95
|
+
def navigate(self, session_id: str, url: str) -> str:
|
|
96
|
+
payload = self.run("navigate", "--session", session_id, url)
|
|
97
|
+
return str(payload.get("final_url") or payload.get("url") or url)
|
|
98
|
+
|
|
99
|
+
def click(self, session_id: str, selector: str, *, modifiers: str = "") -> None:
|
|
100
|
+
args = ["click", "--session", session_id, "--selector", selector]
|
|
101
|
+
if modifiers:
|
|
102
|
+
args += ["--modifiers", modifiers]
|
|
103
|
+
self.run(*args)
|
|
104
|
+
|
|
105
|
+
def fill(self, session_id: str, selector: str, text: str) -> None:
|
|
106
|
+
self.run(
|
|
107
|
+
"fill", "--session", session_id, "--selector", selector, "--text", text
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def browsers(self) -> list[dict]:
|
|
111
|
+
payload = self.run("browsers")
|
|
112
|
+
return payload if isinstance(payload, list) else []
|
|
113
|
+
|
|
114
|
+
def tab_list(self, session_id: str, *, scope: str = "user") -> list[dict]:
|
|
115
|
+
payload = self.run("tab", "list", "--session", session_id, "--scope", scope)
|
|
116
|
+
if isinstance(payload, list):
|
|
117
|
+
return payload
|
|
118
|
+
return payload.get("tabs", []) if isinstance(payload, dict) else []
|
|
119
|
+
|
|
120
|
+
def tab_borrow(self, session_id: str, tab_id: str) -> dict:
|
|
121
|
+
"""借用用户标签页进 Agent Window(返回含 tab_id/original_window_id)。"""
|
|
122
|
+
return self.run("tab", "borrow", "--session", session_id, str(tab_id))
|
|
123
|
+
|
|
124
|
+
def tab_return(self, session_id: str, tab_id: str) -> None:
|
|
125
|
+
"""归还借用标签页到原窗口原位置。"""
|
|
126
|
+
self.run("tab", "return", "--session", session_id, str(tab_id))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .browser import BrowserCaptureSession
|
|
2
|
+
from .browser_bsk import BrowserBskCaptureSession
|
|
3
|
+
from .desktop import DesktopCaptureSession
|
|
4
|
+
from .extension import ExtensionCaptureSession
|
|
5
|
+
from .hybrid import HybridCaptureSession
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"BrowserCaptureSession",
|
|
9
|
+
"BrowserBskCaptureSession",
|
|
10
|
+
"DesktopCaptureSession",
|
|
11
|
+
"ExtensionCaptureSession",
|
|
12
|
+
"HybridCaptureSession",
|
|
13
|
+
]
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""浏览器元素捕获会话(M10)。
|
|
2
|
+
|
|
3
|
+
两种传输(docs/capture-transport.md §3):
|
|
4
|
+
- persistent: launch_persistent_context 专用 profile(M10a 主路线)
|
|
5
|
+
- user-browser: chrome-inspect-ws —— 读取日常浏览器 User Data 下的
|
|
6
|
+
DevToolsActivePort 文件构造 WS URL,connect_over_cdp 挂真实浏览器(M10b,S1 已验证)
|
|
7
|
+
|
|
8
|
+
捕获交互:注入 picker(hover 高亮 + 点击采集),点击后生成 CSS selector
|
|
9
|
+
并在页面内回验命中数;cancel 会清理注入并结束等待。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import os
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
_PICKER_JS = r"""
|
|
20
|
+
() => {
|
|
21
|
+
if (window.__rpaCapture) return true;
|
|
22
|
+
window.__rpaCaptureResult = undefined;
|
|
23
|
+
const box = document.createElement('div');
|
|
24
|
+
box.style.cssText = 'position:fixed;pointer-events:none;border:2px solid #0969da;'
|
|
25
|
+
+ 'background:rgba(9,105,218,.12);z-index:2147483647;border-radius:4px;display:none;';
|
|
26
|
+
document.documentElement.appendChild(box);
|
|
27
|
+
function uniqueIn(sel) {
|
|
28
|
+
try { return document.querySelectorAll(sel).length === 1; } catch (e) { return false; }
|
|
29
|
+
}
|
|
30
|
+
function nthOf(el) {
|
|
31
|
+
const parent = el.parentElement;
|
|
32
|
+
if (!parent) return '';
|
|
33
|
+
const same = Array.from(parent.children).filter((c) => c.tagName === el.tagName);
|
|
34
|
+
return same.length > 1 ? ':nth-of-type(' + (same.indexOf(el) + 1) + ')' : '';
|
|
35
|
+
}
|
|
36
|
+
function buildSelector(el) {
|
|
37
|
+
if (el.id) {
|
|
38
|
+
const sel = '#' + CSS.escape(el.id);
|
|
39
|
+
if (uniqueIn(sel)) return sel;
|
|
40
|
+
}
|
|
41
|
+
const nameAttr = el.getAttribute && el.getAttribute('name');
|
|
42
|
+
if (nameAttr) {
|
|
43
|
+
const sel = el.tagName.toLowerCase() + '[name="' + nameAttr + '"]';
|
|
44
|
+
if (uniqueIn(sel)) return sel;
|
|
45
|
+
}
|
|
46
|
+
const parts = [];
|
|
47
|
+
let cur = el;
|
|
48
|
+
let guard = 0;
|
|
49
|
+
while (cur && cur.nodeType === 1 && cur !== document.documentElement && guard < 12) {
|
|
50
|
+
if (cur.id) {
|
|
51
|
+
const withId = '#' + CSS.escape(cur.id)
|
|
52
|
+
+ (parts.length ? ' > ' + parts.join(' > ') : '');
|
|
53
|
+
if (uniqueIn(withId)) return withId;
|
|
54
|
+
}
|
|
55
|
+
parts.unshift(cur.tagName.toLowerCase() + nthOf(cur));
|
|
56
|
+
const cand = parts.join(' > ');
|
|
57
|
+
if (parts.length >= 2 && uniqueIn(cand)) return cand;
|
|
58
|
+
cur = cur.parentElement;
|
|
59
|
+
guard += 1;
|
|
60
|
+
}
|
|
61
|
+
return 'body > ' + parts.join(' > ');
|
|
62
|
+
}
|
|
63
|
+
function describe(el) {
|
|
64
|
+
const selector = buildSelector(el);
|
|
65
|
+
let count = 0;
|
|
66
|
+
try { count = document.querySelectorAll(selector).length; } catch (e) { count = 0; }
|
|
67
|
+
const rect = el.getBoundingClientRect();
|
|
68
|
+
return {
|
|
69
|
+
selector: selector,
|
|
70
|
+
verifyCount: count,
|
|
71
|
+
tag: el.tagName.toLowerCase(),
|
|
72
|
+
id: el.id || '',
|
|
73
|
+
classes: typeof el.className === 'string' ? el.className : '',
|
|
74
|
+
text: ((el.innerText || el.textContent || '').trim()).slice(0, 120),
|
|
75
|
+
rect: { x: Math.round(rect.x), y: Math.round(rect.y),
|
|
76
|
+
w: Math.round(rect.width), h: Math.round(rect.height) },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function onOver(e) {
|
|
80
|
+
const rect = e.target.getBoundingClientRect();
|
|
81
|
+
box.style.display = 'block';
|
|
82
|
+
box.style.top = rect.top + 'px';
|
|
83
|
+
box.style.left = rect.left + 'px';
|
|
84
|
+
box.style.width = rect.width + 'px';
|
|
85
|
+
box.style.height = rect.height + 'px';
|
|
86
|
+
}
|
|
87
|
+
function onClick(e) {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
e.stopPropagation();
|
|
90
|
+
finish(e.target);
|
|
91
|
+
}
|
|
92
|
+
function onKey(e) { if (e.key === 'Escape') finish(null); }
|
|
93
|
+
function cleanup() {
|
|
94
|
+
box.remove();
|
|
95
|
+
document.removeEventListener('mouseover', onOver, true);
|
|
96
|
+
document.removeEventListener('click', onClick, true);
|
|
97
|
+
document.removeEventListener('keydown', onKey, true);
|
|
98
|
+
window.__rpaCaptureCleanup = undefined;
|
|
99
|
+
window.__rpaCapture = undefined;
|
|
100
|
+
}
|
|
101
|
+
function finish(el) {
|
|
102
|
+
cleanup();
|
|
103
|
+
window.__rpaCaptureResult = el ? describe(el) : 'cancelled';
|
|
104
|
+
}
|
|
105
|
+
document.addEventListener('mouseover', onOver, true);
|
|
106
|
+
document.addEventListener('click', onClick, true);
|
|
107
|
+
document.addEventListener('keydown', onKey, true);
|
|
108
|
+
window.__rpaCaptureCleanup = cleanup;
|
|
109
|
+
window.__rpaCapture = true;
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
_CANCEL_JS = r"""
|
|
115
|
+
() => {
|
|
116
|
+
if (window.__rpaCaptureCleanup) window.__rpaCaptureCleanup();
|
|
117
|
+
window.__rpaCaptureResult = 'cancelled';
|
|
118
|
+
}
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
_DONE_JS = "() => window.__rpaCaptureResult !== undefined"
|
|
122
|
+
_RESULT_JS = "() => window.__rpaCaptureResult"
|
|
123
|
+
_CLEANUP_JS = "() => { if (window.__rpaCaptureCleanup) window.__rpaCaptureCleanup(); }"
|
|
124
|
+
|
|
125
|
+
_BROWSER_DATA_DIRS = {
|
|
126
|
+
"edge": "Microsoft/Edge/User Data",
|
|
127
|
+
"chrome": "Google/Chrome/User Data",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def devtools_active_port_url(browser_type: str = "edge", user_data_dir: str | None = None) -> str:
|
|
132
|
+
"""从 DevToolsActivePort 文件构造 WS URL(S1 结论:无需 UI 交互)。"""
|
|
133
|
+
if user_data_dir:
|
|
134
|
+
base = Path(user_data_dir)
|
|
135
|
+
else:
|
|
136
|
+
key = browser_type if browser_type in _BROWSER_DATA_DIRS else "edge"
|
|
137
|
+
base = Path(os.environ.get("LOCALAPPDATA", "")) / _BROWSER_DATA_DIRS[key]
|
|
138
|
+
port_file = base / "DevToolsActivePort"
|
|
139
|
+
lines = port_file.read_text(encoding="utf-8").splitlines()
|
|
140
|
+
if len(lines) < 2:
|
|
141
|
+
raise RuntimeError(f"DevToolsActivePort file is malformed: {port_file}")
|
|
142
|
+
return f"ws://127.0.0.1:{lines[0].strip()}{lines[1].strip()}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class BrowserCaptureSession:
|
|
146
|
+
"""带独立事件循环线程的浏览器捕获会话;pick/cancel 从任意线程调用。"""
|
|
147
|
+
|
|
148
|
+
def __init__(
|
|
149
|
+
self,
|
|
150
|
+
*,
|
|
151
|
+
transport: str,
|
|
152
|
+
user_data_dir: str | None = None,
|
|
153
|
+
headless: bool = False,
|
|
154
|
+
user_agent: str | None = None,
|
|
155
|
+
browser_type: str = "edge",
|
|
156
|
+
page_url: str | None = None,
|
|
157
|
+
start_url: str | None = None,
|
|
158
|
+
):
|
|
159
|
+
self.transport = transport
|
|
160
|
+
self._config = {
|
|
161
|
+
"user_data_dir": user_data_dir,
|
|
162
|
+
"headless": headless,
|
|
163
|
+
"user_agent": user_agent,
|
|
164
|
+
"browser_type": browser_type,
|
|
165
|
+
"page_url": page_url,
|
|
166
|
+
"start_url": start_url,
|
|
167
|
+
}
|
|
168
|
+
self._cancelled = False
|
|
169
|
+
self._closed = False
|
|
170
|
+
self._loop = asyncio.new_event_loop()
|
|
171
|
+
self._thread = threading.Thread(target=self._loop.run_forever, daemon=True)
|
|
172
|
+
|
|
173
|
+
def _run(self, coro):
|
|
174
|
+
return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
|
|
175
|
+
|
|
176
|
+
def start(self) -> list[str]:
|
|
177
|
+
self._thread.start()
|
|
178
|
+
return self._run(self._start_async())
|
|
179
|
+
|
|
180
|
+
async def _start_async(self) -> list[str]:
|
|
181
|
+
from playwright.async_api import async_playwright
|
|
182
|
+
|
|
183
|
+
self._playwright = await async_playwright().start()
|
|
184
|
+
config = self._config
|
|
185
|
+
if self.transport == "persistent":
|
|
186
|
+
user_data_dir = config["user_data_dir"] or str(
|
|
187
|
+
Path.home() / ".rpa_core" / "capture-profile"
|
|
188
|
+
)
|
|
189
|
+
kwargs: dict[str, Any] = {
|
|
190
|
+
"headless": bool(config["headless"]),
|
|
191
|
+
"ignore_default_args": ["--enable-automation"],
|
|
192
|
+
}
|
|
193
|
+
if config["user_agent"]:
|
|
194
|
+
kwargs["user_agent"] = config["user_agent"]
|
|
195
|
+
self._context = await self._playwright.chromium.launch_persistent_context(
|
|
196
|
+
user_data_dir, **kwargs
|
|
197
|
+
)
|
|
198
|
+
self._browser = None
|
|
199
|
+
else:
|
|
200
|
+
ws_url = devtools_active_port_url(
|
|
201
|
+
config["browser_type"], config["user_data_dir"]
|
|
202
|
+
)
|
|
203
|
+
self._browser = await self._playwright.chromium.connect_over_cdp(ws_url)
|
|
204
|
+
contexts = self._browser.contexts
|
|
205
|
+
self._context = contexts[0] if contexts else await self._browser.new_context()
|
|
206
|
+
|
|
207
|
+
pages = self._context.pages
|
|
208
|
+
page = None
|
|
209
|
+
if config["page_url"]:
|
|
210
|
+
for candidate in reversed(pages):
|
|
211
|
+
if config["page_url"] in candidate.url:
|
|
212
|
+
page = candidate
|
|
213
|
+
break
|
|
214
|
+
if page is None:
|
|
215
|
+
page = pages[-1] if pages else await self._context.new_page()
|
|
216
|
+
self._page = page
|
|
217
|
+
if config["start_url"]:
|
|
218
|
+
await page.goto(config["start_url"], wait_until="domcontentloaded")
|
|
219
|
+
return [candidate.url for candidate in pages]
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def page_url(self) -> str:
|
|
223
|
+
return self._page.url
|
|
224
|
+
|
|
225
|
+
def pick(self, timeout_seconds: float = 60.0, click_css: str | None = None) -> dict:
|
|
226
|
+
return self._run(self._pick_async(timeout_seconds, click_css))
|
|
227
|
+
|
|
228
|
+
async def _pick_async(self, timeout_seconds: float, click_css: str | None) -> dict:
|
|
229
|
+
await self._page.evaluate(_PICKER_JS)
|
|
230
|
+
if click_css:
|
|
231
|
+
async def click_later() -> None:
|
|
232
|
+
await self._page.wait_for_timeout(300)
|
|
233
|
+
try:
|
|
234
|
+
await self._page.evaluate(
|
|
235
|
+
"(sel) => { const el = document.querySelector(sel);"
|
|
236
|
+
" if (el) el.dispatchEvent(new MouseEvent('click', {bubbles: true})); }",
|
|
237
|
+
click_css,
|
|
238
|
+
)
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
asyncio.ensure_future(click_later())
|
|
243
|
+
deadline = time.monotonic() + timeout_seconds
|
|
244
|
+
while time.monotonic() < deadline:
|
|
245
|
+
if self._cancelled or self._closed:
|
|
246
|
+
await self._safe(_CANCEL_JS)
|
|
247
|
+
return {"cancelled": True}
|
|
248
|
+
try:
|
|
249
|
+
await self._page.wait_for_function(_DONE_JS, timeout=500)
|
|
250
|
+
except Exception:
|
|
251
|
+
continue
|
|
252
|
+
data = await self._page.evaluate(_RESULT_JS)
|
|
253
|
+
await self._safe(_CLEANUP_JS)
|
|
254
|
+
if data == "cancelled":
|
|
255
|
+
return {"cancelled": True}
|
|
256
|
+
if not isinstance(data, dict):
|
|
257
|
+
continue
|
|
258
|
+
data["kind"] = "browser"
|
|
259
|
+
data["selector"] = {"css": data.get("selector")}
|
|
260
|
+
data["metadata"] = {
|
|
261
|
+
key: data.pop(key)
|
|
262
|
+
for key in ("tag", "id", "classes", "text", "rect")
|
|
263
|
+
if key in data
|
|
264
|
+
}
|
|
265
|
+
data["url"] = self._page.url
|
|
266
|
+
return data
|
|
267
|
+
await self._safe(_CLEANUP_JS)
|
|
268
|
+
return {"timeout": True}
|
|
269
|
+
|
|
270
|
+
async def _safe(self, expression: str) -> None:
|
|
271
|
+
try:
|
|
272
|
+
await self._page.evaluate(expression)
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
def cancel(self) -> None:
|
|
277
|
+
self._cancelled = True
|
|
278
|
+
|
|
279
|
+
def close(self) -> None:
|
|
280
|
+
if self._closed:
|
|
281
|
+
return
|
|
282
|
+
self._closed = True
|
|
283
|
+
self._cancelled = True
|
|
284
|
+
|
|
285
|
+
async def _close_async() -> None:
|
|
286
|
+
try:
|
|
287
|
+
if getattr(self, "_browser", None) is not None:
|
|
288
|
+
await self._browser.close()
|
|
289
|
+
elif getattr(self, "_context", None) is not None:
|
|
290
|
+
await self._context.close()
|
|
291
|
+
except Exception:
|
|
292
|
+
pass
|
|
293
|
+
try:
|
|
294
|
+
await self._playwright.stop()
|
|
295
|
+
except Exception:
|
|
296
|
+
pass
|
|
297
|
+
|
|
298
|
+
if self._thread.is_alive():
|
|
299
|
+
self._run(_close_async())
|
|
300
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|