ba0688a412
route-explorer's /api/token sits behind invisible Cloudflare Turnstile
that requires Apple's Private Access Token attestation. Third-party
iOS apps don't qualify for PAT issuance, and Linux Docker containers
can't pass it either (cross-OS fingerprint, even with patchright /
Camoufox). Migrates direct-flight search to FlightAware; multi-stop
and where-can-I-go remain via embedded SFSafariViewController.
- FlightAwareScheduleClient — scrapes route.rvt + trackpoll JSON for
real schedules without auth. T+0..2 day window. Tests against
captured HTML fixtures.
- BlobRouteClient — pulls the public Vercel blob route catalog
route-explorer's frontend reads (no auth, no Turnstile).
- DiagnosticLogger + LoggingURLSessionDelegate + DiagnosticsView —
device-shareable forensic trace. Boot header captures device, OS,
locale, UA; share-sheet export of session logs.
- TurnstileDebugView — live WKWebView gate inspector. Used to prove
the PAT-entitlement gap on a real device.
- RouteExplorerBrowserView — SFSafariViewController wrapper. Real
Safari clears Turnstile naturally; the in-app browser opens at
pre-filled search URLs. Surfaced from Search ("Open in
route-explorer") and Settings → Tools.
- RouteExplorerTokenStore + RouteExplorerSetupView — bookmarklet
capture flow (token round-tripped via flights://routeexplorer-token
URL scheme). Kept dormant for future use.
backend/ — Docker proxy attempts (Playwright, patchright, Camoufox).
All fail on Linux because Cloudflare auto-denies before the Turnstile
widget renders. Documented; kept as scaffolding for a future paid-
solver integration.
scripts/probe_flightaware.py — reference algorithm for the FA path.
scripts/probe_nodriver.py — local-Mac sanity check confirming the
gate clears with real macOS Chrome (proves the blocker is
fingerprint-level, not network-level).
87 lines
3.1 KiB
Python
87 lines
3.1 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
nodriver-based probe — the modern Cloudflare-evading browser library.
|
|
If this can't mint a route-explorer.com token, no programmatic approach can.
|
|
"""
|
|
import asyncio, json
|
|
import nodriver as uc
|
|
|
|
BASE = "https://route-explorer.com"
|
|
|
|
async def main():
|
|
browser = await uc.start(headless=False) # headed = best chance
|
|
tab = await browser.get(BASE + "/")
|
|
print("loaded homepage")
|
|
|
|
# accept cookies
|
|
await tab.evaluate("""
|
|
for (const b of document.querySelectorAll('button')) {
|
|
if (/accept|agree|allow/i.test((b.innerText||'').trim())) b.click();
|
|
}
|
|
""")
|
|
print("accepted cookies (if banner present)")
|
|
|
|
cleared = False
|
|
for tick in range(1, 45):
|
|
await asyncio.sleep(1)
|
|
status = await tab.evaluate("""
|
|
(async () => {
|
|
try {
|
|
const r = await fetch('/api/token', { credentials: 'include' });
|
|
return r.status;
|
|
} catch (e) { return -1; }
|
|
})()
|
|
""", await_promise=True)
|
|
# also try the page's Retry button
|
|
await tab.evaluate("""
|
|
for (const b of document.querySelectorAll('button')) {
|
|
if (/retry/i.test((b.innerText||'').trim())) b.click();
|
|
}
|
|
""")
|
|
cookies = await browser.cookies.get_all()
|
|
cookie_names = sorted(c.name for c in cookies if "route-explorer" in (c.domain or "") or not c.domain)
|
|
print(f"t+{tick:2d}s /api/token→{status} cookies={cookie_names}")
|
|
if status == 200:
|
|
cleared = True
|
|
break
|
|
|
|
if cleared:
|
|
token_body = await tab.evaluate("""
|
|
(async () => {
|
|
const r = await fetch('/api/token', { credentials: 'include' });
|
|
return await r.text();
|
|
})()
|
|
""", await_promise=True)
|
|
print(f"TOKEN BODY: {token_body[:200]}")
|
|
# try flight-search
|
|
result = await tab.evaluate("""
|
|
(async () => {
|
|
const tk = JSON.parse(await (await fetch('/api/token', {credentials:'include'})).text()).token;
|
|
const r = await fetch('/api/flight-search', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json', 'X-API-Token': tk },
|
|
body: JSON.stringify({
|
|
endpoint: '/route',
|
|
body: { json: {
|
|
departureAirportIata: 'DAL',
|
|
arrivalAirportIata: 'HOU',
|
|
departureDates: [new Date().toISOString().substring(0,10)],
|
|
maxStops: 0, limit: 20, includeAppendix: true
|
|
}}
|
|
})
|
|
});
|
|
return JSON.stringify({status: r.status, body: (await r.text()).substring(0, 1000)});
|
|
})()
|
|
""", await_promise=True)
|
|
print(f"flight-search → {result}")
|
|
else:
|
|
print("NEVER CLEARED — nodriver also can't pass Turnstile.")
|
|
|
|
await asyncio.sleep(2)
|
|
browser.stop()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
uc.loop().run_until_complete(main())
|