r.jina.ai now answers 403/429, so every request returned nothing. The site serves plain HTML to the emulating client; parse cards, detail rows and option lists from HTML, and return empty for no-hit searches (the site renders a recommendation tray under the notice). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
1343 lines
50 KiB
Rust
1343 lines
50 KiB
Rust
use crate::DbPool;
|
|
use crate::api::ClientVersion;
|
|
use crate::providers::{
|
|
Provider, report_provider_error, report_provider_error_background, requester_or_default,
|
|
};
|
|
use crate::status::*;
|
|
use crate::util::cache::VideoCache;
|
|
use crate::util::hoster_proxy::{proxy_name_for_url, rewrite_hoster_url};
|
|
use crate::util::parse_abbreviated_number;
|
|
use crate::util::requester::Requester;
|
|
use crate::videos::{ServerOptions, VideoItem};
|
|
use async_trait::async_trait;
|
|
use chrono::{DateTime, Duration as ChronoDuration, NaiveDate, Utc};
|
|
use error_chain::error_chain;
|
|
use futures::stream::{self, StreamExt};
|
|
use htmlentity::entity::ICodedDataTrait;
|
|
use regex::Regex;
|
|
use std::sync::{Arc, RwLock};
|
|
use std::time::Duration as StdDuration;
|
|
use std::{thread, vec};
|
|
use tokio::time::timeout;
|
|
use url::Url;
|
|
use wreq::Version;
|
|
|
|
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
|
crate::providers::ProviderChannelMetadata {
|
|
group_id: "jav",
|
|
tags: &["jav", "asian", "metadata"],
|
|
};
|
|
|
|
error_chain! {
|
|
foreign_links {
|
|
Io(std::io::Error);
|
|
}
|
|
errors {
|
|
Parse(msg: String) {
|
|
description("parse error")
|
|
display("parse error: {}", msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
const BASE_URL: &str = "https://sextb.net";
|
|
const CHANNEL_ID: &str = "sextb";
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct SextbProvider {
|
|
url: String,
|
|
genres: Arc<RwLock<Vec<FilterOption>>>,
|
|
studios: Arc<RwLock<Vec<FilterOption>>>,
|
|
actresses: Arc<RwLock<Vec<FilterOption>>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum Target {
|
|
Section {
|
|
section: String,
|
|
sort: String,
|
|
},
|
|
Search {
|
|
slug: String,
|
|
},
|
|
Genre {
|
|
slug: String,
|
|
},
|
|
Studio {
|
|
slug: String,
|
|
},
|
|
Actress {
|
|
slug: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
struct ListItemSeed {
|
|
id: String,
|
|
title: String,
|
|
url: String,
|
|
thumb: String,
|
|
duration: u32,
|
|
quality: Option<String>,
|
|
code: Option<String>,
|
|
tags: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
struct DetailMetadata {
|
|
aliases: Vec<String>,
|
|
director: Option<String>,
|
|
label: Option<String>,
|
|
studio: Option<(String, String)>,
|
|
cast: Vec<(String, String)>,
|
|
genres: Vec<(String, String)>,
|
|
quality: Option<String>,
|
|
release_date: Option<u64>,
|
|
added_at: Option<u64>,
|
|
views: Option<u32>,
|
|
rating: Option<f32>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct PlayerEpisode {
|
|
label: String,
|
|
film_id: String,
|
|
episode_id: String,
|
|
}
|
|
|
|
impl SextbProvider {
|
|
pub fn new() -> Self {
|
|
let provider = Self {
|
|
url: BASE_URL.to_string(),
|
|
genres: Arc::new(RwLock::new(vec![FilterOption {
|
|
id: "all".to_string(),
|
|
title: "All".to_string(),
|
|
}])),
|
|
studios: Arc::new(RwLock::new(vec![FilterOption {
|
|
id: "all".to_string(),
|
|
title: "All".to_string(),
|
|
}])),
|
|
actresses: Arc::new(RwLock::new(vec![FilterOption {
|
|
id: "all".to_string(),
|
|
title: "All".to_string(),
|
|
}])),
|
|
};
|
|
provider.spawn_initial_load();
|
|
provider
|
|
}
|
|
|
|
fn spawn_initial_load(&self) {
|
|
let url = self.url.clone();
|
|
let genres = Arc::clone(&self.genres);
|
|
let studios = Arc::clone(&self.studios);
|
|
let actresses = Arc::clone(&self.actresses);
|
|
|
|
thread::spawn(move || {
|
|
let runtime = match tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
{
|
|
Ok(runtime) => runtime,
|
|
Err(error) => {
|
|
report_provider_error_background(
|
|
CHANNEL_ID,
|
|
"spawn_initial_load.runtime_build",
|
|
&error.to_string(),
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
runtime.block_on(async move {
|
|
if let Err(error) = Self::load_genres(&url, Arc::clone(&genres)).await {
|
|
report_provider_error_background(CHANNEL_ID, "load_genres", &error.to_string());
|
|
}
|
|
if let Err(error) = Self::load_studios(&url, Arc::clone(&studios)).await {
|
|
report_provider_error_background(
|
|
CHANNEL_ID,
|
|
"load_studios",
|
|
&error.to_string(),
|
|
);
|
|
}
|
|
if let Err(error) = Self::load_actresses(&url, Arc::clone(&actresses)).await {
|
|
report_provider_error_background(
|
|
CHANNEL_ID,
|
|
"load_actresses",
|
|
&error.to_string(),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
|
let genres = self.genres.read().map(|value| value.clone()).unwrap_or_default();
|
|
let _studios = self.studios.read().map(|value| value.clone()).unwrap_or_default();
|
|
let _actresses = self
|
|
.actresses
|
|
.read()
|
|
.map(|value| value.clone())
|
|
.unwrap_or_default();
|
|
|
|
Channel {
|
|
id: CHANNEL_ID.to_string(),
|
|
name: "SEXTB".to_string(),
|
|
description:
|
|
"JAV archive pages with section, genre, studio, actress, and rich detail metadata."
|
|
.to_string(),
|
|
premium: false,
|
|
favicon: "https://www.google.com/s2/favicons?sz=64&domain=sextb.net".to_string(),
|
|
status: "active".to_string(),
|
|
categories: genres.iter().map(|value| value.title.clone()).collect(),
|
|
options: vec![
|
|
ChannelOption {
|
|
id: "sort".to_string(),
|
|
title: "Sort".to_string(),
|
|
description: "Sort a section archive page.".to_string(),
|
|
systemImage: "list.number".to_string(),
|
|
colorName: "blue".to_string(),
|
|
options: vec![
|
|
FilterOption {
|
|
id: "latest".to_string(),
|
|
title: "Latest".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "release".to_string(),
|
|
title: "New Releases".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "liked".to_string(),
|
|
title: "Most Liked".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "viewed".to_string(),
|
|
title: "Most Viewed".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "favorite".to_string(),
|
|
title: "Most Favorite".to_string(),
|
|
},
|
|
],
|
|
multiSelect: false,
|
|
},
|
|
],
|
|
nsfw: true,
|
|
cacheDuration: Some(1800),
|
|
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
|
|
}
|
|
}
|
|
|
|
fn regex(value: &str) -> Result<Regex> {
|
|
Regex::new(value).map_err(|error| Error::from(format!("regex `{value}` failed: {error}")))
|
|
}
|
|
|
|
fn collapse_whitespace(text: &str) -> String {
|
|
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
|
}
|
|
|
|
fn normalize_title(text: &str) -> String {
|
|
Self::collapse_whitespace(text)
|
|
.trim_matches('"')
|
|
.trim()
|
|
.to_string()
|
|
}
|
|
|
|
fn slugify(value: &str) -> String {
|
|
let mut output = String::new();
|
|
let mut needs_dash = false;
|
|
|
|
for ch in value.chars() {
|
|
if ch.is_ascii_alphanumeric() {
|
|
if needs_dash && !output.is_empty() {
|
|
output.push('-');
|
|
}
|
|
output.push(ch.to_ascii_lowercase());
|
|
needs_dash = false;
|
|
continue;
|
|
}
|
|
|
|
if matches!(ch, ' ' | '-' | '_' | '/' | '&' | '.' | '+' | '\'') {
|
|
needs_dash = !output.is_empty();
|
|
}
|
|
}
|
|
|
|
output.trim_matches('-').to_string()
|
|
}
|
|
|
|
fn parse_abbrev(text: &str) -> Option<u32> {
|
|
let cleaned = text.replace(',', "").trim().to_string();
|
|
parse_abbreviated_number(&cleaned)
|
|
}
|
|
|
|
fn parse_duration(text: &str) -> u32 {
|
|
text.trim().parse::<u32>().unwrap_or(0) * 60
|
|
}
|
|
|
|
fn parse_relative_or_date(text: &str) -> Option<u64> {
|
|
let trimmed = text.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
if let Some(value) = Self::parse_absolute_date(trimmed) {
|
|
return Some(value);
|
|
}
|
|
|
|
let regex = Regex::new(
|
|
r"(?i)^(?P<amount>\d+)\s+(?P<unit>minute|minutes|hour|hours|day|days|week|weeks|month|months|year|years)\s+ago$",
|
|
)
|
|
.ok()?;
|
|
let captures = regex.captures(trimmed)?;
|
|
let amount = captures.name("amount")?.as_str().parse::<i64>().ok()?;
|
|
let unit = captures.name("unit")?.as_str().to_ascii_lowercase();
|
|
let now = Utc::now();
|
|
let timestamp = match unit.as_str() {
|
|
"minute" | "minutes" => now - ChronoDuration::minutes(amount),
|
|
"hour" | "hours" => now - ChronoDuration::hours(amount),
|
|
"day" | "days" => now - ChronoDuration::days(amount),
|
|
"week" | "weeks" => now - ChronoDuration::weeks(amount),
|
|
"month" | "months" => now - ChronoDuration::days(amount * 30),
|
|
"year" | "years" => now - ChronoDuration::days(amount * 365),
|
|
_ => return None,
|
|
};
|
|
Some(timestamp.timestamp() as u64)
|
|
}
|
|
|
|
fn parse_absolute_date(text: &str) -> Option<u64> {
|
|
let normalized = text.replace('.', "");
|
|
let date = NaiveDate::parse_from_str(&normalized, "%b %d, %Y").ok()?;
|
|
let dt = date.and_hms_opt(0, 0, 0)?;
|
|
Some(DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc).timestamp() as u64)
|
|
}
|
|
|
|
fn push_unique(target: &Arc<RwLock<Vec<FilterOption>>>, item: FilterOption) {
|
|
if item.id.is_empty() || item.title.is_empty() {
|
|
return;
|
|
}
|
|
if let Ok(mut values) = target.write() {
|
|
if !values.iter().any(|existing| {
|
|
existing.id.eq_ignore_ascii_case(&item.id)
|
|
|| existing.title.eq_ignore_ascii_case(&item.title)
|
|
}) {
|
|
values.push(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The site serves plain HTML; fetch it directly. (It used to be read
|
|
/// through the r.jina.ai markdown mirror, which now answers 403/429.)
|
|
async fn fetch_html(&self, requester: &mut Requester, url: &str) -> Result<String> {
|
|
requester
|
|
.get(url, Some(Version::HTTP_2))
|
|
.await
|
|
.map_err(|error| Error::from(format!("request failed for {url}: {error}")))
|
|
}
|
|
|
|
fn pagination_last_page(html: &str) -> Option<u16> {
|
|
let regex = Regex::new(r#"/pg-(?P<page>\d+)['"]"#).ok()?;
|
|
regex
|
|
.captures_iter(html)
|
|
.filter_map(|captures| captures.name("page")?.as_str().parse::<u16>().ok())
|
|
.max()
|
|
}
|
|
|
|
fn collect_options(html: &str, pattern: &str) -> Result<Vec<FilterOption>> {
|
|
let regex = Self::regex(pattern)?;
|
|
let mut items = Vec::new();
|
|
for captures in regex.captures_iter(html) {
|
|
let slug = captures
|
|
.name("slug")
|
|
.map(|value| value.as_str().trim().to_string())
|
|
.unwrap_or_default();
|
|
let raw = captures.name("title").map(|value| value.as_str()).unwrap_or_default();
|
|
let decoded = htmlentity::entity::decode(raw.as_bytes())
|
|
.to_string()
|
|
.unwrap_or_else(|_| raw.to_string());
|
|
let title = Self::normalize_title(decoded.trim_start_matches('#'));
|
|
if title.is_empty() || slug.is_empty() {
|
|
continue;
|
|
}
|
|
items.push(FilterOption { id: slug, title });
|
|
}
|
|
Ok(items)
|
|
}
|
|
|
|
fn parse_genre_options(html: &str) -> Result<Vec<FilterOption>> {
|
|
Self::collect_options(
|
|
html,
|
|
r#"<li><i class="fas fa-folder"[^>]*></i>\s*<a href="/genre/(?P<slug>[^"?]+)"[^>]*>(?P<title>[^<]+)</a>"#,
|
|
)
|
|
}
|
|
|
|
fn parse_studio_options(html: &str) -> Result<Vec<FilterOption>> {
|
|
Self::collect_options(
|
|
html,
|
|
r#"<div class="tray-item tray-item-list">\s*<a href="/studio/(?P<slug>[^"?]+)"[^>]*>(?:<i[^>]*></i>)?\s*(?P<title>[^<]+)</a>"#,
|
|
)
|
|
}
|
|
|
|
fn parse_actress_options(html: &str) -> Result<Vec<FilterOption>> {
|
|
Self::collect_options(
|
|
html,
|
|
r#"<div class="tray-item-actress">\s*<a href="/actress/(?P<slug>[^"?]+)">.*?<div class="tray-item-actress-title">(?P<title>[^<]+)</div>"#,
|
|
)
|
|
}
|
|
|
|
async fn load_genres(base_url: &str, genres: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
|
|
let provider = Self {
|
|
url: base_url.to_string(),
|
|
genres: Arc::clone(&genres),
|
|
studios: Arc::new(RwLock::new(vec![])),
|
|
actresses: Arc::new(RwLock::new(vec![])),
|
|
};
|
|
let mut requester = Requester::new();
|
|
let html = provider.fetch_html(&mut requester, &format!("{base_url}/genres")).await?;
|
|
for item in Self::parse_genre_options(&html)? {
|
|
Self::push_unique(&genres, item);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn load_studios(base_url: &str, studios: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
|
|
let provider = Self {
|
|
url: base_url.to_string(),
|
|
genres: Arc::new(RwLock::new(vec![])),
|
|
studios: Arc::clone(&studios),
|
|
actresses: Arc::new(RwLock::new(vec![])),
|
|
};
|
|
let mut requester = Requester::new();
|
|
let html =
|
|
provider.fetch_html(&mut requester, &format!("{base_url}/list-studios")).await?;
|
|
for item in Self::parse_studio_options(&html)? {
|
|
Self::push_unique(&studios, item);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn load_actresses(
|
|
base_url: &str,
|
|
actresses: Arc<RwLock<Vec<FilterOption>>>,
|
|
) -> Result<()> {
|
|
let provider = Self {
|
|
url: base_url.to_string(),
|
|
genres: Arc::new(RwLock::new(vec![])),
|
|
studios: Arc::new(RwLock::new(vec![])),
|
|
actresses: Arc::clone(&actresses),
|
|
};
|
|
let mut requester = Requester::new();
|
|
let first_page =
|
|
provider.fetch_html(&mut requester, &format!("{base_url}/list-actress")).await?;
|
|
for item in Self::parse_actress_options(&first_page)? {
|
|
Self::push_unique(&actresses, item);
|
|
}
|
|
|
|
let last_page = Self::pagination_last_page(&first_page).unwrap_or(1).min(80);
|
|
for page in 2..=last_page {
|
|
let html = provider
|
|
.fetch_html(&mut requester, &format!("{base_url}/list-actress/pg-{page}"))
|
|
.await?;
|
|
for item in Self::parse_actress_options(&html)? {
|
|
Self::push_unique(&actresses, item);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn resolve_section(options: &ServerOptions) -> String {
|
|
match options.filter.as_deref().unwrap_or("censored") {
|
|
"uncensored" => "uncensored".to_string(),
|
|
"subtitle" => "subtitle".to_string(),
|
|
"amateur" => "amateur".to_string(),
|
|
_ => "censored".to_string(),
|
|
}
|
|
}
|
|
|
|
fn resolve_sort(sort: &str) -> String {
|
|
match sort {
|
|
"release" => "release".to_string(),
|
|
"liked" => "liked".to_string(),
|
|
"viewed" | "popular" => "viewed".to_string(),
|
|
"favorite" => "favorite".to_string(),
|
|
_ => "latest".to_string(),
|
|
}
|
|
}
|
|
|
|
fn find_option_slug(options: &Arc<RwLock<Vec<FilterOption>>>, value: &str) -> Option<String> {
|
|
let normalized = value.trim().to_ascii_lowercase();
|
|
let slug = Self::slugify(&normalized);
|
|
let options = options.read().ok()?;
|
|
let option = options.iter().find(|item| {
|
|
item.id.eq_ignore_ascii_case(value)
|
|
|| item.title.eq_ignore_ascii_case(value)
|
|
|| Self::slugify(&item.title) == slug
|
|
})?;
|
|
Some(option.id.clone())
|
|
}
|
|
|
|
fn resolve_option_target(&self, options: &ServerOptions, sort: &str) -> Target {
|
|
if let Some(value) = options.stars.as_deref() {
|
|
if let Some(slug) = Self::find_option_slug(&self.actresses, value) {
|
|
return Target::Actress { slug };
|
|
}
|
|
}
|
|
if let Some(value) = options.sites.as_deref() {
|
|
if let Some(slug) = Self::find_option_slug(&self.studios, value) {
|
|
return Target::Studio { slug };
|
|
}
|
|
}
|
|
if let Some(value) = options.categories.as_deref() {
|
|
if let Some(slug) = Self::find_option_slug(&self.genres, value) {
|
|
return Target::Genre { slug };
|
|
}
|
|
}
|
|
Target::Section {
|
|
section: Self::resolve_section(options),
|
|
sort: Self::resolve_sort(sort),
|
|
}
|
|
}
|
|
|
|
fn resolve_query_target(&self, query: &str, sort: &str) -> Target {
|
|
if let Some(slug) = Self::find_option_slug(&self.actresses, query) {
|
|
return Target::Actress { slug };
|
|
}
|
|
if let Some(slug) = Self::find_option_slug(&self.studios, query) {
|
|
return Target::Studio { slug };
|
|
}
|
|
if let Some(slug) = Self::find_option_slug(&self.genres, query) {
|
|
return Target::Genre { slug };
|
|
}
|
|
|
|
let slug = Self::slugify(query);
|
|
if slug.is_empty() {
|
|
return Target::Section {
|
|
section: "censored".to_string(),
|
|
sort: Self::resolve_sort(sort),
|
|
};
|
|
}
|
|
Target::Search { slug }
|
|
}
|
|
|
|
fn build_url_for_target(&self, target: &Target, page: u16) -> String {
|
|
match target {
|
|
Target::Section { section, sort } => {
|
|
let mut url = Url::parse(&format!("{}/{}", self.url, section))
|
|
.expect("valid section base url");
|
|
{
|
|
let mut pairs = url.query_pairs_mut();
|
|
pairs.append_pair("genre", "all");
|
|
pairs.append_pair("studio", "all");
|
|
pairs.append_pair("quality", "all");
|
|
pairs.append_pair("year", "all");
|
|
if sort != "latest" {
|
|
pairs.append_pair("sort", sort);
|
|
}
|
|
if page > 1 {
|
|
pairs.append_pair("pg", &page.to_string());
|
|
}
|
|
}
|
|
url.to_string()
|
|
}
|
|
Target::Search { slug } => {
|
|
if page > 1 {
|
|
format!("{}/search/{}/pg-{}", self.url, slug, page)
|
|
} else {
|
|
format!("{}/search/{}", self.url, slug)
|
|
}
|
|
}
|
|
Target::Genre { slug } => {
|
|
if page > 1 {
|
|
format!("{}/genre/{}/pg-{}", self.url, slug, page)
|
|
} else {
|
|
format!("{}/genre/{}", self.url, slug)
|
|
}
|
|
}
|
|
Target::Studio { slug } => {
|
|
if page > 1 {
|
|
format!("{}/studio/{}/pg-{}", self.url, slug, page)
|
|
} else {
|
|
format!("{}/studio/{}", self.url, slug)
|
|
}
|
|
}
|
|
Target::Actress { slug } => {
|
|
if page > 1 {
|
|
format!("{}/actress/{}/pg-{}", self.url, slug, page)
|
|
} else {
|
|
format!("{}/actress/{}", self.url, slug)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_list_items(html: &str) -> Result<Vec<ListItemSeed>> {
|
|
// A no-hit search still renders a "recommended" tray under the notice.
|
|
if html.contains("No Video were found") {
|
|
return Ok(vec![]);
|
|
}
|
|
let mut items = Vec::new();
|
|
|
|
// Cards are `<div class="tray-item">`, optionally with modifiers such
|
|
// as `tray-item tray-item-uncensored`; `tray-item-title` etc. must not match.
|
|
let card_start = Self::regex(r#"<div class="tray-item(?: [^"]*)?">"#)?;
|
|
for card in card_start.split(html).skip(1) {
|
|
|
|
let Some(href) = Self::attr_between(card, "<a href=\"", "\"") else {
|
|
continue;
|
|
};
|
|
let slug = href.trim_matches('/').to_string();
|
|
if slug.is_empty() || slug.contains('/') {
|
|
continue;
|
|
}
|
|
let url = format!("{BASE_URL}/{slug}");
|
|
|
|
let title_raw = Self::attr_between(card, "class=\"tray-item-title\">", "</div>")
|
|
.or_else(|| Self::attr_between(card, "alt=\"", "\" "))
|
|
.unwrap_or_default();
|
|
let title = Self::normalize_title(
|
|
&htmlentity::entity::decode(title_raw.as_bytes())
|
|
.to_string()
|
|
.unwrap_or_else(|_| title_raw.to_string()),
|
|
);
|
|
if title.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let thumb = Self::attr_between(card, "data-src=\"", "\"")
|
|
.or_else(|| Self::attr_between(card, "data-poster=\"", "\""))
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
if thumb.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let quality = if card.contains("class=\"hd\"") {
|
|
Some("HD".to_string())
|
|
} else if card.contains("class=\"sd\"") {
|
|
Some("SD".to_string())
|
|
} else {
|
|
None
|
|
};
|
|
let duration = Self::attr_between(card, "class=\"tray-item-runtime\">", "</div>")
|
|
.map(|value| value.replace("min", ""))
|
|
.map(|value| Self::parse_duration(&value))
|
|
.unwrap_or(0);
|
|
let code = Self::attr_between(card, "class=\"tray-item-code\">", "</div>")
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty());
|
|
|
|
let mut tags = Vec::new();
|
|
if let Some(value) = quality.clone() {
|
|
tags.push(value);
|
|
}
|
|
if let Some(value) = code.clone() {
|
|
tags.push(value.clone());
|
|
tags.push(value.to_ascii_uppercase());
|
|
}
|
|
tags.sort();
|
|
tags.dedup();
|
|
|
|
items.push(ListItemSeed {
|
|
id: slug.clone(),
|
|
title,
|
|
url,
|
|
thumb,
|
|
duration,
|
|
quality,
|
|
code,
|
|
tags,
|
|
});
|
|
}
|
|
|
|
Ok(items)
|
|
}
|
|
|
|
fn attr_between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> {
|
|
let from = text.find(start)? + start.len();
|
|
let rest = &text[from..];
|
|
Some(&rest[..rest.find(end)?])
|
|
}
|
|
|
|
/// Returns `(title, absolute-url)` for every `<a ...><strong>Title</strong></a>`.
|
|
fn extract_link_texts(fragment: &str) -> Result<Vec<(String, String)>> {
|
|
let regex = Self::regex(
|
|
r#"<a href="(?P<url>[^"]+)"[^>]*>\s*<strong>(?P<title>[^<]+)</strong>\s*</a>"#,
|
|
)?;
|
|
Ok(regex
|
|
.captures_iter(fragment)
|
|
.filter_map(|captures| {
|
|
let raw = captures.name("title")?.as_str().trim();
|
|
let title = htmlentity::entity::decode(raw.as_bytes())
|
|
.to_string()
|
|
.unwrap_or_else(|_| raw.to_string());
|
|
let url = captures.name("url")?.as_str();
|
|
let url = if url.starts_with('/') {
|
|
format!("{BASE_URL}{url}")
|
|
} else {
|
|
url.to_string()
|
|
};
|
|
Some((title, url))
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
fn detail_link_slug(url: &str, prefix: &str) -> Option<String> {
|
|
let path = url.strip_prefix("https://sextb.net/")?;
|
|
let path = path.strip_prefix(prefix)?;
|
|
Some(path.trim_start_matches('/').trim_end_matches('/').to_string())
|
|
}
|
|
|
|
fn strong_text(fragment: &str) -> String {
|
|
Self::attr_between(fragment, "<strong>", "</strong>")
|
|
.map(|value| value.trim().to_string())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn parse_detail(html: &str) -> Result<DetailMetadata> {
|
|
let mut metadata = DetailMetadata::default();
|
|
|
|
let tag_regex = Self::regex(r"<[^>]+>")?;
|
|
|
|
// Info rows are `<div class="description"><i ..></i> Key: <strong>..</strong></div>`.
|
|
for block in html.split("<div class=\"description").skip(1) {
|
|
let block = block.split("</div>").next().unwrap_or(block);
|
|
// Drop the remainder of the opening tag (extra classes, `>`).
|
|
let Some((_, block)) = block.split_once('>') else {
|
|
continue;
|
|
};
|
|
let Some((key_part, value)) = block.split_once(':') else {
|
|
continue;
|
|
};
|
|
let key = tag_regex.replace_all(key_part, " ").trim().to_string();
|
|
let value = value.trim();
|
|
match key.as_str() {
|
|
"Director" => {
|
|
metadata.director = Self::extract_link_texts(value)?
|
|
.first()
|
|
.map(|(title, _)| title.clone());
|
|
}
|
|
"Label" => {
|
|
metadata.label = Self::extract_link_texts(value)?
|
|
.first()
|
|
.map(|(title, _)| title.clone());
|
|
}
|
|
"Studio" => {
|
|
if let Some((title, url)) = Self::extract_link_texts(value)?.into_iter().next() {
|
|
let slug =
|
|
Self::detail_link_slug(&url, "studio").unwrap_or_else(|| Self::slugify(&title));
|
|
metadata.studio = Some((title, slug));
|
|
}
|
|
}
|
|
"Cast(s)" => {
|
|
for (title, url) in Self::extract_link_texts(value)? {
|
|
let slug = Self::detail_link_slug(&url, "actress")
|
|
.unwrap_or_else(|| Self::slugify(&title));
|
|
metadata.cast.push((title, slug));
|
|
}
|
|
}
|
|
"Genre(s)" => {
|
|
for (title, url) in Self::extract_link_texts(value)? {
|
|
let slug = Self::detail_link_slug(&url, "genre")
|
|
.unwrap_or_else(|| Self::slugify(&title));
|
|
metadata.genres.push((title, slug));
|
|
}
|
|
}
|
|
"Quality" => {
|
|
let quality = Self::strong_text(value);
|
|
if !quality.is_empty() {
|
|
metadata.quality = Some(quality);
|
|
}
|
|
}
|
|
"Release Date" => {
|
|
metadata.release_date = Self::parse_absolute_date(&Self::strong_text(value));
|
|
}
|
|
"Added" => {
|
|
metadata.added_at = Self::parse_relative_or_date(&Self::strong_text(value));
|
|
}
|
|
"Viewed" => {
|
|
let text = tag_regex.replace_all(value, " ").to_string();
|
|
metadata.views = Self::parse_abbrev(text.trim());
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
if metadata.views.is_none() {
|
|
let views_regex = Self::regex(r"(?P<views>[0-9][0-9.,]*[KM]?)\s+Views")?;
|
|
metadata.views = views_regex
|
|
.captures(html)
|
|
.and_then(|captures| captures.name("views"))
|
|
.and_then(|value| Self::parse_abbrev(value.as_str()));
|
|
}
|
|
|
|
// Like/dislike counts are filled in client-side, so no rating here.
|
|
Ok(metadata)
|
|
}
|
|
|
|
fn parse_player_episodes(html: &str) -> Result<Vec<PlayerEpisode>> {
|
|
let regex = Self::regex(
|
|
r#"(?is)<(?:button|a)[^>]*data-source="(?P<film>\d+)"[^>]*data-id="(?P<episode>\d+)"[^>]*>(?P<body>.*?)</(?:button|a)>"#,
|
|
)?;
|
|
let body_text_regex = Self::regex(r#"(?is)<[^>]+>"#)?;
|
|
let mut episodes = regex
|
|
.captures_iter(html)
|
|
.filter_map(|captures| {
|
|
let body = captures.name("body")?.as_str();
|
|
let body_text = body_text_regex.replace_all(body, " ");
|
|
let body_upper = body_text.to_ascii_uppercase();
|
|
let label = ["DD", "TB", "SW", "FL", "US", "PP"]
|
|
.iter()
|
|
.find(|candidate| body_upper.contains(**candidate))
|
|
.map(|candidate| (*candidate).to_string())
|
|
.or_else(|| {
|
|
body_upper
|
|
.split_whitespace()
|
|
.find(|token| token.len() >= 2 && token.len() <= 3)
|
|
.map(|token| token.to_string())
|
|
})
|
|
.unwrap_or_default();
|
|
if label.is_empty() {
|
|
return None;
|
|
}
|
|
let film_id = captures.name("film")?.as_str().trim().to_string();
|
|
let episode_id = captures.name("episode")?.as_str().trim().to_string();
|
|
Some(PlayerEpisode {
|
|
label,
|
|
film_id,
|
|
episode_id,
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
episodes.sort_by_key(|episode| {
|
|
if episode.label == "DD" {
|
|
0
|
|
} else {
|
|
1
|
|
}
|
|
});
|
|
episodes.dedup();
|
|
|
|
Ok(episodes)
|
|
}
|
|
|
|
fn normalize_iframe_url(url: &str) -> Option<String> {
|
|
let trimmed = url.trim();
|
|
if let Some(without) = trimmed.strip_prefix("//") {
|
|
return Some(format!("https://{without}"));
|
|
}
|
|
if trimmed.starts_with("https://") || trimmed.starts_with("http://") {
|
|
return Some(trimmed.to_string());
|
|
}
|
|
None
|
|
}
|
|
|
|
fn extract_hoster_url_from_text(text: &str) -> Option<String> {
|
|
let regex = Regex::new(r#"https?://[^\s"'<>\\]+"#).ok()?;
|
|
regex.find_iter(text).find_map(|match_value| {
|
|
let candidate = match_value.as_str().trim();
|
|
proxy_name_for_url(candidate).map(|_| candidate.to_string())
|
|
})
|
|
}
|
|
|
|
fn parse_player_iframe_url(response: &str) -> Option<String> {
|
|
let iframe_regex =
|
|
Regex::new(r#"(?is)<iframe[^>]+src=['"](?P<url>(?:https?:)?//[^'"]+)['"]"#).ok()?;
|
|
let src_json_regex = Regex::new(r#"(?i)"src"\s*:\s*"(?P<url>https?:\\/\\/[^"]+)""#).ok()?;
|
|
|
|
let player_html = serde_json::from_str::<serde_json::Value>(response)
|
|
.ok()
|
|
.and_then(|value| value.get("player").and_then(|player| player.as_str()).map(str::to_string));
|
|
|
|
let from_iframe = player_html
|
|
.as_deref()
|
|
.and_then(|html| iframe_regex.captures(html))
|
|
.or_else(|| iframe_regex.captures(response))
|
|
.and_then(|captures| captures.name("url"))
|
|
.and_then(|value| Self::normalize_iframe_url(value.as_str()));
|
|
|
|
if from_iframe.is_some() {
|
|
return from_iframe;
|
|
}
|
|
|
|
if let Some(url) = src_json_regex
|
|
.captures(response)
|
|
.and_then(|captures| captures.name("url"))
|
|
.map(|value| value.as_str().replace("\\/", "/"))
|
|
{
|
|
return Self::normalize_iframe_url(&url);
|
|
}
|
|
|
|
Self::extract_hoster_url_from_text(response)
|
|
}
|
|
|
|
async fn resolve_proxy_video_url(
|
|
&self,
|
|
seed_url: &str,
|
|
options: &ServerOptions,
|
|
) -> Option<String> {
|
|
let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_proxy_video_url");
|
|
let html = requester.get(seed_url, Some(Version::HTTP_2)).await.ok()?;
|
|
if let Some(url) = Self::extract_hoster_url_from_text(&html) {
|
|
return Some(rewrite_hoster_url(options, &url));
|
|
}
|
|
let episodes = Self::parse_player_episodes(&html).ok()?;
|
|
|
|
for episode in episodes {
|
|
let ajax_url = format!(
|
|
"{}/ajax/player?episode={}&filmId={}",
|
|
self.url, episode.episode_id, episode.film_id
|
|
);
|
|
let headers = vec![
|
|
("Referer".to_string(), seed_url.to_string()),
|
|
("X-Requested-With".to_string(), "XMLHttpRequest".to_string()),
|
|
("Accept".to_string(), "application/json, text/html, */*".to_string()),
|
|
];
|
|
let Some(response) = requester
|
|
.get_with_headers(&ajax_url, headers, Some(Version::HTTP_2))
|
|
.await
|
|
.ok()
|
|
else {
|
|
continue;
|
|
};
|
|
let iframe_or_hoster = Self::parse_player_iframe_url(&response)
|
|
.or_else(|| Self::extract_hoster_url_from_text(&response));
|
|
let Some(iframe_url) = iframe_or_hoster else {
|
|
continue;
|
|
};
|
|
if proxy_name_for_url(&iframe_url).is_some() {
|
|
return Some(rewrite_hoster_url(options, &iframe_url));
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn apply_detail_metadata(&self, seed: ListItemSeed, detail: DetailMetadata) -> VideoItem {
|
|
let mut item = VideoItem::new(
|
|
seed.id,
|
|
seed.title,
|
|
seed.url,
|
|
CHANNEL_ID.to_string(),
|
|
seed.thumb.clone(),
|
|
seed.duration,
|
|
);
|
|
|
|
item.preview = Some(seed.thumb);
|
|
item.views = detail.views;
|
|
item.rating = detail.rating;
|
|
item.uploadedAt = detail.added_at.or(detail.release_date);
|
|
|
|
if !detail.cast.is_empty() {
|
|
item.uploader = Some(
|
|
detail
|
|
.cast
|
|
.iter()
|
|
.map(|(name, _)| name.clone())
|
|
.collect::<Vec<_>>()
|
|
.join(", "),
|
|
);
|
|
if let Some((_, slug)) = detail.cast.first() {
|
|
item.uploaderUrl = Some(format!("{}/actress/{}", self.url, slug));
|
|
}
|
|
} else if let Some((studio, slug)) = detail.studio.as_ref() {
|
|
item.uploader = Some(studio.clone());
|
|
item.uploaderUrl = Some(format!("{}/studio/{}", self.url, slug));
|
|
}
|
|
|
|
let mut tags = seed.tags;
|
|
if let Some(quality) = seed.quality {
|
|
tags.push(quality);
|
|
}
|
|
if let Some(code) = seed.code {
|
|
tags.push(code);
|
|
}
|
|
for alias in detail.aliases {
|
|
tags.push(alias);
|
|
}
|
|
if let Some(director) = detail.director {
|
|
tags.push(director);
|
|
}
|
|
if let Some(label) = detail.label {
|
|
tags.push(label);
|
|
}
|
|
if let Some((studio, _)) = detail.studio {
|
|
tags.push(studio);
|
|
}
|
|
for (cast, _) in detail.cast {
|
|
tags.push(cast);
|
|
}
|
|
for (genre, _) in detail.genres {
|
|
tags.push(genre);
|
|
}
|
|
if let Some(quality) = detail.quality {
|
|
tags.push(quality);
|
|
}
|
|
|
|
tags.retain(|value| !value.trim().is_empty());
|
|
tags.sort();
|
|
tags.dedup();
|
|
if !tags.is_empty() {
|
|
item.tags = Some(tags);
|
|
}
|
|
|
|
item
|
|
}
|
|
|
|
async fn enrich_seed(&self, seed: ListItemSeed, options: &ServerOptions) -> VideoItem {
|
|
let mut requester = requester_or_default(options, CHANNEL_ID, "enrich_seed");
|
|
let detail_fetch = timeout(
|
|
StdDuration::from_secs(10),
|
|
self.fetch_html(&mut requester, &seed.url),
|
|
)
|
|
.await;
|
|
|
|
let mut item = match detail_fetch {
|
|
Ok(Ok(html)) => match Self::parse_detail(&html) {
|
|
Ok(detail) => self.apply_detail_metadata(seed.clone(), detail),
|
|
Err(error) => {
|
|
report_provider_error_background(CHANNEL_ID, "parse_detail", &error.to_string());
|
|
self.apply_detail_metadata(seed, DetailMetadata::default())
|
|
}
|
|
},
|
|
Ok(Err(error)) => {
|
|
report_provider_error_background(CHANNEL_ID, "fetch_detail", &error.to_string());
|
|
self.apply_detail_metadata(seed, DetailMetadata::default())
|
|
}
|
|
Err(_) => {
|
|
report_provider_error_background(CHANNEL_ID, "fetch_detail_timeout", &seed.url);
|
|
self.apply_detail_metadata(seed, DetailMetadata::default())
|
|
}
|
|
};
|
|
|
|
match timeout(
|
|
StdDuration::from_secs(10),
|
|
self.resolve_proxy_video_url(&item.url, options),
|
|
)
|
|
.await
|
|
{
|
|
Ok(Some(proxy_url)) => item.url = proxy_url,
|
|
Ok(None) => {}
|
|
Err(_) => {
|
|
report_provider_error_background(CHANNEL_ID, "resolve_proxy_video_url_timeout", &item.url);
|
|
}
|
|
}
|
|
|
|
item
|
|
}
|
|
|
|
async fn fetch_items_for_url(
|
|
&self,
|
|
cache: VideoCache,
|
|
url: String,
|
|
per_page_limit: usize,
|
|
options: &ServerOptions,
|
|
) -> Result<Vec<VideoItem>> {
|
|
if let Some((time, items)) = cache.get(&url) {
|
|
if time.elapsed().unwrap_or_default().as_secs() < 60 * 15 {
|
|
return Ok(items.clone());
|
|
}
|
|
}
|
|
|
|
let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_items_for_url");
|
|
let html = timeout(
|
|
StdDuration::from_secs(12),
|
|
self.fetch_html(&mut requester, &url),
|
|
)
|
|
.await
|
|
.map_err(|_| Error::from(format!("list request timed out for {url}")))??;
|
|
|
|
let seeds = Self::parse_list_items(&html)?
|
|
.into_iter()
|
|
.take(per_page_limit.max(1))
|
|
.collect::<Vec<_>>();
|
|
if seeds.is_empty() {
|
|
return Ok(vec![]);
|
|
}
|
|
|
|
let items = stream::iter(seeds.into_iter().map(|seed| {
|
|
let provider = self.clone();
|
|
let options = options.clone();
|
|
async move { provider.enrich_seed(seed, &options).await }
|
|
}))
|
|
.buffer_unordered(4)
|
|
.collect::<Vec<_>>()
|
|
.await;
|
|
|
|
if !items.is_empty() {
|
|
cache.insert(url, items.clone());
|
|
}
|
|
|
|
Ok(items)
|
|
}
|
|
|
|
async fn get(
|
|
&self,
|
|
cache: VideoCache,
|
|
page: u16,
|
|
sort: &str,
|
|
per_page_limit: usize,
|
|
options: ServerOptions,
|
|
) -> Result<Vec<VideoItem>> {
|
|
let target = self.resolve_option_target(&options, sort);
|
|
let url = self.build_url_for_target(&target, page);
|
|
self.fetch_items_for_url(cache, url, per_page_limit, &options)
|
|
.await
|
|
}
|
|
|
|
async fn query(
|
|
&self,
|
|
cache: VideoCache,
|
|
page: u16,
|
|
sort: &str,
|
|
query: &str,
|
|
per_page_limit: usize,
|
|
options: ServerOptions,
|
|
) -> Result<Vec<VideoItem>> {
|
|
let target = self.resolve_query_target(query, sort);
|
|
let url = self.build_url_for_target(&target, page);
|
|
self.fetch_items_for_url(cache, url, per_page_limit, &options)
|
|
.await
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Provider for SextbProvider {
|
|
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 page = page.parse::<u16>().unwrap_or(1);
|
|
let per_page_limit = per_page.parse::<usize>().unwrap_or(30);
|
|
|
|
let result = match query {
|
|
Some(query) if !query.trim().is_empty() => {
|
|
self.query(cache, page, &sort, &query, per_page_limit, options)
|
|
.await
|
|
}
|
|
_ => self.get(cache, page, &sort, per_page_limit, options).await,
|
|
};
|
|
|
|
match result {
|
|
Ok(videos) => videos,
|
|
Err(error) => {
|
|
report_provider_error(CHANNEL_ID, "get_videos", &error.to_string()).await;
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
|
Some(self.build_channel(clientversion))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn provider() -> SextbProvider {
|
|
SextbProvider {
|
|
url: BASE_URL.to_string(),
|
|
genres: Arc::new(RwLock::new(vec![FilterOption {
|
|
id: "big-tits".to_string(),
|
|
title: "Big Tits".to_string(),
|
|
}])),
|
|
studios: Arc::new(RwLock::new(vec![FilterOption {
|
|
id: "madonna".to_string(),
|
|
title: "Madonna".to_string(),
|
|
}])),
|
|
actresses: Arc::new(RwLock::new(vec![FilterOption {
|
|
id: "yui-hatano".to_string(),
|
|
title: "Yui Hatano".to_string(),
|
|
}])),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn builds_section_page_two_url() {
|
|
let url = provider().build_url_for_target(
|
|
&Target::Section {
|
|
section: "amateur".to_string(),
|
|
sort: "viewed".to_string(),
|
|
},
|
|
2,
|
|
);
|
|
assert_eq!(
|
|
url,
|
|
"https://sextb.net/amateur?genre=all&studio=all&quality=all&year=all&sort=viewed&pg=2"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn builds_search_page_two_url() {
|
|
let url = provider().build_url_for_target(
|
|
&Target::Search {
|
|
slug: "yui-hatano".to_string(),
|
|
},
|
|
2,
|
|
);
|
|
assert_eq!(url, "https://sextb.net/search/yui-hatano/pg-2");
|
|
}
|
|
|
|
#[test]
|
|
fn matches_query_to_filter_targets() {
|
|
let provider = provider();
|
|
|
|
match provider.resolve_query_target("Big Tits", "latest") {
|
|
Target::Genre { slug } => assert_eq!(slug, "big-tits"),
|
|
other => panic!("unexpected target: {other:?}"),
|
|
}
|
|
|
|
match provider.resolve_query_target("Madonna", "latest") {
|
|
Target::Studio { slug } => assert_eq!(slug, "madonna"),
|
|
other => panic!("unexpected target: {other:?}"),
|
|
}
|
|
|
|
match provider.resolve_query_target("Yui Hatano", "latest") {
|
|
Target::Actress { slug } => assert_eq!(slug, "yui-hatano"),
|
|
other => panic!("unexpected target: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parses_detail_metadata_block() {
|
|
let html = r#"
|
|
<div class="col-7">
|
|
<div class="description"> <i class="fa fa-user"></i> Director: <a href="/director/himurokku" title="Himurokku"><strong>Himurokku</strong></a> <i class="fas fa-heart favorite-director" data-id="1"></i> </div>
|
|
<div class="description"> <i class="fa fa-tag"></i> Label: <a href="/label/madonna-x1" title="Madonna"><strong>Madonna</strong></a> </div>
|
|
<div class="description"> <i class="fa fa-camera"></i> Studio: <a href="/studio/madonna-abc123" title="MADONNA"><strong>Madonna</strong></a> </div>
|
|
<div class="description"> <i class="fa fa-users"></i> Cast(s): <a href="/actress/miyuu-imai" title="Miyuu Imai"><strong>Miyuu Imai</strong></a> <i class="fas fa-heart"></i> </div>
|
|
<div class="description"> <i class="fa fa-list"></i> Genre(s): <a href="/genre/big-tits-aa11" title="Big Tits"><strong>Big Tits</strong></a>, <a href="/genre/creampie-bb22" title="Creampie"><strong>Creampie</strong></a> </div>
|
|
<div class="description"><i class="fa fa-film"></i> Quality: <strong>HD</strong></div>
|
|
<div class="description"><i class="fa fa-calendar"></i> Release Date: <strong>Mar. 24, 2026</strong></div>
|
|
<div class="description"><i class="fa fa-calendar"></i> Added: <strong>2 days ago</strong></div>
|
|
<div class="description visible-sm"><i class="fa fa-eye"></i> Viewed: 45.2K</div>
|
|
</div>"#;
|
|
|
|
let detail = SextbProvider::parse_detail(html).expect("detail should parse");
|
|
assert_eq!(detail.director.as_deref(), Some("Himurokku"));
|
|
assert_eq!(detail.label.as_deref(), Some("Madonna"));
|
|
assert_eq!(
|
|
detail.studio,
|
|
Some(("Madonna".to_string(), "madonna-abc123".to_string()))
|
|
);
|
|
assert_eq!(detail.cast, vec![("Miyuu Imai".to_string(), "miyuu-imai".to_string())]);
|
|
assert_eq!(detail.genres.len(), 2);
|
|
assert_eq!(detail.genres[0].1, "big-tits-aa11");
|
|
assert_eq!(detail.views, Some(45200));
|
|
assert_eq!(detail.quality.as_deref(), Some("HD"));
|
|
assert!(detail.release_date.is_some());
|
|
assert!(detail.added_at.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn parses_listing_cards() {
|
|
let html = r#"<div class="tray-content"><div class="tray-item"> <a href="/beb-036"> <img class="tray-item-thumbnail lazy" src="/images/default-cover.jpg" data-src="https://cdn001.imggle.net/webp/poster/a.webp" alt="Alt"> <div class="tray-item-description"> <div class="tray-item-title">Hara & Museum</div> <div class="tray-item-meta-info"> <div class="tray-item-quality"><span class="hd">HD</span></div> <div class="tray-item-runtime">120 min</div> <div class="tray-item-code">BEB-036</div> </div> </div> </a> <div class="tray-item-trailer" data-url="x" data-link="/beb-036" data-poster="https://cdn001.imggle.net/thumb/a.jpg"></div> </div>
|
|
<div class="tray-item tray-item-uncensored"> <a href="/akdl-234"> <img data-src="https://cdn001.imggle.net/webp/poster/b.webp" alt="B"> <div class="tray-item-title">Second</div><div class="tray-item-runtime">150 min</div><div class="tray-item-code">AKDL-234</div> </a> </div></div>"#;
|
|
let items = SextbProvider::parse_list_items(html).expect("listing should parse");
|
|
assert_eq!(items.len(), 2);
|
|
assert_eq!(items[0].id, "beb-036");
|
|
assert_eq!(items[0].url, "https://sextb.net/beb-036");
|
|
assert_eq!(items[0].title, "Hara & Museum");
|
|
assert_eq!(items[0].duration, 7200);
|
|
assert_eq!(items[0].quality.as_deref(), Some("HD"));
|
|
assert_eq!(items[0].code.as_deref(), Some("BEB-036"));
|
|
assert_eq!(items[0].thumb, "https://cdn001.imggle.net/webp/poster/a.webp");
|
|
assert_eq!(items[1].duration, 9000);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_search_result_ignores_recommendation_tray() {
|
|
let html = r#"<div class="tray-content"><h3 class="notice msg-result">Sorry, No Video were found that matched your search query.</h3>
|
|
<div class="tray-item"> <a href="/abc-001"> <img data-src="https://x/a.webp"> <div class="tray-item-title">Rec</div></a></div></div>"#;
|
|
assert!(SextbProvider::parse_list_items(html).unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn parses_filter_option_lists() {
|
|
let genres = r#"<li><i class="fas fa-folder" style="x"></i> <a href="/genre/30-years-old-7c1gtr1u" title="Genre 30 Years Old">30 Years Old</a> <span>(1138)</span></li>"#;
|
|
let g = SextbProvider::parse_genre_options(genres).unwrap();
|
|
assert_eq!(g[0].id, "30-years-old-7c1gtr1u");
|
|
assert_eq!(g[0].title, "30 Years Old");
|
|
|
|
let studios = r##"<div class="tray-item tray-item-list"> <a href="/studio/only-amateurs-29p3k0ax" title="Label"><i class="fas fa-folder-open"></i> #only amateurs</a> <span class="total">13</span> </div>"##;
|
|
let s = SextbProvider::parse_studio_options(studios).unwrap();
|
|
assert_eq!(s[0].id, "only-amateurs-29p3k0ax");
|
|
assert_eq!(s[0].title, "only amateurs");
|
|
|
|
let actresses = r#"<div class="tray-item-actress"> <a href="/actress/th1bdtq81"> <img alt="Yumi"> <div class="tray-item-actress-description"> <div class="tray-item-actress-title">Yumi Kazama</div> </div></a></div>"#;
|
|
let a = SextbProvider::parse_actress_options(actresses).unwrap();
|
|
assert_eq!(a[0].id, "th1bdtq81");
|
|
assert_eq!(a[0].title, "Yumi Kazama");
|
|
|
|
assert_eq!(
|
|
SextbProvider::pagination_last_page("<a href='/list-actress/pg-2'>2</a><a href='/list-actress/pg-73'>73</a>"),
|
|
Some(73)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_dd_player_buttons_from_detail_html() {
|
|
let html = r#"
|
|
<div class="episode-list">
|
|
<button class="btn-player episode active" data-source="16900830" data-id="3708319"><i class="fa fa-play-circle"></i> TB</button>
|
|
<button class="btn-player episode" data-source="16900830" data-id="3708330"><i class="fa fa-play-circle"></i> SW</button>
|
|
<button class="btn-player episode" data-source="16900830" data-id="3708339"><i class="fa fa-play-circle"></i> DD</button>
|
|
</div>
|
|
"#;
|
|
|
|
let episodes = SextbProvider::parse_player_episodes(html).expect("episodes should parse");
|
|
assert_eq!(
|
|
episodes.first(),
|
|
Some(&PlayerEpisode {
|
|
label: "DD".to_string(),
|
|
film_id: "16900830".to_string(),
|
|
episode_id: "3708339".to_string(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_dd_player_anchor_layout() {
|
|
let html = r#"
|
|
<div class="episode-list">
|
|
<a class="episode" data-source="200" data-id="301"><span>SW</span></a>
|
|
<a class="episode active" data-source="200" data-id="302"><span>DD</span></a>
|
|
</div>
|
|
"#;
|
|
let episodes = SextbProvider::parse_player_episodes(html).expect("episodes should parse");
|
|
assert_eq!(
|
|
episodes.first(),
|
|
Some(&PlayerEpisode {
|
|
label: "DD".to_string(),
|
|
film_id: "200".to_string(),
|
|
episode_id: "302".to_string(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_iframe_url_from_ajax_player_response() {
|
|
let response = r#"{"player":"<iframe src=\"https://trailerhg.xyz/e/ttdc7a6qpskt\" width=\"100%\" height=\"100%\" frameborder=\"0\" allowfullscreen=\"true\"></iframe>"}"#;
|
|
assert_eq!(
|
|
SextbProvider::parse_player_iframe_url(response).as_deref(),
|
|
Some("https://trailerhg.xyz/e/ttdc7a6qpskt")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_protocol_relative_iframe_url_from_ajax_player_response() {
|
|
let response = r#"{"player":"<iframe src=\"//turboplayers.xyz/t/69bdfb21cc640\"></iframe>"}"#;
|
|
assert_eq!(
|
|
SextbProvider::parse_player_iframe_url(response).as_deref(),
|
|
Some("https://turboplayers.xyz/t/69bdfb21cc640")
|
|
);
|
|
}
|
|
}
|