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.
@@ -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
+ )
@@ -0,0 +1,3 @@
1
+ from creatorloop.server import main
2
+
3
+ main()
@@ -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
+ }