@11agents/cli 0.1.24 → 0.1.25

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 +6 -5
@@ -0,0 +1,636 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import time
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from zoneinfo import ZoneInfo
9
+
10
+ from device_control.adb import AdbClient
11
+ from device_control.appium_client import AppiumClient
12
+ from device_control.models import Device, PublishRecord, utc_now_iso
13
+ from device_control.publishers import ui_helpers as ui
14
+ from device_control.publishers.tiktok_adb import TikTokAdbPublisher
15
+ from device_control.store import append_publish_record
16
+
17
+
18
+ X_PACKAGE = "com.twitter.android"
19
+ X_MAIN_ACTIVITY = "com.twitter.app.main.MainActivity"
20
+ X_COMPOSER_ACTIVITY = "com.twitter.composer.ComposerActivity"
21
+ X_PROFILE_ACTIVITY = "com.twitter.app.profiles.ProfileActivity"
22
+ X_GRADUATED_ACCESS_ACTIVITY = "com.twitter.feature.graduatedaccess.GraduatedAccessPromptActivity"
23
+
24
+ BLOCKING_TEXT_MARKERS = {
25
+ "登录",
26
+ "注册",
27
+ "Sign in",
28
+ "Log in",
29
+ "Create account",
30
+ "验证码",
31
+ "验证你的身份",
32
+ "verify your identity",
33
+ "locked",
34
+ "suspended",
35
+ "unusual activity",
36
+ }
37
+
38
+ POST_PUBLISH_NOTICE_MARKERS = {
39
+ "解锁 X 上的更多精彩",
40
+ "解锁X上的更多精彩",
41
+ "unlock more on x",
42
+ }
43
+
44
+
45
+ class NeedsHumanError(RuntimeError):
46
+ pass
47
+
48
+
49
+ @dataclass
50
+ class XAdbPublishResult:
51
+ device_id: str
52
+ status: str
53
+ record_id: str
54
+ started_at: str
55
+ ended_at: str
56
+ duration_seconds: int
57
+ post_type: str
58
+ text: str
59
+ screenshot_path: str
60
+ remote_media_path: str = ""
61
+ link_url: str = ""
62
+ error: str = ""
63
+
64
+
65
+ class XAdbPublisher:
66
+ """X text/link publisher for the Android POC device flow."""
67
+
68
+ def __init__(
69
+ self,
70
+ adb: AdbClient,
71
+ *,
72
+ appium_server: str = "http://127.0.0.1:4723",
73
+ records_path: str | Path = "data/publish_records.jsonl",
74
+ artifact_root: str | Path = "artifacts/screenshots",
75
+ ) -> None:
76
+ self.adb = adb
77
+ self.appium_server = appium_server
78
+ self.records_path = Path(records_path)
79
+ self.artifact_root = Path(artifact_root)
80
+ self.media_helper = TikTokAdbPublisher(adb, records_path=records_path, artifact_root=artifact_root)
81
+
82
+ def publish_post(
83
+ self,
84
+ device: Device,
85
+ *,
86
+ post_type: str,
87
+ text: str = "",
88
+ link_url: str = "",
89
+ media_path: str | Path | None = None,
90
+ account_id: str,
91
+ dry_run: bool = False,
92
+ text_input: str = "auto",
93
+ verify_profile: bool = True,
94
+ ) -> XAdbPublishResult:
95
+ post_type = _normalize_post_type(post_type)
96
+ if text_input not in {"auto", "appium", "adb"}:
97
+ raise ValueError("text_input must be one of: auto, appium, adb")
98
+ if post_type == "link":
99
+ ui.require_adb_text_safe(link_url, "link_url")
100
+ publish_text = _x_text_body(text=text, link_url=link_url)
101
+ if not publish_text.strip():
102
+ raise ValueError("X post text is required")
103
+ if text_input == "adb":
104
+ ui.require_adb_text_safe(publish_text, "text")
105
+ local: Path | None = Path(media_path).expanduser() if media_path else None
106
+ if post_type == "image":
107
+ if local is None or not local.exists():
108
+ raise FileNotFoundError(f"image not found: {media_path}")
109
+ if ui.media_kind_from_path(local) != "image":
110
+ raise ValueError(f"post_type=image requires an image file: {local}")
111
+
112
+ started_epoch = int(time.time())
113
+ started_at = _now_shanghai()
114
+ stamp = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d-%H%M%S")
115
+ run_dir = self.artifact_root / f"{device.device_id}-x-{stamp}"
116
+ run_dir.mkdir(parents=True, exist_ok=True)
117
+ record_id = f"pub_{stamp.replace('-', '')}_{device.device_id.lower()}_x"
118
+ remote = ""
119
+ if local is not None:
120
+ remote = f"/sdcard/DCIM/Camera/groupctl-{device.device_id}-x-{stamp}{local.suffix}"
121
+
122
+ try:
123
+ self._prepare_device(device)
124
+ if local is not None:
125
+ self.media_helper._push_media(device, local, remote)
126
+ self._open_home(device, run_dir)
127
+ self._open_compose(device, run_dir)
128
+ self._input_text(device, publish_text, run_dir, text_input)
129
+ if post_type == "image":
130
+ self._attach_image(device, run_dir)
131
+
132
+ if dry_run:
133
+ final_shot = run_dir / "dry-run-compose-form.png"
134
+ self.adb.screenshot(device.adb_serial, final_shot)
135
+ status = "dry_run"
136
+ else:
137
+ self._tap_post(device, run_dir)
138
+ final_shot = self._wait_for_publish_complete(device, run_dir, publish_text, verify_profile=verify_profile)
139
+ status = "published"
140
+ append_publish_record(
141
+ self.records_path,
142
+ PublishRecord(
143
+ record_id=record_id,
144
+ platform="x",
145
+ account_id=account_id,
146
+ device_id=device.device_id,
147
+ post_type=post_type,
148
+ local_media_path=str(local) if local else "",
149
+ remote_media_path=remote,
150
+ caption=publish_text,
151
+ platform_permalink="",
152
+ published_at=utc_now_iso(),
153
+ result_screenshot_path=str(final_shot),
154
+ status="published",
155
+ ),
156
+ )
157
+
158
+ return XAdbPublishResult(
159
+ device_id=device.device_id,
160
+ status=status,
161
+ record_id=record_id,
162
+ started_at=started_at,
163
+ ended_at=_now_shanghai(),
164
+ duration_seconds=int(time.time()) - started_epoch,
165
+ post_type=post_type,
166
+ text=publish_text,
167
+ remote_media_path=remote,
168
+ link_url=link_url,
169
+ screenshot_path=str(final_shot),
170
+ )
171
+ except NeedsHumanError as exc:
172
+ return self._failure_result(
173
+ device,
174
+ run_dir,
175
+ record_id,
176
+ started_at,
177
+ started_epoch,
178
+ publish_text,
179
+ post_type=post_type,
180
+ remote_media_path=remote,
181
+ link_url=link_url,
182
+ status="needs_human",
183
+ error=str(exc),
184
+ )
185
+ except Exception as exc:
186
+ return self._failure_result(
187
+ device,
188
+ run_dir,
189
+ record_id,
190
+ started_at,
191
+ started_epoch,
192
+ publish_text,
193
+ post_type=post_type,
194
+ remote_media_path=remote,
195
+ link_url=link_url,
196
+ status="failed",
197
+ error=str(exc),
198
+ )
199
+
200
+ def _failure_result(
201
+ self,
202
+ device: Device,
203
+ run_dir: Path,
204
+ record_id: str,
205
+ started_at: str,
206
+ started_epoch: int,
207
+ text: str,
208
+ *,
209
+ post_type: str,
210
+ remote_media_path: str = "",
211
+ link_url: str = "",
212
+ status: str,
213
+ error: str,
214
+ ) -> XAdbPublishResult:
215
+ failure_shot = run_dir / f"{status}.png"
216
+ self.adb.screenshot(device.adb_serial, failure_shot)
217
+ return XAdbPublishResult(
218
+ device_id=device.device_id,
219
+ status=status,
220
+ record_id=record_id,
221
+ started_at=started_at,
222
+ ended_at=_now_shanghai(),
223
+ duration_seconds=int(time.time()) - started_epoch,
224
+ post_type=post_type,
225
+ text=text,
226
+ remote_media_path=remote_media_path,
227
+ link_url=link_url,
228
+ screenshot_path=str(failure_shot),
229
+ error=error,
230
+ )
231
+
232
+ def _prepare_device(self, device: Device) -> None:
233
+ self._ok(self.adb.wake(device.adb_serial), "wake device")
234
+ time.sleep(1)
235
+ self.adb.swipe(device.adb_serial, 540, 1850, 540, 550, 350)
236
+ time.sleep(1)
237
+
238
+ def _open_home(self, device: Device, run_dir: Path) -> None:
239
+ ui.grant_publish_permissions(self.adb, device, X_PACKAGE)
240
+ self.adb.shell(device.adb_serial, "am", "force-stop", X_PACKAGE)
241
+ time.sleep(1)
242
+ self._ok(self.adb.launch_package(device.adb_serial, X_PACKAGE), "launch X")
243
+ time.sleep(6)
244
+ self.adb.screenshot(device.adb_serial, run_dir / "home.png")
245
+ focus = self.adb.current_focus(device.adb_serial)
246
+ if X_PACKAGE not in focus:
247
+ raise NeedsHumanError("X is not foreground after launch; check install/login/verification state")
248
+ self._raise_if_blocked(device, run_dir / "home.xml")
249
+
250
+ def _open_compose(self, device: Device, run_dir: Path) -> None:
251
+ if self._is_compose_open(device, run_dir / "compose-check-initial.xml"):
252
+ return
253
+
254
+ for attempt in range(3):
255
+ root = ui.dump_ui(self.adb, device, run_dir / f"before-compose-{attempt}.xml")
256
+ node = ui.find_node(
257
+ root,
258
+ ids={f"{X_PACKAGE}:id/composer_write"},
259
+ id_suffixes={"composer_write"},
260
+ descs={"新帖子", "New post", "Post", "Tweet"},
261
+ partial_texts={"发帖"},
262
+ clickable_only=False,
263
+ )
264
+ if node is not None:
265
+ ui.tap_node(self.adb, device, node, sleep_s=1.5)
266
+ if self._is_compose_open(device, run_dir / f"compose-after-tap-{attempt}.xml"):
267
+ self.adb.screenshot(device.adb_serial, run_dir / "compose-form.png")
268
+ return
269
+
270
+ # X may first expand the bottom-right plus menu. Tap the post item again.
271
+ menu_root = ui.dump_ui(self.adb, device, run_dir / f"compose-menu-{attempt}.xml")
272
+ menu_label = ui.find_node(
273
+ menu_root,
274
+ texts={"发帖", "Post", "Tweet"},
275
+ partial_texts={"发帖"},
276
+ clickable_only=False,
277
+ )
278
+ if menu_label is not None:
279
+ x, y = ui.center(menu_label)
280
+ self._ok(self.adb.tap(device.adb_serial, x + 170, y), "tap X post menu item")
281
+ time.sleep(3)
282
+ if self._is_compose_open(device, run_dir / f"compose-after-menu-label-{attempt}.xml"):
283
+ self.adb.screenshot(device.adb_serial, run_dir / "compose-form.png")
284
+ return
285
+
286
+ menu_node = ui.find_node(
287
+ menu_root,
288
+ ids={f"{X_PACKAGE}:id/composer_write"},
289
+ id_suffixes={"composer_write"},
290
+ descs={"新帖子", "New post", "Post", "Tweet"},
291
+ partial_texts={"发帖"},
292
+ clickable_only=False,
293
+ )
294
+ if menu_node is not None:
295
+ ui.tap_node(self.adb, device, menu_node, sleep_s=3)
296
+ if self._is_compose_open(device, run_dir / f"compose-after-menu-{attempt}.xml"):
297
+ self.adb.screenshot(device.adb_serial, run_dir / "compose-form.png")
298
+ return
299
+
300
+ self._reveal_compose_button(device)
301
+
302
+ self.adb.screenshot(device.adb_serial, run_dir / "compose-entry-missing.png")
303
+ raise NeedsHumanError("X compose button was not found after scrolling the timeline")
304
+
305
+ def _reveal_compose_button(self, device: Device) -> None:
306
+ # X hides the bottom-right plus after the timeline sits idle or during scroll.
307
+ self.adb.swipe(device.adb_serial, 540, 980, 540, 1460, 250)
308
+ time.sleep(0.8)
309
+ self.adb.swipe(device.adb_serial, 540, 1460, 540, 1180, 200)
310
+ time.sleep(0.8)
311
+
312
+ def _is_compose_open(self, device: Device, xml_path: Path) -> bool:
313
+ focus = self.adb.current_focus(device.adb_serial)
314
+ if X_COMPOSER_ACTIVITY in focus:
315
+ return True
316
+ try:
317
+ root = ui.dump_ui(self.adb, device, xml_path)
318
+ except Exception:
319
+ return False
320
+ return ui.find_node(root, ids={f"{X_PACKAGE}:id/tweet_text"}, id_suffixes={"tweet_text"}) is not None
321
+
322
+ def _input_text(self, device: Device, text: str, run_dir: Path, text_input: str) -> None:
323
+ use_appium = text_input == "appium" or (text_input == "auto" and not ui.adb_text_safe(text))
324
+ if use_appium:
325
+ self._input_text_appium(device, text, run_dir)
326
+ return
327
+
328
+ ui.require_adb_text_safe(text, "text")
329
+ root = ui.dump_ui(self.adb, device, run_dir / "before-text.xml")
330
+ field = ui.find_node(root, ids={f"{X_PACKAGE}:id/tweet_text"}, id_suffixes={"tweet_text"}, clickable_only=False)
331
+ if field is None:
332
+ raise NeedsHumanError("X compose text field was not found")
333
+ ui.tap_node(self.adb, device, field, sleep_s=0.5)
334
+ result = self.adb.input_text(device.adb_serial, text)
335
+ if not result.ok:
336
+ self.adb.screenshot(device.adb_serial, run_dir / "text-input-failed.png")
337
+ raise RuntimeError(f"text input failed: {result.stderr or result.stdout}")
338
+ time.sleep(1)
339
+ self.adb.keyevent(device.adb_serial, "KEYCODE_BACK")
340
+ time.sleep(1)
341
+ self.adb.screenshot(device.adb_serial, run_dir / "after-text.png")
342
+
343
+ def _input_text_appium(self, device: Device, text: str, run_dir: Path) -> None:
344
+ appium = AppiumClient(self.appium_server)
345
+ try:
346
+ appium.start_session(_capabilities(device.adb_serial))
347
+ field = appium.find_element("id", f"{X_PACKAGE}:id/tweet_text")
348
+ appium.click(field)
349
+ time.sleep(0.5)
350
+ appium.send_keys(field, text)
351
+ time.sleep(1)
352
+ self.adb.screenshot(device.adb_serial, run_dir / "after-text.png")
353
+ except Exception as exc:
354
+ self.adb.screenshot(device.adb_serial, run_dir / "text-input-appium-failed.png")
355
+ raise RuntimeError(f"Appium text input failed: {exc}") from exc
356
+ finally:
357
+ try:
358
+ appium.delete_session()
359
+ except Exception:
360
+ pass
361
+
362
+ def _attach_image(self, device: Device, run_dir: Path) -> None:
363
+ root = ui.dump_ui(self.adb, device, run_dir / "before-gallery.xml")
364
+ gallery = ui.find_node(root, ids={f"{X_PACKAGE}:id/gallery"}, id_suffixes={"gallery"}, descs={"相片", "Photo", "Photos"})
365
+ if gallery is None:
366
+ raise NeedsHumanError("X gallery button was not found on compose form")
367
+ ui.tap_node(self.adb, device, gallery, sleep_s=3)
368
+ self._tap_permission_if_present(device, run_dir / "gallery-permission.xml")
369
+
370
+ grid_root = ui.dump_ui(self.adb, device, run_dir / "gallery-grid.xml")
371
+ image_node = _find_first_gallery_image(grid_root)
372
+ if image_node is None:
373
+ self.adb.screenshot(device.adb_serial, run_dir / "gallery-image-missing.png")
374
+ raise NeedsHumanError("X gallery did not expose a selectable image")
375
+ ui.tap_node(self.adb, device, image_node, sleep_s=1.5)
376
+ self.adb.screenshot(device.adb_serial, run_dir / "gallery-selected.png")
377
+
378
+ selected_root = ui.dump_ui(self.adb, device, run_dir / "gallery-selected.xml")
379
+ if _has_attached_media(selected_root):
380
+ return
381
+ done = ui.find_node(
382
+ selected_root,
383
+ ids={f"{X_PACKAGE}:id/add_images"},
384
+ id_suffixes={"add_images"},
385
+ texts={"完成", "Add", "Done"},
386
+ descs={"完成", "Add", "Done"},
387
+ )
388
+ if done is None:
389
+ self.adb.screenshot(device.adb_serial, run_dir / "gallery-done-missing.png")
390
+ raise NeedsHumanError("X gallery Done/Add button was not found")
391
+ ui.tap_node(self.adb, device, done, sleep_s=3)
392
+ self.adb.screenshot(device.adb_serial, run_dir / "after-image.png")
393
+
394
+ def _tap_permission_if_present(self, device: Device, xml_path: Path) -> None:
395
+ ui.tap_permission_prompt_if_present(self.adb, device, xml_path)
396
+
397
+ def _tap_post(self, device: Device, run_dir: Path) -> None:
398
+ root = ui.dump_ui(self.adb, device, run_dir / "before-post.xml")
399
+ node = ui.find_node(root, ids={f"{X_PACKAGE}:id/button_tweet"}, id_suffixes={"button_tweet"}, texts={"发帖", "Post", "Tweet"})
400
+ if node is None:
401
+ raise NeedsHumanError("X post button was not found on compose form")
402
+ if node.attrib.get("enabled") != "true":
403
+ raise NeedsHumanError("X post button is disabled; check text/media/content declaration")
404
+ ui.tap_node(self.adb, device, node, sleep_s=2)
405
+
406
+ def _wait_for_publish_complete(self, device: Device, run_dir: Path, publish_text: str, *, verify_profile: bool) -> Path:
407
+ deadline = time.time() + 45
408
+ poll = 0
409
+ after_post_shot = run_dir / "after-post.png"
410
+ while time.time() < deadline:
411
+ poll += 1
412
+ focus = self.adb.current_focus(device.adb_serial)
413
+ if X_PACKAGE in focus and X_COMPOSER_ACTIVITY not in focus:
414
+ root = ui.dump_ui(self.adb, device, run_dir / f"post-state-{poll}.xml")
415
+ text = ui.all_text(root)
416
+ if ui.contains_any(text, BLOCKING_TEXT_MARKERS):
417
+ self.adb.screenshot(device.adb_serial, run_dir / "post-blocked.png")
418
+ raise NeedsHumanError("X showed a verification/account restriction prompt after tapping post")
419
+ self.adb.screenshot(device.adb_serial, after_post_shot)
420
+ if not verify_profile:
421
+ return after_post_shot
422
+ self._dismiss_post_publish_notice_if_present(device, run_dir / "post-publish-notice.xml")
423
+ return self._verify_post_on_profile(device, run_dir, publish_text)
424
+ root = ui.dump_ui(self.adb, device, run_dir / f"post-poll-{poll}.xml")
425
+ text = ui.all_text(root)
426
+ if ui.contains_any(text, BLOCKING_TEXT_MARKERS):
427
+ raise NeedsHumanError("X showed a verification/account restriction prompt after tapping post")
428
+ time.sleep(2)
429
+ self.adb.screenshot(device.adb_serial, run_dir / "post-timeout.png")
430
+ raise RuntimeError("X still appears to be on the compose screen after tapping post")
431
+
432
+ def _dismiss_post_publish_notice_if_present(self, device: Device, xml_path: Path) -> None:
433
+ focus = self.adb.current_focus(device.adb_serial)
434
+ try:
435
+ root = ui.dump_ui(self.adb, device, xml_path)
436
+ except Exception:
437
+ return
438
+ text = ui.all_text(root)
439
+ if X_GRADUATED_ACCESS_ACTIVITY not in focus and not ui.contains_any(text, POST_PUBLISH_NOTICE_MARKERS):
440
+ return
441
+ button = ui.find_node(
442
+ root,
443
+ texts={"明白了", "Got it", "OK"},
444
+ partial_texts={"明白了", "got it"},
445
+ clickable_only=False,
446
+ )
447
+ if button is not None:
448
+ ui.tap_node(self.adb, device, button, sleep_s=2)
449
+
450
+ def _verify_post_on_profile(self, device: Device, run_dir: Path, publish_text: str) -> Path:
451
+ self._open_profile(device, run_dir)
452
+ for attempt in range(6):
453
+ root = ui.dump_ui(self.adb, device, run_dir / f"profile-verify-{attempt + 1}.xml")
454
+ if _profile_has_post(root, publish_text):
455
+ final_shot = run_dir / "profile-post-confirmed.png"
456
+ self.adb.screenshot(device.adb_serial, final_shot)
457
+ return final_shot
458
+ self.adb.swipe(device.adb_serial, 540, 1800, 540, 760, 350)
459
+ time.sleep(2)
460
+
461
+ self.adb.screenshot(device.adb_serial, run_dir / "profile-post-missing.png")
462
+ raise NeedsHumanError("X publish completed, but the expected post text was not found on the profile page")
463
+
464
+ def _open_profile(self, device: Device, run_dir: Path) -> None:
465
+ if self._is_profile_open(device):
466
+ return
467
+
468
+ if X_PACKAGE not in self.adb.current_focus(device.adb_serial):
469
+ self._ok(self.adb.launch_package(device.adb_serial, X_PACKAGE), "launch X")
470
+ time.sleep(5)
471
+ self._dismiss_post_publish_notice_if_present(device, run_dir / "profile-open-notice.xml")
472
+ if self._is_profile_open(device):
473
+ return
474
+
475
+ for attempt in range(3):
476
+ root = ui.dump_ui(self.adb, device, run_dir / f"profile-open-home-{attempt + 1}.xml")
477
+ if _is_drawer_open(root):
478
+ current_profile = _find_profile_menu_item(root)
479
+ if current_profile is not None:
480
+ ui.tap_node(self.adb, device, current_profile, sleep_s=3)
481
+ if self._is_profile_open(device):
482
+ self.adb.screenshot(device.adb_serial, run_dir / "profile.png")
483
+ return
484
+
485
+ nav = ui.find_node(
486
+ root,
487
+ descs={"显示导航栏", "Open navigation drawer", "Show navigation drawer", "Navigation menu"},
488
+ partial_texts={"显示导航栏", "navigation drawer", "navigation menu"},
489
+ clickable_only=False,
490
+ )
491
+ if nav is None:
492
+ nav = _find_top_left_clickable(root)
493
+ if nav is None:
494
+ self.adb.keyevent(device.adb_serial, "KEYCODE_BACK")
495
+ time.sleep(1)
496
+ continue
497
+ ui.tap_node(self.adb, device, nav, sleep_s=1.5)
498
+
499
+ drawer_root = ui.dump_ui(self.adb, device, run_dir / f"profile-drawer-{attempt + 1}.xml")
500
+ profile = _find_profile_menu_item(drawer_root)
501
+ if profile is None:
502
+ self.adb.screenshot(device.adb_serial, run_dir / f"profile-menu-missing-{attempt + 1}.png")
503
+ self.adb.keyevent(device.adb_serial, "KEYCODE_BACK")
504
+ time.sleep(1)
505
+ continue
506
+ ui.tap_node(self.adb, device, profile, sleep_s=3)
507
+ if self._is_profile_open(device):
508
+ self.adb.screenshot(device.adb_serial, run_dir / "profile.png")
509
+ return
510
+
511
+ self.adb.screenshot(device.adb_serial, run_dir / "profile-open-failed.png")
512
+ raise NeedsHumanError("X profile page could not be opened from the navigation drawer")
513
+
514
+ def _is_profile_open(self, device: Device) -> bool:
515
+ return X_PROFILE_ACTIVITY in self.adb.current_focus(device.adb_serial)
516
+
517
+ def _raise_if_blocked(self, device: Device, xml_path: Path) -> None:
518
+ try:
519
+ root = ui.dump_ui(self.adb, device, xml_path)
520
+ except Exception:
521
+ return
522
+ text = ui.all_text(root)
523
+ if ui.contains_any(text, BLOCKING_TEXT_MARKERS):
524
+ raise NeedsHumanError("X login, verification, or account restriction prompt is visible")
525
+
526
+ @staticmethod
527
+ def _ok(result, action: str) -> None:
528
+ if not result.ok:
529
+ raise RuntimeError(f"{action} failed: {result.stderr or result.stdout}")
530
+
531
+
532
+ def _normalize_post_type(value: str) -> str:
533
+ normalized = value.strip().lower().replace("_", "-")
534
+ if normalized in {"text", "post", "tweet"}:
535
+ return "text"
536
+ if normalized == "link":
537
+ return "link"
538
+ if normalized in {"image", "photo"}:
539
+ return "image"
540
+ raise ValueError("X post_type must be one of: text, link, image")
541
+
542
+
543
+ def _x_text_body(*, text: str, link_url: str) -> str:
544
+ parts = [item.strip() for item in (text, link_url) if item and item.strip()]
545
+ return " ".join(parts)
546
+
547
+
548
+ def _find_first_gallery_image(root) -> object | None:
549
+ candidates = []
550
+ for node in root.iter("node"):
551
+ if node.attrib.get("clickable") != "true":
552
+ continue
553
+ desc = node.attrib.get("content-desc", "").lower()
554
+ if desc not in {"图像", "image", "photo"}:
555
+ continue
556
+ value = ui.bounds(node)
557
+ if not value:
558
+ continue
559
+ candidates.append(node)
560
+ return ui.sorted_visible_nodes(candidates)[0] if candidates else None
561
+
562
+
563
+ def _has_attached_media(root) -> bool:
564
+ return ui.find_node(root, ids={f"{X_PACKAGE}:id/media_attachments"}, id_suffixes={"media_attachments"}) is not None
565
+
566
+
567
+ def _profile_has_post(root, expected_text: str) -> bool:
568
+ expected = _normalize_for_match(expected_text)
569
+ if not expected:
570
+ return False
571
+ haystack = _normalize_for_match(ui.all_text(root))
572
+ if expected in haystack:
573
+ return True
574
+ if len(expected) >= 80 and expected[:60] in haystack:
575
+ return True
576
+ return False
577
+
578
+
579
+ def _normalize_for_match(value: str) -> str:
580
+ return re.sub(r"\s+", " ", value).strip()
581
+
582
+
583
+ def _find_top_left_clickable(root) -> object | None:
584
+ candidates = []
585
+ for node in root.iter("node"):
586
+ if node.attrib.get("clickable") != "true":
587
+ continue
588
+ value = ui.bounds(node)
589
+ if not value:
590
+ continue
591
+ left, top, right, bottom = value
592
+ if left <= 180 and right <= 240 and top <= 320 and bottom <= 360:
593
+ candidates.append(node)
594
+ return ui.sorted_visible_nodes(candidates)[0] if candidates else None
595
+
596
+
597
+ def _is_drawer_open(root) -> bool:
598
+ return ui.find_node(root, ids={f"{X_PACKAGE}:id/drawer"}, id_suffixes={"drawer"}) is not None
599
+
600
+
601
+ def _find_profile_menu_item(root) -> object | None:
602
+ candidates = []
603
+ for node in root.iter("node"):
604
+ text = node.attrib.get("text", "")
605
+ desc = node.attrib.get("content-desc", "")
606
+ if text not in {"个人资料", "Profile"} and desc not in {"个人资料", "Profile"}:
607
+ continue
608
+ value = ui.bounds(node)
609
+ if not value:
610
+ continue
611
+ left, top, right, _bottom = value
612
+ if left <= 300 and 500 <= top <= 1200 and right <= 1000:
613
+ candidates.append(node)
614
+ return ui.sorted_visible_nodes(candidates)[0] if candidates else None
615
+
616
+
617
+ def _capabilities(serial: str) -> dict:
618
+ return {
619
+ "platformName": "Android",
620
+ "appium:automationName": "UiAutomator2",
621
+ "appium:udid": serial,
622
+ "appium:noReset": True,
623
+ "appium:dontStopAppOnReset": True,
624
+ "appium:newCommandTimeout": 120,
625
+ "appium:disableWindowAnimation": True,
626
+ "appium:skipDeviceInitialization": True,
627
+ "appium:ignoreHiddenApiPolicyError": True,
628
+ "appium:disableSuppressAccessibilityService": True,
629
+ "appium:settings[enableNotificationListener]": False,
630
+ "appium:appPackage": X_PACKAGE,
631
+ "appium:appActivity": X_COMPOSER_ACTIVITY,
632
+ }
633
+
634
+
635
+ def _now_shanghai() -> str:
636
+ return datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S %z")