trek-rs 0.2.0

A web content extraction library that removes clutter from web pages
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
# Extractor Development Guide

This guide explains how to create custom extractors for Trek to handle site-specific content extraction.

## Understanding Extractors

Extractors are components that implement site-specific logic for content extraction. They allow Trek to provide optimized extraction for different websites while maintaining a consistent API.

## The Extractor Trait

All extractors must implement the `Extractor` trait:

```rust
pub trait Extractor: Send + Sync {
    /// Check if this extractor can handle the given URL and schema data
    fn can_extract(&self, url: &str, schema_org_data: &[Value]) -> bool;
    
    /// Extract content from HTML
    fn extract_from_html(&self, html: &str) -> Result<ExtractedContent>;
    
    /// Return the name of this extractor
    fn name(&self) -> &'static str;
}
```

### ExtractedContent Structure

```rust
pub struct ExtractedContent {
    pub title: String,
    pub content: String,
    pub author: Option<String>,
    pub published_at: Option<String>,
    pub excerpt: Option<String>,
    pub site_name: Option<String>,
    pub content_type: Option<String>,
}
```

## Creating a Custom Extractor

### Step 1: Create the Extractor File

Create a new file in `src/extractors/` directory. For example, `src/extractors/medium.rs`:

```rust
use crate::extractor::{Extractor, ExtractedContent};
use crate::error::Result;
use scraper::{Html, Selector};
use serde_json::Value;

pub struct MediumExtractor;

impl Extractor for MediumExtractor {
    fn can_extract(&self, url: &str, _schema_org_data: &[Value]) -> bool {
        url.contains("medium.com") || url.contains("towardsdatascience.com")
    }
    
    fn extract_from_html(&self, html: &str) -> Result<ExtractedContent> {
        let document = Html::parse_document(html);
        
        // Extract title
        let title = extract_title(&document)?;
        
        // Extract main content
        let content = extract_content(&document)?;
        
        // Extract metadata
        let author = extract_author(&document);
        let published_at = extract_publish_date(&document);
        
        Ok(ExtractedContent {
            title,
            content,
            author,
            published_at,
            excerpt: None,
            site_name: Some("Medium".to_string()),
            content_type: Some("BlogPosting".to_string()),
        })
    }
    
    fn name(&self) -> &'static str {
        "MediumExtractor"
    }
}
```

### Step 2: Implement Extraction Logic

```rust
fn extract_title(document: &Html) -> Result<String> {
    // Try multiple selectors in priority order
    let selectors = [
        "h1[data-testid='storyTitle']",
        "h1.pw-post-title",
        "h1",
    ];
    
    for selector_str in &selectors {
        if let Ok(selector) = Selector::parse(selector_str) {
            if let Some(element) = document.select(&selector).next() {
                let title = element.text().collect::<String>().trim().to_string();
                if !title.is_empty() {
                    return Ok(title);
                }
            }
        }
    }
    
    Err("Could not extract title".into())
}

fn extract_content(document: &Html) -> Result<String> {
    let article_selector = Selector::parse("article").unwrap();
    
    if let Some(article) = document.select(&article_selector).next() {
        // Clean up the content
        let mut content = article.html();
        
        // Remove unwanted elements
        content = remove_elements(&content, &[
            "button",
            "nav",
            "[data-testid='headerNav']",
            ".js-postMetaLockup",
        ]);
        
        return Ok(content);
    }
    
    Err("Could not extract content".into())
}

fn extract_author(document: &Html) -> Option<String> {
    let selectors = [
        "a[data-testid='authorName']",
        "a[rel='author']",
        "span[data-testid='authorName']",
    ];
    
    for selector_str in &selectors {
        if let Ok(selector) = Selector::parse(selector_str) {
            if let Some(element) = document.select(&selector).next() {
                let author = element.text().collect::<String>().trim().to_string();
                if !author.is_empty() {
                    return Some(author);
                }
            }
        }
    }
    
    None
}
```

### Step 3: Register the Extractor

Add your extractor to the registry in `src/extractor.rs`:

```rust
impl ExtractorRegistry {
    pub fn new() -> Self {
        let mut registry = Self {
            extractors: Vec::new(),
        };
        
        // Register extractors in priority order
        registry.register(Box::new(MediumExtractor));
        registry.register(Box::new(SubstackExtractor));
        registry.register(Box::new(WikipediaExtractor));
        // Add your new extractor here
        registry.register(Box::new(GenericExtractor)); // Fallback
        
        registry
    }
}
```

### Step 4: Add Tests

Create tests for your extractor:

```rust
#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_can_extract_medium_urls() {
        let extractor = MediumExtractor;
        
        assert!(extractor.can_extract("https://medium.com/@user/article", &[]));
        assert!(extractor.can_extract("https://towardsdatascience.com/article", &[]));
        assert!(!extractor.can_extract("https://example.com", &[]));
    }
    
    #[test]
    fn test_extract_medium_article() {
        let extractor = MediumExtractor;
        let html = include_str!("../../tests/fixtures/medium_article.html");
        
        let result = extractor.extract_from_html(html).unwrap();
        
        assert_eq!(result.title, "Understanding Rust Ownership");
        assert!(result.content.contains("Rust's ownership system"));
        assert_eq!(result.author, Some("Jane Doe".to_string()));
        assert!(result.published_at.is_some());
    }
}
```

## Advanced Techniques

### Using Schema.org Data

