Files
auto_control/scripts/tests/test_read_text.py
T
butubb 68c16a5fde 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 个用例通过。
2026-10-12 13:31:02 +08:00

199 lines
8.4 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.
# -*- 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('✅ 全部通过(没连任何真机)')