swink_agent_plugin_web/
content.rs1use scraper::{ElementRef, Html, Selector};
2use swink_agent::{prefix_chars, suffix_chars};
3
4#[derive(Debug, thiserror::Error)]
6pub enum ContentError {
7 #[error("content extraction failed: {0}")]
8 ExtractionFailed(String),
9}
10
11#[non_exhaustive]
17#[derive(Debug, Clone)]
18pub struct FetchedContent {
19 pub url: String,
20 pub title: Option<String>,
21 pub text: String,
22 pub text_length: usize,
24}
25
26impl FetchedContent {
27 #[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
41pub 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
117pub 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(¬ice);
142 result.push_str(tail);
143
144 (result, true)
145}
146
147pub 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 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 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 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}