1use 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#[non_exhaustive]
23#[derive(Clone, Debug, Default, PartialEq, Eq)]
24pub struct Metadata {
25 pub title: Option<String>,
28 pub description: Option<String>,
31 pub canonical: Option<String>,
33 pub lang: Option<String>,
36 pub site_name: Option<String>,
38 pub image: Option<String>,
41 pub author: Option<String>,
44 pub published: Option<String>,
51 pub keywords: Vec<String>,
54 pub og: Vec<(String, String)>,
60 pub twitter: Vec<(String, String)>,
64 pub json_ld: Vec<String>,
68 pub feeds: Vec<String>,
72}
73
74const 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 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#[derive(Default)]
139struct Scan<'a> {
140 meta: Metadata,
141 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 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 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 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}