@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,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")
|