Skip to main content

libnetrunner/
bootstrap.rs

1use async_recursion::async_recursion;
2use bytes::Buf;
3use dashmap::DashSet;
4use feedfinder::FeedType;
5use flate2::read::GzDecoder;
6use governor::Quota;
7use governor::RateLimiter;
8use nonzero_ext::nonzero;
9use regex::{RegexSet, RegexSetBuilder};
10use reqwest::Client;
11use rss::Channel;
12use sitemap::reader::{SiteMapEntity, SiteMapReader};
13use spyglass_lens::LensConfig;
14use std::sync::Arc;
15use std::time::Duration;
16use std::{collections::HashSet, io::Read};
17use tokio::task::JoinSet;
18use tokio_retry::strategy::ExponentialBackoff;
19use tokio_retry::RetryIf;
20use url::Url;
21
22use super::cdx;
23use super::crawler::RateLimit;
24use crate::{cache::CrawlCache, crawler::http_client, site::SiteInfo};
25
26#[derive(Clone)]
27pub struct Bootstrapper {
28    client: Client,
29    // Urls that need to be processed through a cdx index.
30    cdx_queue: HashSet<String>,
31}
32
33impl Default for Bootstrapper {
34    fn default() -> Self {
35        Self::new(&http_client())
36    }
37}
38
39impl Bootstrapper {
40    pub fn new(client: &Client) -> Self {
41        Self {
42            client: client.clone(),
43            cdx_queue: HashSet::new(),
44        }
45    }
46
47    pub async fn find_urls(&mut self, lens: &LensConfig) -> anyhow::Result<Vec<String>> {
48        // Urls gathered from sitemaps + cdx processing.
49        let mut to_crawl: DashSet<String> = DashSet::new();
50        let mut cache = CrawlCache::new();
51
52        log::info!("Loading lens rules");
53        let filters = lens.into_regexes();
54        let allowed = RegexSetBuilder::new(filters.allowed)
55            .size_limit(100_000_000)
56            .build()?;
57
58        let skipped = RegexSetBuilder::new(filters.skipped)
59            .size_limit(100_000_000)
60            .build()?;
61
62        // ------------------------------------------------------------------------
63        // Second, we fetch robots & sitemaps from the domains/urls represented by the lens
64        // ------------------------------------------------------------------------
65        log::info!("Fetching robots.txt & sitemaps.xml");
66        for domain in lens.domains.iter() {
67            let domain_url = format!("http://{domain}/");
68            to_crawl.insert(domain_url.to_string());
69            // If there are no sitemaps, add to CDX queue
70            if !cache.process_url(&domain_url).await {
71                self.cdx_queue.insert(domain_url);
72            }
73        }
74
75        for prefix in lens.urls.iter() {
76            let url = if prefix.ends_with('$') {
77                // Remove the '$' suffix and add to the crawl queue
78                let url = prefix.trim_end_matches('$');
79                to_crawl.insert(url.to_string());
80                continue;
81            } else {
82                to_crawl.insert(prefix.clone());
83                prefix
84            };
85
86            // If there is no sitemaps in the robots, add to CDX queue
87            if !cache.process_url(url).await {
88                self.cdx_queue.insert(url.to_owned());
89            }
90        }
91
92        // ------------------------------------------------------------------------
93        // Third, either read the sitemaps or pull data from a CDX to determine which
94        // urls to crawl.
95        // ------------------------------------------------------------------------
96        self.process_sitemaps_and_cdx(&cache, &mut to_crawl, &allowed, &skipped)
97            .await;
98
99        // Clear CDX queue after fetching URLs.
100        self.cdx_queue.clear();
101        // Ignore invalid URLs and remove fragments from URLs (e.g. http://example.com#Title
102        // is considered the same as http://example.com)
103        let cleaned: HashSet<String> = to_crawl
104            .iter()
105            .filter_map(|url| {
106                if let Ok(mut url) = Url::parse(&url) {
107                    url.set_fragment(None);
108                    Some(url.to_string())
109                } else {
110                    None
111                }
112            })
113            .collect();
114
115        Ok(cleaned.into_iter().collect())
116    }
117
118    async fn process_sitemaps_and_cdx(
119        &self,
120        cache: &CrawlCache,
121        to_crawl: &mut DashSet<String>,
122        allowed: &RegexSet,
123        skipped: &RegexSet,
124    ) {
125        // Crawl sitemaps & rss feeds
126        let mut handles = JoinSet::new();
127        let mut sitemaps = Vec::new();
128
129        for info in cache.cache.values().flatten() {
130            // Fetch links from RSS feeds
131            to_crawl.extend(fetch_rss(info).await);
132            // Grab list of sitemaps
133            if let Some(robot) = &info.robot {
134                if !robot.sitemaps.is_empty() {
135                    for sitemap in &robot.sitemaps {
136                        sitemaps.push(sitemap.clone());
137                    }
138                }
139            }
140        }
141
142        if !sitemaps.is_empty() {
143            log::info!("spawning {} tasks for sitemap fetching", sitemaps.len());
144            let quota = Quota::per_second(nonzero!(2u32));
145            let lim = Arc::new(RateLimiter::<String, _, _>::keyed(quota));
146
147            for sitemap in sitemaps {
148                let allowed = allowed.clone();
149                let skipped = skipped.clone();
150                let lim = lim.clone();
151                if let Ok(url) = Url::parse(&sitemap) {
152                    handles.spawn(async move {
153                        fetch_sitemap(lim.clone(), &url, &allowed, &skipped).await
154                    });
155                }
156            }
157
158            while let Some(Ok(urls)) = handles.join_next().await {
159                to_crawl.extend(urls);
160            }
161        }
162
163        // Process any URLs in the cdx queue
164        for prefix in self.cdx_queue.iter() {
165            let mut resume_key = None;
166            log::debug!("fetching cdx for: {}", prefix);
167            while let Ok((urls, resume)) =
168                cdx::fetch_cdx(&self.client, prefix, 1000, resume_key.clone()).await
169            {
170                let filtered = urls
171                    .into_iter()
172                    .filter(|url| {
173                        if allowed.is_match(url) && !skipped.is_match(url) {
174                            return true;
175                        }
176
177                        false
178                    })
179                    .collect::<Vec<String>>();
180
181                log::info!("found {} urls", filtered.len());
182                to_crawl.extend(filtered);
183
184                if resume.is_none() {
185                    break;
186                }
187
188                resume_key = resume;
189            }
190        }
191    }
192}
193
194async fn fetch_rss(info: &SiteInfo) -> Vec<String> {
195    let mut feed_urls: Vec<String> = Vec::new();
196
197    for feed in &info.feeds {
198        match feed.feed_type() {
199            FeedType::Atom | FeedType::Rss => {
200                if let Ok(resp) = reqwest::get(feed.url().to_string()).await {
201                    if let Ok(content) = resp.bytes().await {
202                        if let Ok(channel) = Channel::read_from(&content[..]) {
203                            for item in channel.items {
204                                if let Some(link) = item.link {
205                                    feed_urls.push(link);
206                                }
207                            }
208                        }
209                    }
210                }
211            }
212            _ => {}
213        }
214    }
215
216    feed_urls
217}
218
219/// Fetch and parse a sitemap file
220#[async_recursion]
221async fn fetch_sitemap(
222    limiter: Arc<RateLimit>,
223    sitemap_url: &Url,
224    allowed: &RegexSet,
225    skipped: &RegexSet,
226) -> HashSet<String> {
227    let mut urls: HashSet<String> = HashSet::new();
228    let client = http_client();
229
230    let retry_strat = ExponentialBackoff::from_millis(100)
231        .max_delay(Duration::from_secs(5))
232        .take(3);
233
234    let response = RetryIf::spawn(
235        retry_strat,
236        || async {
237            let domain = sitemap_url.domain().expect("No domain in URL");
238            limiter.until_key_ready(&domain.to_string()).await;
239            log::debug!("fetching sitemap: {}", sitemap_url);
240            client.get(sitemap_url.to_string()).send().await
241        },
242        |error: &reqwest::Error| {
243            if error.is_status() {
244                if let Some(status) = error.status() {
245                    let code = status.as_u16();
246                    return code != 404 && code != 403;
247                }
248            }
249
250            true
251        },
252    )
253    .await;
254
255    match response {
256        Ok(resp) => {
257            if resp.status().is_success() {
258                let sitemap_url = sitemap_url.to_string();
259                let mut buf = String::new();
260                // Decode gzipped files. Doesn't work automatically if they were
261                // gzipped before uploading it to their destination.
262                if sitemap_url.ends_with(".gz") {
263                    if let Ok(text) = resp.bytes().await {
264                        let mut decoder = GzDecoder::new(text.reader());
265                        decoder.read_to_string(&mut buf).unwrap();
266                    }
267                } else if let Ok(text) = resp.text().await {
268                    buf = text.replace('\u{feff}', "");
269                }
270
271                let parser = SiteMapReader::new(buf.as_bytes());
272                let mut sitemaps = Vec::new();
273                for entity in parser {
274                    match entity {
275                        SiteMapEntity::Url(url_entry) => {
276                            if let Some(loc) = url_entry.loc.get_url() {
277                                let url = loc.to_string();
278                                if allowed.is_match(&url) && !skipped.is_match(&url) {
279                                    urls.insert(url);
280                                }
281                            }
282                        }
283                        SiteMapEntity::SiteMap(sitemap_entry) => {
284                            if let Some(loc) = sitemap_entry.loc.get_url() {
285                                sitemaps.push(loc.to_string());
286                            }
287                        }
288                        _ => {}
289                    }
290                }
291
292                if !sitemaps.is_empty() {
293                    let mut set = JoinSet::new();
294                    log::info!("spawning {} tasks for sitemap fetching", sitemaps.len());
295                    for sitemap in sitemaps {
296                        let allowed = allowed.clone();
297                        let skipped = skipped.clone();
298                        let limiter = limiter.clone();
299                        if let Ok(url) = Url::parse(&sitemap) {
300                            set.spawn(async move {
301                                fetch_sitemap(limiter.clone(), &url, &allowed, &skipped).await
302                            });
303                        }
304                    }
305
306                    while let Some(Ok(found)) = set.join_next().await {
307                        urls.extend(found);
308                    }
309                }
310            } else {
311                log::debug!("error fetching sitemap: {:?}", resp.error_for_status_ref());
312            }
313        }
314        Err(err) => log::error!("{:?}", err),
315    }
316
317    if !urls.is_empty() {
318        log::info!("found {} urls for {}", urls.len(), sitemap_url);
319    }
320
321    urls
322}
323
324#[cfg(test)]
325mod test {
326    use crate::{bootstrap::fetch_rss, site::SiteInfo};
327
328    #[tokio::test]
329    #[ignore = "only used for dev"]
330    async fn test_fetch_rss() {
331        let info = SiteInfo::new("atp.fm")
332            .await
333            .expect("unable to create siteinfo");
334
335        let feed_urls = fetch_rss(&info).await;
336        assert_eq!(feed_urls.len(), 515);
337    }
338}