Files
auto_control/tasks/generic/task.py
T
butubb fcbdb0a8a1 feat: 通用步骤支持概率触发、循环按时间、预计结束时间上报
- 步骤新增 probability 参数:<100 时按百分比概率决定是否执行(实现"偶尔点赞")
- loop 支持 loop_mode=rounds/time,按时间循环跑满 loop_duration 秒
- BaseWorker._start_timer 上报 end_time(开始+max_duration),task_manager 透出
2026-08-08 16:30:54 +08:00

298 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""通用步骤任务定义。
前端步骤编辑器编排步骤链,保存为 params.steps(JSON 数组)。
worker 按 steps 顺序执行,支持 loop 步骤循环、停止信号、进度上报。
步骤 schema:
{
"id": "step_1", # 唯一 id(前端生成)
"type": "open_app", # 步骤类型
"label": "打开抖音", # 用户可读名称
"params": { ... } # 步骤参数(按 type 不同)
}
支持的步骤类型:
open_app - 启动 app
swipe - 滑动(方向/时长)
click - 点击元素(选择器)
input_text - 输入文字(随机候选/指定文字)
wait - 等待时长
loop - 循环块(含 children 步骤列表 + max_iterations)
group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用)
"""
import random
import time
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
_log = get_logger("task.generic")
# ================== 步骤类型定义(前端操作库 + 后端执行共用)==================
STEP_TYPES = [
{"type": "open_app", "label": "打开App", "icon": "▶",
"params": {"package": "", "wait_home": False, "home_feature": ""}},
{"type": "swipe", "label": "滑动", "icon": "↕",
"params": {"direction": "up", "duration_min": 0.25, "duration_max": 0.50}},
{"type": "click", "label": "点击元素", "icon": "✦",
"params": {"selector_type": "description", "selector_value": "", "wait_timeout": 2}},
{"type": "input_text", "label": "输入文字", "icon": "⌨",
"params": {"mode": "random", "texts": "你好\n有趣\n支持", "fixed_text": ""}},
{"type": "wait", "label": "等待", "icon": "⏱",
"params": {"min": 1.0, "max": 3.0}},
{"type": "loop", "label": "循环块", "icon": "↻",
"params": {"loop_mode": "rounds", "max_iterations": 10, "loop_duration": 600, "children": []}},
{"type": "group", "label": "动作组", "icon": "📦",
"params": {"children": []}},
]
DEFAULT_PARAMS = {
"max_duration": 0, # 最大运行时长(秒),0=不限时
"steps": [ # 默认示例:打开抖音→循环(看视频→滑动)
{
"id": "step_1", "type": "open_app", "label": "打开抖音",
"params": {"package": "com.ss.android.ugc.aweme", "wait_home": True, "home_feature": "拍摄"},
},
{
"id": "step_2", "type": "loop", "label": "循环看视频",
"params": {"max_iterations": 20, "children": [
{"id": "step_2_1", "type": "wait", "label": "观看视频",
"params": {"min": 5.0, "max": 35.0}},
{"id": "step_2_2", "type": "swipe", "label": "上滑下一个",
"params": {"direction": "up", "duration_min": 0.25, "duration_max": 0.50}},
]},
},
],
}
class GenericStepsWorker(BaseWorker):
"""通用步骤 worker:按 params.steps 顺序执行步骤链。
复用 BaseWorker 的设备生命周期(STF 占用/释放、u2 连接、异常、状态上报、stop)。
只实现 run_task,按步骤类型分发到 _exec_<type> 方法。
"""
def __init__(self, stf_client, serial, params=None):
super().__init__(stf_client, serial, params)
p = {**DEFAULT_PARAMS, **(self.params or {})}
self.max_duration = int(p.get("max_duration", 0))
self.steps = p.get("steps", [])
self._action_counts = {} # 步骤执行计数 {step_label: count}
def run_task(self, d):
"""按 steps 顺序执行。loop 步骤递归执行其 children。"""
self._start_timer()
self.set_action("开始执行步骤链")
self.set_progress(done=0, total=len(self.steps), unit="步骤",
action_counts=dict(self._action_counts))
try:
self._exec_steps(d, self.steps)
finally:
elapsed = self.elapsed()
summary = f"完成步骤链,运行 {elapsed//60}m{elapsed%60}s"
_update_status(self.serial, douyin_running=False, current_action=summary)
def _exec_steps(self, d, steps, depth=0):
"""执行步骤列表。depth 防止无限嵌套。"""
if depth > 5:
_log.warning(f"[{self.serial}] 步骤嵌套深度超限(>5),跳过")
return
for step in steps:
if self.stopped() or self.is_time_up():
return
self._exec_one(d, step, depth)
def _exec_one(self, d, step, depth=0):
"""执行单个步骤。"""
stype = step.get("type", "")
label = step.get("label", stype)
params = step.get("params", {})
# 概率触发:probability=100 必执行,<100 按百分比概率决定本次是否执行
prob = float(params.get("probability", 100))
if prob < 100 and random.random() * 100 > prob:
_log.info(f"[{self.serial}] 步骤 '{label}' 概率 {prob}% 未触发,跳过")
return
self.set_action(f"执行: {label}")
_log.info(f"[{self.serial}] 步骤: {label}({stype}) params={params}")
handler = getattr(self, f"_exec_{stype}", None)
if handler:
try:
handler(d, params, depth)
except Exception as e:
_log.error(f"[{self.serial}] 步骤 {label}({stype}) 异常: {e}")
else:
_log.warning(f"[{self.serial}] 未知步骤类型: {stype}")
self._action_counts[label] = self._action_counts.get(label, 0) + 1
elapsed = self.elapsed()
self.set_progress(done=sum(self._action_counts.values()),
total=len(self.steps), unit="步骤",
action_counts=dict(self._action_counts), elapsed=elapsed)
# ================== 步骤执行器 ==================
def _exec_open_app(self, d, params, depth=0):
package = params.get("package", "")
if not package:
_log.warning("open_app 缺少 package 参数")
return
wait_home = params.get("wait_home", False)
home_feature = params.get("home_feature", "")
d.app_start(package, wait=True)
if wait_home:
home_check = (lambda d: d(descriptionContains=home_feature).exists) if home_feature else (lambda d: True)
if not wait_for_app_home(d, package, home_check, timeout=40):
self.set_action(f"{package} 首页加载超时,继续")
def _exec_swipe(self, d, params, depth=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)
def _exec_click(self, d, params, depth=0):
sel_type = params.get("selector_type", "xpath")
sel_val = params.get("selector_value", "")
timeout = float(params.get("wait_timeout", 2))
if not sel_val:
_log.warning(f"[{self.serial}] click 跳过:selector_value 为空,请在步骤编辑器中填写或抓取元素")
return
# 兼容旧配置:selector_type 不是 xpath 但值是 XPath(以 // 开头)
if sel_type != "xpath" and sel_val.strip().startswith("//"):
sel_type = "xpath"
try:
if sel_type == "xpath":
# u2 的 d.xpath() 返回 XPathSelector,exists 是属性需用 wait()
el = d.xpath(sel_val)
if el.wait(timeout=timeout):
el.click()
_log.info(f"[{self.serial}] click 命中: xpath={sel_val}")
else:
_log.warning(f"[{self.serial}] click 未找到元素: xpath={sel_val} (等待{timeout}s)")
else:
# UiSelector 风格:d(description=xxx) 等,exists() 支持 timeout 参数
el = d(**{sel_type: sel_val})
if el.exists(timeout=timeout):
el.click()
_log.info(f"[{self.serial}] click 命中: {sel_type}={sel_val}")
else:
_log.warning(f"[{self.serial}] click 未找到元素: {sel_type}={sel_val} (等待{timeout}s)")
except Exception as e:
_log.warning(f"[{self.serial}] click 异常: {e}")
def _exec_wait(self, d, params, depth=0):
min_s = float(params.get("min", 1.0))
max_s = float(params.get("max", 3.0))
random_sleep(min_s, max_s)
def _exec_loop(self, d, params, depth=0):
children = params.get("children", [])
if not children:
return
mode = params.get("loop_mode", "rounds")
if mode == "time":
# 按时间循环:跑满 loop_duration 秒,同时受全局 stopped/max_duration 约束
duration = float(params.get("loop_duration", 0) or 0)
if duration <= 0:
_log.warning(f"[{self.serial}] 按时间循环未设置时长,跳过")
return
start = time.time()
i = 0
while not self.stopped() and not self.is_time_up() and (time.time() - start) < duration:
i += 1
self.set_action(f"循环第 {i} 轮(按时间)")
_log.info(f"[{self.serial}] loop 第 {i} 轮(按时间, 已{(time.time()-start):.0f}s/{duration:.0f}s)")
self._exec_steps(d, children, depth + 1)
else:
# 按轮次循环
max_iter = int(params.get("max_iterations", 10))
for i in range(max_iter):
if self.stopped() or self.is_time_up():
return
self.set_action(f"循环第 {i+1}/{max_iter} 轮")
_log.info(f"[{self.serial}] loop 第 {i+1}/{max_iter} 轮")
self._exec_steps(d, children, depth + 1)
def _exec_group(self, d, params, depth=0):
"""动作组:按序执行 children 一次(类似 loop 但不循环)。
用于把一系列步骤打包成一个可复用的动作。
"""
children = params.get("children", [])
if not children:
return
self._exec_steps(d, children, depth + 1)
def _exec_input_text(self, d, params, depth=0):
"""在当前焦点输入框输入文字。
需先用"点击元素"步骤定位到输入框,本步骤只负责输入。
mode=random: 从 texts(换行分隔)随机选一条
mode=fixed: 输入 fixed_text
"""
mode = params.get("mode", "random")
if mode == "fixed":
text = params.get("fixed_text", "")
else:
raw = params.get("texts", "")
candidates = [t.strip() for t in raw.splitlines() if t.strip()]
if not candidates:
_log.warning("input_text 无候选文字")
return
text = random.choice(candidates)
if not text:
return
try:
# 优先用 u2 的 send_keys(对当前焦点元素输入)
d.send_keys(text)
_log.info(f"[{self.serial}] input_text: {text}")
except Exception as e:
# 兜底:找 EditText 设置文本
try:
edit = d(className="android.widget.EditText")
if edit.exists:
edit.set_text(text)
_log.info(f"[{self.serial}] input_text(set_text): {text}")
else:
_log.warning(f"[{self.serial}] input_text 未找到输入框: {e}")
except Exception as e2:
_log.warning(f"[{self.serial}] input_text 失败: {e2}")
@register_task
class GenericStepsTask(BaseTask):
"""通用步骤任务:前端编辑器编排步骤链,worker 按顺序执行。"""
task_type = "generic_steps"
name = "通用步骤"
description = "通过步骤编辑器编排任务流程,支持循环/点击/滑动/点赞/评论等"
default_params = dict(DEFAULT_PARAMS)
@classmethod
def list_action_types(cls):
"""返回支持的步骤类型列表(前端操作库用)。"""
return STEP_TYPES
@classmethod
def get_action_class(cls, action_type):
return None
def create_worker(self, stf, serial, params):
merged = {**DEFAULT_PARAMS, **(params or {})}
return GenericStepsWorker(stf, serial, params=merged)