1#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
8#![allow(
9 clippy::module_name_repetitions,
10 clippy::must_use_candidate,
11 clippy::multiple_crate_versions,
12 clippy::missing_errors_doc,
13 clippy::missing_panics_doc
14)]
15
16use eyre::Result;
17use lol_html::{RewriteStrSettings, element, rewrite_str, text};
18use serde_json::Value;
19use std::sync::{Arc, Mutex};
20use tracing::{debug, info, instrument};
21
22pub mod constants;
23pub mod elements;
24pub mod error;
25pub mod extractor;
26pub mod extractors;
27pub mod html_to_text;
28pub mod metadata;
29pub mod scoring;
30pub mod standardize;
31pub mod types;
32pub mod utils;
33
34#[cfg(target_arch = "wasm32")]
35pub mod wasm;
36
37use crate::extractor::{ExtractorRegistry, GenericExtractor};
38use crate::metadata::MetadataExtractor;
39pub use crate::types::{MetaTagItem, TrekOptions, TrekResponse};
40
41#[derive(Debug)]
43pub struct Trek {
44 options: TrekOptions,
45 extractor_registry: ExtractorRegistry,
46}
47
48impl Trek {
49 #[instrument(skip(options))]
51 pub fn new(options: TrekOptions) -> Self {
52 let mut extractor_registry = ExtractorRegistry::new();
53 extractor_registry.register(Box::new(GenericExtractor));
55
56 Self {
57 options,
58 extractor_registry,
59 }
60 }
61
62 #[instrument(skip(self, html))]
64 pub fn parse(&self, html: &str) -> Result<TrekResponse> {
65 let start_time = utils::current_time_ms();
66
67 let collected_data = self.collect_initial_data(html)?;
69
70 let metadata = MetadataExtractor::extract_from_collected_data(
72 &collected_data,
73 self.options.url.as_deref(),
74 );
75
76 let url = self.options.url.as_deref().unwrap_or("");
78 if let Some(extractor) = self
79 .extractor_registry
80 .find_extractor_from_data(url, &collected_data.schema_org_data)
81 {
82 info!("Using site-specific extractor: {}", extractor.name());
83 let extracted = extractor.extract_from_html(html)?;
84
85 #[allow(clippy::redundant_clone)]
86 let mut final_metadata = metadata.clone();
87 if let Some(title) = extracted.title {
88 final_metadata.title = title;
89 }
90 if let Some(author) = extracted.author {
91 final_metadata.author = author;
92 }
93 if let Some(published) = extracted.published {
94 final_metadata.published = published;
95 }
96
97 let content = extracted.content_html.unwrap_or_default();
98 final_metadata.word_count = utils::count_words(&content);
99 final_metadata.parse_time = utils::current_time_ms() - start_time;
100
101 return Ok(TrekResponse {
102 content,
103 content_markdown: None,
104 extractor_type: Some(extractor.name().to_string()),
105 meta_tags: collected_data.meta_tags.clone(),
106 metadata: final_metadata,
107 });
108 }
109
110 let result = self.parse_internal(html, &metadata, &collected_data.meta_tags, start_time)?;
112
113 if result.metadata.word_count < 200
115 && (self.options.removal.remove_exact_selectors
116 || self.options.removal.remove_partial_selectors)
117 {
118 info!(
119 "Initial parse returned very little content, trying again without clutter removal"
120 );
121 let mut retry_options = self.options.clone();
122 retry_options.removal.remove_exact_selectors = false;
123 retry_options.removal.remove_partial_selectors = false;
124
125 let retry_trek = Self::new(retry_options);
126 let retry_metadata = MetadataExtractor::extract_from_collected_data(
127 &collected_data,
128 self.options.url.as_deref(),
129 );
130 if let Ok(retry_result) = retry_trek.parse_internal(
131 html,
132 &retry_metadata,
133 &collected_data.meta_tags,
134 start_time,
135 ) {
136 if retry_result.metadata.word_count > result.metadata.word_count {
137 debug!("Retry produced more content");
138 return Ok(retry_result);
139 }
140 }
141 }
142
143 Ok(result)
144 }
145
146 fn parse_internal(
147 &self,
148 html: &str,
149 metadata: &types::TrekMetadata,
150 meta_tags: &[MetaTagItem],
151 start_time: u64,
152 ) -> Result<TrekResponse> {
153 let main_content = self.extract_main_content(html);
155
156 let body_content = self.extract_body_content(&main_content);
158
159 let cleaned_content = if self.options.removal.remove_exact_selectors
161 || self.options.removal.remove_partial_selectors
162 {
163 let result = self.remove_clutter(&body_content)?;
164 if self.options.debug {
165 debug!("After clutter removal, content length: {}", result.len());
166 }
167 result
168 } else {
169 body_content
170 };
171
172 let final_content =
174 standardize::standardize_content(&cleaned_content, &metadata.title, self.options.debug);
175
176 let mut final_metadata = metadata.clone();
177 final_metadata.word_count = utils::count_words(&final_content);
178 final_metadata.parse_time = utils::current_time_ms() - start_time;
179
180 if final_metadata.image.is_empty() {
182 if let Some(first_image) = Self::extract_first_image_from_content(&final_content) {
183 debug!("Found first image in content: {}", first_image);
184 final_metadata.image = first_image;
185 }
186 }
187
188 Ok(TrekResponse {
189 content: final_content,
190 content_markdown: None,
191 extractor_type: None,
192 meta_tags: meta_tags.to_vec(),
193 metadata: final_metadata,
194 })
195 }
196
197 #[allow(clippy::disallowed_methods, clippy::unused_self)] fn collect_initial_data(&self, html: &str) -> Result<CollectedData> {
199 let collected_data = Arc::new(Mutex::new(CollectedData::default()));
200 let data_clone = Arc::clone(&collected_data);
201 let data_clone2 = Arc::clone(&collected_data);
202
203 let script_content = Arc::new(Mutex::new(String::new()));
205 let script_clone = Arc::clone(&script_content);
206
207 let title_content = Arc::new(Mutex::new(String::new()));
209 let title_clone = Arc::clone(&title_content);
210 let data_clone3 = Arc::clone(&collected_data);
211
212 let data_clone4 = Arc::clone(&collected_data);
213
214 let settings = RewriteStrSettings {
215 element_content_handlers: vec![
216 element!("meta[name], meta[property]", move |el| {
218 if let Some(content) = el.get_attribute("content") {
219 let mut data = data_clone.lock().expect("Failed to acquire lock");
220
221 let decoded_content = utils::decode_html_entities(&content);
223
224 if el.get_attribute("name").as_deref() == Some("fc:frame") {
226 data.mini_app_embed = Some(decoded_content.clone());
227 }
228
229 data.meta_tags.push(MetaTagItem {
230 name: el.get_attribute("name"),
231 property: el.get_attribute("property"),
232 content: decoded_content,
233 });
234 }
235 Ok(())
236 }),
237 element!("link[rel~=icon], link[rel~=shortcut]", move |el| {
239 if let Some(href) = el.get_attribute("href") {
240 let mut data = data_clone4.lock().expect("Failed to acquire lock");
241 if data.favicon.is_none()
243 || el.get_attribute("rel").as_deref() == Some("icon")
244 {
245 data.favicon = Some(href);
246 }
247 }
248 Ok(())
249 }),
250 element!("title", move |_el| {
252 {
254 let mut content = title_clone.lock().expect("Failed to acquire lock");
255 content.clear();
256 }
257 Ok(())
258 }),
259 text!("title", move |t| {
261 {
262 let mut content = title_content.lock().expect("Failed to acquire lock");
263 content.push_str(t.as_str());
264
265 if t.last_in_text_node() {
267 let title_str = content.trim().to_string();
268 drop(content); let mut data = data_clone3.lock().expect("Failed to acquire lock");
270 data.title = Some(title_str);
271 }
272 }
273 Ok(())
274 }),
275 element!(r#"script[type="application/ld+json"]"#, move |_el| {
277 {
279 let mut content = script_clone.lock().expect("Failed to acquire lock");
280 content.clear();
281 }
282 Ok(())
283 }),
284 text!(r#"script[type="application/ld+json"]"#, move |t| {
286 {
287 let mut content = script_content.lock().expect("Failed to acquire lock");
288 content.push_str(t.as_str());
289
290 if t.last_in_text_node() {
292 if let Ok(json_data) = serde_json::from_str::<Value>(&content) {
294 drop(content); let mut data = data_clone2.lock().expect("Failed to acquire lock");
296 if let Some(graph) =
297 json_data.get("@graph").and_then(Value::as_array)
298 {
299 data.schema_org_data.extend(graph.clone());
300 } else {
301 data.schema_org_data.push(json_data);
302 }
303 }
304 }
305 }
306 Ok(())
307 }),
308 ],
309 ..RewriteStrSettings::default()
310 };
311
312 rewrite_str(html, settings)?;
313
314 let data = Arc::try_unwrap(collected_data).map_or_else(
315 |arc| arc.lock().expect("Failed to acquire lock").clone(),
316 |mutex| mutex.into_inner().expect("Failed to get inner value"),
317 );
318
319 Ok(data)
320 }
321
322 #[allow(clippy::unused_self, clippy::disallowed_methods)] fn extract_main_content(&self, html: &str) -> String {
324 html.to_string()
327 }
328
329 #[allow(clippy::unused_self)]
330 fn extract_body_content(&self, html: &str) -> String {
331 if let Some(body_start) = html.find("<body") {
333 if let Some(tag_end) = html[body_start..].find('>') {
334 let content_start = body_start + tag_end + 1;
335 if let Some(body_end) = html.rfind("</body>") {
336 let content = html[content_start..body_end].trim();
337 return content.trim_start_matches('\n').to_string();
339 }
340 }
341 }
342
343 html.trim_start_matches('\n').to_string()
345 }
346
347 #[allow(clippy::disallowed_methods)] fn extract_first_image_from_content(html: &str) -> Option<String> {
349 use lol_html::{RewriteStrSettings, element, rewrite_str};
350
351 let first_image = Arc::new(Mutex::new(None::<String>));
352 let image_clone = Arc::clone(&first_image);
353
354 let settings = RewriteStrSettings {
355 element_content_handlers: vec![element!("img", move |el| {
356 let mut image_guard = image_clone.lock().expect("Failed to acquire lock");
357
358 if image_guard.is_some() {
360 return Ok(());
361 }
362
363 if let Some(src) = el.get_attribute("src") {
365 if !src.starts_with("data:") && !src.is_empty() {
367 let width = el
369 .get_attribute("width")
370 .and_then(|w| w.parse::<u32>().ok())
371 .unwrap_or(100);
372 let height = el
373 .get_attribute("height")
374 .and_then(|h| h.parse::<u32>().ok())
375 .unwrap_or(100);
376
377 if width >= 50 && height >= 50 {
379 *image_guard = Some(src);
380 }
381 }
382 }
383 drop(image_guard);
384
385 Ok(())
386 })],
387 ..RewriteStrSettings::default()
388 };
389
390 let _ = rewrite_str(html, settings).ok()?;
392
393 match Arc::try_unwrap(first_image) {
395 Ok(mutex) => mutex.into_inner().expect("Failed to get inner value"),
396 Err(arc) => {
397 let guard = arc.lock().expect("Failed to acquire lock");
398 guard.clone()
399 }
400 }
401 }
402
403 #[allow(clippy::unused_self, clippy::disallowed_methods)] fn remove_clutter(&self, html: &str) -> Result<String> {
405 use crate::constants::{PARTIAL_SELECTORS, TEST_ATTRIBUTES};
406 use lol_html::html_content::ContentType;
407
408 let remove_exact = self.options.removal.remove_exact_selectors;
410 let remove_partial = self.options.removal.remove_partial_selectors;
411
412 let settings = RewriteStrSettings {
414 element_content_handlers: vec![
415 element!(
417 "script, style, nav, footer, header, aside, noscript",
418 move |el| {
419 if remove_exact {
420 el.before("<!--REMOVE-->", ContentType::Html);
421 el.after("<!--/REMOVE-->", ContentType::Html);
422 el.remove();
423 }
424 Ok(())
425 }
426 ),
427 element!(
429 "div, section, article, main, span, p, ul, ol, li, h1, h2, h3, h4, h5, h6",
430 move |el| {
431 let mut should_remove = false;
432
433 if remove_exact {
434 if let Some(class_attr) = el.get_attribute("class") {
436 for class in class_attr.split_whitespace() {
437 if class == "navigation" || class == "sidebar" {
438 should_remove = true;
439 break;
440 }
441 }
442 }
443 }
444
445 if !should_remove && remove_partial {
446 for attr in TEST_ATTRIBUTES {
448 if let Some(value) = el.get_attribute(attr) {
449 let value_lower = value.to_lowercase();
450 for pattern in PARTIAL_SELECTORS {
451 if value_lower.contains(pattern) {
452 should_remove = true;
453 break;
454 }
455 }
456 }
457 if should_remove {
458 break;
459 }
460 }
461 }
462
463 if should_remove {
464 el.before("<!--REMOVE-->", ContentType::Html);
465 el.after("<!--/REMOVE-->", ContentType::Html);
466 el.remove();
467 }
468
469 Ok(())
470 }
471 ),
472 ],
473 ..RewriteStrSettings::default()
474 };
475
476 let result = rewrite_str(html, settings)?;
477
478 let remove_pattern = regex::Regex::new(r"(?s)<!--REMOVE-->.*?<!--/REMOVE-->").unwrap();
480 let cleaned = remove_pattern.replace_all(&result, "").to_string();
481
482 Ok(cleaned)
483 }
484}
485
486#[derive(Debug, Clone, Default)]
487pub struct CollectedData {
488 pub meta_tags: Vec<MetaTagItem>,
489 pub schema_org_data: Vec<Value>,
490 pub title: Option<String>,
491 pub favicon: Option<String>,
492 pub mini_app_embed: Option<String>,
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498
499 #[test]
500 fn test_new() {
501 let options = TrekOptions::default();
502 let _trek = Trek::new(options);
503 }
504
505 #[test]
506 fn test_fallback_image_extraction() {
507 let trek = Trek::new(TrekOptions::default());
508
509 let html = r#"
511 <!DOCTYPE html>
512 <html>
513 <head>
514 <title>Test Article</title>
515 <meta name="description" content="Test description">
516 </head>
517 <body>
518 <article>
519 <h1>Article Title</h1>
520 <img src="/tracking.gif" width="1" height="1" alt="">
521 <p>Some text here</p>
522 <img src="https://example.com/main-image.jpg" width="800" height="600" alt="Main article image">
523 <p>More content</p>
524 <img src="https://example.com/another-image.jpg" alt="Another image">
525 </article>
526 </body>
527 </html>
528 "#;
529
530 let result = trek.parse(html).unwrap();
531
532 assert_eq!(result.metadata.image, "https://example.com/main-image.jpg");
534 }
535
536 #[test]
537 fn test_no_fallback_when_og_image_exists() {
538 let trek = Trek::new(TrekOptions::default());
539
540 let html = r#"
542 <!DOCTYPE html>
543 <html>
544 <head>
545 <title>Test Article</title>
546 <meta property="og:image" content="https://example.com/og-image.jpg">
547 </head>
548 <body>
549 <article>
550 <h1>Article Title</h1>
551 <img src="https://example.com/content-image.jpg" width="800" height="600" alt="Content image">
552 </article>
553 </body>
554 </html>
555 "#;
556
557 let result = trek.parse(html).unwrap();
558
559 assert_eq!(result.metadata.image, "https://example.com/og-image.jpg");
561 }
562
563 #[test]
564 fn test_no_suitable_images() {
565 let trek = Trek::new(TrekOptions::default());
566
567 let html = r#"
569 <!DOCTYPE html>
570 <html>
571 <head>
572 <title>Test Article</title>
573 </head>
574 <body>
575 <article>
576 <h1>Article Title</h1>
577 <img src="/tracking.gif" width="1" height="1" alt="">
578 <img src="/icon.png" width="16" height="16" alt="Icon">
579 <img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" alt="">
580 <p>Content without suitable images</p>
581 </article>
582 </body>
583 </html>
584 "#;
585
586 let result = trek.parse(html).unwrap();
587
588 assert_eq!(result.metadata.image, "");
590 }
591
592 #[test]
593 #[allow(clippy::disallowed_methods)] fn test_basic_extraction() {
595 let trek = Trek::new(TrekOptions::default());
596 let html = r#"
597 <html>
598 <head>
599 <title>Test Page</title>
600 <meta name="description" content="A test page">
601 </head>
602 <body>
603 <article>
604 <h1>Main Title</h1>
605 <p>This is a test paragraph with some content.</p>
606 </article>
607 </body>
608 </html>
609 "#;
610
611 let result = trek.parse(html).unwrap();
612 assert!(result.metadata.word_count > 0);
613 assert_eq!(result.metadata.title, "Test Page");
614 assert_eq!(result.metadata.description, "A test page");
615 }
616
617 #[test]
618 #[allow(clippy::disallowed_methods)]
619 fn test_debug_extraction() {
620 let trek = Trek::new(TrekOptions {
621 debug: true,
622 ..Default::default()
623 });
624
625 let html = r#"
626 <html>
627 <body>
628 <main>
629 <h1>Main Content</h1>
630 <p>First paragraph here.</p>
631 <p>Second paragraph here.</p>
632 </main>
633 </body>
634 </html>
635 "#;
636
637 let result = trek.parse(html).unwrap();
638 println!("Debug - Content: {}", result.content);
639 println!("Debug - Word count: {}", result.metadata.word_count);
640
641 assert!(!result.content.is_empty(), "Should have content");
642 assert!(result.metadata.word_count > 0, "Should count words");
643 }
644
645 #[test]
646 #[allow(clippy::disallowed_methods)]
647 fn test_remove_clutter() {
648 let trek = Trek::new(TrekOptions::default());
649
650 let html = r#"
651 <html>
652 <body>
653 <nav>Navigation</nav>
654 <article>Content</article>
655 <footer>Footer</footer>
656 </body>
657 </html>
658 "#;
659
660 let result = trek.remove_clutter(html).unwrap();
661 println!("After clutter removal: {result}");
662
663 assert!(!result.contains("<nav>"), "Should remove nav");
664 assert!(!result.contains("<footer>"), "Should remove footer");
665 assert!(result.contains("<article>"), "Should keep article");
666 }
667
668 #[test]
669 #[allow(clippy::disallowed_methods)]
670 fn test_html_tags_preserved_in_extraction() {
671 let trek = Trek::new(TrekOptions::default());
672
673 let html = r#"
674 <html>
675 <head>
676 <title>Test Article</title>
677 </head>
678 <body>
679 <article>
680 <h1>Main Title</h1>
681 <p>This article references <a href="https://example.com">an important source</a> for context.</p>
682 <p>You can also check <a href="https://test.com">this link</a> and <a href="https://another.com">another link</a> for more info.</p>
683 <p>This text is <strong>very important</strong> and <em>emphasized</em>.</p>
684 </article>
685 </body>
686 </html>
687 "#;
688
689 let result = trek.parse(html).unwrap();
690 println!("Extracted content: {:?}", result.content);
691
692 assert!(
694 result.content.contains("<a href="),
695 "Should preserve anchor tags"
696 );
697 assert!(
698 result.content.contains("<strong>"),
699 "Should preserve strong tags"
700 );
701 assert!(result.content.contains("<em>"), "Should preserve em tags");
702 assert!(
703 result.content.contains("</a>"),
704 "Should preserve closing anchor tags"
705 );
706 assert!(
707 result.content.contains("</strong>"),
708 "Should preserve closing strong tags"
709 );
710 assert!(
711 result.content.contains("</em>"),
712 "Should preserve closing em tags"
713 );
714
715 assert!(
717 result.content.contains("an important source"),
718 "Should preserve link text"
719 );
720 assert!(
721 result.content.contains("very important"),
722 "Should preserve strong text"
723 );
724 assert!(
725 result.content.contains("emphasized"),
726 "Should preserve em text"
727 );
728 }
729
730 #[test]
731 #[allow(clippy::disallowed_methods)]
732 fn test_whitespace_handling_in_extraction() {
733 let trek = Trek::new(TrekOptions::default());
734
735 let html = r#"
736 <html>
737 <head>
738 <title>Test Article</title>
739 </head>
740 <body>
741 <article>
742 <h1>Title with excessive spaces</h1>
743 <p>This paragraph has multiple spaces between words.</p>
744 <p>
745 This paragraph has
746 line breaks and multiple
747 spaces throughout.
748 </p>
749 <p>Normal paragraph.</p>
750 </article>
751 </body>
752 </html>
753 "#;
754
755 let result = trek.parse(html).unwrap();
756 println!("Whitespace test result:\n{}", result.content);
757
758 assert!(
760 !result.content.contains(" "),
761 "Should not have triple spaces"
762 );
763 assert!(
764 !result.content.contains(" "),
765 "Should not have double spaces"
766 );
767
768 assert!(result.content.contains("<p>"), "Should have paragraph tags");
770 assert!(
771 result.content.contains("</p>"),
772 "Should have closing paragraph tags"
773 );
774
775 assert!(
777 result.content.contains("Title with excessive spaces"),
778 "Title should be normalized"
779 );
780 assert!(
781 result
782 .content
783 .contains("This paragraph has multiple spaces between words"),
784 "First paragraph should be normalized"
785 );
786 }
787
788 #[test]
789 #[allow(clippy::disallowed_methods)]
790 fn test_div_flattening_reduces_newlines() {
791 let trek = Trek::new(TrekOptions::default());
792
793 let html = r#"
794 <html>
795 <head>
796 <title>Test Article</title>
797 </head>
798 <body>
799 <div>
800 <div>
801 <div>
802 <h1>How A.I. Sees Us</h1>
803 </div>
804 </div>
805 <div>
806 <div>
807 <p>Not only can A.I. now make these assessments with remarkable accuracy.</p>
808 </div>
809 </div>
810 </div>
811 </body>
812 </html>
813 "#;
814
815 let result = trek.parse(html).unwrap();
816 println!("Div flattening result:\n{}", result.content);
817
818 assert!(
820 !result.content.starts_with("\n\n\n"),
821 "Should not start with multiple newlines"
822 );
823
824 let div_count = result.content.matches("<div").count();
826 assert!(
827 div_count == 0,
828 "All wrapper divs should be flattened, found {div_count} divs"
829 );
830
831 assert!(result.content.contains("<h1>How A.I. Sees Us</h1>"));
833 assert!(
834 result
835 .content
836 .contains("<p>Not only can A.I. now make these assessments")
837 );
838 }
839}