1pub 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
27pub fn convert_html(html: &str, source_url: &str, options: &FetchOptions) -> FetchResult {
32 convert_body(html, source_url, Some("text/html"), options)
33}
34
35pub 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 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 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
104fn 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#[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 let body_text = strip_duplicate_title(&title, converted.content);
146
147 let (content, references) = match options.content_type {
148 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 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 ContentType::Structured => budget_structured(&doc, source_url, options.max_tokens),
174 };
175
176 (title, content, references, metadata, options.content_type)
177}
178
179fn 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
187fn 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 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
246fn classify_content(media: &Media, content: &str, raw: &str) -> ContentStatus {
249 let empty = match media {
250 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
263fn 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
275fn has_scripts(raw: &str) -> bool {
282 raw.as_bytes()
283 .windows(7)
284 .any(|w| w.eq_ignore_ascii_case(b"<script"))
285}
286
287fn 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
308fn 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
328pub 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 result.source = options.url;
340 result.final_url = page.final_url;
341 if page.undecodable_charset.is_some() {
344 result.metadata.charset = page.undecodable_charset;
345 }
346 Ok(result)
347}
348
349pub fn parse_content_type(s: &str) -> ContentType {
351 ContentType::parse(s)
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[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}