From 10d2eb8334b8430d2a7770a3f52fb9b7f74c0958 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Mon, 12 Oct 2026 11:49:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=8F=91=E5=B8=83=E8=AE=A1=E5=88=92):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E3=80=8C=E6=A0=A1=E9=AA=8C=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E3=80=8D=E6=AD=A5=E9=AA=A4=EF=BC=88expect=5Fel=EF=BC=89?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E6=AF=8F=E4=B8=80=E6=AD=A5=E5=85=88?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E4=B8=8A=E4=B8=80=E6=AD=A5=E7=9C=9F=E7=9A=84?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户要求:"进行每一步的时候先校验上一步有没有完成,比如相册界面肯定有特征,如果没有到相册界面 就出现执行,你看看怎么处理" → 方案 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 个用例通过 --- .env.example | 5 +- core/notify_events.py | 5 + core/task_draft.py | 2 +- core/video_plan.py | 39 +++++-- doc/NOTIFY.md | 2 +- doc/TASK_DEV.md | 6 +- doc/research/U2_ELEMENT_SELECTORS.md | 29 ++++++ scripts/tests/run_all.py | 4 +- scripts/tests/test_expect_el.py | 145 +++++++++++++++++++++++++++ static/admin/editor.js | 17 ++++ static/admin/release.js | 6 ++ tasks/generic/task.py | 82 +++++++++++++++ 12 files changed, 328 insertions(+), 14 deletions(-) create mode 100644 scripts/tests/test_expect_el.py diff --git a/.env.example b/.env.example index f81634b..8793770 100644 --- a/.env.example +++ b/.env.example @@ -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。 diff --git a/core/notify_events.py b/core/notify_events.py index a4cb7b9..6b71e68 100644 --- a/core/notify_events.py +++ b/core/notify_events.py @@ -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"], "想切到某个账号但没切成功(列表里找不到这个名字,或切完读回来的还是别人)——" diff --git a/core/task_draft.py b/core/task_draft.py index 9bb716c..92d795d 100644 --- a/core/task_draft.py +++ b/core/task_draft.py @@ -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 的容器 diff --git a/core/video_plan.py b/core/video_plan.py index 4427cdb..d63e4ad 100644 --- a/core/video_plan.py +++ b/core/video_plan.py @@ -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` 自己会先看"当前是不是已经是目标"(第一个号不用切), diff --git a/doc/NOTIFY.md b/doc/NOTIFY.md index 0517f24..1e879c0 100644 --- a/doc/NOTIFY.md +++ b/doc/NOTIFY.md @@ -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` | diff --git a/doc/TASK_DEV.md b/doc/TASK_DEV.md index da07c57..71ecfcf 100644 --- a/doc/TASK_DEV.md +++ b/doc/TASK_DEV.md @@ -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()`): ``` diff --git a/doc/research/U2_ELEMENT_SELECTORS.md b/doc/research/U2_ELEMENT_SELECTORS.md index 0d13a52..8fc1ff7 100644 --- a/doc/research/U2_ELEMENT_SELECTORS.md +++ b/doc/research/U2_ELEMENT_SELECTORS.md @@ -231,3 +231,32 @@ .venv/Scripts/python -c "import uiautomator2 as u2; d=u2.connect(''); 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 也行)。 diff --git a/scripts/tests/run_all.py b/scripts/tests/run_all.py index 977b6e2..01cd79e 100644 --- a/scripts/tests/run_all.py +++ b/scripts/tests/run_all.py @@ -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") diff --git a/scripts/tests/test_expect_el.py b/scripts/tests/test_expect_el.py new file mode 100644 index 0000000..5279e55 --- /dev/null +++ b/scripts/tests/test_expect_el.py @@ -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('✅ 全部通过(没连任何真机)') diff --git a/static/admin/editor.js b/static/admin/editor.js index 9605638..58367b6 100644 --- a/static/admin/editor.js +++ b/static/admin/editor.js @@ -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={ '
'+ '
'; h+='
将下方子步骤循环执行指定轮次/时长;一直循环无限跑直到任务被外部停止——配合调度"定时启动+停止"(如 9:00 启动 / 18:00 停止)实现全天候养号,或配合任务"运行时长上限"自动停止
'; + }else if(step.type==='expect_el'){ + // 校验页面:只做校验、不带副作用 —— 确认"上一步真的完成了" + var om=p.on_missing||'skip_rest'; + h+='
'+ + '
'; + h+='
'+ + '
'; + h+='
'; + h+='
放在关键点击前面:确认上一步真的完成了(该出现的特征元素在不在)。'+ + '页面不对就按上面的选项处置 —— 避免在错的页面上继续盲点,最后"跑完了但什么都没发出去"。
'; }else if(step.type==='foreach_account'){ // 循环本机待发的号(发布流程的最外层容器):把"跑哪些号"讲清楚,别让人以为是普通循环 const fds=(p.date_source==='fixed')?'fixed':'today'; diff --git a/static/admin/release.js b/static/admin/release.js index 5102d76..f3c7cd0 100644 --- a/static/admin/release.js +++ b/static/admin/release.js @@ -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'], diff --git a/tasks/generic/task.py b/tasks/generic/task.py index 38cff87..0af40e0 100644 --- a/tasks/generic/task.py +++ b/tasks/generic/task.py @@ -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}』"