@11agents/cli 0.1.23 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +52 -0
  2. package/bin/11agents.js +12 -0
  3. package/mobile-runtime/README.md +19 -0
  4. package/mobile-runtime/configs/platforms/xiaohongshu_d01.json +73 -0
  5. package/mobile-runtime/configs/platforms/xiaohongshu_d02.json +70 -0
  6. package/mobile-runtime/configs/platforms/xiaohongshu_d03.json +73 -0
  7. package/mobile-runtime/configs/publish_policy.json +40 -0
  8. package/mobile-runtime/data-templates/README.md +4 -0
  9. package/mobile-runtime/data-templates/accounts.example.csv +6 -0
  10. package/mobile-runtime/data-templates/devices.example.csv +2 -0
  11. package/mobile-runtime/data-templates/publish_records.example.jsonl +2 -0
  12. package/mobile-runtime/data-templates/tasks.example.jsonl +5 -0
  13. package/mobile-runtime/data-templates/video_metrics.example.jsonl +1 -0
  14. package/mobile-runtime/python/pyproject.toml +34 -0
  15. package/mobile-runtime/python/src/device_control/__init__.py +5 -0
  16. package/mobile-runtime/python/src/device_control/adapters/__init__.py +31 -0
  17. package/mobile-runtime/python/src/device_control/adapters/base.py +43 -0
  18. package/mobile-runtime/python/src/device_control/adapters/facebook.py +30 -0
  19. package/mobile-runtime/python/src/device_control/adapters/instagram.py +25 -0
  20. package/mobile-runtime/python/src/device_control/adapters/reddit.py +29 -0
  21. package/mobile-runtime/python/src/device_control/adapters/tiktok.py +25 -0
  22. package/mobile-runtime/python/src/device_control/adapters/x.py +29 -0
  23. package/mobile-runtime/python/src/device_control/adapters/xiaohongshu.py +26 -0
  24. package/mobile-runtime/python/src/device_control/adb.py +161 -0
  25. package/mobile-runtime/python/src/device_control/appium_client.py +131 -0
  26. package/mobile-runtime/python/src/device_control/appium_manager.py +403 -0
  27. package/mobile-runtime/python/src/device_control/cli.py +1608 -0
  28. package/mobile-runtime/python/src/device_control/entrypoints.py +60 -0
  29. package/mobile-runtime/python/src/device_control/locks.py +162 -0
  30. package/mobile-runtime/python/src/device_control/metrics/__init__.py +33 -0
  31. package/mobile-runtime/python/src/device_control/metrics/collectors.py +320 -0
  32. package/mobile-runtime/python/src/device_control/metrics/tiktok_account_adb.py +367 -0
  33. package/mobile-runtime/python/src/device_control/metrics/tiktok_video_adb.py +714 -0
  34. package/mobile-runtime/python/src/device_control/models.py +439 -0
  35. package/mobile-runtime/python/src/device_control/publish_policy.py +173 -0
  36. package/mobile-runtime/python/src/device_control/publishers/__init__.py +24 -0
  37. package/mobile-runtime/python/src/device_control/publishers/facebook_adb.py +494 -0
  38. package/mobile-runtime/python/src/device_control/publishers/instagram_adb.py +663 -0
  39. package/mobile-runtime/python/src/device_control/publishers/reddit_adb.py +595 -0
  40. package/mobile-runtime/python/src/device_control/publishers/tiktok_adb.py +477 -0
  41. package/mobile-runtime/python/src/device_control/publishers/tiktok_appium.py +259 -0
  42. package/mobile-runtime/python/src/device_control/publishers/ui_helpers.py +372 -0
  43. package/mobile-runtime/python/src/device_control/publishers/x_adb.py +636 -0
  44. package/mobile-runtime/python/src/device_control/publishers/xiaohongshu_adb.py +1143 -0
  45. package/mobile-runtime/python/src/device_control/store.py +137 -0
  46. package/mobile-runtime/scripts/appium_smoke.py +71 -0
  47. package/mobile-runtime/skills/android-collect-tiktok-metrics/SKILL.md +60 -0
  48. package/mobile-runtime/skills/android-collect-tiktok-metrics/agents/openai.yaml +4 -0
  49. package/mobile-runtime/skills/android-group-control-cli/SKILL.md +76 -0
  50. package/mobile-runtime/skills/android-group-control-cli/agents/openai.yaml +4 -0
  51. package/mobile-runtime/skills/android-group-control-cli/references/command-reference.md +122 -0
  52. package/mobile-runtime/skills/android-publish-facebook/SKILL.md +41 -0
  53. package/mobile-runtime/skills/android-publish-facebook/agents/openai.yaml +4 -0
  54. package/mobile-runtime/skills/android-publish-instagram/SKILL.md +45 -0
  55. package/mobile-runtime/skills/android-publish-instagram/agents/openai.yaml +4 -0
  56. package/mobile-runtime/skills/android-publish-reddit/SKILL.md +41 -0
  57. package/mobile-runtime/skills/android-publish-reddit/agents/openai.yaml +4 -0
  58. package/mobile-runtime/skills/android-publish-tiktok/SKILL.md +43 -0
  59. package/mobile-runtime/skills/android-publish-tiktok/agents/openai.yaml +4 -0
  60. package/mobile-runtime/skills/android-publish-x/SKILL.md +40 -0
  61. package/mobile-runtime/skills/android-publish-x/agents/openai.yaml +4 -0
  62. package/mobile-runtime/skills/android-publish-xiaohongshu/SKILL.md +50 -0
  63. package/mobile-runtime/skills/android-publish-xiaohongshu/agents/openai.yaml +4 -0
  64. package/mobile-runtime/skills/mobile-publish-data-collection/SKILL.md +49 -0
  65. package/mobile-runtime/skills/mobile-publish-device-health/SKILL.md +47 -0
  66. package/mobile-runtime/skills/mobile-publish-execution/SKILL.md +57 -0
  67. package/mobile-runtime/skills/mobile-publish-records/SKILL.md +29 -0
  68. package/package.json +4 -1
  69. package/scripts/mobile-postinstall.js +26 -0
  70. package/src/commands/mobile.js +695 -0
  71. package/src/commands/runtime.js +63 -28
@@ -0,0 +1,714 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import struct
6
+ import time
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from zoneinfo import ZoneInfo
12
+
13
+ from device_control.adb import AdbClient, CommandResult
14
+ from device_control.models import Device, TikTokVideoMetricsSnapshot, new_id, utc_now_iso
15
+ from device_control.publishers import ui_helpers as ui
16
+ from device_control.publishers.tiktok_adb import TIKTOK_PACKAGE
17
+
18
+
19
+ BASE_SCREEN = (1080, 2245)
20
+ VIDEO_INSIGHTS_TEXT_MARKERS = {
21
+ "More insights",
22
+ "View insights",
23
+ "Analytics",
24
+ "数据分析",
25
+ "更多数据",
26
+ }
27
+ VIDEO_MORE_MENU_TEXT_MARKERS = {
28
+ "More",
29
+ "More options",
30
+ "更多",
31
+ "更多选项",
32
+ }
33
+ PROFILE_PROMPT_TEXT_MARKERS = {
34
+ "Your avatar, your style",
35
+ "Create your TikTok avatar",
36
+ }
37
+ PROFILE_VIDEO_SCROLL_LIMIT = 12
38
+ PROFILE_VIDEO_SCROLL_ADVANCE = 3
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class TikTokVideoMetricValues:
43
+ published_at: str = ""
44
+ published_at_raw: str = ""
45
+ video_views: int | None = None
46
+ likes: int | None = None
47
+ comments: int | None = None
48
+ shares: int | None = None
49
+ saves: int | None = None
50
+ total_play_time_seconds: int | None = None
51
+ total_play_time_raw: str = ""
52
+ average_watch_time_seconds: float | None = None
53
+ average_watch_time_raw: str = ""
54
+ completion_rate: float | None = None
55
+ completion_rate_raw: str = ""
56
+ new_followers: int | None = None
57
+ top_counters: dict[str, int | None] | None = None
58
+ raw: dict[str, Any] | None = None
59
+
60
+
61
+ class TikTokVideoMetricsAdbCollector:
62
+ """Read-only ADB collector for one TikTok video's Studio analytics page."""
63
+
64
+ def __init__(self, adb: AdbClient, *, artifact_root: str | Path = "artifacts/screenshots") -> None:
65
+ self.adb = adb
66
+ self.artifact_root = Path(artifact_root)
67
+
68
+ def collect(
69
+ self,
70
+ device: Device,
71
+ *,
72
+ account_id: str,
73
+ video_order: int = 1,
74
+ resume_insights: bool = False,
75
+ manual_values: TikTokVideoMetricValues | None = None,
76
+ ) -> TikTokVideoMetricsSnapshot:
77
+ started_epoch = int(time.time())
78
+ started_at = _now_shanghai()
79
+ stamp = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d-%H%M%S")
80
+ run_dir = self.artifact_root / f"{device.device_id}-tiktok-video-metrics-{stamp}"
81
+ run_dir.mkdir(parents=True, exist_ok=True)
82
+ raw: dict[str, Any] = {
83
+ "resume_insights": resume_insights,
84
+ "video_order": video_order,
85
+ "metric_keys": [
86
+ "video_views",
87
+ "likes",
88
+ "comments",
89
+ "shares",
90
+ "saves",
91
+ "average_watch_time_seconds",
92
+ "completion_rate",
93
+ ],
94
+ }
95
+
96
+ try:
97
+ self._prepare_device(device)
98
+ if not resume_insights:
99
+ self._navigate_to_video_insights(device, run_dir, video_order=video_order)
100
+ width, height = self._screen_size(device)
101
+ self._scroll_insights_to_key_metrics_top(device, run_dir, width, height)
102
+
103
+ screenshot_path = run_dir / "video-insights.png"
104
+ ui_dump_path = run_dir / "video-insights.xml"
105
+ self._ok(self.adb.screenshot(device.adb_serial, screenshot_path), "capture TikTok video insights screenshot")
106
+ dump = self.adb.dump_ui(device.adb_serial, ui_dump_path)
107
+ if not dump.ok:
108
+ ui_dump_path = Path("")
109
+ width, height = _png_size(screenshot_path) or (width, height)
110
+ raw["screen_size"] = {"width": width, "height": height}
111
+
112
+ ended_epoch = int(time.time())
113
+ snapshot = self._build_snapshot(
114
+ account_id=account_id,
115
+ device=device,
116
+ video_order=video_order,
117
+ manual_values=manual_values,
118
+ screenshot_path=screenshot_path,
119
+ ui_dump_path=ui_dump_path,
120
+ run_dir=run_dir,
121
+ started_at=started_at,
122
+ ended_at=_now_shanghai(),
123
+ duration_seconds=ended_epoch - started_epoch,
124
+ raw=raw,
125
+ )
126
+ return snapshot
127
+ except Exception as exc:
128
+ failure_shot = run_dir / "failed.png"
129
+ self.adb.screenshot(device.adb_serial, failure_shot)
130
+ ended_epoch = int(time.time())
131
+ return TikTokVideoMetricsSnapshot(
132
+ snapshot_id=new_id("tkvideo"),
133
+ platform="tiktok",
134
+ account_id=account_id,
135
+ device_id=device.device_id,
136
+ fetched_at=utc_now_iso(),
137
+ source="tiktok_video_insights_adb",
138
+ status="failed",
139
+ video_order=video_order,
140
+ screenshot_path=str(failure_shot),
141
+ artifact_dir=str(run_dir),
142
+ started_at=started_at,
143
+ ended_at=_now_shanghai(),
144
+ duration_seconds=ended_epoch - started_epoch,
145
+ raw={**raw, "error": str(exc), "failure_screenshot_path": str(failure_shot)},
146
+ )
147
+
148
+ def _build_snapshot(
149
+ self,
150
+ *,
151
+ account_id: str,
152
+ device: Device,
153
+ video_order: int,
154
+ manual_values: TikTokVideoMetricValues | None,
155
+ screenshot_path: Path,
156
+ ui_dump_path: Path,
157
+ run_dir: Path,
158
+ started_at: str,
159
+ ended_at: str,
160
+ duration_seconds: int,
161
+ raw: dict[str, Any],
162
+ ) -> TikTokVideoMetricsSnapshot:
163
+ if manual_values is None:
164
+ return TikTokVideoMetricsSnapshot(
165
+ snapshot_id=new_id("tkvideo"),
166
+ platform="tiktok",
167
+ account_id=account_id,
168
+ device_id=device.device_id,
169
+ fetched_at=utc_now_iso(),
170
+ source="tiktok_video_insights_adb",
171
+ status="needs_human",
172
+ video_order=video_order,
173
+ screenshot_path=str(screenshot_path),
174
+ ui_dump_path=str(ui_dump_path),
175
+ artifact_dir=str(run_dir),
176
+ started_at=started_at,
177
+ ended_at=ended_at,
178
+ duration_seconds=duration_seconds,
179
+ raw={**raw, "extraction_status": "manual_values_required"},
180
+ )
181
+
182
+ combined_raw = dict(manual_values.raw or {})
183
+ combined_raw.update(raw)
184
+ combined_raw["extraction_status"] = "manual_values_recorded"
185
+ if manual_values.top_counters:
186
+ combined_raw["top_counters"] = manual_values.top_counters
187
+ snapshot = TikTokVideoMetricsSnapshot(
188
+ snapshot_id=new_id("tkvideo"),
189
+ platform="tiktok",
190
+ account_id=account_id,
191
+ device_id=device.device_id,
192
+ fetched_at=utc_now_iso(),
193
+ source="tiktok_video_insights_adb",
194
+ status="collected",
195
+ published_at=manual_values.published_at,
196
+ published_at_raw=manual_values.published_at_raw,
197
+ video_order=video_order,
198
+ video_views=manual_values.video_views,
199
+ likes=manual_values.likes,
200
+ comments=manual_values.comments,
201
+ shares=manual_values.shares,
202
+ saves=manual_values.saves,
203
+ total_play_time_seconds=manual_values.total_play_time_seconds,
204
+ total_play_time_raw=manual_values.total_play_time_raw,
205
+ average_watch_time_seconds=manual_values.average_watch_time_seconds,
206
+ average_watch_time_raw=manual_values.average_watch_time_raw,
207
+ completion_rate=manual_values.completion_rate,
208
+ completion_rate_raw=manual_values.completion_rate_raw,
209
+ new_followers=manual_values.new_followers,
210
+ screenshot_path=str(screenshot_path),
211
+ ui_dump_path=str(ui_dump_path),
212
+ artifact_dir=str(run_dir),
213
+ started_at=started_at,
214
+ ended_at=ended_at,
215
+ duration_seconds=duration_seconds,
216
+ raw=combined_raw,
217
+ )
218
+ if not snapshot.has_key_metrics():
219
+ snapshot.status = "needs_human"
220
+ return snapshot
221
+
222
+ def _prepare_device(self, device: Device) -> None:
223
+ self.adb.wake(device.adb_serial)
224
+ time.sleep(0.5)
225
+
226
+ def _scroll_insights_to_key_metrics_top(self, device: Device, run_dir: Path, width: int, height: int) -> None:
227
+ for index in range(3):
228
+ self.adb.swipe(
229
+ device.adb_serial,
230
+ width // 2,
231
+ int(height * 0.40),
232
+ width // 2,
233
+ int(height * 0.82),
234
+ 450,
235
+ )
236
+ time.sleep(0.8)
237
+ self.adb.screenshot(device.adb_serial, run_dir / f"scroll-top-{index + 1}.png")
238
+
239
+ def _navigate_to_video_insights(self, device: Device, run_dir: Path, *, video_order: int) -> None:
240
+ width, height = self._screen_size(device)
241
+ self.adb.shell(device.adb_serial, "am", "force-stop", TIKTOK_PACKAGE)
242
+ time.sleep(1)
243
+ self._ok(self.adb.launch_package(device.adb_serial, TIKTOK_PACKAGE), "launch TikTok")
244
+ time.sleep(5)
245
+ self._tap(device, int(width * 0.92), int(height * 0.96), "open profile tab")
246
+ time.sleep(3)
247
+ self.adb.screenshot(device.adb_serial, run_dir / "profile.png")
248
+ self._dismiss_profile_prompt_if_present(device, run_dir, width, height)
249
+ self._select_profile_posts_tab(device, run_dir, width, height)
250
+
251
+ self._open_profile_video_by_order(device, run_dir, width, height, video_order)
252
+ time.sleep(3)
253
+ self.adb.screenshot(device.adb_serial, run_dir / "video-open.png")
254
+ self._open_video_insights_from_video_page(device, run_dir, width, height)
255
+
256
+ def _open_video_insights_from_video_page(
257
+ self,
258
+ device: Device,
259
+ run_dir: Path,
260
+ width: int,
261
+ height: int,
262
+ ) -> None:
263
+ video_root = self._dump_or_none(device, run_dir / "video-open.xml")
264
+ if video_root is not None and self._tap_visible_video_insights(device, video_root, width, height):
265
+ time.sleep(4)
266
+ self.adb.screenshot(device.adb_serial, run_dir / "after-more-insights.png")
267
+ return
268
+ if self._tap_visible_video_insights_fallback(device, run_dir, width, height):
269
+ return
270
+
271
+ self._open_video_more_menu(device, run_dir, width, height, video_root)
272
+ time.sleep(2)
273
+ self.adb.screenshot(device.adb_serial, run_dir / "video-more-menu.png")
274
+ root = self._dump_or_none(device, run_dir / "video-more-menu.xml")
275
+ if root is not None and ui.tap_first(
276
+ self.adb,
277
+ device,
278
+ root,
279
+ partial_texts={"数据分析", "Analytics", "More insights", "更多数据"},
280
+ sleep_s=4,
281
+ ):
282
+ return
283
+ self.adb.screenshot(device.adb_serial, run_dir / "video-analytics-entry-missing.png")
284
+ raise RuntimeError("TikTok video analytics entry was not found after opening the video more menu")
285
+
286
+ def _tap_visible_video_insights(self, device: Device, root, width: int, height: int) -> bool:
287
+ node = _find_video_insights_button(root, width, height)
288
+ if node is not None:
289
+ ui.tap_node(self.adb, device, node, sleep_s=0)
290
+ return True
291
+ if not _has_video_insights_text(root):
292
+ return False
293
+ self._tap(device, int(width * 0.82), int(height * 0.945), "open visible More insights button fallback")
294
+ return True
295
+
296
+ def _tap_visible_video_insights_fallback(
297
+ self,
298
+ device: Device,
299
+ run_dir: Path,
300
+ width: int,
301
+ height: int,
302
+ ) -> bool:
303
+ self._tap(device, int(width * 0.82), int(height * 0.955), "open lower-right More insights fallback")
304
+ time.sleep(4)
305
+ self.adb.screenshot(device.adb_serial, run_dir / "after-more-insights.png")
306
+ # TikTok renders this analytics page as a Spark/WebView surface on D05, so
307
+ # the UI dump may not expose visible labels like "Key metrics".
308
+ return True
309
+
310
+ def _open_video_more_menu(
311
+ self,
312
+ device: Device,
313
+ run_dir: Path,
314
+ width: int,
315
+ height: int,
316
+ video_root,
317
+ ) -> None:
318
+ node = _find_video_more_menu_button(video_root, width, height) if video_root is not None else None
319
+ if node is not None:
320
+ ui.tap_node(self.adb, device, node, sleep_s=0)
321
+ return
322
+
323
+ # D05/OnePlus 8 owned-video layout: the three-dot action is on the right rail
324
+ # above the bottom music/avatar button. The previous lower tap hit the sound page.
325
+ self._tap(device, int(width * 0.92), int(height * 0.77), "open video more menu fallback")
326
+
327
+ def _dismiss_profile_prompt_if_present(self, device: Device, run_dir: Path, width: int, height: int) -> None:
328
+ root = self._dump_or_none(device, run_dir / "profile-prompt-check.xml")
329
+ if root is None or not ui.contains_any(ui.all_text(root), PROFILE_PROMPT_TEXT_MARKERS):
330
+ return
331
+ node = _find_profile_prompt_close_button(root, width, height)
332
+ if node is not None:
333
+ ui.tap_node(self.adb, device, node, sleep_s=1.5)
334
+ else:
335
+ self._tap(device, int(width * 0.85), int(height * 0.47), "close TikTok profile prompt fallback")
336
+ time.sleep(1.5)
337
+ self.adb.screenshot(device.adb_serial, run_dir / "profile-prompt-closed.png")
338
+
339
+ def _select_profile_posts_tab(self, device: Device, run_dir: Path, width: int, height: int) -> None:
340
+ root = self._dump_or_none(device, run_dir / "profile-posts-tab-check.xml")
341
+ if root is not None and _profile_posts_tab_is_selected(root):
342
+ self.adb.screenshot(device.adb_serial, run_dir / "profile-posts-tab.png")
343
+ return
344
+ node = _find_profile_posts_tab(root, width, height) if root is not None else None
345
+ if node is not None:
346
+ ui.tap_node(self.adb, device, node, sleep_s=1)
347
+ else:
348
+ self._tap(device, int(width * 0.11), int(height * 0.50), "select profile posts tab fallback")
349
+ time.sleep(1)
350
+ self.adb.screenshot(device.adb_serial, run_dir / "profile-posts-tab.png")
351
+
352
+ def _open_profile_video_by_order(
353
+ self,
354
+ device: Device,
355
+ run_dir: Path,
356
+ width: int,
357
+ height: int,
358
+ video_order: int,
359
+ ) -> None:
360
+ index = video_order - 1
361
+ if index < 0:
362
+ raise ValueError("video_order must be >= 1")
363
+ visible_start_order = 1
364
+ last_layout_signature: tuple[tuple[int, int, int, int, str], ...] | None = None
365
+ last_content_signature: tuple[str, ...] | None = None
366
+ visible_count = 0
367
+ for attempt in range(PROFILE_VIDEO_SCROLL_LIMIT + 1):
368
+ xml_name = "profile-video-grid.xml" if attempt == 0 else f"profile-video-grid-scroll-{attempt}.xml"
369
+ root = self._dump_or_none(device, run_dir / xml_name)
370
+ nodes = _find_profile_video_nodes(root, width, height) if root is not None else []
371
+ visible_count = len(nodes)
372
+ layout_signature = _profile_video_nodes_signature(nodes)
373
+ content_signature = _profile_video_nodes_content_signature(nodes)
374
+ if last_content_signature is not None and content_signature != last_content_signature:
375
+ visible_start_order += PROFILE_VIDEO_SCROLL_ADVANCE
376
+ if last_layout_signature is not None and layout_signature == last_layout_signature:
377
+ break
378
+ if root is not None and _profile_all_posts_previewed(root):
379
+ last_visible_order = visible_start_order + len(nodes) - 1
380
+ if video_order > last_visible_order:
381
+ break
382
+
383
+ local_index = video_order - visible_start_order
384
+ if 0 <= local_index < len(nodes):
385
+ ui.tap_node(self.adb, device, nodes[local_index], sleep_s=0)
386
+ return
387
+
388
+ if attempt >= PROFILE_VIDEO_SCROLL_LIMIT:
389
+ break
390
+ last_layout_signature = layout_signature
391
+ last_content_signature = content_signature
392
+ self._scroll_profile_video_grid(device, run_dir, width, height, attempt + 1)
393
+
394
+ self.adb.screenshot(device.adb_serial, run_dir / "profile-video-not-visible.png")
395
+ raise RuntimeError(
396
+ f"profile video order {video_order} is not visible; "
397
+ f"visible_start_order={visible_start_order} visible_video_count={visible_count}"
398
+ )
399
+
400
+ def _scroll_profile_video_grid(
401
+ self,
402
+ device: Device,
403
+ run_dir: Path,
404
+ width: int,
405
+ height: int,
406
+ attempt: int,
407
+ ) -> None:
408
+ self.adb.swipe(
409
+ device.adb_serial,
410
+ width // 2,
411
+ int(height * 0.84),
412
+ width // 2,
413
+ int(height * 0.63),
414
+ 450,
415
+ )
416
+ time.sleep(1.2)
417
+ self.adb.screenshot(device.adb_serial, run_dir / f"profile-video-grid-scroll-{attempt}.png")
418
+
419
+ def _screen_size(self, device: Device) -> tuple[int, int]:
420
+ result = self.adb.shell(device.adb_serial, "wm", "size")
421
+ if result.ok:
422
+ for part in result.stdout.split():
423
+ if "x" not in part:
424
+ continue
425
+ left, right = part.split("x", 1)
426
+ if left.isdigit() and right.isdigit():
427
+ return int(left), int(right)
428
+ return BASE_SCREEN
429
+
430
+ def _tap(self, device: Device, x: int, y: int, label: str) -> None:
431
+ self._ok(self.adb.tap(device.adb_serial, x, y), label)
432
+
433
+ def _dump_or_none(self, device: Device, path: Path):
434
+ import xml.etree.ElementTree as ET
435
+
436
+ for attempt in range(3):
437
+ result = self.adb.dump_ui(device.adb_serial, path)
438
+ if result.ok:
439
+ try:
440
+ return ET.parse(path).getroot()
441
+ except Exception:
442
+ pass
443
+ if attempt < 2:
444
+ time.sleep(0.6)
445
+ return None
446
+
447
+ def _ok(self, result: CommandResult, label: str) -> None:
448
+ if not result.ok:
449
+ raise RuntimeError(f"{label} failed: {result.stderr or result.stdout}")
450
+
451
+
452
+ def _find_video_insights_button(root, width: int, height: int):
453
+ candidates = []
454
+ for node in root.iter("node"):
455
+ if not ui.is_visible(node):
456
+ continue
457
+ text = node.attrib.get("text", "")
458
+ desc = node.attrib.get("content-desc", "")
459
+ haystack = f"{text}\n{desc}".lower()
460
+ if not any(marker.lower() in haystack for marker in VIDEO_INSIGHTS_TEXT_MARKERS):
461
+ continue
462
+ value = ui.bounds(node)
463
+ if not value:
464
+ continue
465
+ left, top, right, bottom = value
466
+ # The direct owned-video insights pill is in the lower-right region.
467
+ # This filter avoids matching unrelated Studio or profile labels.
468
+ if left < int(width * 0.45) or top < int(height * 0.72):
469
+ continue
470
+ candidates.append(node)
471
+ sorted_candidates = ui.sorted_visible_nodes(candidates)
472
+ return sorted_candidates[0] if sorted_candidates else None
473
+
474
+
475
+ def _has_video_insights_text(root) -> bool:
476
+ text = ui.all_text(root)
477
+ return ui.contains_any(text, VIDEO_INSIGHTS_TEXT_MARKERS)
478
+
479
+
480
+ def _find_video_more_menu_button(root, width: int, height: int):
481
+ candidates = []
482
+ for node in root.iter("node"):
483
+ if not ui.is_visible(node):
484
+ continue
485
+ text = node.attrib.get("text", "")
486
+ desc = node.attrib.get("content-desc", "")
487
+ haystack = f"{text}\n{desc}".lower()
488
+ if not any(marker.lower() in haystack for marker in VIDEO_MORE_MENU_TEXT_MARKERS):
489
+ continue
490
+ if any(marker.lower() in haystack for marker in VIDEO_INSIGHTS_TEXT_MARKERS):
491
+ continue
492
+ value = ui.bounds(node)
493
+ if not value:
494
+ continue
495
+ left, top, right, bottom = value
496
+ center_x = (left + right) // 2
497
+ center_y = (top + bottom) // 2
498
+ if center_x < int(width * 0.78):
499
+ continue
500
+ if center_y < int(height * 0.45) or center_y > int(height * 0.88):
501
+ continue
502
+ candidates.append(node)
503
+ sorted_candidates = ui.sorted_visible_nodes(candidates)
504
+ return sorted_candidates[-1] if sorted_candidates else None
505
+
506
+
507
+ def _find_profile_prompt_close_button(root, width: int, height: int):
508
+ candidates = []
509
+ for node in root.iter("node"):
510
+ if not ui.is_visible(node) or node.attrib.get("clickable") != "true":
511
+ continue
512
+ value = ui.bounds(node)
513
+ if not value:
514
+ continue
515
+ left, top, right, bottom = value
516
+ center_x = (left + right) // 2
517
+ center_y = (top + bottom) // 2
518
+ if center_x < int(width * 0.75):
519
+ continue
520
+ if center_y < int(height * 0.35) or center_y > int(height * 0.65):
521
+ continue
522
+ candidates.append(node)
523
+ sorted_candidates = ui.sorted_visible_nodes(candidates)
524
+ return sorted_candidates[0] if sorted_candidates else None
525
+
526
+
527
+ def _profile_posts_tab_is_selected(root) -> bool:
528
+ for node in root.iter("node"):
529
+ desc = node.attrib.get("content-desc", "")
530
+ if desc not in {"Videos", "Posts", "视频", "作品"}:
531
+ continue
532
+ if node.attrib.get("selected") == "true":
533
+ return True
534
+ return False
535
+
536
+
537
+ def _find_profile_posts_tab(root, width: int, height: int):
538
+ candidates = []
539
+ for node in root.iter("node"):
540
+ if not ui.is_visible(node) or node.attrib.get("clickable") != "true":
541
+ continue
542
+ value = ui.bounds(node)
543
+ if not value:
544
+ continue
545
+ left, top, right, bottom = value
546
+ center_x = (left + right) // 2
547
+ center_y = (top + bottom) // 2
548
+ if center_x > int(width * 0.22):
549
+ continue
550
+ if center_y < int(height * 0.42) or center_y > int(height * 0.62):
551
+ continue
552
+ candidates.append(node)
553
+ sorted_candidates = ui.sorted_visible_nodes(candidates)
554
+ return sorted_candidates[0] if sorted_candidates else None
555
+
556
+
557
+ def _find_profile_video_nodes(root, width: int, height: int):
558
+ nodes = []
559
+ for node in root.iter("node"):
560
+ if not ui.is_visible(node) or node.attrib.get("clickable") != "true":
561
+ continue
562
+ resource_id = node.attrib.get("resource-id", "")
563
+ if resource_id and not resource_id.endswith(":id/ekl"):
564
+ continue
565
+ if resource_id.endswith(":id/ekl") and node.attrib.get("long-clickable") != "true":
566
+ continue
567
+ value = ui.bounds(node)
568
+ if not value:
569
+ continue
570
+ left, top, right, bottom = value
571
+ node_width = right - left
572
+ node_height = bottom - top
573
+ if top < int(height * 0.25):
574
+ continue
575
+ if bottom > int(height * 0.93):
576
+ continue
577
+ if node_width < int(width * 0.20) or node_height < int(height * 0.10):
578
+ continue
579
+ if node_width > int(width * 0.38):
580
+ continue
581
+ nodes.append(node)
582
+ return ui.sorted_visible_nodes(nodes)
583
+
584
+
585
+ def _profile_video_nodes_signature(nodes) -> tuple[tuple[int, int, int, int, str], ...]:
586
+ signature = []
587
+ for node in nodes:
588
+ left, top, right, bottom = ui.bounds(node) or (0, 0, 0, 0)
589
+ signature.append((left, top, right, bottom, _node_text_signature(node)))
590
+ return tuple(signature)
591
+
592
+
593
+ def _profile_video_nodes_content_signature(nodes) -> tuple[str, ...]:
594
+ return tuple(_node_text_signature(node) for node in nodes)
595
+
596
+
597
+ def _node_text_signature(node) -> str:
598
+ parts: list[str] = []
599
+ for child in node.iter("node"):
600
+ text = child.attrib.get("text", "")
601
+ desc = child.attrib.get("content-desc", "")
602
+ if text:
603
+ parts.append(text)
604
+ if desc:
605
+ parts.append(desc)
606
+ return "|".join(parts)
607
+
608
+
609
+ def _profile_all_posts_previewed(root) -> bool:
610
+ return ui.contains_any(ui.all_text(root), {"You've previewed all posts", "已浏览所有作品"})
611
+
612
+
613
+ def parse_manual_video_values_json(raw_json: str) -> TikTokVideoMetricValues | None:
614
+ if not raw_json.strip():
615
+ return None
616
+ loaded = json.loads(raw_json)
617
+ if not isinstance(loaded, dict):
618
+ raise ValueError("manual video values JSON must be an object")
619
+ total_play_time_raw = str(loaded.get("total_play_time_raw") or loaded.get("total_play_time") or "")
620
+ average_watch_time_raw = str(loaded.get("average_watch_time_raw") or loaded.get("average_watch_time") or "")
621
+ completion_rate_raw = str(loaded.get("completion_rate_raw") or loaded.get("completion_rate") or "")
622
+ top_counters = dict(loaded.get("top_counters") or {})
623
+ return TikTokVideoMetricValues(
624
+ published_at=str(loaded.get("published_at", "")),
625
+ published_at_raw=str(loaded.get("published_at_raw") or loaded.get("published_at") or ""),
626
+ video_views=_optional_int(loaded.get("video_views", loaded.get("views", top_counters.get("views")))),
627
+ likes=_optional_int(loaded.get("likes", top_counters.get("likes"))),
628
+ comments=_optional_int(loaded.get("comments", top_counters.get("comments"))),
629
+ shares=_optional_int(loaded.get("shares", top_counters.get("shares"))),
630
+ saves=_optional_int(loaded.get("saves", top_counters.get("saves"))),
631
+ total_play_time_seconds=_optional_int(
632
+ loaded.get("total_play_time_seconds"),
633
+ default=parse_duration_seconds(total_play_time_raw),
634
+ ),
635
+ total_play_time_raw=total_play_time_raw,
636
+ average_watch_time_seconds=_optional_float(
637
+ loaded.get("average_watch_time_seconds"),
638
+ default=parse_seconds_value(average_watch_time_raw),
639
+ ),
640
+ average_watch_time_raw=average_watch_time_raw,
641
+ completion_rate=_optional_float(
642
+ loaded.get("completion_rate"),
643
+ default=parse_percent_value(completion_rate_raw),
644
+ ),
645
+ completion_rate_raw=completion_rate_raw,
646
+ new_followers=_optional_int(loaded.get("new_followers")),
647
+ top_counters={
648
+ "views": _optional_int(loaded.get("video_views", loaded.get("views", top_counters.get("views")))),
649
+ "likes": _optional_int(loaded.get("likes", top_counters.get("likes"))),
650
+ "comments": _optional_int(loaded.get("comments", top_counters.get("comments"))),
651
+ "shares": _optional_int(loaded.get("shares", top_counters.get("shares"))),
652
+ "saves": _optional_int(loaded.get("saves", top_counters.get("saves"))),
653
+ },
654
+ raw=dict(loaded.get("raw") or {}),
655
+ )
656
+
657
+
658
+ def parse_duration_seconds(value: str) -> int | None:
659
+ if not value.strip():
660
+ return None
661
+ text = value.strip()
662
+ total = 0
663
+ matched = False
664
+ for pattern, multiplier in (
665
+ (r"(\d+)\s*(?:小时|h|hour|hours)", 3600),
666
+ (r"(\d+)\s*(?:分钟|m|min|minute|minutes)", 60),
667
+ (r"(\d+)\s*(?:秒|s|sec|second|seconds)", 1),
668
+ ):
669
+ match = re.search(pattern, text, flags=re.IGNORECASE)
670
+ if match:
671
+ total += int(match.group(1)) * multiplier
672
+ matched = True
673
+ if matched:
674
+ return total
675
+ if text.isdigit():
676
+ return int(text)
677
+ return None
678
+
679
+
680
+ def parse_seconds_value(value: str) -> float | None:
681
+ match = re.search(r"(\d+(?:\.\d+)?)", value)
682
+ return float(match.group(1)) if match else None
683
+
684
+
685
+ def parse_percent_value(value: str) -> float | None:
686
+ match = re.search(r"(\d+(?:\.\d+)?)", value)
687
+ return float(match.group(1)) / 100 if match else None
688
+
689
+
690
+ def _png_size(path: Path) -> tuple[int, int] | None:
691
+ try:
692
+ with path.open("rb") as handle:
693
+ header = handle.read(24)
694
+ if len(header) < 24 or header[:8] != b"\x89PNG\r\n\x1a\n":
695
+ return None
696
+ return struct.unpack(">II", header[16:24])
697
+ except Exception:
698
+ return None
699
+
700
+
701
+ def _optional_int(value: Any, *, default: int | None = None) -> int | None:
702
+ if value in (None, ""):
703
+ return default
704
+ return int(value)
705
+
706
+
707
+ def _optional_float(value: Any, *, default: float | None = None) -> float | None:
708
+ if value in (None, ""):
709
+ return default
710
+ return float(value)
711
+
712
+
713
+ def _now_shanghai() -> str:
714
+ return datetime.now(ZoneInfo("Asia/Shanghai")).replace(microsecond=0).isoformat()