@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,595 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import ssl
6
+ import time
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ import xml.etree.ElementTree as ET
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from zoneinfo import ZoneInfo
16
+
17
+ from device_control.adb import AdbClient
18
+ from device_control.models import Device, PublishRecord, utc_now_iso
19
+ from device_control.publishers import ui_helpers as ui
20
+ from device_control.publishers.tiktok_adb import TikTokAdbPublisher
21
+ from device_control.store import append_publish_record
22
+
23
+
24
+ REDDIT_PACKAGE = "com.reddit.frontpage"
25
+ REDDIT_USER_AGENT = "elevenagents-mobile-runtime/0.1"
26
+
27
+
28
+ @dataclass
29
+ class RedditAdbPublishResult:
30
+ device_id: str
31
+ status: str
32
+ record_id: str
33
+ started_at: str
34
+ ended_at: str
35
+ duration_seconds: int
36
+ subreddit: str
37
+ title: str
38
+ post_type: str
39
+ screenshot_path: str
40
+ remote_media_path: str = ""
41
+ link_url: str = ""
42
+ platform_post_id: str = ""
43
+ platform_permalink: str = ""
44
+ error: str = ""
45
+
46
+
47
+ class RedditAdbPublisher:
48
+ """ADB-only Reddit text-post publisher for the Samsung SM-G9910 POC devices."""
49
+
50
+ def __init__(
51
+ self,
52
+ adb: AdbClient,
53
+ *,
54
+ records_path: str | Path = "data/publish_records.jsonl",
55
+ artifact_root: str | Path = "artifacts/screenshots",
56
+ ) -> None:
57
+ self.adb = adb
58
+ self.records_path = Path(records_path)
59
+ self.artifact_root = Path(artifact_root)
60
+ self.media_helper = TikTokAdbPublisher(adb, records_path=records_path, artifact_root=artifact_root)
61
+
62
+ def publish_text(
63
+ self,
64
+ device: Device,
65
+ *,
66
+ subreddit: str,
67
+ title: str,
68
+ body: str = "",
69
+ account_id: str,
70
+ reddit_username: str = "",
71
+ dry_run: bool = False,
72
+ fetch_permalink: bool = True,
73
+ ) -> RedditAdbPublishResult:
74
+ return self.publish_post(
75
+ device,
76
+ post_type="text",
77
+ subreddit=subreddit,
78
+ title=title,
79
+ body=body,
80
+ account_id=account_id,
81
+ reddit_username=reddit_username,
82
+ dry_run=dry_run,
83
+ fetch_permalink=fetch_permalink,
84
+ )
85
+
86
+ def publish_post(
87
+ self,
88
+ device: Device,
89
+ *,
90
+ post_type: str,
91
+ subreddit: str,
92
+ title: str,
93
+ body: str = "",
94
+ link_url: str = "",
95
+ media_path: str | Path | None = None,
96
+ account_id: str,
97
+ reddit_username: str = "",
98
+ dry_run: bool = False,
99
+ fetch_permalink: bool = True,
100
+ ) -> RedditAdbPublishResult:
101
+ post_type = _normalize_post_type(post_type)
102
+ subreddit = _normalize_subreddit(subreddit)
103
+ _require_adb_text_safe(title, "title")
104
+ if body:
105
+ _require_adb_text_safe(body, "body")
106
+ if post_type == "link":
107
+ _require_adb_text_safe(link_url, "link_url")
108
+ local: Path | None = Path(media_path).expanduser() if media_path else None
109
+ if post_type in {"image", "video"}:
110
+ if local is None or not local.exists():
111
+ raise FileNotFoundError(f"media not found: {media_path}")
112
+ if ui.media_kind_from_path(local) != post_type:
113
+ raise ValueError(f"post_type={post_type} does not match media file suffix: {local}")
114
+
115
+ started_epoch = int(time.time())
116
+ started_at = _now_shanghai()
117
+ stamp = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d-%H%M%S")
118
+ run_dir = self.artifact_root / f"{device.device_id}-reddit-{stamp}"
119
+ run_dir.mkdir(parents=True, exist_ok=True)
120
+ record_id = f"pub_{stamp.replace('-', '')}_{device.device_id.lower()}_reddit"
121
+ remote = ""
122
+ if local is not None:
123
+ remote = f"/sdcard/DCIM/Camera/groupctl-{device.device_id}-reddit-{stamp}{local.suffix}"
124
+
125
+ try:
126
+ self._prepare_device(device)
127
+ if local is not None:
128
+ self.media_helper._push_media(device, local, remote)
129
+ self._open_submit(device, subreddit)
130
+ self._select_subreddit(device, subreddit, run_dir)
131
+ self.adb.screenshot(device.adb_serial, run_dir / "submit-form.png")
132
+ self._input_title(device, title, run_dir)
133
+ if post_type == "link":
134
+ self._select_post_type(device, "link", run_dir)
135
+ self._input_link(device, link_url, run_dir)
136
+ elif post_type in {"image", "video"}:
137
+ self._select_post_type(device, post_type, run_dir)
138
+ self._attach_media(device, post_type, run_dir)
139
+ if body:
140
+ self._input_body(device, body, run_dir)
141
+
142
+ if dry_run:
143
+ final_shot = run_dir / "dry-run-post-form.png"
144
+ self.adb.screenshot(device.adb_serial, final_shot)
145
+ status = "dry_run"
146
+ post_id = ""
147
+ permalink = ""
148
+ else:
149
+ self._tap_post(device)
150
+ time.sleep(10)
151
+ self._dismiss_after_post_prompt(device)
152
+ final_shot = run_dir / "after-post.png"
153
+ self.adb.screenshot(device.adb_serial, final_shot)
154
+ self._assert_not_still_editing(device, run_dir)
155
+ status = "published"
156
+ post_id = ""
157
+ permalink = ""
158
+ lookup_username = reddit_username or account_id
159
+ if fetch_permalink and lookup_username:
160
+ found = _lookup_recent_post(
161
+ username=lookup_username,
162
+ subreddit=subreddit,
163
+ title=title,
164
+ body=body,
165
+ link_url=link_url if post_type == "link" else "",
166
+ min_created_utc=started_epoch - 120,
167
+ )
168
+ post_id = found.get("id", "")
169
+ path = found.get("permalink", "")
170
+ permalink = f"https://www.reddit.com{path}" if path.startswith("/") else path
171
+ if not post_id or not permalink:
172
+ raise RuntimeError("Reddit publish was not confirmed by public JSON lookup")
173
+
174
+ record = PublishRecord(
175
+ record_id=record_id,
176
+ platform="reddit",
177
+ account_id=account_id,
178
+ device_id=device.device_id,
179
+ post_type=post_type,
180
+ local_media_path=str(local) if local else "",
181
+ remote_media_path=remote,
182
+ platform_post_id=post_id,
183
+ platform_permalink=permalink,
184
+ caption=_caption(title, body or link_url),
185
+ published_at=utc_now_iso(),
186
+ result_screenshot_path=str(final_shot),
187
+ status="published",
188
+ )
189
+ append_publish_record(self.records_path, record)
190
+
191
+ ended_epoch = int(time.time())
192
+ return RedditAdbPublishResult(
193
+ device_id=device.device_id,
194
+ status=status,
195
+ record_id=record_id,
196
+ started_at=started_at,
197
+ ended_at=_now_shanghai(),
198
+ duration_seconds=ended_epoch - started_epoch,
199
+ subreddit=subreddit,
200
+ title=title,
201
+ post_type=post_type,
202
+ screenshot_path=str(final_shot),
203
+ remote_media_path=remote,
204
+ link_url=link_url,
205
+ platform_post_id=post_id,
206
+ platform_permalink=permalink,
207
+ )
208
+ except Exception as exc:
209
+ ended_epoch = int(time.time())
210
+ failure_shot = run_dir / "failed.png"
211
+ self.adb.screenshot(device.adb_serial, failure_shot)
212
+ return RedditAdbPublishResult(
213
+ device_id=device.device_id,
214
+ status="failed",
215
+ record_id=record_id,
216
+ started_at=started_at,
217
+ ended_at=_now_shanghai(),
218
+ duration_seconds=ended_epoch - started_epoch,
219
+ subreddit=subreddit,
220
+ title=title,
221
+ post_type=post_type,
222
+ screenshot_path=str(failure_shot),
223
+ remote_media_path=remote,
224
+ link_url=link_url,
225
+ error=str(exc),
226
+ )
227
+
228
+ def _prepare_device(self, device: Device) -> None:
229
+ self._ok(self.adb.wake(device.adb_serial), "wake device")
230
+ time.sleep(1)
231
+ self.adb.swipe(device.adb_serial, 540, 1850, 540, 550, 350)
232
+ time.sleep(1)
233
+
234
+ def _open_submit(self, device: Device, subreddit: str) -> None:
235
+ url = f"https://www.reddit.com/r/{urllib.parse.quote(subreddit)}/submit"
236
+ ui.grant_publish_permissions(self.adb, device, REDDIT_PACKAGE)
237
+ self.adb.shell(device.adb_serial, "am", "force-stop", REDDIT_PACKAGE)
238
+ time.sleep(1)
239
+ result = self.adb.shell(
240
+ device.adb_serial,
241
+ "am",
242
+ "start",
243
+ "-W",
244
+ "-a",
245
+ "android.intent.action.VIEW",
246
+ "-d",
247
+ url,
248
+ "-p",
249
+ REDDIT_PACKAGE,
250
+ )
251
+ self._ok(result, f"open Reddit submit URL {url}")
252
+ time.sleep(5)
253
+
254
+ def _select_subreddit(self, device: Device, subreddit: str, run_dir: Path) -> None:
255
+ current_xml = run_dir / "community-current.xml"
256
+ current = self.adb.dump_ui(device.adb_serial, current_xml)
257
+ if current.ok and _find_text_center(current_xml, f"r/{subreddit}"):
258
+ self.adb.screenshot(device.adb_serial, run_dir / "community-already-selected.png")
259
+ return
260
+
261
+ self._tap(device, 360, 175, 2)
262
+ self._tap_resource(
263
+ device,
264
+ "community_picker_search_bar_tag",
265
+ run_dir / "community-search-before-input.xml",
266
+ fallback=(540, 320),
267
+ )
268
+ time.sleep(1)
269
+ result = self.adb.input_text(device.adb_serial, subreddit)
270
+ if not result.ok:
271
+ self.adb.screenshot(device.adb_serial, run_dir / "community-input-failed.png")
272
+ raise RuntimeError(f"community input failed: {result.stderr or result.stdout}")
273
+
274
+ xml_path = run_dir / "community-results.xml"
275
+ last_error = ""
276
+ for attempt in range(8):
277
+ time.sleep(2)
278
+ dump = self.adb.dump_ui(device.adb_serial, xml_path)
279
+ if not dump.ok:
280
+ last_error = dump.stderr or dump.stdout
281
+ continue
282
+ search_text = _find_resource_text(xml_path, "community_picker_search_bar_tag")
283
+ if attempt == 0 and search_text != subreddit:
284
+ self._tap_resource(
285
+ device,
286
+ "community_picker_search_bar_tag",
287
+ run_dir / "community-search-retry.xml",
288
+ fallback=(540, 320),
289
+ )
290
+ time.sleep(1)
291
+ self.adb.input_text(device.adb_serial, subreddit)
292
+ continue
293
+ center = _find_text_center(xml_path, f"r/{subreddit}")
294
+ if center:
295
+ self._tap(device, center[0], center[1], 3)
296
+ self.adb.screenshot(device.adb_serial, run_dir / "after-community.png")
297
+ return
298
+
299
+ self.adb.screenshot(device.adb_serial, run_dir / "community-not-found.png")
300
+ detail = f"; last dump error={last_error}" if last_error else ""
301
+ raise RuntimeError(f"community r/{subreddit} not found in Reddit search results{detail}")
302
+
303
+ def _input_title(self, device: Device, title: str, run_dir: Path) -> None:
304
+ self._tap_resource(device, "post_title_field", run_dir / "before-title.xml", fallback=(180, 460))
305
+ time.sleep(1)
306
+ result = self.adb.input_text(device.adb_serial, title)
307
+ if not result.ok:
308
+ self.adb.screenshot(device.adb_serial, run_dir / "title-input-failed.png")
309
+ raise RuntimeError(f"title input failed: {result.stderr or result.stdout}")
310
+ time.sleep(1)
311
+ self.adb.screenshot(device.adb_serial, run_dir / "after-title.png")
312
+
313
+ def _input_body(self, device: Device, body: str, run_dir: Path) -> None:
314
+ self._tap_resource(
315
+ device,
316
+ "com.reddit.frontpage:id/richtext_edit_text_view",
317
+ run_dir / "before-body.xml",
318
+ fallback=(220, 820),
319
+ )
320
+ time.sleep(1)
321
+ result = self.adb.input_text(device.adb_serial, body)
322
+ if not result.ok:
323
+ self.adb.screenshot(device.adb_serial, run_dir / "body-input-failed.png")
324
+ raise RuntimeError(f"body input failed: {result.stderr or result.stdout}")
325
+ time.sleep(1)
326
+ self.adb.screenshot(device.adb_serial, run_dir / "after-body.png")
327
+
328
+ def _select_post_type(self, device: Device, post_type: str, run_dir: Path) -> None:
329
+ resource_id = {
330
+ "link": "link_post_type_button",
331
+ "image": "image_post_type_button",
332
+ "video": "video_post_type_button",
333
+ }[post_type]
334
+ self._tap_resource(device, resource_id, run_dir / f"before-{post_type}-type.xml", fallback=_post_type_fallback(post_type))
335
+ time.sleep(2)
336
+ self.adb.screenshot(device.adb_serial, run_dir / f"after-{post_type}-type.png")
337
+
338
+ def _input_link(self, device: Device, link_url: str, run_dir: Path) -> None:
339
+ xml_path = run_dir / "before-link.xml"
340
+ dump = self.adb.dump_ui(device.adb_serial, xml_path)
341
+ center = _find_desc_or_text_center(xml_path, {"submit_link", "输入链接", "Enter link"}) if dump.ok else None
342
+ x, y = center or (180, 735)
343
+ self._tap(device, x, y, 1)
344
+ result = self.adb.input_text(device.adb_serial, link_url)
345
+ if not result.ok:
346
+ self.adb.screenshot(device.adb_serial, run_dir / "link-input-failed.png")
347
+ raise RuntimeError(f"link input failed: {result.stderr or result.stdout}")
348
+ time.sleep(1)
349
+ self.adb.screenshot(device.adb_serial, run_dir / "after-link.png")
350
+
351
+ def _attach_media(self, device: Device, post_type: str, run_dir: Path) -> None:
352
+ sheet_xml = run_dir / f"{post_type}-media-sheet.xml"
353
+ root = self._dump(device, sheet_xml)
354
+ if ui.tap_permission_prompt_if_present(self.adb, device, run_dir / f"{post_type}-media-permission.xml"):
355
+ root = self._dump(device, run_dir / f"{post_type}-media-sheet-after-permission.xml")
356
+ permission = ui.find_node(
357
+ root,
358
+ ids={"com.android.permissioncontroller:id/permission_allow_button"},
359
+ id_suffixes={"permission_allow_button"},
360
+ texts={"允许", "Allow"},
361
+ partial_texts={"允许", "Allow"},
362
+ clickable_only=False,
363
+ )
364
+ if permission is not None:
365
+ ui.tap_node(self.adb, device, permission, sleep_s=3)
366
+ root = self._dump(device, run_dir / f"{post_type}-media-sheet-after-permission.xml")
367
+ if ui.find_node(root, id_suffixes={"permission_allow_button"}, partial_texts={"允许", "Allow"}) is not None:
368
+ self._tap(device, 540, 1368, 3)
369
+ root = self._dump(device, run_dir / f"{post_type}-media-sheet-after-permission-fallback.xml")
370
+ self.adb.screenshot(device.adb_serial, run_dir / f"after-{post_type}-permission.png")
371
+ library_texts = {"照片库", "Photo library", "Gallery"}
372
+ if post_type == "video":
373
+ library_texts |= {"视频库", "Video library"}
374
+ node = ui.find_node(root, texts=library_texts, partial_texts=library_texts)
375
+ if node is None:
376
+ self.adb.screenshot(device.adb_serial, run_dir / f"{post_type}-media-sheet-missing.png")
377
+ raise RuntimeError(f"Reddit {post_type} media library entry not found")
378
+ ui.tap_node(self.adb, device, node, sleep_s=3)
379
+
380
+ picker_xml = run_dir / f"{post_type}-photo-picker.xml"
381
+ picker = self._dump(device, picker_xml)
382
+ thumbnails = ui.sorted_visible_nodes(ui.find_nodes(picker, id_suffixes={"icon_thumbnail"}))
383
+ if not thumbnails:
384
+ self.adb.screenshot(device.adb_serial, run_dir / f"{post_type}-picker-empty.png")
385
+ raise RuntimeError(f"Reddit {post_type} picker did not expose media thumbnails")
386
+ ui.tap_node(self.adb, device, thumbnails[0], sleep_s=1)
387
+
388
+ selected_xml = run_dir / f"{post_type}-photo-picker-selected.xml"
389
+ selected = self._dump(device, selected_xml)
390
+ add = ui.find_node(
391
+ selected,
392
+ ids={"com.android.providers.media.module:id/button_add"},
393
+ id_suffixes={"button_add"},
394
+ partial_texts={"添加", "Add"},
395
+ clickable_only=False,
396
+ )
397
+ if add is None:
398
+ self.adb.screenshot(device.adb_serial, run_dir / f"{post_type}-picker-add-missing.png")
399
+ raise RuntimeError(f"Reddit {post_type} picker add button not found")
400
+ ui.tap_node(self.adb, device, add, sleep_s=4)
401
+ self.adb.screenshot(device.adb_serial, run_dir / f"after-{post_type}-media.png")
402
+
403
+ def _tap_post(self, device: Device) -> None:
404
+ self._tap(device, 945, 175, 0)
405
+
406
+ def _dismiss_after_post_prompt(self, device: Device) -> None:
407
+ # Reddit often shows a post-publish crosspost prompt. Dismiss it if present.
408
+ time.sleep(2)
409
+ self.adb.tap(device.adb_serial, 980, 1788)
410
+ time.sleep(1)
411
+
412
+ def _assert_not_still_editing(self, device: Device, run_dir: Path) -> None:
413
+ xml_path = run_dir / "after-post.xml"
414
+ dump = self.adb.dump_ui(device.adb_serial, xml_path)
415
+ if not dump.ok:
416
+ return
417
+ root = ET.parse(xml_path).getroot()
418
+ texts = {node.attrib.get("text", "") for node in root.iter("node")}
419
+ resource_ids = {node.attrib.get("resource-id", "") for node in root.iter("node")}
420
+ if "保存草稿?" in texts or "post_submit_screen" in resource_ids or "post_title_field" in resource_ids:
421
+ raise RuntimeError("Reddit is still on composer/draft screen; publish not confirmed")
422
+
423
+ def _tap(self, device: Device, x: int, y: int, sleep_s: int) -> None:
424
+ self._ok(self.adb.tap(device.adb_serial, x, y), f"tap {x},{y}")
425
+ if sleep_s:
426
+ time.sleep(sleep_s)
427
+
428
+ def _tap_resource(self, device: Device, resource_id: str, xml_path: Path, *, fallback: tuple[int, int]) -> None:
429
+ dump = self.adb.dump_ui(device.adb_serial, xml_path)
430
+ center = _find_resource_center(xml_path, resource_id) if dump.ok else None
431
+ x, y = center or fallback
432
+ self._tap(device, x, y, 0)
433
+
434
+ def _dump(self, device: Device, xml_path: Path) -> ET.Element:
435
+ dump = self.adb.dump_ui(device.adb_serial, xml_path)
436
+ if not dump.ok:
437
+ raise RuntimeError(f"dump ui failed: {dump.stderr or dump.stdout}")
438
+ return ET.parse(xml_path).getroot()
439
+
440
+ @staticmethod
441
+ def _ok(result, action: str) -> None:
442
+ if not result.ok:
443
+ raise RuntimeError(f"{action} failed: {result.stderr or result.stdout}")
444
+
445
+
446
+ def _normalize_subreddit(value: str) -> str:
447
+ text = value.strip()
448
+ if text.startswith("/r/"):
449
+ text = text[3:]
450
+ if text.lower().startswith("r/"):
451
+ text = text[2:]
452
+ if not re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_]{1,20}", text):
453
+ raise ValueError(f"invalid subreddit: {value!r}")
454
+ return text
455
+
456
+
457
+ def _normalize_post_type(value: str) -> str:
458
+ text = value.strip().lower().replace("_", "-")
459
+ aliases = {
460
+ "text": "text",
461
+ "self": "text",
462
+ "link": "link",
463
+ "url": "link",
464
+ "image": "image",
465
+ "photo": "image",
466
+ "video": "video",
467
+ }
468
+ if text in aliases:
469
+ return aliases[text]
470
+ raise ValueError("Reddit post_type must be one of: text, link, image, video")
471
+
472
+
473
+ def _post_type_fallback(post_type: str) -> tuple[int, int]:
474
+ return {
475
+ "link": (255, 1470),
476
+ "image": (399, 1470),
477
+ "video": (543, 1470),
478
+ }[post_type]
479
+
480
+
481
+ def _require_adb_text_safe(value: str, field_name: str) -> None:
482
+ if not value.strip():
483
+ raise ValueError(f"{field_name} is required")
484
+ if "\n" in value or "\r" in value or "\t" in value:
485
+ raise ValueError(f"{field_name} must be a single line for the current ADB-only POC")
486
+ if not value.isascii():
487
+ raise ValueError(f"{field_name} must be ASCII for the current ADB-only POC")
488
+
489
+
490
+ def _lookup_recent_post(
491
+ *,
492
+ username: str,
493
+ subreddit: str,
494
+ title: str,
495
+ body: str,
496
+ link_url: str = "",
497
+ min_created_utc: int,
498
+ ) -> dict[str, str]:
499
+ safe_user = urllib.parse.quote(username.strip())
500
+ url = f"https://www.reddit.com/user/{safe_user}/submitted.json?limit=10"
501
+ req = urllib.request.Request(url, headers={"User-Agent": REDDIT_USER_AGENT})
502
+ try:
503
+ with urllib.request.urlopen(req, timeout=30, context=_ssl_context()) as resp:
504
+ data = json.loads(resp.read().decode("utf-8"))
505
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
506
+ return {}
507
+
508
+ children = ((data.get("data") or {}).get("children") or []) if isinstance(data, dict) else []
509
+ for child in children:
510
+ post = child.get("data") or {}
511
+ if str(post.get("subreddit", "")).lower() != subreddit.lower():
512
+ continue
513
+ if str(post.get("title", "")) != title:
514
+ continue
515
+ if body and str(post.get("selftext", "")) != body:
516
+ continue
517
+ if link_url and str(post.get("url", "")) != link_url:
518
+ continue
519
+ if int(float(post.get("created_utc") or 0)) < min_created_utc:
520
+ continue
521
+ return {
522
+ "id": str(post.get("id", "")),
523
+ "permalink": str(post.get("permalink", "")),
524
+ }
525
+ return {}
526
+
527
+
528
+ def _find_text_center(xml_path: Path, text: str) -> tuple[int, int] | None:
529
+ root = ET.parse(xml_path).getroot()
530
+ for node in root.iter("node"):
531
+ if node.attrib.get("text") != text:
532
+ continue
533
+ bounds = node.attrib.get("bounds", "")
534
+ match = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
535
+ if not match:
536
+ continue
537
+ left, top, right, bottom = [int(part) for part in match.groups()]
538
+ return ((left + right) // 2, (top + bottom) // 2)
539
+ return None
540
+
541
+
542
+ def _find_resource_center(xml_path: Path, resource_id: str) -> tuple[int, int] | None:
543
+ root = ET.parse(xml_path).getroot()
544
+ for node in root.iter("node"):
545
+ node_resource = node.attrib.get("resource-id", "")
546
+ if node_resource != resource_id and not node_resource.endswith(f":id/{resource_id}"):
547
+ continue
548
+ bounds = node.attrib.get("bounds", "")
549
+ match = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
550
+ if not match:
551
+ continue
552
+ left, top, right, bottom = [int(part) for part in match.groups()]
553
+ return ((left + right) // 2, (top + bottom) // 2)
554
+ return None
555
+
556
+
557
+ def _find_desc_or_text_center(xml_path: Path, values: set[str]) -> tuple[int, int] | None:
558
+ root = ET.parse(xml_path).getroot()
559
+ for node in root.iter("node"):
560
+ if node.attrib.get("text") not in values and node.attrib.get("content-desc") not in values:
561
+ continue
562
+ bounds = node.attrib.get("bounds", "")
563
+ match = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
564
+ if not match:
565
+ continue
566
+ left, top, right, bottom = [int(part) for part in match.groups()]
567
+ if right <= left or bottom <= top:
568
+ continue
569
+ return ((left + right) // 2, (top + bottom) // 2)
570
+ return None
571
+
572
+
573
+ def _find_resource_text(xml_path: Path, resource_id: str) -> str:
574
+ root = ET.parse(xml_path).getroot()
575
+ for node in root.iter("node"):
576
+ node_resource = node.attrib.get("resource-id", "")
577
+ if node_resource == resource_id or node_resource.endswith(f":id/{resource_id}"):
578
+ return node.attrib.get("text", "")
579
+ return ""
580
+
581
+
582
+ def _ssl_context() -> ssl.SSLContext | None:
583
+ try:
584
+ import certifi
585
+ except Exception:
586
+ return None
587
+ return ssl.create_default_context(cafile=certifi.where())
588
+
589
+
590
+ def _caption(title: str, body: str) -> str:
591
+ return f"{title} | {body}" if body else title
592
+
593
+
594
+ def _now_shanghai() -> str:
595
+ return datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S %z")