Skip to main content

pagesift_engine/engine/
scraper.rs

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