This commit is contained in:
Simon
2026-05-21 13:02:26 +00:00
committed by ForgeCode
parent 07154d50de
commit 8ca1df8f5a
3 changed files with 649 additions and 0 deletions

View File

@@ -326,6 +326,11 @@ const PROVIDERS: &[ProviderDef] = &[
module: "thepornbunny",
ty: "ThepornbunnyProvider",
},
ProviderDef {
id: "eporner",
module: "eporner",
ty: "EpornerProvider",
},
];
fn main() {

View File

@@ -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. |
| `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. |
| `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

643
src/providers/eporner.rs Normal file
View 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))
}
}