codebee 0.1.95 → 0.1.97

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 (144) hide show
  1. package/CHANGELOG.md +733 -720
  2. package/LICENSE +21 -21
  3. package/README.md +516 -509
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/agent_context.py +118 -0
  6. package/app/core/aiflavor.py +197 -197
  7. package/app/core/analytics.py +38 -38
  8. package/app/core/artifacts.py +148 -148
  9. package/app/core/assets.py +110 -110
  10. package/app/core/attachments.py +623 -623
  11. package/app/core/audit.py +30 -30
  12. package/app/core/backup.py +470 -470
  13. package/app/core/beekeeper.py +183 -183
  14. package/app/core/benchstore.py +102 -95
  15. package/app/core/board.py +246 -209
  16. package/app/core/bookmeta.py +1036 -1036
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/builtin_agent.py +2055 -1680
  20. package/app/core/capability.py +144 -144
  21. package/app/core/chaptersafe.py +32 -32
  22. package/app/core/checkpoints.py +648 -0
  23. package/app/core/cleanup.py +303 -303
  24. package/app/core/compaction.py +201 -201
  25. package/app/core/contracts.py +255 -255
  26. package/app/core/covergen.py +356 -356
  27. package/app/core/defectretro.py +43 -43
  28. package/app/core/diagnostics.py +114 -114
  29. package/app/core/dispatch.py +198 -191
  30. package/app/core/dispatch_log.py +128 -128
  31. package/app/core/env_scrub.py +85 -85
  32. package/app/core/errorlog.py +137 -137
  33. package/app/core/eval_matrix.py +82 -0
  34. package/app/core/evalbench.py +184 -91
  35. package/app/core/evaluation.py +174 -174
  36. package/app/core/failure_notify.py +290 -290
  37. package/app/core/flow_graph.py +33 -0
  38. package/app/core/goal_service.py +167 -167
  39. package/app/core/health.py +389 -389
  40. package/app/core/history.py +38 -38
  41. package/app/core/hooks.py +162 -162
  42. package/app/core/jobs.py +25 -4
  43. package/app/core/knowledge_pipeline.py +86 -0
  44. package/app/core/manager.py +2232 -2232
  45. package/app/core/mcp_client.py +200 -29
  46. package/app/core/mocks.py +64 -64
  47. package/app/core/notify.py +201 -201
  48. package/app/core/novel_quality.py +181 -189
  49. package/app/core/openapi.py +177 -154
  50. package/app/core/operations.py +228 -228
  51. package/app/core/paihang.py +145 -145
  52. package/app/core/pipeline.py +319 -59
  53. package/app/core/planner.py +17 -11
  54. package/app/core/policy.py +84 -0
  55. package/app/core/portguard.py +111 -111
  56. package/app/core/presence.py +77 -77
  57. package/app/core/preview.py +293 -293
  58. package/app/core/project_memory.py +139 -0
  59. package/app/core/prompt_guard.py +78 -78
  60. package/app/core/publish/__init__.py +7 -7
  61. package/app/core/publish/auto.py +106 -22
  62. package/app/core/publish/browser.py +694 -655
  63. package/app/core/publish/chaprows.py +138 -0
  64. package/app/core/publish/fanqie.py +212 -165
  65. package/app/core/publish/flow.py +832 -640
  66. package/app/core/publish/flows-fanqie-calibrated.json +361 -336
  67. package/app/core/publish/ledger.py +51 -2
  68. package/app/core/publish/manager.py +1150 -1118
  69. package/app/core/publish/qimao.py +176 -176
  70. package/app/core/publish/volumes.py +108 -0
  71. package/app/core/publish/ws.py +139 -139
  72. package/app/core/quality_gate.py +194 -194
  73. package/app/core/redact.py +38 -38
  74. package/app/core/repeat_guard.py +129 -129
  75. package/app/core/retrieval.py +137 -137
  76. package/app/core/router.py +398 -398
  77. package/app/core/runner.py +2239 -2238
  78. package/app/core/selfupdate.py +384 -384
  79. package/app/core/session_log.py +161 -161
  80. package/app/core/sessions.py +312 -312
  81. package/app/core/settings.py +33 -5
  82. package/app/core/settings_schema.py +282 -271
  83. package/app/core/share_page.py +114 -114
  84. package/app/core/skill_scan.py +139 -139
  85. package/app/core/skillpacks/fanqie-novel.md +8 -6
  86. package/app/core/skillpacks/market/character-bible.md +66 -66
  87. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  88. package/app/core/skillpacks/market/git-workflow.md +57 -57
  89. package/app/core/skillpacks/market/release-notes.md +72 -72
  90. package/app/core/skillpacks/market/weekly-report.md +71 -71
  91. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  92. package/app/core/skillpacks/qimao-signing.md +14 -11
  93. package/app/core/skills.py +32 -21
  94. package/app/core/step_runner.py +132 -132
  95. package/app/core/store.py +24 -0
  96. package/app/core/story_tracking.py +275 -275
  97. package/app/core/task_compile.py +199 -199
  98. package/app/core/telemetry.py +292 -292
  99. package/app/core/tlsctx.py +64 -64
  100. package/app/core/token_meter.py +147 -147
  101. package/app/core/tool_outputs.py +107 -0
  102. package/app/core/tracing.py +207 -144
  103. package/app/core/upstream.py +41 -41
  104. package/app/core/usage.py +1038 -1038
  105. package/app/core/volumes.py +389 -389
  106. package/app/core/webhooks.py +89 -0
  107. package/app/core/wxdigest.py +865 -858
  108. package/app/core/zentao.py +2692 -2692
  109. package/app/main.py +179 -10
  110. package/app/mcp_server.py +244 -244
  111. package/app/pick_dialog.py +110 -110
  112. package/app/ui/app.js +563 -21
  113. package/app/ui/artdiff.js +74 -74
  114. package/app/ui/assets/community-qr.png +0 -0
  115. package/app/ui/assets/donate-alipay.jpg +0 -0
  116. package/app/ui/assets/donate-qr.jpg +0 -0
  117. package/app/ui/assets/workbench-reference.png +0 -0
  118. package/app/ui/board.css +451 -666
  119. package/app/ui/board.html +165 -162
  120. package/app/ui/board.js +509 -305
  121. package/app/ui/hive3d.js +242 -0
  122. package/app/ui/i18n.js +125 -9
  123. package/app/ui/icons/bee.svg +79 -79
  124. package/app/ui/index.html +60 -3
  125. package/app/ui/manifest.json +16 -16
  126. package/app/ui/qrcode.js +2297 -2297
  127. package/app/ui/style.css +189 -9
  128. package/bin/tutti.js +147 -147
  129. package/package.json +49 -48
  130. package/tools/wx_reader.py +261 -261
  131. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  132. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  133. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  134. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  135. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  136. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  137. package/app/.playwright-cli/run-detail-after.png +0 -0
  138. package/app/output/_createbook_body.json +0 -1
  139. package/app/output/_probe_fq_click.py +0 -50
  140. package/app/output/_probe_fq_form.py +0 -42
  141. package/app/output/_probe_fq_modal.py +0 -55
  142. package/app/output/_probe_fq_points.py +0 -71
  143. package/app/output/_probe_fq_submit.py +0 -34
  144. package/app/output/_probe_fq_watch.py +0 -62
