#!/usr/bin/env python3 """ Probe route-explorer.com end-to-end from outside our iOS app. Tests, in order: 1. Plain requests.get('/api/token') with browser-shaped headers. 2. Homepage → cookies → retry /api/token (same session). 3. cloudscraper (Cloudflare-aware) if installed. 4. playwright headless Chromium → load homepage → accept cookies → click Retry → wait for /api/token to return 200, capture cookies, re-issue /api/token from a plain requests session using those cookies. 5. If we ever land a token: call /api/flight-search for DAL→HOU today and dump the flight numbers + times. 6. Verify public Vercel blob data (the catalog path). The point: prove or disprove that *anything* outside Safari-with-history can reach /api/flight-search, and if it can, what it took. Usage: python3 probe_route_explorer.py """ from __future__ import annotations import json import sys import time from datetime import date BASE = "https://route-explorer.com" BLOB = "https://g80l6xxwjkrjoai7.public.blob.vercel-storage.com" HEADERS_SAFARI_IPHONE = { "User-Agent": ( "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) " "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 " "Mobile/15E148 Safari/604.1" ), "Accept": "application/json", "Accept-Language": "en-US,en;q=0.9", "Origin": BASE, "Referer": BASE + "/", } def line(s=""): print(s, flush=True) def section(title: str): line() line("=" * 72) line(f" {title}") line("=" * 72) # --------------------------------------------------------------------------- def test_plain_requests(): section("1. Plain requests with browser-shaped headers") import requests r = requests.get(f"{BASE}/api/token", headers=HEADERS_SAFARI_IPHONE, timeout=15) line(f" /api/token → HTTP {r.status_code}") line(f" body: {r.text[:300]}") line(f" set-cookies: {[c.name for c in r.cookies]}") return r def test_session_homepage_first(): section("2. requests.Session: homepage → cookies → retry /api/token") import requests s = requests.Session() s.headers.update(HEADERS_SAFARI_IPHONE) r1 = s.get(BASE + "/", timeout=15) line(f" GET / → HTTP {r1.status_code} cookies: {[c.name for c in s.cookies]}") r2 = s.get(f"{BASE}/api/token", timeout=15) line(f" GET /api/token→ HTTP {r2.status_code} body: {r2.text[:200]}") line(f" cookies after: {[c.name for c in s.cookies]}") return s, r2 def test_cloudscraper(): section("3. cloudscraper (if installed)") try: import cloudscraper # type: ignore except ImportError: line(" cloudscraper NOT installed. (pip install cloudscraper)") return None s = cloudscraper.create_scraper() r = s.get(f"{BASE}/api/token", timeout=30) line(f" /api/token → HTTP {r.status_code}") line(f" body: {r.text[:300]}") line(f" cookies: {[c.name for c in s.cookies]}") return s if r.status_code == 200 else None def test_playwright(headless: bool = True, label: str = "headless"): section(f"4. Playwright Chromium ({label}) — full clearance dance") try: from playwright.sync_api import sync_playwright # type: ignore except ImportError: line(" playwright NOT installed. (pip install playwright && playwright install chromium)") return None with sync_playwright() as p: # In headed mode, use the full chromium build, not the headless shell. if headless: browser = p.chromium.launch(headless=True) else: browser = p.chromium.launch(headless=False, args=["--disable-blink-features=AutomationControlled"]) ctx = browser.new_context( user_agent=HEADERS_SAFARI_IPHONE["User-Agent"], ) page = ctx.new_page() status_codes: list[tuple[str, int]] = [] page.on("response", lambda r: ( status_codes.append((r.url, r.status)) if "/api/" in r.url and BASE in r.url else None )) line(" goto homepage…") page.goto(BASE + "/", wait_until="domcontentloaded", timeout=30000) # accept cookies page.evaluate("""() => { for (const b of document.querySelectorAll('button')) { if (/accept|agree|allow/i.test((b.innerText||'').trim())) b.click(); } }""") line(" accepted cookie banner") # tap Retry repeatedly + wait for clearance cleared = False for tick in range(1, 31): page.wait_for_timeout(1000) page.evaluate("""() => { for (const b of document.querySelectorAll('button')) { if (/retry/i.test((b.innerText||'').trim())) b.click(); } }""") try: status = page.evaluate("""async () => { try { const r = await fetch('/api/token', { credentials: 'include' }); return r.status; } catch (e) { return -1; } }""") except Exception as e: status = -1 cookie_names = sorted(c["name"] for c in ctx.cookies()) line(f" t+{tick:2d}s /api/token→{status} cookies={cookie_names}") if status == 200: cleared = True break cookies = ctx.cookies() ua = ctx._impl_obj._initializer.get("userAgent") # type: ignore line(f" final cleared={cleared} cookies={[c['name'] for c in cookies]}") browser.close() if cleared: # Build a plain requests session pre-loaded with the cookies and # test whether /api/token survives outside the browser context. import requests s = requests.Session() s.headers.update(HEADERS_SAFARI_IPHONE) for c in cookies: s.cookies.set(c["name"], c["value"], domain=c["domain"], path=c["path"]) r = s.get(f"{BASE}/api/token", timeout=15) line(f" REPLAY via requests with captured cookies → HTTP {r.status_code}") line(f" body: {r.text[:200]}") if r.status_code == 200: token = r.json().get("token") line(f" TOKEN MINTED: {token[:24]}…") return s, token return None def test_undetected_chromedriver(): section("4b. undetected-chromedriver (Cloudflare-aware Selenium)") try: import undetected_chromedriver as uc # type: ignore except ImportError: line(" undetected-chromedriver NOT installed.") return None opts = uc.ChromeOptions() opts.add_argument("--headless=new") driver = uc.Chrome(options=opts, version_main=None) try: driver.get(BASE + "/") time.sleep(2) # accept cookies driver.execute_script(""" for (const b of document.querySelectorAll('button')) { if (/accept|agree|allow/i.test((b.innerText||'').trim())) b.click(); } """) cleared = False for tick in range(1, 31): time.sleep(1) try: status = driver.execute_script(""" return new Promise((res) => { fetch('/api/token', { credentials: 'include' }) .then(r => res(r.status)) .catch(() => res(-1)); }); """) except Exception: status = -1 cookies = sorted(c["name"] for c in driver.get_cookies()) line(f" t+{tick:2d}s /api/token→{status} cookies={cookies}") if status == 200: cleared = True break result = None if cleared: import requests s = requests.Session() s.headers.update(HEADERS_SAFARI_IPHONE) for c in driver.get_cookies(): s.cookies.set(c["name"], c["value"], domain=c["domain"], path=c["path"]) r = s.get(f"{BASE}/api/token", timeout=15) line(f" REPLAY via requests → HTTP {r.status_code} body: {r.text[:200]}") if r.status_code == 200: result = (s, r.json().get("token")) return result finally: driver.quit() def test_flight_search(session, token): section("5. /api/flight-search for DAL→HOU today") if not session or not token: line(" no session/token → skipped") return today = date.today().isoformat() body = { "endpoint": "/route", "body": { "json": { "departureAirportIata": "DAL", "arrivalAirportIata": "HOU", "departureDates": [today], "maxStops": 0, "limit": 50, "includeAppendix": True, } } } import requests r = session.post( f"{BASE}/api/flight-search", headers={**HEADERS_SAFARI_IPHONE, "Content-Type": "application/json", "X-API-Token": token}, json=body, timeout=20, ) line(f" /api/flight-search → HTTP {r.status_code}") if r.status_code != 200: line(f" body: {r.text[:400]}") return data = r.json() conns = data.get("json", {}).get("connections", []) line(f" → {len(conns)} connections") for c in conns[:8]: for f in c.get("flights", []): line(f" {f.get('carrierIata')}{f.get('flightNumber')} " f"{f.get('departure',{}).get('airportIata')}@" f"{f.get('departure',{}).get('dateTime')} → " f"{f.get('arrival',{}).get('airportIata')}@" f"{f.get('arrival',{}).get('dateTime')} " f"({f.get('equipmentIata')})") def test_blob_catalog(): section("6. Public Vercel blob — no auth, raw route catalog") import requests urls = [ "/data/airports-with-routes.json", "/data/airlines.json", "/data/routes/DAL.json", ] for u in urls: r = requests.get(BLOB + u, timeout=15) line(f" GET {u} → HTTP {r.status_code} size={len(r.content):,}B") # sample DAL→HOU from blob dal = requests.get(BLOB + "/data/routes/DAL.json", timeout=15).json() hou = [r for r in dal["routes"] if r["dest"] == "HOU"] line(f" DAL→HOU in blob: {hou[0] if hou else ''}") # --------------------------------------------------------------------------- def main(): sess = None token = None test_plain_requests() test_session_homepage_first() if r := test_cloudscraper(): sess, token = r, None # cloudscraper currently won't carry token, see below if not (sess and token): if result := test_playwright(headless=True, label="headless"): sess, token = result if not (sess and token): if result := test_undetected_chromedriver(): sess, token = result if not (sess and token): line() line(">>> headless approaches all failed. Trying HEADED Chromium...") line(">>> (window will appear on your screen)") if result := test_playwright(headless=False, label="HEADED"): sess, token = result if sess and token: test_flight_search(sess, token) else: line() line("No path produced a token — /api/flight-search step skipped.") test_blob_catalog() section("CONCLUSION") if sess and token: line(f" Reached /api/flight-search with status 200. The data IS reachable") line(f" programmatically — Playwright-with-real-Chromium passes the gate.") line(f" Path forward: small backend that mints tokens this way and serves") line(f" the iOS app, or pin the captured cookie into the app's WKWebView.") else: line(" No request shape outside real Safari managed to mint a token.") line(" The gate categorically rejects URLSession + WKWebView + headless") line(" Chromium without sticky cumulative session state.") line() line(" But blob catalog data IS public — browse-style UX is achievable") line(" without any auth.") if __name__ == "__main__": main()