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, VideoFormat, VideoItem}; use async_trait::async_trait; use error_chain::error_chain; use htmlentity::entity::{ICodedDataTrait, decode}; use scraper::{ElementRef, Html, Selector}; use serde_json::Value; use std::process::Command; use url::form_urlencoded::byte_serialize; pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata = crate::providers::ProviderChannelMetadata { group_id: "mainstream-tube", tags: &["mainstream", "mixed", "search"], }; 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![ ( "accept".to_string(), "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" .to_string(), ), ("accept-language".to_string(), "en-US,en;q=0.6".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()), ( "sec-ch-ua".to_string(), r#""Chromium";v="146", "Not-A.Brand";v="24", "Brave";v="146""#.to_string(), ), ("sec-ch-ua-mobile".to_string(), "?0".to_string()), ("sec-ch-ua-platform".to_string(), "\"Linux\"".to_string()), ("sec-fetch-dest".to_string(), "document".to_string()), ("sec-fetch-mode".to_string(), "navigate".to_string()), ("sec-fetch-site".to_string(), "none".to_string()), ("sec-fetch-user".to_string(), "?1".to_string()), ("sec-gpc".to_string(), "1".to_string()), ( "upgrade-insecure-requests".to_string(), "1".to_string(), ), ( "user-agent".to_string(), "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" .to_string(), ), ("Referer".to_string(), format!("{}/", self.url)), ] } fn is_cloudflare_block(text: &str) -> bool { let lowercase = text.to_ascii_lowercase(); lowercase.contains("attention required") || lowercase.contains("you have been blocked") || lowercase.contains("cloudflare ray id") } fn fetch_items_with_curl_cffi(&self, page_url: &str, proxy_base_url: &str) -> Vec { crate::flow_debug!( "trace={} spankbang curl_cffi fetch start url={}", "none", crate::util::flow_debug::preview(page_url, 120) ); let output = match Command::new("python3") .arg("-c") .arg( r#"from curl_cffi import requests from bs4 import BeautifulSoup import json import sys url = sys.argv[1] r = requests.get(url, impersonate='chrome124', timeout=45, headers={'Referer': 'https://spankbang.com/'}) if r.status_code >= 400: raise SystemExit(2) soup = BeautifulSoup(r.text, 'html.parser') cards = soup.select('[data-testid="video-list"] [data-testid="video-item"]') if not cards: cards = soup.select('[data-testid="video-item"]') items = [] for card in cards: vid = (card.get('data-id') or '').strip() link = card.select_one('a[href*="/video/"]') if not vid or link is None: continue href = (link.get('href') or '').strip() if not href: continue img = card.select_one('picture img, img') title_anchor = card.select_one('p a[title], a[title]') duration = card.select_one('[data-testid="video-item-length"]') views = card.select_one('[data-testid="views"]') uploader = card.select_one('[data-testid="video-info-with-badge"] a[data-testid="title"]') preview = card.select_one('video source[data-src]') items.append({ 'id': vid, 'href': href, 'title': (title_anchor.get('title') if title_anchor else '') or (img.get('alt') if img else ''), 'thumb': ((img.get('src') if img else '') or (img.get('data-src') if img else '') or '').strip(), 'preview': (preview.get('data-src') if preview else '') or '', 'duration': duration.get_text(' ', strip=True) if duration else '', 'views': views.get_text(' ', strip=True) if views else '', 'uploader': uploader.get_text(' ', strip=True) if uploader else '', 'uploader_href': (uploader.get('href') if uploader else '') or '', }) sys.stdout.write(json.dumps(items)) "#, ) .arg(page_url) .output() { Ok(output) if output.status.success() => output, Ok(output) => { let stderr = String::from_utf8_lossy(&output.stderr); crate::providers::report_provider_error_background( "spankbang", "curl_cffi.fetch.status", &format!( "url={page_url}; status={}; stderr={}", output.status, crate::util::flow_debug::preview(&stderr, 300) ), ); return vec![]; } Err(e) => { crate::providers::report_provider_error_background( "spankbang", "curl_cffi.fetch.spawn", &format!("url={page_url}; error={e}"), ); return vec![]; } }; let payload = String::from_utf8(output.stdout).ok(); if payload.as_deref().unwrap_or("").trim().is_empty() { crate::providers::report_provider_error_background( "spankbang", "curl_cffi.fetch.empty", &format!("url={page_url}"), ); return vec![]; } crate::flow_debug!( "trace={} spankbang curl_cffi fetch ok url={} bytes={}", "none", crate::util::flow_debug::preview(page_url, 120), payload.as_deref().unwrap_or("").len() ); let items_json: Value = match serde_json::from_str(payload.as_deref().unwrap_or("")) { Ok(value) => value, Err(e) => { crate::providers::report_provider_error_background( "spankbang", "curl_cffi.parse.json", &format!("url={page_url}; error={e}"), ); return vec![]; } }; let Some(entries) = items_json.as_array() else { return vec![]; }; let mut items = Vec::new(); for entry in entries { let id = entry .get("id") .and_then(|value| value.as_str()) .unwrap_or("") .trim() .to_string(); let href = entry .get("href") .and_then(|value| value.as_str()) .unwrap_or("") .trim() .to_string(); if id.is_empty() || href.is_empty() { continue; } let detail_url = self.normalize_url(&href); let title = entry .get("title") .and_then(|value| value.as_str()) .map(Self::decode_html) .unwrap_or_default(); if title.is_empty() { continue; } let thumb = entry .get("thumb") .and_then(|value| value.as_str()) .map(|value| self.normalize_url(value)) .unwrap_or_default(); let preview = entry .get("preview") .and_then(|value| value.as_str()) .map(|value| self.normalize_url(value)) .unwrap_or_default(); let duration = entry .get("duration") .and_then(|value| value.as_str()) .map(Self::parse_duration) .unwrap_or(0); let views = entry .get("views") .and_then(|value| value.as_str()) .and_then(parse_abbreviated_number); let mut item = VideoItem::new( id, title, self.proxy_url(proxy_base_url, &href), "spankbang".to_string(), thumb, duration, ); if let Some(views) = views { item = item.views(views); } if !preview.is_empty() { let mut format = VideoFormat::new( preview.clone(), "preview".to_string(), "video/mp4".to_string(), ); format.add_http_header("Referer".to_string(), detail_url.clone()); item = item.preview(preview).formats(vec![format]); } let uploader = entry .get("uploader") .and_then(|value| value.as_str()) .map(Self::decode_html) .unwrap_or_default(); if !uploader.is_empty() { item = item.uploader(uploader); } let uploader_href = entry .get("uploader_href") .and_then(|value| value.as_str()) .unwrap_or("") .trim(); if !uploader_href.is_empty() { let uploader_url = self.normalize_url(uploader_href); if !uploader_url.is_empty() { item = item.uploader_url(uploader_url); } } items.push(item); } if items.is_empty() { crate::providers::report_provider_error_background( "spankbang", "curl_cffi.parse.empty", &format!("url={page_url}"), ); return vec![]; } crate::flow_debug!( "trace={} spankbang curl_cffi parsed url={} items={}", "none", crate::util::flow_debug::preview(page_url, 120), items.len() ); items } 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, proxy_base_url: &str, url: &str) -> String { let path = url .strip_prefix(&self.url) .unwrap_or(url) .trim_start_matches('/'); if proxy_base_url.is_empty() { return format!("/proxy/spankbang/{path}"); } format!( "{}/proxy/spankbang/{path}", proxy_base_url.trim_end_matches('/') ) } 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, proxy_base_url: &str, ) -> 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 detail_url = self.normalize_url(&href); 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(proxy_base_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 { let mut format = VideoFormat::new( preview.clone(), "preview".to_string(), "video/mp4".to_string(), ); format.add_http_header("Referer".to_string(), detail_url.clone()); item = item.preview(preview).formats(vec![format]); } 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, proxy_base_url: &str) -> Vec { let document = Html::parse_document(&html); let video_list_selector = Selector::parse(r#"[data-testid="video-list"]"#).unwrap(); 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(); let roots = document.select(&video_list_selector).collect::>(); let cards = if let Some(root) = roots.last() { root.select(&card_selector).collect::>() } else { document.select(&card_selector).collect::>() }; for card in cards { 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, proxy_base_url, ) { 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; let proxy_base_url = options.public_url_base.as_deref().unwrap_or_default(); let curl_cffi_items = self.fetch_items_with_curl_cffi(&video_url, proxy_base_url); if !curl_cffi_items.is_empty() { cache.remove(&video_url); cache.insert(video_url.clone(), curl_cffi_items.clone()); return Ok(curl_cffi_items); } return Ok(old_items); } }; if text.trim().is_empty() { report_provider_error( "spankbang", "get.empty_response", &format!("url={video_url}"), ) .await; let proxy_base_url = options.public_url_base.as_deref().unwrap_or_default(); let curl_cffi_items = self.fetch_items_with_curl_cffi(&video_url, proxy_base_url); if !curl_cffi_items.is_empty() { cache.remove(&video_url); cache.insert(video_url.clone(), curl_cffi_items.clone()); return Ok(curl_cffi_items); } return Ok(old_items); } if Self::is_cloudflare_block(&text) { report_provider_error( "spankbang", "get.cloudflare_block", &format!("url={video_url}"), ) .await; let proxy_base_url = options.public_url_base.as_deref().unwrap_or_default(); let curl_cffi_items = self.fetch_items_with_curl_cffi(&video_url, proxy_base_url); if !curl_cffi_items.is_empty() { cache.remove(&video_url); cache.insert(video_url.clone(), curl_cffi_items.clone()); return Ok(curl_cffi_items); } return Ok(old_items); } let looks_like_html = text.to_ascii_lowercase().contains(" 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; let proxy_base_url = options.public_url_base.as_deref().unwrap_or_default(); let curl_cffi_items = self.fetch_items_with_curl_cffi(&video_url, proxy_base_url); if !curl_cffi_items.is_empty() { cache.remove(&video_url); cache.insert(video_url.clone(), curl_cffi_items.clone()); return Ok(curl_cffi_items); } return Ok(old_items); } }; if text.trim().is_empty() { report_provider_error( "spankbang", "query.empty_response", &format!("url={video_url}"), ) .await; let proxy_base_url = options.public_url_base.as_deref().unwrap_or_default(); let curl_cffi_items = self.fetch_items_with_curl_cffi(&video_url, proxy_base_url); if !curl_cffi_items.is_empty() { cache.remove(&video_url); cache.insert(video_url.clone(), curl_cffi_items.clone()); return Ok(curl_cffi_items); } return Ok(old_items); } if Self::is_cloudflare_block(&text) { report_provider_error( "spankbang", "query.cloudflare_block", &format!("url={video_url}"), ) .await; let proxy_base_url = options.public_url_base.as_deref().unwrap_or_default(); let curl_cffi_items = self.fetch_items_with_curl_cffi(&video_url, proxy_base_url); if !curl_cffi_items.is_empty() { cache.remove(&video_url); cache.insert(video_url.clone(), curl_cffi_items.clone()); return Ok(curl_cffi_items); } return Ok(old_items); } let looks_like_html = text.to_ascii_lowercase().contains(", 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![ ( "accept".to_string(), "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" .to_string(), ), ("accept-language".to_string(), "en-US,en;q=0.6".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()), ( "sec-ch-ua".to_string(), r#""Chromium";v="146", "Not-A.Brand";v="24", "Brave";v="146""#.to_string(), ), ("sec-ch-ua-mobile".to_string(), "?0".to_string()), ("sec-ch-ua-platform".to_string(), "\"Linux\"".to_string()), ("sec-fetch-dest".to_string(), "document".to_string()), ("sec-fetch-mode".to_string(), "navigate".to_string()), ("sec-fetch-site".to_string(), "none".to_string()), ("sec-fetch-user".to_string(), "?1".to_string()), ("sec-gpc".to_string(), "1".to_string()), ( "upgrade-insecure-requests".to_string(), "1".to_string(), ), ( "user-agent".to_string(), "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" .to_string(), ), ("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(), "https://example.com"); 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://example.com/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#" "#; let items = provider.get_video_items_from_html(html.to_string(), "https://example.com"); assert_eq!(items.len(), 1); assert_eq!(items[0].id, "2"); assert_eq!(items[0].title, "Free video"); } #[test] fn prefers_primary_video_list_over_header_dropdown_cards() { let provider = SpankbangProvider::new(); let html = r#" "#; let items = provider.get_video_items_from_html(html.to_string(), "https://example.com"); assert_eq!(items.len(), 1); assert_eq!(items[0].id, "222"); assert_eq!(items[0].title, "Right result"); } }