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:
@@ -1,6 +1,6 @@
|
|||||||
use crate::DbPool;
|
use crate::DbPool;
|
||||||
use crate::api::ClientVersion;
|
use crate::api::ClientVersion;
|
||||||
use crate::providers::{Provider, probe_first_archive, requester_or_default, slug_dashed};
|
use crate::providers::{Provider, requester_or_default, slug_dashed};
|
||||||
use crate::status::*;
|
use crate::status::*;
|
||||||
use crate::util::cache::VideoCache;
|
use crate::util::cache::VideoCache;
|
||||||
use crate::util::discord::send_discord_error_report;
|
use crate::util::discord::send_discord_error_report;
|
||||||
@@ -13,11 +13,22 @@ use error_chain::error_chain;
|
|||||||
use futures::stream::{self, StreamExt};
|
use futures::stream::{self, StreamExt};
|
||||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||||
use std::vec;
|
use std::vec;
|
||||||
|
|
||||||
const ENRICH_CONCURRENCY: usize = 6;
|
const ENRICH_CONCURRENCY: usize = 4;
|
||||||
|
/// The signed `v-acctoken` format URLs last ~12h; keep details well inside that.
|
||||||
|
const DETAIL_TTL: Duration = Duration::from_secs(2 * 60 * 60);
|
||||||
|
const DETAIL_CACHE_SIZE: usize = 5000;
|
||||||
|
const TARGET_TTL: Duration = Duration::from_secs(6 * 60 * 60);
|
||||||
|
const TARGET_CACHE_SIZE: usize = 2000;
|
||||||
|
/// A probed archive smaller than this loses to keyword search: "elf girl" is a
|
||||||
|
/// one-video tag, while the search finds hundreds.
|
||||||
|
const MIN_PROBED_ARCHIVE_ITEMS: usize = 4;
|
||||||
|
|
||||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||||
crate::providers::ProviderChannelMetadata {
|
crate::providers::ProviderChannelMetadata {
|
||||||
@@ -61,15 +72,43 @@ impl Target {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Rule34videoProvider {
|
pub struct Rule34videoProvider {
|
||||||
url: String,
|
url: String,
|
||||||
|
/// Enriched items (formats, tags, artist) by video URL. Every listing costs
|
||||||
|
/// ~23 detail pages, again for the prefetched next page; refetching them
|
||||||
|
/// on each request is what runs into the site's 429 rate limit.
|
||||||
|
details: VideoCache,
|
||||||
|
/// Bare query -> resolved target, so later pages skip the archive probes.
|
||||||
|
targets: Arc<Mutex<HashMap<String, (Instant, Target)>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Rule34videoProvider {
|
impl Rule34videoProvider {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
|
let mut details = VideoCache::new();
|
||||||
|
details.max_size(DETAIL_CACHE_SIZE);
|
||||||
Rule34videoProvider {
|
Rule34videoProvider {
|
||||||
url: "https://rule34video.com".to_string(),
|
url: "https://rule34video.com".to_string(),
|
||||||
|
details,
|
||||||
|
targets: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remembered_target(&self, query: &str) -> Option<Target> {
|
||||||
|
let targets = self.targets.lock().ok()?;
|
||||||
|
let (time, target) = targets.get(&slug_dashed(query))?;
|
||||||
|
(time.elapsed() < TARGET_TTL).then(|| target.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remember_target(&self, query: &str, target: &Target) {
|
||||||
|
if let Ok(mut targets) = self.targets.lock() {
|
||||||
|
if targets.len() >= TARGET_CACHE_SIZE {
|
||||||
|
targets.retain(|_, (time, _)| time.elapsed() < TARGET_TTL);
|
||||||
|
if targets.len() >= TARGET_CACHE_SIZE {
|
||||||
|
targets.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
targets.insert(slug_dashed(query), (Instant::now(), target.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,29 +209,37 @@ impl Rule34videoProvider {
|
|||||||
|
|
||||||
/// A free-form query that names an artist, category or tag goes to that
|
/// A free-form query that names an artist, category or tag goes to that
|
||||||
/// archive (probed on the site; unknown names 404), else keyword search.
|
/// archive (probed on the site; unknown names 404), else keyword search.
|
||||||
async fn resolve_keyword(&self, query: &str, options: &ServerOptions) -> Target {
|
/// The second value is false when a probe failed (rate limit, outage) and
|
||||||
|
/// the answer must not be remembered.
|
||||||
|
async fn resolve_keyword(&self, query: &str, options: &ServerOptions) -> (Target, bool) {
|
||||||
let slug = slug_dashed(query);
|
let slug = slug_dashed(query);
|
||||||
if slug.is_empty() {
|
if slug.is_empty() {
|
||||||
return Target::Search(query.trim().to_string());
|
return (Target::Search(query.trim().to_string()), true);
|
||||||
}
|
}
|
||||||
let candidates = [
|
let candidates = [
|
||||||
Target::Artist(slug.clone()),
|
Target::Artist(slug.clone()),
|
||||||
Target::Category(slug.clone()),
|
Target::Category(slug.clone()),
|
||||||
Target::Tag(slug),
|
Target::Tag(slug),
|
||||||
];
|
];
|
||||||
let urls: Vec<String> = candidates
|
|
||||||
.iter()
|
|
||||||
.filter_map(|t| t.archive_path())
|
|
||||||
.map(|path| format!("{}/{path}/", self.url))
|
|
||||||
.collect();
|
|
||||||
let mut requester = requester_or_default(options, "rule34video", "resolve_keyword");
|
let mut requester = requester_or_default(options, "rule34video", "resolve_keyword");
|
||||||
let referer = format!("{}/", self.url);
|
let referer = format!("{}/", self.url);
|
||||||
if let Some((index, _)) = probe_first_archive(&mut requester, &urls, &referer, |body| {
|
let mut conclusive = true;
|
||||||
body.contains("item thumb video_")
|
for candidate in &candidates {
|
||||||
})
|
let Some(path) = candidate.archive_path() else {
|
||||||
.await
|
continue;
|
||||||
{
|
};
|
||||||
return candidates[index].clone();
|
let url = format!("{}/{path}/", self.url);
|
||||||
|
let headers = vec![("Referer".to_string(), referer.clone())];
|
||||||
|
match requester.get_probe(&url, headers).await {
|
||||||
|
Ok(Some(body)) => {
|
||||||
|
let items = self.get_video_items_from_html(body).len();
|
||||||
|
if items >= MIN_PROBED_ARCHIVE_ITEMS {
|
||||||
|
return (candidate.clone(), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(_) => conclusive = false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Characters are tagged "name (franchise)": ask the site's own tag search.
|
// Characters are tagged "name (franchise)": ask the site's own tag search.
|
||||||
@@ -205,13 +252,17 @@ impl Rule34videoProvider {
|
|||||||
("Referer".to_string(), referer),
|
("Referer".to_string(), referer),
|
||||||
("X-Requested-With".to_string(), "XMLHttpRequest".to_string()),
|
("X-Requested-With".to_string(), "XMLHttpRequest".to_string()),
|
||||||
];
|
];
|
||||||
if let Ok(Some(body)) = requester.get_probe(&suggest_url, headers).await {
|
match requester.get_probe(&suggest_url, headers).await {
|
||||||
|
Ok(Some(body)) => {
|
||||||
let titles = Self::parse_tag_suggestions(&body);
|
let titles = Self::parse_tag_suggestions(&body);
|
||||||
if let Some(title) = Self::best_tag_title(&titles, query) {
|
if let Some(title) = Self::best_tag_title(&titles, query) {
|
||||||
return Target::Tag(slug_dashed(&title));
|
return (Target::Tag(slug_dashed(&title)), conclusive);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Target::Search(query.trim().to_string())
|
Ok(None) => {}
|
||||||
|
Err(_) => conclusive = false,
|
||||||
|
}
|
||||||
|
(Target::Search(query.trim().to_string()), conclusive)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_target(&self, query: Option<&str>, options: &ServerOptions) -> Target {
|
async fn resolve_target(&self, query: Option<&str>, options: &ServerOptions) -> Target {
|
||||||
@@ -221,7 +272,14 @@ impl Rule34videoProvider {
|
|||||||
if let Some(target) = Self::parse_prefixed(query) {
|
if let Some(target) = Self::parse_prefixed(query) {
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
self.resolve_keyword(query, options).await
|
if let Some(target) = self.remembered_target(query) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
let (target, conclusive) = self.resolve_keyword(query, options).await;
|
||||||
|
if conclusive {
|
||||||
|
self.remember_target(query, &target);
|
||||||
|
}
|
||||||
|
target
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn archive(
|
async fn archive(
|
||||||
@@ -254,20 +312,35 @@ impl Rule34videoProvider {
|
|||||||
self.url
|
self.url
|
||||||
);
|
);
|
||||||
let headers = vec![("Referer".to_string(), format!("{}/{path}/", self.url))];
|
let headers = vec![("Referer".to_string(), format!("{}/{path}/", self.url))];
|
||||||
|
let stale = || cache.get(&index).map(|(_, items)| items).unwrap_or_default();
|
||||||
let text = match requester.get_probe(&url, headers).await {
|
let text = match requester.get_probe(&url, headers).await {
|
||||||
Ok(Some(text)) => text,
|
Ok(Some(text)) => text,
|
||||||
// A 404 is an unknown archive (or a page past the end).
|
// A 404 is an unknown archive (or a page past the end).
|
||||||
Ok(None) => return Ok(vec![]),
|
Ok(None) => return Ok(vec![]),
|
||||||
Err(e) => return Err(format!("archive fetch failed for {url}: {e}").into()),
|
Err(e) => {
|
||||||
|
eprintln!("rule34video archive fetch failed for {url}: {e}");
|
||||||
|
return Ok(stale());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let video_items = self.get_video_items_from_html(text);
|
let video_items = self.get_video_items_from_html(text);
|
||||||
let video_items = self.enrich_items(video_items, requester).await;
|
let video_items = self.enrich_items(video_items, requester).await;
|
||||||
if !video_items.is_empty() {
|
if video_items.is_empty() {
|
||||||
cache.insert(index, video_items.clone());
|
return Ok(stale());
|
||||||
}
|
}
|
||||||
|
cache.insert(index, video_items.clone());
|
||||||
Ok(video_items)
|
Ok(video_items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `/search/{q}/` takes dashes for spaces. A raw `/` splits the path (404),
|
||||||
|
/// `%20` is refused (403) and `&` cuts the query short, so keep only words.
|
||||||
|
fn search_path_segment(query: &str) -> String {
|
||||||
|
slug_dashed(query)
|
||||||
|
.split('-')
|
||||||
|
.map(|word| utf8_percent_encode(word, NON_ALPHANUMERIC).to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("-")
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper to safely extract a string between two delimiters
|
/// Helper to safely extract a string between two delimiters
|
||||||
fn extract_between<'a>(content: &'a str, start_pat: &str, end_pat: &str) -> Option<&'a str> {
|
fn extract_between<'a>(content: &'a str, start_pat: &str, end_pat: &str) -> Option<&'a str> {
|
||||||
let start_idx = content.find(start_pat)? + start_pat.len();
|
let start_idx = content.find(start_pat)? + start_pat.len();
|
||||||
@@ -383,7 +456,7 @@ impl Rule34videoProvider {
|
|||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/search/{}/?mode=async&function=get_block&block_id=custom_list_videos_videos_list_search&tag_ids=&sort_by={}&from_videos={}&from_albums={}&_={}",
|
"{}/search/{}/?mode=async&function=get_block&block_id=custom_list_videos_videos_list_search&tag_ids=&sort_by={}&from_videos={}&from_albums={}&_={}",
|
||||||
self.url,
|
self.url,
|
||||||
query.replace(" ", "-"),
|
Self::search_path_segment(query),
|
||||||
sort_val,
|
sort_val,
|
||||||
page,
|
page,
|
||||||
page,
|
page,
|
||||||
@@ -513,25 +586,54 @@ impl Rule34videoProvider {
|
|||||||
artists
|
artists
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cached_detail(&self, item: &mut VideoItem) -> bool {
|
||||||
|
let Some((time, cached)) = self.details.get(&item.url) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(cached) = cached.into_iter().next() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if time.elapsed().unwrap_or(DETAIL_TTL) >= DETAIL_TTL {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
item.formats = cached.formats;
|
||||||
|
item.tags = cached.tags;
|
||||||
|
item.uploader = cached.uploader;
|
||||||
|
item.uploaderUrl = cached.uploaderUrl;
|
||||||
|
item.uploaderId = cached.uploaderId;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
async fn enrich_items(&self, items: Vec<VideoItem>, requester: Requester) -> Vec<VideoItem> {
|
async fn enrich_items(&self, items: Vec<VideoItem>, requester: Requester) -> Vec<VideoItem> {
|
||||||
let referer = format!("{}/", self.url);
|
let referer = format!("{}/", self.url);
|
||||||
let base = self.url.clone();
|
let base = self.url.clone();
|
||||||
|
// Once the site rate-limits a detail page, the rest of the batch keeps
|
||||||
|
// the listing data instead of digging the hole deeper.
|
||||||
|
let throttled = Arc::new(AtomicBool::new(false));
|
||||||
stream::iter(items.into_iter().map(|mut item| {
|
stream::iter(items.into_iter().map(|mut item| {
|
||||||
let mut requester = requester.clone();
|
let mut requester = requester.clone();
|
||||||
let referer = referer.clone();
|
let referer = referer.clone();
|
||||||
let base = base.clone();
|
let base = base.clone();
|
||||||
|
let throttled = throttled.clone();
|
||||||
async move {
|
async move {
|
||||||
if item.url.is_empty() {
|
if item.url.is_empty() || self.cached_detail(&mut item) {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
let html = requester
|
if throttled.load(Ordering::Relaxed) {
|
||||||
.get_with_headers(
|
return item;
|
||||||
&item.url,
|
}
|
||||||
vec![("Referer".to_string(), referer)],
|
// `get_probe` hands challenges to the full fallback chain but
|
||||||
None,
|
// reports a lasting 429 as an error rather than escalating every
|
||||||
)
|
// detail page to FlareSolverr.
|
||||||
.await
|
let headers = vec![("Referer".to_string(), referer)];
|
||||||
.unwrap_or_default();
|
let html = match requester.get_probe(&item.url, headers).await {
|
||||||
|
Ok(Some(html)) => html,
|
||||||
|
Ok(None) => return item,
|
||||||
|
Err(_) => {
|
||||||
|
throttled.store(true, Ordering::Relaxed);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
};
|
||||||
let (formats, mut tags) = Self::parse_detail(&html);
|
let (formats, mut tags) = Self::parse_detail(&html);
|
||||||
if !formats.is_empty() {
|
if !formats.is_empty() {
|
||||||
item.formats = Some(formats);
|
item.formats = Some(formats);
|
||||||
@@ -550,6 +652,9 @@ impl Rule34videoProvider {
|
|||||||
if !tags.is_empty() {
|
if !tags.is_empty() {
|
||||||
item.tags = Some(tags);
|
item.tags = Some(tags);
|
||||||
}
|
}
|
||||||
|
if item.formats.is_some() {
|
||||||
|
self.details.insert(item.url.clone(), vec![item.clone()]);
|
||||||
|
}
|
||||||
item
|
item
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
@@ -563,15 +668,15 @@ impl Rule34videoProvider {
|
|||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safely isolate the video listing section
|
// Isolate the video listing section. A result that fits on one page has
|
||||||
let video_listing = match Self::extract_between(
|
// no pagination block, so the listing then runs to the end.
|
||||||
&html,
|
let Some(start) = html.find("id=\"custom_list_videos") else {
|
||||||
"id=\"custom_list_videos",
|
return vec![];
|
||||||
"<div class=\"pagination\"",
|
|
||||||
) {
|
|
||||||
Some(content) => content,
|
|
||||||
None => return vec![],
|
|
||||||
};
|
};
|
||||||
|
let video_listing = &html[start..];
|
||||||
|
let video_listing = video_listing
|
||||||
|
.find("<div class=\"pagination\"")
|
||||||
|
.map_or(video_listing, |end| &video_listing[..end]);
|
||||||
|
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
// Skip the first split result as it's the preamble
|
// Skip the first split result as it's the preamble
|
||||||
@@ -785,4 +890,34 @@ mod tests {
|
|||||||
vec![("JOIGang (VA)".to_string(), "joigang-va".to_string())]
|
vec![("JOIGang (VA)".to_string(), "joigang-va".to_string())]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_a_single_page_listing_without_pagination() {
|
||||||
|
let html = r#"<div id="custom_list_videos_common_videos_items">
|
||||||
|
<div class="item thumb video_7"><a class="th js-open-popup" href="https://rule34video.com/video/7/elf/">
|
||||||
|
<img class="thumb" src="https://rule34video.com/t/7.jpg"><div class="time">0:30</div>
|
||||||
|
<div class="thumb_title">Elf</div></a></div></div></div>"#;
|
||||||
|
let items = Rule34videoProvider::new().get_video_items_from_html(html.to_string());
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0].id, "7");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_segment_uses_dashes_and_drops_path_breaking_punctuation() {
|
||||||
|
assert_eq!(Rule34videoProvider::search_path_segment("elf girl"), "elf-girl");
|
||||||
|
assert_eq!(Rule34videoProvider::search_path_segment("2b/9s"), "2b-9s");
|
||||||
|
assert_eq!(Rule34videoProvider::search_path_segment("Tifa & Aerith"), "tifa-aerith");
|
||||||
|
assert_eq!(Rule34videoProvider::search_path_segment("Pokémon"), "pok%C3%A9mon");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remembers_resolved_targets_by_slug() {
|
||||||
|
let provider = Rule34videoProvider::new();
|
||||||
|
provider.remember_target("Samus Aran", &Target::Tag("samus-aran-metroid".into()));
|
||||||
|
assert_eq!(
|
||||||
|
provider.remembered_target("samus aran"),
|
||||||
|
Some(Target::Tag("samus-aran-metroid".into()))
|
||||||
|
);
|
||||||
|
assert_eq!(provider.remembered_target("samus"), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -674,6 +674,18 @@ impl Requester {
|
|||||||
url::Url::parse(url).ok().map(|u| u.path().to_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).
|
/// Existence probe for archive pages (pornstar / studio / tag / category).
|
||||||
///
|
///
|
||||||
/// Reads the raw response so a real 404/410 answers immediately with
|
/// Reads the raw response so a real 404/410 answers immediately with
|
||||||
@@ -687,7 +699,11 @@ impl Requester {
|
|||||||
headers: Vec<(String, String)>,
|
headers: Vec<(String, String)>,
|
||||||
) -> Result<Option<String>, AnyErr> {
|
) -> Result<Option<String>, AnyErr> {
|
||||||
let mut last_err = String::new();
|
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 {
|
match self.get_raw_with_headers(url, headers.clone()).await {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
let status = response.status().as_u16();
|
let status = response.status().as_u16();
|
||||||
@@ -696,9 +712,7 @@ impl Requester {
|
|||||||
// lands on a different path is a miss.
|
// lands on a different path is a miss.
|
||||||
let redirected_away = Self::path_of(url)
|
let redirected_away = Self::path_of(url)
|
||||||
.zip(Some(response.uri().path().to_string()))
|
.zip(Some(response.uri().path().to_string()))
|
||||||
.is_some_and(|(asked, landed)| {
|
.is_some_and(|(asked, landed)| !Self::same_path(&asked, &landed));
|
||||||
!asked.trim_end_matches('/').eq_ignore_ascii_case(landed.trim_end_matches('/'))
|
|
||||||
});
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
if status == 404 || status == 410 {
|
if status == 404 || status == 410 {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -893,7 +907,9 @@ impl Requester {
|
|||||||
rate_limit_retries,
|
rate_limit_retries,
|
||||||
MAX_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;
|
continue;
|
||||||
} else {
|
} else {
|
||||||
crate::flow_debug!(
|
crate::flow_debug!(
|
||||||
@@ -1027,6 +1043,14 @@ impl Requester {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::Requester;
|
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]
|
#[test]
|
||||||
fn new_requesters_share_cookie_jar() {
|
fn new_requesters_share_cookie_jar() {
|
||||||
let a = Requester::new();
|
let a = Requester::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user