@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.
@@ -10,7 +10,12 @@ from device_control.adb import AdbClient
10
10
  from device_control.appium_client import AppiumClient
11
11
  from device_control.models import Device, PublishRecord, utc_now_iso
12
12
  from device_control.publishers import ui_helpers as ui
13
- from device_control.publishers.tiktok_adb import TIKTOK_PACKAGE, TikTokAdbPublisher
13
+ from device_control.publishers.tiktok_adb import (
14
+ TIKTOK_PACKAGE,
15
+ TikTokAdbLinkResult,
16
+ TikTokAdbPublisher,
17
+ _is_tiktok_create_home_surface,
18
+ )
14
19
  from device_control.store import append_publish_record
15
20
 
16
21
 
@@ -27,6 +32,12 @@ class TikTokAppiumPublishResult:
27
32
  duration_seconds: int
28
33
  remote_media_path: str
29
34
  screenshot_path: str
35
+ platform_permalink: str = ""
36
+ platform_post_id: str = ""
37
+ link_status: str = ""
38
+ link_error: str = ""
39
+ link_screenshot_path: str = ""
40
+ clipboard_method: str = ""
30
41
  error: str = ""
31
42
 
32
43
 
@@ -45,7 +56,12 @@ class TikTokAppiumPublisher:
45
56
  self.appium_server = appium_server
46
57
  self.records_path = Path(records_path)
47
58
  self.artifact_root = Path(artifact_root)
48
- self.media_helper = TikTokAdbPublisher(adb, records_path=records_path, artifact_root=artifact_root)
59
+ self.media_helper = TikTokAdbPublisher(
60
+ adb,
61
+ appium_server=appium_server,
62
+ records_path=records_path,
63
+ artifact_root=artifact_root,
64
+ )
49
65
 
50
66
  def publish_video(
51
67
  self,
@@ -68,6 +84,8 @@ class TikTokAppiumPublisher:
68
84
  remote = f"/sdcard/DCIM/Camera/groupctl-{device.device_id}-tiktok-appium-{stamp}{local.suffix or '.mp4'}"
69
85
  record_id = f"pub_{stamp.replace('-', '')}_{device.device_id.lower()}_tiktok_appium"
70
86
  appium = AppiumClient(self.appium_server)
87
+ appium_active = False
88
+ link_result: TikTokAdbLinkResult | None = None
71
89
 
72
90
  try:
73
91
  self._prepare_device(device)
@@ -77,6 +95,7 @@ class TikTokAppiumPublisher:
77
95
  time.sleep(1)
78
96
 
79
97
  appium.start_session(_capabilities(device.adb_serial))
98
+ appium_active = True
80
99
  time.sleep(5)
81
100
 
82
101
  appium.source(run_dir / "home.xml")
@@ -114,8 +133,24 @@ class TikTokAppiumPublisher:
114
133
  if dry_run:
115
134
  status = "dry_run"
116
135
  else:
117
- self._tap_publish_button(device)
136
+ self._tap_publish_button(device, run_dir)
137
+ try:
138
+ appium.delete_session()
139
+ finally:
140
+ appium_active = False
118
141
  final_shot = self.media_helper._wait_for_publish_confirmation(device, run_dir)
142
+ link_result = self.media_helper.copy_current_or_latest_video_link(
143
+ device,
144
+ run_dir=run_dir / "link",
145
+ video_order=1,
146
+ close_after_recovery=True,
147
+ )
148
+ if link_result.status != "retrieved" or not link_result.platform_permalink:
149
+ raise RuntimeError(
150
+ "TikTok post-publish link recovery failed: "
151
+ f"{link_result.error or link_result.status}"
152
+ )
153
+ final_shot = Path(link_result.screenshot_path)
119
154
  status = "published"
120
155
 
121
156
  ended_epoch = int(time.time())
@@ -130,13 +165,14 @@ class TikTokAppiumPublisher:
130
165
  post_type="video",
131
166
  local_media_path=str(local),
132
167
  remote_media_path=remote,
168
+ platform_post_id=link_result.platform_post_id if link_result else "",
169
+ platform_permalink=link_result.platform_permalink if link_result else "",
133
170
  caption=caption,
134
171
  published_at=utc_now_iso(),
135
172
  result_screenshot_path=str(final_shot),
136
173
  status="published",
137
174
  ),
138
175
  )
139
- self.media_helper._close_tiktok_best_effort(device, run_dir)
140
176
  return TikTokAppiumPublishResult(
141
177
  device_id=device.device_id,
142
178
  status=status,
@@ -146,6 +182,12 @@ class TikTokAppiumPublisher:
146
182
  duration_seconds=ended_epoch - started_epoch,
147
183
  remote_media_path=remote,
148
184
  screenshot_path=str(final_shot),
185
+ platform_permalink=link_result.platform_permalink if link_result else "",
186
+ platform_post_id=link_result.platform_post_id if link_result else "",
187
+ link_status=link_result.status if link_result else "",
188
+ link_error=link_result.error if link_result else "",
189
+ link_screenshot_path=link_result.screenshot_path if link_result else "",
190
+ clipboard_method=link_result.clipboard_method if link_result else "",
149
191
  )
