//! Content extraction, sanitization and word counting (spec §3.3). //! //! Priority order per article: Miniflux content if it looks like full text → //! fetch + `dom_smoothie` readability → feed excerpt with a "(excerpt only)" note. use std::collections::HashSet; use std::sync::Arc; use std::time::Duration; use futures::StreamExt; use scraper::{Html, Node, Selector}; use url::Url; use crate::epub::images::{tag_end, tag_name}; use crate::types::{Article, ExtractMethod, Extracted}; /// Word count at or above which Miniflux content is treated as full text (§3.3). pub const FULL_TEXT_MIN_WORDS: i64 = 250; /// Maximum bytes downloaded when fetching an article page (§3.3). pub const MAX_FETCH_BYTES: usize = 3 * 1024 * 1024; /// Note appended to bodies we could only excerpt (§3.3). pub const EXCERPT_NOTE: &str = "(excerpt only — read online)"; /// Article pages are fetched with a desktop UA, not our bot UA (§3.3). pub const DESKTOP_UA: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"; /// Per-fetch timeout for article pages (§3.3). pub const FETCH_TIMEOUT: Duration = Duration::from_secs(10); /// Parallel article fetches during the extraction stage. pub const CONCURRENCY: usize = 8; /// Below this many words, a page on a [`DEFAULT_PAYWALL_DOMAINS`] host is a stub (§3.3). pub const PAYWALL_MAX_WORDS: i64 = 400; /// Any page this short is an excerpt regardless of host (§3.3). pub const EXCERPT_MAX_WORDS: i64 = 120; /// Hosts that routinely serve a teaser instead of the article (§3.3). /// /// The built-in list; `curation.paywall_domains` from the config file is merged /// on top of it by [`Extractor::new`] / [`Extractor::offline`] (§3.3). pub const DEFAULT_PAYWALL_DOMAINS: &[&str] = &[ "nytimes.com", "wsj.com", "ft.com", "economist.com", "bloomberg.com", "washingtonpost.com", "newyorker.com", "theatlantic.com", "wired.com", "businessinsider.com", "barrons.com", "forbes.com", "latimes.com", "bostonglobe.com", "theinformation.com", "hbr.org", "nature.com", "science.org", "sciencedirect.com", "seekingalpha.com", "statnews.com", "thetimes.co.uk", "telegraph.co.uk", "medium.com", "towardsdatascience.com", ]; #[derive(Debug, thiserror::Error)] pub enum ExtractError { #[error("fetch failed: {0}")] Http(#[from] reqwest::Error), #[error("response exceeded {MAX_FETCH_BYTES} bytes")] TooLarge, #[error("readability found no main content")] NoContent, #[error("server returned {0}")] Status(u16), #[error("response was {0}, not html")] NotHtml(String), #[error("fetching is disabled on this extractor")] FetchDisabled, } /// Counters for the extraction stage, folded into the run report. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ExtractStats { pub from_miniflux: usize, pub from_readability: usize, pub excerpt_only: usize, pub fetch_failures: usize, } /// The extraction stage for one article (§3.3). #[derive(Debug, Clone)] pub struct Extractor { /// `None` disables the network path entirely (tests, `--dry-run` reruns). http: Option, /// Hosts known to paywall, used by the [`looks_paywalled`] heuristic. paywall_domains: Vec, } impl Extractor { pub fn new(http: reqwest::Client, paywall_domains: Vec) -> Self { Self { http: Some(http), paywall_domains: merge_paywall_domains(paywall_domains), } } /// An extractor that never touches the network: the Miniflux/excerpt paths only. /// /// This is what tests use, and it keeps the fetch step injectable (§6 testing). pub fn offline(paywall_domains: Vec) -> Self { Self { http: None, paywall_domains: merge_paywall_domains(paywall_domains), } } pub fn can_fetch(&self) -> bool { self.http.is_some() } /// Run the full priority order for one article and return its body (§3.3). /// /// Never fails the run: on fetch/readability failure it degrades to the feed /// excerpt (notes §3). pub async fn extract(&self, article: &Article) -> Extracted { let span = tracing::debug_span!("extract", entry = article.best_entry_id); let _guard = span.enter(); // 1. Miniflux content, when it already looks like full text. let feed_html = sanitize_with_base(&normalize_img_tags(&article.content_html), &article.url); let feed_words = word_count(&feed_html); if feed_words >= FULL_TEXT_MIN_WORDS { return self.finish( article, feed_html, feed_words, ExtractMethod::Miniflux, &article.url, ); } // 2. Fetch the page and run readability over it. if self.can_fetch() { match self.fetch_readable(&article.url).await { Ok(page) => { // Relative URLs in the markup belong to the page we ended up // on, not the one we asked for: shortener and syndication // links land on another host entirely. let clean = sanitize_with_base(&normalize_img_tags(&page.html), &page.final_url); let words = word_count(&clean); if words > feed_words && words > 0 { return self.finish( article, clean, words, ExtractMethod::Readability, &page.final_url, ); } tracing::debug!(words, feed_words, "readability was not an improvement"); } Err(e) => tracing::debug!(url = %article.url, "extraction fetch failed: {e}"), } } // 3. Excerpt fallback. Re-extraction must not stack up notes (notes §12). let body = if feed_html.trim().is_empty() { format!("

