use std::sync::LazyLock;
use ::http::header::IntoHeaderName;
use ::http::{HeaderMap, HeaderValue, Method, Uri};
use bytes::Bytes;
mod fingerprint;
mod normalize;
#[cfg(feature = "serde")]
mod repr;
pub use fingerprint::Fingerprint;
use crate::crawler::{Attempt, Depth, Tag};
use crate::http::{Request, RequestMeta};
use crate::{Error, Result};
#[derive(Debug, Clone)]
pub struct Seed {
method: Method,
uri: Uri,
extras: Option<Box<Extras>>,
tag: Option<Tag>,
depth: Depth,
priority: i32,
attempt: Attempt,
}
impl Seed {
pub fn new(method: Method, uri: Uri) -> Result<Self> {
if uri.scheme().is_none() || uri.authority().is_none() {
return Err(Error::invalid(format!("seed uri `{uri}` is not absolute")));
}
Ok(Self {
method,
uri,
extras: None,
tag: None,
depth: Depth::ROOT,
priority: 0,
attempt: Attempt::FIRST,
})
}
pub fn get<U>(uri: U) -> Result<Self>
where
U: TryInto<Uri>,
U::Error: Into<Error>,
{
Self::new(Method::GET, uri.try_into().map_err(Into::into)?)
}
#[must_use]
pub fn with_tag(mut self, tag: impl Into<Tag>) -> Self {
self.tag = Some(tag.into());
self
}
#[must_use]
pub const fn with_depth(mut self, depth: Depth) -> Self {
self.depth = depth;
self
}
#[must_use]
pub const fn with_priority(mut self, priority: i32) -> Self {
self.priority = priority;
self
}
#[must_use]
pub fn with_header(mut self, name: impl IntoHeaderName, value: HeaderValue) -> Self {
self.extras_mut().headers.insert(name, value);
self
}
#[must_use]
pub fn with_body(mut self, body: impl Into<Bytes>) -> Self {
self.extras_mut().body = body.into();
self
}
#[must_use]
pub fn with_identity(mut self, name: impl Into<Box<str>>) -> Self {
self.extras_mut().identity = Some(name.into());
self
}
fn extras_mut(&mut self) -> &mut Extras {
self.extras.get_or_insert_with(Box::default)
}
#[must_use]
pub(crate) fn next_attempt(mut self) -> Self {
self.attempt = self.attempt.next();
self
}
#[must_use]
pub(crate) const fn first_attempt(mut self) -> Self {
self.attempt = Attempt::FIRST;
self
}
#[must_use]
pub const fn method(&self) -> &Method {
&self.method
}
#[must_use]
pub const fn uri(&self) -> &Uri {
&self.uri
}
#[must_use]
pub fn headers(&self) -> &HeaderMap {
static EMPTY: LazyLock<HeaderMap> = LazyLock::new(HeaderMap::new);
self.extras
.as_ref()
.map_or(&EMPTY, |extras| &extras.headers)
}
#[must_use]
pub fn body(&self) -> &Bytes {
static EMPTY: Bytes = Bytes::new();
self.extras.as_ref().map_or(&EMPTY, |extras| &extras.body)
}
#[must_use]
pub const fn tag(&self) -> Option<&Tag> {
self.tag.as_ref()
}
#[must_use]
pub const fn depth(&self) -> Depth {
self.depth
}
#[must_use]
pub fn identity(&self) -> Option<&str> {
self.extras.as_ref()?.identity.as_deref()
}
#[must_use]
pub const fn priority(&self) -> i32 {
self.priority
}
#[must_use]
pub const fn attempt(&self) -> Attempt {
self.attempt
}
#[must_use]
pub fn canonical_uri(&self) -> String {
normalize::canonical(&self.uri)
}
#[must_use]
pub fn fingerprint(&self) -> Fingerprint {
fingerprint(&self.method, &self.uri, self.body())
}
#[must_use]
pub fn into_request(self) -> Request {
let Extras { headers, body, .. } = self.extras.map(|extras| *extras).unwrap_or_default();
let mut request = Request::new(body);
*request.method_mut() = self.method;
*request.uri_mut() = self.uri;
*request.headers_mut() = headers;
request.extensions_mut().insert(RequestMeta {
tag: self.tag,
depth: self.depth,
attempt: self.attempt,
identity: None,
});
request
}
}
pub(crate) fn fingerprint(method: &Method, uri: &Uri, body: &[u8]) -> Fingerprint {
Fingerprint::of(&[
method.as_str().as_bytes(),
normalize::canonical(uri).as_bytes(),
body,
])
}
#[derive(Debug, Clone, Default)]
struct Extras {
headers: HeaderMap,
body: Bytes,
identity: Option<Box<str>>,
}
impl TryFrom<&str> for Seed {
type Error = Error;
fn try_from(uri: &str) -> Result<Self> {
Self::get(uri)
}
}
impl TryFrom<String> for Seed {
type Error = Error;
fn try_from(uri: String) -> Result<Self> {
Self::get(uri)
}
}
impl TryFrom<Uri> for Seed {
type Error = Error;
fn try_from(uri: Uri) -> Result<Self> {
Self::new(Method::GET, uri)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::http::RequestExt;
fn fingerprint(uri: &str) -> Fingerprint {
Seed::get(uri).unwrap().fingerprint()
}
#[test]
fn rejects_relative_uris() {
assert!(Seed::get("/relative").is_err());
assert!(Seed::get("not a uri").is_err());
assert!(Seed::get("https://example.com").is_ok());
}
#[test]
fn fingerprint_normalizes_scheme_host_and_port() {
let base = fingerprint("https://example.com/a?b=1");
assert_eq!(base, fingerprint("HTTPS://Example.COM:443/a?b=1"));
assert_ne!(base, fingerprint("https://example.com:8443/a?b=1"));
assert_ne!(base, fingerprint("https://example.com/a?b=2"));
assert_eq!(
base,
fingerprint("https://example.com/a?utm_source=x&b=1#top")
);
assert_eq!(
fingerprint("https://example.com"),
fingerprint("https://example.com/")
);
}
#[test]
fn fingerprint_ignores_crawl_metadata() {
let seed = Seed::get("https://example.com/").unwrap();
let tagged = seed.clone().with_tag("page").with_depth(Depth::new(3));
assert_eq!(seed.fingerprint(), tagged.fingerprint());
let post = Seed::new(Method::POST, seed.uri().clone()).unwrap();
assert_ne!(seed.fingerprint(), post.fingerprint());
assert_ne!(
post.fingerprint(),
post.clone().with_body("x").fingerprint()
);
}
#[test]
fn into_request_carries_metadata() {
let seed = Seed::get("https://example.com/")
.unwrap()
.with_tag("page")
.with_depth(Depth::new(2))
.next_attempt();
let request = seed.into_request();
assert_eq!(request.uri(), "https://example.com/");
assert_eq!(request.tag(), Some(&Tag::from("page")));
assert_eq!(request.depth(), Depth::new(2));
assert_eq!(request.attempt(), Attempt::new(1));
}
}