@11agents/cli 0.1.24 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +52 -0
  2. package/bin/11agents.js +12 -0
  3. package/mobile-runtime/README.md +19 -0
  4. package/mobile-runtime/configs/platforms/xiaohongshu_d01.json +73 -0
  5. package/mobile-runtime/configs/platforms/xiaohongshu_d02.json +70 -0
  6. package/mobile-runtime/configs/platforms/xiaohongshu_d03.json +73 -0
  7. package/mobile-runtime/configs/publish_policy.json +40 -0
  8. package/mobile-runtime/data-templates/README.md +4 -0
  9. package/mobile-runtime/data-templates/accounts.example.csv +6 -0
  10. package/mobile-runtime/data-templates/devices.example.csv +2 -0
  11. package/mobile-runtime/data-templates/publish_records.example.jsonl +2 -0
  12. package/mobile-runtime/data-templates/tasks.example.jsonl +5 -0
  13. package/mobile-runtime/data-templates/video_metrics.example.jsonl +1 -0
  14. package/mobile-runtime/python/pyproject.toml +34 -0
  15. package/mobile-runtime/python/src/device_control/__init__.py +5 -0
  16. package/mobile-runtime/python/src/device_control/adapters/__init__.py +31 -0
  17. package/mobile-runtime/python/src/device_control/adapters/base.py +43 -0
  18. package/mobile-runtime/python/src/device_control/adapters/facebook.py +30 -0
  19. package/mobile-runtime/python/src/device_control/adapters/instagram.py +25 -0
  20. package/mobile-runtime/python/src/device_control/adapters/reddit.py +29 -0
  21. package/mobile-runtime/python/src/device_control/adapters/tiktok.py +25 -0
  22. package/mobile-runtime/python/src/device_control/adapters/x.py +29 -0
  23. package/mobile-runtime/python/src/device_control/adapters/xiaohongshu.py +26 -0
  24. package/mobile-runtime/python/src/device_control/adb.py +161 -0
  25. package/mobile-runtime/python/src/device_control/appium_client.py +131 -0
  26. package/mobile-runtime/python/src/device_control/appium_manager.py +403 -0
  27. package/mobile-runtime/python/src/device_control/cli.py +1608 -0
  28. package/mobile-runtime/python/src/device_control/entrypoints.py +60 -0
  29. package/mobile-runtime/python/src/device_control/locks.py +162 -0
  30. package/mobile-runtime/python/src/device_control/metrics/__init__.py +33 -0
  31. package/mobile-runtime/python/src/device_control/metrics/collectors.py +320 -0
  32. package/mobile-runtime/python/src/device_control/metrics/tiktok_account_adb.py +367 -0
  33. package/mobile-runtime/python/src/device_control/metrics/tiktok_video_adb.py +714 -0
  34. package/mobile-runtime/python/src/device_control/models.py +439 -0
  35. package/mobile-runtime/python/src/device_control/publish_policy.py +173 -0
  36. package/mobile-runtime/python/src/device_control/publishers/__init__.py +24 -0
  37. package/mobile-runtime/python/src/device_control/publishers/facebook_adb.py +494 -0
  38. package/mobile-runtime/python/src/device_control/publishers/instagram_adb.py +663 -0
  39. package/mobile-runtime/python/src/device_control/publishers/reddit_adb.py +595 -0
  40. package/mobile-runtime/python/src/device_control/publishers/tiktok_adb.py +477 -0
  41. package/mobile-runtime/python/src/device_control/publishers/tiktok_appium.py +259 -0
  42. package/mobile-runtime/python/src/device_control/publishers/ui_helpers.py +372 -0
  43. package/mobile-runtime/python/src/device_control/publishers/x_adb.py +636 -0
  44. package/mobile-runtime/python/src/device_control/publishers/xiaohongshu_adb.py +1143 -0
  45. package/mobile-runtime/python/src/device_control/store.py +137 -0
  46. package/mobile-runtime/scripts/appium_smoke.py +71 -0
  47. package/mobile-runtime/skills/android-collect-tiktok-metrics/SKILL.md +60 -0
  48. package/mobile-runtime/skills/android-collect-tiktok-metrics/agents/openai.yaml +4 -0
  49. package/mobile-runtime/skills/android-group-control-cli/SKILL.md +76 -0
  50. package/mobile-runtime/skills/android-group-control-cli/agents/openai.yaml +4 -0
  51. package/mobile-runtime/skills/android-group-control-cli/references/command-reference.md +122 -0
  52. package/mobile-runtime/skills/android-publish-facebook/SKILL.md +41 -0
  53. package/mobile-runtime/skills/android-publish-facebook/agents/openai.yaml +4 -0
  54. package/mobile-runtime/skills/android-publish-instagram/SKILL.md +45 -0
  55. package/mobile-runtime/skills/android-publish-instagram/agents/openai.yaml +4 -0
  56. package/mobile-runtime/skills/android-publish-reddit/SKILL.md +41 -0
  57. package/mobile-runtime/skills/android-publish-reddit/agents/openai.yaml +4 -0
  58. package/mobile-runtime/skills/android-publish-tiktok/SKILL.md +43 -0
  59. package/mobile-runtime/skills/android-publish-tiktok/agents/openai.yaml +4 -0
  60. package/mobile-runtime/skills/android-publish-x/SKILL.md +40 -0
  61. package/mobile-runtime/skills/android-publish-x/agents/openai.yaml +4 -0
  62. package/mobile-runtime/skills/android-publish-xiaohongshu/SKILL.md +50 -0
  63. package/mobile-runtime/skills/android-publish-xiaohongshu/agents/openai.yaml +4 -0
  64. package/mobile-runtime/skills/mobile-publish-data-collection/SKILL.md +49 -0
  65. package/mobile-runtime/skills/mobile-publish-device-health/SKILL.md +47 -0
  66. package/mobile-runtime/skills/mobile-publish-execution/SKILL.md +57 -0
  67. package/mobile-runtime/skills/mobile-publish-records/SKILL.md +29 -0
  68. package/package.json +4 -1
  69. package/scripts/mobile-postinstall.js +26 -0
  70. package/src/commands/mobile.js +695 -0
  71. package/src/commands/runtime.js +6 -5
