Skip to main content

servo_fetch/
crawl.rs

1//! Site crawling — BFS link traversal with scope, robots.txt, and rate limiting.
2
3use 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/// Options for crawling a site.
19#[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    /// Create crawl options for the given seed URL.
40    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    /// Maximum number of pages to crawl (default: 50).
60    pub fn limit(mut self, n: usize) -> Self {
61        self.limit = n;
62        self
63    }
64
65    /// Maximum link depth from the seed URL (default: 3).
66    pub fn max_depth(mut self, n: usize) -> Self {
67        self.max_depth = n;
68        self
69    }
70
71    /// Page load timeout per page (default: 30s).
72    pub fn timeout(mut self, timeout: Duration) -> Self {
73        self.timeout = timeout;
74        self
75    }
76
77    /// Extra wait after load event per page (default: 0).
78    pub fn settle(mut self, settle: Duration) -> Self {
79        self.settle = settle;
80        self
81    }
82
83    /// URL path glob patterns to include (e.g. `"/docs/**"`).
84    pub fn include(mut self, patterns: &[&str]) -> Self {
85        self.include = patterns.iter().map(|s| (*s).to_string()).collect();
86        self
87    }
88
89    /// URL path glob patterns to exclude (e.g. `"/docs/archive/**"`).
90    pub fn exclude(mut self, patterns: &[&str]) -> Self {
91        self.exclude = patterns.iter().map(|s| (*s).to_string()).collect();
92        self
93    }
94
95    /// Output crawled content as JSON instead of Markdown.
96    pub fn json(mut self, json: bool) -> Self {
97        self.json = json;
98        self
99    }
100
101    /// CSS selector to extract a specific section per page.
102    pub fn selector(mut self, selector: impl Into<String>) -> Self {
103        self.selector = Some(selector.into());
104        self
105    }
106
107    /// Override the User-Agent string for all pages in this crawl.
108    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    /// Maximum parallel fetches (default: 1). Values below 1 are clamped to 1.
114    /// Results are yielded in completion order when greater than 1.
115    pub fn concurrency(mut self, n: usize) -> Self {
116        self.concurrency = n.max(1);
117        self
118    }
119
120    /// Minimum dispatch interval (default: `Some(500ms)`). `None` disables rate limiting.
121    pub fn delay(mut self, delay: Option<Duration>) -> Self {
122        self.delay = delay;
123        self
124    }
125
126    /// Seed session cookies before crawling, scoped to the seed's site.
127    pub fn cookies(mut self, cookies: Vec<crate::cookies::CookieSpec>) -> Self {
128        self.cookies = cookies;
129        self
130    }
131
132    /// Custom request headers sent with every page's navigation request.
133    pub fn headers(mut self, headers: http::HeaderMap) -> Self {
134        self.headers = headers;
135        self
136    }
137}
138
139/// Result for a single crawled page.
140#[derive(Debug)]
141#[non_exhaustive]
142pub struct CrawlResult {
143    /// URL of the crawled page.
144    pub url: String,
145    /// Link depth from the seed URL.
146    pub depth: usize,
147    /// Wall-clock time when the fetch completed.
148    pub fetched_at: SystemTime,
149    /// Page content if successful, or error if failed.
150    pub outcome: Result<CrawlPage, crate::error::Error>,
151}
152
153/// Successfully crawled page.
154#[derive(Debug, Clone)]
155pub struct CrawlPage {
156    /// Page title.
157    pub title: Option<String>,
158    /// Extracted content (Markdown or JSON depending on options).
159    pub content: String,
160    /// Number of links discovered on this page.
161    pub links_found: usize,
162}
163
164/// Mirrors the canonical wire shape `servo_fetch_types::CrawlEvent`; keep both in sync.
165impl 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
217/// Crawl a site, invoking `on_page` for each result as it arrives (blocking).
218pub 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
225/// Crawl a site, invoking `on_page` for each result as it arrives.
226pub 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
248/// Crawl a site and collect all results (blocking).
249pub 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
255/// Crawl a site and collect all results.
256pub 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
292/// Crawl configuration.
293pub(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    /// Parallel fetch limit (clamped to >=1; yields in completion order when >1).
305    pub concurrency: usize,
306    /// Dispatch interval; `None` disables rate limiting.
307    pub delay: Option<Duration>,
308    pub cookies: Vec<crate::cookies::CookieSpec>,
309    pub headers: http::HeaderMap,
310}
311
312/// Result for a single crawled page.
313pub(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
324/// Status of a crawled page.
325pub(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    // `Delay` keeps the steady-state rate correct after fetches exceed `delay`.
396    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
491/// Stable crawl state passed to `process_ok_fetch`.
492struct CrawlContext<'a> {
493    frontier: &'a mut Frontier,
494    robots: &'a RobotsPolicy,
495    opts: &'a CrawlPlan,
496}
497
498/// Build a `CrawlPageResult` and enqueue discovered links.
499fn 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
567/// Fetch result crossing the `JoinSet` boundary.
568struct 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    /// Leaf page with unique body to avoid content-hash dedup.
677    fn distinct_page(tag: &str) -> String {
678        format!("<html><head><title>{tag}</title></head><body>page {tag}</body></html>")
679    }
680
681    /// Test helper: build `CrawlPlan`, run, assert. `delay=None` keeps tests fast.
682    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        // 3 pages at 500ms delay = 2 ticks >= 1s (first dispatch is free).
933        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}