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