@11agents/cli 0.1.25 → 0.1.27

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 (28) hide show
  1. package/mobile-runtime/configs/platforms/xiaohongshu_d01.json +1 -1
  2. package/mobile-runtime/configs/platforms/xiaohongshu_d02.json +1 -1
  3. package/mobile-runtime/configs/platforms/xiaohongshu_d03.json +1 -1
  4. package/mobile-runtime/python/src/device_control/adb.py +3 -0
  5. package/mobile-runtime/python/src/device_control/cli.py +38 -19
  6. package/mobile-runtime/python/src/device_control/metrics/tiktok_account_adb.py +1 -1
  7. package/mobile-runtime/python/src/device_control/metrics/tiktok_video_adb.py +2 -2
  8. package/mobile-runtime/python/src/device_control/models.py +4 -4
  9. package/mobile-runtime/python/src/device_control/publishers/facebook_adb.py +6 -6
  10. package/mobile-runtime/python/src/device_control/publishers/instagram_adb.py +10 -10
  11. package/mobile-runtime/python/src/device_control/publishers/tiktok_adb.py +5 -5
  12. package/mobile-runtime/python/src/device_control/publishers/x_adb.py +16 -16
  13. package/mobile-runtime/python/src/device_control/publishers/xiaohongshu_adb.py +511 -67
  14. package/mobile-runtime/skills/android-collect-tiktok-metrics/SKILL.md +1 -1
  15. package/mobile-runtime/skills/android-group-control-cli/SKILL.md +1 -1
  16. package/mobile-runtime/skills/android-group-control-cli/references/command-reference.md +1 -1
  17. package/mobile-runtime/skills/android-publish-facebook/SKILL.md +1 -1
  18. package/mobile-runtime/skills/android-publish-instagram/SKILL.md +2 -2
  19. package/mobile-runtime/skills/android-publish-reddit/SKILL.md +1 -1
  20. package/mobile-runtime/skills/android-publish-tiktok/SKILL.md +1 -1
  21. package/mobile-runtime/skills/android-publish-x/SKILL.md +1 -1
  22. package/mobile-runtime/skills/android-publish-xiaohongshu/SKILL.md +8 -6
  23. package/mobile-runtime/skills/mobile-publish-device-health/SKILL.md +3 -3
  24. package/mobile-runtime/skills/mobile-publish-execution/SKILL.md +2 -2
  25. package/mobile-runtime/skills/mobile-publish-records/SKILL.md +1 -1
  26. package/package.json +1 -1
  27. package/src/commands/mobile.js +144 -1
  28. package/src/commands/runtime.js +28 -0
@@ -57,7 +57,7 @@
57
57
  "after_paste_probe": 1,
58
58
  "detail_load_timeout": 12,
59
59
  "publish_poll_interval": 2,
60
- "publish_timeout": 45
60
+ "publish_timeout": 180
61
61
  },
62
62
  "success_focus_keywords": [
63
63
  "IndexActivityV2"
@@ -57,7 +57,7 @@
57
57
  "after_paste_probe": 1,
58
58
  "detail_load_timeout": 12,
59
59
  "publish_poll_interval": 2,
60
- "publish_timeout": 45
60
+ "publish_timeout": 180
61
61
  },
62
62
  "success_focus_keywords": ["IndexActivityV2"],
63
63
  "detail_focus_keywords": ["NoteDetailActivity"],
@@ -57,7 +57,7 @@
57
57
  "after_paste_probe": 1,
58
58
  "detail_load_timeout": 12,
59
59
  "publish_poll_interval": 2,
60
- "publish_timeout": 45
60
+ "publish_timeout": 180
61
61
  },
62
62
  "success_focus_keywords": [
63
63
  "IndexActivityV2"
@@ -76,6 +76,9 @@ class AdbClient:
76
76
  "1",
77
77
  )
78
78
 
79
+ def force_stop(self, serial: str, package: str) -> CommandResult:
80
+ return self.shell(serial, "am", "force-stop", package)
81
+
79
82
  def push(self, serial: str, local_path: str | Path, remote_path: str) -> CommandResult:
80
83
  return self.run("-s", serial, "push", str(local_path), remote_path, timeout_s=120)
81
84
 
