@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,367 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import struct
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from zoneinfo import ZoneInfo
|
|
11
|
+
|
|
12
|
+
from device_control.adb import AdbClient, CommandResult
|
|
13
|
+
from device_control.models import Device, TikTokAccountMetricsPeriod, TikTokAccountMetricsSnapshot, new_id, utc_now_iso
|
|
14
|
+
from device_control.publishers import ui_helpers as ui
|
|
15
|
+
from device_control.publishers.tiktok_adb import TIKTOK_PACKAGE
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
ACCOUNT_PERIODS: dict[str, int] = {
|
|
19
|
+
"7d": 7,
|
|
20
|
+
"28d": 28,
|
|
21
|
+
"60d": 60,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
ACCOUNT_METRIC_KEYS = ("video_views", "profile_views", "total_likes", "total_comments", "shares")
|
|
25
|
+
|
|
26
|
+
# Coordinates calibrated from a 1080x2245 TikTok Studio analytics screenshot on D01.
|
|
27
|
+
BASE_SCREEN = (1080, 2245)
|
|
28
|
+
PERIOD_TAB_COORDS = {
|
|
29
|
+
"7d": (121, 473),
|
|
30
|
+
"28d": (308, 473),
|
|
31
|
+
"60d": (492, 473),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class TikTokAccountMetricValues:
|
|
37
|
+
video_views: int | None = None
|
|
38
|
+
profile_views: int | None = None
|
|
39
|
+
total_likes: int | None = None
|
|
40
|
+
total_comments: int | None = None
|
|
41
|
+
shares: int | None = None
|
|
42
|
+
date_range: str = ""
|
|
43
|
+
raw: dict[str, Any] | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TikTokAccountMetricsAdbCollector:
|
|
47
|
+
"""Read-only ADB collector for TikTok Studio account overview metrics."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, adb: AdbClient, *, artifact_root: str | Path = "artifacts/screenshots") -> None:
|
|
50
|
+
self.adb = adb
|
|
51
|
+
self.artifact_root = Path(artifact_root)
|
|
52
|
+
|
|
53
|
+
def collect(
|
|
54
|
+
self,
|
|
55
|
+
device: Device,
|
|
56
|
+
*,
|
|
57
|
+
account_id: str,
|
|
58
|
+
periods: list[str] | None = None,
|
|
59
|
+
resume_analytics: bool = False,
|
|
60
|
+
manual_values: dict[str, TikTokAccountMetricValues] | None = None,
|
|
61
|
+
) -> TikTokAccountMetricsSnapshot:
|
|
62
|
+
requested = _normalize_periods(periods)
|
|
63
|
+
manual_values = manual_values or {}
|
|
64
|
+
started_epoch = int(time.time())
|
|
65
|
+
started_at = _now_shanghai()
|
|
66
|
+
stamp = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d-%H%M%S")
|
|
67
|
+
run_dir = self.artifact_root / f"{device.device_id}-tiktok-account-metrics-{stamp}"
|
|
68
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
raw: dict[str, Any] = {
|
|
71
|
+
"resume_analytics": resume_analytics,
|
|
72
|
+
"requested_periods": requested,
|
|
73
|
+
"metric_keys": list(ACCOUNT_METRIC_KEYS),
|
|
74
|
+
}
|
|
75
|
+
periods_out: dict[str, TikTokAccountMetricsPeriod] = {}
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
self._prepare_device(device)
|
|
79
|
+
if not resume_analytics:
|
|
80
|
+
self._navigate_to_account_analytics(device, run_dir)
|
|
81
|
+
|
|
82
|
+
initial_shot = run_dir / "initial.png"
|
|
83
|
+
self._ok(self.adb.screenshot(device.adb_serial, initial_shot), "capture initial TikTok analytics screenshot")
|
|
84
|
+
width, height = _png_size(initial_shot) or BASE_SCREEN
|
|
85
|
+
raw["screen_size"] = {"width": width, "height": height}
|
|
86
|
+
raw["initial_screenshot_path"] = str(initial_shot)
|
|
87
|
+
self._scroll_overview_to_metrics_top(device, run_dir, width, height)
|
|
88
|
+
|
|
89
|
+
for period_key in requested:
|
|
90
|
+
x, y = _scale_coord(PERIOD_TAB_COORDS[period_key], width, height)
|
|
91
|
+
self._ok(self.adb.tap(device.adb_serial, x, y), f"tap TikTok analytics period {period_key}")
|
|
92
|
+
time.sleep(2)
|
|
93
|
+
|
|
94
|
+
screenshot_path = run_dir / f"{period_key}.png"
|
|
95
|
+
ui_path = run_dir / f"{period_key}.xml"
|
|
96
|
+
self._ok(self.adb.screenshot(device.adb_serial, screenshot_path), f"capture {period_key} screenshot")
|
|
97
|
+
dump = self.adb.dump_ui(device.adb_serial, ui_path)
|
|
98
|
+
if not dump.ok:
|
|
99
|
+
ui_path = Path("")
|
|
100
|
+
|
|
101
|
+
value = manual_values.get(period_key)
|
|
102
|
+
periods_out[period_key] = self._build_period(
|
|
103
|
+
period_key,
|
|
104
|
+
value,
|
|
105
|
+
screenshot_path=screenshot_path,
|
|
106
|
+
ui_dump_path=ui_path,
|
|
107
|
+
tap={"x": x, "y": y},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
ended_epoch = int(time.time())
|
|
111
|
+
status = "collected" if all(period.has_all_metrics() for period in periods_out.values()) else "needs_human"
|
|
112
|
+
raw["extraction_status"] = (
|
|
113
|
+
"manual_values_recorded" if status == "collected" else "manual_values_required"
|
|
114
|
+
)
|
|
115
|
+
return TikTokAccountMetricsSnapshot(
|
|
116
|
+
snapshot_id=new_id("tkacct"),
|
|
117
|
+
platform="tiktok",
|
|
118
|
+
account_id=account_id,
|
|
119
|
+
device_id=device.device_id,
|
|
120
|
+
fetched_at=utc_now_iso(),
|
|
121
|
+
source="tiktok_studio_adb",
|
|
122
|
+
status=status,
|
|
123
|
+
periods=periods_out,
|
|
124
|
+
artifact_dir=str(run_dir),
|
|
125
|
+
started_at=started_at,
|
|
126
|
+
ended_at=_now_shanghai(),
|
|
127
|
+
duration_seconds=ended_epoch - started_epoch,
|
|
128
|
+
raw=raw,
|
|
129
|
+
)
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
failure_shot = run_dir / "failed.png"
|
|
132
|
+
self.adb.screenshot(device.adb_serial, failure_shot)
|
|
133
|
+
ended_epoch = int(time.time())
|
|
134
|
+
return TikTokAccountMetricsSnapshot(
|
|
135
|
+
snapshot_id=new_id("tkacct"),
|
|
136
|
+
platform="tiktok",
|
|
137
|
+
account_id=account_id,
|
|
138
|
+
device_id=device.device_id,
|
|
139
|
+
fetched_at=utc_now_iso(),
|
|
140
|
+
source="tiktok_studio_adb",
|
|
141
|
+
status="failed",
|
|
142
|
+
periods=periods_out,
|
|
143
|
+
artifact_dir=str(run_dir),
|
|
144
|
+
started_at=started_at,
|
|
145
|
+
ended_at=_now_shanghai(),
|
|
146
|
+
duration_seconds=ended_epoch - started_epoch,
|
|
147
|
+
raw={
|
|
148
|
+
**raw,
|
|
149
|
+
"error": str(exc),
|
|
150
|
+
"failure_screenshot_path": str(failure_shot),
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def _build_period(
|
|
155
|
+
self,
|
|
156
|
+
period_key: str,
|
|
157
|
+
value: TikTokAccountMetricValues | None,
|
|
158
|
+
*,
|
|
159
|
+
screenshot_path: Path,
|
|
160
|
+
ui_dump_path: Path,
|
|
161
|
+
tap: dict[str, int],
|
|
162
|
+
) -> TikTokAccountMetricsPeriod:
|
|
163
|
+
raw = {"tap": tap}
|
|
164
|
+
if value is None:
|
|
165
|
+
raw["extraction_status"] = "manual_values_required"
|
|
166
|
+
return TikTokAccountMetricsPeriod(
|
|
167
|
+
period_key=period_key,
|
|
168
|
+
period_days=ACCOUNT_PERIODS[period_key],
|
|
169
|
+
screenshot_path=str(screenshot_path),
|
|
170
|
+
ui_dump_path=str(ui_dump_path),
|
|
171
|
+
extraction_method="manual_required",
|
|
172
|
+
raw=raw,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
raw.update(dict(value.raw or {}))
|
|
176
|
+
raw["extraction_status"] = "manual_values_recorded"
|
|
177
|
+
return TikTokAccountMetricsPeriod(
|
|
178
|
+
period_key=period_key,
|
|
179
|
+
period_days=ACCOUNT_PERIODS[period_key],
|
|
180
|
+
date_range=value.date_range,
|
|
181
|
+
video_views=value.video_views,
|
|
182
|
+
profile_views=value.profile_views,
|
|
183
|
+
total_likes=value.total_likes,
|
|
184
|
+
total_comments=value.total_comments,
|
|
185
|
+
shares=value.shares,
|
|
186
|
+
screenshot_path=str(screenshot_path),
|
|
187
|
+
ui_dump_path=str(ui_dump_path),
|
|
188
|
+
extraction_method="manual",
|
|
189
|
+
raw=raw,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def _prepare_device(self, device: Device) -> None:
|
|
193
|
+
self.adb.wake(device.adb_serial)
|
|
194
|
+
time.sleep(0.5)
|
|
195
|
+
self.adb.swipe(device.adb_serial, 540, 1850, 540, 550, 350)
|
|
196
|
+
time.sleep(0.5)
|
|
197
|
+
|
|
198
|
+
def _scroll_overview_to_metrics_top(self, device: Device, run_dir: Path, width: int, height: int) -> None:
|
|
199
|
+
for index in range(3):
|
|
200
|
+
self.adb.swipe(
|
|
201
|
+
device.adb_serial,
|
|
202
|
+
width // 2,
|
|
203
|
+
int(height * 0.42),
|
|
204
|
+
width // 2,
|
|
205
|
+
int(height * 0.82),
|
|
206
|
+
450,
|
|
207
|
+
)
|
|
208
|
+
time.sleep(0.8)
|
|
209
|
+
self.adb.screenshot(device.adb_serial, run_dir / f"scroll-top-{index + 1}.png")
|
|
210
|
+
|
|
211
|
+
def _navigate_to_account_analytics(self, device: Device, run_dir: Path) -> None:
|
|
212
|
+
self.adb.shell(device.adb_serial, "am", "force-stop", TIKTOK_PACKAGE)
|
|
213
|
+
time.sleep(1)
|
|
214
|
+
self._ok(self.adb.launch_package(device.adb_serial, TIKTOK_PACKAGE), "launch TikTok")
|
|
215
|
+
time.sleep(5)
|
|
216
|
+
|
|
217
|
+
self._tap_ratio(device, 0.91, 0.96, "open TikTok profile tab")
|
|
218
|
+
time.sleep(3)
|
|
219
|
+
self.adb.screenshot(device.adb_serial, run_dir / "profile.png")
|
|
220
|
+
|
|
221
|
+
self._tap_ratio(device, 0.94, 0.055, "open TikTok profile menu")
|
|
222
|
+
time.sleep(2)
|
|
223
|
+
menu_xml = run_dir / "profile-menu.xml"
|
|
224
|
+
root = self._dump_or_none(device, menu_xml)
|
|
225
|
+
if root is not None and ui.tap_first(
|
|
226
|
+
self.adb,
|
|
227
|
+
device,
|
|
228
|
+
root,
|
|
229
|
+
partial_texts={"TikTok Studio", "创作者工具", "创作者中心"},
|
|
230
|
+
sleep_s=3,
|
|
231
|
+
):
|
|
232
|
+
pass
|
|
233
|
+
else:
|
|
234
|
+
self._tap_ratio(device, 0.25, 0.32, "open TikTok Studio fallback")
|
|
235
|
+
time.sleep(3)
|
|
236
|
+
self.adb.screenshot(device.adb_serial, run_dir / "tiktok-studio.png")
|
|
237
|
+
|
|
238
|
+
studio_xml = run_dir / "tiktok-studio.xml"
|
|
239
|
+
root = self._dump_or_none(device, studio_xml)
|
|
240
|
+
if root is not None and ui.tap_first(
|
|
241
|
+
self.adb,
|
|
242
|
+
device,
|
|
243
|
+
root,
|
|
244
|
+
partial_texts={"数据分析", "Analytics"},
|
|
245
|
+
sleep_s=4,
|
|
246
|
+
):
|
|
247
|
+
return
|
|
248
|
+
self._tap_ratio(device, 0.20, 0.32, "open TikTok Studio analytics fallback")
|
|
249
|
+
time.sleep(4)
|
|
250
|
+
|
|
251
|
+
def _tap_ratio(self, device: Device, x_ratio: float, y_ratio: float, label: str) -> None:
|
|
252
|
+
width, height = self._screen_size(device)
|
|
253
|
+
self._ok(self.adb.tap(device.adb_serial, int(width * x_ratio), int(height * y_ratio)), label)
|
|
254
|
+
|
|
255
|
+
def _screen_size(self, device: Device) -> tuple[int, int]:
|
|
256
|
+
result = self.adb.shell(device.adb_serial, "wm", "size")
|
|
257
|
+
if result.ok:
|
|
258
|
+
for part in result.stdout.split():
|
|
259
|
+
if "x" not in part:
|
|
260
|
+
continue
|
|
261
|
+
left, right = part.split("x", 1)
|
|
262
|
+
if left.isdigit() and right.isdigit():
|
|
263
|
+
return int(left), int(right)
|
|
264
|
+
return BASE_SCREEN
|
|
265
|
+
|
|
266
|
+
def _dump_or_none(self, device: Device, path: Path):
|
|
267
|
+
result = self.adb.dump_ui(device.adb_serial, path)
|
|
268
|
+
if not result.ok:
|
|
269
|
+
return None
|
|
270
|
+
try:
|
|
271
|
+
import xml.etree.ElementTree as ET
|
|
272
|
+
|
|
273
|
+
return ET.parse(path).getroot()
|
|
274
|
+
except Exception:
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
def _ok(self, result: CommandResult, label: str) -> None:
|
|
278
|
+
if not result.ok:
|
|
279
|
+
raise RuntimeError(f"{label} failed: {result.stderr or result.stdout}")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def parse_manual_values_json(raw_json: str) -> dict[str, TikTokAccountMetricValues]:
|
|
283
|
+
if not raw_json.strip():
|
|
284
|
+
return {}
|
|
285
|
+
loaded = json.loads(raw_json)
|
|
286
|
+
if not isinstance(loaded, dict):
|
|
287
|
+
raise ValueError("manual values JSON must be an object keyed by period")
|
|
288
|
+
|
|
289
|
+
parsed: dict[str, TikTokAccountMetricValues] = {}
|
|
290
|
+
for raw_period, raw_values in loaded.items():
|
|
291
|
+
period = normalize_period_key(str(raw_period))
|
|
292
|
+
if period not in ACCOUNT_PERIODS:
|
|
293
|
+
raise ValueError(f"unsupported TikTok account metrics period: {raw_period}")
|
|
294
|
+
if not isinstance(raw_values, dict):
|
|
295
|
+
raise ValueError(f"manual values for {raw_period} must be an object")
|
|
296
|
+
parsed[period] = TikTokAccountMetricValues(
|
|
297
|
+
video_views=_optional_int(raw_values.get("video_views")),
|
|
298
|
+
profile_views=_optional_int(raw_values.get("profile_views")),
|
|
299
|
+
total_likes=_optional_int(raw_values.get("total_likes")),
|
|
300
|
+
total_comments=_optional_int(raw_values.get("total_comments")),
|
|
301
|
+
shares=_optional_int(raw_values.get("shares")),
|
|
302
|
+
date_range=str(raw_values.get("date_range", "")),
|
|
303
|
+
raw=dict(raw_values.get("raw") or {}),
|
|
304
|
+
)
|
|
305
|
+
return parsed
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def normalize_period_key(value: str) -> str:
|
|
309
|
+
key = value.strip().lower().replace(" ", "")
|
|
310
|
+
aliases = {
|
|
311
|
+
"7": "7d",
|
|
312
|
+
"7d": "7d",
|
|
313
|
+
"7day": "7d",
|
|
314
|
+
"7days": "7d",
|
|
315
|
+
"7天": "7d",
|
|
316
|
+
"28": "28d",
|
|
317
|
+
"28d": "28d",
|
|
318
|
+
"28day": "28d",
|
|
319
|
+
"28days": "28d",
|
|
320
|
+
"28天": "28d",
|
|
321
|
+
"60": "60d",
|
|
322
|
+
"60d": "60d",
|
|
323
|
+
"60day": "60d",
|
|
324
|
+
"60days": "60d",
|
|
325
|
+
"60天": "60d",
|
|
326
|
+
}
|
|
327
|
+
return aliases.get(key, key)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _normalize_periods(periods: list[str] | None) -> list[str]:
|
|
331
|
+
if not periods:
|
|
332
|
+
return list(ACCOUNT_PERIODS)
|
|
333
|
+
normalized: list[str] = []
|
|
334
|
+
for period in periods:
|
|
335
|
+
key = normalize_period_key(period)
|
|
336
|
+
if key not in ACCOUNT_PERIODS:
|
|
337
|
+
raise ValueError(f"unsupported TikTok account metrics period: {period}")
|
|
338
|
+
if key not in normalized:
|
|
339
|
+
normalized.append(key)
|
|
340
|
+
return normalized
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _scale_coord(coord: tuple[int, int], width: int, height: int) -> tuple[int, int]:
|
|
344
|
+
base_width, base_height = BASE_SCREEN
|
|
345
|
+
x, y = coord
|
|
346
|
+
return round(x / base_width * width), round(y / base_height * height)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _png_size(path: Path) -> tuple[int, int] | None:
|
|
350
|
+
try:
|
|
351
|
+
with path.open("rb") as handle:
|
|
352
|
+
header = handle.read(24)
|
|
353
|
+
if len(header) < 24 or header[:8] != b"\x89PNG\r\n\x1a\n":
|
|
354
|
+
return None
|
|
355
|
+
return struct.unpack(">II", header[16:24])
|
|
356
|
+
except Exception:
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _optional_int(value: Any) -> int | None:
|
|
361
|
+
if value in (None, ""):
|
|
362
|
+
return None
|
|
363
|
+
return int(value)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _now_shanghai() -> str:
|
|
367
|
+
return datetime.now(ZoneInfo("Asia/Shanghai")).replace(microsecond=0).isoformat()
|