eporner
This commit is contained in:
5
build.rs
5
build.rs
@@ -326,6 +326,11 @@ const PROVIDERS: &[ProviderDef] = &[
|
|||||||
module: "thepornbunny",
|
module: "thepornbunny",
|
||||||
ty: "ThepornbunnyProvider",
|
ty: "ThepornbunnyProvider",
|
||||||
},
|
},
|
||||||
|
ProviderDef {
|
||||||
|
id: "eporner",
|
||||||
|
module: "eporner",
|
||||||
|
ty: "EpornerProvider",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us
|
|||||||
| `jable` | `jav` | no | yes | HTML JAV archive scraper; extracts `var hlsUrl` from detail pages; m3u8 format requires Referer + browser User-Agent; proxy route handles HEAD (200 OK) and GET (redirect to watch page) since yt-dlp blocks jable.tv; tag/category/model shortcut queries. |
|
| `jable` | `jav` | no | yes | HTML JAV archive scraper; extracts `var hlsUrl` from detail pages; m3u8 format requires Referer + browser User-Agent; proxy route handles HEAD (200 OK) and GET (redirect to watch page) since yt-dlp blocks jable.tv; tag/category/model shortcut queries. |
|
||||||
| `fullporner` | `mainstream-tube` | no | no | HTML scraper for fullporner.com; thumbnail IDs derived from `/thumb/{id}.jpg` URLs and used to build direct `xiaoshenke.net/vid/{id}/720` media redirect URLs (Referer + User-Agent headers required); supports cat:/category:/pornstar:/star: shortcut queries; no proxy needed. |
|
| `fullporner` | `mainstream-tube` | no | no | HTML scraper for fullporner.com; thumbnail IDs derived from `/thumb/{id}.jpg` URLs and used to build direct `xiaoshenke.net/vid/{id}/720` media redirect URLs (Referer + User-Agent headers required); supports cat:/category:/pornstar:/star: shortcut queries; no proxy needed. |
|
||||||
| `thepornbunny` | `mainstream-tube` | no | yes | KVS-style HTML scraper for thepornbunny.com; 24 items per site page; thumbnails at `https://www.thepornbunny.com/images/thumb/{id}.webp` from `data-original` attribute (no proxy needed); studio exposed as uploader; pornstar names in tags; `/proxy/thepornbunny/{slug}` fetches the video page, extracts `generate_mp4(enc_data, key, rnd, video_id)` args, decrypts `enc_data` via PBKDF2-HMAC-SHA512+AES-256-CBC to get an OK.ru session key, calls `api.ok.ru/fb.do?method=video.get&session_key=KEY&vids=RND` to get signed CDN URLs, and returns 302 to the best-quality okcdn.ru/vkuser.net MP4 URL (no special client headers needed); supports sort: new/popular/rated, 20 hardcoded categories via `categories` option, and tag:/category:/studio:/pornstar: query shortcuts. |
|
| `thepornbunny` | `mainstream-tube` | no | yes | KVS-style HTML scraper for thepornbunny.com; 24 items per site page; thumbnails at `https://www.thepornbunny.com/images/thumb/{id}.webp` from `data-original` attribute (no proxy needed); studio exposed as uploader; pornstar names in tags; `/proxy/thepornbunny/{slug}` fetches the video page, extracts `generate_mp4(enc_data, key, rnd, video_id)` args, decrypts `enc_data` via PBKDF2-HMAC-SHA512+AES-256-CBC to get an OK.ru session key, calls `api.ok.ru/fb.do?method=video.get&session_key=KEY&vids=RND` to get signed CDN URLs, and returns 302 to the best-quality okcdn.ru/vkuser.net MP4 URL (no special client headers needed); supports sort: new/popular/rated, 20 hardcoded categories via `categories` option, and tag:/category:/studio:/pornstar: query shortcuts. |
|
||||||
|
| `eporner` | `mainstream-tube` | no | no | HTML scraper for eporner.com (5M+ videos); card selector `div.mb[data-id]` with inline duration/rating/views/uploader; thumbnails at `static-eu-cdn.eporner.com` (no proxy needed); pagination uses `/{N}/` suffix (page 1 = no suffix, page 2 = `/2/`); search queries map to `/tag/{slug}/` (eporner redirects all keyword searches to tag pages — 404 tag pages still return related content); supports sort: new/popular/rated/best; 65 hardcoded categories via `cat:`, `tag:`, `pornstar:`, `uploader:` query shortcuts; background-loads pornstar name→URL map from `/pornstar-list/`; yt-dlp resolves `video.url` natively (Eporner extractor); no proxy needed. |
|
||||||
|
|
||||||
## Proxy Routes
|
## Proxy Routes
|
||||||
|
|
||||||
|
|||||||
643
src/providers/eporner.rs
Normal file
643
src/providers/eporner.rs
Normal file
@@ -0,0 +1,643 @@
|
|||||||
|
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::parse_abbreviated_number;
|
||||||
|
use crate::util::requester::Requester;
|
||||||
|
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 std::sync::{Arc, RwLock};
|
||||||
|
use std::{collections::HashMap, thread, vec};
|
||||||
|
use wreq::Version;
|
||||||
|
|
||||||
|
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||||
|
crate::providers::ProviderChannelMetadata {
|
||||||
|
group_id: "mainstream-tube",
|
||||||
|
tags: &["tube", "hd", "mixed", "search"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE_URL: &str = "https://www.eporner.com";
|
||||||
|
const CHANNEL_ID: &str = "eporner";
|
||||||
|
const FIREFOX_UA: &str =
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64; rv:147.0) Gecko/20100101 Firefox/147.0";
|
||||||
|
const HTML_ACCEPT: &str =
|
||||||
|
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";
|
||||||
|
|
||||||
|
error_chain! {
|
||||||
|
foreign_links {
|
||||||
|
Io(std::io::Error);
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
Parse(msg: String) {
|
||||||
|
description("parse error")
|
||||||
|
display("parse error: {}", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static category list — eporner categories are stable
|
||||||
|
const CATEGORIES: &[(&str, &str)] = &[
|
||||||
|
("4k-porn", "4K Ultra HD"),
|
||||||
|
("60fps", "60 FPS"),
|
||||||
|
("amateur", "Amateur"),
|
||||||
|
("anal", "Anal"),
|
||||||
|
("asian", "Asian"),
|
||||||
|
("asmr", "ASMR"),
|
||||||
|
("bbw", "BBW"),
|
||||||
|
("bdsm", "BDSM"),
|
||||||
|
("big-ass", "Big Ass"),
|
||||||
|
("big-dick", "Big Dick"),
|
||||||
|
("big-tits", "Big Tits"),
|
||||||
|
("bisexual", "Bisexual"),
|
||||||
|
("blonde", "Blonde"),
|
||||||
|
("blowjob", "Blowjob"),
|
||||||
|
("bondage", "Bondage"),
|
||||||
|
("brunette", "Brunette"),
|
||||||
|
("bukkake", "Bukkake"),
|
||||||
|
("creampie", "Creampie"),
|
||||||
|
("cumshot", "Cumshot"),
|
||||||
|
("double-penetration", "Double Penetration"),
|
||||||
|
("ebony", "Ebony"),
|
||||||
|
("fat", "Fat"),
|
||||||
|
("fetish", "Fetish"),
|
||||||
|
("fisting", "Fisting"),
|
||||||
|
("footjob", "Footjob"),
|
||||||
|
("for-women", "For Women"),
|
||||||
|
("gay", "Gay"),
|
||||||
|
("group-sex", "Group Sex"),
|
||||||
|
("handjob", "Handjob"),
|
||||||
|
("hardcore", "Hardcore"),
|
||||||
|
("hd-1080p", "HD 1080p"),
|
||||||
|
("hentai", "Hentai"),
|
||||||
|
("homemade", "Homemade"),
|
||||||
|
("hotel", "Hotel"),
|
||||||
|
("indian", "Indian"),
|
||||||
|
("interracial", "Interracial"),
|
||||||
|
("japanese", "Japanese"),
|
||||||
|
("latina", "Latina"),
|
||||||
|
("lesbians", "Lesbian"),
|
||||||
|
("lingerie", "Lingerie"),
|
||||||
|
("massage", "Massage"),
|
||||||
|
("masturbation", "Masturbation"),
|
||||||
|
("mature", "Mature"),
|
||||||
|
("milf", "MILF"),
|
||||||
|
("nurse", "Nurse"),
|
||||||
|
("office", "Office"),
|
||||||
|
("orgy", "Orgy"),
|
||||||
|
("outdoor", "Outdoor"),
|
||||||
|
("petite", "Petite"),
|
||||||
|
("pornstar", "Pornstar"),
|
||||||
|
("pov-porn", "POV"),
|
||||||
|
("public", "Public"),
|
||||||
|
("redhead", "Redhead"),
|
||||||
|
("shemale", "Shemale"),
|
||||||
|
("small-tits", "Small Tits"),
|
||||||
|
("squirt", "Squirt"),
|
||||||
|
("striptease", "Striptease"),
|
||||||
|
("teens", "Teen"),
|
||||||
|
("threesome", "Threesome"),
|
||||||
|
("toys", "Toys"),
|
||||||
|
("uncategorized", "Uncategorized"),
|
||||||
|
("uniform", "Uniform"),
|
||||||
|
("vintage", "Vintage"),
|
||||||
|
("vr-porn", "VR Porn"),
|
||||||
|
("webcam", "Webcam"),
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum Target {
|
||||||
|
Latest,
|
||||||
|
MostViewed,
|
||||||
|
TopRated,
|
||||||
|
BestVideos,
|
||||||
|
Search(String),
|
||||||
|
Archive(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EpornerProvider {
|
||||||
|
pornstar_map: Arc<RwLock<HashMap<String, String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EpornerProvider {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let provider = Self {
|
||||||
|
pornstar_map: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
};
|
||||||
|
provider.spawn_initial_load();
|
||||||
|
provider
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_initial_load(&self) {
|
||||||
|
let pornstar_map = Arc::clone(&self.pornstar_map);
|
||||||
|
thread::spawn(move || {
|
||||||
|
let runtime = match tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
report_provider_error_background(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"spawn_initial_load.runtime_build",
|
||||||
|
&e.to_string(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
runtime.block_on(async move {
|
||||||
|
if let Err(e) = Self::load_pornstars(Arc::clone(&pornstar_map)).await {
|
||||||
|
report_provider_error_background(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"load_pornstars",
|
||||||
|
&e.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_channel(&self, _cv: ClientVersion) -> Channel {
|
||||||
|
let mut cat_options: Vec<FilterOption> = vec![FilterOption {
|
||||||
|
id: "all".to_string(),
|
||||||
|
title: "All".to_string(),
|
||||||
|
}];
|
||||||
|
for (slug, label) in CATEGORIES {
|
||||||
|
cat_options.push(FilterOption {
|
||||||
|
id: slug.to_string(),
|
||||||
|
title: label.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Channel {
|
||||||
|
id: CHANNEL_ID.to_string(),
|
||||||
|
name: "EPorner".to_string(),
|
||||||
|
description:
|
||||||
|
"EPorner — 5M+ free HD porn videos with latest, most viewed, top rated, category, tag, and pornstar routing."
|
||||||
|
.to_string(),
|
||||||
|
premium: false,
|
||||||
|
favicon: "https://www.google.com/s2/favicons?sz=64&domain=eporner.com".to_string(),
|
||||||
|
status: "active".to_string(),
|
||||||
|
categories: CATEGORIES.iter().map(|(_, label)| label.to_string()).collect(),
|
||||||
|
options: vec![
|
||||||
|
ChannelOption {
|
||||||
|
id: "sort".to_string(),
|
||||||
|
title: "Sort".to_string(),
|
||||||
|
description: "Browse EPorner ranking feeds.".to_string(),
|
||||||
|
systemImage: "list.number".to_string(),
|
||||||
|
colorName: "blue".to_string(),
|
||||||
|
options: vec![
|
||||||
|
FilterOption {
|
||||||
|
id: "new".to_string(),
|
||||||
|
title: "Latest".to_string(),
|
||||||
|
},
|
||||||
|
FilterOption {
|
||||||
|
id: "popular".to_string(),
|
||||||
|
title: "Most Viewed".to_string(),
|
||||||
|
},
|
||||||
|
FilterOption {
|
||||||
|
id: "rated".to_string(),
|
||||||
|
title: "Top Rated".to_string(),
|
||||||
|
},
|
||||||
|
FilterOption {
|
||||||
|
id: "best".to_string(),
|
||||||
|
title: "Best Videos".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
multiSelect: false,
|
||||||
|
},
|
||||||
|
ChannelOption {
|
||||||
|
id: "categories".to_string(),
|
||||||
|
title: "Categories".to_string(),
|
||||||
|
description: "Browse an EPorner category archive.".to_string(),
|
||||||
|
systemImage: "square.grid.2x2".to_string(),
|
||||||
|
colorName: "orange".to_string(),
|
||||||
|
options: cat_options,
|
||||||
|
multiSelect: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nsfw: true,
|
||||||
|
cacheDuration: Some(1800),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selector(value: &str) -> Result<Selector> {
|
||||||
|
Selector::parse(value)
|
||||||
|
.map_err(|e| Error::from(format!("selector `{value}` parse failed: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_html(text: &str) -> String {
|
||||||
|
decode(text.as_bytes())
|
||||||
|
.to_string()
|
||||||
|
.unwrap_or_else(|_| text.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collapse_ws(text: &str) -> String {
|
||||||
|
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_of(el: &ElementRef<'_>) -> String {
|
||||||
|
Self::decode_html(&Self::collapse_ws(&el.text().collect::<Vec<_>>().join(" ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_key(s: &str) -> String {
|
||||||
|
s.trim()
|
||||||
|
.trim_start_matches('#')
|
||||||
|
.replace(['_', '-'], " ")
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_url(path: &str) -> String {
|
||||||
|
let path = path.trim();
|
||||||
|
if path.starts_with("http://") || path.starts_with("https://") {
|
||||||
|
return path.to_string();
|
||||||
|
}
|
||||||
|
if path.starts_with("//") {
|
||||||
|
return format!("https:{path}");
|
||||||
|
}
|
||||||
|
if path.starts_with('/') {
|
||||||
|
return format!("{BASE_URL}{path}");
|
||||||
|
}
|
||||||
|
format!("{BASE_URL}/{path}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn html_headers(referer: &str) -> Vec<(String, String)> {
|
||||||
|
vec![
|
||||||
|
("User-Agent".to_string(), FIREFOX_UA.to_string()),
|
||||||
|
("Accept".to_string(), HTML_ACCEPT.to_string()),
|
||||||
|
("Referer".to_string(), referer.to_string()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a page URL: page 1 → `{base}/`, page N → `{base}/{N}/`
|
||||||
|
fn page_url(base: &str, page: u16) -> String {
|
||||||
|
let base = base.trim_end_matches('/');
|
||||||
|
if page <= 1 {
|
||||||
|
format!("{base}/")
|
||||||
|
} else {
|
||||||
|
format!("{base}/{page}/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn target_url(target: &Target, page: u16) -> String {
|
||||||
|
match target {
|
||||||
|
Target::Latest => Self::page_url(BASE_URL, page),
|
||||||
|
Target::MostViewed => Self::page_url(&format!("{BASE_URL}/most-viewed"), page),
|
||||||
|
Target::TopRated => Self::page_url(&format!("{BASE_URL}/top-rated"), page),
|
||||||
|
Target::BestVideos => Self::page_url(&format!("{BASE_URL}/best-videos"), page),
|
||||||
|
Target::Search(q) => {
|
||||||
|
let slug = q.trim().replace(' ', "-").to_ascii_lowercase();
|
||||||
|
Self::page_url(&format!("{BASE_URL}/tag/{slug}"), page)
|
||||||
|
}
|
||||||
|
Target::Archive(url) => Self::page_url(url, page),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_html(requester: &mut Requester, url: &str) -> Result<String> {
|
||||||
|
requester
|
||||||
|
.get_with_headers(url, Self::html_headers(url), Some(Version::HTTP_11))
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::from(format!("request failed for {url}: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_duration(text: &str) -> u32 {
|
||||||
|
parse_time_to_seconds(text)
|
||||||
|
.and_then(|v| u32::try_from(v).ok())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_views(text: &str) -> Option<u32> {
|
||||||
|
let cleaned = text
|
||||||
|
.replace("views", "")
|
||||||
|
.replace("view", "")
|
||||||
|
.replace([',', ' '], "");
|
||||||
|
parse_abbreviated_number(cleaned.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_rating_pct(text: &str) -> Option<f32> {
|
||||||
|
let digits: String = text.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||||
|
digits.parse::<f32>().ok().map(|v| v / 100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_list_page(html: &str) -> Result<Vec<VideoItem>> {
|
||||||
|
let document = Html::parse_document(html);
|
||||||
|
let card_sel = Self::selector("div.mb[data-id]")?;
|
||||||
|
let img_sel = Self::selector("div.mbimg a img[src]")?;
|
||||||
|
let link_sel = Self::selector("p.mbtit a[href], div.mbtit a[href]")?;
|
||||||
|
let dur_sel = Self::selector("span.mbtim")?;
|
||||||
|
let rate_sel = Self::selector("span.mbrate")?;
|
||||||
|
let views_sel = Self::selector("span.mbvie")?;
|
||||||
|
let uploader_sel = Self::selector("span.mb-uploader a[href]")?;
|
||||||
|
|
||||||
|
let mut items = Vec::new();
|
||||||
|
|
||||||
|
for card in document.select(&card_sel) {
|
||||||
|
let id = match card.value().attr("data-id") {
|
||||||
|
Some(v) if !v.is_empty() => v.to_string(),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let link = match card.select(&link_sel).next() {
|
||||||
|
Some(el) => el,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let href = link.value().attr("href").unwrap_or_default();
|
||||||
|
let page_url = Self::normalize_url(href);
|
||||||
|
if page_url.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = link
|
||||||
|
.value()
|
||||||
|
.attr("title")
|
||||||
|
.map(Self::decode_html)
|
||||||
|
.filter(|v| !v.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| Self::text_of(&link));
|
||||||
|
if title.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let thumb = card
|
||||||
|
.select(&img_sel)
|
||||||
|
.next()
|
||||||
|
.and_then(|el| el.value().attr("src").or_else(|| el.value().attr("data-src")))
|
||||||
|
.map(Self::normalize_url)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let duration = card
|
||||||
|
.select(&dur_sel)
|
||||||
|
.next()
|
||||||
|
.map(|el| Self::parse_duration(&Self::text_of(&el)))
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let rating = card
|
||||||
|
.select(&rate_sel)
|
||||||
|
.next()
|
||||||
|
.and_then(|el| Self::parse_rating_pct(&Self::text_of(&el)));
|
||||||
|
|
||||||
|
let views = card
|
||||||
|
.select(&views_sel)
|
||||||
|
.next()
|
||||||
|
.and_then(|el| Self::parse_views(&Self::text_of(&el)));
|
||||||
|
|
||||||
|
let uploader_el = card.select(&uploader_sel).next();
|
||||||
|
let uploader_name = uploader_el.as_ref().map(|el| Self::text_of(el));
|
||||||
|
let uploader_url = uploader_el
|
||||||
|
.and_then(|el| el.value().attr("href").map(Self::normalize_url));
|
||||||
|
|
||||||
|
let mut item = VideoItem::new(
|
||||||
|
id,
|
||||||
|
title.trim().to_string(),
|
||||||
|
page_url,
|
||||||
|
CHANNEL_ID.to_string(),
|
||||||
|
thumb,
|
||||||
|
duration,
|
||||||
|
);
|
||||||
|
if let Some(r) = rating {
|
||||||
|
item.rating = Some(r);
|
||||||
|
}
|
||||||
|
if let Some(v) = views {
|
||||||
|
item.views = Some(v);
|
||||||
|
}
|
||||||
|
if let Some(name) = uploader_name.filter(|n| !n.is_empty()) {
|
||||||
|
item.uploader = Some(name);
|
||||||
|
}
|
||||||
|
if let Some(url) = uploader_url.filter(|u| !u.is_empty()) {
|
||||||
|
let uploader_id = url
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
if !uploader_id.is_empty() {
|
||||||
|
item.uploaderId = Some(format!("{CHANNEL_ID}:{uploader_id}"));
|
||||||
|
}
|
||||||
|
item.uploaderUrl = Some(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_pornstars(pornstar_map: Arc<RwLock<HashMap<String, String>>>) -> Result<()> {
|
||||||
|
let mut requester = Requester::new();
|
||||||
|
let url = format!("{BASE_URL}/pornstar-list/");
|
||||||
|
let html = Self::fetch_html(&mut requester, &url).await?;
|
||||||
|
let document = Html::parse_document(&html);
|
||||||
|
let sel = Self::selector("a[href*=\"/pornstar/\"]")?;
|
||||||
|
let prefix = format!("{BASE_URL}/pornstar/");
|
||||||
|
|
||||||
|
for el in document.select(&sel) {
|
||||||
|
let href = el.value().attr("href").unwrap_or_default();
|
||||||
|
let full = Self::normalize_url(href);
|
||||||
|
if !full.starts_with(&prefix) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let slug = full
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
if slug.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = el
|
||||||
|
.value()
|
||||||
|
.attr("title")
|
||||||
|
.map(Self::decode_html)
|
||||||
|
.filter(|v| !v.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| Self::text_of(&el));
|
||||||
|
if name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let canonical = format!("{BASE_URL}/pornstar/{slug}");
|
||||||
|
if let Ok(mut map) = pornstar_map.write() {
|
||||||
|
map.insert(Self::normalize_key(&name), canonical.clone());
|
||||||
|
map.insert(Self::normalize_key(&slug), canonical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup_category(query: &str) -> Option<String> {
|
||||||
|
let normalized = Self::normalize_key(query);
|
||||||
|
for (slug, label) in CATEGORIES {
|
||||||
|
if Self::normalize_key(label) == normalized || Self::normalize_key(slug) == normalized {
|
||||||
|
return Some(format!("{BASE_URL}/cat/{slug}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_query_target(&self, query: &str) -> Target {
|
||||||
|
let trimmed = query.trim().trim_start_matches('@');
|
||||||
|
|
||||||
|
if let Some((kind, value)) = trimmed.split_once(':') {
|
||||||
|
let value = value.trim().replace(' ', "-").to_ascii_lowercase();
|
||||||
|
if !value.is_empty() {
|
||||||
|
match kind.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"cat" | "category" => {
|
||||||
|
return Target::Archive(format!("{BASE_URL}/cat/{value}"));
|
||||||
|
}
|
||||||
|
"tag" => {
|
||||||
|
return Target::Archive(format!("{BASE_URL}/tag/{value}"));
|
||||||
|
}
|
||||||
|
"pornstar" | "star" => {
|
||||||
|
return Target::Archive(format!("{BASE_URL}/pornstar/{value}"));
|
||||||
|
}
|
||||||
|
"uploader" | "profile" => {
|
||||||
|
return Target::Archive(format!("{BASE_URL}/profile/{value}"));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check category name
|
||||||
|
if let Some(url) = Self::lookup_category(trimmed) {
|
||||||
|
return Target::Archive(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check pornstar map
|
||||||
|
let normalized = Self::normalize_key(trimmed);
|
||||||
|
if let Some(url) = self
|
||||||
|
.pornstar_map
|
||||||
|
.read()
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.get(&normalized).cloned())
|
||||||
|
{
|
||||||
|
return Target::Archive(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
Target::Search(trimmed.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_sort_target(sort: &str) -> Target {
|
||||||
|
match sort.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"popular" | "viewed" | "most_viewed" => Target::MostViewed,
|
||||||
|
"rated" | "rating" | "top" => Target::TopRated,
|
||||||
|
"best" => Target::BestVideos,
|
||||||
|
_ => Target::Latest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_option_target(&self, options: &ServerOptions, sort: &str) -> Target {
|
||||||
|
if let Some(cat) = options.categories.as_deref() {
|
||||||
|
if cat != "all" && !cat.is_empty() {
|
||||||
|
let url = if cat.starts_with("http") {
|
||||||
|
cat.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{BASE_URL}/cat/{cat}")
|
||||||
|
};
|
||||||
|
return Target::Archive(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::resolve_sort_target(sort)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_target(
|
||||||
|
&self,
|
||||||
|
cache: VideoCache,
|
||||||
|
target: Target,
|
||||||
|
page: u16,
|
||||||
|
per_page: usize,
|
||||||
|
options: ServerOptions,
|
||||||
|
) -> Result<Vec<VideoItem>> {
|
||||||
|
let url = Self::target_url(&target, page);
|
||||||
|
let cache_key = format!("{url}#per={per_page}");
|
||||||
|
|
||||||
|
if let Some((ts, cached)) = cache.get(&cache_key) {
|
||||||
|
if ts.elapsed().unwrap_or_default().as_secs() < 300 {
|
||||||
|
return Ok(cached.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut requester =
|
||||||
|
requester_or_default(&options, CHANNEL_ID, "eporner.fetch_target.missing_requester");
|
||||||
|
let html = match Self::fetch_html(&mut requester, &url).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
report_provider_error(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"fetch_target.request",
|
||||||
|
&format!("url={url}; error={e}"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if html.trim().is_empty() {
|
||||||
|
report_provider_error(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"fetch_target.empty",
|
||||||
|
&format!("url={url}"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let items = self.parse_list_page_limited(&html, per_page)?;
|
||||||
|
if !items.is_empty() {
|
||||||
|
cache.insert(cache_key, items.clone());
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_list_page_limited(&self, html: &str, limit: usize) -> Result<Vec<VideoItem>> {
|
||||||
|
let all = Self::parse_list_page(html)?;
|
||||||
|
Ok(all.into_iter().take(limit.max(1)).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Provider for EpornerProvider {
|
||||||
|
async fn get_videos(
|
||||||
|
&self,
|
||||||
|
cache: VideoCache,
|
||||||
|
_pool: DbPool,
|
||||||
|
sort: String,
|
||||||
|
query: Option<String>,
|
||||||
|
page: String,
|
||||||
|
per_page: String,
|
||||||
|
options: ServerOptions,
|
||||||
|
) -> Vec<VideoItem> {
|
||||||
|
let page = page.parse::<u16>().unwrap_or(1).max(1);
|
||||||
|
let per_page = per_page.parse::<usize>().unwrap_or(10).clamp(1, 60);
|
||||||
|
|
||||||
|
let target = match query {
|
||||||
|
Some(q) if !q.trim().is_empty() => self.resolve_query_target(q.trim()),
|
||||||
|
_ => self.resolve_option_target(&options, &sort),
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.fetch_target(cache, target, page, per_page, options).await {
|
||||||
|
Ok(items) => items,
|
||||||
|
Err(e) => {
|
||||||
|
report_provider_error(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"get_videos",
|
||||||
|
&format!("sort={sort}; page={page}; error={e}"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_channel(&self, cv: ClientVersion) -> Option<Channel> {
|
||||||
|
Some(self.build_channel(cv))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user