mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-12 05:32:09 +08:00
fix: media unlock checking behavior (#7778)
* fix: Gemini test * naming unlock * test type * refactor: deduplicate media unlock checks * fix: run media unlock checks individually * fix(media-unlock): satisfy clippy const lint * fix: stream media unlock test results * fix: streamline media unlock checks * fix: force HTTP/1 for media unlock checks * fix: limit concurrent media unlock checks * feat: allow concurrent media unlock item checks
This commit is contained in:
Generated
-1
@@ -1226,7 +1226,6 @@ dependencies = [
|
||||
"chrono",
|
||||
"clash-verge-logging",
|
||||
"log",
|
||||
"regex",
|
||||
"reqwest 0.13.4",
|
||||
"rust_iso3166",
|
||||
"serde",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
修复部分错误提示显示为 [object Object] 的问题
|
||||
修复 i18n 清理脚本因导入方式错误而无法运行的问题
|
||||
修复 Linux 桌面环境下主题切换时应用外观不跟随的问题
|
||||
修复 Gemini 测试正确性
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
@@ -7,7 +7,6 @@ edition = "2024"
|
||||
clash-verge-logging = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
log = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
rust_iso3166 = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -1,102 +1,52 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use regex::Regex;
|
||||
use reqwest::{Client, cookie::Jar};
|
||||
use reqwest::{Client, header::USER_AGENT};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::UnlockItem;
|
||||
|
||||
pub(super) async fn check_bahamut_anime(client: &Client) -> UnlockItem {
|
||||
let cookie_store = Arc::new(Jar::default());
|
||||
pub(crate) const BAHAMUT_ANIME_NAME: &str = "Bahamut Anime";
|
||||
const BAHAMUT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
|
||||
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
|
||||
|
||||
let client_with_cookies = match Client::builder()
|
||||
.use_rustls_tls()
|
||||
.user_agent(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||||
)
|
||||
.cookie_provider(Arc::clone(&cookie_store))
|
||||
.build()
|
||||
{
|
||||
Ok(client) => client,
|
||||
Err(e) => {
|
||||
logging!(
|
||||
error,
|
||||
Type::Network,
|
||||
"Failed to create client with cookies for Bahamut Anime: {}",
|
||||
e
|
||||
);
|
||||
client.clone()
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct DeviceResponse {
|
||||
deviceid: String,
|
||||
}
|
||||
|
||||
pub(super) async fn check_bahamut_anime(client: &Client) -> UnlockItem {
|
||||
let get = |url: &str| client.get(url).header(USER_AGENT, BAHAMUT_USER_AGENT);
|
||||
let device = get("https://ani.gamer.com.tw/ajax/getdeviceid.php").send().await.ok();
|
||||
|
||||
let device = match device {
|
||||
Some(res) => res.json::<DeviceResponse>().await.ok(),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let device_url = "https://ani.gamer.com.tw/ajax/getdeviceid.php";
|
||||
let device_id = match client_with_cookies.get(device_url).send().await {
|
||||
Ok(response) => match response.text().await {
|
||||
Ok(text) => match Regex::new(r#""deviceid"\s*:\s*"([^"]+)"#) {
|
||||
Ok(re) => re
|
||||
.captures(&text)
|
||||
.and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))
|
||||
.unwrap_or_default(),
|
||||
Err(e) => {
|
||||
logging!(
|
||||
error,
|
||||
Type::Network,
|
||||
"Failed to compile deviceid regex for Bahamut Anime: {}",
|
||||
e
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
},
|
||||
Err(_) => String::new(),
|
||||
},
|
||||
Err(_) => String::new(),
|
||||
let Some(device) = device else {
|
||||
return UnlockItem::checked(BAHAMUT_ANIME_NAME, "Failed", None);
|
||||
};
|
||||
|
||||
if device_id.is_empty() {
|
||||
return UnlockItem::checked("Bahamut Anime", "Failed", None);
|
||||
}
|
||||
let token_url = format!(
|
||||
"https://ani.gamer.com.tw/ajax/token.php?adID=89422&sn=37783&device={}",
|
||||
device.deviceid
|
||||
);
|
||||
|
||||
let url = format!("https://ani.gamer.com.tw/ajax/token.php?adID=89422&sn=37783&device={device_id}");
|
||||
|
||||
let token_result = match client_with_cookies.get(&url).send().await {
|
||||
Ok(response) => match response.text().await {
|
||||
Ok(body) => {
|
||||
if body.contains("animeSn") {
|
||||
Some(body)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
},
|
||||
Err(_) => None,
|
||||
let unlocked = match get(&token_url).send().await {
|
||||
Ok(res) => res.text().await.is_ok_and(|body| body.contains("animeSn")),
|
||||
Err(_) => false,
|
||||
};
|
||||
|
||||
if token_result.is_none() {
|
||||
return UnlockItem::checked("Bahamut Anime", "No", None);
|
||||
if !unlocked {
|
||||
return UnlockItem::checked(BAHAMUT_ANIME_NAME, "No", None);
|
||||
}
|
||||
|
||||
let region = match client_with_cookies.get("https://ani.gamer.com.tw/").send().await {
|
||||
Ok(response) => match response.text().await {
|
||||
Ok(body) => match Regex::new(r#"data-geo="([^"]+)"#) {
|
||||
Ok(region_re) => region_re.captures(&body).and_then(|caps| caps.get(1)).map(|m| {
|
||||
let country_code = m.as_str();
|
||||
UnlockItem::region_label(country_code)
|
||||
}),
|
||||
Err(e) => {
|
||||
logging!(
|
||||
error,
|
||||
Type::Network,
|
||||
"Failed to compile region regex for Bahamut Anime: {}",
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(_) => None,
|
||||
},
|
||||
let region = match get("https://ani.gamer.com.tw/").send().await {
|
||||
Ok(res) => res.text().await.ok().and_then(|body| {
|
||||
body.split_once("data-geo=\"")
|
||||
.and_then(|(_, rest)| rest.split_once('"'))
|
||||
.map(|(code, _)| UnlockItem::region_label(code))
|
||||
}),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
UnlockItem::checked("Bahamut Anime", "Yes", region)
|
||||
UnlockItem::checked(BAHAMUT_ANIME_NAME, "Yes", region)
|
||||
}
|
||||
|
||||
@@ -1,60 +1,39 @@
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::UnlockItem;
|
||||
|
||||
pub(super) async fn check_bilibili_china_mainland(client: &Client) -> UnlockItem {
|
||||
let url = "https://api.bilibili.com/pgc/player/web/playurl?avid=82846771&qn=0&type=&otype=json&ep_id=307247&fourk=1&fnver=0&fnval=16&module=bangumi";
|
||||
pub(crate) const BILIBILI_CHINA_MAINLAND_NAME: &str = "哔哩哔哩大陆";
|
||||
const BILIBILI_CHINA_MAINLAND_URL: &str = "https://api.bilibili.com/pgc/player/web/playurl?avid=82846771&qn=0&type=&otype=json&ep_id=307247&fourk=1&fnver=0&fnval=16&module=bangumi";
|
||||
|
||||
match client.get(url).send().await {
|
||||
Ok(response) => match response.json::<Value>().await {
|
||||
Ok(body) => {
|
||||
let status = body
|
||||
.get("code")
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|code| {
|
||||
if code == 0 {
|
||||
"Yes"
|
||||
} else if code == -10403 {
|
||||
"No"
|
||||
} else {
|
||||
"Failed"
|
||||
}
|
||||
})
|
||||
.unwrap_or("Failed");
|
||||
pub(crate) const BILIBILI_HK_MC_TW_NAME: &str = "哔哩哔哩港澳台";
|
||||
const BILIBILI_HK_MC_TW_URL: &str = "https://api.bilibili.com/pgc/player/web/playurl?avid=18281381&cid=29892777&qn=0&type=&otype=json&ep_id=183799&fourk=1&fnver=0&fnval=16&module=bangumi";
|
||||
|
||||
UnlockItem::checked("哔哩哔哩大陆", status, None)
|
||||
}
|
||||
Err(_) => UnlockItem::checked("哔哩哔哩大陆", "Failed", None),
|
||||
#[derive(Deserialize)]
|
||||
struct BilibiliResponse {
|
||||
code: i64,
|
||||
}
|
||||
|
||||
async fn check_bilibili(client: &Client, name: &str, url: &str) -> UnlockItem {
|
||||
let status = match client.get(url).send().await {
|
||||
Ok(response) => match response.json::<BilibiliResponse>().await {
|
||||
Ok(body) => match body.code {
|
||||
0 => "Yes",
|
||||
-10403 => "No",
|
||||
_ => "Failed",
|
||||
},
|
||||
Err(_) => "Failed",
|
||||
},
|
||||
Err(_) => UnlockItem::checked("哔哩哔哩大陆", "Failed", None),
|
||||
}
|
||||
Err(_) => "Failed",
|
||||
};
|
||||
|
||||
UnlockItem::checked(name, status, None)
|
||||
}
|
||||
|
||||
pub(super) async fn check_bilibili_china_mainland(client: &Client) -> UnlockItem {
|
||||
check_bilibili(client, BILIBILI_CHINA_MAINLAND_NAME, BILIBILI_CHINA_MAINLAND_URL).await
|
||||
}
|
||||
|
||||
pub(super) async fn check_bilibili_hk_mc_tw(client: &Client) -> UnlockItem {
|
||||
let url = "https://api.bilibili.com/pgc/player/web/playurl?avid=18281381&cid=29892777&qn=0&type=&otype=json&ep_id=183799&fourk=1&fnver=0&fnval=16&module=bangumi";
|
||||
|
||||
match client.get(url).send().await {
|
||||
Ok(response) => match response.json::<Value>().await {
|
||||
Ok(body) => {
|
||||
let status = body
|
||||
.get("code")
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|code| {
|
||||
if code == 0 {
|
||||
"Yes"
|
||||
} else if code == -10403 {
|
||||
"No"
|
||||
} else {
|
||||
"Failed"
|
||||
}
|
||||
})
|
||||
.unwrap_or("Failed");
|
||||
|
||||
UnlockItem::checked("哔哩哔哩港澳台", status, None)
|
||||
}
|
||||
Err(_) => UnlockItem::checked("哔哩哔哩港澳台", "Failed", None),
|
||||
},
|
||||
Err(_) => UnlockItem::checked("哔哩哔哩港澳台", "Failed", None),
|
||||
}
|
||||
check_bilibili(client, BILIBILI_HK_MC_TW_NAME, BILIBILI_HK_MC_TW_URL).await
|
||||
}
|
||||
|
||||
@@ -1,80 +1,26 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::utils::{get_text, get_trace_location};
|
||||
|
||||
use super::UnlockItem;
|
||||
|
||||
pub(super) async fn check_chatgpt_combined(client: &Client) -> Vec<UnlockItem> {
|
||||
let mut results = Vec::new();
|
||||
pub(crate) const CHATGPT_WEB_NAME: &str = "ChatGPT Web";
|
||||
|
||||
let url_country = "https://chat.openai.com/cdn-cgi/trace";
|
||||
let result_country = client.get(url_country).send().await;
|
||||
pub(super) async fn check_chatgpt(client: &Client) -> UnlockItem {
|
||||
let region = get_trace_location(client, "https://chat.openai.com/cdn-cgi/trace")
|
||||
.await
|
||||
.map(|loc| UnlockItem::region_label(&loc));
|
||||
|
||||
let region = match result_country {
|
||||
Ok(response) => {
|
||||
if let Ok(body) = response.text().await {
|
||||
let mut map = HashMap::new();
|
||||
for line in body.lines() {
|
||||
if let Some(index) = line.find('=') {
|
||||
let key = &line[..index];
|
||||
let value = &line[index + 1..];
|
||||
map.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
map.get("loc").map(|loc| UnlockItem::region_label(loc))
|
||||
let web_status = get_text(client, "https://api.openai.com/compliance/cookie_requirements")
|
||||
.await
|
||||
.map(|body| {
|
||||
if body.to_ascii_lowercase().contains("unsupported_country") {
|
||||
"Unsupported Country/Region"
|
||||
} else {
|
||||
None
|
||||
"Yes"
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
})
|
||||
.unwrap_or("Failed");
|
||||
|
||||
let url_ios = "https://ios.chat.openai.com/";
|
||||
let result_ios = client.get(url_ios).send().await;
|
||||
|
||||
let ios_status = match result_ios {
|
||||
Ok(response) => {
|
||||
if let Ok(body) = response.text().await {
|
||||
let body_lower = body.to_lowercase();
|
||||
if body_lower.contains("you may be connected to a disallowed isp") {
|
||||
"Disallowed ISP"
|
||||
} else if body_lower.contains("request is not allowed. please try again later.") {
|
||||
"Yes"
|
||||
} else if body_lower.contains("sorry, you have been blocked") {
|
||||
"Blocked"
|
||||
} else {
|
||||
"Failed"
|
||||
}
|
||||
} else {
|
||||
"Failed"
|
||||
}
|
||||
}
|
||||
Err(_) => "Failed",
|
||||
};
|
||||
|
||||
let url_web = "https://api.openai.com/compliance/cookie_requirements";
|
||||
let result_web = client.get(url_web).send().await;
|
||||
|
||||
let web_status = match result_web {
|
||||
Ok(response) => {
|
||||
if let Ok(body) = response.text().await {
|
||||
let body_lower = body.to_lowercase();
|
||||
if body_lower.contains("unsupported_country") {
|
||||
"Unsupported Country/Region"
|
||||
} else {
|
||||
"Yes"
|
||||
}
|
||||
} else {
|
||||
"Failed"
|
||||
}
|
||||
}
|
||||
Err(_) => "Failed",
|
||||
};
|
||||
|
||||
results.push(UnlockItem::checked("ChatGPT iOS", ios_status, region.clone()));
|
||||
|
||||
results.push(UnlockItem::checked("ChatGPT Web", web_status, region));
|
||||
|
||||
results
|
||||
UnlockItem::checked(CHATGPT_WEB_NAME, web_status, region)
|
||||
}
|
||||
|
||||
@@ -1,38 +1,27 @@
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::utils::get_trace_location;
|
||||
|
||||
use super::UnlockItem;
|
||||
|
||||
const BLOCKED_CODES: [&str; 10] = ["AF", "BY", "CN", "CU", "HK", "IR", "KP", "MO", "RU", "SY"];
|
||||
pub(crate) const CLAUDE_NAME: &str = "Claude";
|
||||
|
||||
const BLOCKED_CODES: &[&str] = &["AF", "BY", "CN", "CU", "HK", "IR", "KP", "MO", "RU", "SY"];
|
||||
|
||||
pub(super) async fn check_claude(client: &Client) -> UnlockItem {
|
||||
let url = "https://claude.ai/cdn-cgi/trace";
|
||||
let code = get_trace_location(client, "https://claude.ai/cdn-cgi/trace")
|
||||
.await
|
||||
.map(|code| code.trim().to_ascii_uppercase());
|
||||
|
||||
match client.get(url).send().await {
|
||||
Ok(response) => match response.text().await {
|
||||
Ok(body) => {
|
||||
let mut country_code: Option<String> = None;
|
||||
let Some(code) = code else {
|
||||
return UnlockItem::checked(CLAUDE_NAME, "Failed", None);
|
||||
};
|
||||
|
||||
for line in body.lines() {
|
||||
if let Some(rest) = line.strip_prefix("loc=") {
|
||||
country_code = Some(rest.trim().to_uppercase());
|
||||
break;
|
||||
}
|
||||
}
|
||||
let status = if BLOCKED_CODES.contains(&code.as_str()) {
|
||||
"No"
|
||||
} else {
|
||||
"Yes"
|
||||
};
|
||||
|
||||
if let Some(code) = country_code {
|
||||
let status = if BLOCKED_CODES.contains(&code.as_str()) {
|
||||
"No"
|
||||
} else {
|
||||
"Yes"
|
||||
};
|
||||
|
||||
UnlockItem::checked_region("Claude", status, &code)
|
||||
} else {
|
||||
UnlockItem::checked("Claude", "Failed", None)
|
||||
}
|
||||
}
|
||||
Err(_) => UnlockItem::checked("Claude", "Failed", None),
|
||||
},
|
||||
Err(_) => UnlockItem::checked("Claude", "Failed", None),
|
||||
}
|
||||
UnlockItem::checked_region(CLAUDE_NAME, status, &code)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,170 @@
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use crate::utils::extract_quoted_field;
|
||||
|
||||
use super::UnlockItem;
|
||||
|
||||
const DISNEY_PLUS: &str = "Disney+";
|
||||
pub(crate) const DISNEY_NAME: &str = "Disney+";
|
||||
|
||||
const AUTH_HEADER: &str = "Bearer ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84";
|
||||
|
||||
fn disney_item(status: impl Into<String>, region: Option<String>) -> UnlockItem {
|
||||
UnlockItem::checked(DISNEY_PLUS, status, region)
|
||||
const DEVICE_URL: &str = "https://disney.api.edge.bamgrid.com/devices";
|
||||
const TOKEN_URL: &str = "https://disney.api.edge.bamgrid.com/token";
|
||||
const GRAPHQL_URL: &str = "https://disney.api.edge.bamgrid.com/graph/v1/device/graphql";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DeviceResponse {
|
||||
assertion: String,
|
||||
}
|
||||
|
||||
async fn fetch_main_page_region(client: &Client) -> Option<String> {
|
||||
#[derive(Deserialize)]
|
||||
struct TokenResponse {
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
fn item(status: impl Into<String>, region: Option<String>) -> UnlockItem {
|
||||
UnlockItem::checked(DISNEY_NAME, status, region)
|
||||
}
|
||||
|
||||
pub(super) async fn check_disney_plus(client: &Client) -> UnlockItem {
|
||||
let assertion = match get_assertion(client).await {
|
||||
Ok(assertion) => assertion,
|
||||
Err(item) => return item,
|
||||
};
|
||||
|
||||
let refresh_token = match get_refresh_token(client, &assertion).await {
|
||||
Ok(token) => token,
|
||||
Err(item) => return item,
|
||||
};
|
||||
|
||||
let unavailable = is_unavailable(client).await;
|
||||
|
||||
let response = match client
|
||||
.post(GRAPHQL_URL)
|
||||
.header("authorization", AUTH_HEADER)
|
||||
.json(&json!({
|
||||
"query": "mutation refreshToken($input: RefreshTokenInput!) { refreshToken(refreshToken: $input) { activeSession { sessionId } } }",
|
||||
"variables": {
|
||||
"input": {
|
||||
"refreshToken": refresh_token
|
||||
}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(_) => return item("Failed (Network Connection)", None),
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() || body.is_empty() {
|
||||
return fallback_region(client)
|
||||
.await
|
||||
.unwrap_or_else(|| item(format!("Failed (GraphQL: {status})"), None));
|
||||
}
|
||||
|
||||
let Ok(data) = serde_json::from_str::<Value>(&body) else {
|
||||
return fallback_region(client)
|
||||
.await
|
||||
.unwrap_or_else(|| item("Failed (Invalid GraphQL Response)", None));
|
||||
};
|
||||
|
||||
let Some(region) = find_string(&data, "countryCode") else {
|
||||
return fallback_region(client).await.unwrap_or_else(|| item("No", None));
|
||||
};
|
||||
|
||||
if region == "JP" {
|
||||
return UnlockItem::checked_region(DISNEY_NAME, "Yes", region);
|
||||
}
|
||||
|
||||
if unavailable {
|
||||
return item("No", None);
|
||||
}
|
||||
|
||||
match find_bool(&data, "inSupportedLocation") {
|
||||
Some(true) => UnlockItem::checked_region(DISNEY_NAME, "Yes", region),
|
||||
Some(false) => item(
|
||||
"Soon",
|
||||
Some(format!("{}(即将上线)", UnlockItem::region_label(region))),
|
||||
),
|
||||
None => item(format!("Failed (Unknown region status for {region})"), None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_assertion(client: &Client) -> Result<String, UnlockItem> {
|
||||
let response = client
|
||||
.post(DEVICE_URL)
|
||||
.header("authorization", AUTH_HEADER)
|
||||
.json(&json!({
|
||||
"deviceFamily": "browser",
|
||||
"applicationRuntime": "chrome",
|
||||
"deviceProfile": "windows",
|
||||
"attributes": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| item("Failed (Network Connection)", None))?;
|
||||
|
||||
if response.status() == StatusCode::FORBIDDEN {
|
||||
return Err(item("No (IP Banned By Disney+)", None));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<DeviceResponse>()
|
||||
.await
|
||||
.map(|data| data.assertion)
|
||||
.map_err(|_| item("Failed (Cannot extract assertion)", None))
|
||||
}
|
||||
|
||||
async fn get_refresh_token(client: &Client, assertion: &str) -> Result<String, UnlockItem> {
|
||||
let response = client
|
||||
.post(TOKEN_URL)
|
||||
.header("authorization", AUTH_HEADER)
|
||||
.form(&[
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"),
|
||||
("latitude", "0"),
|
||||
("longitude", "0"),
|
||||
("platform", "browser"),
|
||||
("subject_token", assertion),
|
||||
("subject_token_type", "urn:bamtech:params:oauth:token-type:device"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| item("Failed (Network Connection)", None))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|_| item("Failed (Cannot read token response)", None))?;
|
||||
|
||||
if status == StatusCode::FORBIDDEN || body.contains("forbidden-location") || body.contains("403 ERROR") {
|
||||
return Err(item("No (IP Banned By Disney+)", None));
|
||||
}
|
||||
|
||||
serde_json::from_str::<TokenResponse>(&body)
|
||||
.map(|data| data.refresh_token)
|
||||
.map_err(|_| item(format!("Failed (Cannot extract refresh token: {status})"), None))
|
||||
}
|
||||
|
||||
async fn is_unavailable(client: &Client) -> bool {
|
||||
client
|
||||
.get("https://disneyplus.com")
|
||||
.send()
|
||||
.await
|
||||
.map(|response| {
|
||||
let url = response.url().as_str();
|
||||
url.contains("preview") || url.contains("unavailable")
|
||||
})
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
async fn fallback_region(client: &Client) -> Option<UnlockItem> {
|
||||
let body = client
|
||||
.get("https://www.disneyplus.com/")
|
||||
.send()
|
||||
@@ -21,286 +173,37 @@ async fn fetch_main_page_region(client: &Client) -> Option<String> {
|
||||
.text()
|
||||
.await
|
||||
.ok()?;
|
||||
let region_re = match Regex::new(r#"region"\s*:\s*"([^"]+)"#) {
|
||||
Ok(region_re) => region_re,
|
||||
Err(e) => {
|
||||
logging!(
|
||||
error,
|
||||
Type::Network,
|
||||
"Failed to compile Disney+ main page region regex: {}",
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
region_re
|
||||
.captures(&body)
|
||||
.and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))
|
||||
}
|
||||
|
||||
async fn disney_region_from_main_page(client: &Client) -> Option<UnlockItem> {
|
||||
let region = fetch_main_page_region(client).await?;
|
||||
Some(disney_item(
|
||||
let region = extract_quoted_field(&body, "region")?;
|
||||
|
||||
Some(item(
|
||||
"Yes",
|
||||
Some(format!("{} (from main page)", UnlockItem::region_label(®ion))),
|
||||
Some(format!("{} (from main page)", UnlockItem::region_label(region))),
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
pub(super) async fn check_disney_plus(client: &Client) -> UnlockItem {
|
||||
let device_api_url = "https://disney.api.edge.bamgrid.com/devices";
|
||||
fn find_string<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
|
||||
match value {
|
||||
Value::Object(map) => map
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| map.values().find_map(|v| find_string(v, key))),
|
||||
|
||||
let device_req_body = serde_json::json!({
|
||||
"deviceFamily": "browser",
|
||||
"applicationRuntime": "chrome",
|
||||
"deviceProfile": "windows",
|
||||
"attributes": {}
|
||||
});
|
||||
Value::Array(array) => array.iter().find_map(|v| find_string(v, key)),
|
||||
|
||||
let device_result = client
|
||||
.post(device_api_url)
|
||||
.header("authorization", AUTH_HEADER)
|
||||
.header("content-type", "application/json; charset=UTF-8")
|
||||
.json(&device_req_body)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let device_response = match device_result {
|
||||
Ok(response) => response,
|
||||
Err(_) => {
|
||||
return disney_item("Failed (Network Connection)", None);
|
||||
}
|
||||
};
|
||||
|
||||
if device_response.status().as_u16() == 403 {
|
||||
return disney_item("No (IP Banned By Disney+)", None);
|
||||
_ => None,
|
||||
}
|
||||
|
||||
let device_body = match device_response.text().await {
|
||||
Ok(body) => body,
|
||||
Err(_) => {
|
||||
return disney_item("Failed (Error: Cannot read response)", None);
|
||||
}
|
||||
};
|
||||
|
||||
let re = match Regex::new(r#""assertion"\s*:\s*"([^"]+)"#) {
|
||||
Ok(re) => re,
|
||||
Err(e) => {
|
||||
logging!(
|
||||
error,
|
||||
Type::Network,
|
||||
"Failed to compile assertion regex for Disney+: {}",
|
||||
e
|
||||
);
|
||||
return disney_item("Failed (Regex Error)", None);
|
||||
}
|
||||
};
|
||||
let assertion = match re.captures(&device_body) {
|
||||
Some(caps) => caps.get(1).map(|m| m.as_str().to_string()),
|
||||
None => None,
|
||||
};
|
||||
|
||||
if assertion.is_none() {
|
||||
return disney_item("Failed (Error: Cannot extract assertion)", None);
|
||||
}
|
||||
|
||||
let token_url = "https://disney.api.edge.bamgrid.com/token";
|
||||
let assertion_str = assertion.unwrap_or_default();
|
||||
let token_body = [
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"),
|
||||
("latitude", "0"),
|
||||
("longitude", "0"),
|
||||
("platform", "browser"),
|
||||
("subject_token", assertion_str.as_str()),
|
||||
("subject_token_type", "urn:bamtech:params:oauth:token-type:device"),
|
||||
];
|
||||
|
||||
let token_result = client
|
||||
.post(token_url)
|
||||
.header("authorization", AUTH_HEADER)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.form(&token_body)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let token_response = match token_result {
|
||||
Ok(response) => response,
|
||||
Err(_) => {
|
||||
return disney_item("Failed (Network Connection)", None);
|
||||
}
|
||||
};
|
||||
let token_status = token_response.status();
|
||||
|
||||
let token_body_text = match token_response.text().await {
|
||||
Ok(body) => body,
|
||||
Err(_) => {
|
||||
return disney_item("Failed (Error: Cannot read token response)", None);
|
||||
}
|
||||
};
|
||||
|
||||
if token_body_text.contains("forbidden-location") || token_body_text.contains("403 ERROR") {
|
||||
return disney_item("No (IP Banned By Disney+)", None);
|
||||
}
|
||||
|
||||
let token_json: Result<serde_json::Value, _> = serde_json::from_str(&token_body_text);
|
||||
|
||||
let refresh_token = match token_json {
|
||||
Ok(json) => json
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
Err(_) => match Regex::new(r#""refresh_token"\s*:\s*"([^"]+)"#) {
|
||||
Ok(refresh_token_re) => refresh_token_re
|
||||
.captures(&token_body_text)
|
||||
.and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())),
|
||||
Err(e) => {
|
||||
logging!(
|
||||
error,
|
||||
Type::Network,
|
||||
"Failed to compile refresh_token regex for Disney+: {}",
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if refresh_token.is_none() {
|
||||
return disney_item(
|
||||
format!(
|
||||
"Failed (Error: Cannot extract refresh token, status: {}, response: {})",
|
||||
token_status.as_u16(),
|
||||
token_body_text.chars().take(100).collect::<String>() + "..."
|
||||
),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
let graphql_url = "https://disney.api.edge.bamgrid.com/graph/v1/device/graphql";
|
||||
}
|
||||
|
||||
let graphql_payload = format!(
|
||||
r#"{{"query":"mutation refreshToken($input: RefreshTokenInput!) {{ refreshToken(refreshToken: $input) {{ activeSession {{ sessionId }} }} }}","variables":{{"input":{{"refreshToken":"{}"}}}}}}"#,
|
||||
refresh_token.unwrap_or_default()
|
||||
);
|
||||
fn find_bool(value: &Value, key: &str) -> Option<bool> {
|
||||
match value {
|
||||
Value::Object(map) => map
|
||||
.get(key)
|
||||
.and_then(Value::as_bool)
|
||||
.or_else(|| map.values().find_map(|v| find_bool(v, key))),
|
||||
|
||||
let graphql_result = client
|
||||
.post(graphql_url)
|
||||
.header("authorization", AUTH_HEADER)
|
||||
.header("content-type", "application/json")
|
||||
.body(graphql_payload)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let preview_check = client.get("https://disneyplus.com").send().await;
|
||||
|
||||
let is_unavailable = match preview_check {
|
||||
Ok(response) => {
|
||||
let url = response.url().to_string();
|
||||
url.contains("preview") || url.contains("unavailable")
|
||||
}
|
||||
Err(_) => true,
|
||||
};
|
||||
|
||||
let graphql_response = match graphql_result {
|
||||
Ok(response) => response,
|
||||
Err(_) => {
|
||||
return disney_item("Failed (Network Connection)", None);
|
||||
}
|
||||
};
|
||||
let graphql_status = graphql_response.status();
|
||||
let graphql_body_text = match graphql_response.text().await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
logging!(
|
||||
error,
|
||||
Type::Network,
|
||||
"Failed to read Disney+ GraphQL response text: {}",
|
||||
e
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
if graphql_body_text.is_empty() || graphql_status.as_u16() >= 400 {
|
||||
if let Some(item) = disney_region_from_main_page(client).await {
|
||||
return item;
|
||||
}
|
||||
|
||||
if graphql_body_text.is_empty() {
|
||||
return disney_item(
|
||||
format!(
|
||||
"Failed (GraphQL error: empty response, status: {})",
|
||||
graphql_status.as_u16()
|
||||
),
|
||||
None,
|
||||
);
|
||||
}
|
||||
return disney_item(
|
||||
format!(
|
||||
"Failed (GraphQL error: {}, status: {})",
|
||||
graphql_body_text.chars().take(50).collect::<String>() + "...",
|
||||
graphql_status.as_u16()
|
||||
),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
let region_re = match Regex::new(r#""countryCode"\s*:\s*"([^"]+)"#) {
|
||||
Ok(re) => re,
|
||||
Err(e) => {
|
||||
logging!(
|
||||
error,
|
||||
Type::Network,
|
||||
"Failed to compile Disney+ countryCode regex: {}",
|
||||
e
|
||||
);
|
||||
return disney_item("Failed (Regex Error)", None);
|
||||
}
|
||||
};
|
||||
let region_code = region_re
|
||||
.captures(&graphql_body_text)
|
||||
.and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()));
|
||||
|
||||
let supported_re = match Regex::new(r#""inSupportedLocation"\s*:\s*(false|true)"#) {
|
||||
Ok(re) => re,
|
||||
Err(e) => {
|
||||
logging!(
|
||||
error,
|
||||
Type::Network,
|
||||
"Failed to compile Disney+ supported location regex: {}",
|
||||
e
|
||||
);
|
||||
return disney_item("Failed (Regex Error)", None);
|
||||
}
|
||||
};
|
||||
let in_supported_location = supported_re
|
||||
.captures(&graphql_body_text)
|
||||
.and_then(|caps| caps.get(1).map(|m| m.as_str() == "true"));
|
||||
|
||||
if region_code.is_none() {
|
||||
if let Some(item) = disney_region_from_main_page(client).await {
|
||||
return item;
|
||||
}
|
||||
|
||||
return disney_item("No", None);
|
||||
}
|
||||
|
||||
let region = region_code.unwrap_or_default();
|
||||
|
||||
if region == "JP" {
|
||||
return UnlockItem::checked_region(DISNEY_PLUS, "Yes", ®ion);
|
||||
}
|
||||
|
||||
if is_unavailable {
|
||||
return disney_item("No", None);
|
||||
}
|
||||
Value::Array(array) => array.iter().find_map(|v| find_bool(v, key)),
|
||||
|
||||
match in_supported_location {
|
||||
Some(false) => disney_item(
|
||||
"Soon",
|
||||
Some(format!("{}(即将上线)", UnlockItem::region_label(®ion))),
|
||||
),
|
||||
Some(true) => UnlockItem::checked_region(DISNEY_PLUS, "Yes", ®ion),
|
||||
None => disney_item(format!("Failed (Error: Unknown region status for {region})"), None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,35 +2,30 @@ use reqwest::Client;
|
||||
|
||||
use super::UnlockItem;
|
||||
|
||||
pub(crate) const GEMINI_NAME: &str = "Gemini";
|
||||
const BLOCKED_CODES: [&str; 9] = ["CHN", "RUS", "BLR", "CUB", "IRN", "PRK", "SYR", "HKG", "MAC"];
|
||||
const REGION_MARKER: &str = ",2,1,200,\"";
|
||||
|
||||
pub(super) async fn check_gemini(client: &Client) -> UnlockItem {
|
||||
let url = "https://gemini.google.com";
|
||||
let failed = || UnlockItem::checked("Gemini", "Failed", None);
|
||||
|
||||
let response = match client.get(url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(_) => return failed(),
|
||||
};
|
||||
let body = match response.text().await {
|
||||
Ok(b) => b,
|
||||
Err(_) => return failed(),
|
||||
let Ok(response) = client.get("https://gemini.google.com").send().await else {
|
||||
return UnlockItem::checked(GEMINI_NAME, "Failed", None);
|
||||
};
|
||||
|
||||
let country_code = body
|
||||
.find(REGION_MARKER)
|
||||
.and_then(|i| {
|
||||
let start = i + REGION_MARKER.len();
|
||||
body.get(start..start + 3)
|
||||
})
|
||||
.filter(|s| s.bytes().all(|b| b.is_ascii_uppercase()));
|
||||
let Ok(body) = response.text().await else {
|
||||
return UnlockItem::checked(GEMINI_NAME, "Failed", None);
|
||||
};
|
||||
|
||||
match country_code {
|
||||
Some(code) => {
|
||||
let status = if BLOCKED_CODES.contains(&code) { "No" } else { "Yes" };
|
||||
UnlockItem::checked_region("Gemini", status, code)
|
||||
}
|
||||
None => failed(),
|
||||
}
|
||||
let Some(code) = body
|
||||
.split_once(REGION_MARKER)
|
||||
.and_then(|(_, rest)| rest.get(..3))
|
||||
.filter(|code| code.bytes().all(|c| c.is_ascii_uppercase()))
|
||||
else {
|
||||
return UnlockItem::checked(GEMINI_NAME, "Failed", None);
|
||||
};
|
||||
|
||||
UnlockItem::checked_region(
|
||||
GEMINI_NAME,
|
||||
if BLOCKED_CODES.contains(&code) { "No" } else { "Yes" },
|
||||
code,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{future::Future, sync::Arc};
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::Client;
|
||||
use tokio::task::JoinSet;
|
||||
@@ -19,95 +19,129 @@ mod types;
|
||||
mod utils;
|
||||
mod youtube;
|
||||
|
||||
pub use types::{UnlockItem, default_unlock_items};
|
||||
pub use types::UnlockItem;
|
||||
|
||||
use bahamut::check_bahamut_anime;
|
||||
use bilibili::{check_bilibili_china_mainland, check_bilibili_hk_mc_tw};
|
||||
use chatgpt::check_chatgpt_combined;
|
||||
use claude::check_claude;
|
||||
use disney_plus::check_disney_plus;
|
||||
use gemini::check_gemini;
|
||||
use netflix::check_netflix;
|
||||
use prime_video::check_prime_video;
|
||||
use spotify::check_spotify;
|
||||
use tiktok::check_tiktok;
|
||||
use youtube::check_youtube_premium;
|
||||
const MAX_CONCURRENT_CHECKS: usize = 4;
|
||||
|
||||
type UnlockResults = Vec<UnlockItem>;
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum UnlockCheck {
|
||||
BilibiliChinaMainland,
|
||||
BilibiliHkMcTw,
|
||||
ChatGptWeb,
|
||||
Claude,
|
||||
Gemini,
|
||||
YouTubePremium,
|
||||
BahamutAnime,
|
||||
Netflix,
|
||||
DisneyPlus,
|
||||
PrimeVideo,
|
||||
Spotify,
|
||||
TikTok,
|
||||
}
|
||||
|
||||
fn spawn_unlock_check<F, Fut>(tasks: &mut JoinSet<UnlockResults>, client: Arc<Client>, check: F)
|
||||
impl UnlockCheck {
|
||||
const ALL: &[Self] = &[
|
||||
Self::BilibiliChinaMainland,
|
||||
Self::BilibiliHkMcTw,
|
||||
Self::ChatGptWeb,
|
||||
Self::Claude,
|
||||
Self::Gemini,
|
||||
Self::YouTubePremium,
|
||||
Self::BahamutAnime,
|
||||
Self::Netflix,
|
||||
Self::DisneyPlus,
|
||||
Self::PrimeVideo,
|
||||
Self::Spotify,
|
||||
Self::TikTok,
|
||||
];
|
||||
|
||||
fn from_name(name: &str) -> Option<Self> {
|
||||
Self::ALL.iter().copied().find(|check| check.name() == name)
|
||||
}
|
||||
|
||||
const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::BilibiliChinaMainland => bilibili::BILIBILI_CHINA_MAINLAND_NAME,
|
||||
Self::BilibiliHkMcTw => bilibili::BILIBILI_HK_MC_TW_NAME,
|
||||
Self::ChatGptWeb => chatgpt::CHATGPT_WEB_NAME,
|
||||
Self::Claude => claude::CLAUDE_NAME,
|
||||
Self::Gemini => gemini::GEMINI_NAME,
|
||||
Self::YouTubePremium => youtube::YOUTUBE_PREMIUM_NAME,
|
||||
Self::BahamutAnime => bahamut::BAHAMUT_ANIME_NAME,
|
||||
Self::Netflix => netflix::NETFLIX_NAME,
|
||||
Self::DisneyPlus => disney_plus::DISNEY_NAME,
|
||||
Self::PrimeVideo => prime_video::PRIME_VIDEO_NAME,
|
||||
Self::Spotify => spotify::SPOTIFY_NAME,
|
||||
Self::TikTok => tiktok::TIKTOK_NAME,
|
||||
}
|
||||
}
|
||||
|
||||
async fn check(self, client: &Client) -> UnlockItem {
|
||||
match self {
|
||||
Self::BilibiliChinaMainland => bilibili::check_bilibili_china_mainland(client).await,
|
||||
Self::BilibiliHkMcTw => bilibili::check_bilibili_hk_mc_tw(client).await,
|
||||
Self::ChatGptWeb => chatgpt::check_chatgpt(client).await,
|
||||
Self::Claude => claude::check_claude(client).await,
|
||||
Self::Gemini => gemini::check_gemini(client).await,
|
||||
Self::YouTubePremium => youtube::check_youtube_premium(client).await,
|
||||
Self::BahamutAnime => bahamut::check_bahamut_anime(client).await,
|
||||
Self::Netflix => netflix::check_netflix(client).await,
|
||||
Self::DisneyPlus => disney_plus::check_disney_plus(client).await,
|
||||
Self::PrimeVideo => prime_video::check_prime_video(client).await,
|
||||
Self::Spotify => spotify::check_spotify(client).await,
|
||||
Self::TikTok => tiktok::check_tiktok(client).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_with_timeout(self, client: &Client) -> UnlockItem {
|
||||
tokio::time::timeout(Duration::from_secs(15), self.check(client))
|
||||
.await
|
||||
.unwrap_or_else(|_| UnlockItem::checked(self.name(), "Failed", None))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_unlock_items() -> Vec<UnlockItem> {
|
||||
UnlockCheck::ALL
|
||||
.iter()
|
||||
.map(|check| UnlockItem::pending(check.name()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn check_media_unlock<F>(client: &Client, on_complete: F) -> Vec<UnlockItem>
|
||||
where
|
||||
F: FnOnce(Arc<Client>) -> Fut + Send + 'static,
|
||||
Fut: Future<Output = UnlockResults> + Send + 'static,
|
||||
F: Fn(&UnlockItem) + Send,
|
||||
{
|
||||
tasks.spawn(async move { check(client).await });
|
||||
}
|
||||
|
||||
fn single_result(item: UnlockItem) -> UnlockResults {
|
||||
vec![item]
|
||||
}
|
||||
|
||||
pub async fn check_media_unlock() -> Result<Vec<UnlockItem>, String> {
|
||||
let client = match Client::builder()
|
||||
.use_rustls_tls()
|
||||
.user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36")
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
.tcp_keepalive(std::time::Duration::from_secs(60))
|
||||
.connection_verbose(true)
|
||||
.build() {
|
||||
Ok(client) => client,
|
||||
Err(e) => return Err(format!("创建HTTP客户端失败: {e}")),
|
||||
let mut tasks = JoinSet::new();
|
||||
let mut checks = UnlockCheck::ALL.iter().copied();
|
||||
let spawn = |tasks: &mut JoinSet<UnlockItem>, check: UnlockCheck| {
|
||||
let client = client.clone();
|
||||
tasks.spawn(async move { check.check_with_timeout(&client).await });
|
||||
};
|
||||
|
||||
let mut tasks = JoinSet::new();
|
||||
let client_arc = Arc::new(client);
|
||||
|
||||
spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move {
|
||||
single_result(check_bilibili_china_mainland(&client).await)
|
||||
});
|
||||
spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move {
|
||||
single_result(check_bilibili_hk_mc_tw(&client).await)
|
||||
});
|
||||
spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move {
|
||||
check_chatgpt_combined(&client).await
|
||||
});
|
||||
spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move {
|
||||
single_result(check_claude(&client).await)
|
||||
});
|
||||
spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move {
|
||||
single_result(check_gemini(&client).await)
|
||||
});
|
||||
spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move {
|
||||
single_result(check_youtube_premium(&client).await)
|
||||
});
|
||||
spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move {
|
||||
single_result(check_bahamut_anime(&client).await)
|
||||
});
|
||||
spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move {
|
||||
single_result(check_netflix(&client).await)
|
||||
});
|
||||
spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move {
|
||||
single_result(check_disney_plus(&client).await)
|
||||
});
|
||||
spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move {
|
||||
single_result(check_spotify(&client).await)
|
||||
});
|
||||
spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move {
|
||||
single_result(check_tiktok(&client).await)
|
||||
});
|
||||
spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move {
|
||||
single_result(check_prime_video(&client).await)
|
||||
});
|
||||
for check in checks.by_ref().take(MAX_CONCURRENT_CHECKS) {
|
||||
spawn(&mut tasks, check);
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
while let Some(res) = tasks.join_next().await {
|
||||
match res {
|
||||
Ok(items) => results.extend(items),
|
||||
Ok(item) => {
|
||||
on_complete(&item);
|
||||
results.push(item);
|
||||
}
|
||||
Err(e) => logging!(error, Type::Network, "任务执行失败: {e}"),
|
||||
}
|
||||
|
||||
if let Some(check) = checks.next() {
|
||||
spawn(&mut tasks, check);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
results
|
||||
}
|
||||
|
||||
pub async fn check_media_unlock_item(client: &Client, name: &str) -> Result<UnlockItem, String> {
|
||||
let check = UnlockCheck::from_name(name).ok_or_else(|| format!("未知的流媒体检测项目: {name}"))?;
|
||||
|
||||
Ok(check.check_with_timeout(client).await)
|
||||
}
|
||||
|
||||
@@ -1,132 +1,127 @@
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde::Deserialize;
|
||||
|
||||
use clash_verge_logging::{Type, logging};
|
||||
|
||||
use super::UnlockItem;
|
||||
|
||||
const NETFLIX: &str = "Netflix";
|
||||
pub(crate) const NETFLIX_NAME: &str = "Netflix";
|
||||
|
||||
fn netflix_item(status: impl Into<String>, region: Option<String>) -> UnlockItem {
|
||||
UnlockItem::checked(NETFLIX, status, region)
|
||||
const TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FastResponse {
|
||||
targets: Vec<FastTarget>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FastTarget {
|
||||
location: FastLocation,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FastLocation {
|
||||
country: String,
|
||||
}
|
||||
|
||||
fn item(status: impl Into<String>) -> UnlockItem {
|
||||
UnlockItem::checked(NETFLIX_NAME, status, None)
|
||||
}
|
||||
|
||||
pub(super) async fn check_netflix(client: &Client) -> UnlockItem {
|
||||
let cdn_result = check_netflix_cdn(client).await;
|
||||
if cdn_result.status == "Yes" {
|
||||
return cdn_result;
|
||||
let cdn = check_netflix_cdn(client).await;
|
||||
if cdn.status == "Yes" || cdn.status.starts_with("No") {
|
||||
return cdn;
|
||||
}
|
||||
|
||||
let url1 = "https://www.netflix.com/title/81280792";
|
||||
let url2 = "https://www.netflix.com/title/70143836";
|
||||
let (r1, r2) = tokio::join!(check_title(client, "81280792"), check_title(client, "70143836"),);
|
||||
|
||||
let result1 = client
|
||||
.get(url1)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
let (Ok(status1), Ok(status2)) = (r1, r2) else {
|
||||
return item("Failed");
|
||||
};
|
||||
|
||||
if status1 == StatusCode::NOT_FOUND && status2 == StatusCode::NOT_FOUND {
|
||||
return item("Originals Only");
|
||||
}
|
||||
|
||||
if status1 == StatusCode::FORBIDDEN || status2 == StatusCode::FORBIDDEN {
|
||||
return item("No");
|
||||
}
|
||||
|
||||
if [status1, status2]
|
||||
.iter()
|
||||
.any(|s| matches!(*s, StatusCode::OK | StatusCode::MOVED_PERMANENTLY))
|
||||
{
|
||||
return check_region(client).await;
|
||||
}
|
||||
|
||||
item(format!("Failed (状态码: {status1}_{status2})"))
|
||||
}
|
||||
|
||||
async fn check_title(client: &Client, id: &str) -> reqwest::Result<StatusCode> {
|
||||
client
|
||||
.get(format!("https://www.netflix.com/title/{id}"))
|
||||
.timeout(TIMEOUT)
|
||||
.send()
|
||||
.await;
|
||||
.await
|
||||
.map(|res| res.status())
|
||||
}
|
||||
|
||||
if let Err(e) = &result1 {
|
||||
logging!(error, Type::Network, "Netflix请求错误: {e}");
|
||||
return netflix_item("Failed", None);
|
||||
}
|
||||
|
||||
let result2 = client
|
||||
.get(url2)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
async fn check_region(client: &Client) -> UnlockItem {
|
||||
let response = match client
|
||||
.get("https://www.netflix.com/title/80018499")
|
||||
.timeout(TIMEOUT)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if let Err(e) = &result2 {
|
||||
logging!(error, Type::Network, "Netflix请求错误: {e}");
|
||||
return netflix_item("Failed", None);
|
||||
}
|
||||
|
||||
let status1 = match result1 {
|
||||
Ok(response) => response.status().as_u16(),
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
logging!(error, Type::Network, "Failed to get Netflix response 1: {}", e);
|
||||
return netflix_item("Failed", None);
|
||||
logging!(error, Type::Network, "获取 Netflix 区域信息失败: {e}");
|
||||
return item("Yes (但无法获取区域)");
|
||||
}
|
||||
};
|
||||
|
||||
let status2 = match result2 {
|
||||
Ok(response) => response.status().as_u16(),
|
||||
Err(e) => {
|
||||
logging!(error, Type::Network, "Failed to get Netflix response 2: {}", e);
|
||||
return netflix_item("Failed", None);
|
||||
}
|
||||
};
|
||||
let region = response
|
||||
.headers()
|
||||
.get("location")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|location| location.split('/').nth(3))
|
||||
.and_then(|region| region.split('-').next())
|
||||
.filter(|region| !region.is_empty())
|
||||
.unwrap_or("us");
|
||||
|
||||
if status1 == 404 && status2 == 404 {
|
||||
return netflix_item("Originals Only", None);
|
||||
}
|
||||
|
||||
if status1 == 403 || status2 == 403 {
|
||||
return netflix_item("No", None);
|
||||
}
|
||||
|
||||
if status1 == 200 || status1 == 301 || status2 == 200 || status2 == 301 {
|
||||
let test_url = "https://www.netflix.com/title/80018499";
|
||||
match client
|
||||
.get(test_url)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
if let Some(location) = response.headers().get("location")
|
||||
&& let Ok(location_str) = location.to_str()
|
||||
{
|
||||
let parts: Vec<&str> = location_str.split('/').collect();
|
||||
if parts.len() >= 4 {
|
||||
let region_code = parts[3].split('-').next().unwrap_or("unknown");
|
||||
return UnlockItem::checked_region(NETFLIX, "Yes", region_code);
|
||||
}
|
||||
}
|
||||
|
||||
UnlockItem::checked_region(NETFLIX, "Yes", "us")
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(error, Type::Network, "获取Netflix区域信息失败: {e}");
|
||||
netflix_item("Yes (但无法获取区域)", None)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
netflix_item(format!("Failed (状态码: {status1}_{status2}"), None)
|
||||
}
|
||||
UnlockItem::checked_region(NETFLIX_NAME, "Yes", region)
|
||||
}
|
||||
|
||||
async fn check_netflix_cdn(client: &Client) -> UnlockItem {
|
||||
let url = "https://api.fast.com/netflix/speedtest/v2?https=true&token=YXNkZmFzZGxmbnNkYWZoYXNkZmhrYWxm&urlCount=5";
|
||||
|
||||
match client.get(url).timeout(std::time::Duration::from_secs(30)).send().await {
|
||||
Ok(response) => {
|
||||
if response.status().as_u16() == 403 {
|
||||
return netflix_item("No (IP Banned By Netflix)", None);
|
||||
}
|
||||
|
||||
match response.json::<Value>().await {
|
||||
Ok(data) => {
|
||||
if let Some(targets) = data.get("targets").and_then(|t| t.as_array())
|
||||
&& !targets.is_empty()
|
||||
&& let Some(location) = targets[0].get("location")
|
||||
&& let Some(country) = location.get("country").and_then(|c| c.as_str())
|
||||
{
|
||||
return UnlockItem::checked_region(NETFLIX, "Yes", country);
|
||||
}
|
||||
|
||||
netflix_item("Unknown", None)
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(error, Type::Network, "解析Fast.com API响应失败: {e}");
|
||||
netflix_item("Failed (解析错误)", None)
|
||||
}
|
||||
}
|
||||
}
|
||||
let response = match client
|
||||
.get("https://api.fast.com/netflix/speedtest/v2?https=true&token=YXNkZmFzZGxmbnNkYWZoYXNkZmhrYWxm&urlCount=5")
|
||||
.timeout(TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
logging!(error, Type::Network, "Fast.com API请求失败: {e}");
|
||||
netflix_item("Failed (CDN API)", None)
|
||||
logging!(error, Type::Network, "Fast.com API 请求失败: {e}");
|
||||
return item("Failed (CDN API)");
|
||||
}
|
||||
};
|
||||
|
||||
if response.status() == StatusCode::FORBIDDEN {
|
||||
return item("No (IP Banned By Netflix)");
|
||||
}
|
||||
|
||||
match response.json::<FastResponse>().await {
|
||||
Ok(data) => data
|
||||
.targets
|
||||
.first()
|
||||
.map(|target| UnlockItem::checked_region(NETFLIX_NAME, "Yes", &target.location.country))
|
||||
.unwrap_or_else(|| item("Unknown")),
|
||||
Err(e) => {
|
||||
logging!(error, Type::Network, "解析 Fast.com API 响应失败: {e}");
|
||||
item("Failed (解析错误)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +1,35 @@
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
|
||||
use clash_verge_logging::{Type, logging};
|
||||
|
||||
use super::UnlockItem;
|
||||
|
||||
const PRIME_VIDEO: &str = "Prime Video";
|
||||
|
||||
fn prime_video_item(status: impl Into<String>, region: Option<String>) -> UnlockItem {
|
||||
UnlockItem::checked(PRIME_VIDEO, status, region)
|
||||
}
|
||||
pub(crate) const PRIME_VIDEO_NAME: &str = "Prime Video";
|
||||
|
||||
pub(super) async fn check_prime_video(client: &Client) -> UnlockItem {
|
||||
let url = "https://www.primevideo.com";
|
||||
|
||||
let result = client.get(url).send().await;
|
||||
|
||||
let response = match result {
|
||||
let response = match client.get("https://www.primevideo.com").send().await {
|
||||
Ok(response) => response,
|
||||
Err(_) => {
|
||||
return prime_video_item("Failed (Network Connection)", None);
|
||||
return UnlockItem::checked(PRIME_VIDEO_NAME, "Failed (Network Connection)", None);
|
||||
}
|
||||
};
|
||||
|
||||
match response.text().await {
|
||||
Ok(body) => {
|
||||
let is_blocked = body.contains("isServiceRestricted");
|
||||
|
||||
let region_re = match Regex::new(r#""currentTerritory":"([^"]+)""#) {
|
||||
Ok(re) => re,
|
||||
Err(e) => {
|
||||
logging!(
|
||||
error,
|
||||
Type::Network,
|
||||
"Failed to compile Prime Video region regex: {}",
|
||||
e
|
||||
);
|
||||
return prime_video_item("Failed (Regex Error)", None);
|
||||
}
|
||||
};
|
||||
let region_code = region_re
|
||||
.captures(&body)
|
||||
.and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()));
|
||||
|
||||
if is_blocked {
|
||||
return prime_video_item("No (Service Not Available)", None);
|
||||
}
|
||||
|
||||
if let Some(region) = region_code {
|
||||
return UnlockItem::checked_region(PRIME_VIDEO, "Yes", ®ion);
|
||||
}
|
||||
|
||||
if !is_blocked {
|
||||
return prime_video_item("Failed (Error: PAGE ERROR)", None);
|
||||
}
|
||||
|
||||
prime_video_item("Failed (Error: Unknown Region)", None)
|
||||
let body = match response.text().await {
|
||||
Ok(body) => body,
|
||||
Err(_) => {
|
||||
return UnlockItem::checked(PRIME_VIDEO_NAME, "Failed (Cannot Read Response)", None);
|
||||
}
|
||||
Err(_) => prime_video_item("Failed (Error: Cannot read response)", None),
|
||||
};
|
||||
|
||||
if body.contains("isServiceRestricted") {
|
||||
return UnlockItem::checked(PRIME_VIDEO_NAME, "No (Service Not Available)", None);
|
||||
}
|
||||
|
||||
let region = body
|
||||
.split_once(r#""currentTerritory":""#)
|
||||
.and_then(|(_, rest)| rest.split_once('"'))
|
||||
.map(|(region, _)| region);
|
||||
|
||||
match region {
|
||||
Some(region) => UnlockItem::checked_region(PRIME_VIDEO_NAME, "Yes", region),
|
||||
None => UnlockItem::checked(PRIME_VIDEO_NAME, "Failed (PAGE ERROR)", None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +1,61 @@
|
||||
use reqwest::{Client, Url};
|
||||
use reqwest::{Client, StatusCode, Url};
|
||||
|
||||
use crate::utils::classify_restricted_status;
|
||||
|
||||
use super::UnlockItem;
|
||||
|
||||
pub(crate) const SPOTIFY_NAME: &str = "Spotify";
|
||||
|
||||
pub(super) async fn check_spotify(client: &Client) -> UnlockItem {
|
||||
let url = "https://www.spotify.com/api/content/v1/country-selector?platform=web&format=json";
|
||||
let response = match client
|
||||
.get("https://www.spotify.com/api/content/v1/country-selector?platform=web&format=json")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(_) => return UnlockItem::checked(SPOTIFY_NAME, "Failed", None),
|
||||
};
|
||||
|
||||
match client.get(url).send().await {
|
||||
Ok(response) => {
|
||||
let final_url = response.url().clone();
|
||||
let status_code = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let status = response.status();
|
||||
let final_url = response.url().clone();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
|
||||
let region = extract_region(&final_url).or_else(|| extract_region_from_body(&body));
|
||||
let status = determine_status(status_code.as_u16(), &body);
|
||||
let region = extract_region(&final_url).or_else(|| extract_region_from_body(&body));
|
||||
|
||||
UnlockItem::checked("Spotify", status, region)
|
||||
}
|
||||
Err(_) => UnlockItem::checked("Spotify", "Failed", None),
|
||||
}
|
||||
UnlockItem::checked(SPOTIFY_NAME, determine_status(status, &body), region)
|
||||
}
|
||||
|
||||
fn determine_status(status: u16, body: &str) -> &'static str {
|
||||
if status == 403 || status == 451 {
|
||||
return "No";
|
||||
fn determine_status(status: StatusCode, body: &str) -> &'static str {
|
||||
if let Some(status) = classify_restricted_status(status) {
|
||||
return status;
|
||||
}
|
||||
|
||||
if !(200..300).contains(&status) {
|
||||
return "Failed";
|
||||
if body.to_ascii_lowercase().contains("not available in your country") {
|
||||
"No"
|
||||
} else {
|
||||
"Yes"
|
||||
}
|
||||
|
||||
let body_lower = body.to_lowercase();
|
||||
if body_lower.contains("not available in your country") {
|
||||
return "No";
|
||||
}
|
||||
|
||||
"Yes"
|
||||
}
|
||||
|
||||
fn extract_region(url: &Url) -> Option<String> {
|
||||
let mut segments = url.path_segments()?;
|
||||
let first_segment = segments.next()?;
|
||||
let segment = url.path_segments()?.next()?;
|
||||
|
||||
if first_segment.is_empty() || first_segment == "api" {
|
||||
if segment.is_empty() || segment == "api" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let country_code = first_segment.split('-').next().unwrap_or(first_segment);
|
||||
let upper = country_code.to_uppercase();
|
||||
Some(UnlockItem::region_label(&upper))
|
||||
let code = segment.split('-').next()?.to_ascii_uppercase();
|
||||
|
||||
Some(UnlockItem::region_label(&code))
|
||||
}
|
||||
|
||||
fn extract_region_from_body(body: &str) -> Option<String> {
|
||||
let marker = "\"countryCode\":\"";
|
||||
if let Some(idx) = body.find(marker) {
|
||||
let start = idx + marker.len();
|
||||
let rest = &body[start..];
|
||||
if let Some(end) = rest.find('"') {
|
||||
let code = rest[..end].to_uppercase();
|
||||
if !code.is_empty() {
|
||||
return Some(UnlockItem::region_label(&code));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
let code = body
|
||||
.split_once(r#""countryCode":""#)?
|
||||
.1
|
||||
.split_once('"')?
|
||||
.0
|
||||
.to_ascii_uppercase();
|
||||
|
||||
(!code.is_empty()).then(|| UnlockItem::region_label(&code))
|
||||
}
|
||||
|
||||
@@ -1,80 +1,66 @@
|
||||
use std::sync::OnceLock;
|
||||
use reqwest::{Client, StatusCode};
|
||||
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use crate::utils::{classify_restricted_status, extract_quoted_field};
|
||||
|
||||
use super::UnlockItem;
|
||||
|
||||
pub(crate) const TIKTOK_NAME: &str = "TikTok";
|
||||
|
||||
pub(super) async fn check_tiktok(client: &Client) -> UnlockItem {
|
||||
let trace_url = "https://www.tiktok.com/cdn-cgi/trace";
|
||||
let (mut status, mut region) = check_url(client, "https://www.tiktok.com/cdn-cgi/trace").await;
|
||||
|
||||
let mut status = String::from("Failed");
|
||||
let mut region = None;
|
||||
if region.is_none() || status == "Failed" {
|
||||
let (fallback_status, fallback_region) = check_url(client, "https://www.tiktok.com/").await;
|
||||
|
||||
if let Ok(response) = client.get(trace_url).send().await {
|
||||
let status_code = response.status().as_u16();
|
||||
if let Ok(body) = response.text().await {
|
||||
status = determine_status(status_code, &body).to_string();
|
||||
region = extract_region_from_body(&body);
|
||||
if status != "No" {
|
||||
status = fallback_status;
|
||||
}
|
||||
|
||||
region = region.or(fallback_region);
|
||||
}
|
||||
|
||||
if (region.is_none() || status == "Failed")
|
||||
&& let Ok(response) = client.get("https://www.tiktok.com/").send().await
|
||||
{
|
||||
let status_code = response.status().as_u16();
|
||||
if let Ok(body) = response.text().await {
|
||||
let fallback_status = determine_status(status_code, &body);
|
||||
let fallback_region = extract_region_from_body(&body);
|
||||
|
||||
if status != "No" {
|
||||
status = fallback_status.to_string();
|
||||
}
|
||||
|
||||
if region.is_none() {
|
||||
region = fallback_region;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UnlockItem::checked("TikTok", status, region)
|
||||
UnlockItem::checked(TIKTOK_NAME, status, region)
|
||||
}
|
||||
|
||||
fn determine_status(status: u16, body: &str) -> &'static str {
|
||||
if status == 403 || status == 451 {
|
||||
return "No";
|
||||
}
|
||||
async fn check_url(client: &Client, url: &str) -> (&'static str, Option<String>) {
|
||||
let Ok(response) = client.get(url).send().await else {
|
||||
return ("Failed", None);
|
||||
};
|
||||
|
||||
if !(200..300).contains(&status) {
|
||||
return "Failed";
|
||||
}
|
||||
let status = response.status();
|
||||
|
||||
let body_lower = body.to_lowercase();
|
||||
if body_lower.contains("access denied")
|
||||
|| body_lower.contains("not available in your region")
|
||||
|| body_lower.contains("tiktok is not available")
|
||||
{
|
||||
return "No";
|
||||
}
|
||||
let Ok(body) = response.text().await else {
|
||||
return ("Failed", None);
|
||||
};
|
||||
|
||||
"Yes"
|
||||
(determine_status(status, &body), extract_region(&body))
|
||||
}
|
||||
|
||||
fn extract_region_from_body(body: &str) -> Option<String> {
|
||||
static REGION_REGEX: OnceLock<Option<Regex>> = OnceLock::new();
|
||||
let regex = REGION_REGEX
|
||||
.get_or_init(|| Regex::new(r#""region"\s*:\s*"([a-zA-Z-]+)""#).ok())
|
||||
.as_ref()?;
|
||||
|
||||
if let Some(caps) = regex.captures(body)
|
||||
&& let Some(matched) = caps.get(1)
|
||||
{
|
||||
let raw = matched.as_str();
|
||||
let country_code = raw.split('-').next().unwrap_or(raw).to_uppercase();
|
||||
if !country_code.is_empty() {
|
||||
return Some(UnlockItem::region_label(&country_code));
|
||||
}
|
||||
fn determine_status(status: StatusCode, body: &str) -> &'static str {
|
||||
if let Some(status) = classify_restricted_status(status) {
|
||||
return status;
|
||||
}
|
||||
|
||||
None
|
||||
let body = body.to_ascii_lowercase();
|
||||
|
||||
if [
|
||||
"access denied",
|
||||
"not available in your region",
|
||||
"tiktok is not available",
|
||||
]
|
||||
.iter()
|
||||
.any(|text| body.contains(text))
|
||||
{
|
||||
"No"
|
||||
} else {
|
||||
"Yes"
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_region(body: &str) -> Option<String> {
|
||||
let region = extract_quoted_field(body, "region")?;
|
||||
|
||||
let code = region.split('-').next()?.to_ascii_uppercase();
|
||||
|
||||
(!code.is_empty()).then(|| UnlockItem::region_label(&code))
|
||||
}
|
||||
|
||||
@@ -38,26 +38,3 @@ impl UnlockItem {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_UNLOCK_ITEM_NAMES: [&str; 13] = [
|
||||
"哔哩哔哩大陆",
|
||||
"哔哩哔哩港澳台",
|
||||
"ChatGPT iOS",
|
||||
"ChatGPT Web",
|
||||
"Claude",
|
||||
"Gemini",
|
||||
"YouTube Premium",
|
||||
"Bahamut Anime",
|
||||
"Netflix",
|
||||
"Disney+",
|
||||
"Prime Video",
|
||||
"Spotify",
|
||||
"TikTok",
|
||||
];
|
||||
|
||||
pub fn default_unlock_items() -> Vec<UnlockItem> {
|
||||
DEFAULT_UNLOCK_ITEM_NAMES
|
||||
.iter()
|
||||
.map(|name| UnlockItem::pending(name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use chrono::Local;
|
||||
use reqwest::{Client, StatusCode};
|
||||
|
||||
pub fn get_local_date_string() -> String {
|
||||
let now = Local::now();
|
||||
@@ -37,3 +38,36 @@ fn alpha2_to_emoji(alpha2: &str) -> String {
|
||||
.and_then(|x| char::from_u32(c2).map(|y| format!("{x}{y}")))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) async fn get_text(client: &Client, url: &str) -> Option<String> {
|
||||
client.get(url).send().await.ok()?.text().await.ok()
|
||||
}
|
||||
|
||||
pub(crate) async fn get_trace_location(client: &Client, url: &str) -> Option<String> {
|
||||
get_text(client, url)
|
||||
.await?
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("loc="))
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
pub(crate) fn extract_quoted_field<'a>(body: &'a str, key: &str) -> Option<&'a str> {
|
||||
let (_, rest) = body.split_once(&format!(r#""{key}""#))?;
|
||||
let value = rest.split_once(':')?.1.trim_start();
|
||||
let value = value.strip_prefix('"')?;
|
||||
|
||||
Some(value.split_once('"')?.0)
|
||||
}
|
||||
|
||||
pub(crate) fn classify_restricted_status(status: StatusCode) -> Option<&'static str> {
|
||||
if matches!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN | StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS
|
||||
) {
|
||||
Some("No")
|
||||
} else if !status.is_success() {
|
||||
Some("Failed")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,67 @@
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use reqwest::{Client, StatusCode};
|
||||
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use crate::utils::extract_quoted_field;
|
||||
|
||||
use super::UnlockItem;
|
||||
|
||||
pub(crate) const YOUTUBE_PREMIUM_NAME: &str = "YouTube Premium";
|
||||
|
||||
pub(super) async fn check_youtube_premium(client: &Client) -> UnlockItem {
|
||||
let url = "https://www.youtube.com/premium?hl=en";
|
||||
let item = |status: &str, region: Option<String>| UnlockItem::checked("YouTube Premium", status, region);
|
||||
let Ok(response) = client.get("https://www.youtube.com/premium?hl=en").send().await else {
|
||||
return UnlockItem::checked(YOUTUBE_PREMIUM_NAME, "Failed", None);
|
||||
};
|
||||
|
||||
match client.get(url).send().await {
|
||||
Ok(response) => {
|
||||
let status_code = response.status().as_u16();
|
||||
let status = response.status();
|
||||
|
||||
if let Ok(body) = response.text().await {
|
||||
let body_lower = body.to_ascii_lowercase();
|
||||
let region = [
|
||||
r#"id=["']country-code["'][^>]*>\s*([A-Za-z]{2,3})\s*<"#,
|
||||
r#""GL"\s*:\s*"([A-Za-z]{2})""#,
|
||||
r#""countryCode"\s*:\s*"([A-Za-z]{2})""#,
|
||||
r#""country_code"\s*:\s*"([A-Za-z]{2})""#,
|
||||
]
|
||||
.iter()
|
||||
.find_map(|pattern| match Regex::new(pattern) {
|
||||
Ok(re) => re
|
||||
.captures(&body)
|
||||
.and_then(|caps| caps.get(1))
|
||||
.map(|m| m.as_str().trim().to_ascii_uppercase()),
|
||||
Err(e) => {
|
||||
logging!(error, Type::Network, "Failed to compile YouTube Premium regex: {}", e);
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|country_code| UnlockItem::region_label(&country_code));
|
||||
let Ok(body) = response.text().await else {
|
||||
return UnlockItem::checked(YOUTUBE_PREMIUM_NAME, "Failed", None);
|
||||
};
|
||||
|
||||
let status = if body_lower.contains("youtube premium is not available in your country")
|
||||
|| body_lower.contains("premium is not available in your country")
|
||||
|| body_lower.contains("premium is not available in your region")
|
||||
{
|
||||
"No"
|
||||
} else if (200..300).contains(&status_code)
|
||||
&& (body_lower.contains("youtube premium")
|
||||
|| body_lower.contains("ad-free")
|
||||
|| body_lower.contains(r#""browseid":"spunlimited""#))
|
||||
{
|
||||
"Yes"
|
||||
} else {
|
||||
"Failed"
|
||||
};
|
||||
let region = extract_region(&body).map(|code| UnlockItem::region_label(&code));
|
||||
|
||||
item(status, region)
|
||||
} else {
|
||||
item("Failed", None)
|
||||
}
|
||||
}
|
||||
Err(_) => item("Failed", None),
|
||||
UnlockItem::checked(YOUTUBE_PREMIUM_NAME, determine_status(status, &body), region)
|
||||
}
|
||||
|
||||
fn determine_status(status: StatusCode, body: &str) -> &'static str {
|
||||
let body = body.to_ascii_lowercase();
|
||||
|
||||
if [
|
||||
"youtube premium is not available in your country",
|
||||
"premium is not available in your country",
|
||||
"premium is not available in your region",
|
||||
]
|
||||
.iter()
|
||||
.any(|text| body.contains(text))
|
||||
{
|
||||
return "No";
|
||||
}
|
||||
|
||||
if status.is_success()
|
||||
&& ["youtube premium", "ad-free", r#""browseid":"spunlimited""#]
|
||||
.iter()
|
||||
.any(|text| body.contains(text))
|
||||
{
|
||||
"Yes"
|
||||
} else {
|
||||
"Failed"
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_region(body: &str) -> Option<String> {
|
||||
["GL", "countryCode", "country_code"]
|
||||
.iter()
|
||||
.find_map(|key| extract_quoted_field(body, key))
|
||||
.or_else(|| extract_country_code(body))
|
||||
.map(str::to_ascii_uppercase)
|
||||
}
|
||||
|
||||
fn extract_country_code(body: &str) -> Option<&str> {
|
||||
let rest = body
|
||||
.split_once(r#"id="country-code""#)
|
||||
.or_else(|| body.split_once("id='country-code'"))?
|
||||
.1;
|
||||
|
||||
let value = rest.split_once('>')?.1.split_once('<')?.0.trim();
|
||||
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,41 @@
|
||||
pub use clash_verge_media_unlock::UnlockItem;
|
||||
use tauri::command;
|
||||
use reqwest::{Client, Proxy};
|
||||
use std::time::Duration;
|
||||
use tauri::{command, ipc::Channel};
|
||||
|
||||
#[command]
|
||||
pub async fn get_unlock_items() -> Result<Vec<UnlockItem>, String> {
|
||||
Ok(clash_verge_media_unlock::default_unlock_items())
|
||||
pub fn get_unlock_items() -> Vec<UnlockItem> {
|
||||
clash_verge_media_unlock::default_unlock_items()
|
||||
}
|
||||
|
||||
async fn create_client() -> Result<Client, String> {
|
||||
let port = crate::config::MixedPort::effective().await;
|
||||
let proxy = Proxy::all(format!("http://127.0.0.1:{port}")).map_err(|error| format!("创建代理失败: {error}"))?;
|
||||
|
||||
Client::builder()
|
||||
.use_rustls_tls()
|
||||
.http1_only()
|
||||
.user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36")
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
.tcp_keepalive(Duration::from_secs(60))
|
||||
.cookie_store(true)
|
||||
.proxy(proxy)
|
||||
.build()
|
||||
.map_err(|error| format!("创建HTTP客户端失败: {error}"))
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn check_media_unlock() -> Result<Vec<UnlockItem>, String> {
|
||||
clash_verge_media_unlock::check_media_unlock().await
|
||||
pub async fn check_media_unlock(on_complete: Channel<UnlockItem>) -> Result<Vec<UnlockItem>, String> {
|
||||
let client = create_client().await?;
|
||||
Ok(clash_verge_media_unlock::check_media_unlock(&client, |item| {
|
||||
let _ = on_complete.send(item.clone());
|
||||
})
|
||||
.await)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn check_media_unlock_item(name: String) -> Result<UnlockItem, String> {
|
||||
let client = create_client().await?;
|
||||
clash_verge_media_unlock::check_media_unlock_item(&client, &name).await
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@ mod app_init {
|
||||
cmd::restore_webdav_backup,
|
||||
cmd::get_unlock_items,
|
||||
cmd::check_media_unlock,
|
||||
cmd::check_media_unlock_item,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+64
-156
@@ -19,9 +19,9 @@ import {
|
||||
alpha,
|
||||
useTheme,
|
||||
} from '@mui/material'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { Channel, invoke } from '@tauri-apps/api/core'
|
||||
import { useLockFn } from 'ahooks'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { BaseEmpty, BasePage } from '@/components/base'
|
||||
@@ -35,7 +35,6 @@ interface UnlockItem {
|
||||
}
|
||||
|
||||
const UNLOCK_RESULTS_STORAGE_KEY = 'clash_verge_unlock_results'
|
||||
const UNLOCK_RESULTS_TIME_KEY = 'clash_verge_unlock_time'
|
||||
|
||||
const STATUS_LABEL_KEYS: Record<string, string> = {
|
||||
Pending: 'tests.statuses.test.pending',
|
||||
@@ -95,201 +94,107 @@ const UnlockPage = () => {
|
||||
const theme = useTheme()
|
||||
|
||||
const [unlockItems, setUnlockItems] = useState<UnlockItem[]>([])
|
||||
const unlockItemsRef = useRef<UnlockItem[]>([])
|
||||
const [isCheckingAll, setIsCheckingAll] = useState(false)
|
||||
const [loadingItems, setLoadingItems] = useState<string[]>([])
|
||||
|
||||
const sortItemsByName = useCallback((items: UnlockItem[]) => {
|
||||
return [...items].sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [])
|
||||
const saveResultsToStorage = (items: UnlockItem[]) => {
|
||||
try {
|
||||
localStorage.setItem(UNLOCK_RESULTS_STORAGE_KEY, JSON.stringify(items))
|
||||
} catch (err) {
|
||||
console.error('Failed to save results to storage:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const mergeUnlockItems = useCallback(
|
||||
(defaults: UnlockItem[], existing?: UnlockItem[] | null) => {
|
||||
if (!existing || existing.length === 0) {
|
||||
return defaults
|
||||
}
|
||||
|
||||
const normalizedExisting = dedupeUnlockItems(existing)
|
||||
const existingMap = new Map(
|
||||
normalizedExisting.map((item) => [
|
||||
normalizeUnlockName(item.name),
|
||||
item,
|
||||
]),
|
||||
)
|
||||
const merged = defaults.map((item) => {
|
||||
const normalizedName = normalizeUnlockName(item.name)
|
||||
const matchedItem = existingMap.get(normalizedName)
|
||||
if (matchedItem) {
|
||||
return { ...matchedItem, name: item.name }
|
||||
}
|
||||
return item
|
||||
})
|
||||
|
||||
const mergedNameSet = new Set(
|
||||
merged.map((item) => normalizeUnlockName(item.name)),
|
||||
)
|
||||
normalizedExisting.forEach((item) => {
|
||||
const normalizedName = normalizeUnlockName(item.name)
|
||||
if (!mergedNameSet.has(normalizedName)) {
|
||||
merged.push(item)
|
||||
mergedNameSet.add(normalizedName)
|
||||
}
|
||||
})
|
||||
|
||||
return merged
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// 保存测试结果到本地存储
|
||||
const saveResultsToStorage = useCallback(
|
||||
(items: UnlockItem[], time: string | null) => {
|
||||
try {
|
||||
localStorage.setItem(UNLOCK_RESULTS_STORAGE_KEY, JSON.stringify(items))
|
||||
if (time) {
|
||||
localStorage.setItem(UNLOCK_RESULTS_TIME_KEY, time)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save results to storage:', err)
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const loadResultsFromStorage = useCallback((): {
|
||||
items: UnlockItem[] | null
|
||||
time: string | null
|
||||
} => {
|
||||
useEffect(() => {
|
||||
let storedItems: UnlockItem[] = []
|
||||
try {
|
||||
const itemsJson = localStorage.getItem(UNLOCK_RESULTS_STORAGE_KEY)
|
||||
const time = localStorage.getItem(UNLOCK_RESULTS_TIME_KEY)
|
||||
|
||||
if (itemsJson) {
|
||||
const parsedItems = JSON.parse(itemsJson) as UnlockItem[]
|
||||
return {
|
||||
items: dedupeUnlockItems(parsedItems),
|
||||
time,
|
||||
}
|
||||
storedItems = dedupeUnlockItems(JSON.parse(itemsJson) as UnlockItem[])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load results from storage:', err)
|
||||
}
|
||||
|
||||
return { items: null, time: null }
|
||||
}, [])
|
||||
|
||||
const getUnlockItems = useCallback(
|
||||
async (
|
||||
existingItems: UnlockItem[] | null = null,
|
||||
existingTime: string | null = null,
|
||||
) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const defaultItems = await invoke<UnlockItem[]>('get_unlock_items')
|
||||
const mergedItems = mergeUnlockItems(defaultItems, existingItems)
|
||||
const sortedItems = sortItemsByName(mergedItems)
|
||||
|
||||
setUnlockItems(sortedItems)
|
||||
saveResultsToStorage(
|
||||
sortedItems,
|
||||
existingItems && existingItems.length > 0 ? existingTime : null,
|
||||
const existingMap = new Map(
|
||||
storedItems.map((item) => [normalizeUnlockName(item.name), item]),
|
||||
)
|
||||
const mergedItems = defaultItems.map((item) => {
|
||||
const matchedItem = existingMap.get(normalizeUnlockName(item.name))
|
||||
return matchedItem ? { ...matchedItem, name: item.name } : item
|
||||
})
|
||||
|
||||
const sortedItems = mergedItems.sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
)
|
||||
unlockItemsRef.current = sortedItems
|
||||
setUnlockItems(sortedItems)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to get unlock items:', err)
|
||||
}
|
||||
},
|
||||
[mergeUnlockItems, saveResultsToStorage, sortItemsByName],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const { items: storedItems, time: storedTime } = loadResultsFromStorage()
|
||||
|
||||
if (storedItems && storedItems.length > 0) {
|
||||
setUnlockItems(sortItemsByName(storedItems))
|
||||
await getUnlockItems(storedItems, storedTime)
|
||||
} else {
|
||||
await getUnlockItems()
|
||||
}
|
||||
})()
|
||||
}, [getUnlockItems, loadResultsFromStorage, sortItemsByName])
|
||||
|
||||
const invokeWithTimeout = async <T,>(
|
||||
cmd: string,
|
||||
args?: any,
|
||||
timeout = 15000,
|
||||
): Promise<T> => {
|
||||
return Promise.race([
|
||||
invoke<T>(cmd, args),
|
||||
new Promise<T>((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(new Error(t('tests.unlock.page.messages.detectionTimeout'))),
|
||||
timeout,
|
||||
),
|
||||
),
|
||||
])
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 执行全部项目检测
|
||||
const checkAllMedia = useLockFn(async () => {
|
||||
const onComplete = new Channel<UnlockItem>((result) => {
|
||||
const updatedItems = unlockItemsRef.current.map((item) =>
|
||||
item.name === result.name ? result : item,
|
||||
)
|
||||
unlockItemsRef.current = updatedItems
|
||||
setUnlockItems(updatedItems)
|
||||
setLoadingItems((items) => items.filter((name) => name !== result.name))
|
||||
})
|
||||
|
||||
try {
|
||||
setIsCheckingAll(true)
|
||||
const result = await invokeWithTimeout<UnlockItem[]>('check_media_unlock')
|
||||
const sortedItems = sortItemsByName(dedupeUnlockItems(result))
|
||||
setLoadingItems(unlockItems.map((item) => item.name))
|
||||
const result = await invoke<UnlockItem[]>('check_media_unlock', {
|
||||
onComplete,
|
||||
})
|
||||
const sortedItems = result.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
unlockItemsRef.current = sortedItems
|
||||
setUnlockItems(sortedItems)
|
||||
const currentTime = new Date().toLocaleString()
|
||||
|
||||
saveResultsToStorage(sortedItems, currentTime)
|
||||
|
||||
setIsCheckingAll(false)
|
||||
saveResultsToStorage(sortedItems)
|
||||
} catch (err: any) {
|
||||
setIsCheckingAll(false)
|
||||
showNotice.error('tests.unlock.page.messages.detectionTimeout', err)
|
||||
console.error('Failed to check media unlock:', err)
|
||||
} finally {
|
||||
setLoadingItems([])
|
||||
setIsCheckingAll(false)
|
||||
}
|
||||
})
|
||||
|
||||
// 检测单个流媒体服务
|
||||
const checkSingleMedia = useLockFn(async (name: string) => {
|
||||
const checkSingleMedia = async (name: string) => {
|
||||
setLoadingItems((items) => [...items, name])
|
||||
try {
|
||||
setLoadingItems((prev) => [...prev, name])
|
||||
const result = await invokeWithTimeout<UnlockItem[]>('check_media_unlock')
|
||||
const dedupedResult = dedupeUnlockItems(result)
|
||||
|
||||
const normalizedTargetName = normalizeUnlockName(name)
|
||||
const targetItem = dedupedResult.find(
|
||||
(item: UnlockItem) =>
|
||||
normalizeUnlockName(item.name) === normalizedTargetName,
|
||||
const result = await invoke<UnlockItem>('check_media_unlock_item', {
|
||||
name,
|
||||
})
|
||||
const updatedItems = unlockItemsRef.current.map((item) =>
|
||||
item.name === name ? result : item,
|
||||
)
|
||||
|
||||
if (targetItem) {
|
||||
const updatedItems = sortItemsByName(
|
||||
dedupeUnlockItems(
|
||||
unlockItems.map((item: UnlockItem) =>
|
||||
normalizeUnlockName(item.name) === normalizedTargetName
|
||||
? targetItem
|
||||
: item,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
setUnlockItems(updatedItems)
|
||||
const currentTime = new Date().toLocaleString()
|
||||
|
||||
saveResultsToStorage(updatedItems, currentTime)
|
||||
}
|
||||
|
||||
setLoadingItems((prev) => prev.filter((item) => item !== name))
|
||||
unlockItemsRef.current = updatedItems
|
||||
setUnlockItems(updatedItems)
|
||||
saveResultsToStorage(updatedItems)
|
||||
} catch (err: any) {
|
||||
setLoadingItems((prev) => prev.filter((item) => item !== name))
|
||||
showNotice.error(
|
||||
'tests.unlock.page.messages.detectionFailedWithName',
|
||||
{ name },
|
||||
err,
|
||||
)
|
||||
console.error(`Failed to check ${name}:`, err)
|
||||
} finally {
|
||||
setLoadingItems((items) => items.filter((item) => item !== name))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
@@ -315,7 +220,6 @@ const UnlockPage = () => {
|
||||
if (status === 'Yes') return <CheckCircleOutlined />
|
||||
if (status === 'No') return <CancelOutlined />
|
||||
if (status === 'Soon') return <AccessTimeOutlined />
|
||||
if (status.includes('Failed')) return <HelpOutlined />
|
||||
return <HelpOutlined />
|
||||
}
|
||||
|
||||
@@ -339,7 +243,11 @@ const UnlockPage = () => {
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={isCheckingAll}
|
||||
disabled={
|
||||
unlockItems.length === 0 ||
|
||||
isCheckingAll ||
|
||||
loadingItems.length > 0
|
||||
}
|
||||
onClick={checkAllMedia}
|
||||
startIcon={
|
||||
isCheckingAll ? (
|
||||
|
||||
Reference in New Issue
Block a user