1use std::time::Duration;
21
22use futures_util::{SinkExt, StreamExt};
23use serde_json::{Value, json};
24use tokio_tungstenite::tungstenite::Message;
25
26use crate::error::{Error, ErrorKind, Result};
27use crate::jwt;
28use crate::session::{Cookie, Session};
29
30fn cdp_err(msg: impl Into<String>) -> Error {
31 Error::new(ErrorKind::CdpUnavailable, msg.into())
32}
33
34async fn version(base: &str) -> Result<(String, String)> {
36 let url = format!("{}/json/version", base.trim_end_matches('/'));
37 let v: Value = reqwest::get(&url)
38 .await
39 .map_err(|e| cdp_err(format!("CDP {url}: {e}")))?
40 .json()
41 .await
42 .map_err(|e| cdp_err(format!("CDP {url}: {e}")))?;
43 let ws = v
44 .get("webSocketDebuggerUrl")
45 .and_then(Value::as_str)
46 .ok_or_else(|| cdp_err("CDP /json/version has no webSocketDebuggerUrl"))?
47 .to_string();
48 let ua = v
49 .get("User-Agent")
50 .and_then(Value::as_str)
51 .unwrap_or_default()
52 .to_string();
53 Ok((ws, ua))
54}
55
56async fn list_pages(base: &str) -> Result<Vec<Value>> {
57 let url = format!("{}/json", base.trim_end_matches('/'));
58 let v: Value = reqwest::get(&url)
59 .await
60 .map_err(|e| cdp_err(format!("CDP {url}: {e}")))?
61 .json()
62 .await
63 .map_err(|e| cdp_err(format!("CDP {url}: {e}")))?;
64 Ok(v.as_array().cloned().unwrap_or_default())
65}
66
67struct Conn {
69 ws: tokio_tungstenite::WebSocketStream<
70 tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
71 >,
72 next_id: u64,
73 events: std::collections::VecDeque<Value>,
75}
76
77impl Conn {
78 async fn connect(ws_url: &str) -> Result<Self> {
79 let (ws, _) = tokio_tungstenite::connect_async(ws_url)
80 .await
81 .map_err(|e| cdp_err(format!("CDP websocket {ws_url}: {e}")))?;
82 Ok(Self {
83 ws,
84 next_id: 0,
85 events: std::collections::VecDeque::new(),
86 })
87 }
88
89 async fn send(&mut self, method: &str, params: Value) -> Result<u64> {
90 self.send_to(None, method, params).await
91 }
92
93 async fn send_to(&mut self, session: Option<&str>, method: &str, params: Value) -> Result<u64> {
96 self.next_id += 1;
97 let id = self.next_id;
98 let mut msg = json!({"id": id, "method": method, "params": params});
99 if let Some(s) = session {
100 msg["sessionId"] = json!(s);
101 }
102 self.ws
103 .send(Message::Text(msg.to_string().into()))
104 .await
105 .map_err(|e| cdp_err(format!("CDP send {method}: {e}")))?;
106 Ok(id)
107 }
108
109 async fn result_for(&mut self, id: u64, timeout: Duration) -> Result<Value> {
111 let deadline = tokio::time::Instant::now() + timeout;
112 loop {
113 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
114 if remaining.is_zero() {
115 return Err(cdp_err("timed out waiting for CDP reply"));
116 }
117 let msg = tokio::time::timeout(remaining, self.ws.next())
118 .await
119 .map_err(|_| cdp_err("timed out waiting for CDP reply"))?
120 .ok_or_else(|| cdp_err("CDP connection closed"))?
121 .map_err(|e| cdp_err(format!("CDP recv: {e}")))?;
122 if let Message::Text(txt) = msg {
123 let v: Value = serde_json::from_str(&txt)?;
124 if v.get("id").and_then(Value::as_u64) == Some(id) {
125 if let Some(err) = v.get("error") {
126 return Err(cdp_err(format!("CDP error: {err}")));
127 }
128 return Ok(v.get("result").cloned().unwrap_or(Value::Null));
129 }
130 if v.get("method").is_some() && self.events.len() < 10_000 {
131 self.events.push_back(v);
132 }
133 }
134 }
135 }
136
137 async fn call(&mut self, session: Option<&str>, method: &str, params: Value) -> Result<Value> {
139 let id = self.send_to(session, method, params).await?;
140 self.result_for(id, Duration::from_secs(20)).await
141 }
142
143 async fn next_event(&mut self, timeout: Duration) -> Result<Option<Value>> {
145 if let Some(v) = self.events.pop_front() {
146 return Ok(Some(v));
147 }
148 loop {
149 match tokio::time::timeout(timeout, self.ws.next()).await {
150 Err(_) => return Ok(None),
151 Ok(None) => return Err(cdp_err("CDP connection closed")),
152 Ok(Some(Err(e))) => return Err(cdp_err(format!("CDP recv: {e}"))),
153 Ok(Some(Ok(Message::Text(t)))) => {
154 let v: Value = serde_json::from_str(&t)?;
155 if v.get("method").is_some() {
156 return Ok(Some(v));
157 }
158 }
159 Ok(Some(Ok(_))) => {}
160 }
161 }
162 }
163}
164
165fn grab_cookies(raw: &[Value]) -> Vec<Cookie> {
167 raw.iter()
168 .filter_map(|c| {
169 let domain = c.get("domain").and_then(Value::as_str)?;
170 let bare = domain.trim_start_matches('.');
171 if !(bare == "grab.com" || bare.ends_with(".grab.com")) {
172 return None;
173 }
174 Some(Cookie {
175 name: c.get("name").and_then(Value::as_str)?.to_string(),
176 value: c.get("value").and_then(Value::as_str)?.to_string(),
177 domain: domain.to_string(),
178 path: c
179 .get("path")
180 .and_then(Value::as_str)
181 .unwrap_or("/")
182 .to_string(),
183 expires: c.get("expires").and_then(Value::as_f64).unwrap_or(-1.0),
184 })
185 })
186 .collect()
187}
188
189#[derive(Debug, Clone)]
191pub struct PageInfo {
192 pub id: String,
193 pub url: String,
194}
195
196pub async fn pages(base: &str) -> Result<Vec<PageInfo>> {
199 Ok(list_pages(base)
200 .await?
201 .iter()
202 .filter(|p| p.get("type").and_then(Value::as_str) == Some("page"))
203 .filter_map(|p| {
204 Some(PageInfo {
205 id: p.get("id").and_then(Value::as_str)?.to_string(),
206 url: p.get("url").and_then(Value::as_str)?.to_string(),
207 })
208 })
209 .collect())
210}
211
212pub struct Browser {
220 conn: Conn,
221 pub user_agent: String,
223}
224
225impl Browser {
226 pub async fn connect(base: &str) -> Result<Self> {
227 let (ws_url, ua) = version(base).await?;
228 Ok(Self {
229 conn: Conn::connect(&ws_url).await?,
230 user_agent: ua,
231 })
232 }
233
234 pub async fn call(
236 &mut self,
237 session: Option<&str>,
238 method: &str,
239 params: Value,
240 ) -> Result<Value> {
241 self.conn.call(session, method, params).await
242 }
243
244 pub async fn create_context(&mut self) -> Result<String> {
246 let r = self
247 .call(
248 None,
249 "Target.createBrowserContext",
250 json!({"disposeOnDetach": false}),
251 )
252 .await?;
253 r.get("browserContextId")
254 .and_then(Value::as_str)
255 .map(ToString::to_string)
256 .ok_or_else(|| cdp_err("Target.createBrowserContext returned no id"))
257 }
258
259 pub async fn context_exists(&mut self, context: &str) -> Result<bool> {
260 let r = self
261 .call(None, "Target.getBrowserContexts", json!({}))
262 .await?;
263 Ok(r.get("browserContextIds")
264 .and_then(Value::as_array)
265 .is_some_and(|ids| ids.iter().any(|i| i.as_str() == Some(context))))
266 }
267
268 pub async fn dispose_context(&mut self, context: &str) -> Result<()> {
270 self.call(
271 None,
272 "Target.disposeBrowserContext",
273 json!({"browserContextId": context}),
274 )
275 .await
276 .map(|_| ())
277 }
278
279 pub async fn open_tab(&mut self, context: &str, url: &str) -> Result<String> {
281 let r = self
282 .call(
283 None,
284 "Target.createTarget",
285 json!({"url": url, "browserContextId": context, "background": true}),
286 )
287 .await?;
288 r.get("targetId")
289 .and_then(Value::as_str)
290 .map(ToString::to_string)
291 .ok_or_else(|| cdp_err("Target.createTarget returned no id"))
292 }
293
294 pub async fn tab_in_context(&mut self, context: &str, target: &str) -> Result<bool> {
296 let r = self.call(None, "Target.getTargets", json!({})).await?;
297 Ok(r.get("targetInfos")
298 .and_then(Value::as_array)
299 .is_some_and(|ts| {
300 ts.iter().any(|t| {
301 t.get("targetId").and_then(Value::as_str) == Some(target)
302 && t.get("browserContextId").and_then(Value::as_str) == Some(context)
303 })
304 }))
305 }
306
307 pub async fn attach(&mut self, target: &str) -> Result<String> {
311 let r = self
312 .call(
313 None,
314 "Target.attachToTarget",
315 json!({"targetId": target, "flatten": true}),
316 )
317 .await?;
318 let s = r
319 .get("sessionId")
320 .and_then(Value::as_str)
321 .map(ToString::to_string)
322 .ok_or_else(|| cdp_err("Target.attachToTarget returned no session"))?;
323 self.call(Some(&s), "Page.enable", json!({})).await?;
324 self.call(
325 Some(&s),
326 "Emulation.setFocusEmulationEnabled",
327 json!({"enabled": true}),
328 )
329 .await?;
330 self.call(
331 Some(&s),
332 "Page.setWebLifecycleState",
333 json!({"state": "active"}),
334 )
335 .await?;
336 Ok(s)
337 }
338
339 pub async fn eval(&mut self, session: &str, expression: &str) -> Result<Value> {
342 let r = self
343 .call(
344 Some(session),
345 "Runtime.evaluate",
346 json!({"expression": expression, "awaitPromise": true, "returnByValue": true}),
347 )
348 .await?;
349 if let Some(ex) = r.get("exceptionDetails") {
350 let text = ex
351 .pointer("/exception/description")
352 .or_else(|| ex.get("text"))
353 .and_then(Value::as_str)
354 .unwrap_or("exception");
355 return Err(cdp_err(format!("page evaluation failed: {text}")));
356 }
357 Ok(r.pointer("/result/value").cloned().unwrap_or(Value::Null))
358 }
359
360 pub async fn navigate(&mut self, session: &str, url: &str) -> Result<()> {
362 self.call(Some(session), "Page.navigate", json!({"url": url}))
363 .await
364 .map(|_| ())
365 }
366
367 pub async fn click_at(&mut self, session: &str, x: f64, y: f64) -> Result<()> {
369 for (kind, buttons) in [("mouseMoved", 0), ("mousePressed", 1), ("mouseReleased", 0)] {
370 self.call(
371 Some(session),
372 "Input.dispatchMouseEvent",
373 json!({"type": kind, "x": x, "y": y, "button": "left", "buttons": buttons, "clickCount": 1}),
374 )
375 .await?;
376 }
377 Ok(())
378 }
379
380 pub async fn type_digits(&mut self, session: &str, digits: &str) -> Result<()> {
382 for ch in digits.chars().filter(char::is_ascii_digit) {
383 let key = ch.to_string();
384 let vk = 48 + ch.to_digit(10).unwrap_or(0);
385 for kind in ["keyDown", "keyUp"] {
386 let mut p = json!({
387 "type": kind, "key": key, "code": format!("Digit{ch}"),
388 "windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk,
389 });
390 if kind == "keyDown" {
391 p["text"] = json!(key);
392 }
393 self.call(Some(session), "Input.dispatchKeyEvent", p)
394 .await?;
395 }
396 tokio::time::sleep(Duration::from_millis(90)).await;
397 }
398 Ok(())
399 }
400
401 pub async fn context_cookies(&mut self, context: &str) -> Result<Vec<Cookie>> {
403 let r = self
404 .call(
405 None,
406 "Storage.getCookies",
407 json!({"browserContextId": context}),
408 )
409 .await?;
410 let raw = r
411 .get("cookies")
412 .and_then(Value::as_array)
413 .cloned()
414 .unwrap_or_default();
415 Ok(grab_cookies(&raw))
416 }
417
418 pub async fn default_cookies(&mut self) -> Result<Vec<Cookie>> {
421 let r = self.call(None, "Storage.getCookies", json!({})).await?;
422 let raw = r
423 .get("cookies")
424 .and_then(Value::as_array)
425 .cloned()
426 .unwrap_or_default();
427 Ok(grab_cookies(&raw))
428 }
429
430 pub async fn drain_events(&mut self, wait: Duration) -> Result<usize> {
433 let mut n = 0;
434 while self.conn.next_event(wait).await?.is_some() {
435 n += 1;
436 }
437 Ok(n)
438 }
439}
440
441pub async fn default_context_cookies(base: &str) -> Result<(Vec<Cookie>, String)> {
445 let (ws_url, ua) = version(base).await?;
446 let mut conn = Conn::connect(&ws_url).await?;
447 let id = conn.send("Storage.getCookies", json!({})).await?;
448 let result = conn.result_for(id, Duration::from_secs(10)).await?;
449 let raw = result
450 .get("cookies")
451 .and_then(Value::as_array)
452 .ok_or_else(|| cdp_err("Storage.getCookies returned no cookies"))?;
453 Ok((grab_cookies(raw), ua))
454}
455
456pub async fn export_cookies(base: &str, session: &mut Session) -> Result<String> {
459 let (cookies, ua) = default_context_cookies(base).await?;
460 if cookies.is_empty() {
461 return Err(cdp_err(
462 "no grab.com cookies in the browser; is it logged in?",
463 ));
464 }
465 session.cookies = cookies;
466 if !ua.is_empty() {
467 session.user_agent = Some(ua.clone());
468 }
469 session.exported_at = Some(jwt::now() as f64);
470 Ok(ua)
471}
472
473pub async fn evaluate_in_origin(
482 base: &str,
483 origin: &str,
484 open_url: &str,
485 expression: &str,
486 timeout: Duration,
487) -> Result<Value> {
488 let on_origin = |p: &Value| {
489 p.get("type").and_then(Value::as_str) == Some("page")
490 && p.get("webSocketDebuggerUrl").is_some()
491 && p.get("url")
492 .and_then(Value::as_str)
493 .is_some_and(|u| u.starts_with(origin))
494 };
495 let pages = list_pages(base).await?;
496 let target = match pages.iter().find(|p| on_origin(p)) {
497 Some(p) => p.clone(),
498 None => {
499 let url = format!(
501 "{}/json/new?{}",
502 base.trim_end_matches('/'),
503 urlencoding::encode(open_url)
504 );
505 reqwest::Client::new()
506 .put(&url)
507 .send()
508 .await
509 .map_err(|e| cdp_err(format!("CDP /json/new: {e}")))?
510 .json::<Value>()
511 .await
512 .map_err(|e| cdp_err(format!("CDP /json/new: {e}")))?
513 }
514 };
515 let ws_url = target
516 .get("webSocketDebuggerUrl")
517 .and_then(Value::as_str)
518 .ok_or_else(|| cdp_err("target tab has no websocket URL"))?;
519 let mut conn = Conn::connect(ws_url).await?;
520
521 let deadline = tokio::time::Instant::now() + timeout;
522 let left = || deadline.saturating_duration_since(tokio::time::Instant::now());
523 loop {
525 let id = conn
526 .send(
527 "Runtime.evaluate",
528 json!({"expression": "location.origin + '|' + document.readyState", "returnByValue": true}),
529 )
530 .await?;
531 let r = conn.result_for(id, left()).await?;
532 let s = r
533 .pointer("/result/value")
534 .and_then(Value::as_str)
535 .unwrap_or_default();
536 if s.starts_with(origin) && !s.ends_with("|loading") {
537 break;
538 }
539 if left().is_zero() {
540 return Err(cdp_err(format!("browser tab never reached {origin}")));
541 }
542 tokio::time::sleep(Duration::from_millis(300)).await;
543 }
544
545 let id = conn
546 .send(
547 "Runtime.evaluate",
548 json!({"expression": expression, "awaitPromise": true, "returnByValue": true}),
549 )
550 .await?;
551 let r = conn.result_for(id, left()).await?;
552 if let Some(ex) = r.get("exceptionDetails") {
553 let text = ex
554 .pointer("/exception/description")
555 .or_else(|| ex.get("text"))
556 .and_then(Value::as_str)
557 .unwrap_or("exception");
558 return Err(cdp_err(format!("browser evaluation failed: {text}")));
559 }
560 Ok(r.pointer("/result/value").cloned().unwrap_or(Value::Null))
561}
562
563pub async fn mint_hydra(base: &str, page: &str, session: &mut Session) -> Result<()> {
567 let pages = list_pages(base).await?;
568 let target = pages
570 .iter()
571 .find(|p| {
572 p.get("type").and_then(Value::as_str) == Some("page")
573 && p.get("url")
574 .and_then(Value::as_str)
575 .is_some_and(|u| u.contains("food.grab.com"))
576 })
577 .or_else(|| {
578 pages.iter().find(|p| {
579 p.get("type").and_then(Value::as_str) == Some("page")
580 && p.get("webSocketDebuggerUrl").is_some()
581 })
582 })
583 .ok_or_else(|| cdp_err("no usable browser tab found over CDP"))?;
584
585 let ws_url = target
586 .get("webSocketDebuggerUrl")
587 .and_then(Value::as_str)
588 .ok_or_else(|| cdp_err("target tab has no websocket URL"))?;
589
590 let mut conn = Conn::connect(ws_url).await?;
591 conn.send("Network.enable", json!({})).await?;
592 conn.send("Page.enable", json!({})).await?;
593 conn.send("Page.navigate", json!({ "url": page })).await?;
594
595 let deadline = tokio::time::Instant::now() + Duration::from_secs(25);
597 loop {
598 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
599 if remaining.is_zero() {
600 return Err(Error::new(
601 ErrorKind::HydraExpired,
602 "no x-hydra-jwt seen from the browser within 25s",
603 ));
604 }
605 let msg = match tokio::time::timeout(remaining, conn.ws.next()).await {
606 Ok(Some(Ok(Message::Text(t)))) => t,
607 Ok(Some(Ok(_))) => continue,
608 Ok(Some(Err(e))) => return Err(cdp_err(format!("CDP recv: {e}"))),
609 Ok(None) => return Err(cdp_err("CDP connection closed")),
610 Err(_) => continue,
611 };
612 let v: Value = serde_json::from_str(&msg)?;
613 let method = v.get("method").and_then(Value::as_str).unwrap_or_default();
614 if method != "Network.requestWillBeSent" && method != "Network.requestWillBeSentExtraInfo" {
615 continue;
616 }
617 let headers = v
618 .pointer("/params/request/headers")
619 .or_else(|| v.pointer("/params/headers"));
620 let Some(headers) = headers.and_then(Value::as_object) else {
621 continue;
622 };
623 let find = |want: &str| {
624 headers
625 .iter()
626 .find(|(k, _)| k.eq_ignore_ascii_case(want))
627 .and_then(|(_, val)| val.as_str())
628 .map(ToString::to_string)
629 };
630 if let Some(jwt_tok) = find("x-hydra-jwt") {
631 let exp = jwt::exp(&jwt_tok).unwrap_or_else(|| jwt::now() + 600);
632 session.hydra_jwt = Some(jwt_tok);
633 session.hydra_exp = Some(exp);
634 if let Some(ver) = find("x-grab-web-app-version") {
635 session.web_app_version = Some(ver);
636 }
637 return Ok(());
638 }
639 }
640}