Skip to main content

voidcrawl_mcp/tools/
snapshot.rs

1//! Compact rendered-page snapshots for MCP clients.
2//!
3//! This is intentionally a lossy perception surface. It gives agents enough
4//! structure to orient on large rendered pages without pulling raw HTML into
5//! context; schema-grade extraction remains a caller concern.
6
7use 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    /// Absolute URL to load.
32    pub url:          String,
33    /// Optional wait strategy: "networkidle" (default) or "selector:<css>".
34    #[serde(default)]
35    pub wait_for:     Option<String>,
36    /// Navigation + wait timeout in seconds (default 30).
37    #[serde(default)]
38    pub timeout_secs: Option<u64>,
39    /// Approximate character budget for returned snapshot sections.
40    /// Defaults to 12000 and is hard-capped at 60000.
41    #[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    /// Approximate character budget for returned snapshot sections.
49    /// Defaults to 12000 and is hard-capped at 60000.
50    #[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}