Skip to main content

trek_rs/
metadata.rs

1//! Metadata extraction functionality
2
3use crate::CollectedData;
4use crate::types::{MetaTagItem, MiniAppEmbed, TrekMetadata};
5use once_cell::sync::Lazy;
6use regex::Regex;
7use serde_json::Value;
8use tracing::{debug, instrument};
9
10static TITLE_PATTERN: Lazy<Regex> =
11    Lazy::new(|| Regex::new(r"<title[^>]*>(.*?)</title>").expect("Invalid regex"));
12
13/// Extract metadata from HTML document
14#[derive(Debug)]
15pub struct MetadataExtractor;
16
17impl MetadataExtractor {
18    /// Extract metadata from collected data
19    #[instrument(skip(data, url))]
20    pub fn extract_from_collected_data(data: &CollectedData, url: Option<&str>) -> TrekMetadata {
21        let mut metadata = TrekMetadata::default();
22
23        // Try to extract from schema.org and meta tags first (they have priority)
24        if let Some(title) = Self::extract_title_from_data(&data.schema_org_data, &data.meta_tags) {
25            metadata.title = title;
26        } else if let Some(title) = &data.title {
27            // Use HTML title as fallback
28            metadata.title.clone_from(title);
29        }
30
31        if let Some(description) = Self::extract_description(&data.schema_org_data, &data.meta_tags)
32        {
33            metadata.description = description;
34        }
35
36        if let Some(author) = Self::extract_author(&data.schema_org_data, &data.meta_tags) {
37            metadata.author = author;
38        }
39
40        if let Some(published) =
41            Self::extract_published_date(&data.schema_org_data, &data.meta_tags)
42        {
43            metadata.published = published;
44        }
45
46        if let Some(site) = Self::extract_site_name(&data.meta_tags) {
47            metadata.site = site;
48        }
49
50        if let Some(image) = Self::extract_image(&data.schema_org_data, &data.meta_tags) {
51            metadata.image = image;
52        }
53
54        if let Some(favicon) = &data.favicon {
55            metadata.favicon.clone_from(favicon);
56        }
57
58        // Extract domain from URL if provided
59        if let Some(url_str) = url {
60            if let Ok(parsed_url) = url::Url::parse(url_str) {
61                if let Some(domain) = parsed_url.domain() {
62                    // Remove www. prefix if present
63                    metadata.domain = domain.trim_start_matches("www.").to_string();
64                }
65            }
66        }
67
68        // Include schema.org data
69        metadata.schema_org_data.clone_from(&data.schema_org_data);
70
71        // Parse mini app embed if present
72        if let Some(embed_json) = &data.mini_app_embed {
73            match serde_json::from_str::<MiniAppEmbed>(embed_json) {
74                Ok(embed) => {
75                    debug!("Successfully parsed Mini App embed");
76                    metadata.mini_app_embed = Some(embed);
77                }
78                Err(e) => {
79                    debug!("Failed to parse Mini App embed JSON: {}", e);
80                }
81            }
82        }
83
84        metadata
85    }
86
87    /// Extract metadata from HTML string
88    #[instrument(skip(html))]
89    pub fn extract(html: &str) -> TrekMetadata {
90        let mut metadata = TrekMetadata::default();
91
92        // Extract title from HTML
93        if let Some(captures) = TITLE_PATTERN.captures(html) {
94            if let Some(title_match) = captures.get(1) {
95                metadata.title = title_match.as_str().trim().to_string();
96            }
97        }
98
99        metadata
100    }
101
102    fn extract_title_from_data(
103        schema_org_data: &[Value],
104        meta_tags: &[MetaTagItem],
105    ) -> Option<String> {
106        // Try schema.org first (prioritize structured data)
107        for item in schema_org_data {
108            if let Some(headline) = item.get("headline").and_then(Value::as_str) {
109                return Some(headline.to_string());
110            }
111            if let Some(name) = item.get("name").and_then(Value::as_str) {
112                return Some(name.to_string());
113            }
114        }
115
116        // Try Open Graph as fallback
117        for tag in meta_tags {
118            if tag.property.as_deref() == Some("og:title") {
119                return Some(tag.content.clone());
120            }
121        }
122
123        // Try Twitter Card
124        for tag in meta_tags {
125            if tag.property.as_deref() == Some("twitter:title") {
126                return Some(tag.content.clone());
127            }
128        }
129
130        None
131    }
132
133    fn extract_description(schema_org_data: &[Value], meta_tags: &[MetaTagItem]) -> Option<String> {
134        // Try schema.org first
135        for item in schema_org_data {
136            if let Some(description) = item.get("description").and_then(Value::as_str) {
137                return Some(description.to_string());
138            }
139        }
140
141        // Try meta description
142        for tag in meta_tags {
143            if tag.name.as_deref() == Some("description") {
144                return Some(tag.content.clone());
145            }
146        }
147
148        // Try Open Graph
149        for tag in meta_tags {
150            if tag.property.as_deref() == Some("og:description") {
151                return Some(tag.content.clone());
152            }
153        }
154
155        // Try Twitter Card
156        for tag in meta_tags {
157            if tag.property.as_deref() == Some("twitter:description") {
158                return Some(tag.content.clone());
159            }
160        }
161
162        None
163    }
164
165    fn extract_author(schema_org_data: &[Value], meta_tags: &[MetaTagItem]) -> Option<String> {
166        // Try schema.org
167        for item in schema_org_data {
168            if let Some(author) = item.get("author") {
169                if let Some(name) = author.get("name").and_then(Value::as_str) {
170                    return Some(name.to_string());
171                }
172                if let Some(name) = author.as_str() {
173                    return Some(name.to_string());
174                }
175            }
176        }
177
178        // Try byl meta tag (NY Times specific)
179        for tag in meta_tags {
180            if tag.name.as_deref() == Some("byl") {
181                return Some(tag.content.clone());
182            }
183        }
184
185        // Try author meta tag
186        for tag in meta_tags {
187            if tag.name.as_deref() == Some("author") {
188                return Some(tag.content.clone());
189            }
190        }
191
192        // Try article:author (but skip if it's a URL)
193        for tag in meta_tags {
194            if tag.property.as_deref() == Some("article:author") {
195                // If it looks like a URL, skip it - we want the actual name
196                if !tag.content.starts_with("http://") && !tag.content.starts_with("https://") {
197                    return Some(tag.content.clone());
198                }
199            }
200        }
201
202        None
203    }
204
205    fn extract_published_date(
206        schema_org_data: &[Value],
207        meta_tags: &[MetaTagItem],
208    ) -> Option<String> {
209        // Try schema.org
210        for item in schema_org_data {
211            if let Some(date) = item.get("datePublished").and_then(Value::as_str) {
212                return Some(date.to_string());
213            }
214        }
215
216        // Try meta tags
217        for tag in meta_tags {
218            if tag.property.as_deref() == Some("article:published_time") {
219                return Some(tag.content.clone());
220            }
221            if tag.name.as_deref() == Some("publish_date") {
222                return Some(tag.content.clone());
223            }
224        }
225
226        None
227    }
228
229    fn extract_site_name(meta_tags: &[MetaTagItem]) -> Option<String> {
230        // Try Open Graph
231        for tag in meta_tags {
232            if tag.property.as_deref() == Some("og:site_name") {
233                return Some(tag.content.clone());
234            }
235        }
236
237        // Try Twitter
238        for tag in meta_tags {
239            if tag.name.as_deref() == Some("twitter:site") {
240                return Some(tag.content.clone());
241            }
242        }
243
244        None
245    }
246
247    fn extract_image(schema_org_data: &[Value], meta_tags: &[MetaTagItem]) -> Option<String> {
248        // Try schema.org first
249        for item in schema_org_data {
250            if let Some(image) = item.get("image") {
251                // Handle both string and object representations
252                if let Some(url) = image.as_str() {
253                    return Some(url.to_string());
254                }
255                // Handle ImageObject
256                if let Some(url) = image.get("url").and_then(Value::as_str) {
257                    return Some(url.to_string());
258                }
259                // Handle array of images
260                if let Some(images) = image.as_array() {
261                    if let Some(first_image) = images.first() {
262                        if let Some(url) = first_image.as_str() {
263                            return Some(url.to_string());
264                        }
265                        if let Some(url) = first_image.get("url").and_then(Value::as_str) {
266                            return Some(url.to_string());
267                        }
268                    }
269                }
270            }
271        }
272
273        // Try Open Graph
274        for tag in meta_tags {
275            if tag.property.as_deref() == Some("og:image") {
276                return Some(tag.content.clone());
277            }
278        }
279
280        // Try Twitter
281        for tag in meta_tags {
282            if tag.name.as_deref() == Some("twitter:image") {
283                return Some(tag.content.clone());
284            }
285        }
286
287        None
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn test_extract_title_from_html() {
297        let html = r"<html><head><title>Test Title</title></head></html>";
298        let metadata = MetadataExtractor::extract(html);
299        assert_eq!(metadata.title, "Test Title");
300    }
301
302    #[test]
303    fn test_extract_from_collected_data() {
304        let mut data = CollectedData::default();
305        data.meta_tags.push(MetaTagItem {
306            name: Some("description".to_string()),
307            property: None,
308            content: "Test description".to_string(),
309        });
310
311        let metadata = MetadataExtractor::extract_from_collected_data(&data, None);
312        assert_eq!(metadata.description, "Test description");
313    }
314}