@@ -224,7 +224,7 @@ def main() -> None:
224
224
  collect_tiktok_account_p.add_argument(
225
225
  "--allow-manual-required",
226
226
  action="store_true",
227
- help="Exit 0 after screenshot capture even if values still need human extraction.",
227
+ help="Exit 0 after screenshot capture even if values still need manual extraction.",
228
228
  )
229
229
  collect_tiktok_account_p.add_argument("--no-append", action="store_true", help="Do not append the snapshot JSONL row.")
230
230
  collect_tiktok_account_p.add_argument("--json", action="store_true", help="Print JSON")
@@ -259,7 +259,7 @@ def main() -> None:
259
259
  collect_tiktok_video_p.add_argument(
260
260
  "--allow-manual-required",
261
261
  action="store_true",
262
- help="Exit 0 after screenshot capture even if values still need human extraction.",
262
+ help="Exit 0 after screenshot capture even if values still need manual extraction.",
263
263
  )
264
264
  collect_tiktok_video_p.add_argument("--no-append", action="store_true", help="Do not append the snapshot JSONL row.")
265
265
  collect_tiktok_video_p.add_argument("--json", action="store_true", help="Print JSON")
@@ -319,8 +319,10 @@ def main() -> None:
319
319
  copy_xhs_link_p.add_argument("--appium-server", default="http://127.0.0.1:4723")
320
320
  _add_appium_preflight_args(copy_xhs_link_p)
321
321
  copy_xhs_link_p.add_argument("--flow-config", default="configs/platforms/xiaohongshu_d03.json")
322
- copy_xhs_link_p.add_argument("--device", required=True, help="Device id, e.g. D03.")
322
+ copy_xhs_link_p.add_argument("--device", default="", help="Device id, e.g. D03. If omitted with --record-id, read it from the publish record.")
323
323
  copy_xhs_link_p.add_argument("--record-id", default="", help="Update this publish record when a URL is retrieved.")
324
+ copy_xhs_link_p.add_argument("--expected-title", default="", help="Expected Xiaohongshu note title for latest-note verification.")
325
+ copy_xhs_link_p.add_argument("--expected-caption", default="", help="Expected Xiaohongshu note caption/body for latest-note verification.")
324
326
  copy_xhs_link_p.add_argument("--json", action="store_true", help="Print JSON")
325
327
 
326
328
  args = parser.parse_args()
@@ -654,11 +656,9 @@ def main() -> None:
654
656
  f"periods={complete}/{len(snapshot.periods)} duration={snapshot.duration_seconds}s "
655
657
  f"artifact_dir={snapshot.artifact_dir} out={args.out if not args.no_append else '(not appended)'}"
656
658
  )
657
- if snapshot.status == "needs_human":
658
- print("manual values required: review period screenshots, then rerun with --values-json or --values-file")
659
659
  if snapshot.status == "failed":
660
- print(f"error={snapshot.raw.get('error', '')}")
661
- if snapshot.status == "failed" or (snapshot.status == "needs_human" and not args.allow_manual_required):
660
+ print(f"error={snapshot.raw.get('error', snapshot.raw.get('extraction_status', 'automatic extraction failed'))}")
661
+ if snapshot.status == "failed":
662
662
  raise SystemExit(1)
663
663
  return
664
664
 
@@ -696,11 +696,9 @@ def main() -> None:
696
696
  f"published_at={snapshot.published_at or '(unknown)'} duration={snapshot.duration_seconds}s "
697
697
  f"artifact_dir={snapshot.artifact_dir} out={args.out if not args.no_append else '(not appended)'}"
698
698
  )
699
- if snapshot.status == "needs_human":
700
- print("manual values required: review the video-insights screenshot, then rerun with --values-json or --values-file")
701
699
  if snapshot.status == "failed":
702
- print(f"error={snapshot.raw.get('error', '')}")
703
- if snapshot.status == "failed" or (snapshot.status == "needs_human" and not args.allow_manual_required):
700
+ print(f"error={snapshot.raw.get('error', snapshot.raw.get('extraction_status', 'automatic extraction failed'))}")
701
+ if snapshot.status == "failed":
704
702
  raise SystemExit(1)
705
703
  return
706
704
 
@@ -1006,7 +1004,14 @@ def main() -> None:
1006
1004
  return
1007
1005
 
1008
1006
  if args.command == "copy-xiaohongshu-link":
