Skip to main content

stygian_proxy/
fetcher.rs

1//! Proxy list fetching — port trait and free-list HTTP adapter.
2//!
3//! [`ProxyFetcher`] is the port trait.  Implement it to pull proxies from any
4//! source (remote HTTP list, database, commercial API, etc.) and integrate with
5//! [`ProxyManager`] via [`load_from_fetcher`].
6//!
7//! The built-in [`FreeListFetcher`] downloads plain-text `host:port` proxy
8//! lists from public URLs (e.g. the `TheSpeedX/PROXY-List` feeds on GitHub)
9//! and parses them into [`Proxy`] records.  It is suitable for development,
10//! testing, and low-stakes scraping where proxy quality is less critical.
11//!
12//! ## Example — load from a free list and populate the pool
13//!
14//! ```no_run
15//! use std::sync::Arc;
16//! use stygian_proxy::{
17//!     ProxyManager,
18//!     storage::MemoryProxyStore,
19//!     fetcher::{FreeListFetcher, ProxyFetcher, FreeListSource},
20//! };
21//!
22//! # async fn run() -> stygian_proxy::error::ProxyResult<()> {
23//! let fetcher = FreeListFetcher::new(vec![
24//!     FreeListSource::TheSpeedXHttp,
25//! ]);
26//!
27//! let manager = ProxyManager::builder()
28//!     .storage(Arc::new(MemoryProxyStore::default()))
29//!     .build()?;
30//! let loaded = stygian_proxy::fetcher::load_from_fetcher(&manager, &fetcher).await?;
31//! println!("Loaded {loaded} proxies");
32//! # Ok(())
33//! # }
34//! ```
35
36use std::time::Duration;
37
38use async_trait::async_trait;
39use futures::future::join_all;
40use reqwest::Client;
41use serde::Deserialize;
42use tracing::{debug, warn};
43
44use crate::{
45    Proxy, ProxyManager, ProxyType,
46    error::{ProxyError, ProxyResult},
47};
48
49// ─── Port trait ───────────────────────────────────────────────────────────────
50
51/// A source that can produce a list of [`Proxy`] records asynchronously.
52///
53/// Implement this trait to integrate any proxy source (remote HTTP list,
54/// commercial API, database, file) with [`load_from_fetcher`].
55///
56/// # Example
57///
58/// ```
59/// use async_trait::async_trait;
60/// use stygian_proxy::{Proxy, ProxyType};
61/// use stygian_proxy::fetcher::ProxyFetcher;
62/// use stygian_proxy::error::ProxyResult;
63/// use stygian_proxy::types::ProxyCapabilities;
64///
65/// struct MyStaticFetcher;
66///
67/// #[async_trait]
68/// impl ProxyFetcher for MyStaticFetcher {
69///     async fn fetch(&self) -> ProxyResult<Vec<Proxy>> {
70///         Ok(vec![Proxy {
71///             url: "http://192.168.1.1:8080".into(),
72///             proxy_type: ProxyType::Http,
73///             username: None,
74///             password: None,
75///             weight: 1,
76///             tags: vec!["static".into()],
77///             capabilities: ProxyCapabilities::default(),
78///         }])
79///     }
80/// }
81/// ```
82#[async_trait]
83pub trait ProxyFetcher: Send + Sync {
84    /// Fetch the current proxy list.
85    ///
86    /// # Errors
87    ///
88    /// Returns [`ProxyError::FetchFailed`] if the source is unreachable or
89    /// returns malformed data.
90    async fn fetch(&self) -> ProxyResult<Vec<Proxy>>;
91}
92
93// ─── Free-list sources ────────────────────────────────────────────────────────
94
95/// A well-known free/public proxy list feed.
96///
97/// These lists are community-maintained and quality varies.  They are suitable
98/// for development and testing.  For production use, prefer a commercial
99/// provider adapter.
100///
101/// # Example
102///
103/// ```
104/// use stygian_proxy::fetcher::FreeListSource;
105/// let _src = FreeListSource::TheSpeedXHttp;
106/// ```
107#[derive(Debug, Clone, PartialEq, Eq)]
108#[non_exhaustive]
109pub enum FreeListSource {
110    /// HTTP proxies from `TheSpeedX/PROXY-List` (GitHub, plain `host:port`).
111    TheSpeedXHttp,
112    #[cfg(feature = "socks")]
113    /// SOCKS4 proxies from `TheSpeedX/PROXY-List` (requires the `socks` feature).
114    TheSpeedXSocks4,
115    #[cfg(feature = "socks")]
116    /// SOCKS5 proxies from `TheSpeedX/PROXY-List` (requires the `socks` feature).
117    TheSpeedXSocks5,
118    /// HTTP proxies from `clarketm/proxy-list` (GitHub, plain `host:port`).
119    ClarketmHttp,
120    /// Mixed HTTP proxies from `openproxylist.xyz`.
121    OpenProxyListHttp,
122    /// A custom URL.  Content must be one `host:port` entry per line.
123    Custom {
124        /// The URL to fetch.
125        url: String,
126        /// The [`ProxyType`] to assign all parsed entries.
127        proxy_type: ProxyType,
128    },
129}
130
131impl FreeListSource {
132    const fn url(&self) -> &str {
133        match self {
134            Self::TheSpeedXHttp => {
135                "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/http.txt"
136            }
137            #[cfg(feature = "socks")]
138            Self::TheSpeedXSocks4 => {
139                "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/socks4.txt"
140            }
141            #[cfg(feature = "socks")]
142            Self::TheSpeedXSocks5 => {
143                "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/socks5.txt"
144            }
145            Self::ClarketmHttp => {
146                "https://raw.githubusercontent.com/clarketm/proxy-list/master/proxy-list-raw.txt"
147            }
148            Self::OpenProxyListHttp => "https://openproxylist.xyz/http.txt",
149            Self::Custom { url, .. } => url.as_str(),
150        }
151    }
152
153    const fn proxy_type(&self) -> ProxyType {
154        match self {
155            Self::TheSpeedXHttp | Self::ClarketmHttp | Self::OpenProxyListHttp => ProxyType::Http,
156            #[cfg(feature = "socks")]
157            Self::TheSpeedXSocks4 => ProxyType::Socks4,
158            #[cfg(feature = "socks")]
159            Self::TheSpeedXSocks5 => ProxyType::Socks5,
160            Self::Custom { proxy_type, .. } => *proxy_type,
161        }
162    }
163}
164
165// ─── FreeListFetcher ──────────────────────────────────────────────────────────
166
167/// Fetches plain-text `host:port` proxy lists from one or more public URLs.
168///
169/// Each source is fetched concurrently.  Lines that do not parse as valid
170/// `host:port` entries are silently skipped.  An empty or unreachable source
171/// logs a warning but does not fail the entire fetch — at least one source
172/// must return results for the call to succeed.
173///
174/// # Example
175///
176/// ```no_run
177/// use stygian_proxy::fetcher::{FreeListFetcher, FreeListSource, ProxyFetcher};
178///
179/// # async fn run() -> stygian_proxy::error::ProxyResult<()> {
180/// let fetcher = FreeListFetcher::new(vec![FreeListSource::TheSpeedXHttp]);
181/// let proxies = fetcher.fetch().await?;
182/// println!("Got {} proxies", proxies.len());
183/// # Ok(())
184/// # }
185/// ```
186pub struct FreeListFetcher {
187    sources: Vec<FreeListSource>,
188    client: Client,
189    tags: Vec<String>,
190}
191
192impl FreeListFetcher {
193    /// Create a fetcher for the given sources with default HTTP client settings
194    /// (10 s timeout, TLS enabled).
195    ///
196    /// # Example
197    ///
198    /// ```
199    /// use stygian_proxy::fetcher::{FreeListFetcher, FreeListSource};
200    /// let _f = FreeListFetcher::new(vec![FreeListSource::TheSpeedXHttp]);
201    /// ```
202    pub fn new(sources: Vec<FreeListSource>) -> Self {
203        let client = Client::builder()
204            .timeout(Duration::from_secs(10))
205            .build()
206            .unwrap_or_else(|e| {
207                warn!("Failed to build HTTP client with 10 s timeout (TLS backend issue?): {e}; falling back to default client with per-request timeout enforcement");
208                Client::default()
209            });
210        Self {
211            sources,
212            client,
213            tags: vec!["free-list".into()],
214        }
215    }
216
217    /// Replace the internal HTTP client with a TLS-profiled one.
218    ///
219    /// Proxy-list fetch requests will carry a browser TLS fingerprint and
220    /// matching `Accept` / `Sec-CH-UA` headers.
221    ///
222    /// Only available with the `tls-profiled` feature.
223    ///
224    /// # Example
225    ///
226    /// ```no_run
227    /// use stygian_proxy::fetcher::{FreeListFetcher, FreeListSource};
228    /// use stygian_proxy::http_client::{ProfiledRequestMode, ProfiledRequester};
229    ///
230    /// # fn run() -> Result<(), Box<dyn std::error::Error>> {
231    /// let fetcher = FreeListFetcher::new(vec![FreeListSource::TheSpeedXHttp])
232    ///     .with_profiled_client(ProfiledRequester::chrome_mode(ProfiledRequestMode::Preset)?);
233    /// # Ok(())
234    /// # }
235    /// ```
236    #[cfg(feature = "tls-profiled")]
237    #[must_use]
238    pub fn with_profiled_client(
239        mut self,
240        requester: crate::http_client::ProfiledRequester,
241    ) -> Self {
242        self.client = requester.client().clone();
243        drop(requester);
244        self
245    }
246
247    /// Build and attach a profile-mode-based requester.
248    ///
249    /// Uses Chrome 131 as the baseline browser identity and applies `mode`
250    /// to TLS control mapping.
251    ///
252    /// Only available when the `tls-profiled` feature is enabled.
253    ///
254    /// # Errors
255    ///
256    /// Returns [`crate::error::ProxyError::ConfigError`] if the profiled
257    /// requester cannot be constructed.
258    #[cfg(feature = "tls-profiled")]
259    pub fn with_profiled_mode(
260        self,
261        mode: crate::types::ProfiledRequestMode,
262    ) -> crate::error::ProxyResult<Self> {
263        let requester = crate::http_client::ProfiledRequester::chrome_mode(mode)
264            .map_err(|e| crate::error::ProxyError::ConfigError(e.to_string()))?;
265        Ok(self.with_profiled_client(requester))
266    }
267
268    /// Attach extra tags to every proxy produced by this fetcher.
269    ///
270    /// # Example
271    ///
272    /// ```
273    /// use stygian_proxy::fetcher::{FreeListFetcher, FreeListSource};
274    /// let _f = FreeListFetcher::new(vec![FreeListSource::TheSpeedXHttp])
275    ///     .with_tags(vec!["dev".into(), "http".into()]);
276    /// ```
277    #[must_use]
278    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
279        self.tags.extend(tags);
280        self
281    }
282
283    /// Parse one `host:port` line, including bracketed IPv6 addresses.
284    fn parse_host_port_line(line: &str) -> Option<(String, u16)> {
285        let line = line.trim();
286        if line.is_empty() || line.starts_with('#') {
287            return None;
288        }
289
290        let (host, port_str) = if line.starts_with('[') {
291            let end = line.find(']')?;
292            let host = line.get(..=end)?.trim();
293            let remainder = line.get(end + 1..)?.trim();
294            let (_, port_str) = remainder.rsplit_once(':')?;
295            (host, port_str.trim())
296        } else {
297            let (host, port_str) = line.rsplit_once(':')?;
298            let host = host.trim();
299            if host.contains(':') {
300                return None;
301            }
302            (host, port_str.trim())
303        };
304
305        if host.is_empty() || host == "[]" {
306            return None;
307        }
308
309        let port = port_str.parse::<u16>().ok()?;
310        if port == 0 {
311            return None;
312        }
313
314        Some((host.to_string(), port))
315    }
316
317    /// Fetch a single source, returning parsed proxies (empty on failure).
318    async fn fetch_source(&self, source: &FreeListSource) -> Vec<Proxy> {
319        let url = source.url();
320        let proxy_type = source.proxy_type();
321
322        let body = match self
323            .client
324            .get(url)
325            .timeout(Duration::from_secs(10))
326            .send()
327            .await
328        {
329            Ok(resp) if resp.status().is_success() => match resp.text().await {
330                Ok(t) => t,
331                Err(e) => {
332                    warn!("Failed to read body from {url}: {e}");
333                    return vec![];
334                }
335            },
336            Ok(resp) => {
337                warn!(
338                    "Non-success status {} fetching proxy list from {url}",
339                    resp.status()
340                );
341                return vec![];
342            }
343            Err(e) => {
344                warn!("Failed to fetch proxy list from {url}: {e}");
345                return vec![];
346            }
347        };
348
349        let proxies: Vec<Proxy> = body
350            .lines()
351            .filter_map(|line| {
352                let (host, port) = Self::parse_host_port_line(line)?;
353                let scheme = match proxy_type {
354                    ProxyType::Http => "http",
355                    ProxyType::Https => "https",
356                    #[cfg(feature = "socks")]
357                    ProxyType::Socks4 => "socks4",
358                    #[cfg(feature = "socks")]
359                    ProxyType::Socks5 => "socks5",
360                };
361                Some(Proxy {
362                    url: format!("{scheme}://{host}:{port}"),
363                    proxy_type,
364                    username: None,
365                    password: None,
366                    weight: 1,
367                    tags: self.tags.clone(),
368                    capabilities: crate::types::ProxyCapabilities::default(),
369                })
370            })
371            .collect();
372
373        debug!(source = url, count = proxies.len(), "Fetched proxy list");
374        proxies
375    }
376}
377
378#[async_trait]
379impl ProxyFetcher for FreeListFetcher {
380    async fn fetch(&self) -> ProxyResult<Vec<Proxy>> {
381        if self.sources.is_empty() {
382            return Err(ProxyError::ConfigError(
383                "no sources configured for FreeListFetcher".into(),
384            ));
385        }
386
387        // Drive all source fetches concurrently.
388        let results = join_all(self.sources.iter().map(|s| self.fetch_source(s))).await;
389        let all: Vec<Proxy> = results.into_iter().flatten().collect();
390
391        if all.is_empty() {
392            return Err(ProxyError::FetchFailed {
393                origin: self
394                    .sources
395                    .iter()
396                    .map(FreeListSource::url)
397                    .collect::<Vec<_>>()
398                    .join(", "),
399                message: "all sources returned empty or failed".into(),
400            });
401        }
402
403        Ok(all)
404    }
405}
406
407// ─── FreeAPIProxies adapter ──────────────────────────────────────────────────
408
409/// Fetches proxies from a JSON API compatible with FreeAPIProxies-style
410/// payloads.
411///
412/// The adapter accepts either a top-level array payload or an object payload
413/// with `data` or `results` arrays.  Optional query parameters (`limit`,
414/// `protocol`, `country`) are appended to the endpoint URL when set.
415///
416/// # Example
417///
418/// ```no_run
419/// use stygian_proxy::fetcher::{FreeApiProxiesFetcher, ProxyFetcher};
420///
421/// # async fn run() -> stygian_proxy::error::ProxyResult<()> {
422/// let fetcher = FreeApiProxiesFetcher::new()
423///     .with_limit(100)
424///     .with_protocol_filter("http")
425///     .with_country_filter("US");
426/// let proxies = fetcher.fetch().await?;
427/// println!("Got {} proxies", proxies.len());
428/// # Ok(())
429/// # }
430/// ```
431pub struct FreeApiProxiesFetcher {
432    endpoint: String,
433    client: Client,
434    tags: Vec<String>,
435    /// Maximum number of proxies to request from the API.
436    limit: Option<u32>,
437    /// Protocol filter sent as a query parameter (e.g. `"http"`, `"socks5"`).
438    protocol_filter: Option<String>,
439    /// ISO 3166-1 alpha-2 country code filter (e.g. `"US"`, `"DE"`).
440    country_filter: Option<String>,
441}
442
443#[derive(Debug, Deserialize)]
444#[serde(untagged)]
445enum FreeApiProxiesResponse {
446    List(Vec<FreeApiProxyRecord>),
447    Data { data: Vec<FreeApiProxyRecord> },
448    Results { results: Vec<FreeApiProxyRecord> },
449}
450
451impl FreeApiProxiesResponse {
452    fn into_records(self) -> Vec<FreeApiProxyRecord> {
453        match self {
454            Self::List(records)
455            | Self::Data { data: records }
456            | Self::Results { results: records } => records,
457        }
458    }
459}
460
461#[derive(Debug, Deserialize)]
462struct FreeApiProxyRecord {
463    #[serde(default, alias = "ip", alias = "host")]
464    address_host: String,
465    #[serde(default)]
466    port: Option<u16>,
467    #[serde(default, alias = "proxy", alias = "address")]
468    address: Option<String>,
469    #[serde(default, alias = "protocol", alias = "type", alias = "proxy_type")]
470    protocol: Option<String>,
471    #[serde(default)]
472    username: Option<String>,
473    #[serde(default)]
474    password: Option<String>,
475    #[serde(default, alias = "countryCode", alias = "country_code")]
476    country_code: Option<String>,
477}
478
479impl FreeApiProxiesFetcher {
480    const DEFAULT_ENDPOINT: &str = "https://freeapiproxies.azurewebsites.net/";
481
482    /// Create a `FreeAPIProxies` fetcher using the default endpoint.
483    #[must_use]
484    pub fn new() -> Self {
485        Self::with_endpoint(Self::DEFAULT_ENDPOINT)
486    }
487
488    /// Create a `FreeAPIProxies` fetcher using a custom JSON endpoint.
489    #[must_use]
490    pub fn with_endpoint(endpoint: impl Into<String>) -> Self {
491        let client = Client::builder()
492            .timeout(Duration::from_secs(10))
493            .build()
494            .unwrap_or_else(|e| {
495                warn!("Failed to build HTTP client with 10 s timeout (TLS backend issue?): {e}; falling back to default client with per-request timeout enforcement");
496                Client::default()
497            });
498
499        Self {
500            endpoint: endpoint.into(),
501            client,
502            tags: vec!["freeapiproxies".into()],
503            limit: None,
504            protocol_filter: None,
505            country_filter: None,
506        }
507    }
508
509    /// Attach extra tags to every proxy produced by this fetcher.
510    #[must_use]
511    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
512        self.tags.extend(tags);
513        self
514    }
515
516    /// Set the maximum number of proxies to request from the API.
517    ///
518    /// Appended to the request as `?limit=<n>`.  Ignored when `None`.
519    ///
520    /// # Example
521    ///
522    /// ```
523    /// use stygian_proxy::fetcher::FreeApiProxiesFetcher;
524    /// let _f = FreeApiProxiesFetcher::new().with_limit(50);
525    /// ```
526    #[must_use]
527    pub const fn with_limit(mut self, limit: u32) -> Self {
528        self.limit = Some(limit);
529        self
530    }
531
532    /// Filter by proxy protocol on the server side.
533    ///
534    /// Appended to the request as `?protocol=<value>`.  Common values are
535    /// `"http"`, `"https"`, `"socks4"`, and `"socks5"`.
536    ///
537    /// # Example
538    ///
539    /// ```
540    /// use stygian_proxy::fetcher::FreeApiProxiesFetcher;
541    /// let _f = FreeApiProxiesFetcher::new().with_protocol_filter("http");
542    /// ```
543    #[must_use]
544    pub fn with_protocol_filter(mut self, protocol: impl Into<String>) -> Self {
545        self.protocol_filter = Some(protocol.into());
546        self
547    }
548
549    /// Filter by ISO 3166-1 alpha-2 country code on the server side.
550    ///
551    /// Appended to the request as `?country=<value>` (uppercased).
552    ///
553    /// # Example
554    ///
555    /// ```
556    /// use stygian_proxy::fetcher::FreeApiProxiesFetcher;
557    /// let _f = FreeApiProxiesFetcher::new().with_country_filter("US");
558    /// ```
559    #[must_use]
560    pub fn with_country_filter(mut self, country_code: impl Into<String>) -> Self {
561        self.country_filter = Some(country_code.into().to_ascii_uppercase());
562        self
563    }
564
565    /// Build the full request URL with any configured query parameters.
566    fn request_url(&self) -> String {
567        let mut params: Vec<(&str, String)> = Vec::new();
568        if let Some(limit) = self.limit {
569            params.push(("limit", limit.to_string()));
570        }
571        if let Some(ref protocol) = self.protocol_filter {
572            params.push(("protocol", protocol.clone()));
573        }
574        if let Some(ref country) = self.country_filter {
575            params.push(("country", country.clone()));
576        }
577        if params.is_empty() {
578            return self.endpoint.clone();
579        }
580        let qs = params
581            .iter()
582            .enumerate()
583            .fold(String::new(), |mut acc, (i, (k, v))| {
584                use std::fmt::Write as _;
585                let sep = if i == 0 { "?" } else { "&" };
586                let _ = write!(acc, "{sep}{k}={v}");
587                acc
588            });
589        format!("{}{qs}", self.endpoint)
590    }
591
592    fn protocol_to_proxy_type(protocol: Option<&str>) -> Option<ProxyType> {
593        let normalized = protocol.map(str::trim).map(str::to_ascii_lowercase);
594        match normalized.as_deref() {
595            None | Some("" | "http") => Some(ProxyType::Http),
596            Some("https") => Some(ProxyType::Https),
597            #[cfg(feature = "socks")]
598            Some("socks" | "socks5") => Some(ProxyType::Socks5),
599            #[cfg(feature = "socks")]
600            Some("socks4") => Some(ProxyType::Socks4),
601            _ => None,
602        }
603    }
604
605    fn parse_address(record: &FreeApiProxyRecord) -> Option<(String, u16)> {
606        if let Some(address) = record.address.as_deref() {
607            if let Some((host, port)) = FreeListFetcher::parse_host_port_line(address) {
608                return Some((host, port));
609            }
610
611            if let Ok(url) = reqwest::Url::parse(address)
612                && let Some(port) = url.port_or_known_default()
613            {
614                return Some((url.host_str()?.to_string(), port));
615            }
616        }
617
618        let host = record.address_host.trim();
619        let port = record.port?;
620        if host.is_empty() || port == 0 {
621            return None;
622        }
623        Some((host.to_string(), port))
624    }
625
626    fn record_to_proxy(&self, record: FreeApiProxyRecord) -> Option<Proxy> {
627        let proxy_type = Self::protocol_to_proxy_type(record.protocol.as_deref())?;
628        let (host, port) = Self::parse_address(&record)?;
629
630        let scheme = match proxy_type {
631            ProxyType::Http => "http",
632            ProxyType::Https => "https",
633            #[cfg(feature = "socks")]
634            ProxyType::Socks4 => "socks4",
635            #[cfg(feature = "socks")]
636            ProxyType::Socks5 => "socks5",
637        };
638
639        let mut tags = self.tags.clone();
640        if let Some(country_code) = record.country_code.as_deref()
641            && !country_code.trim().is_empty()
642        {
643            tags.push(format!(
644                "country:{}",
645                country_code.trim().to_ascii_uppercase()
646            ));
647        }
648
649        Some(Proxy {
650            url: format!("{scheme}://{host}:{port}"),
651            proxy_type,
652            username: record.username.filter(|v| !v.trim().is_empty()),
653            password: record.password.filter(|v| !v.trim().is_empty()),
654            weight: 1,
655            tags,
656            capabilities: crate::types::ProxyCapabilities::default(),
657        })
658    }
659
660    fn parse_payload(&self, body: &str) -> ProxyResult<Vec<Proxy>> {
661        let response: FreeApiProxiesResponse =
662            serde_json::from_str(body).map_err(|e| ProxyError::FetchFailed {
663                origin: self.endpoint.clone(),
664                message: format!("invalid freeapiproxies json payload: {e}"),
665            })?;
666
667        let proxies: Vec<Proxy> = response
668            .into_records()
669            .into_iter()
670            .filter_map(|record| self.record_to_proxy(record))
671            .collect();
672
673        if proxies.is_empty() {
674            return Err(ProxyError::FetchFailed {
675                origin: self.endpoint.clone(),
676                message: "freeapiproxies payload contained no usable proxies".into(),
677            });
678        }
679
680        Ok(proxies)
681    }
682}
683
684impl Default for FreeApiProxiesFetcher {
685    fn default() -> Self {
686        Self::new()
687    }
688}
689
690#[async_trait]
691impl ProxyFetcher for FreeApiProxiesFetcher {
692    async fn fetch(&self) -> ProxyResult<Vec<Proxy>> {
693        let url = self.request_url();
694        let body = self
695            .client
696            .get(&url)
697            .timeout(Duration::from_secs(10))
698            .send()
699            .await
700            .map_err(|e| ProxyError::FetchFailed {
701                origin: url.clone(),
702                message: e.to_string(),
703            })?
704            .error_for_status()
705            .map_err(|e| ProxyError::FetchFailed {
706                origin: url.clone(),
707                message: e.to_string(),
708            })?
709            .text()
710            .await
711            .map_err(|e| ProxyError::FetchFailed {
712                origin: url.clone(),
713                message: e.to_string(),
714            })?;
715
716        self.parse_payload(&body)
717    }
718}
719
720// ─── Helper ───────────────────────────────────────────────────────────────────
721
722/// Fetch proxies from `fetcher` and add them all to `manager`.
723///
724/// Returns the number of proxies successfully added.  Individual `add_proxy`
725/// failures (e.g. duplicate URL) are logged as warnings and do not abort the
726/// load.
727///
728/// # Errors
729///
730/// Returns any [`ProxyError`] emitted by `fetcher.fetch()` if the fetcher
731/// itself fails.
732///
733/// # Example
734///
735/// ```no_run
736/// use std::sync::Arc;
737/// use stygian_proxy::{ProxyManager, storage::MemoryProxyStore, fetcher::{FreeListFetcher, FreeListSource, load_from_fetcher}};
738///
739/// # async fn run() -> stygian_proxy::error::ProxyResult<()> {
740/// let manager = ProxyManager::builder()
741///     .storage(Arc::new(MemoryProxyStore::default()))
742///     .build()?;
743/// let fetcher = FreeListFetcher::new(vec![FreeListSource::TheSpeedXHttp]);
744/// let n = load_from_fetcher(&manager, &fetcher).await?;
745/// println!("Loaded {n} proxies");
746/// # Ok(())
747/// # }
748/// ```
749pub async fn load_from_fetcher(
750    manager: &ProxyManager,
751    fetcher: &dyn ProxyFetcher,
752) -> ProxyResult<usize> {
753    let proxies = fetcher.fetch().await?;
754    let total = proxies.len();
755    let mut loaded = 0usize;
756
757    for proxy in proxies {
758        match manager.add_proxy(proxy).await {
759            Ok(_) => loaded += 1,
760            Err(e) => warn!("Skipped proxy during load: {e}"),
761        }
762    }
763
764    debug!(total, loaded, "Proxy list loaded into manager");
765    Ok(loaded)
766}
767
768// ─── Tests ────────────────────────────────────────────────────────────────────
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773
774    #[test]
775    fn free_api_proxies_fetcher_request_url_no_params() {
776        let f = FreeApiProxiesFetcher::with_endpoint("https://example.test/api");
777        assert_eq!(f.request_url(), "https://example.test/api");
778    }
779
780    #[test]
781    fn free_api_proxies_fetcher_request_url_with_params() {
782        let f = FreeApiProxiesFetcher::with_endpoint("https://example.test/api")
783            .with_limit(50)
784            .with_protocol_filter("http")
785            .with_country_filter("us");
786        let url = f.request_url();
787        assert!(url.contains("limit=50"), "expected limit param in {url}");
788        assert!(
789            url.contains("protocol=http"),
790            "expected protocol param in {url}"
791        );
792        assert!(
793            url.contains("country=US"),
794            "expected country uppercased in {url}"
795        );
796        assert!(url.starts_with("https://example.test/api?"), "missing ?");
797    }
798
799    #[test]
800    fn free_api_proxies_fetcher_country_filter_uppercased() {
801        let f = FreeApiProxiesFetcher::new().with_country_filter("de");
802        assert_eq!(f.country_filter.as_deref(), Some("DE"));
803    }
804
805    /// Integration test — hits the live `FreeAPIProxies` endpoint.
806    /// Run with: `cargo test -p stygian-proxy --all-features -- --ignored`
807    #[test]
808    #[ignore = "requires live network access to freeapiproxies.azurewebsites.net"]
809    fn free_api_proxies_fetcher_live_fetch() -> std::result::Result<(), Box<dyn std::error::Error>>
810    {
811        let fetcher = FreeApiProxiesFetcher::new().with_limit(20);
812        let rt = tokio::runtime::Builder::new_current_thread()
813            .enable_all()
814            .build()
815            .map_err(|e| std::io::Error::other(format!("failed to build runtime for test: {e}")))?;
816        let proxies = rt.block_on(fetcher.fetch())?;
817        assert!(
818            !proxies.is_empty(),
819            "expected at least one proxy from live endpoint"
820        );
821        for proxy in &proxies {
822            assert!(
823                proxy.url.starts_with("http://")
824                    || proxy.url.starts_with("https://")
825                    || proxy.url.starts_with("socks4://")
826                    || proxy.url.starts_with("socks5://"),
827                "unexpected proxy url scheme: {}",
828                proxy.url
829            );
830        }
831        Ok(())
832    }
833
834    #[test]
835    fn free_list_source_url_is_nonempty() {
836        #[cfg(not(feature = "socks"))]
837        let sources = vec![
838            FreeListSource::TheSpeedXHttp,
839            FreeListSource::ClarketmHttp,
840            FreeListSource::OpenProxyListHttp,
841            FreeListSource::Custom {
842                url: "https://example.com/proxies.txt".into(),
843                proxy_type: ProxyType::Http,
844            },
845        ];
846        #[cfg(feature = "socks")]
847        let sources = {
848            let mut s = vec![
849                FreeListSource::TheSpeedXHttp,
850                FreeListSource::ClarketmHttp,
851                FreeListSource::OpenProxyListHttp,
852                FreeListSource::Custom {
853                    url: "https://example.com/proxies.txt".into(),
854                    proxy_type: ProxyType::Http,
855                },
856            ];
857            s.extend([
858                FreeListSource::TheSpeedXSocks4,
859                FreeListSource::TheSpeedXSocks5,
860            ]);
861            s
862        };
863        for src in &sources {
864            assert!(
865                !src.url().is_empty(),
866                "FreeListSource::{src:?} has empty URL"
867            );
868        }
869    }
870
871    #[test]
872    fn free_list_source_proxy_types() {
873        assert_eq!(FreeListSource::TheSpeedXHttp.proxy_type(), ProxyType::Http);
874        #[cfg(feature = "socks")]
875        assert_eq!(
876            FreeListSource::TheSpeedXSocks4.proxy_type(),
877            ProxyType::Socks4
878        );
879        #[cfg(feature = "socks")]
880        assert_eq!(
881            FreeListSource::TheSpeedXSocks5.proxy_type(),
882            ProxyType::Socks5
883        );
884        assert_eq!(FreeListSource::ClarketmHttp.proxy_type(), ProxyType::Http);
885    }
886
887    #[test]
888    fn free_api_proxies_fetcher_parses_array_payload() -> crate::error::ProxyResult<()> {
889        let fetcher = FreeApiProxiesFetcher::with_endpoint("https://example.test/freeapi");
890        let body = r#"
891[
892    {"host":"1.2.3.4","port":8080,"protocol":"http","countryCode":"us"},
893    {"address":"5.6.7.8:8443","protocol":"https"}
894]
895"#;
896
897        let proxies = fetcher.parse_payload(body)?;
898        assert_eq!(proxies.len(), 2);
899        assert_eq!(
900            proxies.first().map(|proxy| proxy.url.as_str()),
901            Some("http://1.2.3.4:8080")
902        );
903        assert_eq!(
904            proxies.get(1).map(|proxy| proxy.url.as_str()),
905            Some("https://5.6.7.8:8443")
906        );
907        Ok(())
908    }
909
910    #[test]
911    fn free_api_proxies_fetcher_parses_wrapped_results_payload() -> crate::error::ProxyResult<()> {
912        let fetcher = FreeApiProxiesFetcher::with_endpoint("https://example.test/freeapi");
913        let body = r#"
914{
915    "results": [
916        {"ip":"9.9.9.9","port":3128,"type":"http"}
917    ]
918}
919"#;
920
921        let proxies = fetcher.parse_payload(body)?;
922        assert_eq!(proxies.len(), 1);
923        assert_eq!(
924            proxies.first().map(|proxy| proxy.url.as_str()),
925            Some("http://9.9.9.9:3128")
926        );
927        Ok(())
928    }
929
930    #[test]
931    fn free_list_fetcher_parse_valid_lines() {
932        let fetcher = FreeListFetcher::new(vec![]);
933        // Test the parsing logic directly by calling parse on synthetic text.
934        let text = "1.2.3.4:8080\n# comment\n\nbad-line\n5.6.7.8:3128\n[2001:db8::1]:8081\n";
935        let parsed: Vec<Proxy> = text
936            .lines()
937            .filter_map(|line| {
938                let (host, port) = FreeListFetcher::parse_host_port_line(line)?;
939                Some(Proxy {
940                    url: format!("http://{host}:{port}"),
941                    proxy_type: ProxyType::Http,
942                    username: None,
943                    password: None,
944                    weight: 1,
945                    tags: fetcher.tags.clone(),
946                    capabilities: crate::types::ProxyCapabilities::default(),
947                })
948            })
949            .collect();
950
951        assert_eq!(parsed.len(), 3);
952        assert_eq!(
953            parsed.first().map(|proxy| proxy.url.as_str()),
954            Some("http://1.2.3.4:8080")
955        );
956        assert_eq!(
957            parsed.get(1).map(|proxy| proxy.url.as_str()),
958            Some("http://5.6.7.8:3128")
959        );
960        assert_eq!(
961            parsed.get(2).map(|proxy| proxy.url.as_str()),
962            Some("http://[2001:db8::1]:8081")
963        );
964    }
965
966    #[test]
967    fn free_list_fetcher_with_tags_extends() {
968        let f = FreeListFetcher::new(vec![]).with_tags(vec!["custom".into()]);
969        assert!(f.tags.contains(&"free-list".to_string()));
970        assert!(f.tags.contains(&"custom".to_string()));
971    }
972
973    #[test]
974    fn free_list_fetcher_skips_invalid_port() {
975        assert!(FreeListFetcher::parse_host_port_line("1.2.3.4:notaport").is_none());
976        assert!(FreeListFetcher::parse_host_port_line("1.2.3.4:0").is_none());
977        assert!(FreeListFetcher::parse_host_port_line(":8080").is_none());
978        assert!(FreeListFetcher::parse_host_port_line("2001:db8::1:8080").is_none());
979    }
980
981    #[test]
982    fn free_list_fetcher_empty_sources_is_config_error()
983    -> std::result::Result<(), Box<dyn std::error::Error>> {
984        let fetcher = FreeListFetcher::new(vec![]);
985        let rt = tokio::runtime::Builder::new_current_thread()
986            .enable_time()
987            .build()
988            .map_err(|e| std::io::Error::other(format!("failed to build runtime for test: {e}")))?;
989        let err = rt
990            .block_on(fetcher.fetch())
991            .err()
992            .ok_or_else(|| std::io::Error::other("empty sources should fail"))?;
993        match err {
994            ProxyError::ConfigError(msg) => {
995                assert!(msg.contains("no sources configured"));
996            }
997            other => {
998                return Err(
999                    std::io::Error::other(format!("unexpected error variant: {other}")).into(),
1000                );
1001            }
1002        }
1003        Ok(())
1004    }
1005
1006    #[test]
1007    fn proxy_error_fetch_failed_display() {
1008        let e = ProxyError::FetchFailed {
1009            origin: "https://example.com".into(),
1010            message: "timed out".into(),
1011        };
1012        assert!(e.to_string().contains("https://example.com"));
1013        assert!(e.to_string().contains("timed out"));
1014    }
1015}