@11agents/cli 0.1.41 → 0.1.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mobile-runtime/python/src/device_control/cli.py +17 -2
- package/mobile-runtime/python/src/device_control/metrics/tiktok_video_adb.py +8 -3
- package/mobile-runtime/python/src/device_control/publishers/tiktok_adb.py +679 -12
- package/mobile-runtime/python/src/device_control/publishers/tiktok_appium.py +173 -14
- package/mobile-runtime/python/src/device_control/publishers/x_adb.py +319 -5
- package/mobile-runtime/skills/android-collect-tiktok-metrics/SKILL.md +76 -0
- package/mobile-runtime/skills/android-publish-tiktok/SKILL.md +3 -3
- package/mobile-runtime/skills/android-publish-x/SKILL.md +5 -1
- package/mobile-runtime/skills/android-publish-xiaohongshu/SKILL.md +1 -1
- package/mobile-runtime/skills/mobile-publish-data-collection/SKILL.md +79 -3
- package/mobile-runtime/skills/mobile-publish-execution/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/knowledge.js +23 -1
- package/src/commands/mobile.js +29 -6
- package/src/commands/runtime.js +4 -1
- package/src/schema.js +5 -2
|
@@ -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,
|
|
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.
|
|
291
|
-
|
|
292
|
-
|
|
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=
|
|
831
|
+
node = self._find_visible_text_node(root, ("发布", "Post"), clickable_only=False)
|
|
349
832
|
if node is None:
|
|
350
|
-
self._tap(device,
|
|
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() +
|
|
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
|
-
|
|
422
|
-
|
|
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
|
|
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]
|