Fix six dead providers (shooshtime, hdporn92, camsoda, sextb, supjav, hentaimama)

- 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
This commit is contained in:
Simon
2026-09-22 13:56:36 +00:00
parent c244461d0b
commit c40b48b0db
8 changed files with 356 additions and 78 deletions

View File

@@ -265,53 +265,99 @@ impl Hdporn92Provider {
}
}
/// 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> {
/// 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 re = Regex::new(
let Ok(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();
) else {
return Vec::new();
};
// 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)
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()
}
/// 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}"))
}
/// 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
@@ -336,11 +382,21 @@ impl Hdporn92Provider {
// 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);
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.
@@ -568,7 +624,7 @@ mod tests {
.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)
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");
@@ -603,18 +659,19 @@ mod tests {
"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 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);
let duration = Hdporn92Provider::extract_duration(decoded);
assert!(duration.unwrap_or(0) > 0, "duration should be > 0");
}
}