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:
2026-10-12 13:31:02 +08:00
parent 10d2eb8334
commit 68c16a5fde
9 changed files with 485 additions and 36 deletions
+170 -15
View File
@@ -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 为空,请在步骤编辑器中填写或抓取元素")