1use std::{
8 sync::Arc,
9 time::{Duration, Instant},
10};
11
12use rmcp::ErrorData;
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15use tokio::time::timeout;
16use void_crawl_core::{AntibotVerdict, Page, VoidCrawlError};
17
18use crate::{
19 errors::map_err,
20 server::VoidCrawlServer,
21 sessions::DedicatedSession,
22 tools::{fetch::AntibotInfo, wait},
23};
24
25pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
26pub const DEFAULT_MAX_CHARS: usize = 12_000;
27pub const HARD_MAX_CHARS: usize = 60_000;
28
29#[derive(Debug, Clone, Deserialize, JsonSchema, Default)]
30pub struct FetchSnapshotArgs {
31 pub url: String,
33 #[serde(default)]
35 pub wait_for: Option<String>,
36 #[serde(default)]
38 pub timeout_secs: Option<u64>,
39 #[serde(default)]
42 pub max_chars: Option<usize>,
43}
44
45#[derive(Debug, Clone, Deserialize, JsonSchema, Default)]
46pub struct SessionSnapshotArgs {
47 pub session_id: String,
48 #[serde(default)]
51 pub max_chars: Option<usize>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
55pub struct HeadingSnapshot {
56 pub level: u8,
57 pub text: String,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
61pub struct TextBlockSnapshot {
62 pub tag: String,
63 pub text: String,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
67pub struct LinkSnapshot {
68 pub text: String,
69 pub href: String,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
73pub struct ControlSnapshot {
74 pub tag: String,
75 #[serde(default)]
76 pub r#type: Option<String>,
77 #[serde(default)]
78 pub role: Option<String>,
79 #[serde(default)]
80 pub name: Option<String>,
81 #[serde(default)]
82 pub placeholder: Option<String>,
83 #[serde(default)]
84 pub disabled: bool,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
88pub struct FormSnapshot {
89 #[serde(default)]
90 pub action: Option<String>,
91 #[serde(default)]
92 pub method: Option<String>,
93 #[serde(default)]
94 pub controls: Vec<ControlSnapshot>,
95}
96
97#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default)]
98pub struct SnapshotCounts {
99 pub headings: usize,
100 pub text_blocks: usize,
101 pub links: usize,
102 pub controls: usize,
103 pub forms: usize,
104}
105
106#[derive(Debug, Clone, Serialize, JsonSchema)]
107pub struct SnapshotStats {
108 pub max_chars: usize,
109 pub total_chars: usize,
110 pub returned_chars: usize,
111 pub truncated: bool,
112 pub total: SnapshotCounts,
113 pub returned: SnapshotCounts,
114 pub omitted: SnapshotCounts,
115}
116
117#[derive(Debug, Serialize, JsonSchema)]
118pub struct PageSnapshot {
119 pub url: String,
120 pub title: Option<String>,
121 pub status_code: Option<u16>,
122 pub redirected: Option<bool>,
123 pub antibot: Option<AntibotInfo>,
124 pub headings: Vec<HeadingSnapshot>,
125 pub text_blocks: Vec<TextBlockSnapshot>,
126 pub links: Vec<LinkSnapshot>,
127 pub controls: Vec<ControlSnapshot>,
128 pub forms: Vec<FormSnapshot>,
129 pub stats: SnapshotStats,
130}
131
132#[derive(Debug, Clone, Deserialize, Default)]
133struct RawSnapshot {
134 #[serde(default)]
135 url: String,
136 #[serde(default)]
137 title: Option<String>,
138 #[serde(default)]
139 headings: Vec<HeadingSnapshot>,
140 #[serde(default)]
141 text_blocks: Vec<TextBlockSnapshot>,
142 #[serde(default)]
143 links: Vec<LinkSnapshot>,
144 #[serde(default)]
145 controls: Vec<ControlSnapshot>,
146 #[serde(default)]
147 forms: Vec<FormSnapshot>,
148 #[serde(default)]
149 total: SnapshotCounts,
150}
151
152#[derive(Debug, Default)]
153struct SnapshotMeta {
154 status_code: Option<u16>,
155 redirected: Option<bool>,
156 antibot: Option<AntibotInfo>,
157}
158
159pub async fn fetch(
160 server: &VoidCrawlServer,
161 args: FetchSnapshotArgs,
162) -> Result<PageSnapshot, VoidCrawlError> {
163 let pool = server.state().pool().await?;
164 let tab = pool.acquire().await?;
165 let result = fetch_on_tab(&tab.page, args).await;
166 pool.release(tab).await;
167 result
168}
169
170async fn fetch_on_tab(
171 page: &Page,
172 args: FetchSnapshotArgs,
173) -> Result<PageSnapshot, VoidCrawlError> {
174 let total_timeout = Duration::from_secs(args.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
175 let start = Instant::now();
176 let resp = page.goto_and_wait_for_idle(&args.url, total_timeout).await?;
177 wait::apply_post_navigate(page, args.wait_for.as_deref(), total_timeout).await?;
178 let remaining = total_timeout.saturating_sub(start.elapsed());
179 let raw = timeout(remaining, collect_raw(page))
180 .await
181 .map_err(|_| VoidCrawlError::Timeout("snapshot read exceeded timeout_secs".into()))??;
182 let meta = SnapshotMeta {
183 status_code: resp.status_code,
184 redirected: Some(resp.redirected),
185 antibot: resp.antibot.filter(AntibotVerdict::detected).map(AntibotInfo::from),
186 };
187 Ok(apply_budget(raw, meta, args.max_chars))
188}
189
190pub async fn session(
191 server: &VoidCrawlServer,
192 args: SessionSnapshotArgs,
193) -> Result<PageSnapshot, ErrorData> {
194 let handle = lookup(server, &args.session_id).await?;
195 let last_navigation = handle.last_navigation.lock().await.clone();
196 let meta = SnapshotMeta {
197 status_code: last_navigation.as_ref().and_then(|nav| nav.status_code),
198 redirected: None,
199 antibot: last_navigation
200 .and_then(|nav| nav.antibot)
201 .filter(AntibotVerdict::detected)
202 .map(AntibotInfo::from),
203 };
204 let page = handle.page.lock().await;
205 let raw = collect_raw(&page).await.map_err(map_err)?;
206 Ok(apply_budget(raw, meta, args.max_chars))
207}
208
209async fn lookup(server: &VoidCrawlServer, id: &str) -> Result<Arc<DedicatedSession>, ErrorData> {
210 server
211 .state()
212 .sessions
213 .get(id)
214 .await
215 .ok_or_else(|| ErrorData::invalid_params(format!("unknown session_id: {id}"), None))
216}
217
218async fn collect_raw(page: &Page) -> Result<RawSnapshot, VoidCrawlError> {
219 let value = page.evaluate_js(SNAPSHOT_JS).await?;
220 serde_json::from_value(value)
221 .map_err(|e| VoidCrawlError::JsEvalError(format!("snapshot decode failed: {e}")))
222}
223
224fn apply_budget(
225 raw: RawSnapshot,
226 meta: SnapshotMeta,
227 requested_max_chars: Option<usize>,
228) -> PageSnapshot {
229 let max_chars = requested_max_chars.unwrap_or(DEFAULT_MAX_CHARS).min(HARD_MAX_CHARS);
230 let total = normalize_totals(&raw);
231 let total_chars = chars_headings(&raw.headings)
232 + chars_text_blocks(&raw.text_blocks)
233 + chars_links(&raw.links)
234 + chars_controls(&raw.controls)
235 + chars_forms(&raw.forms);
236
237 let mut returned_chars = 0usize;
238 let headings = take_entries(raw.headings, max_chars, &mut returned_chars, heading_chars);
239 let text_blocks =
240 take_entries(raw.text_blocks, max_chars, &mut returned_chars, text_block_chars);
241 let links = take_entries(raw.links, max_chars, &mut returned_chars, link_chars);
242 let controls = take_entries(raw.controls, max_chars, &mut returned_chars, control_chars);
243 let forms = take_entries(raw.forms, max_chars, &mut returned_chars, form_chars);
244
245 let returned = SnapshotCounts {
246 headings: headings.len(),
247 text_blocks: text_blocks.len(),
248 links: links.len(),
249 controls: controls.len(),
250 forms: forms.len(),
251 };
252 let omitted = SnapshotCounts {
253 headings: total.headings.saturating_sub(returned.headings),
254 text_blocks: total.text_blocks.saturating_sub(returned.text_blocks),
255 links: total.links.saturating_sub(returned.links),
256 controls: total.controls.saturating_sub(returned.controls),
257 forms: total.forms.saturating_sub(returned.forms),
258 };
259 let truncated = returned_chars < total_chars
260 || omitted.headings > 0
261 || omitted.text_blocks > 0
262 || omitted.links > 0
263 || omitted.controls > 0
264 || omitted.forms > 0
265 || requested_max_chars.is_some_and(|n| n > HARD_MAX_CHARS);
266
267 PageSnapshot {
268 url: raw.url,
269 title: raw.title,
270 status_code: meta.status_code,
271 redirected: meta.redirected,
272 antibot: meta.antibot,
273 headings,
274 text_blocks,
275 links,
276 controls,
277 forms,
278 stats: SnapshotStats {
279 max_chars,
280 total_chars,
281 returned_chars,
282 truncated,
283 total,
284 returned,
285 omitted,
286 },
287 }
288}
289
290fn normalize_totals(raw: &RawSnapshot) -> SnapshotCounts {
291 SnapshotCounts {
292 headings: raw.total.headings.max(raw.headings.len()),
293 text_blocks: raw.total.text_blocks.max(raw.text_blocks.len()),
294 links: raw.total.links.max(raw.links.len()),
295 controls: raw.total.controls.max(raw.controls.len()),
296 forms: raw.total.forms.max(raw.forms.len()),
297 }
298}
299
300fn take_entries<T>(
301 entries: Vec<T>,
302 max_chars: usize,
303 returned_chars: &mut usize,
304 chars: fn(&T) -> usize,
305) -> Vec<T> {
306 let mut kept = Vec::new();
307 for entry in entries {
308 let entry_chars = chars(&entry);
309 if *returned_chars + entry_chars > max_chars {
310 break;
311 }
312 *returned_chars += entry_chars;
313 kept.push(entry);
314 }
315 kept
316}
317
318fn chars(s: &str) -> usize {
319 s.chars().count()
320}
321
322fn opt_chars(s: Option<&String>) -> usize {
323 s.map_or(0, |value| chars(value))
324}
325
326fn heading_chars(h: &HeadingSnapshot) -> usize {
327 chars(&h.text) + 2
328}
329
330fn text_block_chars(t: &TextBlockSnapshot) -> usize {
331 chars(&t.tag) + chars(&t.text)
332}
333
334fn link_chars(l: &LinkSnapshot) -> usize {
335 chars(&l.text) + chars(&l.href)
336}
337
338fn control_chars(c: &ControlSnapshot) -> usize {
339 chars(&c.tag)
340 + opt_chars(c.r#type.as_ref())
341 + opt_chars(c.role.as_ref())
342 + opt_chars(c.name.as_ref())
343 + opt_chars(c.placeholder.as_ref())
344 + usize::from(c.disabled)
345}
346
347fn form_chars(f: &FormSnapshot) -> usize {
348 opt_chars(f.action.as_ref()) + opt_chars(f.method.as_ref()) + chars_controls(&f.controls)
349}
350
351fn chars_headings(v: &[HeadingSnapshot]) -> usize {
352 v.iter().map(heading_chars).sum()
353}
354
355fn chars_text_blocks(v: &[TextBlockSnapshot]) -> usize {
356 v.iter().map(text_block_chars).sum()
357}
358
359fn chars_links(v: &[LinkSnapshot]) -> usize {
360 v.iter().map(link_chars).sum()
361}
362
363fn chars_controls(v: &[ControlSnapshot]) -> usize {
364 v.iter().map(control_chars).sum()
365}
366
367fn chars_forms(v: &[FormSnapshot]) -> usize {
368 v.iter().map(form_chars).sum()
369}
370
371const SNAPSHOT_JS: &str = r#"
372(() => {
373 const MAX = {
374 headings: 80,
375 textBlocks: 240,
376 links: 160,
377 controls: 160,
378 forms: 60,
379 formControls: 30,
380 textChars: 700,
381 smallChars: 220
382 };
383 const clean = (value) => String(value || '').replace(/\s+/g, ' ').trim();
384 const clip = (value, limit) => {
385 const text = clean(value);
386 return text.length > limit ? text.slice(0, Math.max(0, limit - 3)) + '...' : text;
387 };
388 const visible = (el) => {
389 if (!el || !el.isConnected) return false;
390 const style = window.getComputedStyle(el);
391 if (!style || style.display === 'none' || style.visibility === 'hidden') return false;
392 const rect = el.getBoundingClientRect();
393 return rect.width > 0 && rect.height > 0;
394 };
395 const attr = (el, name) => {
396 const value = el.getAttribute(name);
397 return value == null || value === '' ? null : clip(value, MAX.smallChars);
398 };
399 const labelText = (el) => {
400 const id = el.id ? CSS.escape(el.id) : null;
401 const label = id ? document.querySelector(`label[for="${id}"]`) : null;
402 return clip(
403 el.getAttribute('aria-label')
404 || el.getAttribute('title')
405 || el.getAttribute('placeholder')
406 || (label && label.textContent)
407 || el.value
408 || el.textContent
409 || el.name
410 || '',
411 MAX.smallChars
412 );
413 };
414 const control = (el) => ({
415 tag: el.tagName.toLowerCase(),
416 type: attr(el, 'type'),
417 role: attr(el, 'role'),
418 name: labelText(el) || null,
419 placeholder: attr(el, 'placeholder'),
420 disabled: Boolean(el.disabled || el.getAttribute('aria-disabled') === 'true')
421 });
422 const all = (selector) => Array.from(document.querySelectorAll(selector)).filter(visible);
423 const unique = (items) => Array.from(new Set(items));
424
425 const headingNodes = all('h1,h2,h3,h4,h5,h6');
426 const headings = headingNodes.slice(0, MAX.headings).map((el) => ({
427 level: Number(el.tagName.slice(1)),
428 text: clip(el.textContent, MAX.smallChars)
429 })).filter((h) => h.text);
430
431 const textNodes = unique([
432 ...all('main p, main li, article p, article li, section p, blockquote, body > p, td, th'),
433 ...all('[role="main"] p, [role="article"] p')
434 ]).filter((el) => clean(el.textContent).length >= 20);
435 const text_blocks = textNodes.slice(0, MAX.textBlocks).map((el) => ({
436 tag: el.tagName.toLowerCase(),
437 text: clip(el.textContent, MAX.textChars)
438 })).filter((b) => b.text);
439
440 const linkNodes = all('a[href]');
441 const links = linkNodes.slice(0, MAX.links).map((el) => ({
442 text: clip(el.textContent || el.getAttribute('aria-label') || el.href, MAX.smallChars),
443 href: clip(el.href, MAX.smallChars)
444 })).filter((l) => l.href);
445
446 const controlNodes = all('button,input,select,textarea,[role="button"],[role="link"],[role="textbox"],[role="combobox"],[contenteditable="true"]');
447 const controls = controlNodes.slice(0, MAX.controls).map(control);
448
449 const formNodes = all('form');
450 const forms = formNodes.slice(0, MAX.forms).map((form) => {
451 const fields = Array.from(form.querySelectorAll('button,input,select,textarea,[role="button"],[role="textbox"],[role="combobox"]'))
452 .filter(visible)
453 .slice(0, MAX.formControls)
454 .map(control);
455 return {
456 action: attr(form, 'action') || (form.action ? clip(form.action, MAX.smallChars) : null),
457 method: clip(form.method || 'get', 20).toLowerCase(),
458 controls: fields
459 };
460 });
461
462 return {
463 url: location.href,
464 title: document.title || null,
465 headings,
466 text_blocks,
467 links,
468 controls,
469 forms,
470 total: {
471 headings: headingNodes.length,
472 text_blocks: textNodes.length,
473 links: linkNodes.length,
474 controls: controlNodes.length,
475 forms: formNodes.length
476 }
477 };
478})()
479"#;
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 fn raw_with_text_blocks(count: usize) -> RawSnapshot {
486 RawSnapshot {
487 url: "data:text/html,test".into(),
488 title: Some("Test".into()),
489 text_blocks: (0..count)
490 .map(|i| TextBlockSnapshot {
491 tag: "p".into(),
492 text: format!("block {i} abcdefghijklmnopqrstuvwxyz"),
493 })
494 .collect(),
495 total: SnapshotCounts { text_blocks: count, ..SnapshotCounts::default() },
496 ..RawSnapshot::default()
497 }
498 }
499
500 #[test]
501 fn max_chars_defaults_and_caps() {
502 let snapshot = apply_budget(raw_with_text_blocks(1), SnapshotMeta::default(), None);
503 assert_eq!(snapshot.stats.max_chars, DEFAULT_MAX_CHARS);
504
505 let snapshot = apply_budget(
506 raw_with_text_blocks(10),
507 SnapshotMeta::default(),
508 Some(HARD_MAX_CHARS + 1),
509 );
510 assert_eq!(snapshot.stats.max_chars, HARD_MAX_CHARS);
511 assert!(snapshot.stats.truncated);
512 }
513
514 #[test]
515 fn budget_enforces_returned_chars_and_omission_counts() {
516 let snapshot = apply_budget(raw_with_text_blocks(20), SnapshotMeta::default(), Some(120));
517
518 assert!(snapshot.stats.returned_chars <= 120);
519 assert!(snapshot.stats.truncated);
520 assert_eq!(snapshot.stats.total.text_blocks, 20);
521 assert_eq!(snapshot.stats.omitted.text_blocks, 20 - snapshot.stats.returned.text_blocks);
522 assert_eq!(snapshot.text_blocks.len(), snapshot.stats.returned.text_blocks);
523 }
524}