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 {
|
do {
|
||||||
let token = try await currentToken()
|
let token = try await currentToken()
|
||||||
let url = baseURL.appendingPathComponent("api/flight-search")
|
|
||||||
let body = try JSONSerialization.data(withJSONObject: [
|
let body = try JSONSerialization.data(withJSONObject: [
|
||||||
"endpoint": "/schedule",
|
"endpoint": "/schedule",
|
||||||
"body": ["json": payload]
|
"body": ["json": payload]
|
||||||
])
|
])
|
||||||
var req = URLRequest(url: url)
|
let respStr = try await fetchViaWebView(
|
||||||
req.httpMethod = "POST"
|
method: "POST",
|
||||||
Self.applyBrowserHeaders(to: &req)
|
apiPath: "/api/flight-search",
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
extraHeaders: ["X-API-Token": token],
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
requestBody: body
|
||||||
req.setValue(token, forHTTPHeaderField: "X-API-Token")
|
)
|
||||||
req.httpBody = body
|
guard let data = respStr.data(using: .utf8) else { return [] }
|
||||||
|
|
||||||
let (data, response) = try await session.data(for: req)
|
|
||||||
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
|
||||||
guard (200...299).contains(status) else { return [] }
|
|
||||||
|
|
||||||
let decoded = try JSONDecoder.routeExplorer().decode(
|
let decoded = try JSONDecoder.routeExplorer().decode(
|
||||||
RouteExplorerScheduleResponse.self, from: data
|
RouteExplorerScheduleResponse.self, from: data
|
||||||
)
|
)
|
||||||
@@ -163,21 +157,22 @@ actor RouteExplorerClient {
|
|||||||
if let cached = cachedToken, cached.expiresAt > Date() {
|
if let cached = cachedToken, cached.expiresAt > Date() {
|
||||||
return cached.value
|
return cached.value
|
||||||
}
|
}
|
||||||
let url = baseURL.appendingPathComponent("api/token")
|
// route-explorer's edge now rejects URLSession-shaped requests
|
||||||
var req = URLRequest(url: url)
|
// (returns 403 "clearance"). A WKWebView running inside the
|
||||||
req.httpMethod = "GET"
|
// route-explorer.com origin passes the gate, presumably because
|
||||||
Self.applyBrowserHeaders(to: &req)
|
// the TLS fingerprint + same-origin cookies match what their
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
// bot rules expect. We route both /api/token and
|
||||||
|
// /api/flight-search through that path.
|
||||||
let (data, response) = try await session.data(for: req)
|
let bodyStr = try await fetchViaWebView(
|
||||||
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
method: "GET",
|
||||||
if status != 200 {
|
apiPath: "/api/token",
|
||||||
let bodyStr = String(data: data, encoding: .utf8) ?? "<no body>"
|
extraHeaders: [:],
|
||||||
print("[RouteExplorer] /api/token failed status=\(status) body=\(bodyStr.prefix(300))")
|
requestBody: nil
|
||||||
throw ClientError.tokenFetchFailed(status: status)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
struct TokenResponse: Decodable { let token: String }
|
struct TokenResponse: Decodable { let token: String }
|
||||||
|
guard let data = bodyStr.data(using: .utf8) else {
|
||||||
|
throw ClientError.tokenFetchFailed(status: -1)
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
|
let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
|
||||||
cachedToken = (decoded.token, Date().addingTimeInterval(30 * 60))
|
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
|
/// Browser-shaped headers — `/api/token` and `/api/flight-search` are
|
||||||
/// gated by Origin/Referer in production. Without these the server
|
/// gated by Origin/Referer in production. Without these the server
|
||||||
/// returns 403 even though the endpoints are otherwise anonymous.
|
/// returns 403 even though the endpoints are otherwise anonymous.
|
||||||
@@ -209,65 +251,41 @@ actor RouteExplorerClient {
|
|||||||
json: [String: Any]
|
json: [String: Any]
|
||||||
) async throws -> RouteSearchResult {
|
) async throws -> RouteSearchResult {
|
||||||
let token = try await currentToken()
|
let token = try await currentToken()
|
||||||
let url = baseURL.appendingPathComponent("api/flight-search")
|
|
||||||
|
|
||||||
let outerBody: [String: Any] = [
|
let outerBody: [String: Any] = [
|
||||||
"endpoint": endpoint,
|
"endpoint": endpoint,
|
||||||
"body": ["json": json]
|
"body": ["json": json]
|
||||||
]
|
]
|
||||||
let bodyData = try JSONSerialization.data(withJSONObject: outerBody)
|
let bodyData = try JSONSerialization.data(withJSONObject: outerBody)
|
||||||
|
|
||||||
var req = URLRequest(url: url)
|
do {
|
||||||
req.httpMethod = "POST"
|
let respStr = try await fetchViaWebView(
|
||||||
Self.applyBrowserHeaders(to: &req)
|
method: "POST",
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
apiPath: "/api/flight-search",
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
extraHeaders: ["X-API-Token": token],
|
||||||
req.setValue(token, forHTTPHeaderField: "X-API-Token")
|
requestBody: bodyData
|
||||||
req.httpBody = bodyData
|
)
|
||||||
|
guard let data = respStr.data(using: .utf8) else {
|
||||||
let (data, response) = try await session.data(for: req)
|
throw ClientError.requestFailed(status: -1, body: nil)
|
||||||
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
}
|
||||||
|
return try decode(data: data)
|
||||||
// 401 / 403 likely means the token rotated. Drop cache and retry once.
|
} catch let err as ClientError {
|
||||||
if status == 401 || status == 403 {
|
// Token may have rotated server-side. Drop cache and retry once.
|
||||||
cachedToken = nil
|
if case .tokenFetchFailed = err {
|
||||||
return try await retryAfterTokenRotation(endpoint: endpoint, json: json)
|
cachedToken = nil
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
return try decode(data: data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func decode(data: Data) throws -> RouteSearchResult {
|
private func decode(data: Data) throws -> RouteSearchResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user