Skip to main content

pagesift_engine/crawl/
mod.rs

1//! 整站爬取和 map(PRD 6.6):URL 规范化、爬取范围、sitemap 解析、广度优先遍历,
2//! 以及带查询时的相关性优先遍历(见 [`crate::focus`])。
3//!
4//! 限速、robots 和冷却全部走策略层,所以整站爬取和单页抓取一样守规矩。
5
6use 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
38/// sitemapindex 最多展开几层、最多读多少个 sitemap 文件、单个文件解压后最大多少。
39/// 读多少由预算决定(VOI 停止):收集到的 URL 够爬取上限就不再读(多出来的 URL 轮不到抓,
40/// 信息价值为 0,见 `sitemap_urls`)。下面三个是安全上限:
41/// [策略] 协议只允许 sitemapindex 一层(sitemaps.org 0.9),宽容到三层防循环引用
42pub(super) const SITEMAP_MAX_DEPTH: usize = 3;
43/// [策略] 文件数上限:防一个站点用海量子 sitemap 拖住爬取的开头
44pub(super) const SITEMAP_MAX_FILES: usize = 50;
45/// [协议] sitemaps.org 0.9:单个 sitemap 解压后不超过 50 MB(52,428,800 字节),按它防解压炸弹
46pub(super) const SITEMAP_MAX_BYTES: u64 = 50 * 1024 * 1024;
47/// robots.txt 没有声明 sitemap 时依次尝试的位置(常见 CMS 的默认路径)。
48pub(super) const SITEMAP_FALLBACKS: &[&str] =
49    &["/sitemap.xml", "/sitemap_index.xml", "/wp-sitemap.xml"];
50
51/// 临时性失败(超时、网络错误、5xx、429、主机冷却)最多重试几次。
52/// [策略] 安全上限,同 Scrapy 的 RETRY_TIMES 2;上限之内要不要重试由失败类别的重试成功率决定
53/// ([`Retries`])
54const MAX_RETRIES: u32 = 2;
55/// 主机冷却或 Retry-After 超过这个时长就不等了,这一页按失败报告。
56/// [策略] 一页最多为一次重试等 1 分钟(用户策略:爬取不为单页长时间挂起)
57pub(super) const MAX_WAIT: Duration = Duration::from_secs(60);
58/// 没有 Retry-After 时,第 n 次重试前等 RETRY_BASE × 2^(n-1)。
59/// [策略] 先验包 v0:0.81 的现值(为什么不换成恢复时间分布见 [`Retries`])
60const RETRY_BASE: Duration = Duration::from_millis(500);
61/// 种子页的重试次数和等待基数:种子页失败整个爬取就什么都没有了(站点一时超时,0.5 秒、1 秒后
62/// 两次重试常常还在同一个波动里),多等几次、等久一点(2、4、8、16 秒)。
63/// [策略] 先验包 v0:0.81 的现值;种子页的次数上限同样受重试成功率约束([`Retries`])
64const SEED_MAX_RETRIES: u32 = 4;
65/// [策略] 先验包 v0:0.81 的现值
66const SEED_RETRY_BASE: Duration = Duration::from_secs(2);
67
68/// URL 规范化(F-6.2),用来去重:去掉 fragment;查询参数排序,或者整个去掉。
69/// 协议和主机的小写、默认端口、`..` 路径由 URL 解析本身处理。
70pub 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
86/// 去重用的键:在 [`normalize`] 的基础上把 `www.` 视为同一主机,避免同一页面因写法不同被抓两次。
87fn dedup_key(url: &Url, ignore_query: bool) -> String {
88    normalize(url, ignore_query).replacen("://www.", "://", 1)
89}
90
91/// 爬取去重表里存的是去重键的 64 位哈希,不是 URL 本身:每条 8 字节,原来是一两百字节。
92/// 一百万个 URL 里出现一次碰撞(误判为已见过而漏抓一页)的概率约 3×10⁻⁸。
93pub(super) fn seen_hash(url: &Url, ignore_query: bool) -> u64 {
94    use std::hash::{Hash, Hasher};
95    // DefaultHasher::new() 的密钥固定,同一个键总是得到同一个哈希
96    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
109/// 页面上的链接(或 sitemap 里的 URL)解析、清洗之后,值得抓的才返回。
110/// 先按固定规则清洗,再按这次爬取学到的改写规则改成规范写法。
111pub(super) fn candidate(base: &Url, link: &str, learner: &Learner) -> Option<Url> {
112    let mut u = base.join(link).ok()?;
113    // 页面是 https 拿到的,它链回同一站点的 http 地址(老模板):直接用 https,省一次跳转,
114    // 而且跟不了跳转的环境(只放行 https 的代理)也抓得到
115    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            // map 只要链接,不需要渲染
164            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        // 种子页失败且 sitemap 也没给出任何 URL 时,不要返回“只有种子”的假成功。
180        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            // 按命中的搜索词个数排序,命中多的在前;没命中的保留在后面
195            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    /// 整站爬取。深度按发现顺序计算:起始页和 sitemap 里的页面是 0。
214    /// 带 `query` 时按相关性优先调度(见 [`crate::focus`]),否则广度优先。
215    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    /// 新任务开始时 sitemap 里的页面(`sitemap: skip` 时为空)。从断点继续的任务不再读 sitemap。
270    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    /// 需要链接来继续发现页面;用户没要 links 格式时,内部照样取,输出前去掉。
279    /// 只爬一个主机时,同时进行的请求数超过每主机并发上限也只是排队,所以并发默认就取这个上限;
280    /// 允许子域名或外链时取全局上限。调用方显式给的 maxConcurrency 照用。
281    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        if opts.image_assets.is_some() && opts.asset_fetch_cache.is_none() {
288            opts.asset_fetch_cache = Some(Default::default());
289        }
290        for kind in std::iter::once("links").chain(extra.iter().copied()) {
291            if !opts.wants(kind) {
292                opts.formats.push(Format::new(kind));
293            }
294        }
295        // 允许子域名或外链时会同时访问多个主机:默认并发放到全局上限,每主机仍受各自的并发和间隔约束
296        let rate = self.policy().limiter().config();
297        let default = if allow_subdomains(req) || req.allow_external_links {
298            rate.global_concurrency
299        } else {
300            rate.per_host_concurrency
301        };
302        let concurrency = req.max_concurrency.unwrap_or(default).max(1);
303        (Arc::new(opts), concurrency)
304    }
305}
306
307/// 要不要进同站点的其他子域名:没明说时,带查询的爬取要(国际生服务、招生常在子域上),广度优先不要。
308pub fn allow_subdomains(req: &CrawlRequest) -> bool {
309    req.allow_subdomains.unwrap_or(req.query.is_some())
310}
311
312/// 一次爬取在各阶段之间共享的参数。
313pub(super) struct Crawl<'a> {
314    pub(super) req: &'a CrawlRequest,
315    pub(super) sink: &'a dyn CrawlSink,
316    pub(super) scope: &'a Scope,
317    pub(super) mode: RobotsMode,
318}