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/setup_cli.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Add the "creatorloop" MCP server to the Claude Desktop config (keeps all other settings).
|
|
2
|
+
|
|
3
|
+
Usage (from the project folder, on Windows, macOS or Linux):
|
|
4
|
+
uv run creatorloop-setup [--config PATH] [--no-restart]
|
|
5
|
+
|
|
6
|
+
Claude Desktop rewrites its config from memory while it runs, so the config is only written while
|
|
7
|
+
Desktop is closed: on macOS this command quits and reopens it, elsewhere it asks you to quit it first.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from creatorloop import DISCLAIMER, paths
|
|
24
|
+
|
|
25
|
+
CONFIG_NAME = "claude_desktop_config.json"
|
|
26
|
+
MAC_APP_BINARY = "Claude.app/Contents/MacOS/Claude$"
|
|
27
|
+
QUIT_TIMEOUT_S = 30
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def config_candidates(platform: str, *, home: Path, env: Mapping[str, str]) -> list[Path]:
|
|
31
|
+
"""Where Claude Desktop keeps its config on this OS, most usual location first."""
|
|
32
|
+
if platform == "win32":
|
|
33
|
+
candidates = [Path(env.get("APPDATA", home / "AppData" / "Roaming")) / "Claude" / CONFIG_NAME]
|
|
34
|
+
# Microsoft Store (MSIX) build keeps a virtualised copy of %APPDATA%
|
|
35
|
+
if local := env.get("LOCALAPPDATA"):
|
|
36
|
+
for pkg in sorted((Path(local) / "Packages").glob("Claude_*")):
|
|
37
|
+
candidates.append(pkg / "LocalCache" / "Roaming" / "Claude" / CONFIG_NAME)
|
|
38
|
+
return candidates
|
|
39
|
+
if platform == "darwin":
|
|
40
|
+
return [home / "Library" / "Application Support" / "Claude" / CONFIG_NAME]
|
|
41
|
+
return [Path(env.get("XDG_CONFIG_HOME") or home / ".config") / "Claude" / CONFIG_NAME]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def pick_targets(candidates: list[Path]) -> list[Path]:
|
|
45
|
+
"""Every config that already exists; if none does, the first (default) location."""
|
|
46
|
+
return [p for p in candidates if p.exists()] or candidates[:1]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def find_uv() -> str | None:
|
|
50
|
+
if found := shutil.which("uv"):
|
|
51
|
+
return found
|
|
52
|
+
# Claude Desktop does not inherit the shell PATH: we need an absolute path anyway
|
|
53
|
+
exe = "uv.exe" if sys.platform == "win32" else "uv"
|
|
54
|
+
for d in (Path.home() / ".local" / "bin", Path.home() / ".cargo" / "bin", Path("/opt/homebrew/bin"), Path("/usr/local/bin")):
|
|
55
|
+
if (d / exe).exists():
|
|
56
|
+
return str(d / exe)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def server_entry(*, uv: str, source: Path | None) -> dict:
|
|
61
|
+
"""How Claude Desktop starts the server: from this source checkout, or the published package (= uvx)."""
|
|
62
|
+
if source is not None:
|
|
63
|
+
return {"command": uv, "args": ["--directory", str(source), "run", "creatorloop"]}
|
|
64
|
+
return {"command": uv, "args": ["tool", "run", "creatorloop"]}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def install(config_path: Path, *, entry: dict) -> Path | None:
|
|
68
|
+
"""Write the server entry; return the backup path (None when the config did not exist)."""
|
|
69
|
+
backup = None
|
|
70
|
+
config: dict = {}
|
|
71
|
+
if config_path.exists():
|
|
72
|
+
raw = config_path.read_text(encoding="utf-8-sig") # tolerate a BOM left by Windows editors
|
|
73
|
+
if raw.strip():
|
|
74
|
+
try:
|
|
75
|
+
config = json.loads(raw)
|
|
76
|
+
except json.JSONDecodeError as e:
|
|
77
|
+
sys.exit(f"{config_path} is not valid JSON ({e}). Fix it or move it away, then run again.")
|
|
78
|
+
backup = config_path.with_name(f"{config_path.name}.backup-{datetime.now():%Y%m%d-%H%M%S}")
|
|
79
|
+
shutil.copy2(config_path, backup)
|
|
80
|
+
|
|
81
|
+
servers = config.get("mcpServers") or {}
|
|
82
|
+
servers["creatorloop"] = entry
|
|
83
|
+
config["mcpServers"] = servers
|
|
84
|
+
|
|
85
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
# UTF-8 without BOM: Claude Desktop fails to parse a BOM-prefixed file
|
|
87
|
+
config_path.write_text(json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
88
|
+
json.loads(config_path.read_text(encoding="utf-8")) # validate
|
|
89
|
+
return backup
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_desktop_running(platform: str) -> bool:
|
|
93
|
+
if platform == "darwin":
|
|
94
|
+
cmd = ["pgrep", "-f", MAC_APP_BINARY]
|
|
95
|
+
elif platform == "win32":
|
|
96
|
+
cmd = ["tasklist", "/FI", "IMAGENAME eq Claude.exe", "/NH"]
|
|
97
|
+
else:
|
|
98
|
+
cmd = ["pgrep", "-x", "claude-desktop|Claude"]
|
|
99
|
+
try:
|
|
100
|
+
res = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
101
|
+
except FileNotFoundError:
|
|
102
|
+
return False
|
|
103
|
+
if platform == "win32":
|
|
104
|
+
return "claude.exe" in res.stdout.lower()
|
|
105
|
+
return res.returncode == 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def quit_desktop(timeout: float = QUIT_TIMEOUT_S) -> bool:
|
|
109
|
+
"""macOS only: the same graceful quit as Claude -> Quit; True once the process is gone."""
|
|
110
|
+
subprocess.run(["osascript", "-e", 'quit app "Claude"'], capture_output=True, check=False)
|
|
111
|
+
deadline = time.monotonic() + timeout
|
|
112
|
+
while time.monotonic() < deadline:
|
|
113
|
+
if not is_desktop_running("darwin"):
|
|
114
|
+
return True
|
|
115
|
+
time.sleep(1)
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def launch_desktop() -> None:
|
|
120
|
+
subprocess.run(["open", "-a", "Claude"], check=False)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def close_desktop_if_running(platform: str, *, restart: bool) -> bool:
|
|
124
|
+
"""Desktop rewrites the config from memory while running, so it must be closed first.
|
|
125
|
+
|
|
126
|
+
Returns True when we quit it and should start it again afterwards.
|
|
127
|
+
"""
|
|
128
|
+
if not is_desktop_running(platform):
|
|
129
|
+
return False
|
|
130
|
+
if platform == "darwin" and restart:
|
|
131
|
+
print("Claude Desktop is running: quitting it so it does not overwrite the config...")
|
|
132
|
+
if not quit_desktop():
|
|
133
|
+
sys.exit(f"Claude Desktop did not quit within {QUIT_TIMEOUT_S} s; nothing was written. Quit it and run again.")
|
|
134
|
+
return True
|
|
135
|
+
hint = " (or run without --no-restart to let this command do it)" if platform == "darwin" else ""
|
|
136
|
+
sys.exit(
|
|
137
|
+
"Claude Desktop is running and would overwrite the config with its own copy. "
|
|
138
|
+
f"Quit it (tray / menu bar -> Quit), then run this command again{hint}."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main(argv: list[str] | None = None, platform: str = sys.platform) -> None:
|
|
143
|
+
ap = argparse.ArgumentParser(description=__doc__, epilog=DISCLAIMER, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
144
|
+
ap.add_argument("--config", type=Path, help=f"path to {CONFIG_NAME} (default: auto-detect)")
|
|
145
|
+
ap.add_argument("--no-restart", action="store_true", help="macOS: do not quit and reopen a running Claude Desktop")
|
|
146
|
+
args = ap.parse_args(argv)
|
|
147
|
+
|
|
148
|
+
uv = find_uv()
|
|
149
|
+
if not uv:
|
|
150
|
+
sys.exit("uv not found. Install it first: https://docs.astral.sh/uv/getting-started/installation/")
|
|
151
|
+
targets = [args.config] if args.config else pick_targets(config_candidates(platform, home=Path.home(), env=os.environ))
|
|
152
|
+
|
|
153
|
+
source = paths.source_root()
|
|
154
|
+
entry = server_entry(uv=uv, source=source)
|
|
155
|
+
|
|
156
|
+
relaunch = close_desktop_if_running(platform, restart=not args.no_restart)
|
|
157
|
+
for path in targets:
|
|
158
|
+
backup = install(path, entry=entry)
|
|
159
|
+
if backup:
|
|
160
|
+
print(f"Backup: {backup}")
|
|
161
|
+
print(f"OK: 'creatorloop' server written to {path}")
|
|
162
|
+
|
|
163
|
+
home = paths.home_dir()
|
|
164
|
+
if env_file := paths.ensure_env_file(home):
|
|
165
|
+
print(f"Created {env_file}: fill in IG_ACCESS_TOKEN and IG_USER_ID (see README).")
|
|
166
|
+
print(f"\nrun: {' '.join([entry['command'], *entry['args']])}\ndata: {home}")
|
|
167
|
+
if relaunch:
|
|
168
|
+
launch_desktop()
|
|
169
|
+
print("Claude Desktop restarted: the 'creatorloop' tools appear in a new chat.")
|
|
170
|
+
else:
|
|
171
|
+
print("Start Claude Desktop: the 'creatorloop' tools appear in a new chat.")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == "__main__":
|
|
175
|
+
main()
|
creatorloop/storage.py
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""SQLite storage: response cache, follower snapshots, hashtag quota tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from datetime import UTC, datetime, timedelta
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
SCHEMA = """
|
|
15
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
16
|
+
key TEXT PRIMARY KEY,
|
|
17
|
+
value TEXT NOT NULL,
|
|
18
|
+
expires_at REAL NOT NULL
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE TABLE IF NOT EXISTS profile_snapshots (
|
|
22
|
+
username TEXT NOT NULL,
|
|
23
|
+
captured_on TEXT NOT NULL, -- YYYY-MM-DD (UTC), one row per day
|
|
24
|
+
captured_at TEXT NOT NULL, -- ISO timestamp of the last update that day
|
|
25
|
+
followers_count INTEGER,
|
|
26
|
+
follows_count INTEGER,
|
|
27
|
+
media_count INTEGER,
|
|
28
|
+
PRIMARY KEY (username, captured_on)
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
-- v2: post metric history and competitor tracking (additive, no migration of old tables needed)
|
|
32
|
+
CREATE TABLE IF NOT EXISTS media (
|
|
33
|
+
media_id TEXT PRIMARY KEY,
|
|
34
|
+
owner TEXT NOT NULL, -- username, lower case
|
|
35
|
+
is_own INTEGER NOT NULL DEFAULT 0,
|
|
36
|
+
published_at TEXT, -- Graph API timestamp
|
|
37
|
+
format TEXT,
|
|
38
|
+
permalink TEXT,
|
|
39
|
+
caption_head TEXT
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE TABLE IF NOT EXISTS media_snapshots (
|
|
43
|
+
media_id TEXT NOT NULL,
|
|
44
|
+
captured_hr TEXT NOT NULL, -- YYYY-MM-DDTHH (UTC), at most one row per hour
|
|
45
|
+
captured_at TEXT NOT NULL,
|
|
46
|
+
reach INTEGER,
|
|
47
|
+
views INTEGER,
|
|
48
|
+
likes INTEGER,
|
|
49
|
+
comments INTEGER,
|
|
50
|
+
saved INTEGER,
|
|
51
|
+
shares INTEGER,
|
|
52
|
+
follows INTEGER,
|
|
53
|
+
PRIMARY KEY (media_id, captured_hr)
|
|
54
|
+
);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_media_owner ON media (owner);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE IF NOT EXISTS tracked_accounts (
|
|
58
|
+
username TEXT PRIMARY KEY,
|
|
59
|
+
added_at TEXT NOT NULL,
|
|
60
|
+
note TEXT
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE TABLE IF NOT EXISTS publications (
|
|
64
|
+
creation_id TEXT PRIMARY KEY,
|
|
65
|
+
folder TEXT,
|
|
66
|
+
caption TEXT,
|
|
67
|
+
items INTEGER,
|
|
68
|
+
status TEXT NOT NULL, -- prepared | published | failed
|
|
69
|
+
media_id TEXT,
|
|
70
|
+
permalink TEXT,
|
|
71
|
+
error TEXT,
|
|
72
|
+
prepared_at TEXT NOT NULL,
|
|
73
|
+
published_at TEXT
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
CREATE TABLE IF NOT EXISTS post_reviews (
|
|
77
|
+
media_id TEXT NOT NULL,
|
|
78
|
+
stage TEXT NOT NULL, -- 24h | 72h
|
|
79
|
+
reported_at TEXT NOT NULL,
|
|
80
|
+
metrics TEXT, -- JSON snapshot at review time
|
|
81
|
+
PRIMARY KEY (media_id, stage)
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
CREATE TABLE IF NOT EXISTS post_queue (
|
|
85
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
86
|
+
folder TEXT NOT NULL,
|
|
87
|
+
publish_at TEXT NOT NULL, -- UTC ISO
|
|
88
|
+
fingerprint TEXT NOT NULL, -- hash of caption + media approved by the user
|
|
89
|
+
media_type TEXT,
|
|
90
|
+
caption_head TEXT,
|
|
91
|
+
status TEXT NOT NULL, -- queued | publishing | published | failed | missed | blocked | cancelled
|
|
92
|
+
creation_id TEXT,
|
|
93
|
+
permalink TEXT,
|
|
94
|
+
error TEXT,
|
|
95
|
+
approved_at TEXT NOT NULL,
|
|
96
|
+
updated_at TEXT NOT NULL
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
CREATE TABLE IF NOT EXISTS comments_seen (
|
|
100
|
+
comment_id TEXT PRIMARY KEY,
|
|
101
|
+
media_id TEXT,
|
|
102
|
+
seen_at TEXT NOT NULL,
|
|
103
|
+
replied_at TEXT,
|
|
104
|
+
reply_id TEXT
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
CREATE TABLE IF NOT EXISTS hashtag_queries (
|
|
108
|
+
hashtag TEXT PRIMARY KEY,
|
|
109
|
+
hashtag_id TEXT NOT NULL,
|
|
110
|
+
first_queried_at TEXT NOT NULL -- start of the current 7-day window for this tag
|
|
111
|
+
);
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
# v3: additive column migrations (ignored when already applied)
|
|
115
|
+
MIGRATIONS = ["ALTER TABLE publications ADD COLUMN media_type TEXT"]
|
|
116
|
+
|
|
117
|
+
HASHTAG_LIMIT = 30
|
|
118
|
+
HASHTAG_WINDOW = timedelta(days=7)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Storage:
|
|
122
|
+
def __init__(self, path: Path) -> None:
|
|
123
|
+
self.path = path
|
|
124
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
with self._conn() as conn:
|
|
126
|
+
conn.executescript(SCHEMA)
|
|
127
|
+
for ddl in MIGRATIONS:
|
|
128
|
+
try:
|
|
129
|
+
conn.execute(ddl)
|
|
130
|
+
except sqlite3.OperationalError: # column already exists
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
@contextmanager
|
|
134
|
+
def _conn(self) -> Iterator[sqlite3.Connection]:
|
|
135
|
+
conn = sqlite3.connect(self.path)
|
|
136
|
+
conn.row_factory = sqlite3.Row
|
|
137
|
+
try:
|
|
138
|
+
yield conn
|
|
139
|
+
conn.commit()
|
|
140
|
+
finally:
|
|
141
|
+
conn.close()
|
|
142
|
+
|
|
143
|
+
# ---- cache -------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def cache_get(self, key: str) -> Any | None:
|
|
146
|
+
with self._conn() as conn:
|
|
147
|
+
row = conn.execute("SELECT value, expires_at FROM cache WHERE key = ?", (key,)).fetchone()
|
|
148
|
+
if row is None or row["expires_at"] < time.time():
|
|
149
|
+
return None
|
|
150
|
+
return json.loads(row["value"])
|
|
151
|
+
|
|
152
|
+
def cache_set(self, key: str, value: Any, ttl_s: int) -> None:
|
|
153
|
+
with self._conn() as conn:
|
|
154
|
+
conn.execute(
|
|
155
|
+
"INSERT OR REPLACE INTO cache (key, value, expires_at) VALUES (?, ?, ?)",
|
|
156
|
+
(key, json.dumps(value, ensure_ascii=False), time.time() + ttl_s),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def cache_clear(self, prefix: str | None = None) -> int:
|
|
160
|
+
with self._conn() as conn:
|
|
161
|
+
if prefix:
|
|
162
|
+
cur = conn.execute("DELETE FROM cache WHERE key LIKE ?", (f"{prefix}%",))
|
|
163
|
+
else:
|
|
164
|
+
cur = conn.execute("DELETE FROM cache")
|
|
165
|
+
return cur.rowcount
|
|
166
|
+
|
|
167
|
+
# ---- profile snapshots -------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def save_snapshot(self, username: str, followers: int | None, follows: int | None, media: int | None) -> None:
|
|
170
|
+
now = datetime.now(UTC)
|
|
171
|
+
with self._conn() as conn:
|
|
172
|
+
conn.execute(
|
|
173
|
+
"""
|
|
174
|
+
INSERT INTO profile_snapshots
|
|
175
|
+
(username, captured_on, captured_at, followers_count, follows_count, media_count)
|
|
176
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
177
|
+
ON CONFLICT (username, captured_on) DO UPDATE SET
|
|
178
|
+
captured_at = excluded.captured_at,
|
|
179
|
+
followers_count = excluded.followers_count,
|
|
180
|
+
follows_count = excluded.follows_count,
|
|
181
|
+
media_count = excluded.media_count
|
|
182
|
+
""",
|
|
183
|
+
(username.lower(), now.date().isoformat(), now.isoformat(timespec="seconds"), followers, follows, media),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def get_snapshots(self, username: str, days: int) -> list[dict[str, Any]]:
|
|
187
|
+
since = (datetime.now(UTC).date() - timedelta(days=days)).isoformat()
|
|
188
|
+
with self._conn() as conn:
|
|
189
|
+
rows = conn.execute(
|
|
190
|
+
"""
|
|
191
|
+
SELECT captured_on, followers_count, follows_count, media_count
|
|
192
|
+
FROM profile_snapshots
|
|
193
|
+
WHERE username = ? AND captured_on >= ?
|
|
194
|
+
ORDER BY captured_on
|
|
195
|
+
""",
|
|
196
|
+
(username.lower(), since),
|
|
197
|
+
).fetchall()
|
|
198
|
+
return [dict(r) for r in rows]
|
|
199
|
+
|
|
200
|
+
def tracked_usernames(self) -> list[str]:
|
|
201
|
+
with self._conn() as conn:
|
|
202
|
+
rows = conn.execute("SELECT DISTINCT username FROM profile_snapshots ORDER BY username").fetchall()
|
|
203
|
+
return [r["username"] for r in rows]
|
|
204
|
+
|
|
205
|
+
# ---- media history -----------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def save_media_snapshots(self, owner: str, items: list[dict[str, Any]], *, is_own: bool) -> int:
|
|
208
|
+
"""items: {id, timestamp, format, permalink, caption, metrics:{reach, views, likes, ...}}"""
|
|
209
|
+
now = datetime.now(UTC)
|
|
210
|
+
hour = now.strftime("%Y-%m-%dT%H")
|
|
211
|
+
stamp = now.isoformat(timespec="seconds")
|
|
212
|
+
cols = ("reach", "views", "likes", "comments", "saved", "shares", "follows")
|
|
213
|
+
with self._conn() as conn:
|
|
214
|
+
for it in items:
|
|
215
|
+
conn.execute(
|
|
216
|
+
"""
|
|
217
|
+
INSERT INTO media (media_id, owner, is_own, published_at, format, permalink, caption_head)
|
|
218
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
219
|
+
ON CONFLICT (media_id) DO UPDATE SET
|
|
220
|
+
is_own = MAX(media.is_own, excluded.is_own),
|
|
221
|
+
format = excluded.format, permalink = excluded.permalink, caption_head = excluded.caption_head
|
|
222
|
+
""",
|
|
223
|
+
(
|
|
224
|
+
it["id"],
|
|
225
|
+
owner.lower(),
|
|
226
|
+
int(is_own),
|
|
227
|
+
it.get("timestamp"),
|
|
228
|
+
it.get("format"),
|
|
229
|
+
it.get("permalink"),
|
|
230
|
+
(it.get("caption") or "").split("\n", 1)[0][:200],
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
m = it.get("metrics") or {}
|
|
234
|
+
conn.execute(
|
|
235
|
+
f"""
|
|
236
|
+
INSERT OR REPLACE INTO media_snapshots (media_id, captured_hr, captured_at, {", ".join(cols)})
|
|
237
|
+
VALUES (?, ?, ?, {", ".join("?" for _ in cols)})
|
|
238
|
+
""",
|
|
239
|
+
(it["id"], hour, stamp, *(m.get(c) for c in cols)),
|
|
240
|
+
)
|
|
241
|
+
return len(items)
|
|
242
|
+
|
|
243
|
+
def media_history(self, media_id: str) -> dict[str, Any] | None:
|
|
244
|
+
with self._conn() as conn:
|
|
245
|
+
meta = conn.execute("SELECT * FROM media WHERE media_id = ?", (media_id,)).fetchone()
|
|
246
|
+
if meta is None:
|
|
247
|
+
return None
|
|
248
|
+
rows = conn.execute(
|
|
249
|
+
"""
|
|
250
|
+
SELECT captured_at, reach, views, likes, comments, saved, shares, follows
|
|
251
|
+
FROM media_snapshots WHERE media_id = ? ORDER BY captured_at
|
|
252
|
+
""",
|
|
253
|
+
(media_id,),
|
|
254
|
+
).fetchall()
|
|
255
|
+
return {"media": dict(meta), "snapshots": [dict(r) for r in rows]}
|
|
256
|
+
|
|
257
|
+
def owner_media_ids(self, owner: str) -> set[str]:
|
|
258
|
+
with self._conn() as conn:
|
|
259
|
+
rows = conn.execute("SELECT media_id FROM media WHERE owner = ?", (owner.lower(),)).fetchall()
|
|
260
|
+
return {r["media_id"] for r in rows}
|
|
261
|
+
|
|
262
|
+
# ---- tracked accounts --------------------------------------------------
|
|
263
|
+
|
|
264
|
+
def track(self, username: str, note: str | None = None) -> None:
|
|
265
|
+
with self._conn() as conn:
|
|
266
|
+
conn.execute(
|
|
267
|
+
"""
|
|
268
|
+
INSERT INTO tracked_accounts (username, added_at, note) VALUES (?, ?, ?)
|
|
269
|
+
ON CONFLICT (username) DO UPDATE SET note = COALESCE(excluded.note, tracked_accounts.note)
|
|
270
|
+
""",
|
|
271
|
+
(username.lower(), datetime.now(UTC).isoformat(timespec="seconds"), note),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def untrack(self, username: str) -> bool:
|
|
275
|
+
with self._conn() as conn:
|
|
276
|
+
return conn.execute("DELETE FROM tracked_accounts WHERE username = ?", (username.lower(),)).rowcount > 0
|
|
277
|
+
|
|
278
|
+
def tracked(self) -> list[dict[str, Any]]:
|
|
279
|
+
with self._conn() as conn:
|
|
280
|
+
rows = conn.execute("SELECT username, added_at, note FROM tracked_accounts ORDER BY username").fetchall()
|
|
281
|
+
return [dict(r) for r in rows]
|
|
282
|
+
|
|
283
|
+
# ---- publications ------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
def save_publication(
|
|
286
|
+
self, creation_id: str, folder: str, caption: str, items: int, media_type: str | None = None
|
|
287
|
+
) -> None:
|
|
288
|
+
with self._conn() as conn:
|
|
289
|
+
conn.execute(
|
|
290
|
+
"""
|
|
291
|
+
INSERT OR REPLACE INTO publications (creation_id, folder, caption, items, status, prepared_at, media_type)
|
|
292
|
+
VALUES (?, ?, ?, ?, 'prepared', ?, ?)
|
|
293
|
+
""",
|
|
294
|
+
(creation_id, folder, caption, items, datetime.now(UTC).isoformat(timespec="seconds"), media_type),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def update_publication(self, creation_id: str, **fields: Any) -> None:
|
|
298
|
+
if fields.get("status") == "published":
|
|
299
|
+
fields["published_at"] = datetime.now(UTC).isoformat(timespec="seconds")
|
|
300
|
+
cols = ", ".join(f"{k} = ?" for k in fields)
|
|
301
|
+
with self._conn() as conn:
|
|
302
|
+
conn.execute(f"UPDATE publications SET {cols} WHERE creation_id = ?", (*fields.values(), creation_id))
|
|
303
|
+
|
|
304
|
+
def get_publication(self, creation_id: str) -> dict[str, Any] | None:
|
|
305
|
+
with self._conn() as conn:
|
|
306
|
+
row = conn.execute("SELECT * FROM publications WHERE creation_id = ?", (creation_id,)).fetchone()
|
|
307
|
+
return dict(row) if row else None
|
|
308
|
+
|
|
309
|
+
def publications(self, limit: int = 20) -> list[dict[str, Any]]:
|
|
310
|
+
with self._conn() as conn:
|
|
311
|
+
rows = conn.execute(
|
|
312
|
+
"SELECT creation_id, folder, media_type, items, status, media_id, permalink, prepared_at, published_at, error "
|
|
313
|
+
"FROM publications ORDER BY prepared_at DESC LIMIT ?",
|
|
314
|
+
(limit,),
|
|
315
|
+
).fetchall()
|
|
316
|
+
return [dict(r) for r in rows]
|
|
317
|
+
|
|
318
|
+
# ---- publishing queue --------------------------------------------------
|
|
319
|
+
|
|
320
|
+
def queue_add(self, folder: str, publish_at: str, fingerprint: str, media_type: str, caption_head: str) -> int:
|
|
321
|
+
now = datetime.now(UTC).isoformat(timespec="seconds")
|
|
322
|
+
with self._conn() as conn:
|
|
323
|
+
cur = conn.execute(
|
|
324
|
+
"INSERT INTO post_queue (folder, publish_at, fingerprint, media_type, caption_head, status, approved_at,"
|
|
325
|
+
" updated_at) VALUES (?, ?, ?, ?, ?, 'queued', ?, ?)",
|
|
326
|
+
(folder, publish_at, fingerprint, media_type, caption_head, now, now),
|
|
327
|
+
)
|
|
328
|
+
return int(cur.lastrowid)
|
|
329
|
+
|
|
330
|
+
def queue_get(self, qid: int) -> dict[str, Any] | None:
|
|
331
|
+
with self._conn() as conn:
|
|
332
|
+
row = conn.execute("SELECT * FROM post_queue WHERE id = ?", (qid,)).fetchone()
|
|
333
|
+
return dict(row) if row else None
|
|
334
|
+
|
|
335
|
+
def queue_update(self, qid: int, **fields: Any) -> None:
|
|
336
|
+
fields["updated_at"] = datetime.now(UTC).isoformat(timespec="seconds")
|
|
337
|
+
cols = ", ".join(f"{k} = ?" for k in fields)
|
|
338
|
+
with self._conn() as conn:
|
|
339
|
+
conn.execute(f"UPDATE post_queue SET {cols} WHERE id = ?", (*fields.values(), qid))
|
|
340
|
+
|
|
341
|
+
def queue_due(self, now_iso: str) -> list[dict[str, Any]]:
|
|
342
|
+
with self._conn() as conn:
|
|
343
|
+
rows = conn.execute(
|
|
344
|
+
"SELECT * FROM post_queue WHERE status = 'queued' AND publish_at <= ? ORDER BY publish_at", (now_iso,)
|
|
345
|
+
).fetchall()
|
|
346
|
+
return [dict(r) for r in rows]
|
|
347
|
+
|
|
348
|
+
def queue_list(self, include_done: bool = False, limit: int = 50) -> list[dict[str, Any]]:
|
|
349
|
+
where = "" if include_done else "WHERE status IN ('queued', 'publishing')"
|
|
350
|
+
with self._conn() as conn:
|
|
351
|
+
rows = conn.execute(
|
|
352
|
+
f"SELECT * FROM post_queue {where} ORDER BY publish_at DESC LIMIT ?", (limit,)
|
|
353
|
+
).fetchall()
|
|
354
|
+
return [dict(r) for r in rows]
|
|
355
|
+
|
|
356
|
+
def queue_active_for(self, folder: str) -> dict[str, Any] | None:
|
|
357
|
+
with self._conn() as conn:
|
|
358
|
+
row = conn.execute(
|
|
359
|
+
"SELECT * FROM post_queue WHERE folder = ? AND status IN ('queued', 'publishing')", (folder,)
|
|
360
|
+
).fetchone()
|
|
361
|
+
return dict(row) if row else None
|
|
362
|
+
|
|
363
|
+
# ---- comments ----------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
def comment_seen(self, comment_id: str) -> dict[str, Any] | None:
|
|
366
|
+
with self._conn() as conn:
|
|
367
|
+
row = conn.execute("SELECT * FROM comments_seen WHERE comment_id = ?", (comment_id,)).fetchone()
|
|
368
|
+
return dict(row) if row else None
|
|
369
|
+
|
|
370
|
+
def comment_mark(self, comment_id: str, media_id: str | None, reply_id: str | None = None) -> None:
|
|
371
|
+
now = datetime.now(UTC).isoformat(timespec="seconds")
|
|
372
|
+
with self._conn() as conn:
|
|
373
|
+
conn.execute(
|
|
374
|
+
"INSERT INTO comments_seen (comment_id, media_id, seen_at) VALUES (?, ?, ?) "
|
|
375
|
+
"ON CONFLICT(comment_id) DO UPDATE SET media_id = COALESCE(excluded.media_id, media_id)",
|
|
376
|
+
(comment_id, media_id, now),
|
|
377
|
+
)
|
|
378
|
+
if reply_id:
|
|
379
|
+
conn.execute(
|
|
380
|
+
"UPDATE comments_seen SET replied_at = ?, reply_id = ? WHERE comment_id = ?",
|
|
381
|
+
(now, reply_id, comment_id),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
# ---- post reviews ------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
def review_get(self, media_id: str, stage: str) -> dict[str, Any] | None:
|
|
387
|
+
with self._conn() as conn:
|
|
388
|
+
row = conn.execute(
|
|
389
|
+
"SELECT * FROM post_reviews WHERE media_id = ? AND stage = ?", (media_id, stage)
|
|
390
|
+
).fetchone()
|
|
391
|
+
return dict(row) if row else None
|
|
392
|
+
|
|
393
|
+
def review_save(self, media_id: str, stage: str, metrics: dict[str, Any]) -> None:
|
|
394
|
+
with self._conn() as conn:
|
|
395
|
+
conn.execute(
|
|
396
|
+
"INSERT OR REPLACE INTO post_reviews (media_id, stage, reported_at, metrics) VALUES (?, ?, ?, ?)",
|
|
397
|
+
(media_id, stage, datetime.now(UTC).isoformat(timespec="seconds"), json.dumps(metrics)),
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
# ---- hashtag quota -----------------------------------------------------
|
|
401
|
+
|
|
402
|
+
def _active_hashtags(self, conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
|
403
|
+
cutoff = (datetime.now(UTC) - HASHTAG_WINDOW).isoformat(timespec="seconds")
|
|
404
|
+
return conn.execute(
|
|
405
|
+
"SELECT hashtag, hashtag_id, first_queried_at FROM hashtag_queries WHERE first_queried_at >= ?",
|
|
406
|
+
(cutoff,),
|
|
407
|
+
).fetchall()
|
|
408
|
+
|
|
409
|
+
def hashtag_quota(self) -> dict[str, Any]:
|
|
410
|
+
with self._conn() as conn:
|
|
411
|
+
active = self._active_hashtags(conn)
|
|
412
|
+
used = sorted(active, key=lambda r: r["first_queried_at"])
|
|
413
|
+
return {
|
|
414
|
+
"limit": HASHTAG_LIMIT,
|
|
415
|
+
"used": len(used),
|
|
416
|
+
"remaining": max(HASHTAG_LIMIT - len(used), 0),
|
|
417
|
+
"hashtags": [
|
|
418
|
+
{
|
|
419
|
+
"hashtag": r["hashtag"],
|
|
420
|
+
"frees_up_at": (datetime.fromisoformat(r["first_queried_at"]) + HASHTAG_WINDOW).isoformat(
|
|
421
|
+
timespec="minutes"
|
|
422
|
+
),
|
|
423
|
+
}
|
|
424
|
+
for r in used
|
|
425
|
+
],
|
|
426
|
+
"note": "Local estimate: counts only queries made through this server.",
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
def plan_hashtags(self, hashtags: list[str]) -> dict[str, Any]:
|
|
430
|
+
"""Dry run: which tags are free (already in their 7-day window) and which cost a new slot."""
|
|
431
|
+
quota = self.hashtag_quota()
|
|
432
|
+
active = {h["hashtag"] for h in quota["hashtags"]}
|
|
433
|
+
tags = list(dict.fromkeys(t.lstrip("#").strip().lower() for t in hashtags if t.strip("# ")))
|
|
434
|
+
free = [t for t in tags if t in active]
|
|
435
|
+
new = [t for t in tags if t not in active]
|
|
436
|
+
fits = len(new) <= quota["remaining"]
|
|
437
|
+
return {
|
|
438
|
+
"free_already_queried": [f"#{t}" for t in free],
|
|
439
|
+
"new_slots_needed": [f"#{t}" for t in new],
|
|
440
|
+
"remaining_before": quota["remaining"],
|
|
441
|
+
"remaining_after": max(quota["remaining"] - len(new), 0),
|
|
442
|
+
"fits_in_quota": fits,
|
|
443
|
+
"would_be_skipped": [f"#{t}" for t in new[quota["remaining"]:]] if not fits else [],
|
|
444
|
+
"next_slot_frees_up_at": quota["hashtags"][0]["frees_up_at"] if quota["hashtags"] else None,
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
def known_hashtag_id(self, hashtag: str) -> str | None:
|
|
448
|
+
"""Hashtag ID if the tag is inside its current 7-day window (re-query costs no quota)."""
|
|
449
|
+
with self._conn() as conn:
|
|
450
|
+
for row in self._active_hashtags(conn):
|
|
451
|
+
if row["hashtag"] == hashtag:
|
|
452
|
+
return row["hashtag_id"]
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
def can_query_new_hashtag(self) -> bool:
|
|
456
|
+
with self._conn() as conn:
|
|
457
|
+
return len(self._active_hashtags(conn)) < HASHTAG_LIMIT
|
|
458
|
+
|
|
459
|
+
def register_hashtag(self, hashtag: str, hashtag_id: str) -> None:
|
|
460
|
+
with self._conn() as conn:
|
|
461
|
+
conn.execute(
|
|
462
|
+
"INSERT OR REPLACE INTO hashtag_queries (hashtag, hashtag_id, first_queried_at) VALUES (?, ?, ?)",
|
|
463
|
+
(hashtag, hashtag_id, datetime.now(UTC).isoformat(timespec="seconds")),
|
|
464
|
+
)
|