diff --git a/src/application/mod.rs b/src/application/mod.rs index f4ca339..33edec2 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -5,3 +5,4 @@ pub mod komik; pub mod misc; pub mod proxy; pub mod stalk; +pub mod weebs; diff --git a/src/application/weebs.rs b/src/application/weebs.rs new file mode 100644 index 0000000..345d0f4 --- /dev/null +++ b/src/application/weebs.rs @@ -0,0 +1,35 @@ +//! Application use-cases for weebs (anime/manga info, waifu, whatanime). + +use crate::domain::error::ScrapingError; +use crate::infrastructure::repository::weebs as Repo; +use serde_json::Value; + +pub async fn anime_info(query: &str) -> Result { + Repo::fetch_anime_info(query) + .await + .map_err(ScrapingError::Http) +} + +pub async fn manga_info(query: &str) -> Result { + Repo::fetch_manga_info(query) + .await + .map_err(ScrapingError::Http) +} + +pub async fn whatanime(url: &str) -> Result { + Repo::fetch_whatanime(url) + .await + .map_err(ScrapingError::Http) +} + +pub async fn sfw_waifu(tag: &str) -> Result { + Repo::fetch_sfw_waifu(tag) + .await + .map_err(ScrapingError::Http) +} + +pub async fn nsfw_waifu(tag: &str) -> Result { + Repo::fetch_nsfw_waifu(tag) + .await + .map_err(ScrapingError::Http) +} diff --git a/src/infrastructure/repository/mod.rs b/src/infrastructure/repository/mod.rs index fd83ab2..9a17a9c 100644 --- a/src/infrastructure/repository/mod.rs +++ b/src/infrastructure/repository/mod.rs @@ -6,6 +6,7 @@ pub mod otakudesu; pub mod parsers; pub mod proxy; pub mod stalk; +pub mod weebs; pub use alqanime::AlqanimeRepository; pub use downloader::DownloaderRepository; diff --git a/src/infrastructure/repository/weebs.rs b/src/infrastructure/repository/weebs.rs new file mode 100644 index 0000000..208988c --- /dev/null +++ b/src/infrastructure/repository/weebs.rs @@ -0,0 +1,192 @@ +//! Infrastructure — Weebs endpoints (anime/manga info, waifu pics, whatanime). +//! +//! Ported from Shirokami-API `scraper/weebs/*.js`. These are public JSON +//! APIs that we proxy/forward (jikan.moe, waifu.pics, trace.moe). + +use crate::infrastructure::utils::http_client::http_client; +use reqwest::header::USER_AGENT; +use serde_json::{json, Value}; + +/// Anime info from jikan.moe, ported from anime-search's `getAnimeInfo`. +pub async fn fetch_anime_info(query: &str) -> Result { + let url = format!("https://api.jikan.moe/v4/anime?q={}", urlencode(query)); + let data = get_json(&url).await?; + + let arr = data + .get("data") + .and_then(|d| d.as_array()) + .cloned() + .unwrap_or_default(); + if arr.is_empty() { + return Ok(json!({ "error": "Anime tidak ditemukan" })); + } + let anime = &arr[0]; + + let judul = anime + .get("titles") + .and_then(|t| t.as_array()) + .and_then(|t| t.first()) + .and_then(|t| t.get("title")) + .and_then(|t| t.as_str()) + .unwrap_or("Title not available"); + let genrenya = join_names(anime.get("genres")); + + Ok(json!({ + "title": judul, + "url": anime.get("url").and_then(|v| v.as_str()).unwrap_or(""), + "type": anime.get("type").and_then(|v| v.as_str()).unwrap_or(""), + "score": anime.get("score").cloned().unwrap_or(Value::Null), + "members": anime.get("members").cloned().unwrap_or(Value::Null), + "status": anime.get("status").and_then(|v| v.as_str()).unwrap_or(""), + "synopsis": anime.get("synopsis").and_then(|v| v.as_str()).unwrap_or(""), + "favorites": anime.get("favorites").cloned().unwrap_or(Value::Null), + "images": anime.get("images").cloned().unwrap_or(Value::Null), + "genres": genrenya, + })) +} + +/// Manga info from jikan.moe, ported from anime-search's `getMangaInfo`. +pub async fn fetch_manga_info(query: &str) -> Result { + let url = format!("https://api.jikan.moe/v4/manga?q={}", urlencode(query)); + let data = get_json(&url).await?; + + let arr = data + .get("data") + .and_then(|d| d.as_array()) + .cloned() + .unwrap_or_default(); + if arr.is_empty() { + return Ok(json!({ "error": "Manga tidak ditemukan" })); + } + let manga = &arr[0]; + + let judul = manga + .get("titles") + .and_then(|t| t.as_array()) + .map(|tt| { + tt.iter() + .filter_map(|j| { + let title = j.get("title").and_then(|v| v.as_str()).unwrap_or(""); + let ty = j.get("type").and_then(|v| v.as_str()).unwrap_or(""); + if title.is_empty() { + None + } else { + Some(format!("{} [{}]", title, ty)) + } + }) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + let genrenya = join_names(manga.get("genres")); + + Ok(json!({ + "title": judul, + "chapters": manga.get("chapters").cloned().unwrap_or(Value::Null), + "type": manga.get("type").and_then(|v| v.as_str()).unwrap_or(""), + "status": manga.get("status").and_then(|v| v.as_str()).unwrap_or(""), + "genre": genrenya, + "volumes": manga.get("volumes").cloned().unwrap_or(Value::Null), + "favorites": manga.get("favorites").cloned().unwrap_or(Value::Null), + "score": manga.get("score").cloned().unwrap_or(Value::Null), + "scored": manga.get("scored").cloned().unwrap_or(Value::Null), + "scored_by": manga.get("scored_by").cloned().unwrap_or(Value::Null), + "rank": manga.get("rank").cloned().unwrap_or(Value::Null), + "popularity": manga.get("popularity").cloned().unwrap_or(Value::Null), + "members": manga.get("members").cloned().unwrap_or(Value::Null), + "url": manga.get("url").and_then(|v| v.as_str()).unwrap_or(""), + "background": manga.get("background").and_then(|v| v.as_str()).unwrap_or(""), + "synopsis": manga.get("synopsis").and_then(|v| v.as_str()).unwrap_or(""), + })) +} + +/// What anime is in this screenshot? trace.moe reverse-image search. +pub async fn fetch_whatanime(url: &str) -> Result { + let api = format!( + "https://api.trace.moe/search?cutBorders&url={}", + urlencode(url) + ); + let data = get_json(&api).await?; + + let result = data + .get("result") + .and_then(|r| r.as_array()) + .and_then(|r| r.first()) + .cloned(); + let Some(r) = result else { + return Ok(json!({ "error": "Anime tidak ditemukan" })); + }; + + let similarity = r + .get("similarity") + .and_then(|s| s.as_f64()) + .map(|s| (s * 100.0).round() as i64) + .unwrap_or(0); + + Ok(json!({ + "judul": r.get("filename").and_then(|v| v.as_str()).unwrap_or(""), + "episode": r.get("episode").and_then(|v| v.as_str()).unwrap_or(""), + "similarity": similarity, + "videoURL": r.get("video").and_then(|v| v.as_str()).unwrap_or(""), + "videoIMG": r.get("image").and_then(|v| v.as_str()).unwrap_or(""), + })) +} + +/// SFW waifu image from waifu.pics. +pub async fn fetch_sfw_waifu(tag: &str) -> Result { + let url = format!("https://api.waifu.pics/sfw/{}", urlencode(tag)); + get_json(&url).await +} + +/// NSFW waifu image from waifu.pics. +pub async fn fetch_nsfw_waifu(tag: &str) -> Result { + let url = format!("https://api.waifu.pics/nsfw/{}", urlencode(tag)); + get_json(&url).await +} + +// ============================================================================ +// Helpers +// ============================================================================ + +async fn get_json(url: &str) -> Result { + 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 Safari/537.36") + .send() + .await + .map_err(|e| format!("HTTP error: {}", e))?; + if !resp.status().is_success() { + return Err(format!("HTTP {} from upstream", resp.status())); + } + resp.json::() + .await + .map_err(|e| format!("JSON parse: {}", e)) +} + +/// Join an array of { name: "X" } objects into a comma-separated string. +fn join_names(v: Option<&Value>) -> String { + v.and_then(|g| g.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|g| g.get("name").and_then(|n| n.as_str()).map(|s| s.to_string())) + .collect::>() + .join(", ") + }) + .unwrap_or_default() +} + +/// Minimal URL-encode (space -> %20, keep alphanumerics and safe chars). +fn urlencode(s: &str) -> String { + let mut out = String::new(); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + b' ' => out.push_str("%20"), + _ => out.push_str(&format!("%{:02X}", b)), + } + } + out +} diff --git a/src/presentation/handler/mod.rs b/src/presentation/handler/mod.rs index cdeac2a..f169b66 100644 --- a/src/presentation/handler/mod.rs +++ b/src/presentation/handler/mod.rs @@ -6,3 +6,4 @@ pub mod komik; pub mod misc; pub mod proxy; pub mod stalk; +pub mod weebs; diff --git a/src/presentation/handler/weebs.rs b/src/presentation/handler/weebs.rs new file mode 100644 index 0000000..bb06d9d --- /dev/null +++ b/src/presentation/handler/weebs.rs @@ -0,0 +1,65 @@ +//! Axum handlers for weebs endpoints. +//! +//! Ported from Shirokami-API `scraper/weebs/*.js`. + +use axum::extract::Query; +use axum::http::StatusCode; +use axum::Json; +use serde::Deserialize; +use serde_json::Value; + +use crate::application::weebs as use_cases; +use crate::presentation::error::AppError; + +#[derive(Deserialize)] +pub struct WeebsParams { + pub query: Option, + pub q: Option, + pub url: Option, + pub tag: Option, +} + +fn require<'a>(v: Option<&'a String>, name: &str) -> Result { + v.cloned() + .ok_or_else(|| AppError::BadRequest(format!("Missing '{}' parameter", name))) +} + +/// GET /weebs/anime-info?query=naruto +pub async fn anime_info_handler( + Query(p): Query, +) -> Result<(StatusCode, Json), AppError> { + let query = require(p.query.as_ref().or(p.q.as_ref()), "query")?; + Ok((StatusCode::OK, Json(use_cases::anime_info(&query).await?))) +} + +/// GET /weebs/manga-info?query=one%20piece +pub async fn manga_info_handler( + Query(p): Query, +) -> Result<(StatusCode, Json), AppError> { + let query = require(p.query.as_ref().or(p.q.as_ref()), "query")?; + Ok((StatusCode::OK, Json(use_cases::manga_info(&query).await?))) +} + +/// GET /weebs/whatanime?url= +pub async fn whatanime_handler( + Query(p): Query, +) -> Result<(StatusCode, Json), AppError> { + let url = require(p.url.as_ref(), "url")?; + Ok((StatusCode::OK, Json(use_cases::whatanime(&url).await?))) +} + +/// GET /weebs/sfw-waifu?tag=waifu +pub async fn sfw_waifu_handler( + Query(p): Query, +) -> Result<(StatusCode, Json), AppError> { + let tag = require(p.tag.as_ref(), "tag")?; + Ok((StatusCode::OK, Json(use_cases::sfw_waifu(&tag).await?))) +} + +/// GET /weebs/nsfw-waifu?tag=waifu +pub async fn nsfw_waifu_handler( + Query(p): Query, +) -> Result<(StatusCode, Json), AppError> { + let tag = require(p.tag.as_ref(), "tag")?; + Ok((StatusCode::OK, Json(use_cases::nsfw_waifu(&tag).await?))) +} diff --git a/src/presentation/router.rs b/src/presentation/router.rs index 86b9745..3099317 100644 --- a/src/presentation/router.rs +++ b/src/presentation/router.rs @@ -282,6 +282,27 @@ pub fn build_router(app_state: Arc) -> anyhow::Result { "/stalk/youtube", axum::routing::get(crate::presentation::handler::stalk::youtube_handler), ) + // Weebs routes + .route( + "/weebs/anime-info", + axum::routing::get(crate::presentation::handler::weebs::anime_info_handler), + ) + .route( + "/weebs/manga-info", + axum::routing::get(crate::presentation::handler::weebs::manga_info_handler), + ) + .route( + "/weebs/whatanime", + axum::routing::get(crate::presentation::handler::weebs::whatanime_handler), + ) + .route( + "/weebs/sfw-waifu", + axum::routing::get(crate::presentation::handler::weebs::sfw_waifu_handler), + ) + .route( + "/weebs/nsfw-waifu", + axum::routing::get(crate::presentation::handler::weebs::nsfw_waifu_handler), + ) // Health .route( "/health",