@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.
@@ -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
- return self._failure_result(
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
- return self._failure_result(
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
- self.adb.screenshot(device.adb_serial, failure_shot)
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 TikTok publish completion. After writing the publish record, the CLI force-stops TikTok and captures `after-app-close.png` in the run artifacts.
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. TikTok notification content does not show a link.
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, permalink or platform post id when available, duration, error action, and the data-collection handoff:
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, permalink or platform post id when available, duration, error, and the data-collection handoff:
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 Xiaohongshu notification includes the recovered link when available and still sends if the link was not recovered.
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. If platform metrics are unavailable, record the missing access and ask for the relevant token/export instead of inventing numbers.
45
- 7. Summarize metrics by platform, account, device, content type, creative pattern, and issue/task id.
46
- 8. Write reusable learning as `agent_memory_delta` for channel agents.
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