Skip to main content

pagesift_engine/engine/
scraper.rs

1use super::*;
2
3impl Scraper {
4    pub fn new(config: ScraperConfig) -> Result<Self, ScraperError> {
5        cpu::install();
6        let browser_private_network = config.fetcher.ssrf.allow_private_network;
7        let browser_allow = browser_allow_list(&config.fetcher.ssrf)?;
8        let mut egress = Vec::new();
9        for (name, proxy) in &config.egress {
10            let mut fc = config.fetcher.clone();
11            // 代理地址为空:`direct` 出口,与默认出口的网络设置相同
12            if !proxy.is_empty() {
13                fc.proxy = Some(proxy.clone());
14            }
15            let class = config.egress_classes.get(name).copied().unwrap_or_default();
16            egress.push((
17                name.clone(),
18                class,
19                Arc::new(Fetcher::new_egress(fc).map_err(ScraperError::Fetcher)?),
20            ));
21        }
22        let fetcher = Arc::new(Fetcher::new(config.fetcher).map_err(ScraperError::Fetcher)?);
23        let llm = config
24            .llm
25            .map(pagesift_llm::ChatClient::new)
26            .transpose()
27            .map_err(ScraperError::Llm)?;
28        let rate = &config.policy.rate;
29        let initial_conc = rate
30            .global_ceiling
31            .map_or(rate.global_concurrency, |c| rate.global_concurrency.min(c))
32            .max(1);
33        let crawl_slots = pagesift_policy::AdaptiveConcurrencyGate::new(initial_conc);
34        Ok(Self {
35            crawl_slots,
36            policy: Arc::new({
37                // 网络层"继续等待"的价格由全局准入按负载发布(空闲时为 0,超时等于配置值)
38                let wait_price = fetcher.config().wait_price.clone();
39                let mut p = Policy::new(fetcher, config.policy);
40                for (name, class, f) in egress {
41                    p.add_egress_class(name, class, f);
42                }
43                p.limiter().publish_wait_price(wait_price);
44                p
45            }),
46            store: config.store,
47            audit: config.audit,
48            llm,
49            search: config.search,
50            browsers: {
51                // allow_ips 以 /32、/128 的形式一起交给 Lightpanda;allow_hosts 它不支持(cdp 侧会告警)
52                let chain = pagesift_cdp::engine_chain_with_allow_list(
53                    &config.browser,
54                    browser_private_network,
55                    &browser_allow,
56                );
57                log_engine_chain(&chain);
58                chain.into_iter().map(|c| (c, OnceCell::new())).collect()
59            },
60            launch_failed: Vec::new(),
61            launch_notes: Vec::new(),
62            launch_broken: Default::default(),
63            browser_private_network,
64            browser_allow,
65            learner: crate::learn::HostLearner::default(),
66            planner: super::planner::RenderPlanner::default(),
67            render_stats: super::render_stats::RenderStats::new(),
68            pages: std::sync::OnceLock::new(),
69        })
70        .map(|mut s| {
71            s.launch_failed = (0..s.browsers.len()).map(|_| Default::default()).collect();
72            s.launch_notes = (0..s.browsers.len()).map(|_| Default::default()).collect();
73            // 上次运行学到的渲染决策计数(没有存储或格式版本不同时从冷启动开始)
74            if let Some(store) = &s.store {
75                super::planner::load(&s.planner, store);
76            }
77            s
78        })
79    }
80
81    /// 第 `i` 个内核的池;不存在或创建失败时返回 `None`。
82    pub(super) async fn browser(&self, i: usize) -> Option<Arc<BrowserPool>> {
83        let (cfg, cell) = self.browsers.get(i)?;
84        cell.get_or_init(|| async {
85            let guard: Arc<dyn RequestGuard> = if self.browser_private_network {
86                Arc::new(SchemeGuard)
87            } else if self.browser_allow.is_empty() {
88                Arc::new(PrivateNetworkGuard)
89            } else {
90                Arc::new(AllowListGuard::new(self.browser_allow.clone()))
91            };
92            match BrowserPool::new(cfg.clone(), guard).await {
93                Ok(pool) => Some(Arc::new(pool)),
94                Err(e) => {
95                    let engine = cfg.engine.map_or("browser", |k| k.name());
96                    *self.launch_notes[i].lock().unwrap() = Some(format!("{engine}: {e}"));
97                    if !matches!(e, pagesift_cdp::Error::NoBrowserFound) {
98                        self.launch_broken
99                            .store(true, std::sync::atomic::Ordering::Relaxed);
100                    }
101                    tracing::warn!("{engine} rendering unavailable: {e}");
102                    None
103                }
104            }
105        })
106        .await
107        .clone()
108    }
109
110    pub(super) async fn has_browser(&self) -> bool {
111        for i in 0..self.browsers.len() {
112            if self.browser(i).await.is_some() {
113                return true;
114            }
115        }
116        false
117    }
118
119    /// 有内核找到了浏览器、但创建失败(不是"一个都没找到")。
120    pub(super) fn browser_launch_failed(&self) -> bool {
121        self.launch_broken
122            .load(std::sync::atomic::Ordering::Relaxed)
123    }
124
125    /// 浏览器不可用时给调用方的说明:逐个内核列出启动失败原因;都还在尝试时退回“没找到”。
126    pub(super) fn browser_unavailable_reason(&self) -> String {
127        let notes: Vec<String> = self
128            .launch_notes
129            .iter()
130            .filter_map(|n| n.lock().unwrap().clone())
131            .collect();
132        if notes.is_empty() {
133            return "no browser engine found (run `pagesift doctor`)".into();
134        }
135        let root = notes.iter().any(|n| n.contains("as root"));
136        let mut reason = format!(
137            "no browser engine could start ({}); run `pagesift doctor` for details",
138            notes.join("; ")
139        );
140        if root && !notes.iter().any(|n| n.contains("unprivileged user")) {
141            reason.push_str(
142                " — pagesift is running as root: the Chrome sandbox does not work as root and pagesift will not disable it; run as an unprivileged user",
143            );
144        }
145        reason
146    }
147
148    /// 把内核链里的每个内核真的启动一次,逐个给出结果(`doctor` 用):文件在不等于能启动,
149    /// 启动即崩的兜底内核要说出来。
150    pub async fn probe_engines(&self) -> Vec<EngineProbe> {
151        let mut out = Vec::new();
152        for i in 0..self.browsers.len() {
153            let cfg = &self.browsers[i].0;
154            let engine = cfg.engine.map_or("chrome", |k| k.name());
155            let location = match &cfg.source {
156                pagesift_cdp::BrowserSource::Executable(p) => p.display().to_string(),
157                pagesift_cdp::BrowserSource::Remote(u) => u.to_string(),
158                pagesift_cdp::BrowserSource::Auto => "auto".into(),
159            };
160            let result = match self.browser(i).await {
161                Some(pool) => pool.probe().await.map_err(|e| e.to_string()),
162                None => Err(self.launch_notes[i]
163                    .lock()
164                    .unwrap()
165                    .clone()
166                    .unwrap_or_else(|| "could not start".into())),
167            };
168            out.push(EngineProbe {
169                engine,
170                location,
171                result,
172            });
173        }
174        out
175    }
176
177    /// 服务启动时在后台把每个内核启动一次,确认哪些能用(日志里写明),能用的浏览器留着备用。
178    pub async fn probe_browsers(&self) {
179        for i in 0..self.browsers.len() {
180            let Some(pool) = self.browser(i).await else {
181                continue;
182            };
183            let engine = pool.engine().name();
184            match pool.probe().await {
185                Ok(()) => tracing::info!(engine, "browser engine ready"),
186                Err(e) => self.note_launch_failure(i, engine, &e),
187            }
188        }
189    }
190
191    /// 内核启动失败:记下来,接下来一段时间跳过它;只在第一次失败时警告,并说明常见原因。
192    pub(super) fn note_launch_failure(&self, i: usize, engine: &str, e: &pagesift_cdp::Error) {
193        let first = self.launch_failed[i]
194            .lock()
195            .unwrap()
196            .replace(Instant::now())
197            .is_none();
198        if first {
199            let msg = e.to_string();
200            *self.launch_notes[i].lock().unwrap() = Some(format!("{engine}: {msg}"));
201            let hint = if msg.contains("sandbox") {
202                ": the browser sandbox needs unprivileged user namespaces, which this host or container \
203                 does not allow (check kernel.apparmor_restrict_unprivileged_userns and the container's \
204                 seccomp/AppArmor profile); pagesift does not disable the sandbox"
205            } else {
206                ""
207            };
208            tracing::warn!(
209                engine,
210                retry_after_secs = LAUNCH_RETRY.as_secs(),
211                "{engine} fallback unavailable: {msg}{hint}"
212            );
213        }
214    }
215
216    pub async fn shutdown(&self) {
217        if let Some(store) = &self.store
218            && let Err(e) = super::planner::save(&self.planner, store)
219        {
220            tracing::warn!("saving the render planner failed: {e}");
221        }
222        for (_, cell) in &self.browsers {
223            if let Some(Some(pool)) = cell.get() {
224                pool.shutdown().await;
225            }
226        }
227    }
228
229    pub async fn scrape(&self, url: &str, opts: &ScrapeOptions) -> Result<Document, ApiError> {
230        let mode = self
231            .policy
232            .robots_mode(RequestKind::Scrape, opts.ignore_robots_txt)
233            .map_err(policy_error)?;
234        self.scrape_with(url, opts, mode).await
235    }
236
237    /// 整站爬取或 map 实际忽略了 robots.txt 时写一条审计记录(没配置审计日志时什么都不做)。
238    pub fn audit_robots_ignored(&self, kind: RequestKind, mode: RobotsMode, url: &str) {
239        if mode == RobotsMode::Ignore
240            && let Some(audit) = &self.audit
241        {
242            let request = match kind {
243                RequestKind::Scrape => "scrape",
244                RequestKind::Crawl => "crawl",
245                RequestKind::Map => "map",
246            };
247            audit.record_robots_ignored(request, url, self.policy.robots_ignore_reason(kind));
248        }
249    }
250
251    /// 审计日志(没配置时为 `None`)。
252    pub fn audit(&self) -> Option<&Arc<crate::audit::Audit>> {
253        self.audit.as_ref()
254    }
255
256    pub fn policy(&self) -> &Policy {
257        &self.policy
258    }
259
260    /// 网关模式:此后爬取里的页面交给 `runner` 执行(只能设一次,之后的调用返回 `false`)。
261    pub fn route_pages(&self, runner: Arc<dyn crate::PageRunner>) -> bool {
262        self.pages.set(runner).is_ok()
263    }
264
265    pub fn page_runner(&self) -> Option<&Arc<dyn crate::PageRunner>> {
266        self.pages.get()
267    }
268
269    /// 渲染决策的计数表(多服务器工人上报统计、导入网关下发的先验用)。
270    pub fn planner(&self) -> &super::planner::RenderPlanner {
271        &self.planner
272    }
273
274    pub fn learner(&self) -> &crate::learn::HostLearner {
275        &self.learner
276    }
277
278    /// 内核链的长度(换内核决策的族按内核序号区分)。
279    pub fn engine_count(&self) -> usize {
280        self.browsers.len()
281    }
282
283    pub fn network_stage_snapshots(&self) -> Vec<pagesift_net::StageSnapshot> {
284        self.policy.fetcher().stage_snapshots()
285    }
286
287    pub fn transport_phase_snapshots(&self) -> Vec<pagesift_net::PhaseSnapshot> {
288        self.policy.fetcher().phase_snapshots()
289    }
290
291    pub fn crawl_slots(&self) -> &pagesift_policy::AdaptiveConcurrencyGate {
292        self.sync_crawl_slots();
293        &self.crawl_slots
294    }
295
296    /// 当前的全局并发目标(限速器的 USL 控制器给出,冷启动等于配置的全局并发)。
297    ///
298    /// 多服务器网关上页面由工人执行:取在线工人报告的准入容量之和(至少 1),
299    /// 爬取的在途上限和名额闸门都跟着它走。单主机没有装执行方,路径不变。
300    pub fn concurrency(&self) -> usize {
301        if let Some(n) = self.pages.get().and_then(|r| r.capacity()) {
302            return n.max(1);
303        }
304        self.policy.limiter().global_limit()
305    }
306
307    /// 爬取里一页结束。并发目标由限速器在名额归还时按观测窗更新(事件驱动),
308    /// 这里只把爬取闸门的上限同步过去;取代 0.81 每秒采样一次的运行时调优器。
309    pub fn observe_crawl(&self, _queue_wait: Duration, _failed: bool) {
310        self.sync_crawl_slots();
311    }
312
313    fn sync_crawl_slots(&self) {
314        let target = self.concurrency();
315        if self.crawl_slots.limit() != target {
316            self.crawl_slots.set_limit(target);
317        }
318    }
319
320    /// 按给定的 robots 模式抓一页;整站爬取和 map 用它,robots 模式由调用方按请求类型决定。
321    /// 先查缓存(语义同 Firecrawl 的 maxAge、minAge、storeInCache),没命中再抓取并写入。
322    /// `queryFilter`、LLM 格式和 `changeTracking` 都在缓存之外、基于正文 Markdown 做,
323    /// 缓存里存的总是完整正文。
324    pub async fn scrape_with(
325        &self,
326        url: &str,
327        opts: &ScrapeOptions,
328        mode: RobotsMode,
329    ) -> Result<Document, ApiError> {
330        self.scrape_audited("scrape", url, opts, mode).await
331    }
332
333    /// 抓一页并写审计记录。`kind` 区分单页抓取和爬取里的页面。
334    pub async fn scrape_audited(
335        &self,
336        kind: &str,
337        url: &str,
338        opts: &ScrapeOptions,
339        mode: RobotsMode,
340    ) -> Result<Document, ApiError> {
341        let Some(audit) = &self.audit else {
342            return self.scrape_inner(Source::Url(url, mode), opts).await;
343        };
344        let started = Instant::now();
345        let mut result = self.scrape_inner(Source::Url(url, mode), opts).await;
346        let raw = result.as_ref().ok().and_then(|d| d.raw_html.as_deref());
347        if mode == RobotsMode::Ignore {
348            let request = if kind == "scrape" {
349                RequestKind::Scrape
350            } else {
351                RequestKind::Crawl
352            };
353            let reason = self.policy.robots_ignore_reason(request);
354            audit.record_scrape_ignoring_robots(
355                kind,
356                url,
357                opts,
358                &result,
359                raw,
360                started.elapsed(),
361                reason,
362            );
363        } else {
364            audit.record_scrape(kind, url, opts, &result, raw, started.elapsed());
365        }
366        // 为快照内部多要的 rawHtml 不交给调用方
367        if !opts.wants("rawHtml")
368            && let Ok(doc) = &mut result
369        {
370            doc.raw_html = None;
371        }
372        result
373    }
374
375    /// 单页抓取、爬取里的页面、上传文件解析共同的出口:结果有数据风险时填 `data.warning`。
376    pub(super) async fn scrape_inner(
377        &self,
378        source: Source<'_>,
379        opts: &ScrapeOptions,
380    ) -> Result<Document, ApiError> {
381        // 选择器写错了在抓取之前就报,不白抓一次
382        tag_filter(opts)?;
383        if opts.export_images {
384            return self.scrape_exporting_images(source, opts).await;
385        }
386        let mut result = self.scrape_document(source, opts).await;
387        if let Ok(doc) = &mut result {
388            crate::notice::fill_warning(doc);
389        }
390        result
391    }
392
393    /// `exportImages`:内部要图片列表(调用方没要 `images` 格式时最后去掉),不读也不写结果缓存
394    /// (缓存里的文档不带附件);PDF、Office 在转换时导出,网页图片在这之后取回。
395    async fn scrape_exporting_images(
396        &self,
397        source: Source<'_>,
398        opts: &ScrapeOptions,
399    ) -> Result<Document, ApiError> {
400        let mode = match &source {
401            Source::Url(_, mode) => *mode,
402            Source::Upload(_) => RobotsMode::Respect,
403        };
404        let mut inner = opts.clone();
405        if !inner.wants("images") {
406            inner
407                .formats
408                .push(pagesift_api::types::Format::new("images"));
409        }
410        inner.max_age = Some(0);
411        inner.store_in_cache = false;
412        let mut result = self.scrape_document(source, &inner).await;
413        if let Ok(doc) = &mut result {
414            self.export_web_images(doc, mode).await;
415            if !opts.wants("images") {
416                doc.images = None;
417            }
418            crate::notice::fill_warning(doc);
419        }
420        result
421    }
422
423    /// 基础文档(抓取或上传文件解析)之上,再做 `queryFilter`、分块、LLM 格式和变化检测。
424    async fn scrape_document(
425        &self,
426        source: Source<'_>,
427        opts: &ScrapeOptions,
428    ) -> Result<Document, ApiError> {
429        let wants_llm = opts.wants("summary") || opts.wants("json");
430        let tracking = opts
431            .formats
432            .iter()
433            .find(|f| f.kind == "changeTracking")
434            .map(crate::change::Tracking::parse)
435            .transpose()?;
436        if !wants_llm
437            && tracking.is_none()
438            && opts.query_filter.is_none()
439            && opts.chunking.is_none()
440        {
441            return self.base_document(source, opts).await;
442        }
443        let url = match &source {
444            Source::Url(url, _) => Some(*url),
445            Source::Upload(_) => None,
446        };
447        if tracking.is_some() && url.is_none() {
448            return Err(ApiError::unsupported(
449                "changeTracking needs a URL: it is not available for uploaded files",
450            ));
451        }
452        if (wants_llm || tracking.as_ref().is_some_and(|t| t.json)) && self.llm.is_none() {
453            return Err(ApiError::unsupported(
454                "summary and json formats (and the json mode of changeTracking) need an LLM: set [llm] in pagesift.toml",
455            ));
456        }
457        if tracking.is_some() && self.store.is_none() {
458            return Err(ApiError::unsupported(
459                "changeTracking keeps history in the store: enable [cache] in pagesift.toml",
460            ));
461        }
462        // 这些都基于正文 Markdown;用户没要 markdown 时内部照样取,最后去掉
463        let mut inner = opts.clone();
464        inner
465            .formats
466            .retain(|f| f.kind != "summary" && f.kind != "json");
467        let wants_md = inner.wants("markdown");
468        if !wants_md {
469            inner
470                .formats
471                .push(pagesift_api::types::Format::new("markdown"));
472        }
473        // 变化检测要和最新的页面比较:调用方没给 maxAge 时不读缓存
474        if tracking.is_some() && inner.max_age.is_none() {
475            inner.max_age = Some(0);
476        }
477        let mut doc = self.base_document(source, &inner).await?;
478        if let Some(q) = &opts.query_filter {
479            apply_query_filter(&mut doc, q).await?;
480        }
481        if let Some(c) = &opts.chunking {
482            doc.chunks = Some(chunk_markdown(
483                doc.markdown.as_deref().unwrap_or_default(),
484                c,
485            )?);
486        }
487        let md = doc.markdown.clone().unwrap_or_default();
488        if let Some(llm) = self.llm.as_ref().filter(|_| wants_llm) {
489            if opts.wants("summary") {
490                let summary = pagesift_llm::summarize(llm, &md, &Default::default())
491                    .await
492                    .map_err(llm_error)?;
493                doc.summary = Some(summary);
494            }
495            if let Some(f) = opts.formats.iter().find(|f| f.kind == "json") {
496                let schema = f
497                    .options
498                    .get("schema")
499                    .cloned()
500                    .unwrap_or_else(|| serde_json::json!({ "type": "object" }));
501                let eo = pagesift_llm::ExtractOptions {
502                    prompt: f
503                        .options
504                        .get("prompt")
505                        .and_then(|p| p.as_str())
506                        .map(String::from),
507                };
508                doc.json = Some(
509                    pagesift_llm::extract_json(llm, &md, &schema, &eo)
510                        .await
511                        .map_err(llm_error)?,
512                );
513            }
514        }
515        if let (Some(t), Some(store), Some(url)) = (&tracking, &self.store, url) {
516            self.track_changes(store, t, url, &mut doc).await?;
517        }
518        if !wants_md {
519            doc.markdown = None;
520        }
521        Ok(doc)
522    }
523
524    async fn base_document(
525        &self,
526        source: Source<'_>,
527        opts: &ScrapeOptions,
528    ) -> Result<Document, ApiError> {
529        match source {
530            Source::Url(url, mode) => self.scrape_cached(url, opts, mode).await,
531            Source::Upload(upload) => self.parse_upload(upload, opts).await,
532        }
533    }
534
535    /// `changeTracking`:和上一次的正文比较,结果放进 `doc.change_tracking`。
536    pub(super) async fn track_changes(
537        &self,
538        store: &pagesift_store::Store,
539        t: &crate::change::Tracking,
540        url: &str,
541        doc: &mut Document,
542    ) -> Result<(), ApiError> {
543        use crate::change::{Current, Previous};
544        let ext = doc.metadata.pagesift.get_or_insert_with(Default::default);
545        let current = if ext.not_modified {
546            Current::NotModified
547        } else if matches!(doc.metadata.status_code, 404 | 410) {
548            Current::Gone
549        } else {
550            Current::Page(doc.markdown.as_deref().unwrap_or_default())
551        };
552        let mut outcome = crate::change::track(store, t, url, current).await?;
553        ext.warnings.append(&mut outcome.warnings);
554        if let (true, "changed", Previous::Page(prev), Some(llm)) = (
555            t.json,
556            outcome.tracking.change_status.as_str(),
557            &outcome.previous,
558            &self.llm,
559        ) {
560            let schema = t
561                .schema
562                .clone()
563                .unwrap_or_else(|| serde_json::json!({ "type": "object" }));
564            let eo = pagesift_llm::ExtractOptions {
565                prompt: t.prompt.clone(),
566            };
567            let cur = doc.markdown.as_deref().unwrap_or_default();
568            let (before, after) = futures_util::future::try_join(
569                pagesift_llm::extract_json(llm, prev, &schema, &eo),
570                pagesift_llm::extract_json(llm, cur, &schema, &eo),
571            )
572            .await
573            .map_err(llm_error)?;
574            outcome.tracking.json = Some(crate::change::json_changes(&before, &after));
575        }
576        doc.change_tracking = Some(outcome.tracking);
577        Ok(())
578    }
579}
580
581/// 把网络层的 SSRF 放行名单原样映射给浏览器子请求拦截器。
582fn browser_allow_list(ssrf: &pagesift_net::SsrfPolicy) -> Result<NetworkAllowList, ScraperError> {
583    let mut allow = NetworkAllowList::default();
584    for host in &ssrf.allow_hosts {
585        allow.allow_host(host.clone());
586    }
587    for ip in &ssrf.allow_ips {
588        allow.allow_ip(*ip);
589    }
590    for cidr in &ssrf.allow_cidrs {
591        let (net, prefix) = match cidr {
592            pagesift_net::IpCidr::V4 { net, prefix } => (std::net::IpAddr::V4(*net), *prefix),
593            pagesift_net::IpCidr::V6 { net, prefix } => (std::net::IpAddr::V6(*net), *prefix),
594        };
595        allow
596            .allow_cidr_parts(net, prefix)
597            .map_err(|e| ScraperError::Fetcher(pagesift_net::Error::Config(e)))?;
598    }
599    Ok(allow)
600}