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