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