diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 24d3114..760c3bd 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -16,17 +16,17 @@ use crate::{ pub mod all; pub mod hanime; -pub mod perverzija; -pub mod pmvhaven; -pub mod pornhub; -// pub mod spankbang; pub mod homoxxx; pub mod okporn; pub mod okxxx; pub mod perfectgirls; +pub mod perverzija; +pub mod pmvhaven; pub mod pornhat; +pub mod pornhub; pub mod redtube; pub mod rule34video; +pub mod spankbang; // pub mod hentaimoon; pub mod beeg; pub mod missav; @@ -73,6 +73,10 @@ pub static ALL_PROVIDERS: Lazy> = Lazy::new(| "pornhub", Arc::new(pornhub::PornhubProvider::new()) as DynProvider, ); + m.insert( + "spankbang", + Arc::new(spankbang::SpankbangProvider::new()) as DynProvider, + ); m.insert( "rule34video", Arc::new(rule34video::Rule34videoProvider::new()) as DynProvider, diff --git a/src/providers/spankbang.rs b/src/providers/spankbang.rs new file mode 100644 index 0000000..a4d3362 --- /dev/null +++ b/src/providers/spankbang.rs @@ -0,0 +1,691 @@ +use crate::DbPool; +use crate::api::ClientVersion; +use crate::providers::{Provider, report_provider_error, requester_or_default}; +use crate::status::*; +use crate::util::cache::VideoCache; +use crate::util::parse_abbreviated_number; +use crate::util::time::parse_time_to_seconds; +use crate::videos::{ServerOptions, VideoItem}; +use async_trait::async_trait; +use error_chain::error_chain; +use htmlentity::entity::{ICodedDataTrait, decode}; +use scraper::{ElementRef, Html, Selector}; +use url::form_urlencoded::byte_serialize; + +error_chain! { + foreign_links { + Io(std::io::Error); + HttpRequest(wreq::Error); + } +} + +#[derive(Debug, Clone)] +pub struct SpankbangProvider { + url: String, +} + +impl SpankbangProvider { + pub fn new() -> Self { + Self { + url: "https://spankbang.com".to_string(), + } + } + + fn build_channel(&self, _clientversion: ClientVersion) -> Channel { + Channel { + id: "spankbang".to_string(), + name: "SpankBang".to_string(), + description: "Porn videos, trending searches, and featured scenes.".to_string(), + premium: false, + favicon: "https://www.google.com/s2/favicons?sz=64&domain=spankbang.com".to_string(), + status: "active".to_string(), + categories: 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: "trending".to_string(), + title: "Trending".to_string(), + }, + FilterOption { + id: "upcoming".to_string(), + title: "Upcoming".to_string(), + }, + FilterOption { + id: "new".to_string(), + title: "New".to_string(), + }, + FilterOption { + id: "popular".to_string(), + title: "Popular".to_string(), + }, + FilterOption { + id: "featured".to_string(), + title: "Featured".to_string(), + }, + ], + multiSelect: false, + }], + nsfw: true, + cacheDuration: Some(1800), + } + } + + fn normalize_get_sort(sort: &str) -> &'static str { + match sort { + "upcoming" => "upcoming", + "new" => "new", + "popular" => "popular", + _ => "trending", + } + } + + fn normalize_query_sort(sort: &str) -> &'static str { + match sort { + "new" => "new", + "popular" => "popular", + "featured" => "featured", + _ => "trending", + } + } + + fn encode_search_query(query: &str) -> String { + query + .split_whitespace() + .map(|part| byte_serialize(part.as_bytes()).collect::()) + .collect::>() + .join("+") + } + + fn build_get_url(&self, page: u32, sort: &str) -> String { + match Self::normalize_get_sort(sort) { + "upcoming" => { + if page > 1 { + format!("{}/upcoming/{page}/", self.url) + } else { + format!("{}/upcoming/", self.url) + } + } + "new" => { + if page > 1 { + format!("{}/new_videos/{page}/", self.url) + } else { + format!("{}/new_videos/", self.url) + } + } + "popular" => { + if page > 1 { + format!("{}/most_popular/{page}/?p=w", self.url) + } else { + format!("{}/most_popular/?p=w", self.url) + } + } + _ => { + if page > 1 { + format!("{}/trending_videos/{page}/", self.url) + } else { + format!("{}/trending_videos/", self.url) + } + } + } + } + + fn request_headers(&self) -> Vec<(String, String)> { + vec![("Referer".to_string(), format!("{}/", self.url))] + } + + fn build_query_url(&self, query: &str, page: u32, sort: &str) -> String { + let encoded_query = Self::encode_search_query(query); + let mut url = if page > 1 { + format!("{}/s/{encoded_query}/{page}/", self.url) + } else { + format!("{}/s/{encoded_query}/", self.url) + }; + + match Self::normalize_query_sort(sort) { + "new" => url.push_str("?o=new"), + "popular" => url.push_str("?o=popular"), + "featured" => url.push_str("?o=featured"), + _ => {} + } + + url + } + + fn normalize_url(&self, url: &str) -> String { + if url.is_empty() { + return String::new(); + } + if url.starts_with("http://") || url.starts_with("https://") { + return url.to_string(); + } + if url.starts_with("//") { + return format!("https:{url}"); + } + if url.starts_with('/') { + return format!("{}{}", self.url, url); + } + format!("{}/{}", self.url, url.trim_start_matches("./")) + } + + fn proxy_url(&self, url: &str) -> String { + let path = url + .strip_prefix(&self.url) + .unwrap_or(url) + .trim_start_matches('/'); + format!("https://hottub.spacemoehre.de/proxy/spankbang/{path}") + } + + fn decode_html(text: &str) -> String { + decode(text.as_bytes()) + .to_string() + .unwrap_or_else(|_| text.to_string()) + } + + fn collapse_whitespace(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") + } + + fn text_of(element: &ElementRef<'_>) -> String { + Self::collapse_whitespace(&element.text().collect::>().join(" ")) + } + + fn parse_duration(text: &str) -> u32 { + let raw = Self::collapse_whitespace(text); + if raw.is_empty() { + return 0; + } + + if raw.contains(':') { + return parse_time_to_seconds(&raw) + .and_then(|seconds| u32::try_from(seconds).ok()) + .unwrap_or(0); + } + + let mut total = 0; + let mut digits = String::new(); + + for ch in raw.chars() { + if ch.is_ascii_digit() { + digits.push(ch); + continue; + } + + if digits.is_empty() { + continue; + } + + let value = digits.parse::().unwrap_or(0); + match ch.to_ascii_lowercase() { + 'h' => total += value * 3600, + 'm' => total += value * 60, + 's' => total += value, + _ => {} + } + digits.clear(); + } + + if total == 0 && !digits.is_empty() { + digits.parse::().unwrap_or(0) + } else { + total + } + } + + fn parse_rating(text: &str) -> Option { + let cleaned = Self::collapse_whitespace(text) + .trim_end_matches('%') + .trim() + .to_string(); + if cleaned.is_empty() || cleaned == "-" { + return None; + } + cleaned.parse::().ok() + } + + fn parse_card( + &self, + card: ElementRef<'_>, + video_link_selector: &Selector, + title_selector: &Selector, + thumb_selector: &Selector, + preview_selector: &Selector, + length_selector: &Selector, + views_selector: &Selector, + rating_selector: &Selector, + meta_link_selector: &Selector, + ) -> Option { + let card_html = card.html(); + let card_text = Self::collapse_whitespace(&card.text().collect::>().join(" ")); + if card_html.contains("SpankBang Gold") || card_text.contains("SpankBang Gold") { + return None; + } + + let id = card.value().attr("data-id")?.to_string(); + let href = card + .select(video_link_selector) + .find_map(|link| link.value().attr("href")) + .map(ToString::to_string)?; + let thumb = card + .select(thumb_selector) + .find_map(|img| img.value().attr("src")) + .map(|src| self.normalize_url(src)) + .unwrap_or_default(); + let preview = card + .select(preview_selector) + .find_map(|source| source.value().attr("data-src")) + .map(|src| self.normalize_url(src)); + let duration = card + .select(length_selector) + .next() + .map(|element| Self::parse_duration(&Self::text_of(&element))) + .unwrap_or(0); + let views = card + .select(views_selector) + .next() + .and_then(|element| parse_abbreviated_number(&Self::text_of(&element))); + let rating = card + .select(rating_selector) + .next() + .and_then(|element| Self::parse_rating(&Self::text_of(&element))); + let title = card + .select(title_selector) + .next() + .and_then(|link| link.value().attr("title")) + .map(Self::decode_html) + .unwrap_or_else(|| { + card.select(thumb_selector) + .next() + .and_then(|img| img.value().attr("alt")) + .map(Self::decode_html) + .unwrap_or_default() + }); + + if title.is_empty() { + return None; + } + + let mut item = VideoItem::new( + id, + title, + self.proxy_url(&href), + "spankbang".to_string(), + thumb, + duration, + ); + + if let Some(views) = views { + item = item.views(views); + } + if let Some(rating) = rating { + item = item.rating(rating); + } + if let Some(preview) = preview { + item = item.preview(preview); + } + + if let Some(meta_link) = card.select(meta_link_selector).next() { + let uploader = Self::decode_html(&Self::text_of(&meta_link)); + if !uploader.is_empty() { + item = item.uploader(uploader); + } + if let Some(meta_href) = meta_link.value().attr("href") { + let uploader_url = self.normalize_url(meta_href); + if !uploader_url.is_empty() { + item = item.uploader_url(uploader_url); + } + } + } + + Some(item) + } + + fn get_video_items_from_html(&self, html: String) -> Vec { + let document = Html::parse_document(&html); + let card_selector = Selector::parse(r#"[data-testid="video-item"]"#).unwrap(); + let video_link_selector = Selector::parse(r#"a[href*="/video/"]"#).unwrap(); + let title_selector = Selector::parse(r#"a[title]"#).unwrap(); + let thumb_selector = Selector::parse("picture img, img").unwrap(); + let preview_selector = Selector::parse(r#"source[data-src]"#).unwrap(); + let length_selector = Selector::parse(r#"[data-testid="video-item-length"]"#).unwrap(); + let views_selector = Selector::parse(r#"[data-testid="views"]"#).unwrap(); + let rating_selector = Selector::parse(r#"[data-testid="rates"]"#).unwrap(); + let meta_link_selector = + Selector::parse(r#"[data-testid="video-info-with-badge"] a[data-testid="title"]"#) + .unwrap(); + + let mut items = Vec::new(); + for card in document.select(&card_selector) { + if let Some(item) = self.parse_card( + card, + &video_link_selector, + &title_selector, + &thumb_selector, + &preview_selector, + &length_selector, + &views_selector, + &rating_selector, + &meta_link_selector, + ) { + items.push(item); + } + } + + items + } + + async fn get( + &self, + cache: VideoCache, + page: u32, + sort: &str, + options: ServerOptions, + ) -> Result> { + let video_url = self.build_get_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()); + } + items.clone() + } + None => vec![], + }; + + let mut requester = + requester_or_default(&options, "spankbang", "spankbang.get.missing_requester"); + let text = match requester + .get_with_headers(&video_url, self.request_headers(), None) + .await + { + Ok(text) => text, + Err(e) => { + report_provider_error( + "spankbang", + "get.request", + &format!("url={video_url}; error={e}"), + ) + .await; + return Ok(old_items); + } + }; + + if text.trim().is_empty() { + report_provider_error( + "spankbang", + "get.empty_response", + &format!("url={video_url}"), + ) + .await; + return Ok(old_items); + } + + let video_items = self.get_video_items_from_html(text); + if !video_items.is_empty() { + cache.remove(&video_url); + cache.insert(video_url.clone(), video_items.clone()); + return Ok(video_items); + } + + Ok(old_items) + } + + async fn query( + &self, + cache: VideoCache, + page: u32, + query: &str, + sort: &str, + options: ServerOptions, + ) -> Result> { + let video_url = self.build_query_url(query, 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()); + } + items.clone() + } + None => vec![], + }; + + let mut requester = + requester_or_default(&options, "spankbang", "spankbang.query.missing_requester"); + let text = match requester + .get_with_headers(&video_url, self.request_headers(), None) + .await + { + Ok(text) => text, + Err(e) => { + report_provider_error( + "spankbang", + "query.request", + &format!("url={video_url}; error={e}"), + ) + .await; + return Ok(old_items); + } + }; + + if text.trim().is_empty() { + report_provider_error( + "spankbang", + "query.empty_response", + &format!("url={video_url}"), + ) + .await; + return Ok(old_items); + } + + let video_items = self.get_video_items_from_html(text); + if !video_items.is_empty() { + cache.remove(&video_url); + cache.insert(video_url.clone(), video_items.clone()); + return Ok(video_items); + } + + Ok(old_items) + } +} + +#[async_trait] +impl Provider for SpankbangProvider { + async fn get_videos( + &self, + cache: VideoCache, + pool: DbPool, + sort: String, + query: Option, + page: String, + per_page: String, + options: ServerOptions, + ) -> Vec { + let _ = pool; + let _ = per_page; + let page = page.parse::().unwrap_or(1); + + let videos = match query { + Some(query) if !query.trim().is_empty() => { + self.query(cache, page, &query, &sort, options).await + } + _ => self.get(cache, page, &sort, options).await, + }; + + match videos { + Ok(videos) => videos, + Err(e) => { + report_provider_error( + "spankbang", + "get_videos", + &format!("page={page}; error={e}"), + ) + .await; + vec![] + } + } + } + + fn get_channel(&self, clientversion: ClientVersion) -> Option { + Some(self.build_channel(clientversion)) + } +} + +#[cfg(test)] +mod tests { + use super::SpankbangProvider; + + #[test] + fn builds_top_level_urls() { + let provider = SpankbangProvider::new(); + assert_eq!( + provider.build_get_url(1, "trending"), + "https://spankbang.com/trending_videos/" + ); + assert_eq!( + provider.build_get_url(2, "upcoming"), + "https://spankbang.com/upcoming/2/" + ); + assert_eq!( + provider.build_get_url(2, "new"), + "https://spankbang.com/new_videos/2/" + ); + assert_eq!( + provider.build_get_url(2, "popular"), + "https://spankbang.com/most_popular/2/?p=w" + ); + assert_eq!( + provider.build_get_url(1, "featured"), + "https://spankbang.com/trending_videos/" + ); + } + + #[test] + fn builds_search_urls_with_exact_sort_shape() { + let provider = SpankbangProvider::new(); + assert_eq!( + provider.build_query_url("adriana chechik", 1, "trending"), + "https://spankbang.com/s/adriana+chechik/" + ); + assert_eq!( + provider.build_query_url("adriana chechik", 2, "new"), + "https://spankbang.com/s/adriana+chechik/2/?o=new" + ); + assert_eq!( + provider.build_query_url("adriana chechik", 2, "popular"), + "https://spankbang.com/s/adriana+chechik/2/?o=popular" + ); + assert_eq!( + provider.build_query_url("adriana chechik", 2, "featured"), + "https://spankbang.com/s/adriana+chechik/2/?o=featured" + ); + assert_eq!( + provider.build_query_url("無修正", 1, "trending"), + "https://spankbang.com/s/%E7%84%A1%E4%BF%AE%E6%AD%A3/" + ); + assert_eq!( + provider.request_headers(), + vec![("Referer".to_string(), "https://spankbang.com/".to_string())] + ); + } + + #[test] + fn parses_cards_and_rewrites_to_proxy_url() { + let provider = SpankbangProvider::new(); + let html = r#" + + "#; + + let items = provider.get_video_items_from_html(html.to_string()); + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, "6597754"); + assert_eq!(items[0].title, "Adriana's Fleshlight Insertion"); + assert_eq!( + items[0].url, + "https://hottub.spacemoehre.de/proxy/spankbang/3xeuy/video/adriana+s+fleshlight+insertion" + ); + assert_eq!( + items[0].thumb, + "https://tbi.sb-cd.com/t/6597754/6/5/w:300/t6-enh/adriana-s-fleshlight-insertion.jpg" + ); + assert_eq!( + items[0].preview, + Some("https://tbv.sb-cd.com/t/6597754/6/5/td.mp4".to_string()) + ); + assert_eq!(items[0].duration, 1020); + assert_eq!(items[0].views, Some(35_000)); + assert_eq!(items[0].rating, Some(96.0)); + assert_eq!(items[0].uploader, Some("Adriana Chechik".to_string())); + assert_eq!( + items[0].uploaderUrl, + Some("https://spankbang.com/76/pornstar/adriana+chechik/".to_string()) + ); + } + + #[test] + fn skips_spankbang_gold_cards() { + let provider = SpankbangProvider::new(); + let html = r#" + +
+ + + Free video + +
5m
+
+
+ 2K +

Free video

+
+
+ "#; + + let items = provider.get_video_items_from_html(html.to_string()); + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, "2"); + assert_eq!(items[0].title, "Free video"); + } +} diff --git a/src/proxies/mod.rs b/src/proxies/mod.rs index 4089304..da2ecc3 100644 --- a/src/proxies/mod.rs +++ b/src/proxies/mod.rs @@ -1,16 +1,19 @@ use ntex::web; +use crate::proxies::spankbang::SpankbangProxy; use crate::{proxies::sxyprn::SxyprnProxy, util::requester::Requester}; pub mod hanimecdn; pub mod hqpornerthumb; pub mod javtiful; +pub mod spankbang; pub mod sxyprn; #[derive(Debug, Clone)] pub enum AnyProxy { Sxyprn(SxyprnProxy), Javtiful(javtiful::JavtifulProxy), + Spankbang(SpankbangProxy), } pub trait Proxy { @@ -22,6 +25,7 @@ impl Proxy for AnyProxy { match self { AnyProxy::Sxyprn(p) => p.get_video_url(url, requester).await, AnyProxy::Javtiful(p) => p.get_video_url(url, requester).await, + AnyProxy::Spankbang(p) => p.get_video_url(url, requester).await, } } } diff --git a/src/proxies/spankbang.rs b/src/proxies/spankbang.rs new file mode 100644 index 0000000..f90b261 --- /dev/null +++ b/src/proxies/spankbang.rs @@ -0,0 +1,105 @@ +use ntex::web; +use regex::Regex; +use wreq::Version; + +use crate::util::requester::Requester; + +#[derive(Debug, Clone)] +pub struct SpankbangProxy {} + +impl SpankbangProxy { + pub fn new() -> Self { + SpankbangProxy {} + } + + fn request_headers() -> Vec<(String, String)> { + vec![("Referer".to_string(), "https://spankbang.com/".to_string())] + } + + fn extract_stream_data(text: &str) -> Option<&str> { + let marker = "var stream_data = "; + let start = text.find(marker)? + marker.len(); + let rest = &text[start..]; + let end = rest.find("};")?; + Some(&rest[..=end]) + } + + fn extract_first_stream_url(stream_data: &str, key: &str) -> Option { + let pattern = format!(r"'{}'\s*:\s*\[\s*'([^']+)'", regex::escape(key)); + let regex = Regex::new(&pattern).ok()?; + regex + .captures(stream_data) + .and_then(|captures| captures.get(1)) + .map(|value| value.as_str().to_string()) + } + + fn select_best_stream_url(stream_data: &str) -> Option { + for key in [ + "m3u8", "4k", "1080p", "720p", "480p", "320p", "240p", "main", + ] { + if let Some(url) = Self::extract_first_stream_url(stream_data, key) { + return Some(url); + } + } + + None + } + + pub async fn get_video_url( + &self, + url: String, + requester: web::types::State, + ) -> String { + let mut requester = requester.get_ref().clone(); + let url = format!("https://spankbang.com/{}", url.trim_start_matches('/')); + let text = requester + .get_with_headers(&url, Self::request_headers(), Some(Version::HTTP_2)) + .await + .unwrap_or_default(); + if text.is_empty() { + return String::new(); + } + + let Some(stream_data) = Self::extract_stream_data(&text) else { + return String::new(); + }; + + Self::select_best_stream_url(stream_data).unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::SpankbangProxy; + + #[test] + fn prefers_m3u8_when_present() { + assert_eq!( + SpankbangProxy::request_headers(), + vec![("Referer".to_string(), "https://spankbang.com/".to_string())] + ); + + let data = r#" + var stream_data = {'240p': ['https://cdn.example/240.mp4'], '720p': ['https://cdn.example/720.mp4'], 'm3u8': ['https://cdn.example/master.m3u8'], 'main': ['https://cdn.example/720.mp4']}; + "#; + + let stream_data = SpankbangProxy::extract_stream_data(data).unwrap(); + assert_eq!( + SpankbangProxy::select_best_stream_url(stream_data).as_deref(), + Some("https://cdn.example/master.m3u8") + ); + } + + #[test] + fn falls_back_to_highest_quality_mp4() { + let data = r#" + var stream_data = {'240p': ['https://cdn.example/240.mp4'], '480p': ['https://cdn.example/480.mp4'], '720p': ['https://cdn.example/720.mp4'], '1080p': [], '4k': [], 'm3u8': [], 'main': ['https://cdn.example/480.mp4']}; + "#; + + let stream_data = SpankbangProxy::extract_stream_data(data).unwrap(); + assert_eq!( + SpankbangProxy::select_best_stream_url(stream_data).as_deref(), + Some("https://cdn.example/720.mp4") + ); + } +} diff --git a/src/proxy.rs b/src/proxy.rs index 1737b44..963e0b0 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -1,6 +1,7 @@ use ntex::web::{self, HttpRequest}; use crate::proxies::javtiful::JavtifulProxy; +use crate::proxies::spankbang::SpankbangProxy; use crate::proxies::sxyprn::SxyprnProxy; use crate::proxies::*; use crate::util::requester::Requester; @@ -16,6 +17,11 @@ pub fn config(cfg: &mut web::ServiceConfig) { .route(web::post().to(proxy2redirect)) .route(web::get().to(proxy2redirect)), ) + .service( + web::resource("/spankbang/{endpoint}*") + .route(web::post().to(proxy2redirect)) + .route(web::get().to(proxy2redirect)), + ) .service( web::resource("/hanime-cdn/{endpoint}*") .route(web::post().to(crate::proxies::hanimecdn::get_image)) @@ -47,6 +53,7 @@ fn get_proxy(proxy: &str) -> Option { match proxy { "sxyprn" => Some(AnyProxy::Sxyprn(SxyprnProxy::new())), "javtiful" => Some(AnyProxy::Javtiful(JavtifulProxy::new())), + "spankbang" => Some(AnyProxy::Spankbang(SpankbangProxy::new())), _ => None, } }