1009
- device = _get_device(args.devices, args.device)
1007
+ record = None
1008
+ if args.record_id:
1009
+ records = load_publish_records(args.records)
1010
+ record = _find_record(records, args.record_id)
1011
+ device_id = args.device or (record.device_id if record else "")
1012
+ if not device_id:
1013
+ raise SystemExit("copy-xiaohongshu-link requires --device or --record-id for a record with device_id")
1014
+ device = _get_device(args.devices, device_id)
1010
1015
  _ensure_appium_preflight(args, [device])
1011
1016
  publisher = XiaohongshuAdbPublisher(
1012
1017
  adb,
@@ -1014,11 +1019,17 @@ def main() -> None:
1014
1019
  records_path=args.records,
1015
1020
  flow_config_path=args.flow_config,
1016
1021
  )
1022
+ expected_title = args.expected_title
1023
+ expected_caption = args.expected_caption
1024
+ if record and not expected_title and not expected_caption:
1025
+ expected_title, expected_caption = _xiaohongshu_expected_from_record(record)
1017
1026
  with device_lock(device.device_id):
1018
- result = publisher.copy_current_note_link(device)
1019
- if result.platform_permalink and args.record_id:
1020
- records = load_publish_records(args.records)
1021
- record = _find_record(records, args.record_id)
1027
+ result = publisher.copy_current_note_link(
1028
+ device,
1029
+ expected_title=expected_title,
1030
+ expected_caption=expected_caption,
1031
+ )
1032
+ if result.platform_permalink and record:
1022
1033
  record.platform_permalink = result.platform_permalink
1023
1034
  write_publish_records(args.records, records)
1024
1035
  row = asdict(result)
@@ -1032,7 +1043,7 @@ def main() -> None:
1032
1043
  + (f" method={result.clipboard_method}" if result.clipboard_method else "")
1033
1044
  + (f" error={result.error}" if result.error else "")
1034
1045
  )
1035
- if result.status in {"failed", "needs_human"}:
1046
+ if result.status == "failed":
1036
1047
  raise SystemExit(1)
1037
1048
  return
1038
1049
 
