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.
Files changed (78) hide show
  1. rpa_core/__init__.py +3 -0
  2. rpa_core/bsk_client.py +126 -0
  3. rpa_core/capture/__init__.py +13 -0
  4. rpa_core/capture/browser.py +300 -0
  5. rpa_core/capture/browser_bsk.py +253 -0
  6. rpa_core/capture/desktop.py +107 -0
  7. rpa_core/capture/desktop_agent.py +656 -0
  8. rpa_core/capture/extension.py +55 -0
  9. rpa_core/capture/hybrid.py +92 -0
  10. rpa_core/catalog/__init__.py +3 -0
  11. rpa_core/catalog/loader.py +48 -0
  12. rpa_core/cli.py +381 -0
  13. rpa_core/commands/browser/click.json +17 -0
  14. rpa_core/commands/browser/close.json +17 -0
  15. rpa_core/commands/browser/getText.json +17 -0
  16. rpa_core/commands/browser/input.json +17 -0
  17. rpa_core/commands/browser/launch.json +35 -0
  18. rpa_core/commands/browser/navigate.json +17 -0
  19. rpa_core/commands/browser/queryAll.json +17 -0
  20. rpa_core/commands/browser/waitFor.json +17 -0
  21. rpa_core/commands/data/format.json +30 -0
  22. rpa_core/commands/data/limit.json +33 -0
  23. rpa_core/commands/data/writeJson.json +17 -0
  24. rpa_core/commands/data/writeText.json +31 -0
  25. rpa_core/commands/desktop/attachWindow.json +17 -0
  26. rpa_core/commands/desktop/click.json +17 -0
  27. rpa_core/commands/desktop/closeSession.json +17 -0
  28. rpa_core/commands/desktop/findElement.json +17 -0
  29. rpa_core/commands/desktop/getText.json +17 -0
  30. rpa_core/commands/desktop/input.json +17 -0
  31. rpa_core/commands/desktop_win32/attachWindow.json +17 -0
  32. rpa_core/commands/desktop_win32/click.json +17 -0
  33. rpa_core/commands/desktop_win32/closeSession.json +17 -0
  34. rpa_core/commands/desktop_win32/findElement.json +17 -0
  35. rpa_core/commands/desktop_win32/getText.json +17 -0
  36. rpa_core/commands/desktop_win32/hotkey.json +17 -0
  37. rpa_core/commands/desktop_win32/input.json +17 -0
  38. rpa_core/commands/desktop_win32/menuSelect.json +17 -0
  39. rpa_core/compiler/__init__.py +4 -0
  40. rpa_core/compiler/compiler.py +125 -0
  41. rpa_core/compiler/plan.py +11 -0
  42. rpa_core/devserver/__init__.py +5 -0
  43. rpa_core/devserver/app.py +415 -0
  44. rpa_core/devserver/server.py +282 -0
  45. rpa_core/devserver/static/app.js +1773 -0
  46. rpa_core/devserver/static/i18n.js +120 -0
  47. rpa_core/devserver/static/icons.js +24 -0
  48. rpa_core/devserver/static/index.html +82 -0
  49. rpa_core/devserver/static/styles.css +145 -0
  50. rpa_core/devserver/store.py +126 -0
  51. rpa_core/executors/__init__.py +15 -0
  52. rpa_core/executors/base.py +15 -0
  53. rpa_core/executors/browser.py +429 -0
  54. rpa_core/executors/browser_bsk.py +98 -0
  55. rpa_core/executors/desktop.py +326 -0
  56. rpa_core/executors/desktop_win32.py +329 -0
  57. rpa_core/executors/python_worker.py +84 -0
  58. rpa_core/executors/registry.py +18 -0
  59. rpa_core/importer/__init__.py +13 -0
  60. rpa_core/importer/legacy_elements.py +240 -0
  61. rpa_core/model/__init__.py +15 -0
  62. rpa_core/model/capture.py +76 -0
  63. rpa_core/model/command.py +212 -0
  64. rpa_core/model/desktop.py +32 -0
  65. rpa_core/model/errors.py +36 -0
  66. rpa_core/model/runtime.py +37 -0
  67. rpa_core/model/workflow.py +82 -0
  68. rpa_core/runtime/__init__.py +3 -0
  69. rpa_core/runtime/checkpoint.py +91 -0
  70. rpa_core/runtime/events.py +54 -0
  71. rpa_core/runtime/orchestrator.py +787 -0
  72. rpa_core/runtime/resolver.py +54 -0
  73. rpa_core/workers/__init__.py +1 -0
  74. rpa_core/workers/python_worker.py +128 -0
  75. rpa_core_runtime-0.1.0.dist-info/METADATA +213 -0
  76. rpa_core_runtime-0.1.0.dist-info/RECORD +78 -0
  77. rpa_core_runtime-0.1.0.dist-info/WHEEL +4 -0
  78. rpa_core_runtime-0.1.0.dist-info/entry_points.txt +2 -0
rpa_core/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Typed, executor-oriented RPA core."""
2
+
3
+ __version__ = "0.1.0"
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)