@11agents/cli 0.1.24 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/bin/11agents.js +12 -0
- package/mobile-runtime/README.md +19 -0
- package/mobile-runtime/configs/platforms/xiaohongshu_d01.json +73 -0
- package/mobile-runtime/configs/platforms/xiaohongshu_d02.json +70 -0
- package/mobile-runtime/configs/platforms/xiaohongshu_d03.json +73 -0
- package/mobile-runtime/configs/publish_policy.json +40 -0
- package/mobile-runtime/data-templates/README.md +4 -0
- package/mobile-runtime/data-templates/accounts.example.csv +6 -0
- package/mobile-runtime/data-templates/devices.example.csv +2 -0
- package/mobile-runtime/data-templates/publish_records.example.jsonl +2 -0
- package/mobile-runtime/data-templates/tasks.example.jsonl +5 -0
- package/mobile-runtime/data-templates/video_metrics.example.jsonl +1 -0
- package/mobile-runtime/python/pyproject.toml +34 -0
- package/mobile-runtime/python/src/device_control/__init__.py +5 -0
- package/mobile-runtime/python/src/device_control/adapters/__init__.py +31 -0
- package/mobile-runtime/python/src/device_control/adapters/base.py +43 -0
- package/mobile-runtime/python/src/device_control/adapters/facebook.py +30 -0
- package/mobile-runtime/python/src/device_control/adapters/instagram.py +25 -0
- package/mobile-runtime/python/src/device_control/adapters/reddit.py +29 -0
- package/mobile-runtime/python/src/device_control/adapters/tiktok.py +25 -0
- package/mobile-runtime/python/src/device_control/adapters/x.py +29 -0
- package/mobile-runtime/python/src/device_control/adapters/xiaohongshu.py +26 -0
- package/mobile-runtime/python/src/device_control/adb.py +161 -0
- package/mobile-runtime/python/src/device_control/appium_client.py +131 -0
- package/mobile-runtime/python/src/device_control/appium_manager.py +403 -0
- package/mobile-runtime/python/src/device_control/cli.py +1608 -0
- package/mobile-runtime/python/src/device_control/entrypoints.py +60 -0
- package/mobile-runtime/python/src/device_control/locks.py +162 -0
- package/mobile-runtime/python/src/device_control/metrics/__init__.py +33 -0
- package/mobile-runtime/python/src/device_control/metrics/collectors.py +320 -0
- package/mobile-runtime/python/src/device_control/metrics/tiktok_account_adb.py +367 -0
- package/mobile-runtime/python/src/device_control/metrics/tiktok_video_adb.py +714 -0
- package/mobile-runtime/python/src/device_control/models.py +439 -0
- package/mobile-runtime/python/src/device_control/publish_policy.py +173 -0
- package/mobile-runtime/python/src/device_control/publishers/__init__.py +24 -0
- package/mobile-runtime/python/src/device_control/publishers/facebook_adb.py +494 -0
- package/mobile-runtime/python/src/device_control/publishers/instagram_adb.py +663 -0
- package/mobile-runtime/python/src/device_control/publishers/reddit_adb.py +595 -0
- package/mobile-runtime/python/src/device_control/publishers/tiktok_adb.py +477 -0
- package/mobile-runtime/python/src/device_control/publishers/tiktok_appium.py +259 -0
- package/mobile-runtime/python/src/device_control/publishers/ui_helpers.py +372 -0
- package/mobile-runtime/python/src/device_control/publishers/x_adb.py +636 -0
- package/mobile-runtime/python/src/device_control/publishers/xiaohongshu_adb.py +1143 -0
- package/mobile-runtime/python/src/device_control/store.py +137 -0
- package/mobile-runtime/scripts/appium_smoke.py +71 -0
- package/mobile-runtime/skills/android-collect-tiktok-metrics/SKILL.md +60 -0
- package/mobile-runtime/skills/android-collect-tiktok-metrics/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-group-control-cli/SKILL.md +76 -0
- package/mobile-runtime/skills/android-group-control-cli/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-group-control-cli/references/command-reference.md +122 -0
- package/mobile-runtime/skills/android-publish-facebook/SKILL.md +41 -0
- package/mobile-runtime/skills/android-publish-facebook/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-instagram/SKILL.md +45 -0
- package/mobile-runtime/skills/android-publish-instagram/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-reddit/SKILL.md +41 -0
- package/mobile-runtime/skills/android-publish-reddit/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-tiktok/SKILL.md +43 -0
- package/mobile-runtime/skills/android-publish-tiktok/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-x/SKILL.md +40 -0
- package/mobile-runtime/skills/android-publish-x/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-xiaohongshu/SKILL.md +50 -0
- package/mobile-runtime/skills/android-publish-xiaohongshu/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/mobile-publish-data-collection/SKILL.md +49 -0
- package/mobile-runtime/skills/mobile-publish-device-health/SKILL.md +47 -0
- package/mobile-runtime/skills/mobile-publish-execution/SKILL.md +57 -0
- package/mobile-runtime/skills/mobile-publish-records/SKILL.md +29 -0
- package/package.json +4 -1
- package/scripts/mobile-postinstall.js +26 -0
- package/src/commands/mobile.js +695 -0
- package/src/commands/runtime.js +21 -5
|
@@ -0,0 +1,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()
|