1use crate::connection::{call_async, CdpEvent, Connection};
2use crate::error::{CdpError, Result};
3use crate::tab::Tab;
4use gthings_common::domain_reputation::QualityFlag;
5use serde_json::Value;
6use std::time::Duration;
7use tokio::sync::broadcast;
8use tokio::task::JoinHandle;
9
10pub struct Session {
12 conn: Connection,
13 dialog_handle: Option<JoinHandle<()>>,
15}
16
17impl std::fmt::Debug for Session {
18 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19 f.debug_struct("Session").finish_non_exhaustive()
20 }
21}
22
23async fn wait_for_event(
26 rx: &mut broadcast::Receiver<CdpEvent>,
27 method: &str,
28 predicate: impl Fn(&CdpEvent) -> bool + Send,
29 timeout: Duration,
30) -> Result<CdpEvent> {
31 tokio::time::timeout(timeout, async move {
32 loop {
33 match rx.recv().await {
34 Ok(event) if event.method.as_str() == method && predicate(&event) => {
35 return Ok(event);
36 }
37 Ok(_) => continue,
38 Err(broadcast::error::RecvError::Closed) => {
39 return Err(CdpError::ConnectionFailed {
40 detail: "event channel closed while waiting".into(),
41 });
42 }
43 Err(broadcast::error::RecvError::Lagged(n)) => {
44 tracing::warn!("Event receiver lagged by {n} messages");
45 continue;
46 }
47 }
48 }
49 })
50 .await
51 .map_err(|_| CdpError::NavigationTimeout {
52 url: "unknown".into(),
53 timeout: timeout.as_secs(),
54 })?
55}
56
57impl Session {
58 pub async fn connect(ws_url: &str) -> Result<Self> {
60 let conn = Connection::connect(ws_url).await?;
61 let dialog_handle = Some(Self::spawn_dialog_handler(&conn));
62 Ok(Session {
63 conn,
64 dialog_handle,
65 })
66 }
67
68 fn spawn_dialog_handler(conn: &Connection) -> JoinHandle<()> {
73 let mut rx = conn.event_rx();
74 let write = conn.write_tx();
75 tokio::spawn(async move {
76 loop {
77 match rx.recv().await {
78 Ok(event) if event.method == "Page.javascriptDialogOpening" => {
79 tracing::debug!(
80 "Auto-accepting dialog: type={:?}, message={:?}",
81 event.params.get("type"),
82 event.params.get("message"),
83 );
84 call_async(
85 &write,
86 "Page.handleJavaScriptDialog",
87 serde_json::json!({"accept": true}),
88 event.session_id,
89 );
90 }
91 Ok(_) => continue,
92 Err(broadcast::error::RecvError::Closed) => {
93 tracing::debug!("Dialog handler: event channel closed, stopping");
94 break;
95 }
96 Err(broadcast::error::RecvError::Lagged(n)) => {
97 tracing::warn!("Dialog event receiver lagged by {n} messages");
98 continue;
99 }
100 }
101 }
102 })
103 }
104
105 pub async fn create_tab(&self, url: &str) -> Result<Tab> {
107 Tab::create(self, url).await
108 }
109
110 pub async fn evaluate(&self, tab: &Tab, js: &str) -> Result<Value> {
112 tab.evaluate(self, js).await
113 }
114
115 pub async fn navigate(&self, tab: &Tab, url: &str) -> Result<()> {
117 let conn = &self.conn;
118 let sid = tab.session_id.as_deref();
119
120 conn.call("Page.enable", serde_json::json!({}), sid).await?;
122
123 conn.call(
125 "Page.setLifecycleEventsEnabled",
126 serde_json::json!({"enabled": true}),
127 sid,
128 )
129 .await?;
130
131 let mut rx = conn.event_rx();
133
134 conn.call("Page.navigate", serde_json::json!({"url": url}), sid)
136 .await?;
137
138 let result = wait_for_event(
140 &mut rx,
141 "Page.lifecycleEvent",
142 |evt| evt.params.get("name").and_then(|v| v.as_str()) == Some("networkIdle"),
143 Duration::from_secs(10),
144 )
145 .await;
146
147 match result {
149 Ok(_) => {}
150 Err(CdpError::NavigationTimeout { .. }) => {
151 tracing::warn!("Lifecycle event timeout, falling back to readyState polling");
152 for _ in 0..10 {
154 if let Ok(val) = conn
155 .call(
156 "Runtime.evaluate",
157 serde_json::json!({
158 "expression": "document.readyState",
159 "returnByValue": true
160 }),
161 sid,
162 )
163 .await
164 {
165 if val
166 .get("result")
167 .and_then(|r| r.get("value"))
168 .and_then(|v| v.as_str())
169 == Some("complete")
170 {
171 return Ok(());
172 }
173 }
174 tokio::time::sleep(Duration::from_millis(500)).await;
175 }
176 return Err(CdpError::NavigationTimeout {
177 url: url.to_string(),
178 timeout: 15,
179 });
180 }
181 Err(e) => return Err(e),
182 }
183
184 Ok(())
185 }
186
187 pub async fn check_page_signals(&self, tab: &Tab) -> Result<Vec<QualityFlag>> {
191 let js = r#"
192 (() => {
193 const flags = [];
194 if (document.querySelector('#cf-challenge, .cf-turnstile, [class*="challenge"], [id*="challenge"]'))
195 flags.push("BotWall");
196 if (document.title.toLowerCase().includes("just a moment"))
197 flags.push("BotWall");
198 if (document.querySelector('iframe[src*="recaptcha"], iframe[src*="hcaptcha"], .h-captcha, .g-recaptcha'))
199 flags.push("Captcha");
200 const text = (document.body?.innerText || '').slice(0, 2000).toLowerCase();
201 if (/subscribe to continue|sign in to read|you have reached your free article limit|subscribe to read|log in to read this/i.test(text))
202 flags.push("Paywall");
203 return flags;
204 })()
205 "#;
206
207 let result = tab.evaluate(self, js).await?;
208 Ok(Self::parse_signal_flags(&result))
209 }
210
211 pub async fn wait_for<F>(
217 &self,
218 method: &str,
219 predicate: F,
220 timeout: Duration,
221 ) -> Result<CdpEvent>
222 where
223 F: Fn(&CdpEvent) -> bool + Send + 'static,
224 {
225 let mut rx = self.conn.event_rx();
226
227 tokio::time::timeout(timeout, async move {
228 loop {
229 match rx.recv().await {
230 Ok(event) if event.method.as_str() == method && predicate(&event) => {
231 return Ok(event);
232 }
233 Ok(_) => continue,
234 Err(broadcast::error::RecvError::Closed) => {
235 return Err(CdpError::ConnectionFailed {
236 detail: "event channel closed while waiting".into(),
237 });
238 }
239 Err(broadcast::error::RecvError::Lagged(n)) => {
240 tracing::warn!("Event receiver lagged by {n} messages");
241 continue;
242 }
243 }
244 }
245 })
246 .await
247 .map_err(|_| CdpError::CdpCallFailed {
248 method: format!("wait_for({method})"),
249 detail: format!("timeout after {timeout:?}"),
250 })?
251 }
252
253 pub async fn close_tab(&self, tab: Tab) -> Result<()> {
255 tab.close(self).await
256 }
257
258 pub async fn disconnect(mut self) -> Result<()> {
260 if let Some(h) = self.dialog_handle.take() {
263 h.abort();
264 }
265 self.conn.close().await;
266 Ok(())
267 }
268
269 pub fn connection(&self) -> &Connection {
271 &self.conn
272 }
273
274 pub(crate) fn parse_signal_flags(value: &Value) -> Vec<QualityFlag> {
279 match value
280 .get("result")
281 .and_then(|r| r.get("value"))
282 .and_then(|v| v.as_array())
283 {
284 Some(arr) => arr
285 .iter()
286 .filter_map(|v| {
287 v.as_str().and_then(|s| match s {
288 "BotWall" => Some(QualityFlag::BotWall),
289 "Captcha" => Some(QualityFlag::Captcha),
290 "Paywall" => Some(QualityFlag::Paywall),
291 "EmptyShell" => Some(QualityFlag::EmptyShell),
292 "Garbled" => Some(QualityFlag::Garbled),
293 "ThinContent" => Some(QualityFlag::ThinContent),
294 "Truncated" => Some(QualityFlag::Truncated),
295 _ => None,
296 })
297 })
298 .collect(),
299 None => Vec::new(),
300 }
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use serde_json::json;
308
309 #[test]
310 fn test_parse_signal_flags_empty() {
311 let val = json!({"result": {"type": "object", "value": []}});
312 let flags = Session::parse_signal_flags(&val);
313 assert!(flags.is_empty());
314 }
315
316 #[test]
317 fn test_parse_signal_flags_botwall() {
318 let val = json!({"result": {"type": "object", "value": ["BotWall"]}});
319 let flags = Session::parse_signal_flags(&val);
320 assert_eq!(flags, vec![QualityFlag::BotWall]);
321 }
322
323 #[test]
324 fn test_parse_signal_flags_multiple() {
325 let val = json!({"result": {"type": "object", "value": ["BotWall", "Captcha"]}});
326 let flags = Session::parse_signal_flags(&val);
327 assert_eq!(flags, vec![QualityFlag::BotWall, QualityFlag::Captcha]);
328 }
329
330 #[test]
331 fn test_parse_signal_flags_paywall() {
332 let val = json!({"result": {"type": "object", "value": ["Paywall"]}});
333 let flags = Session::parse_signal_flags(&val);
334 assert_eq!(flags, vec![QualityFlag::Paywall]);
335 }
336
337 #[test]
338 fn test_parse_signal_flags_unknown_ignored() {
339 let val =
340 json!({"result": {"type": "object", "value": ["BotWall", "UnknownFlag", "Captcha"]}});
341 let flags = Session::parse_signal_flags(&val);
342 assert_eq!(flags, vec![QualityFlag::BotWall, QualityFlag::Captcha]);
343 }
344
345 #[test]
346 fn test_parse_signal_flags_missing_result() {
347 let val = json!({});
348 let flags = Session::parse_signal_flags(&val);
349 assert!(flags.is_empty());
350 }
351
352 #[test]
353 fn test_parse_signal_flags_non_array_value() {
354 let val = json!({"result": {"type": "string", "value": "not_an_array"}});
355 let flags = Session::parse_signal_flags(&val);
356 assert!(flags.is_empty());
357 }
358}