@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
@@ -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": 45,
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
- self.adb.screenshot(device.adb_serial, run_dir / "disclosure-prompt.png")
285
- if not confirm_no_disclosure_needed:
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="needs_human",
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(run_dir / "disclosure-prompt.png"),
296
- error="content disclosure prompt needs explicit confirmation",
310
+ screenshot_path=str(prompt_shot),
311
+ error="content disclosure prompt was not auto-approved by policy",
297
312
  )
298
- self._tap_confirm_publish(device)
299
- time.sleep(self._wait("after_confirm_publish"))
300
- final_shot = self._wait_for_publish_complete(device, run_dir)
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
- if prepare:
372
- self._prepare_device(device)
373
- else:
374
- self._ok(self.adb.wake(device.adb_serial), "wake device")
375
- focus = self.adb.current_focus(device.adb_serial)
376
- if not any(keyword in focus for keyword in self._keywords("detail_focus_keywords")):
377
- self._open_latest_own_note_for_link(device, run_dir)
378
- self.adb.screenshot(device.adb_serial, run_dir / "before-copy-link.png")
379
- self._tap_coord(device, "note_share", self._wait("after_share_tap"))
380
- self.adb.screenshot(device.adb_serial, run_dir / "share-sheet.png")
381
- self._tap_copy_link(device, run_dir)
382
- time.sleep(self._wait("after_copy_link"))
383
- final_shot = run_dir / "after-copy-link.png"
384
- self.adb.screenshot(device.adb_serial, final_shot)
385
-
386
- permalink, method, error = self._read_copied_link(device, run_dir)
387
- if permalink:
388
- status = "retrieved"
389
- else:
390
- status = "needs_human"
391
- error = error or "copied link to device clipboard, but automatic clipboard read returned no URL"
392
- return XiaohongshuLinkResult(
393
- device_id=device.device_id,
394
- status=status,
395
- started_at=started_at,
396
- ended_at=_now_shanghai(),
397
- duration_seconds=int(time.time()) - started_epoch,
398
- platform_permalink=permalink,
399
- screenshot_path=str(final_shot),
400
- clipboard_method=method,
401
- error=error,
402
- )
403
- except Exception as exc:
404
- failure_shot = run_dir / "failed-copy-link.png"
405
- self.adb.screenshot(device.adb_serial, failure_shot)
406
- return XiaohongshuLinkResult(
407
- device_id=device.device_id,
408
- status="failed",
409
- started_at=started_at,
410
- ended_at=_now_shanghai(),
411
- duration_seconds=int(time.time()) - started_epoch,
412
- platform_permalink="",
413
- screenshot_path=str(failure_shot),
414
- error=str(exc),
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 _open_latest_own_note_for_link(self, device: Device, run_dir: Path) -> None:
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
- return
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
- for attempt in range(3):
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
- time.sleep(1)
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._screen_size()
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.attrib.get("clickable") == "true":
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] + "..."