Route RouteExplorer through WKWebView to bypass URLSession block
User reported the Search tab is 403'ing on the deployed app — visible on their phone, but route-explorer.com itself loads in their phone's Safari. So the gating differentiates between Safari/WKWebView and URLSession: same network, same UA strings, different verdict. Most likely TLS-fingerprint-based. Solution: route both /api/token and /api/flight-search through the existing WebViewFetcher service (originally built for the same purpose against an Akamai-protected airline API). XHRs run from inside a WKWebView navigated to route-explorer.com, so the request hits the edge with Safari's TLS fingerprint and any first-party cookies the gate expects. Touched: - RouteExplorerClient.currentToken — now goes via WKWebView XHR - callFlightSearch — same, with one retry on token rotation - searchSchedule — same path - New fetchViaWebView helper takes (method, apiPath, headers, body) and returns the response body string Trade-offs: - Each call now starts with a WKWebView navigation (~2s). Token is cached for 30 min so this hits once per session for most usage. - Searches are slower than before. Can pool the WKWebView later if needed; for now correctness > speed. - Per-call WKWebView allocation runs on MainActor (forced by WebViewFetcher's @MainActor isolation). Awaiting from the actor is fine — the bridge is automatic. If route-explorer relaxes the gate later we can switch back to URLSession by reverting this commit; the URLSession code path was preserved up to deletion. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -112,23 +112,17 @@ actor RouteExplorerClient {
|
||||
]
|
||||
do {
|
||||
let token = try await currentToken()
|
||||
let url = baseURL.appendingPathComponent("api/flight-search")
|
||||
let body = try JSONSerialization.data(withJSONObject: [
|
||||
"endpoint": "/schedule",
|
||||
"body": ["json": payload]
|
||||
])
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
Self.applyBrowserHeaders(to: &req)
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
req.setValue(token, forHTTPHeaderField: "X-API-Token")
|
||||
req.httpBody = body
|
||||
|
||||
let (data, response) = try await session.data(for: req)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
guard (200...299).contains(status) else { return [] }
|
||||
|
||||
let respStr = try await fetchViaWebView(
|
||||
method: "POST",
|
||||
apiPath: "/api/flight-search",
|
||||
extraHeaders: ["X-API-Token": token],
|
||||
requestBody: body
|
||||
)
|
||||
guard let data = respStr.data(using: .utf8) else { return [] }
|
||||
let decoded = try JSONDecoder.routeExplorer().decode(
|
||||
RouteExplorerScheduleResponse.self, from: data
|
||||
)
|
||||
@@ -163,21 +157,22 @@ actor RouteExplorerClient {
|
||||
if let cached = cachedToken, cached.expiresAt > Date() {
|
||||
return cached.value
|
||||
}
|
||||
let url = baseURL.appendingPathComponent("api/token")
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "GET"
|
||||
Self.applyBrowserHeaders(to: &req)
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let (data, response) = try await session.data(for: req)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
if status != 200 {
|
||||
let bodyStr = String(data: data, encoding: .utf8) ?? "<no body>"
|
||||
print("[RouteExplorer] /api/token failed status=\(status) body=\(bodyStr.prefix(300))")
|
||||
throw ClientError.tokenFetchFailed(status: status)
|
||||
}
|
||||
|
||||
// route-explorer's edge now rejects URLSession-shaped requests
|
||||
// (returns 403 "clearance"). A WKWebView running inside the
|
||||
// route-explorer.com origin passes the gate, presumably because
|
||||
// the TLS fingerprint + same-origin cookies match what their
|
||||
// bot rules expect. We route both /api/token and
|
||||
// /api/flight-search through that path.
|
||||
let bodyStr = try await fetchViaWebView(
|
||||
method: "GET",
|
||||
apiPath: "/api/token",
|
||||
extraHeaders: [:],
|
||||
requestBody: nil
|
||||
)
|
||||
struct TokenResponse: Decodable { let token: String }
|
||||
guard let data = bodyStr.data(using: .utf8) else {
|
||||
throw ClientError.tokenFetchFailed(status: -1)
|
||||
}
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
|
||||
cachedToken = (decoded.token, Date().addingTimeInterval(30 * 60))
|
||||
@@ -187,6 +182,53 @@ actor RouteExplorerClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs an XHR from inside a WKWebView that's been navigated to
|
||||
/// `https://route-explorer.com/`. The page context provides the
|
||||
/// real Safari TLS fingerprint and any first-party cookies the
|
||||
/// edge expects. Returns the response body as a string, or
|
||||
/// throws on non-200.
|
||||
private func fetchViaWebView(
|
||||
method: String,
|
||||
apiPath: String,
|
||||
extraHeaders: [String: String],
|
||||
requestBody: Data?
|
||||
) async throws -> String {
|
||||
let fetcher = await WebViewFetcher()
|
||||
var headers: [String: String] = [
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
]
|
||||
for (k, v) in extraHeaders { headers[k] = v }
|
||||
|
||||
// For POST, body must be a JS literal — re-emit the JSON as
|
||||
// a JSON.stringify(...) so it survives template substitution.
|
||||
var bodyJS: String?
|
||||
if let requestBody {
|
||||
// Inline the JSON body verbatim — XMLHttpRequest.send()
|
||||
// takes a string and we're already JSON-encoded.
|
||||
let raw = String(data: requestBody, encoding: .utf8) ?? "null"
|
||||
bodyJS = "JSON.stringify(\(raw))"
|
||||
}
|
||||
|
||||
let result = await fetcher.fetch(
|
||||
navigateTo: "https://route-explorer.com/",
|
||||
fetchURL: "https://route-explorer.com\(apiPath)",
|
||||
method: method,
|
||||
headers: headers,
|
||||
body: bodyJS,
|
||||
userAgent: nil,
|
||||
includeCredentials: true
|
||||
)
|
||||
if let err = result.error {
|
||||
print("[RouteExplorer] WebView \(method) \(apiPath) failed: \(err)")
|
||||
throw ClientError.tokenFetchFailed(status: -1)
|
||||
}
|
||||
guard let data = result.data else {
|
||||
throw ClientError.tokenFetchFailed(status: -1)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/// Browser-shaped headers — `/api/token` and `/api/flight-search` are
|
||||
/// gated by Origin/Referer in production. Without these the server
|
||||
/// returns 403 even though the endpoints are otherwise anonymous.
|
||||
@@ -209,66 +251,42 @@ actor RouteExplorerClient {
|
||||
json: [String: Any]
|
||||
) async throws -> RouteSearchResult {
|
||||
let token = try await currentToken()
|
||||
let url = baseURL.appendingPathComponent("api/flight-search")
|
||||
|
||||
let outerBody: [String: Any] = [
|
||||
"endpoint": endpoint,
|
||||
"body": ["json": json]
|
||||
]
|
||||
let bodyData = try JSONSerialization.data(withJSONObject: outerBody)
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
Self.applyBrowserHeaders(to: &req)
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
req.setValue(token, forHTTPHeaderField: "X-API-Token")
|
||||
req.httpBody = bodyData
|
||||
|
||||
let (data, response) = try await session.data(for: req)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
|
||||
// 401 / 403 likely means the token rotated. Drop cache and retry once.
|
||||
if status == 401 || status == 403 {
|
||||
do {
|
||||
let respStr = try await fetchViaWebView(
|
||||
method: "POST",
|
||||
apiPath: "/api/flight-search",
|
||||
extraHeaders: ["X-API-Token": token],
|
||||
requestBody: bodyData
|
||||
)
|
||||
guard let data = respStr.data(using: .utf8) else {
|
||||
throw ClientError.requestFailed(status: -1, body: nil)
|
||||
}
|
||||
return try decode(data: data)
|
||||
} catch let err as ClientError {
|
||||
// Token may have rotated server-side. Drop cache and retry once.
|
||||
if case .tokenFetchFailed = err {
|
||||
cachedToken = nil
|
||||
return try await retryAfterTokenRotation(endpoint: endpoint, json: json)
|
||||
}
|
||||
|
||||
guard (200...299).contains(status) else {
|
||||
let bodyStr = String(data: data, encoding: .utf8)
|
||||
throw ClientError.requestFailed(status: status, body: bodyStr)
|
||||
}
|
||||
|
||||
return try decode(data: data)
|
||||
}
|
||||
|
||||
private func retryAfterTokenRotation(
|
||||
endpoint: String,
|
||||
json: [String: Any]
|
||||
) async throws -> RouteSearchResult {
|
||||
let token = try await currentToken()
|
||||
let url = baseURL.appendingPathComponent("api/flight-search")
|
||||
|
||||
let outerBody: [String: Any] = [
|
||||
"endpoint": endpoint,
|
||||
"body": ["json": json]
|
||||
]
|
||||
let bodyData = try JSONSerialization.data(withJSONObject: outerBody)
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
Self.applyBrowserHeaders(to: &req)
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue(token, forHTTPHeaderField: "X-API-Token")
|
||||
req.httpBody = bodyData
|
||||
|
||||
let (data, response) = try await session.data(for: req)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
guard (200...299).contains(status) else {
|
||||
throw ClientError.requestFailed(status: status, body: String(data: data, encoding: .utf8))
|
||||
let token2 = try await currentToken()
|
||||
let respStr = try await fetchViaWebView(
|
||||
method: "POST",
|
||||
apiPath: "/api/flight-search",
|
||||
extraHeaders: ["X-API-Token": token2],
|
||||
requestBody: bodyData
|
||||
)
|
||||
guard let data = respStr.data(using: .utf8) else {
|
||||
throw ClientError.requestFailed(status: -1, body: nil)
|
||||
}
|
||||
return try decode(data: data)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private func decode(data: Data) throws -> RouteSearchResult {
|
||||
do {
|
||||
|
||||
Reference in New Issue
Block a user