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