@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.
Files changed (71) hide show
  1. package/README.md +52 -0
  2. package/bin/11agents.js +12 -0
  3. package/mobile-runtime/README.md +19 -0
  4. package/mobile-runtime/configs/platforms/xiaohongshu_d01.json +73 -0
  5. package/mobile-runtime/configs/platforms/xiaohongshu_d02.json +70 -0
  6. package/mobile-runtime/configs/platforms/xiaohongshu_d03.json +73 -0
  7. package/mobile-runtime/configs/publish_policy.json +40 -0
  8. package/mobile-runtime/data-templates/README.md +4 -0
  9. package/mobile-runtime/data-templates/accounts.example.csv +6 -0
  10. package/mobile-runtime/data-templates/devices.example.csv +2 -0
  11. package/mobile-runtime/data-templates/publish_records.example.jsonl +2 -0
  12. package/mobile-runtime/data-templates/tasks.example.jsonl +5 -0
  13. package/mobile-runtime/data-templates/video_metrics.example.jsonl +1 -0
  14. package/mobile-runtime/python/pyproject.toml +34 -0
  15. package/mobile-runtime/python/src/device_control/__init__.py +5 -0
  16. package/mobile-runtime/python/src/device_control/adapters/__init__.py +31 -0
  17. package/mobile-runtime/python/src/device_control/adapters/base.py +43 -0
  18. package/mobile-runtime/python/src/device_control/adapters/facebook.py +30 -0
  19. package/mobile-runtime/python/src/device_control/adapters/instagram.py +25 -0
  20. package/mobile-runtime/python/src/device_control/adapters/reddit.py +29 -0
  21. package/mobile-runtime/python/src/device_control/adapters/tiktok.py +25 -0
  22. package/mobile-runtime/python/src/device_control/adapters/x.py +29 -0
  23. package/mobile-runtime/python/src/device_control/adapters/xiaohongshu.py +26 -0
  24. package/mobile-runtime/python/src/device_control/adb.py +161 -0
  25. package/mobile-runtime/python/src/device_control/appium_client.py +131 -0
  26. package/mobile-runtime/python/src/device_control/appium_manager.py +403 -0
  27. package/mobile-runtime/python/src/device_control/cli.py +1608 -0
  28. package/mobile-runtime/python/src/device_control/entrypoints.py +60 -0
  29. package/mobile-runtime/python/src/device_control/locks.py +162 -0
  30. package/mobile-runtime/python/src/device_control/metrics/__init__.py +33 -0
  31. package/mobile-runtime/python/src/device_control/metrics/collectors.py +320 -0
  32. package/mobile-runtime/python/src/device_control/metrics/tiktok_account_adb.py +367 -0
  33. package/mobile-runtime/python/src/device_control/metrics/tiktok_video_adb.py +714 -0
  34. package/mobile-runtime/python/src/device_control/models.py +439 -0
  35. package/mobile-runtime/python/src/device_control/publish_policy.py +173 -0
  36. package/mobile-runtime/python/src/device_control/publishers/__init__.py +24 -0
  37. package/mobile-runtime/python/src/device_control/publishers/facebook_adb.py +494 -0
  38. package/mobile-runtime/python/src/device_control/publishers/instagram_adb.py +663 -0
  39. package/mobile-runtime/python/src/device_control/publishers/reddit_adb.py +595 -0
  40. package/mobile-runtime/python/src/device_control/publishers/tiktok_adb.py +477 -0
  41. package/mobile-runtime/python/src/device_control/publishers/tiktok_appium.py +259 -0
  42. package/mobile-runtime/python/src/device_control/publishers/ui_helpers.py +372 -0
  43. package/mobile-runtime/python/src/device_control/publishers/x_adb.py +636 -0
  44. package/mobile-runtime/python/src/device_control/publishers/xiaohongshu_adb.py +1143 -0
  45. package/mobile-runtime/python/src/device_control/store.py +137 -0
  46. package/mobile-runtime/scripts/appium_smoke.py +71 -0
  47. package/mobile-runtime/skills/android-collect-tiktok-metrics/SKILL.md +60 -0
  48. package/mobile-runtime/skills/android-collect-tiktok-metrics/agents/openai.yaml +4 -0
  49. package/mobile-runtime/skills/android-group-control-cli/SKILL.md +76 -0
  50. package/mobile-runtime/skills/android-group-control-cli/agents/openai.yaml +4 -0
  51. package/mobile-runtime/skills/android-group-control-cli/references/command-reference.md +122 -0
  52. package/mobile-runtime/skills/android-publish-facebook/SKILL.md +41 -0
  53. package/mobile-runtime/skills/android-publish-facebook/agents/openai.yaml +4 -0
  54. package/mobile-runtime/skills/android-publish-instagram/SKILL.md +45 -0
  55. package/mobile-runtime/skills/android-publish-instagram/agents/openai.yaml +4 -0
  56. package/mobile-runtime/skills/android-publish-reddit/SKILL.md +41 -0
  57. package/mobile-runtime/skills/android-publish-reddit/agents/openai.yaml +4 -0
  58. package/mobile-runtime/skills/android-publish-tiktok/SKILL.md +43 -0
  59. package/mobile-runtime/skills/android-publish-tiktok/agents/openai.yaml +4 -0
  60. package/mobile-runtime/skills/android-publish-x/SKILL.md +40 -0
  61. package/mobile-runtime/skills/android-publish-x/agents/openai.yaml +4 -0
  62. package/mobile-runtime/skills/android-publish-xiaohongshu/SKILL.md +50 -0
  63. package/mobile-runtime/skills/android-publish-xiaohongshu/agents/openai.yaml +4 -0
  64. package/mobile-runtime/skills/mobile-publish-data-collection/SKILL.md +49 -0
  65. package/mobile-runtime/skills/mobile-publish-device-health/SKILL.md +47 -0
  66. package/mobile-runtime/skills/mobile-publish-execution/SKILL.md +57 -0
  67. package/mobile-runtime/skills/mobile-publish-records/SKILL.md +29 -0
  68. package/package.json +4 -1
  69. package/scripts/mobile-postinstall.js +26 -0
  70. package/src/commands/mobile.js +695 -0
  71. package/src/commands/runtime.js +21 -5
