1use std::collections::{HashSet, VecDeque};
4use std::hash::{DefaultHasher, Hash, Hasher};
5use std::time::{Duration, SystemTime};
6
7use tokio::task::{JoinSet, spawn_blocking};
8use tokio::time::{MissedTickBehavior, interval};
9use url::Url;
10
11use crate::bridge::{self, PageFetcher};
12use crate::net;
13use crate::robots::RobotsPolicy;
14use crate::scope::{is_same_site, matches_scope, normalize_url};
15
16const MAX_HTML_BYTES: usize = 2 * 1024 * 1024;
17
18#[must_use = "options do nothing until passed to crawl() or crawl_each()"]
20#[derive(Debug, Clone)]
21pub struct CrawlOptions {
22 pub(crate) url: String,
23 pub(crate) limit: usize,
24 pub(crate) max_depth: usize,
25 pub(crate) timeout: Duration,
26 pub(crate) settle: Duration,
27 pub(crate) include: Vec<String>,
28 pub(crate) exclude: Vec<String>,
29 pub(crate) selector: Option<String>,
30 pub(crate) json: bool,
31 pub(crate) user_agent: Option<String>,
32 pub(crate) concurrency: usize,
33 pub(crate) delay: Option<Duration>,
34 pub(crate) cookies: Vec<crate::cookies::CookieSpec>,
35 pub(crate) headers: http::HeaderMap,
36}
37
38impl CrawlOptions {
39 pub fn new(url: &str) -> Self {
41 Self {
42 url: url.into(),
43 limit: 50,
44 max_depth: 3,
45 timeout: Duration::from_secs(30),
46 settle: Duration::ZERO,
47 include: Vec::new(),
48 exclude: Vec::new(),
49 selector: None,
50 json: false,
51 user_agent: None,
52 concurrency: 1,
53 delay: Some(Duration::from_millis(500)),
54 cookies: Vec::new(),
55 headers: http::HeaderMap::new(),
56 }
57 }
58
59 pub fn limit(mut self, n: usize) -> Self {
61 self.limit = n;
62 self
63 }
64
65 pub fn max_depth(mut self, n: usize) -> Self {
67 self.max_depth = n;
68 self
69 }
70
71 pub fn timeout(mut self, timeout: Duration) -> Self {
73 self.timeout = timeout;
74 self
75 }
76
77 pub fn settle(mut self, settle: Duration) -> Self {
79 self.settle = settle;
80 self
81 }
82
83 pub fn include(mut self, patterns: &[&str]) -> Self {
85 self.include = patterns.iter().map(|s| (*s).to_string()).collect();
86 self
87 }
88
89 pub fn exclude(mut self, patterns: &[&str]) -> Self {
91 self.exclude = patterns.iter().map(|s| (*s).to_string()).collect();
92 self
93 }
94
95 pub fn json(mut self, json: bool) -> Self {
97 self.json = json;
98 self
99 }
100
101 pub fn selector(mut self, selector: impl Into<String>) -> Self {
103 self.selector = Some(selector.into());
104 self
105 }
106
107 pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
109 self.user_agent = Some(net::sanitize_user_agent(ua.into()));
110 self
111 }
112
113 pub fn concurrency(mut self, n: usize) -> Self {
116 self.concurrency = n.max(1);
117 self
118 }
119
120 pub fn delay(mut self, delay: Option<Duration>) -> Self {
122 self.delay = delay;
123 self
124 }
125
126 pub fn cookies(mut self, cookies: Vec<crate::cookies::CookieSpec>) -> Self {
128 self.cookies = cookies;
129 self
130 }
131
132 pub fn headers(mut self, headers: http::HeaderMap) -> Self {
134 self.headers = headers;
135 self
136 }
137}
138
139#[derive(Debug)]
141#[non_exhaustive]
142pub struct CrawlResult {
143 pub url: String,
145 pub depth: usize,
147 pub fetched_at: SystemTime,
149 pub outcome: Result<CrawlPage, crate::error::Error>,
151}
152
153#[derive(Debug, Clone)]
155pub struct CrawlPage {
156 pub title: Option<String>,
158 pub content: String,
160 pub links_found: usize,
162}
163
164impl serde::Serialize for CrawlResult {
166 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
167 use serde::ser::SerializeMap;
168 let fetched_at = humantime::format_rfc3339_millis(self.fetched_at).to_string();
169 match &self.outcome {
170 Ok(page) => {
171 let mut map = serializer.serialize_map(None)?;
172 map.serialize_entry("type", "page")?;
173 map.serialize_entry("url", &self.url)?;
174 map.serialize_entry("depth", &self.depth)?;
175 map.serialize_entry("fetchedAt", &fetched_at)?;
176 if let Some(t) = &page.title {
177 map.serialize_entry("title", t)?;
178 }
179 map.serialize_entry("content", &page.content)?;
180 map.serialize_entry("linksFound", &page.links_found)?;
181 map.end()
182 }
183 Err(e) => {
184 let mut map = serializer.serialize_map(None)?;
185 map.serialize_entry("type", "error")?;
186 map.serialize_entry("url", &self.url)?;
187 map.serialize_entry("depth", &self.depth)?;
188 map.serialize_entry("fetchedAt", &fetched_at)?;
189 map.serialize_entry("error", &e.to_string())?;
190 map.end()
191 }
192 }
193 }
194}
195
196impl CrawlResult {
197 fn from_internal(r: CrawlPageResult) -> Self {
198 let outcome = match r.status {
199 CrawlStatus::Ok => Ok(CrawlPage {
200 title: r.title,
201 content: r.content.unwrap_or_default(),
202 links_found: r.links_found,
203 }),
204 CrawlStatus::Error => Err(r
205 .error
206 .unwrap_or_else(|| crate::error::Error::engine("unknown crawl error", None))),
207 };
208 Self {
209 url: r.url,
210 depth: r.depth,
211 fetched_at: r.fetched_at,
212 outcome,
213 }
214 }
215}
216
217pub fn crawl_each_blocking<F>(opts: &CrawlOptions, on_page: F) -> crate::error::Result<()>
219where
220 F: FnMut(CrawlResult) + Send,
221{
222 crate::runtime::block_on(crawl_each(opts, on_page)).map_err(|e| crate::error::Error::engine(e, None))?
223}
224
225pub async fn crawl_each<F>(opts: &CrawlOptions, mut on_page: F) -> crate::error::Result<()>
227where
228 F: FnMut(CrawlResult) + Send,
229{
230 net::ensure_crypto_provider();
231 let plan = build_crawl_plan(opts)?;
232 let robots = spawn_blocking({
233 let seed = plan.seed.clone();
234 let user_agent = plan.user_agent.clone();
235 let headers = plan.headers.clone();
236 let timeout = Duration::from_secs(plan.timeout_secs);
237 move || crate::robots::RobotsRules::fetch(&seed, user_agent.as_deref(), &headers, timeout)
238 })
239 .await
240 .unwrap_or(RobotsPolicy::Unreachable);
241 run(plan, robots, &bridge::ServoFetcher, |r| {
242 on_page(CrawlResult::from_internal(r));
243 })
244 .await;
245 Ok(())
246}
247
248pub fn crawl_blocking(opts: &CrawlOptions) -> crate::error::Result<Vec<CrawlResult>> {
250 let mut results = Vec::new();
251 crawl_each_blocking(opts, |r| results.push(r))?;
252 Ok(results)
253}
254
255pub async fn crawl(opts: &CrawlOptions) -> crate::error::Result<Vec<CrawlResult>> {
257 let mut results = Vec::new();
258 crawl_each(opts, |r| results.push(r)).await?;
259 Ok(results)
260}
261
262fn build_crawl_plan(opts: &CrawlOptions) -> crate::error::Result<CrawlPlan> {
263 let seed = net::validate_url(&opts.url)?;
264 let include = if opts.include.is_empty() {
265 None
266 } else {
267 Some(crate::scope::build_globset(&opts.include)?)
268 };
269 let exclude = if opts.exclude.is_empty() {
270 None
271 } else {
272 Some(crate::scope::build_globset(&opts.exclude)?)
273 };
274 Ok(CrawlPlan {
275 seed,
276 limit: opts.limit,
277 max_depth: opts.max_depth,
278 timeout_secs: opts.timeout.as_secs().max(1),
279 settle_ms: u64::try_from(opts.settle.as_millis()).unwrap_or(u64::MAX),
280 include,
281 exclude,
282 selector: opts.selector.clone(),
283 json: opts.json,
284 user_agent: opts.user_agent.clone(),
285 concurrency: opts.concurrency,
286 delay: opts.delay,
287 cookies: opts.cookies.clone(),
288 headers: opts.headers.clone(),
289 })
290}
291
292pub(crate) struct CrawlPlan {
294 pub seed: Url,
295 pub limit: usize,
296 pub max_depth: usize,
297 pub timeout_secs: u64,
298 pub settle_ms: u64,
299 pub include: Option<globset::GlobSet>,
300 pub exclude: Option<globset::GlobSet>,
301 pub selector: Option<String>,
302 pub json: bool,
303 pub user_agent: Option<String>,
304 pub concurrency: usize,
306 pub delay: Option<Duration>,
308 pub cookies: Vec<crate::cookies::CookieSpec>,
309 pub headers: http::HeaderMap,
310}
311
312pub(crate) struct CrawlPageResult {
314 pub url: String,
315 pub depth: usize,
316 pub status: CrawlStatus,
317 pub title: Option<String>,
318 pub content: Option<String>,
319 pub error: Option<crate::error::Error>,
320 pub links_found: usize,
321 pub fetched_at: SystemTime,
322}
323
324pub(crate) enum CrawlStatus {
326 Ok,
327 Error,
328}
329
330struct Frontier {
331 queue: VecDeque<(Url, usize)>,
332 visited: HashSet<String>,
333 content_hashes: HashSet<u64>,
334}
335
336impl Frontier {
337 fn new(seed: &Url) -> Self {
338 Self {
339 queue: VecDeque::from([(seed.clone(), 0)]),
340 visited: HashSet::from([normalize_url(seed)]),
341 content_hashes: HashSet::new(),
342 }
343 }
344
345 fn try_enqueue(&mut self, url: Url, depth: usize) -> bool {
346 if self.visited.insert(normalize_url(&url)) {
347 self.queue.push_back((url, depth));
348 true
349 } else {
350 false
351 }
352 }
353
354 fn pop(&mut self) -> Option<(Url, usize)> {
355 self.queue.pop_front()
356 }
357
358 fn is_duplicate_content(&mut self, content: &str) -> bool {
359 let mut h = DefaultHasher::new();
360 content.hash(&mut h);
361 !self.content_hashes.insert(h.finish())
362 }
363
364 fn pending(&self) -> usize {
365 self.queue.len()
366 }
367}
368
369fn extract_links_from_html(html: &str, base: &Url) -> Vec<Url> {
370 dom_query::Document::from(html)
371 .select("a[href]")
372 .iter()
373 .filter_map(|el| {
374 let href = el.attr("href")?;
375 let href = href.trim();
376 if href.is_empty() {
377 return None;
378 }
379 let resolved = base.join(href).ok()?;
380 matches!(resolved.scheme(), "http" | "https").then_some(resolved)
381 })
382 .collect()
383}
384
385pub(crate) async fn run(
386 opts: CrawlPlan,
387 robots: RobotsPolicy,
388 fetcher: &(impl PageFetcher + Clone),
389 mut on_page: impl FnMut(CrawlPageResult),
390) {
391 let mut frontier = Frontier::new(&opts.seed);
392 let mut completed: usize = 0;
393 let mut in_flight: JoinSet<FetchOutcome> = JoinSet::new();
394
395 let mut ticker = opts.delay.map(|period| {
397 let mut t = interval(period);
398 t.set_missed_tick_behavior(MissedTickBehavior::Delay);
399 t
400 });
401
402 let concurrency = opts.concurrency.max(1);
403
404 loop {
405 while in_flight.len() < concurrency && completed + in_flight.len() < opts.limit {
406 let Some((url, depth)) = frontier.pop() else {
407 break;
408 };
409 if let Some(t) = ticker.as_mut() {
410 t.tick().await;
411 }
412 spawn_fetch(&mut in_flight, fetcher, &opts, url, depth);
413 }
414
415 let outcome = match in_flight.join_next().await {
416 None => break,
417 Some(Ok(o)) => o,
418 Some(Err(e)) if e.is_panic() => {
419 tracing::error!(err = %e, "crawl fetch task panicked");
420 continue;
421 }
422 Some(Err(e)) => {
423 tracing::warn!(err = %e, "crawl fetch task cancelled");
424 continue;
425 }
426 };
427
428 let FetchOutcome {
429 url,
430 depth,
431 result,
432 fetched_at,
433 } = outcome;
434 let page = match result {
435 Ok(p) => p,
436 Err(err) => {
437 on_page(error_result(&url, depth, err, fetched_at));
438 completed += 1;
439 continue;
440 }
441 };
442
443 let budget_used = completed + in_flight.len() + 1;
444 let mut ctx = CrawlContext {
445 frontier: &mut frontier,
446 robots: &robots,
447 opts: &opts,
448 };
449 if let Some(r) = process_ok_fetch(&mut ctx, &url, depth, &page, budget_used, fetched_at) {
450 on_page(r);
451 completed += 1;
452 }
453 }
454}
455
456fn spawn_fetch(
457 in_flight: &mut JoinSet<FetchOutcome>,
458 fetcher: &(impl PageFetcher + Clone),
459 opts: &CrawlPlan,
460 url: Url,
461 depth: usize,
462) {
463 let url_str = url.to_string();
464 let timeout = opts.timeout_secs;
465 let settle = opts.settle_ms;
466 let user_agent = opts.user_agent.clone();
467 let cookies = opts.cookies.clone();
468 let headers = opts.headers.clone();
469 let f = fetcher.clone();
470 in_flight.spawn_blocking(move || {
471 let result = f
472 .fetch_page(bridge::FetchOptions {
473 url: &url_str,
474 timeout_secs: timeout,
475 settle_ms: settle,
476 mode: bridge::FetchMode::Content { include_a11y: false },
477 user_agent: user_agent.as_deref(),
478 cookies: &cookies,
479 headers: &headers,
480 })
481 .map_err(|e| crate::error::Error::engine(e, Some(url_str.clone())));
482 FetchOutcome {
483 url,
484 depth,
485 result,
486 fetched_at: SystemTime::now(),
487 }
488 });
489}
490
491struct CrawlContext<'a> {
493 frontier: &'a mut Frontier,
494 robots: &'a RobotsPolicy,
495 opts: &'a CrawlPlan,
496}
497
498fn process_ok_fetch(
500 ctx: &mut CrawlContext<'_>,
501 url: &Url,
502 depth: usize,
503 page: &bridge::ServoPage,
504 budget_used: usize,
505 fetched_at: SystemTime,
506) -> Option<CrawlPageResult> {
507 let html = if page.html.len() > MAX_HTML_BYTES {
508 &page.html[..crate::sanitize::floor_char_boundary(&page.html, MAX_HTML_BYTES)]
509 } else {
510 &page.html
511 };
512
513 let input = crate::extract::ExtractInput::new(html, url.as_str())
514 .with_layout_json(page.layout_json.as_deref())
515 .with_inner_text(page.inner_text.as_deref())
516 .with_selector(ctx.opts.selector.as_deref());
517
518 let content = if ctx.opts.json {
519 crate::extract::extract_article(&input)
520 .ok()
521 .and_then(|a| serde_json::to_string(&a).ok())
522 } else {
523 crate::extract::extract_text(&input).ok()
524 };
525
526 if content.as_ref().is_some_and(|c| ctx.frontier.is_duplicate_content(c)) {
527 return None;
528 }
529
530 let links = extract_links_from_html(html, url);
531 let links_found = links.len();
532
533 if depth < ctx.opts.max_depth {
534 for link in &links {
535 if budget_used + ctx.frontier.pending() >= ctx.opts.limit {
536 break;
537 }
538 if !is_same_site(&ctx.opts.seed, link)
539 || net::validate_url_with_policy(link.as_str(), bridge::engine_policy()).is_err()
540 || !ctx.robots.is_allowed(link)
541 || !matches_scope(link, ctx.opts.include.as_ref(), ctx.opts.exclude.as_ref())
542 {
543 continue;
544 }
545 ctx.frontier.try_enqueue(link.clone(), depth + 1);
546 }
547 }
548
549 let title = {
550 let doc = dom_query::Document::from(html);
551 let t = doc.select("title").text().to_string();
552 (!t.is_empty()).then_some(t)
553 };
554
555 Some(CrawlPageResult {
556 url: url.to_string(),
557 depth,
558 status: CrawlStatus::Ok,
559 title,
560 content: content.map(|c| crate::sanitize::sanitize(&c).into_owned()),
561 error: None,
562 links_found,
563 fetched_at,
564 })
565}
566
567struct FetchOutcome {
569 url: Url,
570 depth: usize,
571 result: Result<bridge::ServoPage, crate::error::Error>,
572 fetched_at: SystemTime,
573}
574
575fn error_result(url: &Url, depth: usize, error: crate::error::Error, fetched_at: SystemTime) -> CrawlPageResult {
576 CrawlPageResult {
577 url: url.to_string(),
578 depth,
579 status: CrawlStatus::Error,
580 title: None,
581 content: None,
582 error: Some(error),
583 links_found: 0,
584 fetched_at,
585 }
586}
587
588#[cfg(test)]
589mod tests {
590 use std::collections::HashMap;
591 use std::sync::Arc;
592
593 use super::*;
594
595 #[test]
596 fn crawl_options_defaults() {
597 let opts = CrawlOptions::new("https://example.com");
598 assert_eq!(opts.url, "https://example.com");
599 assert_eq!(opts.limit, 50);
600 assert_eq!(opts.max_depth, 3);
601 assert_eq!(opts.timeout, Duration::from_secs(30));
602 assert!(opts.include.is_empty());
603 assert!(opts.exclude.is_empty());
604 assert_eq!(opts.concurrency, 1);
605 assert_eq!(opts.delay, Some(Duration::from_millis(500)));
606 }
607
608 #[test]
609 fn crawl_options_chaining() {
610 let opts = CrawlOptions::new("https://example.com")
611 .limit(100)
612 .max_depth(5)
613 .timeout(Duration::from_secs(60))
614 .include(&["/docs/**"])
615 .exclude(&["/docs/archive/**"])
616 .concurrency(4)
617 .delay(None);
618 assert_eq!(opts.limit, 100);
619 assert_eq!(opts.max_depth, 5);
620 assert_eq!(opts.include, vec!["/docs/**"]);
621 assert_eq!(opts.exclude, vec!["/docs/archive/**"]);
622 assert_eq!(opts.concurrency, 4);
623 assert_eq!(opts.delay, None);
624 }
625
626 #[test]
627 fn crawl_options_concurrency_clamps_below_one() {
628 let opts = CrawlOptions::new("https://example.com").concurrency(0);
629 assert_eq!(opts.concurrency, 1);
630 }
631
632 #[test]
633 fn crawl_options_delay_custom_value() {
634 let opts = CrawlOptions::new("https://example.com").delay(Some(Duration::from_secs(2)));
635 assert_eq!(opts.delay, Some(Duration::from_secs(2)));
636 }
637
638 #[test]
639 fn crawl_user_agent_sanitizes_crlf() {
640 let opts = CrawlOptions::new("https://example.com").user_agent("Crawler\r\n/2.0");
641 assert_eq!(opts.user_agent.as_deref(), Some("Crawler /2.0"));
642 }
643
644 #[derive(Clone)]
645 struct MockFetcher(Arc<HashMap<String, String>>);
646
647 impl MockFetcher {
648 fn new(pages: &[(&str, &str)]) -> Self {
649 Self(Arc::new(
650 pages.iter().map(|(u, h)| (u.to_string(), h.to_string())).collect(),
651 ))
652 }
653 }
654
655 impl PageFetcher for MockFetcher {
656 fn fetch_page(&self, opts: bridge::FetchOptions<'_>) -> Result<bridge::ServoPage, bridge::EngineError> {
657 self.0
658 .get(opts.url)
659 .map(|html| bridge::ServoPage {
660 html: html.clone(),
661 ..Default::default()
662 })
663 .ok_or_else(|| bridge::EngineError::Other(anyhow::anyhow!("not found: {}", opts.url)))
664 }
665 }
666
667 fn page(links: &[&str]) -> String {
668 use std::fmt::Write as _;
669 let mut anchors = String::new();
670 for l in links {
671 write!(anchors, r#"<a href="{l}">link</a>"#).unwrap();
672 }
673 format!("<html><head><title>Test</title></head><body>{anchors}</body></html>")
674 }
675
676 fn distinct_page(tag: &str) -> String {
678 format!("<html><head><title>{tag}</title></head><body>page {tag}</body></html>")
679 }
680
681 async fn check(
683 pages: &[(&str, &str)],
684 configure: impl FnOnce(&mut CrawlPlan),
685 assert: impl FnOnce(&[CrawlPageResult]),
686 ) {
687 let fetcher = MockFetcher::new(pages);
688 let seed = pages[0].0;
689 let mut opts = CrawlPlan {
690 seed: Url::parse(seed).unwrap(),
691 limit: 50,
692 max_depth: 3,
693 timeout_secs: 30,
694 settle_ms: 0,
695 include: None,
696 exclude: None,
697 selector: None,
698 json: false,
699 user_agent: None,
700 concurrency: 1,
701 delay: None,
702 cookies: Vec::new(),
703 headers: http::HeaderMap::new(),
704 };
705 configure(&mut opts);
706 let mut results = Vec::new();
707 run(opts, RobotsPolicy::Unavailable, &fetcher, |r| results.push(r)).await;
708 assert(&results);
709 }
710
711 #[tokio::test]
712 async fn crawl_single_page() {
713 check(
714 &[("https://example.com/", &page(&[]))],
715 |_| {},
716 |r| {
717 assert_eq!(r.len(), 1);
718 assert_eq!(r[0].url, "https://example.com/");
719 },
720 )
721 .await;
722 }
723
724 #[tokio::test]
725 async fn crawl_follows_links() {
726 check(
727 &[
728 ("https://example.com/", &page(&["/a", "/b"])),
729 (
730 "https://example.com/a",
731 "<html><head><title>A</title></head><body>page a</body></html>",
732 ),
733 (
734 "https://example.com/b",
735 "<html><head><title>B</title></head><body>page b</body></html>",
736 ),
737 ],
738 |_| {},
739 |r| assert_eq!(r.len(), 3),
740 )
741 .await;
742 }
743
744 #[tokio::test]
745 async fn crawl_respects_depth_limit() {
746 check(
747 &[
748 ("https://example.com/", &page(&["/a"])),
749 ("https://example.com/a", &page(&["/b"])),
750 ("https://example.com/b", &page(&["/c"])),
751 ("https://example.com/c", &page(&[])),
752 ],
753 |o| o.max_depth = 1,
754 |r| assert_eq!(r.len(), 2),
755 )
756 .await;
757 }
758
759 #[tokio::test]
760 async fn crawl_respects_limit() {
761 check(
762 &[
763 ("https://example.com/", &page(&["/a", "/b", "/c"])),
764 ("https://example.com/a", &page(&[])),
765 ("https://example.com/b", &page(&[])),
766 ("https://example.com/c", &page(&[])),
767 ],
768 |o| o.limit = 2,
769 |r| assert_eq!(r.len(), 2),
770 )
771 .await;
772 }
773
774 #[tokio::test]
775 async fn crawl_skips_cross_site_links() {
776 check(
777 &[
778 ("https://example.com/", &page(&["https://other.com/x"])),
779 ("https://other.com/x", &page(&[])),
780 ],
781 |_| {},
782 |r| assert_eq!(r.len(), 1),
783 )
784 .await;
785 }
786
787 #[tokio::test]
788 async fn crawl_deduplicates_urls() {
789 check(
790 &[
791 ("https://example.com/", &page(&["/a", "/a", "/a"])),
792 ("https://example.com/a", &page(&["/"])),
793 ],
794 |_| {},
795 |r| assert_eq!(r.len(), 2),
796 )
797 .await;
798 }
799
800 #[tokio::test]
801 async fn crawl_handles_fetch_errors() {
802 check(
803 &[("https://example.com/", &page(&["/missing"]))],
804 |_| {},
805 |r| {
806 assert_eq!(r.len(), 2);
807 assert!(matches!(r[1].status, CrawlStatus::Error));
808 assert!(r[1].error.is_some());
809 },
810 )
811 .await;
812 }
813
814 #[tokio::test]
815 async fn crawl_applies_include_glob() {
816 check(
817 &[
818 ("https://example.com/", &page(&["/docs/a", "/blog/b"])),
819 ("https://example.com/docs/a", &page(&[])),
820 ("https://example.com/blog/b", &page(&[])),
821 ],
822 |o| o.include = Some(crate::scope::build_globset(&["/docs/**".into()]).unwrap()),
823 |r| {
824 assert_eq!(r.len(), 2);
825 assert!(r.iter().any(|p| p.url == "https://example.com/docs/a"));
826 assert!(!r.iter().any(|p| p.url == "https://example.com/blog/b"));
827 },
828 )
829 .await;
830 }
831
832 #[tokio::test]
833 async fn crawl_applies_exclude_glob() {
834 check(
835 &[
836 ("https://example.com/", &page(&["/public", "/secret/data"])),
837 ("https://example.com/public", &page(&[])),
838 ("https://example.com/secret/data", &page(&[])),
839 ],
840 |o| o.exclude = Some(crate::scope::build_globset(&["/secret/**".into()]).unwrap()),
841 |r| {
842 assert_eq!(r.len(), 2);
843 assert!(!r.iter().any(|p| p.url == "https://example.com/secret/data"));
844 },
845 )
846 .await;
847 }
848
849 #[tokio::test]
850 async fn crawl_deduplicates_content() {
851 let same = "<html><head><title>Same</title></head><body>identical</body></html>";
852 check(
853 &[
854 ("https://example.com/", &page(&["/a", "/b"])),
855 ("https://example.com/a", same),
856 ("https://example.com/b", same),
857 ],
858 |_| {},
859 |r| assert_eq!(r.len(), 2),
860 )
861 .await;
862 }
863
864 #[tokio::test]
865 async fn crawl_concurrency_visits_all_pages() {
866 check(
867 &[
868 ("https://example.com/", &page(&["/a", "/b", "/c", "/d"])),
869 ("https://example.com/a", &distinct_page("a")),
870 ("https://example.com/b", &distinct_page("b")),
871 ("https://example.com/c", &distinct_page("c")),
872 ("https://example.com/d", &distinct_page("d")),
873 ],
874 |o| o.concurrency = 4,
875 |r| {
876 assert_eq!(r.len(), 5);
877 let urls: HashSet<&str> = r.iter().map(|p| p.url.as_str()).collect();
878 for u in [
879 "https://example.com/",
880 "https://example.com/a",
881 "https://example.com/b",
882 "https://example.com/c",
883 "https://example.com/d",
884 ] {
885 assert!(urls.contains(u), "missing {u}");
886 }
887 },
888 )
889 .await;
890 }
891
892 #[tokio::test]
893 async fn crawl_concurrency_respects_limit() {
894 check(
895 &[
896 ("https://example.com/", &page(&["/a", "/b", "/c", "/d"])),
897 ("https://example.com/a", &distinct_page("a")),
898 ("https://example.com/b", &distinct_page("b")),
899 ("https://example.com/c", &distinct_page("c")),
900 ("https://example.com/d", &distinct_page("d")),
901 ],
902 |o| {
903 o.concurrency = 4;
904 o.limit = 3;
905 },
906 |r| assert_eq!(r.len(), 3),
907 )
908 .await;
909 }
910
911 #[tokio::test]
912 async fn crawl_concurrency_one_preserves_bfs_order() {
913 check(
914 &[
915 ("https://example.com/", &page(&["/a", "/b"])),
916 ("https://example.com/a", &distinct_page("a")),
917 ("https://example.com/b", &distinct_page("b")),
918 ],
919 |o| o.concurrency = 1,
920 |r| {
921 assert_eq!(r.len(), 3);
922 assert_eq!(r[0].url, "https://example.com/");
923 assert_eq!(r[1].url, "https://example.com/a");
924 assert_eq!(r[2].url, "https://example.com/b");
925 },
926 )
927 .await;
928 }
929
930 #[tokio::test(start_paused = true)]
931 async fn crawl_delay_enforces_minimum_interval() {
932 let start = tokio::time::Instant::now();
934 check(
935 &[
936 ("https://example.com/", &page(&["/a", "/b"])),
937 ("https://example.com/a", &distinct_page("a")),
938 ("https://example.com/b", &distinct_page("b")),
939 ],
940 |o| {
941 o.concurrency = 1;
942 o.delay = Some(Duration::from_millis(500));
943 },
944 |r| assert_eq!(r.len(), 3),
945 )
946 .await;
947 let elapsed = start.elapsed();
948 assert!(
949 elapsed >= Duration::from_secs(1),
950 "expected >= 1s for 3 pages with 500ms delay, got {elapsed:?}"
951 );
952 }
953
954 #[test]
955 fn frontier_dedup() {
956 let seed = Url::parse("https://example.com/").unwrap();
957 let mut f = Frontier::new(&seed);
958 assert!(!f.try_enqueue(seed, 0));
959 let other = Url::parse("https://example.com/page").unwrap();
960 assert!(f.try_enqueue(other.clone(), 1));
961 assert!(!f.try_enqueue(other, 1));
962 }
963
964 #[test]
965 fn frontier_pop_and_pending() {
966 let seed = Url::parse("https://example.com/").unwrap();
967 let mut f = Frontier::new(&seed);
968 assert_eq!(f.pending(), 1);
969 let (url, depth) = f.pop().unwrap();
970 assert_eq!(url.as_str(), "https://example.com/");
971 assert_eq!(depth, 0);
972 assert_eq!(f.pending(), 0);
973 assert!(f.pop().is_none());
974 }
975
976 #[test]
977 fn extract_links_filters_dangerous_schemes() {
978 let html = r#"<a href="https://example.com/a">A</a>
979 <a href="javascript:void(0)">JS</a>
980 <a href="JAVASCRIPT:alert(1)">JS upper</a>
981 <a href="data:text/html,<h1>hi</h1>">Data</a>
982 <a href="mailto:x@y.com">Mail</a>
983 <a href="/relative">Rel</a>"#;
984 let base = Url::parse("https://example.com/").unwrap();
985 let links = extract_links_from_html(html, &base);
986 assert_eq!(links.len(), 2);
987 assert_eq!(links[0].as_str(), "https://example.com/a");
988 assert_eq!(links[1].as_str(), "https://example.com/relative");
989 }
990
991 #[test]
992 fn error_result_fields() {
993 let url = Url::parse("https://example.com/fail").unwrap();
994 let r = error_result(&url, 2, crate::error::Error::engine("timeout", None), SystemTime::now());
995 assert!(matches!(r.status, CrawlStatus::Error));
996 assert!(r.error.as_ref().is_some_and(|e| e.to_string().contains("timeout")));
997 assert!(r.content.is_none());
998 }
999
1000 #[test]
1001 fn content_hash_dedup() {
1002 let seed = Url::parse("https://example.com/").unwrap();
1003 let mut f = Frontier::new(&seed);
1004 assert!(!f.is_duplicate_content("unique content"));
1005 assert!(f.is_duplicate_content("unique content"));
1006 assert!(!f.is_duplicate_content("different content"));
1007 }
1008}