1use anyhow::Result;
10use futures_util::{SinkExt, StreamExt};
11use serde_json::{json, Value};
12
13const CDP_HTTP: &str = "http://127.0.0.1:9222/json";
14const HTTP_TIMEOUT_MS: u64 = 1500;
15const WS_TIMEOUT_MS: u64 = 3000;
16
17#[derive(Debug, Clone)]
20pub struct CdpElement {
21 pub center_x: f64,
23 pub center_y: f64,
25 pub text: String,
26 pub tag: String,
27 pub match_type: String,
28}
29
30pub async fn is_available() -> bool {
32 let Ok(client) = reqwest::Client::builder()
33 .timeout(std::time::Duration::from_millis(HTTP_TIMEOUT_MS))
34 .build()
35 else {
36 return false;
37 };
38 client.get(CDP_HTTP).send().await.is_ok()
39}
40
41pub async fn find_elements(query: &str) -> Result<Vec<CdpElement>> {
46 let client = reqwest::Client::builder()
48 .timeout(std::time::Duration::from_millis(HTTP_TIMEOUT_MS))
49 .build()?;
50 let tabs: Value = client.get(CDP_HTTP).send().await?.json().await?;
51
52 let ws_url = tabs
53 .as_array()
54 .and_then(|a| a.iter().find(|t| t["type"].as_str() == Some("page")))
55 .and_then(|t| t["webSocketDebuggerUrl"].as_str())
56 .ok_or_else(|| anyhow::anyhow!("No Chrome page tab found on port 9222"))?
57 .to_string();
58
59 let (mut ws, _) = tokio::time::timeout(
61 std::time::Duration::from_millis(WS_TIMEOUT_MS),
62 tokio_tungstenite::connect_async(&ws_url),
63 )
64 .await
65 .map_err(|_| anyhow::anyhow!("WebSocket connect timeout"))??;
66
67 let q = query.replace('`', "\\`").replace('\\', "\\\\");
70 let js = format!(
71 r#"
72(() => {{
73 const q = `{q}`;
74 const ql = q.toLowerCase();
75 const dedup = new Set();
76 const out = [];
77 const push = (e, mt) => {{
78 const r = e.getBoundingClientRect();
79 if (r.width === 0 || r.height === 0) return;
80 const key = `${{Math.round(r.x)}}_${{Math.round(r.y)}}`;
81 if (dedup.has(key)) return;
82 dedup.add(key);
83 out.push({{
84 text: e.textContent.trim().slice(0, 80),
85 tag: e.tagName.toLowerCase(),
86 role: e.getAttribute('role') || '',
87 centerX: r.x + r.width / 2,
88 centerY: r.y + r.height / 2,
89 matchType: mt,
90 }});
91 }};
92 // 1. aria-label
93 Array.from(document.querySelectorAll('[aria-label]'))
94 .filter(e => (e.getAttribute('aria-label') || '').toLowerCase().includes(ql))
95 .forEach(e => push(e, 'aria-label'));
96 // 2. placeholder
97 Array.from(document.querySelectorAll('input[placeholder],textarea[placeholder]'))
98 .filter(e => (e.getAttribute('placeholder') || '').toLowerCase().includes(ql))
99 .forEach(e => push(e, 'placeholder'));
100 // 3. text content (buttons, links, tabs, menu items)
101 Array.from(document.querySelectorAll('button,a,[role="tab"],[role="menuitem"],[role="option"]'))
102 .filter(e => e.textContent.trim().toLowerCase().includes(ql))
103 .forEach(e => push(e, 'text'));
104 // 4. label element text → associated input
105 Array.from(document.querySelectorAll('label'))
106 .filter(e => e.textContent.trim().toLowerCase().includes(ql))
107 .map(l => document.getElementById(l.getAttribute('for') || '') || l)
108 .forEach(e => push(e, 'label'));
109 // 5. title / alt attributes
110 Array.from(document.querySelectorAll('[title],[alt]'))
111 .filter(e => ((e.getAttribute('title') || '') + (e.getAttribute('alt') || '')).toLowerCase().includes(ql))
112 .forEach(e => push(e, 'title'));
113 // 6. CSS selector (for dom_class queries like ".myClass" or id-selectors)
114 if (q.startsWith('.') || q.startsWith('\x23')) {{
115 try {{
116 Array.from(document.querySelectorAll(q)).forEach(e => push(e, 'css'));
117 }} catch(e) {{}}
118 }}
119 return out.slice(0, 20);
120}})()
121"#
122 );
123
124 let cmd = json!({
126 "id": 1,
127 "method": "Runtime.evaluate",
128 "params": { "expression": js, "returnByValue": true }
129 });
130 ws.send(tokio_tungstenite::tungstenite::Message::Text(
131 cmd.to_string(),
132 ))
133 .await?;
134
135 let resp_text = tokio::time::timeout(std::time::Duration::from_millis(WS_TIMEOUT_MS), async {
137 loop {
138 match ws.next().await {
139 Some(Ok(tokio_tungstenite::tungstenite::Message::Text(t))) => {
140 let v: Value = serde_json::from_str(&t)?;
141 if v["id"] == 1 {
142 return Ok::<_, anyhow::Error>(t);
143 }
144 }
145 Some(Ok(_)) => continue, Some(Err(e)) => return Err(anyhow::anyhow!("WebSocket error: {e}")),
147 None => return Err(anyhow::anyhow!("WebSocket closed unexpectedly")),
148 }
149 }
150 })
151 .await
152 .map_err(|_| anyhow::anyhow!("WebSocket response timeout"))??;
153
154 let resp: Value = serde_json::from_str(&resp_text)?;
156 let items = resp["result"]["result"]["value"]
157 .as_array()
158 .cloned()
159 .unwrap_or_default();
160
161 Ok(items
162 .iter()
163 .filter_map(|item| {
164 Some(CdpElement {
165 center_x: item["centerX"].as_f64()?,
166 center_y: item["centerY"].as_f64()?,
167 text: item["text"].as_str().unwrap_or("").to_string(),
168 tag: item["tag"].as_str().unwrap_or("").to_string(),
169 match_type: item["matchType"].as_str().unwrap_or("").to_string(),
170 })
171 })
172 .collect())
173}
174
175pub fn viewport_to_screen(vp_x: f64, vp_y: f64, win_x: i32, win_y: i32) -> (i32, i32) {
185 const CHROME_TOOLBAR_HEIGHT: i32 = 88;
186 (
187 win_x + vp_x as i32,
188 win_y + CHROME_TOOLBAR_HEIGHT + vp_y as i32,
189 )
190}