@@ -0,0 +1,663 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import time
5
+ import xml.etree.ElementTree as ET
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.appium_client import AppiumClient
13
+ from device_control.models import Device, PublishRecord, utc_now_iso
14
+ from device_control.publishers import ui_helpers as ui
15
+ from device_control.publishers.tiktok_adb import TikTokAdbPublisher
16
+ from device_control.store import append_publish_record
17
+
18
+
19
+ INSTAGRAM_PACKAGE = "com.instagram.android"
20
+ INSTAGRAM_SHARE_BUTTON_IDS = {
21
+ "com.instagram.android:id/share_button",
22
+ "com.instagram.android:id/share_footer_button",
23
+ }
24
+
25
+
26
+ class NeedsHumanError(RuntimeError):
27
+ pass
28
+
29
+
30
+ @dataclass
31
+ class InstagramAdbPublishResult:
32
+ device_id: str
33
+ status: str
34
+ record_id: str
35
+ started_at: str
36
+ ended_at: str
37
+ duration_seconds: int
38
+ post_type: str
39
+ remote_media_path: str
40
+ screenshot_path: str
41
+ error: str = ""
42
+
43
+
44
+ class InstagramAdbPublisher:
45
+ """Instagram Reels video publisher for the D03 POC layout."""
46
+
47
+ def __init__(
48
+ self,
49
+ adb: AdbClient,
50
+ *,
51
+ appium_server: str = "http://127.0.0.1:4723",
52
+ records_path: str | Path = "data/publish_records.jsonl",
53
+ artifact_root: str | Path = "artifacts/screenshots",
54
+ ) -> None:
55
+ self.adb = adb
56
+ self.appium_server = appium_server
57
+ self.records_path = Path(records_path)
58
+ self.artifact_root = Path(artifact_root)
59
+ self.media_helper = TikTokAdbPublisher(adb, records_path=records_path, artifact_root=artifact_root)
60
+
61
+ def publish_video(
62
+ self,
63
+ device: Device,
64
+ *,
65
+ video_path: str | Path,
66
+ account_id: str,
67
+ caption: str = "",
68
+ dry_run: bool = False,
69
+ confirm_reels_public: bool = False,
70
+ caption_input: str = "auto",
71
+ ) -> InstagramAdbPublishResult:
72
+ return self.publish_post(
73
+ device,
74
+ media_path=video_path,
75
+ post_type="reel",
76
+ account_id=account_id,
77
+ caption=caption,
78
+ dry_run=dry_run,
79
+ confirm_reels_public=confirm_reels_public,
80
+ caption_input=caption_input,
81
+ )
82
+
83
+ def publish_post(
84
+ self,
85
+ device: Device,
86
+ *,
87
+ media_path: str | Path,
88
+ post_type: str,
89
+ account_id: str,
90
+ caption: str = "",
91
+ dry_run: bool = False,
92
+ confirm_reels_public: bool = False,
93
+ caption_input: str = "auto",
94
+ ) -> InstagramAdbPublishResult:
95
+ post_type = _normalize_post_type(post_type)
96
+ local = Path(media_path).expanduser()
97
+ if not local.exists():
98
+ raise FileNotFoundError(f"media not found: {local}")
99
+ if caption_input not in {"auto", "appium", "adb"}:
100
+ raise ValueError("caption_input must be one of: auto, appium, adb")
101
+ media_kind = "video" if post_type in {"reel", "post-video"} else "image"
102
+ if media_kind == "video" and ui.media_kind_from_path(local) != "video":
103
+ raise ValueError(f"post_type={post_type} requires a video file")
104
+ if media_kind == "image" and ui.media_kind_from_path(local) != "image":
105
+ raise ValueError(f"post_type={post_type} requires an image file")
106
+
107
+ started_epoch = int(time.time())
108
+ started_at = _now_shanghai()
109
+ stamp = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d-%H%M%S")
110
+ run_dir = self.artifact_root / f"{device.device_id}-instagram-{stamp}"
111
+ run_dir.mkdir(parents=True, exist_ok=True)
112
+ remote = f"/sdcard/DCIM/Camera/groupctl-{device.device_id}-instagram-{stamp}{local.suffix}"
113
+ record_id = f"pub_{stamp.replace('-', '')}_{device.device_id.lower()}_instagram"
114
+
115
+ try:
116
+ self._prepare_device(device)
117
+ self.media_helper._push_media(device, local, remote)
118
+ self._launch_instagram(device, run_dir)
119
+ self._open_new_post(device, run_dir)
120
+ self._select_destination(device, run_dir, post_type)
121
+ self._select_first_visible_media(device, run_dir, media_kind)
122
+ self._continue_to_share_form(device, run_dir, post_type)
123
+ if caption:
124
+ self._set_caption(device, caption, run_dir, caption_input)
125
+
126
+ if dry_run:
127
+ final_shot = run_dir / "dry-run-share-form.png"
128
+ self.adb.screenshot(device.adb_serial, final_shot)
129
+ status = "dry_run"
130
+ else:
131
+ final_shot, status = self._share(device, run_dir, confirm_reels_public)
132
+
133
+ ended_epoch = int(time.time())
134
+ if status == "published":
135
+ append_publish_record(
136
+ self.records_path,
137
+ PublishRecord(
138
+ record_id=record_id,
139
+ platform="instagram",
140
+ account_id=account_id,
141
+ device_id=device.device_id,
142
+ post_type=post_type,
143
+ local_media_path=str(local),
144
+ remote_media_path=remote,
145
+ caption=caption,
146
+ published_at=utc_now_iso(),
147
+ result_screenshot_path=str(final_shot),
148
+ status="published",
149
+ ),
150
+ )
151
+ return InstagramAdbPublishResult(
152
+ device_id=device.device_id,
153
+ status=status,
154
+ record_id=record_id,
155
+ started_at=started_at,
156
+ ended_at=_now_shanghai(),
157
+ duration_seconds=ended_epoch - started_epoch,
158
+ post_type=post_type,
159
+ remote_media_path=remote,
160
+ screenshot_path=str(final_shot),
161
+ )
162
+ except NeedsHumanError as exc:
163
+ return self._failure_result(
164
+ device,
165
+ run_dir,
166
+ record_id,
167
+ started_at,
168
+ started_epoch,
169
+ remote,
170
+ post_type=post_type,
171
+ status="needs_human",
172
+ error=str(exc),
173
+ )
174
+ except Exception as exc:
175
+ return self._failure_result(
176
+ device,
177
+ run_dir,
178
+ record_id,
179
+ started_at,
180
+ started_epoch,
181
+ remote,
182
+ post_type=post_type,
183
+ status="failed",
184
+ error=str(exc),
185
+ )
186
+
187
+ def _failure_result(
188
+ self,
189
+ device: Device,
190
+ run_dir: Path,
191
+ record_id: str,
192
+ started_at: str,
193
+ started_epoch: int,
194
+ remote: str,
195
+ *,
196
+ post_type: str,
197
+ status: str,
198
+ error: str,
199
+ ) -> InstagramAdbPublishResult:
200
+ failure_shot = run_dir / f"{status}.png"
201
+ self.adb.screenshot(device.adb_serial, failure_shot)
202
+ return InstagramAdbPublishResult(
203
+ device_id=device.device_id,
204
+ status=status,
205
+ record_id=record_id,
206
+ started_at=started_at,
207
+ ended_at=_now_shanghai(),
208
+ duration_seconds=int(time.time()) - started_epoch,
209
+ post_type=post_type,
210
+ remote_media_path=remote,
211
+ screenshot_path=str(failure_shot),
212
+ error=error,
213
+ )
214
+
215
+ def _prepare_device(self, device: Device) -> None:
216
+ self._ok(self.adb.wake(device.adb_serial), "wake device")
217
+ time.sleep(1)
218
+ self.adb.swipe(device.adb_serial, 540, 1850, 540, 550, 350)
219
+ time.sleep(1)
220
+
221
+ def _launch_instagram(self, device: Device, run_dir: Path) -> None:
222
+ ui.grant_publish_permissions(self.adb, device, INSTAGRAM_PACKAGE)
223
+ self.adb.shell(device.adb_serial, "am", "force-stop", INSTAGRAM_PACKAGE)
224
+ time.sleep(1)
225
+ self._ok(self.adb.launch_package(device.adb_serial, INSTAGRAM_PACKAGE), "launch Instagram")
226
+ time.sleep(6)
227
+ self.adb.screenshot(device.adb_serial, run_dir / "home.png")
228
+ focus = self.adb.current_focus(device.adb_serial)
229
+ if INSTAGRAM_PACKAGE not in focus:
230
+ raise NeedsHumanError("Instagram is not foreground after launch; check install/login/verification state")
231
+ self._raise_if_blocked(device, run_dir / "home.xml")
232
+
233
+ def _open_new_post(self, device: Device, run_dir: Path) -> None:
234
+ root = self._dump(device, run_dir / "before-new-post.xml")
235
+ node = _find_node(root, texts={"新建", "Create", "New"}, clickable_only=False)
236
+ if node is not None:
237
+ x, y = _center(_bounds(node))
238
+ self._tap(device, x, y, 3)
239
+ else:
240
+ self._tap(device, 72, 190, 3)
241
+ self._tap_permission_button_if_present(device, run_dir / "media-permission.xml", {"允许", "Allow"})
242
+ time.sleep(3)
243
+ self.adb.screenshot(device.adb_serial, run_dir / "gallery.png")
244
+ self._raise_if_blocked(device, run_dir / "gallery.xml")
245
+ if not self._has_any_text(device, run_dir / "gallery-check.xml", {"新帖子", "New post"}):
246
+ raise NeedsHumanError("Instagram gallery did not open; check login, verification, prompts, or layout calibration")
247
+
248
+ def _select_destination(self, device: Device, run_dir: Path, post_type: str) -> None:
249
+ target_id = "com.instagram.android:id/cam_dest_clips" if post_type == "reel" else "com.instagram.android:id/cam_dest_feed"
250
+ root = self._dump(device, run_dir / f"gallery-destination-{post_type}.xml")
251
+ node = _find_node(root, ids={target_id}, clickable_only=False)
252
+ if node is not None:
253
+ x, y = _center(_bounds(node))
254
+ self._tap(device, x, y, 1)
255
+ self.adb.screenshot(device.adb_serial, run_dir / f"after-destination-{post_type}.png")
256
+
257
+ def _select_first_visible_media(self, device: Device, run_dir: Path, media_kind: str) -> None:
258
+ xml_path = run_dir / "gallery-before-select.xml"
259
+ root = self._dump(device, xml_path)
260
+ media_node = _find_first_visible_media_thumbnail(root, media_kind)
261
+ if media_node is None:
262
+ self.adb.screenshot(device.adb_serial, run_dir / f"no-visible-{media_kind}.png")
263
+ raise NeedsHumanError(f"No visible {media_kind} thumbnail found in Instagram gallery")
264
+ x, y = _center(_bounds(media_node))
265
+ self._tap(device, x, y, 1)
266
+ self.adb.screenshot(device.adb_serial, run_dir / "gallery-selected.png")
267
+
268
+ def _continue_to_share_form(self, device: Device, run_dir: Path, post_type: str) -> None:
269
+ if not self._tap_node(device, run_dir / "gallery-selected.xml", ids={"com.instagram.android:id/next_button_textview"}):
270
+ self._tap(device, 996, 190, 0)
271
+ time.sleep(5)
272
+ self.adb.screenshot(device.adb_serial, run_dir / "after-gallery-continue.png")
273
+ self._raise_if_blocked(device, run_dir / "after-gallery-continue.xml")
274
+ self._allow_camera_microphone_if_needed(device, run_dir)
275
+
276
+ if post_type == "reel":
277
+ self._next_from_editor(device, run_dir)
278
+ return
279
+
280
+ self._next_until_share_form(device, run_dir)
281
+
282
+ def _next_until_share_form(self, device: Device, run_dir: Path) -> None:
283
+ deadline = time.time() + 45
284
+ attempt = 0
285
+ while time.time() < deadline:
286
+ attempt += 1
287
+ root = self._dump(device, run_dir / f"post-next-{attempt}.xml")
288
+ if self._tap_post_public_sheet_if_present(device, root, run_dir, attempt):
289
+ time.sleep(2)
290
+ continue
291
+ if _find_node(root, ids={"com.instagram.android:id/caption_input_text_view"}) is not None and _find_node(
292
+ root, ids=INSTAGRAM_SHARE_BUTTON_IDS
293
+ ) is not None:
294
+ self.adb.screenshot(device.adb_serial, run_dir / "share-form.png")
295
+ return
296
+ node = _find_node(
297
+ root,
298
+ ids={
299
+ "com.instagram.android:id/next_button_textview",
300
+ "com.instagram.android:id/next_button",
301
+ "com.instagram.android:id/creation_next_button",
302
+ "com.instagram.android:id/media_thumbnail_tray_button",
303
+ },
304
+ texts={"下一步", "继续", "Next", "Continue"},
305
+ clickable_only=False,
306
+ )
307
+ if node is not None:
308
+ x, y = _center(_bounds(node))
309
+ self._tap(device, x, y, 4)
310
+ continue
311
+ time.sleep(2)
312
+ self.adb.screenshot(device.adb_serial, run_dir / "post-share-form-timeout.png")
313
+ raise NeedsHumanError("Instagram Post share form was not detected after selecting media")
314
+
315
+ def _tap_post_public_sheet_if_present(self, device: Device, root: ET.Element, run_dir: Path, attempt: int) -> bool:
316
+ text = _all_text(root)
317
+ if not _contains_any(text, {"分享帖子", "你的账户是公开的", "Share post", "Your account is public"}):
318
+ return False
319
+ node = _find_node(root, texts={"确定", "OK"}, clickable_only=False)
320
+ if node is None:
321
+ return False
322
+ self.adb.screenshot(device.adb_serial, run_dir / f"post-public-sheet-{attempt}.png")
323
+ x, y = _center(_bounds(node))
324
+ self._tap(device, x, y, 0)
325
+ return True
326
+
327
+ def _allow_camera_microphone_if_needed(self, device: Device, run_dir: Path) -> None:
328
+ if self._tap_permission_button_if_present(device, run_dir / "camera-mic-permission.xml", {"确定", "OK", "Allow"}):
329
+ time.sleep(3)
330
+ self.adb.screenshot(device.adb_serial, run_dir / "after-camera-mic-permission.png")
331
+
332
+ def _next_from_editor(self, device: Device, run_dir: Path) -> None:
333
+ deadline = time.time() + 35
334
+ while time.time() < deadline:
335
+ if self._tap_node(device, run_dir / "editor.xml", ids={"com.instagram.android:id/clips_right_action_button"}):
336
+ time.sleep(5)
337
+ self.adb.screenshot(device.adb_serial, run_dir / "share-form.png")
338
+ self._raise_if_blocked(device, run_dir / "share-form.xml")
339
+ if self._has_any_id(device, run_dir / "share-form-check.xml", {"com.instagram.android:id/caption_input_text_view"}):
340
+ return
341
+ time.sleep(2)
342
+ raise NeedsHumanError("Instagram editor next button/share form was not detected")
343
+
344
+ def _set_caption(self, device: Device, caption: str, run_dir: Path, caption_input: str) -> None:
345
+ last_error = ""
346
+ if caption_input in {"auto", "appium"}:
347
+ try:
348
+ self._set_caption_with_appium(device, caption)
349
+ self._dismiss_caption_keyboard(device, run_dir)
350
+ self.adb.screenshot(device.adb_serial, run_dir / "after-caption.png")
351
+ return
352
+ except Exception as exc:
353
+ last_error = str(exc)
354
+ if caption_input == "appium":
355
+ raise RuntimeError(f"caption input via Appium failed: {last_error}") from exc
356
+
357
+ if not _adb_text_safe(caption):
358
+ raise RuntimeError(
359
+ "caption contains characters that adb input text cannot safely enter; "
360
+ "start Appium and use --caption-input appium"
361
+ + (f"; Appium error: {last_error}" if last_error else "")
362
+ )
363
+ if not self._tap_node(device, run_dir / "caption-field.xml", ids={"com.instagram.android:id/caption_input_text_view"}):
364
+ self._tap(device, 150, 340, 0)
365
+ time.sleep(1)
366
+ result = self.adb.input_text(device.adb_serial, caption)
367
+ if not result.ok:
368
+ raise RuntimeError(f"caption input via adb failed: {result.stderr or result.stdout}")
369
+ time.sleep(1)
370
+ self.adb.keyevent(device.adb_serial, "KEYCODE_BACK")
371
+ time.sleep(1)
372
+ self.adb.screenshot(device.adb_serial, run_dir / "after-caption.png")
373
+
374
+ def _set_caption_with_appium(self, device: Device, caption: str) -> None:
375
+ appium = AppiumClient(self.appium_server)
376
+ try:
377
+ appium.start_session(_appium_capabilities(device.adb_serial))
378
+ field = appium.find_element("id", "com.instagram.android:id/caption_input_text_view")
379
+ appium.click(field)
380
+ time.sleep(1)
381
+ appium.send_keys(field, caption)
382
+ time.sleep(1)
383
+ finally:
384
+ try:
385
+ appium.delete_session()
386
+ except Exception:
387
+ pass
388
+
389
+ def _dismiss_caption_keyboard(self, device: Device, run_dir: Path) -> None:
390
+ self.adb.keyevent(device.adb_serial, "KEYCODE_BACK")
391
+ time.sleep(1)
392
+ root = self._dump(device, run_dir / "after-caption-dismiss-keyboard.xml")
393
+ done = _find_node(root, ids={"com.instagram.android:id/next_button_textview"}, texts={"确定", "Done"})
394
+ if done is not None and _find_node(root, ids=INSTAGRAM_SHARE_BUTTON_IDS) is None:
395
+ x, y = _center(_bounds(done))
396
+ self._tap(device, x, y, 1)
397
+
398
+ def _share(self, device: Device, run_dir: Path, confirm_reels_public: bool) -> tuple[Path, str]:
399
+ if not self._tap_share_button(device, run_dir / "before-share.xml"):
400
+ self._tap(device, 800, 2202, 0)
401
+ time.sleep(3)
402
+ self.adb.screenshot(device.adb_serial, run_dir / "after-share-tap.png")
403
+
404
+ if self._is_facebook_crosspost_prompt(device, run_dir / "facebook-crosspost-prompt.xml"):
405
+ self.adb.screenshot(device.adb_serial, run_dir / "facebook-crosspost-prompt.png")
406
+ if not self._tap_node(
407
+ device,
408
+ run_dir / "facebook-crosspost-prompt.xml",
409
+ ids={"com.instagram.android:id/igds_headline_secondary_action_text_button"},
410
+ texts={"以后再说", "Not now"},
411
+ ):
412
+ self._tap(device, 540, 2185, 0)
413
+ time.sleep(5)
414
+
415
+ if self._is_reels_public_sheet(device, run_dir / "reels-public-sheet.xml"):
416
+ self.adb.screenshot(device.adb_serial, run_dir / "reels-public-sheet.png")
417
+ if not confirm_reels_public:
418
+ raise NeedsHumanError("Instagram Reels public sharing prompt needs explicit confirmation")
419
+ if not self._tap_node(
420
+ device,
421
+ run_dir / "reels-public-sheet.xml",
422
+ ids={"com.instagram.android:id/clips_nux_sheet_share_button"},
423
+ texts={"分享", "Share"},
424
+ ):
425
+ self._tap(device, 540, 2094, 0)
426
+ time.sleep(4)
427
+
428
+ final_shot = self._wait_for_publish_complete(device, run_dir)
429
+ return final_shot, "published"
430
+
431
+ def _tap_share_button(self, device: Device, xml_path: Path) -> bool:
432
+ root = self._dump(device, xml_path)
433
+ node = _find_node(root, ids=INSTAGRAM_SHARE_BUTTON_IDS, clickable_only=False)
434
+ if node is None:
435
+ return False
436
+ x, y = _center(_bounds(node))
437
+ if node.attrib.get("resource-id") == "com.instagram.android:id/share_footer_button" and y < 2260:
438
+ y = 2285
439
+ self._tap(device, x, y, 0)
440
+ return True
441
+
442
+ def _wait_for_publish_complete(self, device: Device, run_dir: Path) -> Path:
443
+ deadline = time.time() + 90
444
+ last_xml = run_dir / "publish-poll.xml"
445
+ while time.time() < deadline:
446
+ time.sleep(5)
447
+ root = self._dump(device, last_xml)
448
+ text = _all_text(root)
449
+ if _contains_any(text, {"出错", "重试", "失败", "无法发布", "couldn't post", "try again", "failed"}):
450
+ raise RuntimeError("Instagram publish appears failed or retryable")
451
+ if _contains_any(text, {"正在发布", "正在上传", "Posting", "Uploading"}):
452
+ continue
453
+ if _contains_any(
454
+ text,
455
+ {
456
+ "发布了video",
457
+ "posted a video",
458
+ "发布了 reel",
459
+ "posted a reel",
460
+ "发布了照片",
461
+ "posted a photo",
462
+ "posted a post",
463
+ "已分享",
464
+ },
465
+ ):
466
+ final_shot = run_dir / "after-post.png"
467
+ self.adb.screenshot(device.adb_serial, final_shot)
468
+ return final_shot
469
+ focus = self.adb.current_focus(device.adb_serial)
470
+ if INSTAGRAM_PACKAGE in focus and ("InstagramMainActivity" in focus or "MainTabActivity" in focus):
471
+ final_shot = run_dir / "after-post.png"
472
+ self.adb.screenshot(device.adb_serial, final_shot)
473
+ return final_shot
474
+ timeout_shot = run_dir / "after-post-timeout.png"
475
+ self.adb.screenshot(device.adb_serial, timeout_shot)
476
+ raise RuntimeError("Instagram publish completion not confirmed within 90s")
477
+
478
+ def _raise_if_blocked(self, device: Device, xml_path: Path) -> None:
479
+ root = self._dump(device, xml_path)
480
+ text = _all_text(root)
481
+ if _contains_any(
482
+ text,
483
+ {
484
+ "验证码",
485
+ "人机",
486
+ "验证",
487
+ "登录",
488
+ "Log in",
489
+ "Login",
490
+ "captcha",
491
+ "suspicious",
492
+ "restriction",
493
+ "restricted",
494
+ },
495
+ ):
496
+ raise NeedsHumanError("Instagram needs login, verification, captcha, or account review")
497
+
498
+ def _is_reels_public_sheet(self, device: Device, xml_path: Path) -> bool:
499
+ root = self._dump(device, xml_path)
500
+ if _find_node(root, ids={"com.instagram.android:id/clips_nux_sheet_share_button"}) is not None:
501
+ return True
502
+ return _contains_any(_all_text(root), {"关于 Reels", "About Reels"})
503
+
504
+ def _is_facebook_crosspost_prompt(self, device: Device, xml_path: Path) -> bool:
505
+ root = self._dump(device, xml_path)
506
+ if _find_node(root, ids={"com.instagram.android:id/igds_headline_secondary_action_text_button"}) is not None:
507
+ return _contains_any(_all_text(root), {"同时分享到 Facebook", "Share to Facebook"})
508
+ return False
509
+
510
+ def _has_any_text(self, device: Device, xml_path: Path, texts: set[str]) -> bool:
511
+ return _contains_any(_all_text(self._dump(device, xml_path)), texts)
512
+
513
+ def _has_any_id(self, device: Device, xml_path: Path, ids: set[str]) -> bool:
514
+ return _find_node(self._dump(device, xml_path), ids=ids) is not None
515
+
516
+ def _tap_permission_button_if_present(self, device: Device, xml_path: Path, texts: set[str]) -> bool:
517
+ return ui.tap_permission_prompt_if_present(self.adb, device, xml_path)
518
+
519
+ def _tap_node(
520
+ self,
521
+ device: Device,
522
+ xml_path: Path,
523
+ *,
524
+ ids: set[str] | None = None,
525
+ texts: set[str] | None = None,
526
+ ) -> bool:
527
+ root = self._dump(device, xml_path)
528
+ node = _find_node(root, ids=ids, texts=texts, clickable_only=True)
529
+ if node is None:
530
+ return False
531
+ x, y = _center(_bounds(node))
532
+ self._tap(device, x, y, 0)
533
+ return True
534
+
535
+ def _dump(self, device: Device, xml_path: Path) -> ET.Element:
536
+ dump = self.adb.dump_ui(device.adb_serial, xml_path)
537
+ if not dump.ok:
538
+ raise RuntimeError(f"dump ui failed: {dump.stderr or dump.stdout}")
539
+ return ET.parse(xml_path).getroot()
540
+
541
+ def _tap(self, device: Device, x: int, y: int, sleep_s: float) -> None:
542
+ self._ok(self.adb.tap(device.adb_serial, x, y), f"tap {x},{y}")
543
+ if sleep_s:
544
+ time.sleep(sleep_s)
545
+
546
+ @staticmethod
547
+ def _ok(result, action: str) -> None:
548
+ if not result.ok:
549
+ raise RuntimeError(f"{action} failed: {result.stderr or result.stdout}")
550
+
551
+
552
+ def _normalize_post_type(value: str) -> str:
553
+ text = value.strip().lower().replace("_", "-")
554
+ aliases = {
555
+ "reels": "reel",
556
+ "feed": "post-image",
557
+ "post": "post-image",
558
+ "image": "post-image",
559
+ "photo": "post-image",
560
+ "post-image": "post-image",
561
+ "post-photo": "post-image",
562
+ "video": "post-video",
563
+ "post-video": "post-video",
564
+ }
565
+ if text in aliases:
566
+ return aliases[text]
567
+ raise ValueError("Instagram post_type must be one of: reel, post-image, post-video")
568
+
569
+
570
+ def _find_first_visible_media_thumbnail(root: ET.Element, media_kind: str) -> ET.Element | None:
571
+ candidates: list[tuple[int, int, ET.Element]] = []
572
+ for node in root.iter("node"):
573
+ if node.attrib.get("resource-id") != "com.instagram.android:id/gallery_grid_item_thumbnail":
574
+ continue
575
+ desc = node.attrib.get("content-desc", "")
576
+ if media_kind == "video" and "视频缩略图" not in desc and "Video thumbnail" not in desc:
577
+ continue
578
+ if media_kind == "image" and "照片缩略图" not in desc and "Photo thumbnail" not in desc:
579
+ continue
580
+ bounds = _bounds(node)
581
+ if not bounds:
582
+ continue
583
+ left, top, right, bottom = bounds
584
+ if right <= left or bottom <= top or bottom < 280:
585
+ continue
586
+ candidates.append((top, left, node))
587
+ if not candidates:
588
+ return None
589
+ candidates.sort(key=lambda item: (item[0], item[1]))
590
+ return candidates[0][2]
591
+
592
+
593
+ def _find_node(
594
+ root: ET.Element,
595
+ *,
596
+ ids: set[str] | None = None,
597
+ texts: set[str] | None = None,
598
+ clickable_only: bool = False,
599
+ ) -> ET.Element | None:
600
+ for node in root.iter("node"):
601
+ if clickable_only and node.attrib.get("clickable") != "true":
602
+ continue
603
+ if ids and node.attrib.get("resource-id") in ids:
604
+ return node
605
+ if texts and (node.attrib.get("text") in texts or node.attrib.get("content-desc") in texts):
606
+ return node
607
+ return None
608
+
609
+
610
+ def _bounds(node: ET.Element) -> tuple[int, int, int, int] | None:
611
+ match = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", node.attrib.get("bounds", ""))
612
+ if not match:
613
+ return None
614
+ return tuple(int(part) for part in match.groups())
615
+
616
+
617
+ def _center(bounds: tuple[int, int, int, int] | None) -> tuple[int, int]:
618
+ if not bounds:
619
+ raise RuntimeError("node has no valid bounds")
620
+ left, top, right, bottom = bounds
621
+ return (left + right) // 2, (top + bottom) // 2
622
+
623
+
624
+ def _all_text(root: ET.Element) -> str:
625
+ parts: list[str] = []
626
+ for node in root.iter("node"):
627
+ text = node.attrib.get("text", "")
628
+ desc = node.attrib.get("content-desc", "")
629
+ if text:
630
+ parts.append(text)
631
+ if desc:
632
+ parts.append(desc)
633
+ return "\n".join(parts)
634
+
635
+
636
+ def _contains_any(text: str, needles: set[str]) -> bool:
637
+ lowered = text.lower()
638
+ return any(needle.lower() in lowered for needle in needles)
639
+
640
+
641
+ def _adb_text_safe(text: str) -> bool:
642
+ allowed = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,!?:;'\"_@#%()+-/|")
643
+ return all(char in allowed for char in text)
644
+
645
+
646
+ def _appium_capabilities(serial: str) -> dict:
647
+ return {
648
+ "platformName": "Android",
649
+ "appium:automationName": "UiAutomator2",
650
+ "appium:udid": serial,
651
+ "appium:noReset": True,
652
+ "appium:newCommandTimeout": 120,
653
+ "appium:disableWindowAnimation": True,
654
+ "appium:skipDeviceInitialization": True,
655
+ "appium:ignoreHiddenApiPolicyError": True,
656
+ "appium:disableSuppressAccessibilityService": True,
657
+ "appium:settings[enableNotificationListener]": False,
658
+ "appium:autoLaunch": False,
659
+ }
660
+
661
+
662
+ def _now_shanghai() -> str:
663
+ return datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S %z")