@11agents/cli 0.1.42 → 0.1.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import base64
4
+ import re
4
5
  import shlex
5
6
  import time
6
7
  from dataclasses import dataclass
@@ -50,6 +51,16 @@ TIKTOK_PREVIEW_TEXTS = (
50
51
  "一键成片",
51
52
  "AutoCut",
52
53
  )
54
+ TIKTOK_SHARE_TEXTS = (
55
+ "分享视频",
56
+ "分享",
57
+ "Share video",
58
+ "Share",
59
+ )
60
+ TIKTOK_COPY_LINK_TEXTS = (
61
+ "复制链接",
62
+ "Copy link",
63
+ )
53
64
 
54
65
 
55
66
  class TikTokAutomationBlockedError(RuntimeError):
@@ -66,6 +77,23 @@ class TikTokAdbPublishResult:
66
77
  duration_seconds: int
67
78
  remote_media_path: str
68
79
  screenshot_path: str
80
+ platform_permalink: str = ""
81
+ platform_post_id: str = ""
82
+ link_status: str = ""
83
+ link_error: str = ""
84
+ link_screenshot_path: str = ""
85
+ clipboard_method: str = ""
86
+ error: str = ""
87
+
88
+
89
+ @dataclass
90
+ class TikTokAdbLinkResult:
91
+ device_id: str
92
+ status: str
93
+ platform_permalink: str
94
+ screenshot_path: str
95
+ platform_post_id: str = ""
96
+ clipboard_method: str = ""
69
97
  error: str = ""
70
98
 
71
99
 
@@ -76,10 +104,12 @@ class TikTokAdbPublisher:
76
104
  self,
77
105
  adb: AdbClient,
78
106
  *,
107
+ appium_server: str = "http://127.0.0.1:4723",
79
108
  records_path: str | Path = "data/publish_records.jsonl",
80
109
  artifact_root: str | Path = "artifacts/screenshots",
81
110
  ) -> None:
82
111
  self.adb = adb
112
+ self.appium_server = appium_server
83
113
  self.records_path = Path(records_path)
84
114
  self.artifact_root = Path(artifact_root)
85
115
 
@@ -104,6 +134,7 @@ class TikTokAdbPublisher:
104
134
  run_dir.mkdir(parents=True, exist_ok=True)
105
135
  remote = f"/sdcard/DCIM/Camera/groupctl-{device.device_id}-tiktok-{stamp}{local.suffix or '.mp4'}"
106
136
  record_id = f"pub_{stamp.replace('-', '')}_{device.device_id.lower()}_tiktok"
137
+ link_result: TikTokAdbLinkResult | None = None
107
138
 
108
139
  try:
109
140
  self._prepare_device(device)
@@ -123,6 +154,18 @@ class TikTokAdbPublisher:
123
154
  else:
124
155
  self._tap_publish(device, run_dir)
125
156
  final_shot = self._wait_for_publish_confirmation(device, run_dir)
157
+ link_result = self.copy_current_or_latest_video_link(
158
+ device,
159
+ run_dir=run_dir / "link",
160
+ video_order=1,
161
+ close_after_recovery=True,
162
+ )
163
+ if link_result.status != "retrieved" or not link_result.platform_permalink:
164
+ raise RuntimeError(
165
+ "TikTok post-publish link recovery failed: "
166
+ f"{link_result.error or link_result.status}"
167
+ )
168
+ final_shot = Path(link_result.screenshot_path)
126
169
  status = "published"
127
170
 
128
171
  ended_epoch = int(time.time())
@@ -136,13 +179,14 @@ class TikTokAdbPublisher:
136
179
  post_type="video",
137
180
  local_media_path=str(local),
138
181
  remote_media_path=remote,
182
+ platform_post_id=link_result.platform_post_id if link_result else "",
183
+ platform_permalink=link_result.platform_permalink if link_result else "",
139
184
  caption=caption,
140
185
  published_at=utc_now_iso(),
141
186
  result_screenshot_path=str(final_shot),
142
187
  status="published",
143
188
  )
144
189
  append_publish_record(self.records_path, record)
145
- self._close_tiktok_best_effort(device, run_dir)
146
190
  return TikTokAdbPublishResult(
147
191
  device_id=device.device_id,
148
192
  status=status,
@@ -152,11 +196,18 @@ class TikTokAdbPublisher:
152
196
  duration_seconds=ended_epoch - started_epoch,
153
197
  remote_media_path=remote,
154
198
  screenshot_path=str(final_shot),
199
+ platform_permalink=link_result.platform_permalink if link_result else "",
200
+ platform_post_id=link_result.platform_post_id if link_result else "",
201
+ link_status=link_result.status if link_result else "",
202
+ link_error=link_result.error if link_result else "",
203
+ link_screenshot_path=link_result.screenshot_path if link_result else "",
204
+ clipboard_method=link_result.clipboard_method if link_result else "",
155
205
  )
156
206
  except TikTokAutomationBlockedError as exc:
157
207
  ended_epoch = int(time.time())
158
208
  failure_shot = run_dir / "failed.png"
159
209
  self.adb.screenshot(device.adb_serial, failure_shot)
