Skip to main content

crawlkit_engine/
meta.rs

1use serde::{Deserialize, Serialize};
2use url::Url;
3
4/// Open Graph protocol tags for social media previews.
5///
6/// Extracted from `<meta property="og:*">` tags. Used by the
7/// [`SocialMediaAnalyzer`](crate::SocialMediaAnalyzer) to check
8/// completeness of social sharing metadata.
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10pub struct OpenGraphTags {
11    /// `og:title` — title for social sharing.
12    pub title: Option<String>,
13    /// `og:description` — description for social sharing.
14    pub description: Option<String>,
15    /// `og:image` — image URL for social preview.
16    pub image: Option<String>,
17    /// `og:url` — canonical URL for social sharing.
18    pub url: Option<String>,
19    /// `og:type` — content type (website, article, etc.).
20    pub r#type: Option<String>,
21    /// `og:site_name` — site name.
22    pub site_name: Option<String>,
23    /// `og:locale` — content locale (e.g., "en_US").
24    pub locale: Option<String>,
25}
26
27/// Twitter Card tags for Twitter/X previews.
28///
29/// Extracted from `<meta name="twitter:*">` tags. Used by the
30/// [`SocialMediaAnalyzer`](crate::SocialMediaAnalyzer) to check
31/// Twitter Card completeness.
32#[derive(Debug, Clone, Default, Serialize, Deserialize)]
33pub struct TwitterTags {
34    /// `twitter:card` — card type (summary, summary_large_image).
35    pub card: Option<String>,
36    /// `twitter:site` — site Twitter handle.
37    pub site: Option<String>,
38    /// `twitter:creator` — author Twitter handle.
39    pub creator: Option<String>,
40    /// `twitter:title` — title for Twitter sharing.
41    pub title: Option<String>,
42    /// `twitter:description` — description for Twitter sharing.
43    pub description: Option<String>,
44    /// `twitter:image` — image URL for Twitter card.
45    pub image: Option<String>,
46    /// `twitter:image:alt` — alt text for Twitter card image.
47    pub image_alt: Option<String>,
48}
49
50/// A single hreflang alternate link.
51///
52/// Represents a `<link rel="alternate" hreflang="..." href="...">` tag
53/// for international SEO. Validated by the [`HreflangValidator`](crate::HreflangValidator).
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct HreflangTag {
56    /// Language code (e.g., "en", "fr-CA", "x-default").
57    pub lang: String,
58    /// URL of the alternate language version.
59    pub url: Url,
60}
61
62/// Complete set of meta tags extracted from a page.
63///
64/// Aggregates all SEO-relevant meta information including title,
65/// description, canonical URL, robots directives, Open Graph,
66/// Twitter Cards, and hreflang tags. Provides helper methods
67/// for common checks.
68///
69/// # Examples
70///
71/// ```rust
72/// use crawlkit_engine::MetaTags;
73///
74/// let meta = MetaTags {
75///     title: Some("My Page".to_string()),
76///     robots: Some("noindex, nofollow".to_string()),
77///     ..Default::default()
78/// };
79/// assert!(meta.is_noindex());
80/// assert!(meta.is_nofollow());
81/// assert_eq!(meta.title_length(), Some(7));
82/// ```
83#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84pub struct MetaTags {
85    /// Page title (`<title>` tag).
86    pub title: Option<String>,
87    /// Meta description.
88    pub description: Option<String>,
89    /// Canonical URL (`<link rel="canonical">`).
90    pub canonical: Option<Url>,
91    /// Robots directive (`<meta name="robots">`).
92    pub robots: Option<String>,
93    /// Page language (`<html lang="...">`).
94    pub language: Option<String>,
95    /// Character encoding (`<meta charset="...">`).
96    pub charset: Option<String>,
97    /// Viewport meta tag.
98    pub viewport: Option<String>,
99    /// Open Graph tags.
100    pub og: OpenGraphTags,
101    /// Twitter Card tags.
102    pub twitter: TwitterTags,
103    /// Hreflang alternate links.
104    pub hreflang: Vec<HreflangTag>,
105}
106
107impl MetaTags {
108    /// Returns the length of the title, if present.
109    pub fn title_length(&self) -> Option<usize> {
110        self.title.as_ref().map(|t| t.len())
111    }
112
113    /// Returns the length of the description, if present.
114    pub fn description_length(&self) -> Option<usize> {
115        self.description.as_ref().map(|d| d.len())
116    }
117
118    /// Whether the page has a `<meta name="robots" content="noindex">` directive.
119    pub fn is_noindex(&self) -> bool {
120        self.robots
121            .as_deref()
122            .map(|r| r.split(',').any(|v| v.trim() == "noindex"))
123            .unwrap_or(false)
124    }
125
126    /// Whether the page has a `<meta name="robots" content="nofollow">` directive.
127    pub fn is_nofollow(&self) -> bool {
128        self.robots
129            .as_deref()
130            .map(|r| r.split(',').any(|v| v.trim() == "nofollow"))
131            .unwrap_or(false)
132    }
133}
134
135#[cfg(test)]
136#[allow(clippy::unwrap_used)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_meta_tags_defaults() {
142        let meta = MetaTags::default();
143        assert!(meta.title.is_none());
144        assert!(meta.description.is_none());
145        assert!(meta.canonical.is_none());
146        assert!(meta.og.title.is_none());
147        assert!(meta.twitter.card.is_none());
148        assert!(meta.hreflang.is_empty());
149    }
150
151    #[test]
152    fn test_title_length() {
153        let mut meta = MetaTags::default();
154        assert_eq!(meta.title_length(), None);
155
156        meta.title = Some("Hello World".into());
157        assert_eq!(meta.title_length(), Some(11));
158    }
159
160    #[test]
161    fn test_description_length() {
162        let mut meta = MetaTags::default();
163        assert_eq!(meta.description_length(), None);
164
165        meta.description = Some("A short description".into());
166        assert_eq!(meta.description_length(), Some(19));
167    }
168
169    #[test]
170    fn test_is_noindex() {
171        let mut meta = MetaTags::default();
172        assert!(!meta.is_noindex());
173
174        meta.robots = Some("noindex".into());
175        assert!(meta.is_noindex());
176
177        meta.robots = Some("index, nofollow".into());
178        assert!(!meta.is_noindex());
179
180        meta.robots = Some("noindex, nofollow".into());
181        assert!(meta.is_noindex());
182    }
183
184    #[test]
185    fn test_is_nofollow() {
186        let mut meta = MetaTags::default();
187        assert!(!meta.is_nofollow());
188
189        meta.robots = Some("nofollow".into());
190        assert!(meta.is_nofollow());
191
192        meta.robots = Some("noindex, nofollow".into());
193        assert!(meta.is_nofollow());
194    }
195
196    #[test]
197    fn test_open_graph_serialization() {
198        let og = OpenGraphTags {
199            title: Some("My Page".into()),
200            description: Some("Desc".into()),
201            image: Some("https://example.com/img.png".into()),
202            url: Some("https://example.com".into()),
203            r#type: Some("website".into()),
204            site_name: Some("Example".into()),
205            locale: Some("en_US".into()),
206        };
207        let json = serde_json::to_string(&og).unwrap();
208        let deser: OpenGraphTags = serde_json::from_str(&json).unwrap();
209        assert_eq!(og.title, deser.title);
210        assert_eq!(og.image, deser.image);
211    }
212
213    #[test]
214    fn test_twitter_tags_serialization() {
215        let tw = TwitterTags {
216            card: Some("summary_large_image".into()),
217            site: Some("@example".into()),
218            creator: Some("@author".into()),
219            title: Some("Title".into()),
220            description: Some("Desc".into()),
221            image: Some("https://example.com/tw.png".into()),
222            image_alt: Some("Alt text".into()),
223        };
224        let json = serde_json::to_string(&tw).unwrap();
225        let deser: TwitterTags = serde_json::from_str(&json).unwrap();
226        assert_eq!(tw.card, deser.card);
227        assert_eq!(tw.image_alt, deser.image_alt);
228    }
229
230    #[test]
231    fn test_hreflang_tag() {
232        let tag = HreflangTag {
233            lang: "en".into(),
234            url: Url::parse("https://example.com/en").unwrap(),
235        };
236        assert_eq!(tag.lang, "en");
237        let json = serde_json::to_string(&tag).unwrap();
238        let deser: HreflangTag = serde_json::from_str(&json).unwrap();
239        assert_eq!(tag.url, deser.url);
240    }
241}