diff --git a/src-tauri/src/cmd/media_unlock_checker/claude.rs b/src-tauri/src/cmd/media_unlock_checker/claude.rs new file mode 100644 index 000000000..525b9a7fe --- /dev/null +++ b/src-tauri/src/cmd/media_unlock_checker/claude.rs @@ -0,0 +1,60 @@ +use reqwest::Client; + +use super::UnlockItem; +use super::utils::{country_code_to_emoji, get_local_date_string}; + +const BLOCKED_CODES: [&str; 10] = ["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"; + + match client.get(url).send().await { + Ok(response) => match response.text().await { + Ok(body) => { + let mut country_code: Option = None; + + for line in body.lines() { + if let Some(rest) = line.strip_prefix("loc=") { + country_code = Some(rest.trim().to_uppercase()); + break; + } + } + + if let Some(code) = country_code { + let emoji = country_code_to_emoji(&code); + let status = if BLOCKED_CODES.contains(&code.as_str()) { + "No" + } else { + "Yes" + }; + + UnlockItem { + name: "Claude".to_string(), + status: status.to_string(), + region: Some(format!("{emoji}{code}")), + check_time: Some(get_local_date_string()), + } + } else { + UnlockItem { + name: "Claude".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + } + } + } + Err(_) => UnlockItem { + name: "Claude".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }, + }, + Err(_) => UnlockItem { + name: "Claude".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }, + } +} diff --git a/src-tauri/src/cmd/media_unlock_checker/mod.rs b/src-tauri/src/cmd/media_unlock_checker/mod.rs index 8026057d1..c01df2926 100644 --- a/src-tauri/src/cmd/media_unlock_checker/mod.rs +++ b/src-tauri/src/cmd/media_unlock_checker/mod.rs @@ -9,10 +9,13 @@ use crate::{logging, utils::logging::Type}; mod bahamut; mod bilibili; mod chatgpt; +mod claude; mod disney_plus; mod gemini; mod netflix; mod prime_video; +mod spotify; +mod tiktok; mod types; mod utils; mod youtube; @@ -22,10 +25,13 @@ 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; #[command] @@ -79,6 +85,15 @@ pub async fn check_media_unlock() -> Result, String> { }); } + { + let client = Arc::clone(&client_arc); + let results = Arc::clone(&results); + tasks.spawn(async move { + let result = check_claude(&client).await; + results.lock().await.push(result); + }); + } + { let client = Arc::clone(&client_arc); let results = Arc::clone(&results); @@ -124,6 +139,24 @@ pub async fn check_media_unlock() -> Result, String> { }); } + { + let client = Arc::clone(&client_arc); + let results = Arc::clone(&results); + tasks.spawn(async move { + let result = check_spotify(&client).await; + results.lock().await.push(result); + }); + } + + { + let client = Arc::clone(&client_arc); + let results = Arc::clone(&results); + tasks.spawn(async move { + let result = check_tiktok(&client).await; + results.lock().await.push(result); + }); + } + { let client = Arc::clone(&client_arc); let results = Arc::clone(&results); diff --git a/src-tauri/src/cmd/media_unlock_checker/spotify.rs b/src-tauri/src/cmd/media_unlock_checker/spotify.rs new file mode 100644 index 000000000..88cdea234 --- /dev/null +++ b/src-tauri/src/cmd/media_unlock_checker/spotify.rs @@ -0,0 +1,79 @@ +use reqwest::{Client, Url}; + +use super::UnlockItem; +use super::utils::{country_code_to_emoji, get_local_date_string}; + +pub(super) async fn check_spotify(client: &Client) -> UnlockItem { + let url = "https://www.spotify.com/api/content/v1/country-selector?platform=web&format=json"; + + 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 region = extract_region(&final_url).or_else(|| extract_region_from_body(&body)); + let status = determine_status(status_code.as_u16(), &body); + + UnlockItem { + name: "Spotify".to_string(), + status: status.to_string(), + region, + check_time: Some(get_local_date_string()), + } + } + Err(_) => UnlockItem { + name: "Spotify".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }, + } +} + +fn determine_status(status: u16, body: &str) -> &'static str { + if status == 403 || status == 451 { + return "No"; + } + + if !(200..300).contains(&status) { + return "Failed"; + } + + let body_lower = body.to_lowercase(); + if body_lower.contains("not available in your country") { + return "No"; + } + + "Yes" +} + +fn extract_region(url: &Url) -> Option { + let mut segments = url.path_segments()?; + let first_segment = segments.next()?; + + if first_segment.is_empty() || first_segment == "api" { + return None; + } + + let country_code = first_segment.split('-').next().unwrap_or(first_segment); + let upper = country_code.to_uppercase(); + let emoji = country_code_to_emoji(&upper); + Some(format!("{emoji}{upper}")) +} + +fn extract_region_from_body(body: &str) -> Option { + 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() { + let emoji = country_code_to_emoji(&code); + return Some(format!("{emoji}{code}")); + } + } + } + None +} diff --git a/src-tauri/src/cmd/media_unlock_checker/tiktok.rs b/src-tauri/src/cmd/media_unlock_checker/tiktok.rs new file mode 100644 index 000000000..c3bb46ac1 --- /dev/null +++ b/src-tauri/src/cmd/media_unlock_checker/tiktok.rs @@ -0,0 +1,87 @@ +use std::sync::OnceLock; + +use regex::Regex; +use reqwest::Client; + +use super::UnlockItem; +use super::utils::{country_code_to_emoji, get_local_date_string}; + +pub(super) async fn check_tiktok(client: &Client) -> UnlockItem { + let trace_url = "https://www.tiktok.com/cdn-cgi/trace"; + + let mut status = String::from("Failed"); + let mut region = None; + + 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 (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 { + name: "TikTok".to_string(), + status, + region, + check_time: Some(get_local_date_string()), + } +} + +fn determine_status(status: u16, body: &str) -> &'static str { + if status == 403 || status == 451 { + return "No"; + } + + if !(200..300).contains(&status) { + return "Failed"; + } + + 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"; + } + + "Yes" +} + +fn extract_region_from_body(body: &str) -> Option { + static REGION_REGEX: OnceLock> = 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() { + let emoji = country_code_to_emoji(&country_code); + return Some(format!("{emoji}{country_code}")); + } + } + + None +} diff --git a/src-tauri/src/cmd/media_unlock_checker/types.rs b/src-tauri/src/cmd/media_unlock_checker/types.rs index 32c4990b4..dd93d3781 100644 --- a/src-tauri/src/cmd/media_unlock_checker/types.rs +++ b/src-tauri/src/cmd/media_unlock_checker/types.rs @@ -19,17 +19,20 @@ impl UnlockItem { } } -const DEFAULT_UNLOCK_ITEM_NAMES: [&str; 10] = [ +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 { diff --git a/src/pages/unlock.tsx b/src/pages/unlock.tsx index 562d534cf..1993cebac 100644 --- a/src/pages/unlock.tsx +++ b/src/pages/unlock.tsx @@ -49,19 +49,43 @@ const UnlockPage = () => { return [...items].sort((a, b) => a.name.localeCompare(b.name)); }, []); - // 保存测试结果到本地存储 - const saveResultsToStorage = (items: UnlockItem[], time: string | null) => { - try { - localStorage.setItem(UNLOCK_RESULTS_STORAGE_KEY, JSON.stringify(items)); - if (time) { - localStorage.setItem(UNLOCK_RESULTS_TIME_KEY, time); + const mergeUnlockItems = useCallback( + (defaults: UnlockItem[], existing?: UnlockItem[] | null) => { + if (!existing || existing.length === 0) { + return defaults; } - } catch (err) { - console.error("Failed to save results to storage:", err); - } - }; - const loadResultsFromStorage = (): { + const existingMap = new Map(existing.map((item) => [item.name, item])); + const merged = defaults.map((item) => existingMap.get(item.name) ?? item); + + const mergedNameSet = new Set(merged.map((item) => item.name)); + existing.forEach((item) => { + if (!mergedNameSet.has(item.name)) { + merged.push(item); + } + }); + + 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; } => { @@ -80,34 +104,42 @@ const UnlockPage = () => { } return { items: null, time: null }; - }; + }, []); const getUnlockItems = useCallback( - async (updateUI: boolean = true) => { + async ( + existingItems: UnlockItem[] | null = null, + existingTime: string | null = null, + ) => { try { - const items = await invoke("get_unlock_items"); - const sortedItems = sortItemsByName(items); + const defaultItems = await invoke("get_unlock_items"); + const mergedItems = mergeUnlockItems(defaultItems, existingItems); + const sortedItems = sortItemsByName(mergedItems); - if (updateUI) { - setUnlockItems(sortedItems); - } + setUnlockItems(sortedItems); + saveResultsToStorage( + sortedItems, + existingItems && existingItems.length > 0 ? existingTime : null, + ); } catch (err: any) { console.error("Failed to get unlock items:", err); } }, - [sortItemsByName], + [mergeUnlockItems, saveResultsToStorage, sortItemsByName], ); useEffect(() => { - const { items: storedItems } = loadResultsFromStorage(); + void (async () => { + const { items: storedItems, time: storedTime } = loadResultsFromStorage(); - if (storedItems && storedItems.length > 0) { - setUnlockItems(storedItems); - getUnlockItems(false); - } else { - getUnlockItems(true); - } - }, [getUnlockItems]); + if (storedItems && storedItems.length > 0) { + setUnlockItems(sortItemsByName(storedItems)); + await getUnlockItems(storedItems, storedTime); + } else { + await getUnlockItems(); + } + })(); + }, [getUnlockItems, loadResultsFromStorage, sortItemsByName]); const invokeWithTimeout = async ( cmd: string,