@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.
- package/mobile-runtime/configs/platforms/xiaohongshu_d01.json +1 -1
- package/mobile-runtime/configs/platforms/xiaohongshu_d02.json +1 -1
- package/mobile-runtime/configs/platforms/xiaohongshu_d03.json +1 -1
- package/mobile-runtime/python/src/device_control/adb.py +3 -0
- package/mobile-runtime/python/src/device_control/cli.py +38 -19
- package/mobile-runtime/python/src/device_control/metrics/tiktok_account_adb.py +1 -1
- package/mobile-runtime/python/src/device_control/metrics/tiktok_video_adb.py +2 -2
- package/mobile-runtime/python/src/device_control/models.py +4 -4
- package/mobile-runtime/python/src/device_control/publishers/facebook_adb.py +6 -6
- package/mobile-runtime/python/src/device_control/publishers/instagram_adb.py +10 -10
- package/mobile-runtime/python/src/device_control/publishers/tiktok_adb.py +5 -5
- package/mobile-runtime/python/src/device_control/publishers/x_adb.py +16 -16
- package/mobile-runtime/python/src/device_control/publishers/xiaohongshu_adb.py +511 -67
- package/mobile-runtime/skills/android-collect-tiktok-metrics/SKILL.md +1 -1
- package/mobile-runtime/skills/android-group-control-cli/SKILL.md +1 -1
- package/mobile-runtime/skills/android-group-control-cli/references/command-reference.md +1 -1
- package/mobile-runtime/skills/android-publish-facebook/SKILL.md +1 -1
- package/mobile-runtime/skills/android-publish-instagram/SKILL.md +2 -2
- package/mobile-runtime/skills/android-publish-reddit/SKILL.md +1 -1
- package/mobile-runtime/skills/android-publish-tiktok/SKILL.md +1 -1
- package/mobile-runtime/skills/android-publish-x/SKILL.md +1 -1
- package/mobile-runtime/skills/android-publish-xiaohongshu/SKILL.md +8 -6
- package/mobile-runtime/skills/mobile-publish-device-health/SKILL.md +3 -3
- package/mobile-runtime/skills/mobile-publish-execution/SKILL.md +2 -2
- package/mobile-runtime/skills/mobile-publish-records/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/mobile.js +144 -1
- package/src/commands/runtime.js +28 -0
|
@@ -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
|
|
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
|
|
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",
|
|
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"
|
|
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"
|
|
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
|
-
|
|
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(
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
|
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", "")
|
|
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", "")
|
|
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 "
|
|
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="
|
|
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 = "
|
|
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 = "
|
|
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", "
|
|
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 = "
|
|
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", "
|
|
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
|
|
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
|
|
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="
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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="
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
155
|
+
except TikTokAutomationBlockedError as exc:
|
|
156
156
|
ended_epoch = int(time.time())
|
|
157
|
-
failure_shot = run_dir / "
|
|
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="
|
|
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
|
|
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
|
|
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
|
|
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="
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
401
|
+
raise AutomationBlockedError("X post button was not found on compose form")
|
|
402
402
|
if node.attrib.get("enabled") != "true":
|
|
403
|
-
raise
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|