Skip to main content

webfetch/
lib.rs

1//! webfetch — token-efficient web content fetcher.
2//!
3//! The defining feature is **reference-style URL preservation**: instead of
4//! stripping links to their domain (losing the ability to cite or follow
5//! them) or expanding full URLs inline (wasting tokens), links are replaced
6//! with compact `[N]` markers and collected into a recoverable reference list.
7
8// Shared primitives live in webfetch-core; re-export them so both this
9// crate's internal modules (via `crate::compress` / `crate::refs`) and
10// external callers keep a stable path.
11pub use webfetch_core::{charset, compress, http, refs, tls};
12
13pub mod convert;
14pub mod extract;
15pub mod fetch;
16pub mod guard;
17pub mod limits;
18pub mod media;
19pub mod types;
20
21pub use fetch::fetch_page;
22use media::Media;
23use types::{ContentStatus, ContentType, FetchOptions, FetchResult, Metadata, UrlReference};
24
25use scraper::{Html, Selector};
26
27/// Convert already-fetched HTML into a [`FetchResult`] without any network I/O.
28///
29/// Useful for tests and for callers that obtain HTML by other means. Always
30/// treats the input as HTML; use [`convert_body`] for media-aware handling.
31pub fn convert_html(html: &str, source_url: &str, options: &FetchOptions) -> FetchResult {
32    convert_body(html, source_url, Some("text/html"), options)
33}
34
35/// Convert a fetched body to a [`FetchResult`], choosing how to treat it based
36/// on its `Content-Type` (or a sniff of the body). HTML is extracted; JSON is
37/// pretty-printed; other text is passed through verbatim; binary is summarized.
38pub fn convert_body(
39    body: &str,
40    source_url: &str,
41    content_type_header: Option<&str>,
42    options: &FetchOptions,
43) -> FetchResult {
44    let media = media::classify(content_type_header, body);
45
46    // Refuse a pathologically nested document before parsing it — see
47    // `limits`. This is checked here rather than at the HTTP layer so every
48    // entry point (network fetch, `--from-file`, library caller) is covered.
49    if matches!(media, Media::Html) {
50        if let Some(depth) = limits::too_deeply_nested(body) {
51            return too_complex_result(source_url, depth, options.content_type);
52        }
53    }
54
55    let (title, content, references, metadata, output_type) = match &media {
56        Media::Html => convert_html_body(body, source_url, content_type_header, options),
57        Media::Json => {
58            // Pretty-print so an agent reads clean JSON; fall back to raw.
59            let pretty = serde_json::from_str::<serde_json::Value>(body)
60                .ok()
61                .and_then(|v| serde_json::to_string_pretty(&v).ok())
62                .unwrap_or_else(|| body.trim().to_string());
63            (
64                String::new(),
65                budget_plain(&pretty, options.max_tokens),
66                Vec::new(),
67                Metadata::default(),
68                ContentType::Structured,
69            )
70        }
71        Media::Text => (
72            String::new(),
73            budget_plain(body.trim(), options.max_tokens),
74            Vec::new(),
75            Metadata::default(),
76            ContentType::Text,
77        ),
78        Media::Other(ct) => (
79            String::new(),
80            format!(
81                "[non-text content: {ct}, {} bytes — not rendered]",
82                body.len()
83            ),
84            Vec::new(),
85            Metadata::default(),
86            options.content_type,
87        ),
88    };
89
90    FetchResult {
91        token_estimate: compress::estimate_tokens(&content),
92        status: classify_content(&media, &content, body),
93        title,
94        final_url: source_url.to_string(),
95        content,
96        content_type: output_type,
97        media: media.label(),
98        references,
99        metadata,
100        source: source_url.to_string(),
101    }
102}
103
104/// The result returned for a document refused by [`limits::too_deeply_nested`].
105fn too_complex_result(source_url: &str, depth: usize, content_type: ContentType) -> FetchResult {
106    let content = format!(
107        "[document refused: nesting depth {depth} exceeds the limit of {} — \
108         parsing it would take minutes]",
109        limits::MAX_NESTING_DEPTH
110    );
111    FetchResult {
112        token_estimate: compress::estimate_tokens(&content),
113        status: ContentStatus::TooComplex,
114        title: String::new(),
115        final_url: source_url.to_string(),
116        content,
117        content_type,
118        media: "html".to_string(),
119        references: Vec::new(),
120        metadata: Metadata::default(),
121        source: source_url.to_string(),
122    }
123}
124
125/// The HTML branch of [`convert_body`], kept separate because it is the only
126/// one that parses a document — and it now parses exactly once. Title, metadata
127/// and the conversion itself all read the same tree; the previous version
128/// parsed for the first two and then parsed again inside the converter, which
129/// measured at roughly a third of the whole pipeline's cost.
130#[allow(clippy::type_complexity)]
131fn convert_html_body(
132    body: &str,
133    source_url: &str,
134    content_type_header: Option<&str>,
135    options: &FetchOptions,
136) -> (String, String, Vec<UrlReference>, Metadata, ContentType) {
137    let doc = Html::parse_document(body);
138    let title = extract::extract_title(&doc);
139    let mut metadata = extract::extract_metadata(&doc);
140    metadata.charset = undecodable_charset(content_type_header, &doc);
141
142    let converted = convert::convert_parsed(&doc, source_url, options.content_type);
143    // Drop a leading body line that merely repeats the title (common when the
144    // title was derived from the page's first <h1>, which also opens the body).
145    let body_text = strip_duplicate_title(&title, converted.content);
146
147    let (content, references) = match options.content_type {
148        // Reference-style text: the body cites `[N]`, so the budget rule is
149        // "truncate the body, then keep the references it still cites".
150        ContentType::Text => {
151            let (content, kept) =
152                refs::fit_to_budget(&body_text, &converted.references, options.max_tokens);
153            let references = converted
154                .references
155                .into_iter()
156                .filter(|r| kept.contains(&r.index))
157                .collect();
158            (content, references)
159        }
160        // Markdown carries its links inline, so there are no markers to match
161        // on: keep the references whose URL survives in the truncated text.
162        ContentType::Markdown => {
163            let content = budget_plain(&body_text, options.max_tokens);
164            let references = converted
165                .references
166                .into_iter()
167                .filter(|r| content.contains(&r.url))
168                .collect();
169            (content, references)
170        }
171        // The content is JSON; truncating its text would produce invalid JSON,
172        // so blocks are dropped instead and the document re-serialized.
173        ContentType::Structured => budget_structured(&doc, source_url, options.max_tokens),
174    };
175
176    (title, content, references, metadata, options.content_type)
177}
178
179/// Truncate free-form text (no reference markers to preserve).
180fn budget_plain(text: &str, max_tokens: Option<usize>) -> String {
181    match max_tokens {
182        Some(max) => compress::truncate_to_tokens(text, max),
183        None => text.to_string(),
184    }
185}
186
187/// Fit a structured document to the token budget by dropping trailing blocks
188/// and re-serializing, so the output is always valid JSON.
189///
190/// Block count is monotone in serialized size, so a binary search finds the
191/// largest prefix that fits in a handful of serializations rather than one per
192/// dropped block.
193fn budget_structured(
194    doc: &Html,
195    source_url: &str,
196    max_tokens: Option<usize>,
197) -> (String, Vec<UrlReference>) {
198    use convert::structured::{to_json, StructuredDoc};
199
200    let parsed = convert::structured::structured(doc, source_url);
201
202    let render = |n: usize| -> (String, Vec<UrlReference>) {
203        let blocks = parsed.blocks[..n].to_vec();
204        let cited = refs::cited_indices(
205            &blocks
206                .iter()
207                .map(|b| b.text.as_str())
208                .collect::<Vec<_>>()
209                .join(" "),
210        );
211        let references: Vec<UrlReference> = parsed
212            .references
213            .iter()
214            .filter(|r| cited.contains(&r.index))
215            .cloned()
216            .collect();
217        let json = to_json(&StructuredDoc {
218            blocks,
219            references: references.clone(),
220        });
221        (json, references)
222    };
223
224    let Some(max) = max_tokens else {
225        return render(parsed.blocks.len());
226    };
227
228    let full = render(parsed.blocks.len());
229    if compress::estimate_tokens(&full.0) <= max {
230        return full;
231    }
232
233    // Largest block count that fits.
234    let (mut lo, mut hi) = (0usize, parsed.blocks.len());
235    while lo < hi {
236        let mid = (lo + hi).div_ceil(2);
237        if compress::estimate_tokens(&render(mid).0) <= max {
238            lo = mid;
239        } else {
240            hi = mid - 1;
241        }
242    }
243    render(lo)
244}
245
246/// Decide whether an extraction actually produced content, and if not, whether
247/// the page looks like it needs a browser.
248fn classify_content(media: &Media, content: &str, raw: &str) -> ContentStatus {
249    let empty = match media {
250        // An empty structured document still serializes to a JSON envelope.
251        Media::Html => content.trim().is_empty() || is_empty_structured(content),
252        _ => content.trim().is_empty(),
253    };
254    if !empty {
255        return ContentStatus::Ok;
256    }
257    if matches!(media, Media::Html) && has_scripts(raw) {
258        return ContentStatus::NeedsJs;
259    }
260    ContentStatus::Empty
261}
262
263/// A structured render of a page with nothing in it.
264fn is_empty_structured(content: &str) -> bool {
265    serde_json::from_str::<serde_json::Value>(content)
266        .ok()
267        .and_then(|v| {
268            v.get("blocks")
269                .and_then(|b| b.as_array())
270                .map(|b| b.is_empty())
271        })
272        .unwrap_or(false)
273}
274
275/// Does the raw document carry scripts? A shell with scripts and no text is a
276/// client-rendered page, not an empty one.
277///
278/// Scans in place rather than lowercasing the whole body: this runs on bodies
279/// of up to the 5 MiB cap, and allocating a second copy of one to answer a
280/// yes/no question is not worth it.
281fn has_scripts(raw: &str) -> bool {
282    raw.as_bytes()
283        .windows(7)
284        .any(|w| w.eq_ignore_ascii_case(b"<script"))
285}
286
287/// Report a declared charset no known encoding matches.
288///
289/// The network path decodes the body before it reaches here (see
290/// `webfetch_core::charset`), so this only fires for offline callers passing a
291/// header in directly, and only for labels `encoding_rs` does not recognize at
292/// all — every encoding in the WHATWG standard decodes exactly.
293fn undecodable_charset(header: Option<&str>, doc: &Html) -> Option<String> {
294    let declared = header.and_then(charset::from_content_type).or_else(|| {
295        let sel = Selector::parse("meta[charset]").ok()?;
296        doc.select(&sel)
297            .next()
298            .and_then(|el| el.value().attr("charset"))
299            .map(|c| c.to_string())
300    })?;
301
302    match charset::classify(&declared) {
303        charset::Charset::Unknown(name) => Some(name),
304        _ => None,
305    }
306}
307
308/// When the title was derived from the page's first heading, the body repeats
309/// it as its opening line. Drop that leading line when it normalizes to the
310/// same text as `title`. Conservative: only an exact normalized match of the
311/// *first* line is removed, so genuine content is never lost.
312fn strip_duplicate_title(title: &str, content: String) -> String {
313    if title.is_empty() {
314        return content;
315    }
316    let mut parts = content.splitn(2, '\n');
317    let first = parts.next().unwrap_or("");
318    if compress::compress_text(first) == compress::compress_text(title) {
319        return parts
320            .next()
321            .unwrap_or("")
322            .trim_start_matches('\n')
323            .to_string();
324    }
325    content
326}
327
328/// Fetch a URL and convert it according to `options`.
329pub async fn fetch_and_convert(options: FetchOptions) -> anyhow::Result<FetchResult> {
330    let page = fetch::fetch_page(&options.url, options.timeout_secs, &options.tls).await?;
331    let mut result = convert_body(
332        &page.body,
333        &page.final_url,
334        page.content_type.as_deref(),
335        &options,
336    );
337    // `source` is what was asked for, `final_url` is where it came from. They
338    // were both set to the post-redirect URL, which discarded the request.
339    result.source = options.url;
340    result.final_url = page.final_url;
341    // The fetch layer knows what it actually decoded with, including the
342    // `<meta charset>` fallback, so its verdict wins over re-deriving one here.
343    if page.undecodable_charset.is_some() {
344        result.metadata.charset = page.undecodable_charset;
345    }
346    Ok(result)
347}
348
349/// Parse a content-type string ("text" | "markdown" | "structured").
350pub fn parse_content_type(s: &str) -> ContentType {
351    ContentType::parse(s)
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    /// Only labels no encoding matches are reported. Everything in the WHATWG
359    /// standard decodes exactly, so flagging it would be noise.
360    #[test]
361    fn only_unrecognized_charsets_are_reported() {
362        let doc = Html::parse_document("<html></html>");
363        for header in [
364            "text/html; charset=utf-8",
365            "text/html; charset=ISO-8859-1",
366            "text/html; charset=Shift_JIS",
367            "text/html; charset=GBK",
368        ] {
369            assert_eq!(undecodable_charset(Some(header), &doc), None, "{header}");
370        }
371        let doc = Html::parse_document(r#"<html><head><meta charset="x-made-up"></head></html>"#);
372        assert_eq!(undecodable_charset(None, &doc), Some("x-made-up".into()));
373    }
374
375    #[test]
376    fn script_shell_is_needs_js_not_empty() {
377        let html =
378            "<html><body><div id=\"root\"></div><script src=\"/app.js\"></script></body></html>";
379        let r = convert_html(html, "https://spa.test/", &FetchOptions::default());
380        assert_eq!(r.status, ContentStatus::NeedsJs);
381        assert!(r.status.is_failure());
382    }
383
384    #[test]
385    fn a_page_with_text_is_ok() {
386        let html = "<html><body><article><p>Real words here.</p></article></body></html>";
387        let r = convert_html(html, "https://x.test/", &FetchOptions::default());
388        assert_eq!(r.status, ContentStatus::Ok);
389    }
390}