use alloc::string::String;
use alloc::vec::Vec;
use super::{collapse, has_token, non_empty};
use crate::serialize::walk;
use crate::tree::{Dom, NodeRef};
#[non_exhaustive]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Metadata {
pub title: Option<String>,
pub description: Option<String>,
pub canonical: Option<String>,
pub lang: Option<String>,
pub site_name: Option<String>,
pub image: Option<String>,
pub author: Option<String>,
pub published: Option<String>,
pub keywords: Vec<String>,
pub og: Vec<(String, String)>,
pub twitter: Vec<(String, String)>,
pub json_ld: Vec<String>,
pub feeds: Vec<String>,
}
const DATE_NAMES: &[&str] = &[
"date",
"pubdate",
"publishdate",
"publish-date",
"publish_date",
"article.published",
"dc.date",
"dc.date.issued",
"dcterms.date",
"dcterms.created",
"dcterms.issued",
];
const OG_PREFIXES: &[&str] = &["og:", "article:", "book:", "profile:", "music:", "video:"];
const FEED_TYPES: &[&str] = &[
"application/rss+xml",
"application/atom+xml",
"application/feed+json",
];
impl Dom {
pub fn metadata(&self) -> Metadata {
let mut scan = Scan::default();
for root in self.roots() {
walk(root, |node, enter| scan.visit(node, enter));
}
scan.finish()
}
}
#[derive(Default)]
struct Scan<'a> {
meta: Metadata,
names: Vec<(String, &'a str)>,
title: Option<String>,
html_lang: Option<&'a str>,
seen_html: bool,
canonical: Option<&'a str>,
image_src: Option<&'a str>,
author_link: Option<String>,
item_published: Option<&'a str>,
time: Option<&'a str>,
svg: u32,
article: u32,
}
impl<'a> Scan<'a> {
fn visit(&mut self, node: NodeRef<'a>, enter: bool) -> bool {
let Some(tag) = node.tag_name() else {
return false;
};
if !enter {
match tag {
"svg" => self.svg = self.svg.saturating_sub(1),
"article" => self.article = self.article.saturating_sub(1),
_ => {}
}
return false;
}
match tag {
"svg" => self.svg = self.svg.saturating_add(1),
"article" => self.article = self.article.saturating_add(1),
"html" if !self.seen_html => {
self.seen_html = true;
self.html_lang = node.attr("lang").and_then(non_empty);
}
"title" if self.svg == 0 && self.title.is_none() => {
self.title = Some(collapse(&node.text_content())).filter(|t| !t.is_empty());
return false;
}
"meta" => self.meta_tag(node),
"link" => self.link_tag(node),
"a" if self.author_link.is_none() => {
if node.attr("rel").is_some_and(|r| has_token(r, "author")) {
self.author_link = Some(collapse(&node.to_text())).filter(|t| !t.is_empty());
}
}
"time" if self.time.is_none() => {
let marked = self.article > 0
|| node.has_attr("pubdate")
|| node
.attr("itemprop")
.is_some_and(|p| has_token(p, "datePublished"));
if marked {
self.time = node.attr("datetime").and_then(non_empty);
}
}
"script" => {
let ld = node.attr("type").is_some_and(|t| {
let mime = t.split(';').next().unwrap_or("").trim();
mime.eq_ignore_ascii_case("application/ld+json")
});
if ld && let Some(json) = non_empty(&node.text_content()) {
self.meta.json_ld.push(String::from(json));
}
return false;
}
"style" | "template" => return false,
_ => {}
}
true
}
fn meta_tag(&mut self, node: NodeRef<'a>) {
let Some(content) = node.attr("content").and_then(non_empty) else {
return;
};
if let Some(property) = node.attr("property").and_then(non_empty) {
let key = property.to_ascii_lowercase();
if OG_PREFIXES.iter().any(|p| key.starts_with(p)) {
self.meta.og.push((key, String::from(content)));
} else if key.starts_with("twitter:") {
self.meta.twitter.push((key, String::from(content)));
}
}
if let Some(name) = node.attr("name").and_then(non_empty) {
let key = name.to_ascii_lowercase();
if key.starts_with("twitter:") {
self.meta.twitter.push((key.clone(), String::from(content)));
} else if key.starts_with("og:") {
self.meta.og.push((key.clone(), String::from(content)));
}
self.names.push((key, content));
}
if let Some(http) = node.attr("http-equiv")
&& http.trim().eq_ignore_ascii_case("content-language")
{
self.names.push((String::from("content-language"), content));
}
if self.item_published.is_none()
&& node
.attr("itemprop")
.is_some_and(|p| has_token(p, "datePublished"))
{
self.item_published = Some(content);
}
}
fn link_tag(&mut self, node: NodeRef<'a>) {
let (Some(rel), Some(href)) = (node.attr("rel"), node.attr("href").and_then(non_empty))
else {
return;
};
if self.canonical.is_none() && has_token(rel, "canonical") {
self.canonical = Some(href);
}
if self.image_src.is_none() && has_token(rel, "image_src") {
self.image_src = Some(href);
}
if has_token(rel, "alternate") {
let feed = node.attr("type").is_some_and(|t| {
let mime = t.split(';').next().unwrap_or("").trim();
FEED_TYPES.iter().any(|f| mime.eq_ignore_ascii_case(f))
});
if feed {
self.meta.feeds.push(String::from(href));
}
}
}
fn name(&self, key: &str) -> Option<&'a str> {
self.names
.iter()
.find(|(k, _)| k == key)
.map(|&(_, value)| value)
}
fn og(&self, key: &str) -> Option<&str> {
find(&self.meta.og, key)
}
fn twitter(&self, key: &str) -> Option<&str> {
find(&self.meta.twitter, key)
}
fn finish(mut self) -> Metadata {
let text = |s: Option<&str>| s.map(collapse).filter(|s| !s.is_empty());
let url = |s: Option<&str>| s.map(String::from);
let title = self.title.take().or_else(|| {
text(
self.og("og:title")
.or_else(|| self.twitter("twitter:title")),
)
});
let description = text(
self.name("description")
.or_else(|| self.og("og:description"))
.or_else(|| self.twitter("twitter:description")),
);
let canonical = url(self.canonical.or_else(|| self.og("og:url")));
let lang = url(self.html_lang.or_else(|| self.name("content-language")));
let site_name = text(
self.og("og:site_name")
.or_else(|| self.name("application-name")),
);
let image = url(self
.og("og:image")
.or_else(|| self.og("og:image:url"))
.or_else(|| self.og("og:image:secure_url"))
.or_else(|| self.twitter("twitter:image"))
.or_else(|| self.twitter("twitter:image:src"))
.or(self.image_src));
let og_author = self.og("article:author").map(String::from);
let is_url = |a: &String| a.contains("://");
let author = text(self.name("author"))
.or_else(|| text(og_author.as_deref().filter(|a| !a.contains("://"))))
.or_else(|| self.author_link.take())
.or_else(|| og_author.filter(is_url));
let published = url(self
.og("article:published_time")
.or(self.item_published)
.or_else(|| DATE_NAMES.iter().find_map(|k| self.name(k)))
.or(self.time));
let mut keywords: Vec<String> = self
.name("keywords")
.or_else(|| self.name("news_keywords"))
.map(|k| {
k.split(',')
.filter_map(non_empty)
.map(String::from)
.collect()
})
.unwrap_or_default();
if keywords.is_empty() {
keywords = self
.meta
.og
.iter()
.filter(|(k, _)| k == "article:tag")
.map(|(_, v)| v.clone())
.collect();
}
Metadata {
title,
description,
canonical,
lang,
site_name,
image,
author,
published,
keywords,
..self.meta
}
}
}
fn find<'v>(pairs: &'v [(String, String)], key: &str) -> Option<&'v str> {
pairs
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
#[cfg(feature = "serde")]
impl serde::Serialize for Metadata {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut st = s.serialize_struct("Metadata", 13)?;
st.serialize_field("title", &self.title)?;
st.serialize_field("description", &self.description)?;
st.serialize_field("canonical", &self.canonical)?;
st.serialize_field("lang", &self.lang)?;
st.serialize_field("site_name", &self.site_name)?;
st.serialize_field("image", &self.image)?;
st.serialize_field("author", &self.author)?;
st.serialize_field("published", &self.published)?;
st.serialize_field("keywords", &self.keywords)?;
st.serialize_field("og", &self.og)?;
st.serialize_field("twitter", &self.twitter)?;
st.serialize_field("json_ld", &self.json_ld)?;
st.serialize_field("feeds", &self.feeds)?;
st.end()
}
}