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 crawl_slots: pagesift_policy::AdaptiveConcurrencyGate,
167 learner: crate::learn::HostLearner,
168 planner: planner::RenderPlanner,
170 render_stats: render_stats::RenderStats,
172 audit: Option<Arc<crate::audit::Audit>>,
173 pages: std::sync::OnceLock<Arc<dyn crate::PageRunner>>,
175}
176
177pub(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 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
269struct 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 assert!(!needs_render(&content(&"字".repeat(500), false, false)));
327 assert!(needs_render(&content(&"a".repeat(500), false, false)));
328 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&x=2" height="160"></iframe>
380 <iframe srcdoc="<p>inline</p>"></iframe>
381 <iframe srcdoc="<p>x</p>" 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}