feat(步骤): 选择器支持 {变量} + 新增「读取文字」+ 条件判断可选「变量」;fix(发布): 剪辑页校验等 45 秒、切号前先回主界面
用户要求:"把你每一步操作都放出来让我自己写,你都封在一起我也不知道切号/认号的流程"
→ 把两个打包步骤的内部动作,拆成**画布上拖得出来的普通步骤**(能拆是因为补了下面两个零件)。
① 选择器值里的**变量**(tasks/generic/task.py `_vars_sub`)
`{本次要发的名字}`(循环块设的)/ `{本机账号名字}`(读名字读到的),中文别名与变量名都认。
用在 click / long_click / wait_el / swipe_until / expect_el / read_text / read_account_name /
switch_account 的选择器值,以及 if_el 的选择器与手填候选值。
· 没写花括号的步骤一个字不改(老任务零影响)
· 变量为空 → **换成空串 + 记警告**:宁可"选择器变空、找不到元素"明着报,
也不留 `{…}` 字面量去点到歪的地方
· 不做通用表达式(只认表里那几个名字)
② 「点击元素」的选择器类型下拉补上 **textContains**(文本包含)——
以前只能用打包步骤做到的"按名字点列表那一行",现在自己就能拼:
`textContains` + 值 `{本次要发的名字}`
③ 新步骤 **read_text「读取文字」**:把元素文字读出来存进指定变量
(`var`=account_name 时顺手把昵称控件记给切号/推送核对复用;`clean`=按账号名清洗/只去空白;
`on_missing`=skip 默认只记一句 / fail 算失败)。之前**没有**"读取元素文字"这种步骤。
④ 「条件判断」的候选值来源加 **「变量」**(`cmp_source=var` + `cmp_var`)——
不然 read_text 读出来的东西没处用。空变量 = 候选为空 = 不命中(同"读不到名字"的规矩)。
⑤ fix(发布) 实测踩出来的两处(A06,截图/日志为证):
· **剪辑页校验 12 → 45 秒**:点完「下一步」抖音在「正在导入」(一条 25 秒的视频要导 25 秒+),
导入期间工具栏不渲染,10~12 秒必然判"没到剪辑页"→ 这一条白发
· **切号前先回主界面**(`_goto_me`):点不到「我」页签 = 不在主界面(剪辑页/发布页),
那种页面没有底部页签 → **强杀 + 冷启动**抖音再来。实测卡在剪辑页时,本轮剩下的号全部报废
· **切号后回读为空时点一次「我」再读**:换号会整体重启抖音、起来常落在首页(首页没有昵称控件),
读多少次都是空 —— 等完 wait_after、读两遍仍空才补这一下导航,不打扰它的重启过程
前端:编辑器调色板/字段面板认 read_text(含变量说明)、选择器类型加 textContains、
placeholder 提示 `{本次要发的名字}`、候选值来源加「变量」+ 变量名输入、
「发布计划」页就地编辑字段表同步(release.js)。
文档:TASK_DEV §3 步骤表 28→29 种 + §3.3「选择器里的变量」+ §3.4「自己拼切号+认号」;
API.md 骨架步数修正(18 → 4 步 / 含嵌套 23 步)。
测试:新增 scripts/tests/test_read_text.py(变量代入、空变量不许瞎点、读取文字存变量/清洗、
条件判断取变量、草稿校验),全套 8 个用例通过。
This commit is contained in:
+170
-15
@@ -136,6 +136,9 @@ STEP_TYPES = [
|
||||
{"type": "read_account_name", "label": "读取本机账号名字", "icon": "🏷",
|
||||
"params": {"selector_type": "xpath", "selector_value": "",
|
||||
"timeout": 8, "on_missing": "fail"}},
|
||||
{"type": "read_text", "label": "读取文字", "icon": "📖",
|
||||
"params": {"selector_type": "xpath", "selector_value": "", "timeout": 8,
|
||||
"var": "account_name", "clean": "account", "on_missing": "skip"}},
|
||||
{"type": "switch_account", "label": "切换账号", "icon": "🔁",
|
||||
"params": {"target": "var:target_name", "target_value": "",
|
||||
"switch_selector_type": "xpath", "switch_selector_value": "",
|
||||
@@ -189,6 +192,25 @@ _CMP_ALIASES = {
|
||||
# AI 草稿校验(core/task_draft.py)用它拦"运算符拼错 -> 永远不命中"。
|
||||
CMP_OPS = tuple(_CMP_ALIASES)
|
||||
|
||||
# 选择器值 / 文案里可以写的**变量占位符**:`{本次要发的名字}`、`{本机账号名字}`。
|
||||
# 用花括号是故意的:`{}` 不是合法的 XPath/UiSelector 片段 —— 变量没值时会**明着找不到元素**,
|
||||
# 而不是像 `$名字` 那样被当成普通文本、静默点到歪的地方去。中文别名与变量名都认。
|
||||
_VAR_TOKENS = {
|
||||
"本次要发的名字": "target_name",
|
||||
"要发的名字": "target_name",
|
||||
"target_name": "target_name",
|
||||
"本机账号名字": "account_name",
|
||||
"本机名字": "account_name",
|
||||
"account_name": "account_name",
|
||||
}
|
||||
_VAR_RE = re.compile(r"\{([^{}]{1,32})\}")
|
||||
|
||||
# 「条件判断」的候选值来源(界面下拉就是这几个)。空串 = 手填(历史行为)。
|
||||
# account_target = 本次要发的名字(循环块/读名字给的)
|
||||
# release = 本机下一条待发计划的名字
|
||||
# var = 「读取文字」读到的那个变量
|
||||
CMP_SOURCES = ("", "account_target", "release", "var")
|
||||
|
||||
|
||||
def _canon_op(op):
|
||||
"""运算符规范化成中文那个(认别名);认不出返回 None。"""
|
||||
@@ -602,7 +624,7 @@ class GenericStepsWorker(BaseWorker):
|
||||
返回 True=找到, False=未找到, None=配置缺选择器。
|
||||
"""
|
||||
sel_type = params.get("selector_type", "xpath")
|
||||
sel_val = params.get("selector_value", "")
|
||||
sel_val = self._vars_sub(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)
|
||||
@@ -662,7 +684,7 @@ class GenericStepsWorker(BaseWorker):
|
||||
def _exec_long_click(self, d, params, depth=0):
|
||||
"""长按元素(选择器 + 时长秒)。复制链接/呼出菜单用。"""
|
||||
sel_type = params.get("selector_type", "xpath")
|
||||
sel_val = params.get("selector_value", "")
|
||||
sel_val = self._vars_sub(params.get("selector_value", ""))
|
||||
duration = float(params.get("duration", 1.0))
|
||||
timeout = float(params.get("wait_timeout", 2))
|
||||
if not sel_val:
|
||||
@@ -689,7 +711,7 @@ class GenericStepsWorker(BaseWorker):
|
||||
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", "")
|
||||
sel_val = self._vars_sub(params.get("selector_value", ""))
|
||||
timeout = float(params.get("timeout", 10))
|
||||
if not sel_val:
|
||||
_log.warning("wait_el 缺少选择器")
|
||||
@@ -729,7 +751,7 @@ class GenericStepsWorker(BaseWorker):
|
||||
返回 True=命中 / False=未命中 / None=缺选择器或 OCR 不可用。
|
||||
"""
|
||||
sel_type = params.get("selector_type", "xpath")
|
||||
sel_val = params.get("selector_value", "")
|
||||
sel_val = self._vars_sub(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 "")
|
||||
@@ -873,8 +895,19 @@ class GenericStepsWorker(BaseWorker):
|
||||
去重静默失效(见 core/dedup.py 的 build_key)。
|
||||
"""
|
||||
source = str(params.get("cmp_source") or "").strip()
|
||||
manual = split_cmp_values(manual_raw)
|
||||
if source == "release":
|
||||
# 手填值里也允许写 {变量}(例如"要比的就是刚才读到的东西",再拼个后缀)
|
||||
manual = split_cmp_values(self._vars_sub(manual_raw))
|
||||
if source == "var":
|
||||
# 变量(「读取文字」读到的 / 循环块设的):候选 = 那个变量的值。
|
||||
# ⚠ 变量为空 → 候选为空 → 永远不命中(走未找到分支)——和"读不到名字"同一条规矩。
|
||||
var = str(params.get("cmp_var") or "account_name").strip() or "account_name"
|
||||
val = str(self._vars.get(var) or "").strip()
|
||||
if not val:
|
||||
_log.warning(f"[{self.serial}] 条件判断:变量『{var}』现在是空的"
|
||||
f"(前面要有「读取文字」读到它)→ 候选为空,这一步永远不命中")
|
||||
return manual_raw, f"变量『{var}』是空的(读不到 = 不命中)"
|
||||
ids, note = [val], f"变量『{var}』= 『{val}』"
|
||||
elif source == "release":
|
||||
# 「发布计划」:本机**下一条将要推送**的那条计划的**名字**(推送前校验账号就靠它)。
|
||||
ids, note = self._release_candidates()
|
||||
elif source == "account_target":
|
||||
@@ -896,7 +929,9 @@ class GenericStepsWorker(BaseWorker):
|
||||
f"[{self.serial}] 条件判断取号为空(来源 {source}):{note};手填候选 "
|
||||
f"{len(manual)} 个 → "
|
||||
+ ("仅用手填值" if manual else "候选为空,这一步将永远不命中(走未找到分支)"))
|
||||
where = "发布计划" if source == "release" else "本次要发的名字"
|
||||
where = {"release": "发布计划",
|
||||
"var": f"变量『{params.get('cmp_var') or 'account_name'}』",
|
||||
"account_target": "本次要发的名字"}.get(source, "候选值")
|
||||
src_txt = (f"候选来自{where}:{'、'.join(ids)}" if ids
|
||||
else f"{where}取不到:{note}")
|
||||
if manual:
|
||||
@@ -1010,7 +1045,7 @@ class GenericStepsWorker(BaseWorker):
|
||||
返回 True=看到了;False=没看到(已按 `on_missing` 处置)。
|
||||
"""
|
||||
sel_type = (params.get("selector_type") or "xpath").strip()
|
||||
sel_val = (params.get("selector_value") or "").strip()
|
||||
sel_val = self._vars_sub((params.get("selector_value") or "").strip())
|
||||
what = (params.get("what") or "").strip() or sel_val[:40]
|
||||
timeout = float(params.get("timeout") or 10)
|
||||
on_missing = (params.get("on_missing") or "skip_rest").strip().lower()
|
||||
@@ -1080,7 +1115,7 @@ class GenericStepsWorker(BaseWorker):
|
||||
"""
|
||||
from core import video_plan as vp
|
||||
sel_type = (params.get("selector_type") or "xpath").strip()
|
||||
sel_val = (params.get("selector_value") or "").strip()
|
||||
sel_val = self._vars_sub((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:
|
||||
@@ -1113,6 +1148,62 @@ class GenericStepsWorker(BaseWorker):
|
||||
self.set_action(f"本机账号:{name}")
|
||||
return True
|
||||
|
||||
def _exec_read_text(self, d, params, depth=0):
|
||||
"""读取文字:把某个元素的文字读出来**存进变量**,给后面的步骤用。
|
||||
|
||||
参数:`selector_type`(xpath) `selector_value`(要读的元素,值里可以用 `{变量}`)、
|
||||
`timeout`(8)、`var`(存到哪个变量,默认 `account_name`)、
|
||||
`clean`(`account`=按账号名清洗(默认)| `strip`=只去首尾空白)、
|
||||
`on_missing`(`skip` 默认:读不到只记一句、不拦任务 | `fail`:读不到算这一步失败)
|
||||
|
||||
存进去之后两处能用:
|
||||
· 选择器值/文案里写 `{变量名}`(见 `_vars_sub`,比如列表里点 `textContains={本机账号名字}`)
|
||||
· 「条件判断」的候选值来源选「**变量**」
|
||||
|
||||
与「读取本机账号名字」的区别:那一步是**发布专用**的(顺带把昵称控件记给切号用、
|
||||
固定按账号名清洗);这一步是通用的 —— 读什么、存哪个变量名你定。
|
||||
返回 True=读到 / False=读不到(且 `on_missing=fail`) / None=缺选择器或读不到但 skip。
|
||||
"""
|
||||
from core import video_plan as vp
|
||||
sel_type = (params.get("selector_type") or "xpath").strip()
|
||||
sel_val = self._vars_sub((params.get("selector_value") or "").strip())
|
||||
var = (params.get("var") or "account_name").strip() or "account_name"
|
||||
timeout = float(params.get("timeout") or 8)
|
||||
soft = (params.get("on_missing") or "skip").strip().lower() != "fail"
|
||||
if not sel_val:
|
||||
msg = ("「读取文字」的选择器是空的 —— 用「抓取元素」抓一下要读的那个控件"
|
||||
"(值里也可以写 {本次要发的名字} 这类变量)")
|
||||
_log.error(f"[{self.serial}] {msg}")
|
||||
self.set_action(msg)
|
||||
return None
|
||||
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}")
|
||||
text = (vp.clean_account_name(raw) if (params.get("clean") or "account") == "account"
|
||||
else (raw or "").strip())
|
||||
self._vars[var] = text
|
||||
if var == "account_name":
|
||||
# 沿用既有约定:变量名叫 account_name 时顺手把**昵称控件**记下来
|
||||
# (「切换账号」和推送前核对都用它 —— 必须同一个控件、同一套清洗)
|
||||
self._vars["name_selector"] = (sel_type, sel_val)
|
||||
if not text:
|
||||
_log.warning(f"[{self.serial}] 读取文字:读不到(元素原文 {raw!r})→ 变量"
|
||||
f"『{var}』置空")
|
||||
self.set_action(f"读不到文字({var})")
|
||||
return None if soft else False
|
||||
_log.info(f"[{self.serial}] 读取文字:{var} = 『{text}』(元素原文 {raw!r})")
|
||||
self.set_action(f"{var}:{text}")
|
||||
return True
|
||||
|
||||
# 抖音停在登录/验证码页的字样。**只在"切号后回读校验没过"时才拿它判定** ——
|
||||
# 平时查会把「我」页面上的普通文字误判成登录页,而误判会让整台设备停掉。
|
||||
_LOGIN_HINTS = ("验证码", "其他方式登录", "手机号登录", "一键登录", "密码登录",
|
||||
@@ -1128,18 +1219,74 @@ class GenericStepsWorker(BaseWorker):
|
||||
except Exception as e:
|
||||
_log.warning(f"账号通知发送失败(不影响任务):{e}")
|
||||
|
||||
def _vars_sub(self, text):
|
||||
"""把选择器值/文案里的 `{变量}` 换成本轮的真值。
|
||||
|
||||
认 `{本次要发的名字}`(循环块设的目标)和 `{本机账号名字}`(读名字读到的),
|
||||
中文别名与变量名都行(见 `_VAR_TOKENS`)。
|
||||
|
||||
· **没写花括号的步骤一个字不改** —— 老任务零影响。
|
||||
· 变量还是空的(还没轮到 / 没读到)→ **换成空串 + 记警告**:宁可"选择器变空、
|
||||
找不到元素"明着报,也别留个 `{...}` 字面量去瞎点。
|
||||
"""
|
||||
if not text or "{" not in text:
|
||||
return text
|
||||
|
||||
def rep(m):
|
||||
raw = m.group(1).strip()
|
||||
key = _VAR_TOKENS.get(raw, raw)
|
||||
val = str(self._vars.get(key) or "").strip()
|
||||
if val:
|
||||
_log.info(f"[{self.serial}] 变量 {{{raw}}} → 『{val}』")
|
||||
else:
|
||||
_log.warning(f"[{self.serial}] 变量 {{{raw}}} 现在是空的(还没轮到/没读到)"
|
||||
f"→ 换成空串,这一步多半找不到元素")
|
||||
return val
|
||||
|
||||
return _VAR_RE.sub(rep, text)
|
||||
|
||||
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()
|
||||
sel_val = self._vars_sub((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))
|
||||
|
||||
# 抖音包名:切号前"回首页"要冷启动它(只有这一处用,就不塞进任务参数了)
|
||||
_DOUYIN_PKG = "com.ss.android.ugc.aweme"
|
||||
|
||||
def _goto_me(self, d, me_type, me_val, timeout=4):
|
||||
"""把抖音带回「我」页 —— **不管上一轮停在哪一页**(切号第一步用)。
|
||||
|
||||
先点底部「我」页签(在首页时这一下就够)。点不到 = **不在主界面**(剪辑页/发布页/
|
||||
别人的视频页 —— 上一轮中途出问题、或人手动停在那儿),那种页面**没有底部页签**,
|
||||
点多少下都没用 → **强杀 + 冷启动**抖音,再点一次。
|
||||
|
||||
代价是一次冷启动(几秒),换来"上一轮停在哪都不影响这一轮"。真机实测(2026-10-12
|
||||
A06):卡在剪辑页时,后面几个号**全部**报废 —— 比冷启动贵得多。
|
||||
返回 True=点到了(或尽力了,交给后续"进列表/回读"判成败),False=连冷启动都失败。
|
||||
"""
|
||||
if self._click_sel(d, me_type, me_val, timeout=min(timeout, 4)):
|
||||
return True
|
||||
_log.warning(f"[{self.serial}] 找不到「我」页签 → 不在主界面,冷启动抖音重来"
|
||||
f"—— {self._screen_digest(d)}")
|
||||
try:
|
||||
d.app_stop(self._DOUYIN_PKG)
|
||||
time.sleep(1.0)
|
||||
ensure_app_running(d, self._DOUYIN_PKG)
|
||||
time.sleep(3.0)
|
||||
except Exception as e:
|
||||
_log.warning(f"[{self.serial}] 冷启动抖音失败: {e}")
|
||||
return False
|
||||
self._click_sel(d, me_type, me_val, timeout=max(timeout, 6))
|
||||
time.sleep(1.0)
|
||||
return True
|
||||
|
||||
def _click_sel(self, d, sel_type, sel_val, timeout=8):
|
||||
"""按选择器等元素出现并点击。找不到返回 False(不抛异常)。"""
|
||||
if not sel_val:
|
||||
@@ -1272,7 +1419,7 @@ class GenericStepsWorker(BaseWorker):
|
||||
_log.info(f"[{self.serial}] 切换账号:当前就是『{target}』,不用切")
|
||||
self.set_action(f"已经是『{target}』,不用切")
|
||||
return True
|
||||
switch_val = (params.get("switch_selector_value") or "").strip()
|
||||
switch_val = self._vars_sub((params.get("switch_selector_value") or "").strip())
|
||||
if not switch_val:
|
||||
msg = ("「进账号切换列表」的选择器是空的 —— 用「抓取元素」抓一下抖音里那个入口"
|
||||
"(「我」页面 → 切换账号),填到这一步上")
|
||||
@@ -1280,12 +1427,12 @@ class GenericStepsWorker(BaseWorker):
|
||||
self.set_action(msg)
|
||||
return False
|
||||
me_type = params.get("me_selector_type") or "descriptionContains"
|
||||
me_val = params.get("me_selector_value") or "我"
|
||||
me_val = self._vars_sub(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))
|
||||
self._goto_me(d, me_type, me_val, timeout)
|
||||
time.sleep(1.0)
|
||||
if not self._click_sel(d, params.get("switch_selector_type") or "xpath",
|
||||
switch_val, timeout):
|
||||
@@ -1346,10 +1493,18 @@ class GenericStepsWorker(BaseWorker):
|
||||
time.sleep(0.5)
|
||||
# 等完再回读;**不再点任何东西**,只多读几次(页面渲染有零点几秒的抖动)
|
||||
last_got = ""
|
||||
for _ in range(5):
|
||||
for k in range(5):
|
||||
if self.stopped():
|
||||
return False
|
||||
last_got = self._read_name_now(d, params)
|
||||
if last_got != target and k == 1:
|
||||
# 换号后抖音会**整体重启**,起来通常落在「首页」—— 首页没有昵称控件,
|
||||
# 读多少次都是空。等到这会儿(等完 wait_after、已读两遍)再点一下「我」
|
||||
# 把页面切过去,然后继续读 —— 不动它的重启过程,只补这一下导航。
|
||||
_log.info(f"[{self.serial}] 切号后回读为空 → 点一次「我」再读"
|
||||
f"(换号重启后常落在首页)")
|
||||
self._goto_me(d, me_type, me_val, timeout=6)
|
||||
continue
|
||||
if last_got == target:
|
||||
_log.info(f"[{self.serial}] 切换账号成功:『{target}』(第 {i} 次,"
|
||||
f"等了 {wait_after:.0f}s)")
|
||||
@@ -1722,7 +1877,7 @@ class GenericStepsWorker(BaseWorker):
|
||||
|
||||
def _exec_click(self, d, params, depth=0):
|
||||
sel_type = params.get("selector_type", "xpath")
|
||||
sel_val = params.get("selector_value", "")
|
||||
sel_val = self._vars_sub(params.get("selector_value", ""))
|
||||
timeout = float(params.get("wait_timeout", 2))
|
||||
if not sel_val:
|
||||
_log.warning(f"[{self.serial}] click 跳过:selector_value 为空,请在步骤编辑器中填写或抓取元素")
|
||||
|
||||
Reference in New Issue
Block a user