150
192
  except Exception as exc:
151
193
  failure_shot = run_dir / "failed.png"
@@ -153,6 +195,7 @@ class TikTokAppiumPublisher:
153
195
  appium.screenshot(failure_shot)
154
196
  except Exception:
155
197
  self.adb.screenshot(device.adb_serial, failure_shot)
198
+ self.media_helper._close_tiktok_best_effort(device, run_dir)
156
199
  return TikTokAppiumPublishResult(
157
200
  device_id=device.device_id,
158
201
  status="failed",
@@ -162,13 +205,20 @@ class TikTokAppiumPublisher:
162
205
  duration_seconds=int(time.time()) - started_epoch,
163
206
  remote_media_path=remote,
164
207
  screenshot_path=str(failure_shot),
208
+ platform_permalink=link_result.platform_permalink if link_result else "",
209
+ platform_post_id=link_result.platform_post_id if link_result else "",
210
+ link_status=link_result.status if link_result else "",
211
+ link_error=link_result.error if link_result else "",
212
+ link_screenshot_path=link_result.screenshot_path if link_result else "",
213
+ clipboard_method=link_result.clipboard_method if link_result else "",
165
214
  error=str(exc),
166
215
  )
167
216
  finally:
168
- try:
169
- appium.delete_session()
170
- except Exception:
171
- pass
217
+ if appium_active:
218
+ try:
219
+ appium.delete_session()
220
+ except Exception:
221
+ pass
172
222
 
173
223
  def _prepare_device(self, device: Device) -> None:
174
224
  self._ok(self.adb.wake(device.adb_serial), "wake device")
@@ -187,6 +237,9 @@ class TikTokAppiumPublisher:
187
237
  raise RuntimeError(f"{action} failed: {last_error}")
188
238
 
189
239
  def _set_caption(self, appium: AppiumClient, device: Device, caption: str, run_dir: Path) -> None:
240
+ if caption.isascii():
241
+ self._set_caption_with_adb(device, caption, run_dir)
242
+ return
190
243
  try:
191
244
  field = appium.find_element("id", f"{TIKTOK_PACKAGE}:id/gjp")
192
245
  appium.click(field)
@@ -200,20 +253,76 @@ class TikTokAppiumPublisher:
200
253
  self.adb.screenshot(device.adb_serial, run_dir / "caption-failed.png")
201
254
  raise RuntimeError(f"caption input failed: {exc}") from exc
202
255
 
256
+ def _set_caption_with_adb(self, device: Device, caption: str, run_dir: Path) -> None:
257
+ try:
258
+ node = None
259
+ try:
260
+ root = ui.dump_ui(self.adb, device, run_dir / "before-caption.xml")
261
+ node = ui.find_node(
262
+ root,
263
+ partial_texts={"添加描述", "描述", "Add description", "Describe"},
264
+ clickable_only=False,
265
+ )
266
+ except Exception:
267
+ pass
268
+ if node is not None:
269
+ ui.tap_node(self.adb, device, node, sleep_s=1)
270
+ else:
271
+ width, height = self._screen_size(device)
272
+ self._ok(self.adb.tap(device.adb_serial, int(width * 0.18), int(height * 0.13)), "focus caption field")
273
+ time.sleep(1)
274
+ result = self.adb.input_text(device.adb_serial, caption)
275
+ if not result.ok:
276
+ raise RuntimeError(result.stderr or result.stdout or "adb input text failed")
277
+ time.sleep(1)
278
+ self.adb.keyevent(device.adb_serial, "KEYCODE_BACK")
279
+ time.sleep(1)
280
+ self.adb.screenshot(device.adb_serial, run_dir / "after-caption.png")
281
+ except Exception as exc:
282
+ self.adb.screenshot(device.adb_serial, run_dir / "caption-failed.png")
283
+ raise RuntimeError(f"caption input failed: {exc}") from exc
284
+
203
285
  def _tap_album_entry(self, device: Device) -> None:
204
- self._ok(self.adb.tap(device.adb_serial, 105, 2050), "open album")
286
+ width, height = self._screen_size(device)
287
+ self._ok(self.adb.tap(device.adb_serial, int(width * 0.09), int(height * 0.92)), "open album")
205
288
 
206
289
  def _tap_first_gallery_video(self, device: Device) -> None:
207
- self._ok(self.adb.tap(device.adb_serial, 305, 455), "select first gallery video")
290
+ width, height = self._screen_size(device)
291
+ self._ok(self.adb.tap(device.adb_serial, int(width * 0.28), int(height * 0.20)), "select first gallery video")
208
292
 
209
293
  def _tap_gallery_next(self, device: Device) -> None:
210
- self._ok(self.adb.tap(device.adb_serial, 790, 2208), "next from gallery")
294
+ width, height = self._screen_size(device)
295
+ self._ok(self.adb.tap(device.adb_serial, int(width * 0.73), int(height * 0.92)), "next from gallery")
211
296
 
212
297
  def _tap_editor_next(self, device: Device) -> None:
