Skip to main content

codoseo_crawler/
crawl.rs

1//! The crawl orchestrator: preflight, then a bounded, polite crawl one depth level at a
2//! time, ending in a [`CrawlOutput`].
3//!
4//! Every request goes through one [`Limiter`]. A level starts only when every fetch of
5//! the previous one has finished, so depth is exact even with parallel connections.
6//! Pages found only in sitemaps come after link exploration, with no depth.
7
8use std::collections::{HashMap, HashSet};
9use std::sync::Arc;
10use std::time::Duration;
11
12use codoseo_core::crawl::{CrawlConfig, RobotsFile, SitemapSummary};
13use codoseo_core::output::{CrawlOutput, Edge, LinkGraph, Progress, StopReason};
14use codoseo_core::page::PageRecord;
15use codoseo_core::url::{normalize, url_hash};
16use futures_util::StreamExt;
17use futures_util::stream::FuturesUnordered;
18use tokio::sync::Semaphore;
19use tokio::time::{Instant, sleep_until, timeout_at};
20use url::Url;
21
22use crate::fetch::{FetchError, FetchResult, Fetcher, FetcherConfig};
23use crate::frontier::{Frontier, Queued};
24use crate::politeness::{Limiter, retry_after_of};
25use crate::preflight::{BLOCKED_MSG, LOGIN_MSG, Preflight, origin_of, preflight};
26use crate::record::{self, Built};
27use crate::robots::RobotsRules;
28use crate::scope::SiteScope;
29
30/// This many 401/403/429 responses in a row stop the crawl as `Blocked`.
31const MAX_BLOCKED_STREAK: u32 = 10;
32
33/// Why a crawl could not run at all. A crawl that ran and stopped early is a
34/// `StopReason`, not an error.
35#[derive(Debug, thiserror::Error)]
36pub enum CrawlError {
37    #[error("invalid start address: {0}")]
38    InvalidStart(String),
39    #[error("address not allowed: {0}")]
40    AddressBlocked(String),
41    #[error("could not set up the crawler: {0}")]
42    Client(String),
43}
44
45/// Crawls `cfg.start_url` with its own in-flight cap of `politeness.max_in_flight`.
46pub async fn crawl(
47    cfg: CrawlConfig,
48    on_progress: impl Fn(Progress) + Send + Sync,
49) -> Result<CrawlOutput, CrawlError> {
50    let global = Arc::new(Semaphore::new(cfg.politeness.max_in_flight.max(1) as usize));
51    crawl_shared(cfg, global, on_progress).await
52}
53
54/// Crawls `cfg.start_url`, taking a slot of `global` for every request, so several
55/// crawls can share one in-flight cap. `on_progress` is called after each recorded page.
56pub async fn crawl_shared(
57    cfg: CrawlConfig,
58    global: Arc<Semaphore>,
59    on_progress: impl Fn(Progress) + Send + Sync,
60) -> Result<CrawlOutput, CrawlError> {
61    let started = Instant::now();
62    let start = valid_start(&cfg)?;
63    let fetcher = fetcher_for(&cfg)?;
64    let deadline = started + cfg.limits.max_duration;
65    let limiter = Limiter::new(&cfg.politeness, None, global);
66
67    let Ok(pre) = timeout_at(deadline, preflight(&cfg, &fetcher, &limiter, deadline)).await else {
68        return Ok(CrawlOutput {
69            origin: origin_of(&start),
70            pages: Vec::new(),
71            links: LinkGraph::default(),
72            robots: None,
73            sitemap: SitemapSummary::default(),
74            stop: StopReason::TimeLimit,
75            duration_ms: elapsed_ms(started),
76        });
77    };
78    let Preflight {
79        origin,
80        start,
81        rules,
82        robots,
83        sitemap_urls,
84        sitemap,
85        stop,
86    } = pre?;
87    limiter.set_crawl_delay(rules.crawl_delay());
88
89    let mut run = Run::new(&cfg, &origin, rules, &sitemap_urls, started);
90    run.stop = stop;
91    if let Some((url, result)) = start {
92        run.record_start(url, result);
93        on_progress(run.progress(0));
94    }
95    if run.stop.is_none() {
96        run.crawl(&fetcher, &limiter, sitemap_urls, deadline, &on_progress)
97            .await;
98    }
99    Ok(run.finish(origin, robots, sitemap))
100}
101
102/// Fetches `cfg.start_url` once, without reading robots.txt, and returns the record of
103/// the page it settles on, with every redirect hop in `redirect_chain`.
104pub async fn inspect_page(cfg: &CrawlConfig) -> Result<PageRecord, CrawlError> {
105    let url = valid_start(cfg)?;
106    let fetcher = fetcher_for(cfg)?;
107    let mut res = match fetcher.fetch(&url).await {
108        Ok(res) => res,
109        Err(FetchError::Blocked(msg)) => return Err(CrawlError::AddressBlocked(msg)),
110        Err(e) => return Ok(record::from_error(&url, Some(0), false, &e)),
111    };
112    let chain = std::mem::take(&mut res.chain);
113    let final_url =
114        normalize(&res.final_url, res.final_url.as_str()).unwrap_or_else(|| res.final_url.clone());
115    let scope = SiteScope::new(&final_url);
116    // Without hops of its own, the response gives exactly one record.
117    let built = record::from_fetch(&final_url, Some(0), |_| false, &res, &scope, |_| false);
118    let mut rec = built
119        .into_iter()
120        .next()
121        .expect("a final response always gives a record")
122        .record;
123    if !chain.is_empty() {
124        rec.redirect_chain = chain.into_iter().map(|h| (h.status, h.url)).collect();
125        rec.redirect_target = Some(final_url);
126        rec.key_hash = rec.compute_key_hash();
127    }
128    Ok(rec)
129}
130
131/// Runs only the preflight step with a fetcher and limiter built from `cfg`.
132#[doc(hidden)]
133pub async fn preflight_for_tests(cfg: CrawlConfig) -> Result<Preflight, CrawlError> {
134    let fetcher = fetcher_for(&cfg)?;
135    let limiter = Limiter::new(&cfg.politeness, None, Arc::new(Semaphore::new(64)));
136    let deadline = Instant::now() + cfg.limits.max_duration.max(Duration::from_secs(1));
137    preflight(&cfg, &fetcher, &limiter, deadline).await
138}
139
140fn valid_start(cfg: &CrawlConfig) -> Result<Url, CrawlError> {
141    normalize(&cfg.start_url, cfg.start_url.as_str())
142        .ok_or_else(|| CrawlError::InvalidStart(cfg.start_url.to_string()))
143}
144
145fn fetcher_for(cfg: &CrawlConfig) -> Result<Fetcher, CrawlError> {
146    let mut fetcher_cfg = FetcherConfig::new(cfg.address_policy);
147    fetcher_cfg.user_agent = cfg.user_agent.clone();
148    fetcher_cfg.request_timeout = cfg.limits.request_timeout;
149    fetcher_cfg.connect_timeout = fetcher_cfg.connect_timeout.min(cfg.limits.request_timeout);
150    fetcher_cfg.max_redirects = cfg.limits.max_redirects;
151    fetcher_cfg.max_body_bytes = cfg.limits.max_page_bytes;
152    Fetcher::new(fetcher_cfg).map_err(|e| CrawlError::Client(e.to_string()))
153}
154
155fn elapsed_ms(started: Instant) -> u64 {
156    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
157}
158
159/// One page fetch: wait for the limiter, then fetch within `limit` and the deadline.
160async fn fetch_one(
161    fetcher: &Fetcher,
162    limiter: &Limiter,
163    q: Queued,
164    deadline: Instant,
165    limit: Duration,
166) -> (Queued, Result<FetchResult, FetchError>) {
167    let _permit = limiter.acquire().await;
168    let until = deadline.min(Instant::now() + limit);
169    let res = timeout_at(until, fetcher.fetch(&q.url))
170        .await
171        .unwrap_or(Err(FetchError::Timeout));
172    (q, res)
173}
174
175/// A link between two pages, kept by target hash until every page has its index.
176struct PendingEdge {
177    from: u32,
178    to: u64,
179    anchor: u32,
180    nofollow: bool,
181}
182
183/// The state of one crawl after preflight.
184struct Run {
185    scope: SiteScope,
186    rules: RobotsRules,
187    frontier: Frontier,
188    in_sitemap: HashSet<u64>,
189    pages: Vec<PageRecord>,
190    edges: Vec<PendingEdge>,
191    anchors: HashMap<String, u32>,
192    /// URLs already retried once after a 429 or 503.
193    retried: HashSet<u64>,
194    connections: usize,
195    fetch_timeout: Duration,
196    max_failures: u32,
197    consecutive_failures: u32,
198    failures: u32,
199    last_error: String,
200    blocked_streak: u32,
201    /// The status that last extended the blocked streak.
202    blocked_status: u16,
203    /// The deepest link level recorded so far, for progress.
204    depth: u16,
205    started: Instant,
206    stop: Option<StopReason>,
207}
208
209impl Run {
210    fn new(
211        cfg: &CrawlConfig,
212        origin: &Url,
213        rules: RobotsRules,
214        sitemap_urls: &[Url],
215        started: Instant,
216    ) -> Run {
217        Run {
218            scope: SiteScope::new(origin),
219            rules,
220            frontier: Frontier::new(cfg.limits.max_pages),
221            in_sitemap: sitemap_urls.iter().map(url_hash).collect(),
222            pages: Vec::new(),
223            edges: Vec::new(),
224            anchors: HashMap::new(),
225            retried: HashSet::new(),
226            connections: cfg.politeness.per_site_connections.max(1) as usize,
227            fetch_timeout: cfg.limits.request_timeout.saturating_mul(2),
228            max_failures: cfg.politeness.max_consecutive_failures.max(1),
229            consecutive_failures: 0,
230            failures: 0,
231            last_error: String::new(),
232            blocked_streak: 0,
233            blocked_status: 0,
234            depth: 0,
235            started,
236            stop: None,
237        }
238    }
239
240    /// Records the start page from preflight's fetch (already retried there). The
241    /// requested URL is seeded so it counts toward the page limit.
242    fn record_start(&mut self, url: Url, result: Result<FetchResult, FetchError>) {
243        self.frontier.seed(url.clone());
244        self.frontier.pop();
245        self.record_result(
246            Queued {
247                url,
248                depth: Some(0),
249            },
250            result,
251        );
252        self.check_stops();
253    }
254
255    async fn crawl(
256        &mut self,
257        fetcher: &Fetcher,
258        limiter: &Limiter,
259        sitemap_urls: Vec<Url>,
260        deadline: Instant,
261        on_progress: &(impl Fn(Progress) + Send + Sync),
262    ) {
263        let mut in_flight = FuturesUnordered::new();
264        // Sitemap-only URLs join once link exploration is over, so linked pages keep
265        // their depth and the page limit goes to them first.
266        let mut sitemap_urls = Some(sitemap_urls);
267        while self.stop.is_none() {
268            while in_flight.len() < self.connections {
269                let Some(q) = self.frontier.pop() else { break };
270                if self.rules.allowed(q.url.as_str()) {
271                    in_flight.push(fetch_one(fetcher, limiter, q, deadline, self.fetch_timeout));
272                } else {
273                    let in_sitemap = self.is_in_sitemap(&q.url);
274                    self.pages
275                        .push(record::robots_blocked(&q.url, q.depth, in_sitemap));
276                    on_progress(self.progress(in_flight.len()));
277                }
278            }
279            if in_flight.is_empty() {
280                // The level barrier: nothing of the current level is left or in flight.
281                if self.frontier.advance() {
282                    continue;
283                }
284                if let Some(urls) = sitemap_urls.take() {
285                    let scope = &self.scope;
286                    self.frontier
287                        .add_sitemap_urls(urls.into_iter().filter(|u| scope.is_internal(u)));
288                    if self.frontier.advance() {
289                        continue;
290                    }
291                }
292                break;
293            }
294            // The deadline goes first: a fetch cut short by it ends on the same tick and
295            // must not be recorded as a timeout.
296            tokio::select! {
297                biased;
298                () = sleep_until(deadline) => self.stop = Some(StopReason::TimeLimit),
299                Some((q, res)) = in_flight.next() => {
300                    if Instant::now() >= deadline {
301                        // Too late to count, and no new work may start.
302                        self.stop = Some(StopReason::TimeLimit);
303                    } else if self.on_result(limiter, q, res) {
304                        on_progress(self.progress(in_flight.len()));
305                    }
306                }
307            }
308        }
309    }
310
311    /// Handles one finished fetch. False when nothing was recorded (a first 429 or 503,
312    /// queued again for one retry).
313    fn on_result(
314        &mut self,
315        limiter: &Limiter,
316        q: Queued,
317        res: Result<FetchResult, FetchError>,
318    ) -> bool {
319        if let Ok(r) = &res {
320            // Counted before the retry, so a blocking site stops us after 10 responses.
321            if matches!(r.status, 401 | 403 | 429) {
322                self.blocked_streak += 1;
323                self.blocked_status = r.status;
324            } else {
325                self.blocked_streak = 0;
326            }
327            limiter.on_response(r.status, retry_after_of(&r.headers));
328            if matches!(r.status, 429 | 503) && self.retried.insert(url_hash(&q.url)) {
329                self.frontier.requeue_front(q);
330                self.check_stops();
331                return false;
332            }
333        }
334        self.record_result(q, res);
335        self.check_stops();
336        true
337    }
338
339    /// Builds the records of one result and updates the failure counter.
340    fn record_result(&mut self, q: Queued, res: Result<FetchResult, FetchError>) {
341        match res {
342            Ok(r) => {
343                if r.status >= 500 {
344                    self.fail(format!("server returned HTTP {}", r.status));
345                } else {
346                    self.consecutive_failures = 0;
347                }
348                let in_sitemap = &self.in_sitemap;
349                let rules = &self.rules;
350                let frontier = &mut self.frontier;
351                // An internal redirect target costs no fetch, so it is admitted past the
352                // page limit; one robots.txt disallows is not recorded as a fetched page.
353                let built = record::from_fetch(
354                    &q.url,
355                    q.depth,
356                    |u| in_sitemap.contains(&url_hash(u)),
357                    &r,
358                    &self.scope,
359                    |u| rules.allowed(u.as_str()) && frontier.admit_redirect_target(u),
360                );
361                for b in built {
362                    self.add(b);
363                }
364            }
365            Err(e) => {
366                // Redirect and other errors are recorded but say nothing about reachability.
367                if matches!(e, FetchError::Timeout | FetchError::Connect(_)) {
368                    self.fail(e.to_string());
369                }
370                let in_sitemap = self.is_in_sitemap(&q.url);
371                self.pages
372                    .push(record::from_error(&q.url, q.depth, in_sitemap, &e));
373            }
374        }
375    }
376
377    /// Adds a record, queues its followed internal links and keeps its edges to pages
378    /// the frontier knows. Pages with no depth (sitemap-only) are not followed.
379    ///
380    /// A page that links the same URL several times gets one edge: it keeps the first
381    /// anchor text, and is `nofollow` only when every one of those links is.
382    fn add(&mut self, b: Built) {
383        let from = u32::try_from(self.pages.len()).unwrap_or(u32::MAX);
384        let depth = b.record.depth;
385        if let Some(d) = depth {
386            self.depth = self.depth.max(d);
387        }
388        if b.follow_links
389            && let Some(d) = depth
390        {
391            for link in &b.links {
392                if !link.nofollow && self.scope.is_internal(&link.url) {
393                    self.frontier.push_link(link.url.clone(), d);
394                }
395            }
396        }
397        let mut by_target: HashMap<u64, usize> = HashMap::new();
398        for link in b.links {
399            if !self.scope.is_internal(&link.url) || !self.frontier.is_seen(&link.url) {
400                continue;
401            }
402            let to = url_hash(&link.url);
403            if let Some(&i) = by_target.get(&to) {
404                self.edges[i].nofollow &= link.nofollow;
405                continue;
406            }
407            by_target.insert(to, self.edges.len());
408            let anchor = self.intern(link.anchor);
409            self.edges.push(PendingEdge {
410                from,
411                to,
412                anchor,
413                nofollow: link.nofollow,
414            });
415        }
416        self.pages.push(b.record);
417    }
418
419    fn intern(&mut self, text: String) -> u32 {
420        let next = u32::try_from(self.anchors.len()).unwrap_or(u32::MAX);
421        *self.anchors.entry(text).or_insert(next)
422    }
423
424    fn fail(&mut self, error: String) {
425        self.consecutive_failures += 1;
426        self.failures += 1;
427        self.last_error = error;
428    }
429
430    fn check_stops(&mut self) {
431        if self.stop.is_some() {
432            return;
433        }
434        if self.consecutive_failures >= self.max_failures {
435            self.stop = Some(StopReason::Unreachable(self.last_error.clone()));
436        } else if self.blocked_streak >= MAX_BLOCKED_STREAK {
437            let msg = if self.blocked_status == 401 {
438                LOGIN_MSG
439            } else {
440                BLOCKED_MSG
441            };
442            self.stop = Some(StopReason::Blocked(msg.to_owned()));
443        }
444    }
445
446    fn is_in_sitemap(&self, url: &Url) -> bool {
447        self.in_sitemap.contains(&url_hash(url))
448    }
449
450    fn progress(&self, in_flight: usize) -> Progress {
451        Progress {
452            pages_done: u32::try_from(self.pages.len()).unwrap_or(u32::MAX),
453            queued: u32::try_from(self.frontier.queued() + in_flight).unwrap_or(u32::MAX),
454            failures: self.failures,
455            depth: self.depth,
456            elapsed_ms: elapsed_ms(self.started),
457        }
458    }
459
460    /// Settles the stop reason and turns pending edges into page indices, dropping
461    /// targets that were never recorded and self-links.
462    fn finish(
463        self,
464        origin: Url,
465        robots: Option<RobotsFile>,
466        sitemap: SitemapSummary,
467    ) -> CrawlOutput {
468        let stop = match self.stop {
469            Some(stop) => stop,
470            None if self.frontier.capped() => StopReason::PageLimit,
471            None => StopReason::Completed,
472        };
473        let index: HashMap<u64, u32> = self
474            .pages
475            .iter()
476            .enumerate()
477            .map(|(i, p)| (p.url_hash, u32::try_from(i).unwrap_or(u32::MAX)))
478            .collect();
479        let edges = self
480            .edges
481            .into_iter()
482            .filter_map(|e| {
483                let to = *index.get(&e.to)?;
484                (to != e.from).then_some(Edge {
485                    from: e.from,
486                    to,
487                    anchor: e.anchor,
488                    nofollow: e.nofollow,
489                })
490            })
491            .collect();
492        let mut anchors = vec![String::new(); self.anchors.len()];
493        for (text, id) in self.anchors {
494            anchors[id as usize] = text;
495        }
496        CrawlOutput {
497            origin,
498            pages: self.pages,
499            links: LinkGraph { edges, anchors },
500            robots,
501            sitemap,
502            stop,
503            duration_ms: elapsed_ms(self.started),
504        }
505    }
506}