use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use pagesift_api::types::{Document, Metadata, PagesiftMeta, ScrapeOptions};
use pagesift_api::{ApiError, BoxFuture, Engine, ErrorCode};
use pagesift_cdp::{
AllowListGuard, BlockResources, BrowserPool, NetworkAllowList, PoolConfig, PrivateNetworkGuard,
RenderOptions, RenderedPage, RequestGuard, SubRequest, Verdict,
};
use pagesift_net::{FetchRequest, Fetcher, FetcherConfig, Url};
use pagesift_policy::{Policy, PolicyConfig, RequestKind, RobotsMode};
use tokio::sync::OnceCell;
tokio::task_local! {
static ITNA_NETWORK_BUDGET: Arc<pagesift_itna::NetworkBudget>;
static ITNA_COOKIE_SESSION: Option<Arc<pagesift_net::CookieSession>>;
static ITNA_EXPECTED_DIRECT: Option<bool>;
static ITNA_DEFAULT_PROXY: Option<String>;
}
pub mod agent;
mod autoschema;
mod cache;
mod cpu;
mod csv_text;
mod egress_route;
mod extract;
mod feed;
mod fetch;
mod frames;
mod hooked;
mod http_extract;
mod image_file;
mod images;
mod interact;
mod office;
mod output;
mod parse;
mod pdf;
pub mod planner;
mod render;
mod render_stats;
mod scraper;
mod speculate;
pub use autoschema::ActionSecret;
pub use hooked::ScraperBuilder;
pub use interact::InteractLimits;
mod similar;
mod structured;
use cpu::{CONTENT_WORKSPACE, cpu_task};
pub use cpu::{set_cpu_capacity, set_cpu_groups};
use extract::*;
use frames::*;
use http_extract::*;
use office::*;
pub use office::{office_markdown, office_markdown_from};
pub use output::*;
use render::*;
pub const AUTO_RENDER_MIN_CHARS: usize = 1200;
pub const AUTO_RENDER_ARTICLE_MIN_CHARS: usize = 300;
pub const CJK_WEIGHT: usize = 3;
pub fn text_weight(s: &str) -> usize {
s.chars()
.filter(|c| !c.is_whitespace())
.map(|c| if is_cjk(c) { CJK_WEIGHT } else { 1 })
.sum()
}
fn is_cjk(c: char) -> bool {
matches!(c as u32,
0x3040..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0x20000..=0x2FA1F)
}
pub fn needs_render(doc: &pagesift_content::Document) -> bool {
let w = text_weight(&doc.markdown);
if w >= AUTO_RENDER_MIN_CHARS {
return false;
}
let m = &doc.metadata;
let article = m.published_time.is_some()
|| m.extra
.iter()
.any(|(k, v)| k == "og:type" && v.eq_ignore_ascii_case("article"));
!(article && w >= AUTO_RENDER_ARTICLE_MIN_CHARS)
}
pub const RENDER_FALLBACK_MIN_CHARS: usize = 200;
#[derive(Debug, Clone)]
pub struct EngineProbe {
pub engine: &'static str,
pub location: String,
pub result: Result<(), String>,
}
#[derive(Clone, Default)]
pub struct ScraperConfig {
pub fetcher: FetcherConfig,
pub policy: PolicyConfig,
pub itna: bool,
pub browser: PoolConfig,
pub store: Option<pagesift_store::Store>,
pub llm: Option<pagesift_llm::LlmConfig>,
pub search: Option<Arc<dyn pagesift_search::SearchBackend>>,
pub egress: Vec<(String, String)>,
pub egress_classes: std::collections::HashMap<String, pagesift_policy::EgressClass>,
pub audit: Option<Arc<crate::audit::Audit>>,
pub hooks: pagesift_hooks::Hooks,
pub secrets: std::collections::BTreeMap<String, ActionSecret>,
pub ocr: Option<Arc<dyn pagesift_pdf::PageOcr>>,
pub interact: InteractLimits,
}
#[derive(Debug)]
pub enum ScraperError {
Fetcher(pagesift_net::Error),
Llm(pagesift_llm::LlmError),
}
impl std::fmt::Display for ScraperError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Fetcher(e) => write!(f, "fetcher configuration: {e}"),
Self::Llm(e) => write!(f, "LLM configuration: {e}"),
}
}
}
impl std::error::Error for ScraperError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Fetcher(e) => Some(e),
Self::Llm(e) => Some(e),
}
}
}
pub struct Scraper {
policy: Arc<Policy>,
itna: bool,
identities: pagesift_policy::identity_pool::IdentityPool,
store: Option<pagesift_store::Store>,
llm: Option<pagesift_llm::ChatClient>,
search: Option<Arc<dyn pagesift_search::SearchBackend>>,
ocr: Option<Arc<dyn pagesift_pdf::PageOcr>>,
browser_private_network: bool,
browser_allow: NetworkAllowList,
browsers: Vec<(PoolConfig, OnceCell<Option<Arc<BrowserPool>>>)>,
launch_failed: Vec<std::sync::Mutex<Option<Instant>>>,
launch_notes: Vec<std::sync::Mutex<Option<String>>>,
launch_broken: std::sync::atomic::AtomicBool,
crawl_slots: pagesift_policy::AdaptiveConcurrencyGate,
learner: crate::learn::HostLearner,
planner: planner::RenderPlanner,
render_stats: render_stats::RenderStats,
audit: Option<Arc<crate::audit::Audit>>,
hooks: pagesift_hooks::Hooks,
secrets: std::collections::BTreeMap<String, ActionSecret>,
pages: std::sync::OnceLock<Arc<dyn crate::PageRunner>>,
sessions: Arc<interact::Sessions>,
}
pub(super) enum Source<'a> {
Url(&'a str, RobotsMode),
Upload(pagesift_api::types::Upload),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EngineChoice {
Http,
Browser,
Auto,
}
impl Engine for Scraper {
fn scrape<'a>(
&'a self,
url: &'a str,
opts: &'a ScrapeOptions,
) -> BoxFuture<'a, Result<Document, ApiError>> {
Box::pin(Scraper::scrape(self, url, opts))
}
fn parse<'a>(
&'a self,
file: pagesift_api::types::Upload,
opts: &'a ScrapeOptions,
) -> BoxFuture<'a, Result<Document, ApiError>> {
Box::pin(Scraper::parse(self, file, opts))
}
fn map<'a>(
&'a self,
req: &'a pagesift_api::types::MapRequest,
) -> BoxFuture<'a, Result<Vec<pagesift_api::types::MapLink>, ApiError>> {
Box::pin(Scraper::map(self, req))
}
fn crawl<'a>(
&'a self,
req: &'a pagesift_api::types::CrawlRequest,
sink: Arc<dyn pagesift_api::CrawlSink>,
) -> BoxFuture<'a, Result<(), ApiError>> {
Box::pin(Scraper::crawl(self, req, sink))
}
fn search<'a>(
&'a self,
req: &'a pagesift_api::types::SearchRequest,
) -> BoxFuture<'a, Result<serde_json::Value, ApiError>> {
Box::pin(Scraper::search(self, req))
}
fn extract<'a>(
&'a self,
req: &'a pagesift_api::types::ExtractRequest,
) -> BoxFuture<'a, Result<serde_json::Value, ApiError>> {
Box::pin(Scraper::extract(self, req))
}
fn supports_interact(&self) -> bool {
self.interact_available()
}
fn supports_actions(&self) -> bool {
self.interact_available()
}
fn actions_unavailable(&self) -> &'static str {
"actions need Chrome: no Chrome or Chromium was found on this machine (run `pagesift doctor`)"
}
fn scrape_session<'a>(
&'a self,
url: &'a str,
opts: &'a ScrapeOptions,
keep: bool,
owner: Option<&'a str>,
) -> BoxFuture<'a, Result<Document, ApiError>> {
Box::pin(Scraper::scrape_session(self, url, opts, keep, owner))
}
fn observe<'a>(
&'a self,
url: &'a str,
instruction: &'a str,
) -> BoxFuture<'a, Result<pagesift_api::types::Observed, ApiError>> {
Box::pin(async move {
let (found, used) = Scraper::observe(self, url, instruction).await?;
let usage = format!("llm_usage: {} calls, {} tokens", used.calls, used.tokens);
Ok((found, vec![usage]))
})
}
fn find_similar<'a>(
&'a self,
url: &'a str,
selector: &'a str,
opts: &'a ScrapeOptions,
) -> BoxFuture<'a, Result<pagesift_api::types::SimilarData, ApiError>> {
Box::pin(Scraper::find_similar(self, url, selector, opts))
}
fn interact<'a>(
&'a self,
id: &'a str,
req: &'a pagesift_api::types::InteractRequest,
owner: Option<&'a str>,
) -> BoxFuture<'a, Result<pagesift_api::types::InteractResponse, ApiError>> {
Box::pin(Scraper::interact(self, id, req, owner))
}
fn stop_interact<'a>(
&'a self,
id: &'a str,
owner: Option<&'a str>,
) -> BoxFuture<'a, Result<pagesift_api::types::InteractStopped, ApiError>> {
Box::pin(Scraper::stop_interact(self, id, owner))
}
fn agent_check(&self, req: &pagesift_api::types::AgentRequest) -> Result<(), ApiError> {
agent::check(self.llm.is_some(), self.search.is_some(), req)
}
fn agent<'a>(
&'a self,
req: &'a pagesift_api::types::AgentRequest,
sink: Arc<dyn pagesift_api::AgentSink>,
) -> BoxFuture<'a, pagesift_api::types::AgentOutcome> {
Box::pin(async move {
match &self.llm {
Some(llm) => agent::run(self, llm, self.search.is_some(), req, sink.as_ref()).await,
None => pagesift_api::types::AgentOutcome {
finish_reason: "error".into(),
error: Some(pagesift_api::types::AgentFailure::from(
&ApiError::unsupported("agent needs an LLM: set [llm] in pagesift.toml"),
)),
..Default::default()
},
}
})
}
fn admitted_concurrency(&self) -> Option<usize> {
Some(self.concurrency())
}
fn supported_formats(&self) -> &'static [&'static str] {
match (self.llm.is_some(), self.store.is_some()) {
(true, true) => &[
"markdown",
"html",
"rawHtml",
"links",
"images",
"extract",
"summary",
"json",
"changeTracking",
],
(true, false) => &[
"markdown", "html", "rawHtml", "links", "images", "extract", "summary", "json",
],
(false, true) => &[
"markdown",
"html",
"rawHtml",
"links",
"images",
"extract",
"changeTracking",
],
(false, false) => &["markdown", "html", "rawHtml", "links", "images", "extract"],
}
}
}
struct SchemeGuard;
impl RequestGuard for SchemeGuard {
fn check<'a>(&'a self, req: &'a SubRequest) -> pagesift_cdp::BoxFuture<'a, Verdict> {
let ok = Url::parse(&req.url)
.is_ok_and(|u| matches!(u.scheme(), "http" | "https" | "data" | "blob" | "about"));
Box::pin(async move {
if ok {
Verdict::Allow
} else {
Verdict::Block("scheme not allowed".into())
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn content(md: &str, published: bool, og_article: bool) -> pagesift_content::Document {
let mut d = pagesift_content::process("<p>x</p>", &Default::default());
d.markdown = md.to_string();
d.metadata.published_time = published.then(|| "2026-10-21T00:00:00Z".into());
if og_article {
d.metadata.extra.push(("og:type".into(), "article".into()));
}
d
}
#[test]
fn static_learning_cannot_suppress_client_code() {
for html in [
"<script>hydrate()</script>",
"<script src='/app.js'></script>",
"<SCRIPT TYPE='module' src='/view.js'></SCRIPT>",
"<body onload='hydrate()'>",
"<button onclick='hydrate()'>Open</button>",
] {
assert!(has_client_code(html), "{html}");
}
for html in [
"<article><p>Short but complete.</p></article>",
"<script type='application/ld+json'>{}</script>",
"<script type='APPLICATION/JSON; charset=utf-8'>{}</script>",
"<script type='text/plain'>inert sample</script>",
] {
assert!(!has_client_code(html), "{html}");
}
}
#[test]
fn render_rule_weights_cjk_and_trusts_short_articles() {
assert_eq!(text_weight("ab c\n"), 3);
assert_eq!(text_weight("日本語のニュース"), 8 * CJK_WEIGHT);
assert!(!needs_render(&content(&"字".repeat(500), false, false)));
assert!(needs_render(&content(&"a".repeat(500), false, false)));
assert!(!needs_render(&content(&"a".repeat(400), true, false)));
assert!(!needs_render(&content(&"a".repeat(400), false, true)));
assert!(needs_render(&content(&"a".repeat(100), true, true)));
}
#[test]
fn render_errors_are_classified() {
use pagesift_cdp::Error as E;
for (e, code) in [
(
E::Action {
index: 0,
message: "no element".into(),
},
ErrorCode::InvalidRequest,
),
(E::InvalidOptions("x".into()), ErrorCode::InvalidRequest),
(E::ExecuteJavascriptDisabled, ErrorCode::UnsupportedOption),
(E::Timeout, ErrorCode::Timeout),
(E::Busy, ErrorCode::Busy),
(
E::Navigation("net::ERR_NAME_NOT_RESOLVED".into()),
ErrorCode::DnsFailure,
),
(
E::Navigation("net::ERR_CONNECTION_RESET".into()),
ErrorCode::NetworkError,
),
(
E::Navigation("net::ERR_CERT_DATE_INVALID".into()),
ErrorCode::TlsCertExpired,
),
(
E::Navigation("net::ERR_CERT_COMMON_NAME_INVALID".into()),
ErrorCode::TlsHostnameMismatch,
),
(
E::Navigation("net::ERR_CERT_AUTHORITY_INVALID".into()),
ErrorCode::TlsUntrustedRoot,
),
(
E::Navigation("net::ERR_CERT_REVOKED".into()),
ErrorCode::TlsError,
),
(E::ConnectionClosed, ErrorCode::UpstreamError),
] {
assert_eq!(render_error_code(&e), code, "{e}");
}
}
#[test]
fn finds_only_visible_same_origin_and_srcdoc_iframes() {
let base = Url::parse("https://a.test/page").unwrap();
let html = r#"<body>
<iframe src="https://www.googletagmanager.com/ns.html"></iframe>
<iframe src="/pixel" style="display: none"></iframe>
<iframe src="/hidden" aria-hidden="true"></iframe>
<iframe src="/tiny" width="1" height="1"></iframe>
<iframe src="about:blank"></iframe>
<iframe src="/comments?id=1&x=2" height="160"></iframe>
<iframe srcdoc="<p>inline</p>"></iframe>
<iframe srcdoc="<p>x</p>" style="visibility:hidden"></iframe>
</body>"#;
let frames = content_iframes(html, &base);
assert_eq!(frames.len(), 2, "{frames:?}");
assert_eq!(frames[0].0, "/comments?id=1&x=2");
assert_eq!(
frames[0].1.as_ref().map(Url::as_str),
Some("https://a.test/comments?id=1&x=2")
);
assert_eq!(frames[1], ("<p>inline</p>".to_string(), None));
assert!(content_iframes("<p>no frames</p>", &base).is_empty());
}
#[test]
fn replaces_the_matching_iframe_element() {
let mut html = r#"<p>a</p><IFRAME src="/x" hidden></IFRAME><iframe src="/x">fallback</iframe><p>b</p>"#
.to_string();
assert!(replace_iframe(&mut html, "<div>X</div>", |el| {
!hidden_frame(el) && src_is(el, "/x")
}));
assert_eq!(
html,
r#"<p>a</p><IFRAME src="/x" hidden></IFRAME><div>X</div><p>b</p>"#
);
assert!(!replace_iframe(&mut html, "", |el| src_is(el, "/missing")));
}
#[test]
fn takes_the_body_of_an_iframe_document() {
assert_eq!(
body_inner("<html><head><title>t</title></head><BODY class=x><p>hi</p></body></html>"),
"<p>hi</p>"
);
assert_eq!(body_inner("<p>fragment</p>"), "<p>fragment</p>");
}
#[test]
fn meta_keys_follow_firecrawl_style() {
assert_eq!(pagesift_sink::meta::key("og:image"), "ogImage");
assert_eq!(pagesift_sink::meta::key("og:site_name"), "ogSiteName");
assert_eq!(
pagesift_sink::meta::key("article:published_time"),
"articlePublishedTime"
);
assert_eq!(pagesift_sink::meta::key("dc.title"), "dcTitle");
}
}