@11agents/cli 0.1.26 → 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 +13 -0
|
@@ -74,7 +74,7 @@ DEFAULT_FLOW_CONFIG = {
|
|
|
74
74
|
"after_paste_probe": 1,
|
|
75
75
|
"detail_load_timeout": 12,
|
|
76
76
|
"publish_poll_interval": 2,
|
|
77
|
-
"publish_timeout":
|
|
77
|
+
"publish_timeout": 180,
|
|
78
78
|
},
|
|
79
79
|
"success_focus_keywords": ["IndexActivityV2"],
|
|
80
80
|
"detail_focus_keywords": ["NoteDetailActivity"],
|
|
@@ -116,6 +116,10 @@ class XiaohongshuAdbPublishResult:
|
|
|
116
116
|
link_status: str = ""
|
|
117
117
|
link_error: str = ""
|
|
118
118
|
link_screenshot_path: str = ""
|
|
119
|
+
verification_status: str = ""
|
|
120
|
+
verification_error: str = ""
|
|
121
|
+
profile_screenshot_path: str = ""
|
|
122
|
+
detail_screenshot_path: str = ""
|
|
119
123
|
|
|
120
124
|
|
|
121
125
|
@dataclass
|
|
@@ -131,6 +135,12 @@ class XiaohongshuLinkResult:
|
|
|
131
135
|
error: str = ""
|
|
132
136
|
|
|
133
137
|
|
|
138
|
+
class XiaohongshuAutomationError(RuntimeError):
|
|
139
|
+
def __init__(self, message: str, screenshot_path: str | Path = "") -> None:
|
|
140
|
+
super().__init__(message)
|
|
141
|
+
self.screenshot_path = str(screenshot_path)
|
|
142
|
+
|
|
143
|
+
|
|
134
144
|
class XiaohongshuAdbPublisher:
|
|
135
145
|
"""Xiaohongshu image/video publisher for a configured device flow."""
|
|
136
146
|
|
|
@@ -281,23 +291,32 @@ class XiaohongshuAdbPublisher:
|
|
|
281
291
|
status = "dry_run"
|
|
282
292
|
else:
|
|
283
293
|
self._tap_first_publish(device)
|
|
284
|
-
|
|
285
|
-
|
|
294
|
+
prompt_state, prompt_shot = self._handle_after_first_publish_tap(
|
|
295
|
+
device,
|
|
296
|
+
run_dir,
|
|
297
|
+
confirm_no_disclosure_needed=confirm_no_disclosure_needed,
|
|
298
|
+
)
|
|
299
|
+
if prompt_state == "blocked":
|
|
300
|
+
self._close_xiaohongshu_best_effort(device)
|
|
286
301
|
return XiaohongshuAdbPublishResult(
|
|
287
302
|
device_id=device.device_id,
|
|
288
|
-
status="
|
|
303
|
+
status="failed",
|
|
289
304
|
record_id=record_id,
|
|
290
305
|
started_at=started_at,
|
|
291
306
|
ended_at=_now_shanghai(),
|
|
292
307
|
duration_seconds=int(time.time()) - started_epoch,
|
|
293
308
|
post_type=post_type,
|
|
294
309
|
remote_media_path=remote,
|
|
295
|
-
screenshot_path=str(
|
|
296
|
-
error="content disclosure prompt
|
|
310
|
+
screenshot_path=str(prompt_shot),
|
|
311
|
+
error="content disclosure prompt was not auto-approved by policy",
|
|
297
312
|
)
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
313
|
+
composed_caption = _compose_caption_for_record(caption, tag_values, topic_values)
|
|
314
|
+
final_shot = self._verify_published_note_and_open_detail(
|
|
315
|
+
device,
|
|
316
|
+
run_dir,
|
|
317
|
+
expected_title=title,
|
|
318
|
+
expected_caption=composed_caption,
|
|
319
|
+
)
|
|
301
320
|
status = "published"
|
|
302
321
|
if copy_link_after_publish:
|
|
303
322
|
link_result = self.copy_current_note_link(device, prepare=False, run_dir=run_dir / "link")
|
|
@@ -336,10 +355,35 @@ class XiaohongshuAdbPublisher:
|
|
|
336
355
|
link_status=link_result.status if link_result else "",
|
|
337
356
|
link_error=link_result.error if link_result else "",
|
|
338
357
|
link_screenshot_path=link_result.screenshot_path if link_result else "",
|
|
358
|
+
verification_status="verified" if status == "published" else "",
|
|
359
|
+
profile_screenshot_path=str(run_dir / "profile.png") if (run_dir / "profile.png").exists() else "",
|
|
360
|
+
detail_screenshot_path=str(final_shot) if status == "published" else "",
|
|
361
|
+
)
|
|
362
|
+
except XiaohongshuAutomationError as exc:
|
|
363
|
+
human_shot = Path(exc.screenshot_path) if exc.screenshot_path else run_dir / "verification-failed.png"
|
|
364
|
+
if not human_shot.exists():
|
|
365
|
+
self.adb.screenshot(device.adb_serial, human_shot)
|
|
366
|
+
self._close_xiaohongshu_best_effort(device)
|
|
367
|
+
return XiaohongshuAdbPublishResult(
|
|
368
|
+
device_id=device.device_id,
|
|
369
|
+
status="failed",
|
|
370
|
+
record_id=record_id,
|
|
371
|
+
started_at=started_at,
|
|
372
|
+
ended_at=_now_shanghai(),
|
|
373
|
+
duration_seconds=int(time.time()) - started_epoch,
|
|
374
|
+
post_type=post_type,
|
|
375
|
+
remote_media_path=remote,
|
|
376
|
+
screenshot_path=str(human_shot),
|
|
377
|
+
error=str(exc),
|
|
378
|
+
verification_status="failed",
|
|
379
|
+
verification_error=str(exc),
|
|
380
|
+
profile_screenshot_path=str(run_dir / "profile.png") if (run_dir / "profile.png").exists() else "",
|
|
381
|
+
detail_screenshot_path=str(run_dir / "published-note-detail.png") if (run_dir / "published-note-detail.png").exists() else "",
|
|
339
382
|
)
|
|
340
383
|
except Exception as exc:
|
|
341
384
|
failure_shot = run_dir / "failed.png"
|
|
342
385
|
self.adb.screenshot(device.adb_serial, failure_shot)
|
|
386
|
+
self._close_xiaohongshu_best_effort(device)
|
|
343
387
|
return XiaohongshuAdbPublishResult(
|
|
344
388
|
device_id=device.device_id,
|
|
345
389
|
status="failed",
|
|
@@ -359,6 +403,9 @@ class XiaohongshuAdbPublisher:
|
|
|
359
403
|
*,
|
|
360
404
|
prepare: bool = True,
|
|
361
405
|
run_dir: Path | None = None,
|
|
406
|
+
expected_title: str = "",
|
|
407
|
+
expected_caption: str = "",
|
|
408
|
+
close_after_recovery: bool = True,
|
|
362
409
|
) -> XiaohongshuLinkResult:
|
|
363
410
|
started_epoch = int(time.time())
|
|
364
411
|
started_at = _now_shanghai()
|
|
@@ -368,51 +415,82 @@ class XiaohongshuAdbPublisher:
|
|
|
368
415
|
run_dir.mkdir(parents=True, exist_ok=True)
|
|
369
416
|
|
|
370
417
|
try:
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
self.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
418
|
+
try:
|
|
419
|
+
if prepare:
|
|
420
|
+
self._prepare_device(device)
|
|
421
|
+
else:
|
|
422
|
+
self._ok(self.adb.wake(device.adb_serial), "wake device")
|
|
423
|
+
focus = self.adb.current_focus(device.adb_serial)
|
|
424
|
+
if any(keyword in focus for keyword in self._keywords("detail_focus_keywords")):
|
|
425
|
+
if expected_title or expected_caption:
|
|
426
|
+
try:
|
|
427
|
+
self._verify_current_note_detail(
|
|
428
|
+
device,
|
|
429
|
+
run_dir,
|
|
430
|
+
expected_title=expected_title,
|
|
431
|
+
expected_caption=expected_caption,
|
|
432
|
+
prefix="current-note-detail",
|
|
433
|
+
)
|
|
434
|
+
except XiaohongshuAutomationError:
|
|
435
|
+
self._leave_detail_if_needed(device)
|
|
436
|
+
self._open_latest_own_note_for_link(
|
|
437
|
+
device,
|
|
438
|
+
run_dir,
|
|
439
|
+
expected_title=expected_title,
|
|
440
|
+
expected_caption=expected_caption,
|
|
441
|
+
)
|
|
442
|
+
else:
|
|
443
|
+
self._open_latest_own_note_for_link(
|
|
444
|
+
device,
|
|
445
|
+
run_dir,
|
|
446
|
+
expected_title=expected_title,
|
|
447
|
+
expected_caption=expected_caption,
|
|
448
|
+
)
|
|
449
|
+
self.adb.screenshot(device.adb_serial, run_dir / "before-copy-link.png")
|
|
450
|
+
self._tap_coord(device, "note_share", self._wait("after_share_tap"))
|
|
451
|
+
self.adb.screenshot(device.adb_serial, run_dir / "share-sheet.png")
|
|
452
|
+
self._tap_copy_link(device, run_dir)
|
|
453
|
+
time.sleep(self._wait("after_copy_link"))
|
|
454
|
+
final_shot = run_dir / "after-copy-link.png"
|
|
455
|
+
self.adb.screenshot(device.adb_serial, final_shot)
|
|
456
|
+
|
|
457
|
+
permalink, method, error = self._read_copied_link(device, run_dir)
|
|
458
|
+
if permalink:
|
|
459
|
+
status = "retrieved"
|
|
460
|
+
else:
|
|
461
|
+
status = "failed"
|
|
462
|
+
error = error or "copied link to device clipboard, but automatic clipboard read returned no URL"
|
|
463
|
+
return XiaohongshuLinkResult(
|
|
464
|
+
device_id=device.device_id,
|
|
465
|
+
status=status,
|
|
466
|
+
started_at=started_at,
|
|
467
|
+
ended_at=_now_shanghai(),
|
|
468
|
+
duration_seconds=int(time.time()) - started_epoch,
|
|
469
|
+
platform_permalink=permalink,
|
|
470
|
+
screenshot_path=str(final_shot),
|
|
471
|
+
clipboard_method=method,
|
|
472
|
+
error=error,
|
|
473
|
+
)
|
|
474
|
+
except Exception as exc:
|
|
475
|
+
failure_shot = run_dir / "failed-copy-link.png"
|
|
476
|
+
self.adb.screenshot(device.adb_serial, failure_shot)
|
|
477
|
+
return XiaohongshuLinkResult(
|
|
478
|
+
device_id=device.device_id,
|
|
479
|
+
status="failed",
|
|
480
|
+
started_at=started_at,
|
|
481
|
+
ended_at=_now_shanghai(),
|
|
482
|
+
duration_seconds=int(time.time()) - started_epoch,
|
|
483
|
+
platform_permalink="",
|
|
484
|
+
screenshot_path=str(failure_shot),
|
|
485
|
+
error=str(exc),
|
|
486
|
+
)
|
|
487
|
+
finally:
|
|
488
|
+
if close_after_recovery:
|
|
489
|
+
try:
|
|
490
|
+
self._close_xiaohongshu(device)
|
|
491
|
+
self.adb.screenshot(device.adb_serial, run_dir / "after-app-close.png")
|
|
492
|
+
except Exception:
|
|
493
|
+
pass
|
|
416
494
|
|
|
417
495
|
def _prepare_device(self, device: Device) -> None:
|
|
418
496
|
self._ok(self.adb.wake(device.adb_serial), "wake device")
|
|
@@ -426,6 +504,15 @@ class XiaohongshuAdbPublisher:
|
|
|
426
504
|
self._ok(self.adb.launch_package(device.adb_serial, self.package), "launch Xiaohongshu")
|
|
427
505
|
time.sleep(self._wait("after_launch"))
|
|
428
506
|
|
|
507
|
+
def _close_xiaohongshu(self, device: Device) -> None:
|
|
508
|
+
self._ok(self.adb.force_stop(device.adb_serial, self.package), "close Xiaohongshu")
|
|
509
|
+
|
|
510
|
+
def _close_xiaohongshu_best_effort(self, device: Device) -> None:
|
|
511
|
+
try:
|
|
512
|
+
self._close_xiaohongshu(device)
|
|
513
|
+
except Exception:
|
|
514
|
+
pass
|
|
515
|
+
|
|
429
516
|
def _open_album_flow(self, device: Device, run_dir: Path) -> None:
|
|
430
517
|
self._tap_coord(device, "home_plus", self._wait("after_plus"))
|
|
431
518
|
ui.tap_permission_prompt_if_present(self.adb, device, run_dir / "after-plus-permission.xml")
|
|
@@ -661,20 +748,64 @@ class XiaohongshuAdbPublisher:
|
|
|
661
748
|
if not any(keyword in focus for keyword in self._keywords("post_form_focus_keywords")):
|
|
662
749
|
raise RuntimeError(f"resume_post_form requires Xiaohongshu post form; current_focus={focus}")
|
|
663
750
|
|
|
664
|
-
def
|
|
751
|
+
def _verify_published_note_and_open_detail(
|
|
752
|
+
self,
|
|
753
|
+
device: Device,
|
|
754
|
+
run_dir: Path,
|
|
755
|
+
*,
|
|
756
|
+
expected_title: str,
|
|
757
|
+
expected_caption: str,
|
|
758
|
+
) -> Path:
|
|
759
|
+
self._wait_for_publish_exit_after_confirm(device, run_dir)
|
|
760
|
+
return self._open_latest_own_note_for_link(
|
|
761
|
+
device,
|
|
762
|
+
run_dir,
|
|
763
|
+
expected_title=expected_title,
|
|
764
|
+
expected_caption=expected_caption,
|
|
765
|
+
force_profile=True,
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
def _open_latest_own_note_for_link(
|
|
769
|
+
self,
|
|
770
|
+
device: Device,
|
|
771
|
+
run_dir: Path,
|
|
772
|
+
*,
|
|
773
|
+
expected_title: str = "",
|
|
774
|
+
expected_caption: str = "",
|
|
775
|
+
force_profile: bool = False,
|
|
776
|
+
) -> Path:
|
|
665
777
|
focus = self.adb.current_focus(device.adb_serial)
|
|
666
778
|
if self.package not in focus:
|
|
667
779
|
self._launch_xiaohongshu(device)
|
|
780
|
+
elif any(keyword in focus for keyword in self._keywords("in_progress_focus_keywords")):
|
|
781
|
+
self._close_xiaohongshu(device)
|
|
782
|
+
self._launch_xiaohongshu(device)
|
|
668
783
|
|
|
669
784
|
self._dismiss_link_recovery_prompts(device, run_dir)
|
|
670
785
|
focus = self.adb.current_focus(device.adb_serial)
|
|
671
786
|
if any(keyword in focus for keyword in self._keywords("detail_focus_keywords")):
|
|
672
|
-
|
|
787
|
+
if force_profile:
|
|
788
|
+
self._leave_detail_if_needed(device)
|
|
789
|
+
else:
|
|
790
|
+
return self._verify_current_note_detail(
|
|
791
|
+
device,
|
|
792
|
+
run_dir,
|
|
793
|
+
expected_title=expected_title,
|
|
794
|
+
expected_caption=expected_caption,
|
|
795
|
+
prefix="current-note-detail",
|
|
796
|
+
)
|
|
673
797
|
|
|
674
798
|
self._open_me_tab(device, run_dir)
|
|
675
799
|
self._dismiss_link_recovery_prompts(device, run_dir)
|
|
676
|
-
self._tap_latest_profile_note(device, run_dir)
|
|
800
|
+
self._tap_latest_profile_note(device, run_dir, expected_title=expected_title)
|
|
677
801
|
self._wait_for_detail_screen(device, run_dir)
|
|
802
|
+
return self._verify_current_note_detail(
|
|
803
|
+
device,
|
|
804
|
+
run_dir,
|
|
805
|
+
expected_title=expected_title,
|
|
806
|
+
expected_caption=expected_caption,
|
|
807
|
+
prefix="published-note-detail",
|
|
808
|
+
)
|
|
678
809
|
|
|
679
810
|
def _dismiss_link_recovery_prompts(self, device: Device, run_dir: Path) -> None:
|
|
680
811
|
for idx in range(2):
|
|
@@ -691,7 +822,7 @@ class XiaohongshuAdbPublisher:
|
|
|
691
822
|
if node is not None:
|
|
692
823
|
ui.tap_node(self.adb, device, node, sleep_s=self._wait("after_prompt_dismiss"))
|
|
693
824
|
continue
|
|
694
|
-
if any(marker in all_text for marker in ("
|
|
825
|
+
if any(marker in all_text for marker in ("完善个人资料", "上传头像", "填写名字")):
|
|
695
826
|
self._tap_coord_or_default(
|
|
696
827
|
device,
|
|
697
828
|
"post_publish_prompt_close",
|
|
@@ -713,15 +844,26 @@ class XiaohongshuAdbPublisher:
|
|
|
713
844
|
self._tap_coord_or_default(device, "bottom_me", self._relative_coord(0.9, 0.93), self._wait("after_me_tab"))
|
|
714
845
|
self.adb.screenshot(device.adb_serial, run_dir / "profile.png")
|
|
715
846
|
|
|
716
|
-
def _tap_latest_profile_note(self, device: Device, run_dir: Path) -> None:
|
|
717
|
-
|
|
847
|
+
def _tap_latest_profile_note(self, device: Device, run_dir: Path, *, expected_title: str = "") -> None:
|
|
848
|
+
attempts = 8 if expected_title else 3
|
|
849
|
+
for attempt in range(attempts):
|
|
718
850
|
root = ui.dump_ui(self.adb, device, run_dir / f"profile-before-latest-note-{attempt + 1}.xml")
|
|
719
|
-
node = self._find_latest_profile_note_node(root)
|
|
851
|
+
node = self._find_latest_profile_note_node(root, expected_title=expected_title)
|
|
720
852
|
if node is not None:
|
|
721
853
|
ui.tap_node(self.adb, device, node, sleep_s=self._wait("after_latest_note_tap"))
|
|
722
854
|
self.adb.screenshot(device.adb_serial, run_dir / "latest-note-opened.png")
|
|
723
855
|
return
|
|
724
|
-
|
|
856
|
+
if expected_title and attempt < attempts - 1:
|
|
857
|
+
self._scroll_profile_notes_down_half_screen(device)
|
|
858
|
+
self.adb.screenshot(device.adb_serial, run_dir / f"profile-after-scroll-{attempt + 1}.png")
|
|
859
|
+
time.sleep(2 if expected_title else 1)
|
|
860
|
+
if expected_title:
|
|
861
|
+
not_found_shot = run_dir / "expected-profile-note-not-found.png"
|
|
862
|
+
self.adb.screenshot(device.adb_serial, not_found_shot)
|
|
863
|
+
raise XiaohongshuAutomationError(
|
|
864
|
+
f"expected Xiaohongshu profile note was not visible after publish: {expected_title!r}",
|
|
865
|
+
not_found_shot,
|
|
866
|
+
)
|
|
725
867
|
self._tap_coord_or_default(
|
|
726
868
|
device,
|
|
727
869
|
"profile_latest_note",
|
|
@@ -744,6 +886,46 @@ class XiaohongshuAdbPublisher:
|
|
|
744
886
|
self.adb.screenshot(device.adb_serial, run_dir / "latest-note-detail-timeout.png")
|
|
745
887
|
raise RuntimeError(f"latest own Xiaohongshu note did not open; current_focus={last_focus}")
|
|
746
888
|
|
|
889
|
+
def _leave_detail_if_needed(self, device: Device) -> None:
|
|
890
|
+
focus = self.adb.current_focus(device.adb_serial)
|
|
891
|
+
if any(keyword in focus for keyword in self._keywords("detail_focus_keywords")):
|
|
892
|
+
self._ok(self.adb.keyevent(device.adb_serial, "KEYCODE_BACK"), "leave note detail")
|
|
893
|
+
time.sleep(self._wait("after_prompt_dismiss"))
|
|
894
|
+
|
|
895
|
+
def _verify_current_note_detail(
|
|
896
|
+
self,
|
|
897
|
+
device: Device,
|
|
898
|
+
run_dir: Path,
|
|
899
|
+
*,
|
|
900
|
+
expected_title: str,
|
|
901
|
+
expected_caption: str,
|
|
902
|
+
prefix: str,
|
|
903
|
+
) -> Path:
|
|
904
|
+
detail_shot = run_dir / f"{prefix}.png"
|
|
905
|
+
detail_xml = run_dir / f"{prefix}.xml"
|
|
906
|
+
self.adb.screenshot(device.adb_serial, detail_shot)
|
|
907
|
+
if not expected_title and not expected_caption:
|
|
908
|
+
return detail_shot
|
|
909
|
+
|
|
910
|
+
try:
|
|
911
|
+
root = ui.dump_ui(self.adb, device, detail_xml)
|
|
912
|
+
visible_text = ui.all_text(root)
|
|
913
|
+
except Exception as exc:
|
|
914
|
+
mismatch_shot = run_dir / f"{prefix}-verification-failed.png"
|
|
915
|
+
self.adb.screenshot(device.adb_serial, mismatch_shot)
|
|
916
|
+
raise XiaohongshuAutomationError(
|
|
917
|
+
f"could not verify Xiaohongshu note detail text: {_short(str(exc))}",
|
|
918
|
+
mismatch_shot,
|
|
919
|
+
) from exc
|
|
920
|
+
|
|
921
|
+
matched, reason = _note_text_matches_expected(visible_text, expected_title, expected_caption)
|
|
922
|
+
if matched:
|
|
923
|
+
return detail_shot
|
|
924
|
+
|
|
925
|
+
mismatch_shot = run_dir / f"{prefix}-mismatch.png"
|
|
926
|
+
self.adb.screenshot(device.adb_serial, mismatch_shot)
|
|
927
|
+
raise XiaohongshuAutomationError(f"latest Xiaohongshu note did not match expected content: {reason}", mismatch_shot)
|
|
928
|
+
|
|
747
929
|
def _find_bottom_tab(self, root, labels: set[str]):
|
|
748
930
|
_width, height = self._screen_size()
|
|
749
931
|
min_top = int(height * 0.72)
|
|
@@ -758,10 +940,11 @@ class XiaohongshuAdbPublisher:
|
|
|
758
940
|
return node
|
|
759
941
|
return None
|
|
760
942
|
|
|
761
|
-
def _find_latest_profile_note_node(self, root):
|
|
762
|
-
width, height = self.
|
|
943
|
+
def _find_latest_profile_note_node(self, root, *, expected_title: str = ""):
|
|
944
|
+
width, height = self._ui_root_size(root)
|
|
763
945
|
min_top = self._profile_note_min_top(root, height)
|
|
764
|
-
bottom_guard = int(height * 0.08)
|
|
946
|
+
bottom_guard = 0 if expected_title else int(height * 0.08)
|
|
947
|
+
expected = _normalize_match_text(expected_title)
|
|
765
948
|
candidates = []
|
|
766
949
|
image_candidates = []
|
|
767
950
|
blocked_text = {"首页", "购物", "发布", "消息", "我", "关注", "编辑资料", "设置"}
|
|
@@ -781,10 +964,15 @@ class XiaohongshuAdbPublisher:
|
|
|
781
964
|
desc = node.attrib.get("content-desc", "")
|
|
782
965
|
if text in blocked_text or desc in blocked_text:
|
|
783
966
|
continue
|
|
967
|
+
note_text = f"{text}\n{desc}"
|
|
968
|
+
if expected:
|
|
969
|
+
if not _looks_like_profile_note_card(node) or expected not in _normalize_match_text(note_text):
|
|
970
|
+
continue
|
|
971
|
+
return node
|
|
784
972
|
item = (top, left, node)
|
|
785
|
-
if node
|
|
973
|
+
if _looks_like_profile_note_card(node):
|
|
786
974
|
candidates.append(item)
|
|
787
|
-
elif node.attrib.get("class") == "android.widget.ImageView":
|
|
975
|
+
elif not expected and node.attrib.get("class") == "android.widget.ImageView":
|
|
788
976
|
image_candidates.append(item)
|
|
789
977
|
|
|
790
978
|
chosen = candidates or image_candidates
|
|
@@ -792,6 +980,30 @@ class XiaohongshuAdbPublisher:
|
|
|
792
980
|
return None
|
|
793
981
|
return sorted(chosen, key=lambda item: (item[0], item[1]))[0][2]
|
|
794
982
|
|
|
983
|
+
def _scroll_profile_notes_down_half_screen(self, device: Device) -> None:
|
|
984
|
+
width, height = self._screen_size()
|
|
985
|
+
x = width // 2
|
|
986
|
+
start_y = int(height * 0.82)
|
|
987
|
+
end_y = int(height * 0.50)
|
|
988
|
+
self._ok(self.adb.swipe(device.adb_serial, x, start_y, x, end_y, 350), "scroll profile notes down")
|
|
989
|
+
|
|
990
|
+
def _ui_root_size(self, root) -> tuple[int, int]:
|
|
991
|
+
width, height = self._screen_size()
|
|
992
|
+
max_right = 0
|
|
993
|
+
max_bottom = 0
|
|
994
|
+
for node in root.iter("node"):
|
|
995
|
+
bounds = ui.bounds(node)
|
|
996
|
+
if not bounds:
|
|
997
|
+
continue
|
|
998
|
+
_left, _top, right, bottom = bounds
|
|
999
|
+
max_right = max(max_right, right)
|
|
1000
|
+
max_bottom = max(max_bottom, bottom)
|
|
1001
|
+
if max_right > 0:
|
|
1002
|
+
width = max_right
|
|
1003
|
+
if max_bottom > 0:
|
|
1004
|
+
height = max_bottom
|
|
1005
|
+
return width, height
|
|
1006
|
+
|
|
795
1007
|
def _profile_note_min_top(self, root, height: int) -> int:
|
|
796
1008
|
min_top = int(height * 0.35)
|
|
797
1009
|
for node in root.iter("node"):
|
|
@@ -810,6 +1022,42 @@ class XiaohongshuAdbPublisher:
|
|
|
810
1022
|
def _tap_confirm_publish(self, device: Device) -> None:
|
|
811
1023
|
self._tap_coord(device, "confirm_publish", 0)
|
|
812
1024
|
|
|
1025
|
+
def _handle_after_first_publish_tap(
|
|
1026
|
+
self,
|
|
1027
|
+
device: Device,
|
|
1028
|
+
run_dir: Path,
|
|
1029
|
+
*,
|
|
1030
|
+
confirm_no_disclosure_needed: bool,
|
|
1031
|
+
) -> tuple[str, Path]:
|
|
1032
|
+
xml_path = run_dir / "after-first-publish.xml"
|
|
1033
|
+
shot_path = run_dir / "after-first-publish.png"
|
|
1034
|
+
root = None
|
|
1035
|
+
visible_text = ""
|
|
1036
|
+
try:
|
|
1037
|
+
root = ui.dump_ui(self.adb, device, xml_path)
|
|
1038
|
+
visible_text = ui.all_text(root)
|
|
1039
|
+
finally:
|
|
1040
|
+
self.adb.screenshot(device.adb_serial, shot_path)
|
|
1041
|
+
|
|
1042
|
+
status, reason = _publish_progress_status(visible_text)
|
|
1043
|
+
if status == "failed":
|
|
1044
|
+
raise XiaohongshuAutomationError(f"Xiaohongshu publish failed after first publish tap: {reason}", shot_path)
|
|
1045
|
+
if status in {"uploading", "success"}:
|
|
1046
|
+
return status, shot_path
|
|
1047
|
+
if root is None or not _publish_disclosure_prompt_visible(root, visible_text):
|
|
1048
|
+
return "waiting", shot_path
|
|
1049
|
+
if not confirm_no_disclosure_needed:
|
|
1050
|
+
return "blocked", shot_path
|
|
1051
|
+
|
|
1052
|
+
confirm_node = _find_publish_disclosure_confirm_node(root)
|
|
1053
|
+
if confirm_node is not None:
|
|
1054
|
+
ui.tap_node(self.adb, device, confirm_node, sleep_s=0)
|
|
1055
|
+
else:
|
|
1056
|
+
self._tap_confirm_publish(device)
|
|
1057
|
+
time.sleep(self._wait("after_confirm_publish"))
|
|
1058
|
+
self.adb.screenshot(device.adb_serial, run_dir / "after-disclosure-confirm.png")
|
|
1059
|
+
return "confirmed", shot_path
|
|
1060
|
+
|
|
813
1061
|
def _tap_copy_link(self, device: Device, run_dir: Path) -> None:
|
|
814
1062
|
xml_path = run_dir / "share-sheet.xml"
|
|
815
1063
|
try:
|
|
@@ -927,6 +1175,81 @@ class XiaohongshuAdbPublisher:
|
|
|
927
1175
|
return candidate
|
|
928
1176
|
return None
|
|
929
1177
|
|
|
1178
|
+
def _wait_for_publish_exit_after_confirm(self, device: Device, run_dir: Path) -> None:
|
|
1179
|
+
interval = max(0.5, self._wait("publish_poll_interval"))
|
|
1180
|
+
deadline = time.time() + max(self._wait("publish_timeout"), 180.0)
|
|
1181
|
+
last_focus = ""
|
|
1182
|
+
last_text = ""
|
|
1183
|
+
saw_upload_progress = False
|
|
1184
|
+
clear_progress_polls = 0
|
|
1185
|
+
stable_home_polls = 0
|
|
1186
|
+
attempt = 0
|
|
1187
|
+
|
|
1188
|
+
while time.time() < deadline:
|
|
1189
|
+
attempt += 1
|
|
1190
|
+
focus = self.adb.current_focus(device.adb_serial)
|
|
1191
|
+
if focus:
|
|
1192
|
+
last_focus = focus
|
|
1193
|
+
|
|
1194
|
+
status = "unknown"
|
|
1195
|
+
reason = ""
|
|
1196
|
+
try:
|
|
1197
|
+
root = ui.dump_ui(self.adb, device, run_dir / f"publish-progress-{attempt}.xml")
|
|
1198
|
+
visible_text = ui.all_text(root)
|
|
1199
|
+
last_text = _short(visible_text, 500)
|
|
1200
|
+
status, reason = _publish_progress_status(visible_text)
|
|
1201
|
+
except Exception as exc:
|
|
1202
|
+
last_text = f"ui dump failed: {_short(str(exc))}"
|
|
1203
|
+
|
|
1204
|
+
if status == "failed":
|
|
1205
|
+
failed_shot = run_dir / "publish-failed.png"
|
|
1206
|
+
self.adb.screenshot(device.adb_serial, failed_shot)
|
|
1207
|
+
raise XiaohongshuAutomationError(f"Xiaohongshu publish failed after publish tap: {reason}", failed_shot)
|
|
1208
|
+
if status == "success":
|
|
1209
|
+
success_shot = run_dir / "publish-success.png"
|
|
1210
|
+
self.adb.screenshot(device.adb_serial, success_shot)
|
|
1211
|
+
return
|
|
1212
|
+
if status == "uploading":
|
|
1213
|
+
saw_upload_progress = True
|
|
1214
|
+
clear_progress_polls = 0
|
|
1215
|
+
stable_home_polls = 0
|
|
1216
|
+
if attempt == 1 or attempt % 5 == 0:
|
|
1217
|
+
self.adb.screenshot(device.adb_serial, run_dir / f"publish-uploading-{attempt}.png")
|
|
1218
|
+
time.sleep(interval)
|
|
1219
|
+
continue
|
|
1220
|
+
if saw_upload_progress:
|
|
1221
|
+
clear_progress_polls += 1
|
|
1222
|
+
if clear_progress_polls >= 3:
|
|
1223
|
+
complete_shot = run_dir / "publish-progress-complete.png"
|
|
1224
|
+
self.adb.screenshot(device.adb_serial, complete_shot)
|
|
1225
|
+
return
|
|
1226
|
+
elif self._is_publish_home_focus(focus):
|
|
1227
|
+
stable_home_polls += 1
|
|
1228
|
+
if stable_home_polls >= 3:
|
|
1229
|
+
complete_shot = run_dir / "publish-home-stable.png"
|
|
1230
|
+
self.adb.screenshot(device.adb_serial, complete_shot)
|
|
1231
|
+
return
|
|
1232
|
+
else:
|
|
1233
|
+
stable_home_polls = 0
|
|
1234
|
+
time.sleep(interval)
|
|
1235
|
+
|
|
1236
|
+
timeout_shot = run_dir / "after-publish-still-in-progress.png"
|
|
1237
|
+
self.adb.screenshot(device.adb_serial, timeout_shot)
|
|
1238
|
+
raise XiaohongshuAutomationError(
|
|
1239
|
+
"Xiaohongshu publish result was not confirmed before verification; "
|
|
1240
|
+
f"current_focus={last_focus}; last_text={last_text}",
|
|
1241
|
+
timeout_shot,
|
|
1242
|
+
)
|
|
1243
|
+
|
|
1244
|
+
def _is_publish_home_focus(self, focus: str) -> bool:
|
|
1245
|
+
if self.package not in focus:
|
|
1246
|
+
return False
|
|
1247
|
+
if any(keyword in focus for keyword in self._keywords("detail_focus_keywords")):
|
|
1248
|
+
return False
|
|
1249
|
+
if any(keyword in focus for keyword in self._keywords("in_progress_focus_keywords")):
|
|
1250
|
+
return False
|
|
1251
|
+
return any(keyword in focus for keyword in self._keywords("success_focus_keywords"))
|
|
1252
|
+
|
|
930
1253
|
def _wait_for_publish_complete(self, device: Device, run_dir: Path) -> Path:
|
|
931
1254
|
timeout = self._wait("publish_timeout")
|
|
932
1255
|
interval = max(0.5, self._wait("publish_poll_interval"))
|
|
@@ -1134,6 +1457,127 @@ def _extract_url(text: str) -> str:
|
|
|
1134
1457
|
return urls[0].rstrip(").,;,。]") if urls else ""
|
|
1135
1458
|
|
|
1136
1459
|
|
|
1460
|
+
def _note_text_matches_expected(visible_text: str, expected_title: str, expected_caption: str) -> tuple[bool, str]:
|
|
1461
|
+
haystack = _normalize_match_text(visible_text)
|
|
1462
|
+
title = _normalize_match_text(expected_title)
|
|
1463
|
+
caption_fragment = _verification_fragment(expected_caption)
|
|
1464
|
+
|
|
1465
|
+
if title and title not in haystack:
|
|
1466
|
+
return False, f"title not visible: {expected_title!r}"
|
|
1467
|
+
if caption_fragment and caption_fragment not in haystack:
|
|
1468
|
+
return False, f"caption fragment not visible: {caption_fragment!r}"
|
|
1469
|
+
if not title and not caption_fragment:
|
|
1470
|
+
return True, ""
|
|
1471
|
+
return True, ""
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
def _looks_like_profile_note_card(node) -> bool:
|
|
1475
|
+
text = node.attrib.get("text", "")
|
|
1476
|
+
desc = node.attrib.get("content-desc", "")
|
|
1477
|
+
resource_id = node.attrib.get("resource-id", "")
|
|
1478
|
+
haystack = f"{text}\n{desc}"
|
|
1479
|
+
if "笔记," in desc and "来自" in desc:
|
|
1480
|
+
return True
|
|
1481
|
+
if "笔记" in haystack and "来自" in haystack and ("赞" in haystack or "阅读" in haystack):
|
|
1482
|
+
return True
|
|
1483
|
+
return resource_id.endswith(":id/card_view") and bool(desc.strip()) and "笔记" in desc
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
def _publish_disclosure_prompt_visible(root, visible_text: str) -> bool:
|
|
1487
|
+
text = visible_text or ui.all_text(root)
|
|
1488
|
+
if _publish_progress_status(text)[0] in {"uploading", "success", "failed"}:
|
|
1489
|
+
return False
|
|
1490
|
+
prompt_markers = (
|
|
1491
|
+
"发布声明",
|
|
1492
|
+
"内容声明",
|
|
1493
|
+
"声明",
|
|
1494
|
+
"商业推广",
|
|
1495
|
+
"品牌合作",
|
|
1496
|
+
"利益相关",
|
|
1497
|
+
"推广合作",
|
|
1498
|
+
"是否涉及",
|
|
1499
|
+
"不涉及",
|
|
1500
|
+
"无需声明",
|
|
1501
|
+
)
|
|
1502
|
+
if not any(marker in text for marker in prompt_markers):
|
|
1503
|
+
return False
|
|
1504
|
+
return _find_publish_disclosure_confirm_node(root) is not None
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
def _find_publish_disclosure_confirm_node(root):
|
|
1508
|
+
confirm_texts = {
|
|
1509
|
+
"确认发布",
|
|
1510
|
+
"确认",
|
|
1511
|
+
"确定",
|
|
1512
|
+
"继续发布",
|
|
1513
|
+
"仍要发布",
|
|
1514
|
+
"发布",
|
|
1515
|
+
}
|
|
1516
|
+
deny_markers = ("取消", "返回", "暂不", "稍后", "不同意", "放弃")
|
|
1517
|
+
candidates = []
|
|
1518
|
+
for node in root.iter("node"):
|
|
1519
|
+
if not ui.is_visible(node):
|
|
1520
|
+
continue
|
|
1521
|
+
text = node.attrib.get("text", "")
|
|
1522
|
+
desc = node.attrib.get("content-desc", "")
|
|
1523
|
+
label = f"{text}\n{desc}".strip()
|
|
1524
|
+
if not label or any(marker in label for marker in deny_markers):
|
|
1525
|
+
continue
|
|
1526
|
+
if text not in confirm_texts and desc not in confirm_texts:
|
|
1527
|
+
continue
|
|
1528
|
+
value = ui.bounds(node)
|
|
1529
|
+
if not value:
|
|
1530
|
+
continue
|
|
1531
|
+
_left, top, _right, bottom = value
|
|
1532
|
+
score = 100 if node.attrib.get("clickable") == "true" else 0
|
|
1533
|
+
if bottom > 1600:
|
|
1534
|
+
score += 50
|
|
1535
|
+
candidates.append((score, top, node))
|
|
1536
|
+
if not candidates:
|
|
1537
|
+
return None
|
|
1538
|
+
return sorted(candidates, key=lambda item: (item[0], item[1]), reverse=True)[0][2]
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
def _publish_progress_status(visible_text: str) -> tuple[str, str]:
|
|
1542
|
+
text = visible_text or ""
|
|
1543
|
+
normalized = _normalize_match_text(text)
|
|
1544
|
+
if any(marker in text for marker in ("发布失败", "上传失败", "发送失败")):
|
|
1545
|
+
return "failed", _short(text)
|
|
1546
|
+
if "重试" in text and any(marker in text for marker in ("上传", "发布", "笔记")):
|
|
1547
|
+
return "failed", _short(text)
|
|
1548
|
+
if "发布成功" in text or "发布成功" in normalized:
|
|
1549
|
+
return "success", "publish success marker visible"
|
|
1550
|
+
upload_markers = (
|
|
1551
|
+
"上传中",
|
|
1552
|
+
"请勿离开小红书",
|
|
1553
|
+
"capa_progress_text",
|
|
1554
|
+
"capa_desc_text",
|
|
1555
|
+
"capa_progress_bar",
|
|
1556
|
+
)
|
|
1557
|
+
if any(marker in text for marker in upload_markers):
|
|
1558
|
+
return "uploading", "upload progress marker visible"
|
|
1559
|
+
if re.search(r"\b\d{1,3}%\b", text) and any(marker in text for marker in ("上传", "capa_", "progress")):
|
|
1560
|
+
return "uploading", "upload percent visible"
|
|
1561
|
+
return "unknown", ""
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
def _verification_fragment(text: str) -> str:
|
|
1565
|
+
normalized = _normalize_match_text(_strip_hashtags(text))
|
|
1566
|
+
if not normalized:
|
|
1567
|
+
normalized = _normalize_match_text(text)
|
|
1568
|
+
if len(normalized) <= 18:
|
|
1569
|
+
return normalized
|
|
1570
|
+
return normalized[:18]
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
def _strip_hashtags(text: str) -> str:
|
|
1574
|
+
return re.sub(r"#[^\s#]+", "", text or "").strip()
|
|
1575
|
+
|
|
1576
|
+
|
|
1577
|
+
def _normalize_match_text(text: str) -> str:
|
|
1578
|
+
return re.sub(r"\s+", "", text or "").strip().lower()
|
|
1579
|
+
|
|
1580
|
+
|
|
1137
1581
|
def _short(text: str, limit: int = 220) -> str:
|
|
1138
1582
|
one_line = " ".join((text or "").split())
|
|
1139
1583
|
return one_line if len(one_line) <= limit else one_line[: limit - 3] + "..."
|