#!/usr/bin/env python3 """ Flight Airport Comparator CLI Compares direct flight options from multiple airports in a country to a single destination. Supports both single-date queries and seasonal scanning across multiple months. """ import asyncio import time import sys from typing import Optional try: import click except ImportError: print("Error: click library not installed. Install with: pip install click") sys.exit(1) from date_resolver import resolve_dates, resolve_dates_daily, detect_new_connections, SEARCH_WINDOW_MONTHS from airports import resolve_airport_list, download_and_build_airport_data try: from searcher_v3 import search_multiple_routes print("✓ Using fast-flights v3.0rc1 with SOCS cookie integration") except ImportError: try: from searcher import search_multiple_routes print("⚠️ Using legacy searcher (v2.2) - consider upgrading to v3.0rc1") except ImportError: print("✗ No searcher module found!") sys.exit(1) from formatter import format_table_single_date, format_table_seasonal, format_json, format_csv from progress import SearchProgress @click.command() @click.option('--to', 'destination', help='Destination airport IATA code (e.g., JFK)') @click.option('--to-country', 'destination_country', help='Destination country ISO code for reverse search (e.g., DE, US)') @click.option('--country', help='Origin country ISO code (e.g., DE, US)') @click.option('--date', help='Departure date YYYY-MM-DD. Omit for seasonal scan.') @click.option('--window', default=SEARCH_WINDOW_MONTHS, type=int, help=f'Months to scan in seasonal mode (default: {SEARCH_WINDOW_MONTHS})') @click.option('--daily-scan', is_flag=True, help='Scan every day (Mon-Sun) instead of just the 15th of each month') @click.option('--start-date', help='Start date for daily scan (YYYY-MM-DD). Default: tomorrow') @click.option('--end-date', help='End date for daily scan (YYYY-MM-DD). Default: start + window months') @click.option('--seat', default='economy', type=click.Choice(['economy', 'premium', 'business', 'first']), help='Cabin class') @click.option('--adults', default=1, type=int, help='Number of passengers') @click.option('--sort', default='price', type=click.Choice(['price', 'duration']), help='Sort order') @click.option('--from', 'from_airports', help='Comma-separated IATA codes (overrides --country)') @click.option('--top', default=3, type=int, help='Max results per airport') @click.option('--output', default='table', type=click.Choice(['table', 'json', 'csv']), help='Output format') @click.option('--workers', default=5, type=int, help='Concurrency level') @click.option('--cache-threshold', default=24, type=int, help='Cache validity in hours (default: 24)') @click.option('--no-cache', is_flag=True, help='Disable cache, force fresh API queries') @click.option('--dry-run', is_flag=True, help='List airports and dates without API calls') def main( destination: Optional[str], destination_country: Optional[str], country: Optional[str], date: Optional[str], window: int, daily_scan: bool, start_date: Optional[str], end_date: Optional[str], seat: str, adults: int, sort: str, from_airports: Optional[str], top: int, output: str, workers: int, cache_threshold: int, no_cache: bool, dry_run: bool, ): """ Flight Airport Comparator - Find the best departure or arrival airport. TWO MODES: 1. NORMAL: Multiple origins → Single destination Compares flights from all airports in a country to one destination 2. REVERSE: Single origin → Multiple destinations Compares flights from one airport to all airports in a country Supports seasonal scanning to discover new routes and price trends. Uses SQLite caching to reduce API calls and avoid rate limiting. SCANNING STRATEGIES: - Single date: --date YYYY-MM-DD (one specific day) - Seasonal: Omit --date (queries 15th of each month for N months) - Daily: --daily-scan (queries EVERY day Mon-Sun for N months) Examples: # NORMAL MODE: Country to single destination python main.py --to JFK --country DE --date 2026-06-15 python main.py --to JFK --from FRA,MUC,BER --date 2026-06-15 # REVERSE MODE: Single airport to country python main.py --from BDS --to-country DE --date 2026-06-15 python main.py --from BDS --to-country DE # Seasonal scan # Seasonal scan (6 months, one day per month) python main.py --to JFK --country DE # Daily scan (every day for 3 months) python main.py --from BDS --to DUS --daily-scan --window 3 # Daily scan with custom date range python main.py --from BDS --to-country DE --daily-scan --start-date 2026-04-01 --end-date 2026-06-30 # Force fresh queries (ignore cache) python main.py --to JFK --country DE --no-cache # Use 48-hour cache threshold python main.py --to JFK --country DE --cache-threshold 48 # Dry run to preview scan scope python main.py --to JFK --country DE --dry-run """ start_time = time.time() # Validate inputs - determine search mode # Mode 1: Normal (many origins → single destination) # Mode 2: Reverse (single origin → many destinations) if destination and destination_country: click.echo("Error: Cannot use both --to and --to-country. Choose one.", err=True) sys.exit(1) if not destination and not destination_country: click.echo("Error: Either --to (single destination) or --to-country (destination country) must be provided", err=True) sys.exit(1) # Determine mode reverse_mode = destination_country is not None if reverse_mode: # Reverse mode: single origin → multiple destinations if not from_airports: click.echo("Error: Reverse mode (--to-country) requires --from with a single airport", err=True) sys.exit(1) if ',' in from_airports: click.echo("Error: Reverse mode requires a single origin airport in --from (no commas)", err=True) sys.exit(1) if country: click.echo("Warning: --country is ignored in reverse mode (using --to-country instead)", err=True) else: # Normal mode: multiple origins → single destination if not country and not from_airports: click.echo("Error: Either --country or --from must be provided for origin airports", err=True) sys.exit(1) # Ensure airport data exists try: download_and_build_airport_data() except Exception as e: click.echo(f"Error building airport data: {e}", err=True) sys.exit(1) # Resolve airport list and routes based on mode if reverse_mode: # Reverse mode: single origin → multiple destinations in country origin = from_airports # Single airport code try: destination_airports = resolve_airport_list(destination_country, None) except ValueError as e: click.echo(f"Error: {e}", err=True) sys.exit(1) airports = destination_airports search_label = f"{origin} → {destination_country}" location_label = destination_country else: # Normal mode: multiple origins → single destination try: origin_airports = resolve_airport_list(country, from_airports) except ValueError as e: click.echo(f"Error: {e}", err=True) sys.exit(1) airports = origin_airports search_label = f"{country or 'Custom'} → {destination}" location_label = country or 'Custom' # Resolve dates if date: # Single date mode - explicit date provided dates = [date] elif daily_scan: # Daily scan mode - query every day in the range dates = resolve_dates_daily(start_date, end_date, window) click.echo(f"Daily scan mode: {len(dates)} days from {dates[0]} to {dates[-1]}") else: # Seasonal mode - query one day per month (default: 15th) dates = resolve_dates(None, window) # Dry run mode - just show what would be scanned if dry_run: click.echo() click.echo(f"Dry run: {search_label}") click.echo(f"Mode: {'REVERSE (one → many)' if reverse_mode else 'NORMAL (many → one)'}") click.echo() click.echo(f"Airports to scan ({len(airports)}):") for airport in airports[:10]: click.echo(f" • {airport['iata']} - {airport['name']} ({airport.get('city', '')})") if len(airports) > 10: click.echo(f" ... and {len(airports) - 10} more") click.echo() click.echo(f"Dates to scan ({len(dates)}):") for d in dates: click.echo(f" • {d}") click.echo() click.echo(f"Total API calls: {len(airports)} airports × {len(dates)} dates = {len(airports) * len(dates)} requests") click.echo(f"Estimated time: ~{(len(airports) * len(dates) * 1.0 / workers):.0f}s at {workers} workers") click.echo() return # Build route list (airport × date combinations) routes = [] if reverse_mode: # Reverse: from single origin to each destination airport for airport in airports: for query_date in dates: routes.append((from_airports, airport['iata'], query_date)) else: # Normal: from each origin airport to single destination for airport in airports: for query_date in dates: routes.append((airport['iata'], destination, query_date)) click.echo() click.echo(f"Searching {len(routes)} routes ({len(airports)} airports × {len(dates)} dates)...") click.echo() # Execute searches (with caching and progress display) use_cache = not no_cache try: with SearchProgress(total_routes=len(routes), show_progress=True) as progress: def progress_callback(origin, dest, date, status, count, error=None): progress.update(origin, dest, date, status, count, error) results = asyncio.run( search_multiple_routes( routes, seat_class=seat, adults=adults, max_workers=workers, cache_threshold_hours=cache_threshold, use_cache=use_cache, progress_callback=progress_callback, ) ) except Exception as e: click.echo(f"Error during search: {e}", err=True) sys.exit(1) elapsed_time = time.time() - start_time # Process results if len(dates) == 1: # Single-date mode single_date = dates[0] # Group by airport results_by_airport = {} if reverse_mode: # In reverse mode, results are keyed by (origin, destination, date) # Group by destination for (origin, dest, query_date), flights in results.items(): if query_date == single_date and flights: if dest not in results_by_airport: results_by_airport[dest] = [] results_by_airport[dest].extend(flights) # Sort and limit each destination's flights for dest in results_by_airport: sorted_flights = sorted( results_by_airport[dest], key=lambda f: f.get('price', 999999) if sort == 'price' else f.get('duration_minutes', 999999) ) results_by_airport[dest] = sorted_flights[:top] else: # Normal mode: group by origin for (origin, dest, query_date), flights in results.items(): if query_date == single_date: if flights: # Only include if there are flights # Take top N flights sorted_flights = sorted( flights, key=lambda f: f.get('price', 999999) if sort == 'price' else f.get('duration_minutes', 999999) ) results_by_airport[origin] = sorted_flights[:top] else: results_by_airport[origin] = [] # Format output if output == 'json': format_json(results_by_airport) elif output == 'csv': format_csv(results_by_airport) else: # table # Determine what to show in the table header if reverse_mode: display_destination = destination_country display_origin = from_airports else: display_destination = destination display_origin = country or 'Custom' format_table_single_date( results_by_airport, display_destination if not reverse_mode else from_airports, display_origin if not reverse_mode else destination_country, single_date, seat, sort, len(airports), elapsed_time, ) else: # Seasonal mode results_by_month = {} if reverse_mode: # In reverse mode, group by destination airport for (origin, dest, query_date), flights in results.items(): month_key = query_date[:7] if month_key not in results_by_month: results_by_month[month_key] = {} if flights: if dest not in results_by_month[month_key]: results_by_month[month_key][dest] = [] results_by_month[month_key][dest].extend(flights) # Sort and limit flights for each destination for month_key in results_by_month: for dest in results_by_month[month_key]: sorted_flights = sorted( results_by_month[month_key][dest], key=lambda f: f.get('price', 999999) ) results_by_month[month_key][dest] = sorted_flights[:top] else: # Normal mode: group by origin for (origin, dest, query_date), flights in results.items(): month_key = query_date[:7] if month_key not in results_by_month: results_by_month[month_key] = {} if flights: sorted_flights = sorted(flights, key=lambda f: f.get('price', 999999)) results_by_month[month_key][origin] = sorted_flights[:top] # Detect new connections # Convert to format expected by detect_new_connections monthly_flights_for_detection = {} for month_key, airports_dict in results_by_month.items(): flights_list = [] for airport_code, flights in airports_dict.items(): for flight in flights: flights_list.append({ 'origin': flight['origin'], 'destination': flight['destination'], }) monthly_flights_for_detection[month_key] = flights_list new_connections = detect_new_connections(monthly_flights_for_detection) # Format output if output == 'json': format_json({ 'results_by_month': results_by_month, 'new_connections': new_connections, }) elif output == 'csv': # Flatten seasonal results for CSV flattened = {} for month_key, airports_dict in results_by_month.items(): for airport_code, flights in airports_dict.items(): key = f"{airport_code}_{month_key}" flattened[key] = flights format_csv(flattened) else: # table # Determine what to show in the table header if reverse_mode: display_destination = destination_country display_origin = from_airports else: display_destination = destination display_origin = country or 'Custom' format_table_seasonal( results_by_month, new_connections, display_destination if not reverse_mode else f"from {from_airports}", display_origin if not reverse_mode else destination_country, seat, len(airports), elapsed_time, ) if __name__ == '__main__': main()