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
This commit is contained in:
@@ -674,6 +674,18 @@ impl Requester {
|
||||
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
|
||||
@@ -687,7 +699,11 @@ impl Requester {
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<Option<String>, AnyErr> {
|
||||
let mut last_err = String::new();
|
||||
for _ in 0..2 {
|
||||
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();
|
||||
@@ -696,9 +712,7 @@ impl Requester {
|
||||
// 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)| {
|
||||
!asked.trim_end_matches('/').eq_ignore_ascii_case(landed.trim_end_matches('/'))
|
||||
});
|
||||
.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);
|
||||
@@ -893,7 +907,9 @@ impl Requester {
|
||||
rate_limit_retries,
|
||||
MAX_RATE_LIMIT_RETRIES
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
// 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!(
|
||||
@@ -1027,6 +1043,14 @@ impl Requester {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user