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
+44 -7
View File
@@ -23,6 +23,7 @@ var STEP_LIB=[
{type:'mark_done',label:'记为已做',icon:'✅',cat:'flow',params:{selector_type:'text',selector_value:''}},
{type:'read_account_name',label:'读取本机账号名字',icon:'🏷',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:8,on_missing:'fail'}},
{type:'expect_el',label:'校验页面',icon:'🔎',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:10,on_missing:'skip_rest',what:''}},
{type:'read_text',label:'读取文字',icon:'📖',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:8,var:'account_name',clean:'account',on_missing:'skip'}},
{type:'switch_account',label:'切换账号',icon:'🔁',cat:'flow',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:'🔂',cat:'flow',params:{date_source:'today',fixed_date:'',retry_failed:true,max_accounts:0,children:[]}},
{type:'notify',label:'发通知',icon:'🔔',cat:'flow',params:{title:'',message:'',level:'info'}},
@@ -67,11 +68,12 @@ function _findLib(type){return STEP_LIB.find(function(s){return s.type===type;})
function _selRowHtml(p, extra, withOcr, withDedup, keys){
keys=keys||{};
var kType=keys.type||'selector_type', kVal=keys.value||'selector_value';
var typeList=['xpath','description','text','resourceId','descriptionContains','className'];
var typeList=['xpath','description','text','textContains','resourceId','descriptionContains','className'];
// withOcr(条件判断用)额外提供"屏幕状态 / 前台App / 去重"三类非元素条件
var types=withOcr?typeList.concat(['ocr','screen','foreground']):typeList.slice();
if(withDedup)types.push('dedup');
var labels={xpath:'xpath',description:'description',text:'text',resourceId:'resourceId',
var labels={xpath:'xpath',description:'description',text:'text',textContains:'textContains(文本包含)',
resourceId:'resourceId',
descriptionContains:'descriptionContains',className:'className',ocr:'OCR识别',
screen:'屏幕状态',foreground:'前台App是',dedup:'去重(这个身份做过了吗)'};
var opts=types.map(function(d){
@@ -88,7 +90,7 @@ function _selRowHtml(p, extra, withOcr, withDedup, keys){
'<option value="on"'+(p[kVal]==='on'?' selected':'')+'>亮屏时就命中</option></select>';
}else{
valHtml='<input type="text" class="form-control" data-key="'+kVal+'" value="'+esc(p[kVal]||'')+
'" placeholder="'+(isFg?'如 com.ss.android.ugc.aweme':'如 点赞')+'">'+(isFg?'':pick+test);
'" placeholder="'+(isFg?'如 com.ss.android.ugc.aweme':'如 点赞;可用 {本次要发的名字} / {本机账号名字}')+'">'+(isFg?'':pick+test);
}
var head='<div class="form-group"'+(keys.hideValue?' style="grid-column:span 3"':'')+'>'+
'<label>选择器类型</label><select class="form-control" data-key="'+kType+'"'+toggle+'>'+opts+'</select></div>';
@@ -475,6 +477,28 @@ var _stepEditor={
'<div class="form-group" data-lmode="rounds" style="display:'+(lm==='rounds'?'block':'none')+'"><label>最大循环次数</label><input type="number" class="form-control" data-key="max_iterations" value="'+(p.max_iterations||10)+'" min="1" max="100000"></div>'+
'<div class="form-group" data-lmode="time" style="display:'+(lm==='time'?'block':'none')+'"><label>循环时长(秒)</label><input type="number" class="form-control" data-key="loop_duration" value="'+(p.loop_duration||600)+'" min="1"></div></div>';
h+='<div class="help">将下方子步骤循环执行指定轮次/时长;<b>一直循环</b>无限跑直到任务被外部停止——配合调度"定时启动+停止"(如 9:00 启动 / 18:00 停止)实现全天候养号,或配合任务"运行时长上限"自动停止</div>';
}else if(step.type==='read_text'){
// 读取文字:把某个元素的文字读出来存进变量(通用版;发布专用的那个是「读取本机账号名字」)
var om2=p.on_missing||'skip';
h+='<div class="form-row"><div class="form-group"><label>读哪个元素的文字</label><input type="text" class="form-control" data-key="selector_value" value="'+esc(p.selector_value||'')+'" placeholder="选择器值(xpath / 文本包含等),可用 {本次要发的名字}"></div>'+
'<div class="form-group"><label>最多等(秒)</label><input type="number" class="form-control" data-key="timeout" value="'+(p.timeout||8)+'" min="1" max="120"></div></div>';
h+='<div class="form-row"><div class="form-group"><label>选择器类型</label><select class="form-control" data-key="selector_type">'+
['xpath','textContains','text','resourceId','descriptionContains','description','className'].map(function(t){
return '<option value="'+t+'"'+((p.selector_type||'xpath')===t?' selected':'')+'>'+t+'</option>';}).join('')+
'</select></div>'+
'<div class="form-group"><label>存到哪个变量名</label><input type="text" class="form-control" data-key="var" value="'+esc(p.var||'account_name')+'" placeholder="account_name">'+
'<div class="help">存下之后,选择器值里可以写 <code>{变量名}</code>,「条件判断」的候选值来源也能选「变量」。'+
'变量名叫 <code>account_name</code> 时,顺带把它当本机账号名(切号/推送核对会复用)</div></div></div>';
h+='<div class="form-row"><div class="form-group"><label>清洗方式</label><select class="form-control" data-key="clean">'+
[['account','按账号名清洗(去首尾空白/按钮字,推荐)'],['strip','只去首尾空白(原样,别看错)']].map(function(o){
return '<option value="'+o[0]+'"'+((p.clean||'account')===o[0]?' selected':'')+'>'+o[1]+'</option>';}).join('')+
'</select></div>'+
'<div class="form-group"><label>读不到时</label><select class="form-control" data-key="on_missing">'+
[['skip','记一句、继续(默认)'],['fail','算这一步失败']].map(function(o){
return '<option value="'+o[0]+'"'+(om2===o[0]?' selected':'')+'>'+o[1]+'</option>';}).join('')+
'</select></div></div>';
h+='<div class="help">把元素的文字<b>读出来存成变量</b>,给后面的步骤用(选择器值里写 <code>{变量名}</code>,'+
'或让「条件判断」的候选值来源选「变量」)。只有读、不点任何东西;读不到按上面的选项处置。</div>';
}else if(step.type==='expect_el'){
// 校验页面:只做校验、不带副作用 —— 确认"上一步真的完成了"
var om=p.on_missing||'skip_rest';
@@ -511,7 +535,7 @@ var _stepEditor={
// 文本比对:屏幕状态没有文本可比(亮/熄)、去重也不比文本,只有元素/OCR/前台 App 才给这块
// 候选值来源:默认"手填"(行为与以前完全一致);也可以取"本次要发的名字"/发布计划
var cmpSrc=p.cmp_source||'';
var fromPlatform=(cmpSrc==='account_target'||cmpSrc==='release');
var fromPlatform=(cmpSrc==='account_target'||cmpSrc==='release'||cmpSrc==='var');
var cmpRow=dyn?'':'<div class="form-row"><div class="form-group"><label>文本比对</label>'+
'<select class="form-control" data-key="cmp_op">'+
[['','不比对(只看元素在不在)'],['等于','等于'],['不等于','不等于'],['包含','包含'],['不包含','不包含']]
@@ -520,14 +544,23 @@ var _stepEditor={
'<div class="form-group"><label>候选值来源</label>'+
'<select class="form-control" data-key="cmp_source" onchange="_stepEditor._cmpSourceChanged(this)">'+
[['','手填(默认)'],['account_target','本次要发的名字(读名字/循环块给的)'],
['release','本机当前发布计划(要发的那条)']]
['release','本机当前发布计划(要发的那条)'],
['var','变量(「读取文字」读到的)']]
.map(function(o){return '<option value="'+o[0]+'"'+((p.cmp_source||'')===o[0]?' selected':'')+'>'+o[1]+'</option>';}).join('')+
'</select><div class="help">'+(p.cmp_source==='release'
? '取<b>本机下一条要发的发布计划</b>的<b>名字</b>(推送前校验"当前登的是不是要发的号"就用它)'
: (p.cmp_source==='account_target'
? '取<b>本次要发的那个名字</b>(「读取本机账号名字」读到的,或循环块设的目标)。'
+'<b>读不到名字时候选为空 → 不命中 → 走 else(不发)</b>,这是故意的'
: '手填要比对的文本;要用本机账号名就用上面的两个来源'))+'</div></div>'+
: (p.cmp_source==='var'
? '取<b>「读取文字」存下的那个变量</b>(变量名在右边那格填,默认 account_name)。'
+'<b>变量是空的 → 候选为空 → 不命中</b>'
: '手填要比对的文本;要用本机账号名就用上面的两个来源')))+'</div></div>'+
// 变量名:只有来源选「变量」时才显眼,其余情况**只隐藏不删**(同上,_syncParams 按 DOM 收集)
'<div class="form-group"'+(p.cmp_source==='var'?'':' style="display:none"')+'>'+
'<label>变量名</label><input type="text" class="form-control" data-key="cmp_var" value="'+
esc(p.cmp_var||'account_name')+'" placeholder="account_name">'+
'<div class="help">「读取文字」步骤里填的那个变量名(两边要一模一样)</div></div>'+
// 候选值来自平台时**只隐藏、不删**这个输入框:_syncParams 是按 DOM 存在与否收集的,
// 删了就等于把用户手填的值悄悄抹掉(同类坑见本文件 927 行附近的注释)
'<div class="form-group" style="grid-column:span 2'+(fromPlatform?';display:none':'')+'">'+
@@ -1008,7 +1041,7 @@ var _stepEditor={
warnings.push(name+' "'+label+'": 条件判断的选择器为空,会直接走"未找到"分支。');
}
var cmpSrcV=(s.params.cmp_source||'');
var fromPlatform=(cmpSrcV==='account_target'||cmpSrcV==='release');
var fromPlatform=(cmpSrcV==='account_target'||cmpSrcV==='release'||cmpSrcV==='var');
// 候选值"平台给"的那两档(本次要发的名字 / 发布计划):手填可以留空,别报"没填值"
var fromOther=fromPlatform;
if(cmpUsed&&s.params.cmp_op&&!String(s.params.cmp_value||'').trim()&&!fromOther){
@@ -1020,6 +1053,10 @@ var _stepEditor={
+'「读取本机账号名字」步骤(或循环块设的目标名字),名字读不到时候选为空、'
+'这一步会走"未找到"分支。这是设计如此(读不到名字 = 不发)。');
}
if(cmpUsed&&cmpSrcV==='var'&&s.params.cmp_op){
warnings.push(name+' "'+label+'": 候选值来自变量『'+(s.params.cmp_var||'account_name')+'』 —— '
+'前面要有「读取文字」步骤把那个变量读出来,变量是空的时这一步会走"未找到"分支。');
}
if(cmpUsed&&cmpSrcV==='release'&&s.params.cmp_op){
warnings.push(name+' "'+label+'": 候选值来自"本机当前发布计划" —— 本机没有待发的计划'
+'时取不到号(这一步会走"未找到"分支)。这是设计如此(宁可不发、也不发错号),'