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 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())