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