@@ -1,655 +1,694 @@
1
- # -*- coding: utf-8 -*-
2
- """CDP 浏览器驱动:定位浏览器 → 带 profile 启动 → WebSocket 控制页面。
3
-
4
- 设计取向:
5
- - 「操作」全部走 Runtime.evaluate 注入 JS(querySelector 匹配交给浏览器自己),
6
- Python 侧不解析 DOM——平台改版时只需改流程选择器表,驱动层不动;
7
- - 每平台一个持久化 user-data-dir(profile):登录态落在 profile 里,
8
- 服务重启后重新 launch 即恢复,代码不经手 cookie;
9
- - 浏览器窗口对用户可见(扫码登录、人工确认提交都靠它),不默认 headless;
10
- - CDP 调试端口只绑 127.0.0.1,HTTP 探测禁用系统代理(用户挂代理时不误伤)。
11
- """
12
- from __future__ import annotations
13
-
14
- import json
15
- import os
16
- import re
17
- import shutil
18
- import signal
19
- import socket
20
- import subprocess
21
- import sys
22
- import time
23
- import urllib.request
24
- from pathlib import Path
25
-
26
- from .ws import MiniWS, WebSocketError
27
-
28
- # 本机回环的 CDP HTTP 端点绝不能走系统代理(代理环境下 urlopen 会黑洞)
29
- _OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
30
-
31
- _CANDS = {
32
- "win32": [r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
33
- r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
34
- r"C:\Program Files\Google\Chrome\Application\chrome.exe",
35
- r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe"],
36
- "darwin": ["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
37
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
38
- "linux": [],
39
- }
40
- _WHICH = ("msedge", "microsoft-edge", "google-chrome", "chromium", "chrome")
41
-
42
-
43
- class BrowserError(Exception):
44
- """驱动层失败(找不到浏览器/起不来/JS 执行异常),message 面向用户。"""
45
-
46
-
47
- def find_browser():
48
- """按 Edge → Chrome 顺序找本机浏览器可执行文件;找不到返回 None。"""
49
- for p in _CANDS.get(sys.platform, []):
50
- if Path(p).exists():
51
- return p
52
- for name in _WHICH:
53
- w = shutil.which(name)
54
- if w:
55
- return w
56
- return None
57
-
58
-
59
- def _http_json(url, timeout=5.0, method="GET"):
60
- req = urllib.request.Request(url, method=method)
61
- with _OPENER.open(req, timeout=timeout) as r:
62
- return json.loads(r.read().decode("utf-8", "replace"))
63
-
64
-
65
- def port_alive(port, timeout=2.0):
66
- """探测 127.0.0.1:<port> 上有没有活的 CDP 服务(重启后重连用)。"""
67
- try:
68
- return bool(_http_json("http://127.0.0.1:%d/json/version" % port, timeout=timeout))
69
- except Exception:
70
- return False
71
-
72
-
73
- def _free_port():
74
- s = socket.socket()
75
- s.bind(("127.0.0.1", 0))
76
- port = s.getsockname()[1]
77
- s.close()
78
- return port
79
-
80
-
81
- def _win_edge_proc_lines():
82
- """(pid, 命令行) 清单。wmic 优先(快);Win11 24H2 起系统已移除
83
- wmic——输出为空/报错时落 PowerShell CIM(冷启 ~2s,仅在必要时付)。"""
84
- import subprocess as _sp
85
- try:
86
- out = _sp.check_output(
87
- ["wmic", "process", "where", "Name='msedge.exe'",
88
- "get", "ProcessId,CommandLine"],
89
- stderr=_sp.DEVNULL, timeout=12).decode("utf-8", "replace")
90
- lines = []
91
- for l in out.splitlines():
92
- l = l.strip()
93
- if not l or l.startswith("CommandLine"):
94
- continue
95
- m = re.match(r"^(.*\S)\s+(\d+)$", l) # wmic 列序固定:命令行在前 pid 在后
96
- if m and "msedge" in m.group(1):
97
- lines.append((m.group(2), m.group(1)))
98
- if lines:
99
- return lines
100
- except Exception:
101
- pass
102
- try:
103
- ps = None
104
- for cand in ("powershell",
105
- r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"):
106
- try:
107
- out = _sp.check_output(
108
- [cand, "-NoProfile", "-Command",
109
- "Get-CimInstance Win32_Process -Filter \"Name='msedge.exe'\" | "
110
- "ForEach-Object { \"$($_.ProcessId)|$($_.CommandLine)\" }"],
111
- stderr=_sp.DEVNULL, timeout=30).decode("utf-8", "replace")
112
- break
113
- except OSError:
114
- continue # PATH 没带 System32\WindowsPowerShell 的环境
115
- lines = []
116
- for l in (out or "").splitlines():
117
- pid, _, cmd = l.strip().partition("|")
118
- if pid.isdigit() and cmd:
119
- lines.append((pid, cmd))
120
- return lines
121
- except Exception:
122
- return []
123
-
124
-
125
- def _debug_ports_for_profile(user_data_dir):
126
- """扫描本机浏览器进程命令行,返回使用该 profile 的主实例的调试端口。
127
-
128
- 跨平台:Windows 走 wmic→PowerShell CIM 回退,POSIX 走 ps。只认主进程
129
- (--type= 子进程没有调试端口)。路径按小写+正反斜杠归一后比对。"""
130
- import subprocess as _sp
131
- try:
132
- if sys.platform == "win32":
133
- lines = _win_edge_proc_lines()
134
- else:
135
- out = _sp.check_output(["ps", "-axo", "command"], timeout=12).decode()
136
- lines = [(None, l) for l in out.splitlines()
137
- if "msedge" in l or "chrome" in l or "chromium" in l]
138
- except Exception:
139
- return []
140
- key = str(user_data_dir).replace("\\", "/").strip("/").lower()
141
- ports = []
142
- for _pid, ln in lines:
143
- ln = ln.strip()
144
- if not ln or "--type=" in ln or "--remote-debugging-port=" not in ln:
145
- continue
146
- norm = ln.replace("\\\\", "/").replace("\\", "/").lower()
147
- if key not in norm:
148
- continue
149
- m = re.search(r"--remote-debugging-port=(\d+)", ln)
150
- if m:
151
- ports.append(int(m.group(1)))
152
- return ports
153
-
154
-
155
- class Browser:
156
- """一个浏览器子进程(一个 profile 一个实例)+ 它的调试端口。"""
157
-
158
- def __init__(self, user_data_dir, headless=False, exe=None, extra_args=None):
159
- self.exe = exe or find_browser()
160
- if not self.exe:
161
- raise BrowserError("没找到 Edge/Chrome 浏览器,请先安装 Microsoft Edge")
162
- self.user_data_dir = str(Path(user_data_dir))
163
- self.port = _free_port()
164
- args = [self.exe,
165
- "--remote-debugging-port=%d" % self.port,
166
- "--user-data-dir=%s" % self.user_data_dir,
167
- # 窗口几何必须钉死:真实鼠标事件按视口坐标派发,窗口被 Edge
168
- # 恢复成怪尺寸(2026-09-28 实案 921×920 方窗)时全部点击系统性
169
- # 打偏,建书 radio/弹层一个都点不中还步步报成功
170
- "--window-size=1366,900",
171
- "--no-first-run", "--no-default-browser-check",
172
- "--hide-crash-restore-bubble"] # 崩溃恢复气泡压掉即够;
173
- # --restore-last-session 是无值开关,
174
- # 带 =false 反而激活会话恢复,勿加
175
- if extra_args:
176
- args += list(extra_args)
177
- if headless:
178
- args.append("--headless=new")
179
- try:
180
- self.proc = subprocess.Popen(
181
- args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
182
- start_new_session=(os.name != "nt")) # POSIX 独立进程组供 killpg
183
- # 杀树(runner 同款);Windows 忽略
184
- except OSError as e:
185
- raise BrowserError("浏览器启动失败:%s" % e)
186
- # 挂巢(beekeeper):服务死(含硬杀)自动化浏览器一起带走。接管老实例
187
- # 的分支会把 self.proc 置 None,不受影响(那不是本进程起的,不能杀)。
188
- from .. import beekeeper
189
- beekeeper.adopt(self.proc)
190
- if not self._wait_ready(15.0):
191
- # Edge 对已有实例的 profile:新进程转交参数后秒退(stdout 会打
192
- # 「正在现有浏览器会话中打开」),调试端口是老实例自己的(甚至
193
- # 自选的,真机实测指定 59852 实际跑 56394)。等自己指定的端口必然
194
- # 超时——先扫进程命令行找该 profile 的真端口接管(含隐藏的后台
195
- # 常驻实例:Edge「启动加速/后台运行」的进程锁着 profile 不可见)。
196
- for p in _debug_ports_for_profile(self.user_data_dir):
197
- if p != self.port and port_alive(p, timeout=2.0):
198
- self.port = p
199
- self.proc = None # 老实例不是本对象起的:close 不杀它
200
- return
201
- if self.proc.poll() == 0:
202
- raise BrowserError(
203
- "浏览器把启动请求转给了已在运行的会话(该平台浏览器有隐藏的"
204
- "后台实例占着档案)——请在任务管理器结束所有 Edge 进程,"
205
- "或重启电脑后重试;登录态不受影响")
206
- raise BrowserError(
207
- "浏览器调试端口 15 秒内没就绪:该平台可能已有浏览器实例在跑但"
208
- "端口探测不到——关掉它的所有窗口后重试,或重启电脑后首次连接")
209
-
210
- # ------------------------------------------------------------ 生命周期
211
- def _wait_ready(self, timeout):
212
- deadline = time.time() + timeout
213
- while time.time() < deadline:
214
- if port_alive(self.port, timeout=1.0):
215
- return True
216
- # Edge 147 起 launcher 进程常先退(真身 detached 续跑、调试端口
217
- # 晚几秒才开)——见 proc 退出不能收兵,要把端口窗口等满;真没起
218
- # 来时上面的接管扫描(_debug_ports_for_profile)还兜得住
219
- time.sleep(0.4)
220
- return False
221
-
222
- def alive(self):
223
- return port_alive(self.port, timeout=1.5)
224
-
225
- def close(self):
226
- """杀掉浏览器进程树。登录态在 profile 里,杀掉不丢。
227
-
228
- Edge 147 起 launcher 常先退、真身 detached 续跑——taskkill 只打到
229
- 已退出的 launcher 时,按 profile 再扫一遍主进程补刀,否则孤儿实例
230
- 锁着 profile,下次 launch 误报「已在运行的会话」。"""
231
- pid = getattr(self, "proc", None) and self.proc.pid
232
- try:
233
- if sys.platform == "win32" and pid:
234
- subprocess.call(["taskkill", "/F", "/T", "/PID", str(pid)],
235
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
236
- if self.proc.poll() is not None and self.user_data_dir:
237
- key = str(self.user_data_dir).replace("\\", "/").strip("/").lower()
238
- for opid, cmd in _win_edge_proc_lines():
239
- if "--type=" in cmd:
240
- continue
241
- norm = cmd.replace("\\\\", "/").replace("\\", "/").lower()
242
- if key in norm:
243
- subprocess.call(
244
- ["taskkill", "/F", "/T", "/PID", str(opid)],
245
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
246
- elif pid:
247
- # spawn 时 start_new_session,pgid==pid,连渲染进程带杀(runner 同款);
248
- # SIGTERM 单杀主进程在浏览器挂死时留残余子进程锁 profile
249
- os.killpg(pid, signal.SIGKILL)
250
- elif self.proc:
251
- self.proc.terminate()
252
- except Exception:
253
- pass
254
-
255
- # ------------------------------------------------------------ 页面
256
- def pages(self):
257
- return [t for t in _http_json("http://127.0.0.1:%d/json/list" % self.port)
258
- if t.get("type") == "page"]
259
-
260
- def new_page(self, url="about:blank"):
261
- t = _http_json("http://127.0.0.1:%d/json/new?%s" % (self.port, url),
262
- method="PUT") # Chromium 111+ 要求 PUT
263
- return Page(t["webSocketDebuggerUrl"])
264
-
265
- def first_page(self, create=True):
266
- ps = self.pages()
267
- if ps:
268
- return Page(ps[0]["webSocketDebuggerUrl"])
269
- return self.new_page() if create else None
270
-
271
- @staticmethod
272
- def attach(port):
273
- """不启动进程、直接接管已存活浏览器(服务重启后重连同 profile 的实例)。"""
274
- if not port_alive(port):
275
- raise BrowserError("端口 %s 上没有活着的浏览器" % port)
276
- b = Browser.__new__(Browser)
277
- b.port = int(port)
278
- b.proc = None
279
- b.user_data_dir = ""
280
- b.exe = find_browser()
281
- return b
282
-
283
-
284
- class Page:
285
- """一个标签页的 CDP 会话:命令收发 + 注入式高层操作。"""
286
-
287
- def __init__(self, ws_url, connect_timeout=15.0):
288
- m = re.match(r"ws://([^/:]+):(\d+)(/.+)", ws_url)
289
- if not m:
290
- raise BrowserError("无法解析调试地址:%s" % ws_url[:80])
291
- try:
292
- self.ws = MiniWS(m.group(1), int(m.group(2)), m.group(3),
293
- timeout=connect_timeout)
294
- except (WebSocketError, OSError) as e:
295
- raise BrowserError("连接页面失败:%s" % e)
296
- self._id = 0
297
- self._events = []
298
- try:
299
- self.send("Runtime.enable")
300
- self.send("Page.enable")
301
- self._ua_override()
302
- self._check_viewport()
303
- except BrowserError:
304
- self.ws.close()
305
- raise
306
-
307
- def _check_viewport(self):
308
- """视口几何告警:接管老实例(attach)时窗口尺寸不受我们控制,怪几何
309
- 会让真实点击坐标打偏——只告警不改状态,硬防护在 real_click_text 的
310
- elementFromPoint 守卫。"""
311
- try:
312
- m = self.send("Page.getLayoutMetrics", timeout=5.0) or {}
313
- css = m.get("cssLayoutViewport") or m.get("layoutViewport") or {}
314
- w = int(css.get("clientWidth") or 0)
315
- h = int(css.get("clientHeight") or 0)
316
- if w and (w < 1000 or h < 600):
317
- print("[publish] 视口异常 %dx%d(期望 ≥1000x600):真实点击坐标"
318
- "可能打偏,建议关掉该平台浏览器窗口后重连"
319
- % (w, h), flush=True)
320
- except Exception:
321
- pass
322
-
323
- def _ua_override(self):
324
- """UA 伪装成标准 Chrome:Edge 尾巴(Edg/x.y)会被部分站点(如七猫
325
- 建书页)的浏览器检测判「版本过低」整页替换。用 CDP Browser.getVersion
326
- 的真实内核版本拼标准 Chrome UA,各站点兼容性等同 Chrome。失败不拦
327
- (个别上下文可能禁用该域)。"""
328
- try:
329
- r = self.send("Browser.getVersion", timeout=5.0)
330
- ver = str((r.get("product") or "")).split("/")[-1] or "130.0.0.0"
331
- ua = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
332
- "(KHTML, like Gecko) Chrome/%s Safari/537.36" % ver)
333
- self.send("Network.setUserAgentOverride",
334
- {"userAgent": ua}, timeout=5.0)
335
- except BrowserError:
336
- pass
337
-
338
- # ------------------------------------------------------------ CDP 协议
339
- def send(self, method, params=None, timeout=30.0):
340
- """发一条命令并等它的响应;期间到达的事件进缓存(wait_event 消费)。"""
341
- self._id += 1
342
- mid = self._id
343
- self.ws.send_text(json.dumps({"id": mid, "method": method,
344
- "params": params or {}}))
345
- deadline = time.time() + timeout
346
- while True:
347
- remain = deadline - time.time()
348
- if remain <= 0:
349
- raise BrowserError("命令 %s 等响应超时(%.0fs)" % (method, timeout))
350
- try:
351
- msg = json.loads(self.ws.recv_message(timeout=remain))
352
- except socket.timeout:
353
- continue # 到点抛 BrowserError(上面的分支)
354
- except (WebSocketError, OSError) as e:
355
- raise BrowserError("连接断开:%s" % e)
356
- if msg.get("id") == mid:
357
- if "error" in msg:
358
- raise BrowserError("CDP 拒绝 %s:%s" %
359
- (method, str(msg["error"].get("message"))[:160]))
360
- return msg.get("result") or {}
361
- if "id" not in msg:
362
- self._events.append(msg)
363
-
364
- def wait_event(self, method, timeout=30.0, drain=True):
365
- """等一个指定事件(如 Page.loadEventFired);drain 时清空旧事件。"""
366
- if drain:
367
- self._events = [e for e in self._events if e.get("method") != method]
368
- deadline = time.time() + timeout
369
- while time.time() < deadline:
370
- for i, e in enumerate(self._events):
371
- if e.get("method") == method:
372
- del self._events[i]
373
- return e.get("params") or {}
374
- try:
375
- msg = json.loads(self.ws.recv_message(
376
- timeout=max(0.2, deadline - time.time())))
377
- except socket.timeout:
378
- continue
379
- except (WebSocketError, OSError) as e:
380
- raise BrowserError("连接断开:%s" % e)
381
- if "id" not in msg:
382
- self._events.append(msg)
383
- raise BrowserError("等事件 %s 超时(%.0fs)" % (method, timeout))
384
-
385
- # ------------------------------------------------------------ 求值
386
- def evaluate(self, expr, await_promise=False, timeout=60.0):
387
- """求值 JS 表达式返回其值;JS 抛错时带出人话信息。"""
388
- r = self.send("Runtime.evaluate", {
389
- "expression": expr, "awaitPromise": await_promise,
390
- "returnByValue": True, "userGesture": True,
391
- }, timeout=timeout)
392
- det = r.get("exceptionDetails")
393
- if det:
394
- raise BrowserError("JS 异常:%s" % json.dumps(det, ensure_ascii=False)[:220])
395
- res = r.get("result") or {} # send 已剥掉外层 {id,result} 的 result
396
- if res.get("subtype") == "error":
397
- raise BrowserError("JS 抛错:%s" % str(res.get("description"))[:200])
398
- return res.get("value")
399
-
400
- def call(self, fn, *args, **kw):
401
- """注入一个 JS 函数并调用:call('(sel,t)=>{...}', sel, text)。"""
402
- payload = ", ".join(json.dumps(a, ensure_ascii=False) for a in args)
403
- return self.evaluate("(%s)(%s)" % (fn, payload), **kw)
404
-
405
- # ------------------------------------------------------------ 高层操作
406
- def url(self):
407
- return self.evaluate("location.href")
408
-
409
- def navigate(self, url, timeout=30.0):
410
- """导航并等文档就绪。SPA 路由可能不触发完整 load,readyState 轮询兜底。
411
-
412
- about:blank 起跳时 readyState 本就 complete——必须同时等 location
413
- 真正到达目标域,否则后续步骤打在空白页上(url_any 误报)。"""
414
- from urllib.parse import urlparse as _up
415
- if url == "about:blank": # 中转页:无需等加载(页面忙时会假超时)
416
- try:
417
- self.send("Page.navigate", {"url": url}, timeout=5.0)
418
- except BrowserError:
419
- pass
420
- time.sleep(0.4)
421
- return
422
- host = _up(url).netloc
423
- if not host:
424
- # 无主机名=无效地址(占位符未替换等)。放行会在下方被「host 为空
425
- # 视为已到位」吞掉——页面停在 about:blank 还假装导航成功(0924 案)。
426
- raise BrowserError("无效的导航地址(无主机名,占位符可能未替换):%s"
427
- % str(url)[:90])
428
- try:
429
- self.send("Page.navigate", {"url": url}, timeout=timeout)
430
- except BrowserError:
431
- pass # 老页面销毁时连接报错属正常,轮询兜底
432
- host = _up(url).netloc
433
- deadline = time.time() + timeout
434
- seen_url = False
435
- while time.time() < deadline:
436
- try:
437
- href = self.evaluate("location.href", timeout=3.0) or ""
438
- if not host or host in href:
439
- seen_url = True
440
- if seen_url and self.evaluate("document.readyState", timeout=3.0) == "complete":
441
- time.sleep(0.5) # SPA 首帧渲染余量
442
- return
443
- except BrowserError:
444
- pass # 导航间隙 evaluate 会短暂失败
445
- time.sleep(0.3)
446
- raise BrowserError("页面 %.0f 秒未完成加载:%s" % (timeout, url))
447
-
448
- def exists(self, sel, timeout=1.0):
449
- return bool(self.call("(s)=>!!document.querySelector(s)", sel, timeout=timeout))
450
-
451
- def wait_for(self, sel, timeout=15.0):
452
- deadline = time.time() + timeout
453
- while time.time() < deadline:
454
- if self.exists(sel, timeout=1.0):
455
- return
456
- time.sleep(0.4)
457
- raise BrowserError("等待元素超时(%.0fs):%s" % (timeout, sel))
458
-
459
- def value(self, sel):
460
- return self.call("(s)=>{const e=document.querySelector(s);"
461
- "return e?(e.value!==undefined?e.value:e.textContent):null}", sel)
462
-
463
- def fill(self, sel, text):
464
- """填输入框/文本域/富文本。
465
-
466
- React/Vue 受控组件直接赋 value 不触发框架状态,必须走原型链 native
467
- setter 再补 input/change 事件;富文本(章节正文)走 CDP
468
- Input.insertText——execCommand('insertText') 对千字长文会截断
469
- (真机实测 1020 字只进 615),insertText 走完整输入管道无此限。"""
470
- text = str(text)
471
- ce = None
472
- for _try in range(24): # 编辑器慢加载 + class 动态 + edit-mask
473
- ce = self.call( # 遮罩需真实点击激活后才可编辑
474
- "(s)=>{const els=[...document.querySelectorAll(s)];"
475
- "let best=null;for(const e of els){"
476
- "if(!e.isContentEditable)continue;"
477
- "const r=e.getBoundingClientRect();"
478
- "if(r.width<=0)continue;"
479
- "const a=r.width*r.height;"
480
- "if(!best||a>best.a)best={e,a};}"
481
- "if(best){"
482
- "const el=best.e;"
483
- "el.scrollIntoView({block:'center'});el.focus();"
484
- "const r=document.createRange();r.selectNodeContents(el);"
485
- "const g=getSelection();g.removeAllRanges();g.addRange(r);return {hit:true};}"
486
- "const vis=els.filter(e=>e.getBoundingClientRect().width>0);"
487
- "if(!vis.length)return null;"
488
- "let big=vis[0];for(const e of vis){"
489
- "const r=e.getBoundingClientRect();"
490
- "if(r.width*r.height>big.getBoundingClientRect().width*big.getBoundingClientRect().height)big=e;}"
491
- "const r=big.getBoundingClientRect();"
492
- "return {hit:false,x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};}", sel)
493
- if ce is None:
494
- break
495
- if isinstance(ce, dict) and ce.get("hit"):
496
- ce = True
497
- break
498
- if isinstance(ce, dict) and "x" in ce: # 遮罩未揭:真实点击激活
499
- self.send("Input.dispatchMouseEvent",
500
- {"type": "mousePressed", "x": ce["x"], "y": ce["y"],
501
- "button": "left", "clickCount": 1}, timeout=8.0)
502
- self.send("Input.dispatchMouseEvent",
503
- {"type": "mouseReleased", "x": ce["x"], "y": ce["y"],
504
- "button": "left", "clickCount": 1}, timeout=8.0)
505
- ce = False
506
- time.sleep(0.8)
507
- continue
508
- ce = False
509
- time.sleep(0.5)
510
- if ce is True: # 富文本:焦点就位后键盘级插入
511
- self.send("Input.insertText", {"text": text}, timeout=30.0)
512
- n = self.call("(s)=>{const els=[...document.querySelectorAll(s)].filter(e=>e.isContentEditable);"
513
- "let n=0;for(const e of els)n=Math.max(n,(e.innerText||'').length);return n;}", sel)
514
- if int(n or 0) < min(len(text), 20):
515
- raise BrowserError("富文本插入不完整(%s/%d 字)" % (n, len(text)))
516
- return True
517
- if ce is None:
518
- raise BrowserError("找不到输入框 %s" % sel)
519
- r = self.call(
520
- "(s,t)=>{const el=document.querySelector(s);if(!el)"
521
- "return{ok:false,err:'找不到输入框 '+s};"
522
- "el.scrollIntoView({block:'center'});el.focus();"
523
- "const proto=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype"
524
- ":HTMLInputElement.prototype;"
525
- "const d=Object.getOwnPropertyDescriptor(proto,'value');"
526
- "(d&&d.set?d.set:function(v){el.value=v}).call(el,t);"
527
- "el.dispatchEvent(new Event('input',{bubbles:true}));"
528
- "el.dispatchEvent(new Event('change',{bubbles:true}));"
529
- "return{ok:true};}", sel, text)
530
- if not (r or {}).get("ok"):
531
- raise BrowserError("填入失败:%s" % ((r or {}).get("err") or "目标不是可输入元素"))
532
- return True
533
-
534
- def click(self, sel):
535
- r = self.call(
536
- "(s)=>{const el=document.querySelector(s);if(!el)"
537
- "return{ok:false,err:'找不到 '+s};"
538
- "if(el.disabled)return{ok:false,err:'按钮处于禁用态(表单可能有未通过校验的字段)'};"
539
- "el.scrollIntoView({block:'center'});el.click();return{ok:true};}", sel)
540
- if not (r or {}).get("ok"):
541
- raise BrowserError("点击失败:%s" % ((r or {}).get("err") or sel))
542
- return True
543
-
544
- def real_click_text(self, text, scope="", contains=True, exact_fallback=True,
545
- y_min=None, y_max=None):
546
- """按文本真实点击:JS 定位元素中心坐标 → CDP Input 派发鼠标事件序列。
547
-
548
- qm-btn 一类自定义按钮只认真实事件序列(mousedown/mouseup/focus),
549
- el.click() 对它们无效——建书「确认创建」/发章「立即发布」都栽在这。
550
- y_min/y_max 限定元素纵向范围(区分同名 select、限制弹层内点击)。
551
- 派发前 elementFromPoint 验证落点真是目标:遮罩/浮层/窗口几何漂移会让
552
- 坐标系统性打偏且步步报成功(2026-09-28 建书弹层没开实案)。
553
- 返回 {ok, tag?, via};找不到/被挡/出视口返回 {ok: False, err}。"""
554
- r = self.call(
555
- "(t,scope,c,y0,y1)=>{"
556
- "const vis=e=>e.getBoundingClientRect().width>0;"
557
- "const els=[...document.querySelectorAll(scope||'button,a,[role=button],span,li,[class*=btn]')].filter(vis);"
558
- "let cands=els.filter(e=>{const x=(e.innerText||'').trim();"
559
- "return x&&(c?x.includes(t):x===t);});"
560
- "if(!cands.length&&c){cands=els.filter(e=>(e.innerText||'').trim()===t);}"
561
- "if(y0!==null)cands=cands.filter(e=>e.getBoundingClientRect().y>=y0);"
562
- "if(y1!==null)cands=cands.filter(e=>e.getBoundingClientRect().y<=y1);"
563
- "if(!cands.length)return{ok:false,err:'nf'};"
564
- "cands.sort((a,b)=>((a.innerText||'').trim().length)-((b.innerText||'').trim().length));"
565
- "const el=cands[0];el.scrollIntoView({block:'center'});"
566
- "const rc=el.getBoundingClientRect();"
567
- "const x=Math.round(rc.x+rc.width/2),y=Math.round(rc.y+rc.height/2);"
568
- "const hit=document.elementFromPoint(x,y);"
569
- "if(!hit)return{ok:false,err:'oob'};"
570
- "if(hit!==el&&!el.contains(hit)&&!hit.contains(el))"
571
- "return{ok:false,err:'blocked',by:hit.tagName+' '+"
572
- "((hit.className||'')+'').slice(0,40)};"
573
- "return{ok:true,x:x,y:y,"
574
- "tag:el.tagName,cls:(el.className||'').toString().slice(0,30)};}",
575
- str(text), scope or "", bool(contains),
576
- int(y_min) if y_min is not None else None,
577
- int(y_max) if y_max is not None else None)
578
- if not (r or {}).get("ok"):
579
- err = (r or {}).get("err")
580
- if err == "blocked":
581
- return {"ok": False,
582
- "err": "「%s」的落点被 %s 挡住(遮罩/浮层未退场)"
583
- % (text, (r or {}).get("by") or "未知元素")}
584
- if err == "oob":
585
- return {"ok": False,
586
- "err": "「%s」的落点在视口外——浏览器窗口几何异常,"
587
- "关掉该平台浏览器窗口后重试" % text}
588
- return {"ok": False, "err": "页面上找不到文本为「%s」的可点元素%s"
589
- % (text, self._click_diag(scope))}
590
- x, y = r["x"], r["y"]
591
- self.send("Input.dispatchMouseEvent",
592
- {"type": "mousePressed", "x": x, "y": y,
593
- "button": "left", "clickCount": 1}, timeout=8.0)
594
- self.send("Input.dispatchMouseEvent",
595
- {"type": "mouseReleased", "x": x, "y": y,
596
- "button": "left", "clickCount": 1}, timeout=8.0)
597
- return {"ok": True, "tag": r.get("tag"), "via": "input"}
598
-
599
- def _click_diag(self, scope):
600
- """点击目标找不到时的现场自证:定位范围里到底有没有可见元素、都是
601
- 什么——「弹层没开」和「分类表变了」从此一眼可分,不用再猜。"""
602
- try:
603
- r = self.call(
604
- "(scope)=>{const vis=e=>e.getBoundingClientRect().width>0;"
605
- "const els=[...document.querySelectorAll(scope||"
606
- "'button,a,[role=button],span,li,[class*=btn]')].filter(vis);"
607
- "const ts=[...new Set(els.map(e=>(e.innerText||'').trim())"
608
- ".filter(x=>x&&x.length<=10))].slice(0,8);"
609
- "return{n:els.length,ts:ts};}", scope or "")
610
- n = int((r or {}).get("n") or 0)
611
- ts = "/".join(str(t) for t in ((r or {}).get("ts") or []))
612
- if not n:
613
- return "(定位范围 %s 内无可见元素:目标容器多半没打开)" \
614
- % (str(scope or "默认")[:48])
615
- return "(定位范围 %s 内可见项:%s)" \
616
- % (str(scope or "默认")[:48], ts[:120])
617
- except Exception:
618
- return ""
619
-
620
- def screenshot(self, fp):
621
- """整页截图存证:发布每步之后落一张,出错可回看卡在哪一步。"""
622
- r = self.send("Page.captureScreenshot", {"format": "png", "fromSurface": True})
623
- import base64
624
- fp = Path(fp)
625
- fp.parent.mkdir(parents=True, exist_ok=True)
626
- fp.write_bytes(base64.b64decode(r.get("data") or ""))
627
- return str(fp)
628
-
629
- def close(self):
630
- try:
631
- self.send("Page.close", timeout=3.0)
632
- except Exception:
633
- pass
634
- self.ws.close()
635
-
636
- # ------------------------------------------------------------ 表单探测
637
- def probe(self):
638
- """dump 页面上可交互元素的概要——校准流程选择器表的辅助工具。
639
-
640
- 返回 [{tag, type, sel, text, value, placeholder}],sel 是尽量稳定的
641
- 定位串(id/#id 优先,退而求其次 name/placeholder/文本/序号)。"""
642
- return self.call(
643
- "()=>{const out=[];"
644
- "document.querySelectorAll('input,textarea,button,select,[contenteditable=true]').forEach(el=>{"
645
- "let s='';"
646
- "if(el.id)s='#'+el.id;"
647
- "else if(el.name)s=el.tagName.toLowerCase()+'[name=\"'+el.name+'\"]';"
648
- "else if(el.placeholder)s=el.tagName.toLowerCase()+'[placeholder=\"'+el.placeholder.slice(0,20)+'\"]';"
649
- "else{const t=(el.innerText||el.value||'').trim().slice(0,12);"
650
- "s=t?el.tagName.toLowerCase()+':contains('+t+')':el.tagName.toLowerCase();}"
651
- "out.push({tag:el.tagName.toLowerCase(),type:el.type||'',sel:s,"
652
- "text:(el.innerText||'').trim().slice(0,16),"
653
- "value:(el.value||el.textContent||'').trim().slice(0,24),"
654
- "placeholder:(el.placeholder||'').slice(0,20)});});"
655
- "return out.slice(0,80);}", timeout=15.0) or []
1
+ # -*- coding: utf-8 -*-
2
+ """CDP 浏览器驱动:定位浏览器 → 带 profile 启动 → WebSocket 控制页面。
3
+
4
+ 设计取向:
5
+ - 「操作」全部走 Runtime.evaluate 注入 JS(querySelector 匹配交给浏览器自己),
6
+ Python 侧不解析 DOM——平台改版时只需改流程选择器表,驱动层不动;
7
+ - 每平台一个持久化 user-data-dir(profile):登录态落在 profile 里,
8
+ 服务重启后重新 launch 即恢复,代码不经手 cookie;
9
+ - 浏览器窗口对用户可见(扫码登录、人工确认提交都靠它),不默认 headless;
10
+ - CDP 调试端口只绑 127.0.0.1,HTTP 探测禁用系统代理(用户挂代理时不误伤)。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ import shutil
18
+ import signal
19
+ import socket
20
+ import subprocess
21
+ import sys
22
+ import time
23
+ import urllib.request
24
+ from pathlib import Path
25
+
26
+ from .ws import MiniWS, WebSocketError
27
+
28
+ # 本机回环的 CDP HTTP 端点绝不能走系统代理(代理环境下 urlopen 会黑洞)
29
+ _OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
30
+
31
+ _CANDS = {
32
+ "win32": [r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
33
+ r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
34
+ r"C:\Program Files\Google\Chrome\Application\chrome.exe",
35
+ r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe"],
36
+ "darwin": ["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
37
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
38
+ "linux": [],
39
+ }
40
+ _WHICH = ("msedge", "microsoft-edge", "google-chrome", "chromium", "chrome")
41
+
42
+
43
+ class BrowserError(Exception):
44
+ """驱动层失败(找不到浏览器/起不来/JS 执行异常),message 面向用户。"""
45
+
46
+
47
+ def find_browser():
48
+ """按 Edge → Chrome 顺序找本机浏览器可执行文件;找不到返回 None。"""
49
+ for p in _CANDS.get(sys.platform, []):
50
+ if Path(p).exists():
51
+ return p
52
+ for name in _WHICH:
53
+ w = shutil.which(name)
54
+ if w:
55
+ return w
56
+ return None
57
+
58
+
59
+ def _http_json(url, timeout=5.0, method="GET"):
60
+ req = urllib.request.Request(url, method=method)
61
+ with _OPENER.open(req, timeout=timeout) as r:
62
+ return json.loads(r.read().decode("utf-8", "replace"))
63
+
64
+
65
+ def port_alive(port, timeout=2.0):
66
+ """探测 127.0.0.1:<port> 上有没有活的 CDP 服务(重启后重连用)。"""
67
+ try:
68
+ return bool(_http_json("http://127.0.0.1:%d/json/version" % port, timeout=timeout))
69
+ except Exception:
70
+ return False
71
+
72
+
73
+ def _free_port():
74
+ s = socket.socket()
75
+ s.bind(("127.0.0.1", 0))
76
+ port = s.getsockname()[1]
77
+ s.close()
78
+ return port
79
+
80
+
81
+ def _win_edge_proc_lines():
82
+ """(pid, 命令行) 清单。wmic 优先(快);Win11 24H2 起系统已移除
83
+ wmic——输出为空/报错时落 PowerShell CIM(冷启 ~2s,仅在必要时付)。"""
84
+ import subprocess as _sp
85
+ try:
86
+ out = _sp.check_output(
87
+ ["wmic", "process", "where", "Name='msedge.exe'",
88
+ "get", "ProcessId,CommandLine"],
89
+ stderr=_sp.DEVNULL, timeout=12).decode("utf-8", "replace")
90
+ lines = []
91
+ for l in out.splitlines():
92
+ l = l.strip()
93
+ if not l or l.startswith("CommandLine"):
94
+ continue
95
+ m = re.match(r"^(.*\S)\s+(\d+)$", l) # wmic 列序固定:命令行在前 pid 在后
96
+ if m and "msedge" in m.group(1):
97
+ lines.append((m.group(2), m.group(1)))
98
+ if lines:
99
+ return lines
100
+ except Exception:
101
+ pass
102
+ try:
103
+ ps = None
104
+ for cand in ("powershell",
105
+ r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"):
106
+ try:
107
+ out = _sp.check_output(
108
+ [cand, "-NoProfile", "-Command",
109
+ "Get-CimInstance Win32_Process -Filter \"Name='msedge.exe'\" | "
110
+ "ForEach-Object { \"$($_.ProcessId)|$($_.CommandLine)\" }"],
111
+ stderr=_sp.DEVNULL, timeout=30).decode("utf-8", "replace")
112
+ break
113
+ except OSError:
114
+ continue # PATH 没带 System32\WindowsPowerShell 的环境
115
+ lines = []
116
+ for l in (out or "").splitlines():
117
+ pid, _, cmd = l.strip().partition("|")
118
+ if pid.isdigit() and cmd:
119
+ lines.append((pid, cmd))
120
+ return lines
121
+ except Exception:
122
+ return []
123
+
124
+
125
+ def _debug_ports_for_profile(user_data_dir):
126
+ """扫描本机浏览器进程命令行,返回使用该 profile 的主实例的调试端口。
127
+
128
+ 跨平台:Windows 走 wmic→PowerShell CIM 回退,POSIX 走 ps。只认主进程
129
+ (--type= 子进程没有调试端口)。路径按小写+正反斜杠归一后比对。"""
130
+ import subprocess as _sp
131
+ try:
132
+ if sys.platform == "win32":
133
+ lines = _win_edge_proc_lines()
134
+ else:
135
+ out = _sp.check_output(["ps", "-axo", "command"], timeout=12).decode()
136
+ lines = [(None, l) for l in out.splitlines()
137
+ if "msedge" in l or "chrome" in l or "chromium" in l]
138
+ except Exception:
139
+ return []
140
+ key = str(user_data_dir).replace("\\", "/").strip("/").lower()
141
+ ports = []
142
+ for _pid, ln in lines:
143
+ ln = ln.strip()
144
+ if not ln or "--type=" in ln or "--remote-debugging-port=" not in ln:
145
+ continue
146
+ norm = ln.replace("\\\\", "/").replace("\\", "/").lower()
147
+ if key not in norm:
148
+ continue
149
+ m = re.search(r"--remote-debugging-port=(\d+)", ln)
150
+ if m:
151
+ ports.append(int(m.group(1)))
152
+ return ports
153
+
154
+
155
+ class Browser:
156
+ """一个浏览器子进程(一个 profile 一个实例)+ 它的调试端口。"""
157
+
158
+ def __init__(self, user_data_dir, headless=False, exe=None, extra_args=None):
159
+ self.exe = exe or find_browser()
160
+ if not self.exe:
161
+ raise BrowserError("没找到 Edge/Chrome 浏览器,请先安装 Microsoft Edge")
162
+ self.user_data_dir = str(Path(user_data_dir))
163
+ self.port = _free_port()
164
+ args = [self.exe,
165
+ "--remote-debugging-port=%d" % self.port,
166
+ "--user-data-dir=%s" % self.user_data_dir,
167
+ # 窗口几何必须钉死:真实鼠标事件按视口坐标派发,窗口被 Edge
168
+ # 恢复成怪尺寸(2026-09-28 实案 921×920 方窗)时全部点击系统性
169
+ # 打偏,建书 radio/弹层一个都点不中还步步报成功
170
+ "--window-size=1366,900",
171
+ "--no-first-run", "--no-default-browser-check",
172
+ "--hide-crash-restore-bubble"] # 崩溃恢复气泡压掉即够;
173
+ # --restore-last-session 是无值开关,
174
+ # 带 =false 反而激活会话恢复,勿加
175
+ if extra_args:
176
+ args += list(extra_args)
177
+ if headless:
178
+ args.append("--headless=new")
179
+ try:
180
+ self.proc = subprocess.Popen(
181
+ args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
182
+ start_new_session=(os.name != "nt")) # POSIX 独立进程组供 killpg
183
+ # 杀树(runner 同款);Windows 忽略
184
+ except OSError as e:
185
+ raise BrowserError("浏览器启动失败:%s" % e)
186
+ # 挂巢(beekeeper):服务死(含硬杀)自动化浏览器一起带走。接管老实例
187
+ # 的分支会把 self.proc 置 None,不受影响(那不是本进程起的,不能杀)。
188
+ from .. import beekeeper
189
+ beekeeper.adopt(self.proc)
190
+ if not self._wait_ready(15.0):
191
+ # Edge 对已有实例的 profile:新进程转交参数后秒退(stdout 会打
192
+ # 「正在现有浏览器会话中打开」),调试端口是老实例自己的(甚至
193
+ # 自选的,真机实测指定 59852 实际跑 56394)。等自己指定的端口必然
194
+ # 超时——先扫进程命令行找该 profile 的真端口接管(含隐藏的后台
195
+ # 常驻实例:Edge「启动加速/后台运行」的进程锁着 profile 不可见)。
196
+ for p in _debug_ports_for_profile(self.user_data_dir):
197
+ if p != self.port and port_alive(p, timeout=2.0):
198
+ self.port = p
199
+ self.proc = None # 老实例不是本对象起的:close 不杀它
200
+ return
201
+ if self.proc.poll() == 0:
202
+ raise BrowserError(
203
+ "浏览器把启动请求转给了已在运行的会话(该平台浏览器有隐藏的"
204
+ "后台实例占着档案)——请在任务管理器结束所有 Edge 进程,"
205
+ "或重启电脑后重试;登录态不受影响")
206
+ raise BrowserError(
207
+ "浏览器调试端口 15 秒内没就绪:该平台可能已有浏览器实例在跑但"
208
+ "端口探测不到——关掉它的所有窗口后重试,或重启电脑后首次连接")
209
+
210
+ # ------------------------------------------------------------ 生命周期
211
+ def _wait_ready(self, timeout):
212
+ deadline = time.time() + timeout
213
+ while time.time() < deadline:
214
+ if port_alive(self.port, timeout=1.0):
215
+ return True
216
+ # Edge 147 起 launcher 进程常先退(真身 detached 续跑、调试端口
217
+ # 晚几秒才开)——见 proc 退出不能收兵,要把端口窗口等满;真没起
218
+ # 来时上面的接管扫描(_debug_ports_for_profile)还兜得住
219
+ time.sleep(0.4)
220
+ return False
221
+
222
+ def alive(self):
223
+ return port_alive(self.port, timeout=1.5)
224
+
225
+ def close(self):
226
+ """杀掉浏览器进程树。登录态在 profile 里,杀掉不丢。
227
+
228
+ Edge 147 起 launcher 常先退、真身 detached 续跑——taskkill 只打到
229
+ 已退出的 launcher 时,按 profile 再扫一遍主进程补刀,否则孤儿实例
230
+ 锁着 profile,下次 launch 误报「已在运行的会话」。"""
231
+ pid = getattr(self, "proc", None) and self.proc.pid
232
+ try:
233
+ if sys.platform == "win32" and pid:
234
+ subprocess.call(["taskkill", "/F", "/T", "/PID", str(pid)],
235
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
236
+ if self.proc.poll() is not None and self.user_data_dir:
237
+ key = str(self.user_data_dir).replace("\\", "/").strip("/").lower()
238
+ for opid, cmd in _win_edge_proc_lines():
239
+ if "--type=" in cmd:
240
+ continue
241
+ norm = cmd.replace("\\\\", "/").replace("\\", "/").lower()
242
+ if key in norm:
243
+ subprocess.call(
244
+ ["taskkill", "/F", "/T", "/PID", str(opid)],
245
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
246
+ elif pid:
247
+ # spawn 时 start_new_session,pgid==pid,连渲染进程带杀(runner 同款);
248
+ # SIGTERM 单杀主进程在浏览器挂死时留残余子进程锁 profile
249
+ os.killpg(pid, signal.SIGKILL)
250
+ elif self.proc:
251
+ self.proc.terminate()
252
+ except Exception:
253
+ pass
254
+
255
+ # ------------------------------------------------------------ 页面
256
+ def pages(self):
257
+ return [t for t in _http_json("http://127.0.0.1:%d/json/list" % self.port)
258
+ if t.get("type") == "page"]
259
+
260
+ def new_page(self, url="about:blank"):
261
+ t = _http_json("http://127.0.0.1:%d/json/new?%s" % (self.port, url),
262
+ method="PUT") # Chromium 111+ 要求 PUT
263
+ return Page(t["webSocketDebuggerUrl"])
264
+
265
+ def first_page(self, create=True):
266
+ ps = self.pages()
267
+ if ps:
268
+ return Page(ps[0]["webSocketDebuggerUrl"])
269
+ return self.new_page() if create else None
270
+
271
+ @staticmethod
272
+ def attach(port):
273
+ """不启动进程、直接接管已存活浏览器(服务重启后重连同 profile 的实例)。"""
274
+ if not port_alive(port):
275
+ raise BrowserError("端口 %s 上没有活着的浏览器" % port)
276
+ b = Browser.__new__(Browser)
277
+ b.port = int(port)
278
+ b.proc = None
279
+ b.user_data_dir = ""
280
+ b.exe = find_browser()
281
+ return b
282
+
283
+
284
+ class Page:
285
+ """一个标签页的 CDP 会话:命令收发 + 注入式高层操作。"""
286
+
287
+ def __init__(self, ws_url, connect_timeout=15.0):
288
+ m = re.match(r"ws://([^/:]+):(\d+)(/.+)", ws_url)
289
+ if not m:
290
+ raise BrowserError("无法解析调试地址:%s" % ws_url[:80])
291
+ try:
292
+ self.ws = MiniWS(m.group(1), int(m.group(2)), m.group(3),
293
+ timeout=connect_timeout)
294
+ except (WebSocketError, OSError) as e:
295
+ raise BrowserError("连接页面失败:%s" % e)
296
+ self._id = 0
297
+ self._port = int(m.group(2))
298
+ self._target_id = m.group(3).rsplit("/", 1)[-1]
299
+ self._events = []
300
+ try:
301
+ self.send("Runtime.enable")
302
+ self.send("Page.enable")
303
+ self._ua_override()
304
+ self._check_viewport()
305
+ except BrowserError:
306
+ self.ws.close()
307
+ raise
308
+
309
+ def _check_viewport(self):
310
+ """视口几何告警:接管老实例(attach)时窗口尺寸不受我们控制,怪几何
311
+ 会让真实点击坐标打偏——只告警不改状态,硬防护在 real_click_text 的
312
+ elementFromPoint 守卫。"""
313
+ try:
314
+ m = self.send("Page.getLayoutMetrics", timeout=5.0) or {}
315
+ css = m.get("cssLayoutViewport") or m.get("layoutViewport") or {}
316
+ w = int(css.get("clientWidth") or 0)
317
+ h = int(css.get("clientHeight") or 0)
318
+ if w and (w < 1000 or h < 600):
319
+ print("[publish] 视口异常 %dx%d(期望 ≥1000x600):真实点击坐标"
320
+ "可能打偏,建议关掉该平台浏览器窗口后重连"
321
+ % (w, h), flush=True)
322
+ except Exception:
323
+ pass
324
+
325
+ def _ua_override(self):
326
+ """UA 伪装成标准 Chrome:Edge 尾巴(Edg/x.y)会被部分站点(如七猫
327
+ 建书页)的浏览器检测判「版本过低」整页替换。用 CDP Browser.getVersion
328
+ 的真实内核版本拼标准 Chrome UA,各站点兼容性等同 Chrome。失败不拦
329
+ (个别上下文可能禁用该域)。"""
330
+ try:
331
+ r = self.send("Browser.getVersion", timeout=5.0)
332
+ ver = str((r.get("product") or "")).split("/")[-1] or "130.0.0.0"
333
+ ua = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
334
+ "(KHTML, like Gecko) Chrome/%s Safari/537.36" % ver)
335
+ self.send("Network.setUserAgentOverride",
336
+ {"userAgent": ua}, timeout=5.0)
337
+ except BrowserError:
338
+ pass
339
+
340
+ # ------------------------------------------------------------ 页签
341
+ def neutralize_dialogs(self):
342
+ """灭掉页面级 confirm/alert/prompt:原生弹窗会挂起渲染主线程,无人
343
+ 值守流程没有人点「确定」(2026-10-08 番茄实案:编辑器加载/发布确认
344
+ 阶段整页卡死,CDP 全线超时)。对当前文档立即生效,并注册到本页签
345
+ 之后加载的所有新文档(addScriptToEvaluateOnNewDocument 在平台 JS
346
+ 之前跑,加载期弹窗一并覆盖)。"""
347
+ stub = ("window.confirm=function(){return true;};window.alert=function(){};"
348
+ "window.prompt=function(){return null;};")
349
+ try:
350
+ self.send("Page.addScriptToEvaluateOnNewDocument",
351
+ {"source": stub}, timeout=5.0)
352
+ except BrowserError:
353
+ pass
354
+ try:
355
+ self.evaluate(stub, timeout=5.0)
356
+ except BrowserError:
357
+ pass
358
+
359
+ def open_new_tab(self, url="about:blank"):
360
+ """开新页签并返回新 Page(本页签原地不动)。
361
+
362
+ 发布验证必须走这里:在编辑器页签里导航去管理页会触发 beforeunload
363
+ 原生确认——渲染主线程挂起、导航超时、进行中的发布请求一并被取消
364
+ (2026-10-08 番茄实案:直发章全卡死在验证导航上)。"""
365
+ t = _http_json("http://127.0.0.1:%d/json/new?%s" % (self._port, url),
366
+ method="PUT")
367
+ return Page(t["webSocketDebuggerUrl"])
368
+
369
+ def close_tab(self):
370
+ """关掉自己(target 级浏览器进程命令,渲染器卡死也关得掉)。"""
371
+ try:
372
+ _http_json("http://127.0.0.1:%d/json/close/%s"
373
+ % (self._port, self._target_id), timeout=8)
374
+ except Exception:
375
+ pass # 响应体是纯文本「Target is closing」非 JSON
376
+
377
+ # ------------------------------------------------------------ CDP 协议
378
+ def send(self, method, params=None, timeout=30.0):
379
+ """发一条命令并等它的响应;期间到达的事件进缓存(wait_event 消费)。"""
380
+ self._id += 1
381
+ mid = self._id
382
+ self.ws.send_text(json.dumps({"id": mid, "method": method,
383
+ "params": params or {}}))
384
+ deadline = time.time() + timeout
385
+ while True:
386
+ remain = deadline - time.time()
387
+ if remain <= 0:
388
+ raise BrowserError("命令 %s 等响应超时(%.0fs)" % (method, timeout))
389
+ try:
390
+ msg = json.loads(self.ws.recv_message(timeout=remain))
391
+ except socket.timeout:
392
+ continue # 到点抛 BrowserError(上面的分支)
393
+ except (WebSocketError, OSError) as e:
394
+ raise BrowserError("连接断开:%s" % e)
395
+ if msg.get("id") == mid:
396
+ if "error" in msg:
397
+ raise BrowserError("CDP 拒绝 %s:%s" %
398
+ (method, str(msg["error"].get("message"))[:160]))
399
+ return msg.get("result") or {}
400
+ if "id" not in msg:
401
+ self._events.append(msg)
402
+
403
+ def wait_event(self, method, timeout=30.0, drain=True):
404
+ """等一个指定事件(如 Page.loadEventFired);drain 时清空旧事件。"""
405
+ if drain:
406
+ self._events = [e for e in self._events if e.get("method") != method]
407
+ deadline = time.time() + timeout
408
+ while time.time() < deadline:
409
+ for i, e in enumerate(self._events):
410
+ if e.get("method") == method:
411
+ del self._events[i]
412
+ return e.get("params") or {}
413
+ try:
414
+ msg = json.loads(self.ws.recv_message(
415
+ timeout=max(0.2, deadline - time.time())))
416
+ except socket.timeout:
417
+ continue
418
+ except (WebSocketError, OSError) as e:
419
+ raise BrowserError("连接断开:%s" % e)
420
+ if "id" not in msg:
421
+ self._events.append(msg)
422
+ raise BrowserError("等事件 %s 超时(%.0fs)" % (method, timeout))
423
+
424
+ # ------------------------------------------------------------ 求值
425
+ def evaluate(self, expr, await_promise=False, timeout=60.0):
426
+ """求值 JS 表达式返回其值;JS 抛错时带出人话信息。"""
427
+ r = self.send("Runtime.evaluate", {
428
+ "expression": expr, "awaitPromise": await_promise,
429
+ "returnByValue": True, "userGesture": True,
430
+ }, timeout=timeout)
431
+ det = r.get("exceptionDetails")
432
+ if det:
433
+ raise BrowserError("JS 异常:%s" % json.dumps(det, ensure_ascii=False)[:220])
434
+ res = r.get("result") or {} # send 已剥掉外层 {id,result} 的 result
435
+ if res.get("subtype") == "error":
436
+ raise BrowserError("JS 抛错:%s" % str(res.get("description"))[:200])
437
+ return res.get("value")
438
+
439
+ def call(self, fn, *args, **kw):
440
+ """注入一个 JS 函数并调用:call('(sel,t)=>{...}', sel, text)。"""
441
+ payload = ", ".join(json.dumps(a, ensure_ascii=False) for a in args)
442
+ return self.evaluate("(%s)(%s)" % (fn, payload), **kw)
443
+
444
+ # ------------------------------------------------------------ 高层操作
445
+ def url(self):
446
+ return self.evaluate("location.href")
447
+
448
+ def navigate(self, url, timeout=30.0):
449
+ """导航并等文档就绪。SPA 路由可能不触发完整 load,readyState 轮询兜底。
450
+
451
+ about:blank 起跳时 readyState 本就 complete——必须同时等 location
452
+ 真正到达目标域,否则后续步骤打在空白页上(url_any 误报)。"""
453
+ from urllib.parse import urlparse as _up
454
+ if url == "about:blank": # 中转页:无需等加载(页面忙时会假超时)
455
+ try:
456
+ self.send("Page.navigate", {"url": url}, timeout=5.0)
457
+ except BrowserError:
458
+ pass
459
+ time.sleep(0.4)
460
+ return
461
+ host = _up(url).netloc
462
+ if not host:
463
+ # 无主机名=无效地址(占位符未替换等)。放行会在下方被「host 为空
464
+ # 视为已到位」吞掉——页面停在 about:blank 还假装导航成功(0924 案)。
465
+ raise BrowserError("无效的导航地址(无主机名,占位符可能未替换):%s"
466
+ % str(url)[:90])
467
+ try:
468
+ self.send("Page.navigate", {"url": url}, timeout=timeout)
469
+ except BrowserError:
470
+ pass # 老页面销毁时连接报错属正常,轮询兜底
471
+ host = _up(url).netloc
472
+ deadline = time.time() + timeout
473
+ seen_url = False
474
+ while time.time() < deadline:
475
+ try:
476
+ href = self.evaluate("location.href", timeout=3.0) or ""
477
+ if not host or host in href:
478
+ seen_url = True
479
+ if seen_url and self.evaluate("document.readyState", timeout=3.0) == "complete":
480
+ time.sleep(0.5) # SPA 首帧渲染余量
481
+ return
482
+ except BrowserError:
483
+ pass # 导航间隙 evaluate 会短暂失败
484
+ time.sleep(0.3)
485
+ raise BrowserError("页面 %.0f 秒未完成加载:%s" % (timeout, url))
486
+
487
+ def exists(self, sel, timeout=1.0):
488
+ return bool(self.call("(s)=>!!document.querySelector(s)", sel, timeout=timeout))
489
+
490
+ def wait_for(self, sel, timeout=15.0):
491
+ deadline = time.time() + timeout
492
+ while time.time() < deadline:
493
+ if self.exists(sel, timeout=1.0):
494
+ return
495
+ time.sleep(0.4)
496
+ raise BrowserError("等待元素超时(%.0fs):%s" % (timeout, sel))
497
+
498
+ def value(self, sel):
499
+ return self.call("(s)=>{const e=document.querySelector(s);"
500
+ "return e?(e.value!==undefined?e.value:e.textContent):null}", sel)
501
+
502
+ def fill(self, sel, text):
503
+ """填输入框/文本域/富文本。
504
+
505
+ React/Vue 受控组件直接赋 value 不触发框架状态,必须走原型链 native
506
+ setter 再补 input/change 事件;富文本(章节正文)走 CDP
507
+ Input.insertText——execCommand('insertText') 对千字长文会截断
508
+ (真机实测 1020 字只进 615),insertText 走完整输入管道无此限。"""
509
+ text = str(text)
510
+ ce = None
511
+ for _try in range(24): # 编辑器慢加载 + class 动态 + edit-mask
512
+ ce = self.call( # 遮罩需真实点击激活后才可编辑
513
+ "(s)=>{const els=[...document.querySelectorAll(s)];"
514
+ "let best=null;for(const e of els){"
515
+ "if(!e.isContentEditable)continue;"
516
+ "const r=e.getBoundingClientRect();"
517
+ "if(r.width<=0)continue;"
518
+ "const a=r.width*r.height;"
519
+ "if(!best||a>best.a)best={e,a};}"
520
+ "if(best){"
521
+ "const el=best.e;"
522
+ "el.scrollIntoView({block:'center'});el.focus();"
523
+ "const r=document.createRange();r.selectNodeContents(el);"
524
+ "const g=getSelection();g.removeAllRanges();g.addRange(r);return {hit:true};}"
525
+ "const vis=els.filter(e=>e.getBoundingClientRect().width>0);"
526
+ "if(!vis.length)return null;"
527
+ "let big=vis[0];for(const e of vis){"
528
+ "const r=e.getBoundingClientRect();"
529
+ "if(r.width*r.height>big.getBoundingClientRect().width*big.getBoundingClientRect().height)big=e;}"
530
+ "const r=big.getBoundingClientRect();"
531
+ "return {hit:false,x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};}", sel)
532
+ if ce is None:
533
+ break
534
+ if isinstance(ce, dict) and ce.get("hit"):
535
+ ce = True
536
+ break
537
+ if isinstance(ce, dict) and "x" in ce: # 遮罩未揭:真实点击激活
538
+ self.send("Input.dispatchMouseEvent",
539
+ {"type": "mousePressed", "x": ce["x"], "y": ce["y"],
540
+ "button": "left", "clickCount": 1}, timeout=8.0)
541
+ self.send("Input.dispatchMouseEvent",
542
+ {"type": "mouseReleased", "x": ce["x"], "y": ce["y"],
543
+ "button": "left", "clickCount": 1}, timeout=8.0)
544
+ ce = False
545
+ time.sleep(0.8)
546
+ continue
547
+ ce = False
548
+ time.sleep(0.5)
549
+ if ce is True: # 富文本:焦点就位后键盘级插入
550
+ self.send("Input.insertText", {"text": text}, timeout=30.0)
551
+ n = self.call("(s)=>{const els=[...document.querySelectorAll(s)].filter(e=>e.isContentEditable);"
552
+ "let n=0;for(const e of els)n=Math.max(n,(e.innerText||'').length);return n;}", sel)
553
+ if int(n or 0) < min(len(text), 20):
554
+ raise BrowserError("富文本插入不完整(%s/%d 字)" % (n, len(text)))
555
+ return True
556
+ if ce is None:
557
+ raise BrowserError("找不到输入框 %s" % sel)
558
+ r = self.call(
559
+ "(s,t)=>{const el=document.querySelector(s);if(!el)"
560
+ "return{ok:false,err:'找不到输入框 '+s};"
561
+ "el.scrollIntoView({block:'center'});el.focus();"
562
+ "const proto=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype"
563
+ ":HTMLInputElement.prototype;"
564
+ "const d=Object.getOwnPropertyDescriptor(proto,'value');"
565
+ "(d&&d.set?d.set:function(v){el.value=v}).call(el,t);"
566
+ "el.dispatchEvent(new Event('input',{bubbles:true}));"
567
+ "el.dispatchEvent(new Event('change',{bubbles:true}));"
568
+ "return{ok:true};}", sel, text)
569
+ if not (r or {}).get("ok"):
570
+ raise BrowserError("填入失败:%s" % ((r or {}).get("err") or "目标不是可输入元素"))
571
+ return True
572
+
573
+ def click(self, sel):
574
+ r = self.call(
575
+ "(s)=>{const el=document.querySelector(s);if(!el)"
576
+ "return{ok:false,err:'找不到 '+s};"
577
+ "if(el.disabled)return{ok:false,err:'按钮处于禁用态(表单可能有未通过校验的字段)'};"
578
+ "el.scrollIntoView({block:'center'});el.click();return{ok:true};}", sel)
579
+ if not (r or {}).get("ok"):
580
+ raise BrowserError("点击失败:%s" % ((r or {}).get("err") or sel))
581
+ return True
582
+
583
+ def real_click_text(self, text, scope="", contains=True, exact_fallback=True,
584
+ y_min=None, y_max=None):
585
+ """按文本真实点击:JS 定位元素中心坐标 → CDP Input 派发鼠标事件序列。
586
+
587
+ qm-btn 一类自定义按钮只认真实事件序列(mousedown/mouseup/focus),
588
+ el.click() 对它们无效——建书「确认创建」/发章「立即发布」都栽在这。
589
+ y_min/y_max 限定元素纵向范围(区分同名 select、限制弹层内点击)。
590
+ 派发前 elementFromPoint 验证落点真是目标:遮罩/浮层/窗口几何漂移会让
591
+ 坐标系统性打偏且步步报成功(2026-09-28 建书弹层没开实案)。
592
+ 返回 {ok, tag?, via};找不到/被挡/出视口返回 {ok: False, err}。"""
593
+ r = self.call(
594
+ "(t,scope,c,y0,y1)=>{"
595
+ "const vis=e=>e.getBoundingClientRect().width>0;"
596
+ "const els=[...document.querySelectorAll(scope||'button,a,[role=button],span,li,[class*=btn]')].filter(vis);"
597
+ "let cands=els.filter(e=>{const x=(e.innerText||'').trim();"
598
+ "return x&&(c?x.includes(t):x===t);});"
599
+ "if(!cands.length&&c){cands=els.filter(e=>(e.innerText||'').trim()===t);}"
600
+ "if(y0!==null)cands=cands.filter(e=>e.getBoundingClientRect().y>=y0);"
601
+ "if(y1!==null)cands=cands.filter(e=>e.getBoundingClientRect().y<=y1);"
602
+ "if(!cands.length)return{ok:false,err:'nf'};"
603
+ "cands.sort((a,b)=>((a.innerText||'').trim().length)-((b.innerText||'').trim().length));"
604
+ "const el=cands[0];el.scrollIntoView({block:'center'});"
605
+ "const rc=el.getBoundingClientRect();"
606
+ "const x=Math.round(rc.x+rc.width/2),y=Math.round(rc.y+rc.height/2);"
607
+ "const hit=document.elementFromPoint(x,y);"
608
+ "if(!hit)return{ok:false,err:'oob'};"
609
+ "if(hit!==el&&!el.contains(hit)&&!hit.contains(el))"
610
+ "return{ok:false,err:'blocked',by:hit.tagName+' '+"
611
+ "((hit.className||'')+'').slice(0,40)};"
612
+ "return{ok:true,x:x,y:y,"
613
+ "tag:el.tagName,cls:(el.className||'').toString().slice(0,30)};}",
614
+ str(text), scope or "", bool(contains),
615
+ int(y_min) if y_min is not None else None,
616
+ int(y_max) if y_max is not None else None)
617
+ if not (r or {}).get("ok"):
618
+ err = (r or {}).get("err")
619
+ if err == "blocked":
620
+ return {"ok": False,
621
+ "err": "「%s」的落点被 %s 挡住(遮罩/浮层未退场)"
622
+ % (text, (r or {}).get("by") or "未知元素")}
623
+ if err == "oob":
624
+ return {"ok": False,
625
+ "err": "「%s」的落点在视口外——浏览器窗口几何异常,"
626
+ "关掉该平台浏览器窗口后重试" % text}
627
+ return {"ok": False, "err": "页面上找不到文本为「%s」的可点元素%s"
628
+ % (text, self._click_diag(scope))}
629
+ x, y = r["x"], r["y"]
630
+ self.send("Input.dispatchMouseEvent",
631
+ {"type": "mousePressed", "x": x, "y": y,
632
+ "button": "left", "clickCount": 1}, timeout=8.0)
633
+ self.send("Input.dispatchMouseEvent",
634
+ {"type": "mouseReleased", "x": x, "y": y,
635
+ "button": "left", "clickCount": 1}, timeout=8.0)
636
+ return {"ok": True, "tag": r.get("tag"), "via": "input"}
637
+
638
+ def _click_diag(self, scope):
639
+ """点击目标找不到时的现场自证:定位范围里到底有没有可见元素、都是
640
+ 什么——「弹层没开」和「分类表变了」从此一眼可分,不用再猜。"""
641
+ try:
642
+ r = self.call(
643
+ "(scope)=>{const vis=e=>e.getBoundingClientRect().width>0;"
644
+ "const els=[...document.querySelectorAll(scope||"
645
+ "'button,a,[role=button],span,li,[class*=btn]')].filter(vis);"
646
+ "const ts=[...new Set(els.map(e=>(e.innerText||'').trim())"
647
+ ".filter(x=>x&&x.length<=10))].slice(0,8);"
648
+ "return{n:els.length,ts:ts};}", scope or "")
649
+ n = int((r or {}).get("n") or 0)
650
+ ts = "/".join(str(t) for t in ((r or {}).get("ts") or []))
651
+ if not n:
652
+ return "(定位范围 %s 内无可见元素:目标容器多半没打开)" \
653
+ % (str(scope or "默认")[:48])
654
+ return "(定位范围 %s 内可见项:%s)" \
655
+ % (str(scope or "默认")[:48], ts[:120])
656
+ except Exception:
657
+ return ""
658
+
659
+ def screenshot(self, fp):
660
+ """整页截图存证:发布每步之后落一张,出错可回看卡在哪一步。"""
661
+ r = self.send("Page.captureScreenshot", {"format": "png", "fromSurface": True})
662
+ import base64
663
+ fp = Path(fp)
664
+ fp.parent.mkdir(parents=True, exist_ok=True)
665
+ fp.write_bytes(base64.b64decode(r.get("data") or ""))
666
+ return str(fp)
667
+
668
+ def close(self):
669
+ try:
670
+ self.send("Page.close", timeout=3.0)
671
+ except Exception:
672
+ pass
673
+ self.ws.close()
674
+
675
+ # ------------------------------------------------------------ 表单探测
676
+ def probe(self):
677
+ """dump 页面上可交互元素的概要——校准流程选择器表的辅助工具。
678
+
679
+ 返回 [{tag, type, sel, text, value, placeholder}],sel 是尽量稳定的
680
+ 定位串(id/#id 优先,退而求其次 name/placeholder/文本/序号)。"""
681
+ return self.call(
682
+ "()=>{const out=[];"
683
+ "document.querySelectorAll('input,textarea,button,select,[contenteditable=true]').forEach(el=>{"
684
+ "let s='';"
685
+ "if(el.id)s='#'+el.id;"
686
+ "else if(el.name)s=el.tagName.toLowerCase()+'[name=\"'+el.name+'\"]';"
687
+ "else if(el.placeholder)s=el.tagName.toLowerCase()+'[placeholder=\"'+el.placeholder.slice(0,20)+'\"]';"
688
+ "else{const t=(el.innerText||el.value||'').trim().slice(0,12);"
689
+ "s=t?el.tagName.toLowerCase()+':contains('+t+')':el.tagName.toLowerCase();}"
690
+ "out.push({tag:el.tagName.toLowerCase(),type:el.type||'',sel:s,"
691
+ "text:(el.innerText||'').trim().slice(0,16),"
692
+ "value:(el.value||el.textContent||'').trim().slice(0,24),"
693
+ "placeholder:(el.placeholder||'').slice(0,20)});});"
694
+ "return out.slice(0,80);}", timeout=15.0) or []