use std::{
collections::HashMap,
fmt,
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
};
use url::Url;
use crate::{errors::CrawlError, request::Request, session::SessionId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum RotationStrategy {
#[default]
RoundRobin,
Random,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ProxyResolveContext<'a> {
pub request: Option<&'a Request>,
pub session_id: Option<&'a SessionId>,
pub attempt: u32,
}
impl<'a> ProxyResolveContext<'a> {
pub fn new() -> Self {
Self::default()
}
pub fn request(mut self, value: &'a Request) -> Self {
self.request = Some(value);
self
}
pub fn session_id(mut self, value: &'a SessionId) -> Self {
self.session_id = Some(value);
self
}
pub fn attempt(mut self, value: u32) -> Self {
self.attempt = value;
self
}
}
#[async_trait::async_trait]
pub trait ProxyResolver: Send + Sync + 'static {
async fn resolve(&self, ctx: ProxyResolveContext<'_>) -> Result<Option<Url>, CrawlError>;
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct ProxyInfo {
pub url: Url,
pub hostname: String,
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
pub tier: Option<u8>,
pub session_id: Option<SessionId>,
}
impl ProxyInfo {
pub fn from_url(url: Url) -> Self {
let hostname = url.host_str().unwrap_or_default().to_owned();
let port = url.port_or_known_default().unwrap_or(80);
let username = (!url.username().is_empty()).then(|| url.username().to_owned());
let password = url.password().map(str::to_owned);
Self {
url,
hostname,
port,
username,
password,
tier: None,
session_id: None,
}
}
pub fn with_tier(mut self, value: u8) -> Self {
self.tier = Some(value);
self
}
pub fn with_session_id(mut self, value: SessionId) -> Self {
self.session_id = Some(value);
self
}
}
enum ProxyInner {
Static {
urls: Vec<Url>,
rotation: RotationStrategy,
cursor: AtomicUsize,
},
Custom(Arc<dyn ProxyResolver>),
Tiered(TieredState),
}
struct TieredState {
tiers: Vec<Vec<Option<Url>>>,
probe_interval: u32,
domains: Mutex<HashMap<String, DomainTier>>,
}
#[derive(Default)]
struct DomainTier {
tier: usize,
requests: u32,
probing: Option<usize>,
}
pub struct ProxyConfiguration {
inner: ProxyInner,
}
impl ProxyConfiguration {
pub fn round_robin(urls: impl IntoIterator<Item = Url>) -> Self {
Self::rotating(urls, RotationStrategy::RoundRobin)
}
pub fn rotating(urls: impl IntoIterator<Item = Url>, rotation: RotationStrategy) -> Self {
Self {
inner: ProxyInner::Static {
urls: urls.into_iter().collect(),
rotation,
cursor: AtomicUsize::new(0),
},
}
}
pub fn custom<R: ProxyResolver>(resolver: R) -> Self {
Self {
inner: ProxyInner::Custom(Arc::new(resolver)),
}
}
pub fn tiered(tiers: Vec<Vec<Option<Url>>>) -> Self {
Self::tiered_with_probe_interval(tiers, 20)
}
pub fn tiered_with_probe_interval(tiers: Vec<Vec<Option<Url>>>, probe_interval: u32) -> Self {
Self {
inner: ProxyInner::Tiered(TieredState {
tiers,
probe_interval: probe_interval.max(1),
domains: Mutex::new(HashMap::new()),
}),
}
}
fn tiered_url(state: &TieredState, ctx: ProxyResolveContext<'_>) -> (Option<Url>, Option<u8>) {
if state.tiers.is_empty() {
return (None, None);
}
let key = ctx
.request
.and_then(|request| request.url.host_str())
.unwrap_or_default()
.to_owned();
let mut domains = state.domains.lock().unwrap_or_else(|e| e.into_inner());
let domain = domains.entry(key).or_default();
domain.tier = domain.tier.min(state.tiers.len() - 1);
domain.requests = domain.requests.saturating_add(1);
let serving = if domain.tier > 0 && domain.requests % state.probe_interval == 0 {
let probe = domain.tier - 1;
domain.probing = Some(probe);
probe
} else {
domain.tier
};
let tier = &state.tiers[serving];
if tier.is_empty() {
return (None, Some(serving as u8));
}
(
tier[domain.requests as usize % tier.len()].clone(),
Some(serving as u8),
)
}
async fn resolve(
&self,
ctx: ProxyResolveContext<'_>,
) -> Result<(Option<Url>, Option<u8>), CrawlError> {
match &self.inner {
ProxyInner::Static {
urls,
rotation,
cursor,
} => {
if urls.is_empty() {
return Ok((None, None));
}
let index = match rotation {
RotationStrategy::RoundRobin => cursor.fetch_add(1, Ordering::Relaxed),
RotationStrategy::Random => crate::util::rand_u64() as usize,
} % urls.len();
Ok((Some(urls[index].clone()), None))
}
ProxyInner::Custom(resolver) => resolver.resolve(ctx).await.map(|url| (url, None)),
ProxyInner::Tiered(state) => Ok(Self::tiered_url(state, ctx)),
}
}
pub async fn new_url(&self, ctx: ProxyResolveContext<'_>) -> Result<Option<Url>, CrawlError> {
self.resolve(ctx).await.map(|(url, _)| url)
}
pub async fn new_proxy_info(
&self,
ctx: ProxyResolveContext<'_>,
) -> Result<Option<ProxyInfo>, CrawlError> {
let session_id = ctx.session_id.cloned();
let (url, tier) = self.resolve(ctx).await?;
Ok(url.map(|url| {
let mut info = ProxyInfo::from_url(url);
info.tier = tier;
info.session_id = session_id;
info
}))
}
pub fn report_blocked(&self, target: &Url) {
let ProxyInner::Tiered(state) = &self.inner else {
return;
};
if state.tiers.is_empty() {
return;
}
let key = target.host_str().unwrap_or_default().to_owned();
let mut domains = state.domains.lock().unwrap_or_else(|e| e.into_inner());
let domain = domains.entry(key).or_default();
if domain.probing.take().is_none() {
domain.tier = (domain.tier + 1).min(state.tiers.len() - 1);
domain.requests = 0;
}
}
pub fn report_success(&self, target: &Url) {
let ProxyInner::Tiered(state) = &self.inner else {
return;
};
let key = target.host_str().unwrap_or_default().to_owned();
let mut domains = state.domains.lock().unwrap_or_else(|e| e.into_inner());
let domain = domains.entry(key).or_default();
if let Some(probe) = domain.probing.take() {
domain.tier = probe;
domain.requests = 0;
}
}
}
impl fmt::Debug for ProxyConfiguration {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let variant = match self.inner {
ProxyInner::Static { .. } => "Static",
ProxyInner::Custom(_) => "Custom",
ProxyInner::Tiered(_) => "Tiered",
};
formatter
.debug_struct("ProxyConfiguration")
.field("variant", &variant)
.finish()
}
}
pub trait ProxyStrategy: Send + Sync + 'static {
fn route(&self, ctx: &ProxyRouteContext<'_>) -> ProxyKind;
}
pub struct ProxyRouteContext<'a> {
pub request: &'a Request,
pub attempt: u32,
pub previous_profile_key: Option<&'a str>,
}
impl<'a> ProxyRouteContext<'a> {
pub fn new(request: &'a Request, attempt: u32) -> Self {
Self {
request,
attempt,
previous_profile_key: None,
}
}
pub fn previous_profile_key(mut self, value: &'a str) -> Self {
self.previous_profile_key = Some(value);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub enum ProxyKind {
#[default]
Default,
MediaAsset,
Custom(String),
}
#[derive(Default)]
#[must_use = "proxy buckets do nothing unless installed on a crawler"]
pub struct ProxyBuckets {
default_bucket: Option<ProxyConfiguration>,
media: Option<ProxyConfiguration>,
custom: HashMap<String, ProxyConfiguration>,
}
impl ProxyBuckets {
pub fn new() -> Self {
Self::default()
}
pub fn with_default(mut self, value: ProxyConfiguration) -> Self {
self.default_bucket = Some(value);
self
}
pub fn with_media(mut self, value: ProxyConfiguration) -> Self {
self.media = Some(value);
self
}
pub fn with_custom(mut self, name: impl Into<String>, value: ProxyConfiguration) -> Self {
self.custom.insert(name.into(), value);
self
}
pub fn for_kind(&self, kind: &ProxyKind) -> Option<&ProxyConfiguration> {
match kind {
ProxyKind::Default => self.default_bucket.as_ref(),
ProxyKind::MediaAsset => self.media.as_ref().or(self.default_bucket.as_ref()),
ProxyKind::Custom(name) => self.custom.get(name).or(self.default_bucket.as_ref()),
}
}
}
impl fmt::Debug for ProxyBuckets {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ProxyBuckets")
.field("default_bucket", &self.default_bucket)
.field("media", &self.media)
.field("custom", &self.custom)
.finish()
}
}