Files
auto_control/tasks/generic/task.py
T
butubb 063818e086 fix(切号): 切完只等(默认 15 秒)不再去点「我」刷新 —— wait_after 可调
用户实测要求:"去掉切完账号下拉刷新这一步,直接改成等待 15 秒"。
原来的做法是"回读不到就再点一次「我」页签"—— 在设备上看着像去点刷新,还可能打断
抖音换号后的整体重载。

· tasks/generic/task.py:切完后按 wait_after(默认 15s,步骤参数可调)静等,等完只回读、
  **不再点任何东西**(读不到就多读几次,页面渲染有零点几秒抖动)
· 参数落到位:STEP_TYPES / 骨架 build_switch_step / 编辑器面板 / 发布页就地编辑字段表
· 测试:用例里 wait_after=0.2(不然一跑就一分多钟)
· 文档:TASK_DEV §3 的 switch_account 行 + research/U2_ELEMENT_SELECTORS §七(记下"只能等"这条实测教训)
2026-10-12 11:04:03 +08:00

2010 lines
107 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
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 - 等待时长
notify - 发自定义通知(标题/正文自己写,推送 webhook)
stop_self - 停止本设备的任务(其它设备不受影响)
gesture - 回放录制的手势轨迹(纯录制:路径与时长照录制,见 core/gesture.py)
loop - 循环块(含 children 步骤列表 + max_iterations)
group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用)
"""
import os
import random
import re
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,
screen_is_on, current_package)
from core.logger import get_logger
from core import notifier, step_log, humanize, patrol, gesture, dedup
_log = get_logger("task.generic")
# 历史抓取器生成的"伪序号"选择器://*[@resource-id="x"][4]
# XPath 里这是"父节点内排第 4",不是"第 4 个匹配"——同属性多实例时 [2..n] 全部失配。
_LEGACY_IDX_XPATH = re.compile(r'^(//\*\[@[^\]]+\])\[(\d+)\](.*)$')
# ---- 「保持亮屏」用的系统设置 ----
# 系统息屏超时(毫秒):保持亮屏时顶到最大,恢复时写回原值
_SCREEN_TIMEOUT_MAX = "2147483647"
_SCREEN_TIMEOUT_DEFAULT = "600000" # 兜底恢复值:10 分钟(原值记不住时用)
_SCREEN_TIMEOUT_BACKUP = {} # serial -> 原始 screen_off_timeout
def _norm_legacy_xpath(sel_val):
"""把历史 `//*[@attr=…][k]` 纠正为 `(//*[@attr=…])[k]`(只改整体前缀,保留后续子路径)。
窄范围:只匹配"属性XPath + 数字谓词"开头的形态;结构路径里
`.../FrameLayout[2]` 的兄弟序号是有意为之,不受影响。
"""
if not isinstance(sel_val, str):
return sel_val
m = _LEGACY_IDX_XPATH.match(sel_val.strip())
if not m:
return sel_val
return f"({m.group(1)})[{m.group(2)}]{m.group(3)}"
# ================== 步骤类型定义(前端操作库 + 后端执行共用)==================
# 「输入文字」的取值来源:plan 的字段名 → build_caption 的 key。
# 集中一处定义,免得下面判据分叉(取了标题却当正文填)——加新来源只改这里。
RELEASE_TEXT_SOURCES = {
"release_title": "title",
"release_title_topics": "title",
"release_body": "body",
"release_body_topics": "body",
"release_title_body": "title_body",
}
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,
"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,
"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": "🎯",
"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, "text_source": "manual", "release_topics": ""}},
{"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,
"cmp_op": "", "cmp_value": "", "cmp_source": "", "cmp_group": "",
"ident_type": "text", "ident_value": "",
"then": [], "else": []}},
{"type": "mark_done", "label": "记为已做", "icon": "✅",
"params": {"selector_type": "text", "selector_value": ""}},
{"type": "read_account_name", "label": "读取本机账号名字", "icon": "🏷",
"params": {"selector_type": "xpath", "selector_value": "",
"timeout": 8, "on_missing": "fail"}},
{"type": "switch_account", "label": "切换账号", "icon": "🔁",
"params": {"target": "var:target_name", "target_value": "",
"switch_selector_type": "xpath", "switch_selector_value": "",
"name_selector_type": "", "name_selector_value": "",
"me_selector_type": "descriptionContains", "me_selector_value": "我",
"timeout": 8, "wait_after": 15, "verify": True, "max_attempts": 2,
"on_login_page": "stop"}},
{"type": "foreach_account", "label": "循环本机待发的号", "icon": "🔂",
"params": {"date_source": "today", "fixed_date": "", "retry_failed": True,
"max_accounts": 0, "children": []}},
{"type": "push_release", "label": "推送发布视频", "icon": "📤",
"params": {"date_source": "today", "fixed_date": "",
"account": "name", "account_phone": "",
"album_dir": "/sdcard/DCIM/Camera", "to_clipboard": True,
"retry_failed": True, "max_same_run": 1}},
{"type": "mark_release", "label": "标记发布结果", "icon": "🏁",
"params": {"result": "published", "why": "", "capture_link": True,
"delete_phone": True, "album_dir": "/sdcard/DCIM/Camera"}},
{"type": "notify", "label": "发通知", "icon": "🔔",
"params": {"title": "", "message": "", "level": "info"}},
{"type": "stop_self", "label": "停止本设备", "icon": "⛔",
"params": {"reason": ""}},
{"type": "gesture", "label": "录制手势", "icon": "⏺",
"params": {"points": [], "speed": 1.0}},
]
DEFAULT_PARAMS = {
"max_duration": 0, # 最大运行时长(秒),0=不限时
# 去重账本的有效期(任务级,**只在这一处配**)。检查与记账两处各填一份的话,
# 填不一致就会算出两个不同的 key、去重静默失效——所以强制任务级。
# day(默认,每天一次)/ all(只做一次,永不清理)/ hours(每 dedup_hours 小时)
"dedup_reset": "day",
"dedup_hours": 6,
# 注意:**没有默认 steps**。步骤由前端步骤编辑器编排产出,新建任务时从零开始拖;
# 缺 steps 的任务执行时会明确报错(见 run_task),不会静默空跑。
}
# 「条件判断」文本比对时读元素文本的超时(秒):只读一次属性,正常几十毫秒
_TEXT_READ_TIMEOUT = 2
# 「条件判断」的文本比对运算符:中文为主(界面上就是这个),英文/符号也认,
# 方便 AI 建任务或手写 JSON 时不用猜用哪个词。
_CMP_ALIASES = {
"等于": ("等于", "==", "=", "eq", "equals", "equal"),
"不等于": ("不等于", "!=", "<>", "ne", "not_equal", "notequal"),
"包含": ("包含", "含", "contains", "in", "has"),
"不包含": ("不包含", "不含", "not_contains", "notcontains", "excludes"),
}
# 界面上给的运算符(顺序即下拉框顺序);上面那张表另收了英文/符号别名。
# AI 草稿校验(core/task_draft.py)用它拦"运算符拼错 -> 永远不命中"。
CMP_OPS = tuple(_CMP_ALIASES)
def _canon_op(op):
"""运算符规范化成中文那个(认别名);认不出返回 None。"""
key = (op or "").strip().lower()
for canon, aliases in _CMP_ALIASES.items():
if key in [a.lower() for a in aliases]:
return canon
return None
def split_cmp_values(raw):
"""把"比对的值"拆成多个候选(**任一命中即可**,即 OR 语义)。
分隔符只有两个:**换行** 和 `|`。
**刻意不拆英文/中文逗号**——要比对的文本本身就常常带逗号
(如 `抖音号:123,456`),拆了就永远匹配不上。界面上是一行一个。
"""
if raw is None:
return []
s = str(raw).replace("\r\n", "\n").replace("\r", "\n").replace("|", "\n")
return [v.strip() for v in s.split("\n") if v.strip()]
def _cmp_hit(got, op, want):
"""核心比对:返回 (是否命中, 命中的那个值/惹事的值)。
多个候选值之间是 **OR**("这个号或那个号都算我"):
· 等于 → 文本等于**任一**候选即命中
· 包含 → 文本含**任一**候选即命中
· 不等于 → 文本**不在**任何候选里才命中
· 不包含 → **一个**候选都不出现才命中
认不出的运算符、或候选全为空 → 一律不命中("看不懂就算不命中",比猜一个更好排查)。
"""
got = (got or "").strip()
wants = split_cmp_values(want)
canon = _canon_op(op)
if canon is None or not wants:
return False, ""
if canon == "等于":
hit = got in wants
return hit, (got if hit else "")
if canon == "包含":
for v in wants:
if v in got:
return True, v
return False, ""
if canon == "不等于":
return (got not in wants), ""
for v in wants: # 不包含
if v in got:
return False, v # 命中了"不该出现"的那个 -> 条件不成立
return True, ""
def _text_matches(got, op, want):
"""元素文本比对(布尔版,多处调用只看结果)。"""
return _cmp_hit(got, op, want)[0]
class GenericStepsWorker(BaseWorker):
"""通用步骤 worker:按 params.steps 顺序执行步骤链。
复用 BaseWorker 的设备生命周期(STF 占用/释放、u2 连接、异常、状态上报、stop)。
只实现 run_task,按步骤类型分发到 _exec_<type> 方法。
"""
def __init__(self, serial, params=None, ctx=None):
super().__init__(serial, params, ctx=ctx)
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 -> 连续未命中次数
# 步骤路径:容器步骤(loop/group/if_el)进出时压栈,得到 "2.1.3" 这种位置,
# 写进「步骤明细」用来还原嵌套结构。worker 每台设备一个线程,实例级状态安全。
self._path = []
self._step_rows = 0 # 本次运行已记录的明细条数(封顶见 _record_step)
self._step_capped = False
# 「去重」条件最近一次解析出的身份:`mark_done` 留空时复用它,
# 保证"检查"与"记账"用的是**同一个身份字符串**(否则 key 对不上、去重失效)
self._last_ident = ""
self._last_release = None # 「推送发布视频」推的那条计划,供「标记发布结果」用
# 运行期变量(一台设备一个 worker 实例,天然隔离,不同任务不会串):
# account_name 「读取本机账号名字」刚读到的名字
# name_selector 读名字用的选择器 (sel_type, sel_val) —— 推送前核对要用同一个控件
# target_name 循环块(foreach_account)本轮设的**目标名字**
self._vars = {}
self._dedup_seen = False # 本轮是否跑过「去重」检查(区分"没配"与"配了但没读到")
# 公共巡检(任务级配置,独立于步骤画布;见 core/patrol.py):
# _wid 内部标识,用来记"下次到点 / 上次命中"两个节奏
self._watchers = []
for i, w in enumerate(p.get("watchers") or []):
if not isinstance(w, dict) or not w.get("enabled", True):
continue
w = dict(w)
w["_wid"] = f"w{i}"
self._watchers.append(w)
self._watch_next = {} # wid -> 下次到点(缺省 0 = 任务一开始就先查一次)
self._watch_last = {} # wid -> 上次命中(冷却用)
# 某 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) # 已上报,重置避免刷屏
# 通知:这是"任务显示成功但什么都没做"的隐蔽故障,值得人看一眼
# (重置计数后要再攒满 10 次才会再报,天然不刷屏)
notifier.notify("task.selector.invalid", serial=self.serial,
selector=str(sel_val)[:200], miss_count=n)
def run_task(self, d):
"""按 steps 顺序执行。loop 步骤递归执行其 children。"""
if not self.steps:
# 任务没配步骤(DEFAULT_PARAMS 不含默认步骤,步骤只能由编辑器产出):
# 明确报错而不是"跑完 0 步"当成功——设备表/任务页会显示该错误
raise ValueError("通用步骤任务没有可执行步骤:请在「任务」页编辑该任务并添加步骤")
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, current_action=summary)
def _exec_steps(self, d, steps, depth=0):
"""执行步骤列表。depth 防止无限嵌套。"""
if depth > 5:
_log.warning(f"[{self.serial}] 步骤嵌套深度超限(>5),跳过")
return
for i, step in enumerate(steps):
if self.stopped() or self.is_time_up():
return
self._maybe_patrol(d) # 穿插:每步之前看一眼有没有巡检到点了
# 压入本步在兄弟里的序号(1 起):容器步骤执行 children 时会在其后
# 继续追加,得到 "2.1" 这样的嵌套路径
self._path.append(str(i + 1))
try:
self._exec_one(d, step, depth)
finally:
self._path.pop()
def _exec_one(self, d, step, depth=0):
"""执行单个步骤。"""
stype = step.get("type", "")
label = step.get("label", stype)
params = step.get("params", {})
# 兼容历史选择器://*[@attr=…][k] → (//*[@attr=…])[k](XPath 位置谓词语义)
if params.get("selector_type") == "xpath" and params.get("selector_value"):
fixed = _norm_legacy_xpath(params["selector_value"])
if fixed != params["selector_value"]:
params = dict(params, selector_value=fixed)
_log.info(f"[{self.serial}] 选择器已纠正为: {fixed}")
# 概率触发:probability=100 必执行,<100 按百分比概率决定本次是否执行
prob = float(params.get("probability", 100))
if prob < 100 and random.random() * 100 > prob:
_log.info(f"[{self.serial}] 步骤 '{label}' 概率 {prob}% 未触发,跳过")
self._record_step(step, "skip", f"概率 {prob}% 未触发", 0)
return
self.set_action(f"执行: {label}")
if stype == "gesture": # 点列可能有几十个点,别整段甩进日志
_log.info(f"[{self.serial}] 步骤: {label}({stype}) "
f"{len(params.get('points') or [])} 个轨迹点")
else:
_log.info(f"[{self.serial}] 步骤: {label}({stype}) params={params}")
ret = None
# 结果归类:异常 > 未命中(handler 返回 False)> 正常
# (True/None 都算正常——None 是"没有判断语义"的步骤,如点击坐标)
result, detail = "ok", ""
t0 = time.time()
handler = getattr(self, f"_exec_{stype}", None)
if handler:
try:
ret = handler(d, params, depth)
if ret is False:
result, detail = "miss", "未命中/超时"
except Exception as e:
_log.error(f"[{self.serial}] 步骤 {label}({stype}) 异常: {e}")
result, detail = "error", str(e)
else:
_log.warning(f"[{self.serial}] 未知步骤类型: {stype}")
result, detail = "unknown", f"未知步骤类型: {stype}"
self._record_step(step, result, detail, int((time.time() - t0) * 1000))
# 容器步骤(loop/group/if_el/foreach_account)不计入操作计数,避免监控显示噪音
if stype not in ("loop", "group", "if_el", "foreach_account"):
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 _record_step(self, step, result, detail, duration_ms):
"""把这一步写进「步骤明细」(异步落库,不阻塞任务线程)。
单次运行条数封顶:`forever` 循环任务会执行成千上万步,不封顶这张表
会被瞬间写爆。到顶后只补一条 cap 说明行,此后本次运行不再记录。
"""
try:
# 只记"任务运行"里的步骤:编辑器里的「测试此步骤」也会走 _exec_one,
# 但没有 run_id(不归任何一次运行),记进来只是噪音
if not self.ctx.get("run_id") or self._step_capped:
return
params = step.get("params") or {}
label = step.get("label") or step.get("type", "")
if self._step_rows >= step_log.MAX_ROWS_PER_RUN:
self._step_capped = True
step_log.record(
run_id=self.ctx.get("run_id", ""), serial=self.serial,
device_name=self.ctx.get("device_name", ""),
job_id=self.ctx.get("job_id", ""),
job_name=self.ctx.get("job_name", ""),
step_path=".".join(self._path), step_label=label,
step_type=step.get("type", ""), result="cap",
detail=f"本次运行明细已达上限 {step_log.MAX_ROWS_PER_RUN} 条,"
f"后续步骤不再记录(保留期与上限见 core/step_log.py)")
return
self._step_rows += 1
step_log.record(
run_id=self.ctx.get("run_id", ""), serial=self.serial,
device_name=self.ctx.get("device_name", ""),
job_id=self.ctx.get("job_id", ""),
job_name=self.ctx.get("job_name", ""),
step_path=".".join(self._path), step_label=label,
step_type=step.get("type", ""),
selector=str(params.get("selector_value", "") or ""),
result=result, detail=detail, duration_ms=duration_ms)
except Exception:
pass # 记录失败绝不能影响任务执行
# ================== 公共巡检(任务级,穿插执行) ==================
def _maybe_patrol(self, d):
"""看一眼有没有巡检到点了;到点就跑一次检查(配置见 core/patrol.py)。
穿插式:不起线程、不抢屏幕,所以巡检精度受主流程步长影响——某一步卡
30 秒,巡检最多晚 30 秒(这是刻意取舍,换来的是绝不会和主流程打架)。
"""
if not self._watchers or self.stopped():
return
now = time.time()
for w in self._watchers:
wid = w["_wid"]
if now < self._watch_next.get(wid, 0):
continue
# 先排下一次再执行:检查本身可能慢(元素检查要 dump UI 树),
# 否则慢检查会导致"每分钟"变成"每检查一次"
self._watch_next[wid] = now + max(5, int(w.get("interval") or 60))
try:
self._run_patrol(d, w, now)
except Exception as e:
_log.warning(f"[{self.serial}] 巡检[{w.get('name') or wid}]异常: {e}")
def _run_patrol(self, d, w, now):
"""执行一次巡检:命中 → 过冷却 → 做动作 + 发通知 + 记一条明细。"""
wid, name = w["_wid"], (w.get("name") or patrol.check_label(w.get("check")))
hit, detail = patrol.evaluate(d, w)
if not hit:
return
# 命中后冷却:条件持续成立(比如一直熄屏)时不要每分钟都动作/发通知
cooldown = max(0, int(w.get("cooldown") or 0))
if now - self._watch_last.get(wid, 0) < cooldown:
_log.info(f"[{self.serial}] 巡检[{name}]命中({detail})但在冷却期内,不重复动作")
return
self._watch_last[wid] = now
# 文案在**动作之前**渲染:动作会改变设备状态(点亮后 {screen} 就成了"亮屏"),
# 而用户要看的是"发现时"的样子——"发现熄屏,已点亮"而不是"发现亮屏,已点亮"
title = self._render_vars(d, w.get("title") or "") or f"巡检命中:{name}"
message = self._render_vars(d, w.get("message") or "") or detail
done = patrol.act(d, w, self)
_log.info(f"[{self.serial}] 巡检[{name}]命中:{detail}"
f"{(' → ' + done) if done else '(只通知)'}")
self.set_action(f"巡检[{name}]:{detail}{(',' + done) if done else ''}")
summary = f"{detail}{(' → ' + done) if done else ''}"
try: # 明细:只记命中,不记"没事发生"
step_log.record(run_id=self.ctx.get("run_id", ""), serial=self.serial,
device_name=self.ctx.get("device_name", ""),
job_id=self.ctx.get("job_id", ""),
job_name=self.ctx.get("job_name", ""),
step_label=f"巡检[{name}]", step_type="patrol",
result="hit", detail=summary)
except Exception:
pass
if w.get("notify", True):
notifier.notify("task.patrol.hit",
patrol_name=name,
check_label=patrol.check_label(w.get("check")),
action_label=patrol.action_label(w.get("action")),
detail=detail,
job_id=self.ctx.get("job_id", ""),
job_name=self.ctx.get("job_name", ""),
serial=self.serial,
device_name=self.ctx.get("device_name", ""),
title=title, message=message,
level=w.get("level", "warning"))
# ================== 步骤执行器 ==================
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 true` **不管用**:它管的是「**充电时**屏幕常亮」
(= stay_on_while_plugged_in)。设备走 WiFi 跑任务、没插充电器时它完全不起作用
——以前"保持亮屏"看着像没生效就是这个原因(2026-09-14 用户反馈)。
真正管用的是把系统**息屏超时**顶到最大(`screen_off_timeout`):不充电也不会
自动黑屏。进入时记下原值,`mode=off` 写回;进程重启丢了记录就写回常规的 10 分钟。
"""
mode = params.get("mode", "on")
try:
if mode == "off":
d.shell("svc power stayon false")
orig = _SCREEN_TIMEOUT_BACKUP.pop(self.serial, _SCREEN_TIMEOUT_DEFAULT)
d.shell(f"settings put system screen_off_timeout {orig}")
_log.info(f"[{self.serial}] 恢复自动息屏(超时 {orig}ms)")
else:
cur = ""
try:
cur = (d.shell("settings get system screen_off_timeout") or "").strip()
except Exception:
pass
if self.serial not in _SCREEN_TIMEOUT_BACKUP:
# 没设过时 `settings get` 会返回 "null"(走系统默认)——别把 null 写回去
_SCREEN_TIMEOUT_BACKUP[self.serial] = \
cur if cur.isdigit() else _SCREEN_TIMEOUT_DEFAULT
d.shell("svc power stayon true") # 充电场景(顺带)
d.shell(f"settings put system screen_off_timeout {_SCREEN_TIMEOUT_MAX}")
d.shell("input keyevent 224") # 立刻唤醒(KEYCODE_WAKEUP,单向)
_log.info(f"[{self.serial}] 保持亮屏(息屏超时→最大,原值 "
f"{_SCREEN_TIMEOUT_BACKUP.get(self.serial)}ms)")
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
# 每次滑动都重新生成(弧线方向/幅度/时长都不一样),不用预算好的固定坐标
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
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
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
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 时自动点击该文字中心。
分支里可放任意步骤(含循环/嵌套条件判断),实现"有弹窗就关掉、没弹窗就继续"这类逻辑。
**文本比对(cmp_op / cmp_value,可选)**:光"元素在不在"不够用——常见需求是
"这台机器上登录的抖音号是不是我配的那个"。填了 cmp_op 就变成:
先按选择器**取到元素的文本**,再拿它和 cmp_value 比(等于/不等于/包含/不包含),
比对结果才是命中与否。例:`resourceId=com.ss.android.ugc.aweme:id/506` 的文本是
`抖音号:35377983067`,cmp_op=包含、cmp_value=35377983067 就是"这台上登录的是它"。
`cmp_value` **可以填多个值**(换行或 `|` 分隔,见 `split_cmp_values`),
多个之间是 **OR**——"这几个号都算我的"。要跑多个号的场景就靠它。
`selector_type="dedup"` 时换成**去重判断**(跨设备幂等,见 `core/dedup.py`):
"这个身份在这个任务里做过了吗"——命中=做过。身份元素填在 `ident_value`
(留空则用设备 serial,适合一号一机);有效期取任务级 `dedup_reset`。
多台手机跑同一任务时,任何一台做过了,其余设备的这一步都会命中。
返回 True=命中 / False=未命中 / None=缺选择器或 OCR 不可用。
"""
sel_type = params.get("selector_type", "xpath")
sel_val = params.get("selector_value", "")
timeout = float(params.get("timeout", 3))
cmp_op = str(params.get("cmp_op") or "").strip()
cmp_val = str(params.get("cmp_value") or "")
# 候选值可以来自「账号台账」(cmp_source)——这里只把 cmp_val 这个**字符串**
# 换掉来路,下面 762-780 的比对、候选计数、日志文案全自动跟着对
# (_cmp_hit 保持纯函数,不掺 DB)
src_note = ""
if cmp_op:
cmp_val, src_note = self._resolve_cmp_value(params, cmp_val)
# 去重条件不用 selector_value(身份元素在 ident_value 里),所以别按"缺选择器"拦
if not sel_val and sel_type != "dedup":
_log.warning(f"[{self.serial}] if_el 缺少选择器,跳过")
return None
found = False
got = None # 比对用的"采集到的值":元素文本 / OCR 命中文本 / 前台包名
# 非元素类条件(屏幕状态/前台 App/去重):不参与"选择器健康"统计——它们不是选择器,
# 连续未命中不该被记成"选择器失效"
dynamic = sel_type in ("screen", "foreground", "dedup")
detail = ""
if sel_type == "dedup":
# 去重条件:**这个身份在这个任务里做过了吗**(跨设备共享一份账本,
# 见 core/dedup.py)。命中=做过 → 走 then 分支(通常放"跳过/停止/发通知")。
kind, hours = self._dedup_policy()
self._dedup_seen = True # 供「记为已做」区分"没配检查"与"配了但没读到"
ident = self._dedup_identity(d, params, "ident_type", "ident_value")
if ident is None:
found = False # 身份判定不了 → 当"没做过",照常往下做(宁可重复不误拦)
detail = "去重:身份读不到,按未做过处理"
else:
self._last_ident = ident # 供后面「记为已做」复用(见 _exec_mark_done)
found = dedup.check(self.ctx.get("job_id", ""), ident, kind, hours)
detail = f"去重:身份『{ident}』{'做过' if found else '没做过'}(有效期 {kind})"
elif sel_type == "screen":
# 屏幕亮/熄:selector_value 填 on / off(也认 熄屏/灭屏 这类中文)
want = str(sel_val).strip().lower()
want_off = want in ("off", "0", "false", "no", "熄屏", "灭屏", "黑屏")
state = screen_is_on(d)
found = (state is False) if want_off else (state is True)
detail = (f"屏幕{'熄屏' if state is False else '亮屏' if state else '状态未知'}"
f"(判断{'熄屏' if want_off else '亮屏'})")
elif sel_type == "foreground":
cur = current_package(d)
got = cur or ""
found = cur == str(sel_val).strip()
detail = f"前台='{cur or '未知'}'(期望 {sel_val})"
elif 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)
got = matched or "" if found else ""
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
# 元素"在不在"的原始结果:选择器健康统计要用它、**不能**用比对结果——
# "找到了但文本不对"是条件按预期走了 else 分支,不是选择器失效。
existed = found
# ---- 文本比对(可选)----
if cmp_op and sel_type not in ("screen", "dedup"):
if not found:
# 元素都没找到就没什么可比的:算未命中(走 else 分支)。
# 这条规则很重要——"没读到"永远不能被当成"和我设的不一样",
# 否则界面还没加载出来时会误判成"账号被别人换了"。
nofind = f"未找到目标,无法比对({cmp_op} {cmp_val})"
detail = f"{detail};{nofind}" if detail else nofind
else:
if got is None:
got = self._read_element_text(d, sel_type, sel_val)
hit, hit_val = _cmp_hit(got, cmp_op, cmp_val)
n_val = len(split_cmp_values(cmp_val))
if hit:
extra = f",命中『{hit_val}』" if hit_val else ""
else:
extra = f",因为出现了『{hit_val}』" if hit_val else ""
detail = (f"采集到 '{got}' {cmp_op} {n_val} 个候选值 → "
f"{'符合' if hit else '不符合'}{extra}")
found = hit
elif cmp_op and str(cmp_val or "").strip() and sel_type in ("screen", "dedup"):
# 只在**真的配了比对的值**时才提醒:从元素类切到「去重/屏幕状态」时
# 界面上那块是隐藏的,旧值会留在 params 里,那种残留每轮都刷警告没意义
_log.warning(f"[{self.serial}] {sel_type} 没有文本可比,已忽略文本比对设置")
# 候选值来源(台账/手填)也要进日志与步骤明细:取 0 个号会让这一步
# **永远走 else 分支**,而任务照样"跑完了"——只有这里能看出原因
if src_note:
detail = f"{detail};{src_note}" if detail else src_note
if dynamic:
_log.info(f"[{self.serial}] 条件判断 {detail} → {'命中' if found else '未命中'}")
else:
self._track_selector_health(sel_val, existed)
branch = params.get("then" if found else "else") or []
if cmp_op:
cmp_txt = "比对命中" if found else "比对未命中"
else:
cmp_txt = "命中" if found else "未命中"
self.set_action(f"条件{cmp_txt} → 执行{'then' if found else 'else'}分支({len(branch)}步)")
_log.info(f"[{self.serial}] 条件判断 {'命中' if found else '未命中'}: {detail or sel_val},执行{'then' if found else 'else'}分支 {len(branch)} 步")
self._exec_steps(d, branch, depth + 1)
return found
def _resolve_cmp_value(self, params, manual_raw):
"""「条件判断」的候选值 = 手填值 + 台账取号(合并,OR 语义)。
返回 (候选值文本, 给日志/步骤明细的来源说明)。
· **合并**而不是二选一:台账取空时手填值仍然生效(台账没维护好不会把任务
直接打哑);"台账里的号 + 临时补一个"也是自然需求 —— split_cmp_values
与 `_cmp_hit` 本来就是多值 OR 语义。
· 取 0 个号时**必须留痕**:候选为空 ⇒ 这一步永远走 else 分支,而任务本身
"跑完了、显示成功",不写日志就只能靠人猜。
· ⚠ 这里产出的只是**比对用的文本**,绝不参与去重 identity:
身份是元素原文逐字算 key,候选值格式不同(`小王` vs `昵称:小王`)会让
去重静默失效(见 core/dedup.py 的 build_key)。
"""
source = str(params.get("cmp_source") or "").strip()
manual = split_cmp_values(manual_raw)
if source == "release":
# 「发布计划」:本机**下一条将要推送**的那条计划的**名字**(推送前校验账号就靠它)。
ids, note = self._release_candidates()
elif source == "account_target":
# 「本次要发的名字」= 循环块设的目标名字,没有就用刚读到的名字(见 _release_scope)。
# ⚠ 读不到名字时候选**为空** → 永远不命中 → 走 else 分支(**不发**)。
# 这正是想要的:读不到名字 = 不知道现在登的是谁 = 不能发。
name = self._scope_name()
if not name:
_log.warning(f"[{self.serial}] 条件判断:本次要发的名字读不到"
f"(前面要有「读取本机账号名字」或循环块设目标)→ 候选为空,"
f"这一步永远不命中(走未找到分支)")
return manual_raw, "没读到本次要发的名字(读不到 = 不发)"
ids, note = [name], f"本次要发的名字『{name}』"
else:
return manual_raw, "" # 手填(默认):行为与以前完全一致
merged = list(ids) + [v for v in manual if v not in ids]
if not ids:
_log.warning(
f"[{self.serial}] 条件判断取号为空(来源 {source}):{note};手填候选 "
f"{len(manual)} 个 → "
+ ("仅用手填值" if manual else "候选为空,这一步将永远不命中(走未找到分支)"))
where = "发布计划" if source == "release" else "本次要发的名字"
src_txt = (f"候选来自{where}:{'、'.join(ids)}" if ids
else f"{where}取不到:{note}")
if manual:
src_txt += f",另加手填 {len(manual)} 个"
return "\n".join(merged), src_txt
def _scope_name(self):
"""本次要发的**名字**:循环块设的目标优先,其次刚读到的本机账号名字。取不到返回 ""。"""
return ((self._vars.get("target_name") or "").strip()
or (self._vars.get("account_name") or "").strip())
def _release_scope(self, params):
"""推送/标记/输入文字共用的"这次发的是哪个号"。返回 `(mode, values, who)`。
`account` 参数:
· `name`(默认)—— **本次要发的名字**(`_scope_name()`)。名字取不到时 values 为空,
调用方按"没有号"跳过:**绝不猜一个号去发**(读不到名字 = 不发)。
· `phone` —— 手填手机号(老任务兼容;账号台账已停用,新任务别再用这条)。
**这个口径只在这一处定义**(`push_release` / `mark_release` / `input_text` 都走它):
分叉就会出现"推的是 A 号、标记的是 B 号"这种对不上的账。
"""
mode = (params.get("account") or "name").strip().lower()
if mode == "phone":
phone = (params.get("account_phone") or "").strip()
return ("phone", [phone] if phone else [],
f"指定手机号 {phone}" if phone else "没填手机号 → 跳过")
name = self._scope_name()
if not name:
return ("name", [],
"没读到本机账号名字(前面要有「读取本机账号名字」,"
"或由循环块设目标名字)→ 跳过")
return ("name", [name], f"名字『{name}』")
def _release_fetch(self, mode, values, date="", how="take", limit=1, retry_failed=True):
"""按 scope 取计划。`how`:`take`=候选(**仍要 claim 占位**)/ `peek`=看下一条(不占位) /
`last`=最近一条"已推到手机、还没标记"的。"""
from core import video_plan as vp
if not values:
return [] if how == "take" else None
if mode == "phone":
if how == "peek":
return vp.peek_for_device(values, date)
if how == "last":
return vp.last_pushed_for_device(values)
return vp.take_for_device(values, date, limit=limit, retry_failed=retry_failed)
if how == "peek":
return vp.peek_for_name(values, date)
if how == "last":
return vp.last_pushed_for_name(values)
return vp.take_for_name(values, date, limit=limit, retry_failed=retry_failed)
def _verify_account_name(self, d, name):
"""推送前**再读一次**手机屏幕上的昵称,核对是不是真的要发的那个名字。
返回 `(是否通过, 读到什么)`。**读不到 = 不通过**(宁可不发,也不发错号)。
没记录昵称选择器时返回 `(True, "")` —— 那种情况下前面必然已经校验过
(切号步骤切完会回读、读取名字步骤刚读过),这里不再重复读一次。
比对用**双向包含**:元素文本可能是 `小王`,也可能带前缀/后缀(`@小王`),
只要有一边包含另一边就算对上 —— 但空文本永远不算对上。
"""
from core import video_plan as vp
sel_type, sel_val = self._vars.get("name_selector") or ("", "")
if not sel_val:
return True, ""
got = vp.clean_account_name(self._read_element_text(d, sel_type, sel_val))
if not got:
return False, ""
return (name in got or got in name), got
def _release_candidates(self):
"""条件判断的「候选值来源 = 发布计划」:本机要发的那条计划的**名字**。
优先用本次运行里「推送发布视频」已经拿到的那条(`_last_release`);
还没有就**看**下一条(peek,不占位)—— 这正是"推送前先校验账号对不对"的用法:
命中(当前登的就是要发的那个名字)→ 走 then 去推送/发布;未命中 → else 跳过并通知。
配「包含」运算符即可。
"""
from core import video_plan as vp
plan = getattr(self, "_last_release", None)
src = "本次已推送的那条"
if not plan:
mode, values, _who = self._release_scope({})
plan = self._release_fetch(mode, values, vp._today(), how="peek")
src = "本机下一条待发"
if not plan:
return [], "本机没有待发的发布计划"
cands = [c for c in (plan.get("nickname"),) if c]
note = (f"{src}计划的名字『{plan.get('nickname')}』"
f"({plan.get('release_date')} #{plan.get('seq')})")
return cands, note
def _exec_read_account_name(self, d, params, depth=0):
"""读取本机当前登录的账号名字(抖音「我」页面上的昵称控件)。
参数:`selector_type`(默认 xpath) `selector_value`(**昵称控件,先抓一次**)
`timeout`(8) `on_missing`(`fail` 默认 = 读不到就算这一步失败 | `skip` = 记一句继续)
`probability`
读到的东西写进 `self._vars`:
· `account_name` = 清洗后的名字(`core.video_plan.clean_account_name`)
· `name_selector` = 用的选择器 —— 「推送发布视频」推送前**用同一个控件**再读一次核对
为什么单独一步而不是藏在推送里:后面的「条件判断」要拿这个名字当候选值
(`cmp_source=account_target`),推送前还要核对 —— 三处必须**同一个控件、同一套清洗**,
所以选择器与清洗规则都收在这里。读到什么会写进「步骤明细」,
名字对不上时一眼能看出"读到的到底是什么"。
返回 True=读到 / False=读不到(`on_missing=fail`)/ None=读不到但 `on_missing=skip`。
"""
from core import video_plan as vp
sel_type = (params.get("selector_type") or "xpath").strip()
sel_val = (params.get("selector_value") or "").strip()
timeout = float(params.get("timeout") or 8)
soft = (params.get("on_missing") or "fail").strip().lower() == "skip"
if not sel_val:
msg = ("昵称控件的选择器是空的 —— 用「抓取元素」抓一下抖音「我」页面上的昵称,"
"在「读取本机账号名字」和后面的「条件判断」里都填上它")
_log.error(f"[{self.serial}] {msg}")
self.set_action(msg)
return None if soft else False
raw = ""
try:
if sel_type == "xpath":
el = d.xpath(sel_val)
if el.wait(timeout=timeout):
raw = el.get_text() or ""
else:
el = d(**{sel_type: sel_val})
if el.exists(timeout=timeout):
raw = el.get_text(timeout=_TEXT_READ_TIMEOUT) or ""
except Exception as e:
_log.warning(f"[{self.serial}] 读取账号名字失败: {e}")
name = vp.clean_account_name(raw)
self._vars["name_selector"] = (sel_type, sel_val)
if not name:
_log.warning(f"[{self.serial}] 读取账号名字:读不到(元素原文 {raw!r})→ "
f"本机算**没有号**:条件判断会走 else、推送会跳过(不发)")
self.set_action("读不到本机账号名字(不发)")
return None if soft else False
self._vars["account_name"] = name
_log.info(f"[{self.serial}] 本机账号名字 = 『{name}』(元素原文 {raw!r})")
self.set_action(f"本机账号:{name}")
return True
# 抖音停在登录/验证码页的字样。**只在"切号后回读校验没过"时才拿它判定** ——
# 平时查会把「我」页面上的普通文字误判成登录页,而误判会让整台设备停掉。
_LOGIN_HINTS = ("验证码", "其他方式登录", "手机号登录", "一键登录", "密码登录",
"登录/注册", "同意用户协议", "选择登录方式")
def _notify_account(self, event, want="", got="", attempts=0, msg=""):
"""账号相关通知(切号失败 / 需要登录)。notify 零 DB,发不出去不影响任务。"""
try:
notifier.notify(event, serial=self.serial,
device_name=(self.ctx or {}).get("device_name", ""),
model=(self.ctx or {}).get("model", ""),
want=want, got=got, attempts=attempts, msg=msg)
except Exception as e:
_log.warning(f"账号通知发送失败(不影响任务):{e}")
def _read_name_now(self, d, params):
"""现在屏幕上读一次昵称(清洗后)。选择器:步骤参数优先,其次「读取本机账号名字」
记下的那个;都没有 → 返回 ""(= 读不到,调用方按"没有号"处理)。"""
from core import video_plan as vp
sel_type = (params.get("name_selector_type") or "").strip()
sel_val = (params.get("name_selector_value") or "").strip()
if not sel_val:
sel_type, sel_val = self._vars.get("name_selector") or ("", "")
if not sel_val:
return ""
return vp.clean_account_name(self._read_element_text(d, sel_type or "xpath", sel_val))
def _click_sel(self, d, sel_type, sel_val, timeout=8):
"""按选择器等元素出现并点击。找不到返回 False(不抛异常)。"""
if not sel_val:
return False
try:
if sel_type == "xpath":
el = d.xpath(sel_val)
if el.wait(timeout=timeout):
el.click()
return True
return False
el = d(**{sel_type: sel_val})
if el.exists(timeout=timeout):
el.click()
return True
except Exception as e:
_log.warning(f"[{self.serial}] 点击失败({sel_type}={sel_val}): {e}")
return False
def _find_in_list(self, d, name, timeout=1):
"""账号列表里**有没有**这个名字的控件(只判断,不点)。返回那个 UiObject 或 None。"""
for kw in ({"textContains": name}, {"descriptionContains": name}, {"text": name}):
try:
obj = d(**kw)
if obj.exists(timeout=timeout):
return obj
except Exception as e:
_log.debug(f"[{self.serial}] 列表里按 {kw} 找『{name}』失败: {e}")
return None
def _click_in_list(self, d, name, timeout=4):
"""在账号列表里**按名字**找控件并点击(文本优先,再试描述/精确文本)。
⚠ **绝不用 `[N]` 序号**:`doc/research/U2_ELEMENT_SELECTORS.md` 实测序号型会随界面
变形错位 —— 多点一个就是把内容发到别人号上。
"""
obj = self._find_in_list(d, name, timeout)
if obj is None:
return False
try:
obj.click()
return True
except Exception as e:
_log.warning(f"[{self.serial}] 点列表里的『{name}』失败: {e}")
return False
def _screen_digest(self, d, limit=6):
"""给日志用的一小段"现在屏幕上有什么"。
「点了没反应」这类故障,光看"找不到元素"没法判:是息屏了、跳到别的页面了、
还是界面还没加载出来 —— 把当时屏上的头几个字打出来,一眼能分辨。
"""
try:
xml = d.dump_hierarchy()
except Exception as e:
return f"(读元素树失败:{type(e).__name__})"
vals = []
for m in re.finditer(r'(?:text|content-desc)="([^"]{1,24})"', xml):
v = m.group(1).strip()
if v and v not in vals:
vals.append(v)
if len(vals) >= limit:
break
on = ""
try:
on = "亮屏" if screen_is_on(d) else "**息屏/锁屏**"
except Exception:
pass
return f"{on} 前台={current_package(d) or '未知'} 屏上:{'、'.join(vals) or '(空)'}"
def _switch_target(self, params):
"""这一步切到哪个名字:`var:target_name`(默认,循环块设的目标)/
`var:account_name`(刚读到的)/ `literal` + `target_value`。"""
t = (params.get("target") or "var:target_name").strip()
if t.startswith("var:"):
key = t[4:].strip() or "target_name"
val = (self._vars.get(key) or "").strip()
if not val and key == "target_name":
val = (self._vars.get("account_name") or "").strip()
return val
return (params.get("target_value") or "").strip()
def _page_has_text(self, d, words, timeout=0.4):
"""页面上有没有这些字样(返回命中的词,都没有返回 "")。"""
for w in words:
try:
if d(textContains=w).exists(timeout=timeout):
return w
except Exception:
pass
return ""
def _handle_login_page(self, target, hint, params):
"""停在登录/验证码页:通知 + (默认)停本设备。**平台绝不代替登录/过滑块**。"""
msg = f"抖音停在需要登录的页面(出现「{hint}」)—— 请人工登录后再跑"
_log.error(f"[{self.serial}] {msg}(想切『{target}』)")
self.set_action(f"需要人工登录({hint})→ 停本设备")
self._notify_account("task.account.need_login", want=target, msg=msg)
if (params.get("on_login_page") or "stop").strip().lower() != "continue":
self.stop()
return False
def _exec_switch_account(self, d, params, depth=0):
"""切到本次要发的那个账号(**只在本机已登录过的号之间切**)。
参数:`target`(`var:target_name` 默认,或 `literal` + `target_value`)、
`switch_selector_type`/`switch_selector_value`(进「账号切换」列表那一下,**待抓**)、
`name_selector_*`(昵称控件,默认沿用「读取本机账号名字」记下的那个)、
`me_selector_*`(底部「我」,默认 `descriptionContains=我`)、
`timeout`(8) `verify`(True) `max_attempts`(2) `on_login_page`(stop)
流程:当前名字已经是目标 → 直接成功(第一个号不用切)→ 点「我」→ 点切换入口 →
**在列表里按名字找控件点击** → 等待重载 → **回读校验**(`verify`)。
返回 True=已经是目标/切成功;False=没切成(本轮该号会被守卫挡下、**不推送**)。
**列表里没有这个名字 = 本机没登这个号** → 立刻返回 False(不重试、不算故障):
多台设备各自"试自己有的号"就靠这条,不会互相误报。
停在登录/验证码页 → 通知 `task.account.need_login` + 默认停本设备。
"""
target = self._switch_target(params)
if not target:
_log.warning(f"[{self.serial}] 切换账号:没给目标名字 → 跳过(不发)")
self.set_action("切号:没给目标名字 → 跳过")
return False
timeout = float(params.get("timeout") or 8)
want_verify = params.get("verify", True) is not False
attempts = max(1, int(params.get("max_attempts") or 2))
got = self._read_name_now(d, params)
if got and got == target:
_log.info(f"[{self.serial}] 切换账号:当前就是『{target}』,不用切")
self.set_action(f"已经是『{target}』,不用切")
return True
switch_val = (params.get("switch_selector_value") or "").strip()
if not switch_val:
msg = ("「进账号切换列表」的选择器是空的 —— 用「抓取元素」抓一下抖音里那个入口"
"(「我」页面 → 切换账号),填到这一步上")
_log.error(f"[{self.serial}] {msg}")
self.set_action(msg)
return False
me_type = params.get("me_selector_type") or "descriptionContains"
me_val = params.get("me_selector_value") or "我"
last_got = ""
for i in range(1, attempts + 1):
if self.stopped():
return False
self._click_sel(d, me_type, me_val, timeout=min(timeout, 4))
time.sleep(1.0)
if not self._click_sel(d, params.get("switch_selector_type") or "xpath",
switch_val, timeout):
hint = self._page_has_text(d, self._LOGIN_HINTS)
if hint:
return self._handle_login_page(target, hint, params)
_log.warning(f"[{self.serial}] 切换账号:进不去账号列表(第 {i}/{attempts} 次)"
f"—— {self._screen_digest(d)}")
continue
# 先**只等**面板开稳(等目标那一行出现,不点):实测"刚出现就点"会被
# 面板的入场动画**吞掉** —— 号没切成、面板还开着,第二轮又变成"进不去列表"。
hit = False
deadline = time.time() + max(6.0, timeout)
while time.time() < deadline:
if self.stopped():
return False
if self._find_in_list(d, target, timeout=1) is not None:
hit = True
break
time.sleep(0.5)
if hit:
time.sleep(0.8) # 等面板动画结束(上面那条注释说的时间点)
# 点一下;点完**面板还开着**就再点一次,仍开着就按回退把面板关掉。
# 为什么要关:**点的如果正好是当前号,抖音什么都不做、面板也不关** ——
# 那种情况下"面板还开着"不代表失败,得回到「我」页用**回读**来判。
self._click_in_list(d, target, timeout=2)
time.sleep(1.5)
if self._find_in_list(d, target, timeout=1) is not None:
self._click_in_list(d, target, timeout=2)
time.sleep(1.5)
if self._find_in_list(d, target, timeout=1) is not None:
try:
d.press("back") # 关掉面板,露出「我」页
except Exception:
pass
time.sleep(1.5)
if not hit:
hint = self._page_has_text(d, self._LOGIN_HINTS)
if hint:
return self._handle_login_page(target, hint, params)
_log.warning(f"[{self.serial}] 切换账号:账号列表里没有『{target}』"
f"(等了 {int(max(6.0, timeout))}s)→ 本机没登这个号,跳过(不重试)")
self.set_action(f"本机没有『{target}』这个号 → 跳过")
self._notify_account("task.account.switch_failed", want=target,
msg="账号列表里没有这个名字(本机没登这个号)")
return False
if not want_verify:
self.set_action(f"已点击『{target}』(未校验)")
return True
# 切完之后**先等它自己重载**(默认 15 秒,`wait_after` 可在步骤参数里调)。
# 抖音换号会整体重启,中途去点任何东西(包括再点一次「我」页签那一下"刷新")
# 都可能打断它加载 —— 实测用户的要求就是:"别刷新,等 15 秒就行"。
wait_after = float(params.get("wait_after") or 15)
end = time.time() + max(0.0, wait_after)
while time.time() < end and not self.stopped():
time.sleep(0.5)
# 等完再回读;**不再点任何东西**,只多读几次(页面渲染有零点几秒的抖动)
last_got = ""
for _ in range(5):
if self.stopped():
return False
last_got = self._read_name_now(d, params)
if last_got == target:
_log.info(f"[{self.serial}] 切换账号成功:『{target}』(第 {i} 次,"
f"等了 {wait_after:.0f}s)")
self.set_action(f"已切到『{target}』")
self._vars["account_name"] = target
return True
time.sleep(0.8)
hint = self._page_has_text(d, self._LOGIN_HINTS) if not last_got else ""
if hint:
return self._handle_login_page(target, hint, params)
_log.warning(f"[{self.serial}] 切换账号:点完『{target}』回读是"
f"『{last_got or '读不到'}』(第 {i}/{attempts} 次)")
self._notify_account("task.account.switch_failed", want=target, got=last_got,
attempts=attempts,
msg=f"切换后回读仍是『{last_got or '读不到'}』")
self.set_action(f"切号失败:要『{target}』,读到『{last_got or '读不到'}』")
return False
def _exec_push_release(self, d, params, depth=0):
"""推送发布视频:把本机某天待发的计划**推到手机相册**(**不发布**)。
**平台只负责到这里**:推文件 + 触发相册刷新 + 把标题写进剪贴板;
抖音里怎么发(打开→点+→相册→选视频→下一步→粘贴标题→发布)由你在这条步骤
**后面自己画步骤**,最后放一个「标记发布结果」回写状态。
参数:`date_source`(today/fixed) `fixed_date` `account`(name/phone) `account_phone`
`album_dir`(默认 `/sdcard/DCIM/Camera`)`to_clipboard`(True) `retry_failed`(True)
`max_same_run`(1 —— 一次运行推几条;**多条时「标记」只认最后一条**,要发多条就重复"推送+标记"两步)
`account=name`(默认)时**推送前会再读一次屏幕上的昵称核对**:对不上(或读不到)
就**不推**(宁可不发,也不发错号),返回 None 让后面按"没有计划"走。
返回 None=本机没有待推的(跳过);True/False=至少推成一条/全失败。
"""
from tasks.generic import publish_flow as flow
from core import video_plan as vp
date = ((params.get("fixed_date") or "").strip()
if params.get("date_source") == "fixed" else vp._today()) or vp._today()
mode, values, who = self._release_scope(params)
if not values:
_log.warning(f"[{self.serial}] 推送发布视频:{who}")
self.set_action(f"跳过:{who}")
return None
if mode == "name":
ok, got = self._verify_account_name(d, values[0])
if not ok:
_log.warning(f"[{self.serial}] 推送发布视频:核对不过 —— 屏幕上是『{got or '读不到'}』,"
f"要发的是『{values[0]}』**不推**(宁可不发,也不发错号)")
self.set_action(f"跳过:当前账号『{got or '读不到'}』≠ 要发的『{values[0]}』")
return None
plans = self._release_fetch(mode, values, date, how="take",
limit=int(params.get("max_same_run") or 1),
retry_failed=params.get("retry_failed", True) is not False)
if not plans:
_log.info(f"[{self.serial}] 推送发布视频:{who} 在 {date} 没有待推送的计划 → 跳过")
self.set_action(f"{date} 没有待推送计划")
return None
opts = {"album_dir": (params.get("album_dir") or "").strip() or flow.ALBUM_DIR,
"to_clipboard": params.get("to_clipboard", True) is not False,
# 记"这条实际是哪台设备发的"(计划不绑设备,只能推的时候现场记)
"device_name": (self.ctx or {}).get("device_name", "")}
okN = badN = 0
for plan in plans:
if self.stopped():
break
self._last_release = plan # 供后面的「标记发布结果」用
self.set_action(f"推送中:{plan.get('nickname') or plan['phone']} "
f"{plan['release_date']} #{plan['seq']}")
ok, msg = flow.push_one(self.serial, plan, opts,
log=lambda s: _log.info(f"[{self.serial}] {s}"))
if ok:
okN += 1
# 相册校验结果**单独说一句**:文件上去了 ≠ 相册里点得到它,
# 后面那步"点相册里第一个"靠的就是索引(不写这句,出了问题只能猜)
row = vp.get_plan(plan["id"]) or {}
if row.get("push_verify") == "no_index":
_log.warning(f"[{self.serial}] ⚠ 素材推上去了但**没进相册索引**"
f"(抖音选视频时可能看不到它)")
else:
badN += 1
self._notify_publish(plan, ok=False, msg=msg)
if okN and self._last_release:
lr = self._last_release
title = (lr.get("title") or "")[:20]
self.set_action(f"已推送到手机:{lr.get('nickname') or lr.get('phone')} "
f"{lr['release_date']} #{lr['seq']}"
+ (f"(标题『{title}』已进剪贴板)" if title else "")
+ ";发完记得放「标记发布结果」")
_log.info(f"[{self.serial}] 已推到手机,标题『{title}』已进剪贴板 —— "
f"后面用「粘贴」把它填进抖音,发完记得放一个「标记发布结果」")
else:
self.set_action(f"推送发布视频:成功 {okN} 条"
+ (f",失败 {badN} 条" if badN else ""))
return okN > 0 if (okN or badN) else None
def _exec_mark_release(self, d, params, depth=0):
"""标记发布结果:把"发出去了没有"回写到计划行(**你自己发布流程的最后一步**)。
参数:`result` = `published`(发出去了) / `failed`(没发出去,可重试) / `unknown`(不确定)
· `why` 失败原因(可选)· `capture_link`(True) 成功时顺带抓作品分享链接
· `delete_phone`(True) 成功/失败后删掉手机上的素材
放在你画布的**成功分支**里填 `published`、失败分支里填 `failed`;
拿不准有没有发出去就填 `unknown`(平台不会自动重发它)。
返回 True=标记成功 / False=发布失败 / None=找不到对应的计划(跳过)。
"""
from tasks.generic import publish_flow as flow
from core import video_plan as vp
import uiautomator2 as u2
plan = getattr(self, "_last_release", None)
if not plan:
mode, values, _who = self._release_scope(params)
plan = self._release_fetch(mode, values, how="last")
if not plan:
_log.warning(f"[{self.serial}] 标记发布结果:找不到对应的计划行 → 跳过"
f"(前面要先有一个「推送发布视频」步骤)")
self.set_action("没有可标记的计划")
return None
result = (params.get("result") or "published").strip().lower()
if result not in ("published", "failed", "unknown"):
_log.warning(f"[{self.serial}] 标记发布结果:result={result!r} 认不出 → 按 unknown 处理")
result = "unknown"
why = (params.get("why") or "").strip()
# 手机上的素材:先删再回写状态 —— 删失败不影响发布结论。
# 路径优先用**推送时记下的** `push_remote`(视频/图片扩展名不同,不能靠猜),
# 没有就按 `{相册目录}/{计划id}{素材自己的扩展名}` 现算。
if params.get("delete_phone", True) is not False:
try:
dd = d or u2.connect(self.serial)
album = (params.get("album_dir") or "").strip() or flow.ALBUM_DIR
ext = os.path.splitext(plan.get("video_file") or "")[1] or ".mp4"
remote = (plan.get("push_remote") or "").strip() or f"{album}/{plan['id']}{ext}"
flow.clean_phone_video(dd, remote)
except Exception as e:
_log.warning(f"[{self.serial}] 删手机素材失败(不影响标记): {e}")
if result == "published":
url = ""
if params.get("capture_link", True) is not False:
try:
dd = d or u2.connect(self.serial)
url, lmsg = flow.capture_share_link(dd, self.serial)
_log.info(f"[{self.serial}] 抓分享链接:{url or ('失败:' + lmsg)}")
except Exception as e:
_log.warning(f"[{self.serial}] 抓分享链接异常: {e}")
vp.mark_done(plan["id"], share_url=url, msg=why)
self._notify_publish(dict(plan, share_url=url), ok=True, msg=why or "已标记发布成功")
self.set_action(f"已标记发布成功({'链接 ' + url if url else '没抓到链接'})")
return True
if result == "failed":
# **确认没发出去** → 直接落 `failed`(可重试),不能走 mark_failed('post')
# —— 那会落到"结果未知"、反而不敢重试。这里人是亲眼确认过的。
vp.set_status(plan["id"], vp.ST_FAILED, note=why or "人工标记:没发出去")
self._notify_publish(plan, ok=False, msg=why or "人工标记:没发出去")
self.set_action("已标记失败(下次任务会再推一次)")
return False
vp.mark_failed(plan["id"], "verify", why or "人工标记:结果不确定")
self._notify_publish(plan, ok=False, msg=why or "人工标记:结果不确定")
self.set_action("已标记「结果未知」(不会自动重发,需人工确认)")
return False
def _notify_publish(self, plan, ok, msg):
"""发布结果推通知(notify 零 DB:字段都在计划行里,不查库)。"""
try:
notifier.notify("task.video.published" if ok else "task.video.failed",
serial=self.serial,
device_name=(self.ctx or {}).get("device_name", ""),
model=(self.ctx or {}).get("model", ""),
phone=plan.get("phone", ""),
nickname=plan.get("nickname", ""),
release_date=plan.get("release_date", ""),
seq=plan.get("seq", 0),
title=(plan.get("title") or "")[:60],
share_url=plan.get("share_url", ""),
msg=msg)
except Exception as e:
_log.warning(f"发布通知发送失败(不影响发布):{e}")
def _dedup_policy(self):
"""任务级去重有效期(`dedup_reset` / `dedup_hours`)→ (kind, hours)。
**刻意放任务级**:检查与记账两处各填一份的话,填不一致就会算出两个不同的 key、
去重静默失效(最难查的那种 bug)。认不出的值一律回退 `day`。
"""
p = self.params or {}
kind = str(p.get("dedup_reset") or dedup.KIND_DAY).strip()
if kind not in dedup.RESET_KINDS:
kind = dedup.KIND_DAY
try:
hours = int(p.get("dedup_hours") or dedup.DEFAULT_HOURS)
except (TypeError, ValueError):
hours = dedup.DEFAULT_HOURS
return kind, max(1, hours)
def _dedup_identity(self, d, params, tkey, vkey):
"""算去重身份:填了身份元素就读它的文本;留空则用设备 serial(一号一机场景)。
返回 None = **无法判定**(读不到值)→ 调用方必须"不去重"。
宁可漏拦一次,也不能把"读不到"当成空身份——那会让所有设备共用一个 key,
第一台记账之后其余全被误跳过(同一个号一台都没做,却被判"做过")。
"""
sel_val = (params.get(vkey) or "").strip()
if not sel_val:
return self.serial
txt = self._read_element_text(d, params.get(tkey) or "text", sel_val)
if not txt:
_log.warning(f"[{self.serial}] 去重身份元素读不到值({sel_val})→ 本次不去重")
return None
return txt
def _exec_mark_done(self, d, params, depth=0):
"""「记为已做」:把身份值记进去重账本(跨设备幂等)。
**放在动作成功之后**:动作失败就不记账,下次重跑会重试该设备——
拆成"检查在前、记账在后"两步,天然就是"成功才记账、失败不丢"。
身份读不到时**不记账**(记一个空身份会把别人挡住)。
**身份留空 = 复用前面「去重」条件刚解析出的那个身份**(推荐这样配):
检查与记账必须用**同一个身份字符串**,key 才算得到一起;让用户在两处各配一遍
元素,一旦不一致(填了一个、另一个忘了)去重就静默失效——这是最容易踩的坑,
所以默认行为改成"自动跟随上面的检查"。
"""
kind, hours = self._dedup_policy()
if (params.get("selector_value") or "").strip():
ident = self._dedup_identity(d, params, "selector_type", "selector_value")
elif self._last_ident:
ident = self._last_ident
_log.info(f"[{self.serial}] 记为已做:复用上面去重检查的身份『{ident}』")
elif self._dedup_seen:
# 上面**跑了**去重检查、但身份读不到(元素没出来/页面没到)。
# 这时**绝不能**退回设备身份记一笔:那会在"什么都没做成"的情况下
# 把设备标成已做。不记 → 下次重跑会重试。
ident = None
_log.warning(f"[{self.serial}] 记为已做:上面去重检查没读到身份(页面没到?)"
f"→ 本次不记账,下次重跑会重试")
else:
# 任务里根本没有前置去重检查、又没填身份元素 → 退回设备身份(一号一机场景)
ident = self.serial
_log.info(f"[{self.serial}] 记为已做:没填身份元素也没有前置去重检查 → "
f"按设备身份记账({ident})")
if not ident:
self.set_action("记为已做:身份读不到 → 本次不记(下次重跑会重试)")
return True
first = dedup.mark(self.ctx.get("job_id", ""), ident, kind, hours,
serial=self.serial,
device_name=self.ctx.get("device_name", ""),
job_name=self.ctx.get("job_name", ""))
who = "首次" if first else "已存在(别处先记过)"
_log.info(f"[{self.serial}] 记为已做:身份『{ident}』 → {who}")
self.set_action(f"记为已做:『{ident}』{who}")
return True
def _read_element_text(self, d, sel_type, sel_val):
"""取选中元素的文本(文本比对用)。读不到返回 ""。
只读操作,不改任何状态;失败静默返回空串,由调用方按"空文本"去比对
(日志里会打出实际读到的值,排查时能看出是空还是不对)。
注意 u2 两种元素的 exists 语义不同,别"统一"掉:
· `d.xpath(...)` → XPathSelector.exists 是**纯 bool 属性**
· `d(text=...)` → UiObject.exists 是**可调用的 Exists 对象**
(`bool()` 即时判断、`exists(timeout=t)` 带等待)——两种写法都对
"""
try:
if sel_type == "xpath":
el = d.xpath(sel_val)
if not el.exists:
return ""
return (el.get_text() or "").strip()
el = d(**{sel_type: sel_val})
if not el.exists:
return ""
# 限时读:元素在"检查"与"读"之间消失时,别挂满默认等待(默认可能是 10s)
return (el.get_text(timeout=_TEXT_READ_TIMEOUT) or "").strip()
except Exception as e:
_log.warning(f"[{self.serial}] 读取元素文本失败(按空文本比对): {e}")
return ""
def _exec_swipe(self, d, params, depth=0):
"""滑动一次(默认拟人:弧线 + 抖动 + 本设备手速,见 core/humanize.py)。
老行为(正中直线、每次都一样)可以用 humanize=false 还原;
jitter=0 则保留轨迹但去掉随机抖动(对照排障用)。
"""
direction = params.get("direction", "up")
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_notify(self, d, params, depth=0):
"""发一条自定义通知(标题/正文由任务自己写,支持占位符)。
事件是 `task.notify.custom`:**谁能收到取决于 webhook 的事件订阅**——
这个步骤只负责"把消息交出去",不关心发给谁(与平台其它通知同一套口径)。
"""
title = self._render_vars(d, params.get("title") or "")
msg = self._render_vars(d, params.get("message") or "")
if not (title or msg):
_log.warning(f"[{self.serial}] notify 步骤既没标题也没内容,跳过")
return None
notifier.notify("task.notify.custom",
job_id=self.ctx.get("job_id", ""),
job_name=self.ctx.get("job_name", ""),
serial=self.serial,
device_name=self.ctx.get("device_name", ""),
title=title or "任务通知", message=msg,
level=params.get("level", "info"))
_log.info(f"[{self.serial}] 已发自定义通知: {title} {msg}")
return True
def _render_vars(self, d, text):
"""替换通知文本里的占位符:{device} {serial} {job} {time} {app} {screen}。
`{app}` / `{screen}` 要查设备(0.3~0.7s),**只在文本里真用到时才查**,
否则每条通知都白付一次设备往返。
"""
if not text or "{" not in text:
return text
vals = {"device": self.ctx.get("device_name") or self.serial,
"serial": self.serial,
"job": self.ctx.get("job_name", ""),
"time": time.strftime("%Y-%m-%d %H:%M:%S")}
if "{app}" in text:
vals["app"] = current_package(d) or "未知"
if "{screen}" in text:
st = screen_is_on(d)
vals["screen"] = "未知" if st is None else ("亮屏" if st else "熄屏")
for k, v in vals.items():
text = text.replace("{" + k + "}", str(v))
return text
def _exec_stop_self(self, d, params, depth=0):
"""停止本设备的任务(其它设备的任务不受影响)。
实现只是置 worker 的停止位:`_exec_steps` 每步前检查 `stopped()`,所以
后续步骤不会再跑;TaskManager 侧把它记成"被停止"而不是失败
(见 `core/task_manager.py` 的 `_run_with_retry` 自停分支)。
"""
reason = (params.get("reason") or "").strip()
_log.info(f"[{self.serial}] 任务内主动停止本设备({reason or '未填原因'})")
self.set_action(f"停止本设备任务{(':' + reason) if reason else ''}")
self.stop()
return True
def _exec_gesture(self, d, params, depth=0):
"""回放**录制**的手势(路径与时长照录制,不做任何再加工)。
与「滑动」步骤是两套东西:滑动每次重新生成(弧线/抖动/本设备手速),
这里只把录下来的点列原样交给设备插值(原因见 core/gesture.py 模块头)。
"""
pts = params.get("points") or []
if len(pts) < 2:
_log.warning(f"[{self.serial}] gesture 步骤没有有效轨迹(points 为空),跳过")
return None
info = gesture.replay(d, pts, speed=params.get("speed", 1.0))
_log.info(f"[{self.serial}] 录制回放:{info['points']} 个点 / {info['duration']}s")
return True
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)
# 可选:按本设备节奏缩放(勾了"设备节奏差异"才生效,默认关——
# 时长被放大/缩小是业务语义,不能悄悄改)
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():
return
self._maybe_patrol(d) # 长等待里也看一眼,别让巡检被 wait 拖住
# max(0, …):巡检本身要花时间(屏幕 0.3s、前台 0.7s、元素检查更久),
# 扣掉这段时间后可能已经过点了,负数 sleep 会直接抛异常
time.sleep(max(0.0, min(0.5, end - time.time())))
def _exec_foreach_account(self, d, params, depth=0):
"""循环块:**一轮把本机待发的号跑完**(一直切号发,直到发完)。
参数:`date_source`(today/fixed)、`fixed_date`、`retry_failed`(True)、
`max_accounts`(0=不限)、`children`(要重复跑的那段:切号 → 读名 → 守卫 → 推送 → 发布 → 标记)
循环口径(**只在这一处定义**):
· 数据源 = `vp.pending_names(date)` —— 计划表里**还有待发内容**的名字,
按 `(发布日期, 编号)` 升序。**不需要任何账号台账**:谁还有待发内容谁就在列表里。
· 外层:挑一个**还没试过**的名字 → 写 `_vars["target_name"]` → 跑 children。
试过一次就不回头(`tried`)——"本机没登这个号"不会把整轮卡死。
· 内层:这个名字**还有待发内容就一直发**(同一个号多条视频);
跑完 children 后待发数没变(切号失败/守卫没过/什么都没占位)→ 立刻换下一个号,
绝不空转。
· 三个干净出口:`stopped()`(外部停止)/ `is_time_up()`(任务时长上限)/ `max_accounts`。
跑到一半被停是安全的:`claim()` 已经占位的行由僵尸回收落 `failed`/`unknown`,
没跑到的号下一轮 `pending_names` 还会选中 —— **不用怕"没发完就走了"**。
"""
from core import video_plan as vp
children = params.get("children") or []
if not children:
_log.warning(f"[{self.serial}] 循环本机待发的号:没有子步骤 → 直接结束")
return None
date = ((params.get("fixed_date") or "").strip()
if params.get("date_source") == "fixed" else vp._today()) or vp._today()
retry_failed = params.get("retry_failed", True) is not False
max_accounts = int(params.get("max_accounts") or 0)
tried, sent = [], 0
while True:
if self.stopped() or self.is_time_up():
_log.info(f"[{self.serial}] 循环本机待发的号:停止/超时 → 收工(跑过 {sent} 个号)")
break
if max_accounts and sent >= max_accounts:
_log.info(f"[{self.serial}] 循环本机待发的号:到 max_accounts={max_accounts} → 收工")
break
names = [x["name"] for x in vp.pending_names(date, retry_failed)
if x["name"] not in tried]
if not names:
_log.info(f"[{self.serial}] 循环本机待发的号:没有待发的号了 → 收工"
f"(跑过 {sent} 个:{'、'.join(tried) or '无'})")
break
target = names[0]
tried.append(target) # 试过就不回头(防死循环)
self._vars["target_name"] = target
sent += 1
self.set_action(f"第 {sent} 个号:{target}(本机还有 {len(names)} 个待发名字)")
_log.info(f"[{self.serial}] 循环本机待发的号:轮到『{target}』"
f"(本机还有 {len(names)} 个待发名字)")
inner = 0
while True:
if self.stopped() or self.is_time_up():
break
before = vp.pending_count(target, date, retry_failed)
if before <= 0:
break
self._exec_steps(d, children, depth + 1)
after = vp.pending_count(target, date, retry_failed)
if after >= before:
# 这一轮一条都没占到(切号失败 / 守卫没过 / 推送没成)→ 别空转
_log.info(f"[{self.serial}] 『{target}』这一轮没有进展(仍 {after} 条待发)"
f"→ 换下一个号")
break
inner += 1
if inner > 20: # 同一个号一次跑这么多轮显然不对(正常一个号几条)
_log.warning(f"[{self.serial}] 『{target}』已跑 {inner} 轮 → 收手,换下一个号")
break
self._vars.pop("target_name", None)
self.set_action(f"本轮跑过 {sent} 个号:{'、'.join(tried)}" if tried else "没有待发的号")
return None
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
**`text_source`(可选):文字从哪来**
· `manual`(默认)= 用上面的 mode(行为与以前完全一致)
· `release_title` = **本机当前那条发布计划的标题**("推送发布视频"推的那条;
本机没有就取最近一条已推送的)· 配 `release_topics` 还能自动加话题
· `release_body` = 那条计划的**正文**(同一套来源与回读校验)
· `release_title_body` = 标题 + 换行 + 正文(抖音只有一个输入框时用这个)
选发布计划来源时**会回读校验**(读输入框的 text):不校验的后果是
"抖音不吃 send_keys → 发出一篇空文案,而每一步都显示成功";
校验不过就自动改走剪贴板通道粘贴(设备端 Agent)再验一次。
"""
src = str(params.get("text_source") or "manual").strip()
text = ""
if src in RELEASE_TEXT_SOURCES:
from core import video_plan as vp
plan = getattr(self, "_last_release", None)
if not plan:
mode, values, _who = self._release_scope(params)
plan = self._release_fetch(mode, values, how="last")
if plan:
text, _err = vp.build_caption(
plan, params.get("release_topics") or "",
key=RELEASE_TEXT_SOURCES[src])
self._last_release = self._last_release or plan
_log.info(f"[{self.serial}] 输入文字取计划{RELEASE_TEXT_SOURCES[src]}:"
f"{plan.get('nickname')} "
f"{plan.get('release_date')} #{plan.get('seq')}")
if not text:
_log.warning(f"[{self.serial}] 输入文字:本机没有可用的发布计划内容 → 跳过"
f"(前面要先有「推送发布视频」步骤)")
return None
if not 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}")
# 取计划标题时**必须回读校验**:抖音的 EditText 有时不吃 send_keys,
# 不校验就可能发出一篇空文案而每一步都显示成功
if src in RELEASE_TEXT_SOURCES:
return self._verify_input(d, text, clear_first)
return None
def _verify_input(self, d, text, clear_first):
"""回读输入框,确认文字真的进去了;不行就改走剪贴板通道再验一次。"""
def _read():
try:
el = d(className="android.widget.EditText")
if el.exists:
return el.get_text(timeout=2) or ""
except Exception:
pass
return ""
for attempt in (1, 2):
got = _read()
if got and text[:12] in got:
return True
_log.warning(f"[{self.serial}] 文案回读不一致(第 {attempt} 次):"
f"期望『{text[:20]}…』读到『{got[:20]}』")
if attempt == 1:
# 第 2 次改走剪贴板通道(设备端 Agent 写剪贴板 + 粘贴),中文不会变形
try:
from core.clipboard_helper import inject_clipboard
okc, msgc = inject_clipboard(self.serial, text, d=d)
if okc:
d.jsonrpc.pasteClipboard()
time.sleep(1)
else:
_log.warning(f"[{self.serial}] 剪贴板兜底注入失败: {msgc}")
except Exception as e:
_log.warning(f"[{self.serial}] 剪贴板兜底异常: {e}")
time.sleep(1)
got = _read()
if got and text[:12] in got:
return True
_log.warning(f"[{self.serial}] ⚠ 文案没填进去(读回『{got[:30]}』)——"
f"接着发布就会发出空文案,这一步判失败")
return False
@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, ctx=None):
merged = {**DEFAULT_PARAMS, **(params or {})}
return GenericStepsWorker(serial, params=merged, ctx=ctx)
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