- shooshtime: update parse_list_videos selectors for the site's redesigned
markup (div.card / a.title / a.image img / div.duration / div.list div.box
/ div.name a.text)
- hdporn92: mirror the site's own links.hls4||hls3||hls2 fallback and scan
every P.A.C.K.E.R. block instead of assuming the first one is the player
block; prefer a genuine .m3u8 candidate over a decoy .txt one
- camsoda: replace naive first-`{`/last-`}` JSON extraction with a proper
balanced-brace scan so trailing wrapper markup can't corrupt the parse
("key must be a string")
- sextb: fix mirror_url() double-prefixing the scheme when building Jina
mirror URLs (produced a malformed https://r.jina.ai/http://https://... URL)
- supjav: switch curl_cffi impersonation from chrome to safari18_0 - Cloudflare
blocks the chrome/firefox profiles outright but passes Safari
- hentaimama: resolve every card on a listing page (not just the first
perPage) and truncate after filtering, so a bulk "Coming soon" placeholder
batch at the front of the feed no longer empties the whole page; report a
provider error if literally every candidate fails
Refreshes graphify's graph and docs/provider-catalog.md with the new
technical details for all six providers.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QTqf6orbHZ9rFFpVpcgzcR
678 lines
25 KiB
Rust
678 lines
25 KiB
Rust
use crate::DbPool;
|
|
use crate::api::ClientVersion;
|
|
use crate::providers::{
|
|
Provider, report_provider_error, report_provider_error_background, requester_or_default,
|
|
};
|
|
use crate::status::*;
|
|
use crate::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 every P.A.C.K.E.R. `eval(function(p,a,c,k,e,d){...}(...))` block
|
|
/// found in the morencius embed page and returns the deobfuscated JS
|
|
/// source for each. The embed page carries more than one such block (an
|
|
/// ad/VAST setup block plus the actual jwplayer setup block), so callers
|
|
/// must scan all of them for the data they need rather than assume the
|
|
/// first one is the relevant one.
|
|
fn decode_packered_blocks(source: &str) -> Vec<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 Ok(re) = Regex::new(
|
|
r#"eval\(function\(p,a,c,k,e,d\)\{.*?\}\('([\s\S]*?)',(\d+),(\d+),'([\s\S]*?)'\.split\('\|'\)\)\)"#,
|
|
) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
re.captures_iter(source)
|
|
.filter_map(|caps| {
|
|
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)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Decodes the first P.A.C.K.E.R. block found in `source`. Kept for
|
|
/// callers/tests that only care about a single block; production code
|
|
/// that needs to find the block with a specific key (e.g. `hls4`) should
|
|
/// use [`Self::decode_packered_blocks`] and scan all of them instead.
|
|
fn decode_packered(source: &str) -> Option<String> {
|
|
Self::decode_packered_blocks(source).into_iter().next()
|
|
}
|
|
|
|
/// Pulls the morencius HLS URL out of the decoded JS. The player itself
|
|
/// picks `links.hls4 || links.hls3 || links.hls2` (not every tier is
|
|
/// always populated, and hls4 is frequently absent), so mirror that
|
|
/// priority order. At least one tier has been observed serving a `.txt`
|
|
/// decoy instead of a real `.m3u8` playlist, so within that priority
|
|
/// order prefer a candidate that actually looks like a stream URL.
|
|
fn extract_hls_url(decoded_js: &str) -> Option<String> {
|
|
let re = Regex::new(r#""(hls[234])"\s*:\s*"([^"]+)""#).ok()?;
|
|
let candidates: Vec<(String, String)> = re
|
|
.captures_iter(decoded_js)
|
|
.filter_map(|caps| {
|
|
let tier = caps.get(1)?.as_str().to_string();
|
|
let raw = caps.get(2)?.as_str().replace("\\/", "/");
|
|
Some((tier, raw))
|
|
})
|
|
.collect();
|
|
|
|
let normalize = |path: &str| {
|
|
if path.starts_with("http") {
|
|
path.to_string()
|
|
} else if path.starts_with('/') {
|
|
format!("https://morencius.com{path}")
|
|
} else {
|
|
format!("https://morencius.com/{path}")
|
|
}
|
|
};
|
|
|
|
const TIERS: [&str; 3] = ["hls4", "hls3", "hls2"];
|
|
TIERS
|
|
.iter()
|
|
.filter_map(|tier| candidates.iter().find(|(t, _)| t == tier))
|
|
.map(|(_, raw)| normalize(raw))
|
|
.find(|url| url.to_lowercase().ends_with(".m3u8"))
|
|
.or_else(|| {
|
|
TIERS
|
|
.iter()
|
|
.filter_map(|tier| candidates.iter().find(|(t, _)| t == tier))
|
|
.map(|(_, raw)| normalize(raw))
|
|
.next()
|
|
})
|
|
}
|
|
|
|
/// 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_blocks = Self::decode_packered_blocks(&player_html);
|
|
if decoded_blocks.is_empty() {
|
|
return Err(Error::from(format!("no eval block in {embed_url}")));
|
|
}
|
|
// A page may carry multiple P.A.C.K.E.R. blocks (e.g. an ad/VAST
|
|
// setup block); only one decodes to JS containing the player's
|
|
// hls2/3/4 stream keys, so scan all of them rather than assuming
|
|
// the first.
|
|
let decoded = decoded_blocks
|
|
.iter()
|
|
.find(|block| Self::extract_hls_url(block).is_some())
|
|
.ok_or_else(|| Error::from(format!("no hls url in decoded JS of {embed_url}")))?;
|
|
let stream_path = Self::extract_hls_url(decoded)
|
|
.ok_or_else(|| Error::from(format!("no hls 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_hls_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");
|
|
// The page carries several P.A.C.K.E.R. blocks (ad setup, player
|
|
// setup, ...); only the player block decodes to JS containing hls4.
|
|
let blocks = Hdporn92Provider::decode_packered_blocks(&html);
|
|
assert!(!blocks.is_empty(), "real morencius HTML should be deobfuscable");
|
|
let decoded = blocks
|
|
.iter()
|
|
.find(|block| Hdporn92Provider::extract_hls_url(block).is_some())
|
|
.expect("one decoded block should contain an hls2/3/4 key");
|
|
let url = Hdporn92Provider::extract_hls_url(decoded)
|
|
.expect("an hls url should be present after decode");
|
|
assert!(url.starts_with("https://"), "got {url}");
|
|
// The duration is also present.
|
|
let duration = Hdporn92Provider::extract_duration(decoded);
|
|
assert!(duration.unwrap_or(0) > 0, "duration should be > 0");
|
|
}
|
|
}
|