1use 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#[derive(Debug)]
15pub struct MetadataExtractor;
16
17impl MetadataExtractor {
18 #[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 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 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 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 metadata.domain = domain.trim_start_matches("www.").to_string();
64 }
65 }
66 }
67
68 metadata.schema_org_data.clone_from(&data.schema_org_data);
70
71 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 #[instrument(skip(html))]
89 pub fn extract(html: &str) -> TrekMetadata {
90 let mut metadata = TrekMetadata::default();
91
92 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 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 for tag in meta_tags {
118 if tag.property.as_deref() == Some("og:title") {
119 return Some(tag.content.clone());
120 }
121 }
122
123 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 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 for tag in meta_tags {
143 if tag.name.as_deref() == Some("description") {
144 return Some(tag.content.clone());
145 }
146 }
147
148 for tag in meta_tags {
150 if tag.property.as_deref() == Some("og:description") {
151 return Some(tag.content.clone());
152 }
153 }
154
155 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 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 for tag in meta_tags {
180 if tag.name.as_deref() == Some("byl") {
181 return Some(tag.content.clone());
182 }
183 }
184
185 for tag in meta_tags {
187 if tag.name.as_deref() == Some("author") {
188 return Some(tag.content.clone());
189 }
190 }
191
192 for tag in meta_tags {
194 if tag.property.as_deref() == Some("article:author") {
195 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 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 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 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 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 for item in schema_org_data {
250 if let Some(image) = item.get("image") {
251 if let Some(url) = image.as_str() {
253 return Some(url.to_string());
254 }
255 if let Some(url) = image.get("url").and_then(Value::as_str) {
257 return Some(url.to_string());
258 }
259 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 for tag in meta_tags {
275 if tag.property.as_deref() == Some("og:image") {
276 return Some(tag.content.clone());
277 }
278 }
279
280 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}