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
|
+
|