codebee 0.1.68 → 0.1.69

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 (106) hide show
  1. package/CHANGELOG.md +543 -543
  2. package/LICENSE +21 -21
  3. package/README.md +10 -0
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/aiflavor.py +197 -197
  6. package/app/core/artifacts.py +148 -148
  7. package/app/core/assets.py +110 -110
  8. package/app/core/attachments.py +623 -623
  9. package/app/core/automation.py +758 -758
  10. package/app/core/backup.py +470 -470
  11. package/app/core/beekeeper.py +183 -183
  12. package/app/core/board.py +209 -209
  13. package/app/core/bookmeta.py +930 -930
  14. package/app/core/bookmeta_catalog.py +180 -180
  15. package/app/core/branching.py +137 -137
  16. package/app/core/builtin_agent.py +1234 -1234
  17. package/app/core/capability.py +144 -144
  18. package/app/core/chaptersafe.py +32 -32
  19. package/app/core/cleanup.py +303 -303
  20. package/app/core/compaction.py +201 -201
  21. package/app/core/covergen.py +356 -356
  22. package/app/core/defectretro.py +43 -43
  23. package/app/core/diagnostics.py +114 -114
  24. package/app/core/dispatch.py +173 -173
  25. package/app/core/dispatch_log.py +128 -128
  26. package/app/core/env_scrub.py +85 -85
  27. package/app/core/error_codes.py +23 -23
  28. package/app/core/errorlog.py +137 -137
  29. package/app/core/evaluation.py +174 -174
  30. package/app/core/failure_notify.py +290 -290
  31. package/app/core/goal_service.py +167 -167
  32. package/app/core/health.py +374 -374
  33. package/app/core/history.py +38 -38
  34. package/app/core/hooks.py +162 -162
  35. package/app/core/jobs.py +29 -29
  36. package/app/core/manager.py +2122 -2122
  37. package/app/core/market.py +362 -362
  38. package/app/core/mocks.py +64 -64
  39. package/app/core/modelhub.py +8 -8
  40. package/app/core/notify.py +106 -106
  41. package/app/core/operations.py +228 -228
  42. package/app/core/paihang.py +138 -138
  43. package/app/core/paths.py +2 -2
  44. package/app/core/portguard.py +111 -111
  45. package/app/core/preview.py +293 -293
  46. package/app/core/publish/__init__.py +7 -7
  47. package/app/core/publish/auto.py +367 -367
  48. package/app/core/publish/browser.py +539 -539
  49. package/app/core/publish/fanqie.py +159 -159
  50. package/app/core/publish/flow.py +440 -440
  51. package/app/core/publish/flows-fanqie-calibrated.json +332 -332
  52. package/app/core/publish/ledger.py +203 -203
  53. package/app/core/publish/manager.py +692 -692
  54. package/app/core/publish/qimao.py +141 -141
  55. package/app/core/publish/ws.py +139 -139
  56. package/app/core/redact.py +38 -38
  57. package/app/core/repeat_guard.py +129 -129
  58. package/app/core/router.py +398 -398
  59. package/app/core/runner.py +2067 -2067
  60. package/app/core/selfupdate.py +384 -384
  61. package/app/core/session_log.py +161 -161
  62. package/app/core/sessions.py +312 -312
  63. package/app/core/settings.py +149 -149
  64. package/app/core/settings_schema.py +262 -262
  65. package/app/core/share_page.py +114 -114
  66. package/app/core/skill_scan.py +122 -122
  67. package/app/core/skillpacks/fanqie-novel.md +80 -80
  68. package/app/core/skillpacks/market/character-bible.md +66 -66
  69. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  70. package/app/core/skillpacks/market/git-workflow.md +57 -57
  71. package/app/core/skillpacks/market/release-notes.md +72 -72
  72. package/app/core/skillpacks/market/weekly-report.md +71 -71
  73. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  74. package/app/core/step_runner.py +132 -132
  75. package/app/core/store.py +75 -75
  76. package/app/core/task_compile.py +199 -199
  77. package/app/core/telemetry.py +292 -292
  78. package/app/core/tlsctx.py +64 -64
  79. package/app/core/token_meter.py +147 -147
  80. package/app/core/tracing.py +144 -144
  81. package/app/core/upstream.py +41 -41
  82. package/app/core/usage.py +862 -862
  83. package/app/core/volumes.py +389 -389
  84. package/app/core/wxdigest.py +858 -858
  85. package/app/core/zentao.py +2465 -2465
  86. package/app/main.py +2981 -2981
  87. package/app/pick_dialog.py +110 -110
  88. package/app/ui/app.js +14399 -14399
  89. package/app/ui/artdiff.js +74 -74
  90. package/app/ui/board.html +162 -162
  91. package/app/ui/board.js +305 -305
  92. package/app/ui/i18n.js +2770 -2770
  93. package/app/ui/icons/bee.svg +79 -79
  94. package/app/ui/manifest.json +16 -16
  95. package/app/ui/qrcode.js +2297 -2297
  96. package/app/ui/style.css +16 -16
  97. package/bin/tutti.js +147 -147
  98. package/package.json +46 -46
  99. package/tools/wx_reader.py +261 -261
  100. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  101. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  102. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  103. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  104. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  105. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  106. package/app/.playwright-cli/run-detail-after.png +0 -0