@@ -0,0 +1,477 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import shlex
5
+ import time
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from zoneinfo import ZoneInfo
10
+
11
+ from device_control.adb import AdbClient
12
+ from device_control.models import Device, PublishRecord, new_id, utc_now_iso
13
+ from device_control.publishers import ui_helpers as ui
14
+ from device_control.store import append_publish_record
15
+
16
+
17
+ TIKTOK_PACKAGE = "com.zhiliaoapp.musically"
18
+ TIKTOK_BLOCKING_TEXTS = (
19
+ "你已达到每日上限",
20
+ "每日上限",
21
+ "想喝点水吗",
22
+ "暂时返回",
23
+ "daily limit",
24
+ "try again later",
25
+ )
26
+ TIKTOK_STORY_TEXTS = (
27
+ "你的想法",
28
+ "你的限时动态",
29
+ "24 小时内可见",
30
+ "24小时内可见",
31
+ )
32
+ TIKTOK_HOME_TEXTS = (
33
+ "首页",
34
+ "好友",
35
+ "收件箱",
36
+ "主页",
37
+ )
38
+ TIKTOK_PUBLISH_CONFIRMATION_TEXTS = (
39
+ "视频已发布",
40
+ "已发布",
41
+ "所有人可以查看",
42
+ "posted",
43
+ "published",
44
+ )
45
+ TIKTOK_NEXT_TEXTS = (
46
+ "下一步",
47
+ "Next",
48
+ )
49
+ TIKTOK_PREVIEW_TEXTS = (
50
+ "一键成片",
51
+ "AutoCut",
52
+ )
53
+
54
+
55
+ class TikTokNeedsHumanError(RuntimeError):
56
+ """Raised when TikTok blocks the publish flow with an account/platform state."""
57
+
58
+
59
+ @dataclass
60
+ class TikTokAdbPublishResult:
61
+ device_id: str
62
+ status: str
63
+ record_id: str
64
+ started_at: str
65
+ ended_at: str
66
+ duration_seconds: int
67
+ remote_media_path: str
68
+ screenshot_path: str
69
+ error: str = ""
70
+
71
+
72
+ class TikTokAdbPublisher:
73
+ """ADB-only TikTok publisher for the Samsung SM-G9910 POC devices."""
74
+
75
+ def __init__(
76
+ self,
77
+ adb: AdbClient,
78
+ *,
79
+ records_path: str | Path = "data/publish_records.jsonl",
80
+ artifact_root: str | Path = "artifacts/screenshots",
81
+ ) -> None:
82
+ self.adb = adb
83
+ self.records_path = Path(records_path)
84
+ self.artifact_root = Path(artifact_root)
85
+
86
+ def publish_video(
87
+ self,
88
+ device: Device,
89
+ *,
90
+ video_path: str | Path,
91
+ account_id: str,
92
+ caption: str = "",
93
+ dry_run: bool = False,
94
+ allow_caption_fallback: bool = False,
95
+ ) -> TikTokAdbPublishResult:
96
+ local = Path(video_path).expanduser()
97
+ if not local.exists():
98
+ raise FileNotFoundError(f"video not found: {local}")
99
+
100
+ started_epoch = int(time.time())
101
+ started_at = _now_shanghai()
102
+ stamp = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d-%H%M%S")
103
+ run_dir = self.artifact_root / f"{device.device_id}-tiktok-{stamp}"
104
+ run_dir.mkdir(parents=True, exist_ok=True)
105
+ remote = f"/sdcard/DCIM/Camera/groupctl-{device.device_id}-tiktok-{stamp}{local.suffix or '.mp4'}"
106
+ record_id = f"pub_{stamp.replace('-', '')}_{device.device_id.lower()}_tiktok"
107
+
108
+ try:
109
+ self._prepare_device(device)
110
+ self._push_media(device, local, remote)
111
+ self._launch_tiktok(device)
112
+ self._open_create(device, run_dir)
113
+ self._open_gallery(device, run_dir)
114
+ self._select_first_video(device, run_dir)
115
+ self._next_from_gallery(device, run_dir)
116
+ self._next_from_editor(device, run_dir)
117
+ if caption:
118
+ self._try_caption(device, caption, run_dir, allow_fallback=allow_caption_fallback)
119
+ if dry_run:
120
+ final_shot = run_dir / "dry-run-post-form.png"
121
+ self.adb.screenshot(device.adb_serial, final_shot)
122
+ status = "dry_run"
123
+ else:
124
+ self._tap_publish(device, run_dir)
125
+ final_shot = self._wait_for_publish_confirmation(device, run_dir)
126
+ status = "published"
127
+
128
+ ended_epoch = int(time.time())
129
+ ended_at = _now_shanghai()
130
+ if status == "published":
131
+ record = PublishRecord(
132
+ record_id=record_id,
133
+ platform="tiktok",
134
+ account_id=account_id,
135
+ device_id=device.device_id,
136
+ post_type="video",
137
+ local_media_path=str(local),
138
+ remote_media_path=remote,
139
+ caption=caption,
140
+ published_at=utc_now_iso(),
141
+ result_screenshot_path=str(final_shot),
142
+ status="published",
143
+ )
144
+ append_publish_record(self.records_path, record)
145
+ return TikTokAdbPublishResult(
146
+ device_id=device.device_id,
147
+ status=status,
148
+ record_id=record_id,
149
+ started_at=started_at,
150
+ ended_at=ended_at,
151
+ duration_seconds=ended_epoch - started_epoch,
152
+ remote_media_path=remote,
153
+ screenshot_path=str(final_shot),
154
+ )
155
+ except TikTokNeedsHumanError as exc:
156
+ ended_epoch = int(time.time())
157
+ failure_shot = run_dir / "needs-human.png"
158
+ self.adb.screenshot(device.adb_serial, failure_shot)
159
+ return TikTokAdbPublishResult(
160
+ device_id=device.device_id,
161
+ status="needs_human",
162
+ record_id=record_id,
163
+ started_at=started_at,
164
+ ended_at=_now_shanghai(),
165
+ duration_seconds=ended_epoch - started_epoch,
166
+ remote_media_path=remote,
167
+ screenshot_path=str(failure_shot),
168
+ error=str(exc),
169
+ )
170
+ except Exception as exc:
171
+ ended_epoch = int(time.time())
172
+ failure_shot = run_dir / "failed.png"
173
+ self.adb.screenshot(device.adb_serial, failure_shot)
174
+ return TikTokAdbPublishResult(
175
+ device_id=device.device_id,
176
+ status="failed",
177
+ record_id=record_id,
178
+ started_at=started_at,
179
+ ended_at=_now_shanghai(),
180
+ duration_seconds=ended_epoch - started_epoch,
181
+ remote_media_path=remote,
182
+ screenshot_path=str(failure_shot),
183
+ error=str(exc),
184
+ )
185
+
186
+ def _prepare_device(self, device: Device) -> None:
187
+ self._ok(self.adb.wake(device.adb_serial), "wake device")
188
+ time.sleep(1)
189
+ self.adb.swipe(device.adb_serial, 540, 1850, 540, 550, 350)
190
+ time.sleep(1)
191
+ self._ok(self.adb.keyevent(device.adb_serial, "KEYCODE_HOME"), "go home")
192
+ time.sleep(1)
193
+
194
+ def _push_media(self, device: Device, local: Path, remote: str) -> None:
195
+ self._ok(self.adb.shell(device.adb_serial, "mkdir", "-p", "/sdcard/DCIM/Camera"), "make camera dir")
196
+ if local.stat().st_size <= 64 * 1024 and self._push_media_via_base64(device, local, remote):
197
+ self.adb.media_scan(device.adb_serial, remote)
198
+ time.sleep(1)
199
+ return
200
+
201
+ pushed = self.adb.push(device.adb_serial, local, remote)
202
+ if not pushed.ok and not self._remote_file_matches(device, remote, local.stat().st_size):
203
+ streamed = self._push_media_via_base64(device, local, remote)
204
+ if not streamed:
205
+ self._ok(pushed, "push media")
206
+ self.adb.media_scan(device.adb_serial, remote)
207
+ time.sleep(1)
208
+
209
+ def _remote_file_matches(self, device: Device, remote: str, expected_size: int) -> bool:
210
+ result = self.adb.shell(device.adb_serial, "wc", "-c", remote)
211
+ if not result.ok:
212
+ return False
213
+ first = result.stdout.strip().split(maxsplit=1)[0]
214
+ return first.isdigit() and int(first) == expected_size
215
+
216
+ def _push_media_via_base64(self, device: Device, local: Path, remote: str) -> bool:
217
+ tmp = f"{remote}.b64"
218
+ expected_size = local.stat().st_size
219
+ encoded = base64.b64encode(local.read_bytes()).decode("ascii")
220
+ self.adb.shell(device.adb_serial, "rm", "-f", tmp, remote)
221
+
222
+ for i in range(0, len(encoded), 500):
223
+ chunk = encoded[i : i + 500]
224
+ result = self._remote_sh(
225
+ device,
226
+ f"printf '%s' {shlex.quote(chunk)} >> {shlex.quote(tmp)}",
227
+ )
228
+ if not result.ok:
229
+ return False
230
+
231
+ decode = self._remote_sh(device, f"base64 -d {shlex.quote(tmp)} > {shlex.quote(remote)}")
232
+ self.adb.shell(device.adb_serial, "rm", "-f", tmp)
233
+ return decode.ok and self._remote_file_matches(device, remote, expected_size)
234
+
235
+ def _remote_sh(self, device: Device, command: str):
236
+ return self.adb.run("-s", device.adb_serial, "shell", f"sh -c {shlex.quote(command)}")
237
+
238
+ def _launch_tiktok(self, device: Device) -> None:
239
+ ui.grant_publish_permissions(self.adb, device, TIKTOK_PACKAGE)
240
+ self.adb.shell(device.adb_serial, "am", "force-stop", TIKTOK_PACKAGE)
241
+ time.sleep(1)
242
+ self._ok(self.adb.launch_package(device.adb_serial, TIKTOK_PACKAGE), "launch TikTok")
243
+ time.sleep(5)
244
+
245
+ def _open_create(self, device: Device, run_dir: Path) -> None:
246
+ root = self._dump_and_check(device, run_dir, "before-create")
247
+ create_node = ui.find_node(
248
+ root,
249
+ id_suffixes={"neq"},
250
+ descs={"创建", "Create"},
251
+ clickable_only=True,
252
+ )
253
+ if create_node is None:
254
+ self._tap(device, 540, 2225, 0)
255
+ else:
256
+ ui.tap_node(self.adb, device, create_node)
257
+ time.sleep(5)
258
+ self.adb.screenshot(device.adb_serial, run_dir / "create.png")
259
+ root = self._dump_and_check(device, run_dir, "create")
260
+ text = ui.all_text(root)
261
+ if _contains_any(text, TIKTOK_HOME_TEXTS):
262
+ # Home text may remain underneath transient create overlays. Do not fail here unless
263
+ # a known TikTok account/platform block is present; the gallery check below is strict.
264
+ return
265
+
266
+ def _open_gallery(self, device: Device, run_dir: Path) -> None:
267
+ # D01 and D02 currently expose different create-page entry points.
268
+ # Try the D01 bottom-left upload first, then the D02 right-side album icon.
269
+ root = self._dump_and_check(device, run_dir, "before-gallery-tap")
270
+ upload_node = ui.find_node(root, id_suffixes={"upload_hot_area"}, clickable_only=True)
271
+ if upload_node is None:
272
+ self._tap(device, 105, 2050, 0)
273
+ else:
274
+ ui.tap_node(self.adb, device, upload_node)
275
+ time.sleep(2)
276
+ shot = run_dir / "after-gallery-tap-1.png"
277
+ self.adb.screenshot(device.adb_serial, shot)
278
+ self._assert_not_home_or_wrong_surface(
279
+ self._dump_and_check(device, run_dir, "after-gallery-tap-1"),
280
+ "open TikTok gallery",
281
+ )
282
+ ui.tap_permission_prompt_if_present(self.adb, device, run_dir / "gallery-permission.xml")
283
+ if device.device_id == "D02":
284
+ self._tap(device, 985, 610, 3)
285
+ self.adb.screenshot(device.adb_serial, run_dir / "after-gallery-tap-2.png")
286
+ self._assert_not_home_or_wrong_surface(
287
+ self._dump_and_check(device, run_dir, "after-gallery-tap-2"),
288
+ "open TikTok gallery on D02",
289
+ )
290
+ time.sleep(2)
291
+
292
+ def _select_first_video(self, device: Device, run_dir: Path) -> None:
293
+ self._tap(device, 175, 540, 1)
294
+ self.adb.screenshot(device.adb_serial, run_dir / "after-select-first.png")
295
+ self._dump_and_check(device, run_dir, "after-select-first")
296
+
297
+ def _next_from_gallery(self, device: Device, run_dir: Path) -> None:
298
+ self._tap_next_button(device, run_dir, "before-gallery-next", fallback=(790, 2208), sleep_s=5)
299
+ self.adb.screenshot(device.adb_serial, run_dir / "after-gallery-next.png")
300
+ root = self._dump_and_check(device, run_dir, "after-gallery-next")
301
+ if self._is_media_preview(root):
302
+ raise RuntimeError("next from TikTok media preview failed: still on the video selection preview")
303
+
304
+ def _next_from_editor(self, device: Device, run_dir: Path) -> None:
305
+ self._tap_next_button(device, run_dir, "before-editor-next", fallback=(795, 2234), sleep_s=6)
306
+ self.adb.screenshot(device.adb_serial, run_dir / "post-form.png")
307
+ root = self._dump_and_check(device, run_dir, "post-form")
308
+ self._assert_not_home_or_wrong_surface(root, "reach TikTok video post form")
309
+ if self._is_media_preview(root):
310
+ raise RuntimeError("next from TikTok editor failed: still on the video selection preview")
311
+
312
+ def _try_caption(self, device: Device, caption: str, run_dir: Path, *, allow_fallback: bool) -> None:
313
+ self._tap(device, 160, 275, 1)
314
+ result = self.adb.input_text(device.adb_serial, caption)
315
+ if not result.ok:
316
+ self.adb.screenshot(device.adb_serial, run_dir / "caption-input-failed.png")
317
+ (run_dir / "caption-warning.txt").write_text(
318
+ "ADB input text failed. Caption is recorded in PublishRecord but may not be visible in TikTok UI.\n"
319
+ f"caption={caption}\n"
320
+ f"stderr={result.stderr}\n"
321
+ f"stdout={result.stdout}\n",
322
+ encoding="utf-8",
323
+ )
324
+ if not allow_fallback:
325
+ raise RuntimeError(
326
+ "caption input failed; stop before publish. "
327
+ "Use ASCII caption, install a Unicode input bridge, or pass --allow-caption-fallback."
328
+ )
329
+ time.sleep(1)
330
+ self.adb.keyevent(device.adb_serial, "KEYCODE_BACK")
331
+ time.sleep(1)
332
+ self.adb.screenshot(device.adb_serial, run_dir / "after-caption.png")
333
+
334
+ def _tap_publish(self, device: Device, run_dir: Path) -> None:
335
+ root = self._dump_and_check(device, run_dir, "before-publish")
336
+ node = self._find_visible_text_node(root, ("发布", "Post"), clickable_only=True)
337
+ if node is None:
338
+ self._tap(device, 790, 2208, 0)
339
+ else:
340
+ ui.tap_node(self.adb, device, node)
341
+
342
+ def _tap_next_button(
343
+ self,
344
+ device: Device,
345
+ run_dir: Path,
346
+ dump_name: str,
347
+ *,
348
+ fallback: tuple[int, int],
349
+ sleep_s: int,
350
+ ) -> None:
351
+ root = self._dump_and_check(device, run_dir, dump_name)
352
+ node = self._find_visible_text_node(root, TIKTOK_NEXT_TEXTS, clickable_only=False)
353
+ if node is None:
354
+ self._tap(device, fallback[0], fallback[1], 0)
355
+ else:
356
+ ui.tap_node(self.adb, device, node)
357
+ if sleep_s:
358
+ time.sleep(sleep_s)
359
+
360
+ def _find_visible_text_node(self, root, texts: tuple[str, ...], *, clickable_only: bool):
361
+ candidates = []
362
+ for node in root.iter("node"):
363
+ if clickable_only and node.attrib.get("clickable") != "true":
364
+ continue
365
+ if not ui.is_visible(node):
366
+ continue
367
+ text = node.attrib.get("text", "")
368
+ desc = node.attrib.get("content-desc", "")
369
+ if text in texts or desc in texts:
370
+ bounds = ui.bounds(node)
371
+ if bounds:
372
+ candidates.append((bounds, node))
373
+ if not candidates:
374
+ return None
375
+
376
+ def key(item):
377
+ left, top, right, bottom = item[0]
378
+ return bottom, right, top, left
379
+
380
+ return sorted(candidates, key=key, reverse=True)[0][1]
381
+
382
+ def _is_media_preview(self, root) -> bool:
383
+ text = ui.all_text(root)
384
+ return (
385
+ _contains_any(text, TIKTOK_NEXT_TEXTS)
386
+ and _contains_any(text, TIKTOK_PREVIEW_TEXTS)
387
+ and self._has_exact_text(root, ("选择", "Select"))
388
+ )
389
+
390
+ def _has_exact_text(self, root, texts: tuple[str, ...]) -> bool:
391
+ for node in root.iter("node"):
392
+ if node.attrib.get("text", "") in texts or node.attrib.get("content-desc", "") in texts:
393
+ return True
394
+ return False
395
+
396
+ def _is_published_video_surface(self, root) -> bool:
397
+ text = ui.all_text(root)
398
+ has_video_actions = _contains_any(text, ("点赞视频", "分享视频", "阅读或添加评论"))
399
+ has_recent_timestamp = _contains_any(text, ("秒前", "分钟前", "minute ago", "seconds ago"))
400
+ return has_video_actions and has_recent_timestamp
401
+
402
+ def _wait_for_publish_confirmation(self, device: Device, run_dir: Path) -> Path:
403
+ deadline = time.time() + 60
404
+ last_shot = run_dir / "after-post.png"
405
+ last_text = ""
406
+ while time.time() < deadline:
407
+ time.sleep(2)
408
+ self.adb.screenshot(device.adb_serial, last_shot)
409
+ root = self._dump_and_check(device, run_dir, "after-post")
410
+ last_text = ui.all_text(root)
411
+ if ui.tap_permission_prompt_if_present(self.adb, device, run_dir / "after-post-permission.xml"):
412
+ time.sleep(2)
413
+ self.adb.screenshot(device.adb_serial, last_shot)
414
+ continue
415
+ if _contains_any(last_text, TIKTOK_PUBLISH_CONFIRMATION_TEXTS):
416
+ return last_shot
417
+ if self._is_published_video_surface(root):
418
+ return last_shot
419
+ if _contains_any(last_text, TIKTOK_STORY_TEXTS):
420
+ raise RuntimeError("TikTok publish route is on story/thoughts surface, not the video post flow")
421
+ raise RuntimeError(
422
+ "TikTok publish completion was not confirmed within 60s; "
423
+ "not writing a published record. Last UI text: "
424
+ f"{_short_text(last_text)}"
425
+ )
426
+
427
+ def _dump_and_check(self, device: Device, run_dir: Path, name: str):
428
+ xml_path = run_dir / f"{name}.xml"
429
+ root = ui.dump_ui(self.adb, device, xml_path)
430
+ text = ui.all_text(root)
431
+ blocking = _first_contained(text, TIKTOK_BLOCKING_TEXTS)
432
+ if blocking:
433
+ raise TikTokNeedsHumanError(f"TikTok publish blocked by visible prompt: {blocking}")
434
+ if ("访问你的麦克风" in text or "访问麦克风" in text) and "直播" in text:
435
+ raise RuntimeError("TikTok opened the LIVE microphone permission surface, not the video publish flow")
436
+ return root
437
+
438
+ def _assert_not_home_or_wrong_surface(self, root, action: str) -> None:
439
+ text = ui.all_text(root)
440
+ story = _first_contained(text, TIKTOK_STORY_TEXTS)
441
+ if story and not _contains_any(text, TIKTOK_NEXT_TEXTS):
442
+ raise RuntimeError(f"{action} failed: TikTok opened story/thoughts surface ({story}), not video publish")
443
+ home_matches = [part for part in TIKTOK_HOME_TEXTS if part in text]
444
+ if len(home_matches) >= 3:
445
+ raise RuntimeError(f"{action} failed: TikTok is still on home/feed UI ({', '.join(home_matches)})")
446
+
447
+ def _tap(self, device: Device, x: int, y: int, sleep_s: int) -> None:
448
+ self._ok(self.adb.tap(device.adb_serial, x, y), f"tap {x},{y}")
449
+ if sleep_s:
450
+ time.sleep(sleep_s)
451
+
452
+ @staticmethod
453
+ def _ok(result, action: str) -> None:
454
+ if not result.ok:
455
+ raise RuntimeError(f"{action} failed: {result.stderr or result.stdout}")
456
+
457
+
458
+ def _now_shanghai() -> str:
459
+ return datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S %z")
460
+
461
+
462
+ def _contains_any(text: str, needles: tuple[str, ...]) -> bool:
463
+ lowered = text.lower()
464
+ return any(needle.lower() in lowered for needle in needles)
465
+
466
+
467
+ def _first_contained(text: str, needles: tuple[str, ...]) -> str:
468
+ lowered = text.lower()
469
+ for needle in needles:
470
+ if needle.lower() in lowered:
471
+ return needle
472
+ return ""
473
+
474
+
475
+ def _short_text(text: str, limit: int = 240) -> str:
476
+ compact = " ".join(part.strip() for part in text.splitlines() if part.strip())
477
+ return compact[:limit]