Skip to main content

pagesift_engine/engine/
mod.rs

1//! 抓取引擎:把网络层、礼貌策略、正文提取、PDF 和浏览器渲染串成一次完整的抓取。
2//!
3//! 流程:robots 模式 → HTTP 抓取(策略层负责冷却、robots、限速)→ 按内容类型分流(PDF / HTML)
4//! → `engine: auto` 时正文太少就升级到浏览器渲染 → 组装成 Firecrawl 格式的文档。
5
6use std::collections::BTreeMap;
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9
10use pagesift_api::types::{Document, Metadata, PagesiftMeta, ScrapeOptions};
11use pagesift_api::{ApiError, BoxFuture, Engine, ErrorCode};
12use pagesift_cdp::{
13    AllowListGuard, BlockResources, BrowserPool, NetworkAllowList, PoolConfig, PrivateNetworkGuard,
14    RenderOptions, RenderedPage, RequestGuard, SubRequest, Verdict,
15};
16use pagesift_net::{FetchRequest, Fetcher, FetcherConfig, Url};
17use pagesift_policy::{Policy, PolicyConfig, RequestKind, RobotsMode};
18use tokio::sync::OnceCell;
19
20pub mod agent;
21mod autoschema;
22mod cache;
23mod cpu;
24mod csv_text;
25mod egress_route;
26mod extract;
27mod feed;
28mod fetch;
29mod frames;
30mod hooked;
31mod http_extract;
32mod images;
33mod interact;
34mod office;
35mod output;
36mod parse;
37mod pdf;
38pub mod planner;
39mod render;
40mod render_stats;
41mod scraper;
42mod speculate;
43pub use autoschema::ActionSecret;
44pub use hooked::ScraperBuilder;
45pub use interact::InteractLimits;
46mod similar;
47mod structured;
48use cpu::{CONTENT_WORKSPACE, cpu_task};
49pub use cpu::{set_cpu_capacity, set_cpu_groups};
50use extract::*;
51use frames::*;
52use http_extract::*;
53use office::*;
54pub use office::{office_markdown, office_markdown_from};
55pub use output::*;
56use render::*;
57
58/// F-1.9:HTTP 抓到的正文少于这么多(加权)字符时,`engine: auto` 升级到浏览器渲染。
59/// 中日韩文字一个字按 [`CJK_WEIGHT`] 个字符算(信息量和几个拉丁字母相当),见 [`text_weight`]。
60pub const AUTO_RENDER_MIN_CHARS: usize = 1200;
61/// 已经认出是一篇文章(有发布时间或 `og:type=article`)时,正文到这么多加权字符就不再渲染:
62/// 短新闻 HTTP 已经拿全了。AiSeek 的生产数据里,63% 的渲染拿到的文字和 HTTP 一样多,
63/// 多半就是这类短文章。
64pub const AUTO_RENDER_ARTICLE_MIN_CHARS: usize = 300;
65pub const CJK_WEIGHT: usize = 3;
66
67/// 正文的加权长度:空白不算,中日韩文字按 [`CJK_WEIGHT`] 算。
68pub fn text_weight(s: &str) -> usize {
69    s.chars()
70        .filter(|c| !c.is_whitespace())
71        .map(|c| if is_cjk(c) { CJK_WEIGHT } else { 1 })
72        .sum()
73}
74
75fn is_cjk(c: char) -> bool {
76    matches!(c as u32,
77        0x3040..=0x30FF   // 平假名、片假名
78        | 0x3400..=0x4DBF // CJK 扩展 A
79        | 0x4E00..=0x9FFF // CJK 统一汉字
80        | 0xAC00..=0xD7AF // 谚文音节
81        | 0xF900..=0xFAFF // CJK 兼容汉字
82        | 0x20000..=0x2FA1F)
83}
84
85/// `engine: auto` 时 HTTP 的结果是否还要用浏览器渲染。
86pub fn needs_render(doc: &pagesift_content::Document) -> bool {
87    let w = text_weight(&doc.markdown);
88    if w >= AUTO_RENDER_MIN_CHARS {
89        return false;
90    }
91    let m = &doc.metadata;
92    let article = m.published_time.is_some()
93        || m.extra
94            .iter()
95            .any(|(k, v)| k == "og:type" && v.eq_ignore_ascii_case("article"));
96    !(article && w >= AUTO_RENDER_ARTICLE_MIN_CHARS)
97}
98/// 浏览器渲染出的正文少于这么多字符时,换下一个内核再试(Lightpanda 的脚本支持不全,
99/// 单页应用可能渲染不出内容)。最后一个内核的结果照常返回。
100pub const RENDER_FALLBACK_MIN_CHARS: usize = 200;
101
102/// 内核链里一个内核的启动检查结果([`Scraper::probe_engines`])。
103#[derive(Debug, Clone)]
104pub struct EngineProbe {
105    pub engine: &'static str,
106    /// 可执行文件路径或远程地址。
107    pub location: String,
108    pub result: Result<(), String>,
109}
110
111#[derive(Clone, Default)]
112pub struct ScraperConfig {
113    pub fetcher: FetcherConfig,
114    pub policy: PolicyConfig,
115    pub browser: PoolConfig,
116    /// 抓取结果缓存(`maxAge`、`minAge`、`storeInCache`);不设时不缓存。
117    pub store: Option<pagesift_store::Store>,
118    /// `summary`、`json` 格式和 `/v2/extract` 用的 OpenAI 兼容服务。
119    pub llm: Option<pagesift_llm::LlmConfig>,
120    /// `/v2/search` 用的搜索后端。
121    pub search: Option<Arc<dyn pagesift_search::SearchBackend>>,
122    /// 命名出口(名字,代理地址):请求的 `egress` 选其中一个,其余抓取设置和默认出口相同;
123    /// 两个及以上时,不指定 `egress` 的请求由出口池选。
124    pub egress: Vec<(String, String)>,
125    /// 命名出口的类别(没列出的是 `direct`)。代理地址为空的 `direct` 出口与默认出口相同。
126    pub egress_classes: std::collections::HashMap<String, pagesift_policy::EgressClass>,
127    /// 审计日志:每次抓取一行元数据,可疑结果留 HTML 快照(见 [`crate::audit`])。
128    pub audit: Option<Arc<crate::audit::Audit>>,
129    /// 扩展点(`pagesift_hooks`):代码注册的钩子和 `[[rules]]` 编译出的规则。空时不经过任何钩子。
130    pub hooks: pagesift_hooks::Hooks,
131    /// `[secrets]`:act 动作里 `{{secret:NAME}}` 在本地替换成的值(不经过模型)。
132    pub secrets: std::collections::BTreeMap<String, ActionSecret>,
133    /// 扫描页 OCR(`[ocr] enabled`);不设时扫描页只有说明,并报 `scanned_pages_without_ocr`。
134    pub ocr: Option<Arc<dyn pagesift_pdf::PageOcr>>,
135    /// `/interact` 保留会话的时长上限(默认 Firecrawl 文档的值)。
136    pub interact: InteractLimits,
137}
138
139/// 创建 [`Scraper`] 失败:网络层或 LLM 客户端的配置不对。
140#[derive(Debug)]
141pub enum ScraperError {
142    Fetcher(pagesift_net::Error),
143    Llm(pagesift_llm::LlmError),
144}
145
146impl std::fmt::Display for ScraperError {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        match self {
149            Self::Fetcher(e) => write!(f, "fetcher configuration: {e}"),
150            Self::Llm(e) => write!(f, "LLM configuration: {e}"),
151        }
152    }
153}
154
155impl std::error::Error for ScraperError {
156    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
157        match self {
158            Self::Fetcher(e) => Some(e),
159            Self::Llm(e) => Some(e),
160        }
161    }
162}
163
164pub struct Scraper {
165    policy: Arc<Policy>,
166    store: Option<pagesift_store::Store>,
167    llm: Option<pagesift_llm::ChatClient>,
168    search: Option<Arc<dyn pagesift_search::SearchBackend>>,
169    ocr: Option<Arc<dyn pagesift_pdf::PageOcr>>,
170    /// 网络层放开了内网(`allowPrivateNetwork`)时,浏览器的子请求也一样放开。
171    browser_private_network: bool,
172    /// 浏览器子请求的细粒度内网放行名单(allow_hosts / allow_ips / allow_cidrs)。
173    browser_allow: NetworkAllowList,
174    /// 按优先级排列的内核(见 [`pagesift_cdp::engine_chain`])。每个池第一次需要时才创建;
175    /// 创建失败时记住结果,不反复尝试。
176    browsers: Vec<(PoolConfig, OnceCell<Option<Arc<BrowserPool>>>)>,
177    /// 每个内核最近一次启动失败的时间:之后 [`LAUNCH_RETRY`] 内跳过它,不让每个请求都去试一次。
178    launch_failed: Vec<std::sync::Mutex<Option<Instant>>>,
179    /// 每个内核最近一次启动失败的原因,用来在“浏览器不可用”时告诉调用方真因(root、沙箱等)。
180    launch_notes: Vec<std::sync::Mutex<Option<String>>>,
181    /// 有内核找到了浏览器、但创建或启动失败(不是"没找到浏览器")。
182    launch_broken: std::sync::atomic::AtomicBool,
183    /// 爬取里同时进行的抓取数,等于全局并发。排队的页面只占一个很小的等待 future,
184    /// 拿到名额才创建完整的抓取状态机(十几 KB):同时爬上千个站时,排队的页面不再各占一份。
185    /// 上限跟着限速器的全局在途数目标走(USL 控制器,见 `pagesift_policy::RateLimiter`)。
186    crawl_slots: pagesift_policy::AdaptiveConcurrencyGate,
187    learner: crate::learn::HostLearner,
188    /// 要不要开浏览器、渲染结果怎么和 HTTP 结果合并。
189    planner: planner::RenderPlanner,
190    /// 自动升级的在线耗时分布:预算、导航耐心、早停时机都从这里来。
191    render_stats: render_stats::RenderStats,
192    audit: Option<Arc<crate::audit::Audit>>,
193    hooks: pagesift_hooks::Hooks,
194    secrets: std::collections::BTreeMap<String, ActionSecret>,
195    /// 多服务器网关:爬取里的页面交给它(工人执行,见 `pagesift` 的 `cluster` 模块)。单主机不设,路径不变。
196    pages: std::sync::OnceLock<Arc<dyn crate::PageRunner>>,
197    /// `/interact` 的会话表。
198    sessions: Arc<interact::Sessions>,
199}
200
201/// 文档从哪来:抓一个网址,或解析调用方上传的文件(`/v2/parse`)。
202pub(super) enum Source<'a> {
203    Url(&'a str, RobotsMode),
204    Upload(pagesift_api::types::Upload),
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208enum EngineChoice {
209    Http,
210    Browser,
211    Auto,
212}
213
214impl Engine for Scraper {
215    fn scrape<'a>(
216        &'a self,
217        url: &'a str,
218        opts: &'a ScrapeOptions,
219    ) -> BoxFuture<'a, Result<Document, ApiError>> {
220        Box::pin(Scraper::scrape(self, url, opts))
221    }
222
223    fn parse<'a>(
224        &'a self,
225        file: pagesift_api::types::Upload,
226        opts: &'a ScrapeOptions,
227    ) -> BoxFuture<'a, Result<Document, ApiError>> {
228        Box::pin(Scraper::parse(self, file, opts))
229    }
230
231    fn map<'a>(
232        &'a self,
233        req: &'a pagesift_api::types::MapRequest,
234    ) -> BoxFuture<'a, Result<Vec<pagesift_api::types::MapLink>, ApiError>> {
235        Box::pin(Scraper::map(self, req))
236    }
237
238    fn crawl<'a>(
239        &'a self,
240        req: &'a pagesift_api::types::CrawlRequest,
241        sink: Arc<dyn pagesift_api::CrawlSink>,
242    ) -> BoxFuture<'a, Result<(), ApiError>> {
243        Box::pin(Scraper::crawl(self, req, sink))
244    }
245
246    fn search<'a>(
247        &'a self,
248        req: &'a pagesift_api::types::SearchRequest,
249    ) -> BoxFuture<'a, Result<serde_json::Value, ApiError>> {
250        Box::pin(Scraper::search(self, req))
251    }
252
253    fn extract<'a>(
254        &'a self,
255        req: &'a pagesift_api::types::ExtractRequest,
256    ) -> BoxFuture<'a, Result<serde_json::Value, ApiError>> {
257        Box::pin(Scraper::extract(self, req))
258    }
259
260    fn supports_interact(&self) -> bool {
261        self.interact_available()
262    }
263
264    /// 动作只在 Chrome 上验证过,和 interact 同一个条件。
265    fn supports_actions(&self) -> bool {
266        self.interact_available()
267    }
268
269    fn actions_unavailable(&self) -> &'static str {
270        "actions need Chrome: no Chrome or Chromium was found on this machine (run `pagesift doctor`)"
271    }
272
273    fn scrape_session<'a>(
274        &'a self,
275        url: &'a str,
276        opts: &'a ScrapeOptions,
277        keep: bool,
278        owner: Option<&'a str>,
279    ) -> BoxFuture<'a, Result<Document, ApiError>> {
280        Box::pin(Scraper::scrape_session(self, url, opts, keep, owner))
281    }
282
283    fn observe<'a>(
284        &'a self,
285        url: &'a str,
286        instruction: &'a str,
287    ) -> BoxFuture<'a, Result<pagesift_api::types::Observed, ApiError>> {
288        Box::pin(async move {
289            let (found, used) = Scraper::observe(self, url, instruction).await?;
290            let usage = format!("llm_usage: {} calls, {} tokens", used.calls, used.tokens);
291            Ok((found, vec![usage]))
292        })
293    }
294
295    fn find_similar<'a>(
296        &'a self,
297        url: &'a str,
298        selector: &'a str,
299        opts: &'a ScrapeOptions,
300    ) -> BoxFuture<'a, Result<pagesift_api::types::SimilarData, ApiError>> {
301        Box::pin(Scraper::find_similar(self, url, selector, opts))
302    }
303
304    fn interact<'a>(
305        &'a self,
306        id: &'a str,
307        req: &'a pagesift_api::types::InteractRequest,
308        owner: Option<&'a str>,
309    ) -> BoxFuture<'a, Result<pagesift_api::types::InteractResponse, ApiError>> {
310        Box::pin(Scraper::interact(self, id, req, owner))
311    }
312
313    fn stop_interact<'a>(
314        &'a self,
315        id: &'a str,
316        owner: Option<&'a str>,
317    ) -> BoxFuture<'a, Result<pagesift_api::types::InteractStopped, ApiError>> {
318        Box::pin(Scraper::stop_interact(self, id, owner))
319    }
320
321    fn agent_check(&self, req: &pagesift_api::types::AgentRequest) -> Result<(), ApiError> {
322        agent::check(self.llm.is_some(), self.search.is_some(), req)
323    }
324
325    fn agent<'a>(
326        &'a self,
327        req: &'a pagesift_api::types::AgentRequest,
328        sink: Arc<dyn pagesift_api::AgentSink>,
329    ) -> BoxFuture<'a, pagesift_api::types::AgentOutcome> {
330        Box::pin(async move {
331            match &self.llm {
332                Some(llm) => agent::run(self, llm, self.search.is_some(), req, sink.as_ref()).await,
333                None => pagesift_api::types::AgentOutcome {
334                    finish_reason: "error".into(),
335                    error: Some(pagesift_api::types::AgentFailure::from(
336                        &ApiError::unsupported("agent needs an LLM: set [llm] in pagesift.toml"),
337                    )),
338                    ..Default::default()
339                },
340            }
341        })
342    }
343
344    /// 统一准入:批量任务的并发取当前全局在途数目标(限速器的 USL 控制器)。
345    fn admitted_concurrency(&self) -> Option<usize> {
346        Some(self.concurrency())
347    }
348
349    fn supported_formats(&self) -> &'static [&'static str] {
350        match (self.llm.is_some(), self.store.is_some()) {
351            (true, true) => &[
352                "markdown",
353                "html",
354                "rawHtml",
355                "links",
356                "images",
357                "extract",
358                "summary",
359                "json",
360                "changeTracking",
361            ],
362            (true, false) => &[
363                "markdown", "html", "rawHtml", "links", "images", "extract", "summary", "json",
364            ],
365            (false, true) => &[
366                "markdown",
367                "html",
368                "rawHtml",
369                "links",
370                "images",
371                "extract",
372                "changeTracking",
373            ],
374            (false, false) => &["markdown", "html", "rawHtml", "links", "images", "extract"],
375        }
376    }
377}
378
379/// 放开内网时浏览器子请求的检查:只限制协议,不查地址。
380struct SchemeGuard;
381
382impl RequestGuard for SchemeGuard {
383    fn check<'a>(&'a self, req: &'a SubRequest) -> pagesift_cdp::BoxFuture<'a, Verdict> {
384        let ok = Url::parse(&req.url)
385            .is_ok_and(|u| matches!(u.scheme(), "http" | "https" | "data" | "blob" | "about"));
386        Box::pin(async move {
387            if ok {
388                Verdict::Allow
389            } else {
390                Verdict::Block("scheme not allowed".into())
391            }
392        })
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    fn content(md: &str, published: bool, og_article: bool) -> pagesift_content::Document {
401        let mut d = pagesift_content::process("<p>x</p>", &Default::default());
402        d.markdown = md.to_string();
403        d.metadata.published_time = published.then(|| "2026-10-21T00:00:00Z".into());
404        if og_article {
405            d.metadata.extra.push(("og:type".into(), "article".into()));
406        }
407        d
408    }
409
410    #[test]
411    fn static_learning_cannot_suppress_client_code() {
412        for html in [
413            "<script>hydrate()</script>",
414            "<script src='/app.js'></script>",
415            "<SCRIPT TYPE='module' src='/view.js'></SCRIPT>",
416            "<body onload='hydrate()'>",
417            "<button onclick='hydrate()'>Open</button>",
418        ] {
419            assert!(has_client_code(html), "{html}");
420        }
421        for html in [
422            "<article><p>Short but complete.</p></article>",
423            "<script type='application/ld+json'>{}</script>",
424            "<script type='APPLICATION/JSON; charset=utf-8'>{}</script>",
425            "<script type='text/plain'>inert sample</script>",
426        ] {
427            assert!(!has_client_code(html), "{html}");
428        }
429    }
430
431    #[test]
432    fn render_rule_weights_cjk_and_trusts_short_articles() {
433        assert_eq!(text_weight("ab c\n"), 3);
434        assert_eq!(text_weight("日本語のニュース"), 8 * CJK_WEIGHT);
435        // 500 个汉字按 1500 算,不用渲染;500 个拉丁字母要渲染
436        assert!(!needs_render(&content(&"字".repeat(500), false, false)));
437        assert!(needs_render(&content(&"a".repeat(500), false, false)));
438        // 认出是文章且有 300 以上:不渲染;太短的文章照样渲染
439        assert!(!needs_render(&content(&"a".repeat(400), true, false)));
440        assert!(!needs_render(&content(&"a".repeat(400), false, true)));
441        assert!(needs_render(&content(&"a".repeat(100), true, true)));
442    }
443
444    #[test]
445    fn render_errors_are_classified() {
446        use pagesift_cdp::Error as E;
447        for (e, code) in [
448            (
449                E::Action {
450                    index: 0,
451                    message: "no element".into(),
452                },
453                ErrorCode::InvalidRequest,
454            ),
455            (E::InvalidOptions("x".into()), ErrorCode::InvalidRequest),
456            (E::ExecuteJavascriptDisabled, ErrorCode::UnsupportedOption),
457            (E::Timeout, ErrorCode::Timeout),
458            (E::Busy, ErrorCode::Busy),
459            (
460                E::Navigation("net::ERR_NAME_NOT_RESOLVED".into()),
461                ErrorCode::DnsFailure,
462            ),
463            (
464                E::Navigation("net::ERR_CONNECTION_RESET".into()),
465                ErrorCode::NetworkError,
466            ),
467            (
468                E::Navigation("net::ERR_CERT_DATE_INVALID".into()),
469                ErrorCode::TlsCertExpired,
470            ),
471            (
472                E::Navigation("net::ERR_CERT_COMMON_NAME_INVALID".into()),
473                ErrorCode::TlsHostnameMismatch,
474            ),
475            (
476                E::Navigation("net::ERR_CERT_AUTHORITY_INVALID".into()),
477                ErrorCode::TlsUntrustedRoot,
478            ),
479            (
480                E::Navigation("net::ERR_CERT_REVOKED".into()),
481                ErrorCode::TlsError,
482            ),
483            (E::ConnectionClosed, ErrorCode::UpstreamError),
484        ] {
485            assert_eq!(render_error_code(&e), code, "{e}");
486        }
487    }
488
489    #[test]
490    fn finds_only_visible_same_origin_and_srcdoc_iframes() {
491        let base = Url::parse("https://a.test/page").unwrap();
492        let html = r#"<body>
493            <iframe src="https://www.googletagmanager.com/ns.html"></iframe>
494            <iframe src="/pixel" style="display: none"></iframe>
495            <iframe src="/hidden" aria-hidden="true"></iframe>
496            <iframe src="/tiny" width="1" height="1"></iframe>
497            <iframe src="about:blank"></iframe>
498            <iframe src="/comments?id=1&amp;x=2" height="160"></iframe>
499            <iframe srcdoc="&lt;p&gt;inline&lt;/p&gt;"></iframe>
500            <iframe srcdoc="&lt;p&gt;x&lt;/p&gt;" style="visibility:hidden"></iframe>
501        </body>"#;
502        let frames = content_iframes(html, &base);
503        assert_eq!(frames.len(), 2, "{frames:?}");
504        assert_eq!(frames[0].0, "/comments?id=1&x=2");
505        assert_eq!(
506            frames[0].1.as_ref().map(Url::as_str),
507            Some("https://a.test/comments?id=1&x=2")
508        );
509        assert_eq!(frames[1], ("<p>inline</p>".to_string(), None));
510        assert!(content_iframes("<p>no frames</p>", &base).is_empty());
511    }
512
513    #[test]
514    fn replaces_the_matching_iframe_element() {
515        let mut html = r#"<p>a</p><IFRAME src="/x" hidden></IFRAME><iframe src="/x">fallback</iframe><p>b</p>"#
516            .to_string();
517        assert!(replace_iframe(&mut html, "<div>X</div>", |el| {
518            !hidden_frame(el) && src_is(el, "/x")
519        }));
520        assert_eq!(
521            html,
522            r#"<p>a</p><IFRAME src="/x" hidden></IFRAME><div>X</div><p>b</p>"#
523        );
524        assert!(!replace_iframe(&mut html, "", |el| src_is(el, "/missing")));
525    }
526
527    #[test]
528    fn takes_the_body_of_an_iframe_document() {
529        assert_eq!(
530            body_inner("<html><head><title>t</title></head><BODY class=x><p>hi</p></body></html>"),
531            "<p>hi</p>"
532        );
533        assert_eq!(body_inner("<p>fragment</p>"), "<p>fragment</p>");
534    }
535
536    #[test]
537    fn meta_keys_follow_firecrawl_style() {
538        assert_eq!(pagesift_sink::meta::key("og:image"), "ogImage");
539        assert_eq!(pagesift_sink::meta::key("og:site_name"), "ogSiteName");
540        assert_eq!(
541            pagesift_sink::meta::key("article:published_time"),
542            "articlePublishedTime"
543        );
544        assert_eq!(pagesift_sink::meta::key("dc.title"), "dcTitle");
545    }
546}