@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,494 @@
|
|
|
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.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
|
+
FACEBOOK_LITE_PACKAGE = "com.facebook.lite"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NeedsHumanError(RuntimeError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class FacebookAdbPublishResult:
|
|
27
|
+
device_id: str
|
|
28
|
+
status: str
|
|
29
|
+
record_id: str
|
|
30
|
+
started_at: str
|
|
31
|
+
ended_at: str
|
|
32
|
+
duration_seconds: int
|
|
33
|
+
post_type: str
|
|
34
|
+
text: str
|
|
35
|
+
screenshot_path: str
|
|
36
|
+
remote_media_path: str = ""
|
|
37
|
+
link_url: str = ""
|
|
38
|
+
error: str = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class VisualPoint:
|
|
43
|
+
x: int
|
|
44
|
+
y: int
|
|
45
|
+
confidence: float
|
|
46
|
+
source: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FacebookAdbPublisher:
|
|
50
|
+
"""Facebook Lite text-post publisher for the D03 POC layout."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
adb: AdbClient,
|
|
55
|
+
*,
|
|
56
|
+
records_path: str | Path = "data/publish_records.jsonl",
|
|
57
|
+
artifact_root: str | Path = "artifacts/screenshots",
|
|
58
|
+
) -> None:
|
|
59
|
+
self.adb = adb
|
|
60
|
+
self.records_path = Path(records_path)
|
|
61
|
+
self.artifact_root = Path(artifact_root)
|
|
62
|
+
self.media_helper = TikTokAdbPublisher(adb, records_path=records_path, artifact_root=artifact_root)
|
|
63
|
+
|
|
64
|
+
def publish_text(
|
|
65
|
+
self,
|
|
66
|
+
device: Device,
|
|
67
|
+
*,
|
|
68
|
+
text: str,
|
|
69
|
+
account_id: str,
|
|
70
|
+
dry_run: bool = False,
|
|
71
|
+
app_package: str = FACEBOOK_LITE_PACKAGE,
|
|
72
|
+
) -> FacebookAdbPublishResult:
|
|
73
|
+
return self.publish_post(
|
|
74
|
+
device,
|
|
75
|
+
post_type="text",
|
|
76
|
+
text=text,
|
|
77
|
+
account_id=account_id,
|
|
78
|
+
dry_run=dry_run,
|
|
79
|
+
app_package=app_package,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def publish_post(
|
|
83
|
+
self,
|
|
84
|
+
device: Device,
|
|
85
|
+
*,
|
|
86
|
+
post_type: str,
|
|
87
|
+
text: str = "",
|
|
88
|
+
account_id: str,
|
|
89
|
+
media_path: str | Path | None = None,
|
|
90
|
+
link_url: str = "",
|
|
91
|
+
dry_run: bool = False,
|
|
92
|
+
app_package: str = FACEBOOK_LITE_PACKAGE,
|
|
93
|
+
) -> FacebookAdbPublishResult:
|
|
94
|
+
post_type = _normalize_post_type(post_type)
|
|
95
|
+
local: Path | None = Path(media_path).expanduser() if media_path else None
|
|
96
|
+
if post_type in {"image", "video"}:
|
|
97
|
+
if local is None or not local.exists():
|
|
98
|
+
raise FileNotFoundError(f"media not found: {media_path}")
|
|
99
|
+
if ui.media_kind_from_path(local) != post_type:
|
|
100
|
+
raise ValueError(f"post_type={post_type} does not match media file suffix: {local}")
|
|
101
|
+
if post_type == "link":
|
|
102
|
+
_require_adb_text_safe(link_url, "link_url")
|
|
103
|
+
if post_type == "text":
|
|
104
|
+
_require_adb_text_safe(text, "text")
|
|
105
|
+
else:
|
|
106
|
+
_require_adb_text_safe(text, "text", required=False)
|
|
107
|
+
publish_text = _facebook_text_body(text=text, link_url=link_url)
|
|
108
|
+
|
|
109
|
+
started_epoch = int(time.time())
|
|
110
|
+
started_at = _now_shanghai()
|
|
111
|
+
stamp = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d-%H%M%S")
|
|
112
|
+
run_dir = self.artifact_root / f"{device.device_id}-facebook-{stamp}"
|
|
113
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
record_id = f"pub_{stamp.replace('-', '')}_{device.device_id.lower()}_facebook"
|
|
115
|
+
remote = ""
|
|
116
|
+
if local is not None:
|
|
117
|
+
remote = f"/sdcard/DCIM/Camera/groupctl-{device.device_id}-facebook-{stamp}{local.suffix}"
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
self._prepare_device(device)
|
|
121
|
+
if local is not None:
|
|
122
|
+
self.media_helper._push_media(device, local, remote)
|
|
123
|
+
self._open_home(device, app_package, run_dir)
|
|
124
|
+
self._open_composer(device, run_dir)
|
|
125
|
+
if publish_text:
|
|
126
|
+
self._input_text(device, publish_text, run_dir)
|
|
127
|
+
if local is not None:
|
|
128
|
+
self._attach_media(device, run_dir, post_type)
|
|
129
|
+
|
|
130
|
+
if dry_run:
|
|
131
|
+
final_shot = run_dir / "dry-run-post-form.png"
|
|
132
|
+
self.adb.screenshot(device.adb_serial, final_shot)
|
|
133
|
+
status = "dry_run"
|
|
134
|
+
else:
|
|
135
|
+
self._tap_post(device, run_dir)
|
|
136
|
+
self._wait_for_publish_complete(device, run_dir)
|
|
137
|
+
final_shot = run_dir / "after-post.png"
|
|
138
|
+
self.adb.screenshot(device.adb_serial, final_shot)
|
|
139
|
+
status = "published"
|
|
140
|
+
append_publish_record(
|
|
141
|
+
self.records_path,
|
|
142
|
+
PublishRecord(
|
|
143
|
+
record_id=record_id,
|
|
144
|
+
platform="facebook",
|
|
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
|
+
published_at=utc_now_iso(),
|
|
152
|
+
result_screenshot_path=str(final_shot),
|
|
153
|
+
status="published",
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return FacebookAdbPublishResult(
|
|
158
|
+
device_id=device.device_id,
|
|
159
|
+
status=status,
|
|
160
|
+
record_id=record_id,
|
|
161
|
+
started_at=started_at,
|
|
162
|
+
ended_at=_now_shanghai(),
|
|
163
|
+
duration_seconds=int(time.time()) - started_epoch,
|
|
164
|
+
post_type=post_type,
|
|
165
|
+
text=publish_text,
|
|
166
|
+
remote_media_path=remote,
|
|
167
|
+
link_url=link_url,
|
|
168
|
+
screenshot_path=str(final_shot),
|
|
169
|
+
)
|
|
170
|
+
except NeedsHumanError as exc:
|
|
171
|
+
return self._failure_result(
|
|
172
|
+
device,
|
|
173
|
+
run_dir,
|
|
174
|
+
record_id,
|
|
175
|
+
started_at,
|
|
176
|
+
started_epoch,
|
|
177
|
+
publish_text,
|
|
178
|
+
post_type=post_type,
|
|
179
|
+
remote_media_path=remote,
|
|
180
|
+
link_url=link_url,
|
|
181
|
+
status="needs_human",
|
|
182
|
+
error=str(exc),
|
|
183
|
+
)
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
return self._failure_result(
|
|
186
|
+
device,
|
|
187
|
+
run_dir,
|
|
188
|
+
record_id,
|
|
189
|
+
started_at,
|
|
190
|
+
started_epoch,
|
|
191
|
+
publish_text,
|
|
192
|
+
post_type=post_type,
|
|
193
|
+
remote_media_path=remote,
|
|
194
|
+
link_url=link_url,
|
|
195
|
+
status="failed",
|
|
196
|
+
error=str(exc),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def _failure_result(
|
|
200
|
+
self,
|
|
201
|
+
device: Device,
|
|
202
|
+
run_dir: Path,
|
|
203
|
+
record_id: str,
|
|
204
|
+
started_at: str,
|
|
205
|
+
started_epoch: int,
|
|
206
|
+
text: str,
|
|
207
|
+
*,
|
|
208
|
+
post_type: str,
|
|
209
|
+
remote_media_path: str = "",
|
|
210
|
+
link_url: str = "",
|
|
211
|
+
status: str,
|
|
212
|
+
error: str,
|
|
213
|
+
) -> FacebookAdbPublishResult:
|
|
214
|
+
failure_shot = run_dir / f"{status}.png"
|
|
215
|
+
self.adb.screenshot(device.adb_serial, failure_shot)
|
|
216
|
+
return FacebookAdbPublishResult(
|
|
217
|
+
device_id=device.device_id,
|
|
218
|
+
status=status,
|
|
219
|
+
record_id=record_id,
|
|
220
|
+
started_at=started_at,
|
|
221
|
+
ended_at=_now_shanghai(),
|
|
222
|
+
duration_seconds=int(time.time()) - started_epoch,
|
|
223
|
+
post_type=post_type,
|
|
224
|
+
text=text,
|
|
225
|
+
remote_media_path=remote_media_path,
|
|
226
|
+
link_url=link_url,
|
|
227
|
+
screenshot_path=str(failure_shot),
|
|
228
|
+
error=error,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def _prepare_device(self, device: Device) -> None:
|
|
232
|
+
self._ok(self.adb.wake(device.adb_serial), "wake device")
|
|
233
|
+
time.sleep(1)
|
|
234
|
+
self.adb.swipe(device.adb_serial, 540, 1850, 540, 550, 350)
|
|
235
|
+
time.sleep(1)
|
|
236
|
+
|
|
237
|
+
def _open_home(self, device: Device, app_package: str, run_dir: Path) -> None:
|
|
238
|
+
ui.grant_publish_permissions(self.adb, device, app_package)
|
|
239
|
+
self.adb.shell(device.adb_serial, "am", "force-stop", app_package)
|
|
240
|
+
time.sleep(1)
|
|
241
|
+
self._ok(self.adb.launch_package(device.adb_serial, app_package), f"launch {app_package}")
|
|
242
|
+
time.sleep(7)
|
|
243
|
+
self.adb.screenshot(device.adb_serial, run_dir / "home.png")
|
|
244
|
+
if app_package not in self.adb.current_focus(device.adb_serial):
|
|
245
|
+
raise NeedsHumanError(f"Facebook is not foreground after launch; check install/login state for {app_package}")
|
|
246
|
+
|
|
247
|
+
def _open_composer(self, device: Device, run_dir: Path) -> None:
|
|
248
|
+
self._tap(device, 95, 180, 6)
|
|
249
|
+
home_shot = run_dir / "home-ready.png"
|
|
250
|
+
self.adb.screenshot(device.adb_serial, home_shot)
|
|
251
|
+
composer_entry = _locate_home_composer(home_shot) or VisualPoint(540, 480, 0.0, "fallback")
|
|
252
|
+
(run_dir / "composer-entry.txt").write_text(
|
|
253
|
+
f"x={composer_entry.x} y={composer_entry.y} confidence={composer_entry.confidence:.3f} source={composer_entry.source}\n",
|
|
254
|
+
encoding="utf-8",
|
|
255
|
+
)
|
|
256
|
+
self._tap(device, composer_entry.x, composer_entry.y, 4)
|
|
257
|
+
composer_shot = run_dir / "composer.png"
|
|
258
|
+
self.adb.screenshot(device.adb_serial, composer_shot)
|
|
259
|
+
if not self._is_compose_form(device, run_dir / "composer.xml") and not _locate_blue_post_button(composer_shot):
|
|
260
|
+
raise NeedsHumanError("Facebook composer not detected; check login, verification, prompts, or layout calibration")
|
|
261
|
+
|
|
262
|
+
def _input_text(self, device: Device, text: str, run_dir: Path) -> None:
|
|
263
|
+
self._tap(device, 170, 520, 1)
|
|
264
|
+
result = self.adb.input_text(device.adb_serial, text)
|
|
265
|
+
if not result.ok:
|
|
266
|
+
self.adb.screenshot(device.adb_serial, run_dir / "text-input-failed.png")
|
|
267
|
+
raise RuntimeError(f"text input failed: {result.stderr or result.stdout}")
|
|
268
|
+
time.sleep(1)
|
|
269
|
+
self.adb.keyevent(device.adb_serial, "KEYCODE_BACK")
|
|
270
|
+
time.sleep(1)
|
|
271
|
+
self.adb.screenshot(device.adb_serial, run_dir / "after-text.png")
|
|
272
|
+
|
|
273
|
+
def _attach_media(self, device: Device, run_dir: Path, post_type: str) -> None:
|
|
274
|
+
self._tap(device, 220, 1225, 3)
|
|
275
|
+
ui.tap_permission_prompt_if_present(self.adb, device, run_dir / "media-permission.xml")
|
|
276
|
+
picker_xml = run_dir / "media-picker.xml"
|
|
277
|
+
root = self._dump_optional(device, picker_xml)
|
|
278
|
+
if root is not None:
|
|
279
|
+
nodes = ui.sorted_visible_nodes(
|
|
280
|
+
ui.find_nodes(root, ids={"com.facebook.lite:id/gallery_item_image"}, id_suffixes={"gallery_item_image"})
|
|
281
|
+
)
|
|
282
|
+
if nodes:
|
|
283
|
+
ui.tap_node(self.adb, device, nodes[0], sleep_s=3)
|
|
284
|
+
self.adb.screenshot(device.adb_serial, run_dir / "after-media-select.png")
|
|
285
|
+
return
|
|
286
|
+
self.adb.screenshot(device.adb_serial, run_dir / "media-picker-not-found.png")
|
|
287
|
+
raise NeedsHumanError(f"Facebook {post_type} media picker did not expose a selectable gallery item")
|
|
288
|
+
|
|
289
|
+
def _tap_post(self, device: Device, run_dir: Path) -> None:
|
|
290
|
+
post_shot = run_dir / "before-post-locator.png"
|
|
291
|
+
self.adb.screenshot(device.adb_serial, post_shot)
|
|
292
|
+
post_button = _locate_blue_post_button(post_shot)
|
|
293
|
+
(run_dir / "post-button.txt").write_text(
|
|
294
|
+
(
|
|
295
|
+
f"x={post_button.x} y={post_button.y} confidence={post_button.confidence:.3f} source={post_button.source}\n"
|
|
296
|
+
if post_button
|
|
297
|
+
else "not_found\n"
|
|
298
|
+
),
|
|
299
|
+
encoding="utf-8",
|
|
300
|
+
)
|
|
301
|
+
point = post_button or VisualPoint(540, 1845, 0.0, "fallback")
|
|
302
|
+
self._tap(device, point.x, point.y, 0)
|
|
303
|
+
|
|
304
|
+
def _wait_for_publish_complete(self, device: Device, run_dir: Path) -> None:
|
|
305
|
+
last_xml = run_dir / "after-post-poll.xml"
|
|
306
|
+
time.sleep(8)
|
|
307
|
+
for attempt in range(8):
|
|
308
|
+
time.sleep(3)
|
|
309
|
+
poll_shot = run_dir / f"after-post-poll-{attempt + 1}.png"
|
|
310
|
+
self.adb.screenshot(device.adb_serial, poll_shot)
|
|
311
|
+
if not self._is_compose_form(device, last_xml) and not _locate_blue_post_button(poll_shot):
|
|
312
|
+
return
|
|
313
|
+
raise RuntimeError("Facebook still appears to be on the composer after tapping POST")
|
|
314
|
+
|
|
315
|
+
def _is_compose_form(self, device: Device, xml_path: Path) -> bool:
|
|
316
|
+
dump = self.adb.dump_ui(device.adb_serial, xml_path)
|
|
317
|
+
if not dump.ok:
|
|
318
|
+
return False
|
|
319
|
+
try:
|
|
320
|
+
root = ET.parse(xml_path).getroot()
|
|
321
|
+
except ET.ParseError:
|
|
322
|
+
return False
|
|
323
|
+
for node in root.iter("node"):
|
|
324
|
+
if node.attrib.get("clickable") != "true":
|
|
325
|
+
continue
|
|
326
|
+
bounds = node.attrib.get("bounds", "")
|
|
327
|
+
match = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
|
|
328
|
+
if not match:
|
|
329
|
+
continue
|
|
330
|
+
left, top, right, bottom = [int(part) for part in match.groups()]
|
|
331
|
+
if left <= 70 and right >= 1000 and 1760 <= top <= 1820 and 1870 <= bottom <= 1925:
|
|
332
|
+
return True
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
def _dump_optional(self, device: Device, xml_path: Path) -> ET.Element | None:
|
|
336
|
+
dump = self.adb.dump_ui(device.adb_serial, xml_path)
|
|
337
|
+
if not dump.ok:
|
|
338
|
+
return None
|
|
339
|
+
try:
|
|
340
|
+
return ET.parse(xml_path).getroot()
|
|
341
|
+
except ET.ParseError:
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
def _tap(self, device: Device, x: int, y: int, sleep_s: int) -> None:
|
|
345
|
+
self._ok(self.adb.tap(device.adb_serial, x, y), f"tap {x},{y}")
|
|
346
|
+
if sleep_s:
|
|
347
|
+
time.sleep(sleep_s)
|
|
348
|
+
|
|
349
|
+
@staticmethod
|
|
350
|
+
def _ok(result, action: str) -> None:
|
|
351
|
+
if not result.ok:
|
|
352
|
+
raise RuntimeError(f"{action} failed: {result.stderr or result.stdout}")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _require_adb_text_safe(value: str, field_name: str, *, required: bool = True) -> None:
|
|
356
|
+
if required and not value.strip():
|
|
357
|
+
raise ValueError(f"{field_name} is required")
|
|
358
|
+
if not value:
|
|
359
|
+
return
|
|
360
|
+
if "\n" in value or "\r" in value or "\t" in value:
|
|
361
|
+
raise ValueError(f"{field_name} must be a single line for the current Facebook Lite POC")
|
|
362
|
+
if not value.isascii():
|
|
363
|
+
raise ValueError(f"{field_name} must be ASCII for the current Facebook Lite POC")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _normalize_post_type(value: str) -> str:
|
|
367
|
+
text = value.strip().lower().replace("_", "-")
|
|
368
|
+
if text in {"text", "link", "image", "photo", "video"}:
|
|
369
|
+
return "image" if text == "photo" else text
|
|
370
|
+
raise ValueError("Facebook post_type must be one of: text, link, image, video")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _facebook_text_body(*, text: str, link_url: str) -> str:
|
|
374
|
+
if text and link_url:
|
|
375
|
+
return f"{text} {link_url}"
|
|
376
|
+
return text or link_url
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _locate_home_composer(image_path: Path) -> VisualPoint | None:
|
|
380
|
+
return _locate_wide_band(
|
|
381
|
+
image_path,
|
|
382
|
+
x_range=(0.12, 0.9),
|
|
383
|
+
y_range=(0.14, 0.32),
|
|
384
|
+
min_width_ratio=0.42,
|
|
385
|
+
color_predicate=_is_light_neutral_gray,
|
|
386
|
+
source="visual_home_composer",
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _locate_blue_post_button(image_path: Path) -> VisualPoint | None:
|
|
391
|
+
return _locate_wide_band(
|
|
392
|
+
image_path,
|
|
393
|
+
x_range=(0.02, 0.98),
|
|
394
|
+
y_range=(0.6, 0.9),
|
|
395
|
+
min_width_ratio=0.58,
|
|
396
|
+
color_predicate=_is_facebook_blue,
|
|
397
|
+
source="visual_post_button",
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _locate_wide_band(
|
|
402
|
+
image_path: Path,
|
|
403
|
+
*,
|
|
404
|
+
x_range: tuple[float, float],
|
|
405
|
+
y_range: tuple[float, float],
|
|
406
|
+
min_width_ratio: float,
|
|
407
|
+
color_predicate,
|
|
408
|
+
source: str,
|
|
409
|
+
) -> VisualPoint | None:
|
|
410
|
+
try:
|
|
411
|
+
from PIL import Image
|
|
412
|
+
except Exception:
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
image = Image.open(image_path).convert("RGB")
|
|
417
|
+
except Exception:
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
width, height = image.size
|
|
421
|
+
pixels = image.load()
|
|
422
|
+
x0 = max(0, int(width * x_range[0]))
|
|
423
|
+
x1 = min(width, int(width * x_range[1]))
|
|
424
|
+
y0 = max(0, int(height * y_range[0]))
|
|
425
|
+
y1 = min(height, int(height * y_range[1]))
|
|
426
|
+
min_width = int(width * min_width_ratio)
|
|
427
|
+
|
|
428
|
+
rows: list[tuple[int, int, int, int]] = []
|
|
429
|
+
for y in range(y0, y1, 2):
|
|
430
|
+
best_start = -1
|
|
431
|
+
best_end = -1
|
|
432
|
+
run_start = -1
|
|
433
|
+
for x in range(x0, x1):
|
|
434
|
+
if color_predicate(pixels[x, y]):
|
|
435
|
+
if run_start < 0:
|
|
436
|
+
run_start = x
|
|
437
|
+
continue
|
|
438
|
+
if run_start >= 0:
|
|
439
|
+
if x - run_start > best_end - best_start:
|
|
440
|
+
best_start = run_start
|
|
441
|
+
best_end = x
|
|
442
|
+
run_start = -1
|
|
443
|
+
if run_start >= 0 and x1 - run_start > best_end - best_start:
|
|
444
|
+
best_start = run_start
|
|
445
|
+
best_end = x1
|
|
446
|
+
best_width = best_end - best_start
|
|
447
|
+
if best_width >= min_width:
|
|
448
|
+
rows.append((y, best_start, best_end, best_width))
|
|
449
|
+
|
|
450
|
+
if not rows:
|
|
451
|
+
return None
|
|
452
|
+
|
|
453
|
+
groups: list[list[tuple[int, int, int, int]]] = []
|
|
454
|
+
for row in rows:
|
|
455
|
+
if not groups or row[0] - groups[-1][-1][0] > 4:
|
|
456
|
+
groups.append([row])
|
|
457
|
+
else:
|
|
458
|
+
groups[-1].append(row)
|
|
459
|
+
|
|
460
|
+
best_group: list[tuple[int, int, int, int]] | None = None
|
|
461
|
+
best_score = 0
|
|
462
|
+
for group in groups:
|
|
463
|
+
band_height = group[-1][0] - group[0][0] + 2
|
|
464
|
+
avg_width = sum(row[3] for row in group) / len(group)
|
|
465
|
+
if band_height < 20:
|
|
466
|
+
continue
|
|
467
|
+
score = int(avg_width * band_height)
|
|
468
|
+
if score > best_score:
|
|
469
|
+
best_score = score
|
|
470
|
+
best_group = group
|
|
471
|
+
|
|
472
|
+
if not best_group:
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
weighted_x = sum(((row[1] + row[2]) / 2) * row[3] for row in best_group)
|
|
476
|
+
total_width = sum(row[3] for row in best_group)
|
|
477
|
+
center_x = int(weighted_x / total_width)
|
|
478
|
+
center_y = int((best_group[0][0] + best_group[-1][0]) / 2)
|
|
479
|
+
confidence = min(1.0, best_score / max(1, (width * (y1 - y0) * 0.2)))
|
|
480
|
+
return VisualPoint(center_x, center_y, confidence, source)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _is_light_neutral_gray(rgb: tuple[int, int, int]) -> bool:
|
|
484
|
+
red, green, blue = rgb
|
|
485
|
+
return 220 <= red <= 248 and 220 <= green <= 248 and 220 <= blue <= 248 and max(rgb) - min(rgb) <= 10
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _is_facebook_blue(rgb: tuple[int, int, int]) -> bool:
|
|
489
|
+
red, green, blue = rgb
|
|
490
|
+
return red <= 80 and 70 <= green <= 170 and blue >= 180 and blue - red >= 110 and blue - green >= 50
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _now_shanghai() -> str:
|
|
494
|
+
return datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S %z")
|