sextb
This commit is contained in:
@@ -910,12 +910,29 @@ impl SextbProvider {
|
|||||||
|
|
||||||
fn parse_player_episodes(html: &str) -> Result<Vec<PlayerEpisode>> {
|
fn parse_player_episodes(html: &str) -> Result<Vec<PlayerEpisode>> {
|
||||||
let regex = Self::regex(
|
let regex = Self::regex(
|
||||||
r#"(?is)<button[^>]*class="[^"]*\bbtn-player\b[^"]*"[^>]*data-source="(?P<film>\d+)"[^>]*data-id="(?P<episode>\d+)"[^>]*>.*?</i>\s*(?P<label>[A-Z]{2,3})\s*</button>"#,
|
r#"(?is)<(?:button|a)[^>]*data-source="(?P<film>\d+)"[^>]*data-id="(?P<episode>\d+)"[^>]*>(?P<body>.*?)</(?:button|a)>"#,
|
||||||
)?;
|
)?;
|
||||||
|
let body_text_regex = Self::regex(r#"(?is)<[^>]+>"#)?;
|
||||||
let mut episodes = regex
|
let mut episodes = regex
|
||||||
.captures_iter(html)
|
.captures_iter(html)
|
||||||
.filter_map(|captures| {
|
.filter_map(|captures| {
|
||||||
let label = captures.name("label")?.as_str().trim().to_string();
|
let body = captures.name("body")?.as_str();
|
||||||
|
let body_text = body_text_regex.replace_all(body, " ");
|
||||||
|
let body_upper = body_text.to_ascii_uppercase();
|
||||||
|
let label = ["DD", "TB", "SW", "FL", "US", "PP"]
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| body_upper.contains(**candidate))
|
||||||
|
.map(|candidate| (*candidate).to_string())
|
||||||
|
.or_else(|| {
|
||||||
|
body_upper
|
||||||
|
.split_whitespace()
|
||||||
|
.find(|token| token.len() >= 2 && token.len() <= 3)
|
||||||
|
.map(|token| token.to_string())
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
if label.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let film_id = captures.name("film")?.as_str().trim().to_string();
|
let film_id = captures.name("film")?.as_str().trim().to_string();
|
||||||
let episode_id = captures.name("episode")?.as_str().trim().to_string();
|
let episode_id = captures.name("episode")?.as_str().trim().to_string();
|
||||||
Some(PlayerEpisode {
|
Some(PlayerEpisode {
|
||||||
@@ -938,19 +955,54 @@ impl SextbProvider {
|
|||||||
Ok(episodes)
|
Ok(episodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_iframe_url(url: &str) -> Option<String> {
|
||||||
|
let trimmed = url.trim();
|
||||||
|
if let Some(without) = trimmed.strip_prefix("//") {
|
||||||
|
return Some(format!("https://{without}"));
|
||||||
|
}
|
||||||
|
if trimmed.starts_with("https://") || trimmed.starts_with("http://") {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_hoster_url_from_text(text: &str) -> Option<String> {
|
||||||
|
let regex = Regex::new(r#"https?://[^\s"'<>\\]+"#).ok()?;
|
||||||
|
regex.find_iter(text).find_map(|match_value| {
|
||||||
|
let candidate = match_value.as_str().trim();
|
||||||
|
proxy_name_for_url(candidate).map(|_| candidate.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_player_iframe_url(response: &str) -> Option<String> {
|
fn parse_player_iframe_url(response: &str) -> Option<String> {
|
||||||
let regex = Regex::new(r#"(?is)<iframe[^>]+src="(?P<url>https://[^"]+)""#).ok()?;
|
let iframe_regex =
|
||||||
|
Regex::new(r#"(?is)<iframe[^>]+src=['"](?P<url>(?:https?:)?//[^'"]+)['"]"#).ok()?;
|
||||||
|
let src_json_regex = Regex::new(r#"(?i)"src"\s*:\s*"(?P<url>https?:\\/\\/[^"]+)""#).ok()?;
|
||||||
|
|
||||||
let player_html = serde_json::from_str::<serde_json::Value>(response)
|
let player_html = serde_json::from_str::<serde_json::Value>(response)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|value| value.get("player").and_then(|player| player.as_str()).map(str::to_string));
|
.and_then(|value| value.get("player").and_then(|player| player.as_str()).map(str::to_string));
|
||||||
|
|
||||||
player_html
|
let from_iframe = player_html
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|html| regex.captures(html))
|
.and_then(|html| iframe_regex.captures(html))
|
||||||
.or_else(|| regex.captures(response))
|
.or_else(|| iframe_regex.captures(response))
|
||||||
.and_then(|captures| captures.name("url"))
|
.and_then(|captures| captures.name("url"))
|
||||||
.map(|value| value.as_str().trim().to_string())
|
.and_then(|value| Self::normalize_iframe_url(value.as_str()));
|
||||||
|
|
||||||
|
if from_iframe.is_some() {
|
||||||
|
return from_iframe;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(url) = src_json_regex
|
||||||
|
.captures(response)
|
||||||
|
.and_then(|captures| captures.name("url"))
|
||||||
|
.map(|value| value.as_str().replace("\\/", "/"))
|
||||||
|
{
|
||||||
|
return Self::normalize_iframe_url(&url);
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::extract_hoster_url_from_text(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_proxy_video_url(
|
async fn resolve_proxy_video_url(
|
||||||
@@ -960,6 +1012,9 @@ impl SextbProvider {
|
|||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_proxy_video_url");
|
let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_proxy_video_url");
|
||||||
let html = requester.get(seed_url, Some(Version::HTTP_2)).await.ok()?;
|
let html = requester.get(seed_url, Some(Version::HTTP_2)).await.ok()?;
|
||||||
|
if let Some(url) = Self::extract_hoster_url_from_text(&html) {
|
||||||
|
return Some(rewrite_hoster_url(options, &url));
|
||||||
|
}
|
||||||
let episodes = Self::parse_player_episodes(&html).ok()?;
|
let episodes = Self::parse_player_episodes(&html).ok()?;
|
||||||
|
|
||||||
for episode in episodes {
|
for episode in episodes {
|
||||||
@@ -967,10 +1022,21 @@ impl SextbProvider {
|
|||||||
"{}/ajax/player?episode={}&filmId={}",
|
"{}/ajax/player?episode={}&filmId={}",
|
||||||
self.url, episode.episode_id, episode.film_id
|
self.url, episode.episode_id, episode.film_id
|
||||||
);
|
);
|
||||||
let Some(response) = requester.get(&ajax_url, Some(Version::HTTP_2)).await.ok() else {
|
let headers = vec![
|
||||||
|
("Referer".to_string(), seed_url.to_string()),
|
||||||
|
("X-Requested-With".to_string(), "XMLHttpRequest".to_string()),
|
||||||
|
("Accept".to_string(), "application/json, text/html, */*".to_string()),
|
||||||
|
];
|
||||||
|
let Some(response) = requester
|
||||||
|
.get_with_headers(&ajax_url, headers, Some(Version::HTTP_2))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(iframe_url) = Self::parse_player_iframe_url(&response) else {
|
let iframe_or_hoster = Self::parse_player_iframe_url(&response)
|
||||||
|
.or_else(|| Self::extract_hoster_url_from_text(&response));
|
||||||
|
let Some(iframe_url) = iframe_or_hoster else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if proxy_name_for_url(&iframe_url).is_some() {
|
if proxy_name_for_url(&iframe_url).is_some() {
|
||||||
@@ -1325,6 +1391,25 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_dd_player_anchor_layout() {
|
||||||
|
let html = r#"
|
||||||
|
<div class="episode-list">
|
||||||
|
<a class="episode" data-source="200" data-id="301"><span>SW</span></a>
|
||||||
|
<a class="episode active" data-source="200" data-id="302"><span>DD</span></a>
|
||||||
|
</div>
|
||||||
|
"#;
|
||||||
|
let episodes = SextbProvider::parse_player_episodes(html).expect("episodes should parse");
|
||||||
|
assert_eq!(
|
||||||
|
episodes.first(),
|
||||||
|
Some(&PlayerEpisode {
|
||||||
|
label: "DD".to_string(),
|
||||||
|
film_id: "200".to_string(),
|
||||||
|
episode_id: "302".to_string(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_iframe_url_from_ajax_player_response() {
|
fn parses_iframe_url_from_ajax_player_response() {
|
||||||
let response = r#"{"player":"<iframe src=\"https://trailerhg.xyz/e/ttdc7a6qpskt\" width=\"100%\" height=\"100%\" frameborder=\"0\" allowfullscreen=\"true\"></iframe>"}"#;
|
let response = r#"{"player":"<iframe src=\"https://trailerhg.xyz/e/ttdc7a6qpskt\" width=\"100%\" height=\"100%\" frameborder=\"0\" allowfullscreen=\"true\"></iframe>"}"#;
|
||||||
@@ -1333,4 +1418,13 @@ mod tests {
|
|||||||
Some("https://trailerhg.xyz/e/ttdc7a6qpskt")
|
Some("https://trailerhg.xyz/e/ttdc7a6qpskt")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_protocol_relative_iframe_url_from_ajax_player_response() {
|
||||||
|
let response = r#"{"player":"<iframe src=\"//turboplayers.xyz/t/69bdfb21cc640\"></iframe>"}"#;
|
||||||
|
assert_eq!(
|
||||||
|
SextbProvider::parse_player_iframe_url(response).as_deref(),
|
||||||
|
Some("https://turboplayers.xyz/t/69bdfb21cc640")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ use crate::util::requester::Requester;
|
|||||||
pub struct DoodstreamProxy {}
|
pub struct DoodstreamProxy {}
|
||||||
|
|
||||||
impl DoodstreamProxy {
|
impl DoodstreamProxy {
|
||||||
const ROOT_REFERER: &'static str = "https://turboplayers.xyz/";
|
|
||||||
|
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {}
|
Self {}
|
||||||
}
|
}
|
||||||
@@ -55,23 +53,23 @@ impl DoodstreamProxy {
|
|||||||
|| url.path().starts_with("/d/")
|
|| url.path().starts_with("/d/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn request_origin(detail_url: &str) -> Option<String> {
|
||||||
|
let parsed = Url::parse(detail_url).ok()?;
|
||||||
|
let host = parsed.host_str()?;
|
||||||
|
Some(format!("{}://{}", parsed.scheme(), host))
|
||||||
|
}
|
||||||
|
|
||||||
fn request_headers(detail_url: &str) -> Vec<(String, String)> {
|
fn request_headers(detail_url: &str) -> Vec<(String, String)> {
|
||||||
|
let origin = Self::request_origin(detail_url).unwrap_or_else(|| "https://turboplayers.xyz".to_string());
|
||||||
vec![
|
vec![
|
||||||
("Referer".to_string(), Self::ROOT_REFERER.to_string()),
|
("Referer".to_string(), format!("{}/", origin.trim_end_matches('/'))),
|
||||||
("Origin".to_string(), "https://turboplayers.xyz".to_string()),
|
("Origin".to_string(), origin),
|
||||||
(
|
(
|
||||||
"Accept".to_string(),
|
"Accept".to_string(),
|
||||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8".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.9".to_string()),
|
("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()),
|
||||||
(
|
("Sec-Fetch-Site".to_string(), "same-origin".to_string()),
|
||||||
"Sec-Fetch-Site".to_string(),
|
|
||||||
if detail_url.contains("trailerhg.xyz") {
|
|
||||||
"cross-site".to_string()
|
|
||||||
} else {
|
|
||||||
"same-origin".to_string()
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +221,75 @@ impl DoodstreamProxy {
|
|||||||
.next()
|
.next()
|
||||||
.or_else(|| Self::extract_literal_url(&unpacked))
|
.or_else(|| Self::extract_literal_url(&unpacked))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extract_pass_md5_url(text: &str, detail_url: &str) -> Option<String> {
|
||||||
|
let decoded = text.replace("\\/", "/");
|
||||||
|
let absolute_regex =
|
||||||
|
Self::regex(r#"https?://[^\s"'<>]+/pass_md5/[^\s"'<>]+"#)?;
|
||||||
|
if let Some(url) = absolute_regex.find(&decoded).map(|value| value.as_str().to_string()) {
|
||||||
|
return Some(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
let relative_regex = Self::regex(r#"/pass_md5/[^\s"'<>]+"#)?;
|
||||||
|
let relative = relative_regex.find(&decoded)?.as_str();
|
||||||
|
let origin = Self::request_origin(detail_url)?;
|
||||||
|
Some(format!("{origin}{relative}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compose_pass_md5_media_url(pass_md5_url: &str, response_body: &str) -> Option<String> {
|
||||||
|
let raw = response_body
|
||||||
|
.trim()
|
||||||
|
.trim_matches('"')
|
||||||
|
.trim_matches('\'')
|
||||||
|
.replace("\\/", "/");
|
||||||
|
if raw.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut media_url = if raw.starts_with("https://") || raw.starts_with("http://") {
|
||||||
|
raw
|
||||||
|
} else if let Some(rest) = raw.strip_prefix("//") {
|
||||||
|
format!("https://{rest}")
|
||||||
|
} else {
|
||||||
|
let parsed = Url::parse(pass_md5_url).ok()?;
|
||||||
|
let host = parsed.host_str()?;
|
||||||
|
format!("{}://{}{}", parsed.scheme(), host, raw)
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Url::parse(pass_md5_url)
|
||||||
|
.ok()
|
||||||
|
.and_then(|url| url.query().map(str::to_string));
|
||||||
|
if let Some(query) = query {
|
||||||
|
if !query.is_empty() && !media_url.contains("token=") {
|
||||||
|
let separator = if media_url.contains('?') { '&' } else { '?' };
|
||||||
|
media_url.push(separator);
|
||||||
|
media_url.push_str(&query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Self::sanitize_media_url(&media_url))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_stream_from_pass_md5(
|
||||||
|
detail_url: &str,
|
||||||
|
html: &str,
|
||||||
|
requester: &mut Requester,
|
||||||
|
) -> Option<String> {
|
||||||
|
let pass_md5_url = Self::extract_pass_md5_url(html, detail_url).or_else(|| {
|
||||||
|
Self::unpack_packer(html).and_then(|unpacked| Self::extract_pass_md5_url(&unpacked, detail_url))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let headers = vec![
|
||||||
|
("Referer".to_string(), detail_url.to_string()),
|
||||||
|
("X-Requested-With".to_string(), "XMLHttpRequest".to_string()),
|
||||||
|
("Accept".to_string(), "*/*".to_string()),
|
||||||
|
];
|
||||||
|
let response = requester
|
||||||
|
.get_with_headers(&pass_md5_url, headers, None)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
Self::compose_pass_md5_media_url(&pass_md5_url, &response)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl crate::proxies::Proxy for DoodstreamProxy {
|
impl crate::proxies::Proxy for DoodstreamProxy {
|
||||||
@@ -240,7 +307,15 @@ impl crate::proxies::Proxy for DoodstreamProxy {
|
|||||||
Err(_) => return String::new(),
|
Err(_) => return String::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
Self::extract_stream_url(&html).unwrap_or_default()
|
if let Some(url) = Self::extract_stream_url(&html) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(url) = Self::resolve_stream_from_pass_md5(&detail_url, &html, &mut requester).await {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
String::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,4 +367,30 @@ mod tests {
|
|||||||
Some("https://cdn.example/master.m3u8?t=1")
|
Some("https://cdn.example/master.m3u8?t=1")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn composes_media_url_from_pass_md5_response() {
|
||||||
|
let pass_md5_url = "https://trailerhg.xyz/pass_md5/abc123/def456?token=t0k3n&expiry=1775000000";
|
||||||
|
let body = "https://g4vsrqvtrj.pinebrookproductionlab.shop/1ghkpx2e8jnal/hls3/01/08534/syyzvotfnhaa_l/master.txt";
|
||||||
|
assert_eq!(
|
||||||
|
DoodstreamProxy::compose_pass_md5_media_url(pass_md5_url, body).as_deref(),
|
||||||
|
Some(
|
||||||
|
"https://g4vsrqvtrj.pinebrookproductionlab.shop/1ghkpx2e8jnal/hls3/01/08534/syyzvotfnhaa_l/master.txt?token=t0k3n&expiry=1775000000"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_relative_pass_md5_url() {
|
||||||
|
let html = r#"
|
||||||
|
<script>
|
||||||
|
var file = "/pass_md5/abc123/def456?token=t0k3n&expiry=1775000000";
|
||||||
|
</script>
|
||||||
|
"#;
|
||||||
|
assert_eq!(
|
||||||
|
DoodstreamProxy::extract_pass_md5_url(html, "https://trailerhg.xyz/e/ttdc7a6qpskt")
|
||||||
|
.as_deref(),
|
||||||
|
Some("https://trailerhg.xyz/pass_md5/abc123/def456?token=t0k3n&expiry=1775000000")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user