@@ -1497,6 +1508,14 @@ def _filter_records(
1497
1508
  return out
1498
1509
 
1499
1510
 
1511
+ def _xiaohongshu_expected_from_record(record: PublishRecord) -> tuple[str, str]:
1512
+ caption = (record.caption or "").strip()
1513
+ if " | " in caption:
1514
+ title, body = caption.split(" | ", 1)
1515
+ return title.strip(), body.strip()
1516
+ return caption, ""
1517
+
1518
+
1500
1519
  def _run_publish_jobs(devices: list[object], parallel: bool, max_concurrency: int, run_one, print_one) -> bool:
1501
1520
  if max_concurrency < 1:
1502
1521
  raise SystemExit("--max-concurrency must be >= 1")
@@ -1518,7 +1537,7 @@ def _run_publish_jobs(devices: list[object], parallel: bool, max_concurrency: in
1518
1537
  any_failed = True
1519
1538
  continue
1520
1539
  print_one(result)
1521
- any_failed = any_failed or getattr(result, "status", "") in {"failed", "needs_human"}
1540
+ any_failed = any_failed or getattr(result, "status", "") == "failed"
1522
1541
  return any_failed
1523
1542
 
1524
1543
  workers = min(max_concurrency, MAX_PARALLEL_DEVICES, len(devices))
@@ -1533,7 +1552,7 @@ def _run_publish_jobs(devices: list[object], parallel: bool, max_concurrency: in
1533
1552
  any_failed = True
1534
1553
  continue
1535
1554
  print_one(result)
1536
- any_failed = any_failed or getattr(result, "status", "") in {"failed", "needs_human"}
1555
+ any_failed = any_failed or getattr(result, "status", "") == "failed"
1537
1556
  return any_failed
1538
1557
 
1539
1558
 
@@ -108,7 +108,7 @@ class TikTokAccountMetricsAdbCollector:
108
108
  )
109
109
 
110
110
  ended_epoch = int(time.time())
111
- status = "collected" if all(period.has_all_metrics() for period in periods_out.values()) else "needs_human"
111
+ status = "collected" if all(period.has_all_metrics() for period in periods_out.values()) else "failed"
112
112
  raw["extraction_status"] = (
113
113
  "manual_values_recorded" if status == "collected" else "manual_values_required"
114
114
  )
@@ -168,7 +168,7 @@ class TikTokVideoMetricsAdbCollector:
168
168
  device_id=device.device_id,
169
169
  fetched_at=utc_now_iso(),
170
170
  source="tiktok_video_insights_adb",
171
- status="needs_human",
171
+ status="failed",
172
172
  video_order=video_order,
173
173
  screenshot_path=str(screenshot_path),
174
174
  ui_dump_path=str(ui_dump_path),
@@ -216,7 +216,7 @@ class TikTokVideoMetricsAdbCollector:
216
216
  raw=combined_raw,
217
217
  )
218
218
  if not snapshot.has_key_metrics():
219
- snapshot.status = "needs_human"
219
+ snapshot.status = "failed"
220
220
  return snapshot
221
221
 
222
222
  def _prepare_device(self, device: Device) -> None:
@@ -263,7 +263,7 @@ class TikTokAccountMetricsSnapshot:
263
263
  device_id: str
264
264
  fetched_at: str
265
265
  source: str
266
- status: str = "needs_human"
266
+ status: str = "failed"
267
267
  periods: dict[str, TikTokAccountMetricsPeriod] = field(default_factory=dict)
268
268
  artifact_dir: str = ""
269
269
  started_at: str = ""
@@ -284,7 +284,7 @@ class TikTokAccountMetricsSnapshot:
284
284
  device_id=str(data["device_id"]),
285
285
  fetched_at=str(data["fetched_at"]),
286
286
  source=str(data.get("source", "unknown")),
287
- status=str(data.get("status", "needs_human")),
287
+ status=str(data.get("status", "failed")),
288
288
  periods=periods,
289
289
  artifact_dir=str(data.get("artifact_dir", "")),
290
290
  started_at=str(data.get("started_at", "")),
@@ -319,7 +319,7 @@ class TikTokVideoMetricsSnapshot:
319
319
  device_id: str
320
320
  fetched_at: str
321
321
  source: str
322
- status: str = "needs_human"
322
+ status: str = "failed"
323
323
  published_at: str = ""
324
324
  published_at_raw: str = ""
325
325
  video_order: int | None = None
@@ -354,7 +354,7 @@ class TikTokVideoMetricsSnapshot:
354
354
  device_id=str(data["device_id"]),
355
355
  fetched_at=str(data["fetched_at"]),
356
356
  source=str(data.get("source", "unknown")),
357
- status=str(data.get("status", "needs_human")),
357
+ status=str(data.get("status", "failed")),
358
358
  published_at=str(data.get("published_at", "")),
359
359
  published_at_raw=str(data.get("published_at_raw", "")),
360
360
  video_order=_optional_int(data.get("video_order")),
@@ -18,7 +18,7 @@ from device_control.store import append_publish_record
18
18
  FACEBOOK_LITE_PACKAGE = "com.facebook.lite"
19
19
 
20
20
 
21
- class NeedsHumanError(RuntimeError):
21
+ class AutomationBlockedError(RuntimeError):
22
22
  pass
23
23
 
24
24
 
@@ -167,7 +167,7 @@ class FacebookAdbPublisher:
167
167
  link_url=link_url,
168
168
  screenshot_path=str(final_shot),
169
169
  )
170
- except NeedsHumanError as exc:
170
+ except AutomationBlockedError as exc:
171
171
  return self._failure_result(
172
172
  device,
173
173
  run_dir,
@@ -178,7 +178,7 @@ class FacebookAdbPublisher:
178
178
  post_type=post_type,
179
179
  remote_media_path=remote,
180
180
  link_url=link_url,
181
- status="needs_human",
181
+ status="failed",
182
182
  error=str(exc),
183
183
  )
184
184
  except Exception as exc:
@@ -242,7 +242,7 @@ class FacebookAdbPublisher:
242
242
  time.sleep(7)
243
243
  self.adb.screenshot(device.adb_serial, run_dir / "home.png")
244
244
  if app_package not in self.adb.current_focus(device.adb_serial):
245
- raise NeedsHumanError(f"Facebook is not foreground after launch; check install/login state for {app_package}")
245
+ raise AutomationBlockedError(f"Facebook is not foreground after launch; check install/login state for {app_package}")
246
246
 
247
247
  def _open_composer(self, device: Device, run_dir: Path) -> None:
248
248
  self._tap(device, 95, 180, 6)