@@ -1,539 +1,539 @@
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 _debug_ports_for_profile(user_data_dir):
82
- """扫描本机浏览器进程命令行,返回使用该 profile 的主实例的调试端口。
83
-
84
- 跨平台:Windows 走 wmic(PS 启动太慢),POSIX 走 ps。只认主进程
85
- (--type= 子进程没有调试端口)。路径按小写+正反斜杠归一后比对。"""
86
- import subprocess as _sp
87
- try:
88
- if sys.platform == "win32":
89
- out = _sp.check_output(
90
- ["wmic", "process", "where", "Name='msedge.exe'", "get", "CommandLine"],
91
- stderr=_sp.DEVNULL, timeout=12).decode("utf-8", "replace")
92
- lines = out.splitlines()
93
- else:
94
- out = _sp.check_output(["ps", "-axo", "command"], timeout=12).decode()
95
- lines = [l for l in out.splitlines()
96
- if "msedge" in l or "chrome" in l or "chromium" in l]
97
- except Exception:
98
- return []
99
- key = str(user_data_dir).replace("\\", "/").strip("/").lower()
100
- ports = []
101
- for ln in lines:
102
- ln = ln.strip()
103
- if not ln or "--type=" in ln or "--remote-debugging-port=" not in ln:
104
- continue
105
- norm = ln.replace("\\\\", "/").replace("\\", "/").lower()
106
- if key not in norm:
107
- continue
108
- m = re.search(r"--remote-debugging-port=(\d+)", ln)
109
- if m:
110
- ports.append(int(m.group(1)))
111
- return ports
112
-
113
-
114
- class Browser:
115
- """一个浏览器子进程(一个 profile 一个实例)+ 它的调试端口。"""
116
-
117
- def __init__(self, user_data_dir, headless=False, exe=None, extra_args=None):
118
- self.exe = exe or find_browser()
119
- if not self.exe:
120
- raise BrowserError("没找到 Edge/Chrome 浏览器,请先安装 Microsoft Edge")
121
- self.user_data_dir = str(Path(user_data_dir))
122
- self.port = _free_port()
123
- args = [self.exe,
124
- "--remote-debugging-port=%d" % self.port,
125
- "--user-data-dir=%s" % self.user_data_dir,
126
- "--no-first-run", "--no-default-browser-check",
127
- "--hide-crash-restore-bubble"] # 崩溃恢复气泡压掉即够;
128
- # --restore-last-session 是无值开关,
129
- # 带 =false 反而激活会话恢复,勿加
130
- if extra_args:
131
- args += list(extra_args)
132
- if headless:
133
- args.append("--headless=new")
134
- try:
135
- self.proc = subprocess.Popen(
136
- args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
137
- start_new_session=(os.name != "nt")) # POSIX 独立进程组供 killpg
138
- # 杀树(runner 同款);Windows 忽略
139
- except OSError as e:
140
- raise BrowserError("浏览器启动失败:%s" % e)
141
- # 挂巢(beekeeper):服务死(含硬杀)自动化浏览器一起带走。接管老实例
142
- # 的分支会把 self.proc 置 None,不受影响(那不是本进程起的,不能杀)。
143
- from .. import beekeeper
144
- beekeeper.adopt(self.proc)
145
- if not self._wait_ready(15.0):
146
- # Edge 对已有实例的 profile:新进程转交参数后秒退(stdout 会打
147
- # 「正在现有浏览器会话中打开」),调试端口是老实例自己的(甚至
148
- # 自选的,真机实测指定 59852 实际跑 56394)。等自己指定的端口必然
149
- # 超时——先扫进程命令行找该 profile 的真端口接管(含隐藏的后台
150
- # 常驻实例:Edge「启动加速/后台运行」的进程锁着 profile 不可见)。
151
- for p in _debug_ports_for_profile(self.user_data_dir):
152
- if p != self.port and port_alive(p, timeout=2.0):
153
- self.port = p
154
- self.proc = None # 老实例不是本对象起的:close 不杀它
155
- return
156
- if self.proc.poll() == 0:
157
- raise BrowserError(
158
- "浏览器把启动请求转给了已在运行的会话(该平台浏览器有隐藏的"
159
- "后台实例占着档案)——请在任务管理器结束所有 Edge 进程,"
160
- "或重启电脑后重试;登录态不受影响")
161
- raise BrowserError(
162
- "浏览器调试端口 15 秒内没就绪:该平台可能已有浏览器实例在跑但"
163
- "端口探测不到——关掉它的所有窗口后重试,或重启电脑后首次连接")
164
-
165
- # ------------------------------------------------------------ 生命周期
166
- def _wait_ready(self, timeout):
167
- deadline = time.time() + timeout
168
- while time.time() < deadline:
169
- if self.proc.poll() is not None:
170
- return False # 二开 profile 时新进程会把参数转交老实例后退出
171
- if port_alive(self.port, timeout=1.0):
172
- return True
173
- time.sleep(0.4)
174
- return False
175
-
176
- def alive(self):
177
- return port_alive(self.port, timeout=1.5)
178
-
179
- def close(self):
180
- """杀掉浏览器进程树。登录态在 profile 里,杀掉不丢。"""
181
- pid = getattr(self, "proc", None) and self.proc.pid
182
- try:
183
- if sys.platform == "win32" and pid:
184
- subprocess.call(["taskkill", "/F", "/T", "/PID", str(pid)],
185
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
186
- elif pid:
187
- # spawn 时 start_new_session,pgid==pid,连渲染进程带杀(runner 同款);
188
- # SIGTERM 单杀主进程在浏览器挂死时留残余子进程锁 profile
189
- os.killpg(pid, signal.SIGKILL)
190
- elif self.proc:
191
- self.proc.terminate()
192
- except Exception:
193
- pass
194
-
195
- # ------------------------------------------------------------ 页面
196
- def pages(self):
197
- return [t for t in _http_json("http://127.0.0.1:%d/json/list" % self.port)
198
- if t.get("type") == "page"]
199
-
200
- def new_page(self, url="about:blank"):
201
- t = _http_json("http://127.0.0.1:%d/json/new?%s" % (self.port, url),
202
- method="PUT") # Chromium 111+ 要求 PUT
203
- return Page(t["webSocketDebuggerUrl"])
204
-
205
- def first_page(self, create=True):
206
- ps = self.pages()
207
- if ps:
208
- return Page(ps[0]["webSocketDebuggerUrl"])
209
- return self.new_page() if create else None
210
-
211
- @staticmethod
212
- def attach(port):
213
- """不启动进程、直接接管已存活浏览器(服务重启后重连同 profile 的实例)。"""
214
- if not port_alive(port):
215
- raise BrowserError("端口 %s 上没有活着的浏览器" % port)
216
- b = Browser.__new__(Browser)
217
- b.port = int(port)
218
- b.proc = None
219
- b.user_data_dir = ""
220
- b.exe = find_browser()
221
- return b
222
-
223
-
224
- class Page:
225
- """一个标签页的 CDP 会话:命令收发 + 注入式高层操作。"""
226
-
227
- def __init__(self, ws_url, connect_timeout=15.0):
228
- m = re.match(r"ws://([^/:]+):(\d+)(/.+)", ws_url)
229
- if not m:
230
- raise BrowserError("无法解析调试地址:%s" % ws_url[:80])
231
- try:
232
- self.ws = MiniWS(m.group(1), int(m.group(2)), m.group(3),
233
- timeout=connect_timeout)
234
- except (WebSocketError, OSError) as e:
235
- raise BrowserError("连接页面失败:%s" % e)
236
- self._id = 0
237
- self._events = []
238
- try:
239
- self.send("Runtime.enable")
240
- self.send("Page.enable")
241
- self._ua_override()
242
- except BrowserError:
243
- self.ws.close()
244
- raise
245
-
246
- def _ua_override(self):
247
- """UA 伪装成标准 Chrome:Edge 尾巴(Edg/x.y)会被部分站点(如七猫
248
- 建书页)的浏览器检测判「版本过低」整页替换。用 CDP Browser.getVersion
249
- 的真实内核版本拼标准 Chrome UA,各站点兼容性等同 Chrome。失败不拦
250
- (个别上下文可能禁用该域)。"""
251
- try:
252
- r = self.send("Browser.getVersion", timeout=5.0)
253
- ver = str((r.get("product") or "")).split("/")[-1] or "130.0.0.0"
254
- ua = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
255
- "(KHTML, like Gecko) Chrome/%s Safari/537.36" % ver)
256
- self.send("Network.setUserAgentOverride",
257
- {"userAgent": ua}, timeout=5.0)
258
- except BrowserError:
259
- pass
260
-
261
- # ------------------------------------------------------------ CDP 协议
262
- def send(self, method, params=None, timeout=30.0):
263
- """发一条命令并等它的响应;期间到达的事件进缓存(wait_event 消费)。"""
264
- self._id += 1
265
- mid = self._id
266
- self.ws.send_text(json.dumps({"id": mid, "method": method,
267
- "params": params or {}}))
268
- deadline = time.time() + timeout
269
- while True:
270
- remain = deadline - time.time()
271
- if remain <= 0:
272
- raise BrowserError("命令 %s 等响应超时(%.0fs)" % (method, timeout))
273
- try:
274
- msg = json.loads(self.ws.recv_message(timeout=remain))
275
- except socket.timeout:
276
- continue # 到点抛 BrowserError(上面的分支)
277
- except (WebSocketError, OSError) as e:
278
- raise BrowserError("连接断开:%s" % e)
279
- if msg.get("id") == mid:
280
- if "error" in msg:
281
- raise BrowserError("CDP 拒绝 %s:%s" %
282
- (method, str(msg["error"].get("message"))[:160]))
283
- return msg.get("result") or {}
284
- if "id" not in msg:
285
- self._events.append(msg)
286
-
287
- def wait_event(self, method, timeout=30.0, drain=True):
288
- """等一个指定事件(如 Page.loadEventFired);drain 时清空旧事件。"""
289
- if drain:
290
- self._events = [e for e in self._events if e.get("method") != method]
291
- deadline = time.time() + timeout
292
- while time.time() < deadline:
293
- for i, e in enumerate(self._events):
294
- if e.get("method") == method:
295
- del self._events[i]
296
- return e.get("params") or {}
297
- try:
298
- msg = json.loads(self.ws.recv_message(
299
- timeout=max(0.2, deadline - time.time())))
300
- except socket.timeout:
301
- continue
302
- except (WebSocketError, OSError) as e:
303
- raise BrowserError("连接断开:%s" % e)
304
- if "id" not in msg:
305
- self._events.append(msg)
306
- raise BrowserError("等事件 %s 超时(%.0fs)" % (method, timeout))
307
-
308
- # ------------------------------------------------------------ 求值
309
- def evaluate(self, expr, await_promise=False, timeout=60.0):
310
- """求值 JS 表达式返回其值;JS 抛错时带出人话信息。"""
311
- r = self.send("Runtime.evaluate", {
312
- "expression": expr, "awaitPromise": await_promise,
313
- "returnByValue": True, "userGesture": True,
314
- }, timeout=timeout)
315
- det = r.get("exceptionDetails")
316
- if det:
317
- raise BrowserError("JS 异常:%s" % json.dumps(det, ensure_ascii=False)[:220])
318
- res = r.get("result") or {} # send 已剥掉外层 {id,result} 的 result
319
- if res.get("subtype") == "error":
320
- raise BrowserError("JS 抛错:%s" % str(res.get("description"))[:200])
321
- return res.get("value")
322
-
323
- def call(self, fn, *args, **kw):
324
- """注入一个 JS 函数并调用:call('(sel,t)=>{...}', sel, text)。"""
325
- payload = ", ".join(json.dumps(a, ensure_ascii=False) for a in args)
326
- return self.evaluate("(%s)(%s)" % (fn, payload), **kw)
327
-
328
- # ------------------------------------------------------------ 高层操作
329
- def url(self):
330
- return self.evaluate("location.href")
331
-
332
- def navigate(self, url, timeout=30.0):
333
- """导航并等文档就绪。SPA 路由可能不触发完整 load,readyState 轮询兜底。
334
-
335
- about:blank 起跳时 readyState 本就 complete——必须同时等 location
336
- 真正到达目标域,否则后续步骤打在空白页上(url_any 误报)。"""
337
- from urllib.parse import urlparse as _up
338
- if url == "about:blank": # 中转页:无需等加载(页面忙时会假超时)
339
- try:
340
- self.send("Page.navigate", {"url": url}, timeout=5.0)
341
- except BrowserError:
342
- pass
343
- time.sleep(0.4)
344
- return
345
- host = _up(url).netloc
346
- if not host:
347
- # 无主机名=无效地址(占位符未替换等)。放行会在下方被「host 为空
348
- # 视为已到位」吞掉——页面停在 about:blank 还假装导航成功(0924 案)。
349
- raise BrowserError("无效的导航地址(无主机名,占位符可能未替换):%s"
350
- % str(url)[:90])
351
- try:
352
- self.send("Page.navigate", {"url": url}, timeout=timeout)
353
- except BrowserError:
354
- pass # 老页面销毁时连接报错属正常,轮询兜底
355
- host = _up(url).netloc
356
- deadline = time.time() + timeout
357
- seen_url = False
358
- while time.time() < deadline:
359
- try:
360
- href = self.evaluate("location.href", timeout=3.0) or ""
361
- if not host or host in href:
362
- seen_url = True
363
- if seen_url and self.evaluate("document.readyState", timeout=3.0) == "complete":
364
- time.sleep(0.5) # SPA 首帧渲染余量
365
- return
366
- except BrowserError:
367
- pass # 导航间隙 evaluate 会短暂失败
368
- time.sleep(0.3)
369
- raise BrowserError("页面 %.0f 秒未完成加载:%s" % (timeout, url))
370
-
371
- def exists(self, sel, timeout=1.0):
372
- return bool(self.call("(s)=>!!document.querySelector(s)", sel, timeout=timeout))
373
-
374
- def wait_for(self, sel, timeout=15.0):
375
- deadline = time.time() + timeout
376
- while time.time() < deadline:
377
- if self.exists(sel, timeout=1.0):
378
- return
379
- time.sleep(0.4)
380
- raise BrowserError("等待元素超时(%.0fs):%s" % (timeout, sel))
381
-
382
- def value(self, sel):
383
- return self.call("(s)=>{const e=document.querySelector(s);"
384
- "return e?(e.value!==undefined?e.value:e.textContent):null}", sel)
385
-
386
- def fill(self, sel, text):
387
- """填输入框/文本域/富文本。
388
-
389
- React/Vue 受控组件直接赋 value 不触发框架状态,必须走原型链 native
390
- setter 再补 input/change 事件;富文本(章节正文)走 CDP
391
- Input.insertText——execCommand('insertText') 对千字长文会截断
392
- (真机实测 1020 字只进 615),insertText 走完整输入管道无此限。"""
393
- text = str(text)
394
- ce = None
395
- for _try in range(24): # 编辑器慢加载 + class 动态 + edit-mask
396
- ce = self.call( # 遮罩需真实点击激活后才可编辑
397
- "(s)=>{const els=[...document.querySelectorAll(s)];"
398
- "let best=null;for(const e of els){"
399
- "if(!e.isContentEditable)continue;"
400
- "const r=e.getBoundingClientRect();"
401
- "if(r.width<=0)continue;"
402
- "const a=r.width*r.height;"
403
- "if(!best||a>best.a)best={e,a};}"
404
- "if(best){"
405
- "const el=best.e;"
406
- "el.scrollIntoView({block:'center'});el.focus();"
407
- "const r=document.createRange();r.selectNodeContents(el);"
408
- "const g=getSelection();g.removeAllRanges();g.addRange(r);return {hit:true};}"
409
- "const vis=els.filter(e=>e.getBoundingClientRect().width>0);"
410
- "if(!vis.length)return null;"
411
- "let big=vis[0];for(const e of vis){"
412
- "const r=e.getBoundingClientRect();"
413
- "if(r.width*r.height>big.getBoundingClientRect().width*big.getBoundingClientRect().height)big=e;}"
414
- "const r=big.getBoundingClientRect();"
415
- "return {hit:false,x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};}", sel)
416
- if ce is None:
417
- break
418
- if isinstance(ce, dict) and ce.get("hit"):
419
- ce = True
420
- break
421
- if isinstance(ce, dict) and "x" in ce: # 遮罩未揭:真实点击激活
422
- self.send("Input.dispatchMouseEvent",
423
- {"type": "mousePressed", "x": ce["x"], "y": ce["y"],
424
- "button": "left", "clickCount": 1}, timeout=8.0)
425
- self.send("Input.dispatchMouseEvent",
426
- {"type": "mouseReleased", "x": ce["x"], "y": ce["y"],
427
- "button": "left", "clickCount": 1}, timeout=8.0)
428
- ce = False
429
- time.sleep(0.8)
430
- continue
431
- ce = False
432
- time.sleep(0.5)
433
- if ce is True: # 富文本:焦点就位后键盘级插入
434
- self.send("Input.insertText", {"text": text}, timeout=30.0)
435
- n = self.call("(s)=>{const els=[...document.querySelectorAll(s)].filter(e=>e.isContentEditable);"
436
- "let n=0;for(const e of els)n=Math.max(n,(e.innerText||'').length);return n;}", sel)
437
- if int(n or 0) < min(len(text), 20):
438
- raise BrowserError("富文本插入不完整(%s/%d 字)" % (n, len(text)))
439
- return True
440
- if ce is None:
441
- raise BrowserError("找不到输入框 %s" % sel)
442
- r = self.call(
443
- "(s,t)=>{const el=document.querySelector(s);if(!el)"
444
- "return{ok:false,err:'找不到输入框 '+s};"
445
- "el.scrollIntoView({block:'center'});el.focus();"
446
- "const proto=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype"
447
- ":HTMLInputElement.prototype;"
448
- "const d=Object.getOwnPropertyDescriptor(proto,'value');"
449
- "(d&&d.set?d.set:function(v){el.value=v}).call(el,t);"
450
- "el.dispatchEvent(new Event('input',{bubbles:true}));"
451
- "el.dispatchEvent(new Event('change',{bubbles:true}));"
452
- "return{ok:true};}", sel, text)
453
- if not (r or {}).get("ok"):
454
- raise BrowserError("填入失败:%s" % ((r or {}).get("err") or "目标不是可输入元素"))
455
- return True
456
-
457
- def click(self, sel):
458
- r = self.call(
459
- "(s)=>{const el=document.querySelector(s);if(!el)"
460
- "return{ok:false,err:'找不到 '+s};"
461
- "if(el.disabled)return{ok:false,err:'按钮处于禁用态(表单可能有未通过校验的字段)'};"
462
- "el.scrollIntoView({block:'center'});el.click();return{ok:true};}", sel)
463
- if not (r or {}).get("ok"):
464
- raise BrowserError("点击失败:%s" % ((r or {}).get("err") or sel))
465
- return True
466
-
467
- def real_click_text(self, text, scope="", contains=True, exact_fallback=True,
468
- y_min=None, y_max=None):
469
- """按文本真实点击:JS 定位元素中心坐标 → CDP Input 派发鼠标事件序列。
470
-
471
- qm-btn 一类自定义按钮只认真实事件序列(mousedown/mouseup/focus),
472
- el.click() 对它们无效——建书「确认创建」/发章「立即发布」都栽在这。
473
- y_min/y_max 限定元素纵向范围(区分同名 select、限制弹层内点击)。
474
- 返回 {ok, tag?, via};找不到元素返回 {ok: False, err}。"""
475
- r = self.call(
476
- "(t,scope,c,y0,y1)=>{"
477
- "const vis=e=>e.getBoundingClientRect().width>0;"
478
- "const els=[...document.querySelectorAll(scope||'button,a,[role=button],span,li,[class*=btn]')].filter(vis);"
479
- "let cands=els.filter(e=>{const x=(e.innerText||'').trim();"
480
- "return x&&(c?x.includes(t):x===t);});"
481
- "if(!cands.length&&c){cands=els.filter(e=>(e.innerText||'').trim()===t);}"
482
- "if(y0!==null)cands=cands.filter(e=>e.getBoundingClientRect().y>=y0);"
483
- "if(y1!==null)cands=cands.filter(e=>e.getBoundingClientRect().y<=y1);"
484
- "if(!cands.length)return{ok:false,err:'nf'};"
485
- "cands.sort((a,b)=>((a.innerText||'').trim().length)-((b.innerText||'').trim().length));"
486
- "const el=cands[0];el.scrollIntoView({block:'center'});"
487
- "const rc=el.getBoundingClientRect();"
488
- "return{ok:true,x:Math.round(rc.x+rc.width/2),y:Math.round(rc.y+rc.height/2),"
489
- "tag:el.tagName,cls:(el.className||'').toString().slice(0,30)};}",
490
- str(text), scope or "", bool(contains),
491
- int(y_min) if y_min is not None else None,
492
- int(y_max) if y_max is not None else None)
493
- if not (r or {}).get("ok"):
494
- return {"ok": False, "err": "页面上找不到文本为「%s」的可点元素" % text}
495
- x, y = r["x"], r["y"]
496
- self.send("Input.dispatchMouseEvent",
497
- {"type": "mousePressed", "x": x, "y": y,
498
- "button": "left", "clickCount": 1}, timeout=8.0)
499
- self.send("Input.dispatchMouseEvent",
500
- {"type": "mouseReleased", "x": x, "y": y,
501
- "button": "left", "clickCount": 1}, timeout=8.0)
502
- return {"ok": True, "tag": r.get("tag"), "via": "input"}
503
-
504
- def screenshot(self, fp):
505
- """整页截图存证:发布每步之后落一张,出错可回看卡在哪一步。"""
506
- r = self.send("Page.captureScreenshot", {"format": "png", "fromSurface": True})
507
- import base64
508
- fp = Path(fp)
509
- fp.parent.mkdir(parents=True, exist_ok=True)
510
- fp.write_bytes(base64.b64decode(r.get("data") or ""))
511
- return str(fp)
512
-
513
- def close(self):
514
- try:
515
- self.send("Page.close", timeout=3.0)
516
- except Exception:
517
- pass
518
- self.ws.close()
519
-
520
- # ------------------------------------------------------------ 表单探测
521
- def probe(self):
522
- """dump 页面上可交互元素的概要——校准流程选择器表的辅助工具。
523
-
524
- 返回 [{tag, type, sel, text, value, placeholder}],sel 是尽量稳定的
525
- 定位串(id/#id 优先,退而求其次 name/placeholder/文本/序号)。"""
526
- return self.call(
527
- "()=>{const out=[];"
528
- "document.querySelectorAll('input,textarea,button,select,[contenteditable=true]').forEach(el=>{"
529
- "let s='';"
530
- "if(el.id)s='#'+el.id;"
531
- "else if(el.name)s=el.tagName.toLowerCase()+'[name=\"'+el.name+'\"]';"
532
- "else if(el.placeholder)s=el.tagName.toLowerCase()+'[placeholder=\"'+el.placeholder.slice(0,20)+'\"]';"
533
- "else{const t=(el.innerText||el.value||'').trim().slice(0,12);"
534
- "s=t?el.tagName.toLowerCase()+':contains('+t+')':el.tagName.toLowerCase();}"
535
- "out.push({tag:el.tagName.toLowerCase(),type:el.type||'',sel:s,"
536
- "text:(el.innerText||'').trim().slice(0,16),"
537
- "value:(el.value||el.textContent||'').trim().slice(0,24),"
538
- "placeholder:(el.placeholder||'').slice(0,20)});});"
539
- "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 _debug_ports_for_profile(user_data_dir):
82
+ """扫描本机浏览器进程命令行,返回使用该 profile 的主实例的调试端口。
83
+
84
+ 跨平台:Windows 走 wmic(PS 启动太慢),POSIX 走 ps。只认主进程
85
+ (--type= 子进程没有调试端口)。路径按小写+正反斜杠归一后比对。"""
86
+ import subprocess as _sp
87
+ try:
88
+ if sys.platform == "win32":
89
+ out = _sp.check_output(
90
+ ["wmic", "process", "where", "Name='msedge.exe'", "get", "CommandLine"],
91
+ stderr=_sp.DEVNULL, timeout=12).decode("utf-8", "replace")
92
+ lines = out.splitlines()
93
+ else:
94
+ out = _sp.check_output(["ps", "-axo", "command"], timeout=12).decode()
95
+ lines = [l for l in out.splitlines()
96
+ if "msedge" in l or "chrome" in l or "chromium" in l]
97
+ except Exception:
98
+ return []
99
+ key = str(user_data_dir).replace("\\", "/").strip("/").lower()
100
+ ports = []
101
+ for ln in lines:
102
+ ln = ln.strip()
103
+ if not ln or "--type=" in ln or "--remote-debugging-port=" not in ln:
104
+ continue
105
+ norm = ln.replace("\\\\", "/").replace("\\", "/").lower()
106
+ if key not in norm:
107
+ continue
108
+ m = re.search(r"--remote-debugging-port=(\d+)", ln)
109
+ if m:
110
+ ports.append(int(m.group(1)))
111
+ return ports
112
+
113
+
114
+ class Browser:
115
+ """一个浏览器子进程(一个 profile 一个实例)+ 它的调试端口。"""
116
+
117
+ def __init__(self, user_data_dir, headless=False, exe=None, extra_args=None):
118
+ self.exe = exe or find_browser()
119
+ if not self.exe:
120
+ raise BrowserError("没找到 Edge/Chrome 浏览器,请先安装 Microsoft Edge")
121
+ self.user_data_dir = str(Path(user_data_dir))
122
+ self.port = _free_port()
123
+ args = [self.exe,
124
+ "--remote-debugging-port=%d" % self.port,
125
+ "--user-data-dir=%s" % self.user_data_dir,
126
+ "--no-first-run", "--no-default-browser-check",
127
+ "--hide-crash-restore-bubble"] # 崩溃恢复气泡压掉即够;
128
+ # --restore-last-session 是无值开关,
129
+ # 带 =false 反而激活会话恢复,勿加
130
+ if extra_args:
131
+ args += list(extra_args)
132
+ if headless:
133
+ args.append("--headless=new")
134
+ try:
135
+ self.proc = subprocess.Popen(
136
+ args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
137
+ start_new_session=(os.name != "nt")) # POSIX 独立进程组供 killpg
138
+ # 杀树(runner 同款);Windows 忽略
139
+ except OSError as e:
140
+ raise BrowserError("浏览器启动失败:%s" % e)
141
+ # 挂巢(beekeeper):服务死(含硬杀)自动化浏览器一起带走。接管老实例
142
+ # 的分支会把 self.proc 置 None,不受影响(那不是本进程起的,不能杀)。
143
+ from .. import beekeeper
144
+ beekeeper.adopt(self.proc)
145
+ if not self._wait_ready(15.0):
146
+ # Edge 对已有实例的 profile:新进程转交参数后秒退(stdout 会打
147
+ # 「正在现有浏览器会话中打开」),调试端口是老实例自己的(甚至
148
+ # 自选的,真机实测指定 59852 实际跑 56394)。等自己指定的端口必然
149
+ # 超时——先扫进程命令行找该 profile 的真端口接管(含隐藏的后台
150
+ # 常驻实例:Edge「启动加速/后台运行」的进程锁着 profile 不可见)。
151
+ for p in _debug_ports_for_profile(self.user_data_dir):
152
+ if p != self.port and port_alive(p, timeout=2.0):
153
+ self.port = p
154
+ self.proc = None # 老实例不是本对象起的:close 不杀它
155
+ return
156
+ if self.proc.poll() == 0:
157
+ raise BrowserError(
158
+ "浏览器把启动请求转给了已在运行的会话(该平台浏览器有隐藏的"
159
+ "后台实例占着档案)——请在任务管理器结束所有 Edge 进程,"
160
+ "或重启电脑后重试;登录态不受影响")
161
+ raise BrowserError(
162
+ "浏览器调试端口 15 秒内没就绪:该平台可能已有浏览器实例在跑但"
163
+ "端口探测不到——关掉它的所有窗口后重试,或重启电脑后首次连接")
164
+
165
+ # ------------------------------------------------------------ 生命周期
166
+ def _wait_ready(self, timeout):
167
+ deadline = time.time() + timeout
168
+ while time.time() < deadline:
169
+ if self.proc.poll() is not None:
170
+ return False # 二开 profile 时新进程会把参数转交老实例后退出
171
+ if port_alive(self.port, timeout=1.0):
172
+ return True
173
+ time.sleep(0.4)
174
+ return False
175
+
176
+ def alive(self):
177
+ return port_alive(self.port, timeout=1.5)
178
+
179
+ def close(self):
180
+ """杀掉浏览器进程树。登录态在 profile 里,杀掉不丢。"""
181
+ pid = getattr(self, "proc", None) and self.proc.pid
182
+ try:
183
+ if sys.platform == "win32" and pid:
184
+ subprocess.call(["taskkill", "/F", "/T", "/PID", str(pid)],
185
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
186
+ elif pid:
187
+ # spawn 时 start_new_session,pgid==pid,连渲染进程带杀(runner 同款);
188
+ # SIGTERM 单杀主进程在浏览器挂死时留残余子进程锁 profile
189
+ os.killpg(pid, signal.SIGKILL)
190
+ elif self.proc:
191
+ self.proc.terminate()
192
+ except Exception:
193
+ pass
194
+
195
+ # ------------------------------------------------------------ 页面
196
+ def pages(self):
197
+ return [t for t in _http_json("http://127.0.0.1:%d/json/list" % self.port)
198
+ if t.get("type") == "page"]
199
+
200
+ def new_page(self, url="about:blank"):
201
+ t = _http_json("http://127.0.0.1:%d/json/new?%s" % (self.port, url),
202
+ method="PUT") # Chromium 111+ 要求 PUT
203
+ return Page(t["webSocketDebuggerUrl"])
204
+
205
+ def first_page(self, create=True):
206
+ ps = self.pages()
207
+ if ps:
208
+ return Page(ps[0]["webSocketDebuggerUrl"])
209
+ return self.new_page() if create else None
210
+
211
+ @staticmethod
212
+ def attach(port):
213
+ """不启动进程、直接接管已存活浏览器(服务重启后重连同 profile 的实例)。"""
214
+ if not port_alive(port):
215
+ raise BrowserError("端口 %s 上没有活着的浏览器" % port)
216
+ b = Browser.__new__(Browser)
217
+ b.port = int(port)
218
+ b.proc = None
219
+ b.user_data_dir = ""
220
+ b.exe = find_browser()
221
+ return b
222
+
223
+
224
+ class Page:
225
+ """一个标签页的 CDP 会话:命令收发 + 注入式高层操作。"""
226
+
227
+ def __init__(self, ws_url, connect_timeout=15.0):
228
+ m = re.match(r"ws://([^/:]+):(\d+)(/.+)", ws_url)
229
+ if not m:
230
+ raise BrowserError("无法解析调试地址:%s" % ws_url[:80])
231
+ try:
232
+ self.ws = MiniWS(m.group(1), int(m.group(2)), m.group(3),
233
+ timeout=connect_timeout)
234
+ except (WebSocketError, OSError) as e:
235
+ raise BrowserError("连接页面失败:%s" % e)
236
+ self._id = 0
237
+ self._events = []
238
+ try:
239
+ self.send("Runtime.enable")
240
+ self.send("Page.enable")
241
+ self._ua_override()
242
+ except BrowserError:
243
+ self.ws.close()
244
+ raise
245
+
246
+ def _ua_override(self):
247
+ """UA 伪装成标准 Chrome:Edge 尾巴(Edg/x.y)会被部分站点(如七猫
248
+ 建书页)的浏览器检测判「版本过低」整页替换。用 CDP Browser.getVersion
249
+ 的真实内核版本拼标准 Chrome UA,各站点兼容性等同 Chrome。失败不拦
250
+ (个别上下文可能禁用该域)。"""
251
+ try:
252
+ r = self.send("Browser.getVersion", timeout=5.0)
253
+ ver = str((r.get("product") or "")).split("/")[-1] or "130.0.0.0"
254
+ ua = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
255
+ "(KHTML, like Gecko) Chrome/%s Safari/537.36" % ver)
256
+ self.send("Network.setUserAgentOverride",
257
+ {"userAgent": ua}, timeout=5.0)
258
+ except BrowserError:
259
+ pass
260
+
261
+ # ------------------------------------------------------------ CDP 协议
262
+ def send(self, method, params=None, timeout=30.0):
263
+ """发一条命令并等它的响应;期间到达的事件进缓存(wait_event 消费)。"""
264
+ self._id += 1
265
+ mid = self._id
266
+ self.ws.send_text(json.dumps({"id": mid, "method": method,
267
+ "params": params or {}}))
268
+ deadline = time.time() + timeout
269
+ while True:
270
+ remain = deadline - time.time()
271
+ if remain <= 0:
272
+ raise BrowserError("命令 %s 等响应超时(%.0fs)" % (method, timeout))
273
+ try:
274
+ msg = json.loads(self.ws.recv_message(timeout=remain))
275
+ except socket.timeout:
276
+ continue # 到点抛 BrowserError(上面的分支)
277
+ except (WebSocketError, OSError) as e:
278
+ raise BrowserError("连接断开:%s" % e)
279
+ if msg.get("id") == mid:
280
+ if "error" in msg:
281
+ raise BrowserError("CDP 拒绝 %s:%s" %
282
+ (method, str(msg["error"].get("message"))[:160]))
283
+ return msg.get("result") or {}
284
+ if "id" not in msg:
285
+ self._events.append(msg)
286
+
287
+ def wait_event(self, method, timeout=30.0, drain=True):
288
+ """等一个指定事件(如 Page.loadEventFired);drain 时清空旧事件。"""
289
+ if drain:
290
+ self._events = [e for e in self._events if e.get("method") != method]
291
+ deadline = time.time() + timeout
292
+ while time.time() < deadline:
293
+ for i, e in enumerate(self._events):
294
+ if e.get("method") == method:
295
+ del self._events[i]
296
+ return e.get("params") or {}
297
+ try:
298
+ msg = json.loads(self.ws.recv_message(
299
+ timeout=max(0.2, deadline - time.time())))
300
+ except socket.timeout:
301
+ continue
302
+ except (WebSocketError, OSError) as e:
303
+ raise BrowserError("连接断开:%s" % e)
304
+ if "id" not in msg:
305
+ self._events.append(msg)
306
+ raise BrowserError("等事件 %s 超时(%.0fs)" % (method, timeout))
307
+
308
+ # ------------------------------------------------------------ 求值
309
+ def evaluate(self, expr, await_promise=False, timeout=60.0):
310
+ """求值 JS 表达式返回其值;JS 抛错时带出人话信息。"""
311
+ r = self.send("Runtime.evaluate", {
312
+ "expression": expr, "awaitPromise": await_promise,
313
+ "returnByValue": True, "userGesture": True,
314
+ }, timeout=timeout)
315
+ det = r.get("exceptionDetails")
316
+ if det:
317
+ raise BrowserError("JS 异常:%s" % json.dumps(det, ensure_ascii=False)[:220])
318
+ res = r.get("result") or {} # send 已剥掉外层 {id,result} 的 result
319
+ if res.get("subtype") == "error":
320
+ raise BrowserError("JS 抛错:%s" % str(res.get("description"))[:200])
321
+ return res.get("value")
322
+
323
+ def call(self, fn, *args, **kw):
324
+ """注入一个 JS 函数并调用:call('(sel,t)=>{...}', sel, text)。"""
325
+ payload = ", ".join(json.dumps(a, ensure_ascii=False) for a in args)
326
+ return self.evaluate("(%s)(%s)" % (fn, payload), **kw)
327
+
328
+ # ------------------------------------------------------------ 高层操作
329
+ def url(self):
330
+ return self.evaluate("location.href")
331
+
332
+ def navigate(self, url, timeout=30.0):
333
+ """导航并等文档就绪。SPA 路由可能不触发完整 load,readyState 轮询兜底。
334
+
335
+ about:blank 起跳时 readyState 本就 complete——必须同时等 location
336
+ 真正到达目标域,否则后续步骤打在空白页上(url_any 误报)。"""
337
+ from urllib.parse import urlparse as _up
338
+ if url == "about:blank": # 中转页:无需等加载(页面忙时会假超时)
339
+ try:
340
+ self.send("Page.navigate", {"url": url}, timeout=5.0)
341
+ except BrowserError:
342
+ pass
343
+ time.sleep(0.4)
344
+ return
345
+ host = _up(url).netloc
346
+ if not host:
347
+ # 无主机名=无效地址(占位符未替换等)。放行会在下方被「host 为空
348
+ # 视为已到位」吞掉——页面停在 about:blank 还假装导航成功(0924 案)。
349
+ raise BrowserError("无效的导航地址(无主机名,占位符可能未替换):%s"
350
+ % str(url)[:90])
351
+ try:
352
+ self.send("Page.navigate", {"url": url}, timeout=timeout)
353
+ except BrowserError:
354
+ pass # 老页面销毁时连接报错属正常,轮询兜底
355
+ host = _up(url).netloc
356
+ deadline = time.time() + timeout
357
+ seen_url = False
358
+ while time.time() < deadline:
359
+ try:
360
+ href = self.evaluate("location.href", timeout=3.0) or ""
361
+ if not host or host in href:
362
+ seen_url = True
363
+ if seen_url and self.evaluate("document.readyState", timeout=3.0) == "complete":
364
+ time.sleep(0.5) # SPA 首帧渲染余量
365
+ return
366
+ except BrowserError:
367
+ pass # 导航间隙 evaluate 会短暂失败
368
+ time.sleep(0.3)
369
+ raise BrowserError("页面 %.0f 秒未完成加载:%s" % (timeout, url))
370
+
371
+ def exists(self, sel, timeout=1.0):
372
+ return bool(self.call("(s)=>!!document.querySelector(s)", sel, timeout=timeout))
373
+
374
+ def wait_for(self, sel, timeout=15.0):
375
+ deadline = time.time() + timeout
376
+ while time.time() < deadline:
377
+ if self.exists(sel, timeout=1.0):
378
+ return
379
+ time.sleep(0.4)
380
+ raise BrowserError("等待元素超时(%.0fs):%s" % (timeout, sel))
381
+
382
+ def value(self, sel):
383
+ return self.call("(s)=>{const e=document.querySelector(s);"
384
+ "return e?(e.value!==undefined?e.value:e.textContent):null}", sel)
385
+
386
+ def fill(self, sel, text):
387
+ """填输入框/文本域/富文本。
388
+
389
+ React/Vue 受控组件直接赋 value 不触发框架状态,必须走原型链 native
390
+ setter 再补 input/change 事件;富文本(章节正文)走 CDP
391
+ Input.insertText——execCommand('insertText') 对千字长文会截断
392
+ (真机实测 1020 字只进 615),insertText 走完整输入管道无此限。"""
393
+ text = str(text)
394
+ ce = None
395
+ for _try in range(24): # 编辑器慢加载 + class 动态 + edit-mask
396
+ ce = self.call( # 遮罩需真实点击激活后才可编辑
397
+ "(s)=>{const els=[...document.querySelectorAll(s)];"
398
+ "let best=null;for(const e of els){"
399
+ "if(!e.isContentEditable)continue;"
400
+ "const r=e.getBoundingClientRect();"
401
+ "if(r.width<=0)continue;"
402
+ "const a=r.width*r.height;"
403
+ "if(!best||a>best.a)best={e,a};}"
404
+ "if(best){"
405
+ "const el=best.e;"
406
+ "el.scrollIntoView({block:'center'});el.focus();"
407
+ "const r=document.createRange();r.selectNodeContents(el);"
408
+ "const g=getSelection();g.removeAllRanges();g.addRange(r);return {hit:true};}"
409
+ "const vis=els.filter(e=>e.getBoundingClientRect().width>0);"
410
+ "if(!vis.length)return null;"
411
+ "let big=vis[0];for(const e of vis){"
412
+ "const r=e.getBoundingClientRect();"
413
+ "if(r.width*r.height>big.getBoundingClientRect().width*big.getBoundingClientRect().height)big=e;}"
414
+ "const r=big.getBoundingClientRect();"
415
+ "return {hit:false,x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};}", sel)
416
+ if ce is None:
417
+ break
418
+ if isinstance(ce, dict) and ce.get("hit"):
419
+ ce = True
420
+ break
421
+ if isinstance(ce, dict) and "x" in ce: # 遮罩未揭:真实点击激活
422
+ self.send("Input.dispatchMouseEvent",
423
+ {"type": "mousePressed", "x": ce["x"], "y": ce["y"],
424
+ "button": "left", "clickCount": 1}, timeout=8.0)
425
+ self.send("Input.dispatchMouseEvent",
426
+ {"type": "mouseReleased", "x": ce["x"], "y": ce["y"],
427
+ "button": "left", "clickCount": 1}, timeout=8.0)
428
+ ce = False
429
+ time.sleep(0.8)
430
+ continue
431
+ ce = False
432
+ time.sleep(0.5)
433
+ if ce is True: # 富文本:焦点就位后键盘级插入
434
+ self.send("Input.insertText", {"text": text}, timeout=30.0)
435
+ n = self.call("(s)=>{const els=[...document.querySelectorAll(s)].filter(e=>e.isContentEditable);"
436
+ "let n=0;for(const e of els)n=Math.max(n,(e.innerText||'').length);return n;}", sel)
437
+ if int(n or 0) < min(len(text), 20):
438
+ raise BrowserError("富文本插入不完整(%s/%d 字)" % (n, len(text)))
439
+ return True
440
+ if ce is None:
441
+ raise BrowserError("找不到输入框 %s" % sel)
442
+ r = self.call(
443
+ "(s,t)=>{const el=document.querySelector(s);if(!el)"
444
+ "return{ok:false,err:'找不到输入框 '+s};"
445
+ "el.scrollIntoView({block:'center'});el.focus();"
446
+ "const proto=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype"
447
+ ":HTMLInputElement.prototype;"
448
+ "const d=Object.getOwnPropertyDescriptor(proto,'value');"
449
+ "(d&&d.set?d.set:function(v){el.value=v}).call(el,t);"
450
+ "el.dispatchEvent(new Event('input',{bubbles:true}));"
451
+ "el.dispatchEvent(new Event('change',{bubbles:true}));"
452
+ "return{ok:true};}", sel, text)
453
+ if not (r or {}).get("ok"):
454
+ raise BrowserError("填入失败:%s" % ((r or {}).get("err") or "目标不是可输入元素"))
455
+ return True
456
+
457
+ def click(self, sel):
458
+ r = self.call(
459
+ "(s)=>{const el=document.querySelector(s);if(!el)"
460
+ "return{ok:false,err:'找不到 '+s};"
461
+ "if(el.disabled)return{ok:false,err:'按钮处于禁用态(表单可能有未通过校验的字段)'};"
462
+ "el.scrollIntoView({block:'center'});el.click();return{ok:true};}", sel)
463
+ if not (r or {}).get("ok"):
464
+ raise BrowserError("点击失败:%s" % ((r or {}).get("err") or sel))
465
+ return True
466
+
467
+ def real_click_text(self, text, scope="", contains=True, exact_fallback=True,
468
+ y_min=None, y_max=None):
469
+ """按文本真实点击:JS 定位元素中心坐标 → CDP Input 派发鼠标事件序列。
470
+
471
+ qm-btn 一类自定义按钮只认真实事件序列(mousedown/mouseup/focus),
472
+ el.click() 对它们无效——建书「确认创建」/发章「立即发布」都栽在这。
473
+ y_min/y_max 限定元素纵向范围(区分同名 select、限制弹层内点击)。
474
+ 返回 {ok, tag?, via};找不到元素返回 {ok: False, err}。"""
475
+ r = self.call(
476
+ "(t,scope,c,y0,y1)=>{"
477
+ "const vis=e=>e.getBoundingClientRect().width>0;"
478
+ "const els=[...document.querySelectorAll(scope||'button,a,[role=button],span,li,[class*=btn]')].filter(vis);"
479
+ "let cands=els.filter(e=>{const x=(e.innerText||'').trim();"
480
+ "return x&&(c?x.includes(t):x===t);});"
481
+ "if(!cands.length&&c){cands=els.filter(e=>(e.innerText||'').trim()===t);}"
482
+ "if(y0!==null)cands=cands.filter(e=>e.getBoundingClientRect().y>=y0);"
483
+ "if(y1!==null)cands=cands.filter(e=>e.getBoundingClientRect().y<=y1);"
484
+ "if(!cands.length)return{ok:false,err:'nf'};"
485
+ "cands.sort((a,b)=>((a.innerText||'').trim().length)-((b.innerText||'').trim().length));"
486
+ "const el=cands[0];el.scrollIntoView({block:'center'});"
487
+ "const rc=el.getBoundingClientRect();"
488
+ "return{ok:true,x:Math.round(rc.x+rc.width/2),y:Math.round(rc.y+rc.height/2),"
489
+ "tag:el.tagName,cls:(el.className||'').toString().slice(0,30)};}",
490
+ str(text), scope or "", bool(contains),
491
+ int(y_min) if y_min is not None else None,
492
+ int(y_max) if y_max is not None else None)
493
+ if not (r or {}).get("ok"):
494
+ return {"ok": False, "err": "页面上找不到文本为「%s」的可点元素" % text}
495
+ x, y = r["x"], r["y"]
496
+ self.send("Input.dispatchMouseEvent",
497
+ {"type": "mousePressed", "x": x, "y": y,
498
+ "button": "left", "clickCount": 1}, timeout=8.0)
499
+ self.send("Input.dispatchMouseEvent",
500
+ {"type": "mouseReleased", "x": x, "y": y,
501
+ "button": "left", "clickCount": 1}, timeout=8.0)
502
+ return {"ok": True, "tag": r.get("tag"), "via": "input"}
503
+
504
+ def screenshot(self, fp):
505
+ """整页截图存证:发布每步之后落一张,出错可回看卡在哪一步。"""
506
+ r = self.send("Page.captureScreenshot", {"format": "png", "fromSurface": True})
507
+ import base64
508
+ fp = Path(fp)
509
+ fp.parent.mkdir(parents=True, exist_ok=True)
510
+ fp.write_bytes(base64.b64decode(r.get("data") or ""))
511
+ return str(fp)
512
+
513
+ def close(self):
514
+ try:
515
+ self.send("Page.close", timeout=3.0)
516
+ except Exception:
517
+ pass
518
+ self.ws.close()
519
+
520
+ # ------------------------------------------------------------ 表单探测
521
+ def probe(self):
522
+ """dump 页面上可交互元素的概要——校准流程选择器表的辅助工具。
523
+
524
+ 返回 [{tag, type, sel, text, value, placeholder}],sel 是尽量稳定的
525
+ 定位串(id/#id 优先,退而求其次 name/placeholder/文本/序号)。"""
526
+ return self.call(
527
+ "()=>{const out=[];"
528
+ "document.querySelectorAll('input,textarea,button,select,[contenteditable=true]').forEach(el=>{"
529
+ "let s='';"
530
+ "if(el.id)s='#'+el.id;"
531
+ "else if(el.name)s=el.tagName.toLowerCase()+'[name=\"'+el.name+'\"]';"
532
+ "else if(el.placeholder)s=el.tagName.toLowerCase()+'[placeholder=\"'+el.placeholder.slice(0,20)+'\"]';"
533
+ "else{const t=(el.innerText||el.value||'').trim().slice(0,12);"
534
+ "s=t?el.tagName.toLowerCase()+':contains('+t+')':el.tagName.toLowerCase();}"
535
+ "out.push({tag:el.tagName.toLowerCase(),type:el.type||'',sel:s,"
536
+ "text:(el.innerText||'').trim().slice(0,16),"
537
+ "value:(el.value||el.textContent||'').trim().slice(0,24),"
538
+ "placeholder:(el.placeholder||'').slice(0,20)});});"
539
+ "return out.slice(0,80);}", timeout=15.0) or []