requester: treat DDoS-Guard interstitials as challenges (FlareSolverr fallback); mock-origin test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
This commit is contained in:
@@ -222,6 +222,15 @@ impl Requester {
|
||||
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> {
|
||||
@@ -1033,4 +1042,88 @@ mod tests {
|
||||
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user