#!/usr/bin/env python3 """ Update Municipality Channel List Searches for municipal government YouTube channels and adds them to the database. Usage: # Search for channels in specific states python scripts/localview/update_municipality_list.py --states AL,GA,IN,MA,MT,WA,WI # Search for specific municipalities python scripts/localview/update_municipality_list.py --cities "Birmingham,Boston,Seattle" # Verify existing channels python scripts/localview/update_municipality_list.py --verify """ import argparse import sys from pathlib import Path from typing import List, Optional import time sys.path.insert(0, str(Path(__file__).parent.parent.parent)) from loguru import logger import polars as pl from googleapiclient.discovery import build from googleapiclient.errors import HttpError import os from dotenv import load_dotenv # Load environment variables load_dotenv() # Configure logger logger.add(sys.stderr, format="{time} {level} {message}", level="INFO") class ChannelDiscovery: """Discover and verify municipal YouTube channels""" def __init__(self, api_key: Optional[str] = None): self.api_key = api_key or os.getenv('YOUTUBE_API_KEY') if not self.api_key: raise ValueError("YOUTUBE_API_KEY environment variable required") self.youtube = build('youtube', 'v3', developerKey=self.api_key) self.cache_dir = Path("data/cache/localview") self.channels_file = self.cache_dir / "municipality_channels.csv" def search_channel(self, municipality: str, state: str) -> List[dict]: """ Search for a municipal government YouTube channel Args: municipality: City/town name state: Two-letter state code Returns: List of potential channel matches """ search_queries = [ f"{municipality} {state} government", f"{municipality} {state} city council", f"{municipality} {state} meetings", f"{municipality} city government" ] candidates = [] for query in search_queries: try: search_response = self.youtube.search().list( q=query, type='channel', part='id,snippet', maxResults=5 ).execute() for item in search_response.get('items', []): channel_id = item['id']['channelId'] snippet = item['snippet'] # Check if it looks like an official government channel title_lower = snippet['title'].lower() desc_lower = snippet.get('description', '').lower() is_government = any(word in title_lower or word in desc_lower for word in ['government', 'city', 'official', 'council']) if is_government: candidates.append({ 'channel_id': channel_id, 'title': snippet['title'], 'description': snippet.get('description', ''), 'search_query': query }) time.sleep(0.5) # Rate limiting except HttpError as e: logger.error(f"Search error for {query}: {e}") # Deduplicate by channel_id seen = set() unique_candidates = [] for candidate in candidates: if candidate['channel_id'] not in seen: seen.add(candidate['channel_id']) unique_candidates.append(candidate) return unique_candidates def verify_channel(self, channel_id: str) -> Optional[dict]: """Verify a channel exists and get stats""" try: response = self.youtube.channels().list( id=channel_id, part='snippet,statistics,contentDetails' ).execute() if not response.get('items'): return None item = response['items'][0] snippet = item['snippet'] stats = item['statistics'] return { 'channel_id': channel_id, 'title': snippet['title'], 'description': snippet.get('description', ''), 'subscriber_count': int(stats.get('subscriberCount', 0)), 'video_count': int(stats.get('videoCount', 0)), 'view_count': int(stats.get('viewCount', 0)) } except HttpError as e: logger.error(f"Error verifying channel {channel_id}: {e}") return None def load_channels(self) -> pl.DataFrame: """Load existing channel list""" if self.channels_file.exists(): return pl.read_csv(self.channels_file) else: return pl.DataFrame({ 'municipality': [], 'channel_id': [], 'state': [], 'population': [], 'added_date': [] }) def add_channel( self, municipality: str, channel_id: str, state: str, population: Optional[int] = None ): """Add a new channel to the list""" from datetime import datetime channels_df = self.load_channels() # Check if already exists if len(channels_df) > 0: existing = channels_df.filter(pl.col('channel_id') == channel_id) if len(existing) > 0: logger.warning(f"Channel {channel_id} already exists") return # Add new row new_row = pl.DataFrame({ 'municipality': [municipality], 'channel_id': [channel_id], 'state': [state], 'population': [population or 0], 'added_date': [datetime.now().strftime('%Y-%m-%d')] }) if len(channels_df) > 0: channels_df = pl.concat([channels_df, new_row]) else: channels_df = new_row # Save channels_df.write_csv(self.channels_file) logger.success(f"āœ… Added {municipality} ({channel_id})") def discover_channels( self, states: Optional[List[str]] = None, cities: Optional[List[str]] = None ): """Discover channels interactively""" # Load list of major municipalities by state # (In production, load from census data or municipality database) if cities: # Search specific cities for city in cities: state = "Unknown" # Would need to lookup logger.info(f"\nšŸ” Searching for {city}...") candidates = self.search_channel(city, state) if candidates: logger.info(f" Found {len(candidates)} candidates:") for i, candidate in enumerate(candidates, 1): logger.info(f" {i}. {candidate['title']}") logger.info(f" ID: {candidate['channel_id']}") else: logger.warning(f" No channels found for {city}") else: logger.info("No specific cities provided") logger.info("Use --cities flag to search for channels") def main(): parser = argparse.ArgumentParser( description="Update municipal YouTube channel list" ) parser.add_argument( '--states', type=str, help='Comma-separated state codes' ) parser.add_argument( '--cities', type=str, help='Comma-separated city names to search' ) parser.add_argument( '--verify', action='store_true', help='Verify existing channels' ) parser.add_argument( '--add', nargs=3, metavar=('MUNICIPALITY', 'CHANNEL_ID', 'STATE'), help='Manually add a channel' ) args = parser.parse_args() # Initialize discovery logger.info("=" * 80) logger.info("MUNICIPALITY CHANNEL DISCOVERY") logger.info("=" * 80) try: discovery = ChannelDiscovery() except ValueError as e: logger.error(str(e)) logger.info("\nSet YOUTUBE_API_KEY in .env file") sys.exit(1) # Manual add if args.add: municipality, channel_id, state = args.add discovery.add_channel(municipality, channel_id, state) return # Verify existing if args.verify: channels_df = discovery.load_channels() logger.info(f"\nāœ… Verifying {len(channels_df)} channels...") for row in channels_df.iter_rows(named=True): channel_id = row['channel_id'] info = discovery.verify_channel(channel_id) if info: logger.info(f" {row['municipality']}: {info['video_count']} videos") else: logger.warning(f" {row['municipality']}: Channel not found") time.sleep(0.5) return # Search for new channels cities = None if args.cities: cities = [c.strip() for c in args.cities.split(',')] states = None if args.states: states = [s.strip().upper() for s in args.states.split(',')] discovery.discover_channels(states=states, cities=cities) if __name__ == "__main__": main()