Resolve melonstube's top-20 destination hosts into direct formats
melonstube is a meta-search aggregator that redirects out to ~120 third-party hosts. A live crawl of /new, /popular, and /rating (~3,350 out-links) found the previous "assume yt-dlp can resolve it" fallback was hiding real failures on the highest-traffic hosts: - eporner.com is ~34% of all melonstube traffic by itself, and like the eporner channel (fixed earlier), serves a restricted preview clip without its hash/xhr dance. - fapcat.com/momvids.com 403 a plain fetch (Cloudflare). - Several others just needed real extraction instead of a guess. Per-card enrichment now dispatches by destination host to: - Eporner: reuses eporner's own hash/xhr + Referer resolution, moved to src/util/eporner_resolve.rs so both providers' single-provider builds still compile (HOT_TUB_PROVIDER=<id> only compiles the one selected provider module; src/util/* always compiles). - Kvs (pornfreenow, analdin, babepump, w1mp, fetishshrine, pervclips, xozilla, ok.xxx, ebonypeek, xgroovy): a shared get_file extractor, the same KVS backend several other Hot Tub providers already scrape. - KvsCfGated (fapcat, momvids): same extractor via the Safari18 bypass client melonstube.com itself already needs. - XhamsterMirror (xh.partners): a genuine xHamster mirror instance, Referer built from the actual resolved host. - SomePorn: HLS playlist URL built from the page's video id directly. - Pornone: direct <source> tag extraction. flirt4free.com (a live-cam page, not a VOD) moved into the dropped- host bucket alongside the existing junk/join.* hosts. Enrichment is bounded (concurrency, a 10s per-card timeout, and capped to the requested perPage rather than the full ~240-card page) so a slow or rate-limited destination can't stall the response. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -8,14 +8,13 @@ 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, VideoFormat, VideoItem};
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use futures::stream::{self, StreamExt};
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use regex::Regex;
|
||||
use scraper::{ElementRef, Html, Selector};
|
||||
use std::sync::{Arc, OnceLock, RwLock};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::{collections::HashMap, thread, vec};
|
||||
use wreq::Version;
|
||||
|
||||
@@ -351,18 +350,6 @@ impl EpornerProvider {
|
||||
digits.parse::<f32>().ok().map(|v| v / 100.0)
|
||||
}
|
||||
|
||||
fn alnum_id_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"/video-([A-Za-z0-9]+)").expect("valid regex"))
|
||||
}
|
||||
|
||||
fn alnum_id_from_href(href: &str) -> Option<String> {
|
||||
Self::alnum_id_regex()
|
||||
.captures(href)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
}
|
||||
|
||||
fn parse_list_cards(html: &str) -> Result<Vec<EpornerCard>> {
|
||||
let document = Html::parse_document(html);
|
||||
let card_sel = Self::selector("div.mb[data-id]")?;
|
||||
@@ -390,7 +377,7 @@ impl EpornerProvider {
|
||||
if page_url.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(alnum_id) = Self::alnum_id_from_href(href) else {
|
||||
let Some(alnum_id) = crate::util::eporner_resolve::alnum_id_from_href(href) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -453,115 +440,15 @@ impl EpornerProvider {
|
||||
Ok(cards)
|
||||
}
|
||||
|
||||
fn hash_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r#"hash\s*[:=]\s*['"]([0-9a-f]{32})"#).expect("valid regex"))
|
||||
}
|
||||
|
||||
fn height_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"^(\d+)").expect("valid regex"))
|
||||
}
|
||||
|
||||
fn base36(mut n: u32) -> String {
|
||||
const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
if n == 0 {
|
||||
return "0".to_string();
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
while n > 0 {
|
||||
buf.push(DIGITS[(n % 36) as usize]);
|
||||
n /= 36;
|
||||
}
|
||||
buf.reverse();
|
||||
String::from_utf8(buf).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Reverse-engineered from the site's player JS: the page's 32-hex-char
|
||||
/// `hash` is split into four 8-hex-char chunks, each parsed as a u32 and
|
||||
/// base36-encoded, then concatenated. The `/xhr/video/{id}` format API
|
||||
/// requires this derived value or it serves a restricted/preview
|
||||
/// response instead of the real sources.
|
||||
fn calc_hash(hash: &str) -> Option<String> {
|
||||
if hash.len() != 32 || !hash.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
let mut out = String::new();
|
||||
for chunk_start in (0..32).step_by(8) {
|
||||
let chunk = &hash[chunk_start..chunk_start + 8];
|
||||
let n = u32::from_str_radix(chunk, 16).ok()?;
|
||||
out.push_str(&Self::base36(n));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Fetches the detail page for its `hash`, then calls the site's own
|
||||
/// `/xhr/video/{alnum_id}` JSON API (the same one the site's own player
|
||||
/// uses) to get real direct mp4 URLs across all available resolutions.
|
||||
/// Without this, the client is left to open the bare page URL, which
|
||||
/// eporner serves as a small restricted/preview clip rather than the
|
||||
/// full video.
|
||||
///
|
||||
/// The resolved CDN mp4 URLs also enforce a Referer check independent of
|
||||
/// the hash/xhr dance above: a request whose Referer is absent or points
|
||||
/// at eporner.com is served the real file, but any other non-empty
|
||||
/// Referer gets a 302 to a static `.../na.mp4` ("not available") decoy
|
||||
/// clip — the exact "this video is only available at eporner.com"-style
|
||||
/// clip users see when playback doesn't carry the header the CDN wants.
|
||||
/// Each format therefore declares an explicit `Referer` so the client's
|
||||
/// own default (or lack thereof) can't accidentally trip this check.
|
||||
async fn resolve_formats(
|
||||
requester: &mut Requester,
|
||||
alnum_id: &str,
|
||||
page_url: &str,
|
||||
) -> Option<Vec<VideoFormat>> {
|
||||
let detail_html = Self::fetch_html(requester, page_url).await.ok()?;
|
||||
let hash = &Self::hash_regex().captures(&detail_html)?[1];
|
||||
let calc = Self::calc_hash(hash)?;
|
||||
|
||||
let api_url = format!(
|
||||
"{BASE_URL}/xhr/video/{alnum_id}?hash={calc}&device=generic&domain=www.eporner.com&fallback=false"
|
||||
);
|
||||
let json_text = requester
|
||||
.get_with_headers(&api_url, Self::html_headers(page_url), Some(Version::HTTP_11))
|
||||
.await
|
||||
.ok()?;
|
||||
let value: serde_json::Value = serde_json::from_str(&json_text).ok()?;
|
||||
|
||||
if value.get("available").and_then(|v| v.as_bool()) == Some(false) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mp4_sources = value.get("sources")?.get("mp4")?.as_object()?;
|
||||
let mut ranked: Vec<(u32, VideoFormat)> = Vec::new();
|
||||
for (key, entry) in mp4_sources {
|
||||
let Some(src) = entry.get("src").and_then(|v| v.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let label = entry
|
||||
.get("labelShort")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(key.as_str());
|
||||
let height = Self::height_regex()
|
||||
.captures(label)
|
||||
.and_then(|c| c[1].parse::<u32>().ok())
|
||||
.unwrap_or(0);
|
||||
let format = VideoFormat::new(src.to_string(), label.to_string(), "mp4".to_string())
|
||||
.format_id(key.clone())
|
||||
.format_note(key.clone())
|
||||
.height(height)
|
||||
.http_header("Referer".to_string(), format!("{BASE_URL}/"));
|
||||
ranked.push((height, format));
|
||||
}
|
||||
if ranked.is_empty() {
|
||||
return None;
|
||||
}
|
||||
ranked.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
Some(ranked.into_iter().map(|(_, f)| f).collect())
|
||||
}
|
||||
|
||||
async fn build_item(requester: &mut Requester, card: EpornerCard) -> VideoItem {
|
||||
let formats = Self::resolve_formats(requester, &card.alnum_id, &card.page_url).await;
|
||||
// The hash/xhr resolution + Referer handling live in
|
||||
// `crate::util::eporner_resolve` (shared with other providers that
|
||||
// redirect out to eporner.com, e.g. `melonstube`) rather than here,
|
||||
// since single-provider builds can't depend on another provider's
|
||||
// module — see that module's doc comment.
|
||||
let formats =
|
||||
crate::util::eporner_resolve::resolve_formats(requester, &card.alnum_id, &card.page_url)
|
||||
.await;
|
||||
|
||||
let mut item = VideoItem::new(
|
||||
card.id,
|
||||
@@ -813,36 +700,5 @@ impl Provider for EpornerProvider {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn calc_hash_matches_site_algorithm() {
|
||||
// Reverse-engineered from vjs.js; verified against a live page/xhr pair.
|
||||
let hash = "06aadb04e6f6e853e25f5a583f173617";
|
||||
assert_eq!(hash.len(), 32);
|
||||
assert_eq!(
|
||||
EpornerProvider::calc_hash(hash).unwrap(),
|
||||
"1ulk3o1s31fmb1qt66aghi70uv"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calc_hash_rejects_wrong_length() {
|
||||
assert!(EpornerProvider::calc_hash("abc").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alnum_id_from_href_extracts_path_segment() {
|
||||
assert_eq!(
|
||||
EpornerProvider::alnum_id_from_href("/video-jQDTzcHvtjv/vj-blacked/"),
|
||||
Some("jQDTzcHvtjv".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
EpornerProvider::alnum_id_from_href("https://www.eporner.com/video-5Ssyrtrs9FG/-/"),
|
||||
Some("5Ssyrtrs9FG".to_string())
|
||||
);
|
||||
assert_eq!(EpornerProvider::alnum_id_from_href("/hd-porn/95008/foo/"), None);
|
||||
}
|
||||
}
|
||||
// The calc_hash / alnum_id_from_href unit tests moved to
|
||||
// `crate::util::eporner_resolve` along with the functions themselves.
|
||||
|
||||
@@ -5,14 +5,17 @@ use crate::providers::{
|
||||
};
|
||||
use crate::status::*;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use crate::util::requester::Requester;
|
||||
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use error_chain::error_chain;
|
||||
use futures::stream::{self, StreamExt};
|
||||
use regex::Regex;
|
||||
use scraper::{Html, Selector};
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use url::form_urlencoded::Serializer;
|
||||
use wreq::cookie::Jar;
|
||||
use wreq::redirect::Policy;
|
||||
@@ -39,6 +42,75 @@ const HARD_HOSTS: &[&str] = &["manysex.com", "videomanysex.com"];
|
||||
// false-positive "ad" results.
|
||||
const JUNK_HOSTS: &[&str] = &["fhgte.com"];
|
||||
|
||||
// Live-cam profile pages, not a completed VOD — dropped the same way as
|
||||
// junk hosts rather than surfaced as a broken "video".
|
||||
const LIVE_CAM_HOSTS: &[&str] = &["flirt4free.com"];
|
||||
|
||||
// Destination hosts that share the classic KVS (Kernel Video Sharing)
|
||||
// `get_file/{N}/{hash}/{bucket}/{id}/{id}[_variant].mp4[?token]` pattern
|
||||
// already used by several other Hot Tub KVS-style providers (porn4fans.rs,
|
||||
// xgroovy.rs, thepornbunny.rs), reachable directly through the shared
|
||||
// Requester with no anti-bot bypass needed. Verified live against a real
|
||||
// card from each host.
|
||||
const KVS_HOSTS: &[&str] = &[
|
||||
"pornfreenow.com",
|
||||
"analdin.xxx",
|
||||
"babepump.com",
|
||||
"w1mp.com",
|
||||
"fetishshrine.com",
|
||||
"pervclips.com",
|
||||
"xozilla.xxx",
|
||||
"ok.xxx",
|
||||
"ebonypeek.com",
|
||||
"xgroovy.com",
|
||||
];
|
||||
|
||||
// Same KVS backend as `KVS_HOSTS`, but Cloudflare-fronted: a plain fetch
|
||||
// gets a 403 challenge (verified live), so these go through the same
|
||||
// Safari18 bypass client `melonstube.com` itself needs before the KVS
|
||||
// extraction applies.
|
||||
const KVS_CF_GATED_HOSTS: &[&str] = &["fapcat.com", "momvids.com"];
|
||||
|
||||
// Redirect gateways into an xHamster mirror instance — verified live to be
|
||||
// genuine xHamster infrastructure (same xhcdn.com CDN, same embedded
|
||||
// `"sources":{"mp4":{...}}` JSON xhamster.com itself uses), just reached
|
||||
// through an affiliate short-link rather than xhamster.com directly.
|
||||
const XHAMSTER_MIRROR_HOSTS: &[&str] = &["xh.partners"];
|
||||
|
||||
// `some.porn`'s HLS master playlist can be built directly from the numeric
|
||||
// id in the page URL — no page fetch required.
|
||||
const SOME_PORN_HOSTS: &[&str] = &["some.porn"];
|
||||
|
||||
// pornone.com embeds direct, unauthenticated `<source src="...">` mp4 tags
|
||||
// at multiple resolutions right on the detail page.
|
||||
const PORNONE_HOSTS: &[&str] = &["pornone.com"];
|
||||
|
||||
// Bounded concurrency for the per-card destination-page enrichment pass.
|
||||
// Deliberately higher than most other providers' enrichment concurrency:
|
||||
// this fans out to many *different* third-party hosts (not repeated calls
|
||||
// to one site), so there's no single-origin rate limit to respect, and a
|
||||
// low value compounds badly with `ENRICH_TIMEOUT_SECS` when several cards
|
||||
// in one page happen to hit a slow/unreachable host back to back.
|
||||
const ENRICH_CONCURRENCY: usize = 8;
|
||||
// Per-card cap so one slow or unreachable destination host can't stall the
|
||||
// whole listing response.
|
||||
const ENRICH_TIMEOUT_SECS: u64 = 10;
|
||||
// Upper bound on how many cards get enriched per listing fetch, regardless
|
||||
// of how many cards the page actually has (~240 for /new) — matches the
|
||||
// max `perPage` `get_videos` accepts, so any valid request is fully
|
||||
// enriched while still bounding worst-case fetch fan-out.
|
||||
const MAX_ENRICH_PER_FETCH: usize = 120;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SourceKind {
|
||||
Eporner,
|
||||
Kvs,
|
||||
KvsCfGated,
|
||||
XhamsterMirror,
|
||||
SomePorn,
|
||||
Pornone,
|
||||
}
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
@@ -244,7 +316,7 @@ impl MelonstubeProvider {
|
||||
.ok()
|
||||
}
|
||||
|
||||
async fn fetch_html_bypass(url: &str) -> Option<String> {
|
||||
async fn fetch_html_bypass(url: &str, referer: &str) -> Option<String> {
|
||||
let client = Self::build_bypass_client()?;
|
||||
let response = client
|
||||
.get(url)
|
||||
@@ -253,7 +325,7 @@ impl MelonstubeProvider {
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
)
|
||||
.header("accept-language", "en-US,en;q=0.5")
|
||||
.header("referer", BASE_URL)
|
||||
.header("referer", referer)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
@@ -281,48 +353,336 @@ impl MelonstubeProvider {
|
||||
Some(response.uri().to_string().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn bare_host(host: &str) -> &str {
|
||||
host.strip_prefix("www.").unwrap_or(host)
|
||||
}
|
||||
|
||||
fn is_junk_host(host: &str) -> bool {
|
||||
JUNK_HOSTS.contains(&host) || host.starts_with("join.")
|
||||
let bare = Self::bare_host(host);
|
||||
JUNK_HOSTS.contains(&bare) || LIVE_CAM_HOSTS.contains(&bare) || host.starts_with("join.")
|
||||
}
|
||||
|
||||
fn is_hard_host(host: &str) -> bool {
|
||||
HARD_HOSTS.contains(&host)
|
||||
HARD_HOSTS.contains(&Self::bare_host(host))
|
||||
}
|
||||
|
||||
fn parse_listing(&self, html: &str, options: &ServerOptions) -> Vec<VideoItem> {
|
||||
fn classify_source(host: &str) -> Option<SourceKind> {
|
||||
let bare = Self::bare_host(host);
|
||||
if bare == "eporner.com" {
|
||||
Some(SourceKind::Eporner)
|
||||
} else if KVS_HOSTS.contains(&bare) {
|
||||
Some(SourceKind::Kvs)
|
||||
} else if KVS_CF_GATED_HOSTS.contains(&bare) {
|
||||
Some(SourceKind::KvsCfGated)
|
||||
} else if XHAMSTER_MIRROR_HOSTS.contains(&bare) {
|
||||
Some(SourceKind::XhamsterMirror)
|
||||
} else if SOME_PORN_HOSTS.contains(&bare) {
|
||||
Some(SourceKind::SomePorn)
|
||||
} else if PORNONE_HOSTS.contains(&bare) {
|
||||
Some(SourceKind::Pornone)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn kvs_mp4_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| {
|
||||
Regex::new(
|
||||
r#"(?:https?://[^"'\s\\<>]+?)?get_file/\d+/[0-9a-f]+/\d+/\d+/[^"'\s\\<>]+\.mp4[^"'\s\\<>]*"#,
|
||||
)
|
||||
.expect("valid regex")
|
||||
})
|
||||
}
|
||||
|
||||
fn kvs_height_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"_(\d{3,4})p\.mp4").expect("valid regex"))
|
||||
}
|
||||
|
||||
/// Ranks a KVS `get_file` URL for sorting only — not every site tags its
|
||||
/// higher-quality variant with a numeric resolution; several (analdin,
|
||||
/// xozilla) instead suffix it with a bare "hd" (`{id}hd.mp4` vs the
|
||||
/// default `{id}.mp4`), which carries no exact pixel height.
|
||||
fn kvs_quality_score(url: &str) -> u32 {
|
||||
if let Some(height) = Self::kvs_height_regex()
|
||||
.captures(url)
|
||||
.and_then(|c| c[1].parse::<u32>().ok())
|
||||
{
|
||||
return height;
|
||||
}
|
||||
if url.contains("hd.mp4") { 1 } else { 0 }
|
||||
}
|
||||
|
||||
/// Extracts every distinct `get_file` mp4 URL from a KVS-CMS detail page
|
||||
/// (the same backend porn4fans.rs/xgroovy.rs/thepornbunny.rs already
|
||||
/// scrape), regardless of which JS variable name the site wraps them in
|
||||
/// — some expose named `video_url`/`video_alt_url` flashvars, others just
|
||||
/// inline the URL directly, so this scans the raw HTML for the URL shape
|
||||
/// itself. Short teaser/thumbnail-preview variants (`_s.mp4`,
|
||||
/// `_preview.mp4`) are excluded so a card never resolves to a decoy clip.
|
||||
fn parse_kvs_formats(html: &str, origin: &str) -> Option<Vec<VideoFormat>> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut ranked: Vec<(u32, VideoFormat)> = Vec::new();
|
||||
|
||||
for m in Self::kvs_mp4_regex().find_iter(html) {
|
||||
let raw = m.as_str();
|
||||
if raw.contains("_s.mp4") || raw.contains("_preview.mp4") || raw.contains("_thumb") {
|
||||
continue;
|
||||
}
|
||||
let url = if raw.starts_with("http") {
|
||||
raw.to_string()
|
||||
} else {
|
||||
format!("{origin}/{raw}")
|
||||
};
|
||||
if !seen.insert(url.clone()) {
|
||||
continue;
|
||||
}
|
||||
let score = Self::kvs_quality_score(&url);
|
||||
let height = Self::kvs_height_regex()
|
||||
.captures(&url)
|
||||
.and_then(|c| c[1].parse::<u32>().ok())
|
||||
.unwrap_or(0);
|
||||
let quality = if height > 0 {
|
||||
format!("{height}p")
|
||||
} else if url.contains("hd.mp4") {
|
||||
"hd".to_string()
|
||||
} else {
|
||||
"source".to_string()
|
||||
};
|
||||
let format = VideoFormat::new(url, quality.clone(), "mp4".to_string())
|
||||
.format_note(quality)
|
||||
.height(height)
|
||||
.http_header("Referer".to_string(), format!("{origin}/"));
|
||||
ranked.push((score, format));
|
||||
}
|
||||
|
||||
if ranked.is_empty() {
|
||||
return None;
|
||||
}
|
||||
ranked.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
Some(ranked.into_iter().map(|(_, f)| f).collect())
|
||||
}
|
||||
|
||||
async fn resolve_kvs(
|
||||
requester: &mut Requester,
|
||||
detail_url: &str,
|
||||
cf_gated: bool,
|
||||
) -> Option<Vec<VideoFormat>> {
|
||||
let origin = url::Url::parse(detail_url).ok()?.origin().ascii_serialization();
|
||||
|
||||
let html = if cf_gated {
|
||||
Self::fetch_html_bypass(detail_url, &format!("{origin}/")).await?
|
||||
} else {
|
||||
requester
|
||||
.get_with_headers(
|
||||
detail_url,
|
||||
vec![
|
||||
("user-agent".to_string(), BROWSER_UA.to_string()),
|
||||
(
|
||||
"accept".to_string(),
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
||||
.to_string(),
|
||||
),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.ok()?
|
||||
};
|
||||
|
||||
Self::parse_kvs_formats(&html, &origin)
|
||||
}
|
||||
|
||||
async fn resolve_eporner(
|
||||
requester: &mut Requester,
|
||||
detail_url: &str,
|
||||
) -> Option<Vec<VideoFormat>> {
|
||||
crate::util::eporner_resolve::resolve_public_formats(requester, detail_url).await
|
||||
}
|
||||
|
||||
fn xhamster_mirror_mp4_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| {
|
||||
Regex::new(r#""(\d{3,4})p":"(https:\\/\\/[^"]+?\.mp4[^"]*)""#).expect("valid regex")
|
||||
})
|
||||
}
|
||||
|
||||
/// `xh.partners`-style gateways redirect into a live xHamster mirror
|
||||
/// instance whose page embeds a `"sources":{"mp4":{"144p":"...",...}}`
|
||||
/// JSON block identical in shape to xhamster.com's own. The signed CDN
|
||||
/// URLs require a Referer from the exact mirror host that served them
|
||||
/// (verified live), so the Referer is built from the actual final URL
|
||||
/// after following the gateway's redirect rather than assumed.
|
||||
async fn resolve_xhamster_mirror(
|
||||
requester: &mut Requester,
|
||||
gateway_url: &str,
|
||||
) -> Option<Vec<VideoFormat>> {
|
||||
let response = requester
|
||||
.get_raw_with_headers(
|
||||
gateway_url,
|
||||
vec![("User-Agent".to_string(), BROWSER_UA.to_string())],
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
if !response.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let final_host = response.uri().host()?.to_string();
|
||||
let html = response.text().await.ok()?;
|
||||
|
||||
let anchor = html.find(r#""sources":{"mp4":{"#)?;
|
||||
let window_end = (anchor + 6000).min(html.len());
|
||||
let window = &html[anchor..window_end];
|
||||
let referer = format!("https://{final_host}/");
|
||||
|
||||
let mut ranked: Vec<(u32, VideoFormat)> = Vec::new();
|
||||
for cap in Self::xhamster_mirror_mp4_regex().captures_iter(window) {
|
||||
let Ok(height) = cap[1].parse::<u32>() else {
|
||||
continue;
|
||||
};
|
||||
let url = cap[2].replace(r"\/", "/");
|
||||
let format = VideoFormat::new(url, format!("{height}p"), "mp4".to_string())
|
||||
.format_note(format!("{height}p"))
|
||||
.height(height)
|
||||
.http_header("Referer".to_string(), referer.clone());
|
||||
ranked.push((height, format));
|
||||
}
|
||||
if ranked.is_empty() {
|
||||
return None;
|
||||
}
|
||||
ranked.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
Some(ranked.into_iter().map(|(_, f)| f).collect())
|
||||
}
|
||||
|
||||
fn some_porn_id_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"/video/(\d+)").expect("valid regex"))
|
||||
}
|
||||
|
||||
/// `some.porn`'s HLS master playlist is addressable directly from the
|
||||
/// numeric video id in the page URL — no page fetch needed. Verified
|
||||
/// live: `s.cdn3x.com/get-playlist` returns a real segmented `.m3u8`
|
||||
/// with no session/cookie requirement.
|
||||
fn resolve_some_porn(detail_url: &str) -> Option<Vec<VideoFormat>> {
|
||||
let id = Self::some_porn_id_regex().captures(detail_url)?.get(1)?.as_str();
|
||||
let url = format!("https://s.cdn3x.com/get-playlist?id={id}&q=720p");
|
||||
Some(vec![
|
||||
VideoFormat::m3u8(url, "720p".to_string(), "hls".to_string())
|
||||
.format_note("720p".to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
fn pornone_source_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| {
|
||||
Regex::new(r#"<source src="(https://[^"]+?\.mp4[^"]*)""#).expect("valid regex")
|
||||
})
|
||||
}
|
||||
|
||||
fn pornone_resolution_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"_(\d+)x(\d+)_").expect("valid regex"))
|
||||
}
|
||||
|
||||
/// pornone.com embeds direct, unauthenticated `<source src="...">` mp4
|
||||
/// tags at multiple resolutions right on the detail page — no Referer
|
||||
/// required (verified live).
|
||||
async fn resolve_pornone(
|
||||
requester: &mut Requester,
|
||||
detail_url: &str,
|
||||
) -> Option<Vec<VideoFormat>> {
|
||||
let html = requester
|
||||
.get_with_headers(
|
||||
detail_url,
|
||||
vec![("user-agent".to_string(), BROWSER_UA.to_string())],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let mut ranked: Vec<(u32, VideoFormat)> = Vec::new();
|
||||
for cap in Self::pornone_source_regex().captures_iter(&html) {
|
||||
let url = cap[1].to_string();
|
||||
if !seen.insert(url.clone()) {
|
||||
continue;
|
||||
}
|
||||
let height = Self::pornone_resolution_regex()
|
||||
.captures(&url)
|
||||
.and_then(|c| c[2].parse::<u32>().ok())
|
||||
.unwrap_or(0);
|
||||
let quality = if height > 0 {
|
||||
format!("{height}p")
|
||||
} else {
|
||||
"source".to_string()
|
||||
};
|
||||
let format = VideoFormat::new(url, quality.clone(), "mp4".to_string())
|
||||
.format_note(quality)
|
||||
.height(height);
|
||||
ranked.push((height, format));
|
||||
}
|
||||
if ranked.is_empty() {
|
||||
return None;
|
||||
}
|
||||
ranked.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
Some(ranked.into_iter().map(|(_, f)| f).collect())
|
||||
}
|
||||
|
||||
async fn resolve_source_formats(
|
||||
kind: SourceKind,
|
||||
requester: &mut Requester,
|
||||
detail_url: &str,
|
||||
) -> Option<Vec<VideoFormat>> {
|
||||
match kind {
|
||||
SourceKind::Eporner => Self::resolve_eporner(requester, detail_url).await,
|
||||
SourceKind::Kvs => Self::resolve_kvs(requester, detail_url, false).await,
|
||||
SourceKind::KvsCfGated => Self::resolve_kvs(requester, detail_url, true).await,
|
||||
SourceKind::XhamsterMirror => Self::resolve_xhamster_mirror(requester, detail_url).await,
|
||||
SourceKind::SomePorn => Self::resolve_some_porn(detail_url),
|
||||
SourceKind::Pornone => Self::resolve_pornone(requester, detail_url).await,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_listing(
|
||||
&self,
|
||||
html: &str,
|
||||
options: &ServerOptions,
|
||||
) -> (Vec<VideoItem>, Vec<(usize, SourceKind, String)>) {
|
||||
let document = Html::parse_document(html);
|
||||
|
||||
let card_sel = match Selector::parse("div.card[data-public-id]") {
|
||||
Ok(s) => s,
|
||||
Err(_) => return vec![],
|
||||
Err(_) => return (vec![], vec![]),
|
||||
};
|
||||
let link_sel = match Selector::parse("a.item-link[href]") {
|
||||
Ok(s) => s,
|
||||
Err(_) => return vec![],
|
||||
Err(_) => return (vec![], vec![]),
|
||||
};
|
||||
let img_sel = match Selector::parse("img.item-image") {
|
||||
Ok(s) => s,
|
||||
Err(_) => return vec![],
|
||||
Err(_) => return (vec![], vec![]),
|
||||
};
|
||||
let model_sel = match Selector::parse("a.pornstar-label") {
|
||||
Ok(s) => s,
|
||||
Err(_) => return vec![],
|
||||
Err(_) => return (vec![], vec![]),
|
||||
};
|
||||
let source_sel = match Selector::parse(r#"a.item-source[href^="/source/"]"#) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return vec![],
|
||||
Err(_) => return (vec![], vec![]),
|
||||
};
|
||||
|
||||
let dur_re = match Regex::new(r#"class="badge[^"]*"[^>]*>\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*</span>"#) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return vec![],
|
||||
Err(_) => return (vec![], vec![]),
|
||||
};
|
||||
let ago_re = match Regex::new(r#"</a><span class="item-source[^"]*">([^<]+)</span>"#) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return vec![],
|
||||
Err(_) => return (vec![], vec![]),
|
||||
};
|
||||
|
||||
let mut items = Vec::new();
|
||||
let mut pending: Vec<(usize, SourceKind, String)> = Vec::new();
|
||||
|
||||
for card in document.select(&card_sel) {
|
||||
let public_id = match card.value().attr("data-public-id") {
|
||||
@@ -381,14 +741,13 @@ impl MelonstubeProvider {
|
||||
let proxy_target = strip_url_scheme(&destination_url);
|
||||
let proxy_url = build_proxy_url(options, CHANNEL_ID, &proxy_target);
|
||||
if !proxy_url.is_empty() {
|
||||
let mut format = crate::videos::VideoFormat::new(
|
||||
proxy_url,
|
||||
"auto".to_string(),
|
||||
"video/mp4".to_string(),
|
||||
);
|
||||
let mut format =
|
||||
VideoFormat::new(proxy_url, "auto".to_string(), "video/mp4".to_string());
|
||||
format.add_http_header("Referer".to_string(), destination_url.clone());
|
||||
item = item.formats(vec![format]);
|
||||
}
|
||||
} else if let Some(kind) = Self::classify_source(host) {
|
||||
pending.push((items.len(), kind, destination_url.clone()));
|
||||
}
|
||||
|
||||
if let Some(ts) = ago_re
|
||||
@@ -427,7 +786,7 @@ impl MelonstubeProvider {
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
items
|
||||
(items, pending)
|
||||
}
|
||||
|
||||
fn resolve_target(query: &str, sort: &str, page: u32) -> Target {
|
||||
@@ -480,6 +839,7 @@ impl MelonstubeProvider {
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
target: Target,
|
||||
per_page: usize,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let url = match &target {
|
||||
@@ -507,34 +867,70 @@ impl MelonstubeProvider {
|
||||
}
|
||||
}
|
||||
|
||||
let html = match Self::fetch_html_bypass(&url).await {
|
||||
let mut requester = requester_or_default(&options, CHANNEL_ID, "fetch_and_parse");
|
||||
|
||||
let html = match Self::fetch_html_bypass(&url, BASE_URL).await {
|
||||
Some(html) if !html.is_empty() => html,
|
||||
_ => {
|
||||
let mut requester = requester_or_default(&options, CHANNEL_ID, "fetch_and_parse");
|
||||
requester
|
||||
.get_with_headers(
|
||||
&url,
|
||||
vec![
|
||||
("user-agent".to_string(), BROWSER_UA.to_string()),
|
||||
(
|
||||
"accept".to_string(),
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
||||
.to_string(),
|
||||
),
|
||||
("accept-language".to_string(), "en-US,en;q=0.5".to_string()),
|
||||
],
|
||||
Some(wreq::Version::HTTP_11),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Error::from(format!("request failed url={url}: {e}")))?
|
||||
}
|
||||
_ => requester
|
||||
.get_with_headers(
|
||||
&url,
|
||||
vec![
|
||||
("user-agent".to_string(), BROWSER_UA.to_string()),
|
||||
(
|
||||
"accept".to_string(),
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
||||
.to_string(),
|
||||
),
|
||||
("accept-language".to_string(), "en-US,en;q=0.5".to_string()),
|
||||
],
|
||||
Some(wreq::Version::HTTP_11),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Error::from(format!("request failed url={url}: {e}")))?,
|
||||
};
|
||||
|
||||
if html.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let items = self.parse_listing(&html, &options);
|
||||
let (mut items, mut pending) = self.parse_listing(&html, &options);
|
||||
// Only enrich as many cards as this request can actually use — the
|
||||
// page itself may have ~240 cards, but per_page is what the caller
|
||||
// asked for. Cached results still hold the full parsed list for a
|
||||
// later, larger per_page request; only the cards beyond this bound
|
||||
// stay on the plain-destination-URL fallback for *this* response.
|
||||
pending.truncate(per_page.min(MAX_ENRICH_PER_FETCH));
|
||||
|
||||
if !pending.is_empty() {
|
||||
let resolved = stream::iter(pending.into_iter().map(|(idx, kind, dest_url)| {
|
||||
let mut req = requester.clone();
|
||||
async move {
|
||||
// Bounded so one slow/hung third-party destination (e.g.
|
||||
// an unreachable FlareSolverr fallback) can't stall the
|
||||
// whole listing response — the card just falls back to
|
||||
// its plain destination URL with no formats.
|
||||
let formats = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(ENRICH_TIMEOUT_SECS),
|
||||
Self::resolve_source_formats(kind, &mut req, &dest_url),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
(idx, formats)
|
||||
}
|
||||
}))
|
||||
.buffer_unordered(ENRICH_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
for (idx, formats) in resolved {
|
||||
if let Some(formats) = formats {
|
||||
if let Some(item) = items.get_mut(idx) {
|
||||
item.formats = Some(formats);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !items.is_empty() {
|
||||
cache.insert(url, items.clone());
|
||||
@@ -562,7 +958,7 @@ impl Provider for MelonstubeProvider {
|
||||
let query_str = query.unwrap_or_default();
|
||||
let target = Self::resolve_target(&query_str, &sort, page);
|
||||
|
||||
match self.fetch_and_parse(cache, target, options).await {
|
||||
match self.fetch_and_parse(cache, target, per_page, options).await {
|
||||
Ok(mut items) => {
|
||||
items.truncate(per_page);
|
||||
items
|
||||
@@ -662,9 +1058,99 @@ mod tests {
|
||||
assert!(!MelonstubeProvider::is_hard_host("www.429men.com"));
|
||||
assert!(MelonstubeProvider::is_junk_host("fhgte.com"));
|
||||
assert!(MelonstubeProvider::is_junk_host("join.nastyclub.com"));
|
||||
assert!(MelonstubeProvider::is_junk_host("www.flirt4free.com"));
|
||||
assert!(!MelonstubeProvider::is_junk_host("www.429men.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_source_hosts() {
|
||||
use super::SourceKind;
|
||||
|
||||
assert_eq!(
|
||||
MelonstubeProvider::classify_source("www.eporner.com"),
|
||||
Some(SourceKind::Eporner)
|
||||
);
|
||||
assert_eq!(
|
||||
MelonstubeProvider::classify_source("eporner.com"),
|
||||
Some(SourceKind::Eporner)
|
||||
);
|
||||
assert_eq!(
|
||||
MelonstubeProvider::classify_source("xgroovy.com"),
|
||||
Some(SourceKind::Kvs)
|
||||
);
|
||||
assert_eq!(
|
||||
MelonstubeProvider::classify_source("www.analdin.xxx"),
|
||||
Some(SourceKind::Kvs)
|
||||
);
|
||||
assert_eq!(
|
||||
MelonstubeProvider::classify_source("www.fapcat.com"),
|
||||
Some(SourceKind::KvsCfGated)
|
||||
);
|
||||
assert_eq!(
|
||||
MelonstubeProvider::classify_source("xh.partners"),
|
||||
Some(SourceKind::XhamsterMirror)
|
||||
);
|
||||
assert_eq!(
|
||||
MelonstubeProvider::classify_source("some.porn"),
|
||||
Some(SourceKind::SomePorn)
|
||||
);
|
||||
assert_eq!(
|
||||
MelonstubeProvider::classify_source("pornone.com"),
|
||||
Some(SourceKind::Pornone)
|
||||
);
|
||||
assert_eq!(MelonstubeProvider::classify_source("www.429men.com"), None);
|
||||
// Hosts still resolved via manysex.com's dedicated proxy must not
|
||||
// also be picked up here — the two paths are mutually exclusive in
|
||||
// `parse_listing`.
|
||||
assert_eq!(MelonstubeProvider::classify_source("manysex.com"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_kvs_formats_from_html() {
|
||||
let html = r#"
|
||||
var flashvars = {
|
||||
video_url: 'https://www.analdin.xxx/get_file/20/6d9d5daae562d9c7a41c88b6ac9210e5/812000/812240/812240.mp4/',
|
||||
video_alt_url: 'https://www.analdin.xxx/get_file/20/28c406693124ceee1097b4d31cd0fdbb/812000/812240/812240hd.mp4/',
|
||||
video_url_text: '480p',
|
||||
video_alt_url_text: '720p',
|
||||
};
|
||||
var preview = 'https://www.analdin.xxx/get_file/20/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/812000/812240/812240_preview.mp4/';
|
||||
"#;
|
||||
|
||||
let formats = MelonstubeProvider::parse_kvs_formats(html, "https://www.analdin.xxx")
|
||||
.expect("finds formats");
|
||||
assert_eq!(formats.len(), 2);
|
||||
// Higher-labelled resolution ranked first.
|
||||
assert!(formats[0].url.contains("812240hd.mp4"));
|
||||
assert!(formats[1].url.contains("812240.mp4"));
|
||||
for format in &formats {
|
||||
assert!(!format.url.contains("_preview.mp4"));
|
||||
assert_eq!(
|
||||
format.http_headers_pairs(),
|
||||
vec![("Referer".to_string(), "https://www.analdin.xxx/".to_string())]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_kvs_formats_excludes_short_teaser_variant() {
|
||||
let html = r#"video_url: 'https://w1mp.com/get_file/6/4a69e7a72396b25724d177cb557a2b68/561000/561838/561838_s.mp4/'"#;
|
||||
assert!(MelonstubeProvider::parse_kvs_formats(html, "https://w1mp.com").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_some_porn_playlist_url_from_page_id() {
|
||||
let formats = MelonstubeProvider::resolve_some_porn(
|
||||
"https://some.porn/video/278305/the-worlds-hottest-italian?utm_campaign=933ab2ff",
|
||||
)
|
||||
.expect("builds a format");
|
||||
assert_eq!(formats.len(), 1);
|
||||
assert_eq!(
|
||||
formats[0].url,
|
||||
"https://s.cdn3x.com/get-playlist?id=278305&q=720p"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_cards_from_html() {
|
||||
let html = r##"
|
||||
@@ -684,7 +1170,8 @@ mod tests {
|
||||
|
||||
let provider = MelonstubeProvider::new();
|
||||
let options = make_options();
|
||||
let items = provider.parse_listing(html, &options);
|
||||
let (items, pending) = provider.parse_listing(html, &options);
|
||||
assert!(pending.is_empty());
|
||||
assert_eq!(items.len(), 1);
|
||||
let item = &items[0];
|
||||
assert_eq!(item.id, "rrUbb7lXkQG");
|
||||
@@ -712,7 +1199,8 @@ mod tests {
|
||||
|
||||
let provider = MelonstubeProvider::new();
|
||||
let options = make_options();
|
||||
let items = provider.parse_listing(html, &options);
|
||||
let (items, pending) = provider.parse_listing(html, &options);
|
||||
assert!(pending.is_empty());
|
||||
assert_eq!(items.len(), 1);
|
||||
let item = &items[0];
|
||||
assert!(item.url.starts_with("https://videomanysex.com/video/3519093/"));
|
||||
|
||||
198
src/util/eporner_resolve.rs
Normal file
198
src/util/eporner_resolve.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
//! eporner.com direct-mp4 resolution, shared across providers.
|
||||
//!
|
||||
//! `eporner.rs` is the primary user of this (the `eporner` channel itself),
|
||||
//! but other providers that redirect out to eporner.com (e.g. `melonstube`,
|
||||
//! a meta-search aggregator) need the exact same handling to avoid leaving
|
||||
//! the client with a bare page URL — see [`resolve_formats`] for why.
|
||||
//!
|
||||
//! Lives under `src/util/` rather than being imported directly from
|
||||
//! `eporner.rs` because `HOT_TUB_PROVIDER=<id>` single-provider builds only
|
||||
//! compile the one selected provider module; a cross-provider `use` would
|
||||
//! break every other provider's single-provider build. `src/util/*` is
|
||||
//! always compiled regardless of provider selection.
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
use crate::videos::VideoFormat;
|
||||
use regex::Regex;
|
||||
use std::sync::OnceLock;
|
||||
use wreq::Version;
|
||||
|
||||
const BASE_URL: &str = "https://www.eporner.com";
|
||||
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";
|
||||
|
||||
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()),
|
||||
]
|
||||
}
|
||||
|
||||
fn alnum_id_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"/video-([A-Za-z0-9]+)").expect("valid regex"))
|
||||
}
|
||||
|
||||
/// Extracts the alphanumeric video id eporner uses in its page URL
|
||||
/// (`/video-{alnum_id}/...`) and its `/xhr/video/{alnum_id}` format API.
|
||||
pub fn alnum_id_from_href(href: &str) -> Option<String> {
|
||||
alnum_id_regex()
|
||||
.captures(href)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
}
|
||||
|
||||
fn hash_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r#"hash\s*[:=]\s*['"]([0-9a-f]{32})"#).expect("valid regex"))
|
||||
}
|
||||
|
||||
fn height_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"^(\d+)").expect("valid regex"))
|
||||
}
|
||||
|
||||
fn base36(mut n: u32) -> String {
|
||||
const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
if n == 0 {
|
||||
return "0".to_string();
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
while n > 0 {
|
||||
buf.push(DIGITS[(n % 36) as usize]);
|
||||
n /= 36;
|
||||
}
|
||||
buf.reverse();
|
||||
String::from_utf8(buf).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Reverse-engineered from the site's player JS: the page's 32-hex-char
|
||||
/// `hash` is split into four 8-hex-char chunks, each parsed as a u32 and
|
||||
/// base36-encoded, then concatenated. The `/xhr/video/{id}` format API
|
||||
/// requires this derived value or it serves a restricted/preview response
|
||||
/// instead of the real sources.
|
||||
fn calc_hash(hash: &str) -> Option<String> {
|
||||
if hash.len() != 32 || !hash.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
let mut out = String::new();
|
||||
for chunk_start in (0..32).step_by(8) {
|
||||
let chunk = &hash[chunk_start..chunk_start + 8];
|
||||
let n = u32::from_str_radix(chunk, 16).ok()?;
|
||||
out.push_str(&base36(n));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Fetches the detail page for its `hash`, then calls the site's own
|
||||
/// `/xhr/video/{alnum_id}` JSON API (the same one the site's own player
|
||||
/// uses) to get real direct mp4 URLs across all available resolutions.
|
||||
/// Without this, the client is left to open the bare page URL, which
|
||||
/// eporner serves as a small restricted/preview clip rather than the full
|
||||
/// video.
|
||||
///
|
||||
/// The resolved CDN mp4 URLs also enforce a Referer check independent of
|
||||
/// the hash/xhr dance above: a request whose Referer is absent or points at
|
||||
/// eporner.com is served the real file, but any other non-empty Referer
|
||||
/// gets a 302 to a static `.../na.mp4` ("not available") decoy clip — the
|
||||
/// exact "this video is only available at eporner.com"-style clip users see
|
||||
/// when playback doesn't carry the header the CDN wants. Each format
|
||||
/// therefore declares an explicit `Referer` so the client's own default (or
|
||||
/// lack thereof) can't accidentally trip this check.
|
||||
pub async fn resolve_formats(
|
||||
requester: &mut Requester,
|
||||
alnum_id: &str,
|
||||
page_url: &str,
|
||||
) -> Option<Vec<VideoFormat>> {
|
||||
let detail_html = requester
|
||||
.get_with_headers(page_url, html_headers(page_url), Some(Version::HTTP_11))
|
||||
.await
|
||||
.ok()?;
|
||||
let hash = &hash_regex().captures(&detail_html)?[1];
|
||||
let calc = calc_hash(hash)?;
|
||||
|
||||
let api_url = format!(
|
||||
"{BASE_URL}/xhr/video/{alnum_id}?hash={calc}&device=generic&domain=www.eporner.com&fallback=false"
|
||||
);
|
||||
let json_text = requester
|
||||
.get_with_headers(&api_url, html_headers(page_url), Some(Version::HTTP_11))
|
||||
.await
|
||||
.ok()?;
|
||||
let value: serde_json::Value = serde_json::from_str(&json_text).ok()?;
|
||||
|
||||
if value.get("available").and_then(|v| v.as_bool()) == Some(false) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mp4_sources = value.get("sources")?.get("mp4")?.as_object()?;
|
||||
let mut ranked: Vec<(u32, VideoFormat)> = Vec::new();
|
||||
for (key, entry) in mp4_sources {
|
||||
let Some(src) = entry.get("src").and_then(|v| v.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let label = entry
|
||||
.get("labelShort")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(key.as_str());
|
||||
let height = height_regex()
|
||||
.captures(label)
|
||||
.and_then(|c| c[1].parse::<u32>().ok())
|
||||
.unwrap_or(0);
|
||||
let format = VideoFormat::new(src.to_string(), label.to_string(), "mp4".to_string())
|
||||
.format_id(key.clone())
|
||||
.format_note(key.clone())
|
||||
.height(height)
|
||||
.http_header("Referer".to_string(), format!("{BASE_URL}/"));
|
||||
ranked.push((height, format));
|
||||
}
|
||||
if ranked.is_empty() {
|
||||
return None;
|
||||
}
|
||||
ranked.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
Some(ranked.into_iter().map(|(_, f)| f).collect())
|
||||
}
|
||||
|
||||
/// Resolves eporner.com direct mp4 formats for an arbitrary eporner detail
|
||||
/// page URL, extracting the alnum id from the URL itself. For use by
|
||||
/// providers that only have a raw destination URL (e.g. a meta-search
|
||||
/// aggregator's out-link), not a pre-parsed listing card.
|
||||
pub async fn resolve_public_formats(
|
||||
requester: &mut Requester,
|
||||
page_url: &str,
|
||||
) -> Option<Vec<VideoFormat>> {
|
||||
let alnum_id = alnum_id_from_href(page_url)?;
|
||||
resolve_formats(requester, &alnum_id, page_url).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn calc_hash_matches_site_algorithm() {
|
||||
// Reverse-engineered from vjs.js; verified against a live page/xhr pair.
|
||||
let hash = "06aadb04e6f6e853e25f5a583f173617";
|
||||
assert_eq!(hash.len(), 32);
|
||||
assert_eq!(calc_hash(hash).unwrap(), "1ulk3o1s31fmb1qt66aghi70uv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calc_hash_rejects_wrong_length() {
|
||||
assert!(calc_hash("abc").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alnum_id_from_href_extracts_path_segment() {
|
||||
assert_eq!(
|
||||
alnum_id_from_href("/video-jQDTzcHvtjv/vj-blacked/"),
|
||||
Some("jQDTzcHvtjv".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
alnum_id_from_href("https://www.eporner.com/video-5Ssyrtrs9FG/-/"),
|
||||
Some("5Ssyrtrs9FG".to_string())
|
||||
);
|
||||
assert_eq!(alnum_id_from_href("/hd-porn/95008/foo/"), None);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use std::process::Command;
|
||||
|
||||
pub mod cache;
|
||||
pub mod discord;
|
||||
pub mod eporner_resolve;
|
||||
pub mod flaresolverr;
|
||||
pub mod flow_debug;
|
||||
pub mod hoster_proxy;
|
||||
|
||||
Reference in New Issue
Block a user