Skip to main content

millipede_core/sitemap/
mod.rs

1//! Streaming, gzip-aware XML sitemap ingestion.
2
3mod 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
27/// Conventional key used to persist sitemap request-list progress.
28pub const SITEMAP_STATE_KEY: &str = "SITEMAP_REQUEST_LIST_STATE";
29
30/// Number of emitted requests between automatic progress snapshots.
31const AUTO_PERSIST_INTERVAL: u64 = 100;
32const MAX_DEPTH: u8 = 5;
33
34/// One URL entry parsed from a sitemap document.
35#[derive(Debug, Clone, PartialEq)]
36pub struct SitemapEntry {
37    /// Absolute URL contained in the `loc` element.
38    pub loc: String,
39    /// Optional sitemap modification timestamp.
40    pub lastmod: Option<String>,
41    /// Optional sitemap priority.
42    pub priority: Option<f32>,
43    /// Optional sitemap change frequency.
44    pub changefreq: Option<String>,
45}
46
47/// Configures a streaming [`SitemapRequestList`].
48#[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    /// Adds one root sitemap URL.
61    pub fn sitemap_url(mut self, url: Url) -> Self {
62        self.sitemap_urls.push(url);
63        self
64    }
65
66    /// Adds root sitemap URLs.
67    pub fn sitemap_urls(mut self, urls: impl IntoIterator<Item = Url>) -> Self {
68        self.sitemap_urls.extend(urls);
69        self
70    }
71
72    /// Sets the HTTP backend used to stream sitemap documents.
73    pub fn http_client(mut self, client: Arc<dyn HttpClient>) -> Self {
74        self.http_client = Some(client);
75        self
76    }
77
78    /// Enables persisted progress under `key`.
79    ///
80    /// An in-progress sitemap is fetched again from byte zero after restart, and
81    /// its already-emitted entries are skipped. This requires stable sitemap
82    /// ordering across fetches, which is the practical norm for sitemap files.
83    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    /// Applies a routing label to every emitted request.
89    pub fn label(mut self, label: impl Into<String>) -> Self {
90        self.label = Some(label.into());
91        self
92    }
93
94    /// Applies user data to every emitted request.
95    pub fn user_data(mut self, user_data: UserData) -> Self {
96        self.user_data = user_data;
97        self
98    }
99
100    /// Limits the total number of emitted requests.
101    pub fn limit(mut self, limit: u64) -> Self {
102        self.limit = Some(limit);
103        self
104    }
105
106    /// Builds a request list without performing network or storage I/O.
107    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
154/// A lazy, streaming source of requests parsed from XML sitemaps.
155pub struct SitemapRequestList {
156    inner: Mutex<State>,
157}
158
159impl SitemapRequestList {
160    /// Returns the next unique request, fetching sitemap documents only on demand.
161    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    /// Returns whether this list has permanently stopped producing requests.
273    pub async fn is_finished(&self) -> bool {
274        let state = self.inner.lock().await;
275        state.is_finished()
276    }
277
278    /// Returns the number of requests emitted by this list.
279    pub async fn processed_count(&self) -> u64 {
280        let state = self.inner.lock().await;
281        state.emitted_total
282    }
283
284    /// Persists current progress, or does nothing when persistence is disabled.
285    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        // Keep the resume cursor cancellation-safe while start_pump awaits the
438        // first response-body bytes used for gzip detection.
439        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 &current.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    // Version 2 deliberately extends the original six-field version-1 schema:
661    // failed-root classification and nesting depth cannot be derived reliably
662    // after a restart, but both affect retry and depth-cap correctness.
663    #[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}