creatorloop 0.1.0__py3-none-any.whl
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.
- creatorloop/__init__.py +13 -0
- creatorloop/__main__.py +3 -0
- creatorloop/analytics.py +577 -0
- creatorloop/auth_cli.py +133 -0
- creatorloop/comments.py +111 -0
- creatorloop/config.py +110 -0
- creatorloop/env.example +33 -0
- creatorloop/graph_client.py +224 -0
- creatorloop/instagram.py +461 -0
- creatorloop/paths.py +67 -0
- creatorloop/post_queue.py +196 -0
- creatorloop/publish.py +417 -0
- creatorloop/refs.py +188 -0
- creatorloop/reviews.py +116 -0
- creatorloop/server.py +816 -0
- creatorloop/setup_cli.py +175 -0
- creatorloop/storage.py +464 -0
- creatorloop/tunnel.py +169 -0
- creatorloop-0.1.0.dist-info/METADATA +254 -0
- creatorloop-0.1.0.dist-info/RECORD +23 -0
- creatorloop-0.1.0.dist-info/WHEEL +4 -0
- creatorloop-0.1.0.dist-info/entry_points.txt +4 -0
- creatorloop-0.1.0.dist-info/licenses/LICENSE +21 -0
creatorloop/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""creatorloop: an open-source AI content studio for Instagram creators.
|
|
2
|
+
|
|
3
|
+
An MCP server for Claude built on the official Instagram Graph API (Instagram API with Facebook Login):
|
|
4
|
+
analyse your account and competitors, make Reels and carousels, schedule them and review how they did.
|
|
5
|
+
Not affiliated with, endorsed or sponsored by Meta or Instagram.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
DISCLAIMER = (
|
|
11
|
+
"creatorloop is an independent open-source project. Not affiliated with, endorsed or sponsored by Meta or "
|
|
12
|
+
"Instagram. You are responsible for complying with the Meta Platform Terms and Instagram's policies."
|
|
13
|
+
)
|
creatorloop/__main__.py
ADDED
creatorloop/analytics.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
"""Pure analytics over media lists. No I/O here, easy to unit-test."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import statistics
|
|
7
|
+
from collections import Counter, defaultdict
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import Any
|
|
10
|
+
from zoneinfo import ZoneInfo
|
|
11
|
+
|
|
12
|
+
HASHTAG_RE = re.compile(r"#([\wЀ-ӿ]+)", re.UNICODE)
|
|
13
|
+
WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_ts(value: str) -> datetime:
|
|
17
|
+
# Graph API format: 2026-10-01T12:34:56+0000
|
|
18
|
+
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S%z")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def media_format(m: dict[str, Any]) -> str:
|
|
22
|
+
"""REELS / CAROUSEL / IMAGE / VIDEO / STORY."""
|
|
23
|
+
if m.get("media_product_type") in ("REELS", "STORY"):
|
|
24
|
+
return m["media_product_type"]
|
|
25
|
+
match m.get("media_type"):
|
|
26
|
+
case "CAROUSEL_ALBUM":
|
|
27
|
+
return "CAROUSEL"
|
|
28
|
+
case "VIDEO":
|
|
29
|
+
return "VIDEO"
|
|
30
|
+
case "IMAGE":
|
|
31
|
+
return "IMAGE"
|
|
32
|
+
case other:
|
|
33
|
+
return other or "UNKNOWN"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def hashtags(caption: str | None) -> list[str]:
|
|
37
|
+
return [t.lower() for t in HASHTAG_RE.findall(caption or "")]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def interactions(m: dict[str, Any]) -> int | None:
|
|
41
|
+
"""Public interactions (likes + comments). None if likes are hidden."""
|
|
42
|
+
likes = m.get("like_count")
|
|
43
|
+
if likes is None:
|
|
44
|
+
return None
|
|
45
|
+
return likes + (m.get("comments_count") or 0)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _avg(values: list[float | int | None]) -> float | None:
|
|
49
|
+
clean = [v for v in values if v is not None]
|
|
50
|
+
return round(statistics.fmean(clean), 1) if clean else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _median(values: list[float | int | None]) -> float | None:
|
|
54
|
+
clean = [v for v in values if v is not None]
|
|
55
|
+
return round(statistics.median(clean), 1) if clean else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
YOUNG_POST_HOURS = 72 # insights keep growing and lag up to 48h; fresh posts distort averages
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def age_hours(m: dict[str, Any], now: datetime | None = None) -> float | None:
|
|
62
|
+
if not m.get("timestamp"):
|
|
63
|
+
return None
|
|
64
|
+
return ((now or datetime.now(UTC)) - parse_ts(m["timestamp"])).total_seconds() / 3600
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def split_by_age(
|
|
68
|
+
media: list[dict[str, Any]], min_age_hours: float = YOUNG_POST_HOURS
|
|
69
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
70
|
+
"""(mature, young). Posts without timestamp count as mature."""
|
|
71
|
+
if min_age_hours <= 0:
|
|
72
|
+
return list(media), []
|
|
73
|
+
mature, young = [], []
|
|
74
|
+
for m in media:
|
|
75
|
+
age = age_hours(m)
|
|
76
|
+
(young if age is not None and age < min_age_hours else mature).append(m)
|
|
77
|
+
return mature, young
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def own_interactions(metrics: dict[str, Any]) -> int:
|
|
81
|
+
return sum(metrics.get(k) or 0 for k in ("likes", "comments", "saved", "shares"))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def weighted_engagement_rate(items: list[dict[str, Any]]) -> float | None:
|
|
85
|
+
"""Σ(likes+comments+saved+shares) / Σreach * 100 — robust to tiny-reach posts."""
|
|
86
|
+
reach = sum((m.get("insights") or {}).get("reach") or 0 for m in items)
|
|
87
|
+
if not reach:
|
|
88
|
+
return None
|
|
89
|
+
total = sum(own_interactions(m.get("insights") or {}) for m in items)
|
|
90
|
+
return round(100 * total / reach, 2)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _views(m: dict[str, Any]) -> int | None:
|
|
94
|
+
if m.get("view_count") is not None:
|
|
95
|
+
return m["view_count"]
|
|
96
|
+
return (m.get("insights") or {}).get("views")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _short(m: dict[str, Any], extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
100
|
+
caption = (m.get("caption") or "").replace("\n", " ")
|
|
101
|
+
return {
|
|
102
|
+
"id": m.get("id"),
|
|
103
|
+
"format": media_format(m),
|
|
104
|
+
"timestamp": m.get("timestamp"),
|
|
105
|
+
"permalink": m.get("permalink"),
|
|
106
|
+
"caption": caption[:120] + ("…" if len(caption) > 120 else ""),
|
|
107
|
+
"likes": m.get("like_count"),
|
|
108
|
+
"comments": m.get("comments_count"),
|
|
109
|
+
"views": _views(m),
|
|
110
|
+
**(extra or {}),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
KEY_INSIGHTS = ("reach", "views", "likes", "comments", "saved", "shares", "follows", "profile_visits")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def compact_insights(ins: dict[str, Any]) -> dict[str, Any]:
|
|
118
|
+
out = {k: ins[k] for k in KEY_INSIGHTS if ins.get(k) is not None}
|
|
119
|
+
if ins.get("ig_reels_avg_watch_time"):
|
|
120
|
+
out["avg_watch_s"] = round(ins["ig_reels_avg_watch_time"] / 1000, 1)
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def posting_frequency(media: list[dict[str, Any]]) -> dict[str, Any]:
|
|
125
|
+
stamps = sorted(parse_ts(m["timestamp"]) for m in media if m.get("timestamp"))
|
|
126
|
+
if len(stamps) < 2:
|
|
127
|
+
return {"posts": len(stamps), "posts_per_week": None, "span_days": 0}
|
|
128
|
+
span_days = max((stamps[-1] - stamps[0]).total_seconds() / 86400, 1)
|
|
129
|
+
gaps = [(b - a).total_seconds() / 86400 for a, b in zip(stamps, stamps[1:], strict=False)]
|
|
130
|
+
return {
|
|
131
|
+
"posts": len(stamps),
|
|
132
|
+
"span_days": round(span_days, 1),
|
|
133
|
+
"posts_per_week": round(len(stamps) / span_days * 7, 2),
|
|
134
|
+
"median_gap_days": round(statistics.median(gaps), 2),
|
|
135
|
+
"first_post": stamps[0].isoformat(),
|
|
136
|
+
"last_post": stamps[-1].isoformat(),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def best_times(media: list[dict[str, Any]], tz: ZoneInfo, score_key=interactions) -> dict[str, Any]:
|
|
141
|
+
by_day: dict[str, list[float]] = defaultdict(list)
|
|
142
|
+
by_hour: dict[int, list[float]] = defaultdict(list)
|
|
143
|
+
for m in media:
|
|
144
|
+
score = score_key(m)
|
|
145
|
+
if score is None or not m.get("timestamp"):
|
|
146
|
+
continue
|
|
147
|
+
local = parse_ts(m["timestamp"]).astimezone(tz)
|
|
148
|
+
by_day[WEEKDAYS[local.weekday()]].append(score)
|
|
149
|
+
by_hour[local.hour].append(score)
|
|
150
|
+
|
|
151
|
+
def rank(groups: dict[Any, list[float]]) -> list[dict[str, Any]]:
|
|
152
|
+
rows = [{"slot": k, "posts": len(v), "avg_score": round(statistics.fmean(v), 1)} for k, v in groups.items()]
|
|
153
|
+
return sorted(rows, key=lambda r: r["avg_score"], reverse=True)
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
"timezone": str(tz),
|
|
157
|
+
"by_weekday": rank(by_day),
|
|
158
|
+
"by_hour": rank(by_hour),
|
|
159
|
+
"note": "Small samples (posts < 3 per slot) are noise, not a pattern.",
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def format_breakdown(media: list[dict[str, Any]], followers: int | None = None) -> list[dict[str, Any]]:
|
|
164
|
+
groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
165
|
+
for m in media:
|
|
166
|
+
groups[media_format(m)].append(m)
|
|
167
|
+
rows = []
|
|
168
|
+
for fmt, items in groups.items():
|
|
169
|
+
inter = [interactions(m) for m in items]
|
|
170
|
+
row = {
|
|
171
|
+
"format": fmt,
|
|
172
|
+
"posts": len(items),
|
|
173
|
+
"share_pct": round(100 * len(items) / len(media), 1),
|
|
174
|
+
"avg_likes": _avg([m.get("like_count") for m in items]),
|
|
175
|
+
"avg_comments": _avg([m.get("comments_count") for m in items]),
|
|
176
|
+
"avg_views": _avg([m.get("view_count") for m in items]),
|
|
177
|
+
"median_interactions": _median(inter),
|
|
178
|
+
}
|
|
179
|
+
if followers:
|
|
180
|
+
avg_inter = _avg(inter)
|
|
181
|
+
row["er_by_followers_pct"] = round(100 * avg_inter / followers, 2) if avg_inter is not None else None
|
|
182
|
+
rows.append(row)
|
|
183
|
+
return sorted(rows, key=lambda r: r["posts"], reverse=True)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def top_hashtags(media: list[dict[str, Any]], limit: int = 15, exclude: set[str] | None = None) -> list[dict[str, Any]]:
|
|
187
|
+
counter: Counter[str] = Counter()
|
|
188
|
+
for m in media:
|
|
189
|
+
counter.update(set(hashtags(m.get("caption"))) - (exclude or set()))
|
|
190
|
+
return [{"hashtag": f"#{tag}", "posts": n} for tag, n in counter.most_common(limit)]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def summarize_public_media(
|
|
194
|
+
media: list[dict[str, Any]],
|
|
195
|
+
followers: int | None,
|
|
196
|
+
tz: ZoneInfo,
|
|
197
|
+
top_n: int = 5,
|
|
198
|
+
min_age_hours: float = YOUNG_POST_HOURS,
|
|
199
|
+
) -> dict[str, Any]:
|
|
200
|
+
"""Analysis based on public counters only (competitors, hashtags).
|
|
201
|
+
Posts younger than min_age_hours are reported separately as still growing."""
|
|
202
|
+
if not media:
|
|
203
|
+
return {"posts_analyzed": 0}
|
|
204
|
+
all_media = media
|
|
205
|
+
media, young = split_by_age(media, min_age_hours)
|
|
206
|
+
if not media:
|
|
207
|
+
return {"posts_analyzed": 0, "still_growing": [_short(m) for m in young]}
|
|
208
|
+
inter = [interactions(m) for m in media]
|
|
209
|
+
hidden = sum(1 for v in inter if v is None)
|
|
210
|
+
avg_inter = _avg(inter)
|
|
211
|
+
ranked = sorted((m for m in media if interactions(m) is not None), key=interactions, reverse=True)
|
|
212
|
+
captions = [len(m.get("caption") or "") for m in media]
|
|
213
|
+
return {
|
|
214
|
+
"posts_analyzed": len(media),
|
|
215
|
+
"avg_likes": _avg([m.get("like_count") for m in media]),
|
|
216
|
+
"avg_comments": _avg([m.get("comments_count") for m in media]),
|
|
217
|
+
"avg_views_reels": _avg([m.get("view_count") for m in media if media_format(m) == "REELS"]),
|
|
218
|
+
"median_interactions": _median(inter),
|
|
219
|
+
"er_by_followers_pct": round(100 * avg_inter / followers, 2) if followers and avg_inter is not None else None,
|
|
220
|
+
"posts_with_hidden_likes": hidden,
|
|
221
|
+
"avg_caption_length": round(statistics.fmean(captions)) if captions else 0,
|
|
222
|
+
"frequency": posting_frequency(all_media),
|
|
223
|
+
"formats": format_breakdown(media, followers),
|
|
224
|
+
"best_times": best_times(media, tz),
|
|
225
|
+
"top_hashtags": top_hashtags(all_media),
|
|
226
|
+
"top_posts": [_short(m) for m in ranked[:top_n]],
|
|
227
|
+
"worst_posts": [_short(m) for m in ranked[-top_n:][::-1]] if len(ranked) > top_n else [],
|
|
228
|
+
"still_growing": [_short(m) for m in young],
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def own_engagement_rate(metrics: dict[str, Any]) -> float | None:
|
|
233
|
+
"""(likes + comments + saved + shares) / reach * 100."""
|
|
234
|
+
reach = metrics.get("reach")
|
|
235
|
+
if not reach:
|
|
236
|
+
return None
|
|
237
|
+
return round(100 * own_interactions(metrics) / reach, 2)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def summarize_own_media(
|
|
241
|
+
media_with_insights: list[dict[str, Any]],
|
|
242
|
+
tz: ZoneInfo,
|
|
243
|
+
rank_by: str = "reach",
|
|
244
|
+
top_n: int = 5,
|
|
245
|
+
min_age_hours: float = YOUNG_POST_HOURS,
|
|
246
|
+
) -> dict[str, Any]:
|
|
247
|
+
"""Analysis of own media where private insights (reach, saves, shares, views) are available.
|
|
248
|
+
Posts younger than min_age_hours are excluded from averages and rankings and listed as still growing."""
|
|
249
|
+
if not media_with_insights:
|
|
250
|
+
return {"posts_analyzed": 0}
|
|
251
|
+
mature, young = split_by_age(media_with_insights, min_age_hours)
|
|
252
|
+
|
|
253
|
+
def metric(m: dict[str, Any], key: str) -> float | None:
|
|
254
|
+
if key == "engagement_rate":
|
|
255
|
+
return m.get("engagement_rate")
|
|
256
|
+
return (m.get("insights") or {}).get(key)
|
|
257
|
+
|
|
258
|
+
by_format: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
259
|
+
for m in mature:
|
|
260
|
+
by_format[media_format(m)].append(m)
|
|
261
|
+
|
|
262
|
+
formats = []
|
|
263
|
+
for fmt, items in by_format.items():
|
|
264
|
+
formats.append(
|
|
265
|
+
{
|
|
266
|
+
"format": fmt,
|
|
267
|
+
"posts": len(items),
|
|
268
|
+
"avg_reach": _avg([metric(m, "reach") for m in items]),
|
|
269
|
+
"median_reach": _median([metric(m, "reach") for m in items]),
|
|
270
|
+
"avg_views": _avg([metric(m, "views") for m in items]),
|
|
271
|
+
"avg_saved": _avg([metric(m, "saved") for m in items]),
|
|
272
|
+
"avg_shares": _avg([metric(m, "shares") for m in items]),
|
|
273
|
+
"engagement_rate_pct": weighted_engagement_rate(items),
|
|
274
|
+
}
|
|
275
|
+
)
|
|
276
|
+
formats.sort(key=lambda r: r["avg_reach"] or 0, reverse=True)
|
|
277
|
+
|
|
278
|
+
ranked = sorted(
|
|
279
|
+
(m for m in mature if metric(m, rank_by) is not None),
|
|
280
|
+
key=lambda m: metric(m, rank_by),
|
|
281
|
+
reverse=True,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def short(m: dict[str, Any]) -> dict[str, Any]:
|
|
285
|
+
f = caption_features(m)
|
|
286
|
+
row = _short(
|
|
287
|
+
m,
|
|
288
|
+
{
|
|
289
|
+
"hook": f["hook"],
|
|
290
|
+
"language": f["language"],
|
|
291
|
+
"insights": compact_insights(m.get("insights") or {}),
|
|
292
|
+
"engagement_rate_pct": m.get("engagement_rate"),
|
|
293
|
+
},
|
|
294
|
+
)
|
|
295
|
+
row.pop("caption", None) # the hook (first line) is enough; keeps responses small
|
|
296
|
+
return row
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
"posts_analyzed": len(mature),
|
|
300
|
+
"ranked_by": rank_by,
|
|
301
|
+
"avg_reach": _avg([metric(m, "reach") for m in mature]),
|
|
302
|
+
"median_reach": _median([metric(m, "reach") for m in mature]),
|
|
303
|
+
"engagement_rate_pct": weighted_engagement_rate(mature),
|
|
304
|
+
"frequency": posting_frequency(media_with_insights),
|
|
305
|
+
"formats": formats,
|
|
306
|
+
"best_times_by_reach": best_times(mature, tz, score_key=lambda m: metric(m, "reach")),
|
|
307
|
+
"top_hashtags": top_hashtags(media_with_insights),
|
|
308
|
+
"top_posts": [short(m) for m in ranked[:top_n]],
|
|
309
|
+
"worst_posts": [short(m) for m in ranked[-top_n:][::-1]] if len(ranked) > top_n else [],
|
|
310
|
+
"conversion": conversion_by_format(mature),
|
|
311
|
+
"reels_watch": reels_watch(mature),
|
|
312
|
+
"still_growing": [short(m) for m in young],
|
|
313
|
+
"young_post_threshold_hours": min_age_hours,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def follower_growth(snapshots: list[dict[str, Any]]) -> dict[str, Any]:
|
|
318
|
+
points = [s for s in snapshots if s.get("followers_count") is not None]
|
|
319
|
+
if len(points) < 2:
|
|
320
|
+
return {
|
|
321
|
+
"points": points,
|
|
322
|
+
"change": None,
|
|
323
|
+
"note": "Need at least 2 days of snapshots. They are saved automatically every time the profile is fetched.",
|
|
324
|
+
}
|
|
325
|
+
first, last = points[0], points[-1]
|
|
326
|
+
days = max((datetime.fromisoformat(last["captured_on"]) - datetime.fromisoformat(first["captured_on"])).days, 1)
|
|
327
|
+
change = last["followers_count"] - first["followers_count"]
|
|
328
|
+
return {
|
|
329
|
+
"from": first["captured_on"],
|
|
330
|
+
"to": last["captured_on"],
|
|
331
|
+
"followers_start": first["followers_count"],
|
|
332
|
+
"followers_end": last["followers_count"],
|
|
333
|
+
"change": change,
|
|
334
|
+
"change_pct": round(100 * change / first["followers_count"], 2) if first["followers_count"] else None,
|
|
335
|
+
"avg_per_day": round(change / days, 1),
|
|
336
|
+
"points": points,
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def media_since(media: list[dict[str, Any]], days: int) -> list[dict[str, Any]]:
|
|
341
|
+
cutoff = datetime.now(UTC).timestamp() - days * 86400
|
|
342
|
+
return [m for m in media if m.get("timestamp") and parse_ts(m["timestamp"]).timestamp() >= cutoff]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# ---- viral posts & growth curves ----------------------------------------------
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def public_score(m: dict[str, Any]) -> tuple[str, int | None]:
|
|
349
|
+
"""Reels are compared by views (if available), everything else by likes + comments."""
|
|
350
|
+
if media_format(m) == "REELS" and m.get("view_count") is not None:
|
|
351
|
+
return "views", m["view_count"]
|
|
352
|
+
return "interactions", interactions(m)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def viral_posts(
|
|
356
|
+
media: list[dict[str, Any]], multiplier: float = 3.0, min_age_hours: float = 24, limit: int = 10
|
|
357
|
+
) -> dict[str, Any]:
|
|
358
|
+
"""Posts whose score is >= multiplier × the account's median for the same score kind."""
|
|
359
|
+
mature, _ = split_by_age(media, min_age_hours)
|
|
360
|
+
groups: dict[str, list[tuple[dict[str, Any], int]]] = defaultdict(list)
|
|
361
|
+
for m in mature:
|
|
362
|
+
kind, score = public_score(m)
|
|
363
|
+
if score is not None:
|
|
364
|
+
groups[kind].append((m, score))
|
|
365
|
+
|
|
366
|
+
medians = {kind: statistics.median(s for _, s in items) for kind, items in groups.items() if items}
|
|
367
|
+
hits = []
|
|
368
|
+
for kind, items in groups.items():
|
|
369
|
+
median = medians[kind]
|
|
370
|
+
if not median:
|
|
371
|
+
continue
|
|
372
|
+
for m, score in items:
|
|
373
|
+
ratio = score / median
|
|
374
|
+
if ratio >= multiplier:
|
|
375
|
+
hits.append({**_short(m), "score_kind": kind, "score": score, "x_median": round(ratio, 1)})
|
|
376
|
+
hits.sort(key=lambda h: h["x_median"], reverse=True)
|
|
377
|
+
return {
|
|
378
|
+
"multiplier": multiplier,
|
|
379
|
+
"medians": {k: round(v, 1) for k, v in medians.items()},
|
|
380
|
+
"posts_checked": len(mature),
|
|
381
|
+
"viral": hits[:limit],
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def post_growth(history: dict[str, Any]) -> dict[str, Any]:
|
|
386
|
+
"""Growth curve of one post from stored snapshots."""
|
|
387
|
+
meta = history["media"]
|
|
388
|
+
published = parse_ts(meta["published_at"]) if meta.get("published_at") else None
|
|
389
|
+
points = []
|
|
390
|
+
for snap in history["snapshots"]:
|
|
391
|
+
at = datetime.fromisoformat(snap["captured_at"])
|
|
392
|
+
age = round((at - published).total_seconds() / 3600, 1) if published else None
|
|
393
|
+
points.append({"age_hours": age, **{k: v for k, v in snap.items() if v is not None}})
|
|
394
|
+
|
|
395
|
+
result: dict[str, Any] = {"media": meta, "snapshots": len(points), "points": points}
|
|
396
|
+
if len(points) >= 2:
|
|
397
|
+
first, last = points[0], points[-1]
|
|
398
|
+
hours = max(
|
|
399
|
+
(datetime.fromisoformat(last["captured_at"]) - datetime.fromisoformat(first["captured_at"])).total_seconds()
|
|
400
|
+
/ 3600,
|
|
401
|
+
1e-9,
|
|
402
|
+
)
|
|
403
|
+
per_day = {}
|
|
404
|
+
for key in ("reach", "views", "likes", "comments", "saved", "shares"):
|
|
405
|
+
if isinstance(first.get(key), int) and isinstance(last.get(key), int):
|
|
406
|
+
per_day[key] = round((last[key] - first[key]) / hours * 24, 1)
|
|
407
|
+
result["gain_per_day"] = per_day
|
|
408
|
+
main = per_day.get("reach", per_day.get("views", per_day.get("likes")))
|
|
409
|
+
base = last.get("reach") or last.get("views") or last.get("likes") or 0
|
|
410
|
+
result["status"] = "growing" if main and base and main / base >= 0.02 else "plateau"
|
|
411
|
+
else:
|
|
412
|
+
result["note"] = "One snapshot so far. Each fresh fetch adds a point (at most one per hour)."
|
|
413
|
+
return result
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ---- captions, hooks, conversion ----------------------------------------------
|
|
417
|
+
|
|
418
|
+
UZ_CYRILLIC = set("ўқғҳЎҚҒҲ")
|
|
419
|
+
# Uzbek written in Russian letters (no ў/қ): typical suffixes and words
|
|
420
|
+
UZ_CYRILLIC_RE = re.compile(
|
|
421
|
+
r"(миз|ингиз|лар|нинг|даги|гача|япти|моқда|ман|чилик)(е*)\b"
|
|
422
|
+
r"|\b(ва|бу|учун|эмас|билан|нима|керак|бир|эди|экан|бор|йук)\b",
|
|
423
|
+
re.I,
|
|
424
|
+
)
|
|
425
|
+
UZ_LATIN_RE = re.compile(
|
|
426
|
+
r"([og]['ʻ’`])|([a-z]['ʻ’][aeiou])|\b(va|bu|uchun|emas|bilan|qanday|nega|nima|men|siz|kerak|ham|yoki|lekin|endi|hamma)\b"
|
|
427
|
+
r"|\w(lar|ning|dagi|ish|ga|da|ni|gacha|adi|ladi|yapti|moqda|miz|siz)\b",
|
|
428
|
+
re.I,
|
|
429
|
+
)
|
|
430
|
+
CTA_RE = re.compile(
|
|
431
|
+
r"(saqla|obuna|izoh|yozing|havola|link|sohran|сохран|подпиш|пиши|напиши|коммент|ссылк|"
|
|
432
|
+
r"save|follow|comment|share|dm\b|direct|директ|ulash|do'stlaringiz)",
|
|
433
|
+
re.I,
|
|
434
|
+
)
|
|
435
|
+
LENGTH_BUCKETS = ((0, "none"), (1, "short <80"), (80, "medium 80-300"), (300, "long 300+"))
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def caption_language(text: str | None) -> str:
|
|
439
|
+
"""Heuristic uz / ru / en / unknown. Uzbek Cyrillic is detected by ў қ ғ ҳ."""
|
|
440
|
+
t = text or ""
|
|
441
|
+
letters = [c for c in t if c.isalpha()]
|
|
442
|
+
if not letters:
|
|
443
|
+
return "unknown"
|
|
444
|
+
cyr = sum(1 for c in letters if "Ѐ" <= c <= "ӿ")
|
|
445
|
+
if cyr / len(letters) > 0.5:
|
|
446
|
+
return "uz" if UZ_CYRILLIC & set(t) or UZ_CYRILLIC_RE.search(t) else "ru"
|
|
447
|
+
return "uz" if len(UZ_LATIN_RE.findall(t)) >= 2 else "en"
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def caption_hook(text: str | None, limit: int = 150) -> str:
|
|
451
|
+
for line in (text or "").splitlines():
|
|
452
|
+
if line.strip():
|
|
453
|
+
return line.strip()[:limit]
|
|
454
|
+
return ""
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def caption_features(m: dict[str, Any]) -> dict[str, Any]:
|
|
458
|
+
text = m.get("caption") or ""
|
|
459
|
+
hook = caption_hook(text)
|
|
460
|
+
length = len(text)
|
|
461
|
+
bucket = next(label for start, label in reversed(LENGTH_BUCKETS) if length >= start)
|
|
462
|
+
return {
|
|
463
|
+
"hook": hook,
|
|
464
|
+
"language": caption_language(text),
|
|
465
|
+
"hook_is_question": hook.endswith("?") or "?" in hook,
|
|
466
|
+
"has_question": "?" in text,
|
|
467
|
+
"has_cta": bool(CTA_RE.search(text)),
|
|
468
|
+
"hook_has_number": bool(re.search(r"\d", hook)),
|
|
469
|
+
"length": length,
|
|
470
|
+
"length_bucket": bucket,
|
|
471
|
+
"hashtags": len(hashtags(text)),
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _own_score(m: dict[str, Any]) -> float | None:
|
|
476
|
+
return (m.get("insights") or {}).get("reach")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def caption_breakdown(media: list[dict[str, Any]], *, own: bool, min_age_hours: float = YOUNG_POST_HOURS) -> dict[str, Any]:
|
|
480
|
+
"""Compare posts by caption traits. Own posts: median reach + weighted ER; public: median likes+comments."""
|
|
481
|
+
mature, _ = split_by_age(media, min_age_hours)
|
|
482
|
+
feats = [(m, caption_features(m)) for m in mature]
|
|
483
|
+
|
|
484
|
+
def group(key: str) -> list[dict[str, Any]]:
|
|
485
|
+
buckets: dict[Any, list[dict[str, Any]]] = defaultdict(list)
|
|
486
|
+
for m, f in feats:
|
|
487
|
+
buckets[f[key]].append(m)
|
|
488
|
+
rows = []
|
|
489
|
+
for value, items in buckets.items():
|
|
490
|
+
row: dict[str, Any] = {"value": value, "posts": len(items)}
|
|
491
|
+
if own:
|
|
492
|
+
row["median_reach"] = _median([_own_score(m) for m in items])
|
|
493
|
+
row["engagement_rate_pct"] = weighted_engagement_rate(items)
|
|
494
|
+
else:
|
|
495
|
+
row["median_interactions"] = _median([interactions(m) for m in items])
|
|
496
|
+
row["median_views"] = _median([m.get("view_count") for m in items])
|
|
497
|
+
rows.append(row)
|
|
498
|
+
sort_key = "median_reach" if own else "median_interactions"
|
|
499
|
+
return sorted(rows, key=lambda r: r.get(sort_key) or 0, reverse=True)
|
|
500
|
+
|
|
501
|
+
score = _own_score if own else interactions
|
|
502
|
+
ranked = sorted((mf for mf in feats if score(mf[0]) is not None), key=lambda mf: score(mf[0]), reverse=True)
|
|
503
|
+
return {
|
|
504
|
+
"posts_analyzed": len(feats),
|
|
505
|
+
"by_language": group("language"),
|
|
506
|
+
"by_hook_question": group("hook_is_question"),
|
|
507
|
+
"by_cta": group("has_cta"),
|
|
508
|
+
"by_length": group("length_bucket"),
|
|
509
|
+
"best_hooks": [
|
|
510
|
+
{"hook": f["hook"], "format": media_format(m), "score": score(m), "language": f["language"],
|
|
511
|
+
"permalink": m.get("permalink")}
|
|
512
|
+
for m, f in ranked[:7]
|
|
513
|
+
],
|
|
514
|
+
"worst_hooks": [
|
|
515
|
+
{"hook": f["hook"], "format": media_format(m), "score": score(m), "language": f["language"]}
|
|
516
|
+
for m, f in ranked[-5:][::-1]
|
|
517
|
+
]
|
|
518
|
+
if len(ranked) > 7
|
|
519
|
+
else [],
|
|
520
|
+
"score": "reach" if own else "likes+comments",
|
|
521
|
+
"note": "Language and CTA detection are heuristics. Groups with < 3 posts are anecdotes.",
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def conversion_by_format(media: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
526
|
+
"""Profile visits and follows per 1000 reach (FEED/STORY expose them; Reels do not)."""
|
|
527
|
+
groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
528
|
+
for m in media:
|
|
529
|
+
groups[media_format(m)].append(m)
|
|
530
|
+
rows = []
|
|
531
|
+
for fmt, items in groups.items():
|
|
532
|
+
ins = [m.get("insights") or {} for m in items]
|
|
533
|
+
reach = sum(i.get("reach") or 0 for i in ins)
|
|
534
|
+
follows = [i.get("follows") for i in ins if i.get("follows") is not None]
|
|
535
|
+
visits = [i.get("profile_visits") for i in ins if i.get("profile_visits") is not None]
|
|
536
|
+
if not reach or (not follows and not visits):
|
|
537
|
+
continue
|
|
538
|
+
rows.append(
|
|
539
|
+
{
|
|
540
|
+
"format": fmt,
|
|
541
|
+
"posts": len(items),
|
|
542
|
+
"follows_per_1k_reach": round(1000 * sum(follows) / reach, 2) if follows else None,
|
|
543
|
+
"profile_visits_per_1k_reach": round(1000 * sum(visits) / reach, 2) if visits else None,
|
|
544
|
+
"total_follows": sum(follows) if follows else None,
|
|
545
|
+
}
|
|
546
|
+
)
|
|
547
|
+
return sorted(rows, key=lambda r: r["follows_per_1k_reach"] or 0, reverse=True)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def reels_watch(media: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
551
|
+
reels = [m for m in media if media_format(m) == "REELS"]
|
|
552
|
+
if not reels:
|
|
553
|
+
return None
|
|
554
|
+
ins = [m.get("insights") or {} for m in reels]
|
|
555
|
+
avg_ms = [i.get("ig_reels_avg_watch_time") for i in ins]
|
|
556
|
+
total_ms = [i.get("ig_reels_video_view_total_time") for i in ins if i.get("ig_reels_video_view_total_time")]
|
|
557
|
+
skip = [i.get("reels_skip_rate") for i in ins]
|
|
558
|
+
top = sorted(
|
|
559
|
+
(m for m in reels if (m.get("insights") or {}).get("ig_reels_avg_watch_time")),
|
|
560
|
+
key=lambda m: m["insights"]["ig_reels_avg_watch_time"],
|
|
561
|
+
reverse=True,
|
|
562
|
+
)[:3]
|
|
563
|
+
return {
|
|
564
|
+
"reels": len(reels),
|
|
565
|
+
"avg_watch_time_s": round(_avg(avg_ms) / 1000, 1) if _avg(avg_ms) else None,
|
|
566
|
+
"median_watch_time_s": round(_median(avg_ms) / 1000, 1) if _median(avg_ms) else None,
|
|
567
|
+
"total_watch_hours": round(sum(total_ms) / 3_600_000, 1) if total_ms else None,
|
|
568
|
+
"avg_skip_rate_pct": _avg(skip),
|
|
569
|
+
"longest_watched": [
|
|
570
|
+
{
|
|
571
|
+
"hook": caption_hook(m.get("caption")),
|
|
572
|
+
"avg_watch_time_s": round(m["insights"]["ig_reels_avg_watch_time"] / 1000, 1),
|
|
573
|
+
"permalink": m.get("permalink"),
|
|
574
|
+
}
|
|
575
|
+
for m in top
|
|
576
|
+
],
|
|
577
|
+
}
|