1use 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
49pub const AUTO_RENDER_MIN_CHARS: usize = 1200;
52pub const AUTO_RENDER_ARTICLE_MIN_CHARS: usize = 300;
56pub const CJK_WEIGHT: usize = 3;
57
58pub 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 | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0x20000..=0x2FA1F)
74}
75
76pub 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}
89pub const RENDER_FALLBACK_MIN_CHARS: usize = 200;
92
93#[derive(Debug, Clone)]
95pub struct EngineProbe {
96 pub engine: &'static str,
97 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 pub store: Option<pagesift_store::Store>,
109 pub llm: Option<pagesift_llm::LlmConfig>,
111 pub search: Option<Arc<dyn pagesift_search::SearchBackend>>,
113 pub egress: Vec<(String, String)>,
116 pub egress_classes: std::collections::HashMap<String, pagesift_policy::EgressClass>,
118 pub audit: Option<Arc<crate::audit::Audit>>,
120}
121
122#[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 browser_private_network: bool,
154 browser_allow: NetworkAllowList,
156 browsers: Vec<(PoolConfig, OnceCell<Option<Arc<BrowserPool>>>)>,
159 launch_failed: Vec<std::sync::Mutex<Option<Instant>>>,
161 launch_notes: Vec<std::sync::Mutex<Option<String>>>,
163 launch_broken: std::sync::atomic::AtomicBool,
165 crawl_slots: pagesift_policy::AdaptiveConcurrencyGate,
169 learner: crate::learn::HostLearner,
170 planner: planner::RenderPlanner,
172 render_stats: render_stats::RenderStats,
174 audit: Option<Arc<crate::audit::Audit>>,
175 pages: std::sync::OnceLock<Arc<dyn crate::PageRunner>>,
177}
178
179pub(super) enum Source<'a> {
181 Url(&'a str, RobotsMode),
182 Upload(pagesift_api::types::Upload),
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186enum EngineChoice {
187 Http,
188 Browser,
189 Auto,
190}
191
192impl Engine for Scraper {
193 fn scrape<'a>(
194 &'a self,
195 url: &'a str,
196 opts: &'a ScrapeOptions,
197 ) -> BoxFuture<'a, Result<Document, ApiError>> {
198 Box::pin(Scraper::scrape(self, url, opts))
199 }
200
201 fn parse<'a>(
202 &'a self,
203 file: pagesift_api::types::Upload,
204 opts: &'a ScrapeOptions,
205 ) -> BoxFuture<'a, Result<Document, ApiError>> {
206 Box::pin(Scraper::parse(self, file, opts))
207 }
208
209 fn map<'a>(
210 &'a self,
211 req: &'a pagesift_api::types::MapRequest,
212 ) -> BoxFuture<'a, Result<Vec<pagesift_api::types::MapLink>, ApiError>> {
213 Box::pin(Scraper::map(self, req))
214 }
215
216 fn crawl<'a>(
217 &'a self,
218 req: &'a pagesift_api::types::CrawlRequest,
219 sink: Arc<dyn pagesift_api::CrawlSink>,
220 ) -> BoxFuture<'a, Result<(), ApiError>> {
221 Box::pin(Scraper::crawl(self, req, sink))
222 }
223
224 fn search<'a>(
225 &'a self,
226 req: &'a pagesift_api::types::SearchRequest,
227 ) -> BoxFuture<'a, Result<serde_json::Value, ApiError>> {
228 Box::pin(Scraper::search(self, req))
229 }
230
231 fn extract<'a>(
232 &'a self,
233 req: &'a pagesift_api::types::ExtractRequest,
234 ) -> BoxFuture<'a, Result<serde_json::Value, ApiError>> {
235 Box::pin(Scraper::extract(self, req))
236 }
237
238 fn admitted_concurrency(&self) -> Option<usize> {
240 Some(self.concurrency())
241 }
242
243 fn supported_formats(&self) -> &'static [&'static str] {
244 match (self.llm.is_some(), self.store.is_some()) {
245 (true, true) => &[
246 "markdown",
247 "html",
248 "rawHtml",
249 "links",
250 "images",
251 "summary",
252 "json",
253 "changeTracking",
254 ],
255 (true, false) => &[
256 "markdown", "html", "rawHtml", "links", "images", "summary", "json",
257 ],
258 (false, true) => &[
259 "markdown",
260 "html",
261 "rawHtml",
262 "links",
263 "images",
264 "changeTracking",
265 ],
266 (false, false) => &["markdown", "html", "rawHtml", "links", "images"],
267 }
268 }
269}
270
271struct SchemeGuard;
273
274impl RequestGuard for SchemeGuard {
275 fn check<'a>(&'a self, req: &'a SubRequest) -> pagesift_cdp::BoxFuture<'a, Verdict> {
276 let ok = Url::parse(&req.url)
277 .is_ok_and(|u| matches!(u.scheme(), "http" | "https" | "data" | "blob" | "about"));
278 Box::pin(async move {
279 if ok {
280 Verdict::Allow
281 } else {
282 Verdict::Block("scheme not allowed".into())
283 }
284 })
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 fn content(md: &str, published: bool, og_article: bool) -> pagesift_content::Document {
293 let mut d = pagesift_content::process("<p>x</p>", &Default::default());
294 d.markdown = md.to_string();
295 d.metadata.published_time = published.then(|| "2026-10-21T00:00:00Z".into());
296 if og_article {
297 d.metadata.extra.push(("og:type".into(), "article".into()));
298 }
299 d
300 }
301
302 #[test]
303 fn static_learning_cannot_suppress_client_code() {
304 for html in [
305 "<script>hydrate()</script>",
306 "<script src='/app.js'></script>",
307 "<SCRIPT TYPE='module' src='/view.js'></SCRIPT>",
308 "<body onload='hydrate()'>",
309 "<button onclick='hydrate()'>Open</button>",
310 ] {
311 assert!(has_client_code(html), "{html}");
312 }
313 for html in [
314 "<article><p>Short but complete.</p></article>",
315 "<script type='application/ld+json'>{}</script>",
316 "<script type='APPLICATION/JSON; charset=utf-8'>{}</script>",
317 "<script type='text/plain'>inert sample</script>",
318 ] {
319 assert!(!has_client_code(html), "{html}");
320 }
321 }
322
323 #[test]
324 fn render_rule_weights_cjk_and_trusts_short_articles() {
325 assert_eq!(text_weight("ab c\n"), 3);
326 assert_eq!(text_weight("日本語のニュース"), 8 * CJK_WEIGHT);
327 assert!(!needs_render(&content(&"字".repeat(500), false, false)));
329 assert!(needs_render(&content(&"a".repeat(500), false, false)));
330 assert!(!needs_render(&content(&"a".repeat(400), true, false)));
332 assert!(!needs_render(&content(&"a".repeat(400), false, true)));
333 assert!(needs_render(&content(&"a".repeat(100), true, true)));
334 }
335
336 #[test]
337 fn render_errors_are_classified() {
338 use pagesift_cdp::Error as E;
339 for (e, code) in [
340 (E::Timeout, ErrorCode::Timeout),
341 (E::Busy, ErrorCode::Busy),
342 (
343 E::Navigation("net::ERR_NAME_NOT_RESOLVED".into()),
344 ErrorCode::DnsFailure,
345 ),
346 (
347 E::Navigation("net::ERR_CONNECTION_RESET".into()),
348 ErrorCode::NetworkError,
349 ),
350 (
351 E::Navigation("net::ERR_CERT_DATE_INVALID".into()),
352 ErrorCode::TlsCertExpired,
353 ),
354 (
355 E::Navigation("net::ERR_CERT_COMMON_NAME_INVALID".into()),
356 ErrorCode::TlsHostnameMismatch,
357 ),
358 (
359 E::Navigation("net::ERR_CERT_AUTHORITY_INVALID".into()),
360 ErrorCode::TlsUntrustedRoot,
361 ),
362 (
363 E::Navigation("net::ERR_CERT_REVOKED".into()),
364 ErrorCode::TlsError,
365 ),
366 (E::ConnectionClosed, ErrorCode::UpstreamError),
367 ] {
368 assert_eq!(render_error_code(&e), code, "{e}");
369 }
370 }
371
372 #[test]
373 fn finds_only_visible_same_origin_and_srcdoc_iframes() {
374 let base = Url::parse("https://a.test/page").unwrap();
375 let html = r#"<body>
376 <iframe src="https://www.googletagmanager.com/ns.html"></iframe>
377 <iframe src="/pixel" style="display: none"></iframe>
378 <iframe src="/hidden" aria-hidden="true"></iframe>
379 <iframe src="/tiny" width="1" height="1"></iframe>
380 <iframe src="about:blank"></iframe>
381 <iframe src="/comments?id=1&x=2" height="160"></iframe>
382 <iframe srcdoc="<p>inline</p>"></iframe>
383 <iframe srcdoc="<p>x</p>" style="visibility:hidden"></iframe>
384 </body>"#;
385 let frames = content_iframes(html, &base);
386 assert_eq!(frames.len(), 2, "{frames:?}");
387 assert_eq!(frames[0].0, "/comments?id=1&x=2");
388 assert_eq!(
389 frames[0].1.as_ref().map(Url::as_str),
390 Some("https://a.test/comments?id=1&x=2")
391 );
392 assert_eq!(frames[1], ("<p>inline</p>".to_string(), None));
393 assert!(content_iframes("<p>no frames</p>", &base).is_empty());
394 }
395
396 #[test]
397 fn replaces_the_matching_iframe_element() {
398 let mut html = r#"<p>a</p><IFRAME src="/x" hidden></IFRAME><iframe src="/x">fallback</iframe><p>b</p>"#
399 .to_string();
400 assert!(replace_iframe(&mut html, "<div>X</div>", |el| {
401 !hidden_frame(el) && src_is(el, "/x")
402 }));
403 assert_eq!(
404 html,
405 r#"<p>a</p><IFRAME src="/x" hidden></IFRAME><div>X</div><p>b</p>"#
406 );
407 assert!(!replace_iframe(&mut html, "", |el| src_is(el, "/missing")));
408 }
409
410 #[test]
411 fn takes_the_body_of_an_iframe_document() {
412 assert_eq!(
413 body_inner("<html><head><title>t</title></head><BODY class=x><p>hi</p></body></html>"),
414 "<p>hi</p>"
415 );
416 assert_eq!(body_inner("<p>fragment</p>"), "<p>fragment</p>");
417 }
418
419 #[test]
420 fn meta_keys_follow_firecrawl_style() {
421 assert_eq!(pagesift_sink::meta::key("og:image"), "ogImage");
422 assert_eq!(pagesift_sink::meta::key("og:site_name"), "ogSiteName");
423 assert_eq!(
424 pagesift_sink::meta::key("article:published_time"),
425 "articlePublishedTime"
426 );
427 assert_eq!(pagesift_sink::meta::key("dc.title"), "dcTitle");
428 }
429}