@11agents/cli 0.1.24 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/bin/11agents.js +12 -0
- package/mobile-runtime/README.md +19 -0
- package/mobile-runtime/configs/platforms/xiaohongshu_d01.json +73 -0
- package/mobile-runtime/configs/platforms/xiaohongshu_d02.json +70 -0
- package/mobile-runtime/configs/platforms/xiaohongshu_d03.json +73 -0
- package/mobile-runtime/configs/publish_policy.json +40 -0
- package/mobile-runtime/data-templates/README.md +4 -0
- package/mobile-runtime/data-templates/accounts.example.csv +6 -0
- package/mobile-runtime/data-templates/devices.example.csv +2 -0
- package/mobile-runtime/data-templates/publish_records.example.jsonl +2 -0
- package/mobile-runtime/data-templates/tasks.example.jsonl +5 -0
- package/mobile-runtime/data-templates/video_metrics.example.jsonl +1 -0
- package/mobile-runtime/python/pyproject.toml +34 -0
- package/mobile-runtime/python/src/device_control/__init__.py +5 -0
- package/mobile-runtime/python/src/device_control/adapters/__init__.py +31 -0
- package/mobile-runtime/python/src/device_control/adapters/base.py +43 -0
- package/mobile-runtime/python/src/device_control/adapters/facebook.py +30 -0
- package/mobile-runtime/python/src/device_control/adapters/instagram.py +25 -0
- package/mobile-runtime/python/src/device_control/adapters/reddit.py +29 -0
- package/mobile-runtime/python/src/device_control/adapters/tiktok.py +25 -0
- package/mobile-runtime/python/src/device_control/adapters/x.py +29 -0
- package/mobile-runtime/python/src/device_control/adapters/xiaohongshu.py +26 -0
- package/mobile-runtime/python/src/device_control/adb.py +161 -0
- package/mobile-runtime/python/src/device_control/appium_client.py +131 -0
- package/mobile-runtime/python/src/device_control/appium_manager.py +403 -0
- package/mobile-runtime/python/src/device_control/cli.py +1608 -0
- package/mobile-runtime/python/src/device_control/entrypoints.py +60 -0
- package/mobile-runtime/python/src/device_control/locks.py +162 -0
- package/mobile-runtime/python/src/device_control/metrics/__init__.py +33 -0
- package/mobile-runtime/python/src/device_control/metrics/collectors.py +320 -0
- package/mobile-runtime/python/src/device_control/metrics/tiktok_account_adb.py +367 -0
- package/mobile-runtime/python/src/device_control/metrics/tiktok_video_adb.py +714 -0
- package/mobile-runtime/python/src/device_control/models.py +439 -0
- package/mobile-runtime/python/src/device_control/publish_policy.py +173 -0
- package/mobile-runtime/python/src/device_control/publishers/__init__.py +24 -0
- package/mobile-runtime/python/src/device_control/publishers/facebook_adb.py +494 -0
- package/mobile-runtime/python/src/device_control/publishers/instagram_adb.py +663 -0
- package/mobile-runtime/python/src/device_control/publishers/reddit_adb.py +595 -0
- package/mobile-runtime/python/src/device_control/publishers/tiktok_adb.py +477 -0
- package/mobile-runtime/python/src/device_control/publishers/tiktok_appium.py +259 -0
- package/mobile-runtime/python/src/device_control/publishers/ui_helpers.py +372 -0
- package/mobile-runtime/python/src/device_control/publishers/x_adb.py +636 -0
- package/mobile-runtime/python/src/device_control/publishers/xiaohongshu_adb.py +1143 -0
- package/mobile-runtime/python/src/device_control/store.py +137 -0
- package/mobile-runtime/scripts/appium_smoke.py +71 -0
- package/mobile-runtime/skills/android-collect-tiktok-metrics/SKILL.md +60 -0
- package/mobile-runtime/skills/android-collect-tiktok-metrics/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-group-control-cli/SKILL.md +76 -0
- package/mobile-runtime/skills/android-group-control-cli/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-group-control-cli/references/command-reference.md +122 -0
- package/mobile-runtime/skills/android-publish-facebook/SKILL.md +41 -0
- package/mobile-runtime/skills/android-publish-facebook/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-instagram/SKILL.md +45 -0
- package/mobile-runtime/skills/android-publish-instagram/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-reddit/SKILL.md +41 -0
- package/mobile-runtime/skills/android-publish-reddit/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-tiktok/SKILL.md +43 -0
- package/mobile-runtime/skills/android-publish-tiktok/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-x/SKILL.md +40 -0
- package/mobile-runtime/skills/android-publish-x/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-xiaohongshu/SKILL.md +50 -0
- package/mobile-runtime/skills/android-publish-xiaohongshu/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/mobile-publish-data-collection/SKILL.md +49 -0
- package/mobile-runtime/skills/mobile-publish-device-health/SKILL.md +47 -0
- package/mobile-runtime/skills/mobile-publish-execution/SKILL.md +57 -0
- package/mobile-runtime/skills/mobile-publish-records/SKILL.md +29 -0
- package/package.json +4 -1
- package/scripts/mobile-postinstall.js +26 -0
- package/src/commands/mobile.js +695 -0
- package/src/commands/runtime.js +21 -5
|
@@ -0,0 +1,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")
|