leyline-http 0.1.5

HTTP client that impersonates Chrome, Firefox, and Safari on the wire: TLS (JA3/JA4), HTTP/2, and HTTP/3 fingerprints
Documentation
use std::collections::HashMap;
use std::fmt;

use http::StatusCode;
use url::Url;

use crate::html;
use crate::util::redact;

#[derive(Clone)]
pub struct CrawlPage {
    url: Url,
    depth: usize,
    status: StatusCode,
    html: String,
    text: String,
}

impl fmt::Debug for CrawlPage {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("CrawlPage")
            .field("url", &redact(self.url.as_str()))
            .field("depth", &self.depth)
            .field("status", &self.status)
            .field("html_bytes", &self.html.len())
            .field("text_bytes", &self.text.len())
            .finish()
    }
}

impl CrawlPage {
    pub(super) fn new(
        url: Url,
        depth: usize,
        status: StatusCode,
        html: String,
        text: String,
    ) -> Self {
        Self {
            url,
            depth,
            status,
            html,
            text,
        }
    }

    #[must_use]
    pub fn url(&self) -> &Url {
        &self.url
    }

    #[must_use]
    pub fn depth(&self) -> usize {
        self.depth
    }

    #[must_use]
    pub fn status(&self) -> StatusCode {
        self.status
    }

    #[must_use]
    pub fn html(&self) -> &str {
        &self.html
    }

    #[must_use]
    pub fn text(&self) -> &str {
        &self.text
    }

    #[must_use]
    pub fn meta(&self, name: &str) -> Option<String> {
        html::meta(&self.html, name)
    }

    #[must_use]
    pub fn into_html(self) -> String {
        self.html
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SkipReason {
    Robots,
    Signal,
    Status,
    NotHtml,
    Blocked,
    NoIndex,
    NoAi,
    Duplicate,
    Redirects,
    Scope,
}

impl SkipReason {
    const ALL: [SkipReason; 10] = [
        SkipReason::Robots,
        SkipReason::Signal,
        SkipReason::Status,
        SkipReason::NotHtml,
        SkipReason::Blocked,
        SkipReason::NoIndex,
        SkipReason::NoAi,
        SkipReason::Duplicate,
        SkipReason::Redirects,
        SkipReason::Scope,
    ];

    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            SkipReason::Robots => "robots",
            SkipReason::Signal => "signal",
            SkipReason::Status => "status",
            SkipReason::NotHtml => "not_html",
            SkipReason::Blocked => "blocked",
            SkipReason::NoIndex => "noindex",
            SkipReason::NoAi => "noai",
            SkipReason::Duplicate => "duplicate",
            SkipReason::Redirects => "redirects",
            SkipReason::Scope => "scope",
        }
    }
}

#[derive(Debug, Clone, Default)]
pub struct CrawlStats {
    requests: u64,
    pages: u64,
    errors: u64,
    skipped: HashMap<SkipReason, u64>,
}

impl CrawlStats {
    #[must_use]
    pub fn requests(&self) -> u64 {
        self.requests
    }

    #[must_use]
    pub fn pages(&self) -> u64 {
        self.pages
    }

    #[must_use]
    pub fn errors(&self) -> u64 {
        self.errors
    }

    #[must_use]
    pub fn skipped(&self, reason: SkipReason) -> u64 {
        self.skipped.get(&reason).copied().unwrap_or(0)
    }

    pub(super) fn request(&mut self) {
        self.requests += 1;
    }

    pub(super) fn page(&mut self) {
        self.pages += 1;
    }

    pub(super) fn error(&mut self) {
        self.errors += 1;
    }

    pub(super) fn skip(&mut self, reason: SkipReason) {
        *self.skipped.entry(reason).or_default() += 1;
    }
}

impl fmt::Display for CrawlStats {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "requests={} pages={} errors={}",
            self.requests, self.pages, self.errors
        )?;
        for reason in SkipReason::ALL {
            write!(f, " skipped_{}={}", reason.as_str(), self.skipped(reason))?;
        }
        Ok(())
    }
}