codebee 0.1.62 → 0.1.63

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 (98) hide show
  1. package/CHANGELOG.md +499 -490
  2. package/LICENSE +21 -21
  3. package/README.md +484 -486
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/aiflavor.py +197 -197
  12. package/app/core/attachments.py +532 -532
  13. package/app/core/automation.py +758 -758
  14. package/app/core/backup.py +470 -470
  15. package/app/core/beekeeper.py +183 -183
  16. package/app/core/board.py +209 -209
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/builtin_agent.py +1191 -1191
  20. package/app/core/capability.py +144 -144
  21. package/app/core/catalog.py +19 -14
  22. package/app/core/chaptersafe.py +32 -32
  23. package/app/core/cleanup.py +303 -303
  24. package/app/core/compaction.py +186 -186
  25. package/app/core/covergen.py +356 -356
  26. package/app/core/diagnostics.py +114 -114
  27. package/app/core/dispatch.py +173 -173
  28. package/app/core/dispatch_log.py +113 -113
  29. package/app/core/env_scrub.py +85 -85
  30. package/app/core/error_codes.py +64 -64
  31. package/app/core/errorlog.py +137 -137
  32. package/app/core/flows.py +355 -355
  33. package/app/core/goal_service.py +167 -167
  34. package/app/core/health.py +341 -341
  35. package/app/core/history.py +38 -38
  36. package/app/core/hooks.py +156 -156
  37. package/app/core/jobs.py +1022 -1022
  38. package/app/core/manager.py +1996 -1996
  39. package/app/core/market.py +362 -362
  40. package/app/core/market_remote.py +66 -66
  41. package/app/core/mocks.py +64 -64
  42. package/app/core/modelhub.py +3266 -3266
  43. package/app/core/notify.py +106 -106
  44. package/app/core/paihang.py +109 -109
  45. package/app/core/paths.py +2 -2
  46. package/app/core/pipeline.py +4444 -4426
  47. package/app/core/planner.py +27 -27
  48. package/app/core/portguard.py +111 -111
  49. package/app/core/preview.py +293 -293
  50. package/app/core/publish/__init__.py +7 -7
  51. package/app/core/publish/auto.py +367 -367
  52. package/app/core/publish/browser.py +533 -533
  53. package/app/core/publish/fanqie.py +128 -128
  54. package/app/core/publish/flow.py +406 -406
  55. package/app/core/publish/ledger.py +198 -198
  56. package/app/core/publish/manager.py +590 -590
  57. package/app/core/publish/qimao.py +141 -141
  58. package/app/core/publish/ws.py +139 -139
  59. package/app/core/redact.py +38 -38
  60. package/app/core/repeat_guard.py +124 -124
  61. package/app/core/router.py +20 -3
  62. package/app/core/runner.py +2014 -1991
  63. package/app/core/selfupdate.py +384 -384
  64. package/app/core/session_log.py +161 -161
  65. package/app/core/sessions.py +312 -312
  66. package/app/core/settings.py +149 -149
  67. package/app/core/settings_schema.py +262 -262
  68. package/app/core/share_page.py +114 -114
  69. package/app/core/skill_scan.py +106 -106
  70. package/app/core/skillpacks/fanqie-novel.md +80 -80
  71. package/app/core/skillpacks/market/character-bible.md +66 -66
  72. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  73. package/app/core/skillpacks/market/git-workflow.md +57 -57
  74. package/app/core/skillpacks/market/release-notes.md +72 -72
  75. package/app/core/skillpacks/market/weekly-report.md +71 -71
  76. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  77. package/app/core/step_runner.py +132 -132
  78. package/app/core/task_compile.py +199 -199
  79. package/app/core/telemetry.py +292 -292
  80. package/app/core/tlsctx.py +64 -64
  81. package/app/core/token_meter.py +147 -147
  82. package/app/core/volumes.py +355 -355
  83. package/app/core/wxdigest.py +858 -858
  84. package/app/core/zentao.py +2090 -2090
  85. package/app/main.py +2826 -2826
  86. package/app/pick_dialog.py +110 -110
  87. package/app/ui/app.js +13977 -13977
  88. package/app/ui/board.html +162 -162
  89. package/app/ui/board.js +305 -305
  90. package/app/ui/i18n.js +2711 -2711
  91. package/app/ui/icons/bee.svg +79 -79
  92. package/app/ui/index.html +1 -1
  93. package/app/ui/manifest.json +16 -16
  94. package/app/ui/qrcode.js +2297 -2297
  95. package/app/ui/style.css +6125 -6125
  96. package/bin/tutti.js +147 -147
  97. package/package.json +41 -41
  98. package/tools/wx_reader.py +261 -261
