1use std::{
2 fmt,
3 sync::{Arc, Mutex, MutexGuard},
4 time::Duration,
5};
6
7use futures_util::future::BoxFuture;
8use millipede_core::{
9 antibot::AntiBotDetector,
10 crawler::{
11 AttemptObservation, Crawler, CrawlerEnv, CrawlerHandle, CrawlerKind, RequestEnv,
12 RequestOutcome, RequestPrep,
13 },
14 enqueue::EnqueueLinker,
15 errors::CrawlError,
16 http_client::{HttpClient, HttpClientError, HttpResponse},
17 proxy::{ProxyBuckets, ProxyConfiguration, ProxyInfo, ProxyStrategy},
18 request::Request,
19 router::HasRequest,
20 session::{Session, SessionPool, SessionPoolOptions},
21 storage::StorageHandle,
22};
23use millipede_http::{HttpContext, HttpKind, HttpKindBuilder, HttpPostHookCtx, HttpPreHookCtx};
24
25use crate::HtmlLinkExtractor;
26
27pub struct SynchronizedHtml {
47 html: Mutex<scraper::Html>,
48}
49
50impl SynchronizedHtml {
51 pub(crate) fn from_html(html: scraper::Html) -> Self {
52 Self {
53 html: Mutex::new(html),
54 }
55 }
56
57 pub fn with_html<R>(&self, query: impl FnOnce(&scraper::Html) -> R) -> R {
66 let html = self.lock();
67 query(&html)
68 }
69
70 pub fn lock(&self) -> MutexGuard<'_, scraper::Html> {
80 self.html.lock().expect("HTML document mutex poisoned")
81 }
82
83 pub fn select<T, F>(&self, selector: &scraper::Selector, mut map: F) -> Vec<T>
87 where
88 F: for<'a> FnMut(scraper::ElementRef<'a>) -> T,
89 {
90 self.with_html(|html| html.select(selector).map(&mut map).collect())
91 }
92
93 pub fn select_first<T, F>(&self, selector: &scraper::Selector, map: F) -> Option<T>
97 where
98 F: for<'a> FnOnce(scraper::ElementRef<'a>) -> T,
99 {
100 self.with_html(|html| html.select(selector).next().map(map))
101 }
102}
103
104impl fmt::Debug for SynchronizedHtml {
105 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
106 formatter
107 .debug_tuple("SynchronizedHtml")
108 .field(&"<scraper::Html>")
109 .finish()
110 }
111}
112
113#[derive(Clone)]
122#[non_exhaustive]
123pub struct HtmlContext {
124 pub request: Arc<Request>,
126 pub response: Arc<HttpResponse>,
128 pub html: Arc<SynchronizedHtml>,
133 pub session: Option<Arc<Session>>,
135 pub proxy_info: Option<ProxyInfo>,
137 pub enqueue: EnqueueLinker,
139 pub storage: StorageHandle,
142 pub crawler: CrawlerHandle,
144 http: HttpContext,
145}
146
147impl HasRequest for HtmlContext {
148 fn request(&self) -> &Request {
149 &self.request
150 }
151}
152
153impl fmt::Debug for HtmlContext {
154 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
155 formatter
156 .debug_struct("HtmlContext")
157 .field("request", &self.request)
158 .field("response_url", &self.response.url)
159 .field("response_status", &self.response.status)
160 .field("response_headers", &self.response.headers)
161 .field("response_body_bytes", &self.response.body.len())
162 .field("redirect_chain", &self.response.redirect_chain)
163 .field("html", &"<scraper::Html>")
164 .field("session", &self.session)
165 .field("proxy_info", &self.proxy_info)
166 .field("enqueue", &self.enqueue)
167 .field("storage", &self.storage)
168 .field("crawler", &self.crawler)
169 .finish()
170 }
171}
172
173#[derive(Debug, thiserror::Error)]
175#[non_exhaustive]
176pub enum HtmlError {
177 #[error("unsupported content type for HTML parsing: {content_type}")]
179 UnsupportedContentType {
180 content_type: String,
182 },
183}
184
185pub struct HtmlKind {
187 http: HttpKind,
188}
189
190impl HtmlKind {
191 pub fn builder() -> HtmlKindBuilder {
193 HtmlKindBuilder::default()
194 }
195
196 pub fn new() -> Result<Self, HttpClientError> {
198 Self::builder().build()
199 }
200
201 pub fn from_http(http: HttpKind) -> Self {
203 Self { http }
204 }
205}
206
207#[must_use = "builders do nothing unless consumed by build"]
209pub struct HtmlKindBuilder {
210 http: HttpKindBuilder,
211}
212
213impl Default for HtmlKindBuilder {
214 fn default() -> Self {
215 Self {
216 http: HttpKind::builder(),
217 }
218 }
219}
220
221impl HtmlKindBuilder {
222 pub fn http_client(mut self, client: Arc<dyn HttpClient>) -> Self {
224 self.http = self.http.http_client(client);
225 self
226 }
227
228 pub fn coalesce_in_flight(mut self, enabled: bool) -> Self {
230 self.http = self.http.coalesce_in_flight(enabled);
231 self
232 }
233
234 pub fn session_pool(mut self, options: SessionPoolOptions) -> Self {
236 self.http = self.http.session_pool(options);
237 self
238 }
239
240 pub fn shared_session_pool(mut self, pool: Arc<SessionPool>) -> Self {
242 self.http = self.http.shared_session_pool(pool);
243 self
244 }
245
246 pub fn disable_sessions(mut self) -> Self {
248 self.http = self.http.disable_sessions();
249 self
250 }
251
252 pub fn proxy(mut self, proxy: ProxyConfiguration) -> Self {
254 self.http = self.http.proxy(proxy);
255 self
256 }
257
258 pub fn proxy_buckets(mut self, proxies: ProxyBuckets) -> Self {
260 self.http = self.http.proxy_buckets(proxies);
261 self
262 }
263
264 pub fn proxy_strategy<S: ProxyStrategy>(mut self, strategy: S) -> Self {
266 self.http = self.http.proxy_strategy(strategy);
267 self
268 }
269
270 pub fn user_agents<I, U>(mut self, user_agents: I) -> Self
272 where
273 I: IntoIterator<Item = U>,
274 U: Into<String>,
275 {
276 self.http = self.http.user_agents(user_agents);
277 self
278 }
279
280 pub fn retry_status_codes(mut self, codes: impl IntoIterator<Item = u16>) -> Self {
282 self.http = self.http.retry_status_codes(codes);
283 self
284 }
285
286 pub fn retry_server_errors(mut self, enabled: bool) -> Self {
288 self.http = self.http.retry_server_errors(enabled);
289 self
290 }
291
292 pub fn session_status_codes(mut self, codes: impl IntoIterator<Item = u16>) -> Self {
294 self.http = self.http.session_status_codes(codes);
295 self
296 }
297
298 pub fn request_timeout(mut self, timeout: Duration) -> Self {
300 self.http = self.http.request_timeout(timeout);
301 self
302 }
303
304 pub fn max_redirects(mut self, maximum: u32) -> Self {
306 self.http = self.http.max_redirects(maximum);
307 self
308 }
309
310 pub fn detect_anti_bot(mut self, detector: Arc<dyn AntiBotDetector>) -> Self {
312 self.http = self.http.detect_anti_bot(detector);
313 self
314 }
315
316 pub fn detect_anti_bot_default(mut self) -> Self {
318 self.http = self.http.detect_anti_bot_default();
319 self
320 }
321
322 pub fn header_generator(mut self, enabled: bool) -> Self {
324 self.http = self.http.header_generator(enabled);
325 self
326 }
327
328 pub fn snapshot_errors_on_failure(mut self, enabled: bool) -> Self {
330 self.http = self.http.snapshot_errors_on_failure(enabled);
331 self
332 }
333
334 pub fn pre_navigation_hook<F>(mut self, hook: F) -> Self
336 where
337 F: for<'a> Fn(
338 HttpPreHookCtx<'a>,
339 ) -> futures_util::future::BoxFuture<'a, Result<(), CrawlError>>
340 + Send
341 + Sync
342 + 'static,
343 {
344 self.http = self.http.pre_navigation_hook(hook);
345 self
346 }
347
348 pub fn post_navigation_hook<F>(mut self, hook: F) -> Self
350 where
351 F: for<'a> Fn(
352 HttpPostHookCtx<'a>,
353 ) -> futures_util::future::BoxFuture<'a, Result<(), CrawlError>>
354 + Send
355 + Sync
356 + 'static,
357 {
358 self.http = self.http.post_navigation_hook(hook);
359 self
360 }
361
362 pub fn build(self) -> Result<HtmlKind, HttpClientError> {
364 self.http.build().map(HtmlKind::from_http)
365 }
366}
367
368pub type HtmlCrawler = Crawler<HtmlKind>;
370
371impl CrawlerKind for HtmlKind {
372 type Context = HtmlContext;
373
374 fn start<'a>(&'a self, env: &'a CrawlerEnv) -> BoxFuture<'a, Result<(), CrawlError>> {
375 self.http.start(env)
376 }
377
378 fn before_request<'a>(
379 &'a self,
380 prep: &'a mut RequestPrep,
381 ) -> BoxFuture<'a, Result<(), CrawlError>> {
382 self.http.before_request(prep)
383 }
384
385 fn execute<'a>(
386 &'a self,
387 env: RequestEnv<'a>,
388 ) -> BoxFuture<'a, Result<Self::Context, CrawlError>> {
389 Box::pin(async move {
390 let http_ctx = self.http.execute(env).await?;
391 if let Some(value) = http_ctx.response.headers.get(http::header::CONTENT_TYPE) {
392 let content_type = String::from_utf8_lossy(value.as_bytes()).into_owned();
393 let media_type = content_type.split(';').next().unwrap_or_default().trim();
394 if !media_type.eq_ignore_ascii_case("text/html")
395 && !media_type.eq_ignore_ascii_case("application/xhtml+xml")
396 {
397 return Err(CrawlError::non_retryable(
398 HtmlError::UnsupportedContentType { content_type },
399 ));
400 }
401 }
402
403 let html = Arc::new(SynchronizedHtml::from_html(scraper::Html::parse_document(
404 &http_ctx.response.text(),
405 )));
406 let enqueue = EnqueueLinker::with_extractor(
407 http_ctx.crawler.clone(),
408 &http_ctx.request,
409 Arc::new(HtmlLinkExtractor::from_synchronized(
410 Arc::clone(&html),
411 http_ctx.response.url.clone(),
412 )),
413 );
414 Ok(HtmlContext {
415 request: http_ctx.request.clone(),
416 response: http_ctx.response.clone(),
417 html,
418 session: http_ctx.session.clone(),
419 proxy_info: http_ctx.proxy_info.clone(),
420 enqueue,
421 storage: http_ctx.storage.clone(),
422 crawler: http_ctx.crawler.clone(),
423 http: http_ctx,
424 })
425 })
426 }
427
428 fn observe(&self, ctx: &Self::Context) -> AttemptObservation {
429 self.http.observe(&ctx.http)
430 }
431
432 fn after_success<'a>(
433 &'a self,
434 ctx: &'a mut Self::Context,
435 ) -> BoxFuture<'a, Result<(), CrawlError>> {
436 self.http.after_success(&mut ctx.http)
437 }
438
439 fn cleanup(
440 &self,
441 outcome: RequestOutcome<Self::Context>,
442 ) -> BoxFuture<'_, Result<(), CrawlError>> {
443 let outcome = match outcome {
444 RequestOutcome::Handled(ctx) => RequestOutcome::Handled(ctx.http),
445 RequestOutcome::HandlerFailed { ctx, error } => RequestOutcome::HandlerFailed {
446 ctx: ctx.http,
447 error,
448 },
449 RequestOutcome::ExecuteFailed { request, error } => {
450 RequestOutcome::ExecuteFailed { request, error }
451 }
452 };
453 self.http.cleanup(outcome)
454 }
455
456 fn stop<'a>(&'a self, env: &'a CrawlerEnv) -> BoxFuture<'a, Result<(), CrawlError>> {
457 self.http.stop(env)
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464
465 fn assert_send<T: Send>() {}
466
467 fn assert_send_sync<T: Send + Sync>() {}
468
469 fn assert_ctx<T: Send + Clone + 'static>() {}
470
471 #[test]
472 fn context_types_satisfy_engine_bounds() {
473 assert_send::<scraper::Html>();
474 assert_send_sync::<SynchronizedHtml>();
475 assert_send_sync::<Arc<SynchronizedHtml>>();
476 assert_ctx::<HtmlContext>();
477 }
478
479 #[test]
480 fn lock_guard_exposes_the_complete_scraper_api() {
481 let html =
482 SynchronizedHtml::from_html(scraper::Html::parse_document("<title>Phase 5</title>"));
483 let selector = scraper::Selector::parse("title").expect("valid selector");
484 let guard = html.lock();
485 let title = guard
486 .select(&selector)
487 .next()
488 .map(|element| element.text().collect::<String>());
489
490 assert_eq!(title.as_deref(), Some("Phase 5"));
491 }
492}