{EXCERPT_NOTE}

") } else if feed_html.contains(EXCERPT_NOTE) { feed_html } else { format!("{feed_html}

{EXCERPT_NOTE}

") }; let words = word_count(&body); let mut out = self.finish(article, body, words, ExtractMethod::Excerpt, &article.url); out.excerpt_only = true; out } /// Assemble the [`Extracted`] value once a body has been chosen. /// /// `base_url` is what the body's relative URLs resolve against — the page we /// landed on for a fetched article, the article URL otherwise. fn finish( &self, article: &Article, content_html: String, words: i64, method: ExtractMethod, base_url: &str, ) -> Extracted { let image_urls = collect_image_urls(&content_html, base_url); let excerpt_only = method == ExtractMethod::Excerpt || looks_paywalled(&article.url, words, &self.paywall_domains); Extracted { content_html, word_count: words, excerpt_only, image_urls, method, } } /// Extract every article in place, up to [`CONCURRENCY`] fetches at a time (§3.3). pub async fn extract_all(&self, articles: &mut [Article]) -> ExtractStats { let span = tracing::info_span!("extract_all", articles = articles.len()); let _guard = span.enter(); let inputs: Vec
= articles.to_vec(); let results: Vec<(usize, Extracted)> = futures::stream::iter(inputs.iter().enumerate()) .map(|(i, article)| async move { (i, self.extract(article).await) }) .buffer_unordered(CONCURRENCY) .collect() .await; let mut stats = ExtractStats::default(); for (i, extracted) in results { match extracted.method { ExtractMethod::Miniflux => stats.from_miniflux += 1, ExtractMethod::Readability => stats.from_readability += 1, ExtractMethod::Excerpt => stats.fetch_failures += 1, } if extracted.excerpt_only { stats.excerpt_only += 1; } apply(&mut articles[i], extracted); } tracing::info!( miniflux = stats.from_miniflux, readability = stats.from_readability, excerpt_only = stats.excerpt_only, "extraction complete" ); stats } /// Fetch `url` (10s timeout, desktop UA, [`MAX_FETCH_BYTES`] cap) and run /// `dom_smoothie` readability over it (§3.3). /// /// Returns the URL the fetch actually landed on alongside the markup, so /// callers resolve relative links against the right origin. pub async fn fetch_readable(&self, url: &str) -> Result { let Some(http) = &self.http else { return Err(ExtractError::FetchDisabled); }; let mut response = http .get(url) .header(reqwest::header::USER_AGENT, DESKTOP_UA) .header( reqwest::header::ACCEPT, "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", ) .timeout(FETCH_TIMEOUT) .send() .await?; if !response.status().is_success() { return Err(ExtractError::Status(response.status().as_u16())); } let final_url = response.url().to_string(); if let Some(ct) = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) { let ct = ct.to_ascii_lowercase(); if !(ct.contains("html") || ct.contains("xml") || ct.contains("text/plain")) { return Err(ExtractError::NotHtml(ct)); } } let mut body: Vec = Vec::new(); while let Some(chunk) = response.chunk().await? { if body.len() + chunk.len() > MAX_FETCH_BYTES { return Err(ExtractError::TooLarge); } body.extend_from_slice(&chunk); } let html = String::from_utf8_lossy(&body).into_owned(); Ok(Page { html: readability(&html, &final_url)?, final_url, }) } } /// An article page after fetching and readability (§3.3). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Page { /// Readability's main-content markup. pub html: String, /// Where the fetch ended up, after any redirects — the base for relative URLs. pub final_url: String, } /// Run `dom_smoothie` over a fetched page and return its main-content HTML (§3.3). /// /// The page is normalized first ([`prepare_for_readability`]) so that readability /// sees plain, already-resolved `` elements. Left to itself it damages /// them in two ways: it deletes whole subtrees (lightbox `