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/refs.py ADDED
@@ -0,0 +1,188 @@
1
+ """Reference posts: find a competitor's post by URL via Business Discovery and download its slides."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import io
7
+ import logging
8
+ import re
9
+ import statistics
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ from creatorloop import analytics, paths
16
+ from creatorloop.graph_client import GraphAPIError
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ REFS_ROOT = paths.refs_root()
21
+ SHORTCODE_RE = re.compile(r"instagram\.com/(?:[\w.]+/)?(?:p|reel|reels|tv)/([A-Za-z0-9_-]+)")
22
+ CHILD_FIELDS = "media_type,media_url,thumbnail_url"
23
+ FIELDS_EXTENDED = (
24
+ "id,permalink,caption,media_type,media_product_type,timestamp,like_count,comments_count,view_count,"
25
+ f"media_url,thumbnail_url,children{{{CHILD_FIELDS}}}"
26
+ )
27
+ FIELDS_BASE = "id,permalink,caption,media_type,timestamp,like_count,comments_count,media_url,children{media_type,media_url}"
28
+ PAGE_SIZE = 50
29
+ MAX_PAGES = 4
30
+
31
+
32
+ class RefError(RuntimeError):
33
+ pass
34
+
35
+
36
+ def shortcode_from_url(url: str) -> str:
37
+ m = SHORTCODE_RE.search(url)
38
+ if not m:
39
+ raise RefError("Not an Instagram post URL (expected .../p/<code>/, /reel/<code>/ or /tv/<code>/)")
40
+ return m.group(1)
41
+
42
+
43
+ class RefFinder:
44
+ def __init__(self, service, http: httpx.AsyncClient | None = None) -> None:
45
+ self.svc = service
46
+ self.client = service.client
47
+ self._http = http
48
+
49
+ async def author_from_oembed(self, url: str) -> str:
50
+ try:
51
+ body = await self.client.get("/instagram_oembed", {"url": url, "fields": "author_name"})
52
+ except GraphAPIError as exc:
53
+ log.info("oEmbed unavailable: %s", exc)
54
+ raise RefError(
55
+ "Pass username: the author can't be detected from the URL "
56
+ "(Meta oEmbed needs App Review). Take it from the profile link."
57
+ ) from exc
58
+ name = body.get("author_name")
59
+ if not name:
60
+ raise RefError("Pass username: oEmbed returned no author.")
61
+ return name.lstrip("@").lower()
62
+
63
+ async def _pages(self, username: str, fields: str):
64
+ after: str | None = None
65
+ profile: dict[str, Any] = {}
66
+ for _ in range(MAX_PAGES):
67
+ edge = f"media.limit({PAGE_SIZE})" + (f".after({after})" if after else "")
68
+ q = f"business_discovery.username({username}){{followers_count,media_count,{edge}{{{fields}}}}}"
69
+ body = await self.client.get(f"/{self.svc.uid}", {"fields": q})
70
+ bd = body.get("business_discovery", {})
71
+ if not profile:
72
+ profile = {"followers_count": bd.get("followers_count"), "media_count": bd.get("media_count")}
73
+ chunk = bd.get("media", {})
74
+ yield profile, chunk.get("data", [])
75
+ after = chunk.get("paging", {}).get("cursors", {}).get("after")
76
+ if not after or not chunk.get("data"):
77
+ return
78
+
79
+ async def find(self, username: str, shortcode: str) -> tuple[dict[str, Any], dict[str, Any], list[dict[str, Any]]]:
80
+ """(post, profile, all media seen). Retries with base fields if extended ones are rejected."""
81
+ for fields in (FIELDS_EXTENDED, FIELDS_BASE):
82
+ seen: list[dict[str, Any]] = []
83
+ try:
84
+ async for profile, items in self._pages(username, fields):
85
+ seen.extend(items)
86
+ for m in items:
87
+ if f"/{shortcode}" in (m.get("permalink") or ""):
88
+ return m, profile, seen
89
+ except GraphAPIError as exc:
90
+ if exc.code == 100 and "field" in exc.message.lower() and fields is FIELDS_EXTENDED:
91
+ log.info("Extended reference fields rejected (%s); retrying with base fields", exc)
92
+ continue
93
+ raise
94
+ raise RefError(
95
+ f"Post {shortcode} not found among the last {len(seen)} posts of @{username}. "
96
+ "It may be older, deleted, or from another account."
97
+ )
98
+ raise RefError("Unreachable") # pragma: no cover
99
+
100
+ @staticmethod
101
+ def slide_urls(post: dict[str, Any]) -> list[tuple[str, str]]:
102
+ """[(kind, url)] in slide order; videos give their cover image when available."""
103
+ items = (post.get("children") or {}).get("data") or [post]
104
+ out = []
105
+ for it in items:
106
+ if it.get("media_type") == "VIDEO":
107
+ url = it.get("thumbnail_url")
108
+ if url:
109
+ out.append(("video-cover", url))
110
+ elif it.get("media_url"):
111
+ out.append(("image", it["media_url"]))
112
+ return out
113
+
114
+ async def download(self, urls: list[tuple[str, str]], folder: Path) -> list[Path]:
115
+ folder.mkdir(parents=True, exist_ok=True)
116
+ sem = asyncio.Semaphore(5)
117
+ http = self._http or httpx.AsyncClient(timeout=20, follow_redirects=True)
118
+
119
+ async def one(i: int, url: str) -> Path:
120
+ async with sem:
121
+ r = await http.get(url)
122
+ r.raise_for_status()
123
+ path = folder / f"{i:02d}.jpg"
124
+ path.write_bytes(r.content)
125
+ return path
126
+
127
+ try:
128
+ return list(await asyncio.gather(*(one(i, u) for i, (_, u) in enumerate(urls, 1))))
129
+ finally:
130
+ if self._http is None:
131
+ await http.aclose()
132
+
133
+ async def get(self, url: str, username: str | None, *, download: bool = True) -> dict[str, Any]:
134
+ code = shortcode_from_url(url)
135
+ name = (username or "").lstrip("@").strip().lower() or await self.author_from_oembed(url)
136
+ post, profile, seen = await self.find(name, code)
137
+
138
+ kind, score = analytics.public_score(post)
139
+ same_kind = [s for m in seen if (s := analytics.public_score(m))[0] == kind and s[1] is not None]
140
+ median = statistics.median(v for _, v in same_kind) if same_kind else None
141
+ urls = self.slide_urls(post)
142
+ files: list[Path] = []
143
+ if download and urls:
144
+ files = await self.download(urls, REFS_ROOT / f"{name}_{code}")
145
+ feats = analytics.caption_features(post)
146
+ return {
147
+ "username": name,
148
+ "shortcode": code,
149
+ "permalink": post.get("permalink"),
150
+ "timestamp": post.get("timestamp"),
151
+ "format": analytics.media_format(post),
152
+ "likes": post.get("like_count"),
153
+ "comments": post.get("comments_count"),
154
+ "views": post.get("view_count"),
155
+ "score_kind": kind,
156
+ "score": score,
157
+ "account_median": round(median, 1) if median is not None else None,
158
+ "x_median": round(score / median, 1) if score is not None and median else None,
159
+ "followers": profile.get("followers_count"),
160
+ "hook": feats["hook"],
161
+ "language": feats["language"],
162
+ "has_cta": feats["has_cta"],
163
+ "caption": post.get("caption"),
164
+ "slides": len(urls),
165
+ "saved_to": str(files[0].parent) if files else None,
166
+ "files": [p.name for p in files],
167
+ "note": "Comment texts of other accounts are not available via the API.",
168
+ }
169
+
170
+
171
+ PREVIEW_LIMIT = 20 # Instagram app carousels hold up to 20 slides
172
+ PREVIEW_WIDTH = 400
173
+
174
+
175
+ def previews(files: list[Path], width: int = PREVIEW_WIDTH, limit: int = PREVIEW_LIMIT) -> list[bytes]:
176
+ """Small JPEG previews to return inline as images (keeps the MCP response small)."""
177
+ from PIL import Image
178
+
179
+ out = []
180
+ for p in files[:limit]:
181
+ with Image.open(p) as im:
182
+ im = im.convert("RGB")
183
+ if im.width > width:
184
+ im = im.resize((width, round(im.height * width / im.width)))
185
+ buf = io.BytesIO()
186
+ im.save(buf, "JPEG", quality=70, optimize=True)
187
+ out.append(buf.getvalue())
188
+ return out
creatorloop/reviews.py ADDED
@@ -0,0 +1,116 @@
1
+ """Automatic post reviews 24 h and 72 h after publishing (own account).
2
+
3
+ post_reviews() returns every own post that crossed 24 h or 72 h since its last review, with its insights
4
+ compared to the median of mature posts (>= 72 h) of the same format, and remembers what was reported,
5
+ so a daily scheduled run reports each post exactly twice.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ from datetime import UTC, datetime
13
+ from typing import Any
14
+
15
+ from creatorloop import analytics
16
+
17
+ STAGES = ((72, "72h"), (24, "24h")) # highest first
18
+ REVIEW_WINDOW_DAYS = 7 # older posts are never reviewed (no backfill of the whole history)
19
+ BASELINE_DAYS = 90
20
+ COMPARE = ("reach", "views", "saved", "shares", "follows", "likes", "comments", "avg_watch_s")
21
+
22
+
23
+ def stage_for(age_h: float) -> str | None:
24
+ return next((name for hours, name in STAGES if age_h >= hours), None)
25
+
26
+
27
+ async def _with_insights(svc, media: list[dict[str, Any]]) -> list[dict[str, Any]]:
28
+ sem = asyncio.Semaphore(5)
29
+
30
+ async def one(m: dict[str, Any]) -> dict[str, Any]:
31
+ async with sem:
32
+ ins = await svc.media_insights(m["id"], m.get("media_product_type"), media=m)
33
+ return {**m, "metrics": analytics.compact_insights(ins["metrics"])}
34
+
35
+ return list(await asyncio.gather(*(one(m) for m in media)))
36
+
37
+
38
+ def _medians(items: list[dict[str, Any]]) -> dict[str, dict[str, float | None]]:
39
+ by_format: dict[str, list[dict[str, Any]]] = {}
40
+ for m in items:
41
+ by_format.setdefault(analytics.media_format(m), []).append(m)
42
+ return {
43
+ fmt: {"posts": len(group), **{k: analytics._median([g["metrics"].get(k) for g in group]) for k in COMPARE}}
44
+ for fmt, group in by_format.items()
45
+ }
46
+
47
+
48
+ def _compare(metrics: dict[str, Any], median: dict[str, Any] | None) -> dict[str, Any]:
49
+ out = {}
50
+ for k in COMPARE:
51
+ v = metrics.get(k)
52
+ if v is None:
53
+ continue
54
+ base = (median or {}).get(k)
55
+ out[k] = {"value": v, "median": base, "x_median": round(v / base, 2) if base else None}
56
+ return out
57
+
58
+
59
+ async def post_reviews(svc, *, mark_reported: bool = True, now: datetime | None = None) -> dict[str, Any]:
60
+ now = now or datetime.now(UTC)
61
+ media = analytics.media_since(await svc.my_media(limit=60, refresh=True), BASELINE_DAYS)
62
+ due = []
63
+ for m in media:
64
+ age = analytics.age_hours(m, now)
65
+ if age is None or age > REVIEW_WINDOW_DAYS * 24:
66
+ continue
67
+ stage = stage_for(age)
68
+ if stage and svc.storage.review_get(m["id"], stage) is None:
69
+ due.append((m, stage, round(age, 1)))
70
+ if not due:
71
+ return {"due": [], "note": "Nothing crossed 24 h / 72 h since the last run."}
72
+
73
+ due_ids = {m["id"] for m, *_ in due}
74
+ mature_ids = { # baseline: mature posts, never the posts being reviewed
75
+ m["id"] for m in media if (analytics.age_hours(m, now) or 0) >= analytics.YOUNG_POST_HOURS
76
+ } - due_ids
77
+ wanted = [m for m in media if m["id"] in mature_ids | due_ids]
78
+ for m, *_ in due: # the posts under review need fresh numbers, not the 3 h cache
79
+ svc.storage.cache_clear(f"media:insights:{m['id']}")
80
+ enriched = await _with_insights(svc, wanted)
81
+ by_id = {m["id"]: m for m in enriched}
82
+ medians = _medians([m for m in enriched if m["id"] in mature_ids])
83
+
84
+ reports = []
85
+ for m, stage, age in due:
86
+ cur = by_id[m["id"]]
87
+ fmt = analytics.media_format(m)
88
+ report: dict[str, Any] = {
89
+ "media_id": m["id"],
90
+ "stage": stage,
91
+ "age_hours": age,
92
+ "format": fmt,
93
+ "permalink": m.get("permalink"),
94
+ "hook": analytics.caption_hook(m.get("caption"), 100),
95
+ "metrics": cur["metrics"],
96
+ "vs_median": _compare(cur["metrics"], medians.get(fmt)),
97
+ "baseline_posts": (medians.get(fmt) or {}).get("posts", 0),
98
+ }
99
+ if stage == "72h" and (prev := svc.storage.review_get(m["id"], "24h")):
100
+ before = json.loads(prev["metrics"] or "{}")
101
+ report["growth_since_24h"] = {
102
+ k: cur["metrics"][k] - before[k]
103
+ for k in COMPARE
104
+ if isinstance(cur["metrics"].get(k), int | float) and isinstance(before.get(k), int | float)
105
+ }
106
+ reports.append(report)
107
+ if mark_reported:
108
+ svc.storage.review_save(m["id"], stage, cur["metrics"])
109
+ if stage == "72h" and svc.storage.review_get(m["id"], "24h") is None:
110
+ svc.storage.review_save(m["id"], "24h", cur["metrics"]) # skipped 24 h: do not report it later
111
+ return {
112
+ "due": reports,
113
+ "baseline": {fmt: {"posts": v["posts"], "median_reach": v["reach"]} for fmt, v in medians.items()},
114
+ "marked_reported": mark_reported,
115
+ "note": "x_median = value / median of own posts of the same format that are >= 72 h old (last 90 days).",
116
+ }