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/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
|
+
}
|