moonlighting 0.1.0__py3-none-any.whl
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.
- auth.py +65 -0
- browser_daemon.py +213 -0
- cdp_click.py +160 -0
- cred_dialog.py +265 -0
- desk_browser.py +198 -0
- desk_chrome.py +258 -0
- desk_restore.py +84 -0
- desk_stream.py +648 -0
- ext_click.py +152 -0
- login_flow.py +122 -0
- mcp_server.py +250 -0
- moonlighting-0.1.0.data/data/share/moonlighting/cred_form.html +113 -0
- moonlighting-0.1.0.data/data/share/moonlighting/templates/desk_ctl.html +106 -0
- moonlighting-0.1.0.data/data/share/moonlighting/templates/desk_view.html +77 -0
- moonlighting-0.1.0.dist-info/METADATA +240 -0
- moonlighting-0.1.0.dist-info/RECORD +28 -0
- moonlighting-0.1.0.dist-info/WHEEL +5 -0
- moonlighting-0.1.0.dist-info/entry_points.txt +5 -0
- moonlighting-0.1.0.dist-info/licenses/LICENSE +21 -0
- moonlighting-0.1.0.dist-info/top_level.txt +19 -0
- ports.py +169 -0
- private_desktop.py +212 -0
- qclick.py +132 -0
- session.py +176 -0
- setup.py +154 -0
- svc.py +153 -0
- vault_broker.py +663 -0
- wclick.py +131 -0
auth.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""本地服务的访问令牌:挡住同机其他进程乱调。
|
|
2
|
+
|
|
3
|
+
问题:8899 上跑着凭据代理,任何本机进程都能 `GET /inject_sel?handle=...` 让浏览器
|
|
4
|
+
把密码填进一个 origin 相同的页面(同源伪造),8897/8765 同理能驱动浏览器和注入按键。
|
|
5
|
+
|
|
6
|
+
做法:
|
|
7
|
+
* 首次启动生成 32 字节随机 token,写到 `<home>/token.txt`
|
|
8
|
+
* 所有**会改变状态**的接口要求 `X-Hermes-Token` 头(或 `?token=`)
|
|
9
|
+
* 只读接口(/snapshot、/status、/list 的元信息)不强制,方便看画面
|
|
10
|
+
* 客户端(cred_dialog、login_flow)自己从同一个文件读,用户无感
|
|
11
|
+
|
|
12
|
+
环境变量 `HERMES_TOKEN` 优先,便于 CI/临时覆盖。
|
|
13
|
+
"""
|
|
14
|
+
import os
|
|
15
|
+
import secrets
|
|
16
|
+
|
|
17
|
+
HOME = os.environ.get("HERMES_BROWSER_HOME") or os.path.join(
|
|
18
|
+
os.environ.get("LOCALAPPDATA") or os.path.expanduser("~"), "hermes-browser")
|
|
19
|
+
try: # 装了 setup.py 就跟着它走
|
|
20
|
+
import ports as _ports
|
|
21
|
+
HOME = _ports.home()
|
|
22
|
+
except Exception: # noqa: BLE001
|
|
23
|
+
pass
|
|
24
|
+
TOKEN_FILE = os.environ.get("HERMES_TOKEN_FILE") or os.path.join(HOME, "token.txt")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def token():
|
|
28
|
+
env = os.environ.get("HERMES_TOKEN")
|
|
29
|
+
if env:
|
|
30
|
+
return env
|
|
31
|
+
try:
|
|
32
|
+
with open(TOKEN_FILE, encoding="utf-8") as f:
|
|
33
|
+
t = f.read().strip()
|
|
34
|
+
if t:
|
|
35
|
+
return t
|
|
36
|
+
except OSError:
|
|
37
|
+
pass
|
|
38
|
+
os.makedirs(os.path.dirname(TOKEN_FILE), exist_ok=True)
|
|
39
|
+
t = secrets.token_urlsafe(24)
|
|
40
|
+
with open(TOKEN_FILE, "w", encoding="utf-8") as f:
|
|
41
|
+
f.write(t)
|
|
42
|
+
try:
|
|
43
|
+
os.chmod(TOKEN_FILE, 0o600)
|
|
44
|
+
except OSError:
|
|
45
|
+
pass
|
|
46
|
+
return t
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check(headers, query):
|
|
50
|
+
"""headers: email.Message;query: dict[str, list[str]] 或 dict[str,str]"""
|
|
51
|
+
want = token()
|
|
52
|
+
got = headers.get("X-Hermes-Token") if headers is not None else None
|
|
53
|
+
if not got and query:
|
|
54
|
+
v = query.get("token")
|
|
55
|
+
got = (v[0] if isinstance(v, list) else v) if v else None
|
|
56
|
+
return bool(got) and secrets.compare_digest(str(got), want)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def headers_for_client():
|
|
60
|
+
return {"X-Hermes-Token": token()}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
print("token 文件:", TOKEN_FILE)
|
|
65
|
+
print("token:", token()[:8] + "…(已写入文件,不完整打印)")
|
browser_daemon.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""常驻浏览器驱动:一个 MCP 会话 + 本地 HTTP 接口,按 ref 操作,不用坐标和选择器。
|
|
2
|
+
|
|
3
|
+
为什么这么做:
|
|
4
|
+
- Playwright MCP 的 browser_snapshot 返回完整无障碍树,每个可交互元素带 [ref=e42],
|
|
5
|
+
一次调用就能看清整页,不用逐个 querySelector 试。
|
|
6
|
+
- ref 在「同一个 MCP 会话」里有效,所以必须常驻一个进程,不能每次动作都新起一个。
|
|
7
|
+
|
|
8
|
+
启动:
|
|
9
|
+
python browser_daemon.py --cdp 18763 --port 8897
|
|
10
|
+
|
|
11
|
+
接口:
|
|
12
|
+
GET /snap 完整快照(含标签页列表、ref)
|
|
13
|
+
GET /click?target=e42&element=按钮描述
|
|
14
|
+
GET /type?target=e12&text=hello&element=输入框
|
|
15
|
+
GET /goto?url=https://example.com
|
|
16
|
+
GET /eval?js=location.href
|
|
17
|
+
GET /find?text=登录
|
|
18
|
+
GET /status
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import urllib.request
|
|
24
|
+
import subprocess
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
import os
|
|
28
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
29
|
+
from urllib.parse import urlparse, parse_qs
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
import auth as _auth
|
|
33
|
+
except ImportError: # 单独拷走也能跑,只是没鉴权
|
|
34
|
+
class _auth: # noqa: N801
|
|
35
|
+
@staticmethod
|
|
36
|
+
def check(_h, _q):
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def headers_for_client():
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
LOCK = threading.Lock()
|
|
44
|
+
PROC = None
|
|
45
|
+
MID = [0]
|
|
46
|
+
STATE = {"ready": False, "calls": 0, "started": 0.0}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _send(obj):
|
|
50
|
+
PROC.stdin.write(json.dumps(obj) + "\n")
|
|
51
|
+
PROC.stdin.flush()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _read(timeout=120):
|
|
55
|
+
end = time.time() + timeout
|
|
56
|
+
while time.time() < end:
|
|
57
|
+
line = PROC.stdout.readline()
|
|
58
|
+
if not line:
|
|
59
|
+
time.sleep(0.02)
|
|
60
|
+
continue
|
|
61
|
+
line = line.strip()
|
|
62
|
+
if not line:
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
return json.loads(line)
|
|
66
|
+
except json.JSONDecodeError:
|
|
67
|
+
continue
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def mcp(method, params, timeout=120):
|
|
72
|
+
"""串行调用 MCP,返回 result 里的文本。"""
|
|
73
|
+
with LOCK:
|
|
74
|
+
MID[0] += 1
|
|
75
|
+
mid = MID[0]
|
|
76
|
+
_send({"jsonrpc": "2.0", "id": mid, "method": method, "params": params})
|
|
77
|
+
while True:
|
|
78
|
+
m = _read(timeout)
|
|
79
|
+
if m is None:
|
|
80
|
+
return None
|
|
81
|
+
if m.get("id") == mid:
|
|
82
|
+
return m
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def tool(name, args, timeout=120):
|
|
86
|
+
r = mcp("tools/call", {"name": name, "arguments": args}, timeout)
|
|
87
|
+
if not r:
|
|
88
|
+
return "MCP 超时"
|
|
89
|
+
if "error" in r:
|
|
90
|
+
return f"MCP 错误: {r['error']}"
|
|
91
|
+
res = r.get("result", {})
|
|
92
|
+
text = "\n".join(c.get("text", "") for c in (res.get("content") or []) if isinstance(c, dict))
|
|
93
|
+
if res.get("isError"):
|
|
94
|
+
return "工具报错:\n" + text
|
|
95
|
+
return text
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def start(cdp, extra):
|
|
99
|
+
global PROC
|
|
100
|
+
# snapshot-mode=none:动作后不再附带整页快照(GitHub 这种大页面能省 2s);
|
|
101
|
+
# timeout-settle=250:动作后只等 250ms 让页面安定。需要看状态时我自己调 /snap。
|
|
102
|
+
cmd = ["npx.cmd", "-y", "@playwright/mcp@latest",
|
|
103
|
+
"--cdp-endpoint", f"http://127.0.0.1:{cdp}",
|
|
104
|
+
"--snapshot-mode", "none", "--timeout-settle", "250"] + extra
|
|
105
|
+
PROC = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
106
|
+
stderr=subprocess.DEVNULL, text=True, encoding="utf-8",
|
|
107
|
+
errors="replace", bufsize=1)
|
|
108
|
+
r = mcp("initialize", {"protocolVersion": "2024-11-05", "capabilities": {},
|
|
109
|
+
"clientInfo": {"name": "hermes-browser", "version": "1.0"}}, 90)
|
|
110
|
+
if not r or "result" not in r:
|
|
111
|
+
raise SystemExit(f"初始化失败: {r}")
|
|
112
|
+
_send({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
|
|
113
|
+
STATE["ready"] = True
|
|
114
|
+
STATE["started"] = time.time()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def sync_preview(url):
|
|
118
|
+
"""让推流页面跟着我操作的标签走(私有桌面上无法真正切换可见标签)。"""
|
|
119
|
+
try:
|
|
120
|
+
import ports as _p
|
|
121
|
+
host = (url or "").split("//")[-1].split("/")[0]
|
|
122
|
+
if not host:
|
|
123
|
+
return
|
|
124
|
+
q = urllib.parse.urlencode({"url": host, "token": _auth.token() if hasattr(_auth, "token") else ""})
|
|
125
|
+
urllib.request.urlopen(f"http://127.0.0.1:{_p.port_of('stream')}/focus?{q}", timeout=3).read()
|
|
126
|
+
except Exception: # noqa: BLE001
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class H(BaseHTTPRequestHandler):
|
|
131
|
+
def log_message(self, *a):
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
def _out(self, text, code=200):
|
|
135
|
+
body = text.encode("utf-8", "replace")
|
|
136
|
+
self.send_response(code)
|
|
137
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
138
|
+
self.send_header("Content-Length", str(len(body)))
|
|
139
|
+
self.send_header("Cache-Control", "no-store")
|
|
140
|
+
self.end_headers()
|
|
141
|
+
self.wfile.write(body)
|
|
142
|
+
|
|
143
|
+
def do_GET(self):
|
|
144
|
+
u = urlparse(self.path)
|
|
145
|
+
q = {k: v[0] for k, v in parse_qs(u.query).items()}
|
|
146
|
+
# 除 /status 外都要 token:这个进程能直接驱动浏览器
|
|
147
|
+
if u.path != "/status" and not _auth.check(self.headers, q):
|
|
148
|
+
return self._out("401 unauthorized: 缺少或错误的 X-Hermes-Token", 401)
|
|
149
|
+
t0 = time.time()
|
|
150
|
+
try:
|
|
151
|
+
if u.path == "/status":
|
|
152
|
+
return self._out(json.dumps({"ready": STATE["ready"], "calls": STATE["calls"],
|
|
153
|
+
"uptime": round(time.time() - STATE["started"], 1)}))
|
|
154
|
+
if u.path == "/snap":
|
|
155
|
+
text = tool("browser_snapshot", {})
|
|
156
|
+
elif u.path == "/click":
|
|
157
|
+
text = tool("browser_click", {"target": q.get("target", ""),
|
|
158
|
+
"element": q.get("element", "")})
|
|
159
|
+
elif u.path == "/type":
|
|
160
|
+
text = tool("browser_type", {"target": q.get("target", ""),
|
|
161
|
+
"text": q.get("text", ""),
|
|
162
|
+
"element": q.get("element", "")})
|
|
163
|
+
elif u.path == "/goto":
|
|
164
|
+
text = tool("browser_navigate", {"url": q.get("url", "")})
|
|
165
|
+
sync_preview(q.get("url", ""))
|
|
166
|
+
elif u.path == "/eval":
|
|
167
|
+
text = tool("browser_evaluate", {"function": q.get("js", "() => 1")})
|
|
168
|
+
elif u.path == "/find":
|
|
169
|
+
text = tool("browser_find", {"query": q.get("text", "")})
|
|
170
|
+
elif u.path == "/key":
|
|
171
|
+
text = tool("browser_press_key", {"key": q.get("key", "Enter")})
|
|
172
|
+
elif u.path == "/tool":
|
|
173
|
+
args = json.loads(q.get("args", "{}"))
|
|
174
|
+
text = tool(q.get("name", ""), args)
|
|
175
|
+
elif u.path == "/back":
|
|
176
|
+
text = tool("browser_navigate_back", {})
|
|
177
|
+
elif u.path == "/tabs":
|
|
178
|
+
text = tool("browser_snapshot", {})
|
|
179
|
+
text = text.split("### Page")[0]
|
|
180
|
+
else:
|
|
181
|
+
return self._out("未知接口", 404)
|
|
182
|
+
STATE["calls"] += 1
|
|
183
|
+
dt = (time.time() - t0) * 1000
|
|
184
|
+
return self._out(f"[{dt:.0f}ms]\n{text}")
|
|
185
|
+
except Exception as e: # noqa: BLE001
|
|
186
|
+
return self._out(f"异常: {e}", 500)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def main():
|
|
190
|
+
ap = argparse.ArgumentParser()
|
|
191
|
+
ap.add_argument("--cdp", type=int, default=int(os.environ.get("HERMES_CDP_PORT", "18763")))
|
|
192
|
+
ap.add_argument("--port", type=int, default=int(os.environ.get("HERMES_DRIVER_PORT", "8897")))
|
|
193
|
+
ap.add_argument("--snapshot-boxes", action="store_true", help="快照里带坐标框")
|
|
194
|
+
a = ap.parse_args()
|
|
195
|
+
extra = ["--snapshot-boxes"] if a.snapshot_boxes else []
|
|
196
|
+
start(a.cdp, extra)
|
|
197
|
+
print(f"[browser] MCP 已连上 CDP {a.cdp},接口 http://127.0.0.1:{a.port}/snap", flush=True)
|
|
198
|
+
# 端口被占就自动换(直接启动也要有这层)
|
|
199
|
+
port = a.port
|
|
200
|
+
try:
|
|
201
|
+
srv = ThreadingHTTPServer(("127.0.0.1", port), H)
|
|
202
|
+
except OSError:
|
|
203
|
+
import socket as _s
|
|
204
|
+
with _s.socket() as probe:
|
|
205
|
+
probe.bind(("127.0.0.1", 0))
|
|
206
|
+
port = probe.getsockname()[1]
|
|
207
|
+
print(f"[port] {a.port} 已被占用,改用 {port}", flush=True)
|
|
208
|
+
srv = ThreadingHTTPServer(("127.0.0.1", port), H)
|
|
209
|
+
srv.serve_forever()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
if __name__ == "__main__":
|
|
213
|
+
main()
|
cdp_click.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""按文字点击页面元素(CDP 真实鼠标事件 + 光标同步)。
|
|
2
|
+
|
|
3
|
+
用法: python cdp_click.py <port> "<文字>"
|
|
4
|
+
先跑 AX/DOM 找到元素 → 换算窗口坐标 → /cursor 画光标 → 派发 mouseMoved/Pressed/Released
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import asyncio
|
|
8
|
+
import ctypes
|
|
9
|
+
import ctypes.wintypes as w
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
|
|
15
|
+
import websockets
|
|
16
|
+
|
|
17
|
+
PORT = int(sys.argv[1])
|
|
18
|
+
TEXT = sys.argv[2]
|
|
19
|
+
STREAM = os.environ.get("HERMES_STREAM", "http://127.0.0.1:8765")
|
|
20
|
+
DESKTOP = os.environ.get("HERMES_DESK", "HermesDesk")
|
|
21
|
+
|
|
22
|
+
FIND_JS = """
|
|
23
|
+
(() => {
|
|
24
|
+
const t = %s;
|
|
25
|
+
const hits = [];
|
|
26
|
+
const rec = (root) => {
|
|
27
|
+
for (const el of root.querySelectorAll('*')) {
|
|
28
|
+
if (el.shadowRoot) rec(el.shadowRoot);
|
|
29
|
+
const txt = (el.textContent || '').trim();
|
|
30
|
+
if (!txt || !txt.includes(t)) continue;
|
|
31
|
+
const r = el.getBoundingClientRect();
|
|
32
|
+
const st = getComputedStyle(el);
|
|
33
|
+
if (r.width < 8 || r.height < 8) continue;
|
|
34
|
+
if (st.visibility === 'hidden' || st.display === 'none' || st.pointerEvents === 'none') continue;
|
|
35
|
+
hits.push({tag: el.tagName, x: r.left + r.width / 2, y: r.top + r.height / 2,
|
|
36
|
+
w: Math.round(r.width), h: Math.round(r.height)});
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
rec(document);
|
|
40
|
+
hits.sort((a, b) => (a.w * a.h) - (b.w * b.h));
|
|
41
|
+
return hits;
|
|
42
|
+
})()
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
|
46
|
+
user32.OpenDesktopW.restype = w.HANDLE
|
|
47
|
+
user32.OpenDesktopW.argtypes = [w.LPCWSTR, w.DWORD, w.BOOL, w.DWORD]
|
|
48
|
+
user32.GetWindowRect.argtypes = [w.HWND, ctypes.POINTER(w.RECT)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def window_rect():
|
|
52
|
+
h = user32.OpenDesktopW(DESKTOP, 0, False, 0x10000000)
|
|
53
|
+
best = [None]
|
|
54
|
+
proc = ctypes.WINFUNCTYPE(w.BOOL, w.HWND, w.LPARAM)
|
|
55
|
+
|
|
56
|
+
def cb(hwnd, _):
|
|
57
|
+
n = user32.GetWindowTextLengthW(hwnd)
|
|
58
|
+
b = ctypes.create_unicode_buffer(n + 1)
|
|
59
|
+
user32.GetWindowTextW(hwnd, b, n + 1)
|
|
60
|
+
if b.value and "Chrome" in b.value:
|
|
61
|
+
r = w.RECT()
|
|
62
|
+
user32.GetWindowRect(hwnd, ctypes.byref(r))
|
|
63
|
+
area = (r.right - r.left) * (r.bottom - r.top)
|
|
64
|
+
if best[0] is None or area > best[0][0]:
|
|
65
|
+
best[0] = (area, r.left, r.top)
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
user32.SetThreadDesktop(h)
|
|
69
|
+
user32.EnumDesktopWindows(h, proc(cb), 0)
|
|
70
|
+
return best[0][1], best[0][2]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def set_cursor(x, y, click=False):
|
|
74
|
+
q = urllib.parse.urlencode({"x": int(x), "y": int(y), "click": 1 if click else 0})
|
|
75
|
+
try:
|
|
76
|
+
urllib.request.urlopen(f"{STREAM}/cursor?{q}", timeout=4).read()
|
|
77
|
+
except Exception: # noqa: BLE001
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CDP:
|
|
82
|
+
def __init__(self, ws):
|
|
83
|
+
self.ws = ws
|
|
84
|
+
self.n = 0
|
|
85
|
+
|
|
86
|
+
async def call(self, method, **params):
|
|
87
|
+
self.n += 1
|
|
88
|
+
mid = self.n
|
|
89
|
+
await self.ws.send(json.dumps({"id": mid, "method": method, "params": params}))
|
|
90
|
+
while True:
|
|
91
|
+
m = json.loads(await self.ws.recv())
|
|
92
|
+
if m.get("id") == mid:
|
|
93
|
+
if "error" in m:
|
|
94
|
+
raise RuntimeError(f"{method}: {m['error']}")
|
|
95
|
+
return m.get("result", {})
|
|
96
|
+
|
|
97
|
+
async def js(self, expr):
|
|
98
|
+
r = await self.call("Runtime.evaluate", expression=expr,
|
|
99
|
+
returnByValue=True, awaitPromise=True)
|
|
100
|
+
return r.get("result", {}).get("value")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def main():
|
|
104
|
+
raw = urllib.request.urlopen(f"http://127.0.0.1:{PORT}/json", timeout=5).read()
|
|
105
|
+
targets = json.loads(raw)
|
|
106
|
+
tab = None
|
|
107
|
+
for kw in ("ses.html", "popup.html"):
|
|
108
|
+
for t in targets:
|
|
109
|
+
if kw in t.get("url", "") and t.get("webSocketDebuggerUrl"):
|
|
110
|
+
tab = t
|
|
111
|
+
break
|
|
112
|
+
if tab:
|
|
113
|
+
break
|
|
114
|
+
if not tab:
|
|
115
|
+
print("没有扩展页面标签")
|
|
116
|
+
return
|
|
117
|
+
print(f"[tab] {tab['url'][:90]}")
|
|
118
|
+
|
|
119
|
+
async with websockets.connect(tab["webSocketDebuggerUrl"], max_size=20000000) as ws:
|
|
120
|
+
cdp = CDP(ws)
|
|
121
|
+
await cdp.call("Runtime.enable")
|
|
122
|
+
await cdp.call("Page.enable")
|
|
123
|
+
|
|
124
|
+
left, top = window_rect()
|
|
125
|
+
ox = (await cdp.js("window.screenX") - left) + max(0, (await cdp.js("window.outerWidth - window.innerWidth")) // 2)
|
|
126
|
+
oy = (await cdp.js("window.screenY") - top) + await cdp.js("window.outerHeight - window.innerHeight")
|
|
127
|
+
|
|
128
|
+
hits = await cdp.js(FIND_JS % json.dumps(TEXT))
|
|
129
|
+
print(f"[find] {TEXT!r} -> {hits}")
|
|
130
|
+
if not hits:
|
|
131
|
+
print("没找到可点元素")
|
|
132
|
+
return
|
|
133
|
+
h = hits[0]
|
|
134
|
+
wx, wy = h["x"] + ox, h["y"] + oy
|
|
135
|
+
print(f"[click] {h['tag']} 视口({h['x']:.0f},{h['y']:.0f}) 窗口({wx:.0f},{wy:.0f})")
|
|
136
|
+
set_cursor(wx, wy)
|
|
137
|
+
await asyncio.sleep(0.4)
|
|
138
|
+
set_cursor(wx, wy, click=True)
|
|
139
|
+
for t in ("mouseMoved", "mousePressed", "mouseReleased"):
|
|
140
|
+
await cdp.call("Input.dispatchMouseEvent", type=t, x=h["x"], y=h["y"],
|
|
141
|
+
button="none" if t == "mouseMoved" else "left",
|
|
142
|
+
clickCount=0 if t == "mouseMoved" else 1)
|
|
143
|
+
await asyncio.sleep(0.08)
|
|
144
|
+
|
|
145
|
+
await asyncio.sleep(2.0)
|
|
146
|
+
print("[url]", await cdp.js("location.href"))
|
|
147
|
+
ax = await cdp.call("Accessibility.getFullAXTree")
|
|
148
|
+
names = []
|
|
149
|
+
for n in ax.get("nodes", []):
|
|
150
|
+
if n.get("ignored"):
|
|
151
|
+
continue
|
|
152
|
+
nm = (n.get("name", {}) or {}).get("value") or ""
|
|
153
|
+
role = (n.get("role", {}) or {}).get("value") or ""
|
|
154
|
+
if nm.strip() and role not in ("StaticText", "InlineTextBox", "generic"):
|
|
155
|
+
names.append(f"{role}:{nm.strip()[:40]}")
|
|
156
|
+
print("[screen]", names[:22])
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
asyncio.run(main())
|