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