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/instagram.py
ADDED
|
@@ -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
|