more providers
This commit is contained in:
620
src/providers/hdporn92.rs
Normal file
620
src/providers/hdporn92.rs
Normal file
@@ -0,0 +1,620 @@
|
||||
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::videos::{ServerOptions, VideoFormat, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use futures::stream::{self, StreamExt};
|
||||
use regex::Regex;
|
||||
use scraper::{Html, Selector};
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "mainstream-tube",
|
||||
tags: &["tube", "mixed", "movies"],
|
||||
};
|
||||
|
||||
const BASE_URL: &str = "https://hdporn92.com";
|
||||
const CHANNEL_ID: &str = "hdporn92";
|
||||
const DEFAULT_PER_PAGE: usize = 24;
|
||||
const ENRICH_CONCURRENCY: usize = 4;
|
||||
|
||||
const BROWSER_UA: &str =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \
|
||||
Chrome/124.0.0.0 Safari/537.36";
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
errors {
|
||||
Parse(msg: String) {
|
||||
description("parse error")
|
||||
display("parse error: {}", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Hdporn92Provider;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Target {
|
||||
Latest,
|
||||
Search { query: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CardStub {
|
||||
id: String,
|
||||
title: String,
|
||||
url: String,
|
||||
thumb: String,
|
||||
quality: Option<String>,
|
||||
category_slug: Option<String>,
|
||||
}
|
||||
|
||||
impl Hdporn92Provider {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: CHANNEL_ID.to_string(),
|
||||
name: "HDPorn92".to_string(),
|
||||
description:
|
||||
"HD Porn videos from network studios on hdporn92.com. Plays through a \
|
||||
monetized morencius.com (VidHide-family) embed; the HLS URL is signed \
|
||||
and Referer-locked, so the page itself does not expose direct media."
|
||||
.to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=hdporn92.com".to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: vec![],
|
||||
options: vec![],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn selector(value: &str) -> Result<Selector> {
|
||||
Selector::parse(value)
|
||||
.map_err(|error| Error::from(format!("selector `{value}` failed: {error}")))
|
||||
}
|
||||
|
||||
fn pick_target(query: Option<&str>) -> Target {
|
||||
if let Some(q) = query.map(str::trim).filter(|q| !q.is_empty()) {
|
||||
return Target::Search { query: q.to_string() };
|
||||
}
|
||||
Target::Latest
|
||||
}
|
||||
|
||||
fn build_listing_url(target: &Target, page: u16) -> String {
|
||||
let page = page.max(1);
|
||||
match target {
|
||||
Target::Latest => {
|
||||
if page <= 1 {
|
||||
format!("{BASE_URL}/")
|
||||
} else {
|
||||
format!("{BASE_URL}/page/{page}/")
|
||||
}
|
||||
}
|
||||
Target::Search { query } => {
|
||||
let encoded: String =
|
||||
url::form_urlencoded::byte_serialize(query.as_bytes()).collect();
|
||||
if page <= 1 {
|
||||
format!("{BASE_URL}/?s={encoded}")
|
||||
} else {
|
||||
format!("{BASE_URL}/page/{page}/?s={encoded}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn html_headers(referer: &str) -> Vec<(String, String)> {
|
||||
vec![
|
||||
("User-Agent".to_string(), BROWSER_UA.to_string()),
|
||||
("Referer".to_string(), referer.to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
async fn fetch_html(options: &ServerOptions, url: &str, referer: &str) -> Result<String> {
|
||||
let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_html");
|
||||
requester
|
||||
.get_with_headers(url, Self::html_headers(referer), None)
|
||||
.await
|
||||
.map_err(|error| Error::from(format!("request failed for {url}: {error}")))
|
||||
}
|
||||
|
||||
fn parse_listing_page(html: &str) -> Result<Vec<CardStub>> {
|
||||
let document = Html::parse_document(html);
|
||||
let card_sel = Self::selector("article.loop-video[data-video-id]")?;
|
||||
let link_sel = Self::selector("a[href]")?;
|
||||
let thumb_attr_sel = Self::selector("[data-main-thumb]")?;
|
||||
let title_sel = Self::selector("header.entry-header span")?;
|
||||
let views_sel = Self::selector("span.views")?;
|
||||
let class_regex = Regex::new(r"\bpost-(\d+)\b")
|
||||
.map_err(|error| Error::from(format!("regex failed: {error}")))?;
|
||||
let category_regex = Regex::new(r"\bcategory-([a-z0-9-]+)")
|
||||
.map_err(|error| Error::from(format!("regex failed: {error}")))?;
|
||||
|
||||
let mut stubs = Vec::new();
|
||||
let mut seen_ids = std::collections::HashSet::new();
|
||||
|
||||
for card in document.select(&card_sel) {
|
||||
let class_attr = card.value().attr("class").unwrap_or_default();
|
||||
|
||||
let id = class_attr
|
||||
.split_whitespace()
|
||||
.find_map(|c| c.strip_prefix("post-"))
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
card.value()
|
||||
.attr("data-post-id")
|
||||
.map(str::to_string)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if id.is_empty() || !seen_ids.insert(id.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let url = card
|
||||
.select(&link_sel)
|
||||
.next()
|
||||
.and_then(|a| a.value().attr("href"))
|
||||
.map(str::to_string)
|
||||
.unwrap_or_default();
|
||||
if url.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let title = card
|
||||
.select(&title_sel)
|
||||
.next()
|
||||
.map(|s| s.text().collect::<Vec<_>>().join(""))
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.unwrap_or_else(|| id.clone());
|
||||
|
||||
let thumb = card
|
||||
.select(&thumb_attr_sel)
|
||||
.next()
|
||||
.and_then(|el| el.value().attr("data-main-thumb"))
|
||||
.map(str::to_string)
|
||||
.filter(|t| !t.is_empty())
|
||||
.or_else(|| {
|
||||
card.select(&Self::selector("img[src]").ok()?)
|
||||
.next()
|
||||
.and_then(|img| img.value().attr("src"))
|
||||
.map(str::to_string)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let quality = card
|
||||
.select(&views_sel)
|
||||
.next()
|
||||
.map(|s| s.text().collect::<Vec<_>>().join(""))
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty() && t != "0");
|
||||
|
||||
let category_slug = class_regex
|
||||
.captures(class_attr)
|
||||
.and_then(|caps| caps.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
.or_else(|| {
|
||||
category_regex
|
||||
.captures(class_attr)
|
||||
.and_then(|caps| caps.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
});
|
||||
|
||||
stubs.push(CardStub {
|
||||
id,
|
||||
title,
|
||||
url,
|
||||
thumb,
|
||||
quality,
|
||||
category_slug,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(stubs)
|
||||
}
|
||||
|
||||
/// Extracts the morencius.com embed URL from a hdporn92 detail page.
|
||||
///
|
||||
/// The detail page renders the embed inside an `<IFRAME SRC="...">` (note
|
||||
/// the uppercase tag and attribute names — the retrotube WordPress theme
|
||||
/// emits mixed-case markup).
|
||||
fn extract_embed_url(html: &str) -> Option<String> {
|
||||
// Find the morencius embed host fragment and walk back to the scheme
|
||||
// (the iframe `SRC` is a quoted URL like `SRC="https://morencius..."`).
|
||||
let needle = "morencius.com/embed/";
|
||||
let needle_idx = html.find(needle)?;
|
||||
// Walk back to the start of the URL (find `https://` or `http://`).
|
||||
let head = &html[..=needle_idx];
|
||||
let scheme_idx = head
|
||||
.rfind("https://")
|
||||
.or_else(|| head.rfind("http://"))?;
|
||||
let after_scheme = &html[scheme_idx..];
|
||||
// End of the URL: the next quote, space, or `>`.
|
||||
let end = after_scheme
|
||||
.find(|c: char| c == '"' || c == '\'' || c == ' ' || c == '>')
|
||||
.unwrap_or(after_scheme.len());
|
||||
Some(after_scheme[..end].to_string())
|
||||
}
|
||||
|
||||
/// Extracts the embed ID from a `https://morencius.com/embed/{id}` URL.
|
||||
fn extract_embed_id(embed_url: &str) -> Option<String> {
|
||||
let prefix = "morencius.com/embed/";
|
||||
let idx = embed_url.find(prefix)?;
|
||||
let after = &embed_url[idx + prefix.len()..];
|
||||
let end = after
|
||||
.find(|c: char| c == '?' || c == '/' || c == '"' || c == '\'')
|
||||
.unwrap_or(after.len());
|
||||
if end == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(after[..end].to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes the P.A.C.K.E.R. `eval(function(p,a,c,k,e,d){...}(...))` block in
|
||||
/// the morencius embed page and returns the deobfuscated JavaScript source.
|
||||
fn decode_packered(source: &str) -> Option<String> {
|
||||
// Match: eval(function(p,a,c,k,e,d){...}('P',A,C,'K'.split('|')
|
||||
// We capture P, A, C, and the dictionary K.
|
||||
let re = Regex::new(
|
||||
r#"eval\(function\(p,a,c,k,e,d\)\{.*?\}\('([\s\S]*?)',(\d+),(\d+),'([\s\S]*?)'\.split\('\|'\)\)\)"#,
|
||||
)
|
||||
.ok()?;
|
||||
let caps = re.captures(source)?;
|
||||
let p = caps.get(1)?.as_str();
|
||||
let a: u32 = caps.get(2)?.as_str().parse().ok()?;
|
||||
let c: usize = caps.get(3)?.as_str().parse().ok()?;
|
||||
let k: Vec<&str> = caps.get(4)?.as_str().split('|').collect();
|
||||
|
||||
// P.A.C.K.E.R. deobfuscation: iterate c-1..=0; replace \b<token>\b with
|
||||
// dictionary[i] (the original JS uses a `\w+` lookalike via the `e`
|
||||
// parameter, but in practice every token is a base-A digit string).
|
||||
let mut result = p.to_string();
|
||||
for i in (0..c).rev() {
|
||||
let value = k.get(i).copied().unwrap_or("");
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let token = to_base(i, a);
|
||||
// Build a word-boundary regex for the token. Base-A tokens are
|
||||
// always made of [0-9a-z], so \b is a real word boundary.
|
||||
let token_re = Regex::new(&format!(r"\b{}\b", regex::escape(&token))).ok()?;
|
||||
result = token_re.replace_all(&result, value).into_owned();
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// Pulls the morencius `/stream/.../master.m3u8` URL out of the decoded JS.
|
||||
fn extract_hls4_url(decoded_js: &str) -> Option<String> {
|
||||
// The decoded JS has: "hls4":"/stream/<token>/<secret>/<expiry>/<file_id>/master.m3u8"
|
||||
// The value is a path (no scheme), so we prefix it.
|
||||
let re = Regex::new(r#""hls4"\s*:\s*"([^"]+)""#).ok()?;
|
||||
let caps = re.captures(decoded_js)?;
|
||||
let path = caps.get(1)?.as_str();
|
||||
if path.starts_with("http") {
|
||||
Some(path.to_string())
|
||||
} else if path.starts_with('/') {
|
||||
Some(format!("https://morencius.com{path}"))
|
||||
} else {
|
||||
Some(format!("https://morencius.com/{path}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Pulls the integer duration (in seconds, may be decimal) from the decoded
|
||||
/// jwplayer setup block. The JS has `duration:"<sec>.<frac>"`.
|
||||
fn extract_duration(decoded_js: &str) -> Option<u32> {
|
||||
let re = Regex::new(r#"duration\s*:\s*"?(\d+(?:\.\d+)?)"?"#).ok()?;
|
||||
let caps = re.captures(decoded_js)?;
|
||||
let raw = caps.get(1)?.as_str().parse::<f64>().ok()?;
|
||||
Some(raw.round() as u32)
|
||||
}
|
||||
|
||||
async fn enrich_card(stub: CardStub, options: &ServerOptions) -> Result<VideoItem> {
|
||||
// Step 1: fetch the detail page to find the morencius embed.
|
||||
let detail_html = Self::fetch_html(options, &stub.url, BASE_URL).await?;
|
||||
let embed_url = Self::extract_embed_url(&detail_html)
|
||||
.ok_or_else(|| Error::from(format!("no morencius embed in {}", stub.url)))?;
|
||||
// Sanity check the embed id — the URL is what we use as Referer, but
|
||||
// we want to fail early if the URL is malformed rather than silently
|
||||
// building a broken `formats[0]`.
|
||||
let _embed_id = Self::extract_embed_id(&embed_url)
|
||||
.ok_or_else(|| Error::from(format!("could not parse embed id from {embed_url}")))?;
|
||||
|
||||
// Step 2: fetch the morencius embed page (referer = the hdporn92 detail).
|
||||
let player_html = Self::fetch_html(options, &embed_url, &stub.url).await?;
|
||||
let decoded = Self::decode_packered(&player_html)
|
||||
.ok_or_else(|| Error::from(format!("no eval block in {embed_url}")))?;
|
||||
let stream_path = Self::extract_hls4_url(&decoded)
|
||||
.ok_or_else(|| Error::from(format!("no hls4 url in decoded JS of {embed_url}")))?;
|
||||
let duration = Self::extract_duration(&decoded).unwrap_or(0);
|
||||
|
||||
// Build the format. morencius serves the m3u8 only when Referer is the
|
||||
// embed URL — without it, the response is 403.
|
||||
let mut format =
|
||||
VideoFormat::m3u8(stream_path, "auto".to_string(), "m3u8".to_string());
|
||||
format.add_http_header("Referer".to_string(), embed_url.clone());
|
||||
format.add_http_header("User-Agent".to_string(), BROWSER_UA.to_string());
|
||||
|
||||
let mut item = VideoItem::new(
|
||||
stub.id,
|
||||
stub.title,
|
||||
stub.url,
|
||||
CHANNEL_ID.to_string(),
|
||||
stub.thumb,
|
||||
duration,
|
||||
);
|
||||
item.formats = Some(vec![format]);
|
||||
if let Some(slug) = stub.category_slug {
|
||||
item.tags = Some(vec![slug]);
|
||||
}
|
||||
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
async fn fetch_page(
|
||||
target: Target,
|
||||
page: u16,
|
||||
per_page: usize,
|
||||
options: &ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let url = Self::build_listing_url(&target, page);
|
||||
let html = Self::fetch_html(options, &url, BASE_URL).await?;
|
||||
let stubs = Self::parse_listing_page(&html)?;
|
||||
if stubs.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let limited: Vec<_> = stubs.into_iter().take(per_page).collect();
|
||||
let options = options.clone();
|
||||
|
||||
let items = stream::iter(limited.into_iter().map(|stub| {
|
||||
let options = options.clone();
|
||||
async move {
|
||||
match Self::enrich_card(stub, &options).await {
|
||||
Ok(item) => Some(item),
|
||||
Err(error) => {
|
||||
report_provider_error_background(
|
||||
CHANNEL_ID,
|
||||
"fetch_page.enrich_card",
|
||||
&error.to_string(),
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.buffer_unordered(ENRICH_CONCURRENCY)
|
||||
.filter_map(async move |v| v)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a non-negative integer to a string in the given base (radix).
|
||||
/// The result uses lowercase 0-9a-z. Used for the P.A.C.K.E.R. base-A tokens.
|
||||
fn to_base(mut value: usize, base: u32) -> String {
|
||||
debug_assert!(base >= 2 && base <= 36);
|
||||
if value == 0 {
|
||||
return "0".to_string();
|
||||
}
|
||||
let mut digits = Vec::new();
|
||||
let base = base as usize;
|
||||
while value > 0 {
|
||||
let d = (value % base) as u8;
|
||||
digits.push(if d < 10 {
|
||||
(b'0' + d) as char
|
||||
} else {
|
||||
(b'a' + (d - 10)) as char
|
||||
});
|
||||
value /= base;
|
||||
}
|
||||
digits.into_iter().rev().collect()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for Hdporn92Provider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
_cache: crate::util::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(DEFAULT_PER_PAGE)
|
||||
.clamp(1, 48);
|
||||
|
||||
let normalized_query = query
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|q| !q.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
|
||||
let target = Self::pick_target(normalized_query.as_deref());
|
||||
|
||||
match Self::fetch_page(target, page, per_page, &options).await {
|
||||
Ok(items) => items,
|
||||
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::*;
|
||||
|
||||
#[test]
|
||||
fn builds_listing_urls() {
|
||||
assert_eq!(
|
||||
Hdporn92Provider::build_listing_url(&Target::Latest, 1),
|
||||
"https://hdporn92.com/"
|
||||
);
|
||||
assert_eq!(
|
||||
Hdporn92Provider::build_listing_url(&Target::Latest, 2),
|
||||
"https://hdporn92.com/page/2/"
|
||||
);
|
||||
assert_eq!(
|
||||
Hdporn92Provider::build_listing_url(
|
||||
&Target::Search {
|
||||
query: "isla wild".to_string()
|
||||
},
|
||||
1
|
||||
),
|
||||
"https://hdporn92.com/?s=isla+wild"
|
||||
);
|
||||
assert_eq!(
|
||||
Hdporn92Provider::build_listing_url(
|
||||
&Target::Search {
|
||||
query: "milf".to_string()
|
||||
},
|
||||
2
|
||||
),
|
||||
"https://hdporn92.com/page/2/?s=milf"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_search_for_query() {
|
||||
match Hdporn92Provider::pick_target(Some("test")) {
|
||||
Target::Search { query } => assert_eq!(query, "test"),
|
||||
other => panic!("expected Search, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_latest_for_no_query() {
|
||||
assert!(matches!(
|
||||
Hdporn92Provider::pick_target(None),
|
||||
Target::Latest
|
||||
));
|
||||
assert!(matches!(
|
||||
Hdporn92Provider::pick_target(Some("")),
|
||||
Target::Latest
|
||||
));
|
||||
assert!(matches!(
|
||||
Hdporn92Provider::pick_target(Some(" ")),
|
||||
Target::Latest
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_embed_id_from_url() {
|
||||
assert_eq!(
|
||||
Hdporn92Provider::extract_embed_id("https://morencius.com/embed/vc6vwjo6ngbn"),
|
||||
Some("vc6vwjo6ngbn".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
Hdporn92Provider::extract_embed_id("https://morencius.com/embed/abc123?foo=bar"),
|
||||
Some("abc123".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
Hdporn92Provider::extract_embed_id("https://other.com/embed/abc"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_embed_url_from_detail_html() {
|
||||
let html = r#"<IFRAME SRC="https://morencius.com/embed/vc6vwjo6ngbn" FRAMEBORDER=0>"#;
|
||||
assert_eq!(
|
||||
Hdporn92Provider::extract_embed_url(html).as_deref(),
|
||||
Some("https://morencius.com/embed/vc6vwjo6ngbn")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_hls4_url_from_packered_js() {
|
||||
// A minimal P.A.C.K.E.R. block that decodes to JSON containing an
|
||||
// hls4 key. We construct it so the base-36 token `0` decodes to
|
||||
// "hls4" and `1` decodes to the m3u8 path. The `2` is the empty
|
||||
// string (no key beyond index 1) and the loop skips it.
|
||||
//
|
||||
// a = 36, c = 2, k = ["hls4", "/stream/abc/secret/123/456/master.m3u8"]
|
||||
// p = '{"0":"1"}' (after substitution: {"hls4":"/stream/.../master.m3u8"})
|
||||
//
|
||||
// Reverse iteration:
|
||||
// i=1: token="1", k[1]="..." → '{"0":"/stream/.../master.m3u8"}'
|
||||
// i=0: token="0", k[0]="hls4" → '{"hls4":"/stream/.../master.m3u8"}'
|
||||
let source = r#"eval(function(p,a,c,k,e,d){e=function(c){return c};if(!''.replace(/^/,String)){while(c--){d[c.toString(a)]=k[c]||c.toString(a)};k=[function(e){return d[e]}];e=function(){return'\w+'};c=1};while(c--){if(k[c]){p=p.replace(new RegExp('\b'+e(c)+'\b','g'),k[c])}}return p}('{"0":"1"}',36,2,'hls4|/stream/abc/secret/123/456/master.m3u8'.split('|')));"#;
|
||||
let decoded = Hdporn92Provider::decode_packered(source)
|
||||
.expect("deobfuscator should return Some");
|
||||
// Sanity: the decoded text contains the hls4 key.
|
||||
assert!(decoded.contains("\"hls4\""), "decoded: {decoded}");
|
||||
let url = Hdporn92Provider::extract_hls4_url(&decoded)
|
||||
.expect("hls4 should be present after decode");
|
||||
// The path was /stream/abc/secret/123/456/master.m3u8.
|
||||
assert_eq!(url, "https://morencius.com/stream/abc/secret/123/456/master.m3u8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_duration_from_decoded_js() {
|
||||
let decoded = r#"jwplayer().setup({duration:"1709.40",preload:'auto'})"#;
|
||||
assert_eq!(
|
||||
Hdporn92Provider::extract_duration(decoded),
|
||||
Some(1709)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_conversion() {
|
||||
assert_eq!(to_base(0, 36), "0");
|
||||
assert_eq!(to_base(35, 36), "z");
|
||||
assert_eq!(to_base(36, 36), "10");
|
||||
// 49 in base 36 is "1d" (the dictionary key for "hls3")
|
||||
assert_eq!(to_base(49, 36), "1d");
|
||||
// 51 in base 36 is "1f" (the dictionary key for "hls4")
|
||||
assert_eq!(to_base(51, 36), "1f");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_real_morencius_html() {
|
||||
// Exercise the deobfuscator against a real captured morencius embed
|
||||
// page. The fixture is the iframe served to the iOS webview when
|
||||
// opening `hdporn92.com/mypervyfamily-isla-wild-.../`.
|
||||
let html = std::fs::read_to_string(
|
||||
"src/providers/testdata/hdporn92_morencius.html",
|
||||
)
|
||||
.expect("test fixture should exist");
|
||||
let decoded = Hdporn92Provider::decode_packered(&html)
|
||||
.expect("real morencius HTML should be deobfuscable");
|
||||
// The decoded JS contains the hls4 value.
|
||||
let url = Hdporn92Provider::extract_hls4_url(&decoded)
|
||||
.expect("hls4 should be present after decode");
|
||||
assert!(
|
||||
url.starts_with("https://morencius.com/stream/"),
|
||||
"got {url}"
|
||||
);
|
||||
assert!(url.ends_with("/master.m3u8"), "got {url}");
|
||||
// The duration is also present.
|
||||
let duration = Hdporn92Provider::extract_duration(&decoded);
|
||||
assert!(duration.unwrap_or(0) > 0, "duration should be > 0");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user