@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.
Files changed (71) hide show
  1. package/README.md +52 -0
  2. package/bin/11agents.js +12 -0
  3. package/mobile-runtime/README.md +19 -0
  4. package/mobile-runtime/configs/platforms/xiaohongshu_d01.json +73 -0
  5. package/mobile-runtime/configs/platforms/xiaohongshu_d02.json +70 -0
  6. package/mobile-runtime/configs/platforms/xiaohongshu_d03.json +73 -0
  7. package/mobile-runtime/configs/publish_policy.json +40 -0
  8. package/mobile-runtime/data-templates/README.md +4 -0
  9. package/mobile-runtime/data-templates/accounts.example.csv +6 -0
  10. package/mobile-runtime/data-templates/devices.example.csv +2 -0
  11. package/mobile-runtime/data-templates/publish_records.example.jsonl +2 -0
  12. package/mobile-runtime/data-templates/tasks.example.jsonl +5 -0
  13. package/mobile-runtime/data-templates/video_metrics.example.jsonl +1 -0
  14. package/mobile-runtime/python/pyproject.toml +34 -0
  15. package/mobile-runtime/python/src/device_control/__init__.py +5 -0
  16. package/mobile-runtime/python/src/device_control/adapters/__init__.py +31 -0
  17. package/mobile-runtime/python/src/device_control/adapters/base.py +43 -0
  18. package/mobile-runtime/python/src/device_control/adapters/facebook.py +30 -0
  19. package/mobile-runtime/python/src/device_control/adapters/instagram.py +25 -0
  20. package/mobile-runtime/python/src/device_control/adapters/reddit.py +29 -0
  21. package/mobile-runtime/python/src/device_control/adapters/tiktok.py +25 -0
  22. package/mobile-runtime/python/src/device_control/adapters/x.py +29 -0
  23. package/mobile-runtime/python/src/device_control/adapters/xiaohongshu.py +26 -0
  24. package/mobile-runtime/python/src/device_control/adb.py +161 -0
  25. package/mobile-runtime/python/src/device_control/appium_client.py +131 -0
  26. package/mobile-runtime/python/src/device_control/appium_manager.py +403 -0
  27. package/mobile-runtime/python/src/device_control/cli.py +1608 -0
  28. package/mobile-runtime/python/src/device_control/entrypoints.py +60 -0
  29. package/mobile-runtime/python/src/device_control/locks.py +162 -0
  30. package/mobile-runtime/python/src/device_control/metrics/__init__.py +33 -0
  31. package/mobile-runtime/python/src/device_control/metrics/collectors.py +320 -0
  32. package/mobile-runtime/python/src/device_control/metrics/tiktok_account_adb.py +367 -0
  33. package/mobile-runtime/python/src/device_control/metrics/tiktok_video_adb.py +714 -0
  34. package/mobile-runtime/python/src/device_control/models.py +439 -0
  35. package/mobile-runtime/python/src/device_control/publish_policy.py +173 -0
  36. package/mobile-runtime/python/src/device_control/publishers/__init__.py +24 -0
  37. package/mobile-runtime/python/src/device_control/publishers/facebook_adb.py +494 -0
  38. package/mobile-runtime/python/src/device_control/publishers/instagram_adb.py +663 -0
  39. package/mobile-runtime/python/src/device_control/publishers/reddit_adb.py +595 -0
  40. package/mobile-runtime/python/src/device_control/publishers/tiktok_adb.py +477 -0
  41. package/mobile-runtime/python/src/device_control/publishers/tiktok_appium.py +259 -0
  42. package/mobile-runtime/python/src/device_control/publishers/ui_helpers.py +372 -0
  43. package/mobile-runtime/python/src/device_control/publishers/x_adb.py +636 -0
  44. package/mobile-runtime/python/src/device_control/publishers/xiaohongshu_adb.py +1143 -0
  45. package/mobile-runtime/python/src/device_control/store.py +137 -0
  46. package/mobile-runtime/scripts/appium_smoke.py +71 -0
  47. package/mobile-runtime/skills/android-collect-tiktok-metrics/SKILL.md +60 -0
  48. package/mobile-runtime/skills/android-collect-tiktok-metrics/agents/openai.yaml +4 -0
  49. package/mobile-runtime/skills/android-group-control-cli/SKILL.md +76 -0
  50. package/mobile-runtime/skills/android-group-control-cli/agents/openai.yaml +4 -0
  51. package/mobile-runtime/skills/android-group-control-cli/references/command-reference.md +122 -0
  52. package/mobile-runtime/skills/android-publish-facebook/SKILL.md +41 -0
  53. package/mobile-runtime/skills/android-publish-facebook/agents/openai.yaml +4 -0
  54. package/mobile-runtime/skills/android-publish-instagram/SKILL.md +45 -0
  55. package/mobile-runtime/skills/android-publish-instagram/agents/openai.yaml +4 -0
  56. package/mobile-runtime/skills/android-publish-reddit/SKILL.md +41 -0
  57. package/mobile-runtime/skills/android-publish-reddit/agents/openai.yaml +4 -0
  58. package/mobile-runtime/skills/android-publish-tiktok/SKILL.md +43 -0
  59. package/mobile-runtime/skills/android-publish-tiktok/agents/openai.yaml +4 -0
  60. package/mobile-runtime/skills/android-publish-x/SKILL.md +40 -0
  61. package/mobile-runtime/skills/android-publish-x/agents/openai.yaml +4 -0
  62. package/mobile-runtime/skills/android-publish-xiaohongshu/SKILL.md +50 -0
  63. package/mobile-runtime/skills/android-publish-xiaohongshu/agents/openai.yaml +4 -0
  64. package/mobile-runtime/skills/mobile-publish-data-collection/SKILL.md +49 -0
  65. package/mobile-runtime/skills/mobile-publish-device-health/SKILL.md +47 -0
  66. package/mobile-runtime/skills/mobile-publish-execution/SKILL.md +57 -0
  67. package/mobile-runtime/skills/mobile-publish-records/SKILL.md +29 -0
  68. package/package.json +4 -1
  69. package/scripts/mobile-postinstall.js +26 -0
  70. package/src/commands/mobile.js +695 -0
  71. 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")