@@ -1,533 +1,533 @@
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
- try:
346
- self.send("Page.navigate", {"url": url}, timeout=timeout)
347
- except BrowserError:
348
- pass # 老页面销毁时连接报错属正常,轮询兜底
349
- host = _up(url).netloc
350
- deadline = time.time() + timeout
351
- seen_url = False
352
- while time.time() < deadline:
353
- try:
354
- href = self.evaluate("location.href", timeout=3.0) or ""
355
- if not host or host in href:
356
- seen_url = True
357
- if seen_url and self.evaluate("document.readyState", timeout=3.0) == "complete":
358
- time.sleep(0.5) # SPA 首帧渲染余量
359
- return
360
- except BrowserError:
361
- pass # 导航间隙 evaluate 会短暂失败
362
- time.sleep(0.3)
363
- raise BrowserError("页面 %.0f 秒未完成加载:%s" % (timeout, url))
364
-
365
- def exists(self, sel, timeout=1.0):
366
- return bool(self.call("(s)=>!!document.querySelector(s)", sel, timeout=timeout))
367
-
368
- def wait_for(self, sel, timeout=15.0):
369
- deadline = time.time() + timeout
370
- while time.time() < deadline:
371
- if self.exists(sel, timeout=1.0):
372
- return
373
- time.sleep(0.4)
374
- raise BrowserError("等待元素超时(%.0fs):%s" % (timeout, sel))
375
-
376
- def value(self, sel):
377
- return self.call("(s)=>{const e=document.querySelector(s);"
378
- "return e?(e.value!==undefined?e.value:e.textContent):null}", sel)
379
-
380
- def fill(self, sel, text):
381
- """填输入框/文本域/富文本。
382
-
383
- React/Vue 受控组件直接赋 value 不触发框架状态,必须走原型链 native
384
- setter 再补 input/change 事件;富文本(章节正文)走 CDP
385
- Input.insertText——execCommand('insertText') 对千字长文会截断
386
- (真机实测 1020 字只进 615),insertText 走完整输入管道无此限。"""
387
- text = str(text)
388
- ce = None
389
- for _try in range(24): # 编辑器慢加载 + class 动态 + edit-mask
390
- ce = self.call( # 遮罩需真实点击激活后才可编辑
391
- "(s)=>{const els=[...document.querySelectorAll(s)];"
392
- "let best=null;for(const e of els){"
393
- "if(!e.isContentEditable)continue;"
394
- "const r=e.getBoundingClientRect();"
395
- "if(r.width<=0)continue;"
396
- "const a=r.width*r.height;"
397
- "if(!best||a>best.a)best={e,a};}"
398
- "if(best){"
399
- "const el=best.e;"
400
- "el.scrollIntoView({block:'center'});el.focus();"
401
- "const r=document.createRange();r.selectNodeContents(el);"
402
- "const g=getSelection();g.removeAllRanges();g.addRange(r);return {hit:true};}"
403
- "const vis=els.filter(e=>e.getBoundingClientRect().width>0);"
404
- "if(!vis.length)return null;"
405
- "let big=vis[0];for(const e of vis){"
406
- "const r=e.getBoundingClientRect();"
407
- "if(r.width*r.height>big.getBoundingClientRect().width*big.getBoundingClientRect().height)big=e;}"
408
- "const r=big.getBoundingClientRect();"
409
- "return {hit:false,x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};}", sel)
410
- if ce is None:
411
- break
412
- if isinstance(ce, dict) and ce.get("hit"):
413
- ce = True
414
- break
415
- if isinstance(ce, dict) and "x" in ce: # 遮罩未揭:真实点击激活
416
- self.send("Input.dispatchMouseEvent",
417
- {"type": "mousePressed", "x": ce["x"], "y": ce["y"],
418
- "button": "left", "clickCount": 1}, timeout=8.0)
419
- self.send("Input.dispatchMouseEvent",
420
- {"type": "mouseReleased", "x": ce["x"], "y": ce["y"],
421
- "button": "left", "clickCount": 1}, timeout=8.0)
422
- ce = False
423
- time.sleep(0.8)
424
- continue
425
- ce = False
426
- time.sleep(0.5)
427
- if ce is True: # 富文本:焦点就位后键盘级插入
428
- self.send("Input.insertText", {"text": text}, timeout=30.0)
429
- n = self.call("(s)=>{const els=[...document.querySelectorAll(s)].filter(e=>e.isContentEditable);"
430
- "let n=0;for(const e of els)n=Math.max(n,(e.innerText||'').length);return n;}", sel)
431
- if int(n or 0) < min(len(text), 20):
432
- raise BrowserError("富文本插入不完整(%s/%d 字)" % (n, len(text)))
433
- return True
434
- if ce is None:
435
- raise BrowserError("找不到输入框 %s" % sel)
436
- r = self.call(
437
- "(s,t)=>{const el=document.querySelector(s);if(!el)"
438
- "return{ok:false,err:'找不到输入框 '+s};"
439
- "el.scrollIntoView({block:'center'});el.focus();"
440
- "const proto=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype"
441
- ":HTMLInputElement.prototype;"
442
- "const d=Object.getOwnPropertyDescriptor(proto,'value');"
443
- "(d&&d.set?d.set:function(v){el.value=v}).call(el,t);"
444
- "el.dispatchEvent(new Event('input',{bubbles:true}));"
445
- "el.dispatchEvent(new Event('change',{bubbles:true}));"
446
- "return{ok:true};}", sel, text)
447
- if not (r or {}).get("ok"):
448
- raise BrowserError("填入失败:%s" % ((r or {}).get("err") or "目标不是可输入元素"))
449
- return True
450
-
451
- def click(self, sel):
452
- r = self.call(
453
- "(s)=>{const el=document.querySelector(s);if(!el)"
454
- "return{ok:false,err:'找不到 '+s};"
455
- "if(el.disabled)return{ok:false,err:'按钮处于禁用态(表单可能有未通过校验的字段)'};"
456
- "el.scrollIntoView({block:'center'});el.click();return{ok:true};}", sel)
457
- if not (r or {}).get("ok"):
458
- raise BrowserError("点击失败:%s" % ((r or {}).get("err") or sel))
459
- return True
460
-
461
- def real_click_text(self, text, scope="", contains=True, exact_fallback=True,
462
- y_min=None, y_max=None):
463
- """按文本真实点击:JS 定位元素中心坐标 → CDP Input 派发鼠标事件序列。
464
-
465
- qm-btn 一类自定义按钮只认真实事件序列(mousedown/mouseup/focus),
466
- el.click() 对它们无效——建书「确认创建」/发章「立即发布」都栽在这。
467
- y_min/y_max 限定元素纵向范围(区分同名 select、限制弹层内点击)。
468
- 返回 {ok, tag?, via};找不到元素返回 {ok: False, err}。"""
469
- r = self.call(
470
- "(t,scope,c,y0,y1)=>{"
471
- "const vis=e=>e.getBoundingClientRect().width>0;"
472
- "const els=[...document.querySelectorAll(scope||'button,a,[role=button],span,li,[class*=btn]')].filter(vis);"
473
- "let cands=els.filter(e=>{const x=(e.innerText||'').trim();"
474
- "return x&&(c?x.includes(t):x===t);});"
475
- "if(!cands.length&&c){cands=els.filter(e=>(e.innerText||'').trim()===t);}"
476
- "if(y0!==null)cands=cands.filter(e=>e.getBoundingClientRect().y>=y0);"
477
- "if(y1!==null)cands=cands.filter(e=>e.getBoundingClientRect().y<=y1);"
478
- "if(!cands.length)return{ok:false,err:'nf'};"
479
- "cands.sort((a,b)=>((a.innerText||'').trim().length)-((b.innerText||'').trim().length));"
480
- "const el=cands[0];el.scrollIntoView({block:'center'});"
481
- "const rc=el.getBoundingClientRect();"
482
- "return{ok:true,x:Math.round(rc.x+rc.width/2),y:Math.round(rc.y+rc.height/2),"
483
- "tag:el.tagName,cls:(el.className||'').toString().slice(0,30)};}",
484
- str(text), scope or "", bool(contains),
485
- int(y_min) if y_min is not None else None,
486
- int(y_max) if y_max is not None else None)
487
- if not (r or {}).get("ok"):
488
- return {"ok": False, "err": "页面上找不到文本为「%s」的可点元素" % text}
489
- x, y = r["x"], r["y"]
490
- self.send("Input.dispatchMouseEvent",
491
- {"type": "mousePressed", "x": x, "y": y,
492
- "button": "left", "clickCount": 1}, timeout=8.0)
493
- self.send("Input.dispatchMouseEvent",
494
- {"type": "mouseReleased", "x": x, "y": y,
495
- "button": "left", "clickCount": 1}, timeout=8.0)
496
- return {"ok": True, "tag": r.get("tag"), "via": "input"}
497
-
498
- def screenshot(self, fp):
499
- """整页截图存证:发布每步之后落一张,出错可回看卡在哪一步。"""
500
- r = self.send("Page.captureScreenshot", {"format": "png", "fromSurface": True})
501
- import base64
502
- fp = Path(fp)
503
- fp.parent.mkdir(parents=True, exist_ok=True)
504
- fp.write_bytes(base64.b64decode(r.get("data") or ""))
505
- return str(fp)
506
-
507
- def close(self):
508
- try:
509
- self.send("Page.close", timeout=3.0)
510
- except Exception:
511
- pass
512
- self.ws.close()
513
-
514
- # ------------------------------------------------------------ 表单探测
515
- def probe(self):
516
- """dump 页面上可交互元素的概要——校准流程选择器表的辅助工具。
517
-
518
- 返回 [{tag, type, sel, text, value, placeholder}],sel 是尽量稳定的
519
- 定位串(id/#id 优先,退而求其次 name/placeholder/文本/序号)。"""
520
- return self.call(
521
- "()=>{const out=[];"
522
- "document.querySelectorAll('input,textarea,button,select,[contenteditable=true]').forEach(el=>{"
523
- "let s='';"
524
- "if(el.id)s='#'+el.id;"
525
- "else if(el.name)s=el.tagName.toLowerCase()+'[name=\"'+el.name+'\"]';"
526
- "else if(el.placeholder)s=el.tagName.toLowerCase()+'[placeholder=\"'+el.placeholder.slice(0,20)+'\"]';"
527
- "else{const t=(el.innerText||el.value||'').trim().slice(0,12);"
528
- "s=t?el.tagName.toLowerCase()+':contains('+t+')':el.tagName.toLowerCase();}"
529
- "out.push({tag:el.tagName.toLowerCase(),type:el.type||'',sel:s,"
530
- "text:(el.innerText||'').trim().slice(0,16),"
531
- "value:(el.value||el.textContent||'').trim().slice(0,24),"
532
- "placeholder:(el.placeholder||'').slice(0,20)});});"
533
- "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
+ try:
346
+ self.send("Page.navigate", {"url": url}, timeout=timeout)
347
+ except BrowserError:
348
+ pass # 老页面销毁时连接报错属正常,轮询兜底
349
+ host = _up(url).netloc
350
+ deadline = time.time() + timeout
351
+ seen_url = False
352
+ while time.time() < deadline:
353
+ try:
354
+ href = self.evaluate("location.href", timeout=3.0) or ""
355
+ if not host or host in href:
356
+ seen_url = True
357
+ if seen_url and self.evaluate("document.readyState", timeout=3.0) == "complete":
358
+ time.sleep(0.5) # SPA 首帧渲染余量
359
+ return
360
+ except BrowserError:
361
+ pass # 导航间隙 evaluate 会短暂失败
362
+ time.sleep(0.3)
363
+ raise BrowserError("页面 %.0f 秒未完成加载:%s" % (timeout, url))
364
+
365
+ def exists(self, sel, timeout=1.0):
366
+ return bool(self.call("(s)=>!!document.querySelector(s)", sel, timeout=timeout))
367
+
368
+ def wait_for(self, sel, timeout=15.0):
369
+ deadline = time.time() + timeout
370
+ while time.time() < deadline:
371
+ if self.exists(sel, timeout=1.0):
372
+ return
373
+ time.sleep(0.4)
374
+ raise BrowserError("等待元素超时(%.0fs):%s" % (timeout, sel))
375
+
376
+ def value(self, sel):
377
+ return self.call("(s)=>{const e=document.querySelector(s);"
378
+ "return e?(e.value!==undefined?e.value:e.textContent):null}", sel)
379
+
380
+ def fill(self, sel, text):
381
+ """填输入框/文本域/富文本。
382
+
383
+ React/Vue 受控组件直接赋 value 不触发框架状态,必须走原型链 native
384
+ setter 再补 input/change 事件;富文本(章节正文)走 CDP
385
+ Input.insertText——execCommand('insertText') 对千字长文会截断
386
+ (真机实测 1020 字只进 615),insertText 走完整输入管道无此限。"""
387
+ text = str(text)
388
+ ce = None
389
+ for _try in range(24): # 编辑器慢加载 + class 动态 + edit-mask
390
+ ce = self.call( # 遮罩需真实点击激活后才可编辑
391
+ "(s)=>{const els=[...document.querySelectorAll(s)];"
392
+ "let best=null;for(const e of els){"
393
+ "if(!e.isContentEditable)continue;"
394
+ "const r=e.getBoundingClientRect();"
395
+ "if(r.width<=0)continue;"
396
+ "const a=r.width*r.height;"
397
+ "if(!best||a>best.a)best={e,a};}"
398
+ "if(best){"
399
+ "const el=best.e;"
400
+ "el.scrollIntoView({block:'center'});el.focus();"
401
+ "const r=document.createRange();r.selectNodeContents(el);"
402
+ "const g=getSelection();g.removeAllRanges();g.addRange(r);return {hit:true};}"
403
+ "const vis=els.filter(e=>e.getBoundingClientRect().width>0);"
404
+ "if(!vis.length)return null;"
405
+ "let big=vis[0];for(const e of vis){"
406
+ "const r=e.getBoundingClientRect();"
407
+ "if(r.width*r.height>big.getBoundingClientRect().width*big.getBoundingClientRect().height)big=e;}"
408
+ "const r=big.getBoundingClientRect();"
409
+ "return {hit:false,x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};}", sel)
410
+ if ce is None:
411
+ break
412
+ if isinstance(ce, dict) and ce.get("hit"):
413
+ ce = True
414
+ break
415
+ if isinstance(ce, dict) and "x" in ce: # 遮罩未揭:真实点击激活
416
+ self.send("Input.dispatchMouseEvent",
417
+ {"type": "mousePressed", "x": ce["x"], "y": ce["y"],
418
+ "button": "left", "clickCount": 1}, timeout=8.0)
419
+ self.send("Input.dispatchMouseEvent",
420
+ {"type": "mouseReleased", "x": ce["x"], "y": ce["y"],
421
+ "button": "left", "clickCount": 1}, timeout=8.0)
422
+ ce = False
423
+ time.sleep(0.8)
424
+ continue
425
+ ce = False
426
+ time.sleep(0.5)
427
+ if ce is True: # 富文本:焦点就位后键盘级插入
428
+ self.send("Input.insertText", {"text": text}, timeout=30.0)
429
+ n = self.call("(s)=>{const els=[...document.querySelectorAll(s)].filter(e=>e.isContentEditable);"
430
+ "let n=0;for(const e of els)n=Math.max(n,(e.innerText||'').length);return n;}", sel)
431
+ if int(n or 0) < min(len(text), 20):
432
+ raise BrowserError("富文本插入不完整(%s/%d 字)" % (n, len(text)))
433
+ return True
434
+ if ce is None:
435
+ raise BrowserError("找不到输入框 %s" % sel)
436
+ r = self.call(
437
+ "(s,t)=>{const el=document.querySelector(s);if(!el)"
438
+ "return{ok:false,err:'找不到输入框 '+s};"
439
+ "el.scrollIntoView({block:'center'});el.focus();"
440
+ "const proto=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype"
441
+ ":HTMLInputElement.prototype;"
442
+ "const d=Object.getOwnPropertyDescriptor(proto,'value');"
443
+ "(d&&d.set?d.set:function(v){el.value=v}).call(el,t);"
444
+ "el.dispatchEvent(new Event('input',{bubbles:true}));"
445
+ "el.dispatchEvent(new Event('change',{bubbles:true}));"
446
+ "return{ok:true};}", sel, text)
447
+ if not (r or {}).get("ok"):
448
+ raise BrowserError("填入失败:%s" % ((r or {}).get("err") or "目标不是可输入元素"))
449
+ return True
450
+
451
+ def click(self, sel):
452
+ r = self.call(
453
+ "(s)=>{const el=document.querySelector(s);if(!el)"
454
+ "return{ok:false,err:'找不到 '+s};"
455
+ "if(el.disabled)return{ok:false,err:'按钮处于禁用态(表单可能有未通过校验的字段)'};"
456
+ "el.scrollIntoView({block:'center'});el.click();return{ok:true};}", sel)
457
+ if not (r or {}).get("ok"):
458
+ raise BrowserError("点击失败:%s" % ((r or {}).get("err") or sel))
459
+ return True
460
+
461
+ def real_click_text(self, text, scope="", contains=True, exact_fallback=True,
462
+ y_min=None, y_max=None):
463
+ """按文本真实点击:JS 定位元素中心坐标 → CDP Input 派发鼠标事件序列。
464
+
465
+ qm-btn 一类自定义按钮只认真实事件序列(mousedown/mouseup/focus),
466
+ el.click() 对它们无效——建书「确认创建」/发章「立即发布」都栽在这。
467
+ y_min/y_max 限定元素纵向范围(区分同名 select、限制弹层内点击)。
468
+ 返回 {ok, tag?, via};找不到元素返回 {ok: False, err}。"""
469
+ r = self.call(
470
+ "(t,scope,c,y0,y1)=>{"
471
+ "const vis=e=>e.getBoundingClientRect().width>0;"
472
+ "const els=[...document.querySelectorAll(scope||'button,a,[role=button],span,li,[class*=btn]')].filter(vis);"
473
+ "let cands=els.filter(e=>{const x=(e.innerText||'').trim();"
474
+ "return x&&(c?x.includes(t):x===t);});"
475
+ "if(!cands.length&&c){cands=els.filter(e=>(e.innerText||'').trim()===t);}"
476
+ "if(y0!==null)cands=cands.filter(e=>e.getBoundingClientRect().y>=y0);"
477
+ "if(y1!==null)cands=cands.filter(e=>e.getBoundingClientRect().y<=y1);"
478
+ "if(!cands.length)return{ok:false,err:'nf'};"
479
+ "cands.sort((a,b)=>((a.innerText||'').trim().length)-((b.innerText||'').trim().length));"
480
+ "const el=cands[0];el.scrollIntoView({block:'center'});"
481
+ "const rc=el.getBoundingClientRect();"
482
+ "return{ok:true,x:Math.round(rc.x+rc.width/2),y:Math.round(rc.y+rc.height/2),"
483
+ "tag:el.tagName,cls:(el.className||'').toString().slice(0,30)};}",
484
+ str(text), scope or "", bool(contains),
485
+ int(y_min) if y_min is not None else None,
486
+ int(y_max) if y_max is not None else None)
487
+ if not (r or {}).get("ok"):
488
+ return {"ok": False, "err": "页面上找不到文本为「%s」的可点元素" % text}
489
+ x, y = r["x"], r["y"]
490
+ self.send("Input.dispatchMouseEvent",
491
+ {"type": "mousePressed", "x": x, "y": y,
492
+ "button": "left", "clickCount": 1}, timeout=8.0)
493
+ self.send("Input.dispatchMouseEvent",
494
+ {"type": "mouseReleased", "x": x, "y": y,
495
+ "button": "left", "clickCount": 1}, timeout=8.0)
496
+ return {"ok": True, "tag": r.get("tag"), "via": "input"}
497
+
498
+ def screenshot(self, fp):
499
+ """整页截图存证:发布每步之后落一张,出错可回看卡在哪一步。"""
500
+ r = self.send("Page.captureScreenshot", {"format": "png", "fromSurface": True})
501
+ import base64
502
+ fp = Path(fp)
503
+ fp.parent.mkdir(parents=True, exist_ok=True)
504
+ fp.write_bytes(base64.b64decode(r.get("data") or ""))
505
+ return str(fp)
506
+
507
+ def close(self):
508
+ try:
509
+ self.send("Page.close", timeout=3.0)
510
+ except Exception:
511
+ pass
512
+ self.ws.close()
513
+
514
+ # ------------------------------------------------------------ 表单探测
515
+ def probe(self):
516
+ """dump 页面上可交互元素的概要——校准流程选择器表的辅助工具。
517
+
518
+ 返回 [{tag, type, sel, text, value, placeholder}],sel 是尽量稳定的
519
+ 定位串(id/#id 优先,退而求其次 name/placeholder/文本/序号)。"""
520
+ return self.call(
521
+ "()=>{const out=[];"
522
+ "document.querySelectorAll('input,textarea,button,select,[contenteditable=true]').forEach(el=>{"
523
+ "let s='';"
524
+ "if(el.id)s='#'+el.id;"
525
+ "else if(el.name)s=el.tagName.toLowerCase()+'[name=\"'+el.name+'\"]';"
526
+ "else if(el.placeholder)s=el.tagName.toLowerCase()+'[placeholder=\"'+el.placeholder.slice(0,20)+'\"]';"
527
+ "else{const t=(el.innerText||el.value||'').trim().slice(0,12);"
528
+ "s=t?el.tagName.toLowerCase()+':contains('+t+')':el.tagName.toLowerCase();}"
529
+ "out.push({tag:el.tagName.toLowerCase(),type:el.type||'',sel:s,"
530
+ "text:(el.innerText||'').trim().slice(0,16),"
531
+ "value:(el.value||el.textContent||'').trim().slice(0,24),"
532
+ "placeholder:(el.placeholder||'').slice(0,20)});});"
533
+ "return out.slice(0,80);}", timeout=15.0) or []