Add wechat-ops skill
macOS GUI automation via a screenshot → vision-locate → act → verify loop. Coordinates are never hard-coded; every step is located live and confirmed by re-screenshot. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
---
|
||||||
|
name: wechat-ops
|
||||||
|
description: 用「截图 → 视觉识别 → 动作 → 截图验证」的闭环操作 macOS 电脑,代替人手点按钮、输文字、切窗口、走界面流程。只要用户想让你「帮我点一下」「操作我的电脑」「自动发个朋友圈/消息」「把这段文字输入到某个软件」「帮我走一遍这个界面流程」「模拟点击」「识图点击」「截图找按钮」「GUI 自动化」「RPA」,都必须使用本技能——即使用户没说要"用技能"。核心特征:坐标绝不写死落盘,每次实时看图现算、事后验证。若目标是在 Figma 画布内做设计,请改用 Figma 系列技能。
|
||||||
|
---
|
||||||
|
|
||||||
|
# WeChat Ops
|
||||||
|
|
||||||
|
用视觉驱动的方式操作 macOS 电脑:**看屏幕 → 判断点哪里 → 动手 → 再看一眼确认**。
|
||||||
|
|
||||||
|
## 一、核心原则
|
||||||
|
|
||||||
|
### 1. 坐标是易耗品,绝不落盘
|
||||||
|
|
||||||
|
分辨率、窗口位置、应用版本、系统语言——任何一样变了,写死的坐标就失效。所以:
|
||||||
|
|
||||||
|
- **技能里存的是「怎么找到它」,不是「它在哪」**
|
||||||
|
- ❌ `朋友圈入口在 (30, 297)`
|
||||||
|
- ✅ `朋友圈是左侧边栏第 5 个图标,光圈形状`
|
||||||
|
- 坐标只存在于**本次任务的上下文**里,任务结束即作废。
|
||||||
|
- 如果你正想「把这次成功的坐标记下来下次直接用」——**停**。那是在造一个明天就会坏的缓存。该记的是语义特征和判断方法。
|
||||||
|
|
||||||
|
这条是整个技能的地基。写死坐标的方案(无论存成 JSON 还是 Markdown)都只是把脆弱的快照换了个格式。
|
||||||
|
|
||||||
|
### 2. 每步都要验证
|
||||||
|
|
||||||
|
视觉方案真正的价值**不是"能看图",而是"点完能确认"**。
|
||||||
|
|
||||||
|
每次动作后截一张图,确认结果符合预期,再做下一步。看起来慢,实际上:
|
||||||
|
|
||||||
|
- 点错了能立刻发现并纠正,而不是一路错到底
|
||||||
|
- 界面和预想不一致时(弹窗、加载中、位置变了)能及时改道
|
||||||
|
|
||||||
|
**判断标准**:如果这一步失败了你不会察觉,那这一步就该加验证。
|
||||||
|
|
||||||
|
### 3. 先想清楚交互方式,再动手
|
||||||
|
|
||||||
|
同一个控件,单击和长按可能完全是两回事。动手前先问:这个操作是单击、双击、长按、右键还是拖拽?
|
||||||
|
|
||||||
|
> 真实案例:微信朋友圈的相机图标,**单击打开"选图片"文件框,长按才出纯文字发布框**。没搞清楚就会点出一个文件选择器。
|
||||||
|
|
||||||
|
拿不准就先用 `--dry` 或在无关紧要的地方试。
|
||||||
|
|
||||||
|
## 二、坐标系(先读这段,否则必错)
|
||||||
|
|
||||||
|
**macOS Retina 屏上,截图分辨率和点击分辨率不是一回事。**
|
||||||
|
|
||||||
|
- `screencapture` 输出的是**物理像素**(Retina 通常是 2 倍)
|
||||||
|
- `pyautogui` 点击用的是**逻辑像素**
|
||||||
|
|
||||||
|
`vision.py` 已经处理了这件事:**截图时自动缩放到逻辑分辨率**,所以
|
||||||
|
|
||||||
|
> **图上量到的坐标 = 直接可用的点击坐标**
|
||||||
|
|
||||||
|
这正是为什么所有操作都要走 `vision.py` 而不是直接 `screencapture`——手工换算 2 倍关系是最高频的错误来源。
|
||||||
|
|
||||||
|
## 三、工具:`scripts/vision.py`
|
||||||
|
|
||||||
|
| 命令 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `shot [名字]` | 截当前屏,缩放到逻辑分辨率。始终刷新 `cur.png` |
|
||||||
|
| `grid [名字] [间距]` | 截图并叠加带刻度的坐标网格,**读位置不用估算** |
|
||||||
|
| `crop x1 y1 x2 y2 [名字] [倍数]` | 精确裁剪放大,看小图标/小字。左上角标注原点 |
|
||||||
|
| `click X Y` | 单击 |
|
||||||
|
| `dclick X Y` | 双击 |
|
||||||
|
| `longpress X Y [秒]` | 长按(默认 1.2 秒) |
|
||||||
|
| `move X Y` | 只移动鼠标 |
|
||||||
|
| `activate 名称` | 按窗口/进程名激活并置前 |
|
||||||
|
| `type "文本"` | 输入文本,走剪贴板,支持中文长文本 |
|
||||||
|
| `key cmd+v` | 按键,支持 `cmd+shift+f` 这类组合 |
|
||||||
|
| `scroll N [X Y]` | 滚动。**可指定鼠标落点**——滚轮事件发给指针所在的视图 |
|
||||||
|
| `pos` | 打印屏幕尺寸和当前鼠标位置 |
|
||||||
|
|
||||||
|
截图统一存在 `/tmp/wechat_ops/`。
|
||||||
|
|
||||||
|
**环境依赖**:`pip install pyautogui pyperclip pillow`,并在「系统设置 → 隐私与安全性 → 辅助功能」里授权终端。
|
||||||
|
|
||||||
|
## 四、标准流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 激活目标窗口 activate <名称>
|
||||||
|
2. 看清全貌 shot / grid
|
||||||
|
3. 定位目标元素 grid 读数 → crop 放大确认(两种方法对上再动手)
|
||||||
|
4. 执行动作 click / longpress / type / key
|
||||||
|
5. 截图验证 shot,确认结果符合预期
|
||||||
|
└─ 不符合 → 回到 2 重新定位
|
||||||
|
```
|
||||||
|
|
||||||
|
第 3 步的「两种方法对上」很重要:网格给你绝对位置,裁剪放大给你细节确认。两者一致时基本不会点错。
|
||||||
|
|
||||||
|
## 五、定位技巧
|
||||||
|
|
||||||
|
### 网格读数(找大致位置)
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 vision.py grid wxgrid 50
|
||||||
|
```
|
||||||
|
|
||||||
|
在截图上画出每 50px 的红色竖线、蓝色横线并标注刻度。直接读刻度,不靠目测。
|
||||||
|
|
||||||
|
### 裁剪放大(确认细节 + 精确定位)
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 vision.py crop 0 275 70 335 icon_check 6
|
||||||
|
```
|
||||||
|
|
||||||
|
放大 6 倍后能看清图标形状、文字内容。输出图**左上角标注了裁剪原点**(如 `origin=(0,275) x6`),在放大图里量到的偏移除以倍数、加上原点,就是绝对坐标。
|
||||||
|
|
||||||
|
这条标注是刻意设计的:没有它,在放大图里读数极易把原点搞错。
|
||||||
|
|
||||||
|
### 判断元素身份
|
||||||
|
|
||||||
|
图标类元素靠**形状**认(光圈=朋友圈、相机=发表、铃铛=通知),不靠位置猜。文字类直接放大读。
|
||||||
|
|
||||||
|
## 六、已知陷阱(都是实测踩过的)
|
||||||
|
|
||||||
|
### 1. 中文输入法会吃掉组合键
|
||||||
|
|
||||||
|
`pyautogui.hotkey('command', 'v')` 在拼音输入法下会**退化成裸字母 `v`**,弹出一个拼音候选框,文字根本没粘进去。
|
||||||
|
|
||||||
|
**解法**:用 AppleScript 发组合键。`vision.py` 的 `type` 命令在 macOS 上已默认走这条路:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
osascript -e 'tell application "System Events" to keystroke "v" using command down'
|
||||||
|
```
|
||||||
|
|
||||||
|
自己写脚本时也要注意这一点。
|
||||||
|
|
||||||
|
### 2. 读图命令会读到旧截图
|
||||||
|
|
||||||
|
这是**最隐蔽**的一类错误:图本身没错,错的是它描述的那个瞬间已经过去了。你会对着一个不存在的界面做判断,然后点空,而且**毫无察觉**。
|
||||||
|
|
||||||
|
实测踩过两次:`click → type → crop` 这样连写时,中间没有 `shot`,`crop` 读到的是动作**之前**的 `cur.png`——于是明明输入成功了,看到的却是「占位文字还在」,差点重输一遍。
|
||||||
|
|
||||||
|
`vision.py` 的处理:**`crop` / `grid` 每次自动重新截图**。宁可多截一张(几十毫秒),也不要看错。自己写脚本时照做。
|
||||||
|
|
||||||
|
### 3. 弹窗会挡住目标
|
||||||
|
|
||||||
|
点了一个按钮后可能弹出对话框、文件选择器、权限提示。**动作后一定截图**,否则下一步会点在弹窗上。
|
||||||
|
|
||||||
|
按 `esc` 通常能关掉误弹的框。
|
||||||
|
|
||||||
|
### 4. 输入前先确认焦点
|
||||||
|
|
||||||
|
点击输入框后,最好截图确认光标进去了(占位文字消失、出现光标),再输入。否则文字可能跑到搜索框之类的别处。
|
||||||
|
|
||||||
|
### 5. 滚动前先把鼠标移进目标区域
|
||||||
|
|
||||||
|
滚轮事件发给**指针当前所在的那个视图**。指针停在别处(比如左侧聊天列表),你想滚朋友圈流,滚的是聊天列表——而且界面上看起来"没反应",很容易误判成"已经到底了"。
|
||||||
|
|
||||||
|
`vision.py scroll N X Y` 会先移动鼠标再滚,就是为这个。
|
||||||
|
|
||||||
|
另外**滚动方向要实测**,别照搬直觉:本机实测 `pyautogui.scroll(正数)` 是让内容**往上走**(即翻到更旧的位置),和"正数向上"的字面意思相反。滚错方向时,你会以为已经到顶了,其实是在往下翻。
|
||||||
|
|
||||||
|
判断是否到顶:滚一大段后再反着滚回来,如果位置没变,那就是真到边界了。
|
||||||
|
|
||||||
|
### 6. 别在用户的真实账号里乱试
|
||||||
|
|
||||||
|
操作微信、邮箱、支付这类真实应用时,先想清楚这一步有没有副作用。
|
||||||
|
|
||||||
|
## 七、安全边界
|
||||||
|
|
||||||
|
以下动作**先跟用户确认再执行**:
|
||||||
|
|
||||||
|
- **对外发布**:发朋友圈、发消息、发邮件、提交表单
|
||||||
|
- **破坏性**:删除、覆盖、清空、退出未保存的编辑
|
||||||
|
- **花钱**:下单、支付、订阅
|
||||||
|
- **不可逆**:任何做了就回不去的操作
|
||||||
|
|
||||||
|
确认时把「将要发生什么」说清楚,而不是只问「可以吗」。
|
||||||
|
|
||||||
|
一个实用的做法:**把流程走到最后一步停住**,让用户看到完整状态(文案已填、设置已选),再决定要不要按下那个不可逆的按钮。
|
||||||
|
|
||||||
|
## 八、完整案例:微信朋友圈发布纯文字
|
||||||
|
|
||||||
|
这个案例贯穿了上面所有原则,可作模板参考。
|
||||||
|
|
||||||
|
> ⚠️ 下面括号里的坐标是**某一次实测值,仅供理解流程,不要照抄**——每次都要重新定位。
|
||||||
|
|
||||||
|
| 步骤 | 动作 | 定位方式 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `activate 微信` | 按进程名激活 |
|
||||||
|
| 2 | `grid` 看全貌 | 网格读数找到侧边栏第 5 个图标(光圈形状)= 朋友圈 |
|
||||||
|
| 3 | `crop` 放大确认形状 | 交叉验证坐标 (30, 297) |
|
||||||
|
| 4 | `click` 进入朋友圈 | 截图确认已进入 |
|
||||||
|
| 5 | 找发布按钮 | 裁剪右上角,认出三个图标中最右的**相机** |
|
||||||
|
| 6 | **`longpress` 相机图标** | ⚠️ 单击会弹文件框,长按才出纯文字框 |
|
||||||
|
| 7 | `click` 输入区 → `type "文案"` | 截图确认文字进去了 |
|
||||||
|
| 8 | 点「谁可以看」→ 选「私密」→ 确定 | 截图确认已变成「私密」 |
|
||||||
|
| 9 | ⏸️ **停住,问用户** | 发布是对外动作,确认后再点「发表」 |
|
||||||
|
|
||||||
|
关键教训都在这条流程里:长按 vs 单击、输入后验证、可见范围要显式设置、最后一步停下来问。
|
||||||
|
|
||||||
|
### 附:发图文朋友圈(和纯文字的关键差别)
|
||||||
|
|
||||||
|
同一个相机图标,**要图就单击,纯文字就长按**:
|
||||||
|
|
||||||
|
| 想要 | 对相机图标 | 后果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 图文 | **单击** | 弹出系统文件选择器,选完图进发布框 |
|
||||||
|
| 纯文字 | **长按** | 直接进发布框(单击只会弹文件框) |
|
||||||
|
|
||||||
|
图文流程比纯文字多两步:
|
||||||
|
|
||||||
|
1. 文件选择器里进目标目录(左侧「下载」「桌面」等),选中文件 → 点「打开」
|
||||||
|
- 选完**先裁剪确认高亮**再点打开,否则可能选中的是相邻文件
|
||||||
|
2. 发布框里多了图片缩略图,其余(输入文案、谁可以看、发表)与纯文字一致
|
||||||
|
|
||||||
|
### 发表之后怎么验证
|
||||||
|
|
||||||
|
**微信 Mac 版的朋友圈信息流不显示自己发的私密动态**——发完回到信息流看不到自己的帖子,这是正常的,别据此判断失败、更别重发。
|
||||||
|
|
||||||
|
真正可靠的验证入口:**点左下角自己的头像 → 资料卡里的「朋友圈」一行 → 我的朋友圈相册**,按「今天 / 昨天 / 更早」分组列出自己的全部动态。刚发的那条会出现在最上面。
|
||||||
|
|
||||||
|
(顺带一提:这个「信息流看不到」的现象反过来也能当作旁证——如果一条动态在信息流里也出现了,那它多半不是私密。)
|
||||||
|
|
||||||
|
## 九、和「录制回放」方案的区别
|
||||||
|
|
||||||
|
| | 录制回放(写死坐标) | 本技能(视觉驱动) |
|
||||||
|
|---|---|---|
|
||||||
|
| 定位时机 | 录制时定一次 | **每次执行时实时定位** |
|
||||||
|
| 界面变了 | 静默点错 | 看图发现异常,改道 |
|
||||||
|
| 出错后 | 不知道,继续错 | **截图验证,立即发现** |
|
||||||
|
| 存什么 | 像素坐标 | 语义特征 + 判断方法 |
|
||||||
|
| 适合 | 固定不动的界面 | 会变、会弹窗、跨机器的真实场景 |
|
||||||
|
|
||||||
|
代价是慢一些、每步要截图。但对真实桌面应用来说,可靠性远比速度重要。
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
视觉自动化助手 — 用「截图 + 视觉识别」代替固定坐标定位。
|
||||||
|
|
||||||
|
设计原则:
|
||||||
|
1. 截图统一缩放到逻辑分辨率,图上坐标 == 点击坐标(免换算)
|
||||||
|
2. 每个原子动作可独立调用,由视觉判断决定下一步
|
||||||
|
3. 点完可再截图验证,形成闭环
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 vision.py shot [名字] 截图到 /tmp/wechat_ops/<名字>.png
|
||||||
|
python3 vision.py click X Y 在逻辑坐标 (X,Y) 单击
|
||||||
|
python3 vision.py dclick X Y 双击
|
||||||
|
python3 vision.py move X Y 移动鼠标
|
||||||
|
python3 vision.py activate 微信 激活窗口
|
||||||
|
python3 vision.py type "文本" 输入文本(走剪贴板,支持中文)
|
||||||
|
python3 vision.py key enter 按键,如 enter / esc / cmd+v
|
||||||
|
python3 vision.py scroll N [X Y] 滚动(正数向上),可指定鼠标落点
|
||||||
|
python3 vision.py pos 打印当前鼠标位置与屏幕尺寸
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import shutil
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pyautogui
|
||||||
|
|
||||||
|
pyautogui.FAILSAFE = False
|
||||||
|
|
||||||
|
SHOT_DIR = "/tmp/wechat_ops"
|
||||||
|
IS_MAC = platform.system() == "Darwin"
|
||||||
|
MOD = "command" if IS_MAC else "ctrl"
|
||||||
|
|
||||||
|
|
||||||
|
def logical_size():
|
||||||
|
return pyautogui.size()
|
||||||
|
|
||||||
|
|
||||||
|
def shot(name="cur"):
|
||||||
|
"""截图并缩放到逻辑分辨率。始终刷新 cur.png(crop/grid 的唯一数据源)。"""
|
||||||
|
os.makedirs(SHOT_DIR, exist_ok=True)
|
||||||
|
sw, sh = logical_size()
|
||||||
|
raw = os.path.join(SHOT_DIR, "_raw.png")
|
||||||
|
cur = os.path.join(SHOT_DIR, "cur.png")
|
||||||
|
|
||||||
|
subprocess.run(["screencapture", "-x", raw], check=True)
|
||||||
|
# 缩放到逻辑尺寸:之后图上量到的坐标可直接用于点击
|
||||||
|
subprocess.run(
|
||||||
|
["sips", "-z", str(sh), str(sw), raw, "--out", cur],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
os.unlink(raw)
|
||||||
|
if name != "cur":
|
||||||
|
shutil.copyfile(cur, os.path.join(SHOT_DIR, f"{name}.png"))
|
||||||
|
print(f"{cur} ({sw}x{sh} 逻辑像素)")
|
||||||
|
return cur
|
||||||
|
|
||||||
|
|
||||||
|
def fresh_cur():
|
||||||
|
"""确保 cur.png 是当前屏幕,返回其路径。
|
||||||
|
|
||||||
|
只读图的命令(crop/grid)都先走这里。旧截图是最隐蔽的错误来源:
|
||||||
|
图本身没错,错的是它描述的那个瞬间已经过去了。
|
||||||
|
"""
|
||||||
|
return shot("cur")
|
||||||
|
|
||||||
|
|
||||||
|
def crop(x1, y1, x2, y2, name="crop", zoom=3):
|
||||||
|
"""裁剪截图局部并放大(PIL 精确裁剪)。坐标同逻辑坐标系。
|
||||||
|
|
||||||
|
输出图左上角带该区域的原点标注,便于在放大图内换算绝对坐标。
|
||||||
|
|
||||||
|
默认先重新截图:crop 若读到上一次动作之前的旧图,你会对着一个
|
||||||
|
已经不存在的界面做判断(实测踩过两次)。宁可多截一张,也不要看错。
|
||||||
|
"""
|
||||||
|
src = fresh_cur()
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)
|
||||||
|
im = Image.open(src).convert("RGB")
|
||||||
|
region = im.crop((x1, y1, x2, y2))
|
||||||
|
w, h = region.size
|
||||||
|
if zoom != 1:
|
||||||
|
region = region.resize((int(w * zoom), int(h * zoom)), Image.NEAREST)
|
||||||
|
|
||||||
|
d = ImageDraw.Draw(region)
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 12)
|
||||||
|
except Exception:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
# 标注原点,避免误读
|
||||||
|
d.rectangle([0, 0, 150, 16], fill=(255, 255, 0))
|
||||||
|
d.text((2, 2), f"origin=({x1},{y1}) x{zoom}", fill=(0, 0, 0), font=font)
|
||||||
|
|
||||||
|
out = os.path.join(SHOT_DIR, f"{name}.png")
|
||||||
|
region.save(out)
|
||||||
|
print(f"{out} 裁剪自 ({x1},{y1})-({x2},{y2}) 放大 {zoom}x")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def grid(name="grid", step=50):
|
||||||
|
"""截当前屏并叠加带刻度的坐标网格,消除读坐标时的估算误差。"""
|
||||||
|
src = shot("cur")
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
im = Image.open(src).convert("RGB")
|
||||||
|
d = ImageDraw.Draw(im)
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 12)
|
||||||
|
except Exception:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
w, h = im.size
|
||||||
|
for x in range(0, w, step):
|
||||||
|
d.line([(x, 0), (x, h)], fill=(255, 60, 60), width=1)
|
||||||
|
for y in range(0, h, step):
|
||||||
|
d.line([(0, y), (w, y)], fill=(60, 140, 255), width=1)
|
||||||
|
# 刻度标签(沿上边与左边)
|
||||||
|
for x in range(0, w, step):
|
||||||
|
d.text((x + 2, 2), str(x), fill=(255, 0, 0), font=font)
|
||||||
|
for y in range(0, h, step):
|
||||||
|
d.text((2, y + 2), str(y), fill=(0, 90, 255), font=font)
|
||||||
|
|
||||||
|
out = os.path.join(SHOT_DIR, f"{name}.png")
|
||||||
|
im.save(out)
|
||||||
|
print(f"{out} 网格间隔 {step}px")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def activate(title):
|
||||||
|
"""激活标题匹配的窗口(macOS 优先 AppleScript + open -a)。"""
|
||||||
|
if not IS_MAC:
|
||||||
|
wins = pyautogui.getWindowsWithTitle(title)
|
||||||
|
if not wins:
|
||||||
|
raise SystemExit(f'窗口 "{title}" 未找到')
|
||||||
|
wins[0].activate()
|
||||||
|
time.sleep(0.4)
|
||||||
|
print(f"已激活: {title}")
|
||||||
|
return
|
||||||
|
|
||||||
|
r = subprocess.run(
|
||||||
|
["osascript", "-e",
|
||||||
|
'tell application "System Events" to get name of first application '
|
||||||
|
f'process whose (name contains "{title}" or displayed name contains "{title}")'],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise SystemExit(f'未找到进程 "{title}"')
|
||||||
|
|
||||||
|
proc = r.stdout.strip()
|
||||||
|
subprocess.run(["open", "-a", proc], capture_output=True, timeout=5)
|
||||||
|
time.sleep(0.6)
|
||||||
|
# 多窗口时聚焦标题匹配的那个
|
||||||
|
subprocess.run(
|
||||||
|
["osascript", "-e", f'''
|
||||||
|
tell application "System Events"
|
||||||
|
tell process "{proc}"
|
||||||
|
try
|
||||||
|
perform action "AXRaise" of (first window whose title contains "{title}")
|
||||||
|
end try
|
||||||
|
end tell
|
||||||
|
end tell
|
||||||
|
'''],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
time.sleep(0.4)
|
||||||
|
print(f"已激活: {proc}")
|
||||||
|
|
||||||
|
|
||||||
|
def click(x, y, double=False):
|
||||||
|
x, y = int(x), int(y)
|
||||||
|
pyautogui.moveTo(x, y, duration=0.15)
|
||||||
|
pyautogui.click(x, y, clicks=2 if double else 1, interval=0.08)
|
||||||
|
print(f"已点击 ({x}, {y}){' 双击' if double else ''}")
|
||||||
|
|
||||||
|
|
||||||
|
def longpress(x, y, duration=1.2):
|
||||||
|
"""在 (x,y) 长按。微信朋友圈的相机图标需长按才出纯文字发布框。"""
|
||||||
|
x, y = int(x), int(y)
|
||||||
|
pyautogui.moveTo(x, y, duration=0.15)
|
||||||
|
time.sleep(0.2)
|
||||||
|
pyautogui.mouseDown()
|
||||||
|
time.sleep(duration)
|
||||||
|
pyautogui.mouseUp()
|
||||||
|
time.sleep(0.2)
|
||||||
|
print(f"已长按 ({x}, {y}) 持续 {duration}s")
|
||||||
|
|
||||||
|
|
||||||
|
def scroll(amount, x=None, y=None):
|
||||||
|
"""滚动。正数向上(看更早的内容),负数向下。
|
||||||
|
|
||||||
|
先把鼠标移到目标区域再滚:滚轮事件发给指针所在的视图,
|
||||||
|
指针停在别处就会滚错列表(实测:想滚朋友圈流却滚了聊天列表)。
|
||||||
|
"""
|
||||||
|
if x is not None and y is not None:
|
||||||
|
pyautogui.moveTo(int(x), int(y), duration=0.15)
|
||||||
|
time.sleep(0.15)
|
||||||
|
pyautogui.scroll(int(amount))
|
||||||
|
time.sleep(0.3)
|
||||||
|
print(f"已滚动 {amount}{f' @({x},{y})' if x is not None else ''}")
|
||||||
|
|
||||||
|
|
||||||
|
def type_text(text):
|
||||||
|
"""走剪贴板输入,支持中文与长文本。"""
|
||||||
|
try:
|
||||||
|
import pyperclip
|
||||||
|
except ImportError:
|
||||||
|
pyautogui.write(text, interval=0.03)
|
||||||
|
print(f"已输入(逐字): {text[:40]}")
|
||||||
|
return
|
||||||
|
|
||||||
|
old = None
|
||||||
|
try:
|
||||||
|
old = pyperclip.paste()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
pyperclip.copy(text)
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
# macOS 上用 AppleScript 发 Cmd+V:中文输入法下 pyautogui.hotkey 会把
|
||||||
|
# 组合键退化成裸字母(实测会输入一个 "v" 并弹出候选框)
|
||||||
|
if IS_MAC:
|
||||||
|
r = subprocess.run(
|
||||||
|
["osascript", "-e",
|
||||||
|
'tell application "System Events" to keystroke "v" using command down'],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
pyautogui.hotkey(MOD, "v")
|
||||||
|
else:
|
||||||
|
pyautogui.hotkey(MOD, "v")
|
||||||
|
time.sleep(0.3)
|
||||||
|
# 还原剪贴板
|
||||||
|
if old is not None:
|
||||||
|
try:
|
||||||
|
pyperclip.copy(old)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print(f"已输入(剪贴板): {text[:40]}{'...' if len(text) > 40 else ''}")
|
||||||
|
|
||||||
|
|
||||||
|
KEYMAP = {
|
||||||
|
"enter": "enter", "return": "enter", "esc": "esc", "escape": "esc",
|
||||||
|
"tab": "tab", "space": "space", "delete": "delete", "backspace": "backspace",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def press_key(spec):
|
||||||
|
"""按键。支持 "cmd+v" / "cmd+shift+f" / "enter" 形式。"""
|
||||||
|
parts = [p.strip().lower() for p in spec.split("+")]
|
||||||
|
key = KEYMAP.get(parts[-1], parts[-1])
|
||||||
|
mods = []
|
||||||
|
for m in parts[:-1]:
|
||||||
|
mods.append({"cmd": "command", "command": "command", "ctrl": "ctrl",
|
||||||
|
"control": "ctrl", "alt": "option", "opt": "option",
|
||||||
|
"shift": "shift"}.get(m, m))
|
||||||
|
if mods:
|
||||||
|
pyautogui.hotkey(*mods, key)
|
||||||
|
else:
|
||||||
|
pyautogui.press(key)
|
||||||
|
print(f"已按键: {spec}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
a = sys.argv[2:]
|
||||||
|
|
||||||
|
if cmd == "shot":
|
||||||
|
shot(a[0] if a else "cur")
|
||||||
|
elif cmd == "click":
|
||||||
|
click(a[0], a[1])
|
||||||
|
elif cmd == "dclick":
|
||||||
|
click(a[0], a[1], double=True)
|
||||||
|
elif cmd == "longpress":
|
||||||
|
longpress(a[0], a[1], float(a[2]) if len(a) > 2 else 1.2)
|
||||||
|
elif cmd == "move":
|
||||||
|
pyautogui.moveTo(int(a[0]), int(a[1]), duration=0.15)
|
||||||
|
print(f"已移动 ({a[0]}, {a[1]})")
|
||||||
|
elif cmd == "grid":
|
||||||
|
grid(a[0] if a else "grid", int(a[1]) if len(a) > 1 else 50)
|
||||||
|
elif cmd == "crop":
|
||||||
|
crop(a[0], a[1], a[2], a[3],
|
||||||
|
a[4] if len(a) > 4 else "crop",
|
||||||
|
float(a[5]) if len(a) > 5 else 3)
|
||||||
|
elif cmd == "scroll":
|
||||||
|
if not a:
|
||||||
|
raise SystemExit("用法: vision.py scroll N [X Y] N 为正数时内容上移")
|
||||||
|
scroll(a[0], a[1] if len(a) > 1 else None, a[2] if len(a) > 2 else None)
|
||||||
|
elif cmd == "activate":
|
||||||
|
activate(a[0])
|
||||||
|
elif cmd == "type":
|
||||||
|
type_text(a[0] if a else "")
|
||||||
|
elif cmd == "key":
|
||||||
|
press_key(a[0])
|
||||||
|
elif cmd == "pos":
|
||||||
|
sw, sh = logical_size()
|
||||||
|
print(f"屏幕: {sw}x{sh} 鼠标: {pyautogui.position()}")
|
||||||
|
else:
|
||||||
|
print(f"未知命令: {cmd}")
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except IndexError:
|
||||||
|
# 参数少写一个就抛裸 IndexError,看不出是哪条命令缺什么。给出用法更省事。
|
||||||
|
print(f"命令 {sys.argv[1] if len(sys.argv) > 1 else ''} 参数不足\n")
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(1)
|
||||||
Reference in New Issue
Block a user