1mod parser;
4mod tandem;
5
6pub use tandem::RequestQueueWithSitemap;
7
8use std::{
9 collections::{HashMap, HashSet},
10 sync::Arc,
11};
12
13use futures_util::StreamExt;
14use serde::{Deserialize, Serialize};
15use tokio::{sync::Mutex, task::JoinHandle};
16use url::Url;
17
18use crate::{
19 errors::CrawlError,
20 http_client::{HttpClient, HttpRequest, StreamingResponse},
21 request::{Request, UserData},
22 storage::{KeyValueStore, KeyValueStoreExt},
23};
24
25use parser::{SitemapEvent, SitemapParseError, XmlPump};
26
27pub const SITEMAP_STATE_KEY: &str = "SITEMAP_REQUEST_LIST_STATE";
29
30const AUTO_PERSIST_INTERVAL: u64 = 100;
32const MAX_DEPTH: u8 = 5;
33
34#[derive(Debug, Clone, PartialEq)]
36pub struct SitemapEntry {
37 pub loc: String,
39 pub lastmod: Option<String>,
41 pub priority: Option<f32>,
43 pub changefreq: Option<String>,
45}
46
47#[derive(Default)]
49#[must_use = "builders do nothing unless consumed by build"]
50pub struct SitemapRequestListBuilder {
51 sitemap_urls: Vec<Url>,
52 http_client: Option<Arc<dyn HttpClient>>,
53 persistence: Option<(Arc<dyn KeyValueStore>, String)>,
54 label: Option<String>,
55 user_data: UserData,
56 limit: Option<u64>,
57}
58
59impl SitemapRequestListBuilder {
60 pub fn sitemap_url(mut self, url: Url) -> Self {
62 self.sitemap_urls.push(url);
63 self
64 }
65
66 pub fn sitemap_urls(mut self, urls: impl IntoIterator<Item = Url>) -> Self {
68 self.sitemap_urls.extend(urls);
69 self
70 }
71
72 pub fn http_client(mut self, client: Arc<dyn HttpClient>) -> Self {
74 self.http_client = Some(client);
75 self
76 }
77
78 pub fn persist(mut self, kvs: Arc<dyn KeyValueStore>, key: impl Into<String>) -> Self {
84 self.persistence = Some((kvs, key.into()));
85 self
86 }
87
88 pub fn label(mut self, label: impl Into<String>) -> Self {
90 self.label = Some(label.into());
91 self
92 }
93
94 pub fn user_data(mut self, user_data: UserData) -> Self {
96 self.user_data = user_data;
97 self
98 }
99
100 pub fn limit(mut self, limit: u64) -> Self {
102 self.limit = Some(limit);
103 self
104 }
105
106 pub fn build(self) -> Result<SitemapRequestList, CrawlError> {
108 if self.sitemap_urls.is_empty() {
109 return Err(CrawlError::non_retryable(anyhow::anyhow!(
110 "at least one sitemap URL is required"
111 )));
112 }
113 let http_client = self.http_client.ok_or_else(|| {
114 CrawlError::non_retryable(anyhow::anyhow!("an HTTP client is required"))
115 })?;
116 let mut sitemap_urls = self.sitemap_urls;
117 let mut unique_roots = HashSet::new();
118 sitemap_urls.retain(|url| unique_roots.insert(url.clone()));
119 let pending = sitemap_urls
120 .iter()
121 .rev()
122 .cloned()
123 .map(|url| PendingSitemap { url, depth: 0 })
124 .collect();
125 Ok(SitemapRequestList {
126 inner: Mutex::new(State {
127 roots: sitemap_urls,
128 http_client,
129 persistence: self.persistence,
130 label: self.label,
131 user_data: self.user_data,
132 limit: self.limit,
133 pending,
134 completed: HashSet::new(),
135 completed_failures: HashSet::new(),
136 seen_sitemaps: unique_roots
137 .into_iter()
138 .map(|url| url.as_str().to_owned())
139 .collect(),
140 emitted_urls: HashSet::new(),
141 current: None,
142 emitted_total: 0,
143 loaded: false,
144 finished: false,
145 successful_roots: HashSet::new(),
146 failed_roots: HashSet::new(),
147 resume_skip: None,
148 pending_emission: None,
149 }),
150 })
151 }
152}
153
154pub struct SitemapRequestList {
156 inner: Mutex<State>,
157}
158
159impl SitemapRequestList {
160 pub async fn fetch_next(&self) -> Result<Option<Request>, CrawlError> {
162 self.fetch_next_inner(true).await
163 }
164
165 pub(super) async fn fetch_next_for_tandem(&self) -> Result<Option<Request>, CrawlError> {
166 self.fetch_next_inner(false).await
167 }
168
169 async fn fetch_next_inner(
170 &self,
171 auto_persist_emission: bool,
172 ) -> Result<Option<Request>, CrawlError> {
173 let mut state = self.inner.lock().await;
174 state.load_persisted().await?;
175 loop {
176 if state.finished
177 || state
178 .limit
179 .is_some_and(|limit| state.emitted_total >= limit)
180 {
181 state.finished = true;
182 state.current = None;
183 return Ok(None);
184 }
185
186 if state.pending_emission.is_some() {
187 let emitted_total = state.emitted_total + 1;
188 if auto_persist_emission && emitted_total % AUTO_PERSIST_INTERVAL == 0 {
189 if let Err(error) = state.persist_emission(emitted_total).await {
190 tracing::warn!(%error, "automatic sitemap checkpoint failed");
191 }
192 }
193 state.emitted_total = emitted_total;
194 return Ok(state.pending_emission.take());
195 }
196
197 if state.current.is_none() {
198 let Some(next) = state.peek_pending() else {
199 state.finished = true;
200 if !state.roots.is_empty()
201 && state.successful_roots.is_empty()
202 && state.failed_roots.len() == state.roots.len()
203 {
204 return Err(CrawlError::retry(anyhow::anyhow!(
205 "all configured root sitemaps failed"
206 )));
207 }
208 return Ok(None);
209 };
210 match state.open(next.clone()).await {
211 Ok(current) => {
212 state.pending.pop();
213 state.current = Some(current);
214 }
215 Err(error) => {
216 state.pending.pop();
217 state
218 .mark_fetch_failure(next, error, auto_persist_emission)
219 .await?;
220 continue;
221 }
222 }
223 }
224
225 let event = {
226 let current = state.current.as_mut().expect("current sitemap exists");
227 current.events.recv().await
228 };
229 match event {
230 Some(Ok(SitemapEvent::Entry(entry))) => {
231 let current = state.current.as_mut().expect("current sitemap exists");
232 current.entries_seen += 1;
233 let url = match Url::parse(&entry.loc) {
234 Ok(url) => url,
235 Err(error) => {
236 tracing::warn!(loc = %entry.loc, %error, "skipping invalid sitemap URL");
237 continue;
238 }
239 };
240 if current.entries_seen <= current.skip_entries {
241 state.emitted_urls.insert(url.as_str().to_owned());
242 continue;
243 }
244 if !state.emitted_urls.insert(url.as_str().to_owned()) {
245 continue;
246 }
247 let mut builder = Request::get(url)
248 .user_data(state.user_data.clone())
249 .crawl_depth(0);
250 if let Some(label) = &state.label {
251 builder = builder.label(label.clone());
252 }
253 let request = builder.build().map_err(CrawlError::non_retryable)?;
254 state.pending_emission = Some(request);
255 }
256 Some(Ok(SitemapEvent::Nested(location))) => {
257 state.add_nested(location);
258 }
259 Some(Err(error)) => {
260 tracing::warn!(%error, "sitemap parsing failed");
261 if error.is_body() {
262 state.fail_current(auto_persist_emission).await?;
263 } else {
264 state.complete_current(auto_persist_emission).await?;
265 }
266 }
267 None => state.complete_current(auto_persist_emission).await?,
268 }
269 }
270 }
271
272 pub async fn is_finished(&self) -> bool {
274 let state = self.inner.lock().await;
275 state.is_finished()
276 }
277
278 pub async fn processed_count(&self) -> u64 {
280 let state = self.inner.lock().await;
281 state.emitted_total
282 }
283
284 pub async fn persist(&self) -> Result<(), CrawlError> {
286 let mut state = self.inner.lock().await;
287 state.load_persisted().await?;
288 state.persist_now().await
289 }
290}
291
292#[derive(Clone)]
293struct PendingSitemap {
294 url: Url,
295 depth: u8,
296}
297
298struct ActiveSitemap {
299 pending: PendingSitemap,
300 events: tokio::sync::mpsc::Receiver<Result<SitemapEvent, SitemapParseError>>,
301 feeder: JoinHandle<()>,
302 entries_seen: u64,
303 skip_entries: u64,
304 nested: Vec<PendingSitemap>,
305}
306
307impl Drop for ActiveSitemap {
308 fn drop(&mut self) {
309 self.feeder.abort();
310 }
311}
312
313struct State {
314 roots: Vec<Url>,
315 http_client: Arc<dyn HttpClient>,
316 persistence: Option<(Arc<dyn KeyValueStore>, String)>,
317 label: Option<String>,
318 user_data: UserData,
319 limit: Option<u64>,
320 pending: Vec<PendingSitemap>,
321 completed: HashSet<String>,
322 completed_failures: HashSet<String>,
323 seen_sitemaps: HashSet<String>,
324 emitted_urls: HashSet<String>,
325 current: Option<ActiveSitemap>,
326 emitted_total: u64,
327 loaded: bool,
328 finished: bool,
329 successful_roots: HashSet<String>,
330 failed_roots: HashSet<String>,
331 resume_skip: Option<u64>,
332 pending_emission: Option<Request>,
333}
334
335impl State {
336 async fn load_persisted(&mut self) -> Result<(), CrawlError> {
337 if self.loaded {
338 return Ok(());
339 }
340 let Some((kvs, key)) = self.persistence.clone() else {
341 self.loaded = true;
342 return Ok(());
343 };
344 let Some(saved) = kvs.get::<PersistedState>(&key).await? else {
345 self.loaded = true;
346 return Ok(());
347 };
348 if saved.version != 2 {
349 return Err(CrawlError::non_retryable(anyhow::anyhow!(
350 "unsupported sitemap state version {}",
351 saved.version
352 )));
353 }
354 self.completed = saved.completed.into_iter().collect();
355 self.completed_failures = saved.completed_failures.into_iter().collect();
356 self.emitted_total = saved.emitted_total;
357 self.pending = saved
358 .pending
359 .into_iter()
360 .filter_map(|value| Url::parse(&value).ok())
361 .rev()
362 .map(|url| {
363 let depth = saved
364 .pending_depths
365 .get(url.as_str())
366 .copied()
367 .unwrap_or_else(|| if self.roots.contains(&url) { 0 } else { 1 });
368 PendingSitemap { url, depth }
369 })
370 .collect();
371 if let Some(value) = saved.in_progress {
372 if let Ok(url) = Url::parse(&value) {
373 let depth = saved
374 .in_progress_depth
375 .unwrap_or_else(|| if self.roots.contains(&url) { 0 } else { 1 });
376 self.pending.push(PendingSitemap { url, depth });
377 self.resume_skip = Some(saved.emitted_in_progress);
378 }
379 }
380 self.seen_sitemaps.extend(self.completed.iter().cloned());
381 self.seen_sitemaps.extend(
382 self.pending
383 .iter()
384 .map(|pending| pending.url.as_str().to_owned()),
385 );
386 for root in &self.roots {
387 if self.completed.contains(root.as_str()) {
388 if self.completed_failures.contains(root.as_str()) {
389 self.failed_roots.insert(root.as_str().to_owned());
390 } else {
391 self.successful_roots.insert(root.as_str().to_owned());
392 }
393 }
394 }
395 self.loaded = true;
396 Ok(())
397 }
398
399 fn is_finished(&self) -> bool {
400 if self.finished || self.limit.is_some_and(|limit| self.emitted_total >= limit) {
401 return true;
402 }
403 self.current.is_none()
404 && self
405 .pending
406 .iter()
407 .all(|pending| self.completed.contains(pending.url.as_str()))
408 }
409
410 fn peek_pending(&mut self) -> Option<PendingSitemap> {
411 while let Some(next) = self.pending.last() {
412 if self.completed.contains(next.url.as_str()) {
413 self.pending.pop();
414 } else {
415 let next = next.clone();
416 self.seen_sitemaps.insert(next.url.as_str().to_owned());
417 return Some(next);
418 }
419 }
420 None
421 }
422
423 async fn open(&mut self, pending: PendingSitemap) -> Result<ActiveSitemap, CrawlError> {
424 let response = self
425 .http_client
426 .stream(HttpRequest::new(pending.url.clone()))
427 .await
428 .map_err(CrawlError::retry)?;
429 if !response.status.is_success() {
430 return Err(CrawlError::retry(anyhow::anyhow!(
431 "sitemap {} returned HTTP {}",
432 pending.url,
433 response.status
434 )));
435 }
436 let (events, feeder) = start_pump(response).await?;
437 let skip_entries = self.resume_skip.take().unwrap_or(0);
440 if pending.depth == 0 {
441 self.successful_roots
442 .insert(pending.url.as_str().to_owned());
443 }
444 Ok(ActiveSitemap {
445 pending,
446 events,
447 feeder,
448 entries_seen: 0,
449 skip_entries,
450 nested: Vec::new(),
451 })
452 }
453
454 fn add_nested(&mut self, location: String) {
455 let Some(current) = &mut self.current else {
456 return;
457 };
458 if current.pending.depth >= MAX_DEPTH {
459 tracing::warn!(url = %location, "skipping sitemap beyond nesting depth cap");
460 return;
461 }
462 match Url::parse(&location) {
463 Ok(url) => {
464 if self.seen_sitemaps.insert(url.as_str().to_owned()) {
465 let depth = if self.roots.contains(&url) {
466 0
467 } else {
468 current.pending.depth + 1
469 };
470 current.nested.push(PendingSitemap { url, depth });
471 }
472 }
473 Err(error) => {
474 tracing::warn!(loc = %location, %error, "skipping invalid nested sitemap URL")
475 }
476 }
477 }
478
479 async fn complete_current(&mut self, persist: bool) -> Result<(), CrawlError> {
480 if let Some(mut current) = self.current.take() {
481 for nested in current.nested.drain(..).rev() {
482 self.pending.push(nested);
483 }
484 let url = current.pending.url.as_str().to_owned();
485 self.completed_failures.remove(&url);
486 self.completed.insert(url);
487 }
488 if persist {
489 self.persist_now().await?;
490 }
491 Ok(())
492 }
493
494 async fn mark_fetch_failure(
495 &mut self,
496 failed: PendingSitemap,
497 error: CrawlError,
498 persist: bool,
499 ) -> Result<(), CrawlError> {
500 let url = failed.url.as_str().to_owned();
501 tracing::warn!(%url, %error, "sitemap fetch failed; continuing");
502 self.resume_skip = None;
503 if failed.depth == 0 {
504 self.failed_roots.insert(url.clone());
505 }
506 self.completed_failures.insert(url.clone());
507 self.completed.insert(url);
508 if persist {
509 self.persist_now().await?;
510 }
511 Ok(())
512 }
513
514 async fn fail_current(&mut self, persist: bool) -> Result<(), CrawlError> {
515 if let Some(current) = self.current.take() {
516 for nested in ¤t.nested {
517 self.seen_sitemaps.remove(nested.url.as_str());
518 }
519 let url = current.pending.url.as_str().to_owned();
520 if current.pending.depth == 0 {
521 self.successful_roots.remove(&url);
522 self.failed_roots.insert(url.clone());
523 }
524 self.completed_failures.insert(url.clone());
525 self.completed.insert(url);
526 }
527 if persist {
528 self.persist_now().await?;
529 }
530 Ok(())
531 }
532
533 async fn persist_now(&self) -> Result<(), CrawlError> {
534 self.persist_snapshot(self.emitted_total, false).await
535 }
536
537 async fn persist_emission(&self, emitted_total: u64) -> Result<(), CrawlError> {
538 self.persist_snapshot(emitted_total, true).await
539 }
540
541 async fn persist_snapshot(
542 &self,
543 emitted_total: u64,
544 include_pending_emission: bool,
545 ) -> Result<(), CrawlError> {
546 let Some((kvs, key)) = &self.persistence else {
547 return Ok(());
548 };
549 let staged_resume = if self.current.is_none() && self.resume_skip.is_some() {
550 self.pending.last()
551 } else {
552 None
553 };
554 let pending_end = self.pending.len() - usize::from(staged_resume.is_some());
555 let state = PersistedState {
556 version: 2,
557 completed: self.completed.iter().cloned().collect(),
558 completed_failures: self.completed_failures.iter().cloned().collect(),
559 in_progress: self
560 .current
561 .as_ref()
562 .map(|current| current.pending.url.as_str().to_owned())
563 .or_else(|| staged_resume.map(|pending| pending.url.as_str().to_owned())),
564 in_progress_depth: self
565 .current
566 .as_ref()
567 .map(|current| current.pending.depth)
568 .or_else(|| staged_resume.map(|pending| pending.depth)),
569 emitted_in_progress: self.current.as_ref().map_or_else(
570 || self.resume_skip.unwrap_or(0),
571 |current| {
572 if include_pending_emission {
573 current.entries_seen
574 } else {
575 current.entries_seen - u64::from(self.pending_emission.is_some())
576 }
577 },
578 ),
579 pending: self.pending[..pending_end]
580 .iter()
581 .rev()
582 .map(|pending| pending.url.as_str().to_owned())
583 .collect(),
584 pending_depths: self.pending[..pending_end]
585 .iter()
586 .map(|pending| (pending.url.as_str().to_owned(), pending.depth))
587 .collect(),
588 emitted_total,
589 };
590 kvs.set(key, &state).await?;
591 Ok(())
592 }
593}
594
595async fn start_pump(
596 mut response: StreamingResponse,
597) -> Result<
598 (
599 tokio::sync::mpsc::Receiver<Result<SitemapEvent, SitemapParseError>>,
600 JoinHandle<()>,
601 ),
602 CrawlError,
603> {
604 let mut initial = Vec::new();
605 let mut prefix = Vec::new();
606 while prefix.len() < 2 {
607 match response.body.next().await {
608 Some(Ok(chunk)) => {
609 prefix.extend_from_slice(&chunk[..chunk.len().min(2 - prefix.len())]);
610 initial.push(chunk);
611 }
612 Some(Err(error)) => return Err(CrawlError::retry(error)),
613 None => break,
614 }
615 }
616 let gzip = response.url.path().ends_with(".gz") || prefix.as_slice() == [0x1f, 0x8b];
617 let (chunk_tx, event_tx, events) = XmlPump::spawn(gzip);
618 let feeder = tokio::spawn(async move {
619 for chunk in initial {
620 let sender = chunk_tx.clone();
621 if tokio::task::spawn_blocking(move || sender.send(chunk))
622 .await
623 .ok()
624 .and_then(Result::ok)
625 .is_none()
626 {
627 return;
628 }
629 }
630 while let Some(result) = response.body.next().await {
631 match result {
632 Ok(chunk) => {
633 let sender = chunk_tx.clone();
634 if tokio::task::spawn_blocking(move || sender.send(chunk))
635 .await
636 .ok()
637 .and_then(Result::ok)
638 .is_none()
639 {
640 return;
641 }
642 }
643 Err(error) => {
644 tracing::warn!(%error, "sitemap response body failed");
645 let _ = event_tx
646 .send(Err(SitemapParseError::body(error.to_string())))
647 .await;
648 return;
649 }
650 }
651 }
652 });
653 Ok((events, feeder))
654}
655
656#[derive(Debug, Serialize, Deserialize)]
657struct PersistedState {
658 version: u8,
659 completed: Vec<String>,
660 #[serde(default)]
664 completed_failures: Vec<String>,
665 in_progress: Option<String>,
666 #[serde(default)]
667 in_progress_depth: Option<u8>,
668 emitted_in_progress: u64,
669 pending: Vec<String>,
670 #[serde(default)]
671 pending_depths: HashMap<String, u8>,
672 emitted_total: u64,
673}