@11agents/cli 0.1.42 → 0.1.43
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/python/src/device_control/cli.py +17 -2
- package/mobile-runtime/python/src/device_control/metrics/tiktok_video_adb.py +8 -3
- package/mobile-runtime/python/src/device_control/publishers/tiktok_adb.py +679 -12
- package/mobile-runtime/python/src/device_control/publishers/tiktok_appium.py +173 -14
- package/mobile-runtime/python/src/device_control/publishers/x_adb.py +319 -5
- package/mobile-runtime/skills/android-publish-tiktok/SKILL.md +3 -3
- package/mobile-runtime/skills/android-publish-x/SKILL.md +5 -1
- package/mobile-runtime/skills/android-publish-xiaohongshu/SKILL.md +1 -1
- package/mobile-runtime/skills/mobile-publish-execution/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/knowledge.js +23 -1
- package/src/commands/mobile.js +29 -6
- package/src/commands/runtime.js +4 -1
|
@@ -59,6 +59,21 @@ class XAdbPublishResult:
|
|
|
59
59
|
screenshot_path: str
|
|
60
60
|
remote_media_path: str = ""
|
|
61
61
|
link_url: str = ""
|
|
62
|
+
platform_permalink: str = ""
|
|
63
|
+
link_status: str = ""
|
|
64
|
+
link_error: str = ""
|
|
65
|
+
link_screenshot_path: str = ""
|
|
66
|
+
clipboard_method: str = ""
|
|
67
|
+
error: str = ""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class XAdbLinkResult:
|
|
72
|
+
device_id: str
|
|
73
|
+
status: str
|
|
74
|
+
platform_permalink: str
|
|
75
|
+
screenshot_path: str
|
|
76
|
+
clipboard_method: str = ""
|
|
62
77
|
error: str = ""
|
|
63
78
|
|
|
64
79
|
|
|
@@ -118,6 +133,7 @@ class XAdbPublisher:
|
|
|
118
133
|
remote = ""
|
|
119
134
|
if local is not None:
|
|
120
135
|
remote = f"/sdcard/DCIM/Camera/groupctl-{device.device_id}-x-{stamp}{local.suffix}"
|
|
136
|
+
link_result: XAdbLinkResult | None = None
|
|
121
137
|
|
|
122
138
|
try:
|
|
123
139
|
self._prepare_device(device)
|
|
@@ -136,6 +152,10 @@ class XAdbPublisher:
|
|
|
136
152
|
else:
|
|
137
153
|
self._tap_post(device, run_dir)
|
|
138
154
|
final_shot = self._wait_for_publish_complete(device, run_dir, publish_text, verify_profile=verify_profile)
|
|
155
|
+
link_result = self._copy_published_post_link(device, run_dir / "link", publish_text)
|
|
156
|
+
if link_result.status != "retrieved" or not link_result.platform_permalink:
|
|
157
|
+
raise AutomationBlockedError(f"X post-publish link recovery failed: {link_result.error or link_result.status}")
|
|
158
|
+
final_shot = Path(link_result.screenshot_path)
|
|
139
159
|
status = "published"
|
|
140
160
|
append_publish_record(
|
|
141
161
|
self.records_path,
|
|
@@ -148,12 +168,13 @@ class XAdbPublisher:
|
|
|
148
168
|
local_media_path=str(local) if local else "",
|
|
149
169
|
remote_media_path=remote,
|
|
150
170
|
caption=publish_text,
|
|
151
|
-
platform_permalink=
|
|
171
|
+
platform_permalink=link_result.platform_permalink,
|
|
152
172
|
published_at=utc_now_iso(),
|
|
153
173
|
result_screenshot_path=str(final_shot),
|
|
154
174
|
status="published",
|
|
155
175
|
),
|
|
156
176
|
)
|
|
177
|
+
self._close_x_best_effort(device, run_dir)
|
|
157
178
|
|
|
158
179
|
return XAdbPublishResult(
|
|
159
180
|
device_id=device.device_id,
|
|
@@ -167,9 +188,14 @@ class XAdbPublisher:
|
|
|
167
188
|
remote_media_path=remote,
|
|
168
189
|
link_url=link_url,
|
|
169
190
|
screenshot_path=str(final_shot),
|
|
191
|
+
platform_permalink=link_result.platform_permalink if link_result else "",
|
|
192
|
+
link_status=link_result.status if link_result else "",
|
|
193
|
+
link_error=link_result.error if link_result else "",
|
|
194
|
+
link_screenshot_path=link_result.screenshot_path if link_result else "",
|
|
195
|
+
clipboard_method=link_result.clipboard_method if link_result else "",
|
|
170
196
|
)
|
|
171
197
|
except AutomationBlockedError as exc:
|
|
172
|
-
|
|
198
|
+
result = self._failure_result(
|
|
173
199
|
device,
|
|
174
200
|
run_dir,
|
|
175
201
|
record_id,
|
|
@@ -181,9 +207,12 @@ class XAdbPublisher:
|
|
|
181
207
|
link_url=link_url,
|
|
182
208
|
status="failed",
|
|
183
209
|
error=str(exc),
|
|
210
|
+
link_result=link_result,
|
|
184
211
|
)
|
|
212
|
+
self._close_x_best_effort(device, run_dir)
|
|
213
|
+
return result
|
|
185
214
|
except Exception as exc:
|
|
186
|
-
|
|
215
|
+
result = self._failure_result(
|
|
187
216
|
device,
|
|
188
217
|
run_dir,
|
|
189
218
|
record_id,
|
|
@@ -195,7 +224,10 @@ class XAdbPublisher:
|
|
|
195
224
|
link_url=link_url,
|
|
196
225
|
status="failed",
|
|
197
226
|
error=str(exc),
|
|
227
|
+
link_result=link_result,
|
|
198
228
|
)
|
|
229
|
+
self._close_x_best_effort(device, run_dir)
|
|
230
|
+
return result
|
|
199
231
|
|
|
200
232
|
def _failure_result(
|
|
201
233
|
self,
|
|
@@ -211,9 +243,11 @@ class XAdbPublisher:
|
|
|
211
243
|
link_url: str = "",
|
|
212
244
|
status: str,
|
|
213
245
|
error: str,
|
|
246
|
+
link_result: XAdbLinkResult | None = None,
|
|
214
247
|
) -> XAdbPublishResult:
|
|
215
|
-
failure_shot = run_dir / f"{status}.png"
|
|
216
|
-
|
|
248
|
+
failure_shot = Path(link_result.screenshot_path) if link_result and link_result.screenshot_path else run_dir / f"{status}.png"
|
|
249
|
+
if not failure_shot.exists():
|
|
250
|
+
self.adb.screenshot(device.adb_serial, failure_shot)
|
|
217
251
|
return XAdbPublishResult(
|
|
218
252
|
device_id=device.device_id,
|
|
219
253
|
status=status,
|
|
@@ -226,6 +260,11 @@ class XAdbPublisher:
|
|
|
226
260
|
remote_media_path=remote_media_path,
|
|
227
261
|
link_url=link_url,
|
|
228
262
|
screenshot_path=str(failure_shot),
|
|
263
|
+
platform_permalink=link_result.platform_permalink if link_result else "",
|
|
264
|
+
link_status=link_result.status if link_result else "",
|
|
265
|
+
link_error=link_result.error if link_result else "",
|
|
266
|
+
link_screenshot_path=link_result.screenshot_path if link_result else "",
|
|
267
|
+
clipboard_method=link_result.clipboard_method if link_result else "",
|
|
229
268
|
error=error,
|
|
230
269
|
)
|
|
231
270
|
|
|
@@ -247,6 +286,18 @@ class XAdbPublisher:
|
|
|
247
286
|
raise AutomationBlockedError("X is not foreground after launch; check install/login/verification state")
|
|
248
287
|
self._raise_if_blocked(device, run_dir / "home.xml")
|
|
249
288
|
|
|
289
|
+
def _close_x(self, device: Device) -> None:
|
|
290
|
+
self._ok(self.adb.force_stop(device.adb_serial, X_PACKAGE), "close X")
|
|
291
|
+
|
|
292
|
+
def _close_x_best_effort(self, device: Device, run_dir: Path | None = None) -> None:
|
|
293
|
+
try:
|
|
294
|
+
self._close_x(device)
|
|
295
|
+
if run_dir is not None:
|
|
296
|
+
time.sleep(1)
|
|
297
|
+
self.adb.screenshot(device.adb_serial, run_dir / "after-app-close.png")
|
|
298
|
+
except Exception:
|
|
299
|
+
pass
|
|
300
|
+
|
|
250
301
|
def _open_compose(self, device: Device, run_dir: Path) -> None:
|
|
251
302
|
if self._is_compose_open(device, run_dir / "compose-check-initial.xml"):
|
|
252
303
|
return
|
|
@@ -461,6 +512,209 @@ class XAdbPublisher:
|
|
|
461
512
|
self.adb.screenshot(device.adb_serial, run_dir / "profile-post-missing.png")
|
|
462
513
|
raise AutomationBlockedError("X publish completed, but the expected post text was not found on the profile page")
|
|
463
514
|
|
|
515
|
+
def _copy_published_post_link(self, device: Device, run_dir: Path, publish_text: str) -> XAdbLinkResult:
|
|
516
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
517
|
+
try:
|
|
518
|
+
self._open_published_post_detail(device, run_dir, publish_text)
|
|
519
|
+
self._tap_share_from_detail(device, run_dir)
|
|
520
|
+
self._tap_copy_link(device, run_dir)
|
|
521
|
+
time.sleep(1.5)
|
|
522
|
+
final_shot = run_dir / "after-copy-link.png"
|
|
523
|
+
self.adb.screenshot(device.adb_serial, final_shot)
|
|
524
|
+
permalink, method, error = self._read_copied_link(device, run_dir)
|
|
525
|
+
if permalink:
|
|
526
|
+
return XAdbLinkResult(
|
|
527
|
+
device_id=device.device_id,
|
|
528
|
+
status="retrieved",
|
|
529
|
+
platform_permalink=permalink,
|
|
530
|
+
screenshot_path=str(final_shot),
|
|
531
|
+
clipboard_method=method,
|
|
532
|
+
)
|
|
533
|
+
return XAdbLinkResult(
|
|
534
|
+
device_id=device.device_id,
|
|
535
|
+
status="failed",
|
|
536
|
+
platform_permalink="",
|
|
537
|
+
screenshot_path=str(final_shot),
|
|
538
|
+
error=error or "copied link to device clipboard, but automatic clipboard read returned no URL",
|
|
539
|
+
)
|
|
540
|
+
except Exception as exc:
|
|
541
|
+
failure_shot = run_dir / "failed-copy-link.png"
|
|
542
|
+
self.adb.screenshot(device.adb_serial, failure_shot)
|
|
543
|
+
return XAdbLinkResult(
|
|
544
|
+
device_id=device.device_id,
|
|
545
|
+
status="failed",
|
|
546
|
+
platform_permalink="",
|
|
547
|
+
screenshot_path=str(failure_shot),
|
|
548
|
+
error=str(exc),
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
def _open_published_post_detail(self, device: Device, run_dir: Path, publish_text: str) -> Path:
|
|
552
|
+
if not self._is_profile_open(device):
|
|
553
|
+
self._open_profile(device, run_dir)
|
|
554
|
+
for attempt in range(6):
|
|
555
|
+
root = ui.dump_ui(self.adb, device, run_dir / f"profile-before-detail-{attempt + 1}.xml")
|
|
556
|
+
row = _find_post_row(root, publish_text)
|
|
557
|
+
if row is not None:
|
|
558
|
+
target = _find_post_text_node(row, publish_text) or row
|
|
559
|
+
self._tap_post_detail_target(device, target)
|
|
560
|
+
time.sleep(3)
|
|
561
|
+
detail_root = ui.dump_ui(self.adb, device, run_dir / f"detail-after-tap-{attempt + 1}.xml")
|
|
562
|
+
if self._is_post_detail_open(device, detail_root, publish_text):
|
|
563
|
+
detail_shot = run_dir / "post-detail.png"
|
|
564
|
+
self.adb.screenshot(device.adb_serial, detail_shot)
|
|
565
|
+
return detail_shot
|
|
566
|
+
if not self._is_profile_open(device):
|
|
567
|
+
self.adb.keyevent(device.adb_serial, "KEYCODE_BACK")
|
|
568
|
+
time.sleep(1)
|
|
569
|
+
self.adb.swipe(device.adb_serial, 540, 1800, 540, 760, 350)
|
|
570
|
+
time.sleep(2)
|
|
571
|
+
|
|
572
|
+
self.adb.screenshot(device.adb_serial, run_dir / "post-detail-open-failed.png")
|
|
573
|
+
raise AutomationBlockedError("X published post could not be opened from the profile page")
|
|
574
|
+
|
|
575
|
+
def _tap_post_detail_target(self, device: Device, node) -> None:
|
|
576
|
+
bounds = ui.bounds(node)
|
|
577
|
+
if not bounds:
|
|
578
|
+
raise AutomationBlockedError("X published post row has no tappable bounds")
|
|
579
|
+
left, top, right, bottom = bounds
|
|
580
|
+
x = min(max((left + right) // 2, 220), 940)
|
|
581
|
+
y = min(max((top + bottom) // 2, 320), 1840)
|
|
582
|
+
self._ok(self.adb.tap(device.adb_serial, x, y), "tap X published post")
|
|
583
|
+
|
|
584
|
+
def _is_post_detail_open(self, device: Device, root, publish_text: str) -> bool:
|
|
585
|
+
focus = self.adb.current_focus(device.adb_serial)
|
|
586
|
+
if X_PACKAGE not in focus or X_COMPOSER_ACTIVITY in focus:
|
|
587
|
+
return False
|
|
588
|
+
if not _profile_has_post(root, publish_text):
|
|
589
|
+
return False
|
|
590
|
+
if X_PROFILE_ACTIVITY not in focus:
|
|
591
|
+
return True
|
|
592
|
+
return ui.find_node(
|
|
593
|
+
root,
|
|
594
|
+
ids={f"{X_PACKAGE}:id/tweet_detail_view"},
|
|
595
|
+
id_suffixes={"tweet_detail_view", "tweet_detail_container", "tweet_detail"},
|
|
596
|
+
visible_only=False,
|
|
597
|
+
) is not None
|
|
598
|
+
|
|
599
|
+
def _tap_share_from_detail(self, device: Device, run_dir: Path) -> None:
|
|
600
|
+
for attempt in range(5):
|
|
601
|
+
root = ui.dump_ui(self.adb, device, run_dir / f"detail-before-share-{attempt + 1}.xml")
|
|
602
|
+
node = ui.find_node(
|
|
603
|
+
root,
|
|
604
|
+
ids={f"{X_PACKAGE}:id/inline_twitter_share"},
|
|
605
|
+
id_suffixes={"inline_twitter_share", "tweet_share_button", "share_button"},
|
|
606
|
+
descs={"分享", "Share"},
|
|
607
|
+
partial_texts={"分享", "share"},
|
|
608
|
+
clickable_only=False,
|
|
609
|
+
)
|
|
610
|
+
if node is not None:
|
|
611
|
+
ui.tap_node(self.adb, device, node, sleep_s=2)
|
|
612
|
+
self.adb.screenshot(device.adb_serial, run_dir / "share-sheet.png")
|
|
613
|
+
return
|
|
614
|
+
self.adb.swipe(device.adb_serial, 540, 1700, 540, 950, 300)
|
|
615
|
+
time.sleep(1)
|
|
616
|
+
self.adb.screenshot(device.adb_serial, run_dir / "share-icon-missing.png")
|
|
617
|
+
raise AutomationBlockedError("X share icon was not found on the post detail page")
|
|
618
|
+
|
|
619
|
+
def _tap_copy_link(self, device: Device, run_dir: Path) -> None:
|
|
620
|
+
for attempt in range(5):
|
|
621
|
+
root = ui.dump_ui(self.adb, device, run_dir / f"share-sheet-{attempt + 1}.xml")
|
|
622
|
+
if attempt == 0:
|
|
623
|
+
self.adb.screenshot(device.adb_serial, run_dir / "share-sheet.png")
|
|
624
|
+
node = ui.find_node(
|
|
625
|
+
root,
|
|
626
|
+
texts={"复制链接", "复制帖子链接", "复制推文链接", "Copy link", "Copy link to post", "Copy link to Tweet"},
|
|
627
|
+
descs={"复制链接", "复制帖子链接", "复制推文链接", "Copy link", "Copy link to post", "Copy link to Tweet"},
|
|
628
|
+
partial_texts={"复制链接", "复制帖子链接", "复制推文链接", "copy link"},
|
|
629
|
+
clickable_only=False,
|
|
630
|
+
)
|
|
631
|
+
if node is not None:
|
|
632
|
+
ui.tap_node(self.adb, device, node, sleep_s=1)
|
|
633
|
+
return
|
|
634
|
+
time.sleep(1)
|
|
635
|
+
self.adb.screenshot(device.adb_serial, run_dir / "copy-link-missing.png")
|
|
636
|
+
raise AutomationBlockedError("X copy link action was not found in the share sheet")
|
|
637
|
+
|
|
638
|
+
def _read_copied_link(self, device: Device, run_dir: Path) -> tuple[str, str, str]:
|
|
639
|
+
errors: list[str] = []
|
|
640
|
+
|
|
641
|
+
result = self.adb.shell(device.adb_serial, "cmd", "clipboard", "get")
|
|
642
|
+
if result.ok:
|
|
643
|
+
link = _extract_x_url(result.stdout)
|
|
644
|
+
if link:
|
|
645
|
+
return link, "adb_cmd_clipboard", ""
|
|
646
|
+
errors.append("adb_cmd_clipboard returned no URL")
|
|
647
|
+
else:
|
|
648
|
+
errors.append(f"adb_cmd_clipboard failed: {_short(result.stderr or result.stdout)}")
|
|
649
|
+
|
|
650
|
+
result = self.adb.shell(device.adb_serial, "dumpsys", "clipboard")
|
|
651
|
+
if result.ok:
|
|
652
|
+
link = _extract_x_url(result.stdout)
|
|
653
|
+
if link:
|
|
654
|
+
return link, "adb_dumpsys_clipboard", ""
|
|
655
|
+
errors.append("adb_dumpsys_clipboard returned no URL")
|
|
656
|
+
else:
|
|
657
|
+
errors.append(f"adb_dumpsys_clipboard failed: {_short(result.stderr or result.stdout)}")
|
|
658
|
+
|
|
659
|
+
link = self._read_link_by_paste_probe(device, run_dir)
|
|
660
|
+
if link:
|
|
661
|
+
return link, "paste_probe_ui_dump", ""
|
|
662
|
+
errors.append("paste_probe_ui_dump returned no URL")
|
|
663
|
+
|
|
664
|
+
appium = AppiumClient(self.appium_server)
|
|
665
|
+
try:
|
|
666
|
+
appium.start_session(_capabilities(device.adb_serial))
|
|
667
|
+
text = appium.get_clipboard()
|
|
668
|
+
link = _extract_x_url(text)
|
|
669
|
+
if link:
|
|
670
|
+
return link, "appium_get_clipboard", ""
|
|
671
|
+
errors.append("appium_get_clipboard returned no URL")
|
|
672
|
+
except Exception as exc:
|
|
673
|
+
errors.append(f"appium_get_clipboard failed: {_short(str(exc))}")
|
|
674
|
+
finally:
|
|
675
|
+
try:
|
|
676
|
+
appium.delete_session()
|
|
677
|
+
except Exception:
|
|
678
|
+
pass
|
|
679
|
+
|
|
680
|
+
return "", "", "; ".join(errors)
|
|
681
|
+
|
|
682
|
+
def _read_link_by_paste_probe(self, device: Device, run_dir: Path) -> str:
|
|
683
|
+
opened_input = False
|
|
684
|
+
try:
|
|
685
|
+
root = ui.dump_ui(self.adb, device, run_dir / "before-paste-probe.xml")
|
|
686
|
+
node = ui.find_node(
|
|
687
|
+
root,
|
|
688
|
+
ids={f"{X_PACKAGE}:id/tweet_text"},
|
|
689
|
+
id_suffixes={"tweet_text"},
|
|
690
|
+
partial_texts={"发布你的回复", "Post your reply", "Tweet your reply"},
|
|
691
|
+
clickable_only=False,
|
|
692
|
+
)
|
|
693
|
+
if node is None:
|
|
694
|
+
node = ui.find_node(
|
|
695
|
+
root,
|
|
696
|
+
ids={f"{X_PACKAGE}:id/persistent_reply"},
|
|
697
|
+
id_suffixes={"persistent_reply"},
|
|
698
|
+
partial_texts={"发布你的回复", "Post your reply", "reply"},
|
|
699
|
+
clickable_only=True,
|
|
700
|
+
)
|
|
701
|
+
if node is None:
|
|
702
|
+
self.adb.screenshot(device.adb_serial, run_dir / "paste-probe-input-missing.png")
|
|
703
|
+
return ""
|
|
704
|
+
ui.tap_node(self.adb, device, node, sleep_s=1)
|
|
705
|
+
opened_input = True
|
|
706
|
+
self.adb.keyevent(device.adb_serial, "KEYCODE_PASTE")
|
|
707
|
+
time.sleep(1)
|
|
708
|
+
root = ui.dump_ui(self.adb, device, run_dir / "paste-probe.xml")
|
|
709
|
+
self.adb.screenshot(device.adb_serial, run_dir / "paste-probe.png")
|
|
710
|
+
return _extract_x_url(ui.all_text(root))
|
|
711
|
+
except Exception:
|
|
712
|
+
return ""
|
|
713
|
+
finally:
|
|
714
|
+
if opened_input:
|
|
715
|
+
self.adb.keyevent(device.adb_serial, "KEYCODE_BACK")
|
|
716
|
+
time.sleep(0.3)
|
|
717
|
+
|
|
464
718
|
def _open_profile(self, device: Device, run_dir: Path) -> None:
|
|
465
719
|
if self._is_profile_open(device):
|
|
466
720
|
return
|
|
@@ -576,10 +830,70 @@ def _profile_has_post(root, expected_text: str) -> bool:
|
|
|
576
830
|
return False
|
|
577
831
|
|
|
578
832
|
|
|
833
|
+
def _find_post_row(root, expected_text: str) -> object | None:
|
|
834
|
+
expected = _normalize_for_match(expected_text)
|
|
835
|
+
if not expected:
|
|
836
|
+
return None
|
|
837
|
+
candidates = []
|
|
838
|
+
for node in root.iter("node"):
|
|
839
|
+
resource_id = node.attrib.get("resource-id", "")
|
|
840
|
+
if not (resource_id == f"{X_PACKAGE}:id/row" or resource_id.endswith(":id/row")):
|
|
841
|
+
continue
|
|
842
|
+
if not ui.is_visible(node):
|
|
843
|
+
continue
|
|
844
|
+
text = _normalize_for_match(_node_text(node))
|
|
845
|
+
if expected in text or (len(expected) >= 80 and expected[:60] in text):
|
|
846
|
+
candidates.append(node)
|
|
847
|
+
return ui.sorted_visible_nodes(candidates)[0] if candidates else None
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _find_post_text_node(row, expected_text: str) -> object | None:
|
|
851
|
+
expected = _normalize_for_match(expected_text)
|
|
852
|
+
candidates = []
|
|
853
|
+
for node in row.iter("node"):
|
|
854
|
+
resource_id = node.attrib.get("resource-id", "")
|
|
855
|
+
if not (resource_id.endswith(":id/tweet_content_text") or node.attrib.get("text", "")):
|
|
856
|
+
continue
|
|
857
|
+
if not ui.is_visible(node):
|
|
858
|
+
continue
|
|
859
|
+
text = _normalize_for_match(_node_text(node))
|
|
860
|
+
if expected in text or (len(expected) >= 80 and expected[:60] in text):
|
|
861
|
+
candidates.append(node)
|
|
862
|
+
return ui.sorted_visible_nodes(candidates)[0] if candidates else None
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _node_text(node) -> str:
|
|
866
|
+
parts: list[str] = []
|
|
867
|
+
for item in node.iter("node"):
|
|
868
|
+
for key in ("text", "content-desc"):
|
|
869
|
+
value = item.attrib.get(key, "")
|
|
870
|
+
if value:
|
|
871
|
+
parts.append(value)
|
|
872
|
+
return "\n".join(parts)
|
|
873
|
+
|
|
874
|
+
|
|
579
875
|
def _normalize_for_match(value: str) -> str:
|
|
580
876
|
return re.sub(r"\s+", " ", value).strip()
|
|
581
877
|
|
|
582
878
|
|
|
879
|
+
def _extract_x_url(text: str) -> str:
|
|
880
|
+
urls = []
|
|
881
|
+
for raw in re.findall(r"https?://[^\s\"'<>]+", text or ""):
|
|
882
|
+
url = raw.rstrip(").,;,。]")
|
|
883
|
+
if url:
|
|
884
|
+
urls.append(url)
|
|
885
|
+
for url in urls:
|
|
886
|
+
lowered = url.lower()
|
|
887
|
+
if any(marker in lowered for marker in ("x.com/", "twitter.com/", "mobile.twitter.com/", "t.co/")):
|
|
888
|
+
return url
|
|
889
|
+
return urls[0] if urls else ""
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _short(text: str, limit: int = 220) -> str:
|
|
893
|
+
one_line = " ".join((text or "").split())
|
|
894
|
+
return one_line if len(one_line) <= limit else one_line[: limit - 3] + "..."
|
|
895
|
+
|
|
896
|
+
|
|
583
897
|
def _find_top_left_clickable(root) -> object | None:
|
|
584
898
|
candidates = []
|
|
585
899
|
for node in root.iter("node"):
|
|
@@ -34,13 +34,13 @@ ADB fallback when explicitly requested:
|
|
|
34
34
|
|
|
35
35
|
Use `--range`, `--parallel`, and `--max-concurrency N` only when the approved plan targets multiple devices. Keep concurrency at or below 5.
|
|
36
36
|
|
|
37
|
-
Successful live publish is only confirmed after the CLI taps the publish button and verifies
|
|
37
|
+
Successful live publish is only confirmed after the CLI taps the publish button and verifies the post-publish surface. If the just-published video detail page already shows `复制链接`/`Copy link`, the CLI copies that link directly; otherwise it reopens TikTok on the profile post grid, opens the latest owned video, and copies its share link. Link readback uses the Android launcher/system search input as the default paste probe after direct clipboard reads; TikTok search is only a fallback. The CLI stores `platform_permalink`/`platform_post_id` in the publish record when available and force-stops TikTok after recovery. Treat missing `platform_permalink` or `link_status!="retrieved"` as a failed publish workflow even if the video tap itself succeeded. The run artifacts include `after-app-close.png`.
|
|
38
38
|
|
|
39
|
-
When the agent environment contains `ELEVENAGENTS_PUBLISH_DINGTALK_WEBHOOK`, the CLI sends a DingTalk notification after status `published`; `ELEVENAGENTS_PUBLISH_DINGTALK_SECRET` is optional.
|
|
39
|
+
When the agent environment contains `ELEVENAGENTS_PUBLISH_DINGTALK_WEBHOOK`, the CLI sends a DingTalk notification after status `published`; `ELEVENAGENTS_PUBLISH_DINGTALK_SECRET` is optional. The message starts with the project, includes the task/device/link, and uses `链接:未收集到` when link recovery returned empty.
|
|
40
40
|
|
|
41
41
|
## Output
|
|
42
42
|
|
|
43
|
-
Return publish status, record id, account/device, screenshot path,
|
|
43
|
+
Return publish status, record id, account/device, screenshot path, `platform_permalink`, `platform_post_id`, `link_status`, duration, error action, and the data-collection handoff:
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
46
|
11agents mobile data collect --platform tiktok --scope video --record-id <record_id> --json --task-id TASK_123
|
|
@@ -21,6 +21,8 @@ If approval, account access, content fields, or device readiness is missing, ask
|
|
|
21
21
|
11agents mobile list-devices --health --json
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
Live publishing verifies the post on the account profile, opens the published post detail page, taps the share icon, copies the post link, reads the copied permalink back into the publish record, and closes X after link recovery. Treat missing `platform_permalink` or `link_status!="retrieved"` as a failed publish workflow even if the post tap itself succeeded.
|
|
25
|
+
|
|
24
26
|
Examples:
|
|
25
27
|
|
|
26
28
|
```bash
|
|
@@ -31,9 +33,11 @@ Examples:
|
|
|
31
33
|
|
|
32
34
|
Do not automate replies, follows, likes, reposts, random browsing, or DM actions from this skill.
|
|
33
35
|
|
|
36
|
+
When the agent environment contains `ELEVENAGENTS_PUBLISH_DINGTALK_WEBHOOK`, the CLI sends a DingTalk notification after status `published`; `ELEVENAGENTS_PUBLISH_DINGTALK_SECRET` is optional. The message starts with the project, includes the task/device/link, and uses `链接:未收集到` when link recovery returned empty.
|
|
37
|
+
|
|
34
38
|
## Output
|
|
35
39
|
|
|
36
|
-
Return publish status, record id, account/device, screenshot path,
|
|
40
|
+
Return publish status, record id, account/device, screenshot path, `platform_permalink`, `link_status`, duration, error, and the data-collection handoff:
|
|
37
41
|
|
|
38
42
|
```bash
|
|
39
43
|
11agents mobile data collect --platform x --record-id <record_id> --json --task-id TASK_123
|
|
@@ -35,7 +35,7 @@ Structured package:
|
|
|
35
35
|
|
|
36
36
|
Successful live publish is only confirmed after the CLI taps the final publish button, waits without tapping while Xiaohongshu shows upload/progress state, closes and relaunches Xiaohongshu, then opens the "我" page, opens the note whose title matches the expected title or the first visible profile note as a verified fallback, and verifies the detail page against the expected title/body. The CLI recovers the note link by default for live publish; use `--no-copy-link-after-publish` only when link recovery is explicitly not wanted. After writing the publish record, the CLI force-stops Xiaohongshu and captures `after-app-close.png` in the run artifacts. If that evidence does not match, treat the result as `failed`; do not claim publish success from the publish button tap alone.
|
|
37
37
|
|
|
38
|
-
When the agent environment contains `ELEVENAGENTS_PUBLISH_DINGTALK_WEBHOOK`, the CLI sends a DingTalk notification after status `published`; `ELEVENAGENTS_PUBLISH_DINGTALK_SECRET` is optional. The
|
|
38
|
+
When the agent environment contains `ELEVENAGENTS_PUBLISH_DINGTALK_WEBHOOK`, the CLI sends a DingTalk notification after status `published`; `ELEVENAGENTS_PUBLISH_DINGTALK_SECRET` is optional. The message starts with the project, includes the task/device/link, and uses `链接:未收集到` when link recovery returned empty.
|
|
39
39
|
|
|
40
40
|
Link copy after publish or during the recovery skill:
|
|
41
41
|
|
|
@@ -51,7 +51,7 @@ If mobile setup, device online status, media path, app login readiness, or appro
|
|
|
51
51
|
11. Use `11agents mobile publish-tiktok`, `publish-instagram`, `publish-facebook`, `publish-reddit`, `publish-x`, and `publish-xiaohongshu`.
|
|
52
52
|
12. Prefer `--json` and parse status, duration, record id, screenshot path, permalink, platform post id, and error.
|
|
53
53
|
13. Pass `--task-id <task_id>` so logs land at `~/.11agents/mobile/runs/<task_id>/log`.
|
|
54
|
-
14. Do not run custom DingTalk webhook scripts after Xiaohongshu or TikTok publish. When the agent environment contains `ELEVENAGENTS_PUBLISH_DINGTALK_WEBHOOK`, the CLI sends the publish-success notification automatically; `ELEVENAGENTS_PUBLISH_DINGTALK_SECRET` is optional for signed robots.
|
|
54
|
+
14. Do not run custom DingTalk webhook scripts after X, Xiaohongshu, or TikTok publish. When the agent environment contains `ELEVENAGENTS_PUBLISH_DINGTALK_WEBHOOK`, the CLI sends the publish-success notification automatically; `ELEVENAGENTS_PUBLISH_DINGTALK_SECRET` is optional for signed robots. The message starts with the project, includes the task/device/link, and uses `链接:未收集到` when link recovery returned empty.
|
|
55
55
|
15. Stop on `failed`; surface screenshot and error instead of repeated retries.
|
|
56
56
|
|
|
57
57
|
## Output
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
|
1
|
+
import { mkdir, readFile, readdir, rm, rmdir, writeFile } from 'node:fs/promises'
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { flag } from '../args.js'
|
|
@@ -116,6 +116,27 @@ export function buildKnowledgeBaseDir(project, { homeDir = os.homedir() } = {})
|
|
|
116
116
|
return path.join(homeDir, '.11agents', slugify(project), 'knowledge_base')
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// Mirror the skill-sync strategy: after a sync the cloud snapshot is the source
|
|
120
|
+
// of truth, so local knowledge text files missing from it are removed. Scope is
|
|
121
|
+
// strictly the project's knowledge_base directory.
|
|
122
|
+
async function pruneLocalKnowledgeFiles(baseDir, keepPaths) {
|
|
123
|
+
for (const localPath of await listKnowledgeTextFiles(baseDir)) {
|
|
124
|
+
if (keepPaths.has(localPath)) continue
|
|
125
|
+
await rm(path.join(baseDir, localPath), { force: true })
|
|
126
|
+
}
|
|
127
|
+
const removeEmptyDirs = async dir => {
|
|
128
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
129
|
+
for (const entry of entries) {
|
|
130
|
+
if (entry.isDirectory()) await removeEmptyDirs(path.join(dir, entry.name))
|
|
131
|
+
}
|
|
132
|
+
if (dir !== baseDir) {
|
|
133
|
+
const remaining = await readdir(dir).catch(() => ['keep'])
|
|
134
|
+
if (remaining.length === 0) await rmdir(dir).catch(() => {})
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
await removeEmptyDirs(baseDir)
|
|
138
|
+
}
|
|
139
|
+
|
|
119
140
|
export async function writeKnowledgeSnapshot(project, snapshot, { homeDir = os.homedir() } = {}) {
|
|
120
141
|
const normalized = normalizeSnapshot(snapshot)
|
|
121
142
|
const baseDir = buildKnowledgeBaseDir(project, { homeDir })
|
|
@@ -138,6 +159,7 @@ export async function writeKnowledgeSnapshot(project, snapshot, { homeDir = os.h
|
|
|
138
159
|
await mkdir(path.dirname(target), { recursive: true })
|
|
139
160
|
await writeFile(target, markdownForNode(node))
|
|
140
161
|
}
|
|
162
|
+
await pruneLocalKnowledgeFiles(baseDir, new Set(normalized.nodes.map(node => node.local_path)))
|
|
141
163
|
return index
|
|
142
164
|
}
|
|
143
165
|
|
package/src/commands/mobile.js
CHANGED
|
@@ -65,8 +65,10 @@ const COMMAND_ALIASES = new Map([
|
|
|
65
65
|
|
|
66
66
|
const WRAPPER_FLAG_NAMES = new Set([
|
|
67
67
|
'mobile-home',
|
|
68
|
+
'project',
|
|
68
69
|
'task-id',
|
|
69
70
|
'task-title',
|
|
71
|
+
'workspace',
|
|
70
72
|
])
|
|
71
73
|
|
|
72
74
|
const EMPTY_DATA_FILES = [
|
|
@@ -99,7 +101,7 @@ Runtime:
|
|
|
99
101
|
Python/device-control code is bundled with @11agents/cli.
|
|
100
102
|
Mutable runtime state lives in ~/.11agents/mobile by default.
|
|
101
103
|
Every command writes logs under ~/.11agents/mobile/runs/<task_id>/log.
|
|
102
|
-
Xiaohongshu/TikTok publish-success DingTalk notification is enabled by
|
|
104
|
+
X/Xiaohongshu/TikTok publish-success DingTalk notification is enabled by
|
|
103
105
|
ELEVENAGENTS_PUBLISH_DINGTALK_WEBHOOK and optional
|
|
104
106
|
ELEVENAGENTS_PUBLISH_DINGTALK_SECRET.`)
|
|
105
107
|
}
|
|
@@ -602,10 +604,12 @@ function buildRunContext(rawArgs, deps = {}) {
|
|
|
602
604
|
|| env.ELEVENAGENTS_ISSUE_TITLE
|
|
603
605
|
|| taskId
|
|
604
606
|
).trim()
|
|
607
|
+
const project = projectFromMobileContext(flags, env)
|
|
605
608
|
const runDir = path.join(home, 'runs', taskId)
|
|
606
609
|
return {
|
|
607
610
|
flags,
|
|
608
611
|
home,
|
|
612
|
+
project,
|
|
609
613
|
taskId,
|
|
610
614
|
taskTitle,
|
|
611
615
|
runDir,
|
|
@@ -613,6 +617,20 @@ function buildRunContext(rawArgs, deps = {}) {
|
|
|
613
617
|
}
|
|
614
618
|
}
|
|
615
619
|
|
|
620
|
+
function projectFromMobileContext(flags = {}, env = process.env) {
|
|
621
|
+
return String(
|
|
622
|
+
flag(flags, 'project')
|
|
623
|
+
|| flag(flags, 'workspace')
|
|
624
|
+
|| env.ELEVENAGENTS_PROJECT_SLUG
|
|
625
|
+
|| env.ELEVENAGENTS_PROJECT
|
|
626
|
+
|| env.ELEVENAGENTS_PROJECT_NAME
|
|
627
|
+
|| env.ELEVENAGENTS_WORKSPACE_SLUG
|
|
628
|
+
|| env.GTM_SWARM_WORKSPACE
|
|
629
|
+
|| env.WORKSPACE_SLUG
|
|
630
|
+
|| ''
|
|
631
|
+
).trim()
|
|
632
|
+
}
|
|
633
|
+
|
|
616
634
|
async function dispatchPython(rawArgs, deps = {}) {
|
|
617
635
|
const command = normalizePythonCommand(rawArgs[0] || '')
|
|
618
636
|
if (!PYTHON_COMMANDS.has(command)) throw new Error(`unsupported mobile command: ${rawArgs[0] || ''}`)
|
|
@@ -622,6 +640,7 @@ async function dispatchPython(rawArgs, deps = {}) {
|
|
|
622
640
|
const env = pythonEnv(context.home, deps.env || process.env)
|
|
623
641
|
env.ELEVENAGENTS_MOBILE_RUN_DIR = context.runDir
|
|
624
642
|
env.ELEVENAGENTS_TASK_ID = context.taskId
|
|
643
|
+
if (context.project) env.ELEVENAGENTS_PROJECT_SLUG = context.project
|
|
625
644
|
const result = await runAndLog({
|
|
626
645
|
command: python,
|
|
627
646
|
args: childArgs,
|
|
@@ -661,6 +680,7 @@ async function dispatchScript(rawArgs, deps = {}) {
|
|
|
661
680
|
const env = pythonEnv(context.home, deps.env || process.env)
|
|
662
681
|
env.ELEVENAGENTS_MOBILE_RUN_DIR = context.runDir
|
|
663
682
|
env.ELEVENAGENTS_TASK_ID = context.taskId
|
|
683
|
+
if (context.project) env.ELEVENAGENTS_PROJECT_SLUG = context.project
|
|
664
684
|
const result = await runAndLog({
|
|
665
685
|
command: python,
|
|
666
686
|
args: [scriptPath, ...stripWrapperFlags(rawArgs.slice(1))],
|
|
@@ -702,6 +722,7 @@ async function notifyMobilePublishSuccess({ command, context, parsed, env = proc
|
|
|
702
722
|
const link = publishResultLink(row)
|
|
703
723
|
const content = buildPublishNotificationContent({
|
|
704
724
|
platform,
|
|
725
|
+
project: context.project,
|
|
705
726
|
task: context.taskTitle || context.taskId,
|
|
706
727
|
deviceId: row.device_id || row.device || '',
|
|
707
728
|
link,
|
|
@@ -755,7 +776,7 @@ function isPublishedResult(row) {
|
|
|
755
776
|
}
|
|
756
777
|
|
|
757
778
|
function shouldNotifyPublishPlatform(platform) {
|
|
758
|
-
return platform === 'xiaohongshu' || platform === 'tiktok'
|
|
779
|
+
return platform === 'xiaohongshu' || platform === 'tiktok' || platform === 'x'
|
|
759
780
|
}
|
|
760
781
|
|
|
761
782
|
function publishResultLink(row) {
|
|
@@ -768,13 +789,14 @@ function publishResultLink(row) {
|
|
|
768
789
|
).trim()
|
|
769
790
|
}
|
|
770
791
|
|
|
771
|
-
function buildPublishNotificationContent({ platform, task, deviceId = '', link }) {
|
|
792
|
+
function buildPublishNotificationContent({ platform, project = '', task, deviceId = '', link }) {
|
|
772
793
|
const label = platformLabel(platform)
|
|
794
|
+
const projectText = String(project || '').trim()
|
|
773
795
|
const taskText = String(task || 'task').trim()
|
|
774
796
|
const deviceText = deviceId ? `(设备 ${deviceId})` : ''
|
|
775
|
-
|
|
776
|
-
const
|
|
777
|
-
return `${label} ${taskText}${deviceText}已发布,链接:${linkText}`
|
|
797
|
+
const linkText = String(link || '').trim() || '未收集到'
|
|
798
|
+
const prefix = projectText ? `[${projectText}] ` : ''
|
|
799
|
+
return `${prefix}${label} ${taskText}${deviceText}已发布,链接:${linkText}`
|
|
778
800
|
}
|
|
779
801
|
|
|
780
802
|
function platformLabel(platform) {
|
|
@@ -946,6 +968,7 @@ export const mobileInternals = {
|
|
|
946
968
|
mobileHome,
|
|
947
969
|
parseRawArgs,
|
|
948
970
|
platformFromMobileCommand,
|
|
971
|
+
projectFromMobileContext,
|
|
949
972
|
publishResultLink,
|
|
950
973
|
shouldNotifyPublishPlatform,
|
|
951
974
|
signedDingTalkUrl,
|
package/src/commands/runtime.js
CHANGED
|
@@ -1124,10 +1124,13 @@ async function prepareRuntimeTask(task, flags, deps, config) {
|
|
|
1124
1124
|
// Resolve project token before skill sync so MCP auth is available.
|
|
1125
1125
|
const projectToken = await projectSyncToken(projectTokenCandidatesForTask(task, flags), flags, deps, task.workspace?.swarm_token)
|
|
1126
1126
|
const skills = await syncSkillsViaMcp({ task, workdir, flags, deps, projectToken, config })
|
|
1127
|
+
const projectSlug = projectSlugForTask(task, flags)
|
|
1127
1128
|
const env = {
|
|
1128
1129
|
...process.env,
|
|
1129
1130
|
...agentEnvironment(task),
|
|
1130
1131
|
ELEVENAGENTS_PROJECT_DIR: workdir,
|
|
1132
|
+
ELEVENAGENTS_PROJECT_SLUG: projectSlug,
|
|
1133
|
+
ELEVENAGENTS_PROJECT_NAME: String(task.workspace?.name || task.workspace?.slug || task.workspace_slug || ''),
|
|
1131
1134
|
ELEVENAGENTS_AGENT_DIR: agentDir,
|
|
1132
1135
|
ELEVENAGENTS_AGENT_SKILLS_DIR: agentSkillsDir,
|
|
1133
1136
|
ELEVENAGENTS_AGENT_MEMORY_DIR: agentMemoryDir,
|
|
@@ -1146,7 +1149,7 @@ async function prepareRuntimeTask(task, flags, deps, config) {
|
|
|
1146
1149
|
agent_results_dir: agentResultsDir,
|
|
1147
1150
|
tmp_dir: tmpDir,
|
|
1148
1151
|
run_dir: runDir,
|
|
1149
|
-
project_slug:
|
|
1152
|
+
project_slug: projectSlug,
|
|
1150
1153
|
readonly: true,
|
|
1151
1154
|
env,
|
|
1152
1155
|
database,
|