feat(发布计划): 新增「校验页面」步骤(expect_el)—— 每一步先确认上一步真的完成了
用户要求:"进行每一步的时候先校验上一步有没有完成,比如相册界面肯定有特征,如果没有到相册界面
就出现执行,你看看怎么处理" → 方案 A:新增只做校验、不带副作用的步骤。
· tasks/generic/task.py:新步骤 expect_el
- 参数:特征元素选择器 / timeout(10) / what("在等什么") / on_missing
- on_missing:skip_rest(默认,**跳过本轮剩余步骤** —— 这一条不发、换下一个号) / notify / stop
- 为此加了 worker 级的 `_skip_rest`:_exec_steps 每步前检查,foreach_account 每轮复位
· 骨架:关键点击前各插一个 —— 相册页(//*[@text='所有照片']) → 素材已选中(desc 选择/预览)
→ 剪辑页(desc 文字/话题) → 发布页(含'添加标题'/'添加作品描述') → 发布按钮
· 通知:新增 task.page.mismatch(页面不对,本轮剩余步骤已跳过)
· 昵称选择器改**两版通吃**(实测 A06=40.7.0):xpath 联合
`//*[@resource-id="…:id/tkv"] | //*[@resource-id="…:id/tk+"]`
—— 40.7 是 tkv、40.6 是 tk+,联合在 40.7 上实测能读到名字;.env(dev)已改,切号入口两版同为 desc=切换账号
· 前端:编辑器面板 + 参数表单 + 发布页就地编辑字段表都认 expect_el
· 文档:TASK_DEV §3(27→28 种)+ §4.7("每步先校验上一步")、NOTIFY、research §七·补(40.7.0 对照表
+ 联合 xpath + 各页特征 + "别按坐标点页签"的坑)、.env.example
· 测试:新增 scripts/tests/test_expect_el.py(特征在/不在、三种 on_missing、选择器为空),全套 7 个用例通过
This commit is contained in:
+3
-2
@@ -125,8 +125,9 @@ TAILSCALE_API_KEY=请填写Tailscale_API_key
|
||||
#
|
||||
# 2026-10-12 在 A01(抖音 40.6.0)实测可用的值:昵称那个 TextView 的 resource-id 是混淆 id `tk+`,
|
||||
# 所以填 resourceId(等价 xpath://*[@resource-id="com.ss.android.ugc.aweme:id/tk+"])。
|
||||
# DOUYIN_NAME_SELECTOR_TYPE=resourceId
|
||||
# DOUYIN_NAME_SELECTOR_VALUE=com.ss.android.ugc.aweme:id/tk+
|
||||
# 2026-10-12 实测:40.7.0 的昵称 id 是 `tkv`、40.6.0 是 `tk+` —— 用 **xpath 联合**一个值通吃两版
|
||||
# DOUYIN_NAME_SELECTOR_TYPE=xpath
|
||||
# DOUYIN_NAME_SELECTOR_VALUE=//*[@resource-id="com.ss.android.ugc.aweme:id/tkv"] | //*[@resource-id="com.ss.android.ugc.aweme:id/tk+"]
|
||||
|
||||
# 「我」页面 →「切换账号」列表那个入口(自动切号用,同样抓一次填这里)。
|
||||
# 它是个只有 content-desc 的 ImageView(desc 就是「切换账号」),所以用 descriptionContains。
|
||||
|
||||
@@ -111,6 +111,11 @@ EVENTS = [
|
||||
["serial", "device_name", "selector", "miss_count"],
|
||||
"某选择器连续 10 次未命中——任务可能显示成功但什么都没做", agg_window=0,
|
||||
recommend=True),
|
||||
_e("task.page.mismatch", "页面不对(校验没过)", "业务",
|
||||
["serial", "device_name", "model", "want", "msg"],
|
||||
"「校验页面」步骤没等到该出现的特征元素 —— **说明上一步没生效**(没进到相册页/剪辑页等)。"
|
||||
"默认会**跳过本轮剩余步骤**(这一条不发、换下一个号),避免在错的页面上继续盲点",
|
||||
agg_window=0, agg_key=_BY_SERIAL, recommend=True),
|
||||
_e("task.account.switch_failed", "切号失败", "业务",
|
||||
["serial", "device_name", "model", "want", "got", "attempts", "msg"],
|
||||
"想切到某个账号但没切成功(列表里找不到这个名字,或切完读回来的还是别人)——"
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ SCHEDULE_MODES = ("once", "cron", "cron_stop")
|
||||
TARGET_MODES = ("all", "group", "serial")
|
||||
|
||||
# 需要选择器的步骤类型(缺 selector_value 时执行器静默跳过)
|
||||
NEED_SELECTOR = ("click", "long_click", "wait_el", "swipe_until", "if_el")
|
||||
NEED_SELECTOR = ("click", "long_click", "wait_el", "swipe_until", "if_el", "expect_el")
|
||||
# 需要包名的步骤类型
|
||||
NEED_PACKAGE = ("open_app", "stop_app")
|
||||
# 需要非空 children 的容器
|
||||
|
||||
+31
-8
@@ -500,6 +500,20 @@ def build_switch_step(label="切换账号(切到本次要发的那个名字)
|
||||
"on_login_page": "stop", "probability": 100}}
|
||||
|
||||
|
||||
def build_expect_step(label, sel_type, sel_val, what="", timeout=10,
|
||||
on_missing="skip_rest"):
|
||||
"""「校验页面」步骤:放在关键点击**前面**,确认上一步真的完成了(该页的特征元素在不在)。
|
||||
|
||||
等不到默认 `skip_rest`(跳过本轮剩余步骤 = 这一条不发、换下一个号),
|
||||
避免在错的页面上继续盲点。特征元素取实测值,见 doc/research/U2_ELEMENT_SELECTORS.md §七。
|
||||
"""
|
||||
return {"id": f"step_rel_exp_{uuid.uuid4().hex[:6]}", "type": "expect_el",
|
||||
"label": label,
|
||||
"params": {"selector_type": sel_type, "selector_value": sel_val,
|
||||
"timeout": timeout, "on_missing": on_missing,
|
||||
"what": what or label, "probability": 100}}
|
||||
|
||||
|
||||
def build_foreach_step(children=None, label="循环本机待发的号(一轮跑完,一直切到发完)"):
|
||||
"""「循环本机待发的号」容器步骤(`foreach_account`)—— 发布骨架的最外层。
|
||||
|
||||
@@ -581,20 +595,29 @@ def build_release_steps():
|
||||
clk("open", "⑨ 点击抖音底部「+」(实测可用;改版了重新抓)",
|
||||
"descriptionContains", "拍摄"),
|
||||
clk("album", "⑩ 点击「相册」(没命中的话用抓取元素选它)"),
|
||||
clk("video", "⑪ 点击第一个视频(刚推的排最前;抓一次就行)"),
|
||||
clk("next", "⑫ 点击「下一步」"),
|
||||
clk("input", "⑬ 点击标题框(抖音发布页的标题框:hint「添加标题」)"),
|
||||
# 关键点都先"校验页面":上一步没生效就别往下盲点(默认跳过本轮剩余 = 这一条不发)
|
||||
build_expect_step("⑪ 校验:应在相册页(看到「所有照片」)", "xpath",
|
||||
"//*[@text='所有照片']", "相册页(所有照片)", timeout=10),
|
||||
clk("video", "⑫ 点击第一个素材(刚推的排最前;抓一次就行)"),
|
||||
build_expect_step("⑬ 校验:素材应已选中(看到「选择/预览」)", "xpath",
|
||||
"//*[@content-desc='选择' or @content-desc='预览']", "素材已选中", timeout=10),
|
||||
clk("next", "⑭ 点击「下一步」"),
|
||||
build_expect_step("⑮ 校验:应在剪辑页(看到「文字/话题」工具)", "xpath",
|
||||
"//*[@content-desc='文字' or @content-desc='话题']", "剪辑页", timeout=12),
|
||||
clk("input", "⑯ 点击标题框(抖音发布页的标题框:hint「添加标题」)"),
|
||||
{"id": sid("title"), "type": "input_text",
|
||||
"label": "⑭ 填标题(自动取这一条计划的标题,会回读校验)",
|
||||
"label": "⑰ 填标题(自动取这一条计划的标题,会回读校验)",
|
||||
"params": {"text_source": "release_title", "release_topics": "",
|
||||
"clear_first": True, "mode": "random", "texts": "", "fixed_text": ""}},
|
||||
clk("body", "⑮ 点击正文框(发布页的描述框:hint「添加作品描述」)"),
|
||||
clk("body", "⑱ 点击正文框(发布页的描述框:hint「添加作品描述」)"),
|
||||
{"id": sid("bodytxt"), "type": "input_text",
|
||||
"label": "⑯ 填正文(自动取这一条计划的正文,会回读校验)",
|
||||
"label": "⑲ 填正文(自动取这一条计划的正文,会回读校验)",
|
||||
"params": {"text_source": "release_body", "release_topics": "",
|
||||
"clear_first": True, "mode": "random", "texts": "", "fixed_text": ""}},
|
||||
clk("publish", "⑰ 点击「发布」"),
|
||||
dict(build_mark_step("⑱ 标记发布结果(成功走这里;失败分支再放一个 result=failed)")),
|
||||
build_expect_step("⑳ 校验:应在发布页(看到「发布」按钮)", "xpath",
|
||||
"//*[@text='发布' or @content-desc='发布']", "发布页(发布按钮)", timeout=10),
|
||||
clk("publish", "㉑ 点击「发布」"),
|
||||
dict(build_mark_step("㉒ 标记发布结果(成功走这里;失败分支再放一个 result=failed)")),
|
||||
]
|
||||
# 循环体:切号 → 读名字 → 校验 → 推送 → 发布 → 标记。
|
||||
# `switch_account` 自己会先看"当前是不是已经是目标"(第一个号不用切),
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ daemon 线程。所以:
|
||||
| 任务·单设备 | `task.device.success` · `.failed` · `.offline` · `.error` · `.retry` · `.stopped` · `.preempted` · `.preempt_timeout` · `.released` |
|
||||
| 业务 | `task.selector.invalid`(选择器连续 10 次未命中——"任务成功但什么都没做"的隐蔽故障) |
|
||||
| 业务 | `task.video.published`(视频发布成功,带作品分享链接)/ `task.video.failed`(发布失败**或结果未知**——结果未知意味着可能已经发出去了,要到计划页人工确认,平台不会自动重发) |
|
||||
| 业务 | `task.account.switch_failed`(切号失败:账号列表里没这个名字,或切完回读还是别人 —— **本轮不会发这个号的内容**,要人工到设备上确认号还在不在)/ `task.account.need_login`(抖音停在登录/验证码页,**需要人工登录**,平台绝不代替登录/过滑块;本设备已停止本轮任务)—— 都由「切换账号」步骤发出 |
|
||||
| 业务 | `task.page.mismatch`(「校验页面」没等到该出现的特征元素 —— **上一步没生效**,默认跳过本轮剩余步骤,避免在错的页面上继续盲点;见 TASK_DEV §4.7)/ `task.account.switch_failed`(切号失败:账号列表里没这个名字,或切完回读还是别人 —— **本轮不会发这个号的内容**,要人工到设备上确认号还在不在)/ `task.account.need_login`(抖音停在登录/验证码页,**需要人工登录**,平台绝不代替登录/过滑块;本设备已停止本轮任务)—— 都由「切换账号」步骤发出 |
|
||||
| 业务·任务自己发 | `task.notify.custom`(步骤「发通知」)/ `task.patrol.hit`(任务「公共巡检」命中)——**标题正文由任务自己写**,见下 |
|
||||
| Worker | `worker.connected` · `.attempt.done` · `.attempt.error`(单次尝试级,噪音大,默认没人勾) |
|
||||
| 设备 | `device.online` · `.offline` · `.discovered` · `.claimed` · `device.heartbeat_timeout` · `device.battery.low` · `.battery.recovered` |
|
||||
|
||||
+5
-1
@@ -109,7 +109,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|
||||
|
||||
---
|
||||
|
||||
## 3. 27 种步骤全表
|
||||
## 3. 28 种步骤全表
|
||||
|
||||
> 参数与默认值以 `tasks/generic/task.py` 为准;前端 `STEP_LIB`(`static/admin/editor.js`)负责在编辑器里呈现这些字段。
|
||||
|
||||
@@ -142,6 +142,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|
||||
| 24 | `mark_release` | 标记发布结果 | `result`(published/failed/unknown)、`why` | `capture_link`(True)、`delete_phone`(True)、`album_dir` | — | 放在**你自己发布流程的末尾**:把"发出去了没有"回写到计划行(成功时顺手抓作品分享链接、删手机上的素材)。拿不准就填 `unknown` —— 平台不会自动重发它。见 §4.7 |
|
||||
| 25 | `read_account_name` | 读取本机账号名字 | `selector_value`(昵称控件;值填在 `.env` 的 `DOUYIN_NAME_SELECTOR_VALUE`,40.6.0 实测值见 [research/U2_ELEMENT_SELECTORS.md](research/U2_ELEMENT_SELECTORS.md) §七) | `selector_type`(xpath)、`timeout`(8)、`on_missing`(fail) | — | 读抖音「我」页面上的**昵称** → 清洗后存进运行期变量(`account_name`),并记下用的选择器供 `push_release`/`switch_account` 核对。**读不到 = 本机没有号**:条件判断走 `else`、推送跳过(都不发)。`on_missing=fail`(默认) 让这一步直接失败、`skip` 只记一句继续。见 §4.7 |
|
||||
| 26 | `switch_account` | 切换账号 | `switch_selector_value`(进「账号切换」列表那一下;填 `.env` 的 `DOUYIN_SWITCH_SELECTOR_VALUE`,实测值同上) | `target`(`var:target_name`)、`target_value`、`name_selector_*`、`me_selector_*`(descriptionContains=我)、`timeout`(8)、**`wait_after`(15)**、`verify`(True)、`max_attempts`(2)、`on_login_page`(stop) | — | **只在本机已登录过的号之间切**:当前已是目标→直接成功;否则点「我」→ 点切换入口 → **在列表里按名字找控件点击**(**禁 `[N]` 序号**:序号型会随界面变形错位)→ 等重载 → **回读校验**。列表里没这个名字 = 本机没登这个号 → 立刻 False(不重试、不算故障)。**切完先等 `wait_after` 秒(默认 15)再回读** —— 换号会触发抖音整体重启,中途去点任何东西(包括点「我」页签那一下『刷新』)都可能打断它加载。停在登录/验证码页 → 通知 + 默认停本设备(平台不做登录/滑块)。见 §4.7 |
|
||||
| 28 | `expect_el` | 校验页面 | `selector_value`(**这一页的特征元素**) | `selector_type`(xpath)、`timeout`(10)、`on_missing`(skip_rest)、`what`("在等什么",写日志/通知) | — | **只校验、不带副作用**:确认"上一步真的完成了"。等不到时:`skip_rest`(默认,**跳过本轮剩余步骤** —— 这一条不发) / `notify`(只通知继续) / `stop`(停本设备)。紧挨着关键点击**前面**放一个,避免在错的页面上盲点后面一串按钮。见 §4.7 |
|
||||
| 27 | `foreach_account` | 循环本机待发的号 | — | `date_source`(today/fixed)、`fixed_date`、`retry_failed`(True)、`max_accounts`(0=不限) | `children` | **一轮把本机待发的号跑完**:数据源 = 计划表里还有待发内容的名字(`pending_names`,**不需要账号台账**),逐个写进运行期变量 `target_name` 后跑 children;试过的名字不回头(防死循环);内层"这个名字还有待发就一直发",跑一轮没进展(切号失败/守卫没过)就换下一个。三个干净出口:停止 / 任务时长上限 / `max_accounts`。见 §4.7 |
|
||||
|
||||
### 3.2 录制手势:**纯录制、纯回放**(`core/gesture.py`)
|
||||
@@ -516,6 +517,9 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|
||||
**跳过正在跑任务的设备**(不抢屏),单台失败只报那台;结果顺手缓存进 `device.cur_account_name`
|
||||
(**纯缓存**,自动任务永远现场读屏幕)。
|
||||
|
||||
**每一步之前先校验上一步完成了没(`expect_el`)**:发布流程是一串"点 A → 点 B → 点 C",中间任何一步没生效,后面就会**盲点**(点到别的控件上),最后"任务跑完了、什么都没发出去"还不报错。
|
||||
所以骨架在每个关键点击前面放了一个「校验页面」:相册页(`//*[@text='所有照片']`)、素材已选中(`desc=选择/预览`)、剪辑页(`desc=文字/话题`)、发布页(`desc=发布`…)。等不到就**跳过本轮剩余步骤**(这一条不发、换下一个号),并通知 `task.page.mismatch`。
|
||||
|
||||
**「推送发布视频」做六件事**(`tasks/generic/publish_flow.py` 的 `push_one()`):
|
||||
|
||||
```
|
||||
|
||||
@@ -231,3 +231,32 @@
|
||||
.venv/Scripts/python -c "import uiautomator2 as u2; d=u2.connect('<serial>'); open('me.xml','w',encoding='utf-8').write(d.dump_hierarchy())"
|
||||
# 在 me.xml 里找 text 是昵称、或 content-desc 是「切换账号」的节点,看它的 resource-id
|
||||
```
|
||||
|
||||
### 七·补:40.7.0 的「我」页与发布流程各页(2026-10-12,A06 = 192.168.20.75 实测)
|
||||
|
||||
生产是 40.6 与 40.7 **混装**(9 台 40.7 / 3 台 40.6),两版的混淆 id **不一样**。实测对照:
|
||||
|
||||
| 元素 | 40.6.0(A01) | 40.7.0(A06) |
|
||||
|---|---|---|
|
||||
| **「我」页昵称** | `id/tk+` | **`id/tkv`** |
|
||||
| 切换账号入口 | `desc=切换账号`(id/oxp) | `desc=切换账号`(id/ow_)**同一个 desc ✓** |
|
||||
| 抖音号 | `id/504` | `id/5zk` |
|
||||
| 底部「我」页签 | `desc=我,按钮` | 同样能用 `desc 含我` 命中 ✓ |
|
||||
| 拍摄面板「相册」 | `id/cnv` | `id/cnv` **同 id ✓** |
|
||||
| 相册页 | —— | 标题 `id/tv_title` = **`所有照片`**;页签 全部/视频/图片/动图/人像 |
|
||||
| 选中素材后 | —— | 顶部出现 `desc=选择` / `预览` |
|
||||
| 剪辑页 | —— | `desc=文字`/`话题`/`贴纸`/`特效`/`标记`/`滤镜`(都挂在 `id/iv_icon` 上) |
|
||||
| 发布页 | —— | 标题框 `id/ipv`(hint `添加标题`)、描述框 `id/ia-`(hint `添加作品描述...`) |
|
||||
|
||||
**昵称的"两版通吃"写法(已实测)**:xpath 联合 ——
|
||||
`//*[@resource-id="com.ss.android.ugc.aweme:id/tkv"] | //*[@resource-id="com.ss.android.ugc.aweme:id/tk+"]`
|
||||
在 40.7.0 上 `exists=True` 且 `get_text()` 读到昵称 ✓。**比按版本配两套省事**,写进 `.env` 的
|
||||
`DOUYIN_NAME_SELECTOR_VALUE` 即可(`read_account_name` 与「校验账号」共用它)。
|
||||
|
||||
**为什么 `desc=选择` 这类"页面特征"能当校验**:选素材/进剪辑页这些**没有独立 id 的页面**,
|
||||
靠"这一页特有的文字/描述"判断"我在哪一页",比按坐标/序号稳 —— 这正是「校验页面」(expect_el)
|
||||
要的东西(见 `doc/TASK_DEV.md` §3 第 28 行、§4.7)。
|
||||
|
||||
> ⚠ 走查时踩的坑:**别用坐标点底部页签** —— 我按 (0.876,0.899) 点 A06 的「我」,
|
||||
> 结果进了「抖音音乐榜」(`MusicDetailActivity`)。同一台机器上 `desc 含我` 是好的,
|
||||
> 按 desc 点才是对的(40.6 上坐标碰巧能用,不代表 40.7 也行)。
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
test_switch_account 切换账号(按名字点控件、回读校验、登录页停设备)
|
||||
test_foreach_account 循环本机待发的号(一轮跑完、试过不回头、干净退出)
|
||||
test_push_by_name 按名字找设备推送(matched/unmatched、缓存)
|
||||
test_expect_el 「校验页面」:特征在就过;不在就按 on_missing 处置(默认跳过本轮剩余步骤)
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
@@ -24,7 +25,8 @@ import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CASES = ["test_parse_name.py", "test_upload_name.py", "test_name_scope.py",
|
||||
"test_switch_account.py", "test_foreach_account.py", "test_push_by_name.py"]
|
||||
"test_switch_account.py", "test_foreach_account.py", "test_push_by_name.py",
|
||||
"test_expect_el.py"]
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""「校验页面」(expect_el) 用例:看到就过;没看到按 on_missing 处置(默认跳过本轮剩余步骤)。
|
||||
|
||||
假设备,不连真机。重点验两件事:
|
||||
① 特征元素在 → True,且**不影响**后面的步骤;
|
||||
② 特征元素不在 → 默认 `skip_rest`:**本轮剩下的步骤一步都不执行**(不盲点后面的按钮)。
|
||||
"""
|
||||
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
|
||||
|
||||
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):
|
||||
self._flag = flag
|
||||
|
||||
@property
|
||||
def exists(self):
|
||||
return _Ex(self._flag)
|
||||
|
||||
def wait(self, timeout=0):
|
||||
return self._flag
|
||||
|
||||
def get_text(self, timeout=None):
|
||||
return ""
|
||||
|
||||
|
||||
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:
|
||||
"""`present` 里有的 xpath 才算存在(用来模拟"这一页的特征元素")。"""
|
||||
|
||||
def __init__(self, present=()):
|
||||
self.present = set(present)
|
||||
self.calls = []
|
||||
|
||||
def xpath(self, v):
|
||||
self.calls.append(v)
|
||||
return El(v in self.present)
|
||||
|
||||
def __call__(self, **kw):
|
||||
v = kw.get('selector_value') or ''
|
||||
self.calls.append('kw:%s' % kw)
|
||||
return El(v in self.present)
|
||||
|
||||
|
||||
notes = []
|
||||
orig = gt.notifier.notify
|
||||
gt.notifier.notify = lambda ev, **kw: notes.append((ev, kw.get('want') or ''))
|
||||
try:
|
||||
print('=== 1. 特征在 → True,后面的步骤照跑 ===')
|
||||
P = {'selector_type': 'xpath', 'selector_value': '//*[@text="所有照片"]',
|
||||
'timeout': 1, 'on_missing': 'skip_rest', 'what': '相册页'}
|
||||
w = gt.GenericStepsWorker("F1", {})
|
||||
d = StubD(present=['//*[@text="所有照片"]'])
|
||||
check('返回 True', w._exec_expect_el(d, P), True)
|
||||
check('没置"跳过剩余"', w._skip_rest, False)
|
||||
check('没发通知', notes, [])
|
||||
|
||||
print('=== 2. 特征不在 → 默认 skip_rest:**本轮剩余步骤一步都不执行** ===')
|
||||
notes.clear()
|
||||
w = gt.GenericStepsWorker("F2", {})
|
||||
d = StubD(present=[]) # 特征不在(没到相册页)
|
||||
ran = []
|
||||
real_one = gt.GenericStepsWorker._exec_one
|
||||
|
||||
def fake_one(dd, step, depth=0):
|
||||
"""真跑 expect_el 那一步;其它步骤只记一笔(被跳过就不该记)。"""
|
||||
if step.get('type') == 'expect_el':
|
||||
return real_one(w, dd, step, depth)
|
||||
ran.append(step.get('label'))
|
||||
|
||||
w._exec_one = fake_one
|
||||
steps = [{"type": "expect_el", "label": "校验:应在相册页", "params": P},
|
||||
{"type": "click", "label": "点第一个素材", "params": {}},
|
||||
{"type": "click", "label": "点下一步", "params": {}}]
|
||||
w._exec_steps(d, steps)
|
||||
check('后面的点击一步都没执行', ran, [])
|
||||
check('置了"跳过剩余"', w._skip_rest, True)
|
||||
check('发了页面不对的通知', [n[0] for n in notes], ['task.page.mismatch'])
|
||||
check('通知里带上"在等什么"', notes[0][1], '相册页')
|
||||
|
||||
print('=== 3. on_missing=stop → 停本设备 ===')
|
||||
notes.clear()
|
||||
w = gt.GenericStepsWorker("F3", {})
|
||||
d = StubD(present=[])
|
||||
check('返回 False', w._exec_expect_el(d, dict(P, on_missing='stop')), False)
|
||||
check('本设备已停', w.stopped(), True)
|
||||
check('发了通知', [n[0] for n in notes], ['task.page.mismatch'])
|
||||
check('没置"跳过剩余"(停设备就够了)', w._skip_rest, False)
|
||||
|
||||
print('=== 4. on_missing=notify → 只通知、继续 ===')
|
||||
notes.clear()
|
||||
w = gt.GenericStepsWorker("F4", {})
|
||||
d = StubD(present=[])
|
||||
check('返回 False', w._exec_expect_el(d, dict(P, on_missing='notify')), False)
|
||||
check('设备没停', w.stopped(), False)
|
||||
check('没置"跳过剩余"', w._skip_rest, False)
|
||||
check('发了通知', [n[0] for n in notes], ['task.page.mismatch'])
|
||||
|
||||
print('=== 5. 选择器为空 → 明确报错,不乱判 ===')
|
||||
notes.clear()
|
||||
w = gt.GenericStepsWorker("F5", {})
|
||||
d = StubD(present=[])
|
||||
check('返回 None', w._exec_expect_el(d, dict(P, selector_value='')), None)
|
||||
check('没置"跳过剩余"', w._skip_rest, False)
|
||||
check('没发通知', notes, [])
|
||||
finally:
|
||||
gt.notifier.notify = orig
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print('❌ 失败 %d 项:%s' % (len(fails), ' / '.join(fails)))
|
||||
sys.exit(1)
|
||||
print('✅ 全部通过(没连任何真机)')
|
||||
@@ -22,6 +22,7 @@ var STEP_LIB=[
|
||||
{type:'if_el',label:'条件判断',icon:'❓',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:3,ocr_click:false,cmp_op:'',cmp_value:'',cmp_source:'',cmp_group:'',ident_type:'text',ident_value:'',then:[],else:[]}},
|
||||
{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:'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'}},
|
||||
@@ -474,6 +475,22 @@ 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==='expect_el'){
|
||||
// 校验页面:只做校验、不带副作用 —— 确认"上一步真的完成了"
|
||||
var om=p.on_missing||'skip_rest';
|
||||
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="如 //*[@text=\'所有照片\']"></div>'+
|
||||
'<div class="form-group"><label>最多等(秒)</label><input type="number" class="form-control" data-key="timeout" value="'+(p.timeout||10)+'" min="1" max="60"></div></div>';
|
||||
h+='<div class="form-row"><div class="form-group"><label>选择器类型</label><select class="form-control" data-key="selector_type">'+
|
||||
['xpath','resourceId','descriptionContains','text','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><select class="form-control" data-key="on_missing">'+
|
||||
[['skip_rest','跳过本轮剩余步骤(推荐:这一条不发)'],['notify','只发通知、继续(有盲点风险)'],['stop','停本设备']].map(function(o){
|
||||
return '<option value="'+o[0]+'"'+(om===o[0]?' selected':'')+'>'+o[1]+'</option>';}).join('')+
|
||||
'</select></div></div>';
|
||||
h+='<div class="form-group"><label>说明(写进日志/通知,如"应在相册页")</label><input type="text" class="form-control" data-key="what" value="'+esc(p.what||'')+'"></div>';
|
||||
h+='<div class="help">放在关键点击<b>前面</b>:确认上一步真的完成了(该出现的特征元素在不在)。'+
|
||||
'页面不对就按上面的选项处置 —— 避免在错的页面上继续盲点,最后"跑完了但什么都没发出去"。</div>';
|
||||
}else if(step.type==='foreach_account'){
|
||||
// 循环本机待发的号(发布流程的最外层容器):把"跑哪些号"讲清楚,别让人以为是普通循环
|
||||
const fds=(p.date_source==='fixed')?'fixed':'today';
|
||||
|
||||
@@ -734,6 +734,12 @@ var _RP_FIELDS = {
|
||||
['album_dir', '推到手机哪个目录', 'text'],
|
||||
['to_clipboard', '标题写进手机剪贴板', 'chk'],
|
||||
['max_same_run', '一次推几条', 'num']],
|
||||
expect_el: [['what', '说明(如"应在相册页")', 'text'],
|
||||
['selector_type', '选择器类型', 'seltype'],
|
||||
['selector_value', '等它出现(这一页的特征元素)', 'selval'],
|
||||
['timeout', '最多等(秒)', 'num'],
|
||||
['on_missing', '等不到时', 'sel', [['skip_rest', '跳过本轮剩余步骤(推荐)'],
|
||||
['notify', '只通知、继续'], ['stop', '停本设备']]]],
|
||||
read_account_name: [['selector_type', '选择器类型', 'seltype'],
|
||||
['selector_value', '选择器值(抖音「我」页的昵称)', 'selval'],
|
||||
['timeout', '超时(秒)', 'num'],
|
||||
|
||||
@@ -130,6 +130,9 @@ STEP_TYPES = [
|
||||
"then": [], "else": []}},
|
||||
{"type": "mark_done", "label": "记为已做", "icon": "✅",
|
||||
"params": {"selector_type": "text", "selector_value": ""}},
|
||||
{"type": "expect_el", "label": "校验页面", "icon": "🔎",
|
||||
"params": {"selector_type": "xpath", "selector_value": "", "timeout": 10,
|
||||
"on_missing": "skip_rest", "what": ""}},
|
||||
{"type": "read_account_name", "label": "读取本机账号名字", "icon": "🏷",
|
||||
"params": {"selector_type": "xpath", "selector_value": "",
|
||||
"timeout": 8, "on_missing": "fail"}},
|
||||
@@ -268,6 +271,8 @@ class GenericStepsWorker(BaseWorker):
|
||||
# 保证"检查"与"记账"用的是**同一个身份字符串**(否则 key 对不上、去重失效)
|
||||
self._last_ident = ""
|
||||
self._last_release = None # 「推送发布视频」推的那条计划,供「标记发布结果」用
|
||||
# 「校验页面」(expect_el) 判"这页不对"时置位:跳过本轮剩余步骤(见 _exec_steps)
|
||||
self._skip_rest = False
|
||||
# 运行期变量(一台设备一个 worker 实例,天然隔离,不同任务不会串):
|
||||
# account_name 「读取本机账号名字」刚读到的名字
|
||||
# name_selector 读名字用的选择器 (sel_type, sel_val) —— 推送前核对要用同一个控件
|
||||
@@ -334,6 +339,12 @@ class GenericStepsWorker(BaseWorker):
|
||||
for i, step in enumerate(steps):
|
||||
if self.stopped() or self.is_time_up():
|
||||
return
|
||||
# 「校验页面」判"这一页不对"时置位:**本轮剩下的步骤不再执行**
|
||||
# (由 foreach_account 在下一轮开头复位 —— 效果就是"这一条不发,换下一个号")
|
||||
if self._skip_rest:
|
||||
_log.warning(f"[{self.serial}] 本页校验没过 → 跳过本轮剩余 {len(steps) - i} 步"
|
||||
f"(不再盲点后面的按钮)")
|
||||
return
|
||||
self._maybe_patrol(d) # 穿插:每步之前看一眼有没有巡检到点了
|
||||
# 压入本步在兄弟里的序号(1 起):容器步骤执行 children 时会在其后
|
||||
# 继续追加,得到 "2.1" 这样的嵌套路径
|
||||
@@ -979,6 +990,76 @@ class GenericStepsWorker(BaseWorker):
|
||||
f"({plan.get('release_date')} #{plan.get('seq')})")
|
||||
return cands, note
|
||||
|
||||
def _exec_expect_el(self, d, params, depth=0):
|
||||
"""校验页面:**只做校验、不带副作用** —— 确认"上一步真的完成了"(该出现的特征元素在不在)。
|
||||
|
||||
参数:`selector_type`/`selector_value`(**这一页的特征元素**,例如相册页的「所有照片」、
|
||||
剪辑页的「文字/话题」、发布页的标题框)、`timeout`(10,最多等它出现多久)、
|
||||
`what`(可选,人话说明"在等什么",写进日志/通知)、`on_missing`:
|
||||
|
||||
| 值 | 等不到时 |
|
||||
|---|---|
|
||||
| `skip_rest`(默认,推荐) | **本轮剩下的步骤不再执行**(不盲点后面的按钮)—— 配合「循环本机待发的号」= 这一条不发、换下一个号 |
|
||||
| `notify` | 发一条通知,然后**继续**后面的步骤(你自己判断要不要接着点) |
|
||||
| `stop` | 停本设备 + 通知(出了真问题、要人来看时用) |
|
||||
|
||||
为什么要有它:抖音发布流程是一串"点 A → 点 B → 点 C"。中间任何一步没生效,
|
||||
后面就是**盲点**(点到别的控件上),最后"任务跑完了、什么都没发出去"还不报错。
|
||||
在每个关键点击**前面**放一个"应当在某页"的校验,页面不对就停下来留痕/换号。
|
||||
|
||||
返回 True=看到了;False=没看到(已按 `on_missing` 处置)。
|
||||
"""
|
||||
sel_type = (params.get("selector_type") or "xpath").strip()
|
||||
sel_val = (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()
|
||||
if not sel_val:
|
||||
_log.error(f"[{self.serial}] 校验页面:选择器是空的(应填『这一页的特征元素』)")
|
||||
self.set_action("校验页面:没填选择器")
|
||||
return None
|
||||
found = False
|
||||
try:
|
||||
if sel_type == "xpath":
|
||||
found = bool(d.xpath(sel_val).wait(timeout=timeout))
|
||||
else:
|
||||
found = bool(d(**{sel_type: sel_val}).exists(timeout=timeout))
|
||||
except Exception as e:
|
||||
_log.warning(f"[{self.serial}] 校验页面异常(按没看到处理): {e}")
|
||||
if found:
|
||||
_log.info(f"[{self.serial}] 校验页面 ✅ {what}")
|
||||
self.set_action(f"页面对:{what}")
|
||||
return True
|
||||
_log.error(f"[{self.serial}] 校验页面 ❌ 没等到「{what}」—— {self._screen_digest(d)}")
|
||||
self.set_action(f"页面不对:没等到「{what}」")
|
||||
if on_missing == "stop":
|
||||
try:
|
||||
notifier.notify("task.page.mismatch", serial=self.serial,
|
||||
device_name=(self.ctx or {}).get("device_name", ""),
|
||||
model=(self.ctx or {}).get("model", ""),
|
||||
want=what, msg="页面不对,本设备已停止(请看设备屏幕)")
|
||||
except Exception:
|
||||
pass
|
||||
self.stop()
|
||||
elif on_missing == "notify":
|
||||
try:
|
||||
notifier.notify("task.page.mismatch", serial=self.serial,
|
||||
device_name=(self.ctx or {}).get("device_name", ""),
|
||||
model=(self.ctx or {}).get("model", ""),
|
||||
want=what, msg="页面不对,已跳过校验继续执行(有盲点风险)")
|
||||
except Exception:
|
||||
pass
|
||||
else: # skip_rest(默认)
|
||||
self._skip_rest = True
|
||||
try:
|
||||
notifier.notify("task.page.mismatch", serial=self.serial,
|
||||
device_name=(self.ctx or {}).get("device_name", ""),
|
||||
model=(self.ctx or {}).get("model", ""),
|
||||
want=what, msg="页面不对,本轮剩余步骤已跳过(这一条没发)")
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _exec_read_account_name(self, d, params, depth=0):
|
||||
"""读取本机当前登录的账号名字(抖音「我」页面上的昵称控件)。
|
||||
|
||||
@@ -1770,6 +1851,7 @@ class GenericStepsWorker(BaseWorker):
|
||||
target = names[0]
|
||||
tried.append(target) # 试过就不回头(防死循环)
|
||||
self._vars["target_name"] = target
|
||||
self._skip_rest = False # 新一轮:复位"跳过剩余步骤"(上一轮某步判过页面不对)
|
||||
sent += 1
|
||||
self.set_action(f"第 {sent} 个号:{target}(本机还有 {len(names)} 个待发名字)")
|
||||
_log.info(f"[{self.serial}] 循环本机待发的号:轮到『{target}』"
|
||||
|
||||
Reference in New Issue
Block a user