gitcast 1.0.1__tar.gz → 1.0.2__tar.gz
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.
- {gitcast-1.0.1/gitcast.egg-info → gitcast-1.0.2}/PKG-INFO +1 -1
- {gitcast-1.0.1 → gitcast-1.0.2}/cli/gitcast.py +8 -4
- {gitcast-1.0.1 → gitcast-1.0.2/gitcast.egg-info}/PKG-INFO +1 -1
- {gitcast-1.0.1 → gitcast-1.0.2}/gitcast.egg-info/SOURCES.txt +11 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/gitcast.egg-info/top_level.txt +1 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/setup.py +1 -1
- gitcast-1.0.2/storage/cleanup.py +60 -0
- gitcast-1.0.2/storage/engagement.py +114 -0
- gitcast-1.0.2/storage/insights.py +203 -0
- gitcast-1.0.2/storage/key_manager.py +45 -0
- gitcast-1.0.2/storage/logger.py +208 -0
- gitcast-1.0.2/storage/metrics.py +119 -0
- gitcast-1.0.2/storage/sprint.py +40 -0
- gitcast-1.0.2/storage/streak.py +0 -0
- gitcast-1.0.2/storage/supabase_client.py +25 -0
- gitcast-1.0.2/storage/tone_memory.py +139 -0
- gitcast-1.0.2/ui/__init__.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/.env.example +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/LICENSE +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/MANIFEST.in +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/README.md +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/ai/__init__.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/ai/formatter.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/ai/generator.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/ai/prompts.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/ai/viral_patterns.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/api/__init__.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/api/analytics.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/api/auth.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/api/auth_middleware.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/api/auth_routes.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/api/monitoring.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/api/payload.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/api/ratelimit.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/api/routes.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/api/server.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/api/validators.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/assets/__init__.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/assets/favicon-16x16.png +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/assets/favicon-32x32.png +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/assets/favicon-64x64.png +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/assets/favicon.ico +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/assets/icon.png +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/cli/.env.example +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/cli/__init__.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/config/__init__.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/config/settings.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/core/__init__.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/core/capture.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/core/codebase_reader.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/core/framing.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/core/hotkey.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/core/log_stream.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/core/ocr.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/core/screenshot_session.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/core/security.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/core/tray.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/docs/api-keys.md +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/docs/article-generator.md +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/docs/capture-flow.md +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/docs/changelog.md +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/docs/contributing.md +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/docs/dashboard.md +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/docs/insights.md +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/docs/introduction.md +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/docs/post-formats.md +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/docs/providers.md +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/docs/quickstart.md +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/docs/sprint-mode.md +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/gitcast.egg-info/dependency_links.txt +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/gitcast.egg-info/entry_points.txt +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/gitcast.egg-info/requires.txt +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/publisher/__init__.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/publisher/clipboard.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/publisher/twitter.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/pyproject.toml +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/requirements.txt +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/setup.cfg +0 -0
- {gitcast-1.0.1/ui → gitcast-1.0.2/storage}/__init__.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/tests/test_keys.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/tests/test_ocr_fix.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/web/__init__.py +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/web/index.html +0 -0
- {gitcast-1.0.1 → gitcast-1.0.2}/web/landing.html +0 -0
|
@@ -58,17 +58,21 @@ def main():
|
|
|
58
58
|
for _ in range(10):
|
|
59
59
|
try:
|
|
60
60
|
urllib.request.urlopen(
|
|
61
|
-
"http://
|
|
61
|
+
"http://localhost:8000/health",
|
|
62
62
|
timeout=1)
|
|
63
63
|
break
|
|
64
64
|
except Exception:
|
|
65
65
|
time.sleep(0.5)
|
|
66
66
|
|
|
67
67
|
# open browser
|
|
68
|
-
webbrowser.open("http://
|
|
68
|
+
webbrowser.open("http://localhost:8000")
|
|
69
69
|
|
|
70
70
|
# start tray and hotkey
|
|
71
71
|
from core.tray import run_tray
|
|
72
|
+
import sys as _sys
|
|
73
|
+
import os as _os
|
|
74
|
+
_sys.path.insert(0, _os.path.dirname(
|
|
75
|
+
_os.path.dirname(_os.path.abspath(__file__))))
|
|
72
76
|
from main import on_trigger
|
|
73
77
|
run_tray(trigger_callback=on_trigger)
|
|
74
78
|
return
|
|
@@ -87,14 +91,14 @@ def main():
|
|
|
87
91
|
try:
|
|
88
92
|
# Call the internal trigger endpoint
|
|
89
93
|
response = httpx.post(
|
|
90
|
-
"http://
|
|
94
|
+
"http://localhost:8000/api/cli/trigger",
|
|
91
95
|
json={"thought": thought},
|
|
92
96
|
timeout=30
|
|
93
97
|
)
|
|
94
98
|
|
|
95
99
|
if response.status_code == 200:
|
|
96
100
|
print("[Gitcast] Context captured. Opening Draft Room...")
|
|
97
|
-
webbrowser.open("http://
|
|
101
|
+
webbrowser.open("http://localhost:8000")
|
|
98
102
|
else:
|
|
99
103
|
print(f"[Gitcast Error] Failed to trigger: {response.text}")
|
|
100
104
|
|
|
@@ -63,6 +63,17 @@ gitcast.egg-info/top_level.txt
|
|
|
63
63
|
publisher/__init__.py
|
|
64
64
|
publisher/clipboard.py
|
|
65
65
|
publisher/twitter.py
|
|
66
|
+
storage/__init__.py
|
|
67
|
+
storage/cleanup.py
|
|
68
|
+
storage/engagement.py
|
|
69
|
+
storage/insights.py
|
|
70
|
+
storage/key_manager.py
|
|
71
|
+
storage/logger.py
|
|
72
|
+
storage/metrics.py
|
|
73
|
+
storage/sprint.py
|
|
74
|
+
storage/streak.py
|
|
75
|
+
storage/supabase_client.py
|
|
76
|
+
storage/tone_memory.py
|
|
66
77
|
tests/test_keys.py
|
|
67
78
|
tests/test_ocr_fix.py
|
|
68
79
|
ui/__init__.py
|
|
@@ -19,7 +19,7 @@ if os.path.exists(requirements_path):
|
|
|
19
19
|
|
|
20
20
|
setup(
|
|
21
21
|
name="gitcast",
|
|
22
|
-
version="1.0.
|
|
22
|
+
version="1.0.2",
|
|
23
23
|
description="git diff → published post. under 60 seconds.",
|
|
24
24
|
long_description=long_description,
|
|
25
25
|
long_description_content_type="text/markdown",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from config.settings import STORAGE_DIR, screenshot_retention_hours
|
|
5
|
+
|
|
6
|
+
# [Cleanup] module for managing screenshot retention policy
|
|
7
|
+
|
|
8
|
+
def run_cleanup():
|
|
9
|
+
"""Deletes screenshots older than screenshot_retention_hours."""
|
|
10
|
+
screenshots_dir = STORAGE_DIR / "screenshots"
|
|
11
|
+
if not screenshots_dir.exists():
|
|
12
|
+
print("[Cleanup] No screenshots directory found.")
|
|
13
|
+
return
|
|
14
|
+
|
|
15
|
+
now = time.time()
|
|
16
|
+
retention_sec = screenshot_retention_hours * 3600
|
|
17
|
+
deleted_count = 0
|
|
18
|
+
|
|
19
|
+
for file_path in screenshots_dir.glob("*.png"):
|
|
20
|
+
file_age = now - file_path.stat().st_mtime
|
|
21
|
+
if file_age > retention_sec:
|
|
22
|
+
try:
|
|
23
|
+
# Use secure delete if possible, but for cleanup standard unlink is usually fine
|
|
24
|
+
# unless the user specifically wants secure delete for all old files.
|
|
25
|
+
# Given core/security.py exists, we could use it, but it might be overkill for bulk cleanup.
|
|
26
|
+
file_path.unlink()
|
|
27
|
+
deleted_count += 1
|
|
28
|
+
except Exception as e:
|
|
29
|
+
print(f"[Cleanup] Error deleting {file_path.name}: {e}")
|
|
30
|
+
|
|
31
|
+
if deleted_count > 0:
|
|
32
|
+
print(f"[Cleanup] Deleted {deleted_count} old screenshots.")
|
|
33
|
+
else:
|
|
34
|
+
print("[Cleanup] No old screenshots to delete.")
|
|
35
|
+
|
|
36
|
+
def get_storage_stats():
|
|
37
|
+
"""Returns count of screenshots, total size in MB, and oldest file date."""
|
|
38
|
+
screenshots_dir = STORAGE_DIR / "screenshots"
|
|
39
|
+
if not screenshots_dir.exists():
|
|
40
|
+
return {"count": 0, "total_size_mb": 0.0, "oldest_file": "N/A"}
|
|
41
|
+
|
|
42
|
+
files = list(screenshots_dir.glob("*.png"))
|
|
43
|
+
if not files:
|
|
44
|
+
return {"count": 0, "total_size_mb": 0.0, "oldest_file": "N/A"}
|
|
45
|
+
|
|
46
|
+
total_size = sum(f.stat().st_size for f in files)
|
|
47
|
+
oldest_time = min(f.stat().st_mtime for f in files)
|
|
48
|
+
oldest_date = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(oldest_time))
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
"count": len(files),
|
|
52
|
+
"total_size_mb": round(total_size / (1024 * 1024), 2),
|
|
53
|
+
"oldest_file": oldest_date
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
print("=== STORAGE CLEANUP TEST ===")
|
|
58
|
+
stats = get_storage_stats()
|
|
59
|
+
print(f"Current stats: {stats}")
|
|
60
|
+
run_cleanup()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
import threading
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import tweepy
|
|
6
|
+
from config.settings import ENGAGEMENT_LOG, TWITTER_BEARER_TOKEN, POST_LOG
|
|
7
|
+
from storage.tone_memory import update_engagement
|
|
8
|
+
|
|
9
|
+
# [Engagement] module for tracking X metrics 24h after publish
|
|
10
|
+
|
|
11
|
+
def log_pending_fetch(tweet_id: str, post_text: str):
|
|
12
|
+
"""Saves a tweet ID to ENGAGEMENT_LOG for background processing."""
|
|
13
|
+
entry = {
|
|
14
|
+
"tweet_id": tweet_id,
|
|
15
|
+
"post_text": post_text,
|
|
16
|
+
"publish_timestamp": time.time(),
|
|
17
|
+
"fetched": False
|
|
18
|
+
}
|
|
19
|
+
try:
|
|
20
|
+
with open(ENGAGEMENT_LOG, "a", encoding="utf-8") as f:
|
|
21
|
+
f.write(json.dumps(entry) + "\n")
|
|
22
|
+
print(f"[Engagement] Logged pending fetch for tweet {tweet_id}")
|
|
23
|
+
except Exception as e:
|
|
24
|
+
print(f"[Engagement] Error logging pending fetch: {e}")
|
|
25
|
+
|
|
26
|
+
def get_pending_fetches():
|
|
27
|
+
"""Returns list of entries due for metrics fetch (24h+ old, not fetched)."""
|
|
28
|
+
if not ENGAGEMENT_LOG.exists():
|
|
29
|
+
return []
|
|
30
|
+
|
|
31
|
+
pending = []
|
|
32
|
+
now = time.time()
|
|
33
|
+
try:
|
|
34
|
+
with open(ENGAGEMENT_LOG, "r", encoding="utf-8") as f:
|
|
35
|
+
for line in f:
|
|
36
|
+
if not line.strip(): continue
|
|
37
|
+
entry = json.loads(line)
|
|
38
|
+
# 24 hours = 86400 seconds
|
|
39
|
+
if not entry.get("fetched") and (now - entry["publish_timestamp"] > 86400):
|
|
40
|
+
pending.append(entry)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
print(f"[Engagement] Error reading pending fetches: {e}")
|
|
43
|
+
|
|
44
|
+
return pending
|
|
45
|
+
|
|
46
|
+
def fetch_and_store_metrics(tweet_id: str, post_text: str):
|
|
47
|
+
"""Calls X API v2 to get metrics and updates tone_memory."""
|
|
48
|
+
if not TWITTER_BEARER_TOKEN:
|
|
49
|
+
print("[Engagement] No Twitter Bearer Token found, skipping fetch.")
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
client = tweepy.Client(bearer_token=TWITTER_BEARER_TOKEN)
|
|
54
|
+
response = client.get_tweet(tweet_id, tweet_fields=["public_metrics"])
|
|
55
|
+
|
|
56
|
+
if response.data:
|
|
57
|
+
metrics = response.data.public_metrics
|
|
58
|
+
likes = metrics.get("like_count", 0)
|
|
59
|
+
retweets = metrics.get("retweet_count", 0)
|
|
60
|
+
replies = metrics.get("reply_count", 0)
|
|
61
|
+
|
|
62
|
+
update_engagement(post_text, likes, retweets, replies)
|
|
63
|
+
return True
|
|
64
|
+
else:
|
|
65
|
+
print(f"[Engagement] Could not find tweet {tweet_id}")
|
|
66
|
+
return False
|
|
67
|
+
except Exception as e:
|
|
68
|
+
print(f"[Engagement] X API error for {tweet_id}: {e}")
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
def mark_as_fetched(tweet_id: str):
|
|
72
|
+
"""Updates ENGAGEMENT_LOG to mark a tweet as fetched."""
|
|
73
|
+
if not ENGAGEMENT_LOG.exists():
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
entries = []
|
|
77
|
+
try:
|
|
78
|
+
with open(ENGAGEMENT_LOG, "r", encoding="utf-8") as f:
|
|
79
|
+
for line in f:
|
|
80
|
+
if not line.strip(): continue
|
|
81
|
+
entry = json.loads(line)
|
|
82
|
+
if entry["tweet_id"] == tweet_id:
|
|
83
|
+
entry["fetched"] = True
|
|
84
|
+
entries.append(entry)
|
|
85
|
+
|
|
86
|
+
with open(ENGAGEMENT_LOG, "w", encoding="utf-8") as f:
|
|
87
|
+
for e in entries:
|
|
88
|
+
f.write(json.dumps(e) + "\n")
|
|
89
|
+
except Exception as e:
|
|
90
|
+
print(f"[Engagement] Error marking as fetched: {e}")
|
|
91
|
+
|
|
92
|
+
def run_engagement_worker():
|
|
93
|
+
"""Background thread that runs hourly and processes pending fetches."""
|
|
94
|
+
def worker():
|
|
95
|
+
while True:
|
|
96
|
+
print("[Engagement] Checking for pending metrics fetches...")
|
|
97
|
+
pending = get_pending_fetches()
|
|
98
|
+
for entry in pending:
|
|
99
|
+
success = fetch_and_store_metrics(entry["tweet_id"], entry["post_text"])
|
|
100
|
+
if success:
|
|
101
|
+
mark_as_fetched(entry["tweet_id"])
|
|
102
|
+
|
|
103
|
+
# Sleep for 1 hour
|
|
104
|
+
time.sleep(3600)
|
|
105
|
+
|
|
106
|
+
thread = threading.Thread(target=worker, daemon=True)
|
|
107
|
+
thread.start()
|
|
108
|
+
print("[Engagement] Background worker started.")
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
print("=== ENGAGEMENT TRACKING TEST ===")
|
|
112
|
+
log_pending_fetch("123456789", "Test post text")
|
|
113
|
+
print(f"Pending: {get_pending_fetches()}")
|
|
114
|
+
# Note: X API call will fail without real token in test
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
|
|
4
|
+
from storage.logger import get_streak, load_posts
|
|
5
|
+
from storage.metrics import get_all_metrics
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _parse_dt(value: str):
|
|
9
|
+
try:
|
|
10
|
+
return datetime.fromisoformat(value)
|
|
11
|
+
except Exception:
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_metric_post_dt(post_id: str):
|
|
16
|
+
value = str(post_id or "")
|
|
17
|
+
try:
|
|
18
|
+
if len(value) >= 15 and value[8] == "_":
|
|
19
|
+
return datetime.strptime(value[:15], "%Y%m%d_%H%M%S")
|
|
20
|
+
if len(value) >= 19 and value[4] == "-" and "T" in value[:19]:
|
|
21
|
+
return datetime.strptime(value[:19], "%Y-%m-%dT%H:%M:%S")
|
|
22
|
+
except Exception:
|
|
23
|
+
return None
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _avg(values: list) -> float:
|
|
28
|
+
return sum(values) / len(values) if values else 0.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _engagement_rate(metrics: dict) -> float:
|
|
32
|
+
impressions = max(0, int(metrics.get("impressions", 0)))
|
|
33
|
+
if impressions == 0:
|
|
34
|
+
return 0.0
|
|
35
|
+
engagement = int(metrics.get("likes", 0)) + int(metrics.get("comments", 0)) + int(metrics.get("reposts", 0))
|
|
36
|
+
return round((engagement / impressions) * 100, 2)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _bucket_chars(count: int) -> str:
|
|
40
|
+
if count <= 140:
|
|
41
|
+
return "0-140"
|
|
42
|
+
if count <= 200:
|
|
43
|
+
return "141-200"
|
|
44
|
+
if count <= 260:
|
|
45
|
+
return "201-260"
|
|
46
|
+
return "261-280"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _time_window(dt: datetime) -> str:
|
|
50
|
+
hour = dt.hour
|
|
51
|
+
if 6 <= hour < 9:
|
|
52
|
+
return "6am-9am"
|
|
53
|
+
if 9 <= hour < 12:
|
|
54
|
+
return "9am-12pm"
|
|
55
|
+
if 12 <= hour < 15:
|
|
56
|
+
return "12pm-3pm"
|
|
57
|
+
if 15 <= hour < 18:
|
|
58
|
+
return "3pm-6pm"
|
|
59
|
+
if 18 <= hour < 21:
|
|
60
|
+
return "6pm-9pm"
|
|
61
|
+
if 21 <= hour < 23:
|
|
62
|
+
return "9pm-11pm"
|
|
63
|
+
return "late-night"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _format_from_metric_post_id(post_id: str) -> str:
|
|
67
|
+
value = str(post_id or "")
|
|
68
|
+
if "-" not in value:
|
|
69
|
+
return ""
|
|
70
|
+
suffix = value.rsplit("-", 1)[1]
|
|
71
|
+
return suffix if suffix and not suffix[:2].isdigit() else ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _row_from_metric(post_id: str, metrics: dict, post: dict = None) -> dict:
|
|
75
|
+
post = post or {}
|
|
76
|
+
dt = _parse_dt(post.get("timestamp", "")) or _parse_metric_post_dt(post_id) or _parse_dt(metrics.get("measured_at", ""))
|
|
77
|
+
impressions = int(metrics.get("impressions", 0))
|
|
78
|
+
text = post.get("post_text", "")
|
|
79
|
+
return {
|
|
80
|
+
"post_id": post_id,
|
|
81
|
+
"post_text": text,
|
|
82
|
+
"format_key": post.get("format_key", "") or _format_from_metric_post_id(post_id) or metrics.get("platform", ""),
|
|
83
|
+
"timestamp": post.get("timestamp", "") or (dt.isoformat() if dt else metrics.get("measured_at", "")),
|
|
84
|
+
"dt": dt,
|
|
85
|
+
"day": dt.strftime("%A").lower() if dt else "",
|
|
86
|
+
"time_window": _time_window(dt) if dt else "",
|
|
87
|
+
"char_bucket": _bucket_chars(len(text)),
|
|
88
|
+
"impressions": impressions,
|
|
89
|
+
"likes": int(metrics.get("likes", 0)),
|
|
90
|
+
"comments": int(metrics.get("comments", 0)),
|
|
91
|
+
"reposts": int(metrics.get("reposts", 0)),
|
|
92
|
+
"hashtags": metrics.get("hashtags", []),
|
|
93
|
+
"engagement_rate": _engagement_rate(metrics),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _best_group(rows: list, key: str) -> dict:
|
|
98
|
+
groups = defaultdict(list)
|
|
99
|
+
for row in rows:
|
|
100
|
+
if row.get(key):
|
|
101
|
+
groups[row[key]].append(row["impressions"])
|
|
102
|
+
if not groups:
|
|
103
|
+
return {}
|
|
104
|
+
name, values = max(groups.items(), key=lambda item: _avg(item[1]))
|
|
105
|
+
return {"name": name, "avg": round(_avg(values), 1), "sample_size": len(values)}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def calculate_insights(user_id: str) -> dict:
|
|
109
|
+
posts = load_posts(user_id)
|
|
110
|
+
metrics_entries = get_all_metrics(user_id)
|
|
111
|
+
metrics_by_post = {}
|
|
112
|
+
for metric in metrics_entries:
|
|
113
|
+
metrics_by_post[metric.get("post_id")] = metric
|
|
114
|
+
|
|
115
|
+
rows = []
|
|
116
|
+
matched_metric_ids = set()
|
|
117
|
+
for post in posts:
|
|
118
|
+
post_id = post.get("id") or post.get("timestamp")
|
|
119
|
+
metrics = metrics_by_post.get(post_id) or post.get("metrics")
|
|
120
|
+
if not metrics:
|
|
121
|
+
continue
|
|
122
|
+
matched_metric_ids.add(post_id)
|
|
123
|
+
rows.append(_row_from_metric(post_id, metrics, post))
|
|
124
|
+
|
|
125
|
+
for post_id, metrics in metrics_by_post.items():
|
|
126
|
+
if post_id in matched_metric_ids:
|
|
127
|
+
continue
|
|
128
|
+
rows.append(_row_from_metric(post_id, metrics))
|
|
129
|
+
|
|
130
|
+
if len(rows) < 5:
|
|
131
|
+
return {"insufficient_data": True, "posts_needed": 5 - len(rows), "posts_with_metrics": len(rows)}
|
|
132
|
+
|
|
133
|
+
cutoff = datetime.now() - timedelta(days=30)
|
|
134
|
+
recent = [row for row in rows if row["dt"] and row["dt"] >= cutoff] or rows
|
|
135
|
+
streak = get_streak(user_id)
|
|
136
|
+
total_impressions = sum(row["impressions"] for row in recent)
|
|
137
|
+
best_format = _best_group(rows, "format_key")
|
|
138
|
+
best_day = _best_group(rows, "day")
|
|
139
|
+
best_time = _best_group(rows, "time_window")
|
|
140
|
+
top = max(rows, key=lambda row: row["impressions"])
|
|
141
|
+
overall_avg = _avg([row["impressions"] for row in rows])
|
|
142
|
+
|
|
143
|
+
patterns = []
|
|
144
|
+
by_format = defaultdict(list)
|
|
145
|
+
by_chars = defaultdict(list)
|
|
146
|
+
by_day = defaultdict(list)
|
|
147
|
+
tags = defaultdict(list)
|
|
148
|
+
for row in rows:
|
|
149
|
+
by_format[row["format_key"]].append(row["impressions"])
|
|
150
|
+
by_chars[row["char_bucket"]].append(row["impressions"])
|
|
151
|
+
by_day[row["day"]].append(row["impressions"])
|
|
152
|
+
for tag in row["hashtags"]:
|
|
153
|
+
tags[tag].append(row["impressions"])
|
|
154
|
+
|
|
155
|
+
if len(by_format) >= 2:
|
|
156
|
+
ordered = sorted(by_format.items(), key=lambda item: _avg(item[1]), reverse=True)
|
|
157
|
+
high, low = ordered[0], ordered[-1]
|
|
158
|
+
if _avg(low[1]) > 0:
|
|
159
|
+
patterns.append({
|
|
160
|
+
"pattern": "format",
|
|
161
|
+
"description": f"{high[0].upper()} format gets {round(_avg(high[1]) / _avg(low[1]), 1)}x more impressions than {low[0].upper()}",
|
|
162
|
+
"impact": "high",
|
|
163
|
+
})
|
|
164
|
+
if by_chars:
|
|
165
|
+
bucket, values = max(by_chars.items(), key=lambda item: _avg(item[1]))
|
|
166
|
+
lift = round(((_avg(values) - overall_avg) / overall_avg) * 100, 0) if overall_avg else 0
|
|
167
|
+
patterns.append({"pattern": "length", "description": f"posts in the {bucket} char bucket get {lift}% more engagement", "impact": "medium"})
|
|
168
|
+
if by_day:
|
|
169
|
+
day, values = max(by_day.items(), key=lambda item: _avg(item[1]))
|
|
170
|
+
multiple = round(_avg(values) / overall_avg, 1) if overall_avg else 0
|
|
171
|
+
patterns.append({"pattern": "day", "description": f"{day} posts get {multiple}x your average", "impact": "medium"})
|
|
172
|
+
|
|
173
|
+
hashtag_performance = [
|
|
174
|
+
{"hashtag": tag, "avg_impressions": round(_avg(values), 1), "uses": len(values)}
|
|
175
|
+
for tag, values in sorted(tags.items(), key=lambda item: _avg(item[1]), reverse=True)
|
|
176
|
+
]
|
|
177
|
+
for tag in hashtag_performance[:3]:
|
|
178
|
+
patterns.append({
|
|
179
|
+
"pattern": "hashtag",
|
|
180
|
+
"description": f"{tag['hashtag']} adds avg {int(tag['avg_impressions'])} impressions",
|
|
181
|
+
"impact": "medium",
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
"overview": {
|
|
186
|
+
"total_posts": len(recent),
|
|
187
|
+
"total_verified": len([post for post in posts if post.get("posted_verified")]),
|
|
188
|
+
"total_impressions": total_impressions,
|
|
189
|
+
"avg_engagement_rate": round(_avg([row["engagement_rate"] for row in recent]), 2),
|
|
190
|
+
"posting_streak": streak.get("current_streak", 0),
|
|
191
|
+
"best_streak": streak.get("best_streak", streak.get("current_streak", 0)),
|
|
192
|
+
},
|
|
193
|
+
"best_format": {"format_key": best_format.get("name", ""), "avg_impressions": best_format.get("avg", 0.0), "sample_size": best_format.get("sample_size", 0)},
|
|
194
|
+
"best_day": {"day": best_day.get("name", ""), "avg_impressions": best_day.get("avg", 0.0)},
|
|
195
|
+
"best_time": {"window": best_time.get("name", ""), "avg_impressions": best_time.get("avg", 0.0)},
|
|
196
|
+
"top_post": {key: top[key] for key in ["post_text", "impressions", "likes", "format_key", "timestamp"]},
|
|
197
|
+
"viral_patterns": patterns[:8],
|
|
198
|
+
"hashtag_performance": hashtag_performance[:20],
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
print("[Insights] Supabase insights module loaded")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
2
|
+
|
|
3
|
+
from config.settings import ENCRYPTION_KEY_PATH
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _load_or_create_fernet_key() -> bytes:
|
|
7
|
+
ENCRYPTION_KEY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
8
|
+
if ENCRYPTION_KEY_PATH.exists():
|
|
9
|
+
key = ENCRYPTION_KEY_PATH.read_bytes().strip()
|
|
10
|
+
if key:
|
|
11
|
+
return key
|
|
12
|
+
|
|
13
|
+
key = Fernet.generate_key()
|
|
14
|
+
ENCRYPTION_KEY_PATH.write_bytes(key)
|
|
15
|
+
return key
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _fernet() -> Fernet:
|
|
19
|
+
return Fernet(_load_or_create_fernet_key())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def encrypt_key(raw_key: str) -> str:
|
|
23
|
+
if not raw_key:
|
|
24
|
+
raise ValueError("raw_key is required")
|
|
25
|
+
return _fernet().encrypt(raw_key.encode("utf-8")).decode("utf-8")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def decrypt_key(encrypted_key: str) -> str:
|
|
29
|
+
if not encrypted_key:
|
|
30
|
+
raise ValueError("encrypted_key is required")
|
|
31
|
+
try:
|
|
32
|
+
return _fernet().decrypt(encrypted_key.encode("utf-8")).decode("utf-8")
|
|
33
|
+
except InvalidToken as exc:
|
|
34
|
+
raise ValueError("Invalid encrypted key") from exc
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def mask_key(raw_key: str) -> str:
|
|
38
|
+
suffix = (raw_key or "")[-3:]
|
|
39
|
+
return f"••••••••...{suffix}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
sample = "test_api_key_abc"
|
|
44
|
+
encrypted = encrypt_key(sample)
|
|
45
|
+
print(f"[KeyManager] Mask: {mask_key(decrypt_key(encrypted))}")
|