diff --git a/src/application/downloader.rs b/src/application/downloader.rs index 3c34917..0a73e6a 100644 --- a/src/application/downloader.rs +++ b/src/application/downloader.rs @@ -123,14 +123,6 @@ pub async fn download_videy(url: &str) -> Result DownloaderRepository::download_videy(url).await } -pub async fn download_tiktok_v2(url: &str) -> Result { - DownloaderRepository::download_tiktok_v2(url, None).await -} - -pub async fn download_twitter_v2(url: &str) -> Result { - DownloaderRepository::download_twitter_v2(url, None).await -} - pub async fn download_bilibili(url: &str) -> Result { DownloaderRepository::download_bilibili(url).await } diff --git a/src/application/misc.rs b/src/application/misc.rs new file mode 100644 index 0000000..0ae4ec7 --- /dev/null +++ b/src/application/misc.rs @@ -0,0 +1,33 @@ +//! Application use-cases for misc utilities. + +use crate::domain::error::ScrapingError; +use crate::infrastructure::repository::misc as Repo; +use serde_json::Value; + +/// Currency converter. +pub async fn currency_converter(amount: f64, from: &str, to: &str) -> Result { + Repo::fetch_currency_converter(amount, from, to) + .await + .map_err(ScrapingError::Http) +} + +/// Harga emas Antam. +pub async fn harga_emas() -> Result { + Repo::fetch_harga_emas() + .await + .map_err(ScrapingError::Http) +} + +/// Kurs BCA (jual/beli). +pub async fn kurs_bca() -> Result { + Repo::fetch_kurs_bca() + .await + .map_err(ScrapingError::Http) +} + +/// Server info (OS/CPU/RAM/disk). +pub async fn server_info() -> Result { + Repo::fetch_server_info() + .await + .map_err(ScrapingError::Http) +} diff --git a/src/application/mod.rs b/src/application/mod.rs index 97ebf59..3529315 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -2,4 +2,5 @@ pub mod anime; pub mod anime2; pub mod downloader; pub mod komik; +pub mod misc; pub mod proxy; diff --git a/src/infrastructure/repository/downloader.rs b/src/infrastructure/repository/downloader.rs index d104785..215578b 100644 --- a/src/infrastructure/repository/downloader.rs +++ b/src/infrastructure/repository/downloader.rs @@ -614,28 +614,6 @@ impl DownloaderRepository { fetch_videy(url).await } - /// TikTok v2 (douyin.wtf hybrid API; falls back to embed scrape when dead). - pub async fn download_tiktok_v2( - url: &str, - cookies: Option<&str>, - ) -> Result { - match fetch_tiktok_v2(url).await { - ok @ Ok(_) => ok, - Err(_) => Self::download_tiktok(url, cookies).await, - } - } - - /// Twitter v2 (twitsave.com scrape; falls back to Syndication API when blocked). - pub async fn download_twitter_v2( - url: &str, - cookies: Option<&str>, - ) -> Result { - match fetch_twitter_v2(url).await { - ok @ Ok(_) => ok, - Err(_) => Self::download_twitter(url, cookies).await, - } - } - /// Bilibili video via b23.tv short link expansion. pub async fn download_bilibili(url: &str) -> Result { fetch_bilibili(url).await @@ -1286,10 +1264,16 @@ pub async fn fetch_tiktok(url: &str) -> Result { return Ok(result); } Err(_) => { - // All methods failed - return Err(ScrapingError::Http( - "All TikTok download methods failed (tikwm API blocked, yt-dlp blocked, Playwright blocked)".to_string() - )); + // Last resort: douyin.wtf hybrid API (v2). If it also fails, + // report all methods exhausted. + match fetch_tiktok_v2(url).await { + Ok(v2_result) if !v2_result.media.is_empty() => return Ok(v2_result), + _ => { + return Err(ScrapingError::Http( + "All TikTok download methods failed (tikwm, yt-dlp, Playwright, embed, douyin.wtf)".to_string() + )); + } + } } } } @@ -1951,7 +1935,15 @@ pub async fn fetch_twitter(url: &str) -> Result { } Err(_e) => {} } - return Ok(DownloadResult::error("Tidak dapat menemukan video")); + // Last resort: twitsave.com scrape (v2). If it also fails, report. + match fetch_twitter_v2(url).await { + Ok(v2_result) if !v2_result.media.is_empty() => return Ok(v2_result), + _ => { + return Ok(DownloadResult::error( + "Tidak dapat menemukan video (savetwitter, Playwright, twitsave semua gagal)", + )); + } + } } // Sort by resolution desc diff --git a/src/infrastructure/repository/misc.rs b/src/infrastructure/repository/misc.rs new file mode 100644 index 0000000..a4bfd78 --- /dev/null +++ b/src/infrastructure/repository/misc.rs @@ -0,0 +1,351 @@ +//! Infrastructure — Misc utility scrapers (currency-converter, harga-emas, kurs-bca, server-info). +//! +//! Ported from Shirokami-API `scraper/misc/*.js`. +//! HTML scraping via reqwest + scraper crate. + +use crate::infrastructure::utils::http_client::http_client; +use regex::Regex; +use reqwest::header::USER_AGENT; +use scraper::{Html, Selector}; +use serde_json::{json, Value}; + +/// Currency converter: scrape wise.com exchange rate page. +/// `amount`: amount to convert, `from`/`to`: currency codes (e.g. USD→IDR). +pub async fn fetch_currency_converter(amount: f64, from: &str, to: &str) -> Result { + let from_lower = from.to_lowercase(); + let to_lower = to.to_lowercase(); + let url = format!( + "https://wise.com/gb/currency-converter/{}-to-{}-rate?amount={}", + from_lower, to_lower, amount + ); + + let resp = http_client() + .client() + .get(&url) + .header( + USER_AGENT, + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + ) + .send() + .await + .map_err(|e| format!("HTTP error: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("HTTP {} from wise.com", resp.status())); + } + + let body = resp + .text() + .await + .map_err(|e| format!("Failed to read body: {}", e))?; + + // Try regex first: "1 USD = 15,885.00 IDR" + let re = Regex::new(r"1 [A-Z]{3} = ([0-9,.]+) [A-Z]{3}") + .map_err(|e| format!("Regex compile: {}", e))?; + + if let Some(caps) = re.captures(&body) { + let rate_str = caps.get(1).map(|m| m.as_str()).unwrap_or("0"); + let rate = rate_str.replace(',', "").parse::().unwrap_or(0.0); + if rate > 0.0 { + let result = amount * rate; + return Ok(json!({ + "from": from.to_uppercase(), + "to": to.to_uppercase(), + "amount": amount, + "rate": rate, + "result": result, + "updateTime": chrono::Utc::now().to_rfc3339(), + })); + } + } + + // Fallback: scrape the page for any rate info + let document = Html::parse_document(&body); + let selector = Selector::parse("body").unwrap(); + let text = document + .select(&selector) + .flat_map(|e| e.text()) + .collect::(); + + // Look for patterns like "15,885.00 IDR" in body text + let re2 = Regex::new(&format!( + r"([0-9,.]+)\s+{}", + to.to_uppercase() + )) + .map_err(|e| format!("Regex compile: {}", e))?; + + if let Some(caps) = re2.captures(&text) { + let rate_str = caps.get(1).map(|m| m.as_str()).unwrap_or("0"); + let rate = rate_str.replace(',', "").parse::().unwrap_or(0.0); + if rate > 0.0 { + let result = amount * rate; + return Ok(json!({ + "from": from.to_uppercase(), + "to": to.to_uppercase(), + "amount": amount, + "rate": rate, + "result": result, + "updateTime": chrono::Utc::now().to_rfc3339(), + })); + } + } + + Err("Gagal mendapatkan kurs dari wise.com".to_string()) +} + +/// Scrape harga emas Antam from harga-emas.org. +pub async fn fetch_harga_emas() -> Result { + let url = "https://harga-emas.org/"; + + let resp = http_client() + .client() + .get(url) + .header( + USER_AGENT, + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + ) + .send() + .await + .map_err(|e| format!("HTTP error: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("HTTP {} from harga-emas.org", resp.status())); + } + + let body = resp + .text() + .await + .map_err(|e| format!("Failed to read body: {}", e))?; + + let document = Html::parse_document(&body); + + let mut result = json!({ + "title": "Harga Emas Antam", + "hargaUtama": "N/A", + "perubahan": "0", + "detail": [], + }); + + // Look for tables containing "Antam" + "Pegadaian" + let table_sel = Selector::parse("table").unwrap(); + let tr_sel = Selector::parse("tbody tr").unwrap(); + let td_sel = Selector::parse("td").unwrap(); + + let mut detail: Vec = Vec::new(); + + for table in document.select(&table_sel) { + let table_text = table.text().collect::(); + if table_text.contains("Antam") && table_text.contains("Pegadaian") { + for tr in table.select(&tr_sel) { + let cols: Vec = tr + .select(&td_sel) + .map(|td| td.text().collect::()) + .map(|s| s.trim().to_string()) + .collect(); + + if cols.len() >= 2 { + let label = cols[0].trim(); + let price = cols[1].trim(); + + if !label.is_empty() && !price.is_empty() { + // Check if label is a numeric gram value + let numeric_label: String = + label.chars().filter(|c| c.is_ascii_digit()).collect(); + if !numeric_label.is_empty() { + detail.push(json!({ + "ukuran": format!("{} gr", label.replace(" gr", "").replace("gr", "").trim()), + "harga": format!("Rp {}", price), + })); + } + } + } + } + break; + } + } + + if !detail.is_empty() { + // Find the 1gr price as main price (fallback: smallest gram size). + let one_gr = detail.iter().find(|d| { + d["ukuran"] + .as_str() + .map(|s| { + let s = s.trim(); + s == "1 gr" || s.starts_with("1 gr") || s.starts_with("1gr") + }) + .unwrap_or(false) + }); + let main_price = one_gr + .map(|d| d["harga"].as_str().unwrap_or("N/A")) + .unwrap_or_else(|| detail[0]["harga"].as_str().unwrap_or("N/A")); + + result["hargaUtama"] = json!(main_price); + result["detail"] = json!(detail); + } + + Ok(result) +} + +/// Scrape kurs BCA (jual/beli) from bca.co.id. +pub async fn fetch_kurs_bca() -> Result { + let url = "https://www.bca.co.id/id/informasi/kurs"; + + let resp = http_client() + .client() + .get(url) + .header( + USER_AGENT, + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + ) + .header("Referer", "https://www.google.com/") + .send() + .await + .map_err(|e| format!("HTTP error: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("HTTP {} from bca.co.id", resp.status())); + } + + let body = resp + .text() + .await + .map_err(|e| format!("Failed to read body: {}", e))?; + + let document = Html::parse_document(&body); + + let table_sel = Selector::parse("table").unwrap(); + let tr_sel = Selector::parse("tbody tr").unwrap(); + let td_sel = Selector::parse("td").unwrap(); + + let mut kurs_data: Vec = Vec::new(); + + for table in document.select(&table_sel) { + let table_text = table.text().collect::(); + if table_text.contains("USD") || table_text.contains("SGD") || table_text.contains("kurs") { + for tr in table.select(&tr_sel) { + let cols: Vec = tr + .select(&td_sel) + .map(|td| td.text().collect::().trim().to_string()) + .collect(); + + if cols.len() >= 3 && !cols[0].is_empty() && !cols[1].is_empty() && !cols[2].is_empty() { + kurs_data.push(json!({ + "currency": cols[0], + "jual": cols[1], + "beli": cols[2], + })); + } + } + if !kurs_data.is_empty() { + break; + } + } + } + + if kurs_data.is_empty() { + return Err("Gagal mendapatkan data kurs BCA".to_string()); + } + + Ok(json!({ + "source": "BCA", + "url": url, + "data": kurs_data, + })) +} + +/// Local server info: OS, CPU, RAM, storage. +pub async fn fetch_server_info() -> Result { + let os_info = json!({ + "platform": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "family": std::env::consts::FAMILY, + }); + + // CPU info from /proc/cpuinfo (Linux) + let cpu_model = tokio::fs::read_to_string("/proc/cpuinfo") + .await + .ok() + .and_then(|s| { + s.lines() + .find(|l| l.starts_with("model name")) + .and_then(|l| l.split(':').nth(1)) + .map(|s| s.trim().to_string()) + }) + .unwrap_or_else(|| "unknown".to_string()); + + let cpu_cores = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(1); + + // RAM from /proc/meminfo + let mem_info = tokio::fs::read_to_string("/proc/meminfo") + .await + .ok() + .unwrap_or_default(); + let total_mem_kb: u64 = mem_info + .lines() + .find(|l| l.starts_with("MemTotal")) + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + let free_mem_kb: u64 = mem_info + .lines() + .find(|l| l.starts_with("MemAvailable")) + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + let total_mem_gb = total_mem_kb as f64 / 1048576.0; + let free_mem_gb = free_mem_kb as f64 / 1048576.0; + + // Disk usage from `df -B1 /` + let disk_usage = tokio::process::Command::new("df") + .args(["-B1", "/"]) + .output() + .await + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .and_then(|out| { + let lines: Vec<&str> = out.lines().collect(); + if lines.len() >= 2 { + let parts: Vec<&str> = lines[1].split_whitespace().collect(); + if parts.len() >= 4 { + let total: u64 = parts[1].parse().unwrap_or(0); + let used: u64 = parts[2].parse().unwrap_or(0); + let free: u64 = parts[3].parse().unwrap_or(0); + return Some(json!({ + "drive": parts[5], + "total": format_gb(total), + "used": format_gb(used), + "free": format_gb(free), + })); + } + } + None + }) + .unwrap_or(json!({ + "drive": "/", + "total": "N/A", + "used": "N/A", + "free": "N/A", + })); + + Ok(json!({ + "os": os_info, + "cpu": { + "model": cpu_model, + "cores": cpu_cores, + }, + "ram": { + "total": format!("{:.2} GB", total_mem_gb), + "free": format!("{:.2} GB", free_mem_gb), + "used": format!("{:.2} GB", total_mem_gb - free_mem_gb), + }, + "storage": disk_usage, + })) +} + +fn format_gb(bytes: u64) -> String { + let gb = bytes as f64 / 1_073_741_824.0; + format!("{:.2} GB", gb) +} diff --git a/src/infrastructure/repository/mod.rs b/src/infrastructure/repository/mod.rs index 1b4923d..1ec2359 100644 --- a/src/infrastructure/repository/mod.rs +++ b/src/infrastructure/repository/mod.rs @@ -1,6 +1,7 @@ pub mod alqanime; pub mod downloader; pub mod komik; +pub mod misc; pub mod otakudesu; pub mod parsers; pub mod proxy; diff --git a/src/presentation/handler/downloader.rs b/src/presentation/handler/downloader.rs index ba0efa9..a34c43d 100644 --- a/src/presentation/handler/downloader.rs +++ b/src/presentation/handler/downloader.rs @@ -227,22 +227,6 @@ pub async fn download_videy( Ok(Json(DownloadResponse::ok(result))) } -/// Handler for _tiktok_v2. -pub async fn download_tiktok_v2( - Query(params): Query, -) -> Result, AppError> { - let result = use_cases::download_tiktok_v2(¶ms.url).await?; - Ok(Json(DownloadResponse::ok(result))) -} - -/// Handler for _twitter_v2. -pub async fn download_twitter_v2( - Query(params): Query, -) -> Result, AppError> { - let result = use_cases::download_twitter_v2(¶ms.url).await?; - Ok(Json(DownloadResponse::ok(result))) -} - /// Handler for _bilibili. pub async fn download_bilibili( Query(params): Query, diff --git a/src/presentation/handler/misc.rs b/src/presentation/handler/misc.rs new file mode 100644 index 0000000..d2479da --- /dev/null +++ b/src/presentation/handler/misc.rs @@ -0,0 +1,50 @@ +//! Axum handlers for misc utility endpoints. +//! +//! Ported from Shirokami-API `scraper/misc/*.js`. + +use axum::extract::Query; +use axum::http::StatusCode; +use axum::Json; +use serde::Deserialize; +use serde_json::Value; + +use crate::application::misc as use_cases; +use crate::presentation::error::AppError; + +/// Query params for currency converter. +#[derive(Deserialize)] +pub struct CurrencyParams { + pub amount: Option, + pub from: Option, + pub to: Option, +} + +/// GET /misc/currency-converter?amount=100&from=USD&to=IDR +pub async fn currency_converter_handler( + Query(params): Query, +) -> Result<(StatusCode, Json), AppError> { + let amount = params.amount.unwrap_or(1.0); + let from = params.from.unwrap_or_else(|| "USD".to_string()); + let to = params.to.unwrap_or_else(|| "IDR".to_string()); + + let result = use_cases::currency_converter(amount, &from, &to).await?; + Ok((StatusCode::OK, Json(result))) +} + +/// GET /misc/harga-emas +pub async fn harga_emas_handler() -> Result<(StatusCode, Json), AppError> { + let result = use_cases::harga_emas().await?; + Ok((StatusCode::OK, Json(result))) +} + +/// GET /misc/kurs-bca +pub async fn kurs_bca_handler() -> Result<(StatusCode, Json), AppError> { + let result = use_cases::kurs_bca().await?; + Ok((StatusCode::OK, Json(result))) +} + +/// GET /misc/server-info +pub async fn server_info_handler() -> Result<(StatusCode, Json), AppError> { + let result = use_cases::server_info().await?; + Ok((StatusCode::OK, Json(result))) +} diff --git a/src/presentation/handler/mod.rs b/src/presentation/handler/mod.rs index 400e302..4429351 100644 --- a/src/presentation/handler/mod.rs +++ b/src/presentation/handler/mod.rs @@ -3,4 +3,5 @@ pub mod anime2; pub mod downloader; pub mod health; pub mod komik; +pub mod misc; pub mod proxy; diff --git a/src/presentation/router.rs b/src/presentation/router.rs index 5f0547b..61bf767 100644 --- a/src/presentation/router.rs +++ b/src/presentation/router.rs @@ -252,18 +252,27 @@ pub fn build_router(app_state: Arc) -> anyhow::Result { "/download/videy", axum::routing::get(crate::presentation::handler::downloader::download_videy), ) - .route( - "/download/tiktok/v2", - axum::routing::get(crate::presentation::handler::downloader::download_tiktok_v2), - ) - .route( - "/download/twitter/v2", - axum::routing::get(crate::presentation::handler::downloader::download_twitter_v2), - ) .route( "/download/bilibili", axum::routing::get(crate::presentation::handler::downloader::download_bilibili), ) + // Misc routes + .route( + "/misc/currency-converter", + axum::routing::get(crate::presentation::handler::misc::currency_converter_handler), + ) + .route( + "/misc/harga-emas", + axum::routing::get(crate::presentation::handler::misc::harga_emas_handler), + ) + .route( + "/misc/kurs-bca", + axum::routing::get(crate::presentation::handler::misc::kurs_bca_handler), + ) + .route( + "/misc/server-info", + axum::routing::get(crate::presentation::handler::misc::server_info_handler), + ) // Health .route( "/health",