1use std::{env, sync::Arc, time::Duration};
7
8use rmcp::ErrorData;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use tokio::sync::Mutex;
12use uuid::Uuid;
13use void_crawl_core::{
14 AntibotVerdict, BrowserSession, ManagedProfileLease, ProfileRegistry, VoidCrawlError,
15};
16
17use crate::{
18 errors::map_err,
19 server::VoidCrawlServer,
20 sessions::{DedicatedSession, LastNavigation},
21 tools::{fetch::AntibotInfo, wait},
22};
23
24pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
25
26#[derive(Debug, Deserialize, JsonSchema, Default)]
27pub struct SessionOpenArgs {
28 #[serde(default)]
32 pub headful: bool,
33 #[serde(default)]
35 pub proxy: Option<String>,
36 #[serde(default)]
44 pub user_data_dir: Option<String>,
45 #[serde(default)]
48 pub profile_id: Option<String>,
49 #[serde(default)]
52 pub profile_pool: Option<String>,
53 #[serde(default)]
59 pub port: Option<u16>,
60}
61
62#[derive(Debug, Serialize, JsonSchema)]
63pub struct SessionOpenResult {
64 pub session_id: String,
65 pub websocket_url: String,
69 pub port: Option<u16>,
71 pub target_id: String,
73 pub profile_id: Option<String>,
76}
77
78#[derive(Debug, Deserialize, JsonSchema, Default)]
79pub struct SessionNavigateArgs {
80 pub session_id: String,
81 pub url: String,
82 #[serde(default)]
84 pub wait_for: Option<String>,
85 #[serde(default)]
86 pub timeout_secs: Option<u64>,
87}
88
89#[derive(Debug, Serialize, JsonSchema)]
90pub struct SessionNavigateResult {
91 pub url: String,
92 pub status_code: Option<u16>,
93 pub redirected: bool,
94 pub antibot: Option<AntibotInfo>,
97}
98
99#[derive(Debug, Deserialize, JsonSchema, Default)]
100pub struct SessionIdArgs {
101 pub session_id: String,
102}
103
104#[derive(Debug, Serialize, JsonSchema)]
105pub struct SessionContentResult {
106 pub url: Option<String>,
107 pub title: Option<String>,
108 pub html: String,
109}
110
111#[derive(Debug, Serialize, JsonSchema)]
112pub struct SessionCloseResult {
113 pub closed: bool,
114}
115
116pub async fn open(
117 server: &VoidCrawlServer,
118 args: SessionOpenArgs,
119) -> Result<SessionOpenResult, ErrorData> {
120 let profile_inputs = [
121 args.user_data_dir.as_ref().map(|_| "user_data_dir"),
122 args.profile_id.as_ref().map(|_| "profile_id"),
123 args.profile_pool.as_ref().map(|_| "profile_pool"),
124 ]
125 .into_iter()
126 .flatten()
127 .collect::<Vec<_>>();
128 if profile_inputs.len() > 1 {
129 return Err(ErrorData::invalid_params(
130 format!("profile inputs are mutually exclusive: {}", profile_inputs.join(", ")),
131 None,
132 ));
133 }
134
135 let mut builder = BrowserSession::builder();
136 builder = if args.headful { builder.headful() } else { builder.headless() };
137 if let Some(p) = args.port {
138 builder = builder.port(p);
139 }
140 if let Some(proxy) = args.proxy {
141 builder = builder.proxy(proxy);
142 }
143 let mut profile_lease: Option<ManagedProfileLease> = None;
144 let mut selected_profile_id: Option<String> = None;
145 if let Some(profile_id) = args.profile_id {
146 let lease = ProfileRegistry::default().acquire_profile(&profile_id).map_err(map_err)?;
147 selected_profile_id = Some(lease.id().to_string());
148 builder = builder.user_data_dir(lease.path());
149 profile_lease = Some(lease);
150 } else if let Some(pool_name) = args.profile_pool {
151 let lease = ProfileRegistry::default().acquire_from_pool(&pool_name).map_err(map_err)?;
152 selected_profile_id = Some(lease.id().to_string());
153 builder = builder.user_data_dir(lease.path());
154 profile_lease = Some(lease);
155 } else if let Some(path) = args.user_data_dir {
156 builder = builder.user_data_dir(expand_tilde(&path));
157 }
158 let session = builder.launch().await.map_err(map_err)?;
159 let page = session.new_blank_page().await.map_err(map_err)?;
160 let websocket_url = session.websocket_url().await;
163 let target_id = page.target_id();
164 let id = Uuid::new_v4().to_string();
165 let handle = Arc::new(DedicatedSession {
166 session: Arc::new(session),
167 page: Mutex::new(page),
168 profile_lease,
169 last_navigation: Mutex::new(None),
170 challenge: Mutex::new(None),
171 pending_download: Mutex::new(None),
172 });
173 server.state().sessions.insert(id.clone(), handle).await;
174 Ok(SessionOpenResult {
175 session_id: id,
176 websocket_url,
177 port: args.port,
178 target_id,
179 profile_id: selected_profile_id,
180 })
181}
182
183pub async fn navigate(
184 server: &VoidCrawlServer,
185 args: SessionNavigateArgs,
186) -> Result<SessionNavigateResult, ErrorData> {
187 let handle = lookup(server, &args.session_id).await?;
188 let page = handle.page.lock().await;
189 let timeout = Duration::from_secs(args.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
190 let resp = page.goto_and_wait_for_idle(&args.url, timeout).await.map_err(map_err)?;
191 wait::apply_post_navigate(&page, args.wait_for.as_deref(), timeout).await.map_err(map_err)?;
192 let last_navigation = LastNavigation {
193 url: resp.url.clone(),
194 status_code: resp.status_code,
195 antibot: resp.antibot.clone().filter(AntibotVerdict::detected),
196 };
197 *handle.last_navigation.lock().await = Some(last_navigation);
198 let antibot = resp.antibot.filter(AntibotVerdict::detected).map(AntibotInfo::from);
199 Ok(SessionNavigateResult {
200 url: resp.url,
201 status_code: resp.status_code,
202 redirected: resp.redirected,
203 antibot,
204 })
205}
206
207pub async fn content(
208 server: &VoidCrawlServer,
209 args: SessionIdArgs,
210) -> Result<SessionContentResult, ErrorData> {
211 let handle = lookup(server, &args.session_id).await?;
212 let page = handle.page.lock().await;
213 let html = page.content().await.map_err(map_err)?;
214 let title = page.title().await.ok().flatten();
215 let url = page.url().await.ok().flatten();
216 Ok(SessionContentResult { url, title, html })
217}
218
219pub async fn close(
220 server: &VoidCrawlServer,
221 args: SessionIdArgs,
222) -> Result<SessionCloseResult, ErrorData> {
223 let Some(handle) = server.state().sessions.remove(&args.session_id).await else {
224 return Ok(SessionCloseResult { closed: false });
225 };
226 close_handle(handle).await.map_err(map_err)?;
227 Ok(SessionCloseResult { closed: true })
228}
229
230async fn lookup(server: &VoidCrawlServer, id: &str) -> Result<Arc<DedicatedSession>, ErrorData> {
231 server
232 .state()
233 .sessions
234 .get(id)
235 .await
236 .ok_or_else(|| ErrorData::invalid_params(format!("unknown session_id: {id}"), None))
237}
238
239pub async fn close_handle(handle: Arc<DedicatedSession>) -> Result<(), VoidCrawlError> {
241 handle.session.close().await
242}
243
244fn expand_tilde(path: &str) -> String {
249 let Some(rest) = path.strip_prefix('~') else { return path.to_owned() };
250 let Ok(home) = env::var("HOME") else { return path.to_owned() };
251 if rest.is_empty() {
252 home
253 } else if let Some(tail) = rest.strip_prefix('/') {
254 format!("{home}/{tail}")
255 } else {
256 path.to_owned()
257 }
258}