use std::{
fmt,
sync::{Arc, Mutex, MutexGuard},
time::Duration,
};
use futures_util::future::BoxFuture;
use millipede_core::{
antibot::AntiBotDetector,
crawler::{
AttemptObservation, Crawler, CrawlerEnv, CrawlerHandle, CrawlerKind, RequestEnv,
RequestOutcome, RequestPrep,
},
enqueue::EnqueueLinker,
errors::CrawlError,
http_client::{HttpClient, HttpClientError, HttpResponse},
proxy::{ProxyBuckets, ProxyConfiguration, ProxyInfo, ProxyStrategy},
request::Request,
router::HasRequest,
session::{Session, SessionPool, SessionPoolOptions},
storage::StorageHandle,
};
use millipede_http::{HttpContext, HttpKind, HttpKindBuilder, HttpPostHookCtx, HttpPreHookCtx};
use crate::HtmlLinkExtractor;
pub struct SynchronizedHtml {
html: Mutex<scraper::Html>,
}
impl SynchronizedHtml {
pub(crate) fn from_html(html: scraper::Html) -> Self {
Self {
html: Mutex::new(html),
}
}
pub fn with_html<R>(&self, query: impl FnOnce(&scraper::Html) -> R) -> R {
let html = self.lock();
query(&html)
}
pub fn lock(&self) -> MutexGuard<'_, scraper::Html> {
self.html.lock().expect("HTML document mutex poisoned")
}
pub fn select<T, F>(&self, selector: &scraper::Selector, mut map: F) -> Vec<T>
where
F: for<'a> FnMut(scraper::ElementRef<'a>) -> T,
{
self.with_html(|html| html.select(selector).map(&mut map).collect())
}
pub fn select_first<T, F>(&self, selector: &scraper::Selector, map: F) -> Option<T>
where
F: for<'a> FnOnce(scraper::ElementRef<'a>) -> T,
{
self.with_html(|html| html.select(selector).next().map(map))
}
}
impl fmt::Debug for SynchronizedHtml {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_tuple("SynchronizedHtml")
.field(&"<scraper::Html>")
.finish()
}
}
#[derive(Clone)]
#[non_exhaustive]
pub struct HtmlContext {
pub request: Arc<Request>,
pub response: Arc<HttpResponse>,
pub html: Arc<SynchronizedHtml>,
pub session: Option<Arc<Session>>,
pub proxy_info: Option<ProxyInfo>,
pub enqueue: EnqueueLinker,
pub storage: StorageHandle,
pub crawler: CrawlerHandle,
http: HttpContext,
}
impl HasRequest for HtmlContext {
fn request(&self) -> &Request {
&self.request
}
}
impl fmt::Debug for HtmlContext {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("HtmlContext")
.field("request", &self.request)
.field("response_url", &self.response.url)
.field("response_status", &self.response.status)
.field("response_headers", &self.response.headers)
.field("response_body_bytes", &self.response.body.len())
.field("redirect_chain", &self.response.redirect_chain)
.field("html", &"<scraper::Html>")
.field("session", &self.session)
.field("proxy_info", &self.proxy_info)
.field("enqueue", &self.enqueue)
.field("storage", &self.storage)
.field("crawler", &self.crawler)
.finish()
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum HtmlError {
#[error("unsupported content type for HTML parsing: {content_type}")]
UnsupportedContentType {
content_type: String,
},
}
pub struct HtmlKind {
http: HttpKind,
}
impl HtmlKind {
pub fn builder() -> HtmlKindBuilder {
HtmlKindBuilder::default()
}
pub fn new() -> Result<Self, HttpClientError> {
Self::builder().build()
}
pub fn from_http(http: HttpKind) -> Self {
Self { http }
}
}
#[must_use = "builders do nothing unless consumed by build"]
pub struct HtmlKindBuilder {
http: HttpKindBuilder,
}
impl Default for HtmlKindBuilder {
fn default() -> Self {
Self {
http: HttpKind::builder(),
}
}
}
impl HtmlKindBuilder {
pub fn http_client(mut self, client: Arc<dyn HttpClient>) -> Self {
self.http = self.http.http_client(client);
self
}
pub fn coalesce_in_flight(mut self, enabled: bool) -> Self {
self.http = self.http.coalesce_in_flight(enabled);
self
}
pub fn session_pool(mut self, options: SessionPoolOptions) -> Self {
self.http = self.http.session_pool(options);
self
}
pub fn shared_session_pool(mut self, pool: Arc<SessionPool>) -> Self {
self.http = self.http.shared_session_pool(pool);
self
}
pub fn disable_sessions(mut self) -> Self {
self.http = self.http.disable_sessions();
self
}
pub fn proxy(mut self, proxy: ProxyConfiguration) -> Self {
self.http = self.http.proxy(proxy);
self
}
pub fn proxy_buckets(mut self, proxies: ProxyBuckets) -> Self {
self.http = self.http.proxy_buckets(proxies);
self
}
pub fn proxy_strategy<S: ProxyStrategy>(mut self, strategy: S) -> Self {
self.http = self.http.proxy_strategy(strategy);
self
}
pub fn user_agents<I, U>(mut self, user_agents: I) -> Self
where
I: IntoIterator<Item = U>,
U: Into<String>,
{
self.http = self.http.user_agents(user_agents);
self
}
pub fn retry_status_codes(mut self, codes: impl IntoIterator<Item = u16>) -> Self {
self.http = self.http.retry_status_codes(codes);
self
}
pub fn retry_server_errors(mut self, enabled: bool) -> Self {
self.http = self.http.retry_server_errors(enabled);
self
}
pub fn session_status_codes(mut self, codes: impl IntoIterator<Item = u16>) -> Self {
self.http = self.http.session_status_codes(codes);
self
}
pub fn request_timeout(mut self, timeout: Duration) -> Self {
self.http = self.http.request_timeout(timeout);
self
}
pub fn max_redirects(mut self, maximum: u32) -> Self {
self.http = self.http.max_redirects(maximum);
self
}
pub fn detect_anti_bot(mut self, detector: Arc<dyn AntiBotDetector>) -> Self {
self.http = self.http.detect_anti_bot(detector);
self
}
pub fn detect_anti_bot_default(mut self) -> Self {
self.http = self.http.detect_anti_bot_default();
self
}
pub fn header_generator(mut self, enabled: bool) -> Self {
self.http = self.http.header_generator(enabled);
self
}
pub fn snapshot_errors_on_failure(mut self, enabled: bool) -> Self {
self.http = self.http.snapshot_errors_on_failure(enabled);
self
}
pub fn pre_navigation_hook<F>(mut self, hook: F) -> Self
where
F: for<'a> Fn(
HttpPreHookCtx<'a>,
) -> futures_util::future::BoxFuture<'a, Result<(), CrawlError>>
+ Send
+ Sync
+ 'static,
{
self.http = self.http.pre_navigation_hook(hook);
self
}
pub fn post_navigation_hook<F>(mut self, hook: F) -> Self
where
F: for<'a> Fn(
HttpPostHookCtx<'a>,
) -> futures_util::future::BoxFuture<'a, Result<(), CrawlError>>
+ Send
+ Sync
+ 'static,
{
self.http = self.http.post_navigation_hook(hook);
self
}
pub fn build(self) -> Result<HtmlKind, HttpClientError> {
self.http.build().map(HtmlKind::from_http)
}
}
pub type HtmlCrawler = Crawler<HtmlKind>;
impl CrawlerKind for HtmlKind {
type Context = HtmlContext;
fn start<'a>(&'a self, env: &'a CrawlerEnv) -> BoxFuture<'a, Result<(), CrawlError>> {
self.http.start(env)
}
fn before_request<'a>(
&'a self,
prep: &'a mut RequestPrep,
) -> BoxFuture<'a, Result<(), CrawlError>> {
self.http.before_request(prep)
}
fn execute<'a>(
&'a self,
env: RequestEnv<'a>,
) -> BoxFuture<'a, Result<Self::Context, CrawlError>> {
Box::pin(async move {
let http_ctx = self.http.execute(env).await?;
if let Some(value) = http_ctx.response.headers.get(http::header::CONTENT_TYPE) {
let content_type = String::from_utf8_lossy(value.as_bytes()).into_owned();
let media_type = content_type.split(';').next().unwrap_or_default().trim();
if !media_type.eq_ignore_ascii_case("text/html")
&& !media_type.eq_ignore_ascii_case("application/xhtml+xml")
{
return Err(CrawlError::non_retryable(
HtmlError::UnsupportedContentType { content_type },
));
}
}
let html = Arc::new(SynchronizedHtml::from_html(scraper::Html::parse_document(
&http_ctx.response.text(),
)));
let enqueue = EnqueueLinker::with_extractor(
http_ctx.crawler.clone(),
&http_ctx.request,
Arc::new(HtmlLinkExtractor::from_synchronized(
Arc::clone(&html),
http_ctx.response.url.clone(),
)),
);
Ok(HtmlContext {
request: http_ctx.request.clone(),
response: http_ctx.response.clone(),
html,
session: http_ctx.session.clone(),
proxy_info: http_ctx.proxy_info.clone(),
enqueue,
storage: http_ctx.storage.clone(),
crawler: http_ctx.crawler.clone(),
http: http_ctx,
})
})
}
fn observe(&self, ctx: &Self::Context) -> AttemptObservation {
self.http.observe(&ctx.http)
}
fn after_success<'a>(
&'a self,
ctx: &'a mut Self::Context,
) -> BoxFuture<'a, Result<(), CrawlError>> {
self.http.after_success(&mut ctx.http)
}
fn cleanup(
&self,
outcome: RequestOutcome<Self::Context>,
) -> BoxFuture<'_, Result<(), CrawlError>> {
let outcome = match outcome {
RequestOutcome::Handled(ctx) => RequestOutcome::Handled(ctx.http),
RequestOutcome::HandlerFailed { ctx, error } => RequestOutcome::HandlerFailed {
ctx: ctx.http,
error,
},
RequestOutcome::ExecuteFailed { request, error } => {
RequestOutcome::ExecuteFailed { request, error }
}
};
self.http.cleanup(outcome)
}
fn stop<'a>(&'a self, env: &'a CrawlerEnv) -> BoxFuture<'a, Result<(), CrawlError>> {
self.http.stop(env)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_send<T: Send>() {}
fn assert_send_sync<T: Send + Sync>() {}
fn assert_ctx<T: Send + Clone + 'static>() {}
#[test]
fn context_types_satisfy_engine_bounds() {
assert_send::<scraper::Html>();
assert_send_sync::<SynchronizedHtml>();
assert_send_sync::<Arc<SynchronizedHtml>>();
assert_ctx::<HtmlContext>();
}
#[test]
fn lock_guard_exposes_the_complete_scraper_api() {
let html =
SynchronizedHtml::from_html(scraper::Html::parse_document("<title>Phase 5</title>"));
let selector = scraper::Selector::parse("title").expect("valid selector");
let guard = html.lock();
let title = guard
.select(&selector)
.next()
.map(|element| element.text().collect::<String>());
assert_eq!(title.as_deref(), Some("Phase 5"));
}
}