spankbang
This commit is contained in:
@@ -16,17 +16,17 @@ use crate::{
|
|||||||
|
|
||||||
pub mod all;
|
pub mod all;
|
||||||
pub mod hanime;
|
pub mod hanime;
|
||||||
pub mod perverzija;
|
|
||||||
pub mod pmvhaven;
|
|
||||||
pub mod pornhub;
|
|
||||||
// pub mod spankbang;
|
|
||||||
pub mod homoxxx;
|
pub mod homoxxx;
|
||||||
pub mod okporn;
|
pub mod okporn;
|
||||||
pub mod okxxx;
|
pub mod okxxx;
|
||||||
pub mod perfectgirls;
|
pub mod perfectgirls;
|
||||||
|
pub mod perverzija;
|
||||||
|
pub mod pmvhaven;
|
||||||
pub mod pornhat;
|
pub mod pornhat;
|
||||||
|
pub mod pornhub;
|
||||||
pub mod redtube;
|
pub mod redtube;
|
||||||
pub mod rule34video;
|
pub mod rule34video;
|
||||||
|
pub mod spankbang;
|
||||||
// pub mod hentaimoon;
|
// pub mod hentaimoon;
|
||||||
pub mod beeg;
|
pub mod beeg;
|
||||||
pub mod missav;
|
pub mod missav;
|
||||||
@@ -73,6 +73,10 @@ pub static ALL_PROVIDERS: Lazy<HashMap<&'static str, DynProvider>> = Lazy::new(|
|
|||||||
"pornhub",
|
"pornhub",
|
||||||
Arc::new(pornhub::PornhubProvider::new()) as DynProvider,
|
Arc::new(pornhub::PornhubProvider::new()) as DynProvider,
|
||||||
);
|
);
|
||||||
|
m.insert(
|
||||||
|
"spankbang",
|
||||||
|
Arc::new(spankbang::SpankbangProvider::new()) as DynProvider,
|
||||||
|
);
|
||||||
m.insert(
|
m.insert(
|
||||||
"rule34video",
|
"rule34video",
|
||||||
Arc::new(rule34video::Rule34videoProvider::new()) as DynProvider,
|
Arc::new(rule34video::Rule34videoProvider::new()) as DynProvider,
|
||||||
|
|||||||
691
src/providers/spankbang.rs
Normal file
691
src/providers/spankbang.rs
Normal file
@@ -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::<String>())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.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::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_of(element: &ElementRef<'_>) -> String {
|
||||||
|
Self::collapse_whitespace(&element.text().collect::<Vec<_>>().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::<u32>().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::<u32>().unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_rating(text: &str) -> Option<f32> {
|
||||||
|
let cleaned = Self::collapse_whitespace(text)
|
||||||
|
.trim_end_matches('%')
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if cleaned.is_empty() || cleaned == "-" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
cleaned.parse::<f32>().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<VideoItem> {
|
||||||
|
let card_html = card.html();
|
||||||
|
let card_text = Self::collapse_whitespace(&card.text().collect::<Vec<_>>().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<VideoItem> {
|
||||||
|
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<Vec<VideoItem>> {
|
||||||
|
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<Vec<VideoItem>> {
|
||||||
|
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<String>,
|
||||||
|
page: String,
|
||||||
|
per_page: String,
|
||||||
|
options: ServerOptions,
|
||||||
|
) -> Vec<VideoItem> {
|
||||||
|
let _ = pool;
|
||||||
|
let _ = per_page;
|
||||||
|
let page = page.parse::<u32>().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<Channel> {
|
||||||
|
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#"
|
||||||
|
<div data-testid="video-item" data-id="6597754" class="js-video-item z-0 flex flex-col">
|
||||||
|
<a href="/3xeuy/video/adriana+s+fleshlight+insertion" class="relative mb-1 overflow-hidden rounded bg-neutral-900">
|
||||||
|
<picture>
|
||||||
|
<img
|
||||||
|
src="https://tbi.sb-cd.com/t/6597754/6/5/w:300/t6-enh/adriana-s-fleshlight-insertion.jpg"
|
||||||
|
alt="Adriana's Fleshlight Insertion"
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
|
<video>
|
||||||
|
<source data-src="https://tbv.sb-cd.com/t/6597754/6/5/td.mp4" type="video/mp4" />
|
||||||
|
</video>
|
||||||
|
<div data-testid="video-item-length">17m</div>
|
||||||
|
</a>
|
||||||
|
<div data-testid="video-info-with-badge">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<a data-testid="title" href="/76/pornstar/adriana+chechik/">
|
||||||
|
<span>Adriana Chechik</span>
|
||||||
|
</a>
|
||||||
|
<span data-testid="views"><span></span><span>35K</span></span>
|
||||||
|
<span data-testid="rates"><span></span><span>96%</span></span>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<a href="/3xeuy/video/adriana+s+fleshlight+insertion" title="Adriana's Fleshlight Insertion">
|
||||||
|
<span>Adriana's Fleshlight Insertion</span>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"#;
|
||||||
|
|
||||||
|
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#"
|
||||||
|
<div data-testid="video-item" data-id="1">
|
||||||
|
<a href="/gold/video/locked">
|
||||||
|
<picture>
|
||||||
|
<img src="https://example.com/gold.jpg" alt="Gold video" />
|
||||||
|
</picture>
|
||||||
|
<div>SpankBang Gold</div>
|
||||||
|
<div data-testid="video-item-length">10m</div>
|
||||||
|
</a>
|
||||||
|
<div data-testid="video-info-with-badge">
|
||||||
|
<span data-testid="views"><span>1K</span></span>
|
||||||
|
<p><a href="/gold/video/locked" title="Gold video"><span>Gold video</span></a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-testid="video-item" data-id="2">
|
||||||
|
<a href="/free/video/open">
|
||||||
|
<picture>
|
||||||
|
<img src="https://example.com/free.jpg" alt="Free video" />
|
||||||
|
</picture>
|
||||||
|
<div data-testid="video-item-length">5m</div>
|
||||||
|
</a>
|
||||||
|
<div data-testid="video-info-with-badge">
|
||||||
|
<span data-testid="views"><span>2K</span></span>
|
||||||
|
<p><a href="/free/video/open" title="Free video"><span>Free video</span></a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"#;
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
use ntex::web;
|
use ntex::web;
|
||||||
|
|
||||||
|
use crate::proxies::spankbang::SpankbangProxy;
|
||||||
use crate::{proxies::sxyprn::SxyprnProxy, util::requester::Requester};
|
use crate::{proxies::sxyprn::SxyprnProxy, util::requester::Requester};
|
||||||
|
|
||||||
pub mod hanimecdn;
|
pub mod hanimecdn;
|
||||||
pub mod hqpornerthumb;
|
pub mod hqpornerthumb;
|
||||||
pub mod javtiful;
|
pub mod javtiful;
|
||||||
|
pub mod spankbang;
|
||||||
pub mod sxyprn;
|
pub mod sxyprn;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum AnyProxy {
|
pub enum AnyProxy {
|
||||||
Sxyprn(SxyprnProxy),
|
Sxyprn(SxyprnProxy),
|
||||||
Javtiful(javtiful::JavtifulProxy),
|
Javtiful(javtiful::JavtifulProxy),
|
||||||
|
Spankbang(SpankbangProxy),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait Proxy {
|
pub trait Proxy {
|
||||||
@@ -22,6 +25,7 @@ impl Proxy for AnyProxy {
|
|||||||
match self {
|
match self {
|
||||||
AnyProxy::Sxyprn(p) => p.get_video_url(url, requester).await,
|
AnyProxy::Sxyprn(p) => p.get_video_url(url, requester).await,
|
||||||
AnyProxy::Javtiful(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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
105
src/proxies/spankbang.rs
Normal file
105
src/proxies/spankbang.rs
Normal file
@@ -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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<Requester>,
|
||||||
|
) -> 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use ntex::web::{self, HttpRequest};
|
use ntex::web::{self, HttpRequest};
|
||||||
|
|
||||||
use crate::proxies::javtiful::JavtifulProxy;
|
use crate::proxies::javtiful::JavtifulProxy;
|
||||||
|
use crate::proxies::spankbang::SpankbangProxy;
|
||||||
use crate::proxies::sxyprn::SxyprnProxy;
|
use crate::proxies::sxyprn::SxyprnProxy;
|
||||||
use crate::proxies::*;
|
use crate::proxies::*;
|
||||||
use crate::util::requester::Requester;
|
use crate::util::requester::Requester;
|
||||||
@@ -16,6 +17,11 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||||||
.route(web::post().to(proxy2redirect))
|
.route(web::post().to(proxy2redirect))
|
||||||
.route(web::get().to(proxy2redirect)),
|
.route(web::get().to(proxy2redirect)),
|
||||||
)
|
)
|
||||||
|
.service(
|
||||||
|
web::resource("/spankbang/{endpoint}*")
|
||||||
|
.route(web::post().to(proxy2redirect))
|
||||||
|
.route(web::get().to(proxy2redirect)),
|
||||||
|
)
|
||||||
.service(
|
.service(
|
||||||
web::resource("/hanime-cdn/{endpoint}*")
|
web::resource("/hanime-cdn/{endpoint}*")
|
||||||
.route(web::post().to(crate::proxies::hanimecdn::get_image))
|
.route(web::post().to(crate::proxies::hanimecdn::get_image))
|
||||||
@@ -47,6 +53,7 @@ fn get_proxy(proxy: &str) -> Option<AnyProxy> {
|
|||||||
match proxy {
|
match proxy {
|
||||||
"sxyprn" => Some(AnyProxy::Sxyprn(SxyprnProxy::new())),
|
"sxyprn" => Some(AnyProxy::Sxyprn(SxyprnProxy::new())),
|
||||||
"javtiful" => Some(AnyProxy::Javtiful(JavtifulProxy::new())),
|
"javtiful" => Some(AnyProxy::Javtiful(JavtifulProxy::new())),
|
||||||
|
"spankbang" => Some(AnyProxy::Spankbang(SpankbangProxy::new())),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user