xiumiagent 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.
- cdp/__init__.py +0 -0
- cdp/browser.py +98 -0
- cdp/connection.py +158 -0
- cdp/helpers.py +175 -0
- cdp/jslib.py +312 -0
- core/__init__.py +0 -0
- core/agent.py +59 -0
- core/config.py +104 -0
- core/events.py +45 -0
- core/llm.py +46 -0
- core/prompts.py +49 -0
- core/registry.py +135 -0
- main.py +40 -0
- plugins/__init__.py +0 -0
- plugins/browser/__init__.py +0 -0
- plugins/browser/plugin.py +203 -0
- plugins/tool_browser/__init__.py +0 -0
- plugins/tool_browser/backend.py +125 -0
- plugins/tool_browser/plugin.py +63 -0
- plugins/xiumi_editor/__init__.py +0 -0
- plugins/xiumi_editor/plugin.py +227 -0
- plugins/xiumi_editor/selectors.json +11 -0
- plugins/xiumi_login/__init__.py +0 -0
- plugins/xiumi_login/plugin.py +251 -0
- plugins/xiumi_login/selectors.json +16 -0
- scripts/__init__.py +0 -0
- scripts/recon.py +89 -0
- scripts/tui_smoke_test.py +55 -0
- tui/__init__.py +0 -0
- tui/app.py +375 -0
- tui/screens.py +145 -0
- tui/widgets.py +250 -0
- xiumiagent-0.1.0.dist-info/METADATA +10 -0
- xiumiagent-0.1.0.dist-info/RECORD +37 -0
- xiumiagent-0.1.0.dist-info/WHEEL +5 -0
- xiumiagent-0.1.0.dist-info/entry_points.txt +2 -0
- xiumiagent-0.1.0.dist-info/top_level.txt +6 -0
cdp/__init__.py
ADDED
|
File without changes
|
cdp/browser.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Edge 启动与标签页管理(独立自动化 profile)。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from core.config import Config
|
|
8
|
+
|
|
9
|
+
from .connection import CDPConnection
|
|
10
|
+
from .helpers import Tab
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BrowserNotReady(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EdgeBrowser:
|
|
18
|
+
def __init__(self, config: Config):
|
|
19
|
+
self.config = config
|
|
20
|
+
self.cdp: CDPConnection | None = None
|
|
21
|
+
self._proc: subprocess.Popen | None = None
|
|
22
|
+
|
|
23
|
+
# ---- 启动 ----
|
|
24
|
+
async def ensure_started(self) -> None:
|
|
25
|
+
"""端口活着就复用已有 Edge;否则用独立 profile 启动一个。"""
|
|
26
|
+
if await self._port_alive():
|
|
27
|
+
return
|
|
28
|
+
if not self.config.edge_path:
|
|
29
|
+
raise BrowserNotReady(
|
|
30
|
+
"未找到 Edge,请在 .env 的 EDGE_PATH 指定 msedge.exe 路径"
|
|
31
|
+
)
|
|
32
|
+
args = [
|
|
33
|
+
self.config.edge_path,
|
|
34
|
+
f"--remote-debugging-port={self.config.cdp_port}",
|
|
35
|
+
f"--user-data-dir={self.config.profile_dir}",
|
|
36
|
+
"--remote-allow-origins=*",
|
|
37
|
+
"--no-first-run",
|
|
38
|
+
"--no-default-browser-check",
|
|
39
|
+
"--disable-session-crashed-bubble",
|
|
40
|
+
"--hide-crash-restore-bubble",
|
|
41
|
+
"--restore-last-session=false",
|
|
42
|
+
"about:blank",
|
|
43
|
+
]
|
|
44
|
+
self._proc = subprocess.Popen(args, creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP)
|
|
45
|
+
for _ in range(80): # 最多等 20 秒
|
|
46
|
+
if await self._port_alive():
|
|
47
|
+
return
|
|
48
|
+
import asyncio
|
|
49
|
+
|
|
50
|
+
await asyncio.sleep(0.25)
|
|
51
|
+
raise BrowserNotReady(f"Edge 调试端口 {self.config.cdp_port} 未就绪")
|
|
52
|
+
|
|
53
|
+
async def _port_alive(self) -> bool:
|
|
54
|
+
probe: CDPConnection | None = None
|
|
55
|
+
try:
|
|
56
|
+
probe = await CDPConnection.connect(self.config.cdp_port, timeout=1.5)
|
|
57
|
+
return True
|
|
58
|
+
except Exception:
|
|
59
|
+
return False
|
|
60
|
+
finally:
|
|
61
|
+
if probe:
|
|
62
|
+
try:
|
|
63
|
+
await probe.close()
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
async def connect(self) -> None:
|
|
68
|
+
self.cdp = await CDPConnection.connect(self.config.cdp_port)
|
|
69
|
+
|
|
70
|
+
# ---- 标签页 ----
|
|
71
|
+
async def get_or_create_tab(self, url_contains: str = "xiumi.us", default_url: str = "https://xiumi.us/") -> Tab:
|
|
72
|
+
"""找 url 含 url_contains 的标签页;找不到就新建并导航到 default_url。"""
|
|
73
|
+
if not self.cdp:
|
|
74
|
+
raise BrowserNotReady("CDP 未连接")
|
|
75
|
+
pages = await self.cdp.list_pages()
|
|
76
|
+
for p in pages:
|
|
77
|
+
url = p.get("url", "")
|
|
78
|
+
if url_contains in url and "devtools" not in url:
|
|
79
|
+
return await self.attach_tab(p)
|
|
80
|
+
page = await self.cdp.new_tab(default_url)
|
|
81
|
+
return await self.attach_tab(page)
|
|
82
|
+
|
|
83
|
+
async def open_tab(self, url: str) -> Tab:
|
|
84
|
+
if not self.cdp:
|
|
85
|
+
raise BrowserNotReady("CDP 未连接")
|
|
86
|
+
page = await self.cdp.new_tab(url)
|
|
87
|
+
return await self.attach_tab(page)
|
|
88
|
+
|
|
89
|
+
async def attach_tab(self, target_info: dict[str, Any]) -> Tab:
|
|
90
|
+
session_id = await self.cdp.attach(target_info["targetId"])
|
|
91
|
+
tab = Tab(self.cdp, session_id, target_info)
|
|
92
|
+
await tab.enable()
|
|
93
|
+
return tab
|
|
94
|
+
|
|
95
|
+
async def shutdown(self) -> None:
|
|
96
|
+
if self.cdp:
|
|
97
|
+
await self.cdp.close()
|
|
98
|
+
self.cdp = None
|
cdp/connection.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""CDP 浏览器级 WebSocket 连接(flat session 模式)。
|
|
2
|
+
|
|
3
|
+
一条浏览器级连接即可操作所有标签页:Target.attachToTarget(flatten=True)
|
|
4
|
+
拿到 sessionId 后,send() 携带 sessionId 即面向该标签页。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import itertools
|
|
10
|
+
import json
|
|
11
|
+
import urllib.request
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
import websockets
|
|
15
|
+
|
|
16
|
+
DEFAULT_TIMEOUT = 60.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CDPError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CDPProtocolError(CDPError):
|
|
24
|
+
"""CDP 返回了 error 结果。"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, method: str, code: int, message: str):
|
|
27
|
+
super().__init__(f"CDP {method} 失败 [{code}]: {message}")
|
|
28
|
+
self.method, self.code, self.message = method, code, message
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CDPEvalError(CDPError):
|
|
32
|
+
"""页面内 JS 抛出异常。"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, expression: str, detail: str):
|
|
35
|
+
super().__init__(f"JS 执行出错: {detail}\n--- 表达式 ---\n{expression[:500]}")
|
|
36
|
+
self.detail = detail
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
EventCallback = Callable[[str | None, str, dict], None]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CDPConnection:
|
|
43
|
+
def __init__(self, ws_url: str, port: int):
|
|
44
|
+
self._ws_url = ws_url
|
|
45
|
+
self.port = port
|
|
46
|
+
self._ws: websockets.WebSocketCommonProtocol | None = None
|
|
47
|
+
self._ids = itertools.count(1)
|
|
48
|
+
self._pending: dict[int, asyncio.Future] = {}
|
|
49
|
+
self._event_cbs: list[EventCallback] = []
|
|
50
|
+
self._recv_task: asyncio.Task | None = None
|
|
51
|
+
self._closed = False
|
|
52
|
+
|
|
53
|
+
# ---- 生命周期 ----
|
|
54
|
+
@classmethod
|
|
55
|
+
async def connect(cls, port: int, timeout: float = 15.0) -> "CDPConnection":
|
|
56
|
+
version = await asyncio.wait_for(
|
|
57
|
+
asyncio.to_thread(cls._http_get, port, "/json/version"), timeout=timeout
|
|
58
|
+
)
|
|
59
|
+
ws_url = version.get("webSocketDebuggerUrl")
|
|
60
|
+
if not ws_url:
|
|
61
|
+
raise CDPError("未能从 /json/version 获取 webSocketDebuggerUrl")
|
|
62
|
+
self = cls(ws_url, port)
|
|
63
|
+
await self._open()
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _http_get(port: int, path: str) -> dict:
|
|
68
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}", timeout=3) as resp:
|
|
69
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
70
|
+
|
|
71
|
+
async def _open(self) -> None:
|
|
72
|
+
self._ws = await websockets.connect(self._ws_url, max_size=64 * 1024 * 1024, ping_interval=20)
|
|
73
|
+
self._recv_task = asyncio.create_task(self._recv_loop())
|
|
74
|
+
|
|
75
|
+
async def close(self) -> None:
|
|
76
|
+
self._closed = True
|
|
77
|
+
if self._recv_task:
|
|
78
|
+
self._recv_task.cancel()
|
|
79
|
+
if self._ws:
|
|
80
|
+
await self._ws.close()
|
|
81
|
+
|
|
82
|
+
def http_get(self, path: str) -> dict:
|
|
83
|
+
return self._http_get(self.port, path)
|
|
84
|
+
|
|
85
|
+
# ---- 消息收发 ----
|
|
86
|
+
async def send(
|
|
87
|
+
self,
|
|
88
|
+
method: str,
|
|
89
|
+
params: dict | None = None,
|
|
90
|
+
*,
|
|
91
|
+
session_id: str | None = None,
|
|
92
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
93
|
+
) -> Any:
|
|
94
|
+
if not self._ws:
|
|
95
|
+
raise CDPError("CDP 连接未打开")
|
|
96
|
+
mid = next(self._ids)
|
|
97
|
+
msg: dict[str, Any] = {"id": mid, "method": method, "params": params or {}}
|
|
98
|
+
if session_id:
|
|
99
|
+
msg["sessionId"] = session_id
|
|
100
|
+
fut: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
101
|
+
self._pending[mid] = fut
|
|
102
|
+
try:
|
|
103
|
+
await self._ws.send(json.dumps(msg))
|
|
104
|
+
result = await asyncio.wait_for(fut, timeout=timeout)
|
|
105
|
+
finally:
|
|
106
|
+
self._pending.pop(mid, None)
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
def on_event(self, callback: EventCallback) -> None:
|
|
110
|
+
"""callback(session_id, method, params) —— 同步回调。"""
|
|
111
|
+
self._event_cbs.append(callback)
|
|
112
|
+
|
|
113
|
+
async def _recv_loop(self) -> None:
|
|
114
|
+
try:
|
|
115
|
+
async for raw in self._ws:
|
|
116
|
+
msg = json.loads(raw)
|
|
117
|
+
if "id" in msg:
|
|
118
|
+
fut = self._pending.get(msg["id"])
|
|
119
|
+
if fut and not fut.done():
|
|
120
|
+
if "error" in msg:
|
|
121
|
+
fut.set_exception(
|
|
122
|
+
CDPProtocolError(msg.get("method", "?"), msg["error"].get("code", -1), msg["error"].get("message", ""))
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
fut.set_result(msg.get("result"))
|
|
126
|
+
else:
|
|
127
|
+
sid = msg.get("sessionId")
|
|
128
|
+
method = msg.get("method", "")
|
|
129
|
+
params = msg.get("params") or {}
|
|
130
|
+
for cb in list(self._event_cbs):
|
|
131
|
+
try:
|
|
132
|
+
cb(sid, method, params)
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
except asyncio.CancelledError:
|
|
136
|
+
pass
|
|
137
|
+
except Exception:
|
|
138
|
+
if not self._closed:
|
|
139
|
+
for fut in self._pending.values():
|
|
140
|
+
if not fut.done():
|
|
141
|
+
fut.set_exception(CDPError("CDP 连接断开"))
|
|
142
|
+
|
|
143
|
+
# ---- Target 管理 ----
|
|
144
|
+
async def list_pages(self) -> list[dict]:
|
|
145
|
+
res = await self.send("Target.getTargets")
|
|
146
|
+
return [t for t in res.get("targetInfos", []) if t.get("type") == "page"]
|
|
147
|
+
|
|
148
|
+
async def new_tab(self, url: str = "about:blank") -> dict:
|
|
149
|
+
res = await self.send("Target.createTarget", {"url": url})
|
|
150
|
+
tid = res["targetId"]
|
|
151
|
+
for t in await self.list_pages():
|
|
152
|
+
if t["targetId"] == tid:
|
|
153
|
+
return t
|
|
154
|
+
return {"targetId": tid, "url": url}
|
|
155
|
+
|
|
156
|
+
async def attach(self, target_id: str) -> str:
|
|
157
|
+
res = await self.send("Target.attachToTarget", {"targetId": target_id, "flatten": True})
|
|
158
|
+
return res["sessionId"]
|
cdp/helpers.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Tab:面向单个标签页的高级封装(evaluate / 注入 JS 库 / 截图 / 文件注入 / 真实按键)。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .connection import CDPConnection, CDPError, CDPEvalError
|
|
10
|
+
from .jslib import AGENT_JS, call_expr
|
|
11
|
+
|
|
12
|
+
_KEY_MAP = {
|
|
13
|
+
"Enter": ("\r", "Enter", 13),
|
|
14
|
+
"Tab": ("\t", "Tab", 9),
|
|
15
|
+
"Escape": ("", "Escape", 27),
|
|
16
|
+
"Backspace": ("\b", "Backspace", 8),
|
|
17
|
+
"Delete": ("", "Delete", 46),
|
|
18
|
+
"ArrowLeft": ("", "Left", 37),
|
|
19
|
+
"ArrowRight": ("", "Right", 39),
|
|
20
|
+
"ArrowUp": ("", "Up", 38),
|
|
21
|
+
"ArrowDown": ("", "Down", 40),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Tab:
|
|
26
|
+
def __init__(self, conn: CDPConnection, session_id: str, target_info: dict):
|
|
27
|
+
self.conn = conn
|
|
28
|
+
self.session_id = session_id
|
|
29
|
+
self.target_info = target_info
|
|
30
|
+
self._lib_injected = False
|
|
31
|
+
|
|
32
|
+
# ---- 基础 ----
|
|
33
|
+
async def send(self, method: str, params: dict | None = None, timeout: float = 60.0) -> Any:
|
|
34
|
+
return await self.conn.send(method, params, session_id=self.session_id, timeout=timeout)
|
|
35
|
+
|
|
36
|
+
async def enable(self) -> None:
|
|
37
|
+
for m in ("Runtime.enable", "Page.enable", "DOM.enable"):
|
|
38
|
+
try:
|
|
39
|
+
await self.send(m)
|
|
40
|
+
except CDPError:
|
|
41
|
+
pass
|
|
42
|
+
self._lib_injected = False
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def url(self) -> str:
|
|
46
|
+
return self.target_info.get("url", "")
|
|
47
|
+
|
|
48
|
+
async def current_url(self) -> str:
|
|
49
|
+
try:
|
|
50
|
+
return await self.evaluate("location.href")
|
|
51
|
+
except Exception:
|
|
52
|
+
return self.url
|
|
53
|
+
|
|
54
|
+
# ---- JS ----
|
|
55
|
+
async def evaluate(self, expression: str, await_promise: bool = False, timeout: float = 60.0) -> Any:
|
|
56
|
+
res = await self.send(
|
|
57
|
+
"Runtime.evaluate",
|
|
58
|
+
{
|
|
59
|
+
"expression": expression,
|
|
60
|
+
"awaitPromise": await_promise,
|
|
61
|
+
"returnByValue": True,
|
|
62
|
+
"userGesture": True,
|
|
63
|
+
},
|
|
64
|
+
timeout=timeout,
|
|
65
|
+
)
|
|
66
|
+
if res.get("exceptionDetails"):
|
|
67
|
+
d = res["exceptionDetails"]
|
|
68
|
+
detail = d.get("exception", {}).get("description") or d.get("text", "unknown")
|
|
69
|
+
raise CDPEvalError(expression, detail)
|
|
70
|
+
result = res.get("result", {})
|
|
71
|
+
return result.get("value")
|
|
72
|
+
|
|
73
|
+
async def ensure_lib(self) -> None:
|
|
74
|
+
if self._lib_injected:
|
|
75
|
+
return
|
|
76
|
+
state = await self.evaluate(AGENT_JS)
|
|
77
|
+
self._lib_injected = state in ("ok", "already")
|
|
78
|
+
if not self._lib_injected:
|
|
79
|
+
raise CDPError("agent JS 库注入失败")
|
|
80
|
+
|
|
81
|
+
async def agent(self, fn: str, *args: Any) -> Any:
|
|
82
|
+
"""调用 window.__agent.fn(...),返回 JSON 可序列化结果。"""
|
|
83
|
+
await self.ensure_lib()
|
|
84
|
+
return await self.evaluate(call_expr(fn, *args))
|
|
85
|
+
|
|
86
|
+
# ---- 导航 ----
|
|
87
|
+
async def navigate(self, url: str, settle: float = 1.5, timeout: float = 30.0) -> dict:
|
|
88
|
+
self._lib_injected = False
|
|
89
|
+
res = await self.send("Page.navigate", {"url": url}, timeout=timeout)
|
|
90
|
+
import asyncio
|
|
91
|
+
|
|
92
|
+
await asyncio.sleep(0.3)
|
|
93
|
+
deadline = asyncio.get_event_loop().time() + timeout
|
|
94
|
+
while asyncio.get_event_loop().time() < deadline:
|
|
95
|
+
state = await self.evaluate("document.readyState")
|
|
96
|
+
if state == "complete":
|
|
97
|
+
break
|
|
98
|
+
await asyncio.sleep(0.3)
|
|
99
|
+
await asyncio.sleep(settle) # SPA 渲染缓冲
|
|
100
|
+
self.target_info["url"] = url
|
|
101
|
+
return res
|
|
102
|
+
|
|
103
|
+
async def wait_selector(self, selector: str, timeout: float = 10.0) -> bool:
|
|
104
|
+
import asyncio
|
|
105
|
+
|
|
106
|
+
deadline = asyncio.get_event_loop().time() + timeout
|
|
107
|
+
while asyncio.get_event_loop().time() < deadline:
|
|
108
|
+
found = await self.evaluate(f"!!document.querySelector({json.dumps(selector)})")
|
|
109
|
+
if found:
|
|
110
|
+
return True
|
|
111
|
+
await asyncio.sleep(0.4)
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
# ---- 截图 ----
|
|
115
|
+
async def screenshot(self, path: Path | None = None, selector: str | None = None) -> Path:
|
|
116
|
+
params: dict[str, Any] = {"format": "png"}
|
|
117
|
+
if selector:
|
|
118
|
+
rect = await self.evaluate(
|
|
119
|
+
f"(() => {{ const el = document.querySelector({json.dumps(selector)});"
|
|
120
|
+
f" if (!el) return null; const r = el.getBoundingClientRect();"
|
|
121
|
+
f" return {{x: r.x, y: r.y + window.scrollY, width: r.width, height: r.height}}; }})()"
|
|
122
|
+
)
|
|
123
|
+
if not rect or rect["width"] < 2:
|
|
124
|
+
raise CDPError(f"截图失败:找不到可见元素 {selector}")
|
|
125
|
+
params["clip"] = {**rect, "scale": 1}
|
|
126
|
+
params["captureBeyondViewport"] = True
|
|
127
|
+
res = await self.send("Page.captureScreenshot", params, timeout=60)
|
|
128
|
+
data = base64.b64decode(res["data"])
|
|
129
|
+
if path is None:
|
|
130
|
+
from core.config import PROJECT_ROOT
|
|
131
|
+
|
|
132
|
+
path = PROJECT_ROOT / "screenshots" / "shot.png"
|
|
133
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
path.write_bytes(data)
|
|
135
|
+
return path
|
|
136
|
+
|
|
137
|
+
# ---- 文件上传(纯 JS 做不到,必须走 CDP)----
|
|
138
|
+
async def set_files(self, selector: str, file_paths: list[str | Path]) -> None:
|
|
139
|
+
"""把本地文件塞进页面 file input(如秀米图库上传)。"""
|
|
140
|
+
# 先确保 input 存在
|
|
141
|
+
exists = await self.evaluate(f"!!document.querySelector({json.dumps(selector)})")
|
|
142
|
+
if not exists:
|
|
143
|
+
raise CDPError(f"file input 不存在: {selector}")
|
|
144
|
+
res = await self.send(
|
|
145
|
+
"Runtime.evaluate",
|
|
146
|
+
{
|
|
147
|
+
"expression": f"document.querySelector({json.dumps(selector)})",
|
|
148
|
+
"returnByValue": False,
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
object_id = res.get("result", {}).get("objectId")
|
|
152
|
+
if not object_id:
|
|
153
|
+
raise CDPError("无法获取 file input 的 objectId")
|
|
154
|
+
node = await self.send("DOM.requestNode", {"objectId": object_id})
|
|
155
|
+
abs_paths = [str(Path(p).resolve()) for p in file_paths]
|
|
156
|
+
for p in abs_paths:
|
|
157
|
+
if not Path(p).exists():
|
|
158
|
+
raise CDPError(f"文件不存在: {p}")
|
|
159
|
+
await self.send("DOM.setFileInputFiles", {"files": abs_paths, "nodeId": node["nodeId"]})
|
|
160
|
+
# 通知框架文件已选择
|
|
161
|
+
await self.agent("triggerChange", selector)
|
|
162
|
+
|
|
163
|
+
# ---- 真实按键兜底(isTrusted,合成事件不生效时用)----
|
|
164
|
+
async def real_press(self, key: str) -> None:
|
|
165
|
+
text, key_name, vk = _KEY_MAP.get(key, ("", key, 0))
|
|
166
|
+
for type_ in ("keyDown", "keyUp") if not text else (("keyDown", "char", "keyUp")):
|
|
167
|
+
params: dict[str, Any] = {"type": type_, "key": key_name, "windowsVirtualKeyCode": vk}
|
|
168
|
+
if type_ == "char":
|
|
169
|
+
params["text"] = text
|
|
170
|
+
await self.send("Input.dispatchKeyEvent", params)
|
|
171
|
+
|
|
172
|
+
async def real_type(self, text: str) -> None:
|
|
173
|
+
"""逐字符发送真实键盘输入(焦点必须在目标输入框上)。"""
|
|
174
|
+
for ch in text:
|
|
175
|
+
await self.send("Input.dispatchKeyEvent", {"type": "char", "text": ch})
|