```rust
fn can_extract(&self, url: &str, schema_org_data: &[Value]) -> bool {
    // Check URL pattern
    if url.contains("example-news.com") {
        return true;
    }
    
    // Check schema.org type
    for schema in schema_org_data {
        if let Some(type_field) = schema.get("@type") {
            if type_field == "NewsArticle" {
                return true;
            }
        }
    }
    
    false
}
```

### Handling Dynamic Content

For sites with JavaScript-rendered content:

```rust
fn extract_from_html(&self, html: &str) -> Result<ExtractedContent> {
    // Look for JSON-LD data first
    if let Some(json_ld) = extract_json_ld(html) {
        return extract_from_json_ld(json_ld);
    }
    
    // Fall back to HTML parsing
    extract_from_static_html(html)
}

fn extract_json_ld(html: &str) -> Option<Value> {
    let re = regex::Regex::new(r#"<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>"#).ok()?;
    
    for cap in re.captures_iter(html) {
        if let Ok(json) = serde_json::from_str(&cap[1]) {
            return Some(json);
        }
    }
    
    None
}
```

### Content Scoring

Implement content scoring for better extraction:

```rust
fn score_paragraph(text: &str) -> f32 {
    let mut score = 0.0;
    
    // Length bonus
    let word_count = text.split_whitespace().count();
    score += word_count as f32 * 0.5;
    
    // Punctuation bonus
    let punctuation_count = text.chars().filter(|c| c.is_ascii_punctuation()).count();
    score += punctuation_count as f32 * 2.0;
    
    // Penalty for short paragraphs
    if word_count < 10 {
        score *= 0.5;
    }
    
    // Bonus for common article words
    let article_words = ["however", "therefore", "moreover", "furthermore"];
    for word in &article_words {
        if text.to_lowercase().contains(word) {
            score += 5.0;
        }
    }
    
    score
}
```

## Best Practices

### 1. Fallback Gracefully

Always provide fallbacks when selectors don't match:

```rust
fn extract_title(document: &Html) -> Result<String> {
    // Try primary selector
    if let Some(title) = try_selector(document, "h1.article-title") {
        return Ok(title);
    }
    
    // Try secondary selector
    if let Some(title) = try_selector(document, "h1") {
        return Ok(title);
    }
    
    // Last resort: use page title
    if let Some(title) = try_selector(document, "title") {
        return Ok(title);
    }
    
    Err("No title found".into())
}
```

### 2. Clean Extracted Content

Remove unwanted elements:

```rust
const REMOVAL_SELECTORS: &[&str] = &[
    "script",
    "style",
    "nav",
    ".advertisement",
    ".social-share",
    "[class*='newsletter']",
    "[id*='popup']",
];

fn clean_content(html: &str) -> String {
    let mut document = Html::parse_fragment(html);
    
    for selector_str in REMOVAL_SELECTORS {
        if let Ok(selector) = Selector::parse(selector_str) {
            // Remove matching elements
            // (Implementation details depend on your HTML manipulation library)
        }
    }
    
    document.html()
}
```

### 3. Handle Edge Cases

```rust
impl Extractor for RobustExtractor {
    fn extract_from_html(&self, html: &str) -> Result<ExtractedContent> {
        // Handle empty HTML
        if html.trim().is_empty() {
            return Err("Empty HTML provided".into());
        }
        
        // Handle malformed HTML
        let document = Html::parse_document(html);
        
        // Check if content exists
        if !has_meaningful_content(&document) {
            return Err("No meaningful content found".into());
        }
        
        // Proceed with extraction
        extract_content(&document)
    }
}
```

### 4. Test Thoroughly

Create comprehensive tests:

```rust
#[test]
fn test_extractor_edge_cases() {
    let extractor = MyExtractor;
    
    // Test empty HTML
    assert!(extractor.extract_from_html("").is_err());
    
    // Test HTML without content
    let no_content = "<html><head></head><body></body></html>";
    assert!(extractor.extract_from_html(no_content).is_err());
    
    // Test malformed HTML
    let malformed = "<html><body><p>Unclosed paragraph";
    let result = extractor.extract_from_html(malformed);
    assert!(result.is_ok() || result.is_err()); // Should handle gracefully
}
```

## Debugging Tips

### Enable Debug Logging

```rust
use log::debug;

fn extract_from_html(&self, html: &str) -> Result<ExtractedContent> {
    debug!("Starting extraction for {}", self.name());
    
    let document = Html::parse_document(html);
    debug!("Parsed document with {} nodes", count_nodes(&document));
    
    let title = extract_title(&document)?;
    debug!("Extracted title: {}", title);
    
    // Continue extraction...
}
```

### Inspect Intermediate Results

```rust
#[cfg(debug_assertions)]
fn debug_save_content(stage: &str, content: &str) {
    use std::fs;
    let filename = format!("debug_{}_{}.html", self.name(), stage);
    fs::write(filename, content).ok();
}
```

## Performance Considerations

1. **Avoid Regex in Hot Paths**: Compile regex once and reuse
2. **Limit DOM Traversal**: Use specific selectors rather than broad searches
3. **Stream Large Content**: For very large articles, consider streaming
4. **Cache Selectors**: Parse selectors once during initialization

```rust
pub struct OptimizedExtractor {
    title_selector: Selector,
    content_selector: Selector,
    author_selector: Selector,
}

impl OptimizedExtractor {
    pub fn new() -> Self {
        Self {
            title_selector: Selector::parse("h1.title").unwrap(),
            content_selector: Selector::parse("div.content").unwrap(),
            author_selector: Selector::parse(".author-name").unwrap(),
        }
    }
}
```