@@ -257,7 +257,7 @@ class FacebookAdbPublisher:
257
257
  composer_shot = run_dir / "composer.png"
258
258
  self.adb.screenshot(device.adb_serial, composer_shot)
259
259
  if not self._is_compose_form(device, run_dir / "composer.xml") and not _locate_blue_post_button(composer_shot):
260
- raise NeedsHumanError("Facebook composer not detected; check login, verification, prompts, or layout calibration")
260
+ raise AutomationBlockedError("Facebook composer not detected; check login, verification, prompts, or layout calibration")
261
261
 
262
262
  def _input_text(self, device: Device, text: str, run_dir: Path) -> None:
263
263
  self._tap(device, 170, 520, 1)
@@ -284,7 +284,7 @@ class FacebookAdbPublisher:
284
284
  self.adb.screenshot(device.adb_serial, run_dir / "after-media-select.png")
285
285
  return
286
286
  self.adb.screenshot(device.adb_serial, run_dir / "media-picker-not-found.png")
287
- raise NeedsHumanError(f"Facebook {post_type} media picker did not expose a selectable gallery item")
287
+ raise AutomationBlockedError(f"Facebook {post_type} media picker did not expose a selectable gallery item")
288
288
 
289
289
  def _tap_post(self, device: Device, run_dir: Path) -> None:
290
290
  post_shot = run_dir / "before-post-locator.png"
@@ -23,7 +23,7 @@ INSTAGRAM_SHARE_BUTTON_IDS = {
23
23
  }
24
24
 
25
25
 
26
- class NeedsHumanError(RuntimeError):
26
+ class AutomationBlockedError(RuntimeError):
27
27
  pass
28
28
 
29
29
 
@@ -159,7 +159,7 @@ class InstagramAdbPublisher:
159
159
  remote_media_path=remote,
160
160
  screenshot_path=str(final_shot),
161
161
  )
162
- except NeedsHumanError as exc:
162
+ except AutomationBlockedError as exc:
163
163
  return self._failure_result(
164
164
  device,
165
165
  run_dir,
@@ -168,7 +168,7 @@ class InstagramAdbPublisher:
168
168
  started_epoch,
169
169
  remote,
170
170
  post_type=post_type,
171
- status="needs_human",
171
+ status="failed",
172
172
  error=str(exc),
173
173
  )
174
174
  except Exception as exc:
@@ -227,7 +227,7 @@ class InstagramAdbPublisher:
227
227
  self.adb.screenshot(device.adb_serial, run_dir / "home.png")
228
228
  focus = self.adb.current_focus(device.adb_serial)
229
229
  if INSTAGRAM_PACKAGE not in focus:
230
- raise NeedsHumanError("Instagram is not foreground after launch; check install/login/verification state")
230
+ raise AutomationBlockedError("Instagram is not foreground after launch; check install/login/verification state")
231
231
  self._raise_if_blocked(device, run_dir / "home.xml")
232
232
 
233
233
  def _open_new_post(self, device: Device, run_dir: Path) -> None:
@@ -243,7 +243,7 @@ class InstagramAdbPublisher:
243
243
  self.adb.screenshot(device.adb_serial, run_dir / "gallery.png")
244
244
  self._raise_if_blocked(device, run_dir / "gallery.xml")
245
245
  if not self._has_any_text(device, run_dir / "gallery-check.xml", {"新帖子", "New post"}):
246
- raise NeedsHumanError("Instagram gallery did not open; check login, verification, prompts, or layout calibration")
246
+ raise AutomationBlockedError("Instagram gallery did not open; check login, verification, prompts, or layout calibration")
247
247
 
248
248
  def _select_destination(self, device: Device, run_dir: Path, post_type: str) -> None:
249
249
  target_id = "com.instagram.android:id/cam_dest_clips" if post_type == "reel" else "com.instagram.android:id/cam_dest_feed"
@@ -260,7 +260,7 @@ class InstagramAdbPublisher:
260
260
  media_node = _find_first_visible_media_thumbnail(root, media_kind)
261
261
  if media_node is None:
262
262
  self.adb.screenshot(device.adb_serial, run_dir / f"no-visible-{media_kind}.png")
263
- raise NeedsHumanError(f"No visible {media_kind} thumbnail found in Instagram gallery")
263
+ raise AutomationBlockedError(f"No visible {media_kind} thumbnail found in Instagram gallery")
264
264
  x, y = _center(_bounds(media_node))