210
+ self._close_tiktok_best_effort(device, run_dir)
160
211
  return TikTokAdbPublishResult(
161
212
  device_id=device.device_id,
162
213
  status="failed",
@@ -166,12 +217,19 @@ class TikTokAdbPublisher:
166
217
  duration_seconds=ended_epoch - started_epoch,
167
218
  remote_media_path=remote,
168
219
  screenshot_path=str(failure_shot),
220
+ platform_permalink=link_result.platform_permalink if link_result else "",
221
+ platform_post_id=link_result.platform_post_id if link_result else "",
222
+ link_status=link_result.status if link_result else "",
223
+ link_error=link_result.error if link_result else "",
224
+ link_screenshot_path=link_result.screenshot_path if link_result else "",
225
+ clipboard_method=link_result.clipboard_method if link_result else "",
169
226
  error=str(exc),
170
227
  )
171
228
  except Exception as exc:
172
229
  ended_epoch = int(time.time())
173
230
  failure_shot = run_dir / "failed.png"
174
231
  self.adb.screenshot(device.adb_serial, failure_shot)
232
+ self._close_tiktok_best_effort(device, run_dir)
175
233
  return TikTokAdbPublishResult(
176
234
  device_id=device.device_id,
177
235
  status="failed",
@@ -181,8 +239,429 @@ class TikTokAdbPublisher:
181
239
  duration_seconds=ended_epoch - started_epoch,
182
240
  remote_media_path=remote,
183
241
  screenshot_path=str(failure_shot),
242
+ platform_permalink=link_result.platform_permalink if link_result else "",
243
+ platform_post_id=link_result.platform_post_id if link_result else "",
244
+ link_status=link_result.status if link_result else "",
245
+ link_error=link_result.error if link_result else "",
246
+ link_screenshot_path=link_result.screenshot_path if link_result else "",
247
+ clipboard_method=link_result.clipboard_method if link_result else "",
248
+ error=str(exc),
249
+ )
250
+
251
+ def copy_latest_video_link(
252
+ self,
253
+ device: Device,
254
+ *,
255
+ run_dir: Path | None = None,
256
+ prepare: bool = True,
257
+ video_order: int = 1,
258
+ close_after_recovery: bool = True,
259
+ ) -> TikTokAdbLinkResult:
260
+ if run_dir is None:
261
+ stamp = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d-%H%M%S")
262
+ run_dir = self.artifact_root / f"{device.device_id}-tiktok-link-{stamp}"
263
+ run_dir.mkdir(parents=True, exist_ok=True)
264
+
265
+ try:
266
+ self._open_latest_own_video_for_link(
267
+ device,
268
+ run_dir,
269
+ prepare=prepare,
270
+ video_order=video_order,
271
+ )
272
+ self._tap_share_from_video_detail(device, run_dir)
273
+ self._tap_copy_link(device, run_dir)
274
+ time.sleep(1.5)
275
+ final_shot = run_dir / "after-copy-link.png"
276
+ self.adb.screenshot(device.adb_serial, final_shot)
277
+ permalink, method, error = self._read_copied_link(device, run_dir)
278
+ post_id = _tiktok_post_id(permalink)
279
+ if permalink:
280
+ return TikTokAdbLinkResult(
281
+ device_id=device.device_id,
282
+ status="retrieved",
283
+ platform_permalink=permalink,
284
+ platform_post_id=post_id,
285
+ screenshot_path=str(final_shot),
286
+ clipboard_method=method,
287
+ )
288
+ return TikTokAdbLinkResult(
289
+ device_id=device.device_id,
290
+ status="failed",
291
+ platform_permalink="",
292
+ screenshot_path=str(final_shot),
293
+ error=error or "copied link to device clipboard, but automatic clipboard read returned no URL",
294
+ )
295
+ except Exception as exc:
296
+ failure_shot = run_dir / "failed-copy-link.png"
297
+ self.adb.screenshot(device.adb_serial, failure_shot)
298
+ return TikTokAdbLinkResult(
299
+ device_id=device.device_id,
300
+ status="failed",
301
+ platform_permalink="",
302
+ screenshot_path=str(failure_shot),
184
303
  error=str(exc),
185
304
  )
