|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Basic Memory Daily Traction Report - Enhanced with Growth Tracking |
| 4 | +Automated tracking across GitHub, Reddit, YouTube with daily change indicators |
| 5 | +""" |
| 6 | + |
| 7 | +import os |
| 8 | +import requests |
| 9 | +import json |
| 10 | +from datetime import datetime, timedelta |
| 11 | +import praw |
| 12 | +from googleapiclient.discovery import build |
| 13 | +from dateutil import parser |
| 14 | +import base64 |
| 15 | + |
| 16 | +class BasicMemoryTracker: |
| 17 | + def __init__(self): |
| 18 | + self.github_token = os.getenv('GITHUB_TOKEN') |
| 19 | + self.discord_webhook = os.getenv('DISCORD_WEBHOOK') |
| 20 | + self.youtube_api_key = os.getenv('YOUTUBE_API_KEY') |
| 21 | + |
| 22 | + # Reddit setup |
| 23 | + self.reddit = praw.Reddit( |
| 24 | + client_id=os.getenv('REDDIT_CLIENT_ID'), |
| 25 | + client_secret=os.getenv('REDDIT_SECRET'), |
| 26 | + user_agent='BasicMemoryTracker:v1.0' |
| 27 | + ) |
| 28 | + |
| 29 | + # YouTube setup |
| 30 | + self.youtube = build('youtube', 'v3', developerKey=self.youtube_api_key) |
| 31 | + |
| 32 | + self.repo_owner = 'basicmachines-co' |
| 33 | + self.repo_name = 'basic-memory' |
| 34 | + self.youtube_channel = 'basicmachines-co' |
| 35 | + self.metrics_file = 'data/daily_metrics.json' |
| 36 | + |
| 37 | + def get_previous_metrics(self): |
| 38 | + """Get yesterday's metrics from GitHub repo storage""" |
| 39 | + try: |
| 40 | + headers = {'Authorization': f'token {self.github_token}'} |
| 41 | + url = f'https://api.github.com/repos/{self.repo_owner}/{self.repo_name}/contents/{self.metrics_file}' |
| 42 | + response = requests.get(url, headers=headers) |
| 43 | + |
| 44 | + if response.status_code == 200: |
| 45 | + file_data = response.json() |
| 46 | + content = base64.b64decode(file_data['content']).decode('utf-8') |
| 47 | + return json.loads(content) |
| 48 | + else: |
| 49 | + print("📝 No previous metrics found - this is the first run!") |
| 50 | + return {} |
| 51 | + except Exception as e: |
| 52 | + print(f"⚠️ Could not load previous metrics: {e}") |
| 53 | + return {} |
| 54 | + |
| 55 | + def save_current_metrics(self, metrics): |
| 56 | + """Save today's metrics to GitHub repo for tomorrow's comparison""" |
| 57 | + try: |
| 58 | + headers = {'Authorization': f'token {self.github_token}'} |
| 59 | + |
| 60 | + # Prepare data |
| 61 | + metrics_data = { |
| 62 | + 'date': datetime.now().isoformat(), |
| 63 | + 'metrics': metrics |
| 64 | + } |
| 65 | + content = json.dumps(metrics_data, indent=2) |
| 66 | + encoded_content = base64.b64encode(content.encode()).decode() |
| 67 | + |
| 68 | + # Check if file exists |
| 69 | + file_url = f'https://api.github.com/repos/{self.repo_owner}/{self.repo_name}/contents/{self.metrics_file}' |
| 70 | + existing_response = requests.get(file_url, headers=headers) |
| 71 | + |
| 72 | + payload = { |
| 73 | + 'message': f'📊 Daily metrics update - {datetime.now().strftime("%Y-%m-%d")}', |
| 74 | + 'content': encoded_content |
| 75 | + } |
| 76 | + |
| 77 | + if existing_response.status_code == 200: |
| 78 | + # File exists, update it |
| 79 | + payload['sha'] = existing_response.json()['sha'] |
| 80 | + response = requests.put(file_url, headers=headers, json=payload) |
| 81 | + else: |
| 82 | + # File doesn't exist, create it |
| 83 | + response = requests.put(file_url, headers=headers, json=payload) |
| 84 | + |
| 85 | + if response.status_code in [200, 201]: |
| 86 | + print("✅ Metrics saved for tomorrow's comparison!") |
| 87 | + else: |
| 88 | + print(f"⚠️ Failed to save metrics: {response.status_code}") |
| 89 | + |
| 90 | + except Exception as e: |
| 91 | + print(f"⚠️ Error saving metrics: {e}") |
| 92 | + |
| 93 | + def calculate_change(self, current, previous, key): |
| 94 | + """Calculate the change between current and previous values""" |
| 95 | + if not previous or key not in previous: |
| 96 | + return 0, "🆕" |
| 97 | + |
| 98 | + change = current - previous[key] |
| 99 | + if change > 0: |
| 100 | + return change, "📈" |
| 101 | + elif change < 0: |
| 102 | + return abs(change), "📉" |
| 103 | + else: |
| 104 | + return 0, "➡️" |
| 105 | + |
| 106 | + def format_change(self, change, direction): |
| 107 | + """Format the change indicator for display""" |
| 108 | + if direction == "🆕": |
| 109 | + return "🆕" |
| 110 | + elif direction == "📈": |
| 111 | + return f"(+{change})" |
| 112 | + elif direction == "📉": |
| 113 | + return f"(-{change})" |
| 114 | + else: |
| 115 | + return "(±0)" |
| 116 | + |
| 117 | + def get_github_metrics(self): |
| 118 | + """Get GitHub repository metrics""" |
| 119 | + try: |
| 120 | + headers = {'Authorization': f'token {self.github_token}'} |
| 121 | + |
| 122 | + # Repository stats |
| 123 | + repo_url = f'https://api.github.com/repos/{self.repo_owner}/{self.repo_name}' |
| 124 | + repo_response = requests.get(repo_url, headers=headers) |
| 125 | + repo_data = repo_response.json() |
| 126 | + |
| 127 | + # Traffic stats (requires push access) |
| 128 | + traffic_url = f'https://api.github.com/repos/{self.repo_owner}/{self.repo_name}/traffic/views' |
| 129 | + traffic_response = requests.get(traffic_url, headers=headers) |
| 130 | + traffic_data = traffic_response.json() if traffic_response.status_code == 200 else {} |
| 131 | + |
| 132 | + # Recent issues |
| 133 | + issues_url = f'https://api.github.com/repos/{self.repo_owner}/{self.repo_name}/issues' |
| 134 | + issues_response = requests.get(issues_url, headers=headers) |
| 135 | + issues_data = issues_response.json() if issues_response.status_code == 200 else [] |
| 136 | + |
| 137 | + return { |
| 138 | + 'stars': repo_data.get('stargazers_count', 0), |
| 139 | + 'forks': repo_data.get('forks_count', 0), |
| 140 | + 'watchers': repo_data.get('watchers_count', 0), |
| 141 | + 'open_issues': repo_data.get('open_issues_count', 0), |
| 142 | + 'traffic_views': traffic_data.get('count', 0), |
| 143 | + 'traffic_unique': traffic_data.get('uniques', 0), |
| 144 | + 'recent_issues': len([i for i in issues_data if |
| 145 | + parser.parse(i['created_at']).date() >= (datetime.now() - timedelta(days=1)).date()]) |
| 146 | + } |
| 147 | + except Exception as e: |
| 148 | + print(f"GitHub API error: {e}") |
| 149 | + return {'error': str(e)} |
| 150 | + |
| 151 | + def get_reddit_metrics(self): |
| 152 | + """Get Reddit metrics for Basic Memory mentions""" |
| 153 | + try: |
| 154 | + metrics = { |
| 155 | + 'total_mentions': 0, |
| 156 | + 'subreddit_members': 0, |
| 157 | + 'top_posts': [], |
| 158 | + 'hot_discussions': [] |
| 159 | + } |
| 160 | + |
| 161 | + # Search for Basic Memory mentions |
| 162 | + search_results = list(self.reddit.subreddit('all').search( |
| 163 | + 'Basic Memory', time_filter='day', limit=25 |
| 164 | + )) |
| 165 | + metrics['total_mentions'] = len(search_results) |
| 166 | + |
| 167 | + # Get top posts |
| 168 | + for post in search_results[:3]: |
| 169 | + metrics['top_posts'].append({ |
| 170 | + 'title': post.title[:50] + '...' if len(post.title) > 50 else post.title, |
| 171 | + 'score': post.score, |
| 172 | + 'subreddit': post.subreddit.display_name, |
| 173 | + 'num_comments': post.num_comments |
| 174 | + }) |
| 175 | + |
| 176 | + # Check r/BasicMemory if it exists |
| 177 | + try: |
| 178 | + basic_memory_sub = self.reddit.subreddit('BasicMemory') |
| 179 | + metrics['subreddit_members'] = basic_memory_sub.subscribers |
| 180 | + except: |
| 181 | + metrics['subreddit_members'] = 0 |
| 182 | + |
| 183 | + return metrics |
| 184 | + except Exception as e: |
| 185 | + print(f"Reddit API error: {e}") |
| 186 | + return {'error': str(e)} |
| 187 | + |
| 188 | + def get_youtube_metrics(self): |
| 189 | + """Get YouTube channel metrics""" |
| 190 | + try: |
| 191 | + # Get channel statistics |
| 192 | + channel_response = self.youtube.channels().list( |
| 193 | + part='statistics,snippet', |
| 194 | + forUsername=self.youtube_channel |
| 195 | + ).execute() |
| 196 | + |
| 197 | + if not channel_response['items']: |
| 198 | + # Try by channel handle |
| 199 | + search_response = self.youtube.search().list( |
| 200 | + part='snippet', |
| 201 | + q=f'@{self.youtube_channel}', |
| 202 | + type='channel', |
| 203 | + maxResults=1 |
| 204 | + ).execute() |
| 205 | + |
| 206 | + if search_response['items']: |
| 207 | + channel_id = search_response['items'][0]['snippet']['channelId'] |
| 208 | + channel_response = self.youtube.channels().list( |
| 209 | + part='statistics,snippet', |
| 210 | + id=channel_id |
| 211 | + ).execute() |
| 212 | + |
| 213 | + if channel_response['items']: |
| 214 | + stats = channel_response['items'][0]['statistics'] |
| 215 | + return { |
| 216 | + 'subscribers': int(stats.get('subscriberCount', 0)), |
| 217 | + 'total_views': int(stats.get('viewCount', 0)), |
| 218 | + 'video_count': int(stats.get('videoCount', 0)) |
| 219 | + } |
| 220 | + else: |
| 221 | + return {'error': 'Channel not found'} |
| 222 | + |
| 223 | + except Exception as e: |
| 224 | + print(f"YouTube API error: {e}") |
| 225 | + return {'error': str(e)} |
| 226 | + |
| 227 | + def create_discord_embed(self, current_metrics, previous_metrics): |
| 228 | + """Create beautiful Discord embed with all metrics and growth indicators""" |
| 229 | + |
| 230 | + github_data = current_metrics.get('github', {}) |
| 231 | + reddit_data = current_metrics.get('reddit', {}) |
| 232 | + youtube_data = current_metrics.get('youtube', {}) |
| 233 | + |
| 234 | + prev_github = previous_metrics.get('github', {}) |
| 235 | + prev_reddit = previous_metrics.get('reddit', {}) |
| 236 | + prev_youtube = previous_metrics.get('youtube', {}) |
| 237 | + |
| 238 | + # Calculate changes |
| 239 | + star_change, star_dir = self.calculate_change(github_data.get('stars', 0), prev_github, 'stars') |
| 240 | + sub_change, sub_dir = self.calculate_change(youtube_data.get('subscribers', 0), prev_youtube, 'subscribers') |
| 241 | + view_change, view_dir = self.calculate_change(youtube_data.get('total_views', 0), prev_youtube, 'total_views') |
| 242 | + reddit_change, reddit_dir = self.calculate_change(reddit_data.get('total_mentions', 0), prev_reddit, 'total_mentions') |
| 243 | + member_change, member_dir = self.calculate_change(reddit_data.get('subreddit_members', 0), prev_reddit, 'subreddit_members') |
| 244 | + |
| 245 | + # Calculate total reach |
| 246 | + total_reach = ( |
| 247 | + github_data.get('traffic_unique', 0) + |
| 248 | + reddit_data.get('total_mentions', 0) * 100 + |
| 249 | + youtube_data.get('total_views', 0) |
| 250 | + ) |
| 251 | + |
| 252 | + embed = { |
| 253 | + "title": "🚀 Basic Memory Daily Traction Report", |
| 254 | + "description": f"📅 {datetime.now().strftime('%A, %B %d, %Y')}", |
| 255 | + "color": 0x00ff88, |
| 256 | + "fields": [ |
| 257 | + { |
| 258 | + "name": "⭐ GitHub Metrics", |
| 259 | + "value": f""" |
| 260 | +**Stars:** {github_data.get('stars', 'N/A')} {star_dir} {self.format_change(star_change, star_dir)} |
| 261 | +**Forks:** {github_data.get('forks', 'N/A')} 🍴 |
| 262 | +**Traffic:** {github_data.get('traffic_unique', 'N/A')} unique visitors 👀 |
| 263 | +**Issues:** {github_data.get('recent_issues', 0)} new today 🐛 |
| 264 | + """.strip(), |
| 265 | + "inline": True |
| 266 | + }, |
| 267 | + { |
| 268 | + "name": "🗨️ Reddit Activity", |
| 269 | + "value": f""" |
| 270 | +**Mentions:** {reddit_data.get('total_mentions', 'N/A')} {reddit_dir} {self.format_change(reddit_change, reddit_dir)} |
| 271 | +**r/BasicMemory:** {reddit_data.get('subreddit_members', 'N/A')} {member_dir} {self.format_change(member_change, member_dir)} |
| 272 | +**Hot Posts:** {len(reddit_data.get('top_posts', []))} trending 🔥 |
| 273 | + """.strip(), |
| 274 | + "inline": True |
| 275 | + }, |
| 276 | + { |
| 277 | + "name": "📺 YouTube Stats", |
| 278 | + "value": f""" |
| 279 | +**Subscribers:** {youtube_data.get('subscribers', 'N/A')} {sub_dir} {self.format_change(sub_change, sub_dir)} |
| 280 | +**Total Views:** {youtube_data.get('total_views', 'N/A'):,} {view_dir} {self.format_change(view_change, view_dir)} |
| 281 | +**Videos:** {youtube_data.get('video_count', 'N/A')} 🎬 |
| 282 | + """.strip(), |
| 283 | + "inline": True |
| 284 | + } |
| 285 | + ], |
| 286 | + "footer": { |
| 287 | + "text": f"🤖 Automated by Basic Memory • Daily Reach: {total_reach:,}" |
| 288 | + }, |
| 289 | + "timestamp": datetime.now().isoformat() |
| 290 | + } |
| 291 | + |
| 292 | + # Add top Reddit posts if available |
| 293 | + if reddit_data.get('top_posts'): |
| 294 | + top_post = reddit_data['top_posts'][0] |
| 295 | + embed["fields"].append({ |
| 296 | + "name": "🔥 Top Reddit Post", |
| 297 | + "value": f"**{top_post['title']}**\n📊 {top_post['score']} upvotes • 💬 {top_post['num_comments']} comments\n📍 r/{top_post['subreddit']}", |
| 298 | + "inline": False |
| 299 | + }) |
| 300 | + |
| 301 | + return embed |
| 302 | + |
| 303 | + def send_discord_report(self, embed): |
| 304 | + """Send the report to Discord""" |
| 305 | + try: |
| 306 | + payload = {"embeds": [embed]} |
| 307 | + response = requests.post(self.discord_webhook, json=payload) |
| 308 | + |
| 309 | + if response.status_code == 204: |
| 310 | + print("✅ Discord report sent successfully!") |
| 311 | + return True |
| 312 | + else: |
| 313 | + print(f"❌ Discord webhook failed: {response.status_code}") |
| 314 | + print(response.text) |
| 315 | + return False |
| 316 | + |
| 317 | + except Exception as e: |
| 318 | + print(f"Discord send error: {e}") |
| 319 | + return False |
| 320 | + |
| 321 | + def run_daily_report(self): |
| 322 | + """Main function to generate and send daily report""" |
| 323 | + print("🚀 Starting Basic Memory Daily Traction Report...") |
| 324 | + |
| 325 | + # Load previous metrics |
| 326 | + print("📊 Loading previous metrics...") |
| 327 | + previous_metrics = self.get_previous_metrics() |
| 328 | + |
| 329 | + # Collect current metrics |
| 330 | + print("📊 Collecting GitHub metrics...") |
| 331 | + github_data = self.get_github_metrics() |
| 332 | + |
| 333 | + print("🗨️ Collecting Reddit metrics...") |
| 334 | + reddit_data = self.get_reddit_metrics() |
| 335 | + |
| 336 | + print("📺 Collecting YouTube metrics...") |
| 337 | + youtube_data = self.get_youtube_metrics() |
| 338 | + |
| 339 | + # Combine current metrics |
| 340 | + current_metrics = { |
| 341 | + 'github': github_data, |
| 342 | + 'reddit': reddit_data, |
| 343 | + 'youtube': youtube_data |
| 344 | + } |
| 345 | + |
| 346 | + # Create and send report |
| 347 | + print("🎨 Creating Discord embed with growth tracking...") |
| 348 | + embed = self.create_discord_embed(current_metrics, previous_metrics.get('metrics', {})) |
| 349 | + |
| 350 | + print("📤 Sending to Discord...") |
| 351 | + success = self.send_discord_report(embed) |
| 352 | + |
| 353 | + # Save current metrics for tomorrow |
| 354 | + print("💾 Saving metrics for tomorrow's comparison...") |
| 355 | + self.save_current_metrics(current_metrics) |
| 356 | + |
| 357 | + if success: |
| 358 | + print("🎉 Daily traction report completed successfully!") |
| 359 | + else: |
| 360 | + print("😞 Report failed to send") |
| 361 | + |
| 362 | + # Print summary for GitHub Actions logs |
| 363 | + print(f""" |
| 364 | +📊 DAILY SUMMARY: |
| 365 | +⭐ GitHub Stars: {github_data.get('stars', 'Error')} |
| 366 | +👥 Reddit Mentions: {reddit_data.get('total_mentions', 'Error')} |
| 367 | +📺 YouTube Subscribers: {youtube_data.get('subscribers', 'Error')} |
| 368 | +📺 YouTube Views: {youtube_data.get('total_views', 'Error')} |
| 369 | + """) |
| 370 | + |
| 371 | +if __name__ == "__main__": |
| 372 | + tracker = BasicMemoryTracker() |
| 373 | + tracker.run_daily_report() |
0 commit comments