@11agents/cli 0.1.24 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +52 -0
  2. package/bin/11agents.js +12 -0
  3. package/mobile-runtime/README.md +19 -0
  4. package/mobile-runtime/configs/platforms/xiaohongshu_d01.json +73 -0
  5. package/mobile-runtime/configs/platforms/xiaohongshu_d02.json +70 -0
  6. package/mobile-runtime/configs/platforms/xiaohongshu_d03.json +73 -0
  7. package/mobile-runtime/configs/publish_policy.json +40 -0
  8. package/mobile-runtime/data-templates/README.md +4 -0
  9. package/mobile-runtime/data-templates/accounts.example.csv +6 -0
  10. package/mobile-runtime/data-templates/devices.example.csv +2 -0
  11. package/mobile-runtime/data-templates/publish_records.example.jsonl +2 -0
  12. package/mobile-runtime/data-templates/tasks.example.jsonl +5 -0
  13. package/mobile-runtime/data-templates/video_metrics.example.jsonl +1 -0
  14. package/mobile-runtime/python/pyproject.toml +34 -0
  15. package/mobile-runtime/python/src/device_control/__init__.py +5 -0
  16. package/mobile-runtime/python/src/device_control/adapters/__init__.py +31 -0
  17. package/mobile-runtime/python/src/device_control/adapters/base.py +43 -0
  18. package/mobile-runtime/python/src/device_control/adapters/facebook.py +30 -0
  19. package/mobile-runtime/python/src/device_control/adapters/instagram.py +25 -0
  20. package/mobile-runtime/python/src/device_control/adapters/reddit.py +29 -0
  21. package/mobile-runtime/python/src/device_control/adapters/tiktok.py +25 -0
  22. package/mobile-runtime/python/src/device_control/adapters/x.py +29 -0
  23. package/mobile-runtime/python/src/device_control/adapters/xiaohongshu.py +26 -0
  24. package/mobile-runtime/python/src/device_control/adb.py +161 -0
  25. package/mobile-runtime/python/src/device_control/appium_client.py +131 -0
  26. package/mobile-runtime/python/src/device_control/appium_manager.py +403 -0
  27. package/mobile-runtime/python/src/device_control/cli.py +1608 -0
  28. package/mobile-runtime/python/src/device_control/entrypoints.py +60 -0
  29. package/mobile-runtime/python/src/device_control/locks.py +162 -0
  30. package/mobile-runtime/python/src/device_control/metrics/__init__.py +33 -0
  31. package/mobile-runtime/python/src/device_control/metrics/collectors.py +320 -0
  32. package/mobile-runtime/python/src/device_control/metrics/tiktok_account_adb.py +367 -0
  33. package/mobile-runtime/python/src/device_control/metrics/tiktok_video_adb.py +714 -0
  34. package/mobile-runtime/python/src/device_control/models.py +439 -0
  35. package/mobile-runtime/python/src/device_control/publish_policy.py +173 -0
  36. package/mobile-runtime/python/src/device_control/publishers/__init__.py +24 -0
  37. package/mobile-runtime/python/src/device_control/publishers/facebook_adb.py +494 -0
  38. package/mobile-runtime/python/src/device_control/publishers/instagram_adb.py +663 -0
  39. package/mobile-runtime/python/src/device_control/publishers/reddit_adb.py +595 -0
  40. package/mobile-runtime/python/src/device_control/publishers/tiktok_adb.py +477 -0
  41. package/mobile-runtime/python/src/device_control/publishers/tiktok_appium.py +259 -0
  42. package/mobile-runtime/python/src/device_control/publishers/ui_helpers.py +372 -0
  43. package/mobile-runtime/python/src/device_control/publishers/x_adb.py +636 -0
  44. package/mobile-runtime/python/src/device_control/publishers/xiaohongshu_adb.py +1143 -0
  45. package/mobile-runtime/python/src/device_control/store.py +137 -0
  46. package/mobile-runtime/scripts/appium_smoke.py +71 -0
  47. package/mobile-runtime/skills/android-collect-tiktok-metrics/SKILL.md +60 -0
  48. package/mobile-runtime/skills/android-collect-tiktok-metrics/agents/openai.yaml +4 -0
  49. package/mobile-runtime/skills/android-group-control-cli/SKILL.md +76 -0
  50. package/mobile-runtime/skills/android-group-control-cli/agents/openai.yaml +4 -0
  51. package/mobile-runtime/skills/android-group-control-cli/references/command-reference.md +122 -0
  52. package/mobile-runtime/skills/android-publish-facebook/SKILL.md +41 -0
  53. package/mobile-runtime/skills/android-publish-facebook/agents/openai.yaml +4 -0
  54. package/mobile-runtime/skills/android-publish-instagram/SKILL.md +45 -0
  55. package/mobile-runtime/skills/android-publish-instagram/agents/openai.yaml +4 -0
  56. package/mobile-runtime/skills/android-publish-reddit/SKILL.md +41 -0
  57. package/mobile-runtime/skills/android-publish-reddit/agents/openai.yaml +4 -0
  58. package/mobile-runtime/skills/android-publish-tiktok/SKILL.md +43 -0
  59. package/mobile-runtime/skills/android-publish-tiktok/agents/openai.yaml +4 -0
  60. package/mobile-runtime/skills/android-publish-x/SKILL.md +40 -0
  61. package/mobile-runtime/skills/android-publish-x/agents/openai.yaml +4 -0
  62. package/mobile-runtime/skills/android-publish-xiaohongshu/SKILL.md +50 -0
  63. package/mobile-runtime/skills/android-publish-xiaohongshu/agents/openai.yaml +4 -0
  64. package/mobile-runtime/skills/mobile-publish-data-collection/SKILL.md +49 -0
  65. package/mobile-runtime/skills/mobile-publish-device-health/SKILL.md +47 -0
  66. package/mobile-runtime/skills/mobile-publish-execution/SKILL.md +57 -0
  67. package/mobile-runtime/skills/mobile-publish-records/SKILL.md +29 -0
  68. package/package.json +4 -1
  69. package/scripts/mobile-postinstall.js +26 -0
  70. package/src/commands/mobile.js +695 -0
  71. package/src/commands/runtime.js +6 -5
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+
8
+ def publish_reddit() -> None:
9
+ sys.argv = ["groupctl", "publish-reddit", *sys.argv[1:]]
10
+ main()
11
+
12
+
13
+ def publish_facebook() -> None:
14
+ sys.argv = ["groupctl", "publish-facebook", *sys.argv[1:]]
15
+ main()
16
+
17
+
18
+ def publish_instagram() -> None:
19
+ sys.argv = ["groupctl", "publish-instagram", *sys.argv[1:]]
20
+ main()
21
+
22
+
23
+ def publish_tiktok() -> None:
24
+ sys.argv = ["groupctl", "publish-tiktok", *sys.argv[1:]]
25
+ main()
26
+
27
+
28
+ def publish_x() -> None:
29
+ sys.argv = ["groupctl", "publish-x", *sys.argv[1:]]
30
+ main()
31
+
32
+
33
+ def publish_xiaohongshu() -> None:
34
+ sys.argv = ["groupctl", "publish-xiaohongshu", *sys.argv[1:]]
35
+ main()
36
+
37
+
38
+ def copy_xiaohongshu_link() -> None:
39
+ sys.argv = ["groupctl", "copy-xiaohongshu-link", *sys.argv[1:]]
40
+ main()
41
+
42
+
43
+ def ensure_appium() -> None:
44
+ sys.argv = ["groupctl", "ensure-appium", *sys.argv[1:]]
45
+ main()
46
+
47
+
48
+ def stop_appium() -> None:
49
+ sys.argv = ["groupctl", "stop-appium", *sys.argv[1:]]
50
+ main()
51
+
52
+
53
+ def collect_tiktok_account_metrics() -> None:
54
+ sys.argv = ["groupctl", "collect-tiktok-account-metrics-adb", *sys.argv[1:]]
55
+ main()
56
+
57
+
58
+ def collect_tiktok_video_metrics() -> None:
59
+ sys.argv = ["groupctl", "collect-tiktok-video-metrics-adb", *sys.argv[1:]]
60
+ main()
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ import uuid
7
+ from contextlib import contextmanager
8
+ from datetime import datetime, timezone
9
+ from typing import Any
10
+
11
+
12
+ class DeviceLockError(RuntimeError):
13
+ """Raised when a device is already controlled by another process."""
14
+
15
+
16
+ class PublishSlotError(DeviceLockError):
17
+ """Raised when the global publish concurrency cap is already reached."""
18
+
19
+
20
+ def _lock_path(device_id: str) -> str:
21
+ safe = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in device_id)
22
+ return os.path.join(tempfile.gettempdir(), f"groupctl-device-{safe}.lock")
23
+
24
+
25
+ def _publish_slot_path(slot: int) -> str:
26
+ return os.path.join(tempfile.gettempdir(), f"groupctl-publish-slot-{slot}.lock")
27
+
28
+
29
+ def _pid_running(pid: int) -> bool:
30
+ if pid <= 0:
31
+ return False
32
+ try:
33
+ os.kill(pid, 0)
34
+ except ProcessLookupError:
35
+ return False
36
+ except PermissionError:
37
+ return True
38
+ except OSError:
39
+ return False
40
+ return True
41
+
42
+
43
+ def _read_lock(path: str) -> dict[str, Any]:
44
+ try:
45
+ with open(path, "r", encoding="utf-8") as handle:
46
+ data = json.load(handle)
47
+ if isinstance(data, dict):
48
+ return data
49
+ except Exception:
50
+ pass
51
+ return {}
52
+
53
+
54
+ def _write_lock(path: str, payload: dict[str, Any]) -> None:
55
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
56
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
57
+ json.dump(payload, handle, ensure_ascii=False)
58
+
59
+
60
+ def _remove_stale_lock(path: str) -> tuple[bool, dict[str, Any]]:
61
+ data = _read_lock(path)
62
+ pid = data.get("pid")
63
+ if isinstance(pid, int) and _pid_running(pid):
64
+ return False, data
65
+ try:
66
+ os.remove(path)
67
+ except FileNotFoundError:
68
+ pass
69
+ except OSError:
70
+ return False, data
71
+ return True, data
72
+
73
+
74
+ def _conflict_message(device_id: str, data: dict[str, Any]) -> str:
75
+ pid = data.get("pid")
76
+ started_at = data.get("started_at")
77
+ if isinstance(pid, int):
78
+ detail = f"pid={pid}"
79
+ if isinstance(started_at, str) and started_at:
80
+ detail += f", started_at={started_at}"
81
+ return f"device {device_id} is already locked ({detail})"
82
+ return f"device {device_id} is already locked"
83
+
84
+
85
+ @contextmanager
86
+ def device_lock(device_id: str):
87
+ """Acquire an inter-process lock for one physical device."""
88
+ path = _lock_path(device_id)
89
+ token = uuid.uuid4().hex
90
+ payload = {
91
+ "pid": os.getpid(),
92
+ "device_id": device_id,
93
+ "started_at": datetime.now(timezone.utc).isoformat(),
94
+ "token": token,
95
+ }
96
+
97
+ for attempt in range(2):
98
+ try:
99
+ _write_lock(path, payload)
100
+ break
101
+ except FileExistsError:
102
+ removed, data = _remove_stale_lock(path)
103
+ if removed and attempt == 0:
104
+ continue
105
+ raise DeviceLockError(_conflict_message(device_id, data))
106
+ else:
107
+ raise DeviceLockError(f"could not acquire device lock for {device_id}")
108
+
109
+ try:
110
+ yield
111
+ finally:
112
+ try:
113
+ current = _read_lock(path)
114
+ if current.get("token") == token:
115
+ os.remove(path)
116
+ except FileNotFoundError:
117
+ pass
118
+ except OSError:
119
+ pass
120
+
121
+
122
+ @contextmanager
123
+ def publish_slot(max_slots: int):
124
+ """Acquire one global inter-process publishing slot."""
125
+ token = uuid.uuid4().hex
126
+ payload_base = {
127
+ "pid": os.getpid(),
128
+ "started_at": datetime.now(timezone.utc).isoformat(),
129
+ "token": token,
130
+ }
131
+ acquired_path = ""
132
+
133
+ for slot in range(max_slots):
134
+ path = _publish_slot_path(slot)
135
+ payload = {**payload_base, "slot": slot, "max_slots": max_slots}
136
+ for attempt in range(2):
137
+ try:
138
+ _write_lock(path, payload)
139
+ acquired_path = path
140
+ break
141
+ except FileExistsError:
142
+ removed, _data = _remove_stale_lock(path)
143
+ if removed and attempt == 0:
144
+ continue
145
+ break
146
+ if acquired_path:
147
+ break
148
+
149
+ if not acquired_path:
150
+ raise PublishSlotError(f"global publish concurrency limit reached (max_slots={max_slots})")
151
+
152
+ try:
153
+ yield
154
+ finally:
155
+ try:
156
+ current = _read_lock(acquired_path)
157
+ if current.get("token") == token:
158
+ os.remove(acquired_path)
159
+ except FileNotFoundError:
160
+ pass
161
+ except OSError:
162
+ pass
@@ -0,0 +1,33 @@
1
+ from .collectors import (
2
+ InstagramGraphApiCollector,
3
+ MetricsCollectionError,
4
+ MetricsCollector,
5
+ RedditPublicJsonCollector,
6
+ TikTokDisplayApiCollector,
7
+ build_manual_snapshot,
8
+ collect_with_default_collector,
9
+ parse_platform_post_id,
10
+ )
11
+ from .tiktok_account_adb import (
12
+ TikTokAccountMetricsAdbCollector,
13
+ parse_manual_values_json,
14
+ )
15
+ from .tiktok_video_adb import (
16
+ TikTokVideoMetricsAdbCollector,
17
+ parse_manual_video_values_json,
18
+ )
19
+
20
+ __all__ = [
21
+ "InstagramGraphApiCollector",
22
+ "MetricsCollectionError",
23
+ "MetricsCollector",
24
+ "RedditPublicJsonCollector",
25
+ "TikTokAccountMetricsAdbCollector",
26
+ "TikTokDisplayApiCollector",
27
+ "TikTokVideoMetricsAdbCollector",
28
+ "build_manual_snapshot",
29
+ "collect_with_default_collector",
30
+ "parse_manual_values_json",
31
+ "parse_manual_video_values_json",
32
+ "parse_platform_post_id",
33
+ ]
@@ -0,0 +1,320 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import ssl
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ from abc import ABC, abstractmethod
11
+ from typing import Any
12
+
13
+ from device_control.models import PublishRecord, VideoMetricsSnapshot, new_id, utc_now_iso
14
+
15
+
16
+ class MetricsCollectionError(RuntimeError):
17
+ pass
18
+
19
+
20
+ class MetricsCollector(ABC):
21
+ platform: str
22
+
23
+ @abstractmethod
24
+ def collect(self, record: PublishRecord) -> VideoMetricsSnapshot:
25
+ """Collect one metrics snapshot for a published post."""
26
+
27
+
28
+ def build_manual_snapshot(
29
+ record: PublishRecord,
30
+ *,
31
+ views: int | None = None,
32
+ likes: int | None = None,
33
+ comments: int | None = None,
34
+ shares: int | None = None,
35
+ saves: int | None = None,
36
+ score: int | None = None,
37
+ raw: dict[str, Any] | None = None,
38
+ ) -> VideoMetricsSnapshot:
39
+ return VideoMetricsSnapshot(
40
+ snapshot_id=new_id("met"),
41
+ record_id=record.record_id,
42
+ platform=record.platform,
43
+ account_id=record.account_id,
44
+ platform_post_id=record.platform_post_id,
45
+ platform_permalink=record.platform_permalink,
46
+ fetched_at=utc_now_iso(),
47
+ source="manual",
48
+ views=views,
49
+ likes=likes,
50
+ comments=comments,
51
+ shares=shares,
52
+ saves=saves,
53
+ score=score,
54
+ raw=raw or {},
55
+ )
56
+
57
+
58
+ class TikTokDisplayApiCollector(MetricsCollector):
59
+ platform = "tiktok"
60
+
61
+ def __init__(self, access_token: str, api_base: str = "https://open.tiktokapis.com") -> None:
62
+ self.access_token = access_token
63
+ self.api_base = api_base.rstrip("/")
64
+
65
+ def collect(self, record: PublishRecord) -> VideoMetricsSnapshot:
66
+ post_id = _require_post_id(record)
67
+ fields = ",".join(
68
+ [
69
+ "id",
70
+ "create_time",
71
+ "cover_image_url",
72
+ "share_url",
73
+ "video_description",
74
+ "duration",
75
+ "height",
76
+ "width",
77
+ "title",
78
+ "like_count",
79
+ "comment_count",
80
+ "share_count",
81
+ "view_count",
82
+ ]
83
+ )
84
+ url = f"{self.api_base}/v2/video/query/?fields={urllib.parse.quote(fields, safe=',')}"
85
+ payload = {"filters": {"video_ids": [post_id]}}
86
+ data = _request_json(
87
+ "POST",
88
+ url,
89
+ payload,
90
+ {
91
+ "Authorization": f"Bearer {self.access_token}",
92
+ "Content-Type": "application/json",
93
+ },
94
+ )
95
+ videos = (data.get("data") or {}).get("videos") or []
96
+ if not videos:
97
+ raise MetricsCollectionError(f"TikTok returned no video for post_id={post_id}")
98
+ item = videos[0]
99
+ return VideoMetricsSnapshot(
100
+ snapshot_id=new_id("met"),
101
+ record_id=record.record_id,
102
+ platform=record.platform,
103
+ account_id=record.account_id,
104
+ platform_post_id=post_id,
105
+ platform_permalink=record.platform_permalink or str(item.get("share_url", "")),
106
+ fetched_at=utc_now_iso(),
107
+ source="tiktok_display_api",
108
+ views=_optional_int(item.get("view_count")),
109
+ likes=_optional_int(item.get("like_count")),
110
+ comments=_optional_int(item.get("comment_count")),
111
+ shares=_optional_int(item.get("share_count")),
112
+ raw=item,
113
+ )
114
+
115
+
116
+ class InstagramGraphApiCollector(MetricsCollector):
117
+ platform = "instagram"
118
+
119
+ def __init__(
120
+ self,
121
+ access_token: str,
122
+ graph_base: str | None = None,
123
+ metrics: tuple[str, ...] = ("views", "reach", "saved", "shares", "total_interactions"),
124
+ ) -> None:
125
+ self.access_token = access_token
126
+ version = os.environ.get("META_GRAPH_API_VERSION", "v23.0")
127
+ self.graph_base = (graph_base or f"https://graph.facebook.com/{version}").rstrip("/")
128
+ self.metrics = metrics
129
+
130
+ def collect(self, record: PublishRecord) -> VideoMetricsSnapshot:
131
+ post_id = _require_post_id(record)
132
+ media_url = f"{self.graph_base}/{urllib.parse.quote(post_id)}"
133
+ media = _request_json(
134
+ "GET",
135
+ _url_with_params(
136
+ media_url,
137
+ {
138
+ "fields": "id,permalink,like_count,comments_count,caption,timestamp,media_product_type",
139
+ "access_token": self.access_token,
140
+ },
141
+ ),
142
+ None,
143
+ {},
144
+ )
145
+ insights: dict[str, Any] = {}
146
+ try:
147
+ insights_url = f"{self.graph_base}/{urllib.parse.quote(post_id)}/insights"
148
+ insights = _request_json(
149
+ "GET",
150
+ _url_with_params(
151
+ insights_url,
152
+ {
153
+ "metric": ",".join(self.metrics),
154
+ "access_token": self.access_token,
155
+ },
156
+ ),
157
+ None,
158
+ {},
159
+ )
160
+ except MetricsCollectionError as exc:
161
+ insights = {"error": str(exc)}
162
+
163
+ insight_values = _flatten_instagram_insights(insights)
164
+ return VideoMetricsSnapshot(
165
+ snapshot_id=new_id("met"),
166
+ record_id=record.record_id,
167
+ platform=record.platform,
168
+ account_id=record.account_id,
169
+ platform_post_id=post_id,
170
+ platform_permalink=record.platform_permalink or str(media.get("permalink", "")),
171
+ fetched_at=utc_now_iso(),
172
+ source="instagram_graph_api",
173
+ views=_optional_int(insight_values.get("views")),
174
+ likes=_optional_int(media.get("like_count")),
175
+ comments=_optional_int(media.get("comments_count")),
176
+ shares=_optional_int(insight_values.get("shares")),
177
+ saves=_optional_int(insight_values.get("saved")),
178
+ raw={"media": media, "insights": insights},
179
+ )
180
+
181
+
182
+ class RedditPublicJsonCollector(MetricsCollector):
183
+ platform = "reddit"
184
+
185
+ def collect(self, record: PublishRecord) -> VideoMetricsSnapshot:
186
+ url = _reddit_json_url(record)
187
+ data = _request_json(
188
+ "GET",
189
+ url,
190
+ None,
191
+ {"User-Agent": os.environ.get("REDDIT_USER_AGENT", "elevenagents-mobile-runtime/0.1")},
192
+ )
193
+ post = _extract_reddit_post(data)
194
+ permalink = record.platform_permalink or f"https://www.reddit.com{post.get('permalink', '')}"
195
+ return VideoMetricsSnapshot(
196
+ snapshot_id=new_id("met"),
197
+ record_id=record.record_id,
198
+ platform=record.platform,
199
+ account_id=record.account_id,
200
+ platform_post_id=record.platform_post_id or str(post.get("id", "")),
201
+ platform_permalink=permalink,
202
+ fetched_at=utc_now_iso(),
203
+ source="reddit_public_json",
204
+ likes=_optional_int(post.get("ups")),
205
+ comments=_optional_int(post.get("num_comments")),
206
+ score=_optional_int(post.get("score")),
207
+ raw=post,
208
+ )
209
+
210
+
211
+ def collect_with_default_collector(record: PublishRecord) -> VideoMetricsSnapshot:
212
+ if record.platform == "tiktok":
213
+ _require_post_id(record)
214
+ token = _token_for(record, "TIKTOK_ACCESS_TOKEN")
215
+ if not token:
216
+ raise MetricsCollectionError("Missing TikTok access token. Set TIKTOK_ACCESS_TOKEN or TIKTOK_ACCESS_TOKEN_<ACCOUNT_ID>.")
217
+ return TikTokDisplayApiCollector(token).collect(record)
218
+ if record.platform == "instagram":
219
+ _require_post_id(record)
220
+ token = _token_for(record, "INSTAGRAM_ACCESS_TOKEN")
221
+ if not token:
222
+ raise MetricsCollectionError(
223
+ "Missing Instagram access token. Set INSTAGRAM_ACCESS_TOKEN or INSTAGRAM_ACCESS_TOKEN_<ACCOUNT_ID>."
224
+ )
225
+ return InstagramGraphApiCollector(token).collect(record)
226
+ if record.platform == "reddit":
227
+ return RedditPublicJsonCollector().collect(record)
228
+ raise MetricsCollectionError(f"No metrics collector for platform={record.platform}")
229
+
230
+
231
+ def _request_json(method: str, url: str, payload: dict[str, Any] | None, headers: dict[str, str]) -> dict[str, Any]:
232
+ body = json.dumps(payload).encode("utf-8") if payload is not None else None
233
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
234
+ try:
235
+ with urllib.request.urlopen(req, timeout=30, context=_ssl_context()) as resp:
236
+ return json.loads(resp.read().decode("utf-8"))
237
+ except urllib.error.HTTPError as exc:
238
+ detail = exc.read().decode("utf-8", errors="replace")
239
+ raise MetricsCollectionError(f"HTTP {exc.code} from {url}: {detail}") from exc
240
+ except Exception as exc:
241
+ raise MetricsCollectionError(f"Request failed for {url}: {exc}") from exc
242
+
243
+
244
+ def _ssl_context() -> ssl.SSLContext | None:
245
+ try:
246
+ import certifi
247
+ except Exception:
248
+ return None
249
+ return ssl.create_default_context(cafile=certifi.where())
250
+
251
+
252
+ def _url_with_params(url: str, params: dict[str, str]) -> str:
253
+ return f"{url}?{urllib.parse.urlencode(params)}"
254
+
255
+
256
+ def _token_for(record: PublishRecord, base_key: str) -> str:
257
+ account_key = re.sub(r"[^A-Za-z0-9_]", "_", record.account_id).upper()
258
+ return os.environ.get(f"{base_key}_{account_key}") or os.environ.get(base_key, "")
259
+
260
+
261
+ def _require_post_id(record: PublishRecord) -> str:
262
+ post_id = record.platform_post_id or parse_platform_post_id(record.platform, record.platform_permalink)
263
+ if not post_id:
264
+ raise MetricsCollectionError(f"record_id={record.record_id} missing platform_post_id")
265
+ return post_id
266
+
267
+
268
+ def parse_platform_post_id(platform: str, permalink: str) -> str:
269
+ if not permalink:
270
+ return ""
271
+ if platform == "tiktok":
272
+ match = re.search(r"/video/(\d+)", permalink)
273
+ return match.group(1) if match else ""
274
+ if platform == "reddit":
275
+ match = re.search(r"/comments/([A-Za-z0-9_]+)", permalink)
276
+ return match.group(1) if match else ""
277
+ return ""
278
+
279
+
280
+ def _optional_int(value: Any) -> int | None:
281
+ if value in (None, ""):
282
+ return None
283
+ return int(value)
284
+
285
+
286
+ def _flatten_instagram_insights(data: dict[str, Any]) -> dict[str, int]:
287
+ values: dict[str, int] = {}
288
+ for item in data.get("data") or []:
289
+ metric_name = item.get("name")
290
+ metric_values = item.get("values") or []
291
+ if metric_name and metric_values:
292
+ values[str(metric_name)] = int(metric_values[-1].get("value", 0))
293
+ return values
294
+
295
+
296
+ def _reddit_json_url(record: PublishRecord) -> str:
297
+ if record.platform_permalink:
298
+ url = record.platform_permalink
299
+ if not url.startswith("http"):
300
+ url = f"https://www.reddit.com{url}"
301
+ if not url.endswith(".json"):
302
+ url = url.rstrip("/") + ".json"
303
+ return url
304
+ if record.platform_post_id:
305
+ post_id = record.platform_post_id
306
+ if post_id.startswith("t3_"):
307
+ post_id = post_id[3:]
308
+ return f"https://www.reddit.com/by_id/t3_{post_id}.json"
309
+ raise MetricsCollectionError(f"record_id={record.record_id} missing platform_permalink or platform_post_id")
310
+
311
+
312
+ def _extract_reddit_post(data: Any) -> dict[str, Any]:
313
+ if isinstance(data, list) and data:
314
+ listing = data[0]
315
+ else:
316
+ listing = data
317
+ children = ((listing.get("data") or {}).get("children") or []) if isinstance(listing, dict) else []
318
+ if not children:
319
+ raise MetricsCollectionError("Reddit response did not contain a post")
320
+ return dict(children[0].get("data") or {})