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:
Tunglies
2026-08-21 21:27:27 +08:00
committed by GitHub
parent 19401b096d
commit 2717aaadad
20 changed files with 801 additions and 1100 deletions
Generated
-1
View File
@@ -1226,7 +1226,6 @@ dependencies = [
"chrono",
"clash-verge-logging",
"log",
"regex",
"reqwest 0.13.4",
"rust_iso3166",
"serde",
+1
View File
@@ -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"] }
+35 -85
View File
@@ -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)
}
+28 -49
View File
@@ -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
}
+16 -70
View File
@@ -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)
}
+17 -28
View File
@@ -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)
}
+180 -277
View File
@@ -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(&region))),
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", &region);
}
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(&region))),
),
Some(true) => UnlockItem::checked_region(DISNEY_PLUS, "Yes", &region),
None => disney_item(format!("Failed (Error: Unknown region status for {region})"), None),
_ => None,
}
}
+19 -24
View File
@@ -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,
)
}
+111 -77
View File
@@ -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)
}
+100 -105
View File
@@ -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", &region);
}
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),
}
}
+38 -43
View File
@@ -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))
}
+46 -60
View File
@@ -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
}
}
+56 -48
View File
@@ -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)
}
+34 -5
View File
@@ -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
}
+1
View File
@@ -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
View File
@@ -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 ? (