@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,439 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Device:
|
|
11
|
+
device_id: str
|
|
12
|
+
adb_serial: str
|
|
13
|
+
ip_address: str
|
|
14
|
+
brand: str = ""
|
|
15
|
+
model: str = ""
|
|
16
|
+
android_version: str = ""
|
|
17
|
+
status: str = "active"
|
|
18
|
+
notes: str = ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class PlatformAccount:
|
|
23
|
+
account_id: str
|
|
24
|
+
platform: str
|
|
25
|
+
username: str
|
|
26
|
+
bound_device_id: str
|
|
27
|
+
login_status: str = "unknown"
|
|
28
|
+
status: str = "active"
|
|
29
|
+
notes: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class PublishTask:
|
|
34
|
+
task_id: str
|
|
35
|
+
action: str
|
|
36
|
+
platform: str
|
|
37
|
+
post_type: str
|
|
38
|
+
account_id: str
|
|
39
|
+
device_id: str
|
|
40
|
+
target: str | None = None
|
|
41
|
+
title: str | None = None
|
|
42
|
+
body: str | None = None
|
|
43
|
+
link_url: str | None = None
|
|
44
|
+
media_path: str | None = None
|
|
45
|
+
caption: str | None = None
|
|
46
|
+
hashtags: list[str] = field(default_factory=list)
|
|
47
|
+
dry_run: bool = True
|
|
48
|
+
require_human_confirm: bool = True
|
|
49
|
+
status: str = "pending"
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, data: dict[str, Any]) -> "PublishTask":
|
|
53
|
+
return cls(
|
|
54
|
+
task_id=str(data["task_id"]),
|
|
55
|
+
action=str(data.get("action", "publish_content")),
|
|
56
|
+
platform=str(data["platform"]).lower(),
|
|
57
|
+
post_type=str(data["post_type"]).lower(),
|
|
58
|
+
account_id=str(data["account_id"]),
|
|
59
|
+
device_id=str(data["device_id"]),
|
|
60
|
+
target=data.get("target"),
|
|
61
|
+
title=data.get("title"),
|
|
62
|
+
body=data.get("body"),
|
|
63
|
+
link_url=data.get("link_url"),
|
|
64
|
+
media_path=data.get("media_path"),
|
|
65
|
+
caption=data.get("caption"),
|
|
66
|
+
hashtags=list(data.get("hashtags") or []),
|
|
67
|
+
dry_run=bool(data.get("dry_run", True)),
|
|
68
|
+
require_human_confirm=bool(data.get("require_human_confirm", True)),
|
|
69
|
+
status=str(data.get("status", "pending")),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def utc_now_iso() -> str:
|
|
74
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def new_id(prefix: str) -> str:
|
|
78
|
+
return f"{prefix}_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}_{uuid4().hex[:8]}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class PublishRecord:
|
|
83
|
+
record_id: str
|
|
84
|
+
platform: str
|
|
85
|
+
account_id: str
|
|
86
|
+
device_id: str
|
|
87
|
+
post_type: str
|
|
88
|
+
published_at: str
|
|
89
|
+
task_id: str = ""
|
|
90
|
+
local_media_path: str = ""
|
|
91
|
+
remote_media_path: str = ""
|
|
92
|
+
platform_post_id: str = ""
|
|
93
|
+
platform_permalink: str = ""
|
|
94
|
+
caption: str = ""
|
|
95
|
+
result_screenshot_path: str = ""
|
|
96
|
+
status: str = "published"
|
|
97
|
+
created_at: str = field(default_factory=utc_now_iso)
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def from_dict(cls, data: dict[str, Any]) -> "PublishRecord":
|
|
101
|
+
return cls(
|
|
102
|
+
record_id=str(data["record_id"]),
|
|
103
|
+
task_id=str(data.get("task_id", "")),
|
|
104
|
+
platform=str(data["platform"]).lower(),
|
|
105
|
+
account_id=str(data["account_id"]),
|
|
106
|
+
device_id=str(data["device_id"]),
|
|
107
|
+
post_type=str(data.get("post_type", "video")).lower(),
|
|
108
|
+
local_media_path=str(data.get("local_media_path", "")),
|
|
109
|
+
remote_media_path=str(data.get("remote_media_path", "")),
|
|
110
|
+
platform_post_id=str(data.get("platform_post_id", "")),
|
|
111
|
+
platform_permalink=str(data.get("platform_permalink", "")),
|
|
112
|
+
caption=str(data.get("caption", "")),
|
|
113
|
+
published_at=str(data["published_at"]),
|
|
114
|
+
result_screenshot_path=str(data.get("result_screenshot_path", "")),
|
|
115
|
+
status=str(data.get("status", "published")),
|
|
116
|
+
created_at=str(data.get("created_at", utc_now_iso())),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def to_dict(self) -> dict[str, Any]:
|
|
120
|
+
return {
|
|
121
|
+
"record_id": self.record_id,
|
|
122
|
+
"task_id": self.task_id,
|
|
123
|
+
"platform": self.platform,
|
|
124
|
+
"account_id": self.account_id,
|
|
125
|
+
"device_id": self.device_id,
|
|
126
|
+
"post_type": self.post_type,
|
|
127
|
+
"local_media_path": self.local_media_path,
|
|
128
|
+
"remote_media_path": self.remote_media_path,
|
|
129
|
+
"platform_post_id": self.platform_post_id,
|
|
130
|
+
"platform_permalink": self.platform_permalink,
|
|
131
|
+
"caption": self.caption,
|
|
132
|
+
"published_at": self.published_at,
|
|
133
|
+
"result_screenshot_path": self.result_screenshot_path,
|
|
134
|
+
"status": self.status,
|
|
135
|
+
"created_at": self.created_at,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class VideoMetricsSnapshot:
|
|
141
|
+
snapshot_id: str
|
|
142
|
+
record_id: str
|
|
143
|
+
platform: str
|
|
144
|
+
account_id: str
|
|
145
|
+
fetched_at: str
|
|
146
|
+
source: str
|
|
147
|
+
platform_post_id: str = ""
|
|
148
|
+
platform_permalink: str = ""
|
|
149
|
+
views: int | None = None
|
|
150
|
+
likes: int | None = None
|
|
151
|
+
comments: int | None = None
|
|
152
|
+
shares: int | None = None
|
|
153
|
+
saves: int | None = None
|
|
154
|
+
score: int | None = None
|
|
155
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def from_dict(cls, data: dict[str, Any]) -> "VideoMetricsSnapshot":
|
|
159
|
+
return cls(
|
|
160
|
+
snapshot_id=str(data["snapshot_id"]),
|
|
161
|
+
record_id=str(data["record_id"]),
|
|
162
|
+
platform=str(data["platform"]).lower(),
|
|
163
|
+
account_id=str(data["account_id"]),
|
|
164
|
+
platform_post_id=str(data.get("platform_post_id", "")),
|
|
165
|
+
platform_permalink=str(data.get("platform_permalink", "")),
|
|
166
|
+
fetched_at=str(data["fetched_at"]),
|
|
167
|
+
source=str(data.get("source", "unknown")),
|
|
168
|
+
views=_optional_int(data.get("views")),
|
|
169
|
+
likes=_optional_int(data.get("likes")),
|
|
170
|
+
comments=_optional_int(data.get("comments")),
|
|
171
|
+
shares=_optional_int(data.get("shares")),
|
|
172
|
+
saves=_optional_int(data.get("saves")),
|
|
173
|
+
score=_optional_int(data.get("score")),
|
|
174
|
+
raw=dict(data.get("raw") or {}),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def to_dict(self) -> dict[str, Any]:
|
|
178
|
+
return {
|
|
179
|
+
"snapshot_id": self.snapshot_id,
|
|
180
|
+
"record_id": self.record_id,
|
|
181
|
+
"platform": self.platform,
|
|
182
|
+
"account_id": self.account_id,
|
|
183
|
+
"platform_post_id": self.platform_post_id,
|
|
184
|
+
"platform_permalink": self.platform_permalink,
|
|
185
|
+
"fetched_at": self.fetched_at,
|
|
186
|
+
"source": self.source,
|
|
187
|
+
"views": self.views,
|
|
188
|
+
"likes": self.likes,
|
|
189
|
+
"comments": self.comments,
|
|
190
|
+
"shares": self.shares,
|
|
191
|
+
"saves": self.saves,
|
|
192
|
+
"score": self.score,
|
|
193
|
+
"raw": self.raw,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass
|
|
198
|
+
class TikTokAccountMetricsPeriod:
|
|
199
|
+
period_key: str
|
|
200
|
+
period_days: int
|
|
201
|
+
date_range: str = ""
|
|
202
|
+
video_views: int | None = None
|
|
203
|
+
profile_views: int | None = None
|
|
204
|
+
total_likes: int | None = None
|
|
205
|
+
total_comments: int | None = None
|
|
206
|
+
shares: int | None = None
|
|
207
|
+
screenshot_path: str = ""
|
|
208
|
+
ui_dump_path: str = ""
|
|
209
|
+
extraction_method: str = "manual_required"
|
|
210
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
211
|
+
|
|
212
|
+
@classmethod
|
|
213
|
+
def from_dict(cls, data: dict[str, Any]) -> "TikTokAccountMetricsPeriod":
|
|
214
|
+
return cls(
|
|
215
|
+
period_key=str(data["period_key"]),
|
|
216
|
+
period_days=int(data["period_days"]),
|
|
217
|
+
date_range=str(data.get("date_range", "")),
|
|
218
|
+
video_views=_optional_int(data.get("video_views")),
|
|
219
|
+
profile_views=_optional_int(data.get("profile_views")),
|
|
220
|
+
total_likes=_optional_int(data.get("total_likes")),
|
|
221
|
+
total_comments=_optional_int(data.get("total_comments")),
|
|
222
|
+
shares=_optional_int(data.get("shares")),
|
|
223
|
+
screenshot_path=str(data.get("screenshot_path", "")),
|
|
224
|
+
ui_dump_path=str(data.get("ui_dump_path", "")),
|
|
225
|
+
extraction_method=str(data.get("extraction_method", "manual_required")),
|
|
226
|
+
raw=dict(data.get("raw") or {}),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def to_dict(self) -> dict[str, Any]:
|
|
230
|
+
return {
|
|
231
|
+
"period_key": self.period_key,
|
|
232
|
+
"period_days": self.period_days,
|
|
233
|
+
"date_range": self.date_range,
|
|
234
|
+
"video_views": self.video_views,
|
|
235
|
+
"profile_views": self.profile_views,
|
|
236
|
+
"total_likes": self.total_likes,
|
|
237
|
+
"total_comments": self.total_comments,
|
|
238
|
+
"shares": self.shares,
|
|
239
|
+
"screenshot_path": self.screenshot_path,
|
|
240
|
+
"ui_dump_path": self.ui_dump_path,
|
|
241
|
+
"extraction_method": self.extraction_method,
|
|
242
|
+
"raw": self.raw,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
def has_all_metrics(self) -> bool:
|
|
246
|
+
return all(
|
|
247
|
+
value is not None
|
|
248
|
+
for value in (
|
|
249
|
+
self.video_views,
|
|
250
|
+
self.profile_views,
|
|
251
|
+
self.total_likes,
|
|
252
|
+
self.total_comments,
|
|
253
|
+
self.shares,
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@dataclass
|
|
259
|
+
class TikTokAccountMetricsSnapshot:
|
|
260
|
+
snapshot_id: str
|
|
261
|
+
platform: str
|
|
262
|
+
account_id: str
|
|
263
|
+
device_id: str
|
|
264
|
+
fetched_at: str
|
|
265
|
+
source: str
|
|
266
|
+
status: str = "needs_human"
|
|
267
|
+
periods: dict[str, TikTokAccountMetricsPeriod] = field(default_factory=dict)
|
|
268
|
+
artifact_dir: str = ""
|
|
269
|
+
started_at: str = ""
|
|
270
|
+
ended_at: str = ""
|
|
271
|
+
duration_seconds: int | None = None
|
|
272
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
273
|
+
|
|
274
|
+
@classmethod
|
|
275
|
+
def from_dict(cls, data: dict[str, Any]) -> "TikTokAccountMetricsSnapshot":
|
|
276
|
+
periods = {
|
|
277
|
+
str(key): TikTokAccountMetricsPeriod.from_dict(dict(value))
|
|
278
|
+
for key, value in dict(data.get("periods") or {}).items()
|
|
279
|
+
}
|
|
280
|
+
return cls(
|
|
281
|
+
snapshot_id=str(data["snapshot_id"]),
|
|
282
|
+
platform=str(data.get("platform", "tiktok")).lower(),
|
|
283
|
+
account_id=str(data["account_id"]),
|
|
284
|
+
device_id=str(data["device_id"]),
|
|
285
|
+
fetched_at=str(data["fetched_at"]),
|
|
286
|
+
source=str(data.get("source", "unknown")),
|
|
287
|
+
status=str(data.get("status", "needs_human")),
|
|
288
|
+
periods=periods,
|
|
289
|
+
artifact_dir=str(data.get("artifact_dir", "")),
|
|
290
|
+
started_at=str(data.get("started_at", "")),
|
|
291
|
+
ended_at=str(data.get("ended_at", "")),
|
|
292
|
+
duration_seconds=_optional_int(data.get("duration_seconds")),
|
|
293
|
+
raw=dict(data.get("raw") or {}),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def to_dict(self) -> dict[str, Any]:
|
|
297
|
+
return {
|
|
298
|
+
"snapshot_id": self.snapshot_id,
|
|
299
|
+
"platform": self.platform,
|
|
300
|
+
"account_id": self.account_id,
|
|
301
|
+
"device_id": self.device_id,
|
|
302
|
+
"fetched_at": self.fetched_at,
|
|
303
|
+
"source": self.source,
|
|
304
|
+
"status": self.status,
|
|
305
|
+
"periods": {key: period.to_dict() for key, period in self.periods.items()},
|
|
306
|
+
"artifact_dir": self.artifact_dir,
|
|
307
|
+
"started_at": self.started_at,
|
|
308
|
+
"ended_at": self.ended_at,
|
|
309
|
+
"duration_seconds": self.duration_seconds,
|
|
310
|
+
"raw": self.raw,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@dataclass
|
|
315
|
+
class TikTokVideoMetricsSnapshot:
|
|
316
|
+
snapshot_id: str
|
|
317
|
+
platform: str
|
|
318
|
+
account_id: str
|
|
319
|
+
device_id: str
|
|
320
|
+
fetched_at: str
|
|
321
|
+
source: str
|
|
322
|
+
status: str = "needs_human"
|
|
323
|
+
published_at: str = ""
|
|
324
|
+
published_at_raw: str = ""
|
|
325
|
+
video_order: int | None = None
|
|
326
|
+
video_views: int | None = None
|
|
327
|
+
likes: int | None = None
|
|
328
|
+
comments: int | None = None
|
|
329
|
+
shares: int | None = None
|
|
330
|
+
saves: int | None = None
|
|
331
|
+
total_play_time_seconds: int | None = None
|
|
332
|
+
total_play_time_raw: str = ""
|
|
333
|
+
average_watch_time_seconds: float | None = None
|
|
334
|
+
average_watch_time_raw: str = ""
|
|
335
|
+
completion_rate: float | None = None
|
|
336
|
+
completion_rate_raw: str = ""
|
|
337
|
+
new_followers: int | None = None
|
|
338
|
+
screenshot_path: str = ""
|
|
339
|
+
ui_dump_path: str = ""
|
|
340
|
+
artifact_dir: str = ""
|
|
341
|
+
started_at: str = ""
|
|
342
|
+
ended_at: str = ""
|
|
343
|
+
duration_seconds: int | None = None
|
|
344
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
345
|
+
|
|
346
|
+
@classmethod
|
|
347
|
+
def from_dict(cls, data: dict[str, Any]) -> "TikTokVideoMetricsSnapshot":
|
|
348
|
+
raw = dict(data.get("raw") or {})
|
|
349
|
+
top_counters = dict(raw.get("top_counters") or {})
|
|
350
|
+
return cls(
|
|
351
|
+
snapshot_id=str(data["snapshot_id"]),
|
|
352
|
+
platform=str(data.get("platform", "tiktok")).lower(),
|
|
353
|
+
account_id=str(data["account_id"]),
|
|
354
|
+
device_id=str(data["device_id"]),
|
|
355
|
+
fetched_at=str(data["fetched_at"]),
|
|
356
|
+
source=str(data.get("source", "unknown")),
|
|
357
|
+
status=str(data.get("status", "needs_human")),
|
|
358
|
+
published_at=str(data.get("published_at", "")),
|
|
359
|
+
published_at_raw=str(data.get("published_at_raw", "")),
|
|
360
|
+
video_order=_optional_int(data.get("video_order")),
|
|
361
|
+
video_views=_optional_int(data.get("video_views", top_counters.get("views"))),
|
|
362
|
+
likes=_optional_int(data.get("likes", top_counters.get("likes"))),
|
|
363
|
+
comments=_optional_int(data.get("comments", top_counters.get("comments"))),
|
|
364
|
+
shares=_optional_int(data.get("shares", top_counters.get("shares"))),
|
|
365
|
+
saves=_optional_int(data.get("saves", top_counters.get("saves"))),
|
|
366
|
+
total_play_time_seconds=_optional_int(data.get("total_play_time_seconds")),
|
|
367
|
+
total_play_time_raw=str(data.get("total_play_time_raw", "")),
|
|
368
|
+
average_watch_time_seconds=_optional_float(data.get("average_watch_time_seconds")),
|
|
369
|
+
average_watch_time_raw=str(data.get("average_watch_time_raw", "")),
|
|
370
|
+
completion_rate=_optional_float(data.get("completion_rate")),
|
|
371
|
+
completion_rate_raw=str(data.get("completion_rate_raw", "")),
|
|
372
|
+
new_followers=_optional_int(data.get("new_followers")),
|
|
373
|
+
screenshot_path=str(data.get("screenshot_path", "")),
|
|
374
|
+
ui_dump_path=str(data.get("ui_dump_path", "")),
|
|
375
|
+
artifact_dir=str(data.get("artifact_dir", "")),
|
|
376
|
+
started_at=str(data.get("started_at", "")),
|
|
377
|
+
ended_at=str(data.get("ended_at", "")),
|
|
378
|
+
duration_seconds=_optional_int(data.get("duration_seconds")),
|
|
379
|
+
raw=raw,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
def to_dict(self) -> dict[str, Any]:
|
|
383
|
+
return {
|
|
384
|
+
"snapshot_id": self.snapshot_id,
|
|
385
|
+
"platform": self.platform,
|
|
386
|
+
"account_id": self.account_id,
|
|
387
|
+
"device_id": self.device_id,
|
|
388
|
+
"fetched_at": self.fetched_at,
|
|
389
|
+
"source": self.source,
|
|
390
|
+
"status": self.status,
|
|
391
|
+
"published_at": self.published_at,
|
|
392
|
+
"published_at_raw": self.published_at_raw,
|
|
393
|
+
"video_order": self.video_order,
|
|
394
|
+
"video_views": self.video_views,
|
|
395
|
+
"likes": self.likes,
|
|
396
|
+
"comments": self.comments,
|
|
397
|
+
"shares": self.shares,
|
|
398
|
+
"saves": self.saves,
|
|
399
|
+
"total_play_time_seconds": self.total_play_time_seconds,
|
|
400
|
+
"total_play_time_raw": self.total_play_time_raw,
|
|
401
|
+
"average_watch_time_seconds": self.average_watch_time_seconds,
|
|
402
|
+
"average_watch_time_raw": self.average_watch_time_raw,
|
|
403
|
+
"completion_rate": self.completion_rate,
|
|
404
|
+
"completion_rate_raw": self.completion_rate_raw,
|
|
405
|
+
"new_followers": self.new_followers,
|
|
406
|
+
"screenshot_path": self.screenshot_path,
|
|
407
|
+
"ui_dump_path": self.ui_dump_path,
|
|
408
|
+
"artifact_dir": self.artifact_dir,
|
|
409
|
+
"started_at": self.started_at,
|
|
410
|
+
"ended_at": self.ended_at,
|
|
411
|
+
"duration_seconds": self.duration_seconds,
|
|
412
|
+
"raw": self.raw,
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
def has_key_metrics(self) -> bool:
|
|
416
|
+
return bool(self.published_at) and all(
|
|
417
|
+
value is not None
|
|
418
|
+
for value in (
|
|
419
|
+
self.video_views,
|
|
420
|
+
self.likes,
|
|
421
|
+
self.comments,
|
|
422
|
+
self.shares,
|
|
423
|
+
self.saves,
|
|
424
|
+
self.average_watch_time_seconds,
|
|
425
|
+
self.completion_rate,
|
|
426
|
+
)
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _optional_int(value: Any) -> int | None:
|
|
431
|
+
if value in (None, ""):
|
|
432
|
+
return None
|
|
433
|
+
return int(value)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _optional_float(value: Any) -> float | None:
|
|
437
|
+
if value in (None, ""):
|
|
438
|
+
return None
|
|
439
|
+
return float(value)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
DEFAULT_PUBLISH_POLICY_PATH = "configs/publish_policy.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class XiaohongshuPublishPolicyDecision:
|
|
14
|
+
approval_bridge_applied: bool
|
|
15
|
+
approval_state: str
|
|
16
|
+
source_lifecycle_state: str
|
|
17
|
+
commercial_disclosure_decision: str
|
|
18
|
+
auto_confirm_no_disclosure_needed: bool
|
|
19
|
+
blockers: list[str] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_publish_policy(path: str | Path = DEFAULT_PUBLISH_POLICY_PATH) -> dict[str, Any]:
|
|
23
|
+
policy_path = Path(path).expanduser()
|
|
24
|
+
if not policy_path.exists():
|
|
25
|
+
return {}
|
|
26
|
+
with policy_path.open(encoding="utf-8") as handle:
|
|
27
|
+
loaded = json.load(handle)
|
|
28
|
+
if not isinstance(loaded, dict):
|
|
29
|
+
raise ValueError(f"publish policy must be a JSON object: {policy_path}")
|
|
30
|
+
return loaded
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def explicit_live_publish_auto_confirm(policy: dict[str, Any] | None = None) -> bool:
|
|
34
|
+
global_policy = _mapping((policy or {}).get("global"))
|
|
35
|
+
return bool(global_policy.get("explicit_live_publish_auto_confirm", False))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def should_auto_confirm_instagram_reels_public(policy: dict[str, Any] | None = None) -> bool:
|
|
39
|
+
if not explicit_live_publish_auto_confirm(policy):
|
|
40
|
+
return False
|
|
41
|
+
instagram_policy = _mapping((policy or {}).get("instagram"))
|
|
42
|
+
return bool(instagram_policy.get("auto_confirm_reels_public", True))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def should_auto_confirm_xiaohongshu_no_disclosure(policy: dict[str, Any] | None = None) -> bool:
|
|
46
|
+
if not explicit_live_publish_auto_confirm(policy):
|
|
47
|
+
return False
|
|
48
|
+
xhs_policy = _mapping((policy or {}).get("xiaohongshu"))
|
|
49
|
+
disclosure_policy = _mapping(xhs_policy.get("commercial_disclosure"))
|
|
50
|
+
return bool(
|
|
51
|
+
disclosure_policy.get(
|
|
52
|
+
"auto_confirm_no_disclosure_needed",
|
|
53
|
+
xhs_policy.get("auto_confirm_no_disclosure_needed", False),
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def should_trust_xiaohongshu_explicit_publish_request(policy: dict[str, Any] | None = None) -> bool:
|
|
59
|
+
if not explicit_live_publish_auto_confirm(policy):
|
|
60
|
+
return False
|
|
61
|
+
xhs_policy = _mapping((policy or {}).get("xiaohongshu"))
|
|
62
|
+
disclosure_policy = _mapping(xhs_policy.get("commercial_disclosure"))
|
|
63
|
+
return bool(disclosure_policy.get("trust_explicit_publish_request", True))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def xiaohongshu_package_disclosure_is_strict(policy: dict[str, Any] | None = None) -> bool:
|
|
67
|
+
xhs_policy = _mapping((policy or {}).get("xiaohongshu"))
|
|
68
|
+
disclosure_policy = _mapping(xhs_policy.get("commercial_disclosure"))
|
|
69
|
+
return bool(disclosure_policy.get("strict_package_disclosure_decision", False))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def decide_xiaohongshu_publish_package_policy(
|
|
73
|
+
package: dict[str, Any],
|
|
74
|
+
policy: dict[str, Any] | None = None,
|
|
75
|
+
) -> XiaohongshuPublishPolicyDecision:
|
|
76
|
+
xhs_policy = dict((policy or {}).get("xiaohongshu") or {})
|
|
77
|
+
approval_policy = dict(xhs_policy.get("approval_bridge") or {})
|
|
78
|
+
disclosure_policy = dict(xhs_policy.get("commercial_disclosure") or {})
|
|
79
|
+
approval = dict(package.get("approval") or {})
|
|
80
|
+
disclosure = dict(package.get("disclosure") or {})
|
|
81
|
+
|
|
82
|
+
approval_state = _normalize(
|
|
83
|
+
approval.get("content_approval_state")
|
|
84
|
+
or approval.get("approval_state")
|
|
85
|
+
or approval.get("state")
|
|
86
|
+
or approval.get("lifecycle_state")
|
|
87
|
+
)
|
|
88
|
+
source_lifecycle_state = _normalize(
|
|
89
|
+
approval.get("source_lifecycle_state")
|
|
90
|
+
or approval.get("lifecycle_state")
|
|
91
|
+
or approval.get("source_approval_state")
|
|
92
|
+
)
|
|
93
|
+
explicit_publish_states = _normalized_set(
|
|
94
|
+
approval_policy.get("explicit_publish_states"),
|
|
95
|
+
default={
|
|
96
|
+
"approved_for_publish",
|
|
97
|
+
"approved_to_publish",
|
|
98
|
+
"approved_to_publish_by_request",
|
|
99
|
+
"publish_requested_by_member_comment",
|
|
100
|
+
"publish_requested_for_series_by_member_comment",
|
|
101
|
+
"publish_requested_for_single_post_by_member_comment",
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
approval_bridge_applied = bool(approval_policy.get("enabled", True)) and approval_state in explicit_publish_states
|
|
105
|
+
|
|
106
|
+
disclosure_decision = _normalize(
|
|
107
|
+
approval.get("commercial_disclosure_decision")
|
|
108
|
+
or approval.get("content_disclosure_decision")
|
|
109
|
+
or disclosure.get("commercial_disclosure_decision")
|
|
110
|
+
or disclosure.get("decision")
|
|
111
|
+
)
|
|
112
|
+
no_disclosure_decisions = _normalized_set(
|
|
113
|
+
disclosure_policy.get("no_disclosure_decisions"),
|
|
114
|
+
default={"no_disclosure_needed", "not_required", "not_needed", "none"},
|
|
115
|
+
)
|
|
116
|
+
requires_disclosure_decisions = _normalized_set(
|
|
117
|
+
disclosure_policy.get("requires_disclosure_decisions"),
|
|
118
|
+
default={"disclosure_required", "commercial_disclosure_required", "content_declaration_required", "required"},
|
|
119
|
+
)
|
|
120
|
+
default_decision = _normalize(disclosure_policy.get("default_decision") or "")
|
|
121
|
+
auto_confirm_enabled = bool(disclosure_policy.get("auto_confirm_no_disclosure_needed", False))
|
|
122
|
+
unresolved_follows_default = bool(disclosure_policy.get("unresolved_decisions_follow_default", False))
|
|
123
|
+
trust_explicit_publish = should_trust_xiaohongshu_explicit_publish_request(policy)
|
|
124
|
+
|
|
125
|
+
blockers: list[str] = []
|
|
126
|
+
if disclosure_decision in requires_disclosure_decisions:
|
|
127
|
+
if auto_confirm_enabled and trust_explicit_publish:
|
|
128
|
+
auto_confirm = True
|
|
129
|
+
else:
|
|
130
|
+
blockers.append(f"xiaohongshu disclosure decision requires declaration: {disclosure_decision}")
|
|
131
|
+
auto_confirm = False
|
|
132
|
+
elif disclosure_decision in no_disclosure_decisions:
|
|
133
|
+
auto_confirm = auto_confirm_enabled
|
|
134
|
+
elif not disclosure_decision or disclosure_decision in {"unresolved", "unknown", "pending"}:
|
|
135
|
+
auto_confirm = auto_confirm_enabled and (
|
|
136
|
+
(unresolved_follows_default and default_decision in no_disclosure_decisions)
|
|
137
|
+
or trust_explicit_publish
|
|
138
|
+
)
|
|
139
|
+
if not auto_confirm:
|
|
140
|
+
blockers.append("xiaohongshu disclosure decision is unresolved")
|
|
141
|
+
else:
|
|
142
|
+
if auto_confirm_enabled and trust_explicit_publish:
|
|
143
|
+
auto_confirm = True
|
|
144
|
+
else:
|
|
145
|
+
auto_confirm = False
|
|
146
|
+
blockers.append(f"unknown xiaohongshu disclosure decision: {disclosure_decision}")
|
|
147
|
+
|
|
148
|
+
return XiaohongshuPublishPolicyDecision(
|
|
149
|
+
approval_bridge_applied=approval_bridge_applied,
|
|
150
|
+
approval_state=approval_state,
|
|
151
|
+
source_lifecycle_state=source_lifecycle_state,
|
|
152
|
+
commercial_disclosure_decision=disclosure_decision or default_decision,
|
|
153
|
+
auto_confirm_no_disclosure_needed=auto_confirm,
|
|
154
|
+
blockers=blockers,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _normalize(value: Any) -> str:
|
|
159
|
+
return str(value or "").strip().lower().replace("-", "_")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _normalized_set(value: Any, *, default: set[str]) -> set[str]:
|
|
163
|
+
if value is None:
|
|
164
|
+
return set(default)
|
|
165
|
+
if not isinstance(value, list):
|
|
166
|
+
return set(default)
|
|
167
|
+
return {_normalize(item) for item in value if _normalize(item)}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _mapping(value: Any) -> dict[str, Any]:
|
|
171
|
+
if isinstance(value, dict):
|
|
172
|
+
return value
|
|
173
|
+
return {}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from .facebook_adb import FacebookAdbPublishResult, FacebookAdbPublisher
|
|
2
|
+
from .instagram_adb import InstagramAdbPublishResult, InstagramAdbPublisher
|
|
3
|
+
from .reddit_adb import RedditAdbPublishResult, RedditAdbPublisher
|
|
4
|
+
from .tiktok_adb import TikTokAdbPublishResult, TikTokAdbPublisher
|
|
5
|
+
from .tiktok_appium import TikTokAppiumPublishResult, TikTokAppiumPublisher
|
|
6
|
+
from .x_adb import XAdbPublishResult, XAdbPublisher
|
|
7
|
+
from .xiaohongshu_adb import XiaohongshuAdbPublishResult, XiaohongshuAdbPublisher
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"FacebookAdbPublishResult",
|
|
11
|
+
"FacebookAdbPublisher",
|
|
12
|
+
"InstagramAdbPublishResult",
|
|
13
|
+
"InstagramAdbPublisher",
|
|
14
|
+
"RedditAdbPublishResult",
|
|
15
|
+
"RedditAdbPublisher",
|
|
16
|
+
"TikTokAdbPublishResult",
|
|
17
|
+
"TikTokAdbPublisher",
|
|
18
|
+
"TikTokAppiumPublishResult",
|
|
19
|
+
"TikTokAppiumPublisher",
|
|
20
|
+
"XAdbPublishResult",
|
|
21
|
+
"XAdbPublisher",
|
|
22
|
+
"XiaohongshuAdbPublishResult",
|
|
23
|
+
"XiaohongshuAdbPublisher",
|
|
24
|
+
]
|