diff --git a/.env.example b/.env.example index c0a2e27..f81634b 100644 --- a/.env.example +++ b/.env.example @@ -122,13 +122,19 @@ TAILSCALE_API_KEY=请填写Tailscale_API_key # 一处配置、三处一致:任务骨架的「读取本机账号名字」步骤默认值、切号后的回读校验、 # 以及「发布计划 → 一键推送(按名字找设备)」的实时探测。 # 留空 = 还没抓:相关步骤会明确报错并跳过,**绝不会猜一个名字去发**。 -# DOUYIN_NAME_SELECTOR_TYPE=xpath -# DOUYIN_NAME_SELECTOR_VALUE= +# +# 2026-10-12 在 A01(抖音 40.6.0)实测可用的值:昵称那个 TextView 的 resource-id 是混淆 id `tk+`, +# 所以填 resourceId(等价 xpath://*[@resource-id="com.ss.android.ugc.aweme:id/tk+"])。 +# DOUYIN_NAME_SELECTOR_TYPE=resourceId +# DOUYIN_NAME_SELECTOR_VALUE=com.ss.android.ugc.aweme:id/tk+ # 「我」页面 →「切换账号」列表那个入口(自动切号用,同样抓一次填这里)。 +# 它是个只有 content-desc 的 ImageView(desc 就是「切换账号」),所以用 descriptionContains。 # 留空 = 「切换账号」步骤会明确报错并跳过(不会瞎点)。 -# DOUYIN_SWITCH_SELECTOR_TYPE=xpath -# DOUYIN_SWITCH_SELECTOR_VALUE= +# DOUYIN_SWITCH_SELECTOR_TYPE=descriptionContains +# DOUYIN_SWITCH_SELECTOR_VALUE=切换账号 +# +# 细节与结构图见 doc/research/U2_ELEMENT_SELECTORS.md §七(含"为什么不要用 (…b7w)[N]")。 # ============================================================================== diff --git a/core/video_plan.py b/core/video_plan.py index b30388e..803ab90 100644 --- a/core/video_plan.py +++ b/core/video_plan.py @@ -545,10 +545,11 @@ def build_release_steps(): 因为那些是混淆 id / 结构 xpath,写死了改版就废 —— 执行时会明确提示 "selector_value 为空,请抓取元素",不会静默跳过。 - 预填了选择器的只有底部「+」入口(`descriptionContains=拍摄`)与 - 「我」(`descriptionContains=我`),因为这两条在真机上实测过、 - 且是按描述定位、跨版本相对稳。**「我」页面上的昵称控件留空待抓** - (`read_account_name` 与守卫都用它,抓一次两处都填)。 + 预填了选择器的:底部「+」入口(`descriptionContains=拍摄`)、 + 「我」(`descriptionContains=我`)、以及**昵称控件与「切换账号」入口**(后者来自 `.env`, + `config.DOUYIN_NAME_SELECTOR_VALUE` / `DOUYIN_SWITCH_SELECTOR_VALUE`)—— + 这些都在真机上实测过(40.6.0,见 doc/research/U2_ELEMENT_SELECTORS.md §七)。 + `.env` 没填时骨架里那两处就是空的,执行时会明确提示"请抓取元素",不会静默跳过。 """ def sid(name): return f"step_rel_{name}_{uuid.uuid4().hex[:6]}" @@ -588,7 +589,7 @@ def build_release_steps(): # 也会处理"本机没登这个号"(直接跳过、不重试)。 loop_body = [ dict(build_switch_step("③ 切换账号(切到本次要发的那个名字;已在该号则直接过)")), - dict(build_read_name_step("④ 读取本机当前账号名字(昵称控件选择器待抓)")), + dict(build_read_name_step("④ 读取本机当前账号名字")), {"id": sid("check"), "type": "if_el", "label": "⑤ 校验账号:当前登的是不是这次要发的那个名字", "params": {"selector_type": "xpath", diff --git a/doc/API.md b/doc/API.md index 3302847..ac9493c 100644 --- a/doc/API.md +++ b/doc/API.md @@ -521,7 +521,7 @@ | `POST /api/video_plan//resolve` | `{published, note}` | **`unknown` 的人工裁决**:确认真发出去了 → `done`;确认没发 → `failed`(可重试) | | `GET /api/video_plan/links` | `?from=&to=&name=&format=csv` | 已发布作品的分享链接;CSV 带 BOM(Excel 打开中文不乱码)。列:发布日期/**名字**/编号/标题/**正文**/分享链接/发布时间 | | `GET /api/video_plan/tasks` | — | `{ok, tasks:[{id,name,enabled,mode,cron,next_run,target,steps,step_count,schedule,retry,is_release,has_push}], release_count, other_count}`:**全部通用步骤任务**(不止发布任务 —— 只列发布任务的话,手写的抖音发布流程会一条都不显示,人会以为"这页没有能改的地方"),界面按 `is_release`(有 `push_release` **或** `mark_release`,含嵌套)分两组。带完整 `steps` 是为**就地编辑**(抓选择器);`step_count` 数的是**含嵌套**的总步数。权限 `T` | -| `POST /api/video_plan/tasks` | `{"name","target":{"mode":"all\|group\|serial","group_name","serial"},"time":"10:00","enabled"}` | 一键新建:骨架 **18 步**(亮屏 → 打开抖音(等首页) → 点「我」→ `read_account_name` 读本机账号名字(**昵称选择器待抓**)→ `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"}` | 一键新建:骨架 **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` | | `GET/PUT /api/video_plan/tasks/` | `PUT {name?, time?, target?, steps?, enabled?}` | **发布任务的就地编辑**(「发布计划」页里改,不用跳任务页)。只放开这几个字段,**没传的字段原样保留**;`steps` 整块替换但 `params` 里的通用参数(去重有效期、抢占…)**深合并保留**。校验:空名字/坏时间/`steps` 不是数组/什么都不传 → 400,未知任务 → 404。权限 `T`。底层仍是 `TaskManager.update_job()`(内存+库一起改) | | `POST /api/video_plan/tasks//adopt` | — | 给**已有任务**插上平台那两步:`push_release` 放最前、`mark_release` 放最后(**中间你自己的步骤一步不动**;位置理由见 `core.video_plan.insert_release_steps`)。只对「通用步骤」任务;没有步骤 → 400,已经有这两步 → 409,未知任务 → 404。权限 `T` | diff --git a/doc/TASK_DEV.md b/doc/TASK_DEV.md index 7004a55..4de25b9 100644 --- a/doc/TASK_DEV.md +++ b/doc/TASK_DEV.md @@ -140,8 +140,8 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) | 22 | `mark_done` | 记为已做 | —(身份元素可选填 `selector_type`/`selector_value`) | — | — | 把身份值记进**去重账本**(跨设备共享)。放动作**成功之后**——失败不记账,下次重跑会重试。**留空 = 自动跟随上面「去重」检查的身份(推荐)**;没有前置检查时才退回设备 `serial`。见 §4.6 | | 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`(昵称控件,**待抓**) | `selector_type`(xpath)、`timeout`(8)、`on_missing`(fail) | — | 读抖音「我」页面上的**昵称** → 清洗后存进运行期变量(`account_name`),并记下用的选择器供 `push_release`/`switch_account` 核对。**读不到 = 本机没有号**:条件判断走 `else`、推送跳过(都不发)。`on_missing=fail`(默认) 让这一步直接失败、`skip` 只记一句继续。见 §4.7 | -| 26 | `switch_account` | 切换账号 | `switch_selector_value`(进「账号切换」列表那一下,**待抓**) | `target`(`var:target_name`)、`target_value`、`name_selector_*`、`me_selector_*`(descriptionContains=我)、`timeout`(8)、`verify`(True)、`max_attempts`(2)、`on_login_page`(stop) | — | **只在本机已登录过的号之间切**:当前已是目标→直接成功;否则点「我」→ 点切换入口 → **在列表里按名字找控件点击**(**禁 `[N]` 序号**:序号型会随界面变形错位)→ 等重载 → **回读校验**。列表里没这个名字 = 本机没登这个号 → 立刻 False(不重试、不算故障)。停在登录/验证码页 → 通知 + 默认停本设备(平台不做登录/滑块)。见 §4.7 | +| 25 | `read_account_name` | 读取本机账号名字 | `selector_value`(昵称控件;值填在 `.env` 的 `DOUYIN_NAME_SELECTOR_VALUE`,40.6.0 实测值见 [research/U2_ELEMENT_SELECTORS.md](research/U2_ELEMENT_SELECTORS.md) §七) | `selector_type`(xpath)、`timeout`(8)、`on_missing`(fail) | — | 读抖音「我」页面上的**昵称** → 清洗后存进运行期变量(`account_name`),并记下用的选择器供 `push_release`/`switch_account` 核对。**读不到 = 本机没有号**:条件判断走 `else`、推送跳过(都不发)。`on_missing=fail`(默认) 让这一步直接失败、`skip` 只记一句继续。见 §4.7 | +| 26 | `switch_account` | 切换账号 | `switch_selector_value`(进「账号切换」列表那一下;填 `.env` 的 `DOUYIN_SWITCH_SELECTOR_VALUE`,实测值同上) | `target`(`var:target_name`)、`target_value`、`name_selector_*`、`me_selector_*`(descriptionContains=我)、`timeout`(8)、`verify`(True)、`max_attempts`(2)、`on_login_page`(stop) | — | **只在本机已登录过的号之间切**:当前已是目标→直接成功;否则点「我」→ 点切换入口 → **在列表里按名字找控件点击**(**禁 `[N]` 序号**:序号型会随界面变形错位)→ 等重载 → **回读校验**。列表里没这个名字 = 本机没登这个号 → 立刻 False(不重试、不算故障)。停在登录/验证码页 → 通知 + 默认停本设备(平台不做登录/滑块)。见 §4.7 | | 27 | `foreach_account` | 循环本机待发的号 | — | `date_source`(today/fixed)、`fixed_date`、`retry_failed`(True)、`max_accounts`(0=不限) | `children` | **一轮把本机待发的号跑完**:数据源 = 计划表里还有待发内容的名字(`pending_names`,**不需要账号台账**),逐个写进运行期变量 `target_name` 后跑 children;试过的名字不回头(防死循环);内层"这个名字还有待发就一直发",跑一轮没进展(切号失败/守卫没过)就换下一个。三个干净出口:停止 / 任务时长上限 / `max_accounts`。见 §4.7 | ### 3.2 录制手势:**纯录制、纯回放**(`core/gesture.py`) @@ -478,7 +478,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) **先读名字、再校验、最后才发**(一台手机登好几个号时必配): -- 「读取本机账号名字」:抓抖音「我」页面上的**昵称控件**(选择器待抓,抓一次即可), +- 「读取本机账号名字」:读抖音「我」页面上的**昵称控件**(选择器抓一次填进 `.env` 的 `DOUYIN_NAME_SELECTOR_VALUE`), 清洗后的名字进运行期变量(见 §3 第 25 行)。**读不到 = 本机没有号**。 - 条件判断用 `cmp_source=account_target`(候选值 = 本次要发的名字,见 §4.2)+ `包含`; 没命中就走 `else` —— **宁可不发,也不发错号**。 diff --git a/doc/research/U2_ELEMENT_SELECTORS.md b/doc/research/U2_ELEMENT_SELECTORS.md index 49918e5..9a6936b 100644 --- a/doc/research/U2_ELEMENT_SELECTORS.md +++ b/doc/research/U2_ELEMENT_SELECTORS.md @@ -172,3 +172,61 @@ `core/uiauto_helper.py` 里 `_child_path()` 的注释与 `doc/TASK_DEV.md` §5.4 都记了这两个坑, 避免以后又写回来。 + +--- + +## 七、抖音「我」页面 + 账号切换列表的选择器(2026-10-12,A01 实测) + +**背景**:发布计划改成按**名字**发(`doc/TASK_DEV.md` §4.7),平台需要两处控件: +① 「我」页面上的**昵称**(读当前登的是谁);② 「我」页面右上角的**切换账号**入口。 +抖音 40.6.0 / 720×1650 实测(A01 = `192.168.20.82:5555`)。 + +### 用哪个(已实测可用) + +| 用途 | 选择器 | 说明 | +|---|---|---| +| **读昵称** | `resourceId = com.ss.android.ugc.aweme:id/tk+` | 读回来就是昵称原文(如 `周周苏州吃喝玩乐`)。等价 xpath:`//*[@resource-id="com.ss.android.ugc.aweme:id/tk+"]` | +| **进切换列表** | `descriptionContains = 切换账号` | 右上角那个按钮是个 ImageView,**只有 content-desc**(`切换账号`),没有文字 | +| **切到某个号** | `textContains = <名字>` 点它 | 列表每行的名字是 `TextView`(id `tv_nickname`),**本身 `clickable=false`** —— 但 u2 的 `click()` 是**按中心坐标点屏**,行容器(无 id 的 `ViewGroup`,`clickable=true`)会接住这次点击,实测能切成 | +| **底部「我」页签** | `descriptionContains = 我` | 页签的 content-desc 是 `我,按钮`(**全角逗号**),`包含` 就能命中;别用 `text=我`(会撞上「我的订单」等) | + +### 结构(40.6.0) + +``` +「我」页面 +└ LinearLayout uj+ 资料块 + ├ ukd → s=g → TextView tk+ = 昵称 ← 读名字就用它 + │ └ j2f → 1gp → ImageView oxp(desc=切换账号) ← 点它进列表 + └ ukc → l1= → j6c → g7j = 抖音号:xxx + +切换账号列表(点 oxp 后弹出的面板) +└ View(desc=返回,铺满面板,可点) ← "点面板外关掉"那层 + ├ ViewGroup(无 id,clickable)894~1060 ← 第 1 行(当前号) + │ ├ ImageView iv_avatar / TextView tv_nickname / TextView 37_(粉丝数)/ CheckBox b7w + ├ ViewGroup 1060~1228 ← 第 2 行 + ├ ViewGroup 1228~1396 ← 第 3 行 + └ ViewGroup 1396~1556(添加或注册新账号) +``` + +### 为什么**不要**用 `(…b7w)[N]` + +`b7w` 是每行那个单选框(CheckBox)的 id —— `(…b7w)[2]` 看着能选中第 2 行, +但这正是本文 §二说的**序号型**:`[N]` 依赖"列表里有几行、面板从哪一行开始",号一多/一少、 +或者抖音把「添加或注册新账号」挪个位置就点到别人号上了(**把内容发到别人号上**,最严重的一类错)。 +按名字点(`textContains=<名字>`)永远指着那一行本身。 + +### 实测记录 + +- 冷启动(`app_stop` → `app_start`)后切号:**17.2s** 成功;热态切号 12.9s +- ⚠ **第一次实现踩的坑**:把"进列表"后的等待写成**固定 sleep(1.2s) 再找一次**, + 冷启动时列表还没渲染出来 → 判成"本机没登这个号" → **漏发**(还不会报错,只是不发)。 + 现在两处都改成**轮询到 deadline**(`max(6s, timeout)`):等面板出目标行、等回读变成目标名。 + 教训同 §一:**"找不到"和"没加载出来"必须用等待区分开**。 + +### 抓取命令(下次 App 改版后重抓) + +```bash +# 前台确保是抖音且在「我」页,然后 +.venv/Scripts/python -c "import uiautomator2 as u2; d=u2.connect(''); open('me.xml','w',encoding='utf-8').write(d.dump_hierarchy())" +# 在 me.xml 里找 text 是昵称、或 content-desc 是「切换账号」的节点,看它的 resource-id +``` diff --git a/tasks/generic/task.py b/tasks/generic/task.py index 033ea8d..906ac40 100644 --- a/tasks/generic/task.py +++ b/tasks/generic/task.py @@ -1177,30 +1177,44 @@ class GenericStepsWorker(BaseWorker): return self._handle_login_page(target, hint, params) _log.warning(f"[{self.serial}] 切换账号:进不去账号列表(第 {i}/{attempts} 次)") continue - time.sleep(1.2) - if not self._click_in_list(d, target, timeout=int(max(3, timeout))): - # 可能是列表还没加载出来 —— 再等一次;还没有就是"本机没登这个号" - time.sleep(1.5) - if not self._click_in_list(d, target, timeout=3): - hint = self._page_has_text(d, self._LOGIN_HINTS) - if hint: - return self._handle_login_page(target, hint, params) - _log.warning(f"[{self.serial}] 切换账号:账号列表里没有『{target}』" - f"→ 本机没登这个号,跳过(不重试)") - self.set_action(f"本机没有『{target}』这个号 → 跳过") - self._notify_account("task.account.switch_failed", want=target, - msg="账号列表里没有这个名字(本机没登这个号)") + # 等面板真的打开:**轮询找目标那一行,出现就点**。 + # 固定 sleep 不够用(实测:冷启动/慢设备上点了切换入口后列表还没渲染出来, + # 一次找不到就误判成"本机没登这个号" —— 那是会漏发的假故障)。 + hit = False + deadline = time.time() + max(6.0, timeout) + while time.time() < deadline: + if self.stopped(): return False - time.sleep(2.5) # 等切换生效(抖音会重载「我」页面) + if self._click_in_list(d, target, timeout=1): + hit = True + break + time.sleep(0.8) + if not hit: + hint = self._page_has_text(d, self._LOGIN_HINTS) + if hint: + return self._handle_login_page(target, hint, params) + _log.warning(f"[{self.serial}] 切换账号:账号列表里没有『{target}』" + f"(等了 {int(max(6.0, timeout))}s)→ 本机没登这个号,跳过(不重试)") + self.set_action(f"本机没有『{target}』这个号 → 跳过") + self._notify_account("task.account.switch_failed", want=target, + msg="账号列表里没有这个名字(本机没登这个号)") + return False if not want_verify: self.set_action(f"已点击『{target}』(未校验)") return True - last_got = self._read_name_now(d, params) - if last_got == target: - _log.info(f"[{self.serial}] 切换账号成功:『{target}』(第 {i} 次)") - self.set_action(f"已切到『{target}』") - self._vars["account_name"] = target - return True + # 等切换生效:抖音会重载「我」页面,几秒内名字才会变 —— 同样轮询,别只读一次 + last_got = "" + vdeadline = time.time() + max(6.0, timeout) + while time.time() < vdeadline: + if self.stopped(): + return False + last_got = self._read_name_now(d, params) + if last_got == target: + _log.info(f"[{self.serial}] 切换账号成功:『{target}』(第 {i} 次)") + self.set_action(f"已切到『{target}』") + self._vars["account_name"] = target + return True + time.sleep(0.8) hint = self._page_has_text(d, self._LOGIN_HINTS) if not last_got else "" if hint: return self._handle_login_page(target, hint, params)