@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,1608 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import csv
5
+ import json
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+ from dataclasses import asdict
8
+ from pathlib import Path
9
+
10
+ from .adb import AdbClient, resolve_adb_bin
11
+ from .adapters import default_registry
12
+ from .appium_manager import AppiumEnsureError, ensure_appium_server, stop_managed_appium
13
+ from .locks import DeviceLockError, device_lock, publish_slot
14
+ from .metrics import (
15
+ MetricsCollectionError,
16
+ TikTokAccountMetricsAdbCollector,
17
+ TikTokVideoMetricsAdbCollector,
18
+ build_manual_snapshot,
19
+ collect_with_default_collector,
20
+ parse_manual_values_json,
21
+ parse_manual_video_values_json,
22
+ parse_platform_post_id,
23
+ )
24
+ from .models import PublishRecord, new_id, utc_now_iso
25
+ from .publish_policy import (
26
+ DEFAULT_PUBLISH_POLICY_PATH,
27
+ decide_xiaohongshu_publish_package_policy,
28
+ load_publish_policy,
29
+ should_auto_confirm_instagram_reels_public,
30
+ should_auto_confirm_xiaohongshu_no_disclosure,
31
+ xiaohongshu_package_disclosure_is_strict,
32
+ )
33
+ from .publishers import (
34
+ FacebookAdbPublisher,
35
+ InstagramAdbPublisher,
36
+ RedditAdbPublisher,
37
+ TikTokAdbPublisher,
38
+ TikTokAppiumPublisher,
39
+ XAdbPublisher,
40
+ XiaohongshuAdbPublisher,
41
+ )
42
+ from .store import (
43
+ append_metrics_snapshot,
44
+ append_publish_record,
45
+ append_tiktok_account_metrics_snapshot,
46
+ append_tiktok_video_metrics_snapshot,
47
+ load_devices,
48
+ load_metrics_snapshots,
49
+ load_publish_records,
50
+ load_tiktok_account_metrics_snapshots,
51
+ load_tiktok_video_metrics_snapshots,
52
+ load_tasks,
53
+ write_publish_records,
54
+ )
55
+
56
+
57
+ MAX_PARALLEL_DEVICES = 5
58
+
59
+
60
+ def main() -> None:
61
+ parser = argparse.ArgumentParser(prog="groupctl")
62
+ sub = parser.add_subparsers(dest="command", required=True)
63
+
64
+ sub.add_parser("doctor", help="Check local CLI prerequisites")
65
+
66
+ sub.add_parser("list-adb", help="List currently connected ADB devices")
67
+
68
+ ensure_appium_p = sub.add_parser("ensure-appium", help="Start/reuse Appium and verify UiAutomator2 session readiness")
69
+ ensure_appium_p.add_argument("--devices", default="data/devices.csv")
70
+ ensure_appium_p.add_argument("--server", default="http://127.0.0.1:4723")
71
+ ensure_appium_p.add_argument("--device", action="append", default=[], help="Device id for smoke session, e.g. D01. Can be repeated.")
72
+ ensure_appium_p.add_argument("--udid", action="append", default=[], help="ADB serial for smoke session. Can be repeated.")
73
+ ensure_appium_p.add_argument("--no-start", action="store_true", help="Only check an existing Appium server.")
74
+ ensure_appium_p.add_argument("--restart", action="store_true", help="Restart the project-managed Appium server before checking.")
75
+ ensure_appium_p.add_argument("--no-smoke", action="store_true", help="Skip UiAutomator2 session smoke checks.")
76
+ ensure_appium_p.add_argument("--json", action="store_true", help="Print JSON")
77
+
78
+ stop_appium_p = sub.add_parser("stop-appium", help="Stop the project-managed local Appium server")
79
+ stop_appium_p.add_argument("--server", default="http://127.0.0.1:4723")
80
+ stop_appium_p.add_argument(
81
+ "--allow-unmanaged-appium",
82
+ action="store_true",
83
+ help="Also stop a local process on this port if its command line contains appium.",
84
+ )
85
+ stop_appium_p.add_argument("--json", action="store_true", help="Print JSON")
86
+
87
+ list_devices_p = sub.add_parser("list-devices", help="List managed devices from CSV")
88
+ list_devices_p.add_argument("--devices", default="data/devices.csv")
89
+ list_devices_p.add_argument("--health", action="store_true", help="Include online/battery/current focus")
90
+ list_devices_p.add_argument("--json", action="store_true", help="Print JSON lines")
91
+
92
+ init_p = sub.add_parser("init-one-device", help="Write a one-device CSV from first connected ADB device")
93
+ init_p.add_argument("--out", default="data/devices.csv")
94
+ init_p.add_argument("--device-id", default="D01")
95
+
96
+ connect_p = sub.add_parser("connect", help="Connect devices from CSV")
97
+ connect_p.add_argument("--devices", default="data/devices.csv")
98
+
99
+ health_p = sub.add_parser("health", help="Show device health from CSV")
100
+ health_p.add_argument("--devices", default="data/devices.csv")
101
+
102
+ screenshot_p = sub.add_parser("screenshot", help="Take screenshot from one device")
103
+ screenshot_p.add_argument("device_id")
104
+ screenshot_p.add_argument("--devices", default="data/devices.csv")
105
+ screenshot_p.add_argument("--out-dir", default="artifacts/screenshots")
106
+
107
+ dump_p = sub.add_parser("dump-ui", help="Dump uiautomator XML from one device")
108
+ dump_p.add_argument("device_id")
109
+ dump_p.add_argument("--devices", default="data/devices.csv")
110
+ dump_p.add_argument("--out-dir", default="artifacts/ui")
111
+
112
+ tap_p = sub.add_parser("tap", help="Tap coordinates on one device")
113
+ tap_p.add_argument("device_id")
114
+ tap_p.add_argument("x", type=int)
115
+ tap_p.add_argument("y", type=int)
116
+ tap_p.add_argument("--devices", default="data/devices.csv")
117
+
118
+ key_p = sub.add_parser("keyevent", help="Send Android keyevent to one device")
119
+ key_p.add_argument("device_id")
120
+ key_p.add_argument("keycode")
121
+ key_p.add_argument("--devices", default="data/devices.csv")
122
+
123
+ text_p = sub.add_parser("input-text", help="Input text on one device")
124
+ text_p.add_argument("device_id")
125
+ text_p.add_argument("text")
126
+ text_p.add_argument("--devices", default="data/devices.csv")
127
+
128
+ launch_p = sub.add_parser("launch", help="Launch an Android package on one device")
129
+ launch_p.add_argument("device_id")
130
+ launch_p.add_argument("package")
131
+ launch_p.add_argument("--devices", default="data/devices.csv")
132
+
133
+ plan_p = sub.add_parser("plan-task", help="Print adapter steps for a task")
134
+ plan_p.add_argument("--tasks", default="data/tasks.jsonl")
135
+ plan_p.add_argument("--index", type=int, default=0)
136
+
137
+ record_p = sub.add_parser("record-publish", help="Append one published post tracking record")
138
+ record_p.add_argument("--records", default="data/publish_records.jsonl")
139
+ record_p.add_argument("--record-id")
140
+ record_p.add_argument("--task-id", default="")
141
+ record_p.add_argument("--platform", required=True)
142
+ record_p.add_argument("--account-id", required=True)
143
+ record_p.add_argument("--device-id", required=True)
144
+ record_p.add_argument("--post-type", default="video")
145
+ record_p.add_argument("--local-media-path", default="")
146
+ record_p.add_argument("--remote-media-path", default="")
147
+ record_p.add_argument("--platform-post-id", default="")
148
+ record_p.add_argument("--platform-permalink", default="")
149
+ record_p.add_argument("--caption", default="")
150
+ record_p.add_argument("--published-at")
151
+ record_p.add_argument("--result-screenshot-path", default="")
152
+ record_p.add_argument("--status", default="published")
153
+
154
+ list_records_p = sub.add_parser("list-publish-records", help="Print publish records")
155
+ list_records_p.add_argument("--records", default="data/publish_records.jsonl")
156
+ list_records_p.add_argument("--platform")
157
+ list_records_p.add_argument("--account-id")
158
+
159
+ update_record_p = sub.add_parser("update-publish-record", help="Update post id/permalink for one publish record")
160
+ update_record_p.add_argument("--records", default="data/publish_records.jsonl")
161
+ update_record_p.add_argument("--record-id", required=True)
162
+ update_record_p.add_argument("--platform-post-id", default="")
163
+ update_record_p.add_argument("--platform-permalink", default="")
164
+ update_record_p.add_argument("--caption", default=None)
165
+ update_record_p.add_argument("--published-at", default=None)
166
+ update_record_p.add_argument("--result-screenshot-path", default=None)
167
+ update_record_p.add_argument("--status", default=None)
168
+
169
+ add_metrics_p = sub.add_parser("add-metrics", help="Append one manual metrics snapshot")
170
+ add_metrics_p.add_argument("--records", default="data/publish_records.jsonl")
171
+ add_metrics_p.add_argument("--metrics", default="data/video_metrics.jsonl")
172
+ add_metrics_p.add_argument("--record-id", required=True)
173
+ add_metrics_p.add_argument("--views", type=int)
174
+ add_metrics_p.add_argument("--likes", type=int)
175
+ add_metrics_p.add_argument("--comments", type=int)
176
+ add_metrics_p.add_argument("--shares", type=int)
177
+ add_metrics_p.add_argument("--saves", type=int)
178
+ add_metrics_p.add_argument("--score", type=int)
179
+ add_metrics_p.add_argument("--raw-json", default="{}")
180
+
181
+ collect_metrics_p = sub.add_parser("collect-metrics", help="Collect metrics for published records")
182
+ collect_metrics_p.add_argument("--records", default="data/publish_records.jsonl")
183
+ collect_metrics_p.add_argument("--metrics", default="data/video_metrics.jsonl")
184
+ collect_metrics_p.add_argument("--record-id")
185
+ collect_metrics_p.add_argument("--platform")
186
+ collect_metrics_p.add_argument("--account-id")
187
+
188
+ list_metrics_p = sub.add_parser("list-metrics", help="Print metrics snapshots")
189
+ list_metrics_p.add_argument("--metrics", default="data/video_metrics.jsonl")
190
+ list_metrics_p.add_argument("--record-id")
191
+
192
+ collect_tiktok_account_p = sub.add_parser(
193
+ "collect-tiktok-account-metrics-adb",
194
+ help="Collect TikTok Studio account overview metrics through ADB",
195
+ )
196
+ collect_tiktok_account_p.add_argument("--devices", default="data/devices.csv")
197
+ collect_tiktok_account_p.add_argument("--out", default="data/tiktok_account_metrics.jsonl")
198
+ collect_tiktok_account_p.add_argument("--artifact-root", default="artifacts/screenshots")
199
+ collect_tiktok_account_p.add_argument("--device", required=True, help="Device id, e.g. D01.")
200
+ collect_tiktok_account_p.add_argument("--account-id", default="", help="Current logged-in TikTok account id.")
201
+ collect_tiktok_account_p.add_argument("--account-prefix", default="tiktok", help="Fallback account id prefix.")
202
+ collect_tiktok_account_p.add_argument(
203
+ "--period",
204
+ action="append",
205
+ choices=["7d", "28d", "60d"],
206
+ default=[],
207
+ help="Period to collect. Repeatable; default is 7d, 28d, 60d.",
208
+ )
209
+ collect_tiktok_account_p.add_argument(
210
+ "--resume-analytics",
211
+ action="store_true",
212
+ help="Assume TikTok Studio Analytics overview is already open and skip navigation.",
213
+ )
214
+ collect_tiktok_account_p.add_argument(
215
+ "--values-json",
216
+ default="",
217
+ help="Manual metric values JSON keyed by 7d/28d/60d after reviewing screenshots.",
218
+ )
219
+ collect_tiktok_account_p.add_argument(
220
+ "--values-file",
221
+ default="",
222
+ help="Path to manual metric values JSON keyed by 7d/28d/60d.",
223
+ )
224
+ collect_tiktok_account_p.add_argument(
225
+ "--allow-manual-required",
226
+ action="store_true",
227
+ help="Exit 0 after screenshot capture even if values still need human extraction.",
228
+ )
229
+ collect_tiktok_account_p.add_argument("--no-append", action="store_true", help="Do not append the snapshot JSONL row.")
230
+ collect_tiktok_account_p.add_argument("--json", action="store_true", help="Print JSON")
231
+
232
+ list_tiktok_account_p = sub.add_parser("list-tiktok-account-metrics", help="Print TikTok account metrics snapshots")
233
+ list_tiktok_account_p.add_argument("--metrics", default="data/tiktok_account_metrics.jsonl")
234
+ list_tiktok_account_p.add_argument("--account-id")
235
+ list_tiktok_account_p.add_argument("--device-id")
236
+
237
+ collect_tiktok_video_p = sub.add_parser(
238
+ "collect-tiktok-video-metrics-adb",
239
+ help="Collect one TikTok video insights snapshot through ADB",
240
+ )
241
+ collect_tiktok_video_p.add_argument("--devices", default="data/devices.csv")
242
+ collect_tiktok_video_p.add_argument("--out", default="data/tiktok_video_metrics.jsonl")
243
+ collect_tiktok_video_p.add_argument("--artifact-root", default="artifacts/screenshots")
244
+ collect_tiktok_video_p.add_argument("--device", required=True, help="Device id, e.g. D01.")
245
+ collect_tiktok_video_p.add_argument("--account-id", default="", help="Current logged-in TikTok account id.")
246
+ collect_tiktok_video_p.add_argument("--account-prefix", default="tiktok", help="Fallback account id prefix.")
247
+ collect_tiktok_video_p.add_argument("--video-order", type=int, default=1, help="1-based profile video order.")
248
+ collect_tiktok_video_p.add_argument(
249
+ "--resume-insights",
250
+ action="store_true",
251
+ help="Assume a TikTok video insights page is already open and skip navigation.",
252
+ )
253
+ collect_tiktok_video_p.add_argument(
254
+ "--values-json",
255
+ default="",
256
+ help="Manual video metric values JSON after reviewing the screenshot.",
257
+ )
258
+ collect_tiktok_video_p.add_argument("--values-file", default="", help="Path to manual video metric values JSON.")
259
+ collect_tiktok_video_p.add_argument(
260
+ "--allow-manual-required",
261
+ action="store_true",
262
+ help="Exit 0 after screenshot capture even if values still need human extraction.",
263
+ )
264
+ collect_tiktok_video_p.add_argument("--no-append", action="store_true", help="Do not append the snapshot JSONL row.")
265
+ collect_tiktok_video_p.add_argument("--json", action="store_true", help="Print JSON")
266
+
267
+ list_tiktok_video_p = sub.add_parser("list-tiktok-video-metrics", help="Print TikTok video metrics snapshots")
268
+ list_tiktok_video_p.add_argument("--metrics", default="data/tiktok_video_metrics.jsonl")
269
+ list_tiktok_video_p.add_argument("--account-id")
270
+ list_tiktok_video_p.add_argument("--device-id")
271
+ list_tiktok_video_p.add_argument("--published-at")
272
+
273
+ publish_tiktok_p = sub.add_parser("publish-tiktok-adb", help="Publish one video to TikTok through ADB-only POC flow")
274
+ publish_tiktok_p.add_argument("--devices", default="data/devices.csv")
275
+ publish_tiktok_p.add_argument("--records", default="data/publish_records.jsonl")
276
+ publish_tiktok_p.add_argument("--video", required=True)
277
+ publish_tiktok_p.add_argument("--caption", default="")
278
+ publish_tiktok_p.add_argument("--device", action="append", default=[], help="Device id, e.g. D01. Can be repeated.")
279
+ publish_tiktok_p.add_argument("--range", dest="ranges", action="append", default=[], help="Device range, e.g. 1-10 or D01-D10.")
280
+ publish_tiktok_p.add_argument("--account-id", default="", help="Single account id. Use only with one target device.")
281
+ publish_tiktok_p.add_argument("--account-prefix", default="tiktok", help="Fallback account id prefix for multi-device runs.")
282
+ publish_tiktok_p.add_argument("--dry-run", action="store_true", help="Stop at TikTok post form without tapping publish.")
283
+ publish_tiktok_p.add_argument(
284
+ "--allow-caption-fallback",
285
+ action="store_true",
286
+ help="Publish even if caption input fails. Without this flag, caption failures stop before publish.",
287
+ )
288
+ publish_tiktok_p.add_argument("--parallel", action="store_true", help="Run target devices concurrently. Hard-capped at 5.")
289
+ publish_tiktok_p.add_argument("--max-concurrency", type=int, default=MAX_PARALLEL_DEVICES, help="Max concurrent devices, 1-5.")
290
+ publish_tiktok_p.add_argument("--json", action="store_true", help="Print JSON lines")
291
+
292
+ publish_tiktok_default_p = sub.add_parser("publish-tiktok", help="Publish one video to TikTok through the default Appium flow")
293
+ _add_publish_tiktok_appium_args(publish_tiktok_default_p)
294
+
295
+ publish_tiktok_appium_p = sub.add_parser("publish-tiktok-appium", help="Publish one video to TikTok through Appium POC flow")
296
+ _add_publish_tiktok_appium_args(publish_tiktok_appium_p)
297
+
298
+ publish_reddit_adb_p = sub.add_parser("publish-reddit-adb", help="Publish one Reddit post through ADB/App selector POC flow")
299
+ _add_publish_reddit_args(publish_reddit_adb_p)
300
+
301
+ publish_reddit_p = sub.add_parser("publish-reddit", help="Publish one Reddit post through the safe local POC flow")
302
+ _add_publish_reddit_args(publish_reddit_p)
303
+
304
+ publish_facebook_p = sub.add_parser("publish-facebook", help="Publish one Facebook Lite post through the local POC flow")
305
+ _add_publish_facebook_args(publish_facebook_p)
306
+
307
+ publish_instagram_p = sub.add_parser("publish-instagram", help="Publish one Instagram Reels or feed Post through the D03 POC flow")
308
+ _add_publish_instagram_args(publish_instagram_p)
309
+
310
+ publish_x_p = sub.add_parser("publish-x", help="Publish one X text, link, or image post through the local POC flow")
311
+ _add_publish_x_args(publish_x_p)
312
+
313
+ publish_xhs_p = sub.add_parser("publish-xiaohongshu", help="Publish one image or video note to Xiaohongshu through a configured device flow")
314
+ _add_publish_xiaohongshu_args(publish_xhs_p)
315
+
316
+ copy_xhs_link_p = sub.add_parser("copy-xiaohongshu-link", help="Copy a Xiaohongshu note link; from home it opens Me and the latest note first")
317
+ copy_xhs_link_p.add_argument("--devices", default="data/devices.csv")
318
+ copy_xhs_link_p.add_argument("--records", default="data/publish_records.jsonl")
319
+ copy_xhs_link_p.add_argument("--appium-server", default="http://127.0.0.1:4723")
320
+ _add_appium_preflight_args(copy_xhs_link_p)
321
+ copy_xhs_link_p.add_argument("--flow-config", default="configs/platforms/xiaohongshu_d03.json")
322
+ copy_xhs_link_p.add_argument("--device", required=True, help="Device id, e.g. D03.")
323
+ copy_xhs_link_p.add_argument("--record-id", default="", help="Update this publish record when a URL is retrieved.")
324
+ copy_xhs_link_p.add_argument("--json", action="store_true", help="Print JSON")
325
+
326
+ args = parser.parse_args()
327
+ adb = AdbClient()
328
+
329
+ if args.command == "doctor":
330
+ print(f"adb_bin={resolve_adb_bin()}")
331
+ try:
332
+ result = adb.run("version")
333
+ print(result.stdout or result.stderr)
334
+ except FileNotFoundError:
335
+ print("adb_status=missing")
336
+ print("hint=install Android platform-tools or set ADB_BIN")
337
+ return
338
+ devices = adb.list_devices()
339
+ print(f"connected_devices={len(devices)}")
340
+ for serial in devices:
341
+ print(f"- {serial}")
342
+ return
343
+
344
+ if args.command == "list-adb":
345
+ for serial in adb.list_devices():
346
+ print(serial)
347
+ return
348
+
349
+ if args.command == "ensure-appium":
350
+ udids = _resolve_appium_udids(args.devices, args.device, args.udid)
351
+ try:
352
+ result = ensure_appium_server(
353
+ args.server,
354
+ udids=udids,
355
+ start=not args.no_start,
356
+ restart=args.restart,
357
+ smoke=not args.no_smoke,
358
+ )
359
+ except AppiumEnsureError as exc:
360
+ row = {"status": "failed", "server_url": args.server, "message": str(exc)}
361
+ if args.json:
362
+ print(json.dumps(row, ensure_ascii=False, sort_keys=True))
363
+ else:
364
+ print(f"status=failed server={args.server} error={exc}")
365
+ raise SystemExit(1) from exc
366
+ row = result.to_dict()
367
+ if args.json:
368
+ print(json.dumps(row, ensure_ascii=False, sort_keys=True))
369
+ else:
370
+ print(
371
+ f"status={result.status} server={result.server_url} pid={result.pid or ''} "
372
+ f"sdk={result.android_sdk_root} appium={result.appium_bin} log={result.log_path}"
373
+ )
374
+ if result.smoke_udids:
375
+ print(f"smoke_udids={','.join(result.smoke_udids)}")
376
+ return
377
+
378
+ if args.command == "stop-appium":
379
+ stopped = stop_managed_appium(args.server, allow_unmanaged_appium=args.allow_unmanaged_appium)
380
+ row = {"status": "stopped" if stopped else "not_running", "server_url": args.server}
381
+ if args.json:
382
+ print(json.dumps(row, ensure_ascii=False, sort_keys=True))
383
+ else:
384
+ print(f"status={row['status']} server={args.server}")
385
+ return
386
+
387
+ if args.command == "list-devices":
388
+ devices = load_devices(args.devices)
389
+ connected = set(adb.list_devices()) if args.health else set()
390
+ for device in devices.values():
391
+ row = {
392
+ "device_id": device.device_id,
393
+ "adb_serial": device.adb_serial,
394
+ "ip_address": device.ip_address,
395
+ "brand": device.brand,
396
+ "model": device.model,
397
+ "android_version": device.android_version,
398
+ "status": device.status,
399
+ "notes": device.notes,
400
+ }
401
+ if args.health:
402
+ online = device.adb_serial in connected
403
+ row["online"] = online
404
+ if online:
405
+ row["battery"] = adb.battery_level(device.adb_serial)
406
+ row["focus"] = adb.current_focus(device.adb_serial)
407
+ if args.json:
408
+ print(json.dumps(row, ensure_ascii=False, sort_keys=True))
409
+ else:
410
+ suffix = ""
411
+ if args.health:
412
+ suffix = f" online={row['online']} battery={row.get('battery', '')} focus={row.get('focus', '')}"
413
+ print(
414
+ f"{device.device_id} serial={device.adb_serial} ip={device.ip_address} "
415
+ f"model={device.model} status={device.status}{suffix}"
416
+ )
417
+ return
418
+
419
+ if args.command == "init-one-device":
420
+ devices = adb.list_devices()
421
+ if not devices:
422
+ raise SystemExit("No connected ADB device. Connect phone by USB, authorize debugging, then retry.")
423
+ serial = devices[0]
424
+ out = Path(args.out)
425
+ out.parent.mkdir(parents=True, exist_ok=True)
426
+ row = {
427
+ "device_id": args.device_id,
428
+ "adb_serial": serial,
429
+ "ip_address": _ip_from_serial(serial),
430
+ "brand": adb.getprop(serial, "ro.product.brand"),
431
+ "model": adb.getprop(serial, "ro.product.model"),
432
+ "android_version": adb.getprop(serial, "ro.build.version.release"),
433
+ "status": "active",
434
+ "notes": "single-device POC",
435
+ }
436
+ with out.open("w", newline="") as f:
437
+ writer = csv.DictWriter(f, fieldnames=list(row.keys()))
438
+ writer.writeheader()
439
+ writer.writerow(row)
440
+ print(f"wrote {out}")
441
+ return
442
+
443
+ if args.command == "connect":
444
+ devices = load_devices(args.devices)
445
+ for device in devices.values():
446
+ if device.status != "active":
447
+ print(f"{device.device_id}: skipped status={device.status}")
448
+ continue
449
+ result = adb.connect(device.adb_serial)
450
+ status = "ok" if result.ok else "fail"
451
+ print(f"{device.device_id} {device.adb_serial}: {status} {result.stdout or result.stderr}")
452
+ return
453
+
454
+ if args.command == "health":
455
+ devices = load_devices(args.devices)
456
+ connected = set(adb.list_devices())
457
+ for device in devices.values():
458
+ online = device.adb_serial in connected
459
+ if not online:
460
+ print(f"{device.device_id}: offline serial={device.adb_serial}")
461
+ continue
462
+ battery = adb.battery_level(device.adb_serial)
463
+ model = adb.getprop(device.adb_serial, "ro.product.model")
464
+ android = adb.getprop(device.adb_serial, "ro.build.version.release")
465
+ focus = adb.current_focus(device.adb_serial)
466
+ print(f"{device.device_id}: online battery={battery} model={model} android={android} focus={focus}")
467
+ return
468
+
469
+ if args.command == "screenshot":
470
+ device = _get_device(args.devices, args.device_id)
471
+ out = Path(args.out_dir) / f"{device.device_id}.png"
472
+ result = adb.screenshot(device.adb_serial, out)
473
+ if result.ok:
474
+ print(f"saved {out}")
475
+ else:
476
+ print(result.stderr or result.stdout)
477
+ return
478
+
479
+ if args.command == "dump-ui":
480
+ device = _get_device(args.devices, args.device_id)
481
+ out = Path(args.out_dir) / f"{device.device_id}.xml"
482
+ result = adb.dump_ui(device.adb_serial, out)
483
+ if result.ok:
484
+ print(f"saved {out}")
485
+ else:
486
+ print(result.stderr or result.stdout)
487
+ raise SystemExit(result.code)
488
+ return
489
+
490
+ if args.command == "tap":
491
+ device = _get_device(args.devices, args.device_id)
492
+ result = adb.tap(device.adb_serial, args.x, args.y)
493
+ print(result.stdout or result.stderr)
494
+ return
495
+
496
+ if args.command == "keyevent":
497
+ device = _get_device(args.devices, args.device_id)
498
+ result = adb.keyevent(device.adb_serial, args.keycode)
499
+ print(result.stdout or result.stderr)
500
+ return
501
+
502
+ if args.command == "input-text":
503
+ device = _get_device(args.devices, args.device_id)
504
+ result = adb.input_text(device.adb_serial, args.text)
505
+ print(result.stdout or result.stderr)
506
+ return
507
+
508
+ if args.command == "launch":
509
+ device = _get_device(args.devices, args.device_id)
510
+ result = adb.launch_package(device.adb_serial, args.package)
511
+ print(result.stdout or result.stderr)
512
+ return
513
+
514
+ if args.command == "plan-task":
515
+ tasks = load_tasks(args.tasks)
516
+ task = tasks[args.index]
517
+ registry = default_registry()
518
+ adapter = registry.get(task.platform)
519
+ errors = adapter.validate(task)
520
+ if errors:
521
+ print("validation errors:")
522
+ for err in errors:
523
+ print(f"- {err}")
524
+ return
525
+ print(f"task_id={task.task_id}")
526
+ print(f"platform={task.platform}")
527
+ print(f"post_type={task.post_type}")
528
+ print(f"dry_run={task.dry_run}")
529
+ for i, step in enumerate(adapter.plan(task), start=1):
530
+ print(f"{i}. {step}")
531
+ return
532
+
533
+ if args.command == "record-publish":
534
+ record = PublishRecord(
535
+ record_id=args.record_id or new_id("pub"),
536
+ task_id=args.task_id,
537
+ platform=args.platform.lower(),
538
+ account_id=args.account_id,
539
+ device_id=args.device_id,
540
+ post_type=args.post_type.lower(),
541
+ local_media_path=args.local_media_path,
542
+ remote_media_path=args.remote_media_path,
543
+ platform_post_id=args.platform_post_id,
544
+ platform_permalink=args.platform_permalink,
545
+ caption=args.caption,
546
+ published_at=args.published_at or utc_now_iso(),
547
+ result_screenshot_path=args.result_screenshot_path,
548
+ status=args.status,
549
+ )
550
+ append_publish_record(args.records, record)
551
+ print(json.dumps(record.to_dict(), ensure_ascii=False, sort_keys=True))
552
+ return
553
+
554
+ if args.command == "list-publish-records":
555
+ records = _filter_records(load_publish_records(args.records), args.platform, args.account_id, None)
556
+ for record in records:
557
+ print(json.dumps(record.to_dict(), ensure_ascii=False, sort_keys=True))
558
+ return
559
+
560
+ if args.command == "update-publish-record":
561
+ records = load_publish_records(args.records)
562
+ record = _find_record(records, args.record_id)
563
+ if args.platform_permalink:
564
+ record.platform_permalink = args.platform_permalink
565
+ if args.platform_post_id:
566
+ record.platform_post_id = args.platform_post_id
567
+ elif args.platform_permalink and not record.platform_post_id:
568
+ record.platform_post_id = parse_platform_post_id(record.platform, args.platform_permalink)
569
+ if args.caption is not None:
570
+ record.caption = args.caption
571
+ if args.published_at is not None:
572
+ record.published_at = args.published_at
573
+ if args.result_screenshot_path is not None:
574
+ record.result_screenshot_path = args.result_screenshot_path
575
+ if args.status is not None:
576
+ record.status = args.status
577
+ write_publish_records(args.records, records)
578
+ print(json.dumps(record.to_dict(), ensure_ascii=False, sort_keys=True))
579
+ return
580
+
581
+ if args.command == "add-metrics":
582
+ record = _find_record(load_publish_records(args.records), args.record_id)
583
+ try:
584
+ raw = json.loads(args.raw_json)
585
+ except Exception as exc:
586
+ raise SystemExit(f"Invalid --raw-json: {exc}") from exc
587
+ snapshot = build_manual_snapshot(
588
+ record,
589
+ views=args.views,
590
+ likes=args.likes,
591
+ comments=args.comments,
592
+ shares=args.shares,
593
+ saves=args.saves,
594
+ score=args.score,
595
+ raw=raw,
596
+ )
597
+ append_metrics_snapshot(args.metrics, snapshot)
598
+ print(json.dumps(snapshot.to_dict(), ensure_ascii=False, sort_keys=True))
599
+ return
600
+
601
+ if args.command == "collect-metrics":
602
+ records = _filter_records(load_publish_records(args.records), args.platform, args.account_id, args.record_id)
603
+ if not records:
604
+ print("no matching publish records")
605
+ return
606
+ for record in records:
607
+ if record.status != "published":
608
+ print(f"skip record_id={record.record_id} status={record.status}")
609
+ continue
610
+ try:
611
+ snapshot = collect_with_default_collector(record)
612
+ except MetricsCollectionError as exc:
613
+ print(f"skip record_id={record.record_id} platform={record.platform}: {exc}")
614
+ continue
615
+ append_metrics_snapshot(args.metrics, snapshot)
616
+ print(
617
+ "ok "
618
+ f"record_id={snapshot.record_id} source={snapshot.source} "
619
+ f"views={snapshot.views} likes={snapshot.likes} comments={snapshot.comments} "
620
+ f"shares={snapshot.shares} saves={snapshot.saves} score={snapshot.score}"
621
+ )
622
+ return
623
+
624
+ if args.command == "list-metrics":
625
+ snapshots = load_metrics_snapshots(args.metrics)
626
+ for snapshot in snapshots:
627
+ if args.record_id and snapshot.record_id != args.record_id:
628
+ continue
629
+ print(json.dumps(snapshot.to_dict(), ensure_ascii=False, sort_keys=True))
630
+ return
631
+
632
+ if args.command == "collect-tiktok-account-metrics-adb":
633
+ device = _get_device(args.devices, args.device)
634
+ account_id = args.account_id or f"{args.account_prefix}_{device.device_id.lower()}"
635
+ manual_values = _load_tiktok_account_metric_values(args.values_json, args.values_file)
636
+ collector = TikTokAccountMetricsAdbCollector(adb, artifact_root=args.artifact_root)
637
+ with device_lock(device.device_id):
638
+ snapshot = collector.collect(
639
+ device,
640
+ account_id=account_id,
641
+ periods=args.period,
642
+ resume_analytics=args.resume_analytics,
643
+ manual_values=manual_values,
644
+ )
645
+ if not args.no_append:
646
+ append_tiktok_account_metrics_snapshot(args.out, snapshot)
647
+ row = snapshot.to_dict()
648
+ if args.json:
649
+ print(json.dumps(row, ensure_ascii=False, sort_keys=True))
650
+ else:
651
+ complete = sum(1 for period in snapshot.periods.values() if period.has_all_metrics())
652
+ print(
653
+ f"{snapshot.device_id}: status={snapshot.status} account_id={snapshot.account_id} "
654
+ f"periods={complete}/{len(snapshot.periods)} duration={snapshot.duration_seconds}s "
655
+ f"artifact_dir={snapshot.artifact_dir} out={args.out if not args.no_append else '(not appended)'}"
656
+ )
657
+ if snapshot.status == "needs_human":
658
+ print("manual values required: review period screenshots, then rerun with --values-json or --values-file")
659
+ if snapshot.status == "failed":
660
+ print(f"error={snapshot.raw.get('error', '')}")
661
+ if snapshot.status == "failed" or (snapshot.status == "needs_human" and not args.allow_manual_required):
662
+ raise SystemExit(1)
663
+ return
664
+
665
+ if args.command == "list-tiktok-account-metrics":
666
+ snapshots = load_tiktok_account_metrics_snapshots(args.metrics)
667
+ for snapshot in snapshots:
668
+ if args.account_id and snapshot.account_id != args.account_id:
669
+ continue
670
+ if args.device_id and snapshot.device_id != args.device_id:
671
+ continue
672
+ print(json.dumps(snapshot.to_dict(), ensure_ascii=False, sort_keys=True))
673
+ return
674
+
675
+ if args.command == "collect-tiktok-video-metrics-adb":
676
+ device = _get_device(args.devices, args.device)
677
+ account_id = args.account_id or f"{args.account_prefix}_{device.device_id.lower()}"
678
+ manual_values = _load_tiktok_video_metric_values(args.values_json, args.values_file)
679
+ collector = TikTokVideoMetricsAdbCollector(adb, artifact_root=args.artifact_root)
680
+ with device_lock(device.device_id):
681
+ snapshot = collector.collect(
682
+ device,
683
+ account_id=account_id,
684
+ video_order=args.video_order,
685
+ resume_insights=args.resume_insights,
686
+ manual_values=manual_values,
687
+ )
688
+ if not args.no_append:
689
+ append_tiktok_video_metrics_snapshot(args.out, snapshot)
690
+ row = snapshot.to_dict()
691
+ if args.json:
692
+ print(json.dumps(row, ensure_ascii=False, sort_keys=True))
693
+ else:
694
+ print(
695
+ f"{snapshot.device_id}: status={snapshot.status} account_id={snapshot.account_id} "
696
+ f"published_at={snapshot.published_at or '(unknown)'} duration={snapshot.duration_seconds}s "
697
+ f"artifact_dir={snapshot.artifact_dir} out={args.out if not args.no_append else '(not appended)'}"
698
+ )
699
+ if snapshot.status == "needs_human":
700
+ print("manual values required: review the video-insights screenshot, then rerun with --values-json or --values-file")
701
+ if snapshot.status == "failed":
702
+ print(f"error={snapshot.raw.get('error', '')}")
703
+ if snapshot.status == "failed" or (snapshot.status == "needs_human" and not args.allow_manual_required):
704
+ raise SystemExit(1)
705
+ return
706
+
707
+ if args.command == "list-tiktok-video-metrics":
708
+ snapshots = load_tiktok_video_metrics_snapshots(args.metrics)
709
+ for snapshot in snapshots:
710
+ if args.account_id and snapshot.account_id != args.account_id:
711
+ continue
712
+ if args.device_id and snapshot.device_id != args.device_id:
713
+ continue
714
+ if args.published_at and snapshot.published_at != args.published_at:
715
+ continue
716
+ print(json.dumps(snapshot.to_dict(), ensure_ascii=False, sort_keys=True))
717
+ return
718
+
719
+ if args.command == "publish-tiktok-adb":
720
+ devices = load_devices(args.devices)
721
+ selected = _select_devices(devices, args.device, args.ranges)
722
+ if not selected:
723
+ raise SystemExit("No target devices. Use --device D01 or --range 1-10.")
724
+ if args.account_id and len(selected) != 1:
725
+ raise SystemExit("--account-id is only allowed with exactly one target device")
726
+ publisher = TikTokAdbPublisher(adb, records_path=args.records)
727
+
728
+ def run_one(device):
729
+ account_id = args.account_id or f"{args.account_prefix}_{device.device_id.lower()}"
730
+ return publisher.publish_video(
731
+ device,
732
+ video_path=args.video,
733
+ account_id=account_id,
734
+ caption=args.caption,
735
+ dry_run=args.dry_run,
736
+ allow_caption_fallback=args.allow_caption_fallback,
737
+ )
738
+
739
+ def print_one(result) -> None:
740
+ row = asdict(result)
741
+ if args.json:
742
+ print(json.dumps(row, ensure_ascii=False, sort_keys=True))
743
+ else:
744
+ print(
745
+ f"{result.device_id}: status={result.status} duration={result.duration_seconds}s "
746
+ f"record_id={result.record_id} screenshot={result.screenshot_path}"
747
+ + (f" error={result.error}" if result.error else "")
748
+ )
749
+
750
+ any_failed = _run_publish_jobs(selected, args.parallel, args.max_concurrency, run_one, print_one)
751
+ if any_failed:
752
+ raise SystemExit(1)
753
+ return
754
+
755
+ if args.command in {"publish-tiktok", "publish-tiktok-appium"}:
756
+ devices = load_devices(args.devices)
757
+ selected = _select_devices(devices, args.device, args.ranges)
758
+ if not selected:
759
+ raise SystemExit("No target devices. Use --device D03 or --range 1-5.")
760
+ if args.account_id and len(selected) != 1:
761
+ raise SystemExit("--account-id is only allowed with exactly one target device")
762
+ _ensure_appium_preflight(args, selected)
763
+ publisher = TikTokAppiumPublisher(adb, appium_server=args.appium_server, records_path=args.records)
764
+
765
+ def run_one(device):
766
+ account_id = args.account_id or f"{args.account_prefix}_{device.device_id.lower()}"
767
+ return publisher.publish_video(
768
+ device,
769
+ video_path=args.video,
770
+ account_id=account_id,
771
+ caption=args.caption,
772
+ dry_run=args.dry_run,
773
+ )
774
+
775
+ def print_one(result) -> None:
776
+ row = asdict(result)
777
+ if args.json:
778
+ print(json.dumps(row, ensure_ascii=False, sort_keys=True))
779
+ else:
780
+ print(
781
+ f"{result.device_id}: status={result.status} duration={result.duration_seconds}s "
782
+ f"record_id={result.record_id} screenshot={result.screenshot_path}"
783
+ + (f" error={result.error}" if result.error else "")
784
+ )
785
+
786
+ any_failed = _run_publish_jobs(selected, args.parallel, args.max_concurrency, run_one, print_one)
787
+ if any_failed:
788
+ raise SystemExit(1)
789
+ return
790
+
791
+ if args.command in {"publish-reddit", "publish-reddit-adb"}:
792
+ devices = load_devices(args.devices)
793
+ selected = _select_devices(devices, args.device, args.ranges)
794
+ if not selected:
795
+ raise SystemExit("No target devices. Use --device D01 or --range 1-10.")
796
+ if args.account_id and len(selected) != 1:
797
+ raise SystemExit("--account-id is only allowed with exactly one target device")
798
+ if args.reddit_username and len(selected) != 1:
799
+ raise SystemExit("--reddit-username is only allowed with exactly one target device")
800
+ publisher = RedditAdbPublisher(adb, records_path=args.records)
801
+
802
+ def run_one(device):
803
+ account_id = args.account_id or f"{args.account_prefix}_{device.device_id.lower()}"
804
+ return publisher.publish_post(
805
+ device,
806
+ post_type=args.post_type,
807
+ subreddit=args.subreddit,
808
+ title=args.title,
809
+ body=args.body,
810
+ link_url=args.link_url,
811
+ media_path=_media_path_for_post_type(args, args.post_type),
812
+ account_id=account_id,
813
+ reddit_username=args.reddit_username,
814
+ dry_run=args.dry_run,
815
+ fetch_permalink=not args.no_fetch_permalink,
816
+ )
817
+
818
+ def print_one(result) -> None:
819
+ row = asdict(result)
820
+ if args.json:
821
+ print(json.dumps(row, ensure_ascii=False, sort_keys=True))
822
+ else:
823
+ print(
824
+ f"{result.device_id}: status={result.status} duration={result.duration_seconds}s "
825
+ f"record_id={result.record_id} subreddit=r/{result.subreddit} screenshot={result.screenshot_path}"
826
+ + (f" post_id={result.platform_post_id}" if result.platform_post_id else "")
827
+ + (f" permalink={result.platform_permalink}" if result.platform_permalink else "")
828
+ + (f" error={result.error}" if result.error else "")
829
+ )
830
+
831
+ any_failed = _run_publish_jobs(selected, args.parallel, args.max_concurrency, run_one, print_one)
832
+ if any_failed:
833
+ raise SystemExit(1)
834
+ return
835
+
836
+ if args.command == "publish-facebook":
837
+ devices = load_devices(args.devices)
838
+ selected = _select_devices(devices, args.device, args.ranges)
839
+ if not selected:
840
+ raise SystemExit("No target devices. Use --device D03 or --range 1-5.")
841
+ if args.account_id and len(selected) != 1:
842
+ raise SystemExit("--account-id is only allowed with exactly one target device")
843
+ publisher = FacebookAdbPublisher(adb, records_path=args.records)
844
+
845
+ def run_one(device):
846
+ account_id = args.account_id or f"{args.account_prefix}_{device.device_id.lower()}"
847
+ return publisher.publish_post(
848
+ device,
849
+ post_type=args.post_type,
850
+ text=args.caption or args.text,
851
+ media_path=_media_path_for_post_type(args, args.post_type),
852
+ link_url=args.link_url,
853
+ account_id=account_id,
854
+ dry_run=args.dry_run,
855
+ app_package=args.app_package,
856
+ )
857
+
858
+ def print_one(result) -> None:
859
+ row = asdict(result)
860
+ if args.json:
861
+ print(json.dumps(row, ensure_ascii=False, sort_keys=True))
862
+ else:
863
+ print(
864
+ f"{result.device_id}: status={result.status} duration={result.duration_seconds}s "
865
+ f"record_id={result.record_id} screenshot={result.screenshot_path}"
866
+ + (f" error={result.error}" if result.error else "")
867
+ )
868
+
869
+ any_failed = _run_publish_jobs(selected, args.parallel, args.max_concurrency, run_one, print_one)
870
+ if any_failed:
871
+ raise SystemExit(1)
872
+ return
873
+
874
+ if args.command == "publish-instagram":
875
+ _apply_instagram_publish_policy(args)
876
+ devices = load_devices(args.devices)
877
+ selected = _select_devices(devices, args.device, args.ranges)
878
+ if not selected:
879
+ raise SystemExit("No target devices. Use --device D03 or --range 1-5.")
880
+ if args.account_id and len(selected) != 1:
881
+ raise SystemExit("--account-id is only allowed with exactly one target device")
882
+ if args.caption_input in {"auto", "appium"}:
883
+ _ensure_appium_preflight(args, selected)
884
+ publisher = InstagramAdbPublisher(adb, appium_server=args.appium_server, records_path=args.records)
885
+
886
+ def run_one(device):
887
+ account_id = args.account_id or f"{args.account_prefix}_{device.device_id.lower()}"
888
+ return publisher.publish_post(
889
+ device,
890
+ media_path=_instagram_media_path(args),
891
+ post_type=args.post_type,
892
+ account_id=account_id,
893
+ caption=args.caption,
894
+ dry_run=args.dry_run,
895
+ confirm_reels_public=args.confirm_reels_public,
896
+ caption_input=args.caption_input,
897
+ )
898
+
899
+ def print_one(result) -> None:
900
+ row = asdict(result)
901
+ if args.json:
902
+ print(json.dumps(row, ensure_ascii=False, sort_keys=True))
903
+ else:
904
+ print(
905
+ f"{result.device_id}: status={result.status} duration={result.duration_seconds}s "
906
+ f"record_id={result.record_id} screenshot={result.screenshot_path}"
907
+ + (f" error={result.error}" if result.error else "")
908
+ )
909
+
910
+ any_failed = _run_publish_jobs(selected, args.parallel, args.max_concurrency, run_one, print_one)
911
+ if any_failed:
912
+ raise SystemExit(1)
913
+ return
914
+
915
+ if args.command == "publish-x":
916
+ devices = load_devices(args.devices)
917
+ selected = _select_devices(devices, args.device, args.ranges)
918
+ if not selected:
919
+ raise SystemExit("No target devices. Use --device D03 or --range 1-5.")
920
+ if args.account_id and len(selected) != 1:
921
+ raise SystemExit("--account-id is only allowed with exactly one target device")
922
+ post_text = _x_publish_text(args)
923
+ if args.text_input in {"auto", "appium"} and not post_text.isascii():
924
+ _ensure_appium_preflight(args, selected)
925
+ publisher = XAdbPublisher(adb, appium_server=args.appium_server, records_path=args.records)
926
+
927
+ def run_one(device):
928
+ account_id = args.account_id or f"{args.account_prefix}_{device.device_id.lower()}"
929
+ return publisher.publish_post(
930
+ device,
931
+ post_type=args.post_type,
932
+ text=post_text,
933
+ link_url=args.link_url,
934
+ media_path=_media_path_for_post_type(args, args.post_type),
935
+ account_id=account_id,
936
+ dry_run=args.dry_run,
937
+ text_input=args.text_input,
938
+ verify_profile=not args.no_verify_profile,
939
+ )
940
+
941
+ def print_one(result) -> None:
942
+ row = asdict(result)
943
+ if args.json:
944
+ print(json.dumps(row, ensure_ascii=False, sort_keys=True))
945
+ else:
946
+ print(
947
+ f"{result.device_id}: status={result.status} duration={result.duration_seconds}s "
948
+ f"record_id={result.record_id} screenshot={result.screenshot_path}"
949
+ + (f" error={result.error}" if result.error else "")
950
+ )
951
+
952
+ any_failed = _run_publish_jobs(selected, args.parallel, args.max_concurrency, run_one, print_one)
953
+ if any_failed:
954
+ raise SystemExit(1)
955
+ return
956
+
957
+ if args.command == "publish-xiaohongshu":
958
+ _apply_xiaohongshu_publish_package(args)
959
+ _apply_xiaohongshu_live_publish_policy(args)
960
+ devices = load_devices(args.devices)
961
+ selected = _select_devices(devices, args.device, args.ranges)
962
+ if not selected:
963
+ raise SystemExit("No target devices. Use --device Dxx or --range 1-5.")
964
+ if args.account_id and len(selected) != 1:
965
+ raise SystemExit("--account-id is only allowed with exactly one target device")
966
+ _ensure_appium_preflight(args, selected)
967
+ publisher = XiaohongshuAdbPublisher(
968
+ adb,
969
+ appium_server=args.appium_server,
970
+ records_path=args.records,
971
+ flow_config_path=args.flow_config,
972
+ )
973
+
974
+ def run_one(device):
975
+ account_id = args.account_id or f"{args.account_prefix}_{device.device_id.lower()}"
976
+ return publisher.publish_note(
977
+ device,
978
+ media_path=_media_path_for_post_type(args, args.post_type),
979
+ media_paths=_xiaohongshu_media_paths(args),
980
+ post_type=args.post_type,
981
+ account_id=account_id,
982
+ title=args.title,
983
+ caption=args.caption,
984
+ tags=args.tags,
985
+ topics=args.topics,
986
+ dry_run=args.dry_run,
987
+ confirm_no_disclosure_needed=args.confirm_no_disclosure_needed,
988
+ copy_link_after_publish=args.copy_link_after_publish,
989
+ resume_post_form=args.resume_post_form,
990
+ )
991
+
992
+ def print_one(result) -> None:
993
+ row = asdict(result)
994
+ if args.json:
995
+ print(json.dumps(row, ensure_ascii=False, sort_keys=True))
996
+ else:
997
+ print(
998
+ f"{result.device_id}: status={result.status} duration={result.duration_seconds}s "
999
+ f"record_id={result.record_id} screenshot={result.screenshot_path}"
1000
+ + (f" error={result.error}" if result.error else "")
1001
+ )
1002
+
1003
+ any_failed = _run_publish_jobs(selected, args.parallel, args.max_concurrency, run_one, print_one)
1004
+ if any_failed:
1005
+ raise SystemExit(1)
1006
+ return
1007
+
1008
+ if args.command == "copy-xiaohongshu-link":
1009
+ device = _get_device(args.devices, args.device)
1010
+ _ensure_appium_preflight(args, [device])
1011
+ publisher = XiaohongshuAdbPublisher(
1012
+ adb,
1013
+ appium_server=args.appium_server,
1014
+ records_path=args.records,
1015
+ flow_config_path=args.flow_config,
1016
+ )
1017
+ with device_lock(device.device_id):
1018
+ result = publisher.copy_current_note_link(device)
1019
+ if result.platform_permalink and args.record_id:
1020
+ records = load_publish_records(args.records)
1021
+ record = _find_record(records, args.record_id)
1022
+ record.platform_permalink = result.platform_permalink
1023
+ write_publish_records(args.records, records)
1024
+ row = asdict(result)
1025
+ if args.json:
1026
+ print(json.dumps(row, ensure_ascii=False, sort_keys=True))
1027
+ else:
1028
+ print(
1029
+ f"{result.device_id}: status={result.status} duration={result.duration_seconds}s "
1030
+ f"screenshot={result.screenshot_path}"
1031
+ + (f" permalink={result.platform_permalink}" if result.platform_permalink else "")
1032
+ + (f" method={result.clipboard_method}" if result.clipboard_method else "")
1033
+ + (f" error={result.error}" if result.error else "")
1034
+ )
1035
+ if result.status in {"failed", "needs_human"}:
1036
+ raise SystemExit(1)
1037
+ return
1038
+
1039
+
1040
+ def _get_device(devices_path: str, device_id: str):
1041
+ devices = load_devices(devices_path)
1042
+ if device_id not in devices:
1043
+ raise SystemExit(f"Unknown device_id={device_id}")
1044
+ return devices[device_id]
1045
+
1046
+
1047
+ def _resolve_appium_udids(devices_path: str, device_ids: list[str], udids: list[str]) -> list[str]:
1048
+ resolved = [item for item in udids if item]
1049
+ if device_ids:
1050
+ devices = load_devices(devices_path)
1051
+ for device_id in device_ids:
1052
+ if device_id not in devices:
1053
+ raise SystemExit(f"Unknown device_id={device_id}")
1054
+ resolved.append(devices[device_id].adb_serial)
1055
+ seen: set[str] = set()
1056
+ out: list[str] = []
1057
+ for udid in resolved:
1058
+ if udid in seen:
1059
+ continue
1060
+ seen.add(udid)
1061
+ out.append(udid)
1062
+ return out
1063
+
1064
+
1065
+ def _ensure_appium_preflight(args, devices) -> None:
1066
+ if getattr(args, "no_ensure_appium", False):
1067
+ return
1068
+ try:
1069
+ ensure_appium_server(
1070
+ args.appium_server,
1071
+ udids=[device.adb_serial for device in devices],
1072
+ smoke=not getattr(args, "no_appium_smoke", False),
1073
+ )
1074
+ except AppiumEnsureError as exc:
1075
+ raise SystemExit(f"Appium preflight failed: {exc}") from exc
1076
+
1077
+
1078
+ def _load_tiktok_account_metric_values(values_json: str, values_file: str):
1079
+ if values_json and values_file:
1080
+ raise SystemExit("Use only one of --values-json or --values-file")
1081
+ if values_file:
1082
+ try:
1083
+ values_json = Path(values_file).expanduser().read_text(encoding="utf-8")
1084
+ except Exception as exc:
1085
+ raise SystemExit(f"Could not read --values-file: {exc}") from exc
1086
+ try:
1087
+ return parse_manual_values_json(values_json)
1088
+ except Exception as exc:
1089
+ raise SystemExit(f"Invalid TikTok account metric values JSON: {exc}") from exc
1090
+
1091
+
1092
+ def _load_tiktok_video_metric_values(values_json: str, values_file: str):
1093
+ if values_json and values_file:
1094
+ raise SystemExit("Use only one of --values-json or --values-file")
1095
+ if values_file:
1096
+ try:
1097
+ values_json = Path(values_file).expanduser().read_text(encoding="utf-8")
1098
+ except Exception as exc:
1099
+ raise SystemExit(f"Could not read --values-file: {exc}") from exc
1100
+ try:
1101
+ return parse_manual_video_values_json(values_json)
1102
+ except Exception as exc:
1103
+ raise SystemExit(f"Invalid TikTok video metric values JSON: {exc}") from exc
1104
+
1105
+
1106
+ def _add_publish_tiktok_appium_args(parser: argparse.ArgumentParser) -> None:
1107
+ parser.add_argument("--devices", default="data/devices.csv")
1108
+ parser.add_argument("--records", default="data/publish_records.jsonl")
1109
+ parser.add_argument("--appium-server", default="http://127.0.0.1:4723")
1110
+ _add_appium_preflight_args(parser)
1111
+ parser.add_argument("--video", required=True)
1112
+ parser.add_argument("--caption", default="", help="Caption/description text to enter before publishing.")
1113
+ parser.add_argument("--device", action="append", default=[], help="Device id, e.g. D03. Can be repeated.")
1114
+ parser.add_argument("--range", dest="ranges", action="append", default=[], help="Device range, e.g. 1-5 or D01-D05.")
1115
+ parser.add_argument("--account-id", default="", help="Single account id. Use only with one target device.")
1116
+ parser.add_argument("--account-prefix", default="tiktok", help="Fallback account id prefix for multi-device runs.")
1117
+ parser.add_argument("--dry-run", action="store_true", help="Stop at TikTok post form without tapping publish.")
1118
+ parser.add_argument("--parallel", action="store_true", help="Run target devices concurrently. Hard-capped at 5.")
1119
+ parser.add_argument("--max-concurrency", type=int, default=MAX_PARALLEL_DEVICES, help="Max concurrent devices, 1-5.")
1120
+ parser.add_argument("--json", action="store_true", help="Print JSON lines")
1121
+
1122
+
1123
+ def _add_publish_xiaohongshu_args(parser: argparse.ArgumentParser) -> None:
1124
+ parser.add_argument("--devices", default="data/devices.csv")
1125
+ parser.add_argument("--records", default="data/publish_records.jsonl")
1126
+ parser.add_argument("--appium-server", default="http://127.0.0.1:4723")
1127
+ _add_appium_preflight_args(parser)
1128
+ parser.add_argument("--flow-config", default="configs/platforms/xiaohongshu_d03.json")
1129
+ parser.add_argument(
1130
+ "--publish-package",
1131
+ default="",
1132
+ help="Path to a Xiaohongshu publish_package*.json file, or a directory containing publish_package.images-ready.json.",
1133
+ )
1134
+ parser.add_argument(
1135
+ "--publish-policy",
1136
+ default=DEFAULT_PUBLISH_POLICY_PATH,
1137
+ help="Publish policy JSON used to auto-confirm live publish prompts and bridge package approval/disclosure defaults.",
1138
+ )
1139
+ parser.add_argument("--post-type", choices=["image", "video"], default="", help="Xiaohongshu note type.")
1140
+ parser.add_argument("--media", default="", help="Media file path. Use with --post-type image/video.")
1141
+ parser.add_argument("--image", default="", help="Image file path for --post-type image.")
1142
+ parser.add_argument("--images", action="append", default=[], help="Image file path for multi-image Xiaohongshu notes. Can be repeated.")
1143
+ parser.add_argument("--video", default="", help="Video file path for --post-type video. Kept for backward compatibility.")
1144
+ parser.add_argument("--title", default="")
1145
+ parser.add_argument("--caption", default="")
1146
+ parser.add_argument("--caption-file", default="", help="Read Xiaohongshu caption/body text from a local file.")
1147
+ parser.add_argument("--tag", dest="tags", action="append", default=[], help="Tag to type manually in the caption as a hashtag. Can be repeated.")
1148
+ parser.add_argument("--topic", dest="topics", action="append", default=[], help="Topic tag to type manually in the caption as a hashtag. Can be repeated.")
1149
+ parser.add_argument("--device", action="append", default=[], help="Device id, e.g. D03. Can be repeated.")
1150
+ parser.add_argument("--range", dest="ranges", action="append", default=[], help="Device range, e.g. 1-5 or D01-D05.")
1151
+ parser.add_argument("--account-id", default="", help="Single account id. Use only with one target device.")
1152
+ parser.add_argument("--account-prefix", default="xhs", help="Fallback account id prefix for multi-device runs.")
1153
+ parser.add_argument("--dry-run", action="store_true", help="Stop at Xiaohongshu post form without tapping publish.")
1154
+ parser.add_argument(
1155
+ "--resume-post-form",
1156
+ action="store_true",
1157
+ help="Continue from the current Xiaohongshu post form and skip media push/selection.",
1158
+ )
1159
+ parser.add_argument(
1160
+ "--confirm-no-disclosure-needed",
1161
+ action="store_true",
1162
+ help="Optional backward-compatible manual confirmation; live publish auto-confirms this from --publish-policy.",
1163
+ )
1164
+ parser.add_argument(
1165
+ "--copy-link-after-publish",
1166
+ action="store_true",
1167
+ help="After a successful publish, open the share sheet, tap copy link, and try to read the copied URL.",
1168
+ )
1169
+ parser.add_argument("--parallel", action="store_true", help="Run target devices concurrently. Hard-capped at 5.")
1170
+ parser.add_argument("--max-concurrency", type=int, default=MAX_PARALLEL_DEVICES, help="Max concurrent devices, 1-5.")
1171
+ parser.add_argument("--json", action="store_true", help="Print JSON lines")
1172
+
1173
+
1174
+ def _add_publish_reddit_args(parser: argparse.ArgumentParser) -> None:
1175
+ parser.add_argument("--devices", default="data/devices.csv")
1176
+ parser.add_argument("--records", default="data/publish_records.jsonl")
1177
+ parser.add_argument("--post-type", choices=["text", "link", "image", "video"], default="text")
1178
+ parser.add_argument("--subreddit", required=True, help="Target subreddit, e.g. test or r/test.")
1179
+ parser.add_argument("--title", required=True)
1180
+ parser.add_argument("--body", default="")
1181
+ parser.add_argument("--link-url", default="", help="URL for --post-type link.")
1182
+ parser.add_argument("--media", default="", help="Media file path for --post-type image/video.")
1183
+ parser.add_argument("--image", default="", help="Image file path for --post-type image.")
1184
+ parser.add_argument("--video", default="", help="Video file path for --post-type video.")
1185
+ parser.add_argument("--device", action="append", default=[], help="Device id, e.g. D01. Can be repeated.")
1186
+ parser.add_argument("--range", dest="ranges", action="append", default=[], help="Device range, e.g. 1-10 or D01-D10.")
1187
+ parser.add_argument("--account-id", default="", help="Single account id. Use only with one target device.")
1188
+ parser.add_argument("--account-prefix", default="reddit", help="Fallback account id prefix for multi-device runs.")
1189
+ parser.add_argument("--reddit-username", default="", help="Reddit username for post permalink lookup. Use only with one target device.")
1190
+ parser.add_argument("--dry-run", action="store_true", help="Stop at Reddit post form without tapping publish.")
1191
+ parser.add_argument("--no-fetch-permalink", action="store_true", help="Do not query public Reddit JSON after publish.")
1192
+ parser.add_argument("--parallel", action="store_true", help="Run target devices concurrently. Hard-capped at 5.")
1193
+ parser.add_argument("--max-concurrency", type=int, default=MAX_PARALLEL_DEVICES, help="Max concurrent devices, 1-5.")
1194
+ parser.add_argument("--json", action="store_true", help="Print JSON lines")
1195
+
1196
+
1197
+ def _add_publish_facebook_args(parser: argparse.ArgumentParser) -> None:
1198
+ parser.add_argument("--devices", default="data/devices.csv")
1199
+ parser.add_argument("--records", default="data/publish_records.jsonl")
1200
+ parser.add_argument("--post-type", choices=["text", "link", "image", "video"], default="text")
1201
+ parser.add_argument("--text", default="", help="Text body to publish. Current Facebook Lite POC supports ASCII single-line text.")
1202
+ parser.add_argument("--caption", default="", help="Caption text for image/video/link posts. Overrides --text when present.")
1203
+ parser.add_argument("--link-url", default="", help="URL for --post-type link.")
1204
+ parser.add_argument("--media", default="", help="Media file path for --post-type image/video.")
1205
+ parser.add_argument("--image", default="", help="Image file path for --post-type image.")
1206
+ parser.add_argument("--video", default="", help="Video file path for --post-type video.")
1207
+ parser.add_argument("--device", action="append", default=[], help="Device id, e.g. D03. Can be repeated.")
1208
+ parser.add_argument("--range", dest="ranges", action="append", default=[], help="Device range, e.g. 1-5 or D01-D05.")
1209
+ parser.add_argument("--account-id", default="", help="Single account id. Use only with one target device.")
1210
+ parser.add_argument("--account-prefix", default="facebook", help="Fallback account id prefix for multi-device runs.")
1211
+ parser.add_argument("--app-package", default="com.facebook.lite", help="Facebook package to launch. Current calibrated flow is com.facebook.lite.")
1212
+ parser.add_argument("--dry-run", action="store_true", help="Stop at Facebook post form without tapping POST.")
1213
+ parser.add_argument("--parallel", action="store_true", help="Run target devices concurrently. Hard-capped at 5.")
1214
+ parser.add_argument("--max-concurrency", type=int, default=MAX_PARALLEL_DEVICES, help="Max concurrent devices, 1-5.")
1215
+ parser.add_argument("--json", action="store_true", help="Print JSON lines")
1216
+
1217
+
1218
+ def _add_publish_x_args(parser: argparse.ArgumentParser) -> None:
1219
+ parser.add_argument("--devices", default="data/devices.csv")
1220
+ parser.add_argument("--records", default="data/publish_records.jsonl")
1221
+ parser.add_argument("--appium-server", default="http://127.0.0.1:4723")
1222
+ _add_appium_preflight_args(parser)
1223
+ parser.add_argument("--post-type", choices=["text", "link", "image"], default="text")
1224
+ parser.add_argument("--title", default="", help=argparse.SUPPRESS)
1225
+ parser.add_argument("--body", default="", help="Post body text.")
1226
+ parser.add_argument("--text", default="", help=argparse.SUPPRESS)
1227
+ parser.add_argument("--caption", default="", help=argparse.SUPPRESS)
1228
+ parser.add_argument("--link-url", default="", help="URL for --post-type link.")
1229
+ parser.add_argument("--media", default="", help="Media file path for --post-type image.")
1230
+ parser.add_argument("--image", default="", help="Image file path for --post-type image.")
1231
+ parser.add_argument("--text-input", choices=["auto", "appium", "adb"], default="auto")
1232
+ parser.add_argument("--device", action="append", default=[], help="Device id, e.g. D03. Can be repeated.")
1233
+ parser.add_argument("--range", dest="ranges", action="append", default=[], help="Device range, e.g. 1-5 or D01-D05.")
1234
+ parser.add_argument("--account-id", default="", help="Single account id. Use only with one target device.")
1235
+ parser.add_argument("--account-prefix", default="x", help="Fallback account id prefix for multi-device runs.")
1236
+ parser.add_argument("--dry-run", action="store_true", help="Stop at X compose form without tapping Post.")
1237
+ parser.add_argument("--no-verify-profile", action="store_true", help="Do not open the X profile page to verify the published post text.")
1238
+ parser.add_argument("--parallel", action="store_true", help="Run target devices concurrently. Hard-capped at 5.")
1239
+ parser.add_argument("--max-concurrency", type=int, default=MAX_PARALLEL_DEVICES, help="Max concurrent devices, 1-5.")
1240
+ parser.add_argument("--json", action="store_true", help="Print JSON lines")
1241
+
1242
+
1243
+ def _add_publish_instagram_args(parser: argparse.ArgumentParser) -> None:
1244
+ parser.add_argument("--devices", default="data/devices.csv")
1245
+ parser.add_argument("--records", default="data/publish_records.jsonl")
1246
+ parser.add_argument("--appium-server", default="http://127.0.0.1:4723")
1247
+ _add_appium_preflight_args(parser)
1248
+ parser.add_argument(
1249
+ "--publish-policy",
1250
+ default=DEFAULT_PUBLISH_POLICY_PATH,
1251
+ help="Publish policy JSON used to auto-confirm live publish prompts.",
1252
+ )
1253
+ parser.add_argument(
1254
+ "--post-type",
1255
+ choices=["reel", "post-image", "post-video", "image", "video"],
1256
+ default="reel",
1257
+ help="Instagram type. image/video are aliases for ordinary feed Post image/video.",
1258
+ )
1259
+ parser.add_argument("--media", default="", help="Media file path for selected post type.")
1260
+ parser.add_argument("--image", default="", help="Image file path for --post-type post-image/image.")
1261
+ parser.add_argument("--video", default="", help="Video file path. Kept for Reels backward compatibility.")
1262
+ parser.add_argument("--caption", default="")
1263
+ parser.add_argument("--caption-input", choices=["auto", "appium", "adb"], default="auto")
1264
+ parser.add_argument("--device", action="append", default=[], help="Device id, e.g. D03. Can be repeated.")
1265
+ parser.add_argument("--range", dest="ranges", action="append", default=[], help="Device range, e.g. 1-5 or D01-D05.")
1266
+ parser.add_argument("--account-id", default="", help="Single account id. Use only with one target device.")
1267
+ parser.add_argument("--account-prefix", default="instagram", help="Fallback account id prefix for multi-device runs.")
1268
+ parser.add_argument("--dry-run", action="store_true", help="Stop at Instagram share form without tapping share.")
1269
+ parser.add_argument(
1270
+ "--confirm-reels-public",
1271
+ action="store_true",
1272
+ help="Optional backward-compatible manual confirmation; live publish auto-confirms this from --publish-policy.",
1273
+ )
1274
+ parser.add_argument("--parallel", action="store_true", help="Run target devices concurrently. Hard-capped at 5.")
1275
+ parser.add_argument("--max-concurrency", type=int, default=MAX_PARALLEL_DEVICES, help="Max concurrent devices, 1-5.")
1276
+ parser.add_argument("--json", action="store_true", help="Print JSON lines")
1277
+
1278
+
1279
+ def _add_appium_preflight_args(parser: argparse.ArgumentParser) -> None:
1280
+ parser.add_argument(
1281
+ "--no-ensure-appium",
1282
+ action="store_true",
1283
+ help="Do not auto-start/check Appium before this Appium-backed flow.",
1284
+ )
1285
+ parser.add_argument(
1286
+ "--no-appium-smoke",
1287
+ action="store_true",
1288
+ help="Skip the pre-publish UiAutomator2 session smoke check.",
1289
+ )
1290
+
1291
+
1292
+ def _load_publish_policy_for_args(args) -> dict:
1293
+ if hasattr(args, "_publish_policy"):
1294
+ return args._publish_policy
1295
+ try:
1296
+ policy = load_publish_policy(getattr(args, "publish_policy", DEFAULT_PUBLISH_POLICY_PATH))
1297
+ except Exception as exc:
1298
+ raise SystemExit(f"Could not read publish policy: {exc}") from exc
1299
+ args._publish_policy = policy
1300
+ return policy
1301
+
1302
+
1303
+ def _apply_instagram_publish_policy(args) -> None:
1304
+ if args.dry_run or args.confirm_reels_public:
1305
+ return
1306
+ policy = _load_publish_policy_for_args(args)
1307
+ if should_auto_confirm_instagram_reels_public(policy):
1308
+ args.confirm_reels_public = True
1309
+
1310
+
1311
+ def _apply_xiaohongshu_live_publish_policy(args) -> None:
1312
+ if args.dry_run or args.confirm_no_disclosure_needed:
1313
+ return
1314
+ policy = _load_publish_policy_for_args(args)
1315
+ if should_auto_confirm_xiaohongshu_no_disclosure(policy):
1316
+ args.confirm_no_disclosure_needed = True
1317
+
1318
+
1319
+ def _apply_xiaohongshu_publish_package(args) -> None:
1320
+ package: dict | None = None
1321
+ package_path: Path | None = None
1322
+ if args.publish_package:
1323
+ package_path = _resolve_xiaohongshu_package_path(args.publish_package)
1324
+ with package_path.open(encoding="utf-8") as handle:
1325
+ loaded = json.load(handle)
1326
+ if not isinstance(loaded, dict):
1327
+ raise SystemExit(f"Xiaohongshu publish package must be a JSON object: {package_path}")
1328
+ package = loaded
1329
+ _apply_xiaohongshu_package_policy(args, package)
1330
+
1331
+ note = dict(package.get("note") or {}) if package else {}
1332
+ instructions = dict(package.get("publish_instructions") or {}) if package else {}
1333
+
1334
+ if not args.post_type:
1335
+ args.post_type = str(instructions.get("post_type") or note.get("post_type") or "video")
1336
+
1337
+ if not args.title:
1338
+ args.title = str(instructions.get("title") or note.get("selected_title") or "")
1339
+ if not args.title.strip():
1340
+ raise SystemExit("Xiaohongshu --title is required unless --publish-package provides note.selected_title or publish_instructions.title")
1341
+
1342
+ if package and args.post_type == "image" and not args.images:
1343
+ raw_images = (
1344
+ package.get("absolute_upload_order")
1345
+ or instructions.get("images")
1346
+ or package.get("upload_order")
1347
+ or package.get("backup_jpg_upload_order")
1348
+ or []
1349
+ )
1350
+ args.images = [str(_resolve_package_relative_path(str(item), package_path)) for item in raw_images]
1351
+ if args.images and not args.image and not args.media:
1352
+ args.image = args.images[0]
1353
+
1354
+ caption = args.caption
1355
+ caption_file = args.caption_file or str(instructions.get("caption_file") or "")
1356
+ if not caption and caption_file:
1357
+ if not package_path:
1358
+ caption_path = Path(caption_file).expanduser()
1359
+ else:
1360
+ caption_path = _resolve_package_relative_path(caption_file, package_path)
1361
+ caption = caption_path.read_text(encoding="utf-8").strip()
1362
+ if not caption:
1363
+ caption = str(note.get("body") or "")
1364
+
1365
+ package_tags = _string_list(instructions.get("tags") or note.get("tags") or [])
1366
+ package_topics = _string_list(instructions.get("topics") or note.get("topics") or [])
1367
+ args.caption = caption.strip()
1368
+ args.tags = [*package_tags, *list(args.tags or [])]
1369
+ args.topics = [*package_topics, *list(args.topics or [])]
1370
+
1371
+
1372
+ def _apply_xiaohongshu_package_policy(args, package: dict) -> None:
1373
+ try:
1374
+ policy = _load_publish_policy_for_args(args)
1375
+ decision = decide_xiaohongshu_publish_package_policy(package, policy)
1376
+ except Exception as exc:
1377
+ raise SystemExit(f"Could not apply Xiaohongshu publish policy: {exc}") from exc
1378
+
1379
+ args.xiaohongshu_policy_decision = decision
1380
+ if (
1381
+ args.confirm_no_disclosure_needed
1382
+ and any("requires declaration" in item for item in decision.blockers)
1383
+ and xiaohongshu_package_disclosure_is_strict(policy)
1384
+ ):
1385
+ raise SystemExit(
1386
+ "Xiaohongshu package says a content/commercial declaration is required; "
1387
+ "refusing --confirm-no-disclosure-needed"
1388
+ )
1389
+ if not args.dry_run and not args.confirm_no_disclosure_needed and decision.auto_confirm_no_disclosure_needed:
1390
+ args.confirm_no_disclosure_needed = True
1391
+
1392
+
1393
+ def _resolve_xiaohongshu_package_path(value: str) -> Path:
1394
+ path = Path(value).expanduser()
1395
+ if path.is_dir():
1396
+ preferred = path / "publish_package.images-ready.json"
1397
+ if preferred.exists():
1398
+ return preferred
1399
+ matches = sorted(path.glob("publish_package*.json"))
1400
+ if matches:
1401
+ return matches[0]
1402
+ raise SystemExit(f"No publish_package*.json found in {path}")
1403
+ if not path.exists():
1404
+ raise SystemExit(f"Xiaohongshu publish package not found: {path}")
1405
+ return path
1406
+
1407
+
1408
+ def _resolve_package_relative_path(value: str, package_path: Path) -> Path:
1409
+ raw = Path(value).expanduser()
1410
+ if raw.is_absolute():
1411
+ return raw
1412
+ candidates = [Path.cwd() / raw, package_path.parent / raw]
1413
+ candidates.extend(parent / raw for parent in package_path.parent.parents)
1414
+ for candidate in candidates:
1415
+ if candidate.exists():
1416
+ return candidate
1417
+ return package_path.parent / raw
1418
+
1419
+
1420
+ def _string_list(value) -> list[str]:
1421
+ if isinstance(value, list):
1422
+ return [str(item) for item in value if str(item).strip()]
1423
+ if isinstance(value, str) and value.strip():
1424
+ return [value]
1425
+ return []
1426
+
1427
+
1428
+ def _media_path_for_post_type(args, post_type: str) -> str | None:
1429
+ normalized = post_type.strip().lower().replace("_", "-")
1430
+ if normalized in {"image", "photo", "post-image", "post-photo"}:
1431
+ path = getattr(args, "media", "") or getattr(args, "image", "")
1432
+ if not path:
1433
+ raise SystemExit("--post-type image requires --image or --media")
1434
+ return path
1435
+ if normalized in {"video", "reel", "reels", "post-video"}:
1436
+ path = getattr(args, "media", "") or getattr(args, "video", "")
1437
+ if not path:
1438
+ raise SystemExit(f"--post-type {post_type} requires --video or --media")
1439
+ return path
1440
+ return None
1441
+
1442
+
1443
+ def _instagram_media_path(args) -> str:
1444
+ normalized = args.post_type.strip().lower().replace("_", "-")
1445
+ if normalized in {"reel", "reels"}:
1446
+ path = args.media or args.video
1447
+ if not path:
1448
+ raise SystemExit("Instagram Reels requires --video or --media")
1449
+ return path
1450
+ if normalized in {"image", "photo", "post", "post-image", "post-photo"}:
1451
+ path = args.media or args.image
1452
+ if not path:
1453
+ raise SystemExit("Instagram image Post requires --image or --media")
1454
+ return path
1455
+ if normalized in {"video", "post-video"}:
1456
+ path = args.media or args.video
1457
+ if not path:
1458
+ raise SystemExit("Instagram video Post requires --video or --media")
1459
+ return path
1460
+ raise SystemExit(f"unsupported Instagram --post-type {args.post_type!r}")
1461
+
1462
+
1463
+ def _xiaohongshu_media_paths(args) -> list[str] | None:
1464
+ normalized = args.post_type.strip().lower().replace("_", "-")
1465
+ if normalized != "image" or not getattr(args, "images", []):
1466
+ return None
1467
+ return args.images
1468
+
1469
+
1470
+ def _x_publish_text(args) -> str:
1471
+ for value in (args.body, args.text, args.caption, args.title):
1472
+ if value and value.strip():
1473
+ return value.strip()
1474
+ return ""
1475
+
1476
+
1477
+ def _find_record(records: list[PublishRecord], record_id: str) -> PublishRecord:
1478
+ for record in records:
1479
+ if record.record_id == record_id:
1480
+ return record
1481
+ raise SystemExit(f"Unknown record_id={record_id}")
1482
+
1483
+
1484
+ def _filter_records(
1485
+ records: list[PublishRecord],
1486
+ platform: str | None,
1487
+ account_id: str | None,
1488
+ record_id: str | None,
1489
+ ) -> list[PublishRecord]:
1490
+ out = records
1491
+ if platform:
1492
+ out = [record for record in out if record.platform == platform.lower()]
1493
+ if account_id:
1494
+ out = [record for record in out if record.account_id == account_id]
1495
+ if record_id:
1496
+ out = [record for record in out if record.record_id == record_id]
1497
+ return out
1498
+
1499
+
1500
+ def _run_publish_jobs(devices: list[object], parallel: bool, max_concurrency: int, run_one, print_one) -> bool:
1501
+ if max_concurrency < 1:
1502
+ raise SystemExit("--max-concurrency must be >= 1")
1503
+ if max_concurrency > MAX_PARALLEL_DEVICES:
1504
+ raise SystemExit(f"--max-concurrency is capped at {MAX_PARALLEL_DEVICES}")
1505
+
1506
+ def run_locked(device):
1507
+ with publish_slot(MAX_PARALLEL_DEVICES):
1508
+ with device_lock(device.device_id):
1509
+ return run_one(device)
1510
+
1511
+ any_failed = False
1512
+ if not parallel or len(devices) <= 1:
1513
+ for device in devices:
1514
+ try:
1515
+ result = run_locked(device)
1516
+ except DeviceLockError as exc:
1517
+ print(f"{device.device_id}: status=failed error={exc}")
1518
+ any_failed = True
1519
+ continue
1520
+ print_one(result)
1521
+ any_failed = any_failed or getattr(result, "status", "") in {"failed", "needs_human"}
1522
+ return any_failed
1523
+
1524
+ workers = min(max_concurrency, MAX_PARALLEL_DEVICES, len(devices))
1525
+ with ThreadPoolExecutor(max_workers=workers) as pool:
1526
+ futures = {pool.submit(run_locked, device): device for device in devices}
1527
+ for future in as_completed(futures):
1528
+ device = futures[future]
1529
+ try:
1530
+ result = future.result()
1531
+ except DeviceLockError as exc:
1532
+ print(f"{device.device_id}: status=failed error={exc}")
1533
+ any_failed = True
1534
+ continue
1535
+ print_one(result)
1536
+ any_failed = any_failed or getattr(result, "status", "") in {"failed", "needs_human"}
1537
+ return any_failed
1538
+
1539
+
1540
+ def _select_devices(devices: dict[str, object], device_ids: list[str], ranges: list[str]):
1541
+ selected_ids: list[str] = []
1542
+ for device_id in device_ids:
1543
+ selected_ids.append(_normalize_device_id(device_id))
1544
+ for text in ranges:
1545
+ selected_ids.extend(_expand_device_range(text))
1546
+ if not selected_ids:
1547
+ return []
1548
+
1549
+ seen: set[str] = set()
1550
+ selected = []
1551
+ for device_id in selected_ids:
1552
+ if device_id in seen:
1553
+ continue
1554
+ seen.add(device_id)
1555
+ if device_id not in devices:
1556
+ raise SystemExit(f"Unknown device_id={device_id}")
1557
+ device = devices[device_id]
1558
+ if getattr(device, "status", "active") != "active":
1559
+ print(f"{device_id}: skipped status={getattr(device, 'status', '')}")
1560
+ continue
1561
+ selected.append(device)
1562
+ return selected
1563
+
1564
+
1565
+ def _expand_device_range(text: str) -> list[str]:
1566
+ raw = text.strip()
1567
+ if "," in raw:
1568
+ ids: list[str] = []
1569
+ for part in raw.split(","):
1570
+ ids.extend(_expand_device_range(part))
1571
+ return ids
1572
+ if "-" not in raw:
1573
+ return [_normalize_device_id(raw)]
1574
+ left, right = raw.split("-", 1)
1575
+ start = _device_number(left)
1576
+ end = _device_number(right)
1577
+ if start > end:
1578
+ raise SystemExit(f"Invalid device range={text!r}")
1579
+ return [f"D{i:02d}" for i in range(start, end + 1)]
1580
+
1581
+
1582
+ def _normalize_device_id(text: str) -> str:
1583
+ value = text.strip().upper()
1584
+ if value.startswith("D"):
1585
+ number = _device_number(value)
1586
+ return f"D{number:02d}"
1587
+ if value.isdigit():
1588
+ return f"D{int(value):02d}"
1589
+ return value
1590
+
1591
+
1592
+ def _device_number(text: str) -> int:
1593
+ value = text.strip().upper()
1594
+ if value.startswith("D"):
1595
+ value = value[1:]
1596
+ if not value.isdigit():
1597
+ raise SystemExit(f"Invalid device id/range part={text!r}")
1598
+ return int(value)
1599
+
1600
+
1601
+ def _ip_from_serial(serial: str) -> str:
1602
+ if ":" in serial:
1603
+ return serial.split(":", 1)[0]
1604
+ return ""
1605
+
1606
+
1607
+ if __name__ == "__main__":
1608
+ main()