Skip to main content

docbox_web_scraper/
document.rs

1//! # Document
2//!
3//! HTML document related logic for extracting information scraped from remote
4//! HTML pages such as OGP metadata <title/> tags etc
5
6use 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/// Metadata extracted from a website
18#[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/// Favicon extracted from a website
28#[derive(Debug, Clone)]
29pub struct Favicon {
30    /// Mime type for the favicon
31    pub ty: Mime,
32    /// Size if known
33    pub sizes: Option<String>,
34    /// URL of the favicon
35    pub href: String,
36}
37
38/// State for data extracted from a website document
39#[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/// Errors that could occur when website metadata is loaded
50#[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/// Errors that could occur when parsing the website metadata
63#[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
77/// Connects to a website reading the HTML contents, extracts the metadata
78/// required from the <head/> element
79pub 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    // Get the path from the URL
86    let path = url.path();
87
88    // Check if the path ends with a common HTML extension or if it is empty
89    if !path.ends_with(".html") && !path.ends_with(".htm") && path.is_empty() {
90        // Append /index.html if needed
91        url.set_path("/index.html");
92    }
93
94    // Request page at URL
95    let (response, _redirects) = get_request::<D>(client, url).await?;
96
97    // Read response text
98    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/// Error's that can occur when attempting to load robots.txt
107#[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
116/// Attempts to read the robots.txt file for the website to determine if
117/// scraping is allowed
118pub 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    // Change path to /robots.txt
127    url.set_path("/robots.txt");
128
129    // Request page at URL
130    let (response, _redirects) = get_request::<D>(client, url).await?;
131
132    // Read response text
133    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
145/// Parse website metadata contained within the provided HTML content
146pub 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    // Find the head element
153    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            // Extract page title tag
174            b"title" => visit_title_tag(&mut state, parser, tag),
175            // Extract metadata
176            b"meta" => visit_meta_tag(&mut state, tag),
177            // Extract favicons
178            b"link" => visit_link_tag(&mut state, tag),
179            // Ignore other tags
180            _ => {}
181        }
182    }
183
184    // Fallback to description
185    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
196/// Determines which favicon to use from the provided list
197///
198/// Prefers .ico format currently then defaulting to first
199/// available. At a later date might want to check the sizes
200/// field
201pub fn determine_best_favicon(favicons: &[Favicon]) -> Option<&Favicon> {
202    favicons
203        .iter()
204        // Search for an ico first
205        .find(|favicon| favicon.ty.essence_str().eq("image/x-icon"))
206        // Fallback to whatever is first
207        .or_else(|| favicons.first())
208}
209
210/// Visit <title/> tags in the document
211fn 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
220/// Visit metadata tags in the document like:
221///
222/// <meta name="description" content="Website title" />
223/// <meta property="og:title" content="Website title" />
224/// <meta property="og:image" content="https://example.com/image.jpg" />
225/// <meta property="og:description"Website description" />
226fn 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
268/// Visit a link tag attempt to find a favicon image file link:
269///
270/// <link rel="icon" type="image/x-icon" href="/images/favicon.ico">
271/// <link rel="shortcut icon" type="image/x-icon" href="/images/favicon.ico">
272fn 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    // Only match icon link
278    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    // Ignore missing or invalid mimes
288    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    // Ignore missing href
299    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}