@11agents/cli 0.1.23 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +63 -28
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from zoneinfo import ZoneInfo
|
|
11
|
+
|
|
12
|
+
from device_control.adb import AdbClient
|
|
13
|
+
from device_control.appium_client import AppiumClient
|
|
14
|
+
from device_control.models import Device, PublishRecord, utc_now_iso
|
|
15
|
+
from device_control.publishers import ui_helpers as ui
|
|
16
|
+
from device_control.publishers.tiktok_adb import TikTokAdbPublisher
|
|
17
|
+
from device_control.store import append_publish_record
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
XIAOHONGSHU_PACKAGE = "com.xingin.xhs"
|
|
21
|
+
|
|
22
|
+
DEFAULT_FLOW_CONFIG = {
|
|
23
|
+
"package": XIAOHONGSHU_PACKAGE,
|
|
24
|
+
"coords": {
|
|
25
|
+
"unlock_swipe": [540, 1850, 540, 550, 350],
|
|
26
|
+
"home_plus": [540, 2230],
|
|
27
|
+
"album_drawer_first": [540, 1695],
|
|
28
|
+
"latest_video_select": [1025, 425],
|
|
29
|
+
"latest_image_select": [1025, 425],
|
|
30
|
+
"multi_image_select": [
|
|
31
|
+
[1028, 424],
|
|
32
|
+
[665, 424],
|
|
33
|
+
[302, 424],
|
|
34
|
+
[1028, 787],
|
|
35
|
+
[665, 787],
|
|
36
|
+
[302, 787],
|
|
37
|
+
[1028, 1150],
|
|
38
|
+
],
|
|
39
|
+
"gallery_next": [800, 2240],
|
|
40
|
+
"editor_next": [885, 2220],
|
|
41
|
+
"first_publish": [945, 180],
|
|
42
|
+
"confirm_publish": [725, 2200],
|
|
43
|
+
"bottom_me": [972, 2230],
|
|
44
|
+
"profile_latest_note": [270, 1220],
|
|
45
|
+
"post_publish_prompt_close": [1020, 1200],
|
|
46
|
+
"note_share": [988, 186],
|
|
47
|
+
"copy_link": [137, 2142],
|
|
48
|
+
"comment_input": [421, 374],
|
|
49
|
+
},
|
|
50
|
+
"selectors": {
|
|
51
|
+
"title": f"{XIAOHONGSHU_PACKAGE}:id/editTitle",
|
|
52
|
+
"caption": f"{XIAOHONGSHU_PACKAGE}:id/postNoteEditContentView",
|
|
53
|
+
},
|
|
54
|
+
"waits": {
|
|
55
|
+
"after_wake": 1,
|
|
56
|
+
"after_unlock": 1,
|
|
57
|
+
"after_launch": 5,
|
|
58
|
+
"after_plus": 1,
|
|
59
|
+
"after_album_entry": 3,
|
|
60
|
+
"after_latest_video_select": 1,
|
|
61
|
+
"after_gallery_next": 4,
|
|
62
|
+
"after_editor_next": 4,
|
|
63
|
+
"after_title_click": 1,
|
|
64
|
+
"after_caption_click": 1,
|
|
65
|
+
"after_caption_input": 1,
|
|
66
|
+
"after_first_publish": 2,
|
|
67
|
+
"after_confirm_publish": 2,
|
|
68
|
+
"after_prompt_dismiss": 1,
|
|
69
|
+
"after_me_tab": 3,
|
|
70
|
+
"after_latest_note_tap": 3,
|
|
71
|
+
"after_share_tap": 1,
|
|
72
|
+
"after_copy_link": 1,
|
|
73
|
+
"after_comment_input_tap": 1,
|
|
74
|
+
"after_paste_probe": 1,
|
|
75
|
+
"detail_load_timeout": 12,
|
|
76
|
+
"publish_poll_interval": 2,
|
|
77
|
+
"publish_timeout": 45,
|
|
78
|
+
},
|
|
79
|
+
"success_focus_keywords": ["IndexActivityV2"],
|
|
80
|
+
"detail_focus_keywords": ["NoteDetailActivity"],
|
|
81
|
+
"post_form_focus_keywords": ["CapaPostNotePlatformActivity"],
|
|
82
|
+
"in_progress_focus_keywords": [
|
|
83
|
+
"CapaPostNotePlatformActivity",
|
|
84
|
+
"VideoEditActivity",
|
|
85
|
+
"CapaAlbumActivity",
|
|
86
|
+
],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
FIELD_PLACEHOLDERS = {
|
|
90
|
+
"title": {
|
|
91
|
+
"exact": {"添加标题"},
|
|
92
|
+
"partial": set(),
|
|
93
|
+
"index": 0,
|
|
94
|
+
},
|
|
95
|
+
"caption": {
|
|
96
|
+
"exact": {"添加正文或发语音"},
|
|
97
|
+
"partial": {"添加正文"},
|
|
98
|
+
"index": 1,
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class XiaohongshuAdbPublishResult:
|
|
105
|
+
device_id: str
|
|
106
|
+
status: str
|
|
107
|
+
record_id: str
|
|
108
|
+
started_at: str
|
|
109
|
+
ended_at: str
|
|
110
|
+
duration_seconds: int
|
|
111
|
+
post_type: str
|
|
112
|
+
remote_media_path: str
|
|
113
|
+
screenshot_path: str
|
|
114
|
+
error: str = ""
|
|
115
|
+
platform_permalink: str = ""
|
|
116
|
+
link_status: str = ""
|
|
117
|
+
link_error: str = ""
|
|
118
|
+
link_screenshot_path: str = ""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class XiaohongshuLinkResult:
|
|
123
|
+
device_id: str
|
|
124
|
+
status: str
|
|
125
|
+
started_at: str
|
|
126
|
+
ended_at: str
|
|
127
|
+
duration_seconds: int
|
|
128
|
+
platform_permalink: str
|
|
129
|
+
screenshot_path: str
|
|
130
|
+
clipboard_method: str = ""
|
|
131
|
+
error: str = ""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class XiaohongshuAdbPublisher:
|
|
135
|
+
"""Xiaohongshu image/video publisher for a configured device flow."""
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
adb: AdbClient,
|
|
140
|
+
*,
|
|
141
|
+
appium_server: str = "http://127.0.0.1:4723",
|
|
142
|
+
records_path: str | Path = "data/publish_records.jsonl",
|
|
143
|
+
artifact_root: str | Path = "artifacts/screenshots",
|
|
144
|
+
flow_config_path: str | Path = "configs/platforms/xiaohongshu_d03.json",
|
|
145
|
+
) -> None:
|
|
146
|
+
self.adb = adb
|
|
147
|
+
self.appium_server = appium_server
|
|
148
|
+
self.records_path = Path(records_path)
|
|
149
|
+
self.artifact_root = Path(artifact_root)
|
|
150
|
+
self.flow_config = _load_flow_config(flow_config_path)
|
|
151
|
+
self.package = str(self.flow_config.get("package") or XIAOHONGSHU_PACKAGE)
|
|
152
|
+
self.media_helper = TikTokAdbPublisher(adb, records_path=records_path, artifact_root=artifact_root)
|
|
153
|
+
|
|
154
|
+
def publish_video(
|
|
155
|
+
self,
|
|
156
|
+
device: Device,
|
|
157
|
+
*,
|
|
158
|
+
video_path: str | Path,
|
|
159
|
+
account_id: str,
|
|
160
|
+
title: str,
|
|
161
|
+
caption: str = "",
|
|
162
|
+
tags: list[str] | None = None,
|
|
163
|
+
topics: list[str] | None = None,
|
|
164
|
+
dry_run: bool = False,
|
|
165
|
+
confirm_no_disclosure_needed: bool = False,
|
|
166
|
+
copy_link_after_publish: bool = False,
|
|
167
|
+
resume_post_form: bool = False,
|
|
168
|
+
) -> XiaohongshuAdbPublishResult:
|
|
169
|
+
return self.publish_note(
|
|
170
|
+
device,
|
|
171
|
+
media_path=video_path,
|
|
172
|
+
post_type="video",
|
|
173
|
+
account_id=account_id,
|
|
174
|
+
title=title,
|
|
175
|
+
caption=caption,
|
|
176
|
+
tags=tags,
|
|
177
|
+
topics=topics,
|
|
178
|
+
dry_run=dry_run,
|
|
179
|
+
confirm_no_disclosure_needed=confirm_no_disclosure_needed,
|
|
180
|
+
copy_link_after_publish=copy_link_after_publish,
|
|
181
|
+
resume_post_form=resume_post_form,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def publish_image(
|
|
185
|
+
self,
|
|
186
|
+
device: Device,
|
|
187
|
+
*,
|
|
188
|
+
image_path: str | Path,
|
|
189
|
+
account_id: str,
|
|
190
|
+
title: str,
|
|
191
|
+
caption: str = "",
|
|
192
|
+
tags: list[str] | None = None,
|
|
193
|
+
topics: list[str] | None = None,
|
|
194
|
+
dry_run: bool = False,
|
|
195
|
+
confirm_no_disclosure_needed: bool = False,
|
|
196
|
+
copy_link_after_publish: bool = False,
|
|
197
|
+
resume_post_form: bool = False,
|
|
198
|
+
) -> XiaohongshuAdbPublishResult:
|
|
199
|
+
return self.publish_note(
|
|
200
|
+
device,
|
|
201
|
+
media_path=image_path,
|
|
202
|
+
post_type="image",
|
|
203
|
+
account_id=account_id,
|
|
204
|
+
title=title,
|
|
205
|
+
caption=caption,
|
|
206
|
+
tags=tags,
|
|
207
|
+
topics=topics,
|
|
208
|
+
dry_run=dry_run,
|
|
209
|
+
confirm_no_disclosure_needed=confirm_no_disclosure_needed,
|
|
210
|
+
copy_link_after_publish=copy_link_after_publish,
|
|
211
|
+
resume_post_form=resume_post_form,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def publish_note(
|
|
215
|
+
self,
|
|
216
|
+
device: Device,
|
|
217
|
+
*,
|
|
218
|
+
media_path: str | Path,
|
|
219
|
+
media_paths: list[str | Path] | None = None,
|
|
220
|
+
post_type: str,
|
|
221
|
+
account_id: str,
|
|
222
|
+
title: str,
|
|
223
|
+
caption: str = "",
|
|
224
|
+
tags: list[str] | None = None,
|
|
225
|
+
topics: list[str] | None = None,
|
|
226
|
+
dry_run: bool = False,
|
|
227
|
+
confirm_no_disclosure_needed: bool = False,
|
|
228
|
+
copy_link_after_publish: bool = False,
|
|
229
|
+
resume_post_form: bool = False,
|
|
230
|
+
) -> XiaohongshuAdbPublishResult:
|
|
231
|
+
post_type = _normalize_post_type(post_type)
|
|
232
|
+
locals_ = [Path(item).expanduser() for item in (media_paths or [media_path])]
|
|
233
|
+
if not locals_:
|
|
234
|
+
raise ValueError("at least one media path is required")
|
|
235
|
+
if post_type == "video" and len(locals_) != 1:
|
|
236
|
+
raise ValueError("Xiaohongshu video notes support exactly one video")
|
|
237
|
+
if post_type == "image" and len(locals_) > len(self._multi_image_coords()):
|
|
238
|
+
raise ValueError(f"Xiaohongshu image notes support up to {len(self._multi_image_coords())} images in current flow config")
|
|
239
|
+
for local in locals_:
|
|
240
|
+
if not local.exists():
|
|
241
|
+
raise FileNotFoundError(f"media not found: {local}")
|
|
242
|
+
if ui.media_kind_from_path(local) != post_type:
|
|
243
|
+
raise ValueError(f"post_type={post_type} does not match media file suffix: {local}")
|
|
244
|
+
tag_values = _normalize_hashtag_values(tags or [])
|
|
245
|
+
topic_values = _normalize_hashtag_values(topics or [])
|
|
246
|
+
|
|
247
|
+
started_epoch = int(time.time())
|
|
248
|
+
started_at = _now_shanghai()
|
|
249
|
+
stamp = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d-%H%M%S")
|
|
250
|
+
run_dir = self.artifact_root / f"{device.device_id}-xiaohongshu-{stamp}"
|
|
251
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
252
|
+
remote_paths = [
|
|
253
|
+
f"/sdcard/DCIM/Camera/groupctl-{device.device_id}-xhs-{stamp}-{idx:02d}{local.suffix}"
|
|
254
|
+
for idx, local in enumerate(locals_, start=1)
|
|
255
|
+
]
|
|
256
|
+
remote = "current_post_form" if resume_post_form else ",".join(remote_paths)
|
|
257
|
+
record_id = f"pub_{stamp.replace('-', '')}_{device.device_id.lower()}_xiaohongshu"
|
|
258
|
+
link_result: XiaohongshuLinkResult | None = None
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
self._prepare_device(device)
|
|
262
|
+
if resume_post_form:
|
|
263
|
+
self._require_current_post_form(device)
|
|
264
|
+
self.adb.screenshot(device.adb_serial, run_dir / "post-form-resume.png")
|
|
265
|
+
else:
|
|
266
|
+
push_pairs = list(zip(locals_, remote_paths))
|
|
267
|
+
if post_type == "image" and len(push_pairs) > 1:
|
|
268
|
+
push_pairs = list(reversed(push_pairs))
|
|
269
|
+
for local, remote_path in push_pairs:
|
|
270
|
+
self.media_helper._push_media(device, local, remote_path)
|
|
271
|
+
self._launch_xiaohongshu(device)
|
|
272
|
+
self._open_album_flow(device, run_dir)
|
|
273
|
+
self._select_latest_media(device, run_dir, post_type, count=len(locals_))
|
|
274
|
+
self._next_from_gallery(device, run_dir)
|
|
275
|
+
self._next_from_editor(device, run_dir)
|
|
276
|
+
self._set_title_and_caption(device, title, caption, tag_values, topic_values, run_dir)
|
|
277
|
+
|
|
278
|
+
if dry_run:
|
|
279
|
+
final_shot = run_dir / "dry-run-post-form.png"
|
|
280
|
+
self.adb.screenshot(device.adb_serial, final_shot)
|
|
281
|
+
status = "dry_run"
|
|
282
|
+
else:
|
|
283
|
+
self._tap_first_publish(device)
|
|
284
|
+
self.adb.screenshot(device.adb_serial, run_dir / "disclosure-prompt.png")
|
|
285
|
+
if not confirm_no_disclosure_needed:
|
|
286
|
+
return XiaohongshuAdbPublishResult(
|
|
287
|
+
device_id=device.device_id,
|
|
288
|
+
status="needs_human",
|
|
289
|
+
record_id=record_id,
|
|
290
|
+
started_at=started_at,
|
|
291
|
+
ended_at=_now_shanghai(),
|
|
292
|
+
duration_seconds=int(time.time()) - started_epoch,
|
|
293
|
+
post_type=post_type,
|
|
294
|
+
remote_media_path=remote,
|
|
295
|
+
screenshot_path=str(run_dir / "disclosure-prompt.png"),
|
|
296
|
+
error="content disclosure prompt needs explicit confirmation",
|
|
297
|
+
)
|
|
298
|
+
self._tap_confirm_publish(device)
|
|
299
|
+
time.sleep(self._wait("after_confirm_publish"))
|
|
300
|
+
final_shot = self._wait_for_publish_complete(device, run_dir)
|
|
301
|
+
status = "published"
|
|
302
|
+
if copy_link_after_publish:
|
|
303
|
+
link_result = self.copy_current_note_link(device, prepare=False, run_dir=run_dir / "link")
|
|
304
|
+
|
|
305
|
+
ended_epoch = int(time.time())
|
|
306
|
+
if status == "published":
|
|
307
|
+
permalink = link_result.platform_permalink if link_result else ""
|
|
308
|
+
append_publish_record(
|
|
309
|
+
self.records_path,
|
|
310
|
+
PublishRecord(
|
|
311
|
+
record_id=record_id,
|
|
312
|
+
platform="xiaohongshu",
|
|
313
|
+
account_id=account_id,
|
|
314
|
+
device_id=device.device_id,
|
|
315
|
+
post_type=post_type,
|
|
316
|
+
local_media_path=",".join(str(item) for item in locals_),
|
|
317
|
+
remote_media_path=remote,
|
|
318
|
+
platform_permalink=permalink,
|
|
319
|
+
caption=_caption(title, _compose_caption_for_record(caption, tag_values, topic_values)),
|
|
320
|
+
published_at=utc_now_iso(),
|
|
321
|
+
result_screenshot_path=str(final_shot),
|
|
322
|
+
status="published",
|
|
323
|
+
),
|
|
324
|
+
)
|
|
325
|
+
return XiaohongshuAdbPublishResult(
|
|
326
|
+
device_id=device.device_id,
|
|
327
|
+
status=status,
|
|
328
|
+
record_id=record_id,
|
|
329
|
+
started_at=started_at,
|
|
330
|
+
ended_at=_now_shanghai(),
|
|
331
|
+
duration_seconds=ended_epoch - started_epoch,
|
|
332
|
+
post_type=post_type,
|
|
333
|
+
remote_media_path=remote,
|
|
334
|
+
screenshot_path=str(final_shot),
|
|
335
|
+
platform_permalink=link_result.platform_permalink if link_result else "",
|
|
336
|
+
link_status=link_result.status if link_result else "",
|
|
337
|
+
link_error=link_result.error if link_result else "",
|
|
338
|
+
link_screenshot_path=link_result.screenshot_path if link_result else "",
|
|
339
|
+
)
|
|
340
|
+
except Exception as exc:
|
|
341
|
+
failure_shot = run_dir / "failed.png"
|
|
342
|
+
self.adb.screenshot(device.adb_serial, failure_shot)
|
|
343
|
+
return XiaohongshuAdbPublishResult(
|
|
344
|
+
device_id=device.device_id,
|
|
345
|
+
status="failed",
|
|
346
|
+
record_id=record_id,
|
|
347
|
+
started_at=started_at,
|
|
348
|
+
ended_at=_now_shanghai(),
|
|
349
|
+
duration_seconds=int(time.time()) - started_epoch,
|
|
350
|
+
post_type=post_type,
|
|
351
|
+
remote_media_path=remote,
|
|
352
|
+
screenshot_path=str(failure_shot),
|
|
353
|
+
error=str(exc),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
def copy_current_note_link(
|
|
357
|
+
self,
|
|
358
|
+
device: Device,
|
|
359
|
+
*,
|
|
360
|
+
prepare: bool = True,
|
|
361
|
+
run_dir: Path | None = None,
|
|
362
|
+
) -> XiaohongshuLinkResult:
|
|
363
|
+
started_epoch = int(time.time())
|
|
364
|
+
started_at = _now_shanghai()
|
|
365
|
+
if run_dir is None:
|
|
366
|
+
stamp = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d-%H%M%S")
|
|
367
|
+
run_dir = self.artifact_root / f"{device.device_id}-xiaohongshu-link-{stamp}"
|
|
368
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
if prepare:
|
|
372
|
+
self._prepare_device(device)
|
|
373
|
+
else:
|
|
374
|
+
self._ok(self.adb.wake(device.adb_serial), "wake device")
|
|
375
|
+
focus = self.adb.current_focus(device.adb_serial)
|
|
376
|
+
if not any(keyword in focus for keyword in self._keywords("detail_focus_keywords")):
|
|
377
|
+
self._open_latest_own_note_for_link(device, run_dir)
|
|
378
|
+
self.adb.screenshot(device.adb_serial, run_dir / "before-copy-link.png")
|
|
379
|
+
self._tap_coord(device, "note_share", self._wait("after_share_tap"))
|
|
380
|
+
self.adb.screenshot(device.adb_serial, run_dir / "share-sheet.png")
|
|
381
|
+
self._tap_copy_link(device, run_dir)
|
|
382
|
+
time.sleep(self._wait("after_copy_link"))
|
|
383
|
+
final_shot = run_dir / "after-copy-link.png"
|
|
384
|
+
self.adb.screenshot(device.adb_serial, final_shot)
|
|
385
|
+
|
|
386
|
+
permalink, method, error = self._read_copied_link(device, run_dir)
|
|
387
|
+
if permalink:
|
|
388
|
+
status = "retrieved"
|
|
389
|
+
else:
|
|
390
|
+
status = "needs_human"
|
|
391
|
+
error = error or "copied link to device clipboard, but automatic clipboard read returned no URL"
|
|
392
|
+
return XiaohongshuLinkResult(
|
|
393
|
+
device_id=device.device_id,
|
|
394
|
+
status=status,
|
|
395
|
+
started_at=started_at,
|
|
396
|
+
ended_at=_now_shanghai(),
|
|
397
|
+
duration_seconds=int(time.time()) - started_epoch,
|
|
398
|
+
platform_permalink=permalink,
|
|
399
|
+
screenshot_path=str(final_shot),
|
|
400
|
+
clipboard_method=method,
|
|
401
|
+
error=error,
|
|
402
|
+
)
|
|
403
|
+
except Exception as exc:
|
|
404
|
+
failure_shot = run_dir / "failed-copy-link.png"
|
|
405
|
+
self.adb.screenshot(device.adb_serial, failure_shot)
|
|
406
|
+
return XiaohongshuLinkResult(
|
|
407
|
+
device_id=device.device_id,
|
|
408
|
+
status="failed",
|
|
409
|
+
started_at=started_at,
|
|
410
|
+
ended_at=_now_shanghai(),
|
|
411
|
+
duration_seconds=int(time.time()) - started_epoch,
|
|
412
|
+
platform_permalink="",
|
|
413
|
+
screenshot_path=str(failure_shot),
|
|
414
|
+
error=str(exc),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
def _prepare_device(self, device: Device) -> None:
|
|
418
|
+
self._ok(self.adb.wake(device.adb_serial), "wake device")
|
|
419
|
+
time.sleep(self._wait("after_wake"))
|
|
420
|
+
x1, y1, x2, y2, duration_ms = self._coord("unlock_swipe")
|
|
421
|
+
self.adb.swipe(device.adb_serial, x1, y1, x2, y2, duration_ms)
|
|
422
|
+
time.sleep(self._wait("after_unlock"))
|
|
423
|
+
|
|
424
|
+
def _launch_xiaohongshu(self, device: Device) -> None:
|
|
425
|
+
ui.grant_publish_permissions(self.adb, device, self.package)
|
|
426
|
+
self._ok(self.adb.launch_package(device.adb_serial, self.package), "launch Xiaohongshu")
|
|
427
|
+
time.sleep(self._wait("after_launch"))
|
|
428
|
+
|
|
429
|
+
def _open_album_flow(self, device: Device, run_dir: Path) -> None:
|
|
430
|
+
self._tap_coord(device, "home_plus", self._wait("after_plus"))
|
|
431
|
+
ui.tap_permission_prompt_if_present(self.adb, device, run_dir / "after-plus-permission.xml")
|
|
432
|
+
self.adb.screenshot(device.adb_serial, run_dir / "after-plus.png")
|
|
433
|
+
self._tap_coord(device, "album_drawer_first", self._wait("after_album_entry"))
|
|
434
|
+
ui.tap_permission_prompt_if_present(self.adb, device, run_dir / "gallery-permission.xml")
|
|
435
|
+
self.adb.screenshot(device.adb_serial, run_dir / "gallery.png")
|
|
436
|
+
|
|
437
|
+
def _select_latest_media(self, device: Device, run_dir: Path, post_type: str, *, count: int = 1) -> None:
|
|
438
|
+
if post_type == "image" and count > 1:
|
|
439
|
+
for x, y in self._multi_image_coords()[:count]:
|
|
440
|
+
self._tap(device, x, y, self._wait("after_latest_video_select"))
|
|
441
|
+
self.adb.screenshot(device.adb_serial, run_dir / "gallery-selected.png")
|
|
442
|
+
return
|
|
443
|
+
coord_name = "latest_image_select" if post_type == "image" else "latest_video_select"
|
|
444
|
+
self._tap_coord(device, coord_name, self._wait("after_latest_video_select"))
|
|
445
|
+
self.adb.screenshot(device.adb_serial, run_dir / "gallery-selected.png")
|
|
446
|
+
|
|
447
|
+
def _next_from_gallery(self, device: Device, run_dir: Path) -> None:
|
|
448
|
+
self._tap_coord(device, "gallery_next", self._wait("after_gallery_next"))
|
|
449
|
+
self.adb.screenshot(device.adb_serial, run_dir / "editor.png")
|
|
450
|
+
|
|
451
|
+
def _next_from_editor(self, device: Device, run_dir: Path) -> None:
|
|
452
|
+
self._tap_coord(device, "editor_next", self._wait("after_editor_next"))
|
|
453
|
+
self.adb.screenshot(device.adb_serial, run_dir / "post-form.png")
|
|
454
|
+
|
|
455
|
+
def _set_title_and_caption(
|
|
456
|
+
self,
|
|
457
|
+
device: Device,
|
|
458
|
+
title: str,
|
|
459
|
+
caption: str,
|
|
460
|
+
tags: list[str],
|
|
461
|
+
topics: list[str],
|
|
462
|
+
run_dir: Path,
|
|
463
|
+
) -> None:
|
|
464
|
+
appium = AppiumClient(self.appium_server)
|
|
465
|
+
try:
|
|
466
|
+
appium.start_session(_appium_capabilities(device.adb_serial))
|
|
467
|
+
self._set_text_field(appium, device, "title", title, run_dir, sleep_after_click=self._wait("after_title_click"))
|
|
468
|
+
caption_field_id = ""
|
|
469
|
+
if caption:
|
|
470
|
+
caption_field_id = self._set_text_field(
|
|
471
|
+
appium,
|
|
472
|
+
device,
|
|
473
|
+
"caption",
|
|
474
|
+
caption,
|
|
475
|
+
run_dir,
|
|
476
|
+
sleep_after_click=self._wait("after_caption_click"),
|
|
477
|
+
)
|
|
478
|
+
manual_tags = _format_manual_hashtags([*tags, *topics], existing=caption)
|
|
479
|
+
if manual_tags:
|
|
480
|
+
if not caption_field_id:
|
|
481
|
+
caption_field_id = self._focus_text_field(
|
|
482
|
+
appium,
|
|
483
|
+
device,
|
|
484
|
+
"caption",
|
|
485
|
+
run_dir,
|
|
486
|
+
sleep_after_click=self._wait("after_caption_click"),
|
|
487
|
+
)
|
|
488
|
+
if caption:
|
|
489
|
+
self._ok(self.adb.keyevent(device.adb_serial, "KEYCODE_MOVE_END"), "move caption cursor to end")
|
|
490
|
+
time.sleep(0.2)
|
|
491
|
+
self._input_hashtags_manually(appium, device, caption_field_id, manual_tags, has_caption=bool(caption))
|
|
492
|
+
time.sleep(self._wait("after_caption_input"))
|
|
493
|
+
self.adb.screenshot(device.adb_serial, run_dir / "after-caption.png")
|
|
494
|
+
finally:
|
|
495
|
+
try:
|
|
496
|
+
appium.delete_session()
|
|
497
|
+
except Exception:
|
|
498
|
+
pass
|
|
499
|
+
|
|
500
|
+
def _set_text_field(
|
|
501
|
+
self,
|
|
502
|
+
appium: AppiumClient,
|
|
503
|
+
device: Device,
|
|
504
|
+
name: str,
|
|
505
|
+
text: str,
|
|
506
|
+
run_dir: Path,
|
|
507
|
+
*,
|
|
508
|
+
sleep_after_click: float,
|
|
509
|
+
) -> str:
|
|
510
|
+
selector = self._selector(name)
|
|
511
|
+
field_id = ""
|
|
512
|
+
errors: list[str] = []
|
|
513
|
+
try:
|
|
514
|
+
field_id = self._find_and_click_text_field(appium, name, selector, errors)
|
|
515
|
+
except Exception as exc:
|
|
516
|
+
errors.append(f"appium_find_click failed: {_short(str(exc))}")
|
|
517
|
+
|
|
518
|
+
if not field_id and not self._tap_text_field_from_ui_dump(device, name, selector, run_dir):
|
|
519
|
+
raise RuntimeError(f"{name} input field not found: {'; '.join(errors)}")
|
|
520
|
+
|
|
521
|
+
time.sleep(sleep_after_click)
|
|
522
|
+
if field_id:
|
|
523
|
+
try:
|
|
524
|
+
appium.clear(field_id)
|
|
525
|
+
except Exception as exc:
|
|
526
|
+
errors.append(f"appium_clear failed: {_short(str(exc))}")
|
|
527
|
+
|
|
528
|
+
try:
|
|
529
|
+
appium.set_clipboard(text)
|
|
530
|
+
self._ok(self.adb.keyevent(device.adb_serial, "KEYCODE_PASTE"), f"paste {name}")
|
|
531
|
+
time.sleep(0.5)
|
|
532
|
+
return field_id
|
|
533
|
+
except Exception as exc:
|
|
534
|
+
errors.append(f"clipboard_paste failed: {_short(str(exc))}")
|
|
535
|
+
|
|
536
|
+
if field_id:
|
|
537
|
+
try:
|
|
538
|
+
appium.send_keys(field_id, text)
|
|
539
|
+
time.sleep(0.5)
|
|
540
|
+
return field_id
|
|
541
|
+
except Exception as exc:
|
|
542
|
+
errors.append(f"appium_send_keys failed: {_short(str(exc))}")
|
|
543
|
+
|
|
544
|
+
raise RuntimeError(f"{name} input failed: {'; '.join(errors)}")
|
|
545
|
+
|
|
546
|
+
def _focus_text_field(
|
|
547
|
+
self,
|
|
548
|
+
appium: AppiumClient,
|
|
549
|
+
device: Device,
|
|
550
|
+
name: str,
|
|
551
|
+
run_dir: Path,
|
|
552
|
+
*,
|
|
553
|
+
sleep_after_click: float,
|
|
554
|
+
) -> str:
|
|
555
|
+
selector = self._selector(name)
|
|
556
|
+
errors: list[str] = []
|
|
557
|
+
field_id = self._find_and_click_text_field(appium, name, selector, errors)
|
|
558
|
+
if not field_id and not self._tap_text_field_from_ui_dump(device, name, selector, run_dir):
|
|
559
|
+
raise RuntimeError(f"{name} input field not found for manual tags: {'; '.join(errors)}")
|
|
560
|
+
time.sleep(sleep_after_click)
|
|
561
|
+
if field_id:
|
|
562
|
+
return field_id
|
|
563
|
+
field_id = self._find_and_click_text_field(appium, name, selector, errors)
|
|
564
|
+
if not field_id:
|
|
565
|
+
raise RuntimeError(f"{name} input field id not available for manual tags: {'; '.join(errors)}")
|
|
566
|
+
return field_id
|
|
567
|
+
|
|
568
|
+
def _input_hashtags_manually(
|
|
569
|
+
self,
|
|
570
|
+
appium: AppiumClient,
|
|
571
|
+
device: Device,
|
|
572
|
+
field_id: str,
|
|
573
|
+
hashtags: list[str],
|
|
574
|
+
*,
|
|
575
|
+
has_caption: bool,
|
|
576
|
+
) -> None:
|
|
577
|
+
if has_caption:
|
|
578
|
+
self._ok(self.adb.keyevent(device.adb_serial, "KEYCODE_ENTER"), "insert tag separator newline")
|
|
579
|
+
self._ok(self.adb.keyevent(device.adb_serial, "KEYCODE_ENTER"), "insert tag separator newline")
|
|
580
|
+
time.sleep(0.2)
|
|
581
|
+
for hashtag in hashtags:
|
|
582
|
+
tag_text = hashtag[1:] if hashtag.startswith("#") else hashtag
|
|
583
|
+
self._input_adb_text(device, "#", "type hashtag marker")
|
|
584
|
+
if tag_text:
|
|
585
|
+
appium.set_clipboard(tag_text)
|
|
586
|
+
self._ok(self.adb.keyevent(device.adb_serial, "KEYCODE_PASTE"), "paste hashtag text")
|
|
587
|
+
self._input_adb_text(device, " ", "type hashtag separator")
|
|
588
|
+
time.sleep(0.2)
|
|
589
|
+
|
|
590
|
+
def _input_adb_text(self, device: Device, text: str, action: str) -> None:
|
|
591
|
+
result = self.adb.input_text(device.adb_serial, text)
|
|
592
|
+
if not result.ok:
|
|
593
|
+
raise RuntimeError(f"{action} failed: {result.stderr or result.stdout}")
|
|
594
|
+
|
|
595
|
+
def _find_and_click_text_field(self, appium: AppiumClient, name: str, selector: str, errors: list[str]) -> str:
|
|
596
|
+
for using, value in self._text_field_locators(name, selector):
|
|
597
|
+
try:
|
|
598
|
+
field_id = appium.find_element(using, value)
|
|
599
|
+
appium.click(field_id)
|
|
600
|
+
return field_id
|
|
601
|
+
except Exception as exc:
|
|
602
|
+
errors.append(f"appium_{using}_find_click failed: {_short(str(exc))}")
|
|
603
|
+
return ""
|
|
604
|
+
|
|
605
|
+
def _text_field_locators(self, name: str, selector: str) -> list[tuple[str, str]]:
|
|
606
|
+
locators = [("id", selector)]
|
|
607
|
+
if name == "title":
|
|
608
|
+
locators.extend(
|
|
609
|
+
[
|
|
610
|
+
("xpath", "//android.widget.EditText[@text='添加标题']"),
|
|
611
|
+
("xpath", "(//android.widget.EditText)[1]"),
|
|
612
|
+
]
|
|
613
|
+
)
|
|
614
|
+
elif name == "caption":
|
|
615
|
+
locators.extend(
|
|
616
|
+
[
|
|
617
|
+
("xpath", "//android.widget.EditText[contains(@text, '添加正文')]"),
|
|
618
|
+
("xpath", "(//android.widget.EditText)[2]"),
|
|
619
|
+
]
|
|
620
|
+
)
|
|
621
|
+
return locators
|
|
622
|
+
|
|
623
|
+
def _tap_text_field_from_ui_dump(self, device: Device, name: str, selector: str, run_dir: Path) -> bool:
|
|
624
|
+
try:
|
|
625
|
+
root = ui.dump_ui(self.adb, device, run_dir / f"{name}-field-before-input.xml")
|
|
626
|
+
suffix = selector.rsplit(":id/", 1)[-1]
|
|
627
|
+
node = ui.find_node(root, ids={selector}, id_suffixes={suffix}, clickable_only=False)
|
|
628
|
+
if node is None:
|
|
629
|
+
node = self._find_edit_text_from_ui_dump(root, name)
|
|
630
|
+
if node is None:
|
|
631
|
+
return False
|
|
632
|
+
ui.tap_node(self.adb, device, node)
|
|
633
|
+
return True
|
|
634
|
+
except Exception:
|
|
635
|
+
return False
|
|
636
|
+
|
|
637
|
+
def _find_edit_text_from_ui_dump(self, root, name: str):
|
|
638
|
+
fallback = FIELD_PLACEHOLDERS.get(name)
|
|
639
|
+
if not fallback:
|
|
640
|
+
return None
|
|
641
|
+
exact = fallback["exact"]
|
|
642
|
+
partial = fallback["partial"]
|
|
643
|
+
edit_texts = []
|
|
644
|
+
for node in root.iter("node"):
|
|
645
|
+
if node.attrib.get("class") != "android.widget.EditText" or not ui.is_visible(node):
|
|
646
|
+
continue
|
|
647
|
+
text = node.attrib.get("text", "")
|
|
648
|
+
desc = node.attrib.get("content-desc", "")
|
|
649
|
+
haystack = f"{text}\n{desc}".lower()
|
|
650
|
+
if text in exact or desc in exact or any(part.lower() in haystack for part in partial):
|
|
651
|
+
return node
|
|
652
|
+
edit_texts.append(node)
|
|
653
|
+
index = int(fallback["index"])
|
|
654
|
+
edit_texts = ui.sorted_visible_nodes(edit_texts)
|
|
655
|
+
if 0 <= index < len(edit_texts):
|
|
656
|
+
return edit_texts[index]
|
|
657
|
+
return None
|
|
658
|
+
|
|
659
|
+
def _require_current_post_form(self, device: Device) -> None:
|
|
660
|
+
focus = self.adb.current_focus(device.adb_serial)
|
|
661
|
+
if not any(keyword in focus for keyword in self._keywords("post_form_focus_keywords")):
|
|
662
|
+
raise RuntimeError(f"resume_post_form requires Xiaohongshu post form; current_focus={focus}")
|
|
663
|
+
|
|
664
|
+
def _open_latest_own_note_for_link(self, device: Device, run_dir: Path) -> None:
|
|
665
|
+
focus = self.adb.current_focus(device.adb_serial)
|
|
666
|
+
if self.package not in focus:
|
|
667
|
+
self._launch_xiaohongshu(device)
|
|
668
|
+
|
|
669
|
+
self._dismiss_link_recovery_prompts(device, run_dir)
|
|
670
|
+
focus = self.adb.current_focus(device.adb_serial)
|
|
671
|
+
if any(keyword in focus for keyword in self._keywords("detail_focus_keywords")):
|
|
672
|
+
return
|
|
673
|
+
|
|
674
|
+
self._open_me_tab(device, run_dir)
|
|
675
|
+
self._dismiss_link_recovery_prompts(device, run_dir)
|
|
676
|
+
self._tap_latest_profile_note(device, run_dir)
|
|
677
|
+
self._wait_for_detail_screen(device, run_dir)
|
|
678
|
+
|
|
679
|
+
def _dismiss_link_recovery_prompts(self, device: Device, run_dir: Path) -> None:
|
|
680
|
+
for idx in range(2):
|
|
681
|
+
try:
|
|
682
|
+
root = ui.dump_ui(self.adb, device, run_dir / f"link-recovery-prompt-{idx + 1}.xml")
|
|
683
|
+
except Exception:
|
|
684
|
+
return
|
|
685
|
+
all_text = ui.all_text(root)
|
|
686
|
+
node = ui.find_node(
|
|
687
|
+
root,
|
|
688
|
+
texts={"关闭", "取消", "以后再说", "跳过"},
|
|
689
|
+
partial_texts={"以后再说", "暂不", "跳过"},
|
|
690
|
+
)
|
|
691
|
+
if node is not None:
|
|
692
|
+
ui.tap_node(self.adb, device, node, sleep_s=self._wait("after_prompt_dismiss"))
|
|
693
|
+
continue
|
|
694
|
+
if any(marker in all_text for marker in ("发布成功", "完善个人资料", "上传头像", "填写名字")):
|
|
695
|
+
self._tap_coord_or_default(
|
|
696
|
+
device,
|
|
697
|
+
"post_publish_prompt_close",
|
|
698
|
+
self._relative_coord(0.945, 0.5),
|
|
699
|
+
self._wait("after_prompt_dismiss"),
|
|
700
|
+
)
|
|
701
|
+
continue
|
|
702
|
+
return
|
|
703
|
+
|
|
704
|
+
def _open_me_tab(self, device: Device, run_dir: Path) -> None:
|
|
705
|
+
try:
|
|
706
|
+
root = ui.dump_ui(self.adb, device, run_dir / "before-me-tab.xml")
|
|
707
|
+
node = self._find_bottom_tab(root, {"我"})
|
|
708
|
+
if node is not None:
|
|
709
|
+
ui.tap_node(self.adb, device, node, sleep_s=self._wait("after_me_tab"))
|
|
710
|
+
else:
|
|
711
|
+
self._tap_coord_or_default(device, "bottom_me", self._relative_coord(0.9, 0.93), self._wait("after_me_tab"))
|
|
712
|
+
except Exception:
|
|
713
|
+
self._tap_coord_or_default(device, "bottom_me", self._relative_coord(0.9, 0.93), self._wait("after_me_tab"))
|
|
714
|
+
self.adb.screenshot(device.adb_serial, run_dir / "profile.png")
|
|
715
|
+
|
|
716
|
+
def _tap_latest_profile_note(self, device: Device, run_dir: Path) -> None:
|
|
717
|
+
for attempt in range(3):
|
|
718
|
+
root = ui.dump_ui(self.adb, device, run_dir / f"profile-before-latest-note-{attempt + 1}.xml")
|
|
719
|
+
node = self._find_latest_profile_note_node(root)
|
|
720
|
+
if node is not None:
|
|
721
|
+
ui.tap_node(self.adb, device, node, sleep_s=self._wait("after_latest_note_tap"))
|
|
722
|
+
self.adb.screenshot(device.adb_serial, run_dir / "latest-note-opened.png")
|
|
723
|
+
return
|
|
724
|
+
time.sleep(1)
|
|
725
|
+
self._tap_coord_or_default(
|
|
726
|
+
device,
|
|
727
|
+
"profile_latest_note",
|
|
728
|
+
self._relative_coord(0.25, 0.53),
|
|
729
|
+
self._wait("after_latest_note_tap"),
|
|
730
|
+
)
|
|
731
|
+
self.adb.screenshot(device.adb_serial, run_dir / "latest-note-opened.png")
|
|
732
|
+
|
|
733
|
+
def _wait_for_detail_screen(self, device: Device, run_dir: Path) -> None:
|
|
734
|
+
timeout = self._wait("detail_load_timeout")
|
|
735
|
+
deadline = time.time() + timeout
|
|
736
|
+
last_focus = ""
|
|
737
|
+
while time.time() < deadline:
|
|
738
|
+
focus = self.adb.current_focus(device.adb_serial)
|
|
739
|
+
if focus:
|
|
740
|
+
last_focus = focus
|
|
741
|
+
if any(keyword in focus for keyword in self._keywords("detail_focus_keywords")):
|
|
742
|
+
return
|
|
743
|
+
time.sleep(0.5)
|
|
744
|
+
self.adb.screenshot(device.adb_serial, run_dir / "latest-note-detail-timeout.png")
|
|
745
|
+
raise RuntimeError(f"latest own Xiaohongshu note did not open; current_focus={last_focus}")
|
|
746
|
+
|
|
747
|
+
def _find_bottom_tab(self, root, labels: set[str]):
|
|
748
|
+
_width, height = self._screen_size()
|
|
749
|
+
min_top = int(height * 0.72)
|
|
750
|
+
for node in root.iter("node"):
|
|
751
|
+
if not ui.is_visible(node):
|
|
752
|
+
continue
|
|
753
|
+
value = node.attrib.get("text", "") or node.attrib.get("content-desc", "")
|
|
754
|
+
if value not in labels:
|
|
755
|
+
continue
|
|
756
|
+
bounds = ui.bounds(node)
|
|
757
|
+
if bounds and bounds[1] >= min_top:
|
|
758
|
+
return node
|
|
759
|
+
return None
|
|
760
|
+
|
|
761
|
+
def _find_latest_profile_note_node(self, root):
|
|
762
|
+
width, height = self._screen_size()
|
|
763
|
+
min_top = self._profile_note_min_top(root, height)
|
|
764
|
+
bottom_guard = int(height * 0.08)
|
|
765
|
+
candidates = []
|
|
766
|
+
image_candidates = []
|
|
767
|
+
blocked_text = {"首页", "购物", "发布", "消息", "我", "关注", "编辑资料", "设置"}
|
|
768
|
+
|
|
769
|
+
for node in root.iter("node"):
|
|
770
|
+
bounds = ui.bounds(node)
|
|
771
|
+
if not bounds or not ui.is_visible(node):
|
|
772
|
+
continue
|
|
773
|
+
left, top, right, bottom = bounds
|
|
774
|
+
node_width = right - left
|
|
775
|
+
node_height = bottom - top
|
|
776
|
+
if top < min_top or bottom > height - bottom_guard:
|
|
777
|
+
continue
|
|
778
|
+
if node_width < int(width * 0.16) or node_height < 140:
|
|
779
|
+
continue
|
|
780
|
+
text = node.attrib.get("text", "")
|
|
781
|
+
desc = node.attrib.get("content-desc", "")
|
|
782
|
+
if text in blocked_text or desc in blocked_text:
|
|
783
|
+
continue
|
|
784
|
+
item = (top, left, node)
|
|
785
|
+
if node.attrib.get("clickable") == "true":
|
|
786
|
+
candidates.append(item)
|
|
787
|
+
elif node.attrib.get("class") == "android.widget.ImageView":
|
|
788
|
+
image_candidates.append(item)
|
|
789
|
+
|
|
790
|
+
chosen = candidates or image_candidates
|
|
791
|
+
if not chosen:
|
|
792
|
+
return None
|
|
793
|
+
return sorted(chosen, key=lambda item: (item[0], item[1]))[0][2]
|
|
794
|
+
|
|
795
|
+
def _profile_note_min_top(self, root, height: int) -> int:
|
|
796
|
+
min_top = int(height * 0.35)
|
|
797
|
+
for node in root.iter("node"):
|
|
798
|
+
value = node.attrib.get("text", "") or node.attrib.get("content-desc", "")
|
|
799
|
+
if value not in {"笔记", "作品"}:
|
|
800
|
+
continue
|
|
801
|
+
bounds = ui.bounds(node)
|
|
802
|
+
if bounds:
|
|
803
|
+
min_top = max(min_top, bounds[3] + 20)
|
|
804
|
+
return min_top
|
|
805
|
+
|
|
806
|
+
def _tap_first_publish(self, device: Device) -> None:
|
|
807
|
+
self._tap_coord(device, "first_publish", 0)
|
|
808
|
+
time.sleep(self._wait("after_first_publish"))
|
|
809
|
+
|
|
810
|
+
def _tap_confirm_publish(self, device: Device) -> None:
|
|
811
|
+
self._tap_coord(device, "confirm_publish", 0)
|
|
812
|
+
|
|
813
|
+
def _tap_copy_link(self, device: Device, run_dir: Path) -> None:
|
|
814
|
+
xml_path = run_dir / "share-sheet.xml"
|
|
815
|
+
try:
|
|
816
|
+
root = ui.dump_ui(self.adb, device, xml_path)
|
|
817
|
+
node = ui.find_node(root, texts={"复制链接", "Copy link"}, partial_texts={"复制链接", "copy link"})
|
|
818
|
+
if node is not None:
|
|
819
|
+
ui.tap_node(self.adb, device, node)
|
|
820
|
+
return
|
|
821
|
+
except Exception:
|
|
822
|
+
pass
|
|
823
|
+
self._tap_coord(device, "copy_link", 0)
|
|
824
|
+
|
|
825
|
+
def _read_copied_link(self, device: Device, run_dir: Path) -> tuple[str, str, str]:
|
|
826
|
+
errors: list[str] = []
|
|
827
|
+
|
|
828
|
+
result = self.adb.shell(device.adb_serial, "cmd", "clipboard", "get")
|
|
829
|
+
if result.ok:
|
|
830
|
+
link = _extract_url(result.stdout)
|
|
831
|
+
if link:
|
|
832
|
+
return link, "adb_cmd_clipboard", ""
|
|
833
|
+
errors.append("adb_cmd_clipboard returned no URL")
|
|
834
|
+
else:
|
|
835
|
+
errors.append(f"adb_cmd_clipboard failed: {_short(result.stderr or result.stdout)}")
|
|
836
|
+
|
|
837
|
+
result = self.adb.shell(device.adb_serial, "dumpsys", "clipboard")
|
|
838
|
+
if result.ok:
|
|
839
|
+
link = _extract_url(result.stdout)
|
|
840
|
+
if link:
|
|
841
|
+
return link, "adb_dumpsys_clipboard", ""
|
|
842
|
+
errors.append("adb_dumpsys_clipboard returned no URL")
|
|
843
|
+
else:
|
|
844
|
+
errors.append(f"adb_dumpsys_clipboard failed: {_short(result.stderr or result.stdout)}")
|
|
845
|
+
|
|
846
|
+
link = self._read_link_by_paste_probe(device, run_dir)
|
|
847
|
+
if link:
|
|
848
|
+
return link, "paste_probe_ui_dump", ""
|
|
849
|
+
errors.append("paste_probe_ui_dump returned no URL")
|
|
850
|
+
|
|
851
|
+
appium = AppiumClient(self.appium_server)
|
|
852
|
+
try:
|
|
853
|
+
appium.start_session(_appium_capabilities(device.adb_serial))
|
|
854
|
+
text = appium.get_clipboard()
|
|
855
|
+
link = _extract_url(text)
|
|
856
|
+
if link:
|
|
857
|
+
return link, "appium_get_clipboard", ""
|
|
858
|
+
errors.append("appium_get_clipboard returned no URL")
|
|
859
|
+
except Exception as exc:
|
|
860
|
+
errors.append(f"appium_get_clipboard failed: {_short(str(exc))}")
|
|
861
|
+
finally:
|
|
862
|
+
try:
|
|
863
|
+
appium.delete_session()
|
|
864
|
+
except Exception:
|
|
865
|
+
pass
|
|
866
|
+
|
|
867
|
+
return "", "", "; ".join(errors)
|
|
868
|
+
|
|
869
|
+
def _read_link_by_paste_probe(self, device: Device, run_dir: Path) -> str:
|
|
870
|
+
opened_input = False
|
|
871
|
+
try:
|
|
872
|
+
xml_path = run_dir / "before-paste-probe.xml"
|
|
873
|
+
root = ui.dump_ui(self.adb, device, xml_path)
|
|
874
|
+
node = self._find_comment_input_node(root)
|
|
875
|
+
if node is None:
|
|
876
|
+
comment_entry = ui.find_node(
|
|
877
|
+
root,
|
|
878
|
+
id_suffixes={"noteCommentLayout", "commentLayout"},
|
|
879
|
+
partial_texts={"评论"},
|
|
880
|
+
clickable_only=True,
|
|
881
|
+
)
|
|
882
|
+
if comment_entry is not None:
|
|
883
|
+
ui.tap_node(self.adb, device, comment_entry, sleep_s=self._wait("after_comment_input_tap"))
|
|
884
|
+
else:
|
|
885
|
+
self._tap_coord_or_default(
|
|
886
|
+
device,
|
|
887
|
+
"comment_input",
|
|
888
|
+
self._relative_coord(0.9, 0.97),
|
|
889
|
+
self._wait("after_comment_input_tap"),
|
|
890
|
+
)
|
|
891
|
+
root = ui.dump_ui(self.adb, device, run_dir / "comment-panel-before-paste.xml")
|
|
892
|
+
node = self._find_comment_input_node(root)
|
|
893
|
+
if node is not None:
|
|
894
|
+
ui.tap_node(self.adb, device, node, sleep_s=self._wait("after_comment_input_tap"))
|
|
895
|
+
opened_input = True
|
|
896
|
+
|
|
897
|
+
self.adb.keyevent(device.adb_serial, "KEYCODE_PASTE")
|
|
898
|
+
time.sleep(self._wait("after_paste_probe"))
|
|
899
|
+
pasted_xml = run_dir / "paste-probe.xml"
|
|
900
|
+
root = ui.dump_ui(self.adb, device, pasted_xml)
|
|
901
|
+
self.adb.screenshot(device.adb_serial, run_dir / "paste-probe.png")
|
|
902
|
+
return _extract_url(ui.all_text(root))
|
|
903
|
+
except Exception:
|
|
904
|
+
return ""
|
|
905
|
+
finally:
|
|
906
|
+
if opened_input:
|
|
907
|
+
self.adb.keyevent(device.adb_serial, "KEYCODE_BACK")
|
|
908
|
+
time.sleep(0.3)
|
|
909
|
+
self.adb.keyevent(device.adb_serial, "KEYCODE_BACK")
|
|
910
|
+
time.sleep(0.3)
|
|
911
|
+
|
|
912
|
+
def _find_comment_input_node(self, root):
|
|
913
|
+
node = ui.find_node(
|
|
914
|
+
root,
|
|
915
|
+
id_suffixes={"mContentET", "commentEditText", "commentInputEditText"},
|
|
916
|
+
partial_texts={"留下你的想法", "留下你的评论", "说点什么", "友善评论"},
|
|
917
|
+
)
|
|
918
|
+
if node is not None:
|
|
919
|
+
return node
|
|
920
|
+
for candidate in root.iter("node"):
|
|
921
|
+
if candidate.attrib.get("class") != "android.widget.EditText" or not ui.is_visible(candidate):
|
|
922
|
+
continue
|
|
923
|
+
text = candidate.attrib.get("text", "")
|
|
924
|
+
desc = candidate.attrib.get("content-desc", "")
|
|
925
|
+
haystack = f"{text}\n{desc}"
|
|
926
|
+
if any(marker in haystack for marker in ("评论", "想法", "说点")) or not text.strip():
|
|
927
|
+
return candidate
|
|
928
|
+
return None
|
|
929
|
+
|
|
930
|
+
def _wait_for_publish_complete(self, device: Device, run_dir: Path) -> Path:
|
|
931
|
+
timeout = self._wait("publish_timeout")
|
|
932
|
+
interval = max(0.5, self._wait("publish_poll_interval"))
|
|
933
|
+
success_keywords = self._keywords("success_focus_keywords")
|
|
934
|
+
in_progress_keywords = self._keywords("in_progress_focus_keywords")
|
|
935
|
+
deadline = time.time() + timeout
|
|
936
|
+
last_focus = ""
|
|
937
|
+
|
|
938
|
+
while time.time() < deadline:
|
|
939
|
+
focus = self.adb.current_focus(device.adb_serial)
|
|
940
|
+
if focus:
|
|
941
|
+
last_focus = focus
|
|
942
|
+
if any(keyword in focus for keyword in success_keywords):
|
|
943
|
+
final_shot = run_dir / "after-post.png"
|
|
944
|
+
self.adb.screenshot(device.adb_serial, final_shot)
|
|
945
|
+
return final_shot
|
|
946
|
+
if self.package in focus and not any(keyword in focus for keyword in in_progress_keywords):
|
|
947
|
+
final_shot = run_dir / "after-post.png"
|
|
948
|
+
self.adb.screenshot(device.adb_serial, final_shot)
|
|
949
|
+
return final_shot
|
|
950
|
+
time.sleep(interval)
|
|
951
|
+
|
|
952
|
+
timeout_shot = run_dir / "after-post-timeout.png"
|
|
953
|
+
self.adb.screenshot(device.adb_serial, timeout_shot)
|
|
954
|
+
raise RuntimeError(
|
|
955
|
+
f"Xiaohongshu publish completion not confirmed within {int(timeout)}s; "
|
|
956
|
+
f"last_focus={last_focus}"
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
def _tap_coord(self, device: Device, name: str, sleep_s: float) -> None:
|
|
960
|
+
x, y = self._coord(name)
|
|
961
|
+
self._tap(device, x, y, sleep_s)
|
|
962
|
+
|
|
963
|
+
def _tap_coord_or_default(self, device: Device, name: str, default: tuple[int, int], sleep_s: float) -> None:
|
|
964
|
+
try:
|
|
965
|
+
x, y = self._coord(name)
|
|
966
|
+
except Exception:
|
|
967
|
+
x, y = default
|
|
968
|
+
self._tap(device, x, y, sleep_s)
|
|
969
|
+
|
|
970
|
+
def _tap(self, device: Device, x: int, y: int, sleep_s: float) -> None:
|
|
971
|
+
self._ok(self.adb.tap(device.adb_serial, x, y), f"tap {x},{y}")
|
|
972
|
+
if sleep_s:
|
|
973
|
+
time.sleep(sleep_s)
|
|
974
|
+
|
|
975
|
+
def _coord(self, name: str) -> list[int]:
|
|
976
|
+
coords = self.flow_config.get("coords", {})
|
|
977
|
+
value = coords.get(name) if isinstance(coords, dict) else None
|
|
978
|
+
if not isinstance(value, list) or not value or not all(isinstance(item, int) for item in value):
|
|
979
|
+
raise RuntimeError(f"missing or invalid Xiaohongshu coord: {name}")
|
|
980
|
+
return value
|
|
981
|
+
|
|
982
|
+
def _multi_image_coords(self) -> list[list[int]]:
|
|
983
|
+
coords = self.flow_config.get("coords", {})
|
|
984
|
+
value = coords.get("multi_image_select") if isinstance(coords, dict) else None
|
|
985
|
+
if value is None:
|
|
986
|
+
value = DEFAULT_FLOW_CONFIG["coords"]["multi_image_select"]
|
|
987
|
+
if not isinstance(value, list) or not value:
|
|
988
|
+
raise RuntimeError("missing Xiaohongshu multi_image_select coords")
|
|
989
|
+
out: list[list[int]] = []
|
|
990
|
+
for item in value:
|
|
991
|
+
if not isinstance(item, list) or len(item) != 2 or not all(isinstance(part, int) for part in item):
|
|
992
|
+
raise RuntimeError("invalid Xiaohongshu multi_image_select coords")
|
|
993
|
+
out.append(item)
|
|
994
|
+
return out
|
|
995
|
+
|
|
996
|
+
def _selector(self, name: str) -> str:
|
|
997
|
+
selectors = self.flow_config.get("selectors", {})
|
|
998
|
+
value = selectors.get(name) if isinstance(selectors, dict) else None
|
|
999
|
+
if not isinstance(value, str) or not value:
|
|
1000
|
+
raise RuntimeError(f"missing Xiaohongshu selector: {name}")
|
|
1001
|
+
return value
|
|
1002
|
+
|
|
1003
|
+
def _wait(self, name: str) -> float:
|
|
1004
|
+
waits = self.flow_config.get("waits", {})
|
|
1005
|
+
value = waits.get(name) if isinstance(waits, dict) else None
|
|
1006
|
+
if isinstance(value, (int, float)):
|
|
1007
|
+
return float(value)
|
|
1008
|
+
default = DEFAULT_FLOW_CONFIG["waits"].get(name, 0)
|
|
1009
|
+
return float(default)
|
|
1010
|
+
|
|
1011
|
+
def _screen_size(self) -> tuple[int, int]:
|
|
1012
|
+
screen = self.flow_config.get("screen", {})
|
|
1013
|
+
width = screen.get("width") if isinstance(screen, dict) else None
|
|
1014
|
+
height = screen.get("height") if isinstance(screen, dict) else None
|
|
1015
|
+
if isinstance(width, int) and isinstance(height, int) and width > 0 and height > 0:
|
|
1016
|
+
return width, height
|
|
1017
|
+
return 1080, 2400
|
|
1018
|
+
|
|
1019
|
+
def _relative_coord(self, x_ratio: float, y_ratio: float) -> tuple[int, int]:
|
|
1020
|
+
width, height = self._screen_size()
|
|
1021
|
+
return int(width * x_ratio), int(height * y_ratio)
|
|
1022
|
+
|
|
1023
|
+
def _keywords(self, name: str) -> list[str]:
|
|
1024
|
+
value = self.flow_config.get(name)
|
|
1025
|
+
if isinstance(value, list):
|
|
1026
|
+
return [item for item in value if isinstance(item, str) and item]
|
|
1027
|
+
default = DEFAULT_FLOW_CONFIG.get(name, [])
|
|
1028
|
+
return [item for item in default if isinstance(item, str)]
|
|
1029
|
+
|
|
1030
|
+
@staticmethod
|
|
1031
|
+
def _ok(result, action: str) -> None:
|
|
1032
|
+
if not result.ok:
|
|
1033
|
+
raise RuntimeError(f"{action} failed: {result.stderr or result.stdout}")
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _load_flow_config(path: str | Path) -> dict:
|
|
1037
|
+
config = copy.deepcopy(DEFAULT_FLOW_CONFIG)
|
|
1038
|
+
config_path = Path(path)
|
|
1039
|
+
if not config_path.exists():
|
|
1040
|
+
return config
|
|
1041
|
+
|
|
1042
|
+
with config_path.open("r", encoding="utf-8") as handle:
|
|
1043
|
+
loaded = json.load(handle)
|
|
1044
|
+
if not isinstance(loaded, dict):
|
|
1045
|
+
raise RuntimeError(f"Xiaohongshu flow config must be a JSON object: {config_path}")
|
|
1046
|
+
_deep_merge(config, loaded)
|
|
1047
|
+
return config
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _deep_merge(base: dict, updates: dict) -> None:
|
|
1051
|
+
for key, value in updates.items():
|
|
1052
|
+
if isinstance(value, dict) and isinstance(base.get(key), dict):
|
|
1053
|
+
_deep_merge(base[key], value)
|
|
1054
|
+
else:
|
|
1055
|
+
base[key] = value
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _normalize_post_type(value: str) -> str:
|
|
1059
|
+
text = value.strip().lower().replace("_", "-")
|
|
1060
|
+
aliases = {
|
|
1061
|
+
"photo": "image",
|
|
1062
|
+
"picture": "image",
|
|
1063
|
+
"image": "image",
|
|
1064
|
+
"video": "video",
|
|
1065
|
+
}
|
|
1066
|
+
if text in aliases:
|
|
1067
|
+
return aliases[text]
|
|
1068
|
+
raise ValueError("Xiaohongshu post_type must be one of: image, video")
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def _appium_capabilities(serial: str) -> dict:
|
|
1072
|
+
return {
|
|
1073
|
+
"platformName": "Android",
|
|
1074
|
+
"appium:automationName": "UiAutomator2",
|
|
1075
|
+
"appium:udid": serial,
|
|
1076
|
+
"appium:noReset": True,
|
|
1077
|
+
"appium:newCommandTimeout": 120,
|
|
1078
|
+
"appium:disableWindowAnimation": True,
|
|
1079
|
+
"appium:skipDeviceInitialization": True,
|
|
1080
|
+
"appium:ignoreHiddenApiPolicyError": True,
|
|
1081
|
+
"appium:disableSuppressAccessibilityService": True,
|
|
1082
|
+
"appium:settings[enableNotificationListener]": False,
|
|
1083
|
+
"appium:autoLaunch": False,
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _caption(title: str, caption: str) -> str:
|
|
1088
|
+
return f"{title} | {caption}" if caption else title
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _compose_caption_for_record(caption: str, tags: list[str], topics: list[str]) -> str:
|
|
1092
|
+
body = (caption or "").strip()
|
|
1093
|
+
hashtags = " ".join(_format_manual_hashtags([*tags, *topics], existing=body))
|
|
1094
|
+
if body and hashtags:
|
|
1095
|
+
return f"{body}\n\n{hashtags}"
|
|
1096
|
+
return body or hashtags
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _normalize_hashtag_values(values: list[str]) -> list[str]:
|
|
1100
|
+
seen: set[str] = set()
|
|
1101
|
+
out: list[str] = []
|
|
1102
|
+
for value in values:
|
|
1103
|
+
text = re.sub(r"\s+", "", str(value or "").strip())
|
|
1104
|
+
if not text:
|
|
1105
|
+
continue
|
|
1106
|
+
normalized = text[1:] if text.startswith("#") else text
|
|
1107
|
+
if not normalized:
|
|
1108
|
+
continue
|
|
1109
|
+
key = normalized.lower()
|
|
1110
|
+
if key in seen:
|
|
1111
|
+
continue
|
|
1112
|
+
seen.add(key)
|
|
1113
|
+
out.append(normalized)
|
|
1114
|
+
return out
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def _format_manual_hashtags(values: list[str], *, existing: str = "") -> list[str]:
|
|
1118
|
+
out: list[str] = []
|
|
1119
|
+
for value in _normalize_hashtag_values(values):
|
|
1120
|
+
tag = value if value.startswith("#") else f"#{value}"
|
|
1121
|
+
if tag in existing:
|
|
1122
|
+
continue
|
|
1123
|
+
out.append(tag)
|
|
1124
|
+
return out
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def _extract_url(text: str) -> str:
|
|
1128
|
+
for raw in re.findall(r"https?://[^\s\"'<>]+", text or ""):
|
|
1129
|
+
url = raw.rstrip(").,;,。]")
|
|
1130
|
+
lowered = url.lower()
|
|
1131
|
+
if any(marker in lowered for marker in ("xiaohongshu", "xhslink", "xhs.cn", "xhs")):
|
|
1132
|
+
return url
|
|
1133
|
+
urls = re.findall(r"https?://[^\s\"'<>]+", text or "")
|
|
1134
|
+
return urls[0].rstrip(").,;,。]") if urls else ""
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def _short(text: str, limit: int = 220) -> str:
|
|
1138
|
+
one_line = " ".join((text or "").split())
|
|
1139
|
+
return one_line if len(one_line) <= limit else one_line[: limit - 3] + "..."
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def _now_shanghai() -> str:
|
|
1143
|
+
return datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S %z")
|