libnetrunner/
bootstrap.rs1use 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 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 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 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 !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 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 !cache.process_url(url).await {
88 self.cdx_queue.insert(url.to_owned());
89 }
90 }
91
92 self.process_sitemaps_and_cdx(&cache, &mut to_crawl, &allowed, &skipped)
97 .await;
98
99 self.cdx_queue.clear();
101 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 let mut handles = JoinSet::new();
127 let mut sitemaps = Vec::new();
128
129 for info in cache.cache.values().flatten() {
130 to_crawl.extend(fetch_rss(info).await);
132 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 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#[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 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}