paw-control 2.3.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.
paw.py ADDED
@@ -0,0 +1,1714 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ paw —— 用命令行控制鼠标和键盘,配合视觉模型做 Windows 桌面自动化。
5
+
6
+ 一个最小、无依赖滥用的命令行工具:截图 / 点击 / 输入 / 打开页面。
7
+ 支持一条命令做一件事,也支持一次跑一串(run 子命令)。
8
+
9
+ 示例:
10
+ python paw.py size # 屏幕分辨率
11
+ python paw.py shot screen.png # 截屏
12
+ python paw.py launch https://bilibili.com # 打开网页
13
+ python paw.py launch notepad # 打开程序
14
+ python paw.py focus "Edge" # 把标题含 Edge 的窗口切到前台
15
+ python paw.py goto https://bilibili.com # 地址栏打开(输完等 0.6s 再回车)
16
+ python paw.py wait 2 # 等 2 秒(页面/动画加载)
17
+ python paw.py click 960 540 # 左键单击
18
+ python paw.py type "hello" # 输入文字
19
+ python paw.py click 960 540 --dry-run # 只打印行动表,不执行
20
+
21
+ 一次做多件事(每行一条,支持引号与 # 注释):
22
+ python paw.py run steps.txt
23
+ python paw.py run - # 从标准输入读
24
+
25
+ steps.txt 示例:
26
+ launch notepad
27
+ click 300 200
28
+ type "hello"
29
+ hotkey ctrl s
30
+
31
+ 权限等级(--level 或环境变量 PC_LEVEL,默认 safe):
32
+ browse 末级:只能看、只能打开页面/程序(shot/size/windows/find/launch/move)
33
+ safe 次级:再加点击、滚动、打字、按键;
34
+ 但命中「付款 / 注册注销 / 删除改东西」这类危险动作时拒绝
35
+ full 最高级:允许一切
36
+
37
+ 行动表:每个动作前会打印一行「计划:…」,说明接下来要做什么;
38
+ 用 --no-plan 关掉,用 --dry-run 只看计划不执行(预览模式)。
39
+
40
+ 速度:默认每个动作之间隔 0.3s;用 --pause 0.05 加速,用 wait N 显式等页面加载。
41
+ 连续动作建议写进 steps.txt 一次跑(run),只在开头和关键节点截图确认。
42
+
43
+ 安全:动作执行前检查鼠标左右键是否同时按住——同时按住即视为急停。
44
+ 所有动作之间默认间隔 0.3s。
45
+ """
46
+
47
+ import argparse
48
+ import base64
49
+ import ctypes
50
+ import ctypes.wintypes
51
+ import json
52
+ import os
53
+ import shlex
54
+ import socket
55
+ import struct
56
+ import subprocess
57
+ import sys
58
+ import tempfile
59
+ import threading
60
+ import time
61
+ import webbrowser
62
+
63
+ # 4K + 缩放时,不声明 DPI 感知的话 pyautogui 拿到的坐标会和物理像素差一截,
64
+ # 点击就会偏。先声明,再导入 pyautogui。
65
+ try:
66
+ ctypes.windll.shcore.SetProcessDpiAwareness(2) # PER_MONITOR_AWARE_V2
67
+ except Exception:
68
+ try:
69
+ ctypes.windll.user32.SetProcessDPIAware()
70
+ except Exception:
71
+ pass
72
+
73
+ try:
74
+ sys.stdout.reconfigure(errors='replace')
75
+ sys.stderr.reconfigure(errors='replace')
76
+ except Exception:
77
+ pass
78
+
79
+ # 动作之间的默认停顿,--pause 可改(更小更快,更大多留反应时间)。
80
+ _PG_CONF = {"pause": 0.15}
81
+
82
+
83
+ SHOT_KEEP = 50 # 临时截图保留张数
84
+
85
+
86
+ def _shot_dir() -> str:
87
+ """paw 自己产生的截图放这里,和用户指定的路径分开,方便自动清理。"""
88
+ d = os.path.join(tempfile.gettempdir(), "paw")
89
+ os.makedirs(d, exist_ok=True)
90
+ return d
91
+
92
+
93
+ def _default_shot_path() -> str:
94
+ return os.path.join(_shot_dir(), time.strftime("shot-%Y%m%d-%H%M%S.png"))
95
+
96
+
97
+ def _clean_shots(keep: int = SHOT_KEEP) -> int:
98
+ """删 %TEMP%\\paw 里最旧的截图,只留最近 keep 张,返回删除个数。
99
+
100
+ 只碰 paw 自己写进去的文件;用户 `shot 指定路径` 保存的图一张都不动。
101
+ """
102
+ d = _shot_dir()
103
+ try:
104
+ names = os.listdir(d)
105
+ except OSError:
106
+ return 0
107
+ files = [os.path.join(d, n) for n in names
108
+ if n.lower().endswith((".png", ".jpg", ".jpeg"))]
109
+ if len(files) <= keep:
110
+ return 0
111
+ files.sort(key=os.path.getmtime)
112
+ removed = 0
113
+ for f in files[: len(files) - keep]:
114
+ try:
115
+ os.remove(f)
116
+ removed += 1
117
+ except OSError:
118
+ pass
119
+ return removed
120
+
121
+
122
+ def _screen_size():
123
+ """屏幕物理像素尺寸(Windows 系统 API,不导入第三方库)。"""
124
+ u = ctypes.windll.user32
125
+ return (u.GetSystemMetrics(0), u.GetSystemMetrics(1))
126
+
127
+
128
+ def _dpi_scale() -> float:
129
+ """Windows 显示缩放比例(96dpi = 1.0,150% = 1.5)。截图与坐标都用物理像素。"""
130
+ if not sys.platform.startswith("win"):
131
+ return 1.0
132
+ try:
133
+ hdc = ctypes.windll.user32.GetDC(0)
134
+ dpi = ctypes.windll.gdi32.GetDeviceCaps(hdc, 88) # LOGPIXELSX
135
+ ctypes.windll.user32.ReleaseDC(0, hdc)
136
+ return round(dpi / 96.0, 2) if dpi else 1.0
137
+ except Exception:
138
+ return 1.0
139
+
140
+ def _grab_screen():
141
+ """抓整屏,返回 Pillow 图像。走 GDI,Pillow 只负责裁剪和编码。"""
142
+ from PIL import Image
143
+
144
+ u = ctypes.windll.user32
145
+ g = ctypes.windll.gdi32
146
+
147
+ u.GetDC.restype = ctypes.c_void_p
148
+ u.GetDC.argtypes = [ctypes.c_void_p]
149
+ u.ReleaseDC.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
150
+ g.CreateCompatibleDC.restype = ctypes.c_void_p
151
+ g.CreateCompatibleDC.argtypes = [ctypes.c_void_p]
152
+ g.CreateCompatibleBitmap.restype = ctypes.c_void_p
153
+ g.CreateCompatibleBitmap.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int]
154
+ g.SelectObject.restype = ctypes.c_void_p
155
+ g.SelectObject.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
156
+ g.BitBlt.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_int,
157
+ ctypes.c_int, ctypes.c_void_p, ctypes.c_int, ctypes.c_int,
158
+ ctypes.c_uint]
159
+ g.DeleteObject.argtypes = [ctypes.c_void_p]
160
+ g.DeleteDC.argtypes = [ctypes.c_void_p]
161
+
162
+ w, h = _screen_size()
163
+ hdc = u.GetDC(0)
164
+ if not hdc:
165
+ raise OSError("取不到屏幕 DC")
166
+ dc = bmp = None
167
+ try:
168
+ dc = g.CreateCompatibleDC(hdc)
169
+ bmp = g.CreateCompatibleBitmap(hdc, w, h)
170
+ if not dc or not bmp:
171
+ raise OSError("创建兼容位图失败")
172
+ g.SelectObject(dc, bmp)
173
+ if not g.BitBlt(dc, 0, 0, w, h, hdc, 0, 0, 0x00CC0020):
174
+ raise OSError("抓屏失败(BitBlt 被拒)")
175
+
176
+ class _BMIH(ctypes.Structure):
177
+ _fields_ = [("biSize", ctypes.c_uint32), ("biWidth", ctypes.c_int32),
178
+ ("biHeight", ctypes.c_int32), ("biPlanes", ctypes.c_uint16),
179
+ ("biBitCount", ctypes.c_uint16), ("biCompression", ctypes.c_uint32),
180
+ ("biSizeImage", ctypes.c_uint32),
181
+ ("biXPelsPerMeter", ctypes.c_int32),
182
+ ("biYPelsPerMeter", ctypes.c_int32),
183
+ ("biClrUsed", ctypes.c_uint32),
184
+ ("biClrImportant", ctypes.c_uint32)]
185
+
186
+ class _BMI(ctypes.Structure):
187
+ _fields_ = [("bmiHeader", _BMIH), ("bmiColors", ctypes.c_uint32 * 3)]
188
+
189
+ bmi = _BMI()
190
+ bmi.bmiHeader.biSize = ctypes.sizeof(_BMIH)
191
+ bmi.bmiHeader.biWidth = w
192
+ bmi.bmiHeader.biHeight = -h # 负高度 = 从上往下存
193
+ bmi.bmiHeader.biPlanes = 1
194
+ bmi.bmiHeader.biBitCount = 32
195
+ bmi.bmiHeader.biCompression = 0 # BI_RGB
196
+ buf = ctypes.create_string_buffer(w * h * 4)
197
+ g.GetDIBits.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint,
198
+ ctypes.c_uint, ctypes.c_void_p,
199
+ ctypes.POINTER(_BMI), ctypes.c_uint]
200
+ if not g.GetDIBits(dc, bmp, 0, h, buf, ctypes.byref(bmi), 0):
201
+ raise OSError("读取像素失败(GetDIBits)")
202
+ return Image.frombuffer("RGB", (w, h), buf.raw, "raw", "BGRX", 0, 1)
203
+ finally:
204
+ if bmp:
205
+ g.DeleteObject(bmp)
206
+ if dc:
207
+ g.DeleteDC(dc)
208
+ u.ReleaseDC(0, hdc)
209
+
210
+
211
+ # 急停:命令行下靠鼠标左右键同按;Web 看板点「急停」时会置上这个标记。
212
+ STOP_FLAG = threading.Event()
213
+ LEVEL_ORDER = {"browse": 0, "safe": 1, "full": 2}
214
+ CMD_LEVEL = {
215
+ "size": "browse", "shot": "browse", "windows": "browse", "find": "browse",
216
+ "scale": "browse",
217
+ "launch": "browse", "move": "browse",
218
+ "desks": "browse", "desk": "browse", "screens": "browse", "watch": "browse",
219
+ "focus": "browse", "wait": "browse", "goto": "browse",
220
+ "uia": "browse", "start": "browse", "stop": "browse", "clean": "browse", "web": "browse",
221
+ "web-click": "safe",
222
+ "click": "safe", "dclick": "safe", "rclick": "safe", "scroll": "safe", "drag": "safe",
223
+ "type": "safe", "key": "safe", "hotkey": "safe",
224
+ "uia-click": "safe",
225
+ }
226
+ ALL_CMDS = list(CMD_LEVEL.keys())
227
+
228
+ # safe 级下,文本里出现这些词就拒绝(付款 / 注册注销 / 删改东西)
229
+ DANGER_WORDS = [
230
+ "付款", "支付", "购买", "下单", "结账", "充值", "转账", "扣款", "扫码",
231
+ "注册", "注销", "登录", "登出", "绑定", "实名", "密码", "验证码",
232
+ "删除", "卸载", "格式化", "清空", "移除", "移除文件", "删除文件", "抹掉",
233
+ "覆盖保存", "发送", "发布", "提交订单", "退款",
234
+ "pay", "purchase", "checkout", "transfer", "subscribe",
235
+ "sign up", "signup", "sign out", "signout", "log in", "login", "logout",
236
+ "register", "delete", "remove", "uninstall", "format", "erase", "wipe",
237
+ "rm -rf", "drop table",
238
+ ]
239
+
240
+
241
+ def _emergency_pressed() -> bool:
242
+ """左右键同时按住 = 急停;Web 看板触发过急停也算。"""
243
+ if STOP_FLAG.is_set():
244
+ return True
245
+ try:
246
+ user32 = ctypes.windll.user32
247
+ left = user32.GetAsyncKeyState(0x01) & 0x8000
248
+ right = user32.GetAsyncKeyState(0x02) & 0x8000
249
+ return bool(left and right)
250
+ except Exception:
251
+ return False
252
+
253
+
254
+ def _level_allows(level: str, cmd: str) -> bool:
255
+ return LEVEL_ORDER[level] >= LEVEL_ORDER[CMD_LEVEL[cmd]]
256
+
257
+
258
+ def _danger_hits(text: str) -> list:
259
+ low = text.lower()
260
+ return [w for w in DANGER_WORDS if w in low]
261
+
262
+
263
+ class _POINT(ctypes.Structure):
264
+ _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)]
265
+
266
+
267
+ def _window_at(x: int, y: int) -> str:
268
+ """返回屏幕坐标处所在窗口的标题,用来说明「点的是哪个程序的什么」。"""
269
+ if not sys.platform.startswith("win"):
270
+ return ""
271
+ try:
272
+ user32 = ctypes.windll.user32
273
+ user32.WindowFromPoint.argtypes = [_POINT]
274
+ user32.WindowFromPoint.restype = ctypes.c_void_p
275
+ hwnd = user32.WindowFromPoint(_POINT(int(x), int(y)))
276
+ if not hwnd:
277
+ return ""
278
+ root = user32.GetAncestor(hwnd, 2) # GA_ROOT:取最外层窗口
279
+ if not root:
280
+ root = hwnd
281
+ n = user32.GetWindowTextLengthW(root)
282
+ buf = ctypes.create_unicode_buffer(n + 1)
283
+ user32.GetWindowTextW(root, buf, n + 1)
284
+ return buf.value.strip()
285
+ except Exception:
286
+ return ""
287
+
288
+
289
+ def _where(x: int, y: int) -> str:
290
+ """行动表里的位置说明:这个坐标落在哪个窗口上。"""
291
+ win = _window_at(x, y)
292
+ return f"(所在窗口:{win})" if win else ""
293
+
294
+
295
+ # --- UI Automation:按控件名找位置,不靠眼睛估坐标 -------------------------
296
+
297
+ def _uia_import():
298
+ try:
299
+ import uiautomation as auto
300
+ except ImportError:
301
+ print("需要 uiautomation:pip install uiautomation", file=sys.stderr)
302
+ raise SystemExit(2)
303
+ return auto
304
+
305
+
306
+ def _uia_window(auto, kw: str):
307
+ win = auto.WindowControl(searchDepth=1, SubName=kw)
308
+ if win.Exists(1):
309
+ return win
310
+ win = auto.WindowControl(searchDepth=1, Name=kw)
311
+ return win
312
+
313
+
314
+ def _uia_rect_text(r) -> str:
315
+ return f"({r.left},{r.top})-({r.right},{r.bottom})"
316
+
317
+
318
+ def _do_uia(kw: str, deep: bool = False) -> int:
319
+ auto = _uia_import()
320
+ win = _uia_window(auto, kw)
321
+ if not win.Exists(3):
322
+ print(f"没找到标题包含「{kw}」的窗口", file=sys.stderr)
323
+ return 1
324
+ print(f"窗口:{win.Name} {_uia_rect_text(win.BoundingRectangle)}")
325
+ rows = 0
326
+ for c in win.GetChildren():
327
+ try:
328
+ name = c.Name or ""
329
+ rect = c.BoundingRectangle
330
+ print(f" [{c.ControlTypeName}] {name!r} {_uia_rect_text(rect)}")
331
+ rows += 1
332
+ except Exception:
333
+ continue
334
+ print(f"共 {rows} 个直接子控件(要更深就加 --deep)")
335
+ if deep:
336
+ for c in win.GetChildren():
337
+ try:
338
+ for sub in c.GetChildren():
339
+ print(
340
+ f" ▸ [{sub.ControlTypeName}] {sub.Name!r} "
341
+ f"{_uia_rect_text(sub.BoundingRectangle)}"
342
+ )
343
+ except Exception:
344
+ continue
345
+ return 0
346
+
347
+
348
+ def _do_uia_click(kw: str, name: str) -> int:
349
+ auto = _uia_import()
350
+ win = _uia_window(auto, kw)
351
+ if not win.Exists(3):
352
+ print(f"没找到标题包含「{kw}」的窗口", file=sys.stderr)
353
+ return 1
354
+ ctrl = auto.Control(searchFromControl=win, Name=name, searchDepth=12)
355
+ if not ctrl.Exists(3):
356
+ print(f"窗口「{win.Name}」里没找到名为「{name}」的控件", file=sys.stderr)
357
+ return 1
358
+ rect = ctrl.BoundingRectangle
359
+ print(f"控件:{ctrl.ControlTypeName} {ctrl.Name!r} {_uia_rect_text(rect)}")
360
+ try:
361
+ ctrl.Click()
362
+ return 0
363
+ except Exception as exc:
364
+ print(f"控件 Invoke 失败({exc}),退回坐标点击中心", file=sys.stderr)
365
+ cx = (rect.left + rect.right) // 2
366
+ cy = (rect.top + rect.bottom) // 2
367
+ _click_at(cx, cy, "left", False)
368
+ return 0
369
+
370
+
371
+ # ---------- 网页(CDP):让浏览器报出 DOM 里每个控件的位置 ----------
372
+
373
+ CDP_PORT = 9222
374
+
375
+
376
+ def _cdp_pages(port: int = CDP_PORT):
377
+ """列出调试端口上可操作的页面。"""
378
+ import urllib.request
379
+
380
+ try:
381
+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/json", timeout=3) as r:
382
+ data = json.loads(r.read().decode("utf-8", "replace"))
383
+ except Exception as exc:
384
+ raise RuntimeError(
385
+ f"连不上 {port} 端口的调试接口({exc.__class__.__name__})。\n"
386
+ "浏览器要用调试端口启动,而且必须配一个独立的 --user-data-dir\n"
387
+ "(默认配置目录从 Chromium 某版起不再允许开调试端口):\n\n"
388
+ ' msedge.exe --remote-debugging-port=9222 --user-data-dir="%TEMP%\\paw-edge"\n\n'
389
+ "打开后在里面登录一次要操作的网站,之后 paw web 就能用了。"
390
+ )
391
+ return [p for p in data if p.get("type") == "page"]
392
+
393
+
394
+ def _ws_connect(host: str, port: int, path: str, timeout: float = 10.0):
395
+ s = socket.create_connection((host, port), timeout=timeout)
396
+ key = base64.b64encode(os.urandom(16)).decode()
397
+ req = (
398
+ f"GET {path} HTTP/1.1\r\n"
399
+ f"Host: {host}:{port}\r\n"
400
+ "Upgrade: websocket\r\n"
401
+ "Connection: Upgrade\r\n"
402
+ f"Sec-WebSocket-Key: {key}\r\n"
403
+ "Sec-WebSocket-Version: 13\r\n\r\n"
404
+ )
405
+ s.sendall(req.encode())
406
+ buf = b""
407
+ while b"\r\n\r\n" not in buf:
408
+ chunk = s.recv(4096)
409
+ if not chunk:
410
+ raise RuntimeError("握手时连接被关闭")
411
+ buf += chunk
412
+ head, _, rest = buf.partition(b"\r\n\r\n")
413
+ if b"101" not in head.split(b"\r\n")[0]:
414
+ raise RuntimeError("握手失败:" + head.decode("latin1")[:200])
415
+ return s, rest
416
+
417
+
418
+ def _ws_send(s, text: str) -> None:
419
+ data = text.encode()
420
+ header = bytearray([0x81]) # FIN + 文本帧
421
+ n = len(data)
422
+ if n < 126:
423
+ header.append(0x80 | n)
424
+ elif n < 65536:
425
+ header.append(0x80 | 126)
426
+ header += struct.pack(">H", n)
427
+ else:
428
+ header.append(0x80 | 127)
429
+ header += struct.pack(">Q", n)
430
+ mask = os.urandom(4)
431
+ header += mask
432
+ s.sendall(bytes(header) + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
433
+
434
+
435
+ def _recv_exact(s, n: int) -> bytes:
436
+ buf = b""
437
+ while len(buf) < n:
438
+ chunk = s.recv(n - len(buf))
439
+ if not chunk:
440
+ raise RuntimeError("连接关闭")
441
+ buf += chunk
442
+ return buf
443
+
444
+
445
+ def _ws_recv(s, pending: bytes = b"") -> str:
446
+ buf = pending
447
+ while True:
448
+ while len(buf) < 2:
449
+ buf += _recv_exact(s, 2 - len(buf))
450
+ b0, b1 = buf[0], buf[1]
451
+ fin = b0 & 0x80
452
+ opcode = b0 & 0x0F
453
+ masked = b1 & 0x80
454
+ n = b1 & 0x7F
455
+ pos = 2
456
+ if n == 126:
457
+ while len(buf) < pos + 2:
458
+ buf += _recv_exact(s, pos + 2 - len(buf))
459
+ n = struct.unpack(">H", buf[pos:pos + 2])[0]
460
+ pos += 2
461
+ elif n == 127:
462
+ while len(buf) < pos + 8:
463
+ buf += _recv_exact(s, pos + 8 - len(buf))
464
+ n = struct.unpack(">Q", buf[pos:pos + 8])[0]
465
+ pos += 8
466
+ mkey = b""
467
+ if masked:
468
+ while len(buf) < pos + 4:
469
+ buf += _recv_exact(s, pos + 4 - len(buf))
470
+ mkey = buf[pos:pos + 4]
471
+ pos += 4
472
+ while len(buf) < pos + n:
473
+ buf += _recv_exact(s, pos + n - len(buf))
474
+ payload = buf[pos:pos + n]
475
+ buf = buf[pos + n:]
476
+ if mkey:
477
+ payload = bytes(b ^ mkey[i % 4] for i, b in enumerate(payload))
478
+ if opcode == 0x8:
479
+ raise RuntimeError("服务器关闭了连接")
480
+ if opcode == 0x9:
481
+ continue
482
+ if opcode in (0x1, 0x2):
483
+ if not fin:
484
+ continue
485
+ return payload.decode("utf-8", "replace")
486
+
487
+
488
+ def _cdp_call(s, counter, method: str, params=None):
489
+ counter[0] += 1
490
+ mid = counter[0]
491
+ _ws_send(s, json.dumps({"id": mid, "method": method, "params": params or {}}))
492
+ while True:
493
+ msg = json.loads(_ws_recv(s))
494
+ if msg.get("id") == mid:
495
+ return msg
496
+
497
+
498
+ def _pick_page(pages):
499
+ """优先挑真正的内容页:跳过 edge:// 内部页与浏览器欢迎/致谢页。"""
500
+ for p in pages:
501
+ url = (p.get("url") or "").lower()
502
+ if url.startswith(("edge://", "chrome-extension://", "devtools://")):
503
+ continue
504
+ if "welcome." in url or "thankyou" in url:
505
+ continue
506
+ return p
507
+ return pages[0] if pages else None
508
+
509
+
510
+ def _web_elements(port: int = CDP_PORT, keyword: str = ""):
511
+ """连上内容页,拿回可点元素 [(文字, 中心x, 中心y)]。"""
512
+ pages = _cdp_pages(port)
513
+ if not pages:
514
+ raise RuntimeError("没有打开的页面")
515
+ page = _pick_page(pages)
516
+ path = page["webSocketDebuggerUrl"].split(str(port), 1)[1]
517
+ s, pending = _ws_connect("127.0.0.1", port, path)
518
+ counter = [0]
519
+ try:
520
+ js = (
521
+ "JSON.stringify(([...document.querySelectorAll("
522
+ "'a,button,[role=button],input[type=submit]')]"
523
+ ".filter(e=>{const r=e.getBoundingClientRect();"
524
+ "return r.width>0&&r.height>0&&r.top>=0&&r.left>=0;})"
525
+ ".map(e=>{const r=e.getBoundingClientRect();"
526
+ "const t=(e.innerText||e.value||e.getAttribute('aria-label')||'').trim();"
527
+ "return [t, Math.round(r.x+r.width/2), Math.round(r.y+r.height/2)];})"
528
+ ".filter(x=>x[0])))"
529
+ )
530
+ r = _cdp_call(s, counter, "Runtime.evaluate", {"expression": js, "returnByValue": True})
531
+ if r.get("result", {}).get("exceptionDetails"):
532
+ detail = r["result"]["exceptionDetails"].get("text", "JS 执行出错")
533
+ raise RuntimeError(f"页面脚本出错:{detail}")
534
+ raw = r.get("result", {}).get("result", {}).get("value") or "[]"
535
+ items = json.loads(raw)
536
+ if keyword:
537
+ kw = keyword.lower()
538
+ items = [x for x in items if kw in x[0].lower()]
539
+ return page.get("title", ""), items
540
+ finally:
541
+ try:
542
+ s.close()
543
+ except Exception:
544
+ pass
545
+
546
+
547
+ def _do_web(keyword: str = "", port: int = CDP_PORT) -> int:
548
+ title, items = _web_elements(port, keyword)
549
+ print(f"页面:{title}")
550
+ if not items:
551
+ print("没找到可点元素" + (f"(关键词「{keyword}」)" if keyword else ""))
552
+ return 0
553
+ print(f"共 {len(items)} 个:")
554
+ for text, x, y in items[:40]:
555
+ print(f" {text[:34]:36s} ({x}, {y})")
556
+ return 0
557
+
558
+
559
+ def _do_web_click(text: str, port: int = CDP_PORT, real: bool = False) -> int:
560
+ pages = _cdp_pages(port)
561
+ if not pages:
562
+ print("没有打开的页面", file=sys.stderr)
563
+ return 1
564
+ page = _pick_page(pages)
565
+ path = page["webSocketDebuggerUrl"].split(str(port), 1)[1]
566
+ s, pending = _ws_connect("127.0.0.1", port, path)
567
+ counter = [0]
568
+ try:
569
+ js = (
570
+ "JSON.stringify(([...document.querySelectorAll("
571
+ "'a,button,[role=button],input[type=submit]')]"
572
+ ".filter(e=>{const r=e.getBoundingClientRect();"
573
+ "return r.width>0&&r.height>0&&r.top>=0&&r.left>=0;})"
574
+ ".map(e=>{const r=e.getBoundingClientRect();"
575
+ "const t=(e.innerText||e.value||e.getAttribute('aria-label')||'').trim();"
576
+ "return [t, Math.round(r.x+r.width/2), Math.round(r.y+r.height/2)];})"
577
+ ".filter(x=>x[0])))"
578
+ )
579
+ r = _cdp_call(s, counter, "Runtime.evaluate", {"expression": js, "returnByValue": True})
580
+ if r.get("result", {}).get("exceptionDetails"):
581
+ raise RuntimeError("页面脚本出错:" + r["result"]["exceptionDetails"].get("text", "?"))
582
+ items = json.loads(r.get("result", {}).get("result", {}).get("value") or "[]")
583
+ match = [it for it in items if it[0].lower() == text.lower()]
584
+ if not match:
585
+ match = [it for it in items if text.lower() in it[0].lower()]
586
+ if not match:
587
+ print(f"页面「{page.get('title', '')}」里没有文字含「{text}」的可点元素", file=sys.stderr)
588
+ return 1
589
+ label, x, y = match[0]
590
+ print(f"找到:{label!r} 在页面坐标 ({x}, {y})")
591
+ if real:
592
+ _click_at(x, y, "left", False)
593
+ print("已点击(真实鼠标事件,按页面坐标)")
594
+ else:
595
+ _cdp_call(s, counter, "Input.dispatchMouseEvent",
596
+ {"type": "mouseMoved", "x": x, "y": y})
597
+ _cdp_call(s, counter, "Input.dispatchMouseEvent",
598
+ {"type": "mousePressed", "x": x, "y": y, "button": "left", "clickCount": 1})
599
+ _cdp_call(s, counter, "Input.dispatchMouseEvent",
600
+ {"type": "mouseReleased", "x": x, "y": y, "button": "left", "clickCount": 1})
601
+ print("已点击(通过浏览器调试接口,不需要屏幕坐标)")
602
+ return 0
603
+ finally:
604
+ try:
605
+ s.close()
606
+ except Exception:
607
+ pass
608
+
609
+
610
+ def _xy(a: list):
611
+ """行动表里的坐标 + 所在窗口,配 % 格式化用。"""
612
+ x, y = int(a[0]), int(a[1])
613
+ return x, y, _where(x, y)
614
+
615
+
616
+ def _plan_shot(a: list, ns) -> str:
617
+ words = [w for w in a if not w.startswith("--")]
618
+ target = words[-1] if words else "临时目录(自动保留最近 %d 张)" % SHOT_KEEP
619
+ return f"截屏并保存到 {target}(不改任何东西)"
620
+
621
+
622
+ def _plan_web_click(a: list, ns) -> str:
623
+ how = "真实鼠标事件" if ns.real else "浏览器调试接口"
624
+ return "在网页里找文字含「%s」的元素并点击(%s)" % (" ".join(a), how)
625
+
626
+
627
+ def _plan_uia_click(a: list, ns) -> str:
628
+ win, name = (a[0] if a else ""), (a[1] if len(a) > 1 else "")
629
+ return "点击窗口「%s」里名为「%s」的控件(按控件找位置,不用估坐标)" % (win, name)
630
+
631
+
632
+ # 行动表:命令 → 文案。固定文案直接写字符串,要拼参数的用函数;表里没有的命令
633
+ # (drag / screens / watch / start / stop 等)原样返回命令名,和以前一样。
634
+ _PLAN = {
635
+ "size": "读取屏幕分辨率(不改任何东西)",
636
+ "scale": "读取显示缩放比例(不改任何东西)",
637
+ "windows": "列出当前窗口和位置(不改任何东西)",
638
+ "desks": "列出虚拟桌面和当前所在桌面(不改任何东西)",
639
+ "shot": _plan_shot,
640
+ "web": lambda a, ns: f"列出网页里可点的元素{'(过滤:' + ' '.join(a) + ')' if a else ''}(不改任何东西)",
641
+ "web-click": _plan_web_click,
642
+ "uia": lambda a, ns: f"列出窗口「{a[0] if a else ''}」里的控件和坐标(不改任何东西)",
643
+ "uia-click": _plan_uia_click,
644
+ "clean": lambda a, ns: f"清理临时截图,只保留最近 {a[0] if a else SHOT_KEEP} 张({_shot_dir()})",
645
+ "desk": lambda a, ns: f"虚拟桌面操作:{a[0] if a else 'list'}({' '.join(a[1:])})",
646
+ "find": lambda a, ns: f"在屏幕上查找图片 {a[0] if a else '?'},返回坐标(不点击)",
647
+ "launch": lambda a, ns: f"打开:{a[0] if a else '?'}",
648
+ "focus": lambda a, ns: f"把标题含「{a[0] if a else '?'}」的窗口切到前台(不点击)",
649
+ "goto": lambda a, ns: f"在浏览器地址栏打开:{a[0] if a else '?'}(输完等 0.6s 再回车)",
650
+ "wait": lambda a, ns: f"等 {a[0] if a else '?'} 秒(什么都不做,等页面/动画加载)",
651
+ "move": lambda a, ns: "把鼠标移动到 (%d, %d)%s,不点击" % _xy(a),
652
+ "click": lambda a, ns: "在 (%d, %d) 单击鼠标左键%s" % _xy(a),
653
+ "dclick": lambda a, ns: "在 (%d, %d) 双击鼠标左键%s" % _xy(a),
654
+ "rclick": lambda a, ns: "在 (%d, %d) 单击鼠标右键%s" % _xy(a),
655
+ "scroll": lambda a, ns: f"滚动滚轮 {a[0]} 格",
656
+ "type": lambda a, ns: f"输入文本:{' '.join(a)[:60]}",
657
+ "key": lambda a, ns: f"按一次键:{a[0]}",
658
+ "hotkey": lambda a, ns: f"按组合键:{' + '.join(a)}",
659
+ }
660
+
661
+
662
+ def _plan_text(cmd: str, args) -> str:
663
+ """行动表文案。args 可以是 Namespace(CLI),也可以是字符串列表(serve.py、测试)。"""
664
+ fn = _PLAN.get(cmd)
665
+ if fn is None:
666
+ return cmd
667
+ ns = args if isinstance(args, argparse.Namespace) else _parse_list(cmd, args)
668
+ return fn(list(getattr(ns, "args", None) or []), ns) if callable(fn) else fn
669
+
670
+
671
+ def _do_launch(target: str) -> None:
672
+ if "://" in target:
673
+ # http(s) 与 steam://、ms-settings: 这类自定义协议,都交给系统处理
674
+ try:
675
+ os.startfile(target) # noqa: S606 - 用户明确要打开这个地址
676
+ return
677
+ except Exception:
678
+ webbrowser.open(target)
679
+ return
680
+ if os.path.exists(target):
681
+ os.startfile(target) # noqa: S606 - 用户明确要打开这个文件
682
+ else:
683
+ subprocess.Popen(target, shell=False) # noqa: S603 - 用户明确要启动的程序
684
+
685
+
686
+ def _clipboard_get() -> str:
687
+ """读剪贴板文本(读不到返回空串)。"""
688
+ user32 = ctypes.windll.user32
689
+ kernel32 = ctypes.windll.kernel32
690
+ CF_UNICODETEXT = 13
691
+ out = ""
692
+ user32.GetClipboardData.restype = ctypes.c_void_p
693
+ kernel32.GlobalLock.restype = ctypes.c_void_p
694
+ kernel32.GlobalLock.argtypes = [ctypes.c_void_p]
695
+ try:
696
+ if not user32.OpenClipboard(None):
697
+ return ""
698
+ try:
699
+ h = user32.GetClipboardData(CF_UNICODETEXT)
700
+ if h:
701
+ p = kernel32.GlobalLock(h)
702
+ if p:
703
+ try:
704
+ out = ctypes.wstring_at(p)
705
+ finally:
706
+ kernel32.GlobalUnlock(h)
707
+ finally:
708
+ user32.CloseClipboard()
709
+ except Exception:
710
+ return ""
711
+ return out
712
+
713
+
714
+ def _clipboard_set(text: str) -> None:
715
+ """往剪贴板写文本(Win32 API,不依赖 tkinter)。"""
716
+ user32 = ctypes.windll.user32
717
+ kernel32 = ctypes.windll.kernel32
718
+ CF_UNICODETEXT = 13
719
+ GMEM_MOVEABLE = 0x0002
720
+ kernel32.GlobalAlloc.restype = ctypes.c_void_p
721
+ kernel32.GlobalAlloc.argtypes = [ctypes.c_uint, ctypes.c_size_t]
722
+ kernel32.GlobalLock.restype = ctypes.c_void_p
723
+ kernel32.GlobalLock.argtypes = [ctypes.c_void_p]
724
+ user32.SetClipboardData.argtypes = [ctypes.c_uint, ctypes.c_void_p]
725
+ try:
726
+ if not user32.OpenClipboard(None):
727
+ return
728
+ try:
729
+ user32.EmptyClipboard()
730
+ buf = ctypes.create_unicode_buffer(text)
731
+ size = ctypes.sizeof(buf)
732
+ h = kernel32.GlobalAlloc(GMEM_MOVEABLE, size)
733
+ if not h:
734
+ return
735
+ p = kernel32.GlobalLock(h)
736
+ if not p:
737
+ return
738
+ ctypes.memmove(p, buf, size)
739
+ kernel32.GlobalUnlock(h)
740
+ user32.SetClipboardData(CF_UNICODETEXT, h)
741
+ finally:
742
+ user32.CloseClipboard()
743
+ except Exception:
744
+ pass
745
+
746
+
747
+ def _win_input() -> bool:
748
+ return sys.platform.startswith("win")
749
+
750
+
751
+ def _click_at(x: int, y: int, button: str = "left", double: bool = False) -> None:
752
+ """点一下。直接走系统 API(SetCursorPos + mouse_event),不依赖任何第三方库。"""
753
+ u = ctypes.windll.user32
754
+ u.SetCursorPos(int(x), int(y))
755
+ time.sleep(0.02)
756
+ down, up = (0x0008, 0x0010) if button == "right" else (0x0002, 0x0004)
757
+ for i in range(2 if double else 1):
758
+ u.mouse_event(down, 0, 0, 0, 0)
759
+ u.mouse_event(up, 0, 0, 0, 0)
760
+ if double and i == 0:
761
+ time.sleep(0.05)
762
+
763
+
764
+ def _move_to(x: int, y: int) -> None:
765
+ """只移动光标,用 SetCursorPos。"""
766
+ ctypes.windll.user32.SetCursorPos(int(x), int(y))
767
+
768
+
769
+ def _scroll_at(n: int) -> None:
770
+ """滚轮 n 格(正数向上),用 mouse_event 的 WHEEL。"""
771
+ ctypes.windll.user32.mouse_event(0x0800, 0, 0, int(n) * 120, 0)
772
+
773
+
774
+ # ---- Win32 输入层:SendInput / keybd_event,不依赖 pyautogui ----
775
+
776
+
777
+ class _MOUSEINPUT(ctypes.Structure):
778
+ _fields_ = [
779
+ ("dx", ctypes.c_long),
780
+ ("dy", ctypes.c_long),
781
+ ("mouseData", ctypes.c_ulong),
782
+ ("dwFlags", ctypes.c_ulong),
783
+ ("time", ctypes.c_ulong),
784
+ ("dwExtraInfo", ctypes.c_void_p),
785
+ ]
786
+
787
+
788
+ class _KEYBDINPUT(ctypes.Structure):
789
+ _fields_ = [
790
+ ("wVk", ctypes.c_ushort),
791
+ ("wScan", ctypes.c_ushort),
792
+ ("dwFlags", ctypes.c_ulong),
793
+ ("time", ctypes.c_ulong),
794
+ ("dwExtraInfo", ctypes.c_void_p),
795
+ ]
796
+
797
+
798
+ class _HARDWAREINPUT(ctypes.Structure):
799
+ _fields_ = [
800
+ ("uMsg", ctypes.c_ulong),
801
+ ("wParamL", ctypes.c_ushort),
802
+ ("wParamH", ctypes.c_ushort),
803
+ ]
804
+
805
+
806
+ class _INPUTUNION(ctypes.Union):
807
+ _fields_ = [("mi", _MOUSEINPUT), ("ki", _KEYBDINPUT), ("hi", _HARDWAREINPUT)]
808
+
809
+
810
+ class _INPUT(ctypes.Structure):
811
+ _fields_ = [("type", ctypes.c_ulong), ("u", _INPUTUNION)]
812
+
813
+
814
+ _INPUT_KEYBOARD = 1
815
+ _KEYEVENTF_KEYUP = 0x0002
816
+ _KEYEVENTF_UNICODE = 0x0004
817
+
818
+ _VK_NAMES = {
819
+ "enter": 0x0D, "return": 0x0D, "tab": 0x09, "esc": 0x1B, "escape": 0x1B,
820
+ "space": 0x20, "backspace": 0x08, "delete": 0x2E, "del": 0x2E,
821
+ "insert": 0x2D, "home": 0x24, "end": 0x23,
822
+ "pageup": 0x21, "pagedown": 0x22,
823
+ "up": 0x26, "down": 0x28, "left": 0x25, "right": 0x27,
824
+ "ctrl": 0x11, "control": 0x11, "alt": 0x12, "shift": 0x10,
825
+ "win": 0x5B, "cmd": 0x5B, "capslock": 0x14, "printscreen": 0x2C,
826
+ }
827
+ for _i in range(1, 13):
828
+ _VK_NAMES[f"f{_i}"] = 0x6F + _i
829
+
830
+
831
+ def _send_unicode(code: int) -> None:
832
+ """发一个 UTF-16 码元。KEYEVENTF_UNICODE 不经过键盘布局,任何字符都能进。"""
833
+ u = ctypes.windll.user32
834
+ for flags in (0, _KEYEVENTF_KEYUP):
835
+ inp = _INPUT(type=_INPUT_KEYBOARD)
836
+ inp.u.ki = _KEYBDINPUT(
837
+ wVk=0, wScan=code,
838
+ dwFlags=_KEYEVENTF_UNICODE | flags,
839
+ time=0, dwExtraInfo=None,
840
+ )
841
+ u.SendInput(1, ctypes.byref(inp), ctypes.sizeof(_INPUT))
842
+
843
+
844
+ def _send_text(text: str) -> None:
845
+ """逐字符发文字:不碰剪贴板,中文、emoji 都能直接打进去。"""
846
+ for ch in text:
847
+ if ch == "\n":
848
+ _press("enter")
849
+ elif ch == "\t":
850
+ _press("tab")
851
+ else:
852
+ code = ord(ch)
853
+ if code > 0xFFFF: # 超出 BMP:拆代理对
854
+ code -= 0x10000
855
+ _send_unicode(0xD800 + (code >> 10))
856
+ _send_unicode(0xDC00 + (code & 0x3FF))
857
+ else:
858
+ _send_unicode(code)
859
+
860
+
861
+ def _vk_of(name: str) -> int:
862
+ key = name.strip().lower()
863
+ if key in _VK_NAMES:
864
+ return _VK_NAMES[key]
865
+ if len(key) == 1:
866
+ return ord(key.upper())
867
+ raise ValueError(f"不认识的键:{name}")
868
+
869
+
870
+ def _key_down(vk: int) -> None:
871
+ ctypes.windll.user32.keybd_event(vk, 0, 0, 0)
872
+
873
+
874
+ def _key_up(vk: int) -> None:
875
+ ctypes.windll.user32.keybd_event(vk, 0, _KEYEVENTF_KEYUP, 0)
876
+
877
+
878
+ def _press(name: str) -> None:
879
+ vk = _vk_of(name)
880
+ _key_down(vk)
881
+ _key_up(vk)
882
+
883
+
884
+ def _hotkey(*names: str) -> None:
885
+ vks = [_vk_of(n) for n in names]
886
+ for vk in vks:
887
+ _key_down(vk)
888
+ for vk in reversed(vks):
889
+ _key_up(vk)
890
+
891
+
892
+ def _paste_ctrl_v() -> None:
893
+ """发一次 Ctrl+V(保留给需要粘贴的场景)。"""
894
+ _hotkey("ctrl", "v")
895
+
896
+
897
+ def _type_text(text: str) -> None:
898
+ """输入文字。走 SendInput,不占剪贴板、不用逐键映射,中文和 emoji 都能打。"""
899
+ _send_text(text)
900
+
901
+
902
+ def _cursor_pos() -> tuple:
903
+ """当前鼠标位置(物理像素)。"""
904
+ pt = ctypes.wintypes.POINT()
905
+ ctypes.windll.user32.GetCursorPos(ctypes.byref(pt))
906
+ return pt.x, pt.y
907
+
908
+
909
+ def _drag_to(x1: int, y1: int, x2: int, y2: int, dur: float = 0.4) -> None:
910
+ """按住左键从 (x1,y1) 拖到 (x2,y2)。"""
911
+ u = ctypes.windll.user32
912
+ u.SetCursorPos(int(x1), int(y1))
913
+ time.sleep(0.05)
914
+ u.mouse_event(0x0002, 0, 0, 0, 0) # 左键按下
915
+ steps = max(8, int(dur * 60))
916
+ for i in range(1, steps + 1):
917
+ nx = int(x1 + (x2 - x1) * i / steps)
918
+ ny = int(y1 + (y2 - y1) * i / steps)
919
+ u.SetCursorPos(nx, ny)
920
+ time.sleep(dur / steps)
921
+ u.mouse_event(0x0004, 0, 0, 0, 0) # 左键抬起
922
+
923
+
924
+ _WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
925
+
926
+
927
+ def _enum_top_windows(include_minimized: bool = False) -> list:
928
+ """枚举顶层窗口:[(hwnd, title, (l, t, r, b)), ...],按 z 序(前台在前)。"""
929
+ u = ctypes.windll.user32
930
+ out = []
931
+
932
+ def _cb(hwnd, _lparam):
933
+ if not u.IsWindowVisible(hwnd):
934
+ return True
935
+ length = u.GetWindowTextLengthW(hwnd)
936
+ if length == 0:
937
+ return True
938
+ buf = ctypes.create_unicode_buffer(length + 1)
939
+ u.GetWindowTextW(hwnd, buf, length + 1)
940
+ minimized = bool(u.IsIconic(hwnd))
941
+ if minimized and not include_minimized:
942
+ return True
943
+ rect = ctypes.wintypes.RECT()
944
+ u.GetWindowRect(hwnd, ctypes.byref(rect))
945
+ box = (0, 0, 0, 0) if minimized else (rect.left, rect.top, rect.right, rect.bottom)
946
+ out.append((hwnd, buf.value, box))
947
+ return True
948
+
949
+ u.EnumWindows(_WNDENUMPROC(_cb), 0)
950
+ return out
951
+
952
+
953
+ def _window_size(hwnd) -> tuple:
954
+ rect = ctypes.wintypes.RECT()
955
+ ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect))
956
+ return rect.right - rect.left, rect.bottom - rect.top
957
+
958
+
959
+ def _list_windows() -> None:
960
+ for hwnd, title, (l, t, r, b) in _enum_top_windows():
961
+ print(f"{l:>6} {t:>6} {r - l:>6} {b - t:>6} {title}")
962
+
963
+
964
+ def _goto_cmd(args: list) -> int:
965
+ """在浏览器地址栏打开 URL:Ctrl+L → 输入 → 等下拉稳定 → 回车。
966
+
967
+ 直接「Ctrl+L、type、enter」三连最容易翻车:地址栏的自动补全下拉还没收起来,
968
+ 回车会被它吃掉,页面不动。这里在输入后固定等 0.6s 再回车。
969
+ """
970
+ if not args:
971
+ print("用法:paw.py goto <url>", file=sys.stderr)
972
+ return 2
973
+ url = args[0]
974
+ _hotkey("ctrl", "l")
975
+ time.sleep(0.25)
976
+ _type_text(url)
977
+ time.sleep(0.6)
978
+ _press("enter")
979
+ time.sleep(0.3)
980
+ print("goto", url)
981
+ return 0
982
+
983
+
984
+ def _force_foreground(hwnd) -> bool:
985
+ """还原窗口并抢到前台。系统拒绝 SetForegroundWindow 时用 AttachThreadInput 强抢。"""
986
+ u = ctypes.windll.user32
987
+ u.ShowWindow(hwnd, 9) # SW_RESTORE:最小化的也能还原
988
+ time.sleep(0.15)
989
+ try:
990
+ u.SetForegroundWindow(hwnd)
991
+ except Exception:
992
+ pass
993
+ u.BringWindowToTop(hwnd)
994
+ time.sleep(0.15)
995
+ if u.GetForegroundWindow() == hwnd:
996
+ return True
997
+ fg = u.GetForegroundWindow()
998
+ tid_fg = u.GetWindowThreadProcessId(fg, None) if fg else 0
999
+ tid_me = u.GetWindowThreadProcessId(hwnd, None)
1000
+ if tid_fg and tid_me:
1001
+ u.AttachThreadInput(tid_me, tid_fg, True)
1002
+ try:
1003
+ u.ShowWindow(hwnd, 9)
1004
+ u.SetForegroundWindow(hwnd)
1005
+ u.BringWindowToTop(hwnd)
1006
+ finally:
1007
+ u.AttachThreadInput(tid_me, tid_fg, False)
1008
+ time.sleep(0.15)
1009
+ return u.GetForegroundWindow() == hwnd
1010
+
1011
+
1012
+ def _focus_cmd(args: list) -> int:
1013
+ """按标题关键词把窗口切到前台。点击之前先 focus,第一下才不会只落在「激活」上。
1014
+
1015
+ 最小化的窗口也能拉起来:先 ShowWindow(SW_RESTORE) 再抢前台。
1016
+ """
1017
+ if not args:
1018
+ print("用法:paw.py focus <标题关键词>", file=sys.stderr)
1019
+ return 2
1020
+
1021
+ key = " ".join(args).lower()
1022
+ hits = [(h, t) for h, t, _ in _enum_top_windows(include_minimized=True) if key in t.lower()]
1023
+ if not hits:
1024
+ print(f"没找到标题含「{key}」的窗口", file=sys.stderr)
1025
+ return 1
1026
+
1027
+ hwnd, title = hits[0]
1028
+ if _force_foreground(hwnd):
1029
+ print("focused:", title)
1030
+ return 0
1031
+ print("激活失败:系统拒绝切前台(试试先点一下别的窗口)", file=sys.stderr)
1032
+ return 1
1033
+
1034
+
1035
+ def _idle_seconds() -> float:
1036
+ """距上次键鼠输入过了多少秒。"""
1037
+ try:
1038
+ class LASTINPUTINFO(ctypes.Structure):
1039
+ _fields_ = [("cbSize", ctypes.c_uint), ("dwTime", ctypes.c_uint)]
1040
+
1041
+ lii = LASTINPUTINFO()
1042
+ lii.cbSize = ctypes.sizeof(LASTINPUTINFO)
1043
+ if not ctypes.windll.user32.GetLastInputInfo(ctypes.byref(lii)):
1044
+ return -1.0
1045
+ millis = ctypes.windll.kernel32.GetTickCount() - lii.dwTime
1046
+ return round(millis / 1000.0, 1)
1047
+ except Exception:
1048
+ return -1.0
1049
+
1050
+
1051
+ def _foreground_info() -> tuple:
1052
+ """前台窗口的 (标题, 宽, 高);没有就返回 ("(无)", 0, 0)。"""
1053
+ u = ctypes.windll.user32
1054
+ hwnd = u.GetForegroundWindow()
1055
+ if not hwnd:
1056
+ return "(无)", 0, 0
1057
+ length = u.GetWindowTextLengthW(hwnd)
1058
+ buf = ctypes.create_unicode_buffer(length + 1)
1059
+ u.GetWindowTextW(hwnd, buf, length + 1)
1060
+ w, h = _window_size(hwnd)
1061
+ return (buf.value.strip() or "(无标题)"), w, h
1062
+
1063
+
1064
+ def _watch_cmd(args: list) -> int:
1065
+ """实时说明「现在在干什么」:前台窗口、标题变化、鼠标位置、空闲时间。"""
1066
+ interval = float(args[0]) if args else 2.0
1067
+ prev = {}
1068
+ print(f"每 {interval}s 刷新一次,Ctrl+C 退出", file=sys.stderr)
1069
+ try:
1070
+ while True:
1071
+ now = time.strftime("%H:%M:%S")
1072
+ fg_title, fg_w, fg_h = _foreground_info()
1073
+ msg = self = None
1074
+ pos = _cursor_pos()
1075
+ idle = _idle_seconds()
1076
+ idle_txt = f"{idle}s" if idle >= 0 else "?"
1077
+ print(
1078
+ f"[{now}] 前台:{fg_title} [{fg_w}x{fg_h}]"
1079
+ f" | 鼠标 ({pos[0]},{pos[1]}) | 空闲 {idle_txt}",
1080
+ flush=True,
1081
+ )
1082
+
1083
+ cur = {h: t for h, t, _ in _enum_top_windows()}
1084
+ for hwnd, t in cur.items():
1085
+ if hwnd in prev and prev[hwnd] != t:
1086
+ print(f" ↳ 窗口变化:{prev[hwnd]!r} → {t!r}", flush=True)
1087
+ for hwnd, t in cur.items():
1088
+ if hwnd not in prev:
1089
+ print(f" ↳ 新窗口:{t!r}", flush=True)
1090
+ prev = cur
1091
+
1092
+ time.sleep(interval)
1093
+ except KeyboardInterrupt:
1094
+ print("已停止", file=sys.stderr)
1095
+ return 0
1096
+
1097
+
1098
+ def _screens_cmd(args: list) -> int:
1099
+ """用文字说清楚每个虚拟桌面上正开着什么(谁在前台)。"""
1100
+ try:
1101
+ from pyvda import VirtualDesktop
1102
+ except ImportError:
1103
+ print("需要 pyvda:pip install pyvda", file=sys.stderr)
1104
+ return 1
1105
+
1106
+ try:
1107
+ cur = VirtualDesktop.current().number
1108
+ except Exception:
1109
+ cur = -1
1110
+
1111
+ fg_hwnd = ctypes.windll.user32.GetForegroundWindow()
1112
+ groups = {}
1113
+ for hwnd, title, (l, t, r, b) in _enum_top_windows():
1114
+ try:
1115
+ desk = VirtualDesktop.get_desktop_of_window(hwnd).number
1116
+ except Exception:
1117
+ desk = cur # 查不到归属时,按当前桌面算
1118
+ groups.setdefault(desk, []).append((hwnd, title, r - l, b - t))
1119
+
1120
+ if not groups:
1121
+ print("没有可见窗口")
1122
+ return 0
1123
+
1124
+ for desk in sorted(groups):
1125
+ if desk == cur:
1126
+ head = f"桌面 {desk} *(当前)"
1127
+ elif desk == -1:
1128
+ head = "未归属桌面"
1129
+ else:
1130
+ head = f"桌面 {desk}"
1131
+ print(head)
1132
+ for hwnd, title, w, h in groups[desk]:
1133
+ mark = " ← 前台" if hwnd == fg_hwnd else ""
1134
+ print(f" - {title} [{w}x{h}]{mark}")
1135
+ return 0
1136
+
1137
+
1138
+ def _desk_cmd(args: list) -> int:
1139
+ """虚拟桌面管理:list / new / switch <n> / close。"""
1140
+ try:
1141
+ from pyvda import VirtualDesktop, get_virtual_desktops
1142
+ except ImportError:
1143
+ print("需要 pyvda:pip install pyvda", file=sys.stderr)
1144
+ return 1
1145
+
1146
+ sub = args[0] if args else "list"
1147
+ try:
1148
+ if sub == "list":
1149
+ cur = VirtualDesktop.current()
1150
+ for d in get_virtual_desktops():
1151
+ mark = " *" if d.number == cur.number else ""
1152
+ print(f"{d.number}{mark}")
1153
+ return 0
1154
+ if sub == "new":
1155
+ d = VirtualDesktop.create()
1156
+ print("created", d.number)
1157
+ return 0
1158
+ if sub == "switch":
1159
+ n = int(args[1])
1160
+ VirtualDesktop(n).go()
1161
+ print("switched", n)
1162
+ return 0
1163
+ if sub == "close":
1164
+ d = VirtualDesktop.current()
1165
+ n = d.number
1166
+ d.remove()
1167
+ print("closed", n)
1168
+ return 0
1169
+ print(f"未知子命令:{sub}(list / new / switch <n> / close)", file=sys.stderr)
1170
+ return 2
1171
+ except Exception as e: # noqa: BLE001 - 桌面 API 出错时给出人能看懂的提示
1172
+ print(f"虚拟桌面操作失败:{e}", file=sys.stderr)
1173
+ return 1
1174
+
1175
+
1176
+ PAWD_PID = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".pawd.json")
1177
+
1178
+
1179
+ def _pawd_info():
1180
+ try:
1181
+ with open(PAWD_PID, "r", encoding="utf-8") as f:
1182
+ return json.load(f)
1183
+ except Exception:
1184
+ return None
1185
+
1186
+
1187
+ def _pid_alive(pid: int) -> bool:
1188
+ """判断进程还在不在(用 OpenProcess,比 tasklist 快 100ms)。"""
1189
+ if not sys.platform.startswith("win"):
1190
+ try:
1191
+ os.kill(pid, 0)
1192
+ return True
1193
+ except Exception:
1194
+ return False
1195
+ try:
1196
+ kernel32 = ctypes.windll.kernel32
1197
+ handle = kernel32.OpenProcess(0x1000, False, int(pid))
1198
+ if not handle:
1199
+ return False
1200
+ kernel32.CloseHandle(handle)
1201
+ return True
1202
+ except Exception:
1203
+ return False
1204
+
1205
+
1206
+ def _pawd_alive() -> bool:
1207
+ info = _pawd_info()
1208
+ if not info:
1209
+ return False
1210
+ return _pid_alive(info.get("pid", 0))
1211
+
1212
+
1213
+ def _do_start(port: int) -> int:
1214
+ if _pawd_alive():
1215
+ print(f"已经在跑:127.0.0.1:{(_pawd_info() or {}).get('port')}")
1216
+ return 0
1217
+ import subprocess as sp
1218
+ import urllib.request
1219
+
1220
+ here = os.path.dirname(os.path.abspath(__file__))
1221
+ flags = 0
1222
+ if sys.platform.startswith("win"):
1223
+ flags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
1224
+ p = sp.Popen(
1225
+ [sys.executable, os.path.join(here, "serve.py"), str(port)],
1226
+ creationflags=flags,
1227
+ stdout=sp.DEVNULL,
1228
+ stderr=sp.DEVNULL,
1229
+ close_fds=True,
1230
+ )
1231
+ with open(PAWD_PID, "w", encoding="utf-8") as f:
1232
+ json.dump({"pid": p.pid, "port": port}, f)
1233
+ ready = False
1234
+ for _ in range(40):
1235
+ try:
1236
+ req = urllib.request.Request(
1237
+ f"http://127.0.0.1:{port}/api/action",
1238
+ data=b'{"line":"size"}',
1239
+ headers={"Content-Type": "application/json"},
1240
+ method="POST",
1241
+ )
1242
+ urllib.request.urlopen(req, timeout=1).read()
1243
+ ready = True
1244
+ break
1245
+ except Exception:
1246
+ time.sleep(0.2)
1247
+ if ready:
1248
+ print(f"已启动:127.0.0.1:{port}(pid {p.pid}),之后每条命令 ~9ms")
1249
+ return 0
1250
+ print("启动失败:服务没响应", file=sys.stderr)
1251
+ return 1
1252
+
1253
+
1254
+ def _do_stop() -> int:
1255
+ info = _pawd_info()
1256
+ if not info:
1257
+ print("没有在跑的服务")
1258
+ return 0
1259
+ import subprocess as sp
1260
+
1261
+ sp.run(["taskkill", "/PID", str(info["pid"]), "/F"], capture_output=True)
1262
+ try:
1263
+ os.remove(PAWD_PID)
1264
+ except Exception:
1265
+ pass
1266
+ print(f"已关闭(pid {info['pid']})")
1267
+ return 0
1268
+
1269
+
1270
+ def _remote_exec(port: str, cmd: str, args: list, as_json: bool) -> int:
1271
+ """把一条命令转发给常驻服务(serve.py),省掉每次解释器冷启动。"""
1272
+ import json as _json
1273
+ import urllib.request
1274
+
1275
+ import shlex as _shlex
1276
+
1277
+ line = " ".join(_shlex.quote(str(a)) for a in [cmd] + list(args))
1278
+ body = _json.dumps({"line": line}).encode("utf-8")
1279
+ url = f"http://127.0.0.1:{port}/api/action"
1280
+ req = urllib.request.Request(
1281
+ url, data=body, headers={"Content-Type": "application/json"}, method="POST"
1282
+ )
1283
+ try:
1284
+ with urllib.request.urlopen(req, timeout=15) as resp:
1285
+ raw = resp.read().decode("utf-8", "replace")
1286
+ except Exception as exc:
1287
+ print(f"常驻服务没在 127.0.0.1:{port} 上({exc})——先跑 python serve.py {port}", file=sys.stderr)
1288
+ return 1
1289
+
1290
+ try:
1291
+ data = _json.loads(raw)
1292
+ except Exception:
1293
+ print(raw)
1294
+ return 0
1295
+
1296
+ if as_json:
1297
+ print(_json.dumps(data, ensure_ascii=False))
1298
+ return 0 if data.get("ok") else 1
1299
+ out = (data.get("out") or "").strip()
1300
+ err = (data.get("err") or "").strip()
1301
+ if out:
1302
+ print(out)
1303
+ if err:
1304
+ print(err, file=sys.stderr)
1305
+ return 0 if data.get("ok") else 1
1306
+
1307
+
1308
+ def _parse_list(cmd: str, args: list) -> argparse.Namespace:
1309
+ """把参数列表当成命令行解析一遍(serve.py、steps、单元测试走这条路)。
1310
+
1311
+ 认不出的开关不报错,照旧留在位置词里;解析失败按参数错误处理。
1312
+ """
1313
+ try:
1314
+ ns, rest = _PARSER.parse_known_args([cmd] + list(args))
1315
+ except SystemExit:
1316
+ raise ValueError("开关写法不对")
1317
+ ns.args = list(getattr(ns, "args", None) or []) + list(rest)
1318
+ return ns
1319
+
1320
+
1321
+ def _scale(a: list, ns) -> int:
1322
+ w, h = _screen_size()
1323
+ s = _dpi_scale()
1324
+ print(f"缩放 {s:.2f}x({int(s * 100)}%):物理 {w}x{h},逻辑 {int(w / s)}x{int(h / s)}")
1325
+ print("shot 与 click 都用物理像素;按缩略图/预览图估坐标时,先乘这个比例。")
1326
+ return 0
1327
+
1328
+
1329
+ def _web(a: list, ns) -> int:
1330
+ port = int(ns.port) if ns.port else CDP_PORT
1331
+ try:
1332
+ return _do_web(" ".join(a), port)
1333
+ except Exception as exc:
1334
+ print(f"网页读取失败:{exc}", file=sys.stderr)
1335
+ return 1
1336
+
1337
+
1338
+ def _web_click(a: list, ns) -> int:
1339
+ port = int(ns.port) if ns.port else CDP_PORT
1340
+ if not a:
1341
+ print("用法:paw.py web-click \"按钮文字\" [--port 9222] [--real]", file=sys.stderr)
1342
+ return 2
1343
+ try:
1344
+ return _do_web_click(" ".join(a), port, real=bool(ns.real))
1345
+ except Exception as exc:
1346
+ print(f"网页点击失败:{exc}", file=sys.stderr)
1347
+ return 1
1348
+
1349
+
1350
+ def _uia_click(a: list, ns) -> int:
1351
+ if len(a) < 2:
1352
+ print("用法:paw.py uia-click <窗口标题关键词> <控件名>", file=sys.stderr)
1353
+ return 2
1354
+ return _do_uia_click(a[0], " ".join(a[1:]))
1355
+
1356
+
1357
+ def _clean(a: list, ns) -> int:
1358
+ keep = int(a[0]) if a else SHOT_KEEP
1359
+ print(f"清理了 {_clean_shots(keep)} 张(保留最近 {keep} 张):{_shot_dir()}")
1360
+ return 0
1361
+
1362
+
1363
+ def _shot(a: list, ns) -> int:
1364
+ c = [int(v) for v in str(ns.crop).split(",")] if ns.crop else []
1365
+ crop = (c[0], c[1], c[0] + c[2], c[1] + c[3]) if len(c) == 4 else None
1366
+ out = (a[-1] if a else None) or _default_shot_path()
1367
+ img = _grab_screen()
1368
+ if crop:
1369
+ img = img.crop(crop)
1370
+ if ns.jpg:
1371
+ img.convert("RGB").save(out, quality=88)
1372
+ else:
1373
+ img.save(out)
1374
+ _clean_shots()
1375
+ print(out, img.size)
1376
+ return 0
1377
+
1378
+
1379
+ def _drag(a: list, ns) -> int:
1380
+ x1, y1, x2, y2 = (int(v) for v in a[:4])
1381
+ dur = float(a[4]) if len(a) > 4 else 0.4
1382
+ _drag_to(x1, y1, x2, y2, dur)
1383
+ print("dragged", (x1, y1), "->", (x2, y2))
1384
+ return 0
1385
+
1386
+
1387
+ def _wait(a: list, ns) -> int:
1388
+ secs = float(a[0]) if a else 1.0
1389
+ time.sleep(max(0.0, secs))
1390
+ print("waited", secs, "s")
1391
+ return 0
1392
+
1393
+
1394
+ def _hotkey_cmd(a: list, ns) -> int:
1395
+ _hotkey(*a)
1396
+ print("hotkey", " ".join(a)) # 空参数时是 "hotkey ",和老版本一样
1397
+ return 0
1398
+
1399
+
1400
+ # 命令表:命令 → (动作, 打印前缀, 前缀后跟几个位置词;None 全部、0 不跟)。
1401
+ # 动作拿 (位置词, 解析好的开关),返回退出码(None 当 0);前缀为 None 时由
1402
+ # 动作自己打印。顺序照 CMD_LEVEL,方便对着权限表读。
1403
+ _COMMANDS = {
1404
+ "size": (lambda a, ns: print(_screen_size()), None, 0),
1405
+ "shot": (_shot, None, 0),
1406
+ "windows": (lambda a, ns: _list_windows(), None, 0),
1407
+ "find": (lambda a, ns: 0, "find 需要图像识别库,已不再内置;要按图找,用 uia(控件)或 web(网页)", 0),
1408
+ "scale": (_scale, None, 0),
1409
+ "launch": (lambda a, ns: _do_launch(a[0]), "launched", 1),
1410
+ "move": (lambda a, ns: _move_to(int(a[0]), int(a[1])), "moved", 2),
1411
+ "desks": (lambda a, ns: _desk_cmd(["list"]), None, 0),
1412
+ "desk": (lambda a, ns: _desk_cmd(a), None, 0),
1413
+ "screens": (lambda a, ns: _screens_cmd(a), None, 0),
1414
+ "watch": (lambda a, ns: _watch_cmd(a), None, 0),
1415
+ "focus": (lambda a, ns: _focus_cmd(a), None, 0),
1416
+ "wait": (_wait, None, 0),
1417
+ "goto": (lambda a, ns: _goto_cmd(a), None, 0),
1418
+ "uia": (lambda a, ns: _do_uia(a[0] if a else "", bool(getattr(ns, "deep", False))), None, 0),
1419
+ "start": (lambda a, ns: _do_start(int(a[0]) if a else 8000), None, 0),
1420
+ "stop": (lambda a, ns: _do_stop(), None, 0),
1421
+ "clean": (_clean, None, 0),
1422
+ "web": (_web, None, 0),
1423
+ "web-click": (_web_click, None, 0),
1424
+ "click": (lambda a, ns: _click_at(int(a[0]), int(a[1])), "clicked", 2),
1425
+ "dclick": (lambda a, ns: _click_at(int(a[0]), int(a[1]), double=True), "double-clicked", 2),
1426
+ "rclick": (lambda a, ns: _click_at(int(a[0]), int(a[1]), button="right"), "right-clicked", 2),
1427
+ "scroll": (lambda a, ns: _scroll_at(int(a[0])), "scrolled", 1),
1428
+ "drag": (_drag, None, 0),
1429
+ "type": (lambda a, ns: _type_text(" ".join(a)), "typed", 0),
1430
+ "key": (lambda a, ns: _press(a[0]), "key", 1),
1431
+ "hotkey": (_hotkey_cmd, None, 0),
1432
+ "uia-click": (_uia_click, None, 0),
1433
+ }
1434
+
1435
+ # 这几个命令在急停检查之前执行(看板、桌面切换不该被急停挡住)。
1436
+ _EARLY = frozenset(("watch", "screens", "desks", "desk"))
1437
+
1438
+
1439
+ def _dispatch(cmd: str, a: list, ns) -> int:
1440
+ """查表执行一条命令:先做动作,再按表里的前缀打印。"""
1441
+ fn, say, n = _COMMANDS[cmd]
1442
+ rc = fn(a, ns)
1443
+ if say:
1444
+ print(say, *(a if n is None else a[:n]))
1445
+ return 0 if rc is None else rc
1446
+
1447
+
1448
+ def run_one(
1449
+ cmd: str, args, level: str, show_plan: bool, dry_run: bool, note: str = ""
1450
+ ) -> int:
1451
+ """执行一条动作,返回退出码(0 成功)。args 可以是 Namespace 或字符串列表。"""
1452
+ if STOP_FLAG.is_set():
1453
+ print("急停中:拒绝执行(在 Web 看板点「解除急停」或重启进程)", file=sys.stderr)
1454
+ return 5
1455
+ if cmd not in CMD_LEVEL:
1456
+ print(f"未知动作:{cmd}", file=sys.stderr)
1457
+ return 2
1458
+ if not isinstance(args, argparse.Namespace):
1459
+ args = _parse_list(cmd, args)
1460
+ words = list(getattr(args, "args", None) or [])
1461
+
1462
+ if not _level_allows(level, cmd):
1463
+ print(f"权限不足:当前等级 {level},{cmd} 需要 {CMD_LEVEL[cmd]} 级权限", file=sys.stderr)
1464
+ return 3
1465
+
1466
+ hits = _danger_hits(" ".join(words)) if level == "safe" else []
1467
+ if hits:
1468
+ print(f"安全拦截:这一步看起来涉及「{hits[0]}」,safe 级不允许——"
1469
+ "要人工确认,或改用 --level full", file=sys.stderr)
1470
+ return 4
1471
+
1472
+ if show_plan:
1473
+ try:
1474
+ plan = _plan_text(cmd, args)
1475
+ except (IndexError, ValueError) as e:
1476
+ print(f"参数错误:{e}", file=sys.stderr)
1477
+ return 2
1478
+ print(f"计划:{plan + ' · ' + note if note else plan}", file=sys.stderr)
1479
+ if dry_run:
1480
+ return 0
1481
+ if cmd in _EARLY:
1482
+ return _dispatch(cmd, words, args)
1483
+ if cmd not in ("size", "shot", "windows") and _emergency_pressed():
1484
+ print("急停:检测到鼠标左右键同时按下,已中止", file=sys.stderr)
1485
+ return 1
1486
+
1487
+ try:
1488
+ return _dispatch(cmd, words, args)
1489
+ except (IndexError, ValueError) as e:
1490
+ print(f"参数错误:{e}", file=sys.stderr)
1491
+ return 2
1492
+ except Exception as exc: # noqa: BLE001 - failsafe 要单独认出来
1493
+ if type(exc).__name__ == "FailSafeException":
1494
+ print("failsafe: 已中止", file=sys.stderr)
1495
+ return 1
1496
+ raise
1497
+
1498
+
1499
+ def _split_step(line: str):
1500
+ """把一行 steps 拆成 (命令, 参数, 备注)。行内 # 之后是备注,会显示在行动表里。"""
1501
+ code, _, note = line.partition("#")
1502
+ parts = shlex.split(code.strip())
1503
+ if not parts:
1504
+ return "", [], note.strip()
1505
+ return parts[0], parts[1:], note.strip()
1506
+
1507
+
1508
+ def _read_steps(path: str) -> list:
1509
+ if path == "-":
1510
+ text = sys.stdin.read()
1511
+ else:
1512
+ with open(path, "r", encoding="utf-8-sig") as f:
1513
+ text = f.read()
1514
+ steps = []
1515
+ for line in text.splitlines():
1516
+ line = line.strip().lstrip("\ufeff")
1517
+ if not line or line.startswith("#"):
1518
+ continue
1519
+ cmd, args, note = _split_step(line)
1520
+ if cmd:
1521
+ steps.append((line, cmd, args, note))
1522
+ return steps
1523
+
1524
+
1525
+ # 子命令表:命令 → (位置参数的提示文字或 None, 开关列表, 一句话说明)。
1526
+ # 开关项形如 ("crop", "x,y,w,h"):第二个值为 None 表示不带值的开关。
1527
+ # 位置的取值一律当字符串收下,校验留在命令函数里:--json 的 args、行动表文案、
1528
+ # 「参数错误:…」的提示都要和以前逐字一致。
1529
+ _SUBCOMMANDS = {
1530
+ "size": (None, [], "读屏幕分辨率"),
1531
+ "shot": ("path", [("crop", "x,y,w,h"), ("jpg", None)], "截屏保存到文件"),
1532
+ "windows": (None, [], "列出窗口标题和位置"),
1533
+ "find": ("图片", [], "按图找坐标(需要图像识别库)"),
1534
+ "scale": (None, [], "读显示缩放比例"),
1535
+ "launch": ("目标", [], "打开网址或程序"),
1536
+ "move": ("x y", [], "把鼠标移到坐标"),
1537
+ "desks": (None, [], "列出虚拟桌面"),
1538
+ "desk": ("子命令 [参数]", [], "虚拟桌面操作:list / new / switch / close"),
1539
+ "screens": (None, [], "每个虚拟桌面上开着什么"),
1540
+ "watch": ("秒", [], "实时打印当前在做什么"),
1541
+ "focus": ("标题关键词", [], "把窗口切到前台"),
1542
+ "wait": ("秒", [], "等一会儿"),
1543
+ "goto": ("网址", [], "在浏览器地址栏打开"),
1544
+ "uia": ("窗口标题", [("deep", None)], "列出窗口里的控件"),
1545
+ "start": ("端口", [], "启动常驻服务"),
1546
+ "stop": (None, [], "停止常驻服务"),
1547
+ "clean": ("保留张数", [], "清理临时截图"),
1548
+ "web": ("关键词", [("port", "端口")], "列出网页里可点的元素"),
1549
+ "web-click": ("按钮文字", [("port", "端口"), ("real", None)], "点击网页里的元素"),
1550
+ "click": ("x y", [], "左键单击"),
1551
+ "dclick": ("x y", [], "左键双击"),
1552
+ "rclick": ("x y", [], "右键单击"),
1553
+ "scroll": ("格数", [], "滚动滚轮"),
1554
+ "drag": ("x1 y1 x2 y2 [时长]", [], "按住左键拖拽"),
1555
+ "type": ("文字", [], "输入文字"),
1556
+ "key": ("键名", [], "按一次键"),
1557
+ "hotkey": ("键名...", [], "按组合键"),
1558
+ "uia-click": ("窗口标题 控件名", [], "按控件名点击"),
1559
+ "run": ("steps.txt 或 -", [], "跑一串步骤"),
1560
+ }
1561
+
1562
+ _FLAGS_BY_CMD = {name: item[1] for name, item in _SUBCOMMANDS.items()}
1563
+
1564
+ # 公共开关:开关名 → (argparse 关键字参数, 说明)。默认值在 _DEFAULTS 里。
1565
+ _COMMON = [
1566
+ ("--delay", {"type": float}, "动作前等待秒数"),
1567
+ ("--pause", {"type": float}, "每个动作之间的间隔秒数(默认 0.15;想更快设 0.05,页面慢就设 1)"),
1568
+ ("--level", {"choices": list(LEVEL_ORDER.keys())}, "权限等级:browse / safe / full(默认 safe,可用 PC_LEVEL 环境变量)"),
1569
+ ("--no-plan", {"action": "store_true"}, "不打印行动表"),
1570
+ ("--json", {"action": "store_true"}, "以 JSON 输出结果(方便 agent 解析)"),
1571
+ ("--dry-run", {"action": "store_true"}, "只打印行动表,不执行"),
1572
+ ("--remote", {"nargs": "?", "const": os.environ.get("PAW_REMOTE", "8000")}, "转发给常驻服务执行(快 20 倍):--remote 8899,或设 PAW_REMOTE"),
1573
+ ("--parallel", {"action": "store_true"}, "run 时并行执行「打开/查看」类步骤(鼠标键盘动作仍串行,否则会打架)"),
1574
+ ]
1575
+
1576
+ _DEFAULTS = {
1577
+ "delay": 0.0, "pause": 0.15, "level": os.environ.get("PC_LEVEL", "safe"), "remote": None,
1578
+ "no_plan": False, "json": False, "dry_run": False, "parallel": False,
1579
+ }
1580
+
1581
+
1582
+ def _common_parser(quiet: bool = False) -> argparse.ArgumentParser:
1583
+ """公共开关。子 parser 用 SUPPRESS,否则子层的默认值会盖掉命令前面那份。"""
1584
+ p = argparse.ArgumentParser(add_help=False)
1585
+ for flag, kw, help_text in _COMMON:
1586
+ default = argparse.SUPPRESS if quiet else _DEFAULTS[flag[2:].replace("-", "_")]
1587
+ p.add_argument(flag, default=default, help=help_text, **kw)
1588
+ return p
1589
+
1590
+
1591
+ def _build_parser() -> argparse.ArgumentParser:
1592
+ """子命令解析器:CLI 和 _parse_list 共用一份。"""
1593
+ common = _common_parser(quiet=True)
1594
+ ap = argparse.ArgumentParser(
1595
+ description="control the mouse and keyboard from the CLI", parents=[_common_parser()]
1596
+ )
1597
+ subs = ap.add_subparsers(dest="cmd", metavar="cmd", required=True)
1598
+ for name in ALL_CMDS + ["run"]:
1599
+ metavar, flags, help_text = _SUBCOMMANDS[name]
1600
+ sp = subs.add_parser(name, parents=[common], help=help_text, description=help_text)
1601
+ if metavar:
1602
+ sp.add_argument("args", nargs="*", metavar=metavar)
1603
+ for flag, hint in flags:
1604
+ extra = {"action": "store_true"} if hint is None else {"metavar": hint}
1605
+ sp.add_argument("--" + flag, **extra)
1606
+ return ap
1607
+
1608
+
1609
+ _PARSER = _build_parser()
1610
+
1611
+
1612
+ def _send_args(cmd: str, ns, words: list) -> list:
1613
+ """把开关拼回参数表:--remote 转发用的是纯文本,对面收到还要再解析一遍。"""
1614
+ out = list(words)
1615
+ for flag, hint in _FLAGS_BY_CMD.get(cmd, ()):
1616
+ value = getattr(ns, flag, None)
1617
+ if value not in (None, False):
1618
+ out += ["--" + flag] + ([str(value)] if hint is not None else [])
1619
+ return out
1620
+
1621
+
1622
+ def _run_steps(a, args: list) -> int:
1623
+ """跑 steps 文件。--parallel 只并行「打开 / 查看」类步骤,鼠标键盘仍串行。"""
1624
+ if not args:
1625
+ print("用法:paw.py run <steps.txt|->", file=sys.stderr)
1626
+ return 2
1627
+ steps = _read_steps(args[0])
1628
+ if not steps:
1629
+ print("没有可执行的步骤", file=sys.stderr)
1630
+ return 2
1631
+
1632
+ if a.parallel:
1633
+ mouse_cmds = {"click", "dclick", "rclick", "scroll", "type", "key", "hotkey", "move"}
1634
+ bad = [s[1] for s in steps if s[1] in mouse_cmds]
1635
+ if bad:
1636
+ print("并行模式只并行「打开 / 查看」类步骤;鼠标键盘动作必须串行"
1637
+ f"(这些会打架:{', '.join(bad[:3])})", file=sys.stderr)
1638
+ return 2
1639
+ from concurrent.futures import ThreadPoolExecutor
1640
+
1641
+ def _one(item):
1642
+ idx, (line, cmd, sargs, note) = item
1643
+ print(f"[{idx}/{len(steps)}] {line}", file=sys.stderr)
1644
+ return run_one(cmd, sargs, a.level, not a.no_plan, a.dry_run, note)
1645
+
1646
+ with ThreadPoolExecutor(max_workers=min(8, len(steps))) as ex:
1647
+ results = list(ex.map(_one, enumerate(steps, 1)))
1648
+ bad_idx = next((i for i, rc in enumerate(results, 1) if rc != 0), None)
1649
+ if bad_idx is not None:
1650
+ print(f"第 {bad_idx} 步失败(返回码 {results[bad_idx - 1]})", file=sys.stderr)
1651
+ return results[bad_idx - 1]
1652
+ print(f"完成,共 {len(steps)} 步(并行)", file=sys.stderr)
1653
+ return 0
1654
+
1655
+ for idx, (line, cmd, sargs, note) in enumerate(steps, 1):
1656
+ print(f"[{idx}/{len(steps)}] {line}", file=sys.stderr)
1657
+ rc = run_one(cmd, sargs, a.level, not a.no_plan, a.dry_run, note)
1658
+ if rc != 0:
1659
+ print(f"第 {idx} 步失败(返回码 {rc}),已停止", file=sys.stderr)
1660
+ return rc
1661
+ print(f"完成,共 {len(steps)} 步", file=sys.stderr)
1662
+ return 0
1663
+
1664
+
1665
+ def main() -> int:
1666
+ a = _PARSER.parse_args()
1667
+ args = list(getattr(a, "args", None) or [])
1668
+
1669
+ if a.remote:
1670
+ return _remote_exec(a.remote, a.cmd, _send_args(a.cmd, a, args), a.json)
1671
+ if a.cmd in ("start", "stop"):
1672
+ return _do_start(int(args[0]) if args else 8000) if a.cmd == "start" else _do_stop()
1673
+
1674
+ if a.delay:
1675
+ time.sleep(a.delay)
1676
+ if a.pause >= 0:
1677
+ _PG_CONF["pause"] = a.pause
1678
+
1679
+ try:
1680
+ if a.cmd == "run":
1681
+ return _run_steps(a, args)
1682
+ if not a.json:
1683
+ return run_one(a.cmd, a, a.level, not a.no_plan, a.dry_run)
1684
+
1685
+ import contextlib
1686
+ import io
1687
+ import json as _json
1688
+
1689
+ try:
1690
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
1691
+ except Exception:
1692
+ pass
1693
+ out, err = io.StringIO(), io.StringIO()
1694
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
1695
+ rc = run_one(a.cmd, a, a.level, not a.no_plan, a.dry_run)
1696
+ rec = {"ok": rc == 0, "code": rc, "cmd": a.cmd, "args": args,
1697
+ "stdout": out.getvalue().strip(), "stderr": err.getvalue().strip()}
1698
+ if a.cmd in ("click", "dclick", "rclick", "move") and len(args) >= 2:
1699
+ try:
1700
+ rec["x"], rec["y"] = int(args[0]), int(args[1])
1701
+ rec["window"] = _window_at(rec["x"], rec["y"])
1702
+ except (ValueError, OSError):
1703
+ pass
1704
+ print(_json.dumps(rec, ensure_ascii=False))
1705
+ return rc
1706
+
1707
+ except KeyboardInterrupt:
1708
+ print("interrupted", file=sys.stderr)
1709
+ return 130
1710
+
1711
+
1712
+ if __name__ == "__main__":
1713
+ raise SystemExit(main())
1714
+