Skip to main content

swink_agent_plugin_web/
content.rs

1use scraper::{ElementRef, Html, Selector};
2use swink_agent::{prefix_chars, suffix_chars};
3
4/// Errors that can occur during content extraction.
5#[derive(Debug, thiserror::Error)]
6pub enum ContentError {
7    #[error("content extraction failed: {0}")]
8    ExtractionFailed(String),
9}
10
11/// Result of extracting readable content from raw HTML.
12///
13/// Contains only data the extractor actually knows. HTTP metadata
14/// (status code, content type, truncation) belongs in the caller that holds the
15/// real HTTP response.
16#[non_exhaustive]
17#[derive(Debug, Clone)]
18pub struct FetchedContent {
19    pub url: String,
20    pub title: Option<String>,
21    pub text: String,
22    /// Character length of the extracted text.
23    pub text_length: usize,
24}
25
26impl FetchedContent {
27    /// Construct from extracted fields, computing `text_length` from `text`.
28    #[must_use]
29    pub fn new(url: impl Into<String>, title: Option<String>, text: impl Into<String>) -> Self {
30        let text = text.into();
31        let text_length = text.chars().count();
32        Self {
33            url: url.into(),
34            title,
35            text,
36            text_length,
37        }
38    }
39}
40
41/// Extract readable content from raw HTML bytes.
42///
43/// Prefers article-like containers and falls back to the page body, then
44/// flattens common text-bearing block elements into plain text.
45pub fn extract_readable_content(
46    html: &[u8],
47    url: &url::Url,
48) -> Result<FetchedContent, ContentError> {
49    let html = String::from_utf8_lossy(html);
50    let document = Html::parse_document(&html);
51
52    let title = extract_title(&document);
53    let text = extract_main_text(&document).ok_or_else(|| {
54        ContentError::ExtractionFailed("no readable text blocks found".to_string())
55    })?;
56
57    Ok(FetchedContent::new(url.to_string(), title, text))
58}
59
60fn extract_title(document: &Html) -> Option<String> {
61    let selector = Selector::parse("title").expect("valid selector");
62    document
63        .select(&selector)
64        .next()
65        .map(element_text)
66        .filter(|title| !title.is_empty())
67}
68
69fn extract_main_text(document: &Html) -> Option<String> {
70    let candidate_selector = Selector::parse(
71        "article, main, [role='main'], .article, .post, .entry-content, .content, section, body",
72    )
73    .expect("valid selector");
74    let block_selector =
75        Selector::parse("h1, h2, h3, h4, h5, h6, p, li, blockquote, pre").expect("valid selector");
76
77    let mut best: Option<String> = None;
78    let mut best_score = 0usize;
79
80    for candidate in document.select(&candidate_selector) {
81        let text = collect_block_text(candidate, &block_selector);
82        let score = text.chars().count();
83        if score > best_score {
84            best_score = score;
85            best = Some(text);
86        }
87    }
88
89    best.filter(|text| !text.is_empty())
90}
91
92fn collect_block_text(root: ElementRef<'_>, block_selector: &Selector) -> String {
93    let mut blocks = Vec::new();
94
95    for block in root.select(block_selector) {
96        let text = element_text(block);
97        if !text.is_empty() {
98            blocks.push(text);
99        }
100    }
101
102    if blocks.is_empty() {
103        return element_text(root);
104    }
105
106    blocks.join("\n\n")
107}
108
109fn element_text(element: ElementRef<'_>) -> String {
110    normalize_text(&element.text().collect::<Vec<_>>().join(" "))
111}
112
113fn normalize_text(text: &str) -> String {
114    text.split_whitespace().collect::<Vec<_>>().join(" ")
115}
116
117/// Truncate content to fit within `max_len` characters.
118///
119/// If the text is already within the limit, returns it unchanged with `false`.
120/// Otherwise, keeps 80% from the beginning and 20% from the end, inserting a
121/// truncation notice in the middle.
122pub fn truncate_content(text: &str, max_len: usize) -> (String, bool) {
123    let original_len = text.chars().count();
124    if original_len <= max_len {
125        return (text.to_string(), false);
126    }
127
128    let head_len = max_len * 80 / 100;
129    let tail_len = max_len * 20 / 100;
130
131    let head = prefix_chars(text, head_len);
132    let tail = suffix_chars(text, tail_len);
133
134    let notice = format!(
135        "\n\n[... content truncated ({original_len} chars total, \
136         showing first {head_len} and last {tail_len}) ...]\n\n"
137    );
138
139    let mut result = String::with_capacity(head.len() + notice.len() + tail.len());
140    result.push_str(head);
141    result.push_str(&notice);
142    result.push_str(tail);
143
144    (result, true)
145}
146
147/// Check whether a Content-Type header value indicates HTML content.
148pub fn is_html_content_type(content_type: &str) -> bool {
149    content_type.contains("text/html") || content_type.contains("application/xhtml")
150}
151
152#[cfg(test)]
153mod tests {
154    use super::{extract_readable_content, is_html_content_type, truncate_content};
155
156    #[test]
157    fn truncate_content_short_text_no_truncation() {
158        let text = "Hello, world!";
159        let (result, truncated) = truncate_content(text, 100);
160        assert_eq!(result, text);
161        assert!(!truncated);
162    }
163
164    #[test]
165    fn truncate_content_long_text_is_truncated() {
166        let text = "a".repeat(1000);
167        let (result, truncated) = truncate_content(&text, 200);
168
169        assert!(truncated);
170        assert!(result.starts_with(&"a".repeat(160)));
171        assert!(result.ends_with(&"a".repeat(40)));
172        assert!(result.contains("[... content truncated"));
173    }
174
175    #[test]
176    fn truncate_content_multibyte_boundaries_are_utf8_safe() {
177        let text = format!("{}🙂{}", "a".repeat(159), "🙂".repeat(50));
178        let (result, truncated) = truncate_content(&text, 200);
179
180        assert!(truncated);
181        assert!(result.starts_with(&format!("{}🙂", "a".repeat(159))));
182        assert!(result.ends_with(&"🙂".repeat(40)));
183        assert!(result.contains("210 chars total"));
184        assert!(result.contains("showing first 160 and last 40"));
185    }
186
187    #[test]
188    fn html_content_type_detection_matches_expected_values() {
189        assert!(is_html_content_type("text/html"));
190        assert!(is_html_content_type("text/html; charset=utf-8"));
191        assert!(is_html_content_type("application/xhtml+xml"));
192        assert!(!is_html_content_type("application/json"));
193        assert!(!is_html_content_type("text/plain"));
194    }
195
196    #[test]
197    fn extract_readable_content_simple_article() {
198        let html = br"
199        <!DOCTYPE html>
200        <html>
201        <head><title>Test Article</title></head>
202        <body>
203            <nav>Navigation links here</nav>
204            <article>
205                <h1>Main Heading</h1>
206                <p>This is the main article content that should be extracted from the page. It contains enough text to be considered the primary content of the page.</p>
207                <p>Here is a second paragraph with more meaningful content to help the extractor identify this as the main content block of the page.</p>
208            </article>
209            <footer>Footer content here</footer>
210        </body>
211        </html>
212        ";
213
214        let url = url::Url::parse("https://example.com/article").unwrap();
215        let result = extract_readable_content(html, &url).unwrap();
216
217        assert_eq!(result.url, "https://example.com/article");
218        assert!(result.title.is_some());
219        assert!(result.text.contains("main article content"));
220        assert!(result.text_length > 0);
221        assert_eq!(result.text_length, result.text.chars().count());
222    }
223
224    #[test]
225    fn fetched_content_has_no_http_metadata_fields() {
226        // Pin the struct contract: FetchedContent only carries extractor-known data.
227        let content = super::FetchedContent::new(
228            "https://example.com",
229            Some("Title".to_string()),
230            "Body text",
231        );
232        assert_eq!(content.url, "https://example.com");
233        assert_eq!(content.title.as_deref(), Some("Title"));
234        assert_eq!(content.text, "Body text");
235        assert_eq!(content.text_length, 9);
236    }
237
238    #[test]
239    fn extract_readable_content_empty_title_becomes_none() {
240        let html = br"
241        <!DOCTYPE html>
242        <html>
243        <head><title></title></head>
244        <body>
245            <article>
246                <p>Content without a meaningful title in the document head. This paragraph
247                   has enough text for the extractor to pick it up as the main content block.</p>
248                <p>A second paragraph to reinforce that this is the article body content.</p>
249            </article>
250        </body>
251        </html>
252        ";
253
254        let url = url::Url::parse("https://example.com/no-title").unwrap();
255        let result = extract_readable_content(html, &url).unwrap();
256
257        assert!(result.title.is_none());
258        assert!(result.text_length > 0);
259    }
260
261    #[test]
262    fn text_length_counts_chars_not_bytes() {
263        // Multibyte characters: text_length should be char count, not byte count.
264        let html = br"
265        <!DOCTYPE html>
266        <html>
267        <head><title>Unicode</title></head>
268        <body>
269            <article>
270                <p>Here are some emoji characters for testing multibyte length counting
271                in the extractor output.</p>
272                <p>Second paragraph to ensure the extractor picks this up as the main content.</p>
273            </article>
274        </body>
275        </html>
276        ";
277
278        let url = url::Url::parse("https://example.com/unicode").unwrap();
279        let result = extract_readable_content(html, &url).unwrap();
280
281        // text_length must equal chars().count(), not bytes len()
282        assert_eq!(result.text_length, result.text.chars().count());
283    }
284
285    #[test]
286    fn extract_readable_content_falls_back_to_body_when_no_article_exists() {
287        let html = br#"
288        <!DOCTYPE html>
289        <html>
290        <head><title>Body Fallback</title></head>
291        <body>
292            <div class="hero">Short heading</div>
293            <section>
294                <p>This body paragraph should still be extracted even without an article tag.</p>
295                <p>A second paragraph makes this the strongest content candidate on the page.</p>
296            </section>
297        </body>
298        </html>
299        "#;
300
301        let url = url::Url::parse("https://example.com/body").unwrap();
302        let result = extract_readable_content(html, &url).unwrap();
303
304        assert_eq!(result.title.as_deref(), Some("Body Fallback"));
305        assert!(
306            result
307                .text
308                .contains("This body paragraph should still be extracted")
309        );
310        assert!(
311            result
312                .text
313                .contains("A second paragraph makes this the strongest")
314        );
315    }
316}