265
265
  self._tap(device, x, y, 1)
266
266
  self.adb.screenshot(device.adb_serial, run_dir / "gallery-selected.png")
@@ -310,7 +310,7 @@ class InstagramAdbPublisher:
310
310
  continue
311
311
  time.sleep(2)
312
312
  self.adb.screenshot(device.adb_serial, run_dir / "post-share-form-timeout.png")
313
- raise NeedsHumanError("Instagram Post share form was not detected after selecting media")
313
+ raise AutomationBlockedError("Instagram Post share form was not detected after selecting media")
314
314
 
315
315
  def _tap_post_public_sheet_if_present(self, device: Device, root: ET.Element, run_dir: Path, attempt: int) -> bool:
316
316
  text = _all_text(root)
@@ -339,7 +339,7 @@ class InstagramAdbPublisher:
339
339
  if self._has_any_id(device, run_dir / "share-form-check.xml", {"com.instagram.android:id/caption_input_text_view"}):
340
340
  return
341
341
  time.sleep(2)
342
- raise NeedsHumanError("Instagram editor next button/share form was not detected")
342
+ raise AutomationBlockedError("Instagram editor next button/share form was not detected")
343
343
 
344
344
  def _set_caption(self, device: Device, caption: str, run_dir: Path, caption_input: str) -> None:
345
345
  last_error = ""
@@ -415,7 +415,7 @@ class InstagramAdbPublisher:
415
415
  if self._is_reels_public_sheet(device, run_dir / "reels-public-sheet.xml"):
416
416
  self.adb.screenshot(device.adb_serial, run_dir / "reels-public-sheet.png")
417
417
  if not confirm_reels_public:
418
- raise NeedsHumanError("Instagram Reels public sharing prompt needs explicit confirmation")
418
+ raise AutomationBlockedError("Instagram Reels public sharing prompt needs explicit confirmation")
419
419
  if not self._tap_node(
420
420
  device,
421
421
  run_dir / "reels-public-sheet.xml",
@@ -493,7 +493,7 @@ class InstagramAdbPublisher:
493
493
  "restricted",
494
494
  },
495
495
  ):
496
- raise NeedsHumanError("Instagram needs login, verification, captcha, or account review")
496
+ raise AutomationBlockedError("Instagram needs login, verification, captcha, or account review")
497
497
 
498
498
  def _is_reels_public_sheet(self, device: Device, xml_path: Path) -> bool:
499
499
  root = self._dump(device, xml_path)
@@ -52,7 +52,7 @@ TIKTOK_PREVIEW_TEXTS = (
52
52
  )
53
53
 
54
54
 
55
- class TikTokNeedsHumanError(RuntimeError):
55
+ class TikTokAutomationBlockedError(RuntimeError):
56
56
  """Raised when TikTok blocks the publish flow with an account/platform state."""
57
57
 
58
58
 
@@ -152,13 +152,13 @@ class TikTokAdbPublisher:
152
152
  remote_media_path=remote,
153
153
  screenshot_path=str(final_shot),
154
154
  )
155
- except TikTokNeedsHumanError as exc:
155
+ except TikTokAutomationBlockedError as exc:
156
156
  ended_epoch = int(time.time())
157
- failure_shot = run_dir / "needs-human.png"
157
+ failure_shot = run_dir / "failed.png"
158
158
  self.adb.screenshot(device.adb_serial, failure_shot)
159
159
  return TikTokAdbPublishResult(
160
160
  device_id=device.device_id,
161
- status="needs_human",
161
+ status="failed",
162
162
  record_id=record_id,
163
163
  started_at=started_at,
164
164
  ended_at=_now_shanghai(),
@@ -430,7 +430,7 @@ class TikTokAdbPublisher:
430
430
  text = ui.all_text(root)
431
431
  blocking = _first_contained(text, TIKTOK_BLOCKING_TEXTS)
432
432
  if blocking:
433
- raise TikTokNeedsHumanError(f"TikTok publish blocked by visible prompt: {blocking}")
433
+ raise TikTokAutomationBlockedError(f"TikTok publish blocked by visible prompt: {blocking}")
434
434
  if ("访问你的麦克风" in text or "访问麦克风" in text) and "直播" in text:
435
435
  raise RuntimeError("TikTok opened the LIVE microphone permission surface, not the video publish flow")
436
436
  return root
@@ -42,7 +42,7 @@ POST_PUBLISH_NOTICE_MARKERS = {
42
42
  }
43
43
 
44
44
 
45
- class NeedsHumanError(RuntimeError):
45
+ class AutomationBlockedError(RuntimeError):
46
46
  pass
47
47
 
48
48
 
@@ -168,7 +168,7 @@ class XAdbPublisher:
168
168
  link_url=link_url,
169
169
  screenshot_path=str(final_shot),
170
170
  )
