use super::*;
pub(super) use super::render_stats::LAUNCH_RETRY;
impl Scraper {
#[allow(clippy::too_many_arguments)] pub(super) async fn render(
&self,
url: &str,
opts: &ScrapeOptions,
mode: RobotsMode,
progress: Progress,
http_chars: Option<usize>,
early_stop: Option<pagesift_cdp::EarlyStop>,
proxy: Option<String>,
) -> Result<Document, ApiError> {
let Progress {
started,
mut timings,
mut warnings,
} = progress;
let keep = super::interact::KEEP.try_with(Arc::clone).ok();
let actions = pagesift_cdp::parse_actions(&serde_json::Value::Array(opts.actions.clone()))
.map_err(|e| ApiError::invalid(e.to_string()))?;
let chrome_only = keep.is_some() || !actions.is_empty();
if chrome_only && !self.interact_available() {
return Err(ApiError::unsupported(
"actions and keepSession need Chrome: no Chrome or Chromium was found on this machine (run `pagesift doctor`)",
));
}
if !self.has_browser().await {
let why = format!(
"browser rendering is unavailable: {}",
self.browser_unavailable_reason()
);
return Err(if self.browser_launch_failed() {
ApiError::new(ErrorCode::UpstreamError, why)
} else {
ApiError::unsupported(why)
});
}
let target = Url::parse(url).map_err(|e| ApiError::invalid(e.to_string()))?;
let host = target.host_str().unwrap_or_default().to_string();
let crawl_delay = match mode {
RobotsMode::Respect => self
.policy
.check_robots(&target)
.await
.map_err(policy_error)?,
RobotsMode::Ignore => None,
};
let acquire = self.policy.limiter().acquire(&host, crawl_delay);
let learn_deadline = request_deadline(opts, started).is_none() && actions.is_empty();
let action_wait: Duration = actions
.iter()
.map(|a| match a {
pagesift_cdp::Action::Wait {
milliseconds: Some(ms),
..
} => Duration::from_millis(*ms),
_ => Duration::ZERO,
})
.sum();
let visual = actions.iter().any(|a| {
matches!(
a,
pagesift_cdp::Action::Screenshot(_) | pagesift_cdp::Action::Pdf { .. }
)
});
let (permit, deadline) = match request_deadline(opts, started) {
Some(d) => match tokio::time::timeout_at(d.into(), acquire).await {
Ok(p) => (p, d),
Err(_) => {
return Err(ApiError::new(
ErrorCode::Timeout,
"request deadline passed while waiting for the host's rate limit",
));
}
},
None => (
acquire.await,
Instant::now() + self.render_stats.default_timeout() + action_wait,
),
};
let render_start = Instant::now();
let queued = permit.waited();
timings
.entry("queue".into())
.and_modify(|total| *total += ms(queued))
.or_insert_with(|| ms(queued));
let queue_note = permit.robots_crawl_delay().then(|| permit.interval());
let paced = !permit.interval().is_zero();
let mut ropts = RenderOptions {
wait_for: Duration::from_millis(opts.wait_for),
timeout: deadline.saturating_duration_since(Instant::now()),
mobile: opts.mobile,
languages: opts
.location
.as_ref()
.map(|l| l.languages.clone())
.unwrap_or_default(),
block: BlockResources {
ads: opts.block_ads,
fonts: !visual,
media: true,
images: !chrome_only,
},
proxy,
actions,
wait_until: wait_until(opts.respond_timing.as_deref()),
..RenderOptions::default()
};
let mut best: Option<Rendered> = None;
let mut last_err = None;
let mut failed_after: Vec<&'static str> = Vec::new();
let mut pending_fallback: Option<(String, (Option<f64>, usize))> = None;
let from = self
.learner
.start_engine(&host)
.min(self.browsers.len().saturating_sub(1));
let from = if chrome_only { 0 } else { from };
for i in from..self.browsers.len() {
if ropts.proxy.is_some() && !proxy_capable(&self.browsers[i].0) {
continue;
}
if chrome_only && !super::interact::interact_capable(&self.browsers[i].0) {
continue;
}
if self.launch_failed[i]
.lock()
.unwrap()
.is_some_and(|t| t.elapsed() < self.render_stats.launch_retry())
{
continue;
}
let Some(pool) = self.browser(i).await else {
continue;
};
let engine = pool.engine();
if early_stop.as_ref().is_some_and(|s| s.cap_navigation) && !engine.renders_iframes() {
continue;
}
ropts.early_stop = early_stop.clone();
ropts.early_stop_probe = engine.renders_iframes();
ropts.nav_patience = match early_stop.as_ref() {
Some(stop) if stop.baseline_chars > 0 => self.render_stats.nav_patience(),
_ => None,
};
ropts.timeout = deadline.saturating_duration_since(Instant::now());
if ropts.timeout < self.render_stats.min_attempt(engine) {
last_err.get_or_insert(pagesift_cdp::Error::Timeout);
break;
}
let Ok(_tab) = tokio::time::timeout(
ropts.timeout,
self.render_stats.tab_permit(self.tab_capacity()),
)
.await
else {
last_err.get_or_insert(pagesift_cdp::Error::Busy);
break;
};
ropts.timeout = deadline.saturating_duration_since(Instant::now());
let rendered = match &keep {
Some(slot) => pool.render_kept(url, &ropts).await.map(|(p, kept)| {
slot.put(kept, pool.clone());
p
}),
None => pool.render(url, &ropts).await,
};
let page = match rendered {
Ok(p) => {
self.render_stats.note_launch_ok(i);
let t = &p.timings;
self.render_stats
.note_first_byte(engine, t.acquire + t.setup + t.navigation);
p
}
Err(e) => {
tracing::debug!(engine = engine.name(), url, "render failed: {e}");
if matches!(
e,
pagesift_cdp::Error::Launch(_) | pagesift_cdp::Error::Connect(_)
) {
self.note_launch_failure(i, engine.name(), &e);
self.render_stats.note_launch_failure(i);
}
if best.is_some() {
failed_after.push(engine.name());
}
let final_err =
engine.renders_iframes() && matches!(e, pagesift_cdp::Error::Navigation(_));
last_err = Some(e);
if final_err {
break;
}
continue;
}
};
let final_url = Url::parse(&page.final_url).unwrap_or_else(|_| target.clone());
let RenderedPage {
mut html,
status,
content_type,
timings: page_timings,
iframes_inlined,
early_stopped,
first_content,
actions: outputs,
..
} = page;
let mut missing_iframes = 0;
if !engine.renders_iframes() && iframes_inlined == 0 {
let frames = content_iframes(&html, &final_url);
if !frames.is_empty() {
missing_iframes = inline_iframes_http(
self.policy.fetcher(),
&mut html,
frames,
ropts.timeout,
)
.await;
}
}
let t = Instant::now();
let (doc, html) = process_html(html, &final_url, status, opts).await?;
let extract = t.elapsed();
let gained = http_chars.is_some_and(|n| text_weight(&doc.markdown) > n);
if ropts.actions.is_empty() {
self.render_stats.note_render(
page_timings.navigation,
page_timings.wait,
page_timings.total,
first_content,
gained,
);
}
if let Some((family, before)) = pending_fallback.take() {
let after = (
super::planner::quality(&doc, html.len()),
text_weight(&doc.markdown),
);
self.record_fallback(&family, before, after);
}
let thin = status.is_none_or(|s| s < 400)
&& text_weight(&doc.markdown) < RENDER_FALLBACK_MIN_CHARS
&& !engine.renders_iframes()
&& missing_iframes == 0
&& {
let family = super::planner::fallback_family(url, i);
let q = super::planner::quality(&doc, html.len());
let (go, _) = self.plan_fallback(&family, true, q, opts, started);
if go {
pending_fallback = Some((family, (q, text_weight(&doc.markdown))));
}
go
};
let r = Rendered {
index: i,
engine: engine.name(),
final_url,
status,
content_type,
timings: page_timings,
extract,
doc,
html,
actions: (!ropts.actions.is_empty()).then(|| outputs.to_firecrawl_json()),
};
if early_stopped {
best = Some(r);
break;
}
if (missing_iframes == 0 && !thin) || engine.renders_iframes() {
let mut r = r;
if engine.renders_iframes()
&& let Some(prev) = best.as_ref()
{
merge_engines(&mut r.doc, r.html.len(), &prev.doc, prev.html.len());
}
best = Some(r);
break;
}
tracing::debug!(
engine = engine.name(),
url,
missing_iframes,
thin,
"trying the next browser engine"
);
if best.is_none() {
best = Some(r);
}
}
if learn_deadline {
let timed_out =
best.is_none() && matches!(last_err, Some(pagesift_cdp::Error::Timeout));
if best.is_some() || timed_out {
self.render_stats
.note_default_timeout(render_start.elapsed(), best.is_some());
}
}
let Some(r) = best else {
return Err(match last_err {
Some(e) => ApiError::new(render_error_code(&e), format!("rendering failed: {e}")),
None => ApiError::new(ErrorCode::UpstreamError, "no browser engine available"),
});
};
if let Some(kind) = unsupported_content_type(r.content_type.as_deref(), r.html.as_bytes()) {
return Err(ApiError::unsupported(format!(
"unsupported content type `{kind}`: pagesift extracts HTML/text; use `pagesift download` or `media-extract` for binary media"
)));
}
self.learner.record_engine(&host, from, r.index);
let _ = http_chars;
let t = &r.timings;
if paced && queued > t.total {
match queue_note {
Some(interval) => warnings.push(format!(
"queue_wait: request waited {:.0} ms in the rate-limit/admission queue; robots.txt Crawl-delay applies with an effective {:.0} ms interval",
ms(queued),
ms(interval)
)),
None => warnings.push(format!(
"queue_wait: request waited {:.0} ms in the rate-limit/admission queue",
ms(queued)
)),
}
}
for (k, v) in [
("render", t.total),
("render_acquire", t.acquire),
("render_setup", t.setup),
("render_navigation", t.navigation),
("render_wait", t.wait),
("render_html", t.html),
("render_close", t.close),
] {
timings.insert(k.into(), ms(v));
}
timings.insert("extract".into(), ms(r.extract));
check_barrier(&r.doc)?;
let chars = text_weight(&r.doc.markdown);
if !failed_after.is_empty()
&& r.status.is_none_or(|s| s < 400)
&& chars < RENDER_FALLBACK_MIN_CHARS
{
warnings.push(format!(
"render_incomplete: {} rendered only {chars} characters and {} could not render the page; the content may not have loaded",
r.engine,
failed_after.join(", ")
));
}
Ok(rendered_document(r, url, opts, timings, warnings, started))
}
pub(super) async fn live_document(
&self,
page: RenderedPage,
url: &str,
opts: &ScrapeOptions,
warnings: Vec<String>,
started: Instant,
) -> Result<Document, ApiError> {
let final_url = Url::parse(&page.final_url)
.map_err(|e| ApiError::new(ErrorCode::UpstreamError, format!("page URL: {e}")))?;
let t = Instant::now();
let RenderedPage {
html,
status,
content_type,
timings: page_timings,
..
} = page;
let (doc, html) = process_html(html, &final_url, status, opts).await?;
check_barrier(&doc)?;
let r = Rendered {
index: 0,
engine: pagesift_cdp::EngineKind::Chrome.name(),
final_url,
status,
content_type,
timings: page_timings,
extract: t.elapsed(),
doc,
html,
actions: None,
};
Ok(rendered_document(
r,
url,
opts,
BTreeMap::new(),
warnings,
started,
))
}
}
fn rendered_document(
r: Rendered,
url: &str,
opts: &ScrapeOptions,
timings: BTreeMap<String, f64>,
mut warnings: Vec<String>,
started: Instant,
) -> Document {
let mut meta = Metadata {
url: Some(r.final_url.to_string()),
source_url: Some(url.to_string()),
status_code: r.status.unwrap_or(200),
content_type: r.content_type.clone(),
proxy_used: "basic".into(),
..Default::default()
};
fill_metadata(&mut meta, &r.doc.metadata);
let mut ext = PagesiftMeta {
engine: Some("browser".into()),
browser_engine: Some(r.engine.into()),
..Default::default()
};
fill_structured(&mut ext, &r.doc);
fill_fingerprints(&mut ext, &r.doc);
if r.doc.metadata.video_fallback {
warnings.push(
"video_metadata: no readable body text; used the page's embedded video metadata".into(),
);
}
warnings.extend(r.doc.coverage.warnings());
let raw_bytes = Some(thin_basis_bytes(&r.html, &r.doc.markdown));
let video_fallback = r.doc.metadata.video_fallback;
let quality = super::planner::quality(&r.doc, r.html.len());
let mut doc = document(r.doc, opts, r.html);
doc.quality = quality;
doc.actions = r.actions;
finish(
doc,
meta,
ext,
timings,
warnings,
raw_bytes,
video_fallback,
started,
)
}
pub(super) struct Progress {
pub(super) started: Instant,
pub(super) timings: BTreeMap<String, f64>,
pub(super) warnings: Vec<String>,
}
pub(super) fn log_engine_chain(chain: &[PoolConfig]) {
let names: Vec<String> = chain
.iter()
.filter_map(|c| match &c.source {
pagesift_cdp::BrowserSource::Executable(p) => Some(format!(
"{}={}",
c.engine.map_or("chrome", |k| k.name()),
p.display()
)),
pagesift_cdp::BrowserSource::Remote(u) => Some(format!("remote={u}")),
pagesift_cdp::BrowserSource::Auto => None,
})
.collect();
if names.is_empty() {
tracing::info!("no browser engine found; JavaScript rendering is unavailable");
} else {
tracing::info!(engines = %names.join(" -> "), "browser engines");
}
}
pub(super) fn request_deadline(opts: &ScrapeOptions, started: Instant) -> Option<Instant> {
opts.timeout.map(|ms| started + Duration::from_millis(ms))
}
impl Scraper {
fn tab_capacity(&self) -> usize {
self.browsers
.iter()
.filter_map(|(_, cell)| cell.get().and_then(Option::as_ref))
.map(|p| p.tab_capacity())
.sum()
}
}
pub(super) fn proxy_capable(cfg: &pagesift_cdp::PoolConfig) -> bool {
cfg.engine.unwrap_or(pagesift_cdp::EngineKind::Chrome) == pagesift_cdp::EngineKind::Chrome
}
impl Scraper {
pub(super) fn can_render_via(&self, proxy: &str) -> bool {
pagesift_cdp::BrowserProxy::parse(proxy).is_ok()
&& self.browsers.iter().any(|(c, _)| proxy_capable(c))
}
}
pub(super) fn render_error_code(e: &pagesift_cdp::Error) -> ErrorCode {
match e {
pagesift_cdp::Error::Timeout => ErrorCode::Timeout,
pagesift_cdp::Error::Busy => ErrorCode::Busy,
pagesift_cdp::Error::Blocked { .. } => ErrorCode::SsrfBlocked,
pagesift_cdp::Error::Action { .. } | pagesift_cdp::Error::InvalidOptions(_) => {
ErrorCode::InvalidRequest
}
pagesift_cdp::Error::ExecuteJavascriptDisabled => ErrorCode::UnsupportedOption,
pagesift_cdp::Error::Navigation(m) if m.contains("ERR_NAME") => ErrorCode::DnsFailure,
pagesift_cdp::Error::Navigation(m) if m.contains("ERR_CERT_DATE_INVALID") => {
ErrorCode::TlsCertExpired
}
pagesift_cdp::Error::Navigation(m) if m.contains("ERR_CERT_COMMON_NAME_INVALID") => {
ErrorCode::TlsHostnameMismatch
}
pagesift_cdp::Error::Navigation(m) if m.contains("ERR_CERT_AUTHORITY_INVALID") => {
ErrorCode::TlsUntrustedRoot
}
pagesift_cdp::Error::Navigation(m) if m.contains("ERR_CERT") => ErrorCode::TlsError,
pagesift_cdp::Error::Navigation(_) => ErrorCode::NetworkError,
_ => ErrorCode::UpstreamError,
}
}
fn merge_engines(
full: &mut pagesift_content::Document,
full_html: usize,
prev: &pagesift_content::Document,
prev_html: usize,
) {
let (Some(qf), Some(qp)) = (
super::planner::quality(full, full_html),
super::planner::quality(prev, prev_html),
) else {
return;
};
let (md, from) = super::planner::merge_markdown(&full.markdown, qf, &prev.markdown, qp);
if from[2] == 0 {
return;
}
let mut m = full.clone();
m.markdown = md;
m.plain_text = None;
if super::planner::quality(&m, full_html).is_some_and(|qm| qm >= qf) {
*full = m;
}
}
pub(super) struct Rendered {
index: usize,
engine: &'static str,
final_url: Url,
status: Option<u16>,
content_type: Option<String>,
timings: pagesift_cdp::Timings,
extract: Duration,
doc: pagesift_content::Document,
html: String,
actions: Option<serde_json::Value>,
}
fn wait_until(t: Option<&str>) -> pagesift_cdp::WaitUntil {
use pagesift_cdp::WaitUntil;
match t {
Some("visible-content") => WaitUntil::DomContentLoaded,
Some("mutation-idle") => WaitUntil::MutationIdle,
Some("media-idle" | "network-idle") => WaitUntil::LoadIdle,
_ => WaitUntil::NetworkIdle,
}
}