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
20tokio::task_local! {
21 static ITNA_NETWORK_BUDGET: Arc<pagesift_itna::NetworkBudget>;
22 static ITNA_COOKIE_SESSION: Option<Arc<pagesift_net::CookieSession>>;
23 static ITNA_EXPECTED_DIRECT: Option<bool>;
24 static ITNA_DEFAULT_PROXY: Option<String>;
25}
26
27pub mod agent;
28mod asset_modes;
29mod autoschema;
30mod cache;
31mod cpu;
32mod csv_text;
33mod egress_route;
34mod extract;
35mod feed;
36mod fetch;
37mod frames;
38mod hooked;
39mod http_extract;
40mod image_file;
41mod images;
42mod interact;
43mod office;
44mod output;
45mod parse;
46mod pdf;
47pub mod planner;
48mod render;
49mod render_stats;
50mod scraper;
51mod speculate;
52pub use autoschema::ActionSecret;
53pub use hooked::ScraperBuilder;
54pub use interact::InteractLimits;
55mod similar;
56mod structured;
57use cpu::{CONTENT_WORKSPACE, cpu_task};
58pub use cpu::{set_cpu_capacity, set_cpu_groups};
59use extract::*;
60use frames::*;
61use http_extract::*;
62use office::*;
63pub use office::{office_markdown, office_markdown_from};
64pub use output::*;
65use render::*;
66
67pub const AUTO_RENDER_MIN_CHARS: usize = 1200;
70pub const AUTO_RENDER_ARTICLE_MIN_CHARS: usize = 300;
74pub const CJK_WEIGHT: usize = 3;
75
76pub fn text_weight(s: &str) -> usize {
78 s.chars()
79 .filter(|c| !c.is_whitespace())
80 .map(|c| if is_cjk(c) { CJK_WEIGHT } else { 1 })
81 .sum()
82}
83
84fn is_cjk(c: char) -> bool {
85 matches!(c as u32,
86 0x3040..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0x20000..=0x2FA1F)
92}
93
94pub fn needs_render(doc: &pagesift_content::Document) -> bool {
96 let w = text_weight(&doc.markdown);
97 if w >= AUTO_RENDER_MIN_CHARS {
98 return false;
99 }
100 let m = &doc.metadata;
101 let article = m.published_time.is_some()
102 || m.extra
103 .iter()
104 .any(|(k, v)| k == "og:type" && v.eq_ignore_ascii_case("article"));
105 !(article && w >= AUTO_RENDER_ARTICLE_MIN_CHARS)
106}
107pub const RENDER_FALLBACK_MIN_CHARS: usize = 200;
110
111#[derive(Debug, Clone)]
113pub struct EngineProbe {
114 pub engine: &'static str,
115 pub location: String,
117 pub result: Result<(), String>,
118}
119
120#[derive(Clone, Default)]
121pub struct ScraperConfig {
122 pub fetcher: FetcherConfig,
123 pub policy: PolicyConfig,
124 pub itna: bool,
126 pub browser: PoolConfig,
127 pub store: Option<pagesift_store::Store>,
129 pub llm: Option<pagesift_llm::LlmConfig>,
131 pub search: Option<Arc<dyn pagesift_search::SearchBackend>>,
133 pub egress: Vec<(String, String)>,
136 pub egress_classes: std::collections::HashMap<String, pagesift_policy::EgressClass>,
138 pub audit: Option<Arc<crate::audit::Audit>>,
140 pub hooks: pagesift_hooks::Hooks,
142 pub secrets: std::collections::BTreeMap<String, ActionSecret>,
144 pub ocr: Option<Arc<dyn pagesift_pdf::PageOcr>>,
146 pub interact: InteractLimits,
148}
149
150#[derive(Debug)]
152pub enum ScraperError {
153 Fetcher(pagesift_net::Error),
154 Llm(pagesift_llm::LlmError),
155}
156
157impl std::fmt::Display for ScraperError {
158 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159 match self {
160 Self::Fetcher(e) => write!(f, "fetcher configuration: {e}"),
161 Self::Llm(e) => write!(f, "LLM configuration: {e}"),
162 }
163 }
164}
165
166impl std::error::Error for ScraperError {
167 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
168 match self {
169 Self::Fetcher(e) => Some(e),
170 Self::Llm(e) => Some(e),
171 }
172 }
173}
174
175pub struct Scraper {
176 policy: Arc<Policy>,
177 itna: bool,
178 identities: pagesift_policy::identity_pool::IdentityPool,
181 store: Option<pagesift_store::Store>,
182 llm: Option<pagesift_llm::ChatClient>,
183 search: Option<Arc<dyn pagesift_search::SearchBackend>>,
184 ocr: Option<Arc<dyn pagesift_pdf::PageOcr>>,
185 browser_private_network: bool,
187 browser_allow: NetworkAllowList,
189 browsers: Vec<(PoolConfig, OnceCell<Option<Arc<BrowserPool>>>)>,
192 launch_failed: Vec<std::sync::Mutex<Option<Instant>>>,
194 launch_notes: Vec<std::sync::Mutex<Option<String>>>,
196 launch_broken: std::sync::atomic::AtomicBool,
198 crawl_slots: pagesift_policy::AdaptiveConcurrencyGate,
202 learner: crate::learn::HostLearner,
203 planner: planner::RenderPlanner,
205 render_stats: render_stats::RenderStats,
207 audit: Option<Arc<crate::audit::Audit>>,
208 hooks: pagesift_hooks::Hooks,
209 secrets: std::collections::BTreeMap<String, ActionSecret>,
210 pages: std::sync::OnceLock<Arc<dyn crate::PageRunner>>,
212 sessions: Arc<interact::Sessions>,
214}
215
216pub(super) enum Source<'a> {
218 Url(&'a str, RobotsMode),
219 Upload(pagesift_api::types::Upload),
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223enum EngineChoice {
224 Http,
225 Browser,
226 Auto,
227}
228
229impl Engine for Scraper {
230 fn scrape<'a>(
231 &'a self,
232 url: &'a str,
233 opts: &'a ScrapeOptions,
234 ) -> BoxFuture<'a, Result<Document, ApiError>> {
235 Box::pin(Scraper::scrape(self, url, opts))
236 }
237
238 fn parse<'a>(
239 &'a self,
240 file: pagesift_api::types::Upload,
241 opts: &'a ScrapeOptions,
242 ) -> BoxFuture<'a, Result<Document, ApiError>> {
243 Box::pin(Scraper::parse(self, file, opts))
244 }
245
246 fn map<'a>(
247 &'a self,
248 req: &'a pagesift_api::types::MapRequest,
249 ) -> BoxFuture<'a, Result<Vec<pagesift_api::types::MapLink>, ApiError>> {
250 Box::pin(Scraper::map(self, req))
251 }
252
253 fn crawl<'a>(
254 &'a self,
255 req: &'a pagesift_api::types::CrawlRequest,
256 sink: Arc<dyn pagesift_api::CrawlSink>,
257 ) -> BoxFuture<'a, Result<(), ApiError>> {
258 Box::pin(Scraper::crawl(self, req, sink))
259 }
260
261 fn search<'a>(
262 &'a self,
263 req: &'a pagesift_api::types::SearchRequest,
264 ) -> BoxFuture<'a, Result<serde_json::Value, ApiError>> {
265 Box::pin(Scraper::search(self, req))
266 }
267
268 fn extract<'a>(
269 &'a self,
270 req: &'a pagesift_api::types::ExtractRequest,
271 ) -> BoxFuture<'a, Result<serde_json::Value, ApiError>> {
272 Box::pin(Scraper::extract(self, req))
273 }
274
275 fn search_budgeted<'a>(
276 &'a self,
277 req: &'a pagesift_api::types::SearchRequest,
278 budget: Arc<dyn pagesift_api::PageBudget>,
279 ) -> BoxFuture<'a, Result<serde_json::Value, ApiError>> {
280 Box::pin(async move { self.search_with_budget(req, Some(budget.as_ref())).await })
281 }
282
283 fn extract_budgeted<'a>(
284 &'a self,
285 req: &'a pagesift_api::types::ExtractRequest,
286 budget: Arc<dyn pagesift_api::PageBudget>,
287 ) -> BoxFuture<'a, Result<serde_json::Value, ApiError>> {
288 Box::pin(async move { self.extract_with_budget(req, Some(budget.as_ref())).await })
289 }
290
291 fn supports_interact(&self) -> bool {
292 self.interact_available()
293 }
294
295 fn supports_actions(&self) -> bool {
297 self.interact_available()
298 }
299
300 fn actions_unavailable(&self) -> &'static str {
301 "actions need Chrome: no Chrome or Chromium was found on this machine (run `pagesift doctor`)"
302 }
303
304 fn scrape_session<'a>(
305 &'a self,
306 url: &'a str,
307 opts: &'a ScrapeOptions,
308 keep: bool,
309 owner: Option<&'a str>,
310 ) -> BoxFuture<'a, Result<Document, ApiError>> {
311 Box::pin(Scraper::scrape_session(self, url, opts, keep, owner))
312 }
313
314 fn observe<'a>(
315 &'a self,
316 url: &'a str,
317 instruction: &'a str,
318 ) -> BoxFuture<'a, Result<pagesift_api::types::Observed, ApiError>> {
319 Box::pin(async move {
320 let (found, used) = Scraper::observe(self, url, instruction).await?;
321 let usage = format!("llm_usage: {} calls, {} tokens", used.calls, used.tokens);
322 Ok((found, vec![usage]))
323 })
324 }
325
326 fn find_similar<'a>(
327 &'a self,
328 url: &'a str,
329 selector: &'a str,
330 opts: &'a ScrapeOptions,
331 ) -> BoxFuture<'a, Result<pagesift_api::types::SimilarData, ApiError>> {
332 Box::pin(Scraper::find_similar(self, url, selector, opts))
333 }
334
335 fn interact<'a>(
336 &'a self,
337 id: &'a str,
338 req: &'a pagesift_api::types::InteractRequest,
339 owner: Option<&'a str>,
340 ) -> BoxFuture<'a, Result<pagesift_api::types::InteractResponse, ApiError>> {
341 Box::pin(Scraper::interact(self, id, req, owner))
342 }
343
344 fn stop_interact<'a>(
345 &'a self,
346 id: &'a str,
347 owner: Option<&'a str>,
348 ) -> BoxFuture<'a, Result<pagesift_api::types::InteractStopped, ApiError>> {
349 Box::pin(Scraper::stop_interact(self, id, owner))
350 }
351
352 fn agent_check(&self, req: &pagesift_api::types::AgentRequest) -> Result<(), ApiError> {
353 agent::check(self.llm.is_some(), self.search.is_some(), req)
354 }
355
356 fn agent<'a>(
357 &'a self,
358 req: &'a pagesift_api::types::AgentRequest,
359 sink: Arc<dyn pagesift_api::AgentSink>,
360 ) -> BoxFuture<'a, pagesift_api::types::AgentOutcome> {
361 Box::pin(async move {
362 match &self.llm {
363 Some(llm) => agent::run(self, llm, self.search.is_some(), req, sink.as_ref()).await,
364 None => pagesift_api::types::AgentOutcome {
365 finish_reason: "error".into(),
366 error: Some(pagesift_api::types::AgentFailure::from(
367 &ApiError::unsupported("agent needs an LLM: set [llm] in pagesift.toml"),
368 )),
369 ..Default::default()
370 },
371 }
372 })
373 }
374
375 fn admitted_concurrency(&self) -> Option<usize> {
377 Some(self.concurrency())
378 }
379
380 fn supported_formats(&self) -> &'static [&'static str] {
381 match (self.llm.is_some(), self.store.is_some()) {
382 (true, true) => &[
383 "markdown",
384 "html",
385 "rawHtml",
386 "links",
387 "images",
388 "extract",
389 "summary",
390 "json",
391 "changeTracking",
392 ],
393 (true, false) => &[
394 "markdown", "html", "rawHtml", "links", "images", "extract", "summary", "json",
395 ],
396 (false, true) => &[
397 "markdown",
398 "html",
399 "rawHtml",
400 "links",
401 "images",
402 "extract",
403 "changeTracking",
404 ],
405 (false, false) => &["markdown", "html", "rawHtml", "links", "images", "extract"],
406 }
407 }
408}
409
410struct SchemeGuard;
412
413impl RequestGuard for SchemeGuard {
414 fn check<'a>(&'a self, req: &'a SubRequest) -> pagesift_cdp::BoxFuture<'a, Verdict> {
415 let ok = Url::parse(&req.url)
416 .is_ok_and(|u| matches!(u.scheme(), "http" | "https" | "data" | "blob" | "about"));
417 Box::pin(async move {
418 if ok {
419 Verdict::Allow
420 } else {
421 Verdict::Block("scheme not allowed".into())
422 }
423 })
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 fn content(md: &str, published: bool, og_article: bool) -> pagesift_content::Document {
432 let mut d = pagesift_content::process("<p>x</p>", &Default::default());
433 d.markdown = md.to_string();
434 d.metadata.published_time = published.then(|| "2026-10-21T00:00:00Z".into());
435 if og_article {
436 d.metadata.extra.push(("og:type".into(), "article".into()));
437 }
438 d
439 }
440
441 #[test]
442 fn static_learning_cannot_suppress_client_code() {
443 for html in [
444 "<script>hydrate()</script>",
445 "<script src='/app.js'></script>",
446 "<SCRIPT TYPE='module' src='/view.js'></SCRIPT>",
447 "<body onload='hydrate()'>",
448 "<button onclick='hydrate()'>Open</button>",
449 ] {
450 assert!(has_client_code(html), "{html}");
451 }
452 for html in [
453 "<article><p>Short but complete.</p></article>",
454 "<script type='application/ld+json'>{}</script>",
455 "<script type='APPLICATION/JSON; charset=utf-8'>{}</script>",
456 "<script type='text/plain'>inert sample</script>",
457 ] {
458 assert!(!has_client_code(html), "{html}");
459 }
460 }
461
462 #[test]
463 fn render_rule_weights_cjk_and_trusts_short_articles() {
464 assert_eq!(text_weight("ab c\n"), 3);
465 assert_eq!(text_weight("日本語のニュース"), 8 * CJK_WEIGHT);
466 assert!(!needs_render(&content(&"字".repeat(500), false, false)));
468 assert!(needs_render(&content(&"a".repeat(500), false, false)));
469 assert!(!needs_render(&content(&"a".repeat(400), true, false)));
471 assert!(!needs_render(&content(&"a".repeat(400), false, true)));
472 assert!(needs_render(&content(&"a".repeat(100), true, true)));
473 }
474
475 #[test]
476 fn render_errors_are_classified() {
477 use pagesift_cdp::Error as E;
478 for (e, code) in [
479 (
480 E::Action {
481 index: 0,
482 message: "no element".into(),
483 },
484 ErrorCode::InvalidRequest,
485 ),
486 (E::InvalidOptions("x".into()), ErrorCode::InvalidRequest),
487 (E::ExecuteJavascriptDisabled, ErrorCode::UnsupportedOption),
488 (E::Timeout, ErrorCode::Timeout),
489 (E::Busy, ErrorCode::Busy),
490 (
491 E::Navigation("net::ERR_NAME_NOT_RESOLVED".into()),
492 ErrorCode::DnsFailure,
493 ),
494 (
495 E::Navigation("net::ERR_CONNECTION_RESET".into()),
496 ErrorCode::NetworkError,
497 ),
498 (
499 E::Navigation("net::ERR_CERT_DATE_INVALID".into()),
500 ErrorCode::TlsCertExpired,
501 ),
502 (
503 E::Navigation("net::ERR_CERT_COMMON_NAME_INVALID".into()),
504 ErrorCode::TlsHostnameMismatch,
505 ),
506 (
507 E::Navigation("net::ERR_CERT_AUTHORITY_INVALID".into()),
508 ErrorCode::TlsUntrustedRoot,
509 ),
510 (
511 E::Navigation("net::ERR_CERT_REVOKED".into()),
512 ErrorCode::TlsError,
513 ),
514 (E::ConnectionClosed, ErrorCode::UpstreamError),
515 ] {
516 assert_eq!(render_error_code(&e), code, "{e}");
517 }
518 }
519
520 #[test]
521 fn finds_only_visible_same_origin_and_srcdoc_iframes() {
522 let base = Url::parse("https://a.test/page").unwrap();
523 let html = r#"<body>
524 <iframe src="https://www.googletagmanager.com/ns.html"></iframe>
525 <iframe src="/pixel" style="display: none"></iframe>
526 <iframe src="/hidden" aria-hidden="true"></iframe>
527 <iframe src="/tiny" width="1" height="1"></iframe>
528 <iframe src="about:blank"></iframe>
529 <iframe src="/comments?id=1&x=2" height="160"></iframe>
530 <iframe srcdoc="<p>inline</p>"></iframe>
531 <iframe srcdoc="<p>x</p>" style="visibility:hidden"></iframe>
532 </body>"#;
533 let frames = content_iframes(html, &base);
534 assert_eq!(frames.len(), 2, "{frames:?}");
535 assert_eq!(frames[0].0, "/comments?id=1&x=2");
536 assert_eq!(
537 frames[0].1.as_ref().map(Url::as_str),
538 Some("https://a.test/comments?id=1&x=2")
539 );
540 assert_eq!(frames[1], ("<p>inline</p>".to_string(), None));
541 assert!(content_iframes("<p>no frames</p>", &base).is_empty());
542 }
543
544 #[test]
545 fn replaces_the_matching_iframe_element() {
546 let mut html = r#"<p>a</p><IFRAME src="/x" hidden></IFRAME><iframe src="/x">fallback</iframe><p>b</p>"#
547 .to_string();
548 assert!(replace_iframe(&mut html, "<div>X</div>", |el| {
549 !hidden_frame(el) && src_is(el, "/x")
550 }));
551 assert_eq!(
552 html,
553 r#"<p>a</p><IFRAME src="/x" hidden></IFRAME><div>X</div><p>b</p>"#
554 );
555 assert!(!replace_iframe(&mut html, "", |el| src_is(el, "/missing")));
556 }
557
558 #[test]
559 fn takes_the_body_of_an_iframe_document() {
560 assert_eq!(
561 body_inner("<html><head><title>t</title></head><BODY class=x><p>hi</p></body></html>"),
562 "<p>hi</p>"
563 );
564 assert_eq!(body_inner("<p>fragment</p>"), "<p>fragment</p>");
565 }
566
567 #[test]
568 fn meta_keys_follow_firecrawl_style() {
569 assert_eq!(pagesift_sink::meta::key("og:image"), "ogImage");
570 assert_eq!(pagesift_sink::meta::key("og:site_name"), "ogSiteName");
571 assert_eq!(
572 pagesift_sink::meta::key("article:published_time"),
573 "articlePublishedTime"
574 );
575 assert_eq!(pagesift_sink::meta::key("dc.title"), "dcTitle");
576 }
577}