feat(任务): 滑动拟人化 + 每台设备有自己的手感(core/humanize.py)
用户反馈"滑动太像机器人""批量执行时每台设备都一样"。 core/humanize.py(新)分两个层次: - **每次不同**:起止点/幅度/时长抖动、弧线方向随机——最容易被识别的不是"慢", 是"每次都一模一样"; - **每台设备不同**:由 crc32(serial) 派生稳定的"性格"(手速 0.82~1.32、 幅度 0.86~1.16、弧度、常用横坐标 ±9% 屏宽、停顿 0.80~1.35)。同设备多次运行 风格一致,不同设备明显不同——13 台批量跑看着像 13 个人各刷各的。 用独立 Random 实例播种,不碰全局 random(多线程 worker 会打乱取值顺序)。 轨迹用二次贝塞尔走 d.swipe_points(曲线),异常时自动退回直线 d.swipe。 **点数固定 4 个**:swipe_points 在慢设备上每多一个点约多 1 秒(实测 2 点 1.4s / 6 点 6.0s / 10 点 10.6s),设备自己会插值几十步,4 点已足够弯。 顺带修一个真机上的老毛病:滑动原本每次都要读 d.info,而它在部分设备上要 **14 秒**。改用 humanize.screen_size()(走 window_size,同设备 0.6s,缓存 120s)——所以哪怕多了弧线,真机单次滑动反而从 ~15s 降到 ~5s。 - tasks/generic/task.py:swipe / swipe_until 走拟人(新增 distance_ratio、 jitter、humanize 参数,默认开);swipe_until 每轮停顿也抖动;wait 步骤新增 可选 vary_pace(默认关,按设备节奏缩放 0.8~1.35 倍); - static/admin/editor.js:滑动类步骤参数面板加"幅度/抖动/拟人轨迹"+ 说明; - 文档:TASK_DEV.md §8.4(含"点数别调大""别用 d.info"两个坑)、步骤表、 README 能力表与结构。 自测:假设备单测(曲线/抖动/设备间差异/退化路径/不越界/尺寸缓存)+ 真机联调 (4 次滑动全部 ok,标注"弧线"、坐标每次不同、对照的 humanize=false 仍是直线)。
This commit is contained in:
@@ -47,6 +47,7 @@
|
||||
| **设备池** | SQLite 清单 + adb 在线状态;支持手工添加、网段自动发现、一键重连、型号采集、启用/停用 |
|
||||
| **任务调度** | 手动 / cron 定时 / 定时启停;运行窗口;失败重试(含端口耗尽类的长退避) |
|
||||
| **步骤编辑器** | 可视化拖拽编排 18 种步骤(含循环/条件/OCR),可打包成"自定义动作"复用 |
|
||||
| **拟人化操作** | 滑动默认走弧线轨迹、位置/幅度/时长每次抖动,且**每台设备有自己的手速与习惯**(同设备风格稳定、设备之间明显不同)——批量跑时不像同步机器人(见 [TASK_DEV.md](doc/TASK_DEV.md) §8.4) |
|
||||
| **元素抓取** | 拉取设备 UI 元素树 → 点选回填选择器;支持"点一下"与"测选择器"真机验证 |
|
||||
| **实时看屏** | MJPEG 实时流 + 点击/滑动/按键/文字输入;全屏监控大屏(`/wall`) |
|
||||
| **应用管理** | APK 上传/解析/批量安装;设备已装应用与版本查询;剪贴板注入 |
|
||||
@@ -138,6 +139,7 @@ auto_control/
|
||||
│ ├── clipboard_helper.py # 剪贴板注入(ClipInject 通道)
|
||||
│ ├── apk_manager.py # APK 上传/解析/批量安装
|
||||
│ ├── system_backup.py # 数据备份导出/导入(重启生效)
|
||||
│ ├── humanize.py # 拟人化:弧线滑动/抖动 + 每台设备的"手感"(按 serial 播种)
|
||||
│ ├── notifier.py # 通知分发(队列/聚合/限流/适配器)+ notify_events.py 事件目录
|
||||
│ ├── step_log.py # 任务步骤明细(异步写线程 + 保留期清理)
|
||||
│ ├── tailscale_client.py # Tailscale API v2 客户端
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""拟人化操作:滑动不机械,且**每台设备有自己的手感**。
|
||||
|
||||
分两个层次,别混在一起:
|
||||
|
||||
1. **每次都不一样**(抖动):起点/终点位置、滑动幅度、时长、弧线方向都随机——
|
||||
最容易被识别的特征不是"慢",而是"同一帧重复播放"。
|
||||
2. **每台设备稳定不同**(性格):由 `serial` 派生一份固定的偏好(手速、幅度、
|
||||
弧度、停顿、常用触点的横向位置)。同一台设备多次运行风格一致,不同设备之间
|
||||
明显不同——批量跑 13 台时看起来像 13 个人各刷各的,而不是 13 台同步机器人。
|
||||
|
||||
**性格稳定、抖动随机**,两者都由 serial 播种,不引入全局状态、不做任何 IO。
|
||||
|
||||
性格怎么来的:`crc32(serial)` 作种子 → 几个围绕 1.0 的比例因子(见 `profile()`)。
|
||||
换设备换风格,改名字**不改**风格(按 serial 而不是设备名),换地址会换风格——
|
||||
这符合直觉:地址代表"这一台"。
|
||||
|
||||
想手工看一眼某台设备的性格:`python -c "from core import humanize; print(humanize.profile('192.168.20.55:5555'))"`
|
||||
"""
|
||||
import random
|
||||
import time
|
||||
import zlib
|
||||
|
||||
from core.logger import get_logger
|
||||
|
||||
_log = get_logger("core.humanize")
|
||||
|
||||
_PROFILE_CACHE = {}
|
||||
# serial -> (w, h, ts):屏幕尺寸缓存(见 screen_size)
|
||||
_SIZE_CACHE = {}
|
||||
_SIZE_TTL = 120
|
||||
|
||||
|
||||
def profile(serial):
|
||||
"""设备性格(按 serial 缓存,恒定)。字段都是围绕 1.0 的比例因子。"""
|
||||
key = str(serial or "")
|
||||
p = _PROFILE_CACHE.get(key)
|
||||
if p is not None:
|
||||
return p
|
||||
# 用独立的 Random 实例而不是全局 random:worker 是多线程的,全局 random 的
|
||||
# 取值顺序会被其它线程打乱 —— 那样"每台设备的性格"就串味了
|
||||
r = random.Random(zlib.crc32(("human:" + key).encode("utf-8")))
|
||||
p = {
|
||||
"speed": r.uniform(0.82, 1.32), # <1 划得快(时长短),>1 划得慢
|
||||
"distance": r.uniform(0.86, 1.16), # 有人习惯一划到底,有人小步快跑
|
||||
"curve": r.uniform(0.4, 1.6), # 弧线弯度系数
|
||||
"x_bias": r.uniform(-0.09, 0.09), # 常用触点的横向偏移(屏幕宽比例)
|
||||
"pause": r.uniform(0.80, 1.35), # 停顿倍率(wait 步骤可选启用)
|
||||
}
|
||||
_PROFILE_CACHE[key] = p
|
||||
return p
|
||||
|
||||
|
||||
def screen_size(d, serial=""):
|
||||
"""屏幕尺寸,带缓存。
|
||||
|
||||
为什么不用 `d.info`:它在部分设备上**极慢**(实测 14s,见 doc/backlog),而
|
||||
尺寸在一次运行里根本不会变。`d.window_size()` 快得多(同设备 0.6s)。
|
||||
缓存 120s 过期:够躲开慢调用,又能在屏幕旋转/换分辨率后自愈。
|
||||
"""
|
||||
key = str(serial or "")
|
||||
now = time.time()
|
||||
hit = _SIZE_CACHE.get(key)
|
||||
if hit and now - hit[2] < _SIZE_TTL:
|
||||
return hit[0], hit[1]
|
||||
try:
|
||||
w, h = d.window_size()
|
||||
except Exception: # 老 agent 没有 window_size 就退回 info
|
||||
info = d.info
|
||||
w, h = info["displayWidth"], info["displayHeight"]
|
||||
w, h = int(w), int(h)
|
||||
if w > 0 and h > 0:
|
||||
_SIZE_CACHE[key] = (w, h, now)
|
||||
return w, h
|
||||
|
||||
|
||||
def _bezier(p0, p1, p2, n):
|
||||
"""二次贝塞尔采样——手指划出来是弧线,不是尺子画的直线。"""
|
||||
pts = []
|
||||
for i in range(n + 1):
|
||||
t = i / n
|
||||
x = (1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * p1[0] + t * t * p2[0]
|
||||
y = (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * p1[1] + t * t * p2[1]
|
||||
pts.append((int(round(x)), int(round(y))))
|
||||
# 同样的点会连续出现(曲线很平时)——去掉,免得下发冗余坐标
|
||||
out = [pts[0]]
|
||||
for p in pts[1:]:
|
||||
if p != out[-1]:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def swipe(d, direction, serial="", duration_min=0.25, duration_max=0.50,
|
||||
distance_ratio=0.6, jitter=0.15, humanize=True):
|
||||
"""按人类习惯滑一下(上/下/左/右)。
|
||||
|
||||
参数:
|
||||
direction 上滑 up / 下滑 down / 左滑 left / 右滑 right
|
||||
serial 用来取本设备的性格(不传就是"普通人")
|
||||
duration_min/max 手指接触屏幕的时长区间(秒),再乘本设备手速系数
|
||||
distance_ratio 滑动幅度占屏幕高(竖滑)或宽(横滑)的比例,默认 0.6
|
||||
jitter 抖动幅度 0~0.4:位置/幅度/时长各抖这么多;0 = 每次都一样
|
||||
humanize False = 退回老的直线滑动(完全规则,用于对照/排障)
|
||||
|
||||
返回实际参数 dict(时长/起止点),方便日志与排障。异常向上抛,由调用方兜。
|
||||
"""
|
||||
w, h = screen_size(d, serial)
|
||||
prof = profile(serial)
|
||||
vertical = direction in ("up", "down")
|
||||
j = max(0.0, min(float(jitter or 0), 0.4))
|
||||
|
||||
# ---- 时长:用户给的范围 × 本设备手速 × 本次抖动 ----
|
||||
dur = random.uniform(float(duration_min), float(duration_max))
|
||||
dur *= prof["speed"]
|
||||
if j:
|
||||
dur *= 1 + random.uniform(-j, j) * 0.6
|
||||
dur = max(0.08, round(dur, 3))
|
||||
|
||||
# ---- 幅度与中心点 ----
|
||||
span = (h if vertical else w) * float(distance_ratio) * prof["distance"]
|
||||
if j:
|
||||
span *= 1 + random.uniform(-j, j)
|
||||
span = max(0.08, min(span, (h if vertical else w) * 0.92))
|
||||
|
||||
# 起点不在正中:人不会每次都在屏幕同一条线上划
|
||||
if vertical:
|
||||
cx = w * (0.5 + prof["x_bias"] + (random.uniform(-j, j) * 0.6 if j else 0))
|
||||
cx = int(max(w * 0.10, min(cx, w * 0.90)))
|
||||
cy = h * 0.5
|
||||
if j:
|
||||
cy += h * random.uniform(-j, j) * 0.5
|
||||
sign = -1 if direction == "up" else 1
|
||||
p0 = (cx, int(max(h * 0.03, min(cy - sign * span / 2, h * 0.97))))
|
||||
p2 = (cx, int(max(h * 0.03, min(cy + sign * span / 2, h * 0.97))))
|
||||
else:
|
||||
cy = int(h * (0.5 + prof["x_bias"] + (random.uniform(-j, j) * 0.6 if j else 0)))
|
||||
cy = int(max(h * 0.10, min(cy, h * 0.90)))
|
||||
cx = w * 0.5
|
||||
if j:
|
||||
cx += w * random.uniform(-j, j) * 0.5
|
||||
sign = -1 if direction == "left" else 1
|
||||
p0 = (int(max(w * 0.03, min(cx - sign * span / 2, w * 0.97))), cy)
|
||||
p2 = (int(max(w * 0.03, min(cx + sign * span / 2, w * 0.97))), cy)
|
||||
|
||||
if not humanize:
|
||||
# 老行为:正中直线、无抖动(点与点之间由设备等分插值)
|
||||
if vertical:
|
||||
s, e = (0.8, 0.2) if direction == "up" else (0.2, 0.8)
|
||||
base = (int(w * 0.5), int(h * s), int(w * 0.5), int(h * e))
|
||||
else:
|
||||
s, e = (0.8, 0.2) if direction == "left" else (0.2, 0.8)
|
||||
base = (int(w * s), int(h * 0.5), int(w * e), int(h * 0.5))
|
||||
d.swipe(*base, random.uniform(float(duration_min), float(duration_max)))
|
||||
return {"duration": dur, "from": base[:2], "to": base[2:], "human": False}
|
||||
|
||||
# ---- 弧线:控制点在中点、垂直于滑动方向偏移 ----
|
||||
amp = span * 0.05 * prof["curve"]
|
||||
if j:
|
||||
amp *= random.uniform(0.3, 1.0)
|
||||
amp *= random.choice((-1, 1)) # 这次往左弯还是往右弯
|
||||
else:
|
||||
# jitter=0 时不引入任何随机:弯向由设备性格决定,便于对照排障
|
||||
amp *= -1 if prof["curve"] < 1.0 else 1
|
||||
mx, my = (p0[0] + p2[0]) / 2, (p0[1] + p2[1]) / 2
|
||||
if vertical:
|
||||
ctrl = (mx + amp, my)
|
||||
else:
|
||||
ctrl = (mx, my + amp)
|
||||
# 点数刻意压到 4:`swipe_points` 在慢设备上**每多一个点就多约 1s**
|
||||
# (实测 2 点 1.4s / 6 点 6.0s / 10 点 10.6s),而设备自己会在点之间
|
||||
# 插值 70 步,4 个点的贝塞尔已经足够弯
|
||||
pts = _bezier(p0, ctrl, p2, 4)
|
||||
if len(pts) < 2: # 幅度太小时退化成直线
|
||||
pts = [p0, p2]
|
||||
try:
|
||||
d.swipe_points([[x, y] for x, y in pts], dur)
|
||||
except Exception as e: # 老设备/agent 不支持曲线就退回直线
|
||||
_log.warning("swipe_points 不可用,退回直线滑动: %s", e)
|
||||
d.swipe(p0[0], p0[1], p2[0], p2[1], dur)
|
||||
return {"duration": dur, "from": p0, "to": p2, "curve": amp, "human": True}
|
||||
|
||||
|
||||
def pace(serial, seconds, enabled=True):
|
||||
"""按本设备节奏微调一个停顿(wait 步骤可选启用)。enabled=False 原样返回。"""
|
||||
if not enabled:
|
||||
return float(seconds)
|
||||
return float(seconds) * profile(serial)["pause"]
|
||||
+37
-3
@@ -120,15 +120,15 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|
||||
| 4 | `screen_off` | 息屏 | — | — | — | `d.screen_off()` |
|
||||
| 5 | `keep_screen` | 保持亮屏 | `mode`("on") | — | — | `mode="off"` → 恢复自动息屏;否则**把系统息屏超时顶到最大**(`screen_off_timeout=2147483647`)+ `svc power stayon true` + 唤醒一次。⚠️ 只发 `svc power stayon true` 对**没插充电器**的设备(走 WiFi 的那些)**完全无效**——它管的是"充电时屏幕常亮",2026-09-14 就是因此被反馈"保持亮屏没用" |
|
||||
| 6 | `key_event` | 按键 | `key`("back") | — | — | `d.press(key)`;**值直传,无白名单校验** |
|
||||
| 7 | `swipe` | 滑动 | `direction`("up") | `duration_min`(0.25)、`duration_max`(0.50) | — | 时长取随机值;up = 中轴从 0.8h → 0.2h(down 反向,left/right 同理);**非法 direction 静默不滑** |
|
||||
| 8 | `swipe_until` | 滑动直到元素 | `selector_type`、`selector_value` | `direction`("up")、`max_swipes`(8)、`click_when_found`(True) | — | 每轮滑动后 sleep 0.5s;命中即(可选)点击并返回 True;**只有 `direction="down"` 才是向下滑**,其余按上滑 |
|
||||
| 7 | `swipe` | 滑动 | `direction`("up") | `duration_min`(0.25)、`duration_max`(0.50)、`distance_ratio`(0.6)、`jitter`(0.15)、`humanize`(True) | — | **默认拟人**:弧线轨迹 + 起止点/幅度/时长抖动 + 本设备手速偏好(见 §8.4);`humanize:false` 还原老的「正中直线」;`jitter:0` 时位置/幅度不抖(时长仍随机);**非法 direction 静默不滑** |
|
||||
| 8 | `swipe_until` | 滑动直到元素 | `selector_type`、`selector_value` | `direction`("up")、`max_swipes`(8)、`click_when_found`(True)、`duration_min`(0.25)、`duration_max`(0.50)、`distance_ratio`(0.6)、`jitter`(0.15)、`humanize`(True) | — | 每次滑动都重新生成轨迹(同 §8.4);每轮滑动后随机停 0.35~0.75s;命中即(可选)点击并返回 True;**只有 `direction="down"` 才是向下滑**,其余按上滑 |
|
||||
| 9 | `click` | 点击元素 | `selector_type`、`selector_value` | `wait_timeout`(2) | — | xpath 用 `wait(timeout)`,其它选择器用 `exists(timeout)`;`wait_timeout=0` 为立即判断。**空选择器只告警** |
|
||||
| 10 | `click_xy` | 点击坐标 | — | `x`(50)、`y`(50) | — | **屏幕百分比**(0-100,会夹取),50/50 = 屏幕中心。无选择器时的兜底,**最脆的方式** |
|
||||
| 11 | `long_click` | 长按元素 | `selector_type`、`selector_value` | `duration`(1.0)、`wait_timeout`(2) | — | 先等元素出现再 `long_click` |
|
||||
| 12 | `wait_el` | 等待元素 | `selector_type`、`selector_value` | `timeout`(10) | — | 等元素出现(条件等待,优于固定 `wait`) |
|
||||
| 13 | `input_text` | 输入文字 | — | `mode`("random")、`texts`("你好\n有趣\n支持")、`fixed_text`("")、`clear_first`(True) | — | `mode="fixed"` 用 `fixed_text`,否则从 `texts` 按行随机选一条;**只负责输入,不负责定位输入框**(要先 click 输入框) |
|
||||
| 14 | `clipboard` | 剪贴板注入 | `text` | `paste`(True) | — | 走 ClipInject 通道(绕开 Android 10+ 后台写剪贴板限制);`paste=true` 时再触发一次粘贴 |
|
||||
| 15 | `wait` | 等待 | — | `min`(1.0)、`max`(3.0) | — | 随机时长;**分片 sleep**(每 ≤0.5s 检查停止/超时),可被抢占打断 |
|
||||
| 15 | `wait` | 等待 | — | `min`(1.0)、`max`(3.0)、`vary_pace`(False) | — | 随机时长;**分片 sleep**(每 ≤0.5s 检查停止/超时),可被抢占打断;勾了 `vary_pace` 再按**本设备节奏**缩放 0.8~1.35 倍(批量跑时设备之间会逐渐错开) |
|
||||
| 16 | `loop` | 循环块 | — | `loop_mode`("rounds")、`max_iterations`(10)、`loop_duration`(600) | `children` | 见 §4.1 |
|
||||
| 17 | `group` | 动作组 | — | — | `children` | 子步骤**按序执行一次**(不循环);自定义动作拖入画布就是展开成 group |
|
||||
| 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False) | `then` / `else` | 见 §4.2 |
|
||||
@@ -375,6 +375,40 @@ from .myapp import task # ← 新增
|
||||
`TaskManager._run_with_retry` 传入;**只用于结构化记录**(如步骤明细
|
||||
`core/step_log.py`),不参与业务逻辑。不关心就原样透传给 `BaseWorker` 即可。
|
||||
|
||||
### 8.4 拟人化:滑动与设备节奏(`core/humanize.py`)
|
||||
|
||||
自动化最容易被识别的地方不是"慢",是**每次都一模一样**:同一坐标、同一时长、
|
||||
13 台设备整齐划一地做同一个动作。`core/humanize.py` 从两个层次解决:
|
||||
|
||||
| 层次 | 机制 | 谁决定 |
|
||||
|---|---|---|
|
||||
| **每次不同** | 起点/终点/幅度/时长抖动,弧线方向随机 | 每次调用现算(`jitter` 控制幅度,0~0.4) |
|
||||
| **每台设备不同** | 手速(0.82~1.32)、幅度(0.86~1.16)、弧度、常用横坐标(±9%屏宽)、停顿(0.80~1.35) | `crc32(serial)` 播种,**同设备恒定** |
|
||||
|
||||
用法(同步步骤里用):
|
||||
|
||||
```python
|
||||
from core import humanize
|
||||
humanize.swipe(d, "up", serial=self.serial, duration_min=0.25, duration_max=0.4,
|
||||
distance_ratio=0.6, jitter=0.15, humanize=True)
|
||||
humanize.pace(self.serial, 5.0, enabled=True) # wait 步骤的"按设备节奏微调"
|
||||
```
|
||||
|
||||
要点与坑:
|
||||
|
||||
- **性格稳定、抖动随机**:稳定才像"不同的人",随机才不像"机器"。性格按 **serial**
|
||||
而不是设备名播种——改名不改风格,换地址会换风格(地址代表"这一台")。
|
||||
- 内部用**独立的 `random.Random` 实例**,不碰全局 `random`:worker 是多线程的,
|
||||
全局 RNG 的取值顺序会被别的线程打乱,性格就串味了。
|
||||
- **屏幕尺寸别用 `d.info`**:部分设备上一次要 **14 秒**(实测),改用
|
||||
`humanize.screen_size(d, serial)`——它走 `d.window_size()`(同设备 0.6s)并缓存
|
||||
120 秒。这让"加了弧线反而更快"成为可能。
|
||||
- **曲线点数固定为 4**(5 个点):`d.swipe_points` 在慢设备上**每多一个点约多 1 秒**
|
||||
(实测 2 点 1.4s / 6 点 6.0s / 10 点 10.6s),而设备自己会在点之间插值几十步,
|
||||
4 个点的贝塞尔已经足够弯。**不要为了"更平滑"把点数调大。**
|
||||
- `swipe_points` 抛异常时自动退回直线 `d.swipe`(老 agent 兜底),日志会留一行 warning。
|
||||
- 想关掉:步骤参数 `humanize: false`(完全还原老行为);想调轻:`jitter: 0.05`。
|
||||
|
||||
---
|
||||
|
||||
## 9. Action 框架(App 专属操作)
|
||||
|
||||
+25
-4
@@ -5,8 +5,8 @@ var STEP_LIB=[
|
||||
{type:'click',label:'点击元素',icon:'✦',cat:'interact',params:{selector_type:'xpath',selector_value:'',wait_timeout:2}},
|
||||
{type:'click_xy',label:'点击坐标',icon:'🎯',cat:'interact',params:{x:50,y:50}},
|
||||
{type:'long_click',label:'长按元素',icon:'👆',cat:'interact',params:{selector_type:'xpath',selector_value:'',duration:1.0,wait_timeout:2}},
|
||||
{type:'swipe',label:'滑动',icon:'↕',cat:'interact',params:{direction:'up',duration_min:0.25,duration_max:0.50}},
|
||||
{type:'swipe_until',label:'滑动直到元素',icon:'🔍',cat:'interact',params:{selector_type:'xpath',selector_value:'',direction:'up',max_swipes:8,click_when_found:true}},
|
||||
{type:'swipe',label:'滑动',icon:'↕',cat:'interact',params:{direction:'up',duration_min:0.25,duration_max:0.50,distance_ratio:0.6,jitter:0.15,humanize:true}},
|
||||
{type:'swipe_until',label:'滑动直到元素',icon:'🔍',cat:'interact',params:{selector_type:'xpath',selector_value:'',direction:'up',max_swipes:8,click_when_found:true,duration_min:0.25,duration_max:0.50,distance_ratio:0.6,jitter:0.15,humanize:true}},
|
||||
{type:'input_text',label:'输入文字',icon:'⌨',cat:'interact',params:{mode:'random',texts:'你好\n有趣\n支持',fixed_text:'',clear_first:true}},
|
||||
{type:'clipboard',label:'剪贴板注入',icon:'📋',cat:'interact',params:{text:'',paste:true}},
|
||||
{type:'key_event',label:'按键',icon:'⌨️',cat:'interact',params:{key:'back'}},
|
||||
@@ -69,6 +69,22 @@ function _loadCustomActions(){
|
||||
}).catch(function(){});
|
||||
}
|
||||
|
||||
|
||||
// 「拟人」开关 + 抖动幅度(滑动类步骤共用;见 core/humanize.py)
|
||||
function _humanizeChkHtml(p){
|
||||
return '<div class="form-row"><div class="form-group form-check">'
|
||||
+'<input type="checkbox" data-key="humanize" '+(p.humanize!==false?'checked':'')+'>'
|
||||
+'<label>拟人轨迹(弧线)</label></div>'
|
||||
+'<div class="form-group"><label>抖动幅度</label><input type="number" class="form-control" data-key="jitter" value="'
|
||||
+(p.jitter!=null?p.jitter:0.15)+'" step="0.05" min="0" max="0.4"></div></div>';
|
||||
}
|
||||
function _humanizeRowHtml(p,extra){
|
||||
return extra+_humanizeChkHtml(p)
|
||||
+'<div class="help">拟人:滑动走弧线,起止点/幅度/时长每次都抖一点,并按本设备的手速与幅度偏好执行'
|
||||
+'(每台设备风格稳定不同,批量跑时不像同步机器人)。关掉=还原成正中直线;'
|
||||
+'抖动填 0=位置/幅度不抖(时长仍在你填的范围内随机)</div>';
|
||||
}
|
||||
|
||||
var _stepEditor={
|
||||
_steps:[],
|
||||
_selected:[],
|
||||
@@ -299,6 +315,7 @@ var _stepEditor={
|
||||
['up','down','left','right'].map(function(d){return '<option value="'+d+'"'+(p.direction===d?' selected':'')+'>'+d+'</option>';}).join('')+'</select></div>';
|
||||
h+='<div class="form-group"><label>最短时长(秒)</label><input type="number" class="form-control" data-key="duration_min" value="'+(p.duration_min||0.25)+'" step="0.05" min="0.05"></div>';
|
||||
h+='<div class="form-group"><label>最长时长(秒)</label><input type="number" class="form-control" data-key="duration_max" value="'+(p.duration_max||0.50)+'" step="0.05" min="0.05"></div></div>';
|
||||
h+=_humanizeRowHtml(p,'<div class="form-group"><label>幅度(屏占比)</label><input type="number" class="form-control" data-key="distance_ratio" value="'+(p.distance_ratio!=null?p.distance_ratio:0.6)+'" step="0.05" min="0.1" max="0.9"></div>');
|
||||
}else if(step.type==='click'){
|
||||
h+=_selRowHtml(p,'<div class="form-group"><label>等待超时(秒)</label><input type="number" class="form-control" data-key="wait_timeout" value="'+(p.wait_timeout||2)+'" step="0.5" min="0.5"></div>');
|
||||
}else if(step.type==='long_click'){
|
||||
@@ -308,9 +325,11 @@ var _stepEditor={
|
||||
h+=_selRowHtml(p,'<div class="form-row"><div class="form-group"><label>方向</label><select class="form-control" data-key="direction">'+
|
||||
'<option value="up"'+(p.direction!=='down'?' selected':'')+'>上滑</option>'+
|
||||
'<option value="down"'+(p.direction==='down'?' selected':'')+'>下滑</option></select></div>'+
|
||||
'<div class="form-group"><label>最多滑动次数</label><input type="number" class="form-control" data-key="max_swipes" value="'+(p.max_swipes||8)+'" min="1" max="50"></div></div>'+
|
||||
'<div class="form-group"><label>最多滑动次数</label><input type="number" class="form-control" data-key="max_swipes" value="'+(p.max_swipes||8)+'" min="1" max="50"></div>'+
|
||||
'<div class="form-group"><label>幅度(屏占比)</label><input type="number" class="form-control" data-key="distance_ratio" value="'+(p.distance_ratio!=null?p.distance_ratio:0.6)+'" step="0.05" min="0.1" max="0.9"></div></div>'+
|
||||
'<div class="form-group form-check"><input type="checkbox" data-key="click_when_found" '+(p.click_when_found!==false?'checked':'')+'><label>找到后点击</label></div>'+
|
||||
'<div class="help">上滑/下滑直到目标元素出现(每次滑动后检查一次),找不到则跳过继续</div>');
|
||||
_humanizeChkHtml(p)+
|
||||
'<div class="help">上滑/下滑直到目标元素出现(每次滑动后检查一次),找不到则跳过继续。滑动同样走拟人轨迹</div>');
|
||||
}else if(step.type==='wait_el'){
|
||||
h+=_selRowHtml(p,'<div class="form-group"><label>最长等待(秒)</label><input type="number" class="form-control" data-key="timeout" value="'+(p.timeout||10)+'" min="1" max="120"></div>'+
|
||||
'<div class="help">等待目标元素出现(替代固定秒数等待),超时则继续</div>');
|
||||
@@ -327,6 +346,8 @@ var _stepEditor={
|
||||
}else if(step.type==='wait'){
|
||||
h+='<div class="form-row"><div class="form-group"><label>最短(秒)</label><input type="number" class="form-control" data-key="min" value="'+(p.min||1.0)+'" step="0.5" min="0.1"></div>';
|
||||
h+='<div class="form-group"><label>最长(秒)</label><input type="number" class="form-control" data-key="max" value="'+(p.max||3.0)+'" step="0.5" min="0.1"></div></div>';
|
||||
h+='<div class="form-group form-check"><input type="checkbox" data-key="vary_pace" '+(p.vary_pace?'checked':'')+'><label>按设备节奏微调(0.8~1.35 倍)</label></div>';
|
||||
h+='<div class="help">勾上后每台设备按自己的节奏缩放这个等待:批量跑时设备之间会逐渐错开,不再整齐划一。代价是实际时长不再严格等于上面填的值</div>';
|
||||
}else if(step.type==='clipboard'){
|
||||
h+='<div class="form-group"><label>注入内容</label><textarea class="form-control" data-key="text" rows="3" style="font-family:monospace;min-height:70px">'+esc(p.text||'')+'</textarea></div>';
|
||||
h+='<div class="form-group form-check"><input type="checkbox" data-key="paste" '+(p.paste!==false?'checked':'')+'><label>注入后立即粘贴(需输入框已聚焦)</label></div>';
|
||||
|
||||
+34
-26
@@ -37,7 +37,7 @@ from tasks.base import BaseTask, register_task
|
||||
from core.device_worker import BaseWorker, _update_status
|
||||
from core.u2_helper import ensure_app_running, wait_for_app_home, random_sleep
|
||||
from core.logger import get_logger
|
||||
from core import notifier, step_log
|
||||
from core import notifier, step_log, humanize
|
||||
|
||||
_log = get_logger("task.generic")
|
||||
|
||||
@@ -82,10 +82,13 @@ STEP_TYPES = [
|
||||
{"type": "key_event", "label": "按键", "icon": "⌨️",
|
||||
"params": {"key": "back"}},
|
||||
{"type": "swipe", "label": "滑动", "icon": "↕",
|
||||
"params": {"direction": "up", "duration_min": 0.25, "duration_max": 0.50}},
|
||||
"params": {"direction": "up", "duration_min": 0.25, "duration_max": 0.50,
|
||||
"distance_ratio": 0.6, "jitter": 0.15, "humanize": True}},
|
||||
{"type": "swipe_until", "label": "滑动直到元素", "icon": "🔍",
|
||||
"params": {"selector_type": "xpath", "selector_value": "", "direction": "up",
|
||||
"max_swipes": 8, "click_when_found": True}},
|
||||
"max_swipes": 8, "click_when_found": True,
|
||||
"duration_min": 0.25, "duration_max": 0.50,
|
||||
"distance_ratio": 0.6, "jitter": 0.15, "humanize": True}},
|
||||
{"type": "click", "label": "点击元素", "icon": "✦",
|
||||
"params": {"selector_type": "description", "selector_value": "", "wait_timeout": 2}},
|
||||
{"type": "click_xy", "label": "点击坐标", "icon": "🎯",
|
||||
@@ -379,13 +382,14 @@ class GenericStepsWorker(BaseWorker):
|
||||
if not sel_val:
|
||||
_log.warning("swipe_until 缺少选择器")
|
||||
return None
|
||||
info = d.info
|
||||
w, h = info["displayWidth"], info["displayHeight"]
|
||||
cx = int(w * 0.5)
|
||||
if direction == "down":
|
||||
sy, ey = int(h * 0.2), int(h * 0.8)
|
||||
else:
|
||||
sy, ey = int(h * 0.8), int(h * 0.2)
|
||||
# 每次滑动都重新生成(弧线方向/幅度/时长都不一样),不用预算好的固定坐标
|
||||
swipe_kw = dict(
|
||||
serial=self.serial,
|
||||
duration_min=float(params.get("duration_min", 0.25)),
|
||||
duration_max=float(params.get("duration_max", 0.50)),
|
||||
distance_ratio=float(params.get("distance_ratio", 0.6) or 0.6),
|
||||
jitter=params.get("jitter", 0.15),
|
||||
humanize=params.get("humanize", True) is not False)
|
||||
for i in range(1, max_swipes + 1):
|
||||
if self.stopped() or self.is_time_up():
|
||||
return None
|
||||
@@ -411,8 +415,8 @@ class GenericStepsWorker(BaseWorker):
|
||||
_log.info(f"[{self.serial}] 滑动{i}次后找到: {sel_val}")
|
||||
self._track_selector_health(sel_val, True)
|
||||
return True
|
||||
d.swipe(cx, sy, cx, ey, 0.3)
|
||||
time.sleep(0.5)
|
||||
humanize.swipe(d, direction, **swipe_kw)
|
||||
time.sleep(random.uniform(0.35, 0.75)) # 每次滑动后停顿也抖动一下
|
||||
self._track_selector_health(sel_val, False)
|
||||
_log.warning(f"[{self.serial}] 滑动{max_swipes}次未找到元素: {sel_val}")
|
||||
return False
|
||||
@@ -531,21 +535,22 @@ class GenericStepsWorker(BaseWorker):
|
||||
return found
|
||||
|
||||
def _exec_swipe(self, d, params, depth=0):
|
||||
"""滑动一次(默认拟人:弧线 + 抖动 + 本设备手速,见 core/humanize.py)。
|
||||
|
||||
老行为(正中直线、每次都一样)可以用 humanize=false 还原;
|
||||
jitter=0 则保留轨迹但去掉随机抖动(对照排障用)。
|
||||
"""
|
||||
direction = params.get("direction", "up")
|
||||
dur_min = float(params.get("duration_min", 0.25))
|
||||
dur_max = float(params.get("duration_max", 0.50))
|
||||
dur = random.uniform(dur_min, dur_max)
|
||||
info = d.info
|
||||
w, h = info["displayWidth"], info["displayHeight"]
|
||||
cx = int(w * 0.5)
|
||||
if direction == "up":
|
||||
d.swipe(cx, int(h * 0.8), cx, int(h * 0.2), dur)
|
||||
elif direction == "down":
|
||||
d.swipe(cx, int(h * 0.2), cx, int(h * 0.8), dur)
|
||||
elif direction == "left":
|
||||
d.swipe(int(w * 0.8), int(h * 0.5), int(w * 0.2), int(h * 0.5), dur)
|
||||
elif direction == "right":
|
||||
d.swipe(int(w * 0.2), int(h * 0.5), int(w * 0.8), int(h * 0.5), dur)
|
||||
info = humanize.swipe(
|
||||
d, direction, serial=self.serial,
|
||||
duration_min=float(params.get("duration_min", 0.25)),
|
||||
duration_max=float(params.get("duration_max", 0.50)),
|
||||
distance_ratio=float(params.get("distance_ratio", 0.6) or 0.6),
|
||||
jitter=params.get("jitter", 0.15),
|
||||
humanize=params.get("humanize", True) is not False)
|
||||
_log.info(f"[{self.serial}] 滑动 {direction} 时长{info['duration']}s "
|
||||
f"({info['from']}→{info['to']}"
|
||||
f"{',弧线' if info.get('human') else ',直线'})")
|
||||
|
||||
def _exec_click(self, d, params, depth=0):
|
||||
sel_type = params.get("selector_type", "xpath")
|
||||
@@ -620,6 +625,9 @@ class GenericStepsWorker(BaseWorker):
|
||||
max_s = float(params.get("max", 3.0))
|
||||
# 分片 sleep:每 0.5s 检查停止/超时信号,抢占任务能及时接管(纯 sleep 无法中断)
|
||||
total = random.uniform(min_s, max_s)
|
||||
# 可选:按本设备节奏缩放(勾了"设备节奏差异"才生效,默认关——
|
||||
# 时长被放大/缩小是业务语义,不能悄悄改)
|
||||
total = humanize.pace(self.serial, total, enabled=params.get("vary_pace", False))
|
||||
end = time.time() + total
|
||||
while time.time() < end:
|
||||
if self.stopped() or self.is_time_up():
|
||||
|
||||
Reference in New Issue
Block a user