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
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
58pub const AUTO_RENDER_MIN_CHARS: usize = 1200;
61pub const AUTO_RENDER_ARTICLE_MIN_CHARS: usize = 300;
65pub const CJK_WEIGHT: usize = 3;
66
67pub 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 | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0x20000..=0x2FA1F)
83}
84
85pub 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}
98pub const RENDER_FALLBACK_MIN_CHARS: usize = 200;
101
102#[derive(Debug, Clone)]
104pub struct EngineProbe {
105 pub engine: &'static str,
106 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 pub store: Option<pagesift_store::Store>,
118 pub llm: Option<pagesift_llm::LlmConfig>,
120 pub search: Option<Arc<dyn pagesift_search::SearchBackend>>,
122 pub egress: Vec<(String, String)>,
125 pub egress_classes: std::collections::HashMap<String, pagesift_policy::EgressClass>,
127 pub audit: Option<Arc<crate::audit::Audit>>,
129 pub hooks: pagesift_hooks::Hooks,
131 pub secrets: std::collections::BTreeMap<String, ActionSecret>,
133 pub ocr: Option<Arc<dyn pagesift_pdf::PageOcr>>,
135 pub interact: InteractLimits,
137}
138
139#[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 browser_private_network: bool,
172 browser_allow: NetworkAllowList,
174 browsers: Vec<(PoolConfig, OnceCell<Option<Arc<BrowserPool>>>)>,
177 launch_failed: Vec<std::sync::Mutex<Option<Instant>>>,
179 launch_notes: Vec<std::sync::Mutex<Option<String>>>,
181 launch_broken: std::sync::atomic::AtomicBool,
183 crawl_slots: pagesift_policy::AdaptiveConcurrencyGate,
187 learner: crate::learn::HostLearner,
188 planner: planner::RenderPlanner,
190 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 pages: std::sync::OnceLock<Arc<dyn crate::PageRunner>>,
197 sessions: Arc<interact::Sessions>,
199}
200
201pub(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 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 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
379struct 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 assert!(!needs_render(&content(&"字".repeat(500), false, false)));
437 assert!(needs_render(&content(&"a".repeat(500), false, false)));
438 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&x=2" height="160"></iframe>
499 <iframe srcdoc="<p>inline</p>"></iframe>
500 <iframe srcdoc="<p>x</p>" 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}