664 lines
30 KiB
Python
664 lines
30 KiB
Python
"""通用步骤任务定义。
|
||
|
||
前端步骤编辑器编排步骤链,保存为 params.steps(JSON 数组)。
|
||
worker 按 steps 顺序执行,支持 loop 步骤循环、停止信号、进度上报。
|
||
|
||
步骤 schema:
|
||
{
|
||
"id": "step_1", # 唯一 id(前端生成)
|
||
"type": "open_app", # 步骤类型
|
||
"label": "打开抖音", # 用户可读名称
|
||
"params": { ... } # 步骤参数(按 type 不同)
|
||
}
|
||
|
||
支持的步骤类型:
|
||
open_app - 启动 app
|
||
stop_app - 强制结束 app(am force-stop,下次打开冷启动)
|
||
screen_on - 亮屏(息屏时唤醒并滑动解锁)
|
||
screen_off - 息屏
|
||
keep_screen - 保持亮屏/恢复自动息屏(svc power stayon,设备充电时常亮)
|
||
key_event - 按键(返回/Home/回车/菜单等,d.press)
|
||
swipe - 滑动(方向/时长)
|
||
swipe_until - 滑动直到元素出现(最多 N 次,可选中找到后点击)
|
||
click - 点击元素(选择器)
|
||
click_xy - 点击坐标(屏幕百分比,无选择器时兜底)
|
||
long_click - 长按元素(选择器 + 时长)
|
||
wait_el - 等待元素出现(条件等待,替代固定时长 wait)
|
||
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": "stop_app", "label": "结束App", "icon": "⏹",
|
||
"params": {"package": ""}},
|
||
{"type": "screen_on", "label": "亮屏", "icon": "💡",
|
||
"params": {}},
|
||
{"type": "screen_off", "label": "息屏", "icon": "🌙",
|
||
"params": {}},
|
||
{"type": "keep_screen", "label": "保持亮屏", "icon": "🔆",
|
||
"params": {"mode": "on"}},
|
||
{"type": "key_event", "label": "按键", "icon": "⌨️",
|
||
"params": {"key": "back"}},
|
||
{"type": "swipe", "label": "滑动", "icon": "↕",
|
||
"params": {"direction": "up", "duration_min": 0.25, "duration_max": 0.50}},
|
||
{"type": "swipe_until", "label": "滑动直到元素", "icon": "🔍",
|
||
"params": {"selector_type": "xpath", "selector_value": "", "direction": "up",
|
||
"max_swipes": 8, "click_when_found": True}},
|
||
{"type": "click", "label": "点击元素", "icon": "✦",
|
||
"params": {"selector_type": "description", "selector_value": "", "wait_timeout": 2}},
|
||
{"type": "click_xy", "label": "点击坐标", "icon": "🎯",
|
||
"params": {"x": 50, "y": 50}},
|
||
{"type": "long_click", "label": "长按元素", "icon": "👆",
|
||
"params": {"selector_type": "xpath", "selector_value": "", "duration": 1.0, "wait_timeout": 2}},
|
||
{"type": "wait_el", "label": "等待元素", "icon": "⏳",
|
||
"params": {"selector_type": "xpath", "selector_value": "", "timeout": 10}},
|
||
{"type": "input_text", "label": "输入文字", "icon": "⌨",
|
||
"params": {"mode": "random", "texts": "你好\n有趣\n支持", "fixed_text": "", "clear_first": True}},
|
||
{"type": "clipboard", "label": "剪贴板注入", "icon": "📋",
|
||
"params": {"text": "", "paste": True}},
|
||
{"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": []}},
|
||
{"type": "if_el", "label": "条件判断", "icon": "❓",
|
||
"params": {"selector_type": "xpath", "selector_value": "", "timeout": 3,
|
||
"then": [], "else": []}},
|
||
]
|
||
|
||
|
||
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, serial, params=None):
|
||
super().__init__(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}
|
||
self._miss_counts = {} # 选择器健康:selector -> 连续未命中次数
|
||
|
||
# 某 click 选择器连续未找到元素的次数达到该值,判定可能失效(App 改版)
|
||
_MAX_CONSECUTIVE_MISS = 10
|
||
|
||
def _track_selector_health(self, sel_val, found):
|
||
"""记录某选择器的连续未命中次数,达到阈值上报 last_warning 并重置计数。
|
||
|
||
用于发现"选择器失效但任务仍显示成功"的静默空转问题(如 App 改版后元素找不到了)。
|
||
"""
|
||
if found:
|
||
self._miss_counts.pop(sel_val, None)
|
||
return
|
||
n = self._miss_counts.get(sel_val, 0) + 1
|
||
self._miss_counts[sel_val] = n
|
||
if n >= self._MAX_CONSECUTIVE_MISS:
|
||
_log.error(f"[{self.serial}] 选择器连续 {n} 次未找到元素,可能已失效(App 改版?):{sel_val}")
|
||
_update_status(self.serial, last_warning=f"选择器连续{n}次未命中")
|
||
self._miss_counts.pop(sel_val, None) # 已上报,重置避免刷屏
|
||
|
||
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}")
|
||
|
||
ret = None
|
||
handler = getattr(self, f"_exec_{stype}", None)
|
||
if handler:
|
||
try:
|
||
ret = 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_el)不计入操作计数,避免监控显示噪音
|
||
if stype not in ("loop", "group", "if_el"):
|
||
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)
|
||
return ret # 命中结果(True/False/None),"测试此步骤"用
|
||
|
||
# ================== 步骤执行器 ==================
|
||
def _exec_screen_on(self, d, params, depth=0):
|
||
"""亮屏:息屏时唤醒并滑动解锁。任务执行前用(息屏时 u2 无法操作)。"""
|
||
try:
|
||
d.unlock()
|
||
_log.info(f"[{self.serial}] 亮屏")
|
||
except Exception as e:
|
||
_log.warning(f"[{self.serial}] 亮屏异常: {e}")
|
||
|
||
def _exec_screen_off(self, d, params, depth=0):
|
||
"""息屏:任务结束后用,避免长时间亮屏(烧屏/发热)。"""
|
||
try:
|
||
d.screen_off()
|
||
_log.info(f"[{self.serial}] 息屏")
|
||
except Exception as e:
|
||
_log.warning(f"[{self.serial}] 息屏异常: {e}")
|
||
|
||
def _exec_keep_screen(self, d, params, depth=0):
|
||
"""保持亮屏 / 恢复自动息屏。
|
||
|
||
长任务(养号看视频数小时)时屏幕会按系统超时自动熄灭导致任务无法执行,
|
||
用 svc power stayon 让充电时屏幕常亮;任务结束前用 mode=off 恢复自动息屏。
|
||
"""
|
||
mode = params.get("mode", "on")
|
||
try:
|
||
if mode == "off":
|
||
d.shell("svc power stayon false")
|
||
_log.info(f"[{self.serial}] 恢复自动息屏")
|
||
else:
|
||
d.shell("svc power stayon true")
|
||
_log.info(f"[{self.serial}] 保持亮屏(充电时屏幕常亮)")
|
||
except Exception as e:
|
||
_log.warning(f"[{self.serial}] keep_screen({mode}) 异常: {e}")
|
||
|
||
def _exec_stop_app(self, d, params, depth=0):
|
||
"""强制结束 App(am force-stop)。清后台不留进程,下次打开为冷启动。"""
|
||
package = params.get("package", "")
|
||
if not package:
|
||
_log.warning(f"[{self.serial}] stop_app 缺少 package 参数")
|
||
return
|
||
try:
|
||
d.app_stop(package)
|
||
_log.info(f"[{self.serial}] 已强制结束 {package}")
|
||
except Exception as e:
|
||
_log.warning(f"[{self.serial}] 结束 {package} 异常: {e}")
|
||
|
||
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_key_event(self, d, params, depth=0):
|
||
"""按键:返回/Home/回车等(d.press)。退出评论、返回上一页必备。"""
|
||
key = params.get("key", "back")
|
||
try:
|
||
d.press(key)
|
||
_log.info(f"[{self.serial}] 按键: {key}")
|
||
except Exception as e:
|
||
_log.warning(f"[{self.serial}] 按键 {key} 异常: {e}")
|
||
|
||
def _exec_swipe_until(self, d, params, depth=0):
|
||
"""滑动直到元素出现(最多 max_swipes 次),可选找到后点击。
|
||
|
||
养号核心动作:信息流刷到目标按钮(点赞/评论)再操作。
|
||
返回 True=找到, False=未找到, None=配置缺选择器。
|
||
"""
|
||
sel_type = params.get("selector_type", "xpath")
|
||
sel_val = params.get("selector_value", "")
|
||
direction = params.get("direction", "up")
|
||
max_swipes = max(1, int(params.get("max_swipes", 8)))
|
||
click_when_found = params.get("click_when_found", True)
|
||
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)
|
||
for i in range(1, max_swipes + 1):
|
||
if self.stopped() or self.is_time_up():
|
||
return None
|
||
found = False
|
||
try:
|
||
if sel_type == "xpath":
|
||
el = d.xpath(sel_val)
|
||
found = el.wait(timeout=1)
|
||
else:
|
||
el = d(**{sel_type: sel_val})
|
||
found = el.exists(timeout=1)
|
||
except Exception:
|
||
found = False
|
||
if found:
|
||
self.set_action(f"滑动找到元素(第{i}次)")
|
||
if click_when_found:
|
||
try:
|
||
el.click()
|
||
_log.info(f"[{self.serial}] 滑动{i}次后找到并点击: {sel_val}")
|
||
except Exception as e:
|
||
_log.warning(f"[{self.serial}] 找到但点击失败: {e}")
|
||
else:
|
||
_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)
|
||
self._track_selector_health(sel_val, False)
|
||
_log.warning(f"[{self.serial}] 滑动{max_swipes}次未找到元素: {sel_val}")
|
||
return False
|
||
|
||
def _exec_click_xy(self, d, params, depth=0):
|
||
"""点击坐标(屏幕百分比 0-100,50/50=屏幕中心)。无选择器时兜底。"""
|
||
x_pct = float(params.get("x", 50))
|
||
y_pct = float(params.get("y", 50))
|
||
info = d.info
|
||
w, h = info["displayWidth"], info["displayHeight"]
|
||
x = int(w * min(max(x_pct, 0), 100) / 100)
|
||
y = int(h * min(max(y_pct, 0), 100) / 100)
|
||
d.click(x, y)
|
||
_log.info(f"[{self.serial}] 点击坐标 ({x},{y})")
|
||
|
||
def _exec_long_click(self, d, params, depth=0):
|
||
"""长按元素(选择器 + 时长秒)。复制链接/呼出菜单用。"""
|
||
sel_type = params.get("selector_type", "xpath")
|
||
sel_val = params.get("selector_value", "")
|
||
duration = float(params.get("duration", 1.0))
|
||
timeout = float(params.get("wait_timeout", 2))
|
||
if not sel_val:
|
||
_log.warning("long_click 缺少选择器")
|
||
return None
|
||
found = False
|
||
try:
|
||
if sel_type == "xpath":
|
||
el = d.xpath(sel_val)
|
||
if el.wait(timeout=timeout):
|
||
el.long_click(duration=duration)
|
||
found = True
|
||
else:
|
||
el = d(**{sel_type: sel_val})
|
||
if el.exists(timeout=timeout):
|
||
el.long_click(duration=duration)
|
||
found = True
|
||
self._track_selector_health(sel_val, found)
|
||
_log.info(f"[{self.serial}] 长按 {'命中' if found else '未找到'}: {sel_val}")
|
||
except Exception as e:
|
||
_log.warning(f"[{self.serial}] 长按异常: {e}")
|
||
return found
|
||
|
||
def _exec_wait_el(self, d, params, depth=0):
|
||
"""等待元素出现(条件等待)。返回 True=出现, False=超时, None=缺选择器。"""
|
||
sel_type = params.get("selector_type", "xpath")
|
||
sel_val = params.get("selector_value", "")
|
||
timeout = float(params.get("timeout", 10))
|
||
if not sel_val:
|
||
_log.warning("wait_el 缺少选择器")
|
||
return None
|
||
try:
|
||
if sel_type == "xpath":
|
||
found = d.xpath(sel_val).wait(timeout=timeout)
|
||
else:
|
||
found = d(**{sel_type: sel_val}).exists(timeout=timeout)
|
||
self._track_selector_health(sel_val, found)
|
||
_log.info(f"[{self.serial}] 等待元素 {'出现' if found else '超时未出现'}: {sel_val}")
|
||
return found
|
||
except Exception as e:
|
||
_log.warning(f"[{self.serial}] 等待元素异常: {e}")
|
||
return False
|
||
|
||
def _exec_if_el(self, d, params, depth=0):
|
||
"""条件判断:找到元素 → 执行 then 分支;超时未找到 → 执行 else 分支。
|
||
|
||
selector_type=ocr 时用屏幕 OCR 匹配文字(图片/画布/WebView 里的文字也能找到),
|
||
命中且 ocr_click=True 时自动点击该文字中心。
|
||
分支里可放任意步骤(含循环/嵌套条件判断),实现"有弹窗就关掉、没弹窗就继续"这类逻辑。
|
||
返回 True=命中 / False=未命中 / None=缺选择器或 OCR 不可用。
|
||
"""
|
||
sel_type = params.get("selector_type", "xpath")
|
||
sel_val = params.get("selector_value", "")
|
||
timeout = float(params.get("timeout", 3))
|
||
if not sel_val:
|
||
_log.warning(f"[{self.serial}] if_el 缺少选择器,跳过")
|
||
return None
|
||
found = False
|
||
if sel_type == "ocr":
|
||
# OCR 模式:截图 → 识别文字 → 关键词匹配(UI 树里没有的文字也能找到)
|
||
try:
|
||
from core.ocr import available as _ocr_available, find_on_screen
|
||
ok, hint = _ocr_available()
|
||
if not ok:
|
||
_log.error(f"[{self.serial}] OCR 条件判断不可用: {hint}")
|
||
return None
|
||
img = d.screenshot()
|
||
if img is None:
|
||
_log.warning(f"[{self.serial}] OCR 截图失败")
|
||
else:
|
||
found, center, matched = find_on_screen(img, sel_val)
|
||
if found:
|
||
if params.get("ocr_click") and center:
|
||
d.click(*center)
|
||
_log.info(f"[{self.serial}] OCR 命中 '{matched}',已点击 ({center[0]},{center[1]})")
|
||
else:
|
||
_log.info(f"[{self.serial}] OCR 命中 '{matched}'")
|
||
else:
|
||
_log.info(f"[{self.serial}] OCR 未命中: '{sel_val}'")
|
||
except Exception as e:
|
||
_log.warning(f"[{self.serial}] OCR 条件判断异常: {e}")
|
||
found = False
|
||
else:
|
||
try:
|
||
if sel_type == "xpath":
|
||
found = d.xpath(sel_val).wait(timeout=timeout)
|
||
else:
|
||
found = d(**{sel_type: sel_val}).exists(timeout=timeout)
|
||
except Exception as e:
|
||
_log.warning(f"[{self.serial}] 条件判断异常: {e}")
|
||
found = False
|
||
self._track_selector_health(sel_val, found)
|
||
branch = params.get("then" if found else "else") or []
|
||
self.set_action(f"条件{'命中' if found else '未命中'} → 执行{'找到' if found else '未找到'}分支({len(branch)}步)")
|
||
_log.info(f"[{self.serial}] 条件判断 {'命中' if found else '未命中'}: {sel_val},执行{'then' if found else 'else'}分支 {len(branch)} 步")
|
||
self._exec_steps(d, branch, depth + 1)
|
||
return found
|
||
|
||
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"
|
||
found = False
|
||
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}")
|
||
found = True
|
||
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}")
|
||
found = True
|
||
else:
|
||
_log.warning(f"[{self.serial}] click 未找到元素: {sel_type}={sel_val} (等待{timeout}s)")
|
||
self._track_selector_health(sel_val, found)
|
||
except Exception as e:
|
||
_log.warning(f"[{self.serial}] click 异常: {e}")
|
||
return found # 命中结果,"测试此步骤"用
|
||
|
||
def _exec_clipboard(self, d, params, depth=0):
|
||
"""剪贴板注入(ClipInject 通道,Android 10+ 可用),可选立即粘贴。
|
||
|
||
批量发链接场景:先聚焦评论区输入框 → 注入+粘贴链接1 → 换链接 → 注入+粘贴链接2……
|
||
粘贴模式:ClipInject 写入剪贴板后调 atx-agent pasteClipboard 粘贴到当前
|
||
焦点输入框;动作失败时兜底 u2 set_text 直接设置聚焦输入框文本(不依赖
|
||
剪贴板,与"输入文字"兜底同机制)。
|
||
"""
|
||
text = str(params.get("text", "") or "")
|
||
if not text:
|
||
_log.warning(f"[{self.serial}] clipboard 内容为空,跳过")
|
||
return False
|
||
from core.clipboard_helper import inject_clipboard
|
||
ok, msg = inject_clipboard(self.serial, text, d=d)
|
||
if not ok:
|
||
_log.warning(f"[{self.serial}] 剪贴板注入失败: {msg}")
|
||
return False
|
||
_log.info(f"[{self.serial}] 剪贴板注入: {text[:60]}({msg})")
|
||
if not params.get("paste"):
|
||
return True
|
||
try:
|
||
d.jsonrpc.pasteClipboard()
|
||
_log.info(f"[{self.serial}] 已粘贴: {text[:60]}")
|
||
return True
|
||
except Exception as e:
|
||
try:
|
||
d(focused=True).set_text(text)
|
||
_log.info(f"[{self.serial}] 粘贴兜底 set_text: {text[:60]}")
|
||
return True
|
||
except Exception as e2:
|
||
_log.warning(f"[{self.serial}] 粘贴失败: {e} / {e2}")
|
||
return True # 剪贴板已写入,粘贴失败不致命
|
||
|
||
def _exec_wait(self, d, params, depth=0):
|
||
min_s = float(params.get("min", 1.0))
|
||
max_s = float(params.get("max", 3.0))
|
||
# 分片 sleep:每 0.5s 检查停止/超时信号,抢占任务能及时接管(纯 sleep 无法中断)
|
||
total = random.uniform(min_s, max_s)
|
||
end = time.time() + total
|
||
while time.time() < end:
|
||
if self.stopped() or self.is_time_up():
|
||
return
|
||
time.sleep(min(0.5, end - time.time()))
|
||
|
||
def _exec_loop(self, d, params, depth=0):
|
||
children = params.get("children", [])
|
||
if not children:
|
||
return
|
||
mode = params.get("loop_mode", "rounds")
|
||
if mode == "forever":
|
||
# 一直循环:无限跑直到任务被外部停止(定时停止 cron / 运行时长上限 / 手动停止)。
|
||
# 配合"定时启动+停止"调度(如 9:00 启动 / 18:00 停止)实现全天候运行。
|
||
i = 0
|
||
while not self.stopped() and not self.is_time_up():
|
||
i += 1
|
||
self.set_action(f"循环第 {i} 轮(持续)")
|
||
if i % 10 == 0 or i <= 2:
|
||
_log.info(f"[{self.serial}] loop 第 {i} 轮(持续运行)")
|
||
self._exec_steps(d, children, depth + 1)
|
||
elif 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
|
||
clear_first = params.get("clear_first", True)
|
||
try:
|
||
# 优先用 u2 的 send_keys(对当前焦点元素输入);clear=True 先清空再输入
|
||
d.send_keys(text, clear=clear_first)
|
||
_log.info(f"[{self.serial}] input_text: {text}" + ("(已先清空)" if clear_first else ""))
|
||
except Exception as e:
|
||
# 兜底:找 EditText 设置文本
|
||
try:
|
||
edit = d(className="android.widget.EditText")
|
||
if edit.exists:
|
||
if clear_first:
|
||
edit.clear_text()
|
||
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, serial, params):
|
||
merged = {**DEFAULT_PARAMS, **(params or {})}
|
||
return GenericStepsWorker(serial, params=merged)
|
||
|
||
|
||
def test_step(serial, step):
|
||
"""在设备上单步试执行(编辑器"测试此步骤"按钮用)。
|
||
|
||
直接 adb connect + u2 连接后执行单个步骤,验证选择器是否命中。
|
||
返回 (ok, msg, result):result 为执行器返回的命中结果(True/False/None)。
|
||
与运行中的任务互不干扰(只读连接,不占用/不释放 STF)。
|
||
"""
|
||
import uiautomator2 as u2
|
||
from concurrent.futures import ThreadPoolExecutor # 与 device_worker 一致(本机 stdlib threading 无此属性)
|
||
from core.adb_helper import adb_connect
|
||
from core.device_worker import _U2_CONNECT_TIMEOUT
|
||
|
||
try:
|
||
if not adb_connect(serial):
|
||
return False, f"adb connect {serial} 失败", None
|
||
except Exception as e:
|
||
return False, f"adb connect 异常: {e}", None
|
||
try:
|
||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||
fut = pool.submit(u2.connect, serial)
|
||
d = fut.result(timeout=_U2_CONNECT_TIMEOUT)
|
||
except Exception as e:
|
||
return False, f"u2 连接失败: {e}", None
|
||
if d is None:
|
||
return False, "u2 连接超时", None
|
||
try:
|
||
w = GenericStepsWorker(serial, {})
|
||
result = w._exec_one(d, step)
|
||
return True, f"步骤已执行({step.get('label') or step.get('type')})", result
|
||
except Exception as e:
|
||
return False, f"执行异常: {e}", None
|