Skip to main content

trek_rs/
lib.rs

1//! Trek - A modern web content extraction library
2//!
3//! Trek removes clutter from web pages and extracts clean, readable content.
4//! It's designed as a modern alternative to Mozilla Readability with enhanced
5//! features like mobile-aware extraction and consistent HTML standardization.
6
7#![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/// Main Trek struct for content extraction
42#[derive(Debug)]
43pub struct Trek {
44    options: TrekOptions,
45    extractor_registry: ExtractorRegistry,
46}
47
48impl Trek {
49    /// Create a new Trek instance with the given options
50    #[instrument(skip(options))]
51    pub fn new(options: TrekOptions) -> Self {
52        let mut extractor_registry = ExtractorRegistry::new();
53        // Register built-in extractors
54        extractor_registry.register(Box::new(GenericExtractor));
55
56        Self {
57            options,
58            extractor_registry,
59        }
60    }
61
62    /// Parse HTML content and extract the main content
63    #[instrument(skip(self, html))]
64    pub fn parse(&self, html: &str) -> Result<TrekResponse> {
65        let start_time = utils::current_time_ms();
66
67        // First pass: collect metadata and schema.org data
68        let collected_data = self.collect_initial_data(html)?;
69
70        // Extract metadata
71        let metadata = MetadataExtractor::extract_from_collected_data(
72            &collected_data,
73            self.options.url.as_deref(),
74        );
75
76        // Try site-specific extractor first
77        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        // Fall back to generic extraction
111        let result = self.parse_internal(html, &metadata, &collected_data.meta_tags, start_time)?;
112
113        // If result has very little content, try again without clutter removal
114        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        // Find and extract main content
154        let main_content = self.extract_main_content(html);
155
156        // Extract just the body content first
157        let body_content = self.extract_body_content(&main_content);
158
159        // Remove clutter if enabled
160        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        // Standardize content
173        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 no metadata image found, try to extract first suitable image from content
181        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)] // lol_html macros use unwrap internally
198    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        // For script content, we need to track state
204        let script_content = Arc::new(Mutex::new(String::new()));
205        let script_clone = Arc::clone(&script_content);
206
207        // For title content, we need to track state
208        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                // Collect meta tags
217                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                        // Decode HTML entities
222                        let decoded_content = utils::decode_html_entities(&content);
223
224                        // Check for fc:frame meta tag
225                        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                // Collect favicon
238                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                        // Prefer icon over shortcut icon
242                        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                // Collect title tag
251                element!("title", move |_el| {
252                    // Clear the content buffer for this title
253                    {
254                        let mut content = title_clone.lock().expect("Failed to acquire lock");
255                        content.clear();
256                    }
257                    Ok(())
258                }),
259                // Collect text within title tag
260                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                        // Check if this is the last chunk
266                        if t.last_in_text_node() {
267                            let title_str = content.trim().to_string();
268                            drop(content); // Explicitly drop before acquiring next lock
269                            let mut data = data_clone3.lock().expect("Failed to acquire lock");
270                            data.title = Some(title_str);
271                        }
272                    }
273                    Ok(())
274                }),
275                // Collect schema.org data
276                element!(r#"script[type="application/ld+json"]"#, move |_el| {
277                    // Clear the content buffer for this script
278                    {
279                        let mut content = script_clone.lock().expect("Failed to acquire lock");
280                        content.clear();
281                    }
282                    Ok(())
283                }),
284                // Collect text within script tags
285                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                        // Check if this is the last chunk
291                        if t.last_in_text_node() {
292                            // Parse the complete JSON
293                            if let Ok(json_data) = serde_json::from_str::<Value>(&content) {
294                                drop(content); // Drop before acquiring next lock
295                                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)] // lol_html macros use unwrap internally
323    fn extract_main_content(&self, html: &str) -> String {
324        // For now, just return the HTML as-is
325        // The actual content identification happens through the remove_clutter phase
326        html.to_string()
327    }
328
329    #[allow(clippy::unused_self)]
330    fn extract_body_content(&self, html: &str) -> String {
331        // Extract just the content inside the body tag
332        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                    // Remove leading newlines
338                    return content.trim_start_matches('\n').to_string();
339                }
340            }
341        }
342
343        // If no body tags found, return as-is
344        html.trim_start_matches('\n').to_string()
345    }
346
347    #[allow(clippy::disallowed_methods)] // lol_html macros use unwrap internally
348    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                // Skip if we already found an image
359                if image_guard.is_some() {
360                    return Ok(());
361                }
362
363                // Get the src attribute
364                if let Some(src) = el.get_attribute("src") {
365                    // Skip data URLs, tracking pixels, and small images
366                    if !src.starts_with("data:") && !src.is_empty() {
367                        // Check dimensions if available
368                        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                        // Skip small images (likely icons or tracking pixels)
378                        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        // Process the HTML
391        let _ = rewrite_str(html, settings).ok()?;
392
393        // Extract the result
394        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)] // lol_html macros use unwrap internally
404    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        // Capture options in local variables for the closure
409        let remove_exact = self.options.removal.remove_exact_selectors;
410        let remove_partial = self.options.removal.remove_partial_selectors;
411
412        // Use comments to mark content for removal
413        let settings = RewriteStrSettings {
414            element_content_handlers: vec![
415                // Remove common non-content elements by tag name
416                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                // Remove elements matching class/id selectors
428                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                            // Check for .navigation, .sidebar, etc.
435                            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                            // Check each test attribute for partial matches
447                            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        // Second pass: Remove content between REMOVE markers (including newlines)
479        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        // HTML with no og:image meta tag but images in content
510        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        // Should extract the first suitable image (not the tracking pixel)
533        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        // HTML with og:image meta tag
541        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        // Should use og:image, not content image
560        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        // HTML with only small/tracking images
568        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        // Should have empty image since no suitable images found
589        assert_eq!(result.metadata.image, "");
590    }
591
592    #[test]
593    #[allow(clippy::disallowed_methods)] // OK to use unwrap in tests
594    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        // Should preserve HTML tags
693        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        // Should preserve content
716        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        // Should collapse multiple spaces
759        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        // Should preserve paragraph structure
769        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        // Content should be readable
776        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        // Should not have excessive newlines at the start
819        assert!(
820            !result.content.starts_with("\n\n\n"),
821            "Should not start with multiple newlines"
822        );
823
824        // Should have flattened the divs
825        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        // Content should be clean
832        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}