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 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})