gitcast 1.0.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.
- ai/__init__.py +0 -0
- ai/formatter.py +59 -0
- ai/generator.py +604 -0
- ai/prompts.py +197 -0
- ai/viral_patterns.py +75 -0
- api/__init__.py +0 -0
- api/analytics.py +48 -0
- api/auth.py +49 -0
- api/auth_middleware.py +129 -0
- api/auth_routes.py +117 -0
- api/monitoring.py +56 -0
- api/payload.py +253 -0
- api/ratelimit.py +9 -0
- api/routes.py +1565 -0
- api/server.py +162 -0
- api/validators.py +101 -0
- assets/__init__.py +1 -0
- assets/favicon-16x16.png +0 -0
- assets/favicon-32x32.png +0 -0
- assets/favicon-64x64.png +0 -0
- assets/favicon.ico +0 -0
- assets/icon.png +0 -0
- cli/.env.example +26 -0
- cli/__init__.py +1 -0
- cli/gitcast.py +79 -0
- config/__init__.py +0 -0
- config/settings.py +213 -0
- core/__init__.py +0 -0
- core/capture.py +258 -0
- core/codebase_reader.py +90 -0
- core/framing.py +86 -0
- core/hotkey.py +21 -0
- core/log_stream.py +50 -0
- core/ocr.py +173 -0
- core/screenshot_session.py +274 -0
- core/security.py +126 -0
- core/tray.py +54 -0
- gitcast-1.0.0.dist-info/LICENSE +21 -0
- gitcast-1.0.0.dist-info/METADATA +67 -0
- gitcast-1.0.0.dist-info/RECORD +61 -0
- gitcast-1.0.0.dist-info/WHEEL +5 -0
- gitcast-1.0.0.dist-info/entry_points.txt +2 -0
- gitcast-1.0.0.dist-info/top_level.txt +10 -0
- publisher/__init__.py +0 -0
- publisher/clipboard.py +44 -0
- publisher/twitter.py +100 -0
- storage/__init__.py +0 -0
- storage/cleanup.py +60 -0
- storage/engagement.py +114 -0
- storage/insights.py +203 -0
- storage/key_manager.py +45 -0
- storage/logger.py +208 -0
- storage/metrics.py +119 -0
- storage/sprint.py +40 -0
- storage/streak.py +0 -0
- storage/supabase_client.py +25 -0
- storage/tone_memory.py +139 -0
- ui/__init__.py +0 -0
- web/__init__.py +1 -0
- web/index.html +4994 -0
- web/landing.html +925 -0
publisher/twitter.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tweepy
|
|
3
|
+
from config.settings import (
|
|
4
|
+
TWITTER_API_KEY,
|
|
5
|
+
TWITTER_API_SECRET,
|
|
6
|
+
TWITTER_ACCESS_TOKEN,
|
|
7
|
+
TWITTER_ACCESS_SECRET,
|
|
8
|
+
TWITTER_BEARER_TOKEN,
|
|
9
|
+
)
|
|
10
|
+
from publisher.clipboard import open_x_compose
|
|
11
|
+
from api.analytics import track
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_configured() -> bool:
|
|
15
|
+
"""Return True only if all 5 Twitter API keys are present and non-empty."""
|
|
16
|
+
return all([
|
|
17
|
+
TWITTER_API_KEY,
|
|
18
|
+
TWITTER_API_SECRET,
|
|
19
|
+
TWITTER_ACCESS_TOKEN,
|
|
20
|
+
TWITTER_ACCESS_SECRET,
|
|
21
|
+
TWITTER_BEARER_TOKEN,
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def upload_media(screenshot_path: str) -> str:
|
|
26
|
+
"""Upload an image via tweepy v1.1 API and return the media_id string."""
|
|
27
|
+
try:
|
|
28
|
+
auth = tweepy.OAuth1UserHandler(
|
|
29
|
+
TWITTER_API_KEY,
|
|
30
|
+
TWITTER_API_SECRET,
|
|
31
|
+
TWITTER_ACCESS_TOKEN,
|
|
32
|
+
TWITTER_ACCESS_SECRET,
|
|
33
|
+
)
|
|
34
|
+
api = tweepy.API(auth)
|
|
35
|
+
media = api.media_upload(filename=screenshot_path)
|
|
36
|
+
media_id = str(media.media_id)
|
|
37
|
+
print(f"[Publisher] Media uploaded — media_id: {media_id}")
|
|
38
|
+
return media_id
|
|
39
|
+
except Exception as e:
|
|
40
|
+
print(f"[Publisher] Media upload failed: {e}")
|
|
41
|
+
raise
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def publish_post(post_text: str, screenshot_path: str = None) -> dict:
|
|
45
|
+
"""
|
|
46
|
+
Publish a post to X (Twitter) via API v2.
|
|
47
|
+
Falls back to clipboard if keys are missing or on any error.
|
|
48
|
+
"""
|
|
49
|
+
if not is_configured():
|
|
50
|
+
print("[Publisher] Twitter API not configured — falling back to clipboard.")
|
|
51
|
+
result = open_x_compose(post_text)
|
|
52
|
+
result["fallback"] = True
|
|
53
|
+
track("post_published", {"platform": "twitter", "used_fallback": True})
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
client = tweepy.Client(
|
|
58
|
+
bearer_token=TWITTER_BEARER_TOKEN,
|
|
59
|
+
consumer_key=TWITTER_API_KEY,
|
|
60
|
+
consumer_secret=TWITTER_API_SECRET,
|
|
61
|
+
access_token=TWITTER_ACCESS_TOKEN,
|
|
62
|
+
access_token_secret=TWITTER_ACCESS_SECRET,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
media_ids = None
|
|
66
|
+
if screenshot_path and os.path.exists(screenshot_path):
|
|
67
|
+
try:
|
|
68
|
+
media_id = upload_media(screenshot_path)
|
|
69
|
+
media_ids = [media_id]
|
|
70
|
+
except Exception as e:
|
|
71
|
+
print(f"[Publisher] Skipping media attachment: {e}")
|
|
72
|
+
|
|
73
|
+
response = client.create_tweet(text=post_text, media_ids=media_ids)
|
|
74
|
+
tweet_id = str(response.data["id"])
|
|
75
|
+
tweet_url = f"https://twitter.com/i/web/status/{tweet_id}"
|
|
76
|
+
|
|
77
|
+
print(f"[Publisher] Tweet posted — {tweet_url}")
|
|
78
|
+
track("post_published", {"platform": "twitter", "used_fallback": False})
|
|
79
|
+
return {
|
|
80
|
+
"success": True,
|
|
81
|
+
"tweet_url": tweet_url,
|
|
82
|
+
"tweet_id": tweet_id,
|
|
83
|
+
"fallback": False,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
except Exception as e:
|
|
87
|
+
print(f"[Publisher] Twitter API error: {e}")
|
|
88
|
+
print("[Publisher] Falling back to clipboard.")
|
|
89
|
+
result = open_x_compose(post_text)
|
|
90
|
+
result["fallback"] = True
|
|
91
|
+
track("post_published", {"platform": "twitter", "used_fallback": True})
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
print(f"[Publisher] Twitter configured: {is_configured()}")
|
|
97
|
+
if is_configured():
|
|
98
|
+
print("[Publisher] All 5 API keys are present.")
|
|
99
|
+
else:
|
|
100
|
+
print("[Publisher] Missing one or more Twitter API keys — clipboard fallback will be used.")
|
storage/__init__.py
ADDED
|
File without changes
|
storage/cleanup.py
ADDED
|
@@ -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()
|
storage/engagement.py
ADDED
|
@@ -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
|
storage/insights.py
ADDED
|
@@ -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")
|
storage/key_manager.py
ADDED
|
@@ -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))}")
|