Created
September 12, 2025 02:46
-
-
Save marcussabramovitch/304772297a8b7bf23923fd202cb3cb04 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| Viewer Minutes Calculation Method: | |
| β’ Fetches video metadata (duration, view count) from YouTube API for given channels | |
| β’ Calculates estimated viewer minutes = views Γ duration_minutes Γ average_watch_fraction | |
| β’ Default average watch fraction is 50% (configurable via --avg-watch parameter) | |
| β’ Sorts videos by estimated viewer minutes in descending order | |
| β’ Outputs results to CSV with individual video stats and channel totals | |
| """ | |
| import argparse | |
| import os | |
| import time | |
| from datetime import datetime, timezone | |
| import pandas as pd | |
| import isodate | |
| from tqdm import tqdm | |
| from dotenv import load_dotenv | |
| # Google API | |
| from googleapiclient.discovery import build | |
| from googleapiclient.errors import HttpError | |
| # Channel mapping dictionary - add your channels here | |
| CHANNEL_MAPPING = { | |
| "robert_miles": "UCLB7AzTwc6VFZrBsO2ucBMg", | |
| # "kurzgesagt": "UCsXVk37bltHxD1rDPwtNM8Q", | |
| # "veritasium": "UCHnyfMqiRRG1u-2MsSQLbXA", | |
| # "3blue1brown": "UCYO_jab_esuFRV4b17AJtAw", | |
| # "numberphile": "UCoxcjq-8xIDTYp3uz647V5A", | |
| "dwarkesh_podcast": "UC8aM4gqH7dFgHQWYX5_7k3Q", | |
| "doom_debates": "UCote8RH_wwSLza2Qb0GAQJw", | |
| "80k": "UCZYTClx2T1of7BRZ86-8fow", | |
| "rational_animations": "UCgqt1RE0k0MIr0LoyJRy2lg", | |
| "ai_species": "UCEENWVBdvDy-QWfuQoXC9HQ", | |
| "ai_in_context": "UCwicfou4Ewu-koikmga-bEg", | |
| "fli": "UC-rCCy3FQ-GItDimSR9lhzw", | |
| "cognitive_revolutions": "UCjNRVMBVI30Sak_p6HRWhIA", | |
| "the_ai_risk_network": "UCTEpIeib92TuKo2cSs4wg8A", | |
| # Add more channels as needed | |
| # "channel_name": "channel_id", | |
| } | |
| def parse_args(): | |
| ap = argparse.ArgumentParser(description="Fetch YouTube videos and estimate viewer-minutes") | |
| ap.add_argument("--channels", type=str, nargs="+", help="Channel names to process (from CHANNEL_MAPPING)") | |
| ap.add_argument("--channel-id", type=str, default=None, help="YouTube channel ID starting with UC... (for single channel)") | |
| ap.add_argument("--list-channels", action="store_true", help="List available channel names and exit") | |
| ap.add_argument("--avg-watch", type=float, default=0.50, help="Assumed average watch fraction (0.50 = 50%)") | |
| ap.add_argument("--out", type=str, default="data/out.csv", help="Output CSV path") | |
| ap.add_argument("--max", type=int, default=None, help="Optional limit of videos to fetch per channel (for testing)") | |
| ap.add_argument("--sleep", type=float, default=0.0, help="Seconds to sleep between API pages (avoid quota spikes)") | |
| return ap.parse_args() | |
| def get_env(): | |
| load_dotenv() | |
| api_key = os.getenv("YOUTUBE_API_KEY") | |
| if not api_key: | |
| raise RuntimeError("Missing YOUTUBE_API_KEY in environment (.env)") | |
| return api_key | |
| def list_available_channels(): | |
| """Print available channel names and their IDs""" | |
| print("Available channels:") | |
| print("-" * 50) | |
| for name, channel_id in CHANNEL_MAPPING.items(): | |
| print(f"{name:20} -> {channel_id}") | |
| print("-" * 50) | |
| print(f"Total: {len(CHANNEL_MAPPING)} channels") | |
| def get_channel_ids(channel_names): | |
| """Convert channel names to channel IDs""" | |
| channel_ids = [] | |
| invalid_channels = [] | |
| for name in channel_names: | |
| if name in CHANNEL_MAPPING: | |
| channel_ids.append(CHANNEL_MAPPING[name]) | |
| else: | |
| invalid_channels.append(name) | |
| if invalid_channels: | |
| print(f"Warning: Unknown channel names: {invalid_channels}") | |
| print("Available channels:") | |
| for name in CHANNEL_MAPPING.keys(): | |
| print(f" - {name}") | |
| return channel_ids | |
| def get_uploads_playlist_id(youtube, channel_id: str) -> str: | |
| resp = youtube.channels().list(part="contentDetails", id=channel_id).execute() | |
| items = resp.get("items", []) | |
| if not items: | |
| raise RuntimeError(f"Channel not found or no contentDetails for {channel_id}") | |
| return items[0]["contentDetails"]["relatedPlaylists"]["uploads"] | |
| def chunked(lst, n): | |
| for i in range(0, len(lst), n): | |
| yield lst[i : i + n] | |
| def fetch_videos(api_key: str, channel_id: str, limit: int | None = None, sleep_s: float = 0.0): | |
| youtube = build("youtube", "v3", developerKey=api_key) | |
| uploads_id = get_uploads_playlist_id(youtube, channel_id) | |
| video_ids = [] | |
| next_token = None | |
| while True: | |
| req = youtube.playlistItems().list( | |
| part="contentDetails", | |
| playlistId=uploads_id, | |
| maxResults=50, | |
| pageToken=next_token, | |
| ) | |
| resp = req.execute() | |
| for item in resp.get("items", []): | |
| video_ids.append(item["contentDetails"]["videoId"]) | |
| next_token = resp.get("nextPageToken") | |
| if limit and len(video_ids) >= limit: | |
| video_ids = video_ids[:limit] | |
| break | |
| if not next_token: | |
| break | |
| if sleep_s > 0: | |
| time.sleep(sleep_s) | |
| results = [] | |
| for batch in tqdm(list(chunked(video_ids, 50)), desc="Fetching video details"): | |
| vreq = build("youtube", "v3", developerKey=api_key).videos().list( | |
| part="snippet,contentDetails,statistics", | |
| id=",".join(batch), | |
| ) | |
| vresp = vreq.execute() | |
| for it in vresp.get("items", []): | |
| vid = it["id"] | |
| sn = it["snippet"] | |
| cd = it["contentDetails"] | |
| st = it.get("statistics", {}) | |
| title = sn.get("title", "") | |
| published_at = sn.get("publishedAt") | |
| try: | |
| duration_iso = cd.get("duration", "PT0S") | |
| duration_sec = isodate.parse_duration(duration_iso).total_seconds() | |
| except Exception: | |
| duration_sec = 0.0 | |
| duration_min = duration_sec / 60.0 if duration_sec else 0.0 | |
| views = int(st.get("viewCount", 0)) | |
| results.append({ | |
| "video_id": vid, | |
| "title": title, | |
| "published_at": published_at, | |
| "duration_min": round(duration_min, 3), | |
| "views": views, | |
| }) | |
| return pd.DataFrame(results) | |
| def compute_viewer_minutes(df: pd.DataFrame, avg_watch: float) -> pd.DataFrame: | |
| df = df.copy() | |
| df["avg_watch_fraction"] = avg_watch | |
| df["est_viewer_minutes"] = (df["views"] * df["duration_min"] * df["avg_watch_fraction"]).round(2) | |
| df.sort_values(["est_viewer_minutes"], ascending=False, inplace=True) | |
| return df | |
| def main(): | |
| args = parse_args() | |
| api_key = get_env() | |
| # Handle --list-channels option | |
| if args.list_channels: | |
| list_available_channels() | |
| return | |
| # Determine which channels to process | |
| if args.channels: | |
| # Process multiple channels by name | |
| channel_ids = get_channel_ids(args.channels) | |
| if not channel_ids: | |
| print("No valid channels specified. Use --list-channels to see available options.") | |
| return | |
| elif args.channel_id: | |
| # Process single channel by ID | |
| channel_ids = [args.channel_id] | |
| else: | |
| # Process all channels from CHANNEL_MAPPING | |
| channel_ids = list(CHANNEL_MAPPING.values()) | |
| print(f"π No specific channels specified. Processing all {len(channel_ids)} channels from CHANNEL_MAPPING.") | |
| all_results = [] | |
| for channel_id in channel_ids: | |
| # Find channel name for display | |
| channel_name = "unknown" | |
| for name, cid in CHANNEL_MAPPING.items(): | |
| if cid == channel_id: | |
| channel_name = name | |
| break | |
| print(f"\nπΊ Processing channel: {channel_name} ({channel_id})") | |
| try: | |
| raw = fetch_videos(api_key=api_key, channel_id=channel_id, limit=args.max, sleep_s=args.sleep) | |
| if not raw.empty: | |
| df = compute_viewer_minutes(raw, args.avg_watch) | |
| df['channel_name'] = channel_name | |
| df['channel_id'] = channel_id | |
| all_results.append(df) | |
| # Print channel summary | |
| total_views = int(df["views"].sum()) | |
| total_vm = float(df["est_viewer_minutes"].sum()) | |
| print(f" β {len(df)} videos | views: {total_views:,} | est. viewer-minutes: {total_vm:,.0f}") | |
| else: | |
| print(f" β οΈ No videos found for channel {channel_name}") | |
| except HttpError as e: | |
| print(f" β YouTube API error for {channel_name}: {e}") | |
| except Exception as e: | |
| print(f" β Error processing {channel_name}: {e}") | |
| if not all_results: | |
| print("No data to save.") | |
| return | |
| # Combine all results | |
| combined_df = pd.concat(all_results, ignore_index=True) | |
| # Sort by estimated viewer minutes | |
| combined_df.sort_values(["est_viewer_minutes"], ascending=False, inplace=True) | |
| # Save results | |
| os.makedirs(os.path.dirname(args.out), exist_ok=True) | |
| combined_df.to_csv(args.out, index=False, encoding="utf-8-sig") | |
| # Print overall summary | |
| total_views = int(combined_df["views"].sum()) | |
| total_vm = float(combined_df["est_viewer_minutes"].sum()) | |
| print(f"\nπ Overall Results:") | |
| print(f"β Wrote {len(combined_df)} videos from {len(all_results)} channels to {args.out}") | |
| print(f"Totals β views: {total_views:,} | est. viewer-minutes (@{args.avg_watch:.0%}): {total_vm:,.0f}") | |
| # Print per-channel summary | |
| print(f"\nπ Per-channel breakdown:") | |
| channel_summary = combined_df.groupby('channel_name').agg({ | |
| 'views': 'sum', | |
| 'est_viewer_minutes': 'sum', | |
| 'video_id': 'count' | |
| }).round(2) | |
| channel_summary.columns = ['Total Views', 'Est. Viewer Minutes', 'Video Count'] | |
| channel_summary = channel_summary.sort_values('Est. Viewer Minutes', ascending=False) | |
| print(channel_summary.to_string()) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment