Skip to content

Commit 1bf6748

Browse files
committed
Fix Google consent wall breaking all searches
Google started enforcing EU consent redirects that the hardcoded SOCS/CONSENT cookies no longer bypass. Replace static cookie approach with dynamic consent handling: follow redirect chains, detect consent forms, submit acceptance, and resume the flights request. Release v1.6.1
1 parent c589fa7 commit 1bf6748

5 files changed

Lines changed: 115 additions & 26 deletions

File tree

Cargo.lock

Lines changed: 8 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "flyr-cli"
3-
version = "1.6.0"
3+
version = "1.6.1"
44
edition = "2021"
55
description = "Search Google Flights from the terminal"
66
license = "GPL-3.0"
@@ -29,6 +29,7 @@ comfy-table = "7"
2929
open = "5"
3030
rmcp = { version = "0.15", features = ["server", "transport-io"] }
3131
schemars = "1"
32+
urlencoding = "2.1.3"
3233

3334
[dev-dependencies]
3435
assert_cmd = "2"

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ In human mode, errors go to stderr.
285285

286286
1. **Query encoding** -- Flight parameters are protobuf-encoded (hand-rolled encoder, ~130 LOC) and base64-encoded into the `tfs` URL parameter, matching what Google Flights expects.
287287

288-
2. **HTTP request** -- Uses [wreq](https://github.com/nickel-org/wreq) (reqwest fork) with Chrome 137 TLS fingerprint emulation to avoid bot detection. Pre-loads GDPR consent cookies to bypass the EU consent wall.
288+
2. **HTTP request** -- Uses [wreq](https://github.com/nickel-org/wreq) (reqwest fork) with Chrome 137 TLS fingerprint emulation to avoid bot detection. Automatically handles Google's EU consent wall by detecting consent redirects and submitting the acceptance form.
289289

290290
3. **HTML parsing** -- Extracts the `<script class="ds:1">` tag, isolates the `data:` JSON payload, parses with serde_json.
291291

@@ -341,7 +341,7 @@ src/
341341
├── mcp.rs Built-in MCP server (rmcp, stdio transport)
342342
├── proto.rs Hand-rolled protobuf encoder (~130 LOC)
343343
├── query.rs Query building, validation, URL param generation
344-
├── fetch.rs HTTP client with Chrome TLS impersonation + GDPR cookies
344+
├── fetch.rs HTTP client with Chrome TLS impersonation + consent handling
345345
├── parse.rs HTML script extraction + JSON payload navigation
346346
├── model.rs All data types (Serialize + Debug + Clone)
347347
├── table.rs Human-readable table rendering with currency symbols

src/fetch.rs

Lines changed: 102 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::sync::Arc;
22
use std::time::Duration;
33

4+
use scraper::{Html, Selector};
45
use wreq::Client;
56
use wreq::cookie::Jar;
67
use wreq_util::Emulation;
@@ -17,6 +18,7 @@ fn cache_buster() -> String {
1718
}
1819

1920
const BASE_URL: &str = "https://www.google.com/travel/flights";
21+
const MAX_REDIRECTS: u8 = 10;
2022

2123
#[derive(Clone)]
2224
pub struct FetchOptions {
@@ -33,17 +35,102 @@ impl Default for FetchOptions {
3335
}
3436
}
3537

38+
fn is_redirect(status: u16) -> bool {
39+
matches!(status, 301 | 302 | 303 | 307 | 308)
40+
}
41+
42+
fn extract_location(response: &wreq::Response) -> Option<String> {
43+
response
44+
.headers()
45+
.get("location")
46+
.and_then(|v| v.to_str().ok())
47+
.map(String::from)
48+
}
49+
50+
fn extract_consent_form(html: &str) -> Option<String> {
51+
let document = Html::parse_document(html);
52+
let form_sel = Selector::parse("form[action=\"https://consent.google.com/save\"]").ok()?;
53+
let input_sel = Selector::parse("input[type=\"hidden\"]").ok()?;
54+
55+
let form = document.select(&form_sel).next()?;
56+
57+
let mut fields: Vec<(String, String)> = Vec::new();
58+
for input in form.select(&input_sel) {
59+
if let (Some(name), Some(value)) = (input.attr("name"), input.attr("value")) {
60+
fields.push((name.to_string(), value.to_string()));
61+
}
62+
}
63+
64+
if fields.is_empty() {
65+
return None;
66+
}
67+
68+
Some(
69+
fields
70+
.iter()
71+
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
72+
.collect::<Vec<_>>()
73+
.join("&"),
74+
)
75+
}
76+
77+
async fn follow_redirects(client: &Client, start_url: &str) -> Result<String, FlightError> {
78+
let mut url = start_url.to_string();
79+
80+
for _ in 0..MAX_REDIRECTS {
81+
let response = client
82+
.get(&url)
83+
.send()
84+
.await
85+
.map_err(error::from_http_error)?;
86+
87+
let status = response.status().as_u16();
88+
89+
if is_redirect(status) {
90+
url = extract_location(&response)
91+
.ok_or_else(|| FlightError::JsParse("redirect without location".into()))?;
92+
continue;
93+
}
94+
95+
match status {
96+
200 => {}
97+
429 => return Err(FlightError::RateLimited),
98+
403 | 503 => return Err(FlightError::Blocked(status)),
99+
s if s >= 400 => return Err(FlightError::HttpStatus(s)),
100+
_ => {}
101+
}
102+
103+
let html = response.text().await.map_err(error::from_http_error)?;
104+
105+
if let Some(form_body) = extract_consent_form(&html) {
106+
let save_resp = client
107+
.post("https://consent.google.com/save")
108+
.header("content-type", "application/x-www-form-urlencoded")
109+
.body(form_body)
110+
.send()
111+
.await
112+
.map_err(error::from_http_error)?;
113+
114+
if is_redirect(save_resp.status().as_u16()) {
115+
url = extract_location(&save_resp)
116+
.ok_or_else(|| FlightError::JsParse("consent save: no redirect".into()))?;
117+
continue;
118+
}
119+
120+
return Err(FlightError::Blocked(save_resp.status().as_u16()));
121+
}
122+
123+
return Ok(html);
124+
}
125+
126+
Err(FlightError::Blocked(302))
127+
}
128+
36129
pub async fn fetch_html(
37130
params: &[(String, String)],
38131
options: &FetchOptions,
39132
) -> Result<String, FlightError> {
40133
let jar = Arc::new(Jar::default());
41-
let url: wreq::Uri = "https://www.google.com".parse().unwrap();
42-
jar.add(
43-
"SOCS=CAESEwgDEgk2MjA5NDM1NjAaAmVuIAEaBgiA_Le-Bg",
44-
&url,
45-
);
46-
jar.add("CONSENT=PENDING+987", &url);
47134

48135
let mut builder = Client::builder()
49136
.emulation(Emulation::Chrome137)
@@ -61,21 +148,15 @@ pub async fn fetch_html(
61148
let mut params = params.to_vec();
62149
params.push(("cx".to_string(), cache_buster()));
63150

64-
let response = client
65-
.get(BASE_URL)
66-
.query(&params)
67-
.send()
68-
.await
69-
.map_err(error::from_http_error)?;
70-
71-
let status = response.status().as_u16();
72-
match status {
73-
200 => {}
74-
429 => return Err(FlightError::RateLimited),
75-
403 | 503 => return Err(FlightError::Blocked(status)),
76-
_ if status >= 400 => return Err(FlightError::HttpStatus(status)),
77-
_ => {}
151+
let mut start_url = format!("{BASE_URL}?");
152+
for (i, (k, v)) in params.iter().enumerate() {
153+
if i > 0 {
154+
start_url.push('&');
155+
}
156+
start_url.push_str(&urlencoding::encode(k));
157+
start_url.push('=');
158+
start_url.push_str(&urlencoding::encode(v));
78159
}
79160

80-
response.text().await.map_err(error::from_http_error)
161+
follow_redirects(&client, &start_url).await
81162
}

tests/cli_test.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ fn top_level_version() {
2525
.arg("--version")
2626
.assert()
2727
.success()
28-
.stdout(predicate::str::contains("flyr 1.6.0"));
28+
.stdout(predicate::str::contains("flyr 1.6.1"));
2929
}
3030

3131
#[test]

0 commit comments

Comments
 (0)