1use std::collections::HashSet;
7use std::sync::{Arc, OnceLock};
8use std::time::Duration;
9
10use pagesift_api::types::{
11 CrawlRequest, CrawlScope, Document, Format, MapLink, MapRequest, ScrapeOptions,
12};
13use pagesift_api::{ApiError, CrawlSink, ErrorCode};
14use pagesift_net::Url;
15use pagesift_policy::{RequestKind, RobotsMode};
16use regex::Regex;
17
18use crate::engine::{Scraper, policy_error};
19use crate::frontier::{self, Learner};
20
21mod attachments;
22mod runner;
23mod sitemap;
24#[cfg(test)]
25mod tests_sitemap;
26
27#[cfg(test)]
28use sitemap::{gunzip_if_needed, parse_sitemap};
29
30mod retry;
31mod scope;
32#[cfg(test)]
33mod tests;
34
35use retry::*;
36pub use scope::*;
37
38pub(super) const SITEMAP_MAX_DEPTH: usize = 3;
43pub(super) const SITEMAP_MAX_FILES: usize = 50;
45pub(super) const SITEMAP_MAX_BYTES: u64 = 50 * 1024 * 1024;
47pub(super) const SITEMAP_FALLBACKS: &[&str] =
49 &["/sitemap.xml", "/sitemap_index.xml", "/wp-sitemap.xml"];
50
51const MAX_RETRIES: u32 = 2;
55pub(super) const MAX_WAIT: Duration = Duration::from_secs(60);
58const RETRY_BASE: Duration = Duration::from_millis(500);
61const SEED_MAX_RETRIES: u32 = 4;
65const SEED_RETRY_BASE: Duration = Duration::from_secs(2);
67
68pub fn normalize(url: &Url, ignore_query: bool) -> String {
71 let mut u = url.clone();
72 u.set_fragment(None);
73 if ignore_query {
74 u.set_query(None);
75 } else if u.query().is_some() {
76 let mut pairs: Vec<(String, String)> = u
77 .query_pairs()
78 .map(|(k, v)| (k.into_owned(), v.into_owned()))
79 .collect();
80 pairs.sort();
81 u.query_pairs_mut().clear().extend_pairs(pairs);
82 }
83 u.to_string()
84}
85
86fn dedup_key(url: &Url, ignore_query: bool) -> String {
88 normalize(url, ignore_query).replacen("://www.", "://", 1)
89}
90
91pub(super) fn seen_hash(url: &Url, ignore_query: bool) -> u64 {
94 use std::hash::{Hash, Hasher};
95 let mut h = std::hash::DefaultHasher::new();
97 dedup_key(url, ignore_query).hash(&mut h);
98 h.finish()
99}
100
101pub(super) fn push_warning(doc: &mut Document, message: String) {
102 doc.metadata
103 .pagesift
104 .get_or_insert_with(Default::default)
105 .warnings
106 .push(message);
107}
108
109pub(super) fn candidate(base: &Url, link: &str, learner: &Learner) -> Option<Url> {
112 let mut u = base.join(link).ok()?;
113 if u.scheme() == "http"
116 && base.scheme() == "https"
117 && u.host_str().zip(base.host_str()).is_some_and(|(a, b)| {
118 a == b || pagesift_content::site_key(a) == pagesift_content::site_key(b)
119 })
120 {
121 let _ = u.set_scheme("https");
122 }
123 frontier::clean(&mut u);
124 learner.rewrite(&mut u);
125 (!frontier::skip(&u)).then_some(u)
126}
127
128impl Scraper {
129 pub async fn map(&self, req: &MapRequest) -> Result<Vec<MapLink>, ApiError> {
130 let work = self.map_inner(req);
131 match req.timeout {
132 Some(ms) => tokio::time::timeout(Duration::from_millis(ms), work)
133 .await
134 .map_err(|_| ApiError::new(ErrorCode::Timeout, "map timed out"))?,
135 None => work.await,
136 }
137 }
138
139 async fn map_inner(&self, req: &MapRequest) -> Result<Vec<MapLink>, ApiError> {
140 let start = Url::parse(&req.url).map_err(|e| ApiError::invalid(e.to_string()))?;
141 let mode = self
142 .policy()
143 .robots_mode(RequestKind::Map, req.ignore_robots_txt)
144 .map_err(policy_error)?;
145 self.audit_robots_ignored(RequestKind::Map, mode, &req.url);
146 let scope = Scope::new(
147 &start,
148 req.include_subdomains,
149 false,
150 CrawlScope::Domain,
151 &[],
152 &[],
153 )?;
154 let mut found: Vec<Url> = Vec::new();
155 if req.sitemap != "skip" {
156 found.extend(
157 self.sitemap_urls(&start, mode, req.limit.saturating_mul(2))
158 .await,
159 );
160 }
161 let mut root_error = None;
162 if req.sitemap != "only" {
163 let opts = ScrapeOptions {
165 formats: vec![Format::new("links")],
166 engine: Some("http".into()),
167 ..Default::default()
168 };
169 match self.scrape_with(start.as_str(), &opts, mode).await {
170 Ok(doc) => found.extend(
171 doc.links
172 .unwrap_or_default()
173 .iter()
174 .filter_map(|l| Url::parse(l).ok()),
175 ),
176 Err(error) => root_error = Some(error),
177 }
178 }
179 if found.is_empty()
181 && let Some(error) = root_error
182 {
183 return Err(error);
184 }
185 let mut seen = HashSet::new();
186 let mut links: Vec<String> = Vec::new();
187 for mut u in std::iter::once(start.clone()).chain(found) {
188 frontier::clean(&mut u);
189 if scope.allows(&u) && seen.insert(dedup_key(&u, req.ignore_query_parameters)) {
190 links.push(normalize(&u, req.ignore_query_parameters));
191 }
192 }
193 if let Some(search) = req.search.as_deref().filter(|s| !s.trim().is_empty()) {
194 let terms: Vec<String> = search.split_whitespace().map(str::to_lowercase).collect();
196 let hits = |u: &str| {
197 let l = u.to_lowercase();
198 terms.iter().filter(|t| l.contains(t.as_str())).count()
199 };
200 links.sort_by_key(|u| std::cmp::Reverse(hits(u)));
201 }
202 links.truncate(req.limit);
203 Ok(links
204 .into_iter()
205 .map(|url| MapLink {
206 url,
207 title: None,
208 description: None,
209 })
210 .collect())
211 }
212
213 pub async fn crawl(
216 &self,
217 req: &CrawlRequest,
218 sink: Arc<dyn CrawlSink>,
219 ) -> Result<(), ApiError> {
220 if req.adaptive.is_some() && req.query.is_none() {
221 return Err(ApiError::invalid("adaptive crawling needs a query"));
222 }
223 if let Some(a) = &req.adaptive
224 && !(a.coverage_threshold > 0.0
225 && a.coverage_threshold <= 1.0
226 && (0.0..=1.0).contains(&a.min_gain))
227 {
228 return Err(ApiError::invalid(
229 "adaptive.coverageThreshold must be in (0, 1] and adaptive.minGain in [0, 1]",
230 ));
231 }
232 let start = Url::parse(&req.url).map_err(|e| ApiError::invalid(e.to_string()))?;
233 let mode = self
234 .policy()
235 .robots_mode(RequestKind::Crawl, req.ignore_robots_txt)
236 .map_err(policy_error)?;
237 self.audit_robots_ignored(RequestKind::Crawl, mode, &req.url);
238 if req.attachments && req.query.is_none() {
239 return Err(ApiError::invalid("attachments need a query"));
240 }
241 let scope = Scope::new(
242 &start,
243 allow_subdomains(req),
244 req.allow_external_links,
245 if req.crawl_entire_domain {
246 CrawlScope::Domain
247 } else {
248 req.scope.unwrap_or(if req.query.is_some() {
249 CrawlScope::Domain
250 } else {
251 CrawlScope::Auto
252 })
253 },
254 &req.include_paths,
255 &req.exclude_paths,
256 )?;
257 let crawl = Crawl {
258 req,
259 sink: sink.as_ref(),
260 scope: &scope,
261 mode,
262 };
263 match req.query.as_deref() {
264 Some(q) => self.crawl_focused(crawl, start, q).await,
265 None => self.crawl_bfs(crawl, start).await,
266 }
267 }
268
269 pub(super) async fn initial_sitemap(&self, c: &Crawl<'_>, start: &Url) -> Vec<Url> {
271 if c.req.sitemap == "skip" {
272 return Vec::new();
273 }
274 self.sitemap_urls(start, c.mode, c.req.limit.saturating_mul(2))
275 .await
276 }
277
278 pub(super) fn crawl_settings(
282 &self,
283 req: &CrawlRequest,
284 extra: &[&str],
285 ) -> (Arc<ScrapeOptions>, usize) {
286 let mut opts = req.scrape_options.clone();
287 for kind in std::iter::once("links").chain(extra.iter().copied()) {
288 if !opts.wants(kind) {
289 opts.formats.push(Format::new(kind));
290 }
291 }
292 let rate = self.policy().limiter().config();
294 let default = if allow_subdomains(req) || req.allow_external_links {
295 rate.global_concurrency
296 } else {
297 rate.per_host_concurrency
298 };
299 let concurrency = req.max_concurrency.unwrap_or(default).max(1);
300 (Arc::new(opts), concurrency)
301 }
302}
303
304pub fn allow_subdomains(req: &CrawlRequest) -> bool {
306 req.allow_subdomains.unwrap_or(req.query.is_some())
307}
308
309pub(super) struct Crawl<'a> {
311 pub(super) req: &'a CrawlRequest,
312 pub(super) sink: &'a dyn CrawlSink,
313 pub(super) scope: &'a Scope,
314 pub(super) mode: RobotsMode,
315}