pimpbunny partial fixes

This commit is contained in:
Simon
2026-05-16 10:06:23 +00:00
committed by ForgeCode
parent 1aaa388f9e
commit dc70e2c9a6
3 changed files with 264 additions and 119 deletions

View File

@@ -800,11 +800,13 @@ impl PimpbunnyProvider {
VideoFormat::new(proxy_url.clone(), "auto".into(), "video/mp4".into()) VideoFormat::new(proxy_url.clone(), "auto".into(), "video/mp4".into())
.format_id("auto".into()) .format_id("auto".into())
.format_note("proxied".into()) .format_note("proxied".into())
.http_header("Referer".to_string(), video_url.clone()), .http_header("Referer".to_string(), video_url.clone())
.http_header("Accept-Language".to_string(), "en-US,en;q=0.9".to_string())
,
]; ];
Ok( Ok(
VideoItem::new(id, title, proxy_url, "pimpbunny".into(), thumb, duration) VideoItem::new(id, title, video_url, "pimpbunny".into(), thumb, duration)
.formats(formats) .formats(formats)
.preview(preview) .preview(preview)
.views(views), .views(views),

View File

@@ -1,11 +1,10 @@
use crate::util::requester::Requester;
use ntex::web; use ntex::web;
use regex::Regex; use regex::Regex;
use serde_json::Value; use std::{collections::HashMap, time::{SystemTime, UNIX_EPOCH}};
use url::Url; use url::Url;
use wreq::Version; use wreq::Version;
use crate::util::requester::Requester;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PimpbunnyProxy {} pub struct PimpbunnyProxy {}
@@ -15,17 +14,26 @@ impl PimpbunnyProxy {
const HTML_ACCEPT: &'static str = const HTML_ACCEPT: &'static str =
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"; "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";
/// Site-wide fallback key only used when the page omits a license_code.
const LICENSE_CODE: &'static str = "$576262819011919";
pub fn new() -> Self { pub fn new() -> Self {
PimpbunnyProxy {} PimpbunnyProxy {}
} }
fn js_now_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
fn normalize_detail_url(url: &str) -> Option<String> { fn normalize_detail_url(url: &str) -> Option<String> {
let normalized = if url.starts_with("http://") || url.starts_with("https://") { let normalized = if url.starts_with("http://") || url.starts_with("https://") {
url.to_string() url.to_string()
} else { } else {
format!("https://{}", url.trim_start_matches('/')) format!("https://{}", url.trim_start_matches('/'))
}; };
Self::is_allowed_detail_url(&normalized).then_some(normalized) Self::is_allowed_detail_url(&normalized).then_some(normalized)
} }
@@ -39,7 +47,6 @@ impl PimpbunnyProxy {
let Some(host) = url.host_str() else { let Some(host) = url.host_str() else {
return false; return false;
}; };
matches!(host, "pimpbunny.com" | "www.pimpbunny.com") matches!(host, "pimpbunny.com" | "www.pimpbunny.com")
&& !url.path().starts_with("/contents/videos_screenshots/") && !url.path().starts_with("/contents/videos_screenshots/")
} }
@@ -51,10 +58,7 @@ impl PimpbunnyProxy {
fn html_headers_with_referer(referer: &str) -> Vec<(String, String)> { fn html_headers_with_referer(referer: &str) -> Vec<(String, String)> {
vec![ vec![
("Referer".to_string(), referer.to_string()), ("Referer".to_string(), referer.to_string()),
( ("User-Agent".to_string(), Self::FIREFOX_USER_AGENT.to_string()),
"User-Agent".to_string(),
Self::FIREFOX_USER_AGENT.to_string(),
),
("Accept".to_string(), Self::HTML_ACCEPT.to_string()), ("Accept".to_string(), Self::HTML_ACCEPT.to_string()),
("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()), ("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()),
] ]
@@ -82,85 +86,193 @@ impl PimpbunnyProxy {
.await; .await;
} }
fn extract_json_ld_video(text: &str) -> Option<Value> { // ── Key generation (kt_player.js IIFE "function c", lines 541-586) ────────
let script_regex = //
Regex::new(r#"(?s)<script[^>]+application/ld\+json[^>]*>(.*?)</script>"#).ok()?; // Derives the 32-char shuffle key from the page's license_code value.
//
// Steps:
// a) Build digit-string f from license_code[1:]; replace every 0 with 1.
// b) Split f at midpoint j = len/2; compute f_str = str(4 * |k l|).
// c) For g in 0..=j and h in 1..=4:
// n = digit(license_code[g+h]) + digit(f_str[g]);
// if n >= 10 { n -= 10 }
// push n onto key.
fn generate_key(license_code: &str) -> String {
let d: Vec<char> = license_code.chars().collect();
for captures in script_regex.captures_iter(text) { // (a) Build digit string, replacing 0 → 1
let raw = captures.get(1).map(|value| value.as_str().trim())?; let mut f = String::new();
let parsed: Value = serde_json::from_str(raw).ok()?; for ch in d.iter().skip(1) {
match ch.to_digit(10) {
if let Some(video) = Self::find_video_object(&parsed) { Some(0) => f.push('1'),
return Some(video); Some(v) => f.push(char::from_digit(v, 10).unwrap()),
None => f.push('1'),
} }
} }
None // (b) Split and compute intermediate
let j = f.len() / 2;
let k: i64 = f[..=j].parse().unwrap_or(0);
let l: i64 = f[j..].parse().unwrap_or(0);
let f_str = ((k - l).unsigned_abs() as i64 * 4).to_string();
// (c) Build 32-char key
let mut key = String::new();
for g in 0..=j {
for h in 1..=4usize {
let nd = d.get(g + h).and_then(|c| c.to_digit(10)).unwrap_or(0) as i64;
let nf = f_str.chars().nth(g).and_then(|c| c.to_digit(10)).unwrap_or(0) as i64;
let mut n = nd + nf;
if n >= 10 { n -= 10; }
key.push(char::from_digit(n as u32, 10).unwrap());
}
}
key
} }
fn find_video_object(parsed: &Value) -> Option<Value> { // ── Hash permutation (kt_player.js IIFE "function b", lines 521-539) ──────
if parsed //
.get("@type") // Reverse Fisher-Yates over the first 32 chars of the hash, driven by
.and_then(Value::as_str) // the cumulative digit-sum suffix of `key`.
.is_some_and(|value| value == "VideoObject") //
{ // for k from 31 downto 0:
return Some(parsed.clone()); // l = k + Σ key_digit[m] for m in k..len(key)
// l %= 32
// swap hash[k] and hash[l]
fn transform_hash(hash_str: &str, key: &str) -> String {
let mut chars: Vec<char> = hash_str.chars().collect();
let tail_start = chars.len().min(32);
let (head, _) = chars.split_at(tail_start);
let mut h: Vec<char> = head.to_vec();
let tail: String = chars.drain(tail_start..).collect();
let key_digits: Vec<usize> = key
.chars()
.filter_map(|c| c.to_digit(10))
.map(|d| d as usize)
.collect();
let len = h.len();
for k in (0..len).rev() {
let mut l = k;
for m in k..key_digits.len() {
l += key_digits[m];
}
l %= len;
h.swap(k, l);
} }
if parsed let mut result: String = h.into_iter().collect();
.get("contentUrl") result.push_str(&tail);
.and_then(Value::as_str) result
.is_some_and(|value| !value.trim().is_empty())
{
return Some(parsed.clone());
} }
if let Some(graph) = parsed.get("@graph").and_then(Value::as_array) { // ── URL decoder ────────────────────────────────────────────────────────────
for item in graph { //
if item // Strips the `function/0/` prefix, un-shuffles the hash at path segment
.get("@type") // index 5, then returns the bare URL (without ?rnd append separately).
.and_then(Value::as_str) fn decode_function0_url(input: &str, license_code: &str) -> Option<String> {
.is_some_and(|value| value == "VideoObject") if !input.starts_with("function/") {
{ return Some(input.to_string());
return Some(item.clone());
} }
if item
.get("contentUrl") // Strip "function/<N>/"
.and_then(Value::as_str) let raw = input.splitn(3, '/').nth(2)?;
.is_some_and(|value| !value.trim().is_empty())
{ let mut parts: Vec<&str> = raw.split('/').collect();
return Some(item.clone());
// Expected segments after splitting on '/':
// 0:"https:" 1:"" 2:"host" 3:"get_file" 4:N 5:HASH …
if parts.len() < 6 {
return None;
} }
let key = Self::generate_key(license_code);
let unscrambled = Self::transform_hash(parts[5], &key);
// Rebuild we need owned strings only for the one replaced segment
let mut owned: Vec<String> = parts.iter().map(|s| s.to_string()).collect();
owned[5] = unscrambled;
Some(owned.join("/"))
}
fn append_rnd(url: &str) -> String {
let rnd = Self::js_now_millis();
if url.contains("?rnd=") {
url.to_string()
} else if url.contains('?') {
format!("{url}&rnd={rnd}")
} else {
format!("{url}?rnd={rnd}")
} }
} }
if let Some(array) = parsed.as_array() { // ── Page parser ────────────────────────────────────────────────────────────
for item in array { //
if let Some(video) = Self::find_video_object(item) { // 1. Extracts license_code: any config key whose name contains "code"
return Some(video); // (not at position 0) and whose value is exactly 16 chars matching
} // the IIFE's own detection logic.
} // 2. Finds all video_url / video_alt_url keys, decodes them, picks the
// highest quality.
fn extract_video_from_player_js(text: &str) -> Option<String> {
// Extract license_code from the player config.
// The IIFE looks for a property name containing "code" at pos > 0
// with a value exactly 16 chars long.
let license_code = Regex::new(
r#"[a-z_$][a-z0-9_$]*code[a-z0-9_$]*\s*:\s*'([^']{16})'"#,
)
.ok()
.and_then(|re| re.captures(text))
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().to_string())
.unwrap_or_else(|| Self::LICENSE_CODE.to_string());
let pair_regex = Regex::new(
r#"(?P<key>video_url\d*|video_alt_url\d*(?:_text)?):\s*'(?P<value>[^']+)'"#,
)
.ok()?;
let mut data: HashMap<String, String> = HashMap::new();
for cap in pair_regex.captures_iter(text) {
data.insert(cap["key"].to_string(), cap["value"].to_string());
} }
None let mut sources: Vec<(u32, String)> = Vec::new();
for (key, value) in &data {
if !(key.starts_with("video_url") || key.starts_with("video_alt_url")) {
continue;
}
if key.ends_with("_text") {
continue;
} }
fn extract_stream_url(json_ld: &Value) -> Option<String> { let quality_key = format!("{key}_text");
json_ld let quality = data
.get("contentUrl") .get(&quality_key)
.and_then(Value::as_str) .and_then(|v| v.replace('p', "").parse::<u32>().ok())
.map(str::trim) .unwrap_or(0);
.filter(|value| !value.is_empty())
.map(str::to_string) let decoded = match Self::decode_function0_url(value, &license_code) {
Some(v) => v,
None => continue,
};
sources.push((quality, Self::append_rnd(&decoded)));
} }
sources.sort_by(|a, b| b.0.cmp(&a.0));
sources.first().map(|(_, u)| u.clone())
}
// Fallback for standard JSON-LD if they ever use it again
fn extract_stream_url_from_html(text: &str) -> Option<String> { fn extract_stream_url_from_html(text: &str) -> Option<String> {
Regex::new(r#""contentUrl"\s*:\s*"([^"]+)""#) Regex::new(r#""contentUrl"\s*:\s*"([^"]+)""#)
.ok()? .ok()?
.captures(text) .captures(text)
.and_then(|captures| captures.get(1)) .and_then(|captures| captures.get(1))
.map(|value| value.as_str().trim().to_string()) .map(|value| value.as_str().trim().to_string())
.filter(|value| !value.is_empty())
} }
} }
@@ -171,8 +283,11 @@ impl crate::proxies::Proxy for PimpbunnyProxy {
}; };
let mut requester = requester.get_ref().clone(); let mut requester = requester.get_ref().clone();
Self::warm_root_session(&mut requester).await; Self::warm_root_session(&mut requester).await;
let headers = Self::headers_with_cookies(&requester, &detail_url, &detail_url); let headers = Self::headers_with_cookies(&requester, &detail_url, &detail_url);
let text = match requester let text = match requester
.get_with_headers(&detail_url, headers, Some(Version::HTTP_2)) .get_with_headers(&detail_url, headers, Some(Version::HTTP_2))
.await .await
@@ -181,8 +296,7 @@ impl crate::proxies::Proxy for PimpbunnyProxy {
Err(_) => return String::new(), Err(_) => return String::new(),
}; };
Self::extract_json_ld_video(&text) Self::extract_video_from_player_js(&text)
.and_then(|json_ld| Self::extract_stream_url(&json_ld))
.or_else(|| Self::extract_stream_url_from_html(&text)) .or_else(|| Self::extract_stream_url_from_html(&text))
.unwrap_or_default() .unwrap_or_default()
} }
@@ -190,63 +304,90 @@ impl crate::proxies::Proxy for PimpbunnyProxy {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::PimpbunnyProxy; use super::*;
#[test] #[test]
fn allows_only_pimpbunny_detail_urls() { fn test_generate_key() {
assert!(PimpbunnyProxy::is_allowed_detail_url( // Deterministic: same license_code always produces the same 32-char key
"https://pimpbunny.com/videos/example-video/" let key = PimpbunnyProxy::generate_key(PimpbunnyProxy::LICENSE_CODE);
)); assert_eq!(key.len(), 32, "key must be 32 digits");
assert!(PimpbunnyProxy::is_allowed_detail_url( assert!(key.chars().all(|c| c.is_ascii_digit()), "key must be all digits");
"https://www.pimpbunny.com/video/example/"
));
assert!(!PimpbunnyProxy::is_allowed_detail_url(
"http://pimpbunny.com/videos/example-video/"
));
assert!(!PimpbunnyProxy::is_allowed_detail_url(
"https://pimpbunny.com/contents/videos_screenshots/1/2/3.jpg"
));
assert!(!PimpbunnyProxy::is_allowed_detail_url(
"https://example.com/videos/example-video/"
));
} }
#[test] #[test]
fn extracts_content_url_from_json_ld() { fn test_decode_function0_url() {
let html = r#" // Example taken from live player config, verified against browser output
<script type="application/ld+json">{"contentUrl":"https://cdn.example/video.mp4"}</script> let input = "function/0/https://pimpbunny.com/get_file/34/a4b50a90de7b3a7fc401e91b4b152b15580b7d689f/530000/530112/530112_pb_720p.mp4/";
"#; let expected = "https://pimpbunny.com/get_file/34/b54c19bdbf5b7a3b4492111aa70e5e00580b7d689f/530000/530112/530112_pb_720p.mp4/";
let json_ld = PimpbunnyProxy::extract_json_ld_video(html).expect("json-ld should parse"); let decoded = PimpbunnyProxy::decode_function0_url(input, PimpbunnyProxy::LICENSE_CODE)
assert_eq!( .expect("decode failed");
PimpbunnyProxy::extract_stream_url(&json_ld).as_deref(),
Some("https://cdn.example/video.mp4") assert_eq!(decoded, expected);
);
} }
#[test] #[test]
fn extracts_video_object_from_graph_script() { fn test_decode_canonical_example() {
// The original known-good example used to verify the algorithm
let input = "function/0/https://pimpbunny.com/get_file/40/7e4df9f504c7ec5b02b0101ed28edfa4c687186454/564000/564161/564161_720p.mp4/";
let expected = "https://pimpbunny.com/get_file/40/fde01fe04b42c7e72d0d0a8c95b1e45fc687186454/564000/564161/564161_720p.mp4/";
let decoded = PimpbunnyProxy::decode_function0_url(input, PimpbunnyProxy::LICENSE_CODE)
.expect("decode failed");
assert_eq!(decoded, expected);
}
#[test]
fn test_append_rnd() {
let url = "https://example.com/video.mp4";
let result = PimpbunnyProxy::append_rnd(url);
assert!(result.starts_with(url));
assert!(result.contains("?rnd="));
}
#[test]
fn test_append_rnd_existing_query() {
let url = "https://example.com/video.mp4?foo=bar";
let result = PimpbunnyProxy::append_rnd(url);
assert!(result.contains("&rnd="));
}
#[test]
fn test_passthrough_non_function_url() {
let url = "https://cdn.example.com/video.mp4";
let decoded = PimpbunnyProxy::decode_function0_url(url, PimpbunnyProxy::LICENSE_CODE)
.expect("passthrough failed");
assert_eq!(decoded, url);
}
#[test]
fn test_extract_highest_quality() {
let html = r#" let html = r#"
<script type="application/ld+json"> <script>
{"@graph":[{"@type":"BreadcrumbList"},{"@type":"VideoObject","contentUrl":"https://cdn.example/graph.mp4"}]} var t123456789abcdef = {
license_code: '$576262819011919',
video_url: 'function/0/https://pimpbunny.com/get_file/34/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/530000/530112/530112_pb_360p.mp4/',
video_url_text: '360p',
video_alt_url: 'function/0/https://pimpbunny.com/get_file/34/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/530000/530112/530112_pb_480p.mp4/',
video_alt_url_text: '480p',
video_alt_url2: 'function/0/https://pimpbunny.com/get_file/34/a4b50a90de7b3a7fc401e91b4b152b15580b7d689f/530000/530112/530112_pb_720p.mp4/',
video_alt_url2_text: '720p'
};
</script> </script>
"#; "#;
let json_ld = let result = PimpbunnyProxy::extract_video_from_player_js(html)
PimpbunnyProxy::extract_json_ld_video(html).expect("video object should parse"); .expect("no url extracted");
assert_eq!(
PimpbunnyProxy::extract_stream_url(&json_ld).as_deref(),
Some("https://cdn.example/graph.mp4")
);
}
#[test] assert!(result.contains("530112_pb_720p.mp4"), "did not choose highest quality");
fn falls_back_to_raw_content_url_match() { assert!(
let html = r#"{"contentUrl":"https://cdn.example/fallback.mp4"}"#; result.contains("b54c19bdbf5b7a3b4492111aa70e5e00580b7d689f"),
"did not decode scrambled token"
assert_eq!(
PimpbunnyProxy::extract_stream_url_from_html(html).as_deref(),
Some("https://cdn.example/fallback.mp4")
); );
assert!(result.contains("?rnd="), "missing rnd parameter");
} }
} }

View File

@@ -52,11 +52,6 @@ pub async fn send_discord_error_report(
ERROR_CACHE.insert(error_signature, now); ERROR_CACHE.insert(error_signature, now);
// --------------------------- // ---------------------------
let webhook_url = match std::env::var("DISCORD_WEBHOOK") {
Ok(url) => url,
Err(_) => return,
};
const MAX_FIELD: usize = 1024; const MAX_FIELD: usize = 1024;
let truncate = |s: &str| { let truncate = |s: &str| {
if s.len() > MAX_FIELD { if s.len() > MAX_FIELD {
@@ -103,6 +98,13 @@ pub async fn send_discord_error_report(
}] }]
}); });
let webhook_url = match std::env::var("DISCORD_WEBHOOK") {
Ok(url) => url,
Err(_) => {
// println!("{:#?}", &payload);
return;
}
};
let mut requester = requester::Requester::new(); let mut requester = requester::Requester::new();
let _ = requester.post_json(&webhook_url, &payload, vec![]).await; let _ = requester.post_json(&webhook_url, &payload, vec![]).await;
} }