@11agents/cli 0.1.41 → 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-collect-tiktok-metrics/SKILL.md +76 -0
- 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-data-collection/SKILL.md +79 -3
- 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
- package/src/schema.js +5 -2
|
@@ -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"):
|
|
@@ -55,6 +55,82 @@ If device, logged-in account, target video, or measurement window is missing, as
|
|
|
55
55
|
11agents mobile add-metrics --record-id pub_123 --platform tiktok --metric views=0 --json --task-id TASK_123
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
+
6. When a project swarm token is available, map collected TikTok data into `swarm.telemetry.v1` instead of returning only a markdown summary:
|
|
59
|
+
- Use `platform: "tiktok_public"`.
|
|
60
|
+
- Account overview rows are `artifact_type: "account_period"`; create one artifact per account/period/date range and put `period`, `period_days`, `date_range`, account id, device id, and evidence paths in payload fields.
|
|
61
|
+
- Single-video rows are `artifact_type: "video"`; create one artifact per video/profile order/post id and put `profile_order`, `posted_time`, caption/title, account id, device id, and evidence paths in artifact or observation payload fields.
|
|
62
|
+
- Put numeric analytics in `observations[].metrics`.
|
|
63
|
+
- Include `dashboard_spec.metric_schema` and set recovered account/video metrics to `aggregation: "snapshot"` so Agent Performance does not sum values that already include a time window.
|
|
64
|
+
- Include `observation_table` widgets with explicit columns for account period rows and video rows so both account-level and single-video fields are visible.
|
|
65
|
+
|
|
66
|
+
## Swarm Dashboard Contract
|
|
67
|
+
|
|
68
|
+
Use explicit snapshot tables for TikTok metrics:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"schema_version": "swarm.dashboard.v1",
|
|
73
|
+
"title": "TikTok Data Collection",
|
|
74
|
+
"metric_schema": {
|
|
75
|
+
"post_views": { "label": "Post views", "aggregation": "snapshot" },
|
|
76
|
+
"profile_views": { "label": "Profile views", "aggregation": "snapshot" },
|
|
77
|
+
"views": { "label": "Views", "aggregation": "snapshot" },
|
|
78
|
+
"likes": { "label": "Likes", "aggregation": "snapshot" },
|
|
79
|
+
"comments": { "label": "Comments", "aggregation": "snapshot" },
|
|
80
|
+
"shares": { "label": "Shares", "aggregation": "snapshot" },
|
|
81
|
+
"saves": { "label": "Saves", "aggregation": "snapshot" },
|
|
82
|
+
"avg_watch_seconds": { "label": "Avg watch", "aggregation": "snapshot", "unit": "seconds" },
|
|
83
|
+
"watched_full_rate": { "label": "Watched full", "aggregation": "snapshot", "format": "percent" },
|
|
84
|
+
"new_followers": { "label": "New followers", "aggregation": "snapshot" }
|
|
85
|
+
},
|
|
86
|
+
"widgets": [
|
|
87
|
+
{
|
|
88
|
+
"id": "account_periods",
|
|
89
|
+
"title": "Account Period Snapshots",
|
|
90
|
+
"type": "table",
|
|
91
|
+
"query": {
|
|
92
|
+
"kind": "observation_table",
|
|
93
|
+
"platform": "tiktok_public",
|
|
94
|
+
"artifact_type": "account_period",
|
|
95
|
+
"latest_per_artifact": true,
|
|
96
|
+
"columns": [
|
|
97
|
+
{ "key": "period", "label": "Period", "source": "payload" },
|
|
98
|
+
{ "key": "date_range", "label": "Date range", "source": "payload" },
|
|
99
|
+
{ "key": "post_views", "label": "Post views", "source": "metric" },
|
|
100
|
+
{ "key": "profile_views", "label": "Profile views", "source": "metric" },
|
|
101
|
+
{ "key": "likes", "label": "Likes", "source": "metric" },
|
|
102
|
+
{ "key": "comments", "label": "Comments", "source": "metric" },
|
|
103
|
+
{ "key": "shares", "label": "Shares", "source": "metric" }
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"id": "video_snapshots",
|
|
109
|
+
"title": "Video Snapshots",
|
|
110
|
+
"type": "table",
|
|
111
|
+
"query": {
|
|
112
|
+
"kind": "observation_table",
|
|
113
|
+
"platform": "tiktok_public",
|
|
114
|
+
"artifact_type": "video",
|
|
115
|
+
"latest_per_artifact": true,
|
|
116
|
+
"columns": [
|
|
117
|
+
{ "key": "profile_order", "label": "Profile order", "source": "payload" },
|
|
118
|
+
{ "key": "posted_time", "label": "Posted time", "source": "payload" },
|
|
119
|
+
{ "key": "views", "label": "Views", "source": "metric" },
|
|
120
|
+
{ "key": "likes", "label": "Likes", "source": "metric" },
|
|
121
|
+
{ "key": "comments", "label": "Comments", "source": "metric" },
|
|
122
|
+
{ "key": "shares", "label": "Shares", "source": "metric" },
|
|
123
|
+
{ "key": "saves", "label": "Saves", "source": "metric" },
|
|
124
|
+
{ "key": "avg_watch_seconds", "label": "Avg watch", "source": "metric" },
|
|
125
|
+
{ "key": "watched_full_rate", "label": "Watched full", "source": "metric" },
|
|
126
|
+
{ "key": "new_followers", "label": "New followers", "source": "metric" }
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
58
134
|
## Output
|
|
59
135
|
|
|
60
136
|
Return metric status, snapshot ids, account/video target, data freshness, screenshot paths when created, run log path, missing access, and `agent_memory_delta` with reusable creative or account learning.
|
|
@@ -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
|
|
|
@@ -41,9 +41,85 @@ If records, platform post id/permalink, or analytics access is missing, ask the
|
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
5. Treat X, Facebook, and unsupported platform collectors that return `missing_access` as a valid recovery status. Ask for the relevant API token, analytics export, or manual values, then append with `11agents mobile add-metrics`.
|
|
44
|
-
6.
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
6. For TikTok account/video recovery, map the data into `swarm.telemetry.v1` when the project swarm token is available:
|
|
45
|
+
- Use `platform: "tiktok_public"`.
|
|
46
|
+
- Use `artifact_type: "account_period"` for account overview period/date-range rows.
|
|
47
|
+
- Use `artifact_type: "video"` for single-video/profile-order rows.
|
|
48
|
+
- Put period/date range, posted time, profile order, account id, device id, and evidence paths in payload/artifact fields.
|
|
49
|
+
- Put numeric analytics in `observations[].metrics`.
|
|
50
|
+
- In `dashboard_spec.metric_schema`, mark windowed/snapshot metrics as `aggregation: "snapshot"` so Agent Performance does not sum them again.
|
|
51
|
+
- Include `observation_table` widgets for account period snapshots and video snapshots, with explicit columns for every recovered field.
|
|
52
|
+
7. If platform metrics are unavailable, record the missing access and ask for the relevant token/export instead of inventing numbers.
|
|
53
|
+
8. Summarize metrics by platform, account, device, content type, creative pattern, and issue/task id.
|
|
54
|
+
9. Write reusable learning as `agent_memory_delta` for channel agents.
|
|
55
|
+
|
|
56
|
+
## TikTok Dashboard Contract
|
|
57
|
+
|
|
58
|
+
TikTok recovery should include account-level and video-level tables in `dashboard_spec`:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"schema_version": "swarm.dashboard.v1",
|
|
63
|
+
"title": "TikTok Data Collection",
|
|
64
|
+
"metric_schema": {
|
|
65
|
+
"post_views": { "label": "Post views", "aggregation": "snapshot" },
|
|
66
|
+
"profile_views": { "label": "Profile views", "aggregation": "snapshot" },
|
|
67
|
+
"views": { "label": "Views", "aggregation": "snapshot" },
|
|
68
|
+
"likes": { "label": "Likes", "aggregation": "snapshot" },
|
|
69
|
+
"comments": { "label": "Comments", "aggregation": "snapshot" },
|
|
70
|
+
"shares": { "label": "Shares", "aggregation": "snapshot" },
|
|
71
|
+
"saves": { "label": "Saves", "aggregation": "snapshot" },
|
|
72
|
+
"avg_watch_seconds": { "label": "Avg watch", "aggregation": "snapshot", "unit": "seconds" },
|
|
73
|
+
"watched_full_rate": { "label": "Watched full", "aggregation": "snapshot", "format": "percent" },
|
|
74
|
+
"new_followers": { "label": "New followers", "aggregation": "snapshot" }
|
|
75
|
+
},
|
|
76
|
+
"widgets": [
|
|
77
|
+
{
|
|
78
|
+
"id": "account_periods",
|
|
79
|
+
"title": "Account Period Snapshots",
|
|
80
|
+
"type": "table",
|
|
81
|
+
"query": {
|
|
82
|
+
"kind": "observation_table",
|
|
83
|
+
"platform": "tiktok_public",
|
|
84
|
+
"artifact_type": "account_period",
|
|
85
|
+
"latest_per_artifact": true,
|
|
86
|
+
"columns": [
|
|
87
|
+
{ "key": "period", "label": "Period", "source": "payload" },
|
|
88
|
+
{ "key": "date_range", "label": "Date range", "source": "payload" },
|
|
89
|
+
{ "key": "post_views", "label": "Post views", "source": "metric" },
|
|
90
|
+
{ "key": "profile_views", "label": "Profile views", "source": "metric" },
|
|
91
|
+
{ "key": "likes", "label": "Likes", "source": "metric" },
|
|
92
|
+
{ "key": "comments", "label": "Comments", "source": "metric" },
|
|
93
|
+
{ "key": "shares", "label": "Shares", "source": "metric" }
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"id": "video_snapshots",
|
|
99
|
+
"title": "Video Snapshots",
|
|
100
|
+
"type": "table",
|
|
101
|
+
"query": {
|
|
102
|
+
"kind": "observation_table",
|
|
103
|
+
"platform": "tiktok_public",
|
|
104
|
+
"artifact_type": "video",
|
|
105
|
+
"latest_per_artifact": true,
|
|
106
|
+
"columns": [
|
|
107
|
+
{ "key": "profile_order", "label": "Profile order", "source": "payload" },
|
|
108
|
+
{ "key": "posted_time", "label": "Posted time", "source": "payload" },
|
|
109
|
+
{ "key": "views", "label": "Views", "source": "metric" },
|
|
110
|
+
{ "key": "likes", "label": "Likes", "source": "metric" },
|
|
111
|
+
{ "key": "comments", "label": "Comments", "source": "metric" },
|
|
112
|
+
{ "key": "shares", "label": "Shares", "source": "metric" },
|
|
113
|
+
{ "key": "saves", "label": "Saves", "source": "metric" },
|
|
114
|
+
{ "key": "avg_watch_seconds", "label": "Avg watch", "source": "metric" },
|
|
115
|
+
{ "key": "watched_full_rate", "label": "Watched full", "source": "metric" },
|
|
116
|
+
{ "key": "new_followers", "label": "New followers", "source": "metric" }
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
```
|
|
47
123
|
|
|
48
124
|
## Output
|
|
49
125
|
Return recovered links/metrics, missing analytics access, record ids covered, data freshness, run log path, notable performance patterns, and follow-up tasks for planning.
|
|
@@ -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
|