1use mime::Mime;
7use std::str::FromStr;
8use thiserror::Error;
9use tl::{HTMLTag, Parser};
10use url::Url;
11
12use crate::{
13 request::{RequestError, get_request},
14 url_validation::UrlValidation,
15};
16
17#[derive(Debug)]
19pub struct WebsiteMetadata {
20 pub title: Option<String>,
21 pub og_title: Option<String>,
22 pub og_description: Option<String>,
23 pub og_image: Option<String>,
24 pub favicons: Vec<Favicon>,
25}
26
27#[derive(Debug, Clone)]
29pub struct Favicon {
30 pub ty: Mime,
32 pub sizes: Option<String>,
34 pub href: String,
36}
37
38#[derive(Default)]
40struct WebsiteDocumentState {
41 title: Option<String>,
42 description: Option<String>,
43 og_title: Option<String>,
44 og_description: Option<String>,
45 og_image: Option<String>,
46 favicons: Vec<Favicon>,
47}
48
49#[derive(Debug, Error)]
51pub enum WebsiteMetadataError {
52 #[error(transparent)]
53 Request(#[from] RequestError),
54
55 #[error("failed to read response")]
56 ReadResponse(reqwest::Error),
57
58 #[error(transparent)]
59 Parse(WebsiteMetadataParseError),
60}
61
62#[derive(Debug, Error)]
64pub enum WebsiteMetadataParseError {
65 #[error("failed to parse resource response")]
66 Parsing,
67 #[error("failed to query page head")]
68 QueryHead,
69 #[error("page missing head element")]
70 MissingHead,
71 #[error("failed to parse head element")]
72 InvalidHead,
73 #[error("head element has no children")]
74 EmptyHead,
75}
76
77pub async fn get_website_metadata<D: UrlValidation>(
80 client: &reqwest::Client,
81 url: &Url,
82) -> Result<WebsiteMetadata, WebsiteMetadataError> {
83 let mut url = url.clone();
84
85 let path = url.path();
87
88 if !path.ends_with(".html") && !path.ends_with(".htm") && path.is_empty() {
90 url.set_path("/index.html");
92 }
93
94 let (response, _redirects) = get_request::<D>(client, url).await?;
96
97 let text = response
99 .text()
100 .await
101 .map_err(WebsiteMetadataError::ReadResponse)?;
102
103 parse_website_metadata(&text).map_err(WebsiteMetadataError::Parse)
104}
105
106#[derive(Debug, Error)]
108pub enum RobotsTxtError {
109 #[error(transparent)]
110 Request(#[from] RequestError),
111
112 #[error("failed to read response")]
113 ReadResponse(reqwest::Error),
114}
115
116pub async fn is_allowed_robots_txt<D: UrlValidation>(
119 client: &reqwest::Client,
120 url: &Url,
121) -> Result<bool, RobotsTxtError> {
122 let mut url = url.clone();
123
124 let original_url = url.to_string();
125
126 url.set_path("/robots.txt");
128
129 let (response, _redirects) = get_request::<D>(client, url).await?;
131
132 let robots_txt = response
134 .text()
135 .await
136 .map_err(RobotsTxtError::ReadResponse)?;
137
138 let mut matcher = robotstxt::DefaultMatcher::default();
139 let is_allowed =
140 matcher.one_agent_allowed_by_robots(&robots_txt, "DocboxLinkBot", &original_url);
141
142 Ok(is_allowed)
143}
144
145pub fn parse_website_metadata(html: &str) -> Result<WebsiteMetadata, WebsiteMetadataParseError> {
147 let dom = tl::parse(html, tl::ParserOptions::default())
148 .map_err(|_| WebsiteMetadataParseError::Parsing)?;
149
150 let parser = dom.parser();
151
152 let head = dom
154 .query_selector("head")
155 .ok_or(WebsiteMetadataParseError::QueryHead)?
156 .next()
157 .ok_or(WebsiteMetadataParseError::MissingHead)?
158 .get(parser)
159 .ok_or(WebsiteMetadataParseError::InvalidHead)?;
160
161 let mut state = WebsiteDocumentState::default();
162
163 let children = head
164 .children()
165 .ok_or(WebsiteMetadataParseError::EmptyHead)?;
166 for child in children.all(parser) {
167 let tag = match child.as_tag() {
168 Some(tag) => tag,
169 None => continue,
170 };
171
172 match tag.name().as_bytes() {
173 b"title" => visit_title_tag(&mut state, parser, tag),
175 b"meta" => visit_meta_tag(&mut state, tag),
177 b"link" => visit_link_tag(&mut state, tag),
179 _ => {}
181 }
182 }
183
184 let og_description = state.og_description.or(state.description);
186
187 Ok(WebsiteMetadata {
188 title: state.title,
189 og_title: state.og_title,
190 og_description,
191 og_image: state.og_image,
192 favicons: state.favicons,
193 })
194}
195
196pub fn determine_best_favicon(favicons: &[Favicon]) -> Option<&Favicon> {
202 favicons
203 .iter()
204 .find(|favicon| favicon.ty.essence_str().eq("image/x-icon"))
206 .or_else(|| favicons.first())
208}
209
210fn visit_title_tag<'doc>(
212 state: &mut WebsiteDocumentState,
213 parser: &Parser<'doc>,
214 tag: &HTMLTag<'doc>,
215) {
216 let value = tag.inner_text(parser).to_string();
217 state.title = Some(value);
218}
219
220fn visit_meta_tag<'doc>(state: &mut WebsiteDocumentState, tag: &HTMLTag<'doc>) {
227 let attributes = tag.attributes();
228 let property = match attributes.get("property").flatten() {
229 Some(value) => value.as_bytes(),
230 None => match attributes.get("name").flatten() {
231 Some(value) => value.as_bytes(),
232 None => return,
233 },
234 };
235
236 fn get_content_value<'doc>(attributes: &tl::Attributes<'doc>) -> Option<String> {
237 attributes
238 .get("content")
239 .flatten()
240 .map(|value| value.as_utf8_str().to_string())
241 }
242
243 match property {
244 b"description" => {
245 if let Some(content) = get_content_value(attributes) {
246 state.description = Some(content);
247 }
248 }
249 b"og:title" => {
250 if let Some(content) = get_content_value(attributes) {
251 state.og_title = Some(content);
252 }
253 }
254 b"og:description" => {
255 if let Some(content) = get_content_value(attributes) {
256 state.og_description = Some(content);
257 }
258 }
259 b"og:image" => {
260 if let Some(content) = get_content_value(attributes) {
261 state.og_image = Some(content);
262 }
263 }
264 _ => {}
265 }
266}
267
268fn visit_link_tag(state: &mut WebsiteDocumentState, tag: &HTMLTag<'_>) {
273 let attributes = tag.attributes();
274
275 let rel = attributes.get("rel").flatten().map(tl::Bytes::as_bytes);
276
277 if !matches!(rel, Some(b"icon" | b"shortcut icon")) {
279 return;
280 }
281
282 let mime = attributes
283 .get("type")
284 .flatten()
285 .and_then(|value| Mime::from_str(value.as_utf8_str().as_ref()).ok());
286
287 let ty = match mime {
289 Some(value) => value,
290 None => return,
291 };
292
293 let href = attributes
294 .get("href")
295 .flatten()
296 .map(|value| value.as_utf8_str().to_string());
297
298 let href = match href {
300 Some(value) => value,
301 None => return,
302 };
303
304 let sizes = attributes
305 .get("sizes")
306 .flatten()
307 .map(|value| value.as_utf8_str().to_string());
308
309 state.favicons.push(Favicon { ty, sizes, href });
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn test_parse_website_metadata_all_fields() {
318 let html = r#"
319 <html>
320 <head>
321 <title>Test Title</title>
322 <meta name="description" content="Fallback description" />
323 <meta property="og:title" content="OG Title" />
324 <meta property="og:description" content="OG Description" />
325 <meta property="og:image" content="https://example.com/image.png" />
326 <link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="16x16" />
327 </head>
328 </html>
329 "#;
330
331 let metadata = parse_website_metadata(html).expect("Failed to parse metadata");
332
333 assert_eq!(metadata.title, Some("Test Title".to_string()));
334 assert_eq!(metadata.og_title, Some("OG Title".to_string()));
335 assert_eq!(metadata.og_description, Some("OG Description".to_string()));
336 assert_eq!(
337 metadata.og_image,
338 Some("https://example.com/image.png".to_string())
339 );
340 assert_eq!(metadata.favicons.len(), 1);
341 let favicon = &metadata.favicons[0];
342 assert_eq!(favicon.ty, mime::Mime::from_str("image/x-icon").unwrap());
343 assert_eq!(favicon.href, "/favicon.ico");
344 assert_eq!(favicon.sizes, Some("16x16".to_string()));
345 }
346
347 #[test]
348 fn test_parse_website_metadata_fallback_description() {
349 let html = r#"
350 <html>
351 <head>
352 <title>Test Title</title>
353 <meta name="description" content="Fallback description" />
354 </head>
355 </html>
356 "#;
357
358 let metadata = parse_website_metadata(html).expect("Failed to parse metadata");
359
360 assert_eq!(
361 metadata.og_description,
362 Some("Fallback description".to_string())
363 );
364 }
365
366 #[test]
367 fn test_parse_website_metadata_missing_tags() {
368 let html = r"
369 <html>
370 <head>
371 <!-- Empty head -->
372 </head>
373 </html>
374 ";
375
376 let metadata = parse_website_metadata(html).expect("Failed to parse metadata");
377
378 assert!(metadata.title.is_none());
379 assert!(metadata.og_title.is_none());
380 assert!(metadata.og_description.is_none());
381 assert!(metadata.og_image.is_none());
382 assert!(metadata.favicons.is_empty());
383 }
384
385 #[test]
386 fn test_determine_best_favicon_prefers_ico() {
387 let favicons = vec![
388 Favicon {
389 ty: mime::Mime::from_str("image/png").unwrap(),
390 href: "/favicon.png".to_string(),
391 sizes: Some("32x32".to_string()),
392 },
393 Favicon {
394 ty: mime::Mime::from_str("image/x-icon").unwrap(),
395 href: "/favicon.ico".to_string(),
396 sizes: Some("16x16".to_string()),
397 },
398 ];
399
400 let best = determine_best_favicon(&favicons);
401 assert!(best.is_some());
402 assert_eq!(best.unwrap().href, "/favicon.ico");
403 }
404
405 #[test]
406 fn test_determine_best_favicon_fallback() {
407 let favicons = vec![Favicon {
408 ty: mime::Mime::from_str("image/png").unwrap(),
409 href: "/favicon.png".to_string(),
410 sizes: None,
411 }];
412
413 let best = determine_best_favicon(&favicons);
414 assert!(best.is_some());
415 assert_eq!(best.unwrap().href, "/favicon.png");
416 }
417
418 #[test]
419 fn test_determine_best_favicon_none() {
420 let favicons = vec![];
421 let best = determine_best_favicon(&favicons);
422 assert!(best.is_none());
423 }
424}