use crate::DbPool; use crate::api::ClientVersion; use crate::providers::Provider; use crate::status::*; use crate::util::parse_abbreviated_number; use crate::util::cache::VideoCache; use crate::util::discord::{format_error_chain, send_discord_error_report}; use crate::util::requester::Requester; use crate::util::time::parse_time_to_seconds; use crate::videos::{ServerOptions, VideoFormat, VideoItem}; use async_trait::async_trait; use error_chain::error_chain; use htmlentity::entity::{ICodedDataTrait, decode}; use std::sync::{Arc, RwLock}; use std::{thread, vec}; use titlecase::Titlecase; use url::Url; use wreq::Version; pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata = crate::providers::ProviderChannelMetadata { group_id: "onlyfans", tags: &["creator", "onlyfans", "amateur"], }; error_chain! { foreign_links { Io(std::io::Error); HttpRequest(wreq::Error); Json(serde_json::Error); } errors { Parse(msg: String) { description("parse error") display("parse error: {}", msg) } } } #[derive(Debug, Clone)] pub struct PimpbunnyProvider { url: String, stars: Arc>>, categories: Arc>>, } impl PimpbunnyProvider { const FIREFOX_USER_AGENT: &'static str = "Mozilla/5.0 (X11; Linux x86_64; rv:147.0) Gecko/20100101 Firefox/147.0"; const HTML_ACCEPT: &'static str = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"; pub fn new() -> Self { let provider = Self { url: "https://pimpbunny.com".to_string(), stars: Arc::new(RwLock::new(vec![])), categories: Arc::new(RwLock::new(vec![])), }; provider.spawn_initial_load(); provider } fn build_channel(&self, clientversion: ClientVersion) -> Channel { let _ = clientversion; Channel { id: "pimpbunny".to_string(), name: "Pimpbunny".to_string(), description: "Watch Porn!".to_string(), premium: false, favicon: "https://www.google.com/s2/favicons?sz=64&domain=pimpbunny.com".to_string(), status: "active".to_string(), categories: self .categories .read() .map(|categories| categories.iter().map(|c| c.title.clone()).collect()) .unwrap_or_else(|e| { crate::providers::report_provider_error_background( "pimpbunny", "build_channel.categories_read", &e.to_string(), ); vec![] }), options: vec![ChannelOption { id: "sort".to_string(), title: "Sort".to_string(), description: "Sort the Videos".to_string(), systemImage: "list.number".to_string(), colorName: "blue".to_string(), options: vec![ FilterOption { id: "featured".into(), title: "Featured".into(), }, FilterOption { id: "most recent".into(), title: "Most Recent".into(), }, FilterOption { id: "most viewed".into(), title: "Most Viewed".into(), }, FilterOption { id: "best rated".into(), title: "Best Rated".into(), }, ], multiSelect: false, }], nsfw: true, cacheDuration: None, } } fn spawn_initial_load(&self) { let url = self.url.clone(); let stars = Arc::clone(&self.stars); let categories = Arc::clone(&self.categories); thread::spawn(async move || { let rt = match tokio::runtime::Builder::new_current_thread() .enable_all() .build() { Ok(rt) => rt, Err(e) => { eprintln!("tokio runtime failed: {e}"); send_discord_error_report( e.to_string(), Some(format_error_chain(&e)), Some("Pimpbunny Provider"), Some("Failed to create tokio runtime"), file!(), line!(), module_path!(), ) .await; return; } }; rt.block_on(async { if let Err(e) = Self::load_stars(&url, Arc::clone(&stars)).await { eprintln!("load_stars failed: {e}"); send_discord_error_report( e.to_string(), Some(format_error_chain(&e)), Some("Pimpbunny Provider"), Some("Failed to load stars during initial load"), file!(), line!(), module_path!(), ) .await; } if let Err(e) = Self::load_categories(&url, Arc::clone(&categories)).await { eprintln!("load_categories failed: {e}"); send_discord_error_report( e.to_string(), Some(format_error_chain(&e)), Some("Pimpbunny Provider"), Some("Failed to load categories during initial load"), file!(), line!(), module_path!(), ) .await; } }); }); } fn push_unique(target: &Arc>>, item: FilterOption) { if let Ok(mut vec) = target.write() { if !vec.iter().any(|x| x.id == item.id) { vec.push(item); } } } fn is_allowed_thumb_url(url: &str) -> bool { let Some(url) = Url::parse(url).ok() else { return false; }; if url.scheme() != "https" { return false; } let Some(host) = url.host_str() else { return false; }; matches!(host, "pimpbunny.com" | "www.pimpbunny.com") && url.path().starts_with("/contents/videos_screenshots/") } fn proxied_thumb(&self, options: &ServerOptions, thumb: &str) -> String { if thumb.is_empty() || !Self::is_allowed_thumb_url(thumb) { return thumb.to_string(); } crate::providers::build_proxy_url( options, "pimpbunny-thumb", &crate::providers::strip_url_scheme(thumb), ) } fn is_allowed_detail_url(url: &str) -> bool { let Some(url) = Url::parse(url).ok() else { return false; }; if url.scheme() != "https" { return false; } let Some(host) = url.host_str() else { return false; }; matches!(host, "pimpbunny.com" | "www.pimpbunny.com") && !url.path().starts_with("/contents/videos_screenshots/") } fn proxied_video(&self, options: &ServerOptions, page_url: &str) -> String { if page_url.is_empty() || !Self::is_allowed_detail_url(page_url) { return page_url.to_string(); } crate::providers::build_proxy_url( options, "pimpbunny", &crate::providers::strip_url_scheme(page_url), ) } fn root_referer(&self) -> String { format!("{}/", self.url.trim_end_matches('/')) } fn sort_by(sort: &str) -> &'static str { match sort { "best rated" => "rating", "most viewed" => "video_viewed", _ => "post_date", } } fn build_search_path_query(query: &str, separator: &str) -> String { query.split_whitespace().collect::>().join(separator) } fn append_archive_query(url: String, sort: &str) -> String { let separator = if url.contains('?') { '&' } else { '?' }; format!("{url}{separator}sort_by={}", Self::sort_by(sort)) } fn page_family_referer(&self, request_url: &str) -> String { let Some(url) = Url::parse(request_url).ok() else { return self.root_referer(); }; let path = url.path(); let referer_path = if path.starts_with("/videos/") { "/videos/".to_string() } else if path.starts_with("/search/") { let parts: Vec<_> = path.trim_matches('/').split('/').collect(); if parts.len() >= 2 { format!("/search/{}/", parts[1]) } else { "/search/".to_string() } } else if path.starts_with("/categories/") { let parts: Vec<_> = path.trim_matches('/').split('/').collect(); if parts.len() >= 2 { format!("/categories/{}/", parts[1]) } else { "/categories/".to_string() } } else if path.starts_with("/onlyfans-models/") { let parts: Vec<_> = path.trim_matches('/').split('/').collect(); if parts.len() >= 2 { format!("/onlyfans-models/{}/", parts[1]) } else { "/onlyfans-models/".to_string() } } else { "/".to_string() }; format!("{}{}", self.url.trim_end_matches('/'), referer_path) } fn build_browse_url(&self, page: u8, sort: &str) -> String { let base = if page <= 1 { format!("{}/videos/", self.url) } else { format!("{}/videos/{page}/", self.url) }; Self::append_archive_query(base, sort) } fn build_search_url(&self, query: &str, page: u8, sort: &str) -> String { let path_query = Self::build_search_path_query(query, "-"); let base = if page <= 1 { format!("{}/search/{path_query}/", self.url) } else { format!("{}/search/{path_query}/{page}/", self.url) }; Self::append_archive_query(base, sort) } fn build_common_archive_url(&self, archive_path: &str, page: u8, sort: &str) -> String { let canonical = format!( "{}/{}", self.url.trim_end_matches('/'), archive_path.trim_start_matches('/') ); let base = if page <= 1 { canonical } else { format!("{}/{}", canonical.trim_end_matches('/'), page) }; let base = if base.ends_with('/') { base } else { format!("{base}/") }; Self::append_archive_query(base, sort) } fn navigation_headers( referer: Option<&str>, sec_fetch_site: &'static str, ) -> Vec<(String, String)> { let mut headers = vec![ ( "User-Agent".to_string(), Self::FIREFOX_USER_AGENT.to_string(), ), ("Accept".to_string(), Self::HTML_ACCEPT.to_string()), ("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()), ("Cache-Control".to_string(), "no-cache".to_string()), ("Pragma".to_string(), "no-cache".to_string()), ("Priority".to_string(), "u=0, i".to_string()), ("Connection".to_string(), "keep-alive".to_string()), ("TE".to_string(), "trailers".to_string()), ("Sec-Fetch-Dest".to_string(), "document".to_string()), ("Sec-Fetch-Mode".to_string(), "navigate".to_string()), ("Sec-Fetch-Site".to_string(), sec_fetch_site.to_string()), ("Sec-Fetch-User".to_string(), "?1".to_string()), ("Upgrade-Insecure-Requests".to_string(), "1".to_string()), ]; if let Some(referer) = referer { headers.push(("Referer".to_string(), referer.to_string())); } headers } fn headers_with_cookies( &self, requester: &Requester, request_url: &str, referer: Option<&str>, sec_fetch_site: &'static str, ) -> Vec<(String, String)> { let mut headers = Self::navigation_headers(referer, sec_fetch_site); if let Some(cookie) = requester.cookie_header_for_url(request_url) { headers.push(("Cookie".to_string(), cookie)); } headers } fn is_cloudflare_challenge(html: &str) -> bool { html.contains("cf-turnstile-response") || html.contains("Performing security verification") || html.contains("__cf_chl_rt_tk") || html.contains("cUPMDTk:\"") || html.contains("Just a moment...") } fn extract_challenge_path(html: &str) -> Option { html.split("cUPMDTk:\"") .nth(1) .and_then(|s| s.split('"').next()) .map(str::to_string) .or_else(|| { html.split("__cf_chl_rt_tk=") .nth(1) .and_then(|s| s.split('"').next()) .map(|token| format!("/?__cf_chl_rt_tk={token}")) }) } fn absolute_site_url(&self, path_or_url: &str) -> String { if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") { path_or_url.to_string() } else { format!( "{}/{}", self.url.trim_end_matches('/'), path_or_url.trim_start_matches('/') ) } } async fn fetch_html( &self, requester: &mut Requester, request_url: &str, referer: Option<&str>, sec_fetch_site: &'static str, ) -> Result { let headers = self.headers_with_cookies(requester, request_url, referer, sec_fetch_site); let response = requester .get_raw_with_headers(request_url, headers.clone()) .await .map_err(Error::from)?; let status = response.status(); let body = response.text().await.map_err(Error::from)?; if status.is_success() || status.as_u16() == 404 { return Ok(body); } if status.as_u16() == 403 && Self::is_cloudflare_challenge(&body) { if let Some(challenge_path) = Self::extract_challenge_path(&body) { let challenge_url = self.absolute_site_url(&challenge_path); let challenge_headers = self.headers_with_cookies( requester, &challenge_url, Some(request_url), "same-origin", ); let _ = requester .get_raw_with_headers(&challenge_url, challenge_headers) .await; } } let retry_headers = self.headers_with_cookies(requester, request_url, referer, sec_fetch_site); requester .get_with_headers(request_url, retry_headers, Some(Version::HTTP_11)) .await .map_err(|e| Error::from(format!("{e}"))) } async fn warm_root_session(&self, requester: &mut Requester) { let root_url = self.root_referer(); let _ = self .fetch_html(requester, &root_url, None, "none") .await; } async fn warm_root_session_for_base(base: &str, requester: &mut Requester) { let root_url = format!("{}/", base.trim_end_matches('/')); let _ = requester .get_with_headers( &root_url, Self::navigation_headers(None, "none"), Some(Version::HTTP_11), ) .await; } async fn load_stars(base: &str, stars: Arc>>) -> Result<()> { let mut requester = Requester::new(); Self::warm_root_session_for_base(base, &mut requester).await; let request_url = format!("{base}/onlyfans-models/?models_per_page=20"); let headers = { let root_url = format!("{}/", base.trim_end_matches('/')); let mut headers = Self::navigation_headers(Some(&root_url), "same-origin"); if let Some(cookie) = requester.cookie_header_for_url(&request_url) { headers.push(("Cookie".to_string(), cookie)); } headers }; let text = requester .get_with_headers( &request_url, headers, Some(Version::HTTP_2), ) .await .map_err(|e| Error::from(format!("{}", e)))?; let block = text .split("vt_list_models_with_advertising_custom_models_list_items") .last() .ok_or_else(|| ErrorKind::Parse("missing stars block".into()))? .split("pb-page-description") .next() .unwrap_or(""); for el in block.split("
").skip(1) { if el.contains("pb-promoted-link") || !el.contains("href=\"https://pimpbunny.com/onlyfans-models/") { continue; } let id = el .split("href=\"https://pimpbunny.com/onlyfans-models/") .nth(1) .and_then(|s| s.split("/\"").next()) .ok_or_else(|| ErrorKind::Parse(format!("star id: {el}").into()))? .to_string(); let title = el .split("ui-card-title") .nth(1) .and_then(|s| s.split('<').next()) .ok_or_else(|| ErrorKind::Parse(format!("star title: {el}").into()))? .to_string(); Self::push_unique(&stars, FilterOption { id, title }); } Ok(()) } async fn load_categories(base: &str, cats: Arc>>) -> Result<()> { let mut requester = Requester::new(); Self::warm_root_session_for_base(base, &mut requester).await; let request_url = format!("{base}/categories/?items_per_page=120"); let headers = { let root_url = format!("{}/", base.trim_end_matches('/')); let mut headers = Self::navigation_headers(Some(&root_url), "same-origin"); if let Some(cookie) = requester.cookie_header_for_url(&request_url) { headers.push(("Cookie".to_string(), cookie)); } headers }; let text = requester .get_with_headers( &request_url, headers, Some(Version::HTTP_2), ) .await .map_err(|e| Error::from(format!("{}", e)))?; let block = text .split("list_categories_categories_list_items") .last() .ok_or_else(|| ErrorKind::Parse("categories block".into()))? .split("pb-pagination-wrapper") .next() .unwrap_or(""); for el in block.split("
").skip(1) { let id = el .split("href=\"https://pimpbunny.com/categories/") .nth(1) .and_then(|s| s.split("/\"").next()) .ok_or_else(|| ErrorKind::Parse(format!("category id: {el}").into()))? .to_string(); let title = el .split("ui-heading-h3") .nth(1) .and_then(|s| s.split('<').next()) .ok_or_else(|| ErrorKind::Parse(format!("category title: {el}").into()))? .titlecase(); Self::push_unique(&cats, FilterOption { id, title }); } Ok(()) } async fn get( &self, cache: VideoCache, page: u8, sort: &str, options: ServerOptions, ) -> Result> { let video_url = self.build_browse_url(page, sort); let old_items = match cache.get(&video_url) { Some((time, items)) => { if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 { return Ok(items.clone()); } else { items.clone() } } None => { vec![] } }; let mut requester = crate::providers::requester_or_default(&options, module_path!(), "missing_requester"); self.warm_root_session(&mut requester).await; let referer = self.page_family_referer(&video_url); let text = match self .fetch_html( &mut requester, &video_url, Some(&referer), "same-origin", ) .await { Ok(text) => text, Err(e) => { crate::providers::report_provider_error( "pimpbunny", "get.request", &format!("url={video_url}; error={e}"), ) .await; return Ok(old_items); } }; let video_items = self.get_video_items_from_html(text.clone(), &options); if !video_items.is_empty() { cache.remove(&video_url); cache.insert(video_url.clone(), video_items.clone()); } else { return Ok(old_items); } Ok(video_items) } async fn query( &self, cache: VideoCache, page: u8, query: &str, options: ServerOptions, ) -> Result> { let search_string = query.trim().to_string(); let sort = options.sort.as_deref().unwrap_or(""); let mut video_url = self.build_search_url(&search_string, page, sort); if let Ok(stars) = self.stars.read() { if let Some(star) = stars .iter() .find(|s| s.title.to_ascii_lowercase() == search_string.to_ascii_lowercase()) { video_url = self.build_common_archive_url( &format!("/onlyfans-models/{}/", star.id), page, sort, ); } } else { crate::providers::report_provider_error_background( "pimpbunny", "query.stars_read", "failed to lock stars", ); } if let Ok(categories) = self.categories.read() { if let Some(cat) = categories .iter() .find(|c| c.title.to_ascii_lowercase() == search_string.to_ascii_lowercase()) { video_url = self.build_common_archive_url(&format!("/categories/{}/", cat.id), page, sort); } } else { crate::providers::report_provider_error_background( "pimpbunny", "query.categories_read", "failed to lock categories", ); } // Check our Video Cache. If the result is younger than 1 hour, we return it. let old_items = match cache.get(&video_url) { Some((time, items)) => { if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 { return Ok(items.clone()); } else { let _ = cache.check().await; return Ok(items.clone()); } } None => { vec![] } }; let mut requester = crate::providers::requester_or_default(&options, module_path!(), "missing_requester"); self.warm_root_session(&mut requester).await; let referer = self.page_family_referer(&video_url); let text = match self .fetch_html( &mut requester, &video_url, Some(&referer), "same-origin", ) .await { Ok(text) => text, Err(e) => { crate::providers::report_provider_error( "pimpbunny", "query.request", &format!("url={video_url}; error={e}"), ) .await; return Ok(old_items); } }; let video_items = self.get_video_items_from_html(text.clone(), &options); if !video_items.is_empty() { cache.remove(&video_url); cache.insert(video_url.clone(), video_items.clone()); } else { return Ok(old_items); } Ok(video_items) } fn get_video_items_from_html(&self, html: String, options: &ServerOptions) -> Vec { if html.is_empty() || html.contains("404 Not Found") { return vec![]; } let block = match html .split("-pagination-wrapper") .next() .and_then(|s| s.split("videos_videos_list").nth(2)) { Some(b) => b, None => return vec![], }; block .split("
") .skip(1) .filter_map(|el| self.get_video_item(el.to_string(), options).ok()) .into_iter() .collect() } fn extract_duration_from_segment(&self, seg: &str) -> u32 { for token in seg.split(|ch: char| ch == '<' || ch == '>' || ch.is_whitespace()) { let candidate = token.trim(); if candidate.is_empty() || !candidate.contains(':') { continue; } if let Some(parsed) = parse_time_to_seconds(candidate) { return parsed as u32; } } 0 } fn extract_views_from_segment(&self, seg: &str) -> u32 { let Some(before_views) = seg.split("Views").next() else { return 0; }; let candidate = before_views .split(|ch: char| ch == '<' || ch == '>' || ch.is_whitespace()) .filter(|value| !value.trim().is_empty()) .next_back() .unwrap_or("") .trim_matches(|ch: char| ch == '(' || ch == ')' || ch == ','); parse_abbreviated_number(candidate).unwrap_or(0) } fn get_video_item(&self, seg: String, options: &ServerOptions) -> Result { let video_url = seg .split(" href=\"") .nth(1) .and_then(|s| s.split('"').next()) .ok_or_else(|| ErrorKind::Parse("video url".into()))? .to_string(); let mut title = seg .split("card-title") .nth(1) .and_then(|s| s.split('>').nth(1)) .and_then(|s| s.split('<').next()) .ok_or_else(|| ErrorKind::Parse("video title".into()))? .trim() .to_string(); title = decode(title.as_bytes()) .to_string() .unwrap_or(title) .titlecase(); let id = video_url .split('/') .nth(4) .and_then(|s| s.split('.').next()) .ok_or_else(|| ErrorKind::Parse("video id".into()))? .to_string(); let thumb_block = seg .split("card-thumbnail") .nth(1) .ok_or_else(|| ErrorKind::Parse("thumb block".into()))?; let mut thumb = thumb_block .split("src=\"") .nth(1) .and_then(|s| s.split('"').next()) .unwrap_or("") .to_string(); if thumb.contains("data:image/jpg;base64") { thumb = thumb_block .split("data-original=\"") .nth(1) .and_then(|s| s.split('"').next()) .unwrap_or("") .to_string(); } let preview = thumb_block .split("data-preview=\"") .nth(1) .and_then(|s| s.split('"').next()) .unwrap_or("") .to_string(); let proxy_url = self.proxied_video(options, &video_url); let views = self.extract_views_from_segment(&seg); let duration = self.extract_duration_from_segment(&seg); let formats = vec![ VideoFormat::new(proxy_url.clone(), "auto".into(), "video/mp4".into()) .format_id("auto".into()) .format_note("proxied".into()), ]; Ok( VideoItem::new(id, title, proxy_url, "pimpbunny".into(), thumb, duration) .formats(formats) .preview(preview) .views(views), ) } } #[async_trait] impl Provider for PimpbunnyProvider { async fn get_videos( &self, cache: VideoCache, _pool: DbPool, sort: String, query: Option, page: String, _per_page: String, options: ServerOptions, ) -> Vec { let page = page.parse::().unwrap_or(1); let thumb_options = options.clone(); let res = match query { Some(q) => self.to_owned().query(cache, page, &q, options).await, None => self.get(cache, page, &sort, options).await, }; res.unwrap_or_else(|e| { eprintln!("pimpbunny error: {e}"); vec![] }) .into_iter() .map(|mut item| { if !item.thumb.is_empty() { item.thumb = self.proxied_thumb(&thumb_options, &item.thumb); } item }) .collect() } fn get_channel(&self, v: ClientVersion) -> Option { Some(self.build_channel(v)) } } #[cfg(test)] mod tests { use super::PimpbunnyProvider; use crate::videos::ServerOptions; use std::sync::{Arc, RwLock}; fn test_provider() -> PimpbunnyProvider { PimpbunnyProvider { url: "https://pimpbunny.com".to_string(), stars: Arc::new(RwLock::new(vec![])), categories: Arc::new(RwLock::new(vec![])), } } #[test] fn rewrites_allowed_thumbs_to_proxy_urls() { let provider = test_provider(); let options = ServerOptions { featured: None, category: None, sites: None, filter: None, language: None, public_url_base: Some("https://example.com".to_string()), requester: None, network: None, stars: None, categories: None, duration: None, sort: None, sexuality: None, }; let proxied = provider.proxied_thumb( &options, "https://pimpbunny.com/contents/videos_screenshots/517000/517329/800x450/1.jpg", ); assert_eq!( proxied, "https://example.com/proxy/pimpbunny-thumb/pimpbunny.com/contents/videos_screenshots/517000/517329/800x450/1.jpg" ); } #[test] fn rewrites_video_pages_to_redirect_proxy() { let provider = test_provider(); let options = ServerOptions { featured: None, category: None, sites: None, filter: None, language: None, public_url_base: Some("https://example.com".to_string()), requester: None, network: None, stars: None, categories: None, duration: None, sort: None, sexuality: None, }; let proxied = provider.proxied_video( &options, "https://pimpbunny.com/videos/example-video/", ); assert_eq!( proxied, "https://example.com/proxy/pimpbunny/pimpbunny.com/videos/example-video/" ); } #[test] fn parses_listing_without_detail_requests() { let provider = test_provider(); let options = ServerOptions { featured: None, category: None, sites: None, filter: None, language: None, public_url_base: Some("https://example.com".to_string()), requester: None, network: None, stars: None, categories: None, duration: None, sort: None, sexuality: None, }; let html = r#"
-pagination-wrapper "#; let items = provider.get_video_items_from_html(html.to_string(), &options); assert_eq!(items.len(), 1); assert_eq!( items[0].url, "https://example.com/proxy/pimpbunny/pimpbunny.com/videos/example-video/" ); assert_eq!(items[0].duration, 754); assert_eq!(items[0].views, Some(1200)); assert_eq!(items[0].formats.as_ref().map(|f| f.len()), Some(1)); } #[test] fn extracts_cloudflare_challenge_path() { let html = r#" "#; assert!(PimpbunnyProvider::is_cloudflare_challenge(html)); assert_eq!( PimpbunnyProvider::extract_challenge_path(html).as_deref(), Some( "/?mode=async&function=get_block&block_id=videos_videos_list&videos_per_page=8&sort_by=post_date&from=1&__cf_chl_tk=test-token" ) ); } #[test] fn builds_async_browse_url_instead_of_numbered_videos_path() { let provider = test_provider(); assert_eq!( provider.build_browse_url(1, "most recent"), "https://pimpbunny.com/videos/?sort_by=post_date" ); assert_eq!( provider.build_browse_url(2, "most recent"), "https://pimpbunny.com/videos/2/?sort_by=post_date" ); } #[test] fn builds_search_url_with_query_and_pagination() { let provider = test_provider(); assert_eq!( provider.build_search_url("adriana chechik", 1, "most viewed"), "https://pimpbunny.com/search/adriana-chechik/?sort_by=video_viewed" ); assert_eq!( provider.build_search_url("adriana chechik", 3, "most viewed"), "https://pimpbunny.com/search/adriana-chechik/3/?sort_by=video_viewed" ); } #[test] fn builds_common_archive_url_with_async_block() { let provider = test_provider(); assert_eq!( provider.build_common_archive_url("/categories/amateur/", 1, "best rated"), "https://pimpbunny.com/categories/amateur/?sort_by=rating" ); assert_eq!( provider.build_common_archive_url("/categories/amateur/", 4, "best rated"), "https://pimpbunny.com/categories/amateur/4/?sort_by=rating" ); } #[test] fn derives_page_family_referer() { let provider = test_provider(); assert_eq!( provider.page_family_referer("https://pimpbunny.com/videos/2/?sort_by=post_date"), "https://pimpbunny.com/videos/" ); assert_eq!( provider.page_family_referer( "https://pimpbunny.com/categories/blowjob/2/?sort_by=post_date" ), "https://pimpbunny.com/categories/blowjob/" ); assert_eq!( provider.page_family_referer( "https://pimpbunny.com/search/adriana-chechik/3/?sort_by=video_viewed" ), "https://pimpbunny.com/search/adriana-chechik/" ); assert_eq!( provider.page_family_referer( "https://pimpbunny.com/onlyfans-models/momoitenshi/3/?sort_by=post_date" ), "https://pimpbunny.com/onlyfans-models/momoitenshi/" ); } }