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
+9 -5
View File
@@ -55,7 +55,8 @@ SCHEDULE_MODES = ("once", "cron", "cron_stop")
TARGET_MODES = ("all", "group", "serial") TARGET_MODES = ("all", "group", "serial")
# 需要选择器的步骤类型(缺 selector_value 时执行器静默跳过) # 需要选择器的步骤类型(缺 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") NEED_PACKAGE = ("open_app", "stop_app")
# 需要非空 children 的容器 # 需要非空 children 的容器
@@ -226,17 +227,20 @@ def validate_steps(steps, depth=1, path="steps", errors=None, warnings=None,
# 所以在这里拦住,别让 AI 草稿悄悄变成"永远不命中" # 所以在这里拦住,别让 AI 草稿悄悄变成"永远不命中"
cmp_op = (params.get("cmp_op") or "").strip() cmp_op = (params.get("cmp_op") or "").strip()
cmp_src = (params.get("cmp_source") 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}" 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: if cmp_op:
from tasks.generic.task import CMP_OPS from tasks.generic.task import CMP_OPS
if cmp_op not in CMP_OPS: if cmp_op not in CMP_OPS:
errors.append(f"{here}.params.cmp_op: 不支持的比对运算符 {cmp_op!r}" errors.append(f"{here}.params.cmp_op: 不支持的比对运算符 {cmp_op!r}"
f"(可用:{'、'.join(CMP_OPS)})") f"(可用:{'、'.join(CMP_OPS)})")
elif not str(params.get("cmp_value") or "").strip() \ 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: 选了文本比对" errors.append(f"{here}.params.cmp_value: 选了文本比对"
f"({cmp_op})就必须填比对的值,否则永远不命中") f"({cmp_op})就必须填比对的值,否则永远不命中")
+3 -1
View File
@@ -602,8 +602,10 @@ def build_release_steps():
build_expect_step("⑬ 校验:素材应已选中(看到「选择/预览」)", "xpath", build_expect_step("⑬ 校验:素材应已选中(看到「选择/预览」)", "xpath",
"//*[@content-desc='选择' or @content-desc='预览']", "素材已选中", timeout=10), "//*[@content-desc='选择' or @content-desc='预览']", "素材已选中", timeout=10),
clk("next", "⑭ 点击「下一步」"), clk("next", "⑭ 点击「下一步」"),
# 剪辑页要等**视频导入完成**("正在导入"弹窗挡住工具栏):真机实测(2026-10-12 A06)
# 一条 25 秒的视频导入要 25 秒+,10~12 秒必然判"没到剪辑页"→ 这一条白发。给到 45 秒。
build_expect_step("⑮ 校验:应在剪辑页(看到「文字/话题」工具)", "xpath", build_expect_step("⑮ 校验:应在剪辑页(看到「文字/话题」工具)", "xpath",
"//*[@content-desc='文字' or @content-desc='话题']", "剪辑页", timeout=12), "//*[@content-desc='文字' or @content-desc='话题']", "剪辑页", timeout=45),
clk("input", "⑯ 点击标题框(抖音发布页的标题框:hint「添加标题」)"), clk("input", "⑯ 点击标题框(抖音发布页的标题框:hint「添加标题」)"),
{"id": sid("title"), "type": "input_text", {"id": sid("title"), "type": "input_text",
"label": "⑰ 填标题(自动取这一条计划的标题,会回读校验)", "label": "⑰ 填标题(自动取这一条计划的标题,会回读校验)",
+1 -1
View File
@@ -521,7 +521,7 @@
| `POST /api/video_plan/<id>/resolve` | `{published, note}` | **`unknown` 的人工裁决**:确认真发出去了 → `done`;确认没发 → `failed`(可重试) | | `POST /api/video_plan/<id>/resolve` | `{published, note}` | **`unknown` 的人工裁决**:确认真发出去了 → `done`;确认没发 → `failed`(可重试) |
| `GET /api/video_plan/links` | `?from=&to=&name=&format=csv` | 已发布作品的分享链接;CSV 带 BOM(Excel 打开中文不乱码)。列:发布日期/**名字**/编号/标题/**正文**/分享链接/发布时间 | | `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` | | `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/<job_id>` | `PUT {name?, time?, target?, steps?, enabled?}` | **发布任务的就地编辑**(「发布计划」页里改,不用跳任务页)。只放开这几个字段,**没传的字段原样保留**;`steps` 整块替换但 `params` 里的通用参数(去重有效期、抢占…)**深合并保留**。校验:空名字/坏时间/`steps` 不是数组/什么都不传 → 400,未知任务 → 404。权限 `T`。底层仍是 `TaskManager.update_job()`(内存+库一起改) | | `GET/PUT /api/video_plan/tasks/<job_id>` | `PUT {name?, time?, target?, steps?, enabled?}` | **发布任务的就地编辑**(「发布计划」页里改,不用跳任务页)。只放开这几个字段,**没传的字段原样保留**;`steps` 整块替换但 `params` 里的通用参数(去重有效期、抢占…)**深合并保留**。校验:空名字/坏时间/`steps` 不是数组/什么都不传 → 400,未知任务 → 404。权限 `T`。底层仍是 `TaskManager.update_job()`(内存+库一起改) |
| `POST /api/video_plan/tasks/<job_id>/adopt` | — | 给**已有任务**插上平台那两步:`push_release` 放最前、`mark_release` 放最后(**中间你自己的步骤一步不动**;位置理由见 `core.video_plan.insert_release_steps`)。只对「通用步骤」任务;没有步骤 → 400,已经有这两步 → 409,未知任务 → 404。权限 `T` | | `POST /api/video_plan/tasks/<job_id>/adopt` | — | 给**已有任务**插上平台那两步:`push_release` 放最前、`mark_release` 放最后(**中间你自己的步骤一步不动**;位置理由见 `core.video_plan.insert_release_steps`)。只对「通用步骤」任务;没有步骤 → 400,已经有这两步 → 409,未知任务 → 404。权限 `T` |
+44 -3
View File
@@ -109,7 +109,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
--- ---
## 3. 28 种步骤全表 ## 3. 29 种步骤全表
> 参数与默认值以 `tasks/generic/task.py` 为准;前端 `STEP_LIB`(`static/admin/editor.js`)负责在编辑器里呈现这些字段。 > 参数与默认值以 `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)`;**值直传,无白名单校验** | | 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 静默不滑** | | 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"` 才是向下滑**,其余按上滑 | | 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 = 屏幕中心。无选择器时的兜底,**最脆的方式** | | 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` | | 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`) | | 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 |
| 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 | | 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 的事件订阅。两者都空则跳过 | | 19 | `notify` | 发通知 | — | `title`("")、`message`("")、`level`("info") | — | 推一条**自定义**通知(事件 `task.notify.custom`):标题正文自己写,支持 `{device} {serial} {job} {time} {app} {screen}`;谁收到取决于 webhook 的事件订阅。两者都空则跳过 |
| 20 | `stop_self` | 停止本设备 | — | `reason`("") | — | 只停**本设备**的任务(其它设备照跑):置 worker 停止位,后续步骤不再执行,任务记成**被停止而不是失败**(不触发重试) | | 20 | `stop_self` | 停止本设备 | — | `reason`("") | — | 只停**本设备**的任务(其它设备照跑):置 worker 停止位,后续步骤不再执行,任务记成**被停止而不是失败**(不触发重试) |
| 21 | `gesture` | 录制手势 | `points`(录出来的) | `speed`(1.0) | — | **纯录制回放**:把录下的轨迹点列 `[[x,y,t_ms],…]` 按原路径与时长交给设备插值,不做弧线/抖动/手速加工。与「滑动」是两套东西,见 §3.2 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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. 容器步骤与公共参数
### 4.1 `loop` 三种模式 ### 4.1 `loop` 三种模式
+2 -1
View File
@@ -18,6 +18,7 @@
test_foreach_account 循环本机待发的号(一轮跑完、试过不回头、干净退出) test_foreach_account 循环本机待发的号(一轮跑完、试过不回头、干净退出)
test_push_by_name 按名字找设备推送(matched/unmatched、缓存) test_push_by_name 按名字找设备推送(matched/unmatched、缓存)
test_expect_el 「校验页面」:特征在就过;不在就按 on_missing 处置(默认跳过本轮剩余步骤) test_expect_el 「校验页面」:特征在就过;不在就按 on_missing 处置(默认跳过本轮剩余步骤)
test_read_text 「读取文字」+ 选择器里的 {变量} +「条件判断→变量」
""" """
import os import os
import subprocess import subprocess
@@ -26,7 +27,7 @@ import sys
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
CASES = ["test_parse_name.py", "test_upload_name.py", "test_name_scope.py", 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"] "test_expect_el.py", "test_read_text.py"]
if hasattr(sys.stdout, "reconfigure"): if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+198
View File
@@ -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('✅ 全部通过(没连任何真机)')
+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:'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:'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:'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:'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:'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'}}, {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){ function _selRowHtml(p, extra, withOcr, withDedup, keys){
keys=keys||{}; keys=keys||{};
var kType=keys.type||'selector_type', kVal=keys.value||'selector_value'; 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 / 去重"三类非元素条件 // withOcr(条件判断用)额外提供"屏幕状态 / 前台App / 去重"三类非元素条件
var types=withOcr?typeList.concat(['ocr','screen','foreground']):typeList.slice(); var types=withOcr?typeList.concat(['ocr','screen','foreground']):typeList.slice();
if(withDedup)types.push('dedup'); 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识别', descriptionContains:'descriptionContains',className:'className',ocr:'OCR识别',
screen:'屏幕状态',foreground:'前台App是',dedup:'去重(这个身份做过了吗)'}; screen:'屏幕状态',foreground:'前台App是',dedup:'去重(这个身份做过了吗)'};
var opts=types.map(function(d){ 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>'; '<option value="on"'+(p[kVal]==='on'?' selected':'')+'>亮屏时就命中</option></select>';
}else{ }else{
valHtml='<input type="text" class="form-control" data-key="'+kVal+'" value="'+esc(p[kVal]||'')+ 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"':'')+'>'+ 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>'; '<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="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>'; '<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>'; 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'){ }else if(step.type==='expect_el'){
// 校验页面:只做校验、不带副作用 —— 确认"上一步真的完成了" // 校验页面:只做校验、不带副作用 —— 确认"上一步真的完成了"
var om=p.on_missing||'skip_rest'; var om=p.on_missing||'skip_rest';
@@ -511,7 +535,7 @@ var _stepEditor={
// 文本比对:屏幕状态没有文本可比(亮/熄)、去重也不比文本,只有元素/OCR/前台 App 才给这块 // 文本比对:屏幕状态没有文本可比(亮/熄)、去重也不比文本,只有元素/OCR/前台 App 才给这块
// 候选值来源:默认"手填"(行为与以前完全一致);也可以取"本次要发的名字"/发布计划 // 候选值来源:默认"手填"(行为与以前完全一致);也可以取"本次要发的名字"/发布计划
var cmpSrc=p.cmp_source||''; 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>'+ var cmpRow=dyn?'':'<div class="form-row"><div class="form-group"><label>文本比对</label>'+
'<select class="form-control" data-key="cmp_op">'+ '<select class="form-control" data-key="cmp_op">'+
[['','不比对(只看元素在不在)'],['等于','等于'],['不等于','不等于'],['包含','包含'],['不包含','不包含']] [['','不比对(只看元素在不在)'],['等于','等于'],['不等于','不等于'],['包含','包含'],['不包含','不包含']]
@@ -520,14 +544,23 @@ var _stepEditor={
'<div class="form-group"><label>候选值来源</label>'+ '<div class="form-group"><label>候选值来源</label>'+
'<select class="form-control" data-key="cmp_source" onchange="_stepEditor._cmpSourceChanged(this)">'+ '<select class="form-control" data-key="cmp_source" onchange="_stepEditor._cmpSourceChanged(this)">'+
[['','手填(默认)'],['account_target','本次要发的名字(读名字/循环块给的)'], [['','手填(默认)'],['account_target','本次要发的名字(读名字/循环块给的)'],
['release','本机当前发布计划(要发的那条)']] ['release','本机当前发布计划(要发的那条)'],
['var','变量(「读取文字」读到的)']]
.map(function(o){return '<option value="'+o[0]+'"'+((p.cmp_source||'')===o[0]?' selected':'')+'>'+o[1]+'</option>';}).join('')+ .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' '</select><div class="help">'+(p.cmp_source==='release'
? '取<b>本机下一条要发的发布计划</b>的<b>名字</b>(推送前校验"当前登的是不是要发的号"就用它)' ? '取<b>本机下一条要发的发布计划</b>的<b>名字</b>(推送前校验"当前登的是不是要发的号"就用它)'
: (p.cmp_source==='account_target' : (p.cmp_source==='account_target'
? '取<b>本次要发的那个名字</b>(「读取本机账号名字」读到的,或循环块设的目标)。' ? '取<b>本次要发的那个名字</b>(「读取本机账号名字」读到的,或循环块设的目标)。'
+'<b>读不到名字时候选为空 → 不命中 → 走 else(不发)</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 存在与否收集的, // 候选值来自平台时**只隐藏、不删**这个输入框:_syncParams 是按 DOM 存在与否收集的,
// 删了就等于把用户手填的值悄悄抹掉(同类坑见本文件 927 行附近的注释) // 删了就等于把用户手填的值悄悄抹掉(同类坑见本文件 927 行附近的注释)
'<div class="form-group" style="grid-column:span 2'+(fromPlatform?';display:none':'')+'">'+ '<div class="form-group" style="grid-column:span 2'+(fromPlatform?';display:none':'')+'">'+
@@ -1008,7 +1041,7 @@ var _stepEditor={
warnings.push(name+' "'+label+'": 条件判断的选择器为空,会直接走"未找到"分支。'); warnings.push(name+' "'+label+'": 条件判断的选择器为空,会直接走"未找到"分支。');
} }
var cmpSrcV=(s.params.cmp_source||''); 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; var fromOther=fromPlatform;
if(cmpUsed&&s.params.cmp_op&&!String(s.params.cmp_value||'').trim()&&!fromOther){ 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){ if(cmpUsed&&cmpSrcV==='release'&&s.params.cmp_op){
warnings.push(name+' "'+label+'": 候选值来自"本机当前发布计划" —— 本机没有待发的计划' warnings.push(name+' "'+label+'": 候选值来自"本机当前发布计划" —— 本机没有待发的计划'
+'时取不到号(这一步会走"未找到"分支)。这是设计如此(宁可不发、也不发错号),' +'时取不到号(这一步会走"未找到"分支)。这是设计如此(宁可不发、也不发错号),'
+14 -3
View File
@@ -708,7 +708,8 @@ function rpNewTargetChanged() {
// 保存口径:整块 steps 交给 `PUT /api/video_plan/tasks/<id>`,后端深合并进 params // 保存口径:整块 steps 交给 `PUT /api/video_plan/tasks/<id>`,后端深合并进 params
// (不能整块覆盖 params —— 去重有效期、抢占那些通用参数会被抹掉)。 // (不能整块覆盖 params —— 去重有效期、抢占那些通用参数会被抹掉)。
var _rpEdit = null; // 正在编辑的任务(steps 是深拷贝,取消就丢弃) 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 = { var _RP_FIELDS = {
@@ -740,6 +741,13 @@ var _RP_FIELDS = {
['timeout', '最多等(秒)', 'num'], ['timeout', '最多等(秒)', 'num'],
['on_missing', '等不到时', 'sel', [['skip_rest', '跳过本轮剩余步骤(推荐)'], ['on_missing', '等不到时', 'sel', [['skip_rest', '跳过本轮剩余步骤(推荐)'],
['notify', '只通知、继续'], ['stop', '停本设备']]]], ['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'], read_account_name: [['selector_type', '选择器类型', 'seltype'],
['selector_value', '选择器值(抖音「我」页的昵称)', 'selval'], ['selector_value', '选择器值(抖音「我」页的昵称)', 'selval'],
['timeout', '超时(秒)', 'num'], ['timeout', '超时(秒)', 'num'],
@@ -776,7 +784,9 @@ var _RP_FIELDS = {
['不等于', '不等于'], ['包含', '包含'], ['不包含', '不包含']]], ['不等于', '不等于'], ['包含', '包含'], ['不包含', '不包含']]],
['cmp_source', '候选值来源', 'sel', [['', '手填'], ['cmp_source', '候选值来源', 'sel', [['', '手填'],
['account_target', '本次要发的名字(读名字/循环块给的)'], ['account_target', '本次要发的名字(读名字/循环块给的)'],
['release', '本机当前发布计划(要发的那条)']]], ['release', '本机当前发布计划(要发的那条)'],
['var', '变量(「读取文字」读到的)']]],
['cmp_var', '变量名(上面选"变量"时填)', 'text'],
['cmp_value', '比对的值(一行一个,任一命中即可)', 'area']], ['cmp_value', '比对的值(一行一个,任一命中即可)', 'area']],
mark_done: [['selector_type', '选择器类型', 'seltype'], ['selector_value', '选择器值', 'selval']], mark_done: [['selector_type', '选择器类型', 'seltype'], ['selector_value', '选择器值', 'selval']],
clipboard: [['text', '要放进剪贴板的文字', 'text']], clipboard: [['text', '要放进剪贴板的文字', 'text']],
@@ -958,8 +968,9 @@ function _rpField(type, p, f, path) {
var pick = (kind === 'selval' && typeof _can === 'function' && _can('devices')) var pick = (kind === 'selval' && typeof _can === 'function' && _can('devices'))
? '<button class="btn btn-xs btn-pick" style="margin-left:4px" onclick="_stepEditor.openPicker(this)">抓取元素</button>' ? '<button class="btn btn-xs btn-pick" style="margin-left:4px" onclick="_stepEditor.openPicker(this)">抓取元素</button>'
: ''; : '';
var ph = (kind === 'selval') ? ' placeholder="如 点赞;可用 {本次要发的名字}"' : '';
return '<div class="form-group"' + span + ' style="margin-bottom:0">' + head return '<div class="form-group"' + span + ' style="margin-bottom:0">' + head
+ '<div style="display:flex"><input type="text" class="form-control" ' + attrs + ' value="' + '<div style="display:flex"><input type="text" class="form-control"' + ph + ' ' + attrs + ' value="'
+ esc(v == null ? '' : v) + '">' + pick + '</div></div>'; + esc(v == null ? '' : v) + '">' + pick + '</div></div>';
} }
+170 -15
View File
@@ -136,6 +136,9 @@ STEP_TYPES = [
{"type": "read_account_name", "label": "读取本机账号名字", "icon": "🏷", {"type": "read_account_name", "label": "读取本机账号名字", "icon": "🏷",
"params": {"selector_type": "xpath", "selector_value": "", "params": {"selector_type": "xpath", "selector_value": "",
"timeout": 8, "on_missing": "fail"}}, "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": "🔁", {"type": "switch_account", "label": "切换账号", "icon": "🔁",
"params": {"target": "var:target_name", "target_value": "", "params": {"target": "var:target_name", "target_value": "",
"switch_selector_type": "xpath", "switch_selector_value": "", "switch_selector_type": "xpath", "switch_selector_value": "",
@@ -189,6 +192,25 @@ _CMP_ALIASES = {
# AI 草稿校验(core/task_draft.py)用它拦"运算符拼错 -> 永远不命中"。 # AI 草稿校验(core/task_draft.py)用它拦"运算符拼错 -> 永远不命中"。
CMP_OPS = tuple(_CMP_ALIASES) 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): def _canon_op(op):
"""运算符规范化成中文那个(认别名);认不出返回 None。""" """运算符规范化成中文那个(认别名);认不出返回 None。"""
@@ -602,7 +624,7 @@ class GenericStepsWorker(BaseWorker):
返回 True=找到, False=未找到, None=配置缺选择器。 返回 True=找到, False=未找到, None=配置缺选择器。
""" """
sel_type = params.get("selector_type", "xpath") 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") direction = params.get("direction", "up")
max_swipes = max(1, int(params.get("max_swipes", 8))) max_swipes = max(1, int(params.get("max_swipes", 8)))
click_when_found = params.get("click_when_found", True) 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): def _exec_long_click(self, d, params, depth=0):
"""长按元素(选择器 + 时长秒)。复制链接/呼出菜单用。""" """长按元素(选择器 + 时长秒)。复制链接/呼出菜单用。"""
sel_type = params.get("selector_type", "xpath") 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)) duration = float(params.get("duration", 1.0))
timeout = float(params.get("wait_timeout", 2)) timeout = float(params.get("wait_timeout", 2))
if not sel_val: if not sel_val:
@@ -689,7 +711,7 @@ class GenericStepsWorker(BaseWorker):
def _exec_wait_el(self, d, params, depth=0): def _exec_wait_el(self, d, params, depth=0):
"""等待元素出现(条件等待)。返回 True=出现, False=超时, None=缺选择器。""" """等待元素出现(条件等待)。返回 True=出现, False=超时, None=缺选择器。"""
sel_type = params.get("selector_type", "xpath") 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)) timeout = float(params.get("timeout", 10))
if not sel_val: if not sel_val:
_log.warning("wait_el 缺少选择器") _log.warning("wait_el 缺少选择器")
@@ -729,7 +751,7 @@ class GenericStepsWorker(BaseWorker):
返回 True=命中 / False=未命中 / None=缺选择器或 OCR 不可用。 返回 True=命中 / False=未命中 / None=缺选择器或 OCR 不可用。
""" """
sel_type = params.get("selector_type", "xpath") 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)) timeout = float(params.get("timeout", 3))
cmp_op = str(params.get("cmp_op") or "").strip() cmp_op = str(params.get("cmp_op") or "").strip()
cmp_val = str(params.get("cmp_value") or "") cmp_val = str(params.get("cmp_value") or "")
@@ -873,8 +895,19 @@ class GenericStepsWorker(BaseWorker):
去重静默失效(见 core/dedup.py 的 build_key)。 去重静默失效(见 core/dedup.py 的 build_key)。
""" """
source = str(params.get("cmp_source") or "").strip() 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() ids, note = self._release_candidates()
elif source == "account_target": elif source == "account_target":
@@ -896,7 +929,9 @@ class GenericStepsWorker(BaseWorker):
f"[{self.serial}] 条件判断取号为空(来源 {source}):{note};手填候选 " f"[{self.serial}] 条件判断取号为空(来源 {source}):{note};手填候选 "
f"{len(manual)} 个 → " f"{len(manual)} 个 → "
+ ("仅用手填值" if manual else "候选为空,这一步将永远不命中(走未找到分支)")) + ("仅用手填值" 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 src_txt = (f"候选来自{where}:{'、'.join(ids)}" if ids
else f"{where}取不到:{note}") else f"{where}取不到:{note}")
if manual: if manual:
@@ -1010,7 +1045,7 @@ class GenericStepsWorker(BaseWorker):
返回 True=看到了;False=没看到(已按 `on_missing` 处置)。 返回 True=看到了;False=没看到(已按 `on_missing` 处置)。
""" """
sel_type = (params.get("selector_type") or "xpath").strip() 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] what = (params.get("what") or "").strip() or sel_val[:40]
timeout = float(params.get("timeout") or 10) timeout = float(params.get("timeout") or 10)
on_missing = (params.get("on_missing") or "skip_rest").strip().lower() 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 from core import video_plan as vp
sel_type = (params.get("selector_type") or "xpath").strip() 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) timeout = float(params.get("timeout") or 8)
soft = (params.get("on_missing") or "fail").strip().lower() == "skip" soft = (params.get("on_missing") or "fail").strip().lower() == "skip"
if not sel_val: if not sel_val:
@@ -1113,6 +1148,62 @@ class GenericStepsWorker(BaseWorker):
self.set_action(f"本机账号:{name}") self.set_action(f"本机账号:{name}")
return True 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 = ("验证码", "其他方式登录", "手机号登录", "一键登录", "密码登录", _LOGIN_HINTS = ("验证码", "其他方式登录", "手机号登录", "一键登录", "密码登录",
@@ -1128,18 +1219,74 @@ class GenericStepsWorker(BaseWorker):
except Exception as e: except Exception as e:
_log.warning(f"账号通知发送失败(不影响任务):{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): def _read_name_now(self, d, params):
"""现在屏幕上读一次昵称(清洗后)。选择器:步骤参数优先,其次「读取本机账号名字」 """现在屏幕上读一次昵称(清洗后)。选择器:步骤参数优先,其次「读取本机账号名字」
记下的那个;都没有 → 返回 ""(= 读不到,调用方按"没有号"处理)。""" 记下的那个;都没有 → 返回 ""(= 读不到,调用方按"没有号"处理)。"""
from core import video_plan as vp from core import video_plan as vp
sel_type = (params.get("name_selector_type") or "").strip() 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: if not sel_val:
sel_type, sel_val = self._vars.get("name_selector") or ("", "") sel_type, sel_val = self._vars.get("name_selector") or ("", "")
if not sel_val: if not sel_val:
return "" return ""
return vp.clean_account_name(self._read_element_text(d, sel_type or "xpath", sel_val)) 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): def _click_sel(self, d, sel_type, sel_val, timeout=8):
"""按选择器等元素出现并点击。找不到返回 False(不抛异常)。""" """按选择器等元素出现并点击。找不到返回 False(不抛异常)。"""
if not sel_val: if not sel_val:
@@ -1272,7 +1419,7 @@ class GenericStepsWorker(BaseWorker):
_log.info(f"[{self.serial}] 切换账号:当前就是『{target}』,不用切") _log.info(f"[{self.serial}] 切换账号:当前就是『{target}』,不用切")
self.set_action(f"已经是『{target}』,不用切") self.set_action(f"已经是『{target}』,不用切")
return True 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: if not switch_val:
msg = ("「进账号切换列表」的选择器是空的 —— 用「抓取元素」抓一下抖音里那个入口" msg = ("「进账号切换列表」的选择器是空的 —— 用「抓取元素」抓一下抖音里那个入口"
"(「我」页面 → 切换账号),填到这一步上") "(「我」页面 → 切换账号),填到这一步上")
@@ -1280,12 +1427,12 @@ class GenericStepsWorker(BaseWorker):
self.set_action(msg) self.set_action(msg)
return False return False
me_type = params.get("me_selector_type") or "descriptionContains" 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 = "" last_got = ""
for i in range(1, attempts + 1): for i in range(1, attempts + 1):
if self.stopped(): if self.stopped():
return False 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) time.sleep(1.0)
if not self._click_sel(d, params.get("switch_selector_type") or "xpath", if not self._click_sel(d, params.get("switch_selector_type") or "xpath",
switch_val, timeout): switch_val, timeout):
@@ -1346,10 +1493,18 @@ class GenericStepsWorker(BaseWorker):
time.sleep(0.5) time.sleep(0.5)
# 等完再回读;**不再点任何东西**,只多读几次(页面渲染有零点几秒的抖动) # 等完再回读;**不再点任何东西**,只多读几次(页面渲染有零点几秒的抖动)
last_got = "" last_got = ""
for _ in range(5): for k in range(5):
if self.stopped(): if self.stopped():
return False return False
last_got = self._read_name_now(d, params) 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: if last_got == target:
_log.info(f"[{self.serial}] 切换账号成功:『{target}』(第 {i} 次," _log.info(f"[{self.serial}] 切换账号成功:『{target}』(第 {i} 次,"
f"等了 {wait_after:.0f}s)") f"等了 {wait_after:.0f}s)")
@@ -1722,7 +1877,7 @@ class GenericStepsWorker(BaseWorker):
def _exec_click(self, d, params, depth=0): def _exec_click(self, d, params, depth=0):
sel_type = params.get("selector_type", "xpath") 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)) timeout = float(params.get("wait_timeout", 2))
if not sel_val: if not sel_val:
_log.warning(f"[{self.serial}] click 跳过:selector_value 为空,请在步骤编辑器中填写或抓取元素") _log.warning(f"[{self.serial}] click 跳过:selector_value 为空,请在步骤编辑器中填写或抓取元素")