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,461 @@
1
+ """Instagram Graph API endpoints used by the MCP tools (with caching)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from datetime import UTC, datetime, timedelta
7
+ from typing import Any
8
+
9
+ from creatorloop.config import Settings
10
+ from creatorloop.graph_client import GraphAPIError, GraphClient
11
+ from creatorloop.storage import Storage
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+ PROFILE_FIELDS = "id,username,name,biography,website,followers_count,follows_count,media_count,profile_picture_url"
16
+
17
+ OWN_MEDIA_FIELDS = (
18
+ "id,caption,media_type,media_product_type,permalink,timestamp,like_count,comments_count,thumbnail_url"
19
+ )
20
+
21
+ # Business Discovery: base fields are documented for years; extended ones were added later.
22
+ # If the API rejects an extended field we retry with the base set.
23
+ BD_PROFILE_FIELDS = "username,name,biography,website,followers_count,follows_count,media_count,profile_picture_url"
24
+ BD_MEDIA_BASE = "id,caption,media_type,permalink,timestamp,like_count,comments_count"
25
+ BD_MEDIA_EXTENDED = BD_MEDIA_BASE + ",media_product_type,view_count"
26
+
27
+ HASHTAG_MEDIA_FIELDS = "id,caption,media_type,permalink,timestamp,like_count,comments_count"
28
+
29
+ # Media insights per media_product_type (Graph API v26, deprecated metrics removed).
30
+ MEDIA_METRICS: dict[str, list[str]] = {
31
+ "FEED": [
32
+ "reach",
33
+ "views",
34
+ "likes",
35
+ "comments",
36
+ "saved",
37
+ "shares",
38
+ "reposts",
39
+ "total_interactions",
40
+ "profile_visits",
41
+ "follows",
42
+ ],
43
+ "REELS": [
44
+ "reach",
45
+ "views",
46
+ "likes",
47
+ "comments",
48
+ "saved",
49
+ "shares",
50
+ "total_interactions",
51
+ "reposts",
52
+ "ig_reels_avg_watch_time",
53
+ "ig_reels_video_view_total_time",
54
+ "reels_skip_rate",
55
+ ],
56
+ "STORY": ["reach", "views", "shares", "total_interactions", "replies", "profile_visits", "follows"],
57
+ }
58
+
59
+ # Account-level metrics with metric_type=total_value, period=day.
60
+ ACCOUNT_TOTAL_METRICS = [
61
+ "reach",
62
+ "views",
63
+ "accounts_engaged",
64
+ "total_interactions",
65
+ "likes",
66
+ "comments",
67
+ "saves",
68
+ "shares",
69
+ "replies",
70
+ "profile_links_taps",
71
+ ]
72
+
73
+ MAX_INSIGHTS_RANGE = timedelta(days=30)
74
+ DATA_LAG_DAYS = 2
75
+ TOKEN_WARN_DAYS = 14
76
+
77
+
78
+ class InstagramService:
79
+ def __init__(self, settings: Settings, client: GraphClient, storage: Storage) -> None:
80
+ self.settings = settings
81
+ self.client = client
82
+ self.storage = storage
83
+ self._my_username: str | None = None
84
+
85
+ # ---- helpers -----------------------------------------------------------
86
+
87
+ async def _cached(self, key: str, ttl_s: int, fetch, *, refresh: bool = False) -> Any:
88
+ if not refresh:
89
+ hit = self.storage.cache_get(key)
90
+ if hit is not None:
91
+ return hit
92
+ value = await fetch()
93
+ self.storage.cache_set(key, value, ttl_s)
94
+ return value
95
+
96
+ @property
97
+ def uid(self) -> str:
98
+ return self.settings.ig_user_id
99
+
100
+ # ---- own account -------------------------------------------------------
101
+
102
+ async def my_profile(self, refresh: bool = False) -> dict[str, Any]:
103
+ async def fetch() -> dict[str, Any]:
104
+ return await self.client.get(f"/{self.uid}", {"fields": PROFILE_FIELDS})
105
+
106
+ profile = await self._cached(f"me:profile:{self.uid}", self.settings.ttl_profile_s, fetch, refresh=refresh)
107
+ self._my_username = profile.get("username")
108
+ if self._my_username:
109
+ self.storage.save_snapshot(
110
+ self._my_username,
111
+ profile.get("followers_count"),
112
+ profile.get("follows_count"),
113
+ profile.get("media_count"),
114
+ )
115
+ return profile
116
+
117
+ async def my_username(self) -> str:
118
+ if not self._my_username:
119
+ await self.my_profile()
120
+ return self._my_username or "me"
121
+
122
+ async def my_media(self, limit: int = 25, refresh: bool = False) -> list[dict[str, Any]]:
123
+ """Newest-first media. One cache entry for all limits: a larger cached list serves smaller requests."""
124
+ key = f"me:media:{self.uid}"
125
+ if not refresh:
126
+ hit = self.storage.cache_get(key)
127
+ if hit and (len(hit["items"]) >= limit or hit["exhausted"]):
128
+ return hit["items"][:limit]
129
+ items = await self.client.paginate(f"/{self.uid}/media", {"fields": OWN_MEDIA_FIELDS}, max_items=limit)
130
+ self.storage.cache_set(key, {"items": items, "exhausted": len(items) < limit}, self.settings.ttl_media_s)
131
+ return items
132
+
133
+ async def media_insights(
134
+ self, media_id: str, product_type: str | None = None, media: dict[str, Any] | None = None
135
+ ) -> dict[str, Any]:
136
+ """Insights for one media. Unsupported metrics are skipped instead of failing the whole call.
137
+ Every fresh (non-cached) fetch is also stored as a history snapshot."""
138
+
139
+ async def fetch() -> dict[str, Any]:
140
+ meta = media or {}
141
+ ptype = product_type or meta.get("media_product_type")
142
+ if not ptype:
143
+ meta = await self.client.get(
144
+ f"/{media_id}", {"fields": "media_type,media_product_type,timestamp,permalink,caption"}
145
+ )
146
+ ptype = ptype or meta.get("media_product_type", "FEED")
147
+ values, unavailable = await self._fetch_insights(f"/{media_id}/insights", self._metrics_for(ptype))
148
+ self._remember_unsupported(ptype, unavailable)
149
+ self.storage.save_media_snapshots(
150
+ await self.my_username(), [_snapshot_item({**meta, "id": media_id}, values)], is_own=True
151
+ )
152
+ return {"media_id": media_id, "media_product_type": ptype, "metrics": values, "unavailable": unavailable}
153
+
154
+ return await self._cached(f"media:insights:{media_id}", self.settings.ttl_media_s, fetch)
155
+
156
+ def _metrics_for(self, ptype: str) -> list[str]:
157
+ """Metric list minus those the API rejected recently for this media type (avoids N extra calls per post)."""
158
+ skip = set(self.storage.cache_get(f"unsupported:{ptype}") or [])
159
+ return [m for m in MEDIA_METRICS.get(ptype, MEDIA_METRICS["FEED"]) if m not in skip]
160
+
161
+ def _remember_unsupported(self, ptype: str, unavailable: dict[str, str]) -> None:
162
+ # code 10 "not enough viewers" is per-post, not a metric problem: don't blacklist on it
163
+ rejected = [m for m, msg in unavailable.items() if "not enough" not in msg.lower()]
164
+ if rejected:
165
+ known = set(self.storage.cache_get(f"unsupported:{ptype}") or [])
166
+ self.storage.cache_set(f"unsupported:{ptype}", sorted(known | set(rejected)), 7 * 86400)
167
+
168
+ async def _fetch_insights(self, path: str, metrics: list[str]) -> tuple[dict[str, Any], dict[str, str]]:
169
+ try:
170
+ body = await self.client.get(path, {"metric": ",".join(metrics)})
171
+ return _parse_insight_values(body), {}
172
+ except GraphAPIError as exc:
173
+ if exc.code not in (10, 100):
174
+ raise
175
+ log.info("Bulk insights failed (%s), falling back to per-metric requests", exc)
176
+
177
+ values: dict[str, Any] = {}
178
+ unavailable: dict[str, str] = {}
179
+ for metric in metrics:
180
+ try:
181
+ body = await self.client.get(path, {"metric": metric})
182
+ values.update(_parse_insight_values(body))
183
+ except GraphAPIError as exc:
184
+ if exc.code not in (10, 100):
185
+ raise
186
+ unavailable[metric] = exc.message
187
+ return values, unavailable
188
+
189
+ async def account_insights(self, days: int, lag_days: int = DATA_LAG_DAYS) -> dict[str, Any]:
190
+ """Totals for the period plus daily reach. Splits long ranges into 30-day chunks.
191
+ The window ends `lag_days` ago because Meta finalises insights with up to 48h delay."""
192
+ today = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
193
+ until = today - timedelta(days=lag_days)
194
+ since = until - timedelta(days=days)
195
+
196
+ async def fetch() -> dict[str, Any]:
197
+ totals: dict[str, int] = {}
198
+ daily_reach: list[dict[str, Any]] = []
199
+ follows_by_type: dict[str, int] = {}
200
+ unavailable: dict[str, str] = {}
201
+
202
+ for chunk_since, chunk_until in _chunks(since, until, MAX_INSIGHTS_RANGE):
203
+ window = {"since": int(chunk_since.timestamp()), "until": int(chunk_until.timestamp()), "period": "day"}
204
+
205
+ for metric in ACCOUNT_TOTAL_METRICS:
206
+ try:
207
+ body = await self.client.get(
208
+ f"/{self.uid}/insights", {"metric": metric, "metric_type": "total_value", **window}
209
+ )
210
+ except GraphAPIError as exc:
211
+ if exc.code not in (10, 100):
212
+ raise
213
+ unavailable[metric] = exc.message
214
+ continue
215
+ for name, value in _parse_insight_values(body).items():
216
+ if isinstance(value, (int, float)):
217
+ totals[name] = totals.get(name, 0) + value
218
+
219
+ try:
220
+ body = await self.client.get(
221
+ f"/{self.uid}/insights",
222
+ {"metric": "follows_and_unfollows", "metric_type": "total_value", "breakdown": "follow_type", **window},
223
+ )
224
+ for item in body.get("data", []):
225
+ for br in item.get("total_value", {}).get("breakdowns", []):
226
+ for res in br.get("results", []):
227
+ kind = (res.get("dimension_values") or ["UNKNOWN"])[0].upper()
228
+ follows_by_type[kind] = follows_by_type.get(kind, 0) + res.get("value", 0)
229
+ except GraphAPIError as exc:
230
+ if exc.code not in (10, 100):
231
+ raise
232
+ unavailable["follows_and_unfollows"] = exc.message
233
+
234
+ try:
235
+ body = await self.client.get(
236
+ f"/{self.uid}/insights", {"metric": "reach", "metric_type": "time_series", **window}
237
+ )
238
+ for item in body.get("data", []):
239
+ for point in item.get("values", []):
240
+ daily_reach.append({"date": point.get("end_time", "")[:10], "reach": point.get("value")})
241
+ except GraphAPIError as exc:
242
+ if exc.code not in (10, 100):
243
+ raise
244
+ unavailable["reach_time_series"] = exc.message
245
+
246
+ return {
247
+ "since": since.date().isoformat(),
248
+ "until": until.date().isoformat(),
249
+ "totals": totals,
250
+ # Raw breakdown by follow_type. Per Meta docs FOLLOWER = new follows,
251
+ # NON_FOLLOWER = unfollows; verify against the Instagram app on first run.
252
+ "follows_and_unfollows": follows_by_type,
253
+ "daily_reach": daily_reach,
254
+ "unavailable": unavailable,
255
+ "note": f"Window ends {lag_days} day(s) ago (Meta data lags up to 48h). Totals are summed over "
256
+ "30-day chunks; reach/accounts_engaged are de-duplicated by Meta only inside a chunk.",
257
+ }
258
+
259
+ return await self._cached(f"me:insights:{self.uid}:{days}:{since.date()}", self.settings.ttl_media_s, fetch)
260
+
261
+ async def demographics(self, breakdown: str, timeframe: str, engaged: bool) -> dict[str, Any]:
262
+ metric = "engaged_audience_demographics" if engaged else "follower_demographics"
263
+
264
+ async def fetch() -> dict[str, Any]:
265
+ body = await self.client.get(
266
+ f"/{self.uid}/insights",
267
+ {
268
+ "metric": metric,
269
+ "period": "lifetime",
270
+ "metric_type": "total_value",
271
+ "timeframe": timeframe,
272
+ "breakdown": breakdown,
273
+ },
274
+ )
275
+ rows: list[dict[str, Any]] = []
276
+ for item in body.get("data", []):
277
+ for br in item.get("total_value", {}).get("breakdowns", []):
278
+ for res in br.get("results", []):
279
+ rows.append({"value": " / ".join(res.get("dimension_values", [])), "count": res.get("value", 0)})
280
+ rows.sort(key=lambda r: r["count"], reverse=True)
281
+ total = sum(r["count"] for r in rows) or 1
282
+ for r in rows:
283
+ r["share_pct"] = round(100 * r["count"] / total, 1)
284
+ return {"metric": metric, "breakdown": breakdown, "timeframe": timeframe, "rows": rows}
285
+
286
+ return await self._cached(
287
+ f"me:demo:{self.uid}:{metric}:{breakdown}:{timeframe}", self.settings.ttl_profile_s * 6, fetch
288
+ )
289
+
290
+ # ---- competitors (Business Discovery) ----------------------------------
291
+
292
+ async def competitor(self, username: str, media_limit: int = 25, refresh: bool = False) -> dict[str, Any]:
293
+ username = username.lstrip("@").strip().lower()
294
+
295
+ async def fetch() -> dict[str, Any]:
296
+ try:
297
+ data = await self._business_discovery(username, media_limit, BD_MEDIA_EXTENDED)
298
+ except GraphAPIError as exc:
299
+ # "(#100) Tried accessing nonexisting field (view_count)" -> retry without extended fields
300
+ if exc.code != 100 or "field" not in exc.message.lower():
301
+ raise
302
+ log.info("Extended Business Discovery fields rejected (%s); retrying with base fields", exc)
303
+ data = await self._business_discovery(username, media_limit, BD_MEDIA_BASE)
304
+ self.storage.save_media_snapshots(
305
+ username,
306
+ [
307
+ _snapshot_item(
308
+ m, {"likes": m.get("like_count"), "comments": m.get("comments_count"), "views": m.get("view_count")}
309
+ )
310
+ for m in data.get("media", [])
311
+ ],
312
+ is_own=False,
313
+ )
314
+ return data
315
+
316
+ data = await self._cached(f"bd:{username}:{media_limit}", self.settings.ttl_profile_s, fetch, refresh=refresh)
317
+ self.storage.save_snapshot(
318
+ username, data.get("followers_count"), data.get("follows_count"), data.get("media_count")
319
+ )
320
+ return data
321
+
322
+ async def _business_discovery(self, username: str, media_limit: int, media_fields: str) -> dict[str, Any]:
323
+ media: list[dict[str, Any]] = []
324
+ profile: dict[str, Any] = {}
325
+ after: str | None = None
326
+ while len(media) < media_limit:
327
+ page = min(50, media_limit - len(media))
328
+ edge = f"media.limit({page})" + (f".after({after})" if after else "")
329
+ fields = f"business_discovery.username({username}){{{BD_PROFILE_FIELDS},{edge}{{{media_fields}}}}}"
330
+ body = await self.client.get(f"/{self.uid}", {"fields": fields})
331
+ bd = body.get("business_discovery", {})
332
+ if not profile:
333
+ profile = {k: v for k, v in bd.items() if k != "media"}
334
+ chunk = bd.get("media", {})
335
+ media.extend(chunk.get("data", []))
336
+ after = chunk.get("paging", {}).get("cursors", {}).get("after")
337
+ if not after or not chunk.get("data"):
338
+ break
339
+ profile["media"] = media[:media_limit]
340
+ return profile
341
+
342
+ # ---- hashtags ----------------------------------------------------------
343
+
344
+ async def hashtag_id(self, hashtag: str) -> str:
345
+ tag = hashtag.lstrip("#").strip().lower()
346
+ if known := self.storage.known_hashtag_id(tag):
347
+ return known
348
+ if not self.storage.can_query_new_hashtag():
349
+ quota = self.storage.hashtag_quota()
350
+ raise RuntimeError(
351
+ "Hashtag quota exhausted: Meta allows 30 unique hashtags per rolling 7 days. "
352
+ f"Next slot frees up at {quota['hashtags'][0]['frees_up_at']} (UTC)."
353
+ )
354
+ body = await self.client.get("/ig_hashtag_search", {"user_id": self.uid, "q": tag})
355
+ data = body.get("data", [])
356
+ if not data:
357
+ raise RuntimeError(f"Hashtag #{tag} not found")
358
+ hid = data[0]["id"]
359
+ self.storage.register_hashtag(tag, hid)
360
+ return hid
361
+
362
+ async def hashtag_media(self, hashtag: str, mode: str = "top", limit: int = 50) -> list[dict[str, Any]]:
363
+ tag = hashtag.lstrip("#").strip().lower()
364
+ edge = "top_media" if mode == "top" else "recent_media"
365
+
366
+ async def fetch() -> list[dict[str, Any]]:
367
+ hid = await self.hashtag_id(tag)
368
+ return await self.client.paginate(
369
+ f"/{hid}/{edge}", {"user_id": self.uid, "fields": HASHTAG_MEDIA_FIELDS}, max_items=limit
370
+ )
371
+
372
+ return await self._cached(f"hashtag:{tag}:{edge}:{limit}", self.settings.ttl_hashtag_s, fetch)
373
+
374
+ # ---- token -------------------------------------------------------------
375
+
376
+ async def debug_token(self) -> dict[str, Any]:
377
+ """Token validity and expiry. Uses the app token if META_APP_SECRET is set, otherwise the token itself
378
+ (a user token may inspect itself), so no secret is required."""
379
+ inspector = (
380
+ f"{self.settings.app_id}|{self.settings.app_secret}"
381
+ if self.settings.app_id and self.settings.app_secret
382
+ else self.settings.access_token
383
+ )
384
+ body = await self.client.get(
385
+ "/debug_token", {"input_token": self.settings.access_token, "access_token": inspector}
386
+ )
387
+ data = body.get("data", {})
388
+ now = datetime.now(UTC)
389
+
390
+ def ts(value: int | None) -> tuple[str, float | None]:
391
+ if not value:
392
+ return "never", None
393
+ when = datetime.fromtimestamp(value, UTC)
394
+ return when.isoformat(timespec="minutes"), round((when - now).total_seconds() / 86400, 1)
395
+
396
+ expires_at, days_left = ts(data.get("expires_at"))
397
+ data_access_at, data_days_left = ts(data.get("data_access_expires_at"))
398
+ status: dict[str, Any] = {
399
+ "is_valid": data.get("is_valid"),
400
+ "type": data.get("type"),
401
+ "scopes": data.get("scopes", []),
402
+ "expires_at": expires_at,
403
+ "days_left": days_left,
404
+ "data_access_expires_at": data_access_at,
405
+ "data_access_days_left": data_days_left,
406
+ }
407
+ lefts = [d for d in (days_left, data_days_left) if d is not None]
408
+ if not data.get("is_valid"):
409
+ status["warning"] = "Token is invalid. Generate a new one (README, section 'Token')."
410
+ elif lefts and min(lefts) <= TOKEN_WARN_DAYS:
411
+ status["warning"] = (
412
+ f"Token or its data access expires in {min(lefts)} days. "
413
+ "Renew it or switch to a never-expiring System User token (README, section 'Token')."
414
+ )
415
+ return status
416
+
417
+ async def token_warning(self) -> str | None:
418
+ """Cached (12h) short warning for tool responses; never raises."""
419
+ key = f"token:status:{self.uid}"
420
+ cached = self.storage.cache_get(key)
421
+ if cached is None:
422
+ try:
423
+ status = await self.debug_token()
424
+ cached = {"warning": status.get("warning")}
425
+ except Exception as exc: # diagnostics must not break real tools
426
+ log.info("Token check failed: %s", exc)
427
+ cached = {"warning": None}
428
+ self.storage.cache_set(key, cached, 12 * 3600)
429
+ return cached.get("warning")
430
+
431
+
432
+ def _snapshot_item(m: dict[str, Any], metrics: dict[str, Any]) -> dict[str, Any]:
433
+ from creatorloop.analytics import media_format # local import: analytics is pure, avoid cycles
434
+
435
+ return {
436
+ "id": m.get("id"),
437
+ "timestamp": m.get("timestamp"),
438
+ "format": media_format(m),
439
+ "permalink": m.get("permalink"),
440
+ "caption": m.get("caption"),
441
+ "metrics": metrics,
442
+ }
443
+
444
+
445
+ def _parse_insight_values(body: dict[str, Any]) -> dict[str, Any]:
446
+ out: dict[str, Any] = {}
447
+ for item in body.get("data", []):
448
+ name = item.get("name")
449
+ if "total_value" in item:
450
+ out[name] = item["total_value"].get("value")
451
+ elif item.get("values"):
452
+ out[name] = item["values"][-1].get("value")
453
+ return out
454
+
455
+
456
+ def _chunks(start: datetime, end: datetime, size: timedelta):
457
+ cur = start
458
+ while cur < end:
459
+ nxt = min(cur + size, end)
460
+ yield cur, nxt
461
+ cur = nxt
creatorloop/paths.py ADDED
@@ -0,0 +1,67 @@
1
+ """Where creatorloop keeps the user's settings and data.
2
+
3
+ Everything lives in one per-user folder (CREATORLOOP_HOME), never next to the installed code: after
4
+ `uvx creatorloop` / `pip install` the package sits in site-packages, which is not a place for a token,
5
+ a database or content folders.
6
+
7
+ CREATORLOOP_HOME/
8
+ .env # IG_ACCESS_TOKEN, IG_USER_ID, ...
9
+ creatorloop.db # cache, follower/post snapshots, queue
10
+ content/ # post folders for prepare_post / queue_post
11
+ _refs/ # slides saved by get_competitor_post
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import shutil
18
+ import sys
19
+ import tomllib
20
+ from collections.abc import Mapping
21
+ from pathlib import Path
22
+
23
+ APP = "creatorloop"
24
+ PACKAGE_DIR = Path(__file__).resolve().parent
25
+
26
+
27
+ def home_dir(platform: str = sys.platform, *, env: Mapping[str, str] = os.environ, home: Path | None = None) -> Path:
28
+ if custom := env.get("CREATORLOOP_HOME", "").strip():
29
+ return Path(custom).expanduser()
30
+ home = home or Path.home()
31
+ if platform == "win32":
32
+ return Path(env.get("APPDATA") or home / "AppData" / "Roaming") / APP
33
+ if platform == "darwin":
34
+ return home / "Library" / "Application Support" / APP
35
+ return Path(env.get("XDG_DATA_HOME") or home / ".local" / "share") / APP
36
+
37
+
38
+ def content_root() -> Path:
39
+ return home_dir() / "content"
40
+
41
+
42
+ def refs_root() -> Path:
43
+ return content_root() / "_refs"
44
+
45
+
46
+ def source_root(package_dir: Path = PACKAGE_DIR) -> Path | None:
47
+ """The repository root when running from a source checkout (src/creatorloop), else None."""
48
+ pyproject = package_dir.parents[1] / "pyproject.toml"
49
+ try:
50
+ name = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["name"]
51
+ except OSError, KeyError, tomllib.TOMLDecodeError:
52
+ return None
53
+ return pyproject.parent if name == APP else None
54
+
55
+
56
+ def env_template() -> Path:
57
+ return PACKAGE_DIR / "env.example"
58
+
59
+
60
+ def ensure_env_file(home: Path) -> Path | None:
61
+ """Create home/.env from the template; return its path, or None if it already existed."""
62
+ target = home / ".env"
63
+ if target.exists():
64
+ return None
65
+ home.mkdir(parents=True, exist_ok=True)
66
+ shutil.copyfile(env_template(), target)
67
+ return target