diff --git a/core/task_draft.py b/core/task_draft.py index 92d795d..df057a1 100644 --- a/core/task_draft.py +++ b/core/task_draft.py @@ -55,7 +55,8 @@ SCHEDULE_MODES = ("once", "cron", "cron_stop") TARGET_MODES = ("all", "group", "serial") # 需要选择器的步骤类型(缺 selector_value 时执行器静默跳过) -NEED_SELECTOR = ("click", "long_click", "wait_el", "swipe_until", "if_el", "expect_el") +NEED_SELECTOR = ("click", "long_click", "wait_el", "swipe_until", "if_el", "expect_el", + "read_text") # 需要包名的步骤类型 NEED_PACKAGE = ("open_app", "stop_app") # 需要非空 children 的容器 @@ -226,17 +227,20 @@ def validate_steps(steps, depth=1, path="steps", errors=None, warnings=None, # 所以在这里拦住,别让 AI 草稿悄悄变成"永远不命中" cmp_op = (params.get("cmp_op") or "").strip() cmp_src = (params.get("cmp_source") or "").strip() - if cmp_src not in ("", "manual", "release", "account_target"): + if cmp_src not in ("", "manual", "release", "account_target", "var"): errors.append(f"{here}.params.cmp_source: 不支持的候选值来源 {cmp_src!r}" - f"(可用:空/manual/release/account_target)") + f"(可用:空/manual/release/account_target/var)") + if cmp_src == "var" and not (params.get("cmp_var") or "").strip(): + errors.append(f"{here}.params.cmp_var: 候选值来源选了「变量」就必须填变量名" + f"(默认 account_name —— 由「读取文字」写入)") if cmp_op: from tasks.generic.task import CMP_OPS if cmp_op not in CMP_OPS: errors.append(f"{here}.params.cmp_op: 不支持的比对运算符 {cmp_op!r}" f"(可用:{'、'.join(CMP_OPS)})") elif not str(params.get("cmp_value") or "").strip() \ - and cmp_src not in ("release", "account_target"): - # 候选值来自发布计划 / 本次要发的名字时,允许手填留空(候选由平台给); + and cmp_src not in ("release", "account_target", "var"): + # 候选值来自发布计划 / 本次要发的名字 / 变量时,允许手填留空(候选不是手填的); # 其余情况仍要求填值,否则永远不命中 errors.append(f"{here}.params.cmp_value: 选了文本比对" f"({cmp_op})就必须填比对的值,否则永远不命中") diff --git a/core/video_plan.py b/core/video_plan.py index d63e4ad..bb5183b 100644 --- a/core/video_plan.py +++ b/core/video_plan.py @@ -602,8 +602,10 @@ def build_release_steps(): build_expect_step("⑬ 校验:素材应已选中(看到「选择/预览」)", "xpath", "//*[@content-desc='选择' or @content-desc='预览']", "素材已选中", timeout=10), clk("next", "⑭ 点击「下一步」"), + # 剪辑页要等**视频导入完成**("正在导入"弹窗挡住工具栏):真机实测(2026-10-12 A06) + # 一条 25 秒的视频导入要 25 秒+,10~12 秒必然判"没到剪辑页"→ 这一条白发。给到 45 秒。 build_expect_step("⑮ 校验:应在剪辑页(看到「文字/话题」工具)", "xpath", - "//*[@content-desc='文字' or @content-desc='话题']", "剪辑页", timeout=12), + "//*[@content-desc='文字' or @content-desc='话题']", "剪辑页", timeout=45), clk("input", "⑯ 点击标题框(抖音发布页的标题框:hint「添加标题」)"), {"id": sid("title"), "type": "input_text", "label": "⑰ 填标题(自动取这一条计划的标题,会回读校验)", diff --git a/doc/API.md b/doc/API.md index af0c49c..783c108 100644 --- a/doc/API.md +++ b/doc/API.md @@ -521,7 +521,7 @@ | `POST /api/video_plan//resolve` | `{published, note}` | **`unknown` 的人工裁决**:确认真发出去了 → `done`;确认没发 → `failed`(可重试) | | `GET /api/video_plan/links` | `?from=&to=&name=&format=csv` | 已发布作品的分享链接;CSV 带 BOM(Excel 打开中文不乱码)。列:发布日期/**名字**/编号/标题/**正文**/分享链接/发布时间 | | `GET /api/video_plan/tasks` | — | `{ok, tasks:[{id,name,enabled,mode,cron,next_run,target,steps,step_count,schedule,retry,is_release,has_push}], release_count, other_count}`:**全部通用步骤任务**(不止发布任务 —— 只列发布任务的话,手写的抖音发布流程会一条都不显示,人会以为"这页没有能改的地方"),界面按 `is_release`(有 `push_release` **或** `mark_release`,含嵌套)分两组。带完整 `steps` 是为**就地编辑**(抓选择器);`step_count` 数的是**含嵌套**的总步数。权限 `T` | -| `POST /api/video_plan/tasks` | `{"name","target":{"mode":"all\|group\|serial","group_name","serial"},"time":"10:00","enabled"}` | 一键新建:骨架 **18 步**(亮屏 → 打开抖音(等首页) → 点「我」→ `read_account_name` 读本机账号名字(昵称控件的选择器填在 .env)→ `if_el(cmp_source=account_target)` 校验登的是不是要发的名字 → then: `push_release(account=name)` → 「+」(预填 `descriptionContains=拍摄`) → 相册/第一个视频/下一步/标题框/正文框/发布(**空选择器占位**)→ `input_text(text_source=release_title)` → `input_text(text_source=release_body)` → `mark_release`;else: `notify` 跳过);**走 TaskManager 建**(内存+库一起更新,直接写库调度器不认)。⚠ `target` 分组键名是 **`group_name`**(写成 `group` 会静默解析出 0 台设备);分组/单设备没给具体目标 → 400。权限 `T` | +| `POST /api/video_plan/tasks` | `{"name","target":{"mode":"all\|group\|serial","group_name","serial"},"time":"10:00","enabled"}` | 一键新建:骨架 **4 步 / 含循环体内共 23 步**(亮屏 → 打开抖音(等首页) → 点「我」→ `read_account_name` 读本机账号名字(昵称控件的选择器填在 .env)→ `if_el(cmp_source=account_target)` 校验登的是不是要发的名字 → then: `push_release(account=name)` → 「+」(预填 `descriptionContains=拍摄`) → 相册/第一个视频/下一步/标题框/正文框/发布(**空选择器占位**)→ `input_text(text_source=release_title)` → `input_text(text_source=release_body)` → `mark_release`;else: `notify` 跳过);**走 TaskManager 建**(内存+库一起更新,直接写库调度器不认)。⚠ `target` 分组键名是 **`group_name`**(写成 `group` 会静默解析出 0 台设备);分组/单设备没给具体目标 → 400。权限 `T` | | `GET/PUT /api/video_plan/tasks/` | `PUT {name?, time?, target?, steps?, enabled?}` | **发布任务的就地编辑**(「发布计划」页里改,不用跳任务页)。只放开这几个字段,**没传的字段原样保留**;`steps` 整块替换但 `params` 里的通用参数(去重有效期、抢占…)**深合并保留**。校验:空名字/坏时间/`steps` 不是数组/什么都不传 → 400,未知任务 → 404。权限 `T`。底层仍是 `TaskManager.update_job()`(内存+库一起改) | | `POST /api/video_plan/tasks//adopt` | — | 给**已有任务**插上平台那两步:`push_release` 放最前、`mark_release` 放最后(**中间你自己的步骤一步不动**;位置理由见 `core.video_plan.insert_release_steps`)。只对「通用步骤」任务;没有步骤 → 400,已经有这两步 → 409,未知任务 → 404。权限 `T` | diff --git a/doc/TASK_DEV.md b/doc/TASK_DEV.md index 71ecfcf..587e214 100644 --- a/doc/TASK_DEV.md +++ b/doc/TASK_DEV.md @@ -109,7 +109,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) --- -## 3. 28 种步骤全表 +## 3. 29 种步骤全表 > 参数与默认值以 `tasks/generic/task.py` 为准;前端 `STEP_LIB`(`static/admin/editor.js`)负责在编辑器里呈现这些字段。 @@ -123,7 +123,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) | 6 | `key_event` | 按键 | `key`("back") | — | — | `d.press(key)`;**值直传,无白名单校验** | | 7 | `swipe` | 滑动 | `direction`("up") | `duration_min`(0.25)、`duration_max`(0.50)、`distance_ratio`(0.6)、`jitter`(0.15)、`humanize`(True) | — | **默认拟人**:弧线轨迹 + 起止点/幅度/时长抖动 + 本设备手速偏好(见 §8.4);`humanize:false` 还原老的「正中直线」;`jitter:0` 时位置/幅度不抖(时长仍随机);**非法 direction 静默不滑** | | 8 | `swipe_until` | 滑动直到元素 | `selector_type`、`selector_value` | `direction`("up")、`max_swipes`(8)、`click_when_found`(True)、`duration_min`(0.25)、`duration_max`(0.50)、`distance_ratio`(0.6)、`jitter`(0.15)、`humanize`(True) | — | 每次滑动都重新生成轨迹(同 §8.4);每轮滑动后随机停 0.35~0.75s;命中即(可选)点击并返回 True;**只有 `direction="down"` 才是向下滑**,其余按上滑 | -| 9 | `click` | 点击元素 | `selector_type`、`selector_value` | `wait_timeout`(2) | — | xpath 用 `wait(timeout)`,其它选择器用 `exists(timeout)`;`wait_timeout=0` 为立即判断。**空选择器只告警** | +| 9 | `click` | 点击元素 | `selector_type`、`selector_value` | `wait_timeout`(2) | — | xpath 用 `wait(timeout)`,其它选择器用 `exists(timeout)`;`wait_timeout=0` 为立即判断。**空选择器只告警**。选择器值里可以写 `{本次要发的名字}` / `{本机账号名字}`(见 §3.3);按**文本包含**找用 `textContains`(例:账号列表里点 `textContains={本次要发的名字}`) | | 10 | `click_xy` | 点击坐标 | — | `x`(50)、`y`(50) | — | **屏幕百分比**(0-100,会夹取),50/50 = 屏幕中心。无选择器时的兜底,**最脆的方式** | | 11 | `long_click` | 长按元素 | `selector_type`、`selector_value` | `duration`(1.0)、`wait_timeout`(2) | — | 先等元素出现再 `long_click` | | 12 | `wait_el` | 等待元素 | `selector_type`、`selector_value` | `timeout`(10) | — | 等元素出现(条件等待,优于固定 `wait`) | @@ -133,7 +133,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) | 16 | `loop` | 循环块 | — | `loop_mode`("rounds")、`max_iterations`(10)、`loop_duration`(600) | `children` | 见 §4.1 | | 16 | `loop` | 循环块 | — | `loop_mode`("rounds")、`max_iterations`(10)、`loop_duration`(600) | `children` | 见 §4.1 | | 17 | `group` | 动作组 | — | — | `children` | 子步骤**按序执行一次**(不循环);自定义动作拖入画布就是展开成 group | -| 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False)、`cmp_op`("")、`cmp_value`("")、`cmp_source`("")、`ident_type`/`ident_value`(去重身份) | `then` / `else` | 见 §4.2;条件类型除元素/OCR 外还支持 **屏幕状态**、**前台App**、**去重**(见 §4.6);填 `cmp_op` 则改成**比元素的文本**(等于/不等于/包含/不包含,多值任一命中),候选值还可以由平台给:**本次要发的名字**(`cmp_source=account_target`)或**本机要发的那条发布计划**的名字(`cmp_source=release`) | +| 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False)、`cmp_op`("")、`cmp_value`("")、`cmp_source`("")、`ident_type`/`ident_value`(去重身份) | `then` / `else` | 见 §4.2;条件类型除元素/OCR 外还支持 **屏幕状态**、**前台App**、**去重**(见 §4.6);填 `cmp_op` 则改成**比元素的文本**(等于/不等于/包含/不包含,多值任一命中),候选值还可以由平台给:**本次要发的名字**(`cmp_source=account_target`)、**本机要发的那条发布计划**的名字(`cmp_source=release`)或**某个运行期变量**(`cmp_source=var` + `cmp_var=<变量名>`,变量由 `read_text` 写入)。**候选为空(名字读不到/变量是空的)→ 一律不命中**,这是设计如此(不知道登的是谁就不发) | | 19 | `notify` | 发通知 | — | `title`("")、`message`("")、`level`("info") | — | 推一条**自定义**通知(事件 `task.notify.custom`):标题正文自己写,支持 `{device} {serial} {job} {time} {app} {screen}`;谁收到取决于 webhook 的事件订阅。两者都空则跳过 | | 20 | `stop_self` | 停止本设备 | — | `reason`("") | — | 只停**本设备**的任务(其它设备照跑):置 worker 停止位,后续步骤不再执行,任务记成**被停止而不是失败**(不触发重试) | | 21 | `gesture` | 录制手势 | `points`(录出来的) | `speed`(1.0) | — | **纯录制回放**:把录下的轨迹点列 `[[x,y,t_ms],…]` 按原路径与时长交给设备插值,不做弧线/抖动/手速加工。与「滑动」是两套东西,见 §3.2 | @@ -141,6 +141,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) | 23 | `push_release` | 推送发布视频 | `date_source`(today/fixed)、`fixed_date` | `account`(name/phone)、`account_phone`、`album_dir`(/sdcard/DCIM/Camera)、`to_clipboard`(True)、`retry_failed`(True)、`max_same_run`(1) | — | 取**本次要发的那个名字**名下的「发布计划」→ **只把素材推到手机**(+ 触发相册刷新 + 标题写进剪贴板)。**不发布** —— 抖音流程你自己在后面画。`account=name`(默认)时**推送前会重读昵称核对**,对不上就不推。幂等靠原子占位(多设备/重跑不会重复推)。见 §4.7 | | 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 | +| 29 | `read_text` | 读取文字 | `selector_value`(要读的元素,值里可用 `{变量}`) | `selector_type`(xpath)、`timeout`(8)、`var`(account_name)、`clean`(account)、`on_missing`(skip) | — | 把某个元素的文字**读出来存进变量**(通用版;发布专用的那个是 #25 `read_account_name`)。`clean=account` 按账号名清洗(取第一行非标签)/ `strip` 只去首尾空白。变量名叫 `account_name` 时顺手把该控件记给 `push_release`/`switch_account` 复用。存下后:选择器值里写 `{变量名}`、或让「条件判断」的 `cmp_source=var` 取它。读不到默认只记一句(`skip`),`fail` 才算这一步失败 | | 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 | @@ -221,6 +222,46 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) --- +### 3.3 选择器值 / 文案里的变量 `{…}` + +多个账号轮着跑时,"要点的那一行""要比对的名字"每轮都不一样。**选择器值里可以直接写变量**, +执行时按本轮真值替换: + +| 写法 | 取什么 | +|---|---| +| `{本次要发的名字}` / `{target_name}` | 循环块(`foreach_account`)本轮设的目标名字 | +| `{本机账号名字}` / `{account_name}` | `read_account_name` / `read_text`(变量名 `account_name`)读到的本机名字 | + +例:账号列表里点自己要发的那个号 —— 选择器类型 `textContains`、值 `{本次要发的名字}`。 + +**规矩(都是刻意的)**: + +- **没写花括号的步骤一个字不改**,老任务零影响。 +- 变量还是空的(还没轮到 / 没读到)→ **换成空串 + 记一条警告**:宁可"选择器变空、找不到元素" + 明着报,也别留个 `{…}` 字面量去点到歪的地方。 +- 支持变量的地方:`click` / `long_click` / `wait_el` / `swipe_until` / `expect_el` / + `read_text` / `read_account_name` / `switch_account` 的选择器值、`if_el` 的选择器与手填候选值。 +- **不做通用表达式**:只认表里那几个名字(中文别名与变量名都行),拼错就等于空值。 + +### 3.4 自己拼"切号 + 认号"(不想用打包步骤时) + +`switch_account`(#26)和 `read_account_name`(#25)是**打包步骤**,内部动作不展开在画布上。 +想每一步都看得见、自己改,可以这样拼: + +1. `点击元素`:描述包含 `我` —— 进「我」页 +2. `读取文字`:选择器 = 昵称控件、变量名 `account_name` —— 读本机名字(顺带把控件记给后续步骤) +3. `条件判断`:选择器 = 同一个昵称控件、比较 `等于`/`包含`、候选值来源 = **本次要发的名字**(或**变量**) + - `then` = 发布流程;`else` = 发通知"不是要发的号" +4. `点击元素`:描述包含 `切换账号` —— 进账号列表 +5. `点击元素`:**文本包含** `{本次要发的名字}` —— 点自己那一行(这一步以前只能用打包步骤做) +6. `等待` 15 秒 —— 换号会重启抖音,这期间别点任何东西 +7. `条件判断` 再核一遍名字(第 3 步那样),确认真切过去了 + +> ⚠ 两条与安全有关的老规矩不变:**名字之间不要互为子串**(`textContains` 会先匹配到长的那个 → +> 点到别人号上);**列表定位禁用 `[N]` 序号**(界面一变就错位)。 + +--- + ## 4. 容器步骤与公共参数 ### 4.1 `loop` 三种模式 diff --git a/scripts/tests/run_all.py b/scripts/tests/run_all.py index 01cd79e..95fe31a 100644 --- a/scripts/tests/run_all.py +++ b/scripts/tests/run_all.py @@ -18,6 +18,7 @@ test_foreach_account 循环本机待发的号(一轮跑完、试过不回头、干净退出) test_push_by_name 按名字找设备推送(matched/unmatched、缓存) test_expect_el 「校验页面」:特征在就过;不在就按 on_missing 处置(默认跳过本轮剩余步骤) + test_read_text 「读取文字」+ 选择器里的 {变量} +「条件判断→变量」 """ import os import subprocess @@ -26,7 +27,7 @@ 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_expect_el.py"] + "test_expect_el.py", "test_read_text.py"] if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") diff --git a/scripts/tests/test_read_text.py b/scripts/tests/test_read_text.py new file mode 100644 index 0000000..2f718f4 --- /dev/null +++ b/scripts/tests/test_read_text.py @@ -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('✅ 全部通过(没连任何真机)') diff --git a/static/admin/editor.js b/static/admin/editor.js index 58367b6..9674ce1 100644 --- a/static/admin/editor.js +++ b/static/admin/editor.js @@ -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){ ''; }else{ valHtml=''+(isFg?'':pick+test); + '" placeholder="'+(isFg?'如 com.ss.android.ugc.aweme':'如 点赞;可用 {本次要发的名字} / {本机账号名字}')+'">'+(isFg?'':pick+test); } var head='
'+ '
'; @@ -475,6 +477,28 @@ var _stepEditor={ '
'+ '
'; h+='
将下方子步骤循环执行指定轮次/时长;一直循环无限跑直到任务被外部停止——配合调度"定时启动+停止"(如 9:00 启动 / 18:00 停止)实现全天候养号,或配合任务"运行时长上限"自动停止
'; + }else if(step.type==='read_text'){ + // 读取文字:把某个元素的文字读出来存进变量(通用版;发布专用的那个是「读取本机账号名字」) + var om2=p.on_missing||'skip'; + h+='
'+ + '
'; + h+='
'+ + '
'+ + '
存下之后,选择器值里可以写 {变量名},「条件判断」的候选值来源也能选「变量」。'+ + '变量名叫 account_name 时,顺带把它当本机账号名(切号/推送核对会复用)
'; + h+='
'+ + '
'; + h+='
把元素的文字读出来存成变量,给后面的步骤用(选择器值里写 {变量名},'+ + '或让「条件判断」的候选值来源选「变量」)。只有读、不点任何东西;读不到按上面的选项处置。
'; }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?'':'
'+ ''+ [['','手填(默认)'],['account_target','本次要发的名字(读名字/循环块给的)'], - ['release','本机当前发布计划(要发的那条)']] + ['release','本机当前发布计划(要发的那条)'], + ['var','变量(「读取文字」读到的)']] .map(function(o){return '';}).join('')+ '
'+(p.cmp_source==='release' ? '取本机下一条要发的发布计划的名字(推送前校验"当前登的是不是要发的号"就用它)' : (p.cmp_source==='account_target' ? '取本次要发的那个名字(「读取本机账号名字」读到的,或循环块设的目标)。' +'读不到名字时候选为空 → 不命中 → 走 else(不发),这是故意的' - : '手填要比对的文本;要用本机账号名就用上面的两个来源'))+'
'+ + : (p.cmp_source==='var' + ? '取「读取文字」存下的那个变量(变量名在右边那格填,默认 account_name)。' + +'变量是空的 → 候选为空 → 不命中' + : '手填要比对的文本;要用本机账号名就用上面的两个来源')))+'
'+ + // 变量名:只有来源选「变量」时才显眼,其余情况**只隐藏不删**(同上,_syncParams 按 DOM 收集) + ''+ // 候选值来自平台时**只隐藏、不删**这个输入框:_syncParams 是按 DOM 存在与否收集的, // 删了就等于把用户手填的值悄悄抹掉(同类坑见本文件 927 行附近的注释) '
'+ @@ -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+'": 候选值来自"本机当前发布计划" —— 本机没有待发的计划' +'时取不到号(这一步会走"未找到"分支)。这是设计如此(宁可不发、也不发错号),' diff --git a/static/admin/release.js b/static/admin/release.js index f3c7cd0..918b8d5 100644 --- a/static/admin/release.js +++ b/static/admin/release.js @@ -708,7 +708,8 @@ function rpNewTargetChanged() { // 保存口径:整块 steps 交给 `PUT /api/video_plan/tasks/`,后端深合并进 params // (不能整块覆盖 params —— 去重有效期、抢占那些通用参数会被抹掉)。 var _rpEdit = null; // 正在编辑的任务(steps 是深拷贝,取消就丢弃) -var _RP_SEL_TYPES = ['xpath', 'description', 'text', 'resourceId', 'descriptionContains', 'className']; +var _RP_SEL_TYPES = ['xpath', 'description', 'text', 'textContains', 'resourceId', + 'descriptionContains', 'className']; // 每种步骤给哪几个字段(其它参数原样保留,不动它们) var _RP_FIELDS = { @@ -740,6 +741,13 @@ var _RP_FIELDS = { ['timeout', '最多等(秒)', 'num'], ['on_missing', '等不到时', 'sel', [['skip_rest', '跳过本轮剩余步骤(推荐)'], ['notify', '只通知、继续'], ['stop', '停本设备']]]], + read_text: [['selector_type', '选择器类型', 'seltype'], + ['selector_value', '读哪个元素的文字(可用 {本次要发的名字})', 'selval'], + ['var', '存到哪个变量名', 'text'], + ['clean', '清洗方式', 'sel', [['account', '按账号名清洗(去空白/按钮字)'], + ['strip', '只去首尾空白']]], + ['timeout', '超时(秒)', 'num'], + ['on_missing', '读不到时', 'sel', [['skip', '记一句继续'], ['fail', '算失败']]]], read_account_name: [['selector_type', '选择器类型', 'seltype'], ['selector_value', '选择器值(抖音「我」页的昵称)', 'selval'], ['timeout', '超时(秒)', 'num'], @@ -776,7 +784,9 @@ var _RP_FIELDS = { ['不等于', '不等于'], ['包含', '包含'], ['不包含', '不包含']]], ['cmp_source', '候选值来源', 'sel', [['', '手填'], ['account_target', '本次要发的名字(读名字/循环块给的)'], - ['release', '本机当前发布计划(要发的那条)']]], + ['release', '本机当前发布计划(要发的那条)'], + ['var', '变量(「读取文字」读到的)']]], + ['cmp_var', '变量名(上面选"变量"时填)', 'text'], ['cmp_value', '比对的值(一行一个,任一命中即可)', 'area']], mark_done: [['selector_type', '选择器类型', 'seltype'], ['selector_value', '选择器值', 'selval']], clipboard: [['text', '要放进剪贴板的文字', 'text']], @@ -958,8 +968,9 @@ function _rpField(type, p, f, path) { var pick = (kind === 'selval' && typeof _can === 'function' && _can('devices')) ? '' : ''; + var ph = (kind === 'selval') ? ' placeholder="如 点赞;可用 {本次要发的名字}"' : ''; return '
' + head - + '
' + pick + '
'; } diff --git a/tasks/generic/task.py b/tasks/generic/task.py index 0af40e0..8590e3d 100644 --- a/tasks/generic/task.py +++ b/tasks/generic/task.py @@ -136,6 +136,9 @@ STEP_TYPES = [ {"type": "read_account_name", "label": "读取本机账号名字", "icon": "🏷", "params": {"selector_type": "xpath", "selector_value": "", "timeout": 8, "on_missing": "fail"}}, + {"type": "read_text", "label": "读取文字", "icon": "📖", + "params": {"selector_type": "xpath", "selector_value": "", "timeout": 8, + "var": "account_name", "clean": "account", "on_missing": "skip"}}, {"type": "switch_account", "label": "切换账号", "icon": "🔁", "params": {"target": "var:target_name", "target_value": "", "switch_selector_type": "xpath", "switch_selector_value": "", @@ -189,6 +192,25 @@ _CMP_ALIASES = { # AI 草稿校验(core/task_draft.py)用它拦"运算符拼错 -> 永远不命中"。 CMP_OPS = tuple(_CMP_ALIASES) +# 选择器值 / 文案里可以写的**变量占位符**:`{本次要发的名字}`、`{本机账号名字}`。 +# 用花括号是故意的:`{}` 不是合法的 XPath/UiSelector 片段 —— 变量没值时会**明着找不到元素**, +# 而不是像 `$名字` 那样被当成普通文本、静默点到歪的地方去。中文别名与变量名都认。 +_VAR_TOKENS = { + "本次要发的名字": "target_name", + "要发的名字": "target_name", + "target_name": "target_name", + "本机账号名字": "account_name", + "本机名字": "account_name", + "account_name": "account_name", +} +_VAR_RE = re.compile(r"\{([^{}]{1,32})\}") + +# 「条件判断」的候选值来源(界面下拉就是这几个)。空串 = 手填(历史行为)。 +# account_target = 本次要发的名字(循环块/读名字给的) +# release = 本机下一条待发计划的名字 +# var = 「读取文字」读到的那个变量 +CMP_SOURCES = ("", "account_target", "release", "var") + def _canon_op(op): """运算符规范化成中文那个(认别名);认不出返回 None。""" @@ -602,7 +624,7 @@ class GenericStepsWorker(BaseWorker): 返回 True=找到, False=未找到, None=配置缺选择器。 """ sel_type = params.get("selector_type", "xpath") - sel_val = params.get("selector_value", "") + sel_val = self._vars_sub(params.get("selector_value", "")) direction = params.get("direction", "up") max_swipes = max(1, int(params.get("max_swipes", 8))) click_when_found = params.get("click_when_found", True) @@ -662,7 +684,7 @@ class GenericStepsWorker(BaseWorker): def _exec_long_click(self, d, params, depth=0): """长按元素(选择器 + 时长秒)。复制链接/呼出菜单用。""" sel_type = params.get("selector_type", "xpath") - sel_val = params.get("selector_value", "") + sel_val = self._vars_sub(params.get("selector_value", "")) duration = float(params.get("duration", 1.0)) timeout = float(params.get("wait_timeout", 2)) if not sel_val: @@ -689,7 +711,7 @@ class GenericStepsWorker(BaseWorker): def _exec_wait_el(self, d, params, depth=0): """等待元素出现(条件等待)。返回 True=出现, False=超时, None=缺选择器。""" sel_type = params.get("selector_type", "xpath") - sel_val = params.get("selector_value", "") + sel_val = self._vars_sub(params.get("selector_value", "")) timeout = float(params.get("timeout", 10)) if not sel_val: _log.warning("wait_el 缺少选择器") @@ -729,7 +751,7 @@ class GenericStepsWorker(BaseWorker): 返回 True=命中 / False=未命中 / None=缺选择器或 OCR 不可用。 """ sel_type = params.get("selector_type", "xpath") - sel_val = params.get("selector_value", "") + sel_val = self._vars_sub(params.get("selector_value", "")) timeout = float(params.get("timeout", 3)) cmp_op = str(params.get("cmp_op") or "").strip() cmp_val = str(params.get("cmp_value") or "") @@ -873,8 +895,19 @@ class GenericStepsWorker(BaseWorker): 去重静默失效(见 core/dedup.py 的 build_key)。 """ source = str(params.get("cmp_source") or "").strip() - manual = split_cmp_values(manual_raw) - if source == "release": + # 手填值里也允许写 {变量}(例如"要比的就是刚才读到的东西",再拼个后缀) + manual = split_cmp_values(self._vars_sub(manual_raw)) + if source == "var": + # 变量(「读取文字」读到的 / 循环块设的):候选 = 那个变量的值。 + # ⚠ 变量为空 → 候选为空 → 永远不命中(走未找到分支)——和"读不到名字"同一条规矩。 + var = str(params.get("cmp_var") or "account_name").strip() or "account_name" + val = str(self._vars.get(var) or "").strip() + if not val: + _log.warning(f"[{self.serial}] 条件判断:变量『{var}』现在是空的" + f"(前面要有「读取文字」读到它)→ 候选为空,这一步永远不命中") + return manual_raw, f"变量『{var}』是空的(读不到 = 不命中)" + ids, note = [val], f"变量『{var}』= 『{val}』" + elif source == "release": # 「发布计划」:本机**下一条将要推送**的那条计划的**名字**(推送前校验账号就靠它)。 ids, note = self._release_candidates() elif source == "account_target": @@ -896,7 +929,9 @@ class GenericStepsWorker(BaseWorker): f"[{self.serial}] 条件判断取号为空(来源 {source}):{note};手填候选 " f"{len(manual)} 个 → " + ("仅用手填值" if manual else "候选为空,这一步将永远不命中(走未找到分支)")) - where = "发布计划" if source == "release" else "本次要发的名字" + where = {"release": "发布计划", + "var": f"变量『{params.get('cmp_var') or 'account_name'}』", + "account_target": "本次要发的名字"}.get(source, "候选值") src_txt = (f"候选来自{where}:{'、'.join(ids)}" if ids else f"{where}取不到:{note}") if manual: @@ -1010,7 +1045,7 @@ class GenericStepsWorker(BaseWorker): 返回 True=看到了;False=没看到(已按 `on_missing` 处置)。 """ sel_type = (params.get("selector_type") or "xpath").strip() - sel_val = (params.get("selector_value") or "").strip() + sel_val = self._vars_sub((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() @@ -1080,7 +1115,7 @@ class GenericStepsWorker(BaseWorker): """ from core import video_plan as vp sel_type = (params.get("selector_type") or "xpath").strip() - sel_val = (params.get("selector_value") or "").strip() + sel_val = self._vars_sub((params.get("selector_value") or "").strip()) timeout = float(params.get("timeout") or 8) soft = (params.get("on_missing") or "fail").strip().lower() == "skip" if not sel_val: @@ -1113,6 +1148,62 @@ class GenericStepsWorker(BaseWorker): self.set_action(f"本机账号:{name}") return True + def _exec_read_text(self, d, params, depth=0): + """读取文字:把某个元素的文字读出来**存进变量**,给后面的步骤用。 + + 参数:`selector_type`(xpath) `selector_value`(要读的元素,值里可以用 `{变量}`)、 + `timeout`(8)、`var`(存到哪个变量,默认 `account_name`)、 + `clean`(`account`=按账号名清洗(默认)| `strip`=只去首尾空白)、 + `on_missing`(`skip` 默认:读不到只记一句、不拦任务 | `fail`:读不到算这一步失败) + + 存进去之后两处能用: + · 选择器值/文案里写 `{变量名}`(见 `_vars_sub`,比如列表里点 `textContains={本机账号名字}`) + · 「条件判断」的候选值来源选「**变量**」 + + 与「读取本机账号名字」的区别:那一步是**发布专用**的(顺带把昵称控件记给切号用、 + 固定按账号名清洗);这一步是通用的 —— 读什么、存哪个变量名你定。 + 返回 True=读到 / False=读不到(且 `on_missing=fail`) / None=缺选择器或读不到但 skip。 + """ + from core import video_plan as vp + sel_type = (params.get("selector_type") or "xpath").strip() + sel_val = self._vars_sub((params.get("selector_value") or "").strip()) + var = (params.get("var") or "account_name").strip() or "account_name" + timeout = float(params.get("timeout") or 8) + soft = (params.get("on_missing") or "skip").strip().lower() != "fail" + if not sel_val: + msg = ("「读取文字」的选择器是空的 —— 用「抓取元素」抓一下要读的那个控件" + "(值里也可以写 {本次要发的名字} 这类变量)") + _log.error(f"[{self.serial}] {msg}") + self.set_action(msg) + return None + raw = "" + try: + if sel_type == "xpath": + el = d.xpath(sel_val) + if el.wait(timeout=timeout): + raw = el.get_text() or "" + else: + el = d(**{sel_type: sel_val}) + if el.exists(timeout=timeout): + raw = el.get_text(timeout=_TEXT_READ_TIMEOUT) or "" + except Exception as e: + _log.warning(f"[{self.serial}] 读取文字失败: {e}") + text = (vp.clean_account_name(raw) if (params.get("clean") or "account") == "account" + else (raw or "").strip()) + self._vars[var] = text + if var == "account_name": + # 沿用既有约定:变量名叫 account_name 时顺手把**昵称控件**记下来 + # (「切换账号」和推送前核对都用它 —— 必须同一个控件、同一套清洗) + self._vars["name_selector"] = (sel_type, sel_val) + if not text: + _log.warning(f"[{self.serial}] 读取文字:读不到(元素原文 {raw!r})→ 变量" + f"『{var}』置空") + self.set_action(f"读不到文字({var})") + return None if soft else False + _log.info(f"[{self.serial}] 读取文字:{var} = 『{text}』(元素原文 {raw!r})") + self.set_action(f"{var}:{text}") + return True + # 抖音停在登录/验证码页的字样。**只在"切号后回读校验没过"时才拿它判定** —— # 平时查会把「我」页面上的普通文字误判成登录页,而误判会让整台设备停掉。 _LOGIN_HINTS = ("验证码", "其他方式登录", "手机号登录", "一键登录", "密码登录", @@ -1128,18 +1219,74 @@ class GenericStepsWorker(BaseWorker): except Exception as e: _log.warning(f"账号通知发送失败(不影响任务):{e}") + def _vars_sub(self, text): + """把选择器值/文案里的 `{变量}` 换成本轮的真值。 + + 认 `{本次要发的名字}`(循环块设的目标)和 `{本机账号名字}`(读名字读到的), + 中文别名与变量名都行(见 `_VAR_TOKENS`)。 + + · **没写花括号的步骤一个字不改** —— 老任务零影响。 + · 变量还是空的(还没轮到 / 没读到)→ **换成空串 + 记警告**:宁可"选择器变空、 + 找不到元素"明着报,也别留个 `{...}` 字面量去瞎点。 + """ + if not text or "{" not in text: + return text + + def rep(m): + raw = m.group(1).strip() + key = _VAR_TOKENS.get(raw, raw) + val = str(self._vars.get(key) or "").strip() + if val: + _log.info(f"[{self.serial}] 变量 {{{raw}}} → 『{val}』") + else: + _log.warning(f"[{self.serial}] 变量 {{{raw}}} 现在是空的(还没轮到/没读到)" + f"→ 换成空串,这一步多半找不到元素") + return val + + return _VAR_RE.sub(rep, text) + def _read_name_now(self, d, params): """现在屏幕上读一次昵称(清洗后)。选择器:步骤参数优先,其次「读取本机账号名字」 记下的那个;都没有 → 返回 ""(= 读不到,调用方按"没有号"处理)。""" from core import video_plan as vp sel_type = (params.get("name_selector_type") or "").strip() - sel_val = (params.get("name_selector_value") or "").strip() + sel_val = self._vars_sub((params.get("name_selector_value") or "").strip()) if not sel_val: sel_type, sel_val = self._vars.get("name_selector") or ("", "") if not sel_val: return "" return vp.clean_account_name(self._read_element_text(d, sel_type or "xpath", sel_val)) + # 抖音包名:切号前"回首页"要冷启动它(只有这一处用,就不塞进任务参数了) + _DOUYIN_PKG = "com.ss.android.ugc.aweme" + + def _goto_me(self, d, me_type, me_val, timeout=4): + """把抖音带回「我」页 —— **不管上一轮停在哪一页**(切号第一步用)。 + + 先点底部「我」页签(在首页时这一下就够)。点不到 = **不在主界面**(剪辑页/发布页/ + 别人的视频页 —— 上一轮中途出问题、或人手动停在那儿),那种页面**没有底部页签**, + 点多少下都没用 → **强杀 + 冷启动**抖音,再点一次。 + + 代价是一次冷启动(几秒),换来"上一轮停在哪都不影响这一轮"。真机实测(2026-10-12 + A06):卡在剪辑页时,后面几个号**全部**报废 —— 比冷启动贵得多。 + 返回 True=点到了(或尽力了,交给后续"进列表/回读"判成败),False=连冷启动都失败。 + """ + if self._click_sel(d, me_type, me_val, timeout=min(timeout, 4)): + return True + _log.warning(f"[{self.serial}] 找不到「我」页签 → 不在主界面,冷启动抖音重来" + f"—— {self._screen_digest(d)}") + try: + d.app_stop(self._DOUYIN_PKG) + time.sleep(1.0) + ensure_app_running(d, self._DOUYIN_PKG) + time.sleep(3.0) + except Exception as e: + _log.warning(f"[{self.serial}] 冷启动抖音失败: {e}") + return False + self._click_sel(d, me_type, me_val, timeout=max(timeout, 6)) + time.sleep(1.0) + return True + def _click_sel(self, d, sel_type, sel_val, timeout=8): """按选择器等元素出现并点击。找不到返回 False(不抛异常)。""" if not sel_val: @@ -1272,7 +1419,7 @@ class GenericStepsWorker(BaseWorker): _log.info(f"[{self.serial}] 切换账号:当前就是『{target}』,不用切") self.set_action(f"已经是『{target}』,不用切") return True - switch_val = (params.get("switch_selector_value") or "").strip() + switch_val = self._vars_sub((params.get("switch_selector_value") or "").strip()) if not switch_val: msg = ("「进账号切换列表」的选择器是空的 —— 用「抓取元素」抓一下抖音里那个入口" "(「我」页面 → 切换账号),填到这一步上") @@ -1280,12 +1427,12 @@ class GenericStepsWorker(BaseWorker): self.set_action(msg) return False me_type = params.get("me_selector_type") or "descriptionContains" - me_val = params.get("me_selector_value") or "我" + me_val = self._vars_sub(params.get("me_selector_value") or "我") last_got = "" for i in range(1, attempts + 1): if self.stopped(): return False - self._click_sel(d, me_type, me_val, timeout=min(timeout, 4)) + self._goto_me(d, me_type, me_val, timeout) time.sleep(1.0) if not self._click_sel(d, params.get("switch_selector_type") or "xpath", switch_val, timeout): @@ -1346,10 +1493,18 @@ class GenericStepsWorker(BaseWorker): time.sleep(0.5) # 等完再回读;**不再点任何东西**,只多读几次(页面渲染有零点几秒的抖动) last_got = "" - for _ in range(5): + for k in range(5): if self.stopped(): return False last_got = self._read_name_now(d, params) + if last_got != target and k == 1: + # 换号后抖音会**整体重启**,起来通常落在「首页」—— 首页没有昵称控件, + # 读多少次都是空。等到这会儿(等完 wait_after、已读两遍)再点一下「我」 + # 把页面切过去,然后继续读 —— 不动它的重启过程,只补这一下导航。 + _log.info(f"[{self.serial}] 切号后回读为空 → 点一次「我」再读" + f"(换号重启后常落在首页)") + self._goto_me(d, me_type, me_val, timeout=6) + continue if last_got == target: _log.info(f"[{self.serial}] 切换账号成功:『{target}』(第 {i} 次," f"等了 {wait_after:.0f}s)") @@ -1722,7 +1877,7 @@ class GenericStepsWorker(BaseWorker): def _exec_click(self, d, params, depth=0): sel_type = params.get("selector_type", "xpath") - sel_val = params.get("selector_value", "") + sel_val = self._vars_sub(params.get("selector_value", "")) timeout = float(params.get("wait_timeout", 2)) if not sel_val: _log.warning(f"[{self.serial}] click 跳过:selector_value 为空,请在步骤编辑器中填写或抓取元素")