213
- self._ok(self.adb.tap(device.adb_serial, 795, 2234), "next from editor")
298
+ width, height = self._screen_size(device)
299
+ self._ok(self.adb.tap(device.adb_serial, int(width * 0.74), int(height * 0.93)), "next from editor")
300
+
301
+ def _tap_publish_button(self, device: Device, run_dir: Path) -> None:
302
+ width, height = self._screen_size(device)
303
+ for attempt in range(3):
304
+ root = None
305
+ try:
306
+ root = ui.dump_ui(self.adb, device, run_dir / f"before-publish-tap-{attempt + 1}.xml")
307
+ except Exception:
308
+ pass
309
+ if root is not None:
310
+ node = _find_publish_button(root, width, height, prefer_bottom=True)
311
+ if node is not None:
312
+ ui.tap_node(self.adb, device, node, sleep_s=2)
313
+ else:
314
+ self._ok(self.adb.tap(device.adb_serial, int(width * 0.75), int(height * 0.92)), "publish")
315
+ time.sleep(2)
316
+ else:
317
+ self._ok(self.adb.tap(device.adb_serial, int(width * 0.75), int(height * 0.92)), "publish")
318
+ time.sleep(2)
319
+
320
+ self.adb.screenshot(device.adb_serial, run_dir / f"after-publish-tap-{attempt + 1}.png")
321
+ if not self._still_on_publish_form(device, run_dir, attempt + 1):
322
+ self.adb.screenshot(device.adb_serial, run_dir / "after-publish-tap.png")
323
+ return
214
324
 
215
- def _tap_publish_button(self, device: Device) -> None:
216
- self._ok(self.adb.tap(device.adb_serial, 790, 2240), "publish")
325
+ self.adb.screenshot(device.adb_serial, run_dir / "after-publish-tap.png")
217
326
 
218
327
  def _raise_if_live_surface(self, device: Device, run_dir: Path, action: str) -> None:
219
328
  try:
@@ -221,6 +330,8 @@ class TikTokAppiumPublisher:
221
330
  except Exception:
222
331
  return
223
332
  text = ui.all_text(root)
333
+ if _is_tiktok_create_home_surface(root):
334
+ raise RuntimeError(f"{action} failed: TikTok is still on the create camera/template surface")
224
335
  if (
225
336
  ("访问你的麦克风" in text or "访问麦克风" in text)
226
337
  and ("直播" in text or "LIVE" in text.upper())
@@ -234,6 +345,17 @@ class TikTokAppiumPublisher:
234
345
  if not result.ok:
235
346
  raise RuntimeError(f"{action} failed: {result.stderr or result.stdout}")
236
347
 
348
+ def _screen_size(self, device: Device) -> tuple[int, int]:
349
+ return self.media_helper._screen_size(device)
350
+
351
+ def _still_on_publish_form(self, device: Device, run_dir: Path, attempt: int) -> bool:
352
+ try:
353
+ root = ui.dump_ui(self.adb, device, run_dir / f"after-publish-tap-{attempt}.xml")
354
+ except Exception:
355
+ return False
356
+ text = ui.all_text(root)
357
+ return "发布" in text and ("粉丝可以查看这条发布内容" in text or "更多选项" in text or "分享到" in text)
358
+
237
359
 
238
360
  def _capabilities(serial: str) -> dict:
239
361
  return {
@@ -258,3 +380,40 @@ def _now_shanghai() -> str:
258
380
 
259
381
  def _slug(value: str) -> str:
260
382
  return "".join(char.lower() if char.isalnum() else "-" for char in value).strip("-")
383
+
384
+
385
+ def _find_publish_button(root, width: int, height: int, *, prefer_bottom: bool):
386
+ candidates = []
387
+ for node in root.iter("node"):
388
+ if not ui.is_visible(node):
389
+ continue
390
+ text = node.attrib.get("text", "")
391
+ desc = node.attrib.get("content-desc", "")
392
+ if text not in {"发布", "Post"} and desc not in {"发布", "Post"}:
393
+ continue
394
+ value = ui.bounds(node)
395
+ if not value:
396
+ continue
397
+ left, top, right, bottom = value
398
+ center_x = (left + right) // 2
399
+ center_y = (top + bottom) // 2
400
+ if center_x < int(width * 0.45):
401
+ continue
402
+ if center_y < int(height * 0.22):
403
+ region = "top"
404
+ elif center_y > int(height * 0.72):
405
+ region = "bottom"
406
+ else:
407
+ continue
408
+ candidates.append((region, node))
409
+ if prefer_bottom:
410
+ bottom = [node for region, node in candidates if region == "bottom"]
411
+ if bottom:
412
+ return ui.sorted_visible_nodes(bottom)[-1]
413
+ top = [node for region, node in candidates if region == "top"]
414
+ if top:
415
+ return ui.sorted_visible_nodes(top)[-1]
416
+ bottom = [node for region, node in candidates if region == "bottom"]
417
+ if bottom:
418
+ return ui.sorted_visible_nodes(bottom)[-1]
419
+ return None