@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,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 {})
|