use readabilityrs::{Readability, ReadabilityOptions};
use crate::error::{FetchError, SafeUrl};
const MIN_CONTENT_LEN: usize = 100;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Extraction {
Readability,
RawHtml,
Plain,
}
impl Extraction {
pub(crate) fn label(self) -> &'static str {
match self {
Extraction::Readability => "readability",
Extraction::RawHtml => "raw-html",
Extraction::Plain => "plain",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Route {
Html,
Plain {
structured: bool,
},
}
pub(crate) fn classify(mime: &mime::Mime) -> Option<Route> {
let type_ = mime.type_();
let subtype = mime.subtype();
let suffix = mime.suffix();
let is_html = (type_ == mime::TEXT && subtype == mime::HTML)
|| (type_ == mime::APPLICATION && subtype == "xhtml" && suffix == Some(mime::XML));
if is_html {
return Some(Route::Html);
}
let is_json_or_xml = subtype == mime::JSON
|| subtype == mime::XML
|| suffix == Some(mime::JSON)
|| suffix == Some(mime::XML);
if type_ == mime::APPLICATION && is_json_or_xml {
return Some(Route::Plain { structured: true });
}
if type_ == mime::TEXT {
return Some(Route::Plain {
structured: is_json_or_xml,
});
}
None
}
pub(crate) fn decode_body(
bytes: &[u8],
charset: Option<&str>,
url: &str,
) -> Result<String, FetchError> {
match charset {
None => Ok(String::from_utf8_lossy(bytes).into_owned()),
Some(label)
if label.eq_ignore_ascii_case("utf-8") || label.eq_ignore_ascii_case("utf8") =>
{
Ok(String::from_utf8_lossy(bytes).into_owned())
}
Some(label) => {
let encoding = encoding_rs::Encoding::for_label(label.as_bytes()).ok_or_else(|| {
FetchError::Undecodable {
url: SafeUrl::new(url),
charset: label.to_string(),
}
})?;
Ok(encoding.decode(bytes).0.into_owned())
}
}
}
pub(crate) fn extract_html(html: &str, base_url: Option<&str>, raw: bool) -> (String, Extraction) {
if raw {
return (htmd::convert(html).unwrap_or_default(), Extraction::RawHtml);
}
let options = ReadabilityOptions {
output_markdown: true,
..ReadabilityOptions::default()
};
let article_markdown = Readability::new(html, base_url, Some(options))
.ok()
.and_then(Readability::parse)
.and_then(|article| {
article.markdown_content.or_else(|| {
article
.content
.and_then(|content| htmd::convert(&content).ok())
})
})
.unwrap_or_default();
if article_markdown.trim().len() >= MIN_CONTENT_LEN {
return (article_markdown, Extraction::Readability);
}
(
htmd::convert(html).unwrap_or(article_markdown),
Extraction::RawHtml,
)
}
pub(crate) async fn read_body_truncating(
response: reqwest::Response,
max_bytes: usize,
) -> Result<(Vec<u8>, bool), FetchError> {
let url = response.url().to_string();
let mut response = response;
let mut body: Vec<u8> = Vec::new();
let mut truncated = false;
while let Some(chunk) = response
.chunk()
.await
.map_err(|source| FetchError::BodyRead {
url: SafeUrl::new(&url),
source,
})?
{
let remaining = max_bytes - body.len();
if chunk.len() > remaining {
body.extend_from_slice(&chunk[..remaining]);
truncated = true;
break;
}
body.extend_from_slice(&chunk);
}
Ok((body, truncated))
}
pub(crate) async fn read_body_capped(
mut response: reqwest::Response,
url: &str,
max_bytes: usize,
) -> Result<Vec<u8>, FetchError> {
let too_large = || FetchError::TooLarge {
url: SafeUrl::new(url),
limit: max_bytes,
};
if let Some(len) = response.content_length()
&& len > max_bytes as u64
{
return Err(too_large());
}
let mut body: Vec<u8> = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|source| FetchError::BodyRead {
url: SafeUrl::new(url),
source,
})?
{
if body.len() + chunk.len() > max_bytes {
return Err(too_large());
}
body.extend_from_slice(&chunk);
}
Ok(body)
}
pub(crate) fn truncate_to_chars(text: &str, max_chars: usize) -> (&str, bool) {
match text.char_indices().nth(max_chars) {
Some((idx, _)) => (&text[..idx], true),
None => (text, false),
}
}
#[cfg(test)]
mod tests {
use super::extract_html;
#[test]
fn extracts_article_body_and_drops_boilerplate() {
let html = r#"
<html>
<body>
<nav><a href="/home">Home</a><a href="/about">About Us Navigation</a></nav>
<article>
<h1>The Title Of The Piece</h1>
<p>This is the first substantial paragraph of the article body,
long enough to be treated as real content by the extractor.</p>
<p>Here is a second paragraph that continues the discussion with
even more prose so the reader has plenty of material to read.</p>
<p>A third and final paragraph rounds out the article nicely and
keeps the character count comfortably above the threshold.</p>
</article>
<footer>Copyright boilerplate footer text here.</footer>
</body>
</html>
"#;
let (markdown, _mode) = extract_html(html, Some("https://example.com/article"), false);
assert!(
markdown.contains("first substantial paragraph"),
"expected article body in output, got: {markdown}"
);
assert!(
!markdown.contains("About Us Navigation"),
"navigation boilerplate should be stripped, got: {markdown}"
);
}
#[test]
fn falls_back_for_non_article_html() {
let html = "<div>short</div>";
let (markdown, _mode) = extract_html(html, None, false);
assert!(
!markdown.trim().is_empty(),
"fallback conversion should return non-empty markdown, got: {markdown:?}"
);
assert!(
markdown.contains("short"),
"fallback should preserve the page text, got: {markdown}"
);
}
}