xiumiagent 0.1.0__tar.gz

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 (46) hide show
  1. xiumiagent-0.1.0/.env.example +14 -0
  2. xiumiagent-0.1.0/MANIFEST.in +5 -0
  3. xiumiagent-0.1.0/PKG-INFO +10 -0
  4. xiumiagent-0.1.0/README.md +79 -0
  5. xiumiagent-0.1.0/TESTING.md +56 -0
  6. xiumiagent-0.1.0/cdp/__init__.py +0 -0
  7. xiumiagent-0.1.0/cdp/browser.py +98 -0
  8. xiumiagent-0.1.0/cdp/connection.py +158 -0
  9. xiumiagent-0.1.0/cdp/helpers.py +175 -0
  10. xiumiagent-0.1.0/cdp/jslib.py +312 -0
  11. xiumiagent-0.1.0/core/__init__.py +0 -0
  12. xiumiagent-0.1.0/core/agent.py +59 -0
  13. xiumiagent-0.1.0/core/config.py +104 -0
  14. xiumiagent-0.1.0/core/events.py +45 -0
  15. xiumiagent-0.1.0/core/llm.py +46 -0
  16. xiumiagent-0.1.0/core/prompts.py +49 -0
  17. xiumiagent-0.1.0/core/registry.py +135 -0
  18. xiumiagent-0.1.0/examples/task.md +29 -0
  19. xiumiagent-0.1.0/main.py +40 -0
  20. xiumiagent-0.1.0/plugins/__init__.py +0 -0
  21. xiumiagent-0.1.0/plugins/browser/__init__.py +0 -0
  22. xiumiagent-0.1.0/plugins/browser/plugin.py +203 -0
  23. xiumiagent-0.1.0/plugins/tool_browser/__init__.py +0 -0
  24. xiumiagent-0.1.0/plugins/tool_browser/backend.py +125 -0
  25. xiumiagent-0.1.0/plugins/tool_browser/plugin.py +63 -0
  26. xiumiagent-0.1.0/plugins/xiumi_editor/__init__.py +0 -0
  27. xiumiagent-0.1.0/plugins/xiumi_editor/plugin.py +227 -0
  28. xiumiagent-0.1.0/plugins/xiumi_editor/selectors.json +11 -0
  29. xiumiagent-0.1.0/plugins/xiumi_login/__init__.py +0 -0
  30. xiumiagent-0.1.0/plugins/xiumi_login/plugin.py +251 -0
  31. xiumiagent-0.1.0/plugins/xiumi_login/selectors.json +16 -0
  32. xiumiagent-0.1.0/pyproject.toml +28 -0
  33. xiumiagent-0.1.0/scripts/__init__.py +0 -0
  34. xiumiagent-0.1.0/scripts/recon.py +89 -0
  35. xiumiagent-0.1.0/scripts/tui_smoke_test.py +55 -0
  36. xiumiagent-0.1.0/setup.cfg +4 -0
  37. xiumiagent-0.1.0/tui/__init__.py +0 -0
  38. xiumiagent-0.1.0/tui/app.py +375 -0
  39. xiumiagent-0.1.0/tui/screens.py +145 -0
  40. xiumiagent-0.1.0/tui/widgets.py +250 -0
  41. xiumiagent-0.1.0/xiumiagent.egg-info/PKG-INFO +10 -0
  42. xiumiagent-0.1.0/xiumiagent.egg-info/SOURCES.txt +44 -0
  43. xiumiagent-0.1.0/xiumiagent.egg-info/dependency_links.txt +1 -0
  44. xiumiagent-0.1.0/xiumiagent.egg-info/entry_points.txt +2 -0
  45. xiumiagent-0.1.0/xiumiagent.egg-info/requires.txt +6 -0
  46. xiumiagent-0.1.0/xiumiagent.egg-info/top_level.txt +6 -0
