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