171
- except NeedsHumanError as exc:
171
+ except AutomationBlockedError as exc:
172
172
  return self._failure_result(
173
173
  device,
174
174
  run_dir,
@@ -179,7 +179,7 @@ class XAdbPublisher:
179
179
  post_type=post_type,
180
180
  remote_media_path=remote,
181
181
  link_url=link_url,
182
- status="needs_human",
182
+ status="failed",
183
183
  error=str(exc),
184
184
  )
185
185
  except Exception as exc:
@@ -244,7 +244,7 @@ class XAdbPublisher:
244
244
  self.adb.screenshot(device.adb_serial, run_dir / "home.png")
245
245
  focus = self.adb.current_focus(device.adb_serial)
246
246
  if X_PACKAGE not in focus:
247
- raise NeedsHumanError("X is not foreground after launch; check install/login/verification state")
247
+ raise AutomationBlockedError("X is not foreground after launch; check install/login/verification state")
248
248
  self._raise_if_blocked(device, run_dir / "home.xml")
249
249
 
250
250
  def _open_compose(self, device: Device, run_dir: Path) -> None:
@@ -300,7 +300,7 @@ class XAdbPublisher:
300
300
  self._reveal_compose_button(device)
301
301
 
302
302
  self.adb.screenshot(device.adb_serial, run_dir / "compose-entry-missing.png")
303
- raise NeedsHumanError("X compose button was not found after scrolling the timeline")
303
+ raise AutomationBlockedError("X compose button was not found after scrolling the timeline")
304
304
 
305
305
  def _reveal_compose_button(self, device: Device) -> None:
306
306
  # X hides the bottom-right plus after the timeline sits idle or during scroll.
@@ -329,7 +329,7 @@ class XAdbPublisher:
329
329
  root = ui.dump_ui(self.adb, device, run_dir / "before-text.xml")
330
330
  field = ui.find_node(root, ids={f"{X_PACKAGE}:id/tweet_text"}, id_suffixes={"tweet_text"}, clickable_only=False)
331
331
  if field is None:
332
- raise NeedsHumanError("X compose text field was not found")
332
+ raise AutomationBlockedError("X compose text field was not found")
333
333
  ui.tap_node(self.adb, device, field, sleep_s=0.5)
334
334
  result = self.adb.input_text(device.adb_serial, text)
335
335
  if not result.ok:
@@ -363,7 +363,7 @@ class XAdbPublisher:
363
363
  root = ui.dump_ui(self.adb, device, run_dir / "before-gallery.xml")
364
364
  gallery = ui.find_node(root, ids={f"{X_PACKAGE}:id/gallery"}, id_suffixes={"gallery"}, descs={"相片", "Photo", "Photos"})
365
365
  if gallery is None:
366
- raise NeedsHumanError("X gallery button was not found on compose form")
366
+ raise AutomationBlockedError("X gallery button was not found on compose form")
367
367
  ui.tap_node(self.adb, device, gallery, sleep_s=3)
368
368
  self._tap_permission_if_present(device, run_dir / "gallery-permission.xml")
369
369
 
@@ -371,7 +371,7 @@ class XAdbPublisher:
371
371
  image_node = _find_first_gallery_image(grid_root)
372
372
  if image_node is None:
373
373
  self.adb.screenshot(device.adb_serial, run_dir / "gallery-image-missing.png")
374
- raise NeedsHumanError("X gallery did not expose a selectable image")
374
+ raise AutomationBlockedError("X gallery did not expose a selectable image")
375
375
  ui.tap_node(self.adb, device, image_node, sleep_s=1.5)
376
376
  self.adb.screenshot(device.adb_serial, run_dir / "gallery-selected.png")
377
377
 
@@ -387,7 +387,7 @@ class XAdbPublisher:
387
387
  )
388
388
  if done is None:
