Skip to main content

pagesift_engine/crawl/
scope.rs

1//! 爬取范围:种子决定的主机和路径前缀、include/exclude 规则,以及从叶子种子自动放宽范围。
2
3use super::*;
4
5/// 爬取范围的路径前缀:目录部分。
6///
7/// - `/` → 无前缀(整站)
8/// - `/docs/` → `/docs`
9/// - `/docs/index.html` → `/docs`
10/// - `/docs/python` → `/docs/python`(不像文件名的路径按它自己的前缀,和 Firecrawl 一致;
11///   0.71–0.72 曾把它放大到 `/docs`,连 `/docs/java` 也爬进来)
12/// - `/docs` → `/docs`(单段路径保留,避免变成整站)
13fn 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            // 根目录下的单段路径:`/index.html` 这种文件名按整站处理;
25            // `/docs` 这种栏目根保留自身前缀,避免把范围意外放大到整站。
26            (!last.contains('.')).then(|| trimmed.to_string())
27        }
28        None => Some(trimmed.to_string()),
29        // 只有文件名(`index.html`、`page.php`)才换成所在目录
30        Some((parent, last)) if last.contains('.') => Some(parent.to_string()),
31        Some(_) => Some(trimmed.to_string()),
32    }
33}
34
35/// 爬取范围:哪些发现的 URL 可以进入队列。
36pub struct Scope {
37    host: String,
38    allow_subdomains: bool,
39    allow_external: bool,
40    path_prefix: Option<String>,
41    /// `auto` 模式下可以放宽到的祖先目录,由近到远,不含整站根;空表示不放宽。
42    ancestors: Vec<String>,
43    /// 种子页的证据表明它是叶子页之后放宽到其中一个祖先目录,只设一次。
44    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        // 先取最窄的合理范围:种子以文件名结尾时(.../index.html)取所在目录,否则把种子路径本身
67        // 当目录。`auto` 再看种子页的链接决定要不要放宽到某个祖先目录(见 `widen_from_seed`),
68        // 永远不会自动放宽到整站根。
69        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            // 按日期分目录的文章(`/blog/2026/07/21/post/`)的上一级是年月日目录,没有意义:
85            // 跳过日期目录取所在栏目;栏目就是整站根时爬整个主机
86            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        // 同一站点的任何子域名(可注册域名相同):`iss.example.edu` 和 `www.example.edu`,
116        // 不只是起点主机的后代
117        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    /// 还能不能按种子页的证据放宽(`auto` 且还没放宽过)。
135    pub(super) fn can_widen(&self) -> bool {
136        !self.ancestors.is_empty() && self.widened.get().is_none()
137    }
138
139    /// 在当前范围外、但放宽后可能进入范围的 URL(同主机、在最远的祖先目录下,且通过 include/exclude)。
140    /// 已经放宽过或不能放宽时恒为 false。
141    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    /// `auto`:用种子页的链接判断种子是不是叶子页。子页链接很少(< [`WIDEN_MAX_CHILDREN`])时,
159    /// 由近到远找第一个有足够同级链接(≥ [`WIDEN_MIN_SIBLINGS`])的祖先目录放宽过去,返回说明原因的警告。
160    /// 博客文章、新闻报道、文档里的一页都是这种形状;目录页、分类页有自己的子页,不放宽。
161    /// 按日期分目录的博客(`/blog/2026/07/21/post/`)同一天往往没有别的文章,
162    /// 所以不只看上一级:同级文章分散在 `/blog/2026/…` 下时放宽到最近的那一层。
163    /// 种子页自己要有正文(去掉链接后至少 [`WIDEN_MIN_PROSE`] 个字母/数字):只列链接的空分类页、
164    /// 导航页不算叶子内容页。
165    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        // 祖先目录本身和它的首页是“上一级”链接(面包屑),不算同级
202        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    /// 从断点继续时放宽状态不落盘:入过队的 URL 里有窄范围外的,说明上次已经放宽过,
217    /// 恢复到能装下它们的最近的祖先目录。
218    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
236/// 放宽一级的证据门槛:子页链接少于这个数,种子才算叶子页。
237const WIDEN_MAX_CHILDREN: usize = 3;
238/// 放宽一级的证据门槛:同级链接至少这么多。
239const WIDEN_MIN_SIBLINGS: usize = 3;
240
241/// 放宽一级的证据门槛:种子页去掉链接后的正文至少这么多字母/数字(约 40 个英文词或一段中文)。
242const WIDEN_MIN_PROSE: usize = 200;
243
244/// Markdown 里链接以外的字母/数字个数:`[文字](地址)`、`![图](地址)` 整段不算。
245pub(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            // 找配对的 `](...)`;找不到就当普通字符
251            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
267/// `path` 是不是 `prefix` 本身或它下面的路径(按路径段边界,`/docs/python` 不包含 `/docs/pythonic`)。
268fn under(path: &str, prefix: &str) -> bool {
269    path.strip_prefix(prefix)
270        .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
271}
272
273/// 目录的最后一段是不是日期:年份(4 位数字)、月或日(1–2 位数字)、英文月份缩写(`Dec`)。
274fn 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
283/// `/blog/index.html`、`/blog/index.php` 这类目录首页。
284fn 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
290/// 种子路径最后一段是不是页面名(`/blog/post-3`、`/blog/2026/07/21/post/`),而不是文件名
291/// (`/docs/index.html`)。只有页面名种子的范围可能放宽:文件名种子本来就取的是所在目录。
292/// 结尾带 `/` 的种子也可能是页面(很多博客这样写文章地址);是不是目录页交给子页链接判断。
293fn 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}