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