389
389
  self.adb.screenshot(device.adb_serial, run_dir / "gallery-done-missing.png")
390
- raise NeedsHumanError("X gallery Done/Add button was not found")
390
+ raise AutomationBlockedError("X gallery Done/Add button was not found")
391
391
  ui.tap_node(self.adb, device, done, sleep_s=3)
392
392
  self.adb.screenshot(device.adb_serial, run_dir / "after-image.png")
393
393
 
@@ -398,9 +398,9 @@ class XAdbPublisher:
398
398
  root = ui.dump_ui(self.adb, device, run_dir / "before-post.xml")
399
399
  node = ui.find_node(root, ids={f"{X_PACKAGE}:id/button_tweet"}, id_suffixes={"button_tweet"}, texts={"发帖", "Post", "Tweet"})
400
400
  if node is None:
401
- raise NeedsHumanError("X post button was not found on compose form")
401
+ raise AutomationBlockedError("X post button was not found on compose form")
402
402
  if node.attrib.get("enabled") != "true":
403
- raise NeedsHumanError("X post button is disabled; check text/media/content declaration")
403
+ raise AutomationBlockedError("X post button is disabled; check text/media/content declaration")
404
404
  ui.tap_node(self.adb, device, node, sleep_s=2)
405
405
 
406
406
  def _wait_for_publish_complete(self, device: Device, run_dir: Path, publish_text: str, *, verify_profile: bool) -> Path:
@@ -415,7 +415,7 @@ class XAdbPublisher:
415
415
  text = ui.all_text(root)
416
416
  if ui.contains_any(text, BLOCKING_TEXT_MARKERS):
417
417
  self.adb.screenshot(device.adb_serial, run_dir / "post-blocked.png")
418
- raise NeedsHumanError("X showed a verification/account restriction prompt after tapping post")
418
+ raise AutomationBlockedError("X showed a verification/account restriction prompt after tapping post")
419
419
  self.adb.screenshot(device.adb_serial, after_post_shot)
420
420
  if not verify_profile:
421
421
  return after_post_shot
@@ -424,7 +424,7 @@ class XAdbPublisher:
424
424
  root = ui.dump_ui(self.adb, device, run_dir / f"post-poll-{poll}.xml")
425
425
  text = ui.all_text(root)
426
426
  if ui.contains_any(text, BLOCKING_TEXT_MARKERS):
427
- raise NeedsHumanError("X showed a verification/account restriction prompt after tapping post")
427
+ raise AutomationBlockedError("X showed a verification/account restriction prompt after tapping post")
428
428
  time.sleep(2)
429
429
  self.adb.screenshot(device.adb_serial, run_dir / "post-timeout.png")
430
430
  raise RuntimeError("X still appears to be on the compose screen after tapping post")
@@ -459,7 +459,7 @@ class XAdbPublisher:
459
459
  time.sleep(2)
460
460
 
461
461
  self.adb.screenshot(device.adb_serial, run_dir / "profile-post-missing.png")
462
- raise NeedsHumanError("X publish completed, but the expected post text was not found on the profile page")
462
+ raise AutomationBlockedError("X publish completed, but the expected post text was not found on the profile page")
463
463
 
464
464
  def _open_profile(self, device: Device, run_dir: Path) -> None:
465
465
  if self._is_profile_open(device):
@@ -509,7 +509,7 @@ class XAdbPublisher:
509
509
  return
510
510
 
511
511
  self.adb.screenshot(device.adb_serial, run_dir / "profile-open-failed.png")
512
- raise NeedsHumanError("X profile page could not be opened from the navigation drawer")
512
+ raise AutomationBlockedError("X profile page could not be opened from the navigation drawer")
513
513
 
514
514
  def _is_profile_open(self, device: Device) -> bool:
515
515
  return X_PROFILE_ACTIVITY in self.adb.current_focus(device.adb_serial)
@@ -521,7 +521,7 @@ class XAdbPublisher:
521
521
  return
522
522
  text = ui.all_text(root)
523
523
  if ui.contains_any(text, BLOCKING_TEXT_MARKERS):
524
- raise NeedsHumanError("X login, verification, or account restriction prompt is visible")
524
+ raise AutomationBlockedError("X login, verification, or account restriction prompt is visible")
525
525
 
526
526
  @staticmethod
527
527
  def _ok(result, action: str) -> None: