"""通用步骤任务定义。 前端步骤编辑器编排步骤链,保存为 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_ 方法。 """ 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=0, 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}") # 容器步骤(loop/group)不计入操作计数,避免监控显示噪音 if stype not in ("loop", "group"): self._action_counts[label] = self._action_counts.get(label, 0) + 1 elapsed = self.elapsed() # done=累计执行的操作次数(无固定总数),前端显示"已执行 N 次操作" self.set_progress(done=sum(self._action_counts.values()), total=0, 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)