1use 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
30const MAX_BLOCKED_STREAK: u32 = 10;
32
33#[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
45pub 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
54pub 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
102pub 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 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#[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
159async 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
175struct PendingEdge {
177 from: u32,
178 to: u64,
179 anchor: u32,
180 nofollow: bool,
181}
182
183struct 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 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 blocked_status: u16,
203 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 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 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 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 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 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 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 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 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 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 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 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 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}