Files
hottub/src/util/requester.rs
Simon b131910ea2 rule34video: make queries robust (single-page results, safe search paths, 429-proof enrichment)
- Parser no longer requires a pagination block: single-page results
  (small tags, short searches) used to parse to zero items.
- Search path is slugged like the site's own form: "2b/9s" 404'd, "%20"
  is refused with 403 and "&" truncated the query.
- Probed archives need >= 4 videos to beat keyword search ("elf girl" was a
  one-video tag); conclusive query -> target resolutions are remembered for 6h
  so later pages and the prefetch skip the probes.
- Enriched details are cached per video URL for 2h (tokens last ~12h);
  detail fetches go through get_probe so a lasting 429 stops the batch instead
  of escalating each page to Jina/FlareSolverr. Archive failures serve stale
  cache.
- requester: get_probe waits before retrying 429/5xx and compares redirect
  paths percent-decoded (non-ASCII archives like /tags/pokémon-species/ were
  treated as redirected away); get_with_headers backs off 1/2/4s on 429.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-21 20:17:58 +00:00

1154 lines
46 KiB
Rust

use serde::Serialize;
use std::collections::HashMap;
use std::env;
use std::fmt;
use std::sync::{Arc, OnceLock, RwLock};
use std::time::Duration;
use wreq::Client;
use wreq::Proxy;
use wreq::Response;
use wreq::Version;
use wreq::Uri;
use wreq::cookie::{Cookies, CookieStore, Jar};
use wreq::header::{HeaderMap, HeaderValue, SET_COOKIE, USER_AGENT};
use wreq::multipart::Form;
use wreq::redirect::Policy;
use wreq_util::Emulation;
use crate::util::flaresolverr::FlareSolverrRequest;
use crate::util::flaresolverr::Flaresolverr;
use crate::util::proxy;
// A Send + Sync error type for all async paths
type AnyErr = Box<dyn std::error::Error + Send + Sync + 'static>;
#[derive(serde::Serialize, serde::Deserialize, Clone)]
pub struct Requester {
#[serde(skip)]
pub(crate) client: Client,
#[serde(skip)]
pub(crate) cookie_jar: Arc<Jar>,
#[serde(skip)]
pub(crate) debug_trace_id: Option<String>,
pub(crate) proxy: bool,
flaresolverr_session: Option<String>,
pub(crate) user_agent: Option<String>,
}
impl fmt::Debug for Requester {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Requester")
.field("proxy", &self.proxy)
.field("debug_trace_id", &self.debug_trace_id)
.field("flaresolverr_session", &self.flaresolverr_session)
.field("user_agent", &self.user_agent)
.finish()
}
}
impl Requester {
fn shared_cookie_jar() -> Arc<Jar> {
static SHARED_COOKIE_JAR: OnceLock<Arc<Jar>> = OnceLock::new();
SHARED_COOKIE_JAR
.get_or_init(|| Arc::new(Jar::default()))
.clone()
}
/// User agents recovered from FlareSolverr solves, keyed by host.
///
/// The cookie jar is already process-wide, but the solved user agent used to
/// live only on the `Requester` that ran the solve. Providers clone their
/// requester for every concurrent detail fetch, so those clones went out with
/// the default user agent and Cloudflare rejected them — forcing another
/// (~15s) solve per page. Keeping the identity here lets every later request
/// to that host replay the solved user agent alongside the shared cookies and
/// stay on the direct path.
fn flaresolverr_user_agents() -> &'static RwLock<HashMap<String, String>> {
static USER_AGENTS: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
USER_AGENTS.get_or_init(|| RwLock::new(HashMap::new()))
}
fn host_key(url: &str) -> Option<String> {
url::Url::parse(url)
.ok()?
.host_str()
.map(|host| host.trim_start_matches("www.").to_ascii_lowercase())
}
pub(crate) fn remember_flaresolverr_user_agent(url: &str, user_agent: &str) {
if user_agent.is_empty() {
return;
}
let Some(host) = Self::host_key(url) else {
return;
};
if let Ok(mut agents) = Self::flaresolverr_user_agents().write() {
agents.insert(host, user_agent.to_string());
}
}
pub(crate) fn remembered_flaresolverr_user_agent(url: &str) -> Option<String> {
let host = Self::host_key(url)?;
Self::flaresolverr_user_agents()
.read()
.ok()?
.get(&host)
.cloned()
}
/// Adopt a previously solved user agent for `url` when this requester does not
/// already carry one, so the direct attempt reuses the FlareSolverr identity.
/// Returns true when the client was rebuilt.
fn adopt_remembered_identity(&mut self, url: &str) -> bool {
if self.user_agent.is_some() {
return false;
}
let Some(user_agent) = Self::remembered_flaresolverr_user_agent(url) else {
return false;
};
self.user_agent = Some(user_agent);
self.client = Self::build_client(self.cookie_jar.clone(), self.user_agent.as_deref());
true
}
fn origin_url_for_cookie_scope(url: &str) -> Option<url::Url> {
let parsed = url::Url::parse(url).ok()?;
let host = parsed.host_str()?;
let scheme = parsed.scheme();
url::Url::parse(&format!("{scheme}://{host}/")).ok()
}
fn store_response_cookies(&self, url: &str, response: &Response) {
let Some(default_origin) = Self::origin_url_for_cookie_scope(url) else {
return;
};
for value in response.headers().get_all(SET_COOKIE).iter() {
let Ok(cookie) = value.to_str() else {
continue;
};
// Honour the Domain attribute: if Set-Cookie specifies Domain=.foo.com,
// register the cookie against https://foo.com/ so the Jar returns it for
// sub.foo.com requests as well.
let origin = Self::cookie_domain_origin(cookie, &default_origin);
self.cookie_jar.add(cookie, origin.as_str());
}
}
/// Derive the registration origin for a cookie string.
/// If the string contains `Domain=<d>`, strips the leading dot and builds a
/// URL from `<d>` so the jar scopes the cookie to the whole domain tree.
/// Falls back to `default` when no Domain attribute is present.
fn cookie_domain_origin(cookie_str: &str, default: &url::Url) -> url::Url {
for attr in cookie_str.split(';').skip(1) {
let attr = attr.trim();
if attr.len() >= 7 && attr[..7].eq_ignore_ascii_case("domain=") {
let domain = attr[7..].trim().trim_start_matches('.');
if !domain.is_empty() {
let scheme = default.scheme();
if let Ok(u) = url::Url::parse(&format!("{scheme}://{domain}/")) {
return u;
}
}
}
}
default.clone()
}
pub(crate) fn store_flaresolverr_cookies(
&mut self,
request_url: &str,
cookies: &[crate::util::flaresolverr::FlaresolverrCookie],
) {
let fallback_origin = Self::origin_url_for_cookie_scope(request_url);
for cookie in cookies {
let origin = if !cookie.domain.is_empty() {
let scheme = fallback_origin
.as_ref()
.map(|url| url.scheme())
.unwrap_or("https");
let host = cookie.domain.trim_start_matches('.');
url::Url::parse(&format!("{scheme}://{host}/"))
.ok()
.or_else(|| fallback_origin.clone())
} else {
fallback_origin.clone()
};
let Some(origin) = origin else {
continue;
};
let mut cookie_string =
format!("{}={}; Path={}", cookie.name, cookie.value, cookie.path);
if !cookie.domain.is_empty() {
cookie_string.push_str(&format!("; Domain={}", cookie.domain));
}
if cookie.secure {
cookie_string.push_str("; Secure");
}
if cookie.httpOnly {
cookie_string.push_str("; HttpOnly");
}
if let Some(same_site) = cookie.sameSite.as_deref() {
if !same_site.is_empty() {
cookie_string.push_str(&format!("; SameSite={same_site}"));
}
}
self.cookie_jar.add(cookie_string.as_str(), origin.as_str());
}
}
fn debug_cookie_preview_from_owned_headers(
&self,
url: &str,
headers: &[(String, String)],
) -> String {
if let Some((_, value)) = headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case("cookie"))
{
return crate::util::flow_debug::preview(value, 160);
}
self.cookie_header_for_url(url)
.map(|cookie| crate::util::flow_debug::preview(&cookie, 160))
.unwrap_or_else(|| "none".to_string())
}
pub(crate) fn looks_like_cloudflare_challenge(body: &str) -> bool {
body.contains("Just a moment...")
|| body.contains("cf-turnstile-response")
|| body.contains("Enable JavaScript and cookies to continue")
|| Self::looks_like_ddos_guard_challenge(body)
}
/// DDoS-Guard interstitials (rule34video) are short JS-check pages, never real content.
fn looks_like_ddos_guard_challenge(body: &str) -> bool {
body.len() < 20_000
&& (body.contains("/.well-known/ddos-guard/")
|| body.contains("check.ddos-guard.net")
|| body.contains("DDoS-Guard</title>"))
}
fn jina_mirror_url(url: &str) -> Option<String> {
if url.trim().is_empty() {
return None;
}
if url.starts_with("https://r.jina.ai/") || url.starts_with("http://r.jina.ai/") {
return Some(url.to_string());
}
let stripped = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.unwrap_or(url);
Some(format!("https://r.jina.ai/http://{stripped}"))
}
async fn fetch_jina_mirror_body(
cookie_jar: Arc<Jar>,
user_agent: Option<String>,
proxy_enabled: bool,
url: &str,
) -> Result<String, AnyErr> {
let mirror_url = Self::jina_mirror_url(url).ok_or("invalid mirror url")?;
let client = Self::build_client(cookie_jar, user_agent.as_deref());
let mut request = client
.get(&mirror_url)
.version(Version::HTTP_11)
.header("X-Return-Format", "html");
if proxy_enabled {
if let Ok(proxy_url) = env::var("BURP_URL") {
let proxy = Proxy::all(&proxy_url)?;
request = request.proxy(proxy);
}
}
let response = request.send().await?;
if !response.status().is_success() {
return Err(format!("jina mirror returned status {}", response.status()).into());
}
Ok(response.text().await?)
}
fn debug_cookie_preview_from_borrowed_headers(
&self,
url: &str,
headers: &[(&str, &str)],
) -> String {
if let Some((_, value)) = headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case("cookie"))
{
return crate::util::flow_debug::preview(value, 160);
}
self.cookie_header_for_url(url)
.map(|cookie| crate::util::flow_debug::preview(&cookie, 160))
.unwrap_or_else(|| "none".to_string())
}
pub(crate) fn build_client(cookie_jar: Arc<Jar>, user_agent: Option<&str>) -> Client {
let mut builder = Client::builder()
.tls_cert_verification(false)
.emulation(Emulation::Firefox151)
.cookie_provider(cookie_jar)
.redirect(Policy::default());
if let Some(user_agent) = user_agent {
let mut headers = HeaderMap::new();
if let Ok(value) = HeaderValue::from_str(user_agent) {
headers.insert(USER_AGENT, value);
builder = builder.default_headers(headers);
}
}
builder.build().expect("Failed to create HTTP client")
}
pub fn new() -> Self {
let cookie_jar = Self::shared_cookie_jar();
let client = Self::build_client(cookie_jar.clone(), None);
let requester = Requester {
client,
cookie_jar,
debug_trace_id: None,
proxy: false,
flaresolverr_session: None,
user_agent: None,
};
proxy::init_all_proxies_background(requester.clone());
requester
}
pub fn set_proxy(&mut self, proxy: bool) {
if proxy {
println!("Proxy enabled");
}
self.proxy = proxy;
}
pub fn set_debug_trace_id(&mut self, debug_trace_id: Option<String>) {
self.debug_trace_id = debug_trace_id;
}
pub fn debug_trace_id(&self) -> Option<&str> {
self.debug_trace_id.as_deref()
}
pub fn cookie_header_for_url(&self, url: &str) -> Option<String> {
let uri = url.parse::<Uri>().ok()?;
// HTTP/1.1 asks the store for the combined `a=1; b=2` form; HTTP/2 would
// hand back one field value per cookie instead.
let joined = match self.cookie_jar.cookies(&uri, Version::HTTP_11) {
Cookies::Compressed(value) => value.to_str().ok()?.to_string(),
Cookies::Uncompressed(values) => values
.iter()
.filter_map(|value| value.to_str().ok())
.collect::<Vec<_>>()
.join("; "),
_ => return None,
};
(!joined.is_empty()).then_some(joined)
}
pub async fn get_raw(&mut self, url: &str) -> Result<Response, wreq::Error> {
self.adopt_remembered_identity(url);
let cookie_preview = self
.cookie_header_for_url(url)
.map(|cookie| crate::util::flow_debug::preview(&cookie, 160))
.unwrap_or_else(|| "none".to_string());
#[cfg(not(feature = "debug"))]
let _ = &cookie_preview;
crate::flow_debug!(
"trace={} requester get_raw url={} cookies={} proxy={}",
self.debug_trace_id().unwrap_or("none"),
crate::util::flow_debug::preview(url, 120),
cookie_preview,
self.proxy
);
let client = Self::build_client(self.cookie_jar.clone(), self.user_agent.as_deref());
let mut request = client.get(url).version(Version::HTTP_11);
if self.proxy {
if let Ok(proxy_url) = env::var("BURP_URL") {
let proxy = Proxy::all(&proxy_url).unwrap();
request = request.proxy(proxy);
}
}
let response = request.send().await?;
self.store_response_cookies(url, &response);
Ok(response)
}
/// Like [`get_raw`], but disables redirect-following so a 3xx response's
/// `Location` header can be inspected directly. Uses the same
/// Cloudflare-resilient client (Firefox emulation, shared cookie jar,
/// FlareSolverr-derived user agent) as every other request path — unlike
/// a bare `wreq::Client`, which Cloudflare is far more likely to flag,
/// especially from datacenter/production IPs.
pub async fn get_no_redirect_raw(&mut self, url: &str) -> Result<Response, wreq::Error> {
self.adopt_remembered_identity(url);
let cookie_preview = self
.cookie_header_for_url(url)
.map(|cookie| crate::util::flow_debug::preview(&cookie, 160))
.unwrap_or_else(|| "none".to_string());
#[cfg(not(feature = "debug"))]
let _ = &cookie_preview;
crate::flow_debug!(
"trace={} requester get_no_redirect_raw url={} cookies={} proxy={}",
self.debug_trace_id().unwrap_or("none"),
crate::util::flow_debug::preview(url, 120),
cookie_preview,
self.proxy
);
let mut builder = Client::builder()
.tls_cert_verification(false)
.emulation(Emulation::Firefox151)
.cookie_provider(self.cookie_jar.clone())
.redirect(Policy::none());
if let Some(user_agent) = self.user_agent.as_deref() {
let mut headers = HeaderMap::new();
if let Ok(value) = HeaderValue::from_str(user_agent) {
headers.insert(USER_AGENT, value);
builder = builder.default_headers(headers);
}
}
let client = builder.build().expect("Failed to create HTTP client");
let mut request = client.get(url).version(Version::HTTP_11);
if self.proxy {
if let Ok(proxy_url) = env::var("BURP_URL") {
let proxy = Proxy::all(&proxy_url).unwrap();
request = request.proxy(proxy);
}
}
let response = request.send().await?;
self.store_response_cookies(url, &response);
Ok(response)
}
/// Like [`get_raw_with_headers`], but disables redirect-following so a 3xx response's
/// `Location` header can be inspected directly.
pub async fn get_no_redirect_raw_with_headers(
&mut self,
url: &str,
headers: Vec<(String, String)>,
) -> Result<Response, wreq::Error> {
self.adopt_remembered_identity(url);
let cookie_preview = self
.cookie_header_for_url(url)
.map(|cookie| crate::util::flow_debug::preview(&cookie, 160))
.unwrap_or_else(|| "none".to_string());
#[cfg(not(feature = "debug"))]
let _ = &cookie_preview;
crate::flow_debug!(
"trace={} requester get_no_redirect_raw_with_headers url={} headers={} cookies={} proxy={}",
self.debug_trace_id().unwrap_or("none"),
crate::util::flow_debug::preview(url, 120),
headers.len(),
cookie_preview,
self.proxy
);
let mut builder = Client::builder()
.tls_cert_verification(false)
.emulation(Emulation::Firefox151)
.cookie_provider(self.cookie_jar.clone())
.redirect(Policy::none());
if let Some(user_agent) = self.user_agent.as_deref() {
let mut hdrs = HeaderMap::new();
if let Ok(value) = HeaderValue::from_str(user_agent) {
hdrs.insert(USER_AGENT, value);
builder = builder.default_headers(hdrs);
}
}
let client = builder.build().expect("Failed to create HTTP client");
let mut request = client.get(url).version(Version::HTTP_11);
if self.proxy {
if let Ok(proxy_url) = env::var("BURP_URL") {
let proxy = Proxy::all(&proxy_url).unwrap();
request = request.proxy(proxy);
}
}
// Set custom headers
for (key, value) in headers.iter() {
request = request.header(key, value);
}
let response = request.send().await?;
self.store_response_cookies(url, &response);
Ok(response)
}
pub async fn get_raw_with_headers(
&mut self,
url: &str,
headers: Vec<(String, String)>,
) -> Result<Response, wreq::Error> {
self.get_raw_with_headers_timeout(url, headers, None, None).await
}
pub async fn get_raw_with_headers_timeout(
&mut self,
url: &str,
headers: Vec<(String, String)>,
timeout: Option<Duration>,
http_version: Option<Version>,
) -> Result<Response, wreq::Error> {
// Replay the solved FlareSolverr identity here too: a token minted
// through `get_with_headers` under that user agent is rejected (401)
// when the follow-up raw request goes out with the default one.
self.adopt_remembered_identity(url);
let cookie_preview = self.debug_cookie_preview_from_owned_headers(url, &headers);
#[cfg(not(feature = "debug"))]
let _ = &cookie_preview;
crate::flow_debug!(
"trace={} requester get_raw_with_headers url={} headers={} cookies={} http_version={:?} proxy={}",
self.debug_trace_id().unwrap_or("none"),
crate::util::flow_debug::preview(url, 120),
headers.len(),
cookie_preview,
http_version,
self.proxy
);
let client = Self::build_client(self.cookie_jar.clone(), self.user_agent.as_deref());
let mut request = client.get(url);
if let Some(version) = http_version {
request = request.version(version);
} else {
request = request.version(Version::HTTP_11);
}
if let Some(timeout) = timeout {
request = request.timeout(timeout);
}
if self.proxy {
if let Ok(proxy_url) = env::var("BURP_URL") {
let proxy = Proxy::all(&proxy_url).unwrap();
request = request.proxy(proxy);
}
}
// Set custom headers
for (key, value) in headers.iter() {
request = request.header(key, value);
}
let response = request.send().await?;
self.store_response_cookies(url, &response);
Ok(response)
}
pub async fn post_json<S>(
&mut self,
url: &str,
data: &S,
headers: Vec<(String, String)>,
) -> Result<Response, wreq::Error>
where
S: Serialize + ?Sized,
{
let cookie_preview = self.debug_cookie_preview_from_owned_headers(url, &headers);
#[cfg(not(feature = "debug"))]
let _ = &cookie_preview;
crate::flow_debug!(
"trace={} requester post_json url={} headers={} cookies={} proxy={}",
self.debug_trace_id().unwrap_or("none"),
crate::util::flow_debug::preview(url, 120),
headers.len(),
cookie_preview,
self.proxy
);
let mut request = self.client.post(url).version(Version::HTTP_11).json(data);
// Set custom headers
for (key, value) in headers.iter() {
request = request.header(key, value);
}
if self.proxy {
if let Ok(proxy_url) = env::var("BURP_URL") {
let proxy = Proxy::all(&proxy_url).unwrap();
request = request.proxy(proxy);
}
}
let response = request.send().await?;
self.store_response_cookies(url, &response);
Ok(response)
}
pub async fn post(
&mut self,
url: &str,
data: &str,
headers: Vec<(&str, &str)>,
) -> Result<Response, wreq::Error> {
let cookie_preview = self.debug_cookie_preview_from_borrowed_headers(url, &headers);
#[cfg(not(feature = "debug"))]
let _ = &cookie_preview;
crate::flow_debug!(
"trace={} requester post url={} headers={} cookies={} body_len={} proxy={}",
self.debug_trace_id().unwrap_or("none"),
crate::util::flow_debug::preview(url, 120),
headers.len(),
cookie_preview,
data.len(),
self.proxy
);
let mut request = self
.client
.post(url)
.version(Version::HTTP_11)
.body(data.to_string());
// Set custom headers
for (key, value) in headers.iter() {
request = request.header(key.to_string(), value.to_string());
}
if self.proxy {
if let Ok(proxy_url) = env::var("BURP_URL") {
let proxy = Proxy::all(&proxy_url).unwrap();
request = request.proxy(proxy);
}
}
let response = request.send().await?;
self.store_response_cookies(url, &response);
Ok(response)
}
pub async fn post_multipart(
&mut self,
url: &str,
form: Form,
headers: Vec<(String, String)>,
_http_version: Option<Version>,
) -> Result<Response, wreq::Error> {
let cookie_preview = self.debug_cookie_preview_from_owned_headers(url, &headers);
#[cfg(not(feature = "debug"))]
let _ = &cookie_preview;
crate::flow_debug!(
"trace={} requester post_multipart url={} headers={} cookies={} proxy={}",
self.debug_trace_id().unwrap_or("none"),
crate::util::flow_debug::preview(url, 120),
headers.len(),
cookie_preview,
self.proxy
);
let http_version = match _http_version {
Some(v) => v,
None => Version::HTTP_11,
};
let mut request = self.client.post(url).multipart(form).version(http_version);
// Set custom headers
for (key, value) in headers.iter() {
request = request.header(key, value);
}
if self.proxy {
if let Ok(proxy_url) = env::var("BURP_URL") {
let proxy = Proxy::all(&proxy_url).unwrap();
request = request.proxy(proxy);
}
}
let response = request.send().await?;
self.store_response_cookies(url, &response);
Ok(response)
}
fn path_of(url: &str) -> Option<String> {
url::Url::parse(url).ok().map(|u| u.path().to_string())
}
/// `Url` percent-encodes a non-ASCII path ("/tags/pok%C3%A9mon/") while the
/// response URI keeps it raw ("/tags/pokémon/"): compare them decoded.
fn same_path(asked: &str, landed: &str) -> bool {
let normalize = |path: &str| {
percent_encoding::percent_decode_str(path)
.decode_utf8_lossy()
.trim_end_matches('/')
.to_lowercase()
};
normalize(asked) == normalize(landed)
}
/// Existence probe for archive pages (pornstar / studio / tag / category).
///
/// Reads the raw response so a real 404/410 answers immediately with
/// `Ok(None)` instead of escalating through the Jina/FlareSolverr chain
/// (minutes per miss). A Cloudflare challenge (403/503/"Just a moment...")
/// is handed to the full chain so FlareSolverr solves it, and 429/5xx are
/// retried once.
pub async fn get_probe(
&mut self,
url: &str,
headers: Vec<(String, String)>,
) -> Result<Option<String>, AnyErr> {
let mut last_err = String::new();
for attempt in 0..2 {
if attempt > 0 {
// An immediate retry of a 429 is all but certain to hit it again.
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
}
match self.get_raw_with_headers(url, headers.clone()).await {
Ok(response) => {
let status = response.status().as_u16();
// An unknown archive often redirects to the home page or a
// search page instead of answering 404: a redirect that
// lands on a different path is a miss.
let redirected_away = Self::path_of(url)
.zip(Some(response.uri().path().to_string()))
.is_some_and(|(asked, landed)| !Self::same_path(&asked, &landed));
let body = response.text().await.unwrap_or_default();
if status == 404 || status == 410 {
return Ok(None);
}
if status == 403
|| status == 503
|| Self::looks_like_cloudflare_challenge(&body)
{
return self.get_with_headers(url, headers, None).await.map(Some);
}
if redirected_away {
return Ok(None);
}
if status >= 500 || status == 429 {
last_err = format!("HTTP {status}");
continue;
}
return Ok(Some(body));
}
Err(e) => last_err = e.to_string(),
}
}
Err(format!("probe failed for {url}: {last_err}").into())
}
pub async fn get(
&mut self,
url: &str,
_http_version: Option<Version>,
) -> Result<String, AnyErr> {
self.get_with_headers(url, Vec::new(), _http_version).await
}
pub async fn get_with_headers(
&mut self,
url: &str,
headers: Vec<(String, String)>,
_http_version: Option<Version>,
) -> Result<String, AnyErr> {
if self.adopt_remembered_identity(url) {
crate::flow_debug!(
"trace={} requester reusing flaresolverr identity url={} user_agent={}",
self.debug_trace_id().unwrap_or("none"),
crate::util::flow_debug::preview(url, 120),
crate::util::flow_debug::preview(self.user_agent.as_deref().unwrap_or("unknown"), 96)
);
}
let cookie_preview = self.debug_cookie_preview_from_owned_headers(url, &headers);
#[cfg(not(feature = "debug"))]
let _ = &cookie_preview;
crate::flow_debug!(
"trace={} requester get_with_headers start url={} headers={} cookies={} http_version={:?} proxy={}",
self.debug_trace_id().unwrap_or("none"),
crate::util::flow_debug::preview(url, 120),
headers.len(),
cookie_preview,
_http_version,
self.proxy
);
// Only pin the protocol when the caller asked for one. Defaulting to
// HTTP/1.1 used to force every request onto h1 while the TLS handshake
// still advertised the browser emulation's h2 ALPN — a mismatch
// Cloudflare scores as a bot. On jable.tv it is a deterministic 403:
// the identical client returns 200 over the negotiated h2 and 403 the
// moment the version is pinned to HTTP/1.1.
let cookie_jar = self.cookie_jar.clone();
let user_agent = self.user_agent.clone();
let proxy_enabled = self.proxy;
let _trace_id = self.debug_trace_id.as_deref().unwrap_or("none").to_string();
let url_owned = url.to_string();
let jina_handle = tokio::spawn(async move {
Self::fetch_jina_mirror_body(cookie_jar, user_agent, proxy_enabled, &url_owned).await
});
// A persistent 429 (e.g. a site's IP-level rate limit kicking in) used
// to retry forever with no cap and no fallback, hanging until an
// unrelated caller-side timeout cut it off. Cap the retries and fall
// through to the same Jina/FlareSolverr fallback every other failure
// status uses once they're exhausted.
const MAX_RATE_LIMIT_RETRIES: u8 = 3;
let mut rate_limit_retries: u8 = 0;
loop {
let mut request = self.client.get(url);
if let Some(version) = _http_version {
request = request.version(version);
}
for (key, value) in headers.iter() {
request = request.header(key, value);
}
if self.proxy {
if let Ok(proxy_url) = env::var("BURP_URL") {
let proxy = Proxy::all(&proxy_url).unwrap();
request = request.proxy(proxy);
}
}
let response = match request.send().await {
Ok(response) => response,
Err(_error) => {
crate::flow_debug!(
"trace={} requester direct transport failed url={} error={} using_jina_fallback=true",
_trace_id,
crate::util::flow_debug::preview(url, 120),
crate::util::flow_debug::preview(&_error.to_string(), 160)
);
match jina_handle.await {
Ok(Ok(body)) => {
if Self::looks_like_cloudflare_challenge(&body) {
crate::flow_debug!(
"trace={} requester jina fallback returned cloudflare challenge after transport error url={} using_flaresolverr=true",
_trace_id,
crate::util::flow_debug::preview(url, 120)
);
} else {
return Ok(body);
}
}
Ok(Err(_jina_error)) => {
crate::flow_debug!(
"trace={} requester jina fallback failed after transport error url={} error={}",
_trace_id,
crate::util::flow_debug::preview(url, 120),
crate::util::flow_debug::preview(&_jina_error.to_string(), 160)
);
}
Err(_join_error) => {
crate::flow_debug!(
"trace={} requester jina task join failed after transport error url={} error={}",
_trace_id,
crate::util::flow_debug::preview(url, 120),
crate::util::flow_debug::preview(&_join_error.to_string(), 160)
);
}
}
break;
}
};
self.store_response_cookies(url, &response);
crate::flow_debug!(
"trace={} requester direct response url={} status={}",
self.debug_trace_id().unwrap_or("none"),
crate::util::flow_debug::preview(url, 120),
response.status()
);
if response.status().is_success() || response.status().as_u16() == 404 {
let status = response.status();
let body = response.text().await?;
if status.is_success() && Self::looks_like_cloudflare_challenge(&body) {
crate::flow_debug!(
"trace={} requester direct response looks like cloudflare challenge url={} using_jina_fallback=true",
_trace_id,
crate::util::flow_debug::preview(url, 120)
);
match jina_handle.await {
Ok(Ok(jina_body)) => {
if Self::looks_like_cloudflare_challenge(&jina_body) {
crate::flow_debug!(
"trace={} requester jina fallback also returned cloudflare challenge url={}",
_trace_id,
crate::util::flow_debug::preview(url, 120)
);
} else {
return Ok(jina_body);
}
}
Ok(Err(_jina_error)) => {
crate::flow_debug!(
"trace={} requester jina fallback failed after cloudflare challenge url={} error={}",
_trace_id,
crate::util::flow_debug::preview(url, 120),
crate::util::flow_debug::preview(&_jina_error.to_string(), 160)
);
}
Err(_join_error) => {
crate::flow_debug!(
"trace={} requester jina task join failed after cloudflare challenge url={} error={}",
_trace_id,
crate::util::flow_debug::preview(url, 120),
crate::util::flow_debug::preview(&_join_error.to_string(), 160)
);
}
}
break;
}
jina_handle.abort();
return Ok(body);
}
if response.status().as_u16() == 429 && rate_limit_retries < MAX_RATE_LIMIT_RETRIES {
rate_limit_retries += 1;
crate::flow_debug!(
"trace={} requester direct retry url={} status=429 attempt={}/{}",
self.debug_trace_id().unwrap_or("none"),
crate::util::flow_debug::preview(url, 120),
rate_limit_retries,
MAX_RATE_LIMIT_RETRIES
);
// 1s, 2s, 4s: a site-wide limit needs longer than a second to lift.
tokio::time::sleep(std::time::Duration::from_secs(1 << (rate_limit_retries - 1)))
.await;
continue;
} else {
crate::flow_debug!(
"trace={} requester direct failed url={} status={} using_jina_fallback=true",
_trace_id,
crate::util::flow_debug::preview(url, 120),
response.status()
);
match jina_handle.await {
Ok(Ok(body)) => {
if Self::looks_like_cloudflare_challenge(&body) {
crate::flow_debug!(
"trace={} requester jina fallback returned cloudflare challenge url={}",
_trace_id,
crate::util::flow_debug::preview(url, 120)
);
} else {
return Ok(body);
}
}
Ok(Err(_error)) => {
crate::flow_debug!(
"trace={} requester jina fallback failed url={} error={}",
_trace_id,
crate::util::flow_debug::preview(url, 120),
crate::util::flow_debug::preview(&_error.to_string(), 160)
);
}
Err(_error) => {
crate::flow_debug!(
"trace={} requester jina task join failed url={} error={}",
_trace_id,
crate::util::flow_debug::preview(url, 120),
crate::util::flow_debug::preview(&_error.to_string(), 160)
);
}
}
break;
}
}
// If direct request failed, try FlareSolverr. Map its error to a Send+Sync error immediately,
// so no non-Send error value lives across later `.await`s.
let flare_url = match env::var("FLARE_URL") {
Ok(url) => url,
Err(e) => return Err(format!("FLARE_URL not set: {e}").into()),
};
let mut flare = Flaresolverr::new(flare_url);
if self.proxy && env::var("BURP_URL").is_ok() {
flare.set_proxy(true);
}
crate::flow_debug!(
"trace={} requester flaresolverr url={} proxy={}",
self.debug_trace_id().unwrap_or("none"),
crate::util::flow_debug::preview(url, 120),
self.proxy
);
let res = flare
.solve(FlareSolverrRequest {
cmd: "request.get".to_string(),
url: url.to_string(),
maxTimeout: 60000,
})
.await
.map_err(|e| -> AnyErr { format!("Failed to solve FlareSolverr: {e}").into() })?;
// Rebuild client and apply UA/cookies from FlareSolverr. Both are also
// published process-wide (cookies via the shared jar, user agent by host)
// so later requests can go direct instead of solving again.
let useragent = res.solution.userAgent;
Self::remember_flaresolverr_user_agent(url, &useragent);
self.user_agent = Some(useragent);
self.store_flaresolverr_cookies(url, &res.solution.cookies);
self.client = Self::build_client(self.cookie_jar.clone(), self.user_agent.as_deref());
crate::flow_debug!(
"trace={} requester flaresolverr solved url={} user_agent={}",
self.debug_trace_id().unwrap_or("none"),
crate::util::flow_debug::preview(url, 120),
crate::util::flow_debug::preview(self.user_agent.as_deref().unwrap_or("unknown"), 96)
);
// Retry the original URL with the updated client & (optional) proxy.
// Left unpinned so the protocol matches the emulated ALPN, same as the
// direct attempt.
let mut request = self.client.get(url);
for (key, value) in headers.iter() {
request = request.header(key, value);
}
if self.proxy {
if let Ok(proxy_url) = env::var("BURP_URL") {
let proxy = Proxy::all(&proxy_url).unwrap();
request = request.proxy(proxy);
}
}
let response = match request.send().await {
Ok(r) => r,
Err(_retry_err) => {
crate::flow_debug!(
"trace={} requester post-flaresolverr retry transport failed url={} using_flaresolverr_body=true",
self.debug_trace_id().unwrap_or("none"),
crate::util::flow_debug::preview(url, 120)
);
return Ok(res.solution.response);
}
};
self.store_response_cookies(url, &response);
crate::flow_debug!(
"trace={} requester retry response url={} status={}",
self.debug_trace_id().unwrap_or("none"),
crate::util::flow_debug::preview(url, 120),
response.status()
);
if response.status().is_success() {
return Ok(response.text().await?);
}
// Fall back to FlareSolverr-provided body
crate::flow_debug!(
"trace={} requester fallback body url={}",
self.debug_trace_id().unwrap_or("none"),
crate::util::flow_debug::preview(url, 120)
);
Ok(res.solution.response)
}
}
#[cfg(test)]
mod tests {
use super::Requester;
#[test]
fn probe_paths_compare_percent_decoded() {
let asked = Requester::path_of("https://rule34video.com/tags/pokémon-species/").unwrap();
assert!(Requester::same_path(&asked, "/tags/pokémon-species/"));
assert!(Requester::same_path("/Models/X", "/models/x/"));
assert!(!Requester::same_path(&asked, "/search/"));
}
#[test]
fn new_requesters_share_cookie_jar() {
let a = Requester::new();
let b = Requester::new();
let origin = "https://shared-cookie-requester-test.invalid/";
a.cookie_jar
.add("shared_cookie=1; Path=/; SameSite=Lax", origin);
let cookie_header = b
.cookie_header_for_url("https://shared-cookie-requester-test.invalid/path")
.unwrap_or_default();
assert!(cookie_header.contains("shared_cookie=1"));
}
async fn serve(listener: tokio::net::TcpListener, reply: impl Fn(&str) -> String + Send + 'static) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
loop {
let Ok((mut sock, _)) = listener.accept().await else { return };
let mut buf = vec![0u8; 16384];
let mut len = 0;
// read headers, then the declared body
loop {
let Ok(n) = sock.read(&mut buf[len..]).await else { break };
if n == 0 { break; }
len += n;
let text = String::from_utf8_lossy(&buf[..len]).to_string();
if let Some(head_end) = text.find("\r\n\r\n") {
let want = text
.lines()
.find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0)))
.unwrap_or(0);
if len >= head_end + 4 + want { break; }
}
}
let req = String::from_utf8_lossy(&buf[..len]).to_string();
let _ = sock.write_all(reply(&req).as_bytes()).await;
let _ = sock.shutdown().await;
}
}
fn http(status: &str, ctype: &str, extra: &str, body: &str) -> String {
format!(
"HTTP/1.1 {status}\r\ncontent-type: {ctype}\r\n{extra}content-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
)
}
/// DDoS-Guard answers either a bare `403 Access denied.` (blocked fingerprint/IP) or a
/// short JS-check page. Both must end up at FlareSolverr and return the real listing.
#[tokio::test(flavor = "multi_thread")]
async fn ddos_guard_blocks_are_solved_through_flaresolverr() {
// distinct hosts: the cookie jar is process-wide, so a solved cookie would leak across passes
for (interstitial, host) in [(false, "127.0.0.1"), (true, "localhost")] {
let origin = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let flare = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let origin_port = origin.local_addr().unwrap().port();
let flare_port = flare.local_addr().unwrap().port();
tokio::spawn(serve(origin, move |req| {
if req.contains("ddg_ok=1") {
http("200 OK", "text/html", "", "<div class=\"item thumb video_1\">real listing</div>")
} else if interstitial {
http(
"200 OK",
"text/html",
"server: ddos-guard\r\n",
"<html><head><title>DDoS-Guard</title><script src=\"/.well-known/ddos-guard/check?context=free_splash\"></script></head></html>",
)
} else {
http("403 Forbidden", "text/plain", "server: ddos-guard\r\n", "Access denied.")
}
}));
tokio::spawn(serve(flare, move |req| {
let body = if req.contains("sessions.create") {
r#"{"status":"ok","message":"","session":"mock"}"#.to_string()
} else if req.contains("sessions.destroy") {
r#"{"status":"ok","message":""}"#.to_string()
} else {
format!(
r#"{{"status":"ok","message":"","startTimestamp":0,"endTimestamp":1,"version":"mock","solution":{{"url":"http://127.0.0.1:{origin_port}/","status":200,"response":"<div class=\"item thumb video_1\">real listing</div>","headers":{{}},"userAgent":"Mozilla/5.0 mock","cookies":[{{"name":"ddg_ok","value":"1","domain":"","path":"/"}}]}}}}"#
)
};
http("200 OK", "application/json", "", &body)
}));
unsafe { std::env::set_var("FLARE_URL", format!("http://127.0.0.1:{flare_port}/v1")) };
let mut requester = Requester::new();
let body = requester
.get(&format!("http://{host}:{origin_port}/?mode=async"), None)
.await
.expect("requester should fall through to FlareSolverr");
assert!(
body.contains("real listing"),
"interstitial={interstitial}: got {body:?}"
);
}
}
}