pub use webfetch_core::{charset, compress, http, refs, tls};
pub mod convert;
pub mod extract;
pub mod fetch;
pub mod guard;
pub mod limits;
pub mod media;
pub mod types;
pub use fetch::fetch_page;
use media::Media;
use types::{ContentStatus, ContentType, FetchOptions, FetchResult, Metadata, UrlReference};
use scraper::{Html, Selector};
pub fn convert_html(html: &str, source_url: &str, options: &FetchOptions) -> FetchResult {
convert_body(html, source_url, Some("text/html"), options)
}
pub fn convert_body(
body: &str,
source_url: &str,
content_type_header: Option<&str>,
options: &FetchOptions,
) -> FetchResult {
let media = media::classify(content_type_header, body);
if matches!(media, Media::Html) {
if let Some(depth) = limits::too_deeply_nested(body) {
return too_complex_result(source_url, depth, options.content_type);
}
}
let (title, content, references, metadata, output_type) = match &media {
Media::Html => convert_html_body(body, source_url, content_type_header, options),
Media::Json => {
let pretty = serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| serde_json::to_string_pretty(&v).ok())
.unwrap_or_else(|| body.trim().to_string());
(
String::new(),
budget_plain(&pretty, options.max_tokens),
Vec::new(),
Metadata::default(),
ContentType::Structured,
)
}
Media::Text => (
String::new(),
budget_plain(body.trim(), options.max_tokens),
Vec::new(),
Metadata::default(),
ContentType::Text,
),
Media::Other(ct) => (
String::new(),
format!(
"[non-text content: {ct}, {} bytes — not rendered]",
body.len()
),
Vec::new(),
Metadata::default(),
options.content_type,
),
};
FetchResult {
token_estimate: compress::estimate_tokens(&content),
status: classify_content(&media, &content, body),
title,
final_url: source_url.to_string(),
content,
content_type: output_type,
media: media.label(),
references,
metadata,
source: source_url.to_string(),
}
}
fn too_complex_result(source_url: &str, depth: usize, content_type: ContentType) -> FetchResult {
let content = format!(
"[document refused: nesting depth {depth} exceeds the limit of {} — \
parsing it would take minutes]",
limits::MAX_NESTING_DEPTH
);
FetchResult {
token_estimate: compress::estimate_tokens(&content),
status: ContentStatus::TooComplex,
title: String::new(),
final_url: source_url.to_string(),
content,
content_type,
media: "html".to_string(),
references: Vec::new(),
metadata: Metadata::default(),
source: source_url.to_string(),
}
}
#[allow(clippy::type_complexity)]
fn convert_html_body(
body: &str,
source_url: &str,
content_type_header: Option<&str>,
options: &FetchOptions,
) -> (String, String, Vec<UrlReference>, Metadata, ContentType) {
let doc = Html::parse_document(body);
let title = extract::extract_title(&doc);
let mut metadata = extract::extract_metadata(&doc);
metadata.charset = undecodable_charset(content_type_header, &doc);
let converted = convert::convert_parsed(&doc, source_url, options.content_type);
let body_text = strip_duplicate_title(&title, converted.content);
let (content, references) = match options.content_type {
ContentType::Text => {
let (content, kept) =
refs::fit_to_budget(&body_text, &converted.references, options.max_tokens);
let references = converted
.references
.into_iter()
.filter(|r| kept.contains(&r.index))
.collect();
(content, references)
}
ContentType::Markdown => {
let content = budget_plain(&body_text, options.max_tokens);
let references = converted
.references
.into_iter()
.filter(|r| content.contains(&r.url))
.collect();
(content, references)
}
ContentType::Structured => budget_structured(&doc, source_url, options.max_tokens),
};
(title, content, references, metadata, options.content_type)
}
fn budget_plain(text: &str, max_tokens: Option<usize>) -> String {
match max_tokens {
Some(max) => compress::truncate_to_tokens(text, max),
None => text.to_string(),
}
}
fn budget_structured(
doc: &Html,
source_url: &str,
max_tokens: Option<usize>,
) -> (String, Vec<UrlReference>) {
use convert::structured::{to_json, StructuredDoc};
let parsed = convert::structured::structured(doc, source_url);
let render = |n: usize| -> (String, Vec<UrlReference>) {
let blocks = parsed.blocks[..n].to_vec();
let cited = refs::cited_indices(
&blocks
.iter()
.map(|b| b.text.as_str())
.collect::<Vec<_>>()
.join(" "),
);
let references: Vec<UrlReference> = parsed
.references
.iter()
.filter(|r| cited.contains(&r.index))
.cloned()
.collect();
let json = to_json(&StructuredDoc {
blocks,
references: references.clone(),
});
(json, references)
};
let Some(max) = max_tokens else {
return render(parsed.blocks.len());
};
let full = render(parsed.blocks.len());
if compress::estimate_tokens(&full.0) <= max {
return full;
}
let (mut lo, mut hi) = (0usize, parsed.blocks.len());
while lo < hi {
let mid = (lo + hi).div_ceil(2);
if compress::estimate_tokens(&render(mid).0) <= max {
lo = mid;
} else {
hi = mid - 1;
}
}
render(lo)
}
fn classify_content(media: &Media, content: &str, raw: &str) -> ContentStatus {
let empty = match media {
Media::Html => content.trim().is_empty() || is_empty_structured(content),
_ => content.trim().is_empty(),
};
if !empty {
return ContentStatus::Ok;
}
if matches!(media, Media::Html) && has_scripts(raw) {
return ContentStatus::NeedsJs;
}
ContentStatus::Empty
}
fn is_empty_structured(content: &str) -> bool {
serde_json::from_str::<serde_json::Value>(content)
.ok()
.and_then(|v| {
v.get("blocks")
.and_then(|b| b.as_array())
.map(|b| b.is_empty())
})
.unwrap_or(false)
}
fn has_scripts(raw: &str) -> bool {
raw.as_bytes()
.windows(7)
.any(|w| w.eq_ignore_ascii_case(b"<script"))
}
fn undecodable_charset(header: Option<&str>, doc: &Html) -> Option<String> {
let declared = header.and_then(charset::from_content_type).or_else(|| {
let sel = Selector::parse("meta[charset]").ok()?;
doc.select(&sel)
.next()
.and_then(|el| el.value().attr("charset"))
.map(|c| c.to_string())
})?;
match charset::classify(&declared) {
charset::Charset::Unsupported(name) => Some(name),
_ => None,
}
}
fn strip_duplicate_title(title: &str, content: String) -> String {
if title.is_empty() {
return content;
}
let mut parts = content.splitn(2, '\n');
let first = parts.next().unwrap_or("");
if compress::compress_text(first) == compress::compress_text(title) {
return parts
.next()
.unwrap_or("")
.trim_start_matches('\n')
.to_string();
}
content
}
pub async fn fetch_and_convert(options: FetchOptions) -> anyhow::Result<FetchResult> {
let page = fetch::fetch_page(&options.url, options.timeout_secs, &options.tls).await?;
let mut result = convert_body(
&page.body,
&page.final_url,
page.content_type.as_deref(),
&options,
);
result.source = options.url;
result.final_url = page.final_url;
if page.undecodable_charset.is_some() {
result.metadata.charset = page.undecodable_charset;
}
Ok(result)
}
pub fn parse_content_type(s: &str) -> ContentType {
ContentType::parse(s)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_undecodable_charsets_are_reported() {
let doc = Html::parse_document("<html></html>");
assert_eq!(
undecodable_charset(Some("text/html; charset=utf-8"), &doc),
None
);
assert_eq!(
undecodable_charset(Some("text/html; charset=ISO-8859-1"), &doc),
None
);
let doc = Html::parse_document(r#"<html><head><meta charset="shift_jis"></head></html>"#);
assert_eq!(undecodable_charset(None, &doc), Some("shift_jis".into()));
}
#[test]
fn script_shell_is_needs_js_not_empty() {
let html =
"<html><body><div id=\"root\"></div><script src=\"/app.js\"></script></body></html>";
let r = convert_html(html, "https://spa.test/", &FetchOptions::default());
assert_eq!(r.status, ContentStatus::NeedsJs);
assert!(r.status.is_failure());
}
#[test]
fn a_page_with_text_is_ok() {
let html = "<html><body><article><p>Real words here.</p></article></body></html>";
let r = convert_html(html, "https://x.test/", &FetchOptions::default());
assert_eq!(r.status, ContentStatus::Ok);
}
}