@11agents/cli 0.1.24 → 0.1.26
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/README.md +52 -0
- package/bin/11agents.js +12 -0
- package/mobile-runtime/README.md +19 -0
- package/mobile-runtime/configs/platforms/xiaohongshu_d01.json +73 -0
- package/mobile-runtime/configs/platforms/xiaohongshu_d02.json +70 -0
- package/mobile-runtime/configs/platforms/xiaohongshu_d03.json +73 -0
- package/mobile-runtime/configs/publish_policy.json +40 -0
- package/mobile-runtime/data-templates/README.md +4 -0
- package/mobile-runtime/data-templates/accounts.example.csv +6 -0
- package/mobile-runtime/data-templates/devices.example.csv +2 -0
- package/mobile-runtime/data-templates/publish_records.example.jsonl +2 -0
- package/mobile-runtime/data-templates/tasks.example.jsonl +5 -0
- package/mobile-runtime/data-templates/video_metrics.example.jsonl +1 -0
- package/mobile-runtime/python/pyproject.toml +34 -0
- package/mobile-runtime/python/src/device_control/__init__.py +5 -0
- package/mobile-runtime/python/src/device_control/adapters/__init__.py +31 -0
- package/mobile-runtime/python/src/device_control/adapters/base.py +43 -0
- package/mobile-runtime/python/src/device_control/adapters/facebook.py +30 -0
- package/mobile-runtime/python/src/device_control/adapters/instagram.py +25 -0
- package/mobile-runtime/python/src/device_control/adapters/reddit.py +29 -0
- package/mobile-runtime/python/src/device_control/adapters/tiktok.py +25 -0
- package/mobile-runtime/python/src/device_control/adapters/x.py +29 -0
- package/mobile-runtime/python/src/device_control/adapters/xiaohongshu.py +26 -0
- package/mobile-runtime/python/src/device_control/adb.py +161 -0
- package/mobile-runtime/python/src/device_control/appium_client.py +131 -0
- package/mobile-runtime/python/src/device_control/appium_manager.py +403 -0
- package/mobile-runtime/python/src/device_control/cli.py +1608 -0
- package/mobile-runtime/python/src/device_control/entrypoints.py +60 -0
- package/mobile-runtime/python/src/device_control/locks.py +162 -0
- package/mobile-runtime/python/src/device_control/metrics/__init__.py +33 -0
- package/mobile-runtime/python/src/device_control/metrics/collectors.py +320 -0
- package/mobile-runtime/python/src/device_control/metrics/tiktok_account_adb.py +367 -0
- package/mobile-runtime/python/src/device_control/metrics/tiktok_video_adb.py +714 -0
- package/mobile-runtime/python/src/device_control/models.py +439 -0
- package/mobile-runtime/python/src/device_control/publish_policy.py +173 -0
- package/mobile-runtime/python/src/device_control/publishers/__init__.py +24 -0
- package/mobile-runtime/python/src/device_control/publishers/facebook_adb.py +494 -0
- package/mobile-runtime/python/src/device_control/publishers/instagram_adb.py +663 -0
- package/mobile-runtime/python/src/device_control/publishers/reddit_adb.py +595 -0
- package/mobile-runtime/python/src/device_control/publishers/tiktok_adb.py +477 -0
- package/mobile-runtime/python/src/device_control/publishers/tiktok_appium.py +259 -0
- package/mobile-runtime/python/src/device_control/publishers/ui_helpers.py +372 -0
- package/mobile-runtime/python/src/device_control/publishers/x_adb.py +636 -0
- package/mobile-runtime/python/src/device_control/publishers/xiaohongshu_adb.py +1143 -0
- package/mobile-runtime/python/src/device_control/store.py +137 -0
- package/mobile-runtime/scripts/appium_smoke.py +71 -0
- package/mobile-runtime/skills/android-collect-tiktok-metrics/SKILL.md +60 -0
- package/mobile-runtime/skills/android-collect-tiktok-metrics/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-group-control-cli/SKILL.md +76 -0
- package/mobile-runtime/skills/android-group-control-cli/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-group-control-cli/references/command-reference.md +122 -0
- package/mobile-runtime/skills/android-publish-facebook/SKILL.md +41 -0
- package/mobile-runtime/skills/android-publish-facebook/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-instagram/SKILL.md +45 -0
- package/mobile-runtime/skills/android-publish-instagram/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-reddit/SKILL.md +41 -0
- package/mobile-runtime/skills/android-publish-reddit/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-tiktok/SKILL.md +43 -0
- package/mobile-runtime/skills/android-publish-tiktok/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-x/SKILL.md +40 -0
- package/mobile-runtime/skills/android-publish-x/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-xiaohongshu/SKILL.md +50 -0
- package/mobile-runtime/skills/android-publish-xiaohongshu/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/mobile-publish-data-collection/SKILL.md +49 -0
- package/mobile-runtime/skills/mobile-publish-device-health/SKILL.md +47 -0
- package/mobile-runtime/skills/mobile-publish-execution/SKILL.md +57 -0
- package/mobile-runtime/skills/mobile-publish-records/SKILL.md +29 -0
- package/package.json +4 -1
- package/scripts/mobile-postinstall.js +26 -0
- package/src/commands/mobile.js +695 -0
- package/src/commands/runtime.js +21 -5
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import shlex
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from zoneinfo import ZoneInfo
|
|
10
|
+
|
|
11
|
+
from device_control.adb import AdbClient
|
|
12
|
+
from device_control.models import Device, PublishRecord, new_id, utc_now_iso
|
|
13
|
+
from device_control.publishers import ui_helpers as ui
|
|
14
|
+
from device_control.store import append_publish_record
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
TIKTOK_PACKAGE = "com.zhiliaoapp.musically"
|
|
18
|
+
TIKTOK_BLOCKING_TEXTS = (
|
|
19
|
+
"你已达到每日上限",
|
|
20
|
+
"每日上限",
|
|
21
|
+
"想喝点水吗",
|
|
22
|
+
"暂时返回",
|
|
23
|
+
"daily limit",
|
|
24
|
+
"try again later",
|
|
25
|
+
)
|
|
26
|
+
TIKTOK_STORY_TEXTS = (
|
|
27
|
+
"你的想法",
|
|
28
|
+
"你的限时动态",
|
|
29
|
+
"24 小时内可见",
|
|
30
|
+
"24小时内可见",
|
|
31
|
+
)
|
|
32
|
+
TIKTOK_HOME_TEXTS = (
|
|
33
|
+
"首页",
|
|
34
|
+
"好友",
|
|
35
|
+
"收件箱",
|
|
36
|
+
"主页",
|
|
37
|
+
)
|
|
38
|
+
TIKTOK_PUBLISH_CONFIRMATION_TEXTS = (
|
|
39
|
+
"视频已发布",
|
|
40
|
+
"已发布",
|
|
41
|
+
"所有人可以查看",
|
|
42
|
+
"posted",
|
|
43
|
+
"published",
|
|
44
|
+
)
|
|
45
|
+
TIKTOK_NEXT_TEXTS = (
|
|
46
|
+
"下一步",
|
|
47
|
+
"Next",
|
|
48
|
+
)
|
|
49
|
+
TIKTOK_PREVIEW_TEXTS = (
|
|
50
|
+
"一键成片",
|
|
51
|
+
"AutoCut",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TikTokNeedsHumanError(RuntimeError):
|
|
56
|
+
"""Raised when TikTok blocks the publish flow with an account/platform state."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class TikTokAdbPublishResult:
|
|
61
|
+
device_id: str
|
|
62
|
+
status: str
|
|
63
|
+
record_id: str
|
|
64
|
+
started_at: str
|
|
65
|
+
ended_at: str
|
|
66
|
+
duration_seconds: int
|
|
67
|
+
remote_media_path: str
|
|
68
|
+
screenshot_path: str
|
|
69
|
+
error: str = ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class TikTokAdbPublisher:
|
|
73
|
+
"""ADB-only TikTok publisher for the Samsung SM-G9910 POC devices."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
adb: AdbClient,
|
|
78
|
+
*,
|
|
79
|
+
records_path: str | Path = "data/publish_records.jsonl",
|
|
80
|
+
artifact_root: str | Path = "artifacts/screenshots",
|
|
81
|
+
) -> None:
|
|
82
|
+
self.adb = adb
|
|
83
|
+
self.records_path = Path(records_path)
|
|
84
|
+
self.artifact_root = Path(artifact_root)
|
|
85
|
+
|
|
86
|
+
def publish_video(
|
|
87
|
+
self,
|
|
88
|
+
device: Device,
|
|
89
|
+
*,
|
|
90
|
+
video_path: str | Path,
|
|
91
|
+
account_id: str,
|
|
92
|
+
caption: str = "",
|
|
93
|
+
dry_run: bool = False,
|
|
94
|
+
allow_caption_fallback: bool = False,
|
|
95
|
+
) -> TikTokAdbPublishResult:
|
|
96
|
+
local = Path(video_path).expanduser()
|
|
97
|
+
if not local.exists():
|
|
98
|
+
raise FileNotFoundError(f"video not found: {local}")
|
|
99
|
+
|
|
100
|
+
started_epoch = int(time.time())
|
|
101
|
+
started_at = _now_shanghai()
|
|
102
|
+
stamp = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d-%H%M%S")
|
|
103
|
+
run_dir = self.artifact_root / f"{device.device_id}-tiktok-{stamp}"
|
|
104
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
remote = f"/sdcard/DCIM/Camera/groupctl-{device.device_id}-tiktok-{stamp}{local.suffix or '.mp4'}"
|
|
106
|
+
record_id = f"pub_{stamp.replace('-', '')}_{device.device_id.lower()}_tiktok"
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
self._prepare_device(device)
|
|
110
|
+
self._push_media(device, local, remote)
|
|
111
|
+
self._launch_tiktok(device)
|
|
112
|
+
self._open_create(device, run_dir)
|
|
113
|
+
self._open_gallery(device, run_dir)
|
|
114
|
+
self._select_first_video(device, run_dir)
|
|
115
|
+
self._next_from_gallery(device, run_dir)
|
|
116
|
+
self._next_from_editor(device, run_dir)
|
|
117
|
+
if caption:
|
|
118
|
+
self._try_caption(device, caption, run_dir, allow_fallback=allow_caption_fallback)
|
|
119
|
+
if dry_run:
|
|
120
|
+
final_shot = run_dir / "dry-run-post-form.png"
|
|
121
|
+
self.adb.screenshot(device.adb_serial, final_shot)
|
|
122
|
+
status = "dry_run"
|
|
123
|
+
else:
|
|
124
|
+
self._tap_publish(device, run_dir)
|
|
125
|
+
final_shot = self._wait_for_publish_confirmation(device, run_dir)
|
|
126
|
+
status = "published"
|
|
127
|
+
|
|
128
|
+
ended_epoch = int(time.time())
|
|
129
|
+
ended_at = _now_shanghai()
|
|
130
|
+
if status == "published":
|
|
131
|
+
record = PublishRecord(
|
|
132
|
+
record_id=record_id,
|
|
133
|
+
platform="tiktok",
|
|
134
|
+
account_id=account_id,
|
|
135
|
+
device_id=device.device_id,
|
|
136
|
+
post_type="video",
|
|
137
|
+
local_media_path=str(local),
|
|
138
|
+
remote_media_path=remote,
|
|
139
|
+
caption=caption,
|
|
140
|
+
published_at=utc_now_iso(),
|
|
141
|
+
result_screenshot_path=str(final_shot),
|
|
142
|
+
status="published",
|
|
143
|
+
)
|
|
144
|
+
append_publish_record(self.records_path, record)
|
|
145
|
+
return TikTokAdbPublishResult(
|
|
146
|
+
device_id=device.device_id,
|
|
147
|
+
status=status,
|
|
148
|
+
record_id=record_id,
|
|
149
|
+
started_at=started_at,
|
|
150
|
+
ended_at=ended_at,
|
|
151
|
+
duration_seconds=ended_epoch - started_epoch,
|
|
152
|
+
remote_media_path=remote,
|
|
153
|
+
screenshot_path=str(final_shot),
|
|
154
|
+
)
|
|
155
|
+
except TikTokNeedsHumanError as exc:
|
|
156
|
+
ended_epoch = int(time.time())
|
|
157
|
+
failure_shot = run_dir / "needs-human.png"
|
|
158
|
+
self.adb.screenshot(device.adb_serial, failure_shot)
|
|
159
|
+
return TikTokAdbPublishResult(
|
|
160
|
+
device_id=device.device_id,
|
|
161
|
+
status="needs_human",
|
|
162
|
+
record_id=record_id,
|
|
163
|
+
started_at=started_at,
|
|
164
|
+
ended_at=_now_shanghai(),
|
|
165
|
+
duration_seconds=ended_epoch - started_epoch,
|
|
166
|
+
remote_media_path=remote,
|
|
167
|
+
screenshot_path=str(failure_shot),
|
|
168
|
+
error=str(exc),
|
|
169
|
+
)
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
ended_epoch = int(time.time())
|
|
172
|
+
failure_shot = run_dir / "failed.png"
|
|
173
|
+
self.adb.screenshot(device.adb_serial, failure_shot)
|
|
174
|
+
return TikTokAdbPublishResult(
|
|
175
|
+
device_id=device.device_id,
|
|
176
|
+
status="failed",
|
|
177
|
+
record_id=record_id,
|
|
178
|
+
started_at=started_at,
|
|
179
|
+
ended_at=_now_shanghai(),
|
|
180
|
+
duration_seconds=ended_epoch - started_epoch,
|
|
181
|
+
remote_media_path=remote,
|
|
182
|
+
screenshot_path=str(failure_shot),
|
|
183
|
+
error=str(exc),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def _prepare_device(self, device: Device) -> None:
|
|
187
|
+
self._ok(self.adb.wake(device.adb_serial), "wake device")
|
|
188
|
+
time.sleep(1)
|
|
189
|
+
self.adb.swipe(device.adb_serial, 540, 1850, 540, 550, 350)
|
|
190
|
+
time.sleep(1)
|
|
191
|
+
self._ok(self.adb.keyevent(device.adb_serial, "KEYCODE_HOME"), "go home")
|
|
192
|
+
time.sleep(1)
|
|
193
|
+
|
|
194
|
+
def _push_media(self, device: Device, local: Path, remote: str) -> None:
|
|
195
|
+
self._ok(self.adb.shell(device.adb_serial, "mkdir", "-p", "/sdcard/DCIM/Camera"), "make camera dir")
|
|
196
|
+
if local.stat().st_size <= 64 * 1024 and self._push_media_via_base64(device, local, remote):
|
|
197
|
+
self.adb.media_scan(device.adb_serial, remote)
|
|
198
|
+
time.sleep(1)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
pushed = self.adb.push(device.adb_serial, local, remote)
|
|
202
|
+
if not pushed.ok and not self._remote_file_matches(device, remote, local.stat().st_size):
|
|
203
|
+
streamed = self._push_media_via_base64(device, local, remote)
|
|
204
|
+
if not streamed:
|
|
205
|
+
self._ok(pushed, "push media")
|
|
206
|
+
self.adb.media_scan(device.adb_serial, remote)
|
|
207
|
+
time.sleep(1)
|
|
208
|
+
|
|
209
|
+
def _remote_file_matches(self, device: Device, remote: str, expected_size: int) -> bool:
|
|
210
|
+
result = self.adb.shell(device.adb_serial, "wc", "-c", remote)
|
|
211
|
+
if not result.ok:
|
|
212
|
+
return False
|
|
213
|
+
first = result.stdout.strip().split(maxsplit=1)[0]
|
|
214
|
+
return first.isdigit() and int(first) == expected_size
|
|
215
|
+
|
|
216
|
+
def _push_media_via_base64(self, device: Device, local: Path, remote: str) -> bool:
|
|
217
|
+
tmp = f"{remote}.b64"
|
|
218
|
+
expected_size = local.stat().st_size
|
|
219
|
+
encoded = base64.b64encode(local.read_bytes()).decode("ascii")
|
|
220
|
+
self.adb.shell(device.adb_serial, "rm", "-f", tmp, remote)
|
|
221
|
+
|
|
222
|
+
for i in range(0, len(encoded), 500):
|
|
223
|
+
chunk = encoded[i : i + 500]
|
|
224
|
+
result = self._remote_sh(
|
|
225
|
+
device,
|
|
226
|
+
f"printf '%s' {shlex.quote(chunk)} >> {shlex.quote(tmp)}",
|
|
227
|
+
)
|
|
228
|
+
if not result.ok:
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
decode = self._remote_sh(device, f"base64 -d {shlex.quote(tmp)} > {shlex.quote(remote)}")
|
|
232
|
+
self.adb.shell(device.adb_serial, "rm", "-f", tmp)
|
|
233
|
+
return decode.ok and self._remote_file_matches(device, remote, expected_size)
|
|
234
|
+
|
|
235
|
+
def _remote_sh(self, device: Device, command: str):
|
|
236
|
+
return self.adb.run("-s", device.adb_serial, "shell", f"sh -c {shlex.quote(command)}")
|
|
237
|
+
|
|
238
|
+
def _launch_tiktok(self, device: Device) -> None:
|
|
239
|
+
ui.grant_publish_permissions(self.adb, device, TIKTOK_PACKAGE)
|
|
240
|
+
self.adb.shell(device.adb_serial, "am", "force-stop", TIKTOK_PACKAGE)
|
|
241
|
+
time.sleep(1)
|
|
242
|
+
self._ok(self.adb.launch_package(device.adb_serial, TIKTOK_PACKAGE), "launch TikTok")
|
|
243
|
+
time.sleep(5)
|
|
244
|
+
|
|
245
|
+
def _open_create(self, device: Device, run_dir: Path) -> None:
|
|
246
|
+
root = self._dump_and_check(device, run_dir, "before-create")
|
|
247
|
+
create_node = ui.find_node(
|
|
248
|
+
root,
|
|
249
|
+
id_suffixes={"neq"},
|
|
250
|
+
descs={"创建", "Create"},
|
|
251
|
+
clickable_only=True,
|
|
252
|
+
)
|
|
253
|
+
if create_node is None:
|
|
254
|
+
self._tap(device, 540, 2225, 0)
|
|
255
|
+
else:
|
|
256
|
+
ui.tap_node(self.adb, device, create_node)
|
|
257
|
+
time.sleep(5)
|
|
258
|
+
self.adb.screenshot(device.adb_serial, run_dir / "create.png")
|
|
259
|
+
root = self._dump_and_check(device, run_dir, "create")
|
|
260
|
+
text = ui.all_text(root)
|
|
261
|
+
if _contains_any(text, TIKTOK_HOME_TEXTS):
|
|
262
|
+
# Home text may remain underneath transient create overlays. Do not fail here unless
|
|
263
|
+
# a known TikTok account/platform block is present; the gallery check below is strict.
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
def _open_gallery(self, device: Device, run_dir: Path) -> None:
|
|
267
|
+
# D01 and D02 currently expose different create-page entry points.
|
|
268
|
+
# Try the D01 bottom-left upload first, then the D02 right-side album icon.
|
|
269
|
+
root = self._dump_and_check(device, run_dir, "before-gallery-tap")
|
|
270
|
+
upload_node = ui.find_node(root, id_suffixes={"upload_hot_area"}, clickable_only=True)
|
|
271
|
+
if upload_node is None:
|
|
272
|
+
self._tap(device, 105, 2050, 0)
|
|
273
|
+
else:
|
|
274
|
+
ui.tap_node(self.adb, device, upload_node)
|
|
275
|
+
time.sleep(2)
|
|
276
|
+
shot = run_dir / "after-gallery-tap-1.png"
|
|
277
|
+
self.adb.screenshot(device.adb_serial, shot)
|
|
278
|
+
self._assert_not_home_or_wrong_surface(
|
|
279
|
+
self._dump_and_check(device, run_dir, "after-gallery-tap-1"),
|
|
280
|
+
"open TikTok gallery",
|
|
281
|
+
)
|
|
282
|
+
ui.tap_permission_prompt_if_present(self.adb, device, run_dir / "gallery-permission.xml")
|
|
283
|
+
if device.device_id == "D02":
|
|
284
|
+
self._tap(device, 985, 610, 3)
|
|
285
|
+
self.adb.screenshot(device.adb_serial, run_dir / "after-gallery-tap-2.png")
|
|
286
|
+
self._assert_not_home_or_wrong_surface(
|
|
287
|
+
self._dump_and_check(device, run_dir, "after-gallery-tap-2"),
|
|
288
|
+
"open TikTok gallery on D02",
|
|
289
|
+
)
|
|
290
|
+
time.sleep(2)
|
|
291
|
+
|
|
292
|
+
def _select_first_video(self, device: Device, run_dir: Path) -> None:
|
|
293
|
+
self._tap(device, 175, 540, 1)
|
|
294
|
+
self.adb.screenshot(device.adb_serial, run_dir / "after-select-first.png")
|
|
295
|
+
self._dump_and_check(device, run_dir, "after-select-first")
|
|
296
|
+
|
|
297
|
+
def _next_from_gallery(self, device: Device, run_dir: Path) -> None:
|
|
298
|
+
self._tap_next_button(device, run_dir, "before-gallery-next", fallback=(790, 2208), sleep_s=5)
|
|
299
|
+
self.adb.screenshot(device.adb_serial, run_dir / "after-gallery-next.png")
|
|
300
|
+
root = self._dump_and_check(device, run_dir, "after-gallery-next")
|
|
301
|
+
if self._is_media_preview(root):
|
|
302
|
+
raise RuntimeError("next from TikTok media preview failed: still on the video selection preview")
|
|
303
|
+
|
|
304
|
+
def _next_from_editor(self, device: Device, run_dir: Path) -> None:
|
|
305
|
+
self._tap_next_button(device, run_dir, "before-editor-next", fallback=(795, 2234), sleep_s=6)
|
|
306
|
+
self.adb.screenshot(device.adb_serial, run_dir / "post-form.png")
|
|
307
|
+
root = self._dump_and_check(device, run_dir, "post-form")
|
|
308
|
+
self._assert_not_home_or_wrong_surface(root, "reach TikTok video post form")
|
|
309
|
+
if self._is_media_preview(root):
|
|
310
|
+
raise RuntimeError("next from TikTok editor failed: still on the video selection preview")
|
|
311
|
+
|
|
312
|
+
def _try_caption(self, device: Device, caption: str, run_dir: Path, *, allow_fallback: bool) -> None:
|
|
313
|
+
self._tap(device, 160, 275, 1)
|
|
314
|
+
result = self.adb.input_text(device.adb_serial, caption)
|
|
315
|
+
if not result.ok:
|
|
316
|
+
self.adb.screenshot(device.adb_serial, run_dir / "caption-input-failed.png")
|
|
317
|
+
(run_dir / "caption-warning.txt").write_text(
|
|
318
|
+
"ADB input text failed. Caption is recorded in PublishRecord but may not be visible in TikTok UI.\n"
|
|
319
|
+
f"caption={caption}\n"
|
|
320
|
+
f"stderr={result.stderr}\n"
|
|
321
|
+
f"stdout={result.stdout}\n",
|
|
322
|
+
encoding="utf-8",
|
|
323
|
+
)
|
|
324
|
+
if not allow_fallback:
|
|
325
|
+
raise RuntimeError(
|
|
326
|
+
"caption input failed; stop before publish. "
|
|
327
|
+
"Use ASCII caption, install a Unicode input bridge, or pass --allow-caption-fallback."
|
|
328
|
+
)
|
|
329
|
+
time.sleep(1)
|
|
330
|
+
self.adb.keyevent(device.adb_serial, "KEYCODE_BACK")
|
|
331
|
+
time.sleep(1)
|
|
332
|
+
self.adb.screenshot(device.adb_serial, run_dir / "after-caption.png")
|
|
333
|
+
|
|
334
|
+
def _tap_publish(self, device: Device, run_dir: Path) -> None:
|
|
335
|
+
root = self._dump_and_check(device, run_dir, "before-publish")
|
|
336
|
+
node = self._find_visible_text_node(root, ("发布", "Post"), clickable_only=True)
|
|
337
|
+
if node is None:
|
|
338
|
+
self._tap(device, 790, 2208, 0)
|
|
339
|
+
else:
|
|
340
|
+
ui.tap_node(self.adb, device, node)
|
|
341
|
+
|
|
342
|
+
def _tap_next_button(
|
|
343
|
+
self,
|
|
344
|
+
device: Device,
|
|
345
|
+
run_dir: Path,
|
|
346
|
+
dump_name: str,
|
|
347
|
+
*,
|
|
348
|
+
fallback: tuple[int, int],
|
|
349
|
+
sleep_s: int,
|
|
350
|
+
) -> None:
|
|
351
|
+
root = self._dump_and_check(device, run_dir, dump_name)
|
|
352
|
+
node = self._find_visible_text_node(root, TIKTOK_NEXT_TEXTS, clickable_only=False)
|
|
353
|
+
if node is None:
|
|
354
|
+
self._tap(device, fallback[0], fallback[1], 0)
|
|
355
|
+
else:
|
|
356
|
+
ui.tap_node(self.adb, device, node)
|
|
357
|
+
if sleep_s:
|
|
358
|
+
time.sleep(sleep_s)
|
|
359
|
+
|
|
360
|
+
def _find_visible_text_node(self, root, texts: tuple[str, ...], *, clickable_only: bool):
|
|
361
|
+
candidates = []
|
|
362
|
+
for node in root.iter("node"):
|
|
363
|
+
if clickable_only and node.attrib.get("clickable") != "true":
|
|
364
|
+
continue
|
|
365
|
+
if not ui.is_visible(node):
|
|
366
|
+
continue
|
|
367
|
+
text = node.attrib.get("text", "")
|
|
368
|
+
desc = node.attrib.get("content-desc", "")
|
|
369
|
+
if text in texts or desc in texts:
|
|
370
|
+
bounds = ui.bounds(node)
|
|
371
|
+
if bounds:
|
|
372
|
+
candidates.append((bounds, node))
|
|
373
|
+
if not candidates:
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
def key(item):
|
|
377
|
+
left, top, right, bottom = item[0]
|
|
378
|
+
return bottom, right, top, left
|
|
379
|
+
|
|
380
|
+
return sorted(candidates, key=key, reverse=True)[0][1]
|
|
381
|
+
|
|
382
|
+
def _is_media_preview(self, root) -> bool:
|
|
383
|
+
text = ui.all_text(root)
|
|
384
|
+
return (
|
|
385
|
+
_contains_any(text, TIKTOK_NEXT_TEXTS)
|
|
386
|
+
and _contains_any(text, TIKTOK_PREVIEW_TEXTS)
|
|
387
|
+
and self._has_exact_text(root, ("选择", "Select"))
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
def _has_exact_text(self, root, texts: tuple[str, ...]) -> bool:
|
|
391
|
+
for node in root.iter("node"):
|
|
392
|
+
if node.attrib.get("text", "") in texts or node.attrib.get("content-desc", "") in texts:
|
|
393
|
+
return True
|
|
394
|
+
return False
|
|
395
|
+
|
|
396
|
+
def _is_published_video_surface(self, root) -> bool:
|
|
397
|
+
text = ui.all_text(root)
|
|
398
|
+
has_video_actions = _contains_any(text, ("点赞视频", "分享视频", "阅读或添加评论"))
|
|
399
|
+
has_recent_timestamp = _contains_any(text, ("秒前", "分钟前", "minute ago", "seconds ago"))
|
|
400
|
+
return has_video_actions and has_recent_timestamp
|
|
401
|
+
|
|
402
|
+
def _wait_for_publish_confirmation(self, device: Device, run_dir: Path) -> Path:
|
|
403
|
+
deadline = time.time() + 60
|
|
404
|
+
last_shot = run_dir / "after-post.png"
|
|
405
|
+
last_text = ""
|
|
406
|
+
while time.time() < deadline:
|
|
407
|
+
time.sleep(2)
|
|
408
|
+
self.adb.screenshot(device.adb_serial, last_shot)
|
|
409
|
+
root = self._dump_and_check(device, run_dir, "after-post")
|
|
410
|
+
last_text = ui.all_text(root)
|
|
411
|
+
if ui.tap_permission_prompt_if_present(self.adb, device, run_dir / "after-post-permission.xml"):
|
|
412
|
+
time.sleep(2)
|
|
413
|
+
self.adb.screenshot(device.adb_serial, last_shot)
|
|
414
|
+
continue
|
|
415
|
+
if _contains_any(last_text, TIKTOK_PUBLISH_CONFIRMATION_TEXTS):
|
|
416
|
+
return last_shot
|
|
417
|
+
if self._is_published_video_surface(root):
|
|
418
|
+
return last_shot
|
|
419
|
+
if _contains_any(last_text, TIKTOK_STORY_TEXTS):
|
|
420
|
+
raise RuntimeError("TikTok publish route is on story/thoughts surface, not the video post flow")
|
|
421
|
+
raise RuntimeError(
|
|
422
|
+
"TikTok publish completion was not confirmed within 60s; "
|
|
423
|
+
"not writing a published record. Last UI text: "
|
|
424
|
+
f"{_short_text(last_text)}"
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
def _dump_and_check(self, device: Device, run_dir: Path, name: str):
|
|
428
|
+
xml_path = run_dir / f"{name}.xml"
|
|
429
|
+
root = ui.dump_ui(self.adb, device, xml_path)
|
|
430
|
+
text = ui.all_text(root)
|
|
431
|
+
blocking = _first_contained(text, TIKTOK_BLOCKING_TEXTS)
|
|
432
|
+
if blocking:
|
|
433
|
+
raise TikTokNeedsHumanError(f"TikTok publish blocked by visible prompt: {blocking}")
|
|
434
|
+
if ("访问你的麦克风" in text or "访问麦克风" in text) and "直播" in text:
|
|
435
|
+
raise RuntimeError("TikTok opened the LIVE microphone permission surface, not the video publish flow")
|
|
436
|
+
return root
|
|
437
|
+
|
|
438
|
+
def _assert_not_home_or_wrong_surface(self, root, action: str) -> None:
|
|
439
|
+
text = ui.all_text(root)
|
|
440
|
+
story = _first_contained(text, TIKTOK_STORY_TEXTS)
|
|
441
|
+
if story and not _contains_any(text, TIKTOK_NEXT_TEXTS):
|
|
442
|
+
raise RuntimeError(f"{action} failed: TikTok opened story/thoughts surface ({story}), not video publish")
|
|
443
|
+
home_matches = [part for part in TIKTOK_HOME_TEXTS if part in text]
|
|
444
|
+
if len(home_matches) >= 3:
|
|
445
|
+
raise RuntimeError(f"{action} failed: TikTok is still on home/feed UI ({', '.join(home_matches)})")
|
|
446
|
+
|
|
447
|
+
def _tap(self, device: Device, x: int, y: int, sleep_s: int) -> None:
|
|
448
|
+
self._ok(self.adb.tap(device.adb_serial, x, y), f"tap {x},{y}")
|
|
449
|
+
if sleep_s:
|
|
450
|
+
time.sleep(sleep_s)
|
|
451
|
+
|
|
452
|
+
@staticmethod
|
|
453
|
+
def _ok(result, action: str) -> None:
|
|
454
|
+
if not result.ok:
|
|
455
|
+
raise RuntimeError(f"{action} failed: {result.stderr or result.stdout}")
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _now_shanghai() -> str:
|
|
459
|
+
return datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S %z")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _contains_any(text: str, needles: tuple[str, ...]) -> bool:
|
|
463
|
+
lowered = text.lower()
|
|
464
|
+
return any(needle.lower() in lowered for needle in needles)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _first_contained(text: str, needles: tuple[str, ...]) -> str:
|
|
468
|
+
lowered = text.lower()
|
|
469
|
+
for needle in needles:
|
|
470
|
+
if needle.lower() in lowered:
|
|
471
|
+
return needle
|
|
472
|
+
return ""
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _short_text(text: str, limit: int = 240) -> str:
|
|
476
|
+
compact = " ".join(part.strip() for part in text.splitlines() if part.strip())
|
|
477
|
+
return compact[:limit]
|