Skip to main content

domtree/extract/
meta.rs

1//! Page metadata ([`Dom::metadata`]): title, description, canonical URL,
2//! language, OpenGraph / Twitter-card fields, JSON-LD and feeds.
3
4use alloc::string::String;
5use alloc::vec::Vec;
6
7use super::{collapse, has_token, non_empty};
8use crate::serialize::walk;
9use crate::tree::{Dom, NodeRef};
10
11/// Metadata about a page, from its `<title>`, `<meta>` and `<link>` tags,
12/// JSON-LD blocks and a few in-page conventions. Built by [`Dom::metadata`].
13///
14/// Every field is owned, so the struct outlives the [`Dom`]. Scalar fields
15/// take the first non-empty value from a list of sources, in the order given
16/// on each field; text fields have their whitespace collapsed, URL fields are
17/// trimmed but **not** resolved (use [`resolve_url`](crate::resolve_url) with
18/// the page URL or [`Dom::base_url`]).
19///
20/// With the `serde` feature, `Metadata` implements `serde::Serialize` (a map
21/// with one key per field).
22#[non_exhaustive]
23#[derive(Clone, Debug, Default, PartialEq, Eq)]
24pub struct Metadata {
25    /// `<title>` (not one inside an `<svg>`), else `og:title`, else
26    /// `twitter:title`.
27    pub title: Option<String>,
28    /// `<meta name=description>`, else `og:description`, else
29    /// `twitter:description`.
30    pub description: Option<String>,
31    /// `<link rel=canonical href>`, else `og:url`.
32    pub canonical: Option<String>,
33    /// The `lang` of the `<html>` element, else `<meta
34    /// http-equiv=content-language>`.
35    pub lang: Option<String>,
36    /// `og:site_name`, else `<meta name=application-name>`.
37    pub site_name: Option<String>,
38    /// `og:image` (or `og:image:url`, `og:image:secure_url`), else
39    /// `twitter:image` (or `twitter:image:src`), else `<link rel=image_src>`.
40    pub image: Option<String>,
41    /// `<meta name=author>`, else `article:author` unless it is a URL, else
42    /// the text of the first `<a rel=author>`, else the `article:author` URL.
43    pub author: Option<String>,
44    /// The publication date as written (usually ISO 8601):
45    /// `article:published_time`, else `<meta itemprop=datePublished>`, else a
46    /// `date`-style `<meta name>` (`date`, `pubdate`, `publish-date`,
47    /// `dc.date`, `dc.date.issued`, `dcterms.date`, `dcterms.created`, …), else
48    /// the `datetime` of the first `<time>` that is inside an `<article>` or
49    /// carries `pubdate` / `itemprop=datePublished`.
50    pub published: Option<String>,
51    /// `<meta name=keywords>` (else `news_keywords`) split on commas, else the
52    /// `article:tag` values.
53    pub keywords: Vec<String>,
54    /// Every OpenGraph `<meta property=… content=…>` pair — properties in the
55    /// `og:`, `article:`, `book:`, `profile:`, `music:` and `video:`
56    /// namespaces — in document order, with the full, lower-cased property
57    /// name as the key (`"og:title"`, `"article:published_time"`). Repeated
58    /// properties (several `og:image`s) are all kept.
59    pub og: Vec<(String, String)>,
60    /// Every Twitter-card `<meta name=twitter:… content=…>` pair (also when
61    /// written with `property=`), in document order, keys lower-cased
62    /// (`"twitter:card"`).
63    pub twitter: Vec<(String, String)>,
64    /// The raw, trimmed contents of each non-empty `<script
65    /// type="application/ld+json">`, in document order. Not parsed: hand them
66    /// to a JSON parser.
67    pub json_ld: Vec<String>,
68    /// The `href` of each `<link rel=alternate>` whose `type` is RSS
69    /// (`application/rss+xml`), Atom (`application/atom+xml`) or JSON Feed
70    /// (`application/feed+json`), in document order.
71    pub feeds: Vec<String>,
72}
73
74/// `<meta name>` values that hold a publication date.
75const DATE_NAMES: &[&str] = &[
76    "date",
77    "pubdate",
78    "publishdate",
79    "publish-date",
80    "publish_date",
81    "article.published",
82    "dc.date",
83    "dc.date.issued",
84    "dcterms.date",
85    "dcterms.created",
86    "dcterms.issued",
87];
88
89const OG_PREFIXES: &[&str] = &["og:", "article:", "book:", "profile:", "music:", "video:"];
90
91const FEED_TYPES: &[&str] = &[
92    "application/rss+xml",
93    "application/atom+xml",
94    "application/feed+json",
95];
96
97impl Dom {
98    /// The page's [`Metadata`]: title, description, canonical URL, language,
99    /// OpenGraph and Twitter-card fields, raw JSON-LD blocks and feed links.
100    ///
101    /// The whole document is searched (not just `<head>`: this parser does not
102    /// move misplaced tags there), in one pass. Names and properties match
103    /// ASCII case-insensitively; empty values are ignored.
104    ///
105    /// ```
106    /// let dom = domtree::parse(r#"<html lang="en"><head>
107    ///   <title>  Shipping a parser
108    ///     | Blog </title>
109    ///   <meta name="description" content="What we learned.">
110    ///   <meta property="og:image" content="https://example.com/cover.png">
111    ///   <meta name="twitter:card" content="summary_large_image">
112    ///   <link rel="canonical" href="https://example.com/p/1">
113    ///   <link rel="alternate" type="application/rss+xml" href="/feed.xml">
114    ///   <script type="application/ld+json">{"@type": "BlogPosting"}</script>
115    /// </head></html>"#);
116    ///
117    /// let meta = dom.metadata();
118    /// assert_eq!(meta.title.as_deref(), Some("Shipping a parser | Blog"));
119    /// assert_eq!(meta.description.as_deref(), Some("What we learned."));
120    /// assert_eq!(meta.lang.as_deref(), Some("en"));
121    /// assert_eq!(meta.canonical.as_deref(), Some("https://example.com/p/1"));
122    /// assert_eq!(meta.image.as_deref(), Some("https://example.com/cover.png"));
123    /// assert_eq!(meta.twitter, [("twitter:card".to_string(), "summary_large_image".to_string())]);
124    /// assert_eq!(meta.json_ld, [r#"{"@type": "BlogPosting"}"#]);
125    /// assert_eq!(meta.feeds, ["/feed.xml"]);
126    /// ```
127    pub fn metadata(&self) -> Metadata {
128        let mut scan = Scan::default();
129        for root in self.roots() {
130            walk(root, |node, enter| scan.visit(node, enter));
131        }
132        scan.finish()
133    }
134}
135
136/// What one pass over the document collects; [`Scan::finish`] applies the
137/// fallbacks.
138#[derive(Default)]
139struct Scan<'a> {
140    meta: Metadata,
141    /// `<meta name=… content=…>` pairs (names lower-cased), first one wins.
142    names: Vec<(String, &'a str)>,
143    title: Option<String>,
144    html_lang: Option<&'a str>,
145    seen_html: bool,
146    canonical: Option<&'a str>,
147    image_src: Option<&'a str>,
148    author_link: Option<String>,
149    item_published: Option<&'a str>,
150    time: Option<&'a str>,
151    /// Depth of open `<svg>` and `<article>` elements.
152    svg: u32,
153    article: u32,
154}
155
156impl<'a> Scan<'a> {
157    fn visit(&mut self, node: NodeRef<'a>, enter: bool) -> bool {
158        let Some(tag) = node.tag_name() else {
159            return false;
160        };
161        if !enter {
162            match tag {
163                "svg" => self.svg = self.svg.saturating_sub(1),
164                "article" => self.article = self.article.saturating_sub(1),
165                _ => {}
166            }
167            return false;
168        }
169        match tag {
170            "svg" => self.svg = self.svg.saturating_add(1),
171            "article" => self.article = self.article.saturating_add(1),
172            "html" if !self.seen_html => {
173                self.seen_html = true;
174                self.html_lang = node.attr("lang").and_then(non_empty);
175            }
176            "title" if self.svg == 0 && self.title.is_none() => {
177                self.title = Some(collapse(&node.text_content())).filter(|t| !t.is_empty());
178                return false;
179            }
180            "meta" => self.meta_tag(node),
181            "link" => self.link_tag(node),
182            "a" if self.author_link.is_none() => {
183                if node.attr("rel").is_some_and(|r| has_token(r, "author")) {
184                    self.author_link = Some(collapse(&node.to_text())).filter(|t| !t.is_empty());
185                }
186            }
187            "time" if self.time.is_none() => {
188                let marked = self.article > 0
189                    || node.has_attr("pubdate")
190                    || node
191                        .attr("itemprop")
192                        .is_some_and(|p| has_token(p, "datePublished"));
193                if marked {
194                    self.time = node.attr("datetime").and_then(non_empty);
195                }
196            }
197            "script" => {
198                let ld = node.attr("type").is_some_and(|t| {
199                    let mime = t.split(';').next().unwrap_or("").trim();
200                    mime.eq_ignore_ascii_case("application/ld+json")
201                });
202                if ld {
203                    if let Some(json) = non_empty(&node.text_content()) {
204                        self.meta.json_ld.push(String::from(json));
205                    }
206                }
207                return false;
208            }
209            "style" | "template" => return false,
210            _ => {}
211        }
212        true
213    }
214
215    fn meta_tag(&mut self, node: NodeRef<'a>) {
216        let Some(content) = node.attr("content").and_then(non_empty) else {
217            return;
218        };
219        if let Some(property) = node.attr("property").and_then(non_empty) {
220            let key = property.to_ascii_lowercase();
221            if OG_PREFIXES.iter().any(|p| key.starts_with(p)) {
222                self.meta.og.push((key, String::from(content)));
223            } else if key.starts_with("twitter:") {
224                self.meta.twitter.push((key, String::from(content)));
225            }
226        }
227        if let Some(name) = node.attr("name").and_then(non_empty) {
228            let key = name.to_ascii_lowercase();
229            if key.starts_with("twitter:") {
230                self.meta.twitter.push((key.clone(), String::from(content)));
231            } else if key.starts_with("og:") {
232                // A common mistake for `property="og:…"`.
233                self.meta.og.push((key.clone(), String::from(content)));
234            }
235            self.names.push((key, content));
236        }
237        if let Some(http) = node.attr("http-equiv") {
238            if http.trim().eq_ignore_ascii_case("content-language") {
239                self.names.push((String::from("content-language"), content));
240            }
241        }
242        if self.item_published.is_none()
243            && node
244                .attr("itemprop")
245                .is_some_and(|p| has_token(p, "datePublished"))
246        {
247            self.item_published = Some(content);
248        }
249    }
250
251    fn link_tag(&mut self, node: NodeRef<'a>) {
252        let (Some(rel), Some(href)) = (node.attr("rel"), node.attr("href").and_then(non_empty))
253        else {
254            return;
255        };
256        if self.canonical.is_none() && has_token(rel, "canonical") {
257            self.canonical = Some(href);
258        }
259        if self.image_src.is_none() && has_token(rel, "image_src") {
260            self.image_src = Some(href);
261        }
262        if has_token(rel, "alternate") {
263            let feed = node.attr("type").is_some_and(|t| {
264                let mime = t.split(';').next().unwrap_or("").trim();
265                FEED_TYPES.iter().any(|f| mime.eq_ignore_ascii_case(f))
266            });
267            if feed {
268                self.meta.feeds.push(String::from(href));
269            }
270        }
271    }
272
273    fn name(&self, key: &str) -> Option<&'a str> {
274        self.names
275            .iter()
276            .find(|(k, _)| k == key)
277            .map(|&(_, value)| value)
278    }
279
280    fn og(&self, key: &str) -> Option<&str> {
281        find(&self.meta.og, key)
282    }
283
284    fn twitter(&self, key: &str) -> Option<&str> {
285        find(&self.meta.twitter, key)
286    }
287
288    fn finish(mut self) -> Metadata {
289        let text = |s: Option<&str>| s.map(collapse).filter(|s| !s.is_empty());
290        let url = |s: Option<&str>| s.map(String::from);
291
292        let title = self.title.take().or_else(|| {
293            text(
294                self.og("og:title")
295                    .or_else(|| self.twitter("twitter:title")),
296            )
297        });
298        let description = text(
299            self.name("description")
300                .or_else(|| self.og("og:description"))
301                .or_else(|| self.twitter("twitter:description")),
302        );
303        let canonical = url(self.canonical.or_else(|| self.og("og:url")));
304        let lang = url(self.html_lang.or_else(|| self.name("content-language")));
305        let site_name = text(
306            self.og("og:site_name")
307                .or_else(|| self.name("application-name")),
308        );
309        let image = url(self
310            .og("og:image")
311            .or_else(|| self.og("og:image:url"))
312            .or_else(|| self.og("og:image:secure_url"))
313            .or_else(|| self.twitter("twitter:image"))
314            .or_else(|| self.twitter("twitter:image:src"))
315            .or(self.image_src));
316        // `article:author` is often a profile URL: prefer a name.
317        let og_author = self.og("article:author").map(String::from);
318        let is_url = |a: &String| a.contains("://");
319        let author = text(self.name("author"))
320            .or_else(|| text(og_author.as_deref().filter(|a| !a.contains("://"))))
321            .or_else(|| self.author_link.take())
322            .or_else(|| og_author.filter(is_url));
323        let published = url(self
324            .og("article:published_time")
325            .or(self.item_published)
326            .or_else(|| DATE_NAMES.iter().find_map(|k| self.name(k)))
327            .or(self.time));
328        let mut keywords: Vec<String> = self
329            .name("keywords")
330            .or_else(|| self.name("news_keywords"))
331            .map(|k| {
332                k.split(',')
333                    .filter_map(non_empty)
334                    .map(String::from)
335                    .collect()
336            })
337            .unwrap_or_default();
338        if keywords.is_empty() {
339            keywords = self
340                .meta
341                .og
342                .iter()
343                .filter(|(k, _)| k == "article:tag")
344                .map(|(_, v)| v.clone())
345                .collect();
346        }
347
348        Metadata {
349            title,
350            description,
351            canonical,
352            lang,
353            site_name,
354            image,
355            author,
356            published,
357            keywords,
358            ..self.meta
359        }
360    }
361}
362
363fn find<'v>(pairs: &'v [(String, String)], key: &str) -> Option<&'v str> {
364    pairs
365        .iter()
366        .find(|(k, _)| k == key)
367        .map(|(_, v)| v.as_str())
368}
369
370#[cfg(feature = "serde")]
371impl serde::Serialize for Metadata {
372    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
373        use serde::ser::SerializeStruct;
374        let mut st = s.serialize_struct("Metadata", 13)?;
375        st.serialize_field("title", &self.title)?;
376        st.serialize_field("description", &self.description)?;
377        st.serialize_field("canonical", &self.canonical)?;
378        st.serialize_field("lang", &self.lang)?;
379        st.serialize_field("site_name", &self.site_name)?;
380        st.serialize_field("image", &self.image)?;
381        st.serialize_field("author", &self.author)?;
382        st.serialize_field("published", &self.published)?;
383        st.serialize_field("keywords", &self.keywords)?;
384        st.serialize_field("og", &self.og)?;
385        st.serialize_field("twitter", &self.twitter)?;
386        st.serialize_field("json_ld", &self.json_ld)?;
387        st.serialize_field("feeds", &self.feeds)?;
388        st.end()
389    }
390}