feat: 新增通用步骤任务+元素抓取,完善全部项目文档

- 新增 generic_steps 通用步骤任务(可视化步骤编辑器编排流程,支持 open_app/click/swipe/input_text/wait/loop/group)

- 新增 core/uiauto_helper.py 封装 uiautodev 元素抓取客户端

- web_server 新增元素抓取/截图/设备列表等 API

- monitor.html 新增步骤编辑器、元素抓取模态框、独立关闭逻辑

- 新增 README.md 项目总览(快速上手/架构/配置/FAQ)

- 新增 doc/ARCHITECTURE.md 架构详解、doc/DEPLOY.md 部署指南、doc/API.md 接口文档

- 修复 doc/TASK_DEV.md:移除已删除的 comment 引用,补充 generic 包,更新注册示例

- .gitignore 忽略 .claude/ 工具产物
This commit is contained in:
2026-08-08 10:40:06 +08:00
parent 186bded9d9
commit 468e86f0a9
17 changed files with 3202 additions and 146 deletions
+6
View File
@@ -0,0 +1,6 @@
"""通用步骤任务包。
通过前端步骤编辑器编排任务流程,worker 按 steps 顺序执行。
与 douyin_nurture 共存,互不影响。
"""
from . import task # noqa: F401 触发 @register_task 注册通用步骤任务
+275
View File
@@ -0,0 +1,275 @@
"""通用步骤任务定义。
前端步骤编辑器编排步骤链,保存为 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
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": {"max_iterations": 10, "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", {})
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):
max_iter = int(params.get("max_iterations", 10))
children = params.get("children", [])
if not children:
return
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)