use pagesift_policy::{EgressAction, EgressOutcome, EgressPool, Site};
use super::*;
impl Scraper {
pub(super) async fn fetch_and_extract(
&self,
url: &str,
opts: &ScrapeOptions,
mode: RobotsMode,
) -> Result<Document, ApiError> {
let user = opts.egress.as_deref().or_else(|| {
opts.proxy
.as_deref()
.filter(|p| !matches!(*p, "basic" | "auto"))
});
let pool = self.policy.egress_pool().filter(|_| !opts.discover_scripts);
let (Some(pool), Ok(target)) = (pool, Url::parse(url)) else {
return self.fetch_and_extract_via(url, opts, mode, user).await;
};
let host = target.host_str().unwrap_or_default().to_ascii_lowercase();
let domain = pagesift_content::site_key(&host);
let site = Site {
host: &host,
domain: &domain,
};
if let Some(name) = user {
let result = self
.fetch_and_extract_via(url, opts, mode, Some(name))
.await;
if let Some(id) = pool.position(name) {
let pick = pool.pin(id);
pool.report(site, &pick, outcome(&result));
}
return result;
}
self.fetch_pooled(pool, site, url, opts, mode).await
}
async fn fetch_pooled(
&self,
pool: &EgressPool,
site: Site<'_>,
url: &str,
opts: &ScrapeOptions,
mode: RobotsMode,
) -> Result<Document, ApiError> {
let started = Instant::now();
let cookies = opts
.headers
.keys()
.any(|k| k.eq_ignore_ascii_case("cookie"))
|| self.policy.has_configured_cookies(site.host);
let capacity = self.concurrency();
let pick = pool.choose(site, cookies, capacity);
let first = self
.fetch_and_extract_via(url, opts, mode, Some(pick.name()))
.await;
let other_class = match pool.report(site, &pick, outcome(&first)) {
EgressAction::Switch { other_class } => other_class,
EgressAction::SiteWide => return site_wide(first, pool.len()),
EgressAction::Keep | EgressAction::Backoff => return first,
};
let remaining = opts
.timeout
.map(|t| t.saturating_sub(started.elapsed().as_millis() as u64));
if remaining == Some(0) {
return first;
}
let Some(retry) = pool.choose_retry(site, &pick, other_class, capacity) else {
return first;
};
let from = pick.name().to_string();
drop(pick);
let retry_opts;
let o = match remaining {
Some(t) => {
retry_opts = ScrapeOptions {
timeout: Some(t),
..opts.clone()
};
&retry_opts
}
None => opts,
};
let mut second = self
.fetch_and_extract_via(url, o, mode, Some(retry.name()))
.await;
let note = format!("egress_switched: {from} -> {}", retry.name());
if pool.report(site, &retry, outcome(&second)) == EgressAction::SiteWide {
second = site_wide(second, pool.len());
}
match &mut second {
Ok(doc) => {
if let Some(meta) = doc.metadata.pagesift.as_mut() {
meta.warnings.push(note);
}
}
Err(e) => add_detail(e, "egressSwitched", serde_json::json!(note)),
}
second
}
}
fn outcome(result: &Result<Document, ApiError>) -> EgressOutcome {
match result {
Ok(doc) => EgressOutcome::from_status(doc.metadata.status_code, false),
Err(e) => match e.code {
ErrorCode::AccessBarrier => {
let field = |k: &str| {
e.details
.as_ref()
.and_then(|d| d.get(k))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
};
EgressOutcome::from_barrier(&field("kind"), &field("ruleId"))
}
ErrorCode::RateLimited => EgressOutcome::Throttled,
ErrorCode::NetworkError | ErrorCode::UpstreamError | ErrorCode::Timeout => {
EgressOutcome::Unreachable
}
_ => EgressOutcome::Neutral,
},
}
}
fn site_wide(result: Result<Document, ApiError>, egresses: usize) -> Result<Document, ApiError> {
result.map_err(|mut e| {
add_detail(
&mut e,
"egressPool",
serde_json::json!({ "verdict": "denied_on_every_egress_tried", "egresses": egresses }),
);
e
})
}
fn add_detail(e: &mut ApiError, key: &str, value: serde_json::Value) {
match e.details.as_mut().and_then(|d| d.as_object_mut()) {
Some(map) => {
map.insert(key.into(), value);
}
None => {
let mut map = serde_json::Map::new();
map.insert(key.into(), value);
e.details = Some(serde_json::Value::Object(map));
}
}
}