pagesift_engine/crawl/
scope.rs1use super::*;
4
5fn scope_path(start: &Url) -> Option<String> {
14 let path = start.path();
15 let trimmed = path.trim_end_matches('/');
16 if trimmed.is_empty() {
17 return None;
18 }
19 if path.ends_with('/') {
20 return Some(trimmed.to_string());
21 }
22 match trimmed.rsplit_once('/') {
23 Some(("", last)) => {
24 (!last.contains('.')).then(|| trimmed.to_string())
27 }
28 None => Some(trimmed.to_string()),
29 Some((parent, last)) if last.contains('.') => Some(parent.to_string()),
31 Some(_) => Some(trimmed.to_string()),
32 }
33}
34
35pub struct Scope {
37 host: String,
38 allow_subdomains: bool,
39 allow_external: bool,
40 path_prefix: Option<String>,
41 ancestors: Vec<String>,
43 widened: OnceLock<String>,
45 include: Vec<Regex>,
46 exclude: Vec<Regex>,
47}
48
49impl Scope {
50 pub(super) fn new(
51 start: &Url,
52 allow_subdomains: bool,
53 allow_external: bool,
54 mode: CrawlScope,
55 include: &[String],
56 exclude: &[String],
57 ) -> Result<Self, ApiError> {
58 let compile = |ps: &[String]| -> Result<Vec<Regex>, ApiError> {
59 ps.iter()
60 .map(|p| {
61 Regex::new(p)
62 .map_err(|e| ApiError::invalid(format!("invalid path pattern {p:?}: {e}")))
63 })
64 .collect()
65 };
66 let narrow = (mode != CrawlScope::Domain)
70 .then(|| scope_path(start))
71 .flatten()
72 .filter(|p| !p.is_empty());
73 let mut ancestors = Vec::new();
74 if let Some(mut dir) = narrow.as_deref().filter(|_| seed_names_page(start)) {
75 while let Some((up, _)) = dir.rsplit_once('/').filter(|(up, _)| !up.is_empty()) {
76 ancestors.push(up.to_string());
77 dir = up;
78 }
79 }
80 let (path_prefix, ancestors) = match mode {
81 CrawlScope::Domain => (None, Vec::new()),
82 CrawlScope::Exact => (narrow, Vec::new()),
83 CrawlScope::Auto => (narrow, ancestors),
84 CrawlScope::Parent if !ancestors.is_empty() => (
87 ancestors.into_iter().find(|dir| !is_date_dir(dir)),
88 Vec::new(),
89 ),
90 CrawlScope::Parent => (narrow, Vec::new()),
91 };
92 Ok(Self {
93 host: start
94 .host_str()
95 .unwrap_or_default()
96 .trim_start_matches("www.")
97 .to_ascii_lowercase(),
98 allow_subdomains,
99 allow_external,
100 path_prefix,
101 ancestors,
102 widened: OnceLock::new(),
103 include: compile(include)?,
104 exclude: compile(exclude)?,
105 })
106 }
107
108 pub fn allows(&self, u: &Url) -> bool {
109 if !matches!(u.scheme(), "http" | "https") {
110 return false;
111 }
112 let host = u.host_str().unwrap_or_default().to_ascii_lowercase();
113 let bare = host.trim_start_matches("www.");
114 let same = bare == self.host;
115 let sub = pagesift_content::site_key(bare) == pagesift_content::site_key(&self.host);
118 if !(same || (self.allow_subdomains && sub) || self.allow_external) {
119 return false;
120 }
121 let path = u.path();
122 if same
123 && let Some(prefix) = self.widened.get().or(self.path_prefix.as_ref())
124 && !under(path, prefix)
125 {
126 return false;
127 }
128 if !self.include.is_empty() && !self.include.iter().any(|r| r.is_match(path)) {
129 return false;
130 }
131 !self.exclude.iter().any(|r| r.is_match(path))
132 }
133
134 pub(super) fn can_widen(&self) -> bool {
136 !self.ancestors.is_empty() && self.widened.get().is_none()
137 }
138
139 pub(super) fn may_widen_to(&self, u: &Url) -> bool {
142 let (Some(narrow), Some(top)) = (&self.path_prefix, self.ancestors.last()) else {
143 return false;
144 };
145 if self.widened.get().is_some() || self.allows(u) {
146 return false;
147 }
148 let host = u.host_str().unwrap_or_default();
149 let path = u.path();
150 host.trim_start_matches("www.")
151 .eq_ignore_ascii_case(&self.host)
152 && under(path, top)
153 && !under(path, narrow)
154 && (self.include.is_empty() || self.include.iter().any(|r| r.is_match(path)))
155 && !self.exclude.iter().any(|r| r.is_match(path))
156 }
157
158 pub(super) fn widen_from_seed<'a>(
166 &self,
167 seed: &Url,
168 markdown: &str,
169 links: impl IntoIterator<Item = &'a Url>,
170 ) -> Option<String> {
171 let Some(narrow) = &self.path_prefix else {
172 return None;
173 };
174 if self.ancestors.is_empty() || prose_chars(markdown) < WIDEN_MIN_PROSE {
175 return None;
176 }
177 let seed_path = seed.path().trim_end_matches('/');
178 let mut children: HashSet<&str> = HashSet::new();
179 let mut outside: HashSet<&str> = HashSet::new();
180 for u in links {
181 let host = u.host_str().unwrap_or_default();
182 if !host
183 .trim_start_matches("www.")
184 .eq_ignore_ascii_case(&self.host)
185 {
186 continue;
187 }
188 let path = u.path().trim_end_matches('/');
189 if path == seed_path {
190 continue;
191 }
192 if under(path, narrow) {
193 children.insert(path);
194 } else {
195 outside.insert(path);
196 }
197 }
198 if children.len() >= WIDEN_MAX_CHILDREN {
199 return None;
200 }
201 let (dir, siblings) = self.ancestors.iter().find_map(|dir| {
203 let n = outside
204 .iter()
205 .filter(|p| under(p, dir) && **p != dir.as_str() && !is_index_of(p, dir))
206 .count();
207 (n >= WIDEN_MIN_SIBLINGS).then_some((dir, n))
208 })?;
209 self.widened.set(dir.clone()).ok()?;
210 Some(format!(
211 "crawl_scope: widened path scope from {narrow}/ to {dir}/ (start page links {} page(s) below it and {siblings} sibling page(s)); use --scope exact to keep it narrow",
212 children.len(),
213 ))
214 }
215
216 pub(super) fn restore_widened<'a>(&self, enqueued: impl IntoIterator<Item = &'a Url>) {
219 let outside: Vec<&Url> = enqueued
220 .into_iter()
221 .filter(|u| self.may_widen_to(u))
222 .collect();
223 if outside.is_empty() {
224 return;
225 }
226 if let Some(dir) = self
227 .ancestors
228 .iter()
229 .find(|dir| outside.iter().all(|u| under(u.path(), dir)))
230 {
231 let _ = self.widened.set(dir.clone());
232 }
233 }
234}
235
236const WIDEN_MAX_CHILDREN: usize = 3;
238const WIDEN_MIN_SIBLINGS: usize = 3;
240
241const WIDEN_MIN_PROSE: usize = 200;
243
244pub(super) fn prose_chars(md: &str) -> usize {
246 let b = md.as_bytes();
247 let (mut i, mut n) = (0, 0);
248 while i < md.len() {
249 if b[i] == b'[' || (b[i] == b'!' && b.get(i + 1) == Some(&b'[')) {
250 let open = if b[i] == b'!' { i + 1 } else { i };
252 if let Some(close) = md[open..].find("](").map(|k| open + k)
253 && let Some(end) = md[close + 2..].find(')').map(|k| close + 2 + k)
254 && !md[open..close].contains('\n')
255 {
256 i = end + 1;
257 continue;
258 }
259 }
260 let c = md[i..].chars().next().unwrap_or(' ');
261 n += usize::from(c.is_alphanumeric());
262 i += c.len_utf8();
263 }
264 n
265}
266
267fn under(path: &str, prefix: &str) -> bool {
269 path.strip_prefix(prefix)
270 .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
271}
272
273fn is_date_dir(dir: &str) -> bool {
275 const MONTHS: [&str; 12] = [
276 "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
277 ];
278 let last = dir.rsplit('/').next().unwrap_or_default();
279 (last.bytes().all(|b| b.is_ascii_digit()) && matches!(last.len(), 1 | 2 | 4))
280 || MONTHS.contains(&last.to_ascii_lowercase().as_str())
281}
282
283fn is_index_of(path: &str, dir: &str) -> bool {
285 path.strip_prefix(dir)
286 .and_then(|rest| rest.strip_prefix('/'))
287 .is_some_and(|name| name.starts_with("index.") || name.starts_with("default."))
288}
289
290fn seed_names_page(start: &Url) -> bool {
294 start
295 .path()
296 .trim_end_matches('/')
297 .rsplit('/')
298 .next()
299 .is_some_and(|last| !last.is_empty() && !last.contains('.'))
300}