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
+198
View File
@@ -0,0 +1,198 @@
# -*- coding: utf-8 -*-
"""「读取文字」+ 选择器里的 `{变量}` +「条件判断→变量」用例。
假设备,不连真机。验三件事:
① 选择器值里写 `{本次要发的名字}` / `{本机账号名字}` → 真的换成本轮的值;
变量是空的 → 换成空串、这一步找不到元素(**不能留着字面量去瞎点**);
② 「读取文字」把元素文字读出来、清洗、存进指定变量(变量名叫 account_name 时顺手记下昵称控件);
③ 「条件判断」候选值来源选「变量」→ 候选就是那个变量的值,空变量 = 不命中。
"""
import io
import os
import sqlite3
import sys
import tempfile
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
os.environ['DATABASE_URL'] = 'sqlite:///' + os.path.join(tempfile.mkdtemp(), 'ac.db').replace('\\', '/')
os.environ['DATA_VIDEO_DIR'] = tempfile.mkdtemp()
os.environ['DEPLOY_ENV'] = 'dev'
os.environ['DB_ALLOW_SQLITE_FALLBACK'] = '1'
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
os.chdir(sys.path[0])
from tasks.generic import task as gt # noqa: E402
from core import task_draft # noqa: E402
fails = []
def check(label, got, want):
ok = got == want
if not ok:
fails.append(label)
print((' ✅ ' if ok else ' ❌ ') + label + ('' if ok else '\n 得到 %r\n 期望 %r' % (got, want)))
class El:
def __init__(self, flag, text=""):
self._flag = flag
self._text = text
self.clicked = False
@property
def exists(self):
return _Ex(self._flag)
def wait(self, timeout=0):
return self._flag
def get_text(self, timeout=None):
return self._text
def click(self):
self.clicked = True
class _Ex:
def __init__(self, f):
self.f = f
def __call__(self, timeout=0):
return self.f
def __bool__(self):
return self.f
class StubD:
"""`kw` 里记最后一次 d(**kw) 的参数;`texts` 给元素文本。"""
def __init__(self, present=(), texts=None):
self.present = set(present)
self.texts = texts or {}
self.last_kw = None
self.clicked = []
def xpath(self, v):
return El(v in self.present, self.texts.get(v, ""))
def __call__(self, **kw):
self.last_kw = kw
v = list(kw.values())[0]
if v in self.present:
self.clicked.append(v)
return El(v in self.present, self.texts.get(v, ""))
print('=== 1. 选择器值里的 {本次要发的名字} 换成本轮目标 ===')
w = gt.GenericStepsWorker("F1", {})
w._vars['target_name'] = '锋哥苏州寻宝藏小店'
d = StubD(present=['锋哥苏州寻宝藏小店'])
check('点击命中(说明选择器已代入)',
w._exec_click(d, {"selector_type": "textContains",
"selector_value": "{本次要发的名字}", "wait_timeout": 1}, 0), True)
check('实际用的选择器 = 名字本身', d.last_kw, {'textContains': '锋哥苏州寻宝藏小店'})
print('=== 2. 变量还是空的 → 选择器变空 → 不许瞎点 ===')
w = gt.GenericStepsWorker("F2", {})
d = StubD(present=[''])
check('返回 False(没找到)',
w._exec_click(d, {"selector_type": "textContains",
"selector_value": "prefix-{本次要发的名字}", "wait_timeout": 1}, 0), False)
check('空变量 → 字符串里不留 {…} 字面量', d.last_kw, {'textContains': 'prefix-'})
print('=== 3. {本机账号名字} 用「读取文字」读到的值 ===')
w = gt.GenericStepsWorker("F3", {})
w._vars['account_name'] = '小王'
d = StubD(present=['小王'])
check('等待元素命中', w._exec_wait_el(d, {"selector_type": "text",
"selector_value": "{本机账号名字}", "timeout": 1}, 0), True)
check('用的是本机账号名', d.last_kw, {'text': '小王'})
print('=== 4. 「读取文字」读出来、清洗、存变量 ===')
w = gt.GenericStepsWorker("F4", {})
d = StubD(present=['//*[@text="nick"]'],
texts={'//*[@text="nick"]': ' 锋哥苏州康养探店 \n编辑资料 \n抖音号:123'})
check('返回 True', w._exec_read_text(d, {"selector_type": "xpath",
"selector_value": '//*[@text="nick"]',
"var": "account_name", "clean": "account",
"timeout": 1}, 0), True)
check('存进 account_name(按账号名清洗:取第一行非标签)',
w._vars.get('account_name'), '锋哥苏州康养探店')
check('顺手记下昵称控件(切号/推送核对复用)',
w._vars.get('name_selector'), ('xpath', '//*[@text="nick"]'))
print('=== 5. clean=strip:原样(只去首尾空白) ===')
w = gt.GenericStepsWorker("F5", {})
d = StubD(present=['//*[@text="x"]'],
texts={'//*[@text="x"]': ' 小王 \n编辑资料'})
w._exec_read_text(d, {"selector_type": "xpath", "selector_value": '//*[@text="x"]',
"var": "raw_name", "clean": "strip", "timeout": 1}, 0)
check('只去首尾空白、不按行取', w._vars.get('raw_name'), '小王 \n编辑资料')
check('变量名不是 account_name → 不记昵称控件', w._vars.get('name_selector'), None)
print('=== 6. 读不到:默认 skip(只记一句、不拦任务) ===')
w = gt.GenericStepsWorker("F6", {})
d = StubD(present=[])
check('返回 None(skip 不算失败)',
w._exec_read_text(d, {"selector_type": "xpath", "selector_value": '//*[@text="没有"]',
"var": "account_name", "timeout": 1}, 0), None)
check('变量被置空', w._vars.get('account_name'), '')
print('=== 7. 读不到 + on_missing=fail → 算这一步失败 ===')
w = gt.GenericStepsWorker("F7", {})
d = StubD(present=[])
check('返回 False',
w._exec_read_text(d, {"selector_type": "xpath", "selector_value": '//*[@text="没有"]',
"var": "v", "timeout": 1, "on_missing": "fail"}, 0), False)
print('=== 8. 「条件判断」候选值来源=变量 ===')
w = gt.GenericStepsWorker("F8", {})
w._vars['account_name'] = '小王'
val, note = w._resolve_cmp_value({"cmp_op": "等于", "cmp_source": "var",
"cmp_var": "account_name"}, "")
check('候选值 = 变量的值', val, '小王')
check('来源说明写清了', 'account_name' in note and '小王' in note, True)
print('=== 9. 变量是空的 → 候选为空(永远不命中,走 else) ===')
w = gt.GenericStepsWorker("F9", {})
val, note = w._resolve_cmp_value({"cmp_op": "等于", "cmp_source": "var",
"cmp_var": "account_name"}, "手填兜底")
check('只剩手填值', val, '手填兜底')
check('说明里点出变量是空的', '空' in note, True)
print('=== 10. 草稿校验认这两个新能力 ===')
ok_steps = [{"type": "read_text", "label": "读取文字",
"params": {"selector_type": "xpath", "selector_value": '//*[@id="nick"]',
"var": "account_name"}},
{"type": "if_el", "label": "条件判断",
"params": {"selector_type": "xpath", "selector_value": '//*[@id="nick"]',
"cmp_op": "包含", "cmp_source": "var", "cmp_var": "account_name",
"then": [{"type": "notify", "label": "命中",
"params": {"title": "ok", "message": "ok"}}],
"else": []}}]
errs, warns = [], []
task_draft.validate_steps(ok_steps, errors=errs, warnings=warns)
check('合法草稿没有 error', errs, [])
bad_steps = [{"type": "read_text", "label": "读取文字",
"params": {"selector_type": "xpath", "selector_value": ""}}]
errs2 = []
task_draft.validate_steps(bad_steps, errors=errs2, warnings=[])
check('「读取文字」缺选择器 → 报错',
any('selector_value' in e for e in errs2), True)
bad2 = [{"type": "if_el", "label": "条件判断",
"params": {"selector_type": "xpath", "selector_value": '//*[@id="x"]',
"cmp_op": "包含", "cmp_source": "var", "then": [], "else": []}}]
errs3 = []
task_draft.validate_steps(bad2, errors=errs3, warnings=[])
check('来源=变量但没填变量名 → 报错',
any('cmp_var' in e for e in errs3), True)
print()
if fails:
print('❌ 失败 %d 项:%s' % (len(fails), ' / '.join(fails)))
sys.exit(1)
print('✅ 全部通过(没连任何真机)')