@@ -0,0 +1,14 @@
1
+ # ===== LLM (OpenAI 兼容接口) =====
2
+ # 智谱示例: https://open.bigmodel.cn/api/paas/v4 模型: glm-4.6
3
+ # DeepSeek: https://api.deepseek.com 模型: deepseek-chat
4
+ OPENAI_BASE_URL=https://open.bigmodel.cn/api/paas/v4
5
+ OPENAI_API_KEY=sk-xxx
6
+ MODEL=glm-4.6
7
+
8
+ # ===== 浏览器 =====
9
+ # 留空则自动探测常见 Edge 安装路径
10
+ EDGE_PATH=
11
+ CDP_PORT=9222
12
+
13
+ # ===== Agent =====
14
+ MAX_STEPS=40
@@ -0,0 +1,5 @@
1
+ include README.md
2
+ include .env.example
3
+ include TESTING.md
4
+ recursive-include examples *.md
5
+ recursive-include plugins *.json
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: xiumiagent
3
+ Version: 0.1.0
4
+ Summary: TUI agent that drives your Edge browser via CDP to auto-produce WeChat article layouts on xiumi.us (command: xiumi)
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: textual>=0.60
7
+ Requires-Dist: openai>=1.40
8
+ Requires-Dist: websockets>=12
9
+ Requires-Dist: python-dotenv>=1.0
10
+ Provides-Extra: dev
@@ -0,0 +1,79 @@
1
+ # xiumi-agent
2
+
3
+ 终端 TUI 形态的公众号排版 Agent:通过 CDP 直接驱动你本机的 **Edge** 打开 **秀米(xiumi.us)**,由 LLM 调用插件工具自动完成「写稿 → 排版 → 插图 → 保存草稿」。
4
+
5
+ ## 架构
6
+
7
+ ```
8
+ TUI (Textual) ──事件总线──▶ Agent 主循环 (OpenAI 兼容 function calling)
9
+ │ 工具调用
10
+ 插件管理器(browser / xiumi_login / xiumi_editor)
11
+ │ JS 模拟用户操作 + CDP 特例(截图/上传文件/真实按键)
12
+ Edge (msedge.exe, 独立 profile, CDP 9222)
13
+ ```
14
+
15
+ - **插件化**:核心只有主循环+注册表;所有网页能力都是 `plugins/*/plugin.py` 里的 Plugin,挂载即用
16
+ - **凭据安全**:账号密码只在 TUI 模态框输入,直达登录插件执行,不进 LLM 上下文
17
+ - **选择器可维护**:`plugins/*/selectors.json` 集中管理,`recon` 踩点后直接改 JSON
18
+
19
+ ## 快速开始
20
+
21
+ **方式一:一条命令远程安装(推荐,无需克隆)**
22
+
23
+ ```powershell
24
+ pip install git+https://github.com/ZMJJKK123-hub/xiumi_agent.git
25
+ xiumi # 任意目录、任意终端直接进入 TUI
26
+ ```
27
+
28
+ 首次运行会在 `~/.xiumi-agent/.env` 自动生成配置模板,填入 `OPENAI_API_KEY` 后重启即可。
29
+
30
+ **方式二:源码运行**
31
+
32
+ ```bash
33
+ git clone https://github.com/ZMJJKK123-hub/xiumi_agent.git
34
+ cd xiumi_agent
35
+ pip install -r requirements.txt
36
+ pip install -e . # 安装全局命令 xiumi(一次性)
37
+ copy .env.example .env # 填 OPENAI_API_KEY / OPENAI_BASE_URL / MODEL
38
+ xiumi # 或 python main.py
39
+ ```
40
+
41
+ `xiumi recon` = 页面踩点,`xiumi version` = 版本。
42
+
43
+ 首次使用:TUI 弹出登录窗口 → 选「微信扫码」(二维码图片自动弹出)或「账号密码」→ 登录一次长期保留。
44
+
45
+ ## 用法
46
+
47
+ ```
48
+ 输入任务回车:写一篇秋天咖啡店探店推文,主色暖棕,结尾引导关注
49
+ /file examples/task.md 载入文件任务(支持 [img:路径] 图片位置标记)
50
+ /login 重新打开登录窗口
51
+ /shot 截图当前页面
52
+ Ctrl+Q 退出 · Ctrl+L 清屏
53
+ ```
54
+
55
+ 任务两种姿势:
56
+ - **全自动**:只给主题 → Agent 写全文 + 排版 + 保存
57
+ - **只排版**:给正文 → Agent 只做排版加工
58
+
59
+ 图片:在正文任意位置写 `[img:D:\pics\a.jpg]`,Agent 会上传到秀米图库并插入该位置(无需多模态模型)。
60
+
61
+ ## 踩点(重要)
62
+
63
+ 插件里的选择器是启发式默认值。如果页面操作失败,运行:
64
+
65
+ ```bash
66
+ python main.py recon # dump 登录页/首页 DOM 树 + 截图到 recon/
67
+ ```
68
+
69
+ 人工确认选择器后更新 `plugins/xiumi_login/selectors.json` 与 `plugins/xiumi_editor/selectors.json`。手动登录秀米后,把编辑器 URL 加进 `scripts/recon.py` 的 `TARGETS` 再跑一次即可踩编辑器。
70
+
71
+ ## 目录
72
+
73
+ ```
74
+ core/ agent 主循环 / LLM 客户端 / 工具注册表 / 插件契约 / 事件总线
75
+ cdp/ CDP 连接 / Edge 管理 / Tab 封装 / 注入式 JS 操作库
76
+ tui/ Textual 界面(聊天面板、动作面板、登录/账密模态屏)
77
+ plugins/ browser 通用操控 · tool_browser 后台浏览器(dsh适配) · xiumi_login 登录 · xiumi_editor 排版
78
+ scripts/ recon.py 页面踩点
79
+ ```
@@ -0,0 +1,56 @@
1
+ # 自测指南
2
+
3
+ ## 安装与一次性准备
4
+
5
+ ```powershell
6
+ pip install git+https://github.com/ZMJJKK123-hub/xiumi_agent.git # 远程一条命令安装
7
+ notepad %USERPROFILE%\.xiumi-agent\.env # 首次运行 xiumi 后自动生成,填 OPENAI_API_KEY
8
+ xiumi # 任意目录直接进
9
+ ```
10
+
11
+ 首次启动会自动拉起 Edge 打开秀米 → 弹登录窗口(**扫码**:二维码自动弹出系统看图器;**账密**:弹输入框,滑块验证码需你在浏览器里手动拖一下)。登录一次长期保留在 `.edge-profile/`。
12
+
13
+ **登录成功后强烈建议做一次编辑器踩点**(当前编辑器选择器是启发式,踩点后更稳):
14
+ 在 `scripts/recon.py` 的 `TARGETS` 里加一行(去掉注释):`("editor", "https://xiumi.us/studio/v5"),`,然后:
15
+
16
+ ```powershell
17
+ python main.py recon
18
+ ```
19
+
20
+ 把 `recon/editor_outline.json` 里确认的选择器更新到 `plugins/xiumi_editor/selectors.json`。
21
+
22
+ ## 逐项测试清单
23
+
24
+ ### 启动与登录
25
+ - [ ] 欢迎卡:橙色圆角框、标题嵌上边框、左栏 Welcome back!/模型/目录,右栏 Tips / What's new
26
+ - [ ] 未登录时弹登录窗(居中、深色按钮);「账号密码登录」→ 填入 → 返回 → 「跳过」仍可点击(不卡死)
27
+ - [ ] 扫码登录:二维码图片自动弹出
28
+
29
+ ### 输入与命令
30
+ - [ ] 输入任务回车 → 灰底 `> 任务` 命令条回显
31
+ - [ ] `/file examples/task.md` → 命令条折叠显示 `⏎ …(N 行)`(先把示例里的图片路径改成真实存在的)
32
+ - [ ] `/shot` → 截图路径回显;`/login` → 登录窗弹出;`/file 不存在.md` → 红色 `X` 错误行
33
+ - [ ] 空输入/纯空格回车 → 无任何输出且输入框被清空
34
+ - [ ] 纯空格状态按 `?` → 帮助浮层(输入非空时 `?` 是普通字符)
35
+
36
+ ### Agent 任务(需 .env 已配)
37
+ - [ ] 输入「写一篇秋天咖啡店探店推文,主色暖棕」→ spinner(橙符号+动词+秒数)+ 底栏变「esc 中断任务」
38
+ - [ ] 工具行 `└ 工具名(参数)`、结果灰字折叠、空结果 `(no content)`
39
+ - [ ] 任务中按 `esc` → 立即中断并显示提示;中断后再提交新任务正常
40
+ - [ ] 完成后汇总 + `✻ 共 N 次工具调用` 统计行,底栏回到「? 快捷键」
41
+
42
+ ### 快捷键
43
+ - [ ] `↑/↓` 翻输入历史(连按不越界,↓ 到底恢复未提交草稿)
44
+ - [ ] `ctrl+l` 清屏重绘欢迎卡;`ctrl+q` 退出
45
+
46
+ ### 任务后检查
47
+ - [ ] Edge 里秀米草稿已保存:标题、正文块、图片位置正确
48
+ - [ ] `screenshots/` 下有预览截图
49
+
50
+ ## 排错
51
+
52
+ | 现象 | 处理 |
53
+ |---|---|
54
+ | 启动失败「端口 9222」 | 关掉残留 Edge(任务管理器搜 msedge)后重跑 |
55
+ | 编辑器操作总失败 | 重跑 `python main.py recon` 更新选择器(秀米改版了) |
56
+ | 改了代码想快速自检 | `python scripts/tui_smoke_test.py`(无头一键回归,不弹窗) |
File without changes
@@ -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
@@ -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"]
@@ -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})