305
+ finally:
306
+ if close_after_recovery:
307
+ self._close_tiktok_best_effort(device, run_dir)
308
+
309
+ def copy_current_or_latest_video_link(
310
+ self,
311
+ device: Device,
312
+ *,
313
+ run_dir: Path | None = None,
314
+ video_order: int = 1,
315
+ close_after_recovery: bool = True,
316
+ ) -> TikTokAdbLinkResult:
317
+ if run_dir is None:
318
+ stamp = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d-%H%M%S")
319
+ run_dir = self.artifact_root / f"{device.device_id}-tiktok-link-{stamp}"
320
+ run_dir.mkdir(parents=True, exist_ok=True)
321
+
322
+ current = self.copy_current_visible_video_link(
323
+ device,
324
+ run_dir=run_dir / "current",
325
+ close_after_recovery=False,
326
+ )
327
+ current_copy_link_was_visible = current.error != "current TikTok page does not show a copy link action"
328
+ if current_copy_link_was_visible:
329
+ if close_after_recovery:
330
+ self._close_tiktok_best_effort(device, run_dir)
331
+ return current
332
+
333
+ latest = self.copy_latest_video_link(
334
+ device,
335
+ run_dir=run_dir / "latest",
336
+ prepare=True,
337
+ video_order=video_order,
338
+ close_after_recovery=False,
339
+ )
340
+ if close_after_recovery:
341
+ self._close_tiktok_best_effort(device, run_dir)
342
+ return latest
343
+
344
+ def copy_current_visible_video_link(
345
+ self,
346
+ device: Device,
347
+ *,
348
+ run_dir: Path | None = None,
349
+ close_after_recovery: bool = True,
350
+ ) -> TikTokAdbLinkResult:
351
+ if run_dir is None:
352
+ stamp = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d-%H%M%S")
353
+ run_dir = self.artifact_root / f"{device.device_id}-tiktok-current-link-{stamp}"
354
+ run_dir.mkdir(parents=True, exist_ok=True)
355
+
356
+ try:
357
+ root = self._dump_and_check(device, run_dir, "current-before-copy-link")
358
+ node = _find_tiktok_copy_link_node(root)
359
+ if node is None:
360
+ self.adb.screenshot(device.adb_serial, run_dir / "current-copy-link-missing.png")
361
+ return TikTokAdbLinkResult(
362
+ device_id=device.device_id,
363
+ status="failed",
364
+ platform_permalink="",
365
+ screenshot_path=str(run_dir / "current-copy-link-missing.png"),
366
+ error="current TikTok page does not show a copy link action",
367
+ )
368
+ self.adb.screenshot(device.adb_serial, run_dir / "current-copy-link-visible.png")
369
+ ui.tap_node(self.adb, device, node, sleep_s=1.5)
370
+ final_shot = run_dir / "after-copy-link.png"
371
+ self.adb.screenshot(device.adb_serial, final_shot)
372
+ permalink, method, error = self._read_copied_link(device, run_dir)
373
+ post_id = _tiktok_post_id(permalink)
374
+ if permalink:
375
+ return TikTokAdbLinkResult(
376
+ device_id=device.device_id,
377
+ status="retrieved",
378
+ platform_permalink=permalink,
379
+ platform_post_id=post_id,
380
+ screenshot_path=str(final_shot),
381
+ clipboard_method=method,
382
+ )
383
+ return TikTokAdbLinkResult(
384
+ device_id=device.device_id,
385
+ status="failed",
386
+ platform_permalink="",
387
+ screenshot_path=str(final_shot),
388
+ error=error or "copied link to device clipboard, but automatic clipboard read returned no URL",
389
+ )
390
+ except Exception as exc:
391
+ failure_shot = run_dir / "failed-current-copy-link.png"
392
+ self.adb.screenshot(device.adb_serial, failure_shot)
393
+ return TikTokAdbLinkResult(
394
+ device_id=device.device_id,
395
+ status="failed",
396
+ platform_permalink="",
397
+ screenshot_path=str(failure_shot),
398
+ error=str(exc),
399
+ )
400
+ finally:
401
+ if close_after_recovery:
402
+ self._close_tiktok_best_effort(device, run_dir)
403
+
404
+ def _open_latest_own_video_for_link(
405
+ self,
406
+ device: Device,
407
+ run_dir: Path,
408
+ *,
409
+ prepare: bool,
410
+ video_order: int,
411
+ ) -> None:
412
+ from device_control.metrics.tiktok_video_adb import TikTokVideoMetricsAdbCollector
413
+
414
+ collector = TikTokVideoMetricsAdbCollector(self.adb, artifact_root=self.artifact_root)
415
+ width, height = self._screen_size(device)
416
+ if prepare:
417
+ self._prepare_device(device)
418
+ self._launch_tiktok(device)
419
+ else:
420
+ self._ok(self.adb.wake(device.adb_serial), "wake device")
421
+ if TIKTOK_PACKAGE not in self.adb.current_focus(device.adb_serial):
422
+ self._launch_tiktok(device)
423
+
424
+ # Use the same profile-grid route as the TikTok video metrics collector, but stop at
425
+ # the owned video page so the share sheet can expose a stable public link.
426
+ self._tap(device, int(width * 0.92), int(height * 0.96), 0)
427
+ time.sleep(3)
428
+ self.adb.screenshot(device.adb_serial, run_dir / "profile.png")
429
+ collector._dismiss_profile_prompt_if_present(device, run_dir, width, height)
430
+ collector._select_profile_posts_tab(device, run_dir, width, height)
431
+ time.sleep(10)
432
+ self.adb.screenshot(device.adb_serial, run_dir / "profile-posts-after-load-wait.png")
433
+ self._wait_for_profile_video_grid(device, run_dir, width, height)
434
+ collector._open_profile_video_by_order(device, run_dir, width, height, video_order)
435
+ time.sleep(3)
436
+ self.adb.screenshot(device.adb_serial, run_dir / "latest-video-open.png")
437
+
438
+ def _wait_for_profile_video_grid(self, device: Device, run_dir: Path, width: int, height: int) -> None:
439
+ from device_control.metrics.tiktok_video_adb import _find_profile_video_nodes
440
+
441
+ for attempt in range(6):
442
+ root = self._dump_or_none(device, run_dir / f"profile-video-grid-wait-{attempt + 1}.xml")
443
+ nodes = _find_profile_video_nodes(root, width, height) if root is not None else []
444
+ if nodes:
445
+ return
446
+ time.sleep(2)
447
+ self.adb.screenshot(device.adb_serial, run_dir / f"profile-video-grid-wait-{attempt + 1}.png")
448
+
449
+ def _tap_share_from_video_detail(self, device: Device, run_dir: Path) -> None:
450
+ width, height = self._screen_size(device)
451
+ fallback_points = (
452
+ (int(width * 0.92), int(height * 0.72)),
453
+ (int(width * 0.92), int(height * 0.66)),
454
+ (int(width * 0.92), int(height * 0.78)),
455
+ )
456
+ for attempt in range(6):
457
+ root = self._dump_and_check(device, run_dir, f"video-before-share-{attempt + 1}")
458
+ node = _find_tiktok_share_button(root, width, height)
459
+ if node is not None:
460
+ ui.tap_node(self.adb, device, node, sleep_s=2)
461
+ self.adb.screenshot(device.adb_serial, run_dir / "share-sheet.png")
462
+ return
463
+
464
+ if attempt < len(fallback_points):
465
+ x, y = fallback_points[attempt]
466
+ self._tap(device, x, y, 2)
467
+ self.adb.screenshot(device.adb_serial, run_dir / f"share-fallback-{attempt + 1}.png")
468
+ share_root = self._dump_and_check(device, run_dir, f"share-fallback-{attempt + 1}")
469
+ if _find_tiktok_copy_link_node(share_root) is not None:
470
+ self.adb.screenshot(device.adb_serial, run_dir / "share-sheet.png")
471
+ return
472
+ self.adb.keyevent(device.adb_serial, "KEYCODE_BACK")
473
+ time.sleep(1)
474
+ continue
475
+
476
+ time.sleep(1)
477
+
478
+ self.adb.screenshot(device.adb_serial, run_dir / "share-icon-missing.png")
479
+ raise RuntimeError("TikTok share action was not found on the video detail page")
480
+
481
+ def _tap_copy_link(self, device: Device, run_dir: Path) -> None:
482
+ width, height = self._screen_size(device)
483
+ for attempt in range(6):
484
+ root = self._dump_and_check(device, run_dir, f"share-sheet-{attempt + 1}")
485
+ if attempt == 0:
486
+ self.adb.screenshot(device.adb_serial, run_dir / "share-sheet.png")
487
+ node = _find_tiktok_copy_link_node(root)
488
+ if node is not None:
489
+ ui.tap_node(self.adb, device, node, sleep_s=1)
490
+ return
491
+ time.sleep(1)
492
+
493
+ self._tap(device, int(width * 0.14), int(height * 0.87), 1)
494
+ self.adb.screenshot(device.adb_serial, run_dir / "copy-link-fallback.png")
495
+
496
+ def _read_copied_link(self, device: Device, run_dir: Path) -> tuple[str, str, str]:
497
+ errors: list[str] = []
498
+
499
+ result = self.adb.shell(device.adb_serial, "cmd", "clipboard", "get")
500
+ if result.ok:
501
+ link = _extract_tiktok_url(result.stdout)
502
+ if link:
503
+ return link, "adb_cmd_clipboard", ""
504
+ errors.append("adb_cmd_clipboard returned no TikTok URL")
505
+ else:
506
+ errors.append(f"adb_cmd_clipboard failed: {_short(result.stderr or result.stdout)}")
507
+
508
+ result = self.adb.shell(device.adb_serial, "dumpsys", "clipboard")
509
+ if result.ok:
510
+ link = _extract_tiktok_url(result.stdout)
511
+ if link:
512
+ return link, "adb_dumpsys_clipboard", ""
513
+ errors.append("adb_dumpsys_clipboard returned no TikTok URL")
514
+ else:
515
+ errors.append(f"adb_dumpsys_clipboard failed: {_short(result.stderr or result.stdout)}")
516
+
517
+ link = self._read_link_by_launcher_search_paste_probe(device, run_dir)
518
+ if link:
519
+ return link, "launcher_search_paste_probe_ui_dump", ""
520
+ errors.append("launcher_search_paste_probe_ui_dump returned no TikTok URL")
521
+
522
+ link = self._read_link_by_tiktok_search_paste_probe(device, run_dir)
523
+ if link:
524
+ return link, "tiktok_search_paste_probe_ui_dump", ""
525
+ errors.append("tiktok_search_paste_probe_ui_dump returned no TikTok URL")
526
+
527
+ return "", "", "; ".join(errors)
528
+
529
+ def _read_link_by_launcher_search_paste_probe(self, device: Device, run_dir: Path) -> str:
530
+ try:
531
+ self._close_tiktok_best_effort(device, run_dir)
532
+ self.adb.keyevent(device.adb_serial, "KEYCODE_HOME")
533
+ time.sleep(1)
534
+ width, height = self._screen_size(device)
535
+ self._tap(device, int(width * 0.50), int(height * 0.91), 1)
536
+ self.adb.screenshot(device.adb_serial, run_dir / "launcher-search-probe-open.png")
537
+
538
+ root = self._dump_and_check(device, run_dir, "launcher-search-probe-open")
539
+ input_node = _find_launcher_search_input(root)
540
+ if input_node is None:
541
+ self._tap(device, int(width * 0.50), int(height * 0.09), 0.5)
542
+ root = self._dump_and_check(device, run_dir, "launcher-search-probe-input")
543
+ input_node = _find_launcher_search_input(root)
544
+ if input_node is not None:
545
+ existing = _launcher_search_input_text(input_node)
546
+ if existing:
547
+ clear_node = _find_launcher_search_clear_node(root, width, height)
548
+ if clear_node is not None:
549
+ ui.tap_node(self.adb, device, clear_node, sleep_s=0.4)
550
+ else:
551
+ self._tap(device, int(width * 0.93), int(height * 0.09), 0.4)
552
+ root = self._dump_or_none(device, run_dir / "launcher-search-probe-after-clear.xml") or root
553
+ input_node = _find_launcher_search_input(root) or input_node
554
+ ui.tap_node(self.adb, device, input_node, sleep_s=0.4)
555
+ else:
556
+ self._tap(device, int(width * 0.50), int(height * 0.09), 0.4)
557
+
558
+ self.adb.keyevent(device.adb_serial, "KEYCODE_PASTE")
559
+ time.sleep(0.8)
560
+ self.adb.screenshot(device.adb_serial, run_dir / "launcher-search-probe-after-paste.png")
561
+ root = self._dump_and_check(device, run_dir, "launcher-search-probe-after-paste")
562
+ link = _extract_tiktok_url(ui.all_text(root))
563
+ if link:
564
+ return link
565
+
566
+ # Some keyboards show the copied URL in a suggestion strip instead of honoring KEYCODE_PASTE.
567
+ self._tap(device, int(width * 0.40), int(height * 0.742), 0.8)
568
+ self.adb.screenshot(device.adb_serial, run_dir / "launcher-search-probe-after-clipboard-suggestion.png")
569
+ root = self._dump_and_check(device, run_dir, "launcher-search-probe-after-clipboard-suggestion")
570
+ link = _extract_tiktok_url(ui.all_text(root))
571
+ if link:
572
+ return link
573
+
574
+ input_node = _find_launcher_search_input(root)
575
+ if input_node is not None:
576
+ self._long_press_node(device, input_node)
577
+ time.sleep(0.5)
578
+ paste_root = self._dump_and_check(device, run_dir, "launcher-search-probe-paste-menu")
579
+ paste = ui.find_node(
580
+ paste_root,
581
+ texts={"粘贴", "Paste"},
582
+ descs={"粘贴", "Paste"},
583
+ partial_texts={"粘贴", "paste"},
584
+ )
585
+ if paste is not None:
586
+ ui.tap_node(self.adb, device, paste, sleep_s=0.8)
587
+ self.adb.screenshot(device.adb_serial, run_dir / "launcher-search-probe-after-menu-paste.png")
588
+ root = self._dump_and_check(device, run_dir, "launcher-search-probe-after-menu-paste")
589
+ return _extract_tiktok_url(ui.all_text(root))
590
+ return ""
591
+ except Exception:
592
+ return ""
593
+ finally:
594
+ self.adb.keyevent(device.adb_serial, "KEYCODE_BACK")
595
+ time.sleep(0.2)
596
+ self.adb.keyevent(device.adb_serial, "KEYCODE_HOME")
597
+ time.sleep(0.5)
598
+
599
+ def _read_link_by_tiktok_search_paste_probe(self, device: Device, run_dir: Path) -> str:
600
+ try:
601
+ self._launch_tiktok(device)
602
+ width, height = self._screen_size(device)
603
+ root = self._dump_and_check(device, run_dir, "tiktok-before-search-probe")
604
+ search_node = _find_tiktok_search_node(root, width, height)
605
+ if search_node is None:
606
+ self._tap(device, int(width * 0.08), int(height * 0.96), 1.5)
607
+ root = self._dump_and_check(device, run_dir, "tiktok-search-probe-home")
608
+ search_node = _find_tiktok_search_node(root, width, height)
609
+ if search_node is not None:
610
+ ui.tap_node(self.adb, device, search_node, sleep_s=1.5)
611
+ else:
612
+ self._tap(device, int(width * 0.92), int(height * 0.075), 1.5)
613
+ self.adb.screenshot(device.adb_serial, run_dir / "tiktok-search-probe-open.png")
614
+
615
+ for attempt in range(3):
616
+ root = self._dump_and_check(device, run_dir, f"tiktok-search-probe-before-paste-{attempt + 1}")
617
+ input_node = _find_tiktok_search_input(root)
618
+ if input_node is not None:
619
+ ui.tap_node(self.adb, device, input_node, sleep_s=0.5)
620
+ else:
621
+ self._tap(device, int(width * 0.50), int(height * 0.075), 0.5)
622
+ self.adb.keyevent(device.adb_serial, "KEYCODE_PASTE")
623
+ time.sleep(1)
624
+ self.adb.screenshot(device.adb_serial, run_dir / f"tiktok-search-probe-after-paste-{attempt + 1}.png")
625
+ root = self._dump_and_check(device, run_dir, f"tiktok-search-probe-after-paste-{attempt + 1}")
626
+ link = _extract_tiktok_url(ui.all_text(root))
627
+ if link:
628
+ return link
629
+ input_node = _find_tiktok_search_input(root)
630
+ if input_node is None:
631
+ continue
632
+ self._long_press_node(device, input_node)
633
+ time.sleep(0.5)
634
+ paste_root = self._dump_and_check(device, run_dir, f"tiktok-search-probe-paste-menu-{attempt + 1}")
635
+ paste = ui.find_node(paste_root, texts={"粘贴", "Paste"}, descs={"粘贴", "Paste"}, partial_texts={"粘贴", "paste"})
636
+ if paste is not None:
637
+ ui.tap_node(self.adb, device, paste, sleep_s=1)
638
+ self.adb.screenshot(device.adb_serial, run_dir / f"tiktok-search-probe-after-menu-paste-{attempt + 1}.png")
639
+ root = self._dump_and_check(device, run_dir, f"tiktok-search-probe-after-menu-paste-{attempt + 1}")
640
+ link = _extract_tiktok_url(ui.all_text(root))
641
+ if link:
642
+ return link
643
+ return ""
644
+ except Exception:
645
+ return ""
646
+ finally:
647
+ self._close_tiktok_best_effort(device, run_dir)
648
+
649
+ def _long_press_node(self, device: Device, node) -> None:
650
+ left, top, right, bottom = ui.bounds(node) or (0, 0, 0, 0)
651
+ x = (left + right) // 2
652
+ y = min(max((top + bottom) // 2, top + 8), bottom - 8)
653
+ self.adb.swipe(device.adb_serial, x, y, x, y, 900)
654
+
655
+ def _screen_size(self, device: Device) -> tuple[int, int]:
656
+ result = self.adb.shell(device.adb_serial, "wm", "size")
657
+ if result.ok:
658
+ for part in result.stdout.split():
659
+ if "x" not in part:
660
+ continue
661
+ left, right = part.split("x", 1)
662
+ if left.isdigit() and right.isdigit():
663
+ return int(left), int(right)
664
+ return (1080, 2245)
186
665
 
187
666
  def _prepare_device(self, device: Device) -> None:
188
667
  self._ok(self.adb.wake(device.adb_serial), "wake device")
@@ -278,19 +757,22 @@ class TikTokAdbPublisher:
278
757
  def _open_gallery(self, device: Device, run_dir: Path) -> None:
279
758
  # D01 and D02 currently expose different create-page entry points.
280
759
  # Try the D01 bottom-left upload first, then the D02 right-side album icon.
760
+ width, height = self._screen_size(device)
281
761
  root = self._dump_and_check(device, run_dir, "before-gallery-tap")
282
762
  upload_node = ui.find_node(root, id_suffixes={"upload_hot_area"}, clickable_only=True)
283
763
  if upload_node is None:
284
- self._tap(device, 105, 2050, 0)
764
+ self._tap(device, int(width * 0.09), int(height * 0.92), 0)
285
765
  else:
286
766
  ui.tap_node(self.adb, device, upload_node)
287
767
  time.sleep(2)
288
768
  shot = run_dir / "after-gallery-tap-1.png"
289
769
  self.adb.screenshot(device.adb_serial, shot)
290
- self._assert_not_home_or_wrong_surface(
291
- self._dump_and_check(device, run_dir, "after-gallery-tap-1"),
292
- "open TikTok gallery",
293
- )
770
+ root = self._dump_and_check(device, run_dir, "after-gallery-tap-1")
771
+ if _is_tiktok_create_home_surface(root):
772
+ self._tap(device, int(width * 0.09), int(height * 0.955), 2)
773
+ self.adb.screenshot(device.adb_serial, run_dir / "after-gallery-tap-retry.png")
774
+ root = self._dump_and_check(device, run_dir, "after-gallery-tap-retry")
775
+ self._assert_not_home_or_wrong_surface(root, "open TikTok gallery")
294
776
  ui.tap_permission_prompt_if_present(self.adb, device, run_dir / "gallery-permission.xml")
295
777
  if device.device_id == "D02":
296
778
  self._tap(device, 985, 610, 3)
@@ -344,12 +826,15 @@ class TikTokAdbPublisher:
344
826
  self.adb.screenshot(device.adb_serial, run_dir / "after-caption.png")
345
827
 
346
828
  def _tap_publish(self, device: Device, run_dir: Path) -> None:
829
+ width, height = self._screen_size(device)
347
830
  root = self._dump_and_check(device, run_dir, "before-publish")
348
- node = self._find_visible_text_node(root, ("发布", "Post"), clickable_only=True)
831
+ node = self._find_visible_text_node(root, ("发布", "Post"), clickable_only=False)
349
832
  if node is None:
350
- self._tap(device, 790, 2208, 0)
833
+ self._tap(device, int(width * 0.75), int(height * 0.92), 0)
351
834
  else:
352
835
  ui.tap_node(self.adb, device, node)
836
+ time.sleep(2)
837
+ self.adb.screenshot(device.adb_serial, run_dir / "after-publish-tap.png")
353
838
 
354
839
  def _tap_next_button(
355
840
  self,
@@ -412,26 +897,34 @@ class TikTokAdbPublisher:
412
897
  return has_video_actions and has_recent_timestamp
413
898
 
414
899
  def _wait_for_publish_confirmation(self, device: Device, run_dir: Path) -> Path:
415
- deadline = time.time() + 60
900
+ deadline = time.time() + 90
416
901
  last_shot = run_dir / "after-post.png"
417
902
  last_text = ""
418
903
  while time.time() < deadline:
419
904
  time.sleep(2)
420
905
  self.adb.screenshot(device.adb_serial, last_shot)
421
- root = self._dump_and_check(device, run_dir, "after-post")
422
- last_text = ui.all_text(root)
906
+ try:
907
+ root = self._dump_and_check(device, run_dir, "after-post")
908
+ last_text = ui.all_text(root)
909
+ except TikTokAutomationBlockedError:
910
+ raise
911
+ except Exception as exc:
912
+ last_text = f"ui dump unavailable while waiting for TikTok publish completion: {exc}"
913
+ continue
423
914
  if ui.tap_permission_prompt_if_present(self.adb, device, run_dir / "after-post-permission.xml"):
424
915
  time.sleep(2)
425
916
  self.adb.screenshot(device.adb_serial, last_shot)
426
917
  continue
427
918
  if _contains_any(last_text, TIKTOK_PUBLISH_CONFIRMATION_TEXTS):
428
919
  return last_shot
920
+ if _find_tiktok_copy_link_node(root) is not None:
921
+ return last_shot
429
922
  if self._is_published_video_surface(root):
430
923
  return last_shot
431
924
  if _contains_any(last_text, TIKTOK_STORY_TEXTS):
432
925
  raise RuntimeError("TikTok publish route is on story/thoughts surface, not the video post flow")
433
926
  raise RuntimeError(
434
- "TikTok publish completion was not confirmed within 60s; "
927
+ "TikTok publish completion was not confirmed within 90s; "
435
928
  "not writing a published record. Last UI text: "
436
929
  f"{_short_text(last_text)}"
437
930
  )
@@ -447,11 +940,19 @@ class TikTokAdbPublisher:
447
940
  raise RuntimeError("TikTok opened the LIVE microphone permission surface, not the video publish flow")
448
941
  return root
449
942
 
943
+ def _dump_or_none(self, device: Device, path: Path):
944
+ try:
945
+ return ui.dump_ui(self.adb, device, path)
946
+ except Exception:
947
+ return None
948
+
450
949
  def _assert_not_home_or_wrong_surface(self, root, action: str) -> None:
451
950
  text = ui.all_text(root)
452
951
  story = _first_contained(text, TIKTOK_STORY_TEXTS)
453
952
  if story and not _contains_any(text, TIKTOK_NEXT_TEXTS):
454
953
  raise RuntimeError(f"{action} failed: TikTok opened story/thoughts surface ({story}), not video publish")
954
+ if _is_tiktok_create_home_surface(root):
955
+ raise RuntimeError(f"{action} failed: TikTok is still on the create camera/template surface")
455
956
  home_matches = [part for part in TIKTOK_HOME_TEXTS if part in text]
456
957
  if len(home_matches) >= 3:
457
958
  raise RuntimeError(f"{action} failed: TikTok is still on home/feed UI ({', '.join(home_matches)})")
@@ -484,6 +985,172 @@ def _first_contained(text: str, needles: tuple[str, ...]) -> str:
484
985
  return ""
485
986
 
486
987
 
988
+ def _find_tiktok_share_button(root, width: int, height: int):
989
+ candidates = []
990
+ for node in root.iter("node"):
991
+ if not ui.is_visible(node):
992
+ continue
993
+ text = node.attrib.get("text", "")
994
+ desc = node.attrib.get("content-desc", "")
995
+ haystack = f"{text}\n{desc}".lower()
996
+ if not any(marker.lower() in haystack for marker in TIKTOK_SHARE_TEXTS):
997
+ continue
998
+ if any(marker.lower() in haystack for marker in TIKTOK_COPY_LINK_TEXTS):
999
+ continue
1000
+ value = ui.bounds(node)
1001
+ if not value:
1002
+ continue
1003
+ left, top, right, bottom = value
1004
+ center_x = (left + right) // 2
1005
+ center_y = (top + bottom) // 2
1006
+ if center_x < int(width * 0.70):
1007
+ continue
1008
+ if center_y < int(height * 0.35) or center_y > int(height * 0.90):
1009
+ continue
1010
+ candidates.append(node)
1011
+ sorted_candidates = ui.sorted_visible_nodes(candidates)
1012
+ return sorted_candidates[-1] if sorted_candidates else None
1013
+
1014
+
1015
+ def _find_tiktok_copy_link_node(root):
1016
+ return ui.find_node(
1017
+ root,
1018
+ texts=set(TIKTOK_COPY_LINK_TEXTS),
1019
+ descs=set(TIKTOK_COPY_LINK_TEXTS),
1020
+ partial_texts={"复制链接", "copy link"},
1021
+ clickable_only=False,
1022
+ )
1023
+
1024
+
1025
+ def _find_tiktok_search_node(root, width: int, height: int):
1026
+ candidates = []
1027
+ for node in root.iter("node"):
1028
+ if not ui.is_visible(node) or node.attrib.get("clickable") != "true":
1029
+ continue
1030
+ text = node.attrib.get("text", "")
1031
+ desc = node.attrib.get("content-desc", "")
1032
+ resource_id = node.attrib.get("resource-id", "")
1033
+ haystack = f"{text}\n{desc}\n{resource_id}".lower()
1034
+ if not any(marker in haystack for marker in ("搜索", "search", "discover")):
1035
+ continue
1036
+ bounds = ui.bounds(node)
1037
+ if not bounds:
1038
+ continue
1039
+ left, top, right, bottom = bounds
1040
+ center_y = (top + bottom) // 2
1041
+ if center_y > int(height * 0.28):
1042
+ continue
1043
+ candidates.append(node)
1044
+ sorted_candidates = ui.sorted_visible_nodes(candidates)
1045
+ return sorted_candidates[0] if sorted_candidates else None
1046
+
1047
+
1048
+ def _find_tiktok_search_input(root):
1049
+ candidates = []
1050
+ for node in root.iter("node"):
1051
+ if not ui.is_visible(node):
1052
+ continue
1053
+ class_name = node.attrib.get("class", "")
1054
+ text = node.attrib.get("text", "")
1055
+ desc = node.attrib.get("content-desc", "")
1056
+ resource_id = node.attrib.get("resource-id", "")
1057
+ haystack = f"{text}\n{desc}\n{resource_id}".lower()
1058
+ if "edittext" in class_name.lower():
1059
+ candidates.append(node)
1060
+ continue
1061
+ if any(marker in haystack for marker in ("搜索", "search")):
1062
+ candidates.append(node)
1063
+ sorted_candidates = ui.sorted_visible_nodes(candidates)
1064
+ return sorted_candidates[0] if sorted_candidates else None
1065
+
1066
+
1067
+ def _find_launcher_search_input(root):
1068
+ candidates = []
1069
+ for node in root.iter("node"):
1070
+ if not ui.is_visible(node):
1071
+ continue
1072
+ class_name = node.attrib.get("class", "")
1073
+ resource_id = node.attrib.get("resource-id", "")
1074
+ text = node.attrib.get("text", "")
1075
+ desc = node.attrib.get("content-desc", "")
1076
+ haystack = f"{resource_id}\n{text}\n{desc}".lower()
1077
+ if "edittext" in class_name.lower():
1078
+ candidates.append(node)
1079
+ continue
1080
+ if any(marker in haystack for marker in ("search_box", "quicksearch", "搜索本机", "搜索")):
1081
+ candidates.append(node)
1082
+ sorted_candidates = ui.sorted_visible_nodes(candidates)
1083
+ return sorted_candidates[0] if sorted_candidates else None
1084
+
1085
+
1086
+ def _launcher_search_input_text(node) -> str:
1087
+ text = (node.attrib.get("text", "") or "").strip()
1088
+ placeholder_markers = ("搜索本机", "搜索", "search")
1089
+ if any(marker.lower() in text.lower() for marker in placeholder_markers):
1090
+ return ""
1091
+ return text
1092
+
1093
+
1094
+ def _find_launcher_search_clear_node(root, width: int, height: int):
1095
+ candidates = []
1096
+ for node in root.iter("node"):
1097
+ if not ui.is_visible(node):
1098
+ continue
1099
+ text = node.attrib.get("text", "")
1100
+ desc = node.attrib.get("content-desc", "")
1101
+ resource_id = node.attrib.get("resource-id", "")
1102
+ haystack = f"{text}\n{desc}\n{resource_id}".lower()
1103
+ if any(marker in haystack for marker in ("清除", "clear", "delete", "close")):
1104
+ bounds = ui.bounds(node)
1105
+ if not bounds:
1106
+ continue
1107
+ left, top, right, bottom = bounds
1108
+ center_x = (left + right) // 2
1109
+ center_y = (top + bottom) // 2
1110
+ if center_x < int(width * 0.65) or center_y > int(height * 0.18):
1111
+ continue
1112
+ candidates.append(node)
1113
+ sorted_candidates = ui.sorted_visible_nodes(candidates)
1114
+ return sorted_candidates[-1] if sorted_candidates else None
1115
+
1116
+
1117
+ def _is_tiktok_create_home_surface(root) -> bool:
1118
+ text = ui.all_text(root)
1119
+ markers = (
1120
+ "CREATE",
1121
+ "模板",
1122
+ "新视频",
1123
+ "草稿",
1124
+ "照片编辑器",
1125
+ "一键成片",
1126
+ "添加音乐",
1127
+ )
1128
+ return sum(1 for marker in markers if marker.lower() in text.lower()) >= 3
1129
+
1130
+
1131
+ def _extract_tiktok_url(text: str) -> str:
1132
+ urls = []
1133
+ for raw in re.findall(r"https?://[^\s\"'<>]+", text or ""):
1134
+ url = raw.rstrip(").,;,。]")
1135
+ if url:
1136
+ urls.append(url)
1137
+ for url in urls:
1138
+ lowered = url.lower()
1139
+ if "tiktok.com/" in lowered or "tiktokv.com/" in lowered:
1140
+ return url
1141
+ return ""
1142
+
1143
+
1144
+ def _tiktok_post_id(permalink: str) -> str:
1145
+ match = re.search(r"/video/(\d+)", permalink or "")
1146
+ return match.group(1) if match else ""
1147
+
1148
+
1149
+ def _short(text: str, limit: int = 220) -> str:
1150
+ compact = " ".join((text or "").split())
1151
+ return compact if len(compact) <= limit else compact[: limit - 3] + "..."
1152
+
1153
+
487
1154
  def _short_text(text: str, limit: int = 240) -> str:
488
1155
  compact = " ".join(part.strip() for part in text.splitlines() if part.strip())
489
1156
  return compact[:limit]