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/auth_cli.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Helper CLI: exchange tokens and find your Instagram professional account ID.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
uv run creatorloop-auth exchange --short-token <TOKEN> [--write]
|
|
5
|
+
uv run creatorloop-auth whoami [--write]
|
|
6
|
+
uv run creatorloop-auth check
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import UTC, datetime, timedelta
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from creatorloop import DISCLAIMER, paths
|
|
19
|
+
from creatorloop.config import load_settings
|
|
20
|
+
|
|
21
|
+
ENV_PATH = paths.home_dir() / ".env"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get(url: str, params: dict[str, str]) -> dict:
|
|
25
|
+
resp = httpx.get(url, params=params, timeout=30)
|
|
26
|
+
body = resp.json()
|
|
27
|
+
if "error" in body:
|
|
28
|
+
err = body["error"]
|
|
29
|
+
sys.exit(f"Graph API error: {err.get('message')} (code={err.get('code')})")
|
|
30
|
+
return body
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _write_env(key: str, value: str, path: Path = ENV_PATH) -> None:
|
|
34
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
|
|
36
|
+
for i, line in enumerate(lines):
|
|
37
|
+
if line.split("=", 1)[0].strip() == key:
|
|
38
|
+
lines[i] = f"{key}={value}"
|
|
39
|
+
break
|
|
40
|
+
else:
|
|
41
|
+
lines.append(f"{key}={value}")
|
|
42
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
43
|
+
print(f" -> saved {key} to {path}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cmd_exchange(args: argparse.Namespace) -> None:
|
|
47
|
+
s = load_settings()
|
|
48
|
+
if not (s.app_id and s.app_secret):
|
|
49
|
+
sys.exit(f"Set META_APP_ID and META_APP_SECRET in {ENV_PATH} first.")
|
|
50
|
+
body = _get(
|
|
51
|
+
f"{s.base_url}/oauth/access_token",
|
|
52
|
+
{
|
|
53
|
+
"grant_type": "fb_exchange_token",
|
|
54
|
+
"client_id": s.app_id,
|
|
55
|
+
"client_secret": s.app_secret,
|
|
56
|
+
"fb_exchange_token": args.short_token,
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
token = body["access_token"]
|
|
60
|
+
expires_in = int(body.get("expires_in") or 0)
|
|
61
|
+
expiry = (datetime.now(UTC) + timedelta(seconds=expires_in)).date() if expires_in else "unknown (~60 days)"
|
|
62
|
+
print(f"Long-lived token received, expires: {expiry}")
|
|
63
|
+
if args.write:
|
|
64
|
+
_write_env("IG_ACCESS_TOKEN", token)
|
|
65
|
+
else:
|
|
66
|
+
print(token)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_whoami(args: argparse.Namespace) -> None:
|
|
70
|
+
s = load_settings()
|
|
71
|
+
if not s.access_token:
|
|
72
|
+
sys.exit("IG_ACCESS_TOKEN is empty.")
|
|
73
|
+
body = _get(
|
|
74
|
+
f"{s.base_url}/me/accounts",
|
|
75
|
+
{"fields": "name,instagram_business_account{id,username,followers_count}", "access_token": s.access_token},
|
|
76
|
+
)
|
|
77
|
+
found = []
|
|
78
|
+
for page in body.get("data", []):
|
|
79
|
+
ig = page.get("instagram_business_account")
|
|
80
|
+
status = f"IG @{ig['username']} id={ig['id']} followers={ig.get('followers_count')}" if ig else "no IG linked"
|
|
81
|
+
print(f"Facebook Page '{page.get('name')}': {status}")
|
|
82
|
+
if ig:
|
|
83
|
+
found.append(ig["id"])
|
|
84
|
+
if not found:
|
|
85
|
+
sys.exit(
|
|
86
|
+
"No Instagram professional account found. Check that IG is Business/Creator, linked to a Facebook Page, "
|
|
87
|
+
"and the token has instagram_basic + pages_show_list."
|
|
88
|
+
)
|
|
89
|
+
if args.write:
|
|
90
|
+
_write_env("IG_USER_ID", found[0])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cmd_check(_: argparse.Namespace) -> None:
|
|
94
|
+
s = load_settings()
|
|
95
|
+
s.require_credentials()
|
|
96
|
+
body = _get(f"{s.base_url}/{s.ig_user_id}", {"fields": "username,followers_count", "access_token": s.access_token})
|
|
97
|
+
print(f"OK: @{body.get('username')} followers={body.get('followers_count')} api={s.api_version}")
|
|
98
|
+
dbg = _get(f"{s.base_url}/debug_token", {"input_token": s.access_token, "access_token": s.access_token})
|
|
99
|
+
exp = dbg.get("data", {}).get("expires_at") or 0
|
|
100
|
+
if exp:
|
|
101
|
+
left = (datetime.fromtimestamp(exp, UTC) - datetime.now(UTC)).days
|
|
102
|
+
print(f"Token expires {datetime.fromtimestamp(exp, UTC).date()} ({left} days left)")
|
|
103
|
+
else:
|
|
104
|
+
print("Token never expires")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main() -> None:
|
|
108
|
+
parser = argparse.ArgumentParser(prog="creatorloop-auth", description=__doc__.split("\n")[0], epilog=DISCLAIMER)
|
|
109
|
+
sub = parser.add_subparsers(required=True)
|
|
110
|
+
|
|
111
|
+
p = sub.add_parser("exchange", help="Exchange short-lived user token for a long-lived (60 days) one")
|
|
112
|
+
p.add_argument("--short-token", required=True)
|
|
113
|
+
p.add_argument("--write", action="store_true", help="Save to .env instead of printing")
|
|
114
|
+
p.set_defaults(func=cmd_exchange)
|
|
115
|
+
|
|
116
|
+
p = sub.add_parser("whoami", help="List Facebook Pages and linked Instagram account IDs")
|
|
117
|
+
p.add_argument("--write", action="store_true", help="Save the first IG_USER_ID to .env")
|
|
118
|
+
p.set_defaults(func=cmd_whoami)
|
|
119
|
+
|
|
120
|
+
p = sub.add_parser("check", help="Verify .env credentials with a test request")
|
|
121
|
+
p.set_defaults(func=cmd_check)
|
|
122
|
+
|
|
123
|
+
args = parser.parse_args()
|
|
124
|
+
try:
|
|
125
|
+
args.func(args)
|
|
126
|
+
except RuntimeError as exc: # missing settings etc.: show the message, not a traceback
|
|
127
|
+
sys.exit(f"Error: {exc}")
|
|
128
|
+
except httpx.HTTPError as exc:
|
|
129
|
+
sys.exit(f"Network error: {exc}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
main()
|
creatorloop/comments.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Comments on own posts: digest of new / unanswered comments and replies (only after user approval).
|
|
2
|
+
|
|
3
|
+
Needs the token permission instagram_manage_comments (README, section 3).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from creatorloop import analytics
|
|
12
|
+
from creatorloop.graph_client import GraphAPIError
|
|
13
|
+
|
|
14
|
+
COMMENT_FIELDS = "id,text,username,timestamp,like_count,replies{id,text,username,timestamp,like_count}"
|
|
15
|
+
MAX_REPLY = 2200
|
|
16
|
+
PERMISSION_CODES = {10, 200} # (#10) / (#200) permission errors
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CommentsError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _permission_hint(exc: GraphAPIError) -> CommentsError:
|
|
24
|
+
if exc.code in PERMISSION_CODES or "permission" in exc.message.lower():
|
|
25
|
+
return CommentsError(
|
|
26
|
+
"The token has no instagram_manage_comments permission. Regenerate it in Graph API Explorer with "
|
|
27
|
+
"instagram_manage_comments added, then run `uv run creatorloop-auth exchange --short-token <TOKEN> "
|
|
28
|
+
f"--write` (README, section 3). Graph API said: {exc}"
|
|
29
|
+
)
|
|
30
|
+
return CommentsError(f"Graph API error: {exc}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _unanswered(c: dict[str, Any], me: str) -> list[dict[str, Any]]:
|
|
34
|
+
"""Messages in one thread (top-level comment + its replies) written by others AFTER my last word there.
|
|
35
|
+
|
|
36
|
+
A thread I already answered still yields new items when the person writes again. Instagram only allows
|
|
37
|
+
replies to top-level comments, so every item carries `reply_to` = the top-level id to answer in that thread.
|
|
38
|
+
"""
|
|
39
|
+
replies = list((c.get("replies") or {}).get("data") or [])
|
|
40
|
+
if all(r.get("timestamp") for r in replies):
|
|
41
|
+
replies.sort(key=lambda r: r["timestamp"]) # chronological; otherwise keep the API order
|
|
42
|
+
msgs = [c] + replies
|
|
43
|
+
own = [i for i, x in enumerate(msgs) if (x.get("username") or "").lower() == me]
|
|
44
|
+
last_mine = own[-1] if own else -1
|
|
45
|
+
items = []
|
|
46
|
+
for i, x in enumerate(msgs):
|
|
47
|
+
if i <= last_mine or (x.get("username") or "").lower() == me:
|
|
48
|
+
continue # mine, or already answered by a later message of mine
|
|
49
|
+
in_thread = x is not c
|
|
50
|
+
items.append({
|
|
51
|
+
"comment_id": x["id"],
|
|
52
|
+
"reply_to": c["id"],
|
|
53
|
+
"username": x.get("username"),
|
|
54
|
+
"text": x.get("text"),
|
|
55
|
+
"timestamp": x.get("timestamp"),
|
|
56
|
+
"likes": x.get("like_count"),
|
|
57
|
+
"in_thread": in_thread,
|
|
58
|
+
"thread_start": {"username": c.get("username"), "text": c.get("text")} if in_thread else None,
|
|
59
|
+
})
|
|
60
|
+
return items
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def new_comments(svc, *, days: int = 14, only_new: bool = True, mark_seen: bool = True) -> dict[str, Any]:
|
|
64
|
+
me = (await svc.my_username()).lower()
|
|
65
|
+
media = [m for m in analytics.media_since(await svc.my_media(limit=50, refresh=True), days)
|
|
66
|
+
if (m.get("comments_count") or 0) > 0]
|
|
67
|
+
sem = asyncio.Semaphore(4)
|
|
68
|
+
|
|
69
|
+
async def fetch(m: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
70
|
+
async with sem:
|
|
71
|
+
try:
|
|
72
|
+
items = await svc.client.paginate(f"/{m['id']}/comments", {"fields": COMMENT_FIELDS}, max_items=100)
|
|
73
|
+
except GraphAPIError as exc:
|
|
74
|
+
raise _permission_hint(exc) from exc
|
|
75
|
+
return m, items
|
|
76
|
+
|
|
77
|
+
results = await asyncio.gather(*(fetch(m) for m in media))
|
|
78
|
+
out = []
|
|
79
|
+
for m, items in results:
|
|
80
|
+
post = {"media_id": m["id"], "permalink": m.get("permalink"), "hook": analytics.caption_hook(m.get("caption"), 80)}
|
|
81
|
+
for c in items:
|
|
82
|
+
for item in _unanswered(c, me):
|
|
83
|
+
seen = svc.storage.comment_seen(item["comment_id"])
|
|
84
|
+
if only_new and seen:
|
|
85
|
+
continue
|
|
86
|
+
out.append({**item, "post": post, "already_seen": bool(seen)})
|
|
87
|
+
if mark_seen:
|
|
88
|
+
for c in out:
|
|
89
|
+
svc.storage.comment_mark(c["comment_id"], c["post"]["media_id"])
|
|
90
|
+
out.sort(key=lambda c: c["timestamp"] or "", reverse=True)
|
|
91
|
+
return {
|
|
92
|
+
"comments": out,
|
|
93
|
+
"posts_checked": len(media),
|
|
94
|
+
"next": "Draft a short reply for each (same language as the comment, the user's tone). Send with "
|
|
95
|
+
"reply_comment(comment_id=<reply_to>) ONLY after the user explicitly approves that exact text; "
|
|
96
|
+
"in_thread items are answers inside a thread, thread_start shows what they reply to.",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def reply(svc, comment_id: str, message: str) -> dict[str, Any]:
|
|
101
|
+
text = message.strip()
|
|
102
|
+
if not text:
|
|
103
|
+
raise CommentsError("Empty reply")
|
|
104
|
+
if len(text) > MAX_REPLY:
|
|
105
|
+
raise CommentsError(f"Reply is {len(text)} chars (max {MAX_REPLY})")
|
|
106
|
+
try:
|
|
107
|
+
body = await svc.client.post(f"/{comment_id}/replies", {"message": text})
|
|
108
|
+
except GraphAPIError as exc:
|
|
109
|
+
raise _permission_hint(exc) from exc
|
|
110
|
+
svc.storage.comment_mark(comment_id, None, reply_id=body.get("id"))
|
|
111
|
+
return {"replied": True, "reply_id": body.get("id"), "comment_id": comment_id, "text": text}
|
creatorloop/config.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Settings loaded from environment variables / .env files (see creatorloop.paths for locations)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, tzinfo
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
10
|
+
|
|
11
|
+
from dotenv import load_dotenv
|
|
12
|
+
|
|
13
|
+
from creatorloop import paths
|
|
14
|
+
|
|
15
|
+
DEFAULT_API_VERSION = "v26.0"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_env() -> None:
|
|
19
|
+
# Real environment variables win (override=False), then the user's home .env, then ./.env (development).
|
|
20
|
+
# Claude Desktop starts the process with an arbitrary cwd, so the home .env is the one that matters.
|
|
21
|
+
load_dotenv(paths.home_dir() / ".env", override=False)
|
|
22
|
+
load_dotenv(Path.cwd() / ".env", override=False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _zone(name: str) -> ZoneInfo | None:
|
|
26
|
+
try:
|
|
27
|
+
return ZoneInfo(name.strip()) if name.strip() else None
|
|
28
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def system_timezone(localtime: Path = Path("/etc/localtime"), timezone_file: Path = Path("/etc/timezone")) -> tzinfo:
|
|
33
|
+
"""The computer's timezone: TZ, the /etc/localtime link (macOS, Linux), /etc/timezone (Debian),
|
|
34
|
+
else the current fixed UTC offset (Windows; set IG_TIMEZONE there to get daylight-saving changes right)."""
|
|
35
|
+
if tz := _zone(os.getenv("TZ", "").lstrip(":")):
|
|
36
|
+
return tz
|
|
37
|
+
try:
|
|
38
|
+
target = str(localtime.resolve(strict=True))
|
|
39
|
+
if "zoneinfo/" in target and (tz := _zone(target.split("zoneinfo/", 1)[1])):
|
|
40
|
+
return tz
|
|
41
|
+
except OSError:
|
|
42
|
+
pass
|
|
43
|
+
try:
|
|
44
|
+
if tz := _zone(timezone_file.read_text(encoding="utf-8")):
|
|
45
|
+
return tz
|
|
46
|
+
except OSError:
|
|
47
|
+
pass
|
|
48
|
+
return datetime.now().astimezone().tzinfo or ZoneInfo("UTC")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _int(name: str, default: int) -> int:
|
|
52
|
+
raw = os.getenv(name, "").strip()
|
|
53
|
+
return int(raw) if raw else default
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class Settings:
|
|
58
|
+
access_token: str
|
|
59
|
+
ig_user_id: str
|
|
60
|
+
api_version: str
|
|
61
|
+
app_id: str | None
|
|
62
|
+
app_secret: str | None
|
|
63
|
+
db_path: Path
|
|
64
|
+
timezone: tzinfo
|
|
65
|
+
ttl_profile_s: int
|
|
66
|
+
ttl_media_s: int
|
|
67
|
+
ttl_hashtag_s: int
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def base_url(self) -> str:
|
|
71
|
+
return f"https://graph.facebook.com/{self.api_version}"
|
|
72
|
+
|
|
73
|
+
def require_credentials(self) -> None:
|
|
74
|
+
missing = [
|
|
75
|
+
name
|
|
76
|
+
for name, value in (("IG_ACCESS_TOKEN", self.access_token), ("IG_USER_ID", self.ig_user_id))
|
|
77
|
+
if not value
|
|
78
|
+
]
|
|
79
|
+
if missing:
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
f"Missing required settings: {', '.join(missing)}. "
|
|
82
|
+
f"Fill them in {paths.home_dir() / '.env'} (run creatorloop-setup to create it)."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _configured_timezone() -> tzinfo:
|
|
87
|
+
raw = os.getenv("IG_TIMEZONE", "").strip()
|
|
88
|
+
if not raw:
|
|
89
|
+
return system_timezone()
|
|
90
|
+
if tz := _zone(raw):
|
|
91
|
+
return tz
|
|
92
|
+
raise RuntimeError(f"IG_TIMEZONE={raw!r} is not a known timezone; use an IANA name like 'Europe/Berlin'.")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_settings() -> Settings:
|
|
96
|
+
_load_env()
|
|
97
|
+
db_raw = os.getenv("IG_DB_PATH", "").strip()
|
|
98
|
+
db_path = Path(db_raw).expanduser() if db_raw else paths.home_dir() / "creatorloop.db"
|
|
99
|
+
return Settings(
|
|
100
|
+
access_token=os.getenv("IG_ACCESS_TOKEN", "").strip(),
|
|
101
|
+
ig_user_id=os.getenv("IG_USER_ID", "").strip(),
|
|
102
|
+
api_version=os.getenv("GRAPH_API_VERSION", DEFAULT_API_VERSION).strip() or DEFAULT_API_VERSION,
|
|
103
|
+
app_id=os.getenv("META_APP_ID", "").strip() or None,
|
|
104
|
+
app_secret=os.getenv("META_APP_SECRET", "").strip() or None,
|
|
105
|
+
db_path=db_path,
|
|
106
|
+
timezone=_configured_timezone(),
|
|
107
|
+
ttl_profile_s=_int("CACHE_TTL_PROFILE_MIN", 60) * 60,
|
|
108
|
+
ttl_media_s=_int("CACHE_TTL_MEDIA_MIN", 180) * 60,
|
|
109
|
+
ttl_hashtag_s=_int("CACHE_TTL_HASHTAG_MIN", 360) * 60,
|
|
110
|
+
)
|
creatorloop/env.example
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# creatorloop settings. `creatorloop-setup` creates this file in your creatorloop home folder;
|
|
2
|
+
# fill in the two required values below and restart Claude Desktop.
|
|
3
|
+
# Commands below are for a PyPI install; from a source checkout use `uv run creatorloop-auth ...`.
|
|
4
|
+
|
|
5
|
+
# Long-lived user access token (60 days) or a System User token (never expires).
|
|
6
|
+
# Get it with: uvx --from creatorloop creatorloop-auth exchange --short-token <TOKEN> --write
|
|
7
|
+
IG_ACCESS_TOKEN=
|
|
8
|
+
|
|
9
|
+
# Instagram professional account ID (NOT the username). 17841400000000000-like number.
|
|
10
|
+
# Get it with: uvx --from creatorloop creatorloop-auth whoami --write
|
|
11
|
+
IG_USER_ID=
|
|
12
|
+
|
|
13
|
+
# Graph API version. Check https://developers.facebook.com/docs/graph-api/changelog
|
|
14
|
+
GRAPH_API_VERSION=v26.0
|
|
15
|
+
|
|
16
|
+
# Optional: needed only for the token exchange and token expiry check.
|
|
17
|
+
META_APP_ID=
|
|
18
|
+
META_APP_SECRET=
|
|
19
|
+
|
|
20
|
+
# Optional: SQLite database location (default: creatorloop.db in the creatorloop home folder).
|
|
21
|
+
IG_DB_PATH=
|
|
22
|
+
|
|
23
|
+
# Optional: IANA timezone for "best time to post" and the queue, e.g. Europe/Berlin.
|
|
24
|
+
# Default: your computer's timezone (on Windows set it explicitly to get daylight-saving changes right).
|
|
25
|
+
IG_TIMEZONE=
|
|
26
|
+
|
|
27
|
+
# Optional: publishing slots for queue_post(when="next"), local time. Example, not a recommendation.
|
|
28
|
+
QUEUE_SLOTS=Tue,Thu,Sun 19:00
|
|
29
|
+
|
|
30
|
+
# Cache TTL in minutes.
|
|
31
|
+
CACHE_TTL_PROFILE_MIN=60
|
|
32
|
+
CACHE_TTL_MEDIA_MIN=180
|
|
33
|
+
CACHE_TTL_HASHTAG_MIN=360
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Thin async client for the Facebook Graph API (Instagram API with Facebook Login)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# https://developers.facebook.com/docs/graph-api/overview/rate-limiting
|
|
17
|
+
RATE_LIMIT_CODES = {4, 17, 32, 613, 80001, 80002, 80006}
|
|
18
|
+
TRANSIENT_CODES = {1, 2}
|
|
19
|
+
TOKEN_ERROR_CODE = 190
|
|
20
|
+
RUPLOAD_HOST = "https://rupload.facebook.com" # resumable video uploads (Reels)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GraphAPIError(Exception):
|
|
24
|
+
"""Error returned by the Graph API."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
message: str,
|
|
29
|
+
*,
|
|
30
|
+
code: int | None = None,
|
|
31
|
+
subcode: int | None = None,
|
|
32
|
+
status: int | None = None,
|
|
33
|
+
fbtrace_id: str | None = None,
|
|
34
|
+
transient: bool = False,
|
|
35
|
+
) -> None:
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.message = message
|
|
38
|
+
self.code = code
|
|
39
|
+
self.subcode = subcode
|
|
40
|
+
self.status = status
|
|
41
|
+
self.fbtrace_id = fbtrace_id
|
|
42
|
+
self.transient = transient
|
|
43
|
+
|
|
44
|
+
def __str__(self) -> str:
|
|
45
|
+
parts = [self.message]
|
|
46
|
+
if self.code is not None:
|
|
47
|
+
parts.append(f"code={self.code}")
|
|
48
|
+
if self.subcode is not None:
|
|
49
|
+
parts.append(f"subcode={self.subcode}")
|
|
50
|
+
if self.fbtrace_id:
|
|
51
|
+
parts.append(f"fbtrace_id={self.fbtrace_id}")
|
|
52
|
+
return " | ".join(parts)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RateLimitError(GraphAPIError):
|
|
56
|
+
"""Rate limit reached. Retrying immediately makes it worse, so we do not retry."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TokenError(GraphAPIError):
|
|
60
|
+
"""Access token is invalid or expired."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class UsageInfo:
|
|
65
|
+
"""Last seen rate-limit usage headers."""
|
|
66
|
+
|
|
67
|
+
app_usage: dict[str, Any] = field(default_factory=dict)
|
|
68
|
+
business_usage: dict[str, Any] = field(default_factory=dict)
|
|
69
|
+
instagram_usage: dict[str, Any] = field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _parse_json_header(value: str | None) -> dict[str, Any]:
|
|
73
|
+
if not value:
|
|
74
|
+
return {}
|
|
75
|
+
try:
|
|
76
|
+
parsed = json.loads(value)
|
|
77
|
+
except ValueError:
|
|
78
|
+
return {}
|
|
79
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class GraphClient:
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
base_url: str,
|
|
86
|
+
access_token: str,
|
|
87
|
+
*,
|
|
88
|
+
timeout: float = 30.0,
|
|
89
|
+
max_retries: int = 3,
|
|
90
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
self._access_token = access_token
|
|
93
|
+
self._max_retries = max_retries
|
|
94
|
+
self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout, transport=transport)
|
|
95
|
+
self.usage = UsageInfo()
|
|
96
|
+
|
|
97
|
+
async def aclose(self) -> None:
|
|
98
|
+
await self._client.aclose()
|
|
99
|
+
|
|
100
|
+
async def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
101
|
+
return await self._request("GET", path, params, retries=self._max_retries)
|
|
102
|
+
|
|
103
|
+
async def post(self, path: str, params: dict[str, Any] | None = None, *, retries: int = 0) -> dict[str, Any]:
|
|
104
|
+
"""POST with form params. No retries by default: repeating a publish call could post twice."""
|
|
105
|
+
return await self._request("POST", path, params, retries=retries)
|
|
106
|
+
|
|
107
|
+
async def _request(self, method: str, path: str, params: dict[str, Any] | None, *, retries: int) -> dict[str, Any]:
|
|
108
|
+
query = {k: v for k, v in (params or {}).items() if v is not None}
|
|
109
|
+
query.setdefault("access_token", self._access_token)
|
|
110
|
+
|
|
111
|
+
attempt = 0
|
|
112
|
+
while True:
|
|
113
|
+
attempt += 1
|
|
114
|
+
try:
|
|
115
|
+
if method == "GET":
|
|
116
|
+
response = await self._client.get(path, params=query)
|
|
117
|
+
else:
|
|
118
|
+
response = await self._client.post(path, data=query)
|
|
119
|
+
except httpx.TransportError as exc:
|
|
120
|
+
if attempt > retries:
|
|
121
|
+
raise GraphAPIError(f"Network error: {exc}", transient=True) from exc
|
|
122
|
+
await self._backoff(attempt, reason=str(exc))
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
self._record_usage(response)
|
|
126
|
+
try:
|
|
127
|
+
return self._handle(response)
|
|
128
|
+
except GraphAPIError as exc:
|
|
129
|
+
retryable = exc.transient or (exc.status is not None and exc.status >= 500)
|
|
130
|
+
if isinstance(exc, (RateLimitError, TokenError)) or not retryable or attempt > retries:
|
|
131
|
+
raise
|
|
132
|
+
await self._backoff(attempt, reason=str(exc))
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def api_version(self) -> str:
|
|
136
|
+
return str(self._client.base_url).rstrip("/").rsplit("/", 1)[-1]
|
|
137
|
+
|
|
138
|
+
async def upload_video(self, container_id: str, path: Path, *, timeout: float = 600.0) -> dict[str, Any]:
|
|
139
|
+
"""Resumable upload of a local video into a REELS container created with upload_type=resumable.
|
|
140
|
+
|
|
141
|
+
POST https://rupload.facebook.com/ig-api-upload/<version>/<container_id>, headers Authorization/offset/file_size.
|
|
142
|
+
Sent in one request from offset 0. Not retried automatically (a partial upload is simply redone by prepare).
|
|
143
|
+
"""
|
|
144
|
+
url = f"{RUPLOAD_HOST}/ig-api-upload/{self.api_version}/{container_id}"
|
|
145
|
+
data = await asyncio.to_thread(path.read_bytes)
|
|
146
|
+
headers = {"Authorization": f"OAuth {self._access_token}", "offset": "0", "file_size": str(len(data))}
|
|
147
|
+
try:
|
|
148
|
+
response = await self._client.post(url, content=data, headers=headers, timeout=timeout)
|
|
149
|
+
except httpx.TransportError as exc:
|
|
150
|
+
raise GraphAPIError(f"Video upload failed: {exc}", transient=True) from exc
|
|
151
|
+
try:
|
|
152
|
+
body = response.json()
|
|
153
|
+
except ValueError:
|
|
154
|
+
body = {}
|
|
155
|
+
if not response.is_success or not body.get("success"):
|
|
156
|
+
debug = body.get("debug_info") or body.get("error") or response.text[:300]
|
|
157
|
+
raise GraphAPIError(f"Video upload rejected (HTTP {response.status_code}): {debug}", status=response.status_code)
|
|
158
|
+
return body
|
|
159
|
+
|
|
160
|
+
async def paginate(
|
|
161
|
+
self, path: str, params: dict[str, Any] | None = None, *, max_items: int = 100
|
|
162
|
+
) -> list[dict[str, Any]]:
|
|
163
|
+
"""Follow cursor pagination (`paging.cursors.after`) until max_items."""
|
|
164
|
+
items: list[dict[str, Any]] = []
|
|
165
|
+
page_params = dict(params or {})
|
|
166
|
+
while len(items) < max_items:
|
|
167
|
+
page_params["limit"] = min(page_params.get("limit") or 50, max_items - len(items), 100)
|
|
168
|
+
body = await self.get(path, page_params)
|
|
169
|
+
items.extend(body.get("data", []))
|
|
170
|
+
after = body.get("paging", {}).get("cursors", {}).get("after")
|
|
171
|
+
if not after or not body.get("paging", {}).get("next"):
|
|
172
|
+
break
|
|
173
|
+
page_params["after"] = after
|
|
174
|
+
return items[:max_items]
|
|
175
|
+
|
|
176
|
+
async def _backoff(self, attempt: int, *, reason: str) -> None:
|
|
177
|
+
delay = min(2 ** (attempt - 1), 8)
|
|
178
|
+
log.warning("Graph API retry %s in %ss: %s", attempt, delay, reason)
|
|
179
|
+
await asyncio.sleep(delay)
|
|
180
|
+
|
|
181
|
+
def _record_usage(self, response: httpx.Response) -> None:
|
|
182
|
+
headers = response.headers
|
|
183
|
+
if app := _parse_json_header(headers.get("x-app-usage")):
|
|
184
|
+
self.usage.app_usage = app
|
|
185
|
+
if biz := _parse_json_header(headers.get("x-business-use-case-usage")):
|
|
186
|
+
self.usage.business_usage = biz
|
|
187
|
+
if ig := _parse_json_header(headers.get("x-ig-business-use-case-usage")):
|
|
188
|
+
self.usage.instagram_usage = ig
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _handle(response: httpx.Response) -> dict[str, Any]:
|
|
192
|
+
try:
|
|
193
|
+
body = response.json()
|
|
194
|
+
except ValueError:
|
|
195
|
+
if response.is_success:
|
|
196
|
+
raise GraphAPIError("Non-JSON response from Graph API", status=response.status_code) from None
|
|
197
|
+
raise GraphAPIError(
|
|
198
|
+
f"HTTP {response.status_code}: {response.text[:300]}", status=response.status_code
|
|
199
|
+
) from None
|
|
200
|
+
|
|
201
|
+
if response.is_success and "error" not in body:
|
|
202
|
+
return body
|
|
203
|
+
|
|
204
|
+
err = body.get("error", {}) if isinstance(body, dict) else {}
|
|
205
|
+
message = err.get("error_user_msg") or err.get("message") or f"HTTP {response.status_code}"
|
|
206
|
+
code = err.get("code")
|
|
207
|
+
kwargs = dict(
|
|
208
|
+
code=code,
|
|
209
|
+
subcode=err.get("error_subcode"),
|
|
210
|
+
status=response.status_code,
|
|
211
|
+
fbtrace_id=err.get("fbtrace_id"),
|
|
212
|
+
transient=bool(err.get("is_transient")) or code in TRANSIENT_CODES,
|
|
213
|
+
)
|
|
214
|
+
if code in RATE_LIMIT_CODES:
|
|
215
|
+
raise RateLimitError(
|
|
216
|
+
f"Rate limit reached: {message}. Wait before the next request (see get_api_status).",
|
|
217
|
+
**kwargs,
|
|
218
|
+
)
|
|
219
|
+
if code == TOKEN_ERROR_CODE:
|
|
220
|
+
raise TokenError(
|
|
221
|
+
f"Access token problem: {message}. Refresh IG_ACCESS_TOKEN (README, section 'Token').",
|
|
222
|
+
**kwargs,
|
|
223
|
+
)
|
|
224
|
+
raise GraphAPIError(message, **kwargs)
|