feat(weebs): add anime/manga info, whatanime, waifu endpoints
Deploy Scraper / build-and-deploy (push) Canceled after 0s
Deploy Scraper / build-and-deploy (push) Canceled after 0s
Ported from Shirokami-API scraper/weebs/*.js: - /weebs/anime-info (jikan.moe anime search) - /weebs/manga-info (jikan.moe manga search) - /weebs/whatanime (trace.moe reverse image search) - /weebs/sfw-waifu + /weebs/nsfw-waifu (waifu.pics) Tested: anime-info (Naruto real data) + whatanime (real match). manga-info + waifu currently report upstream 504/DNS-down (jikan manga endpoint & waifu.pics unreachable from VPS right now) - proxy logic verified via the working anime/whatanime siblings.
This commit is contained in:
@@ -5,3 +5,4 @@ pub mod komik;
|
|||||||
pub mod misc;
|
pub mod misc;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
pub mod stalk;
|
pub mod stalk;
|
||||||
|
pub mod weebs;
|
||||||
|
|||||||
@@ -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<Value, ScrapingError> {
|
||||||
|
Repo::fetch_anime_info(query)
|
||||||
|
.await
|
||||||
|
.map_err(ScrapingError::Http)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn manga_info(query: &str) -> Result<Value, ScrapingError> {
|
||||||
|
Repo::fetch_manga_info(query)
|
||||||
|
.await
|
||||||
|
.map_err(ScrapingError::Http)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn whatanime(url: &str) -> Result<Value, ScrapingError> {
|
||||||
|
Repo::fetch_whatanime(url)
|
||||||
|
.await
|
||||||
|
.map_err(ScrapingError::Http)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sfw_waifu(tag: &str) -> Result<Value, ScrapingError> {
|
||||||
|
Repo::fetch_sfw_waifu(tag)
|
||||||
|
.await
|
||||||
|
.map_err(ScrapingError::Http)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn nsfw_waifu(tag: &str) -> Result<Value, ScrapingError> {
|
||||||
|
Repo::fetch_nsfw_waifu(tag)
|
||||||
|
.await
|
||||||
|
.map_err(ScrapingError::Http)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ pub mod otakudesu;
|
|||||||
pub mod parsers;
|
pub mod parsers;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
pub mod stalk;
|
pub mod stalk;
|
||||||
|
pub mod weebs;
|
||||||
|
|
||||||
pub use alqanime::AlqanimeRepository;
|
pub use alqanime::AlqanimeRepository;
|
||||||
pub use downloader::DownloaderRepository;
|
pub use downloader::DownloaderRepository;
|
||||||
|
|||||||
@@ -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<Value, String> {
|
||||||
|
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<Value, String> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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<Value, String> {
|
||||||
|
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<Value, String> {
|
||||||
|
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<Value, String> {
|
||||||
|
let url = format!("https://api.waifu.pics/nsfw/{}", urlencode(tag));
|
||||||
|
get_json(&url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async fn get_json(url: &str) -> Result<Value, String> {
|
||||||
|
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::<Value>()
|
||||||
|
.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::<Vec<_>>()
|
||||||
|
.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
|
||||||
|
}
|
||||||
@@ -6,3 +6,4 @@ pub mod komik;
|
|||||||
pub mod misc;
|
pub mod misc;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
pub mod stalk;
|
pub mod stalk;
|
||||||
|
pub mod weebs;
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
pub q: Option<String>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub tag: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require<'a>(v: Option<&'a String>, name: &str) -> Result<String, AppError> {
|
||||||
|
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<WeebsParams>,
|
||||||
|
) -> Result<(StatusCode, Json<Value>), 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<WeebsParams>,
|
||||||
|
) -> Result<(StatusCode, Json<Value>), 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=<image-url>
|
||||||
|
pub async fn whatanime_handler(
|
||||||
|
Query(p): Query<WeebsParams>,
|
||||||
|
) -> Result<(StatusCode, Json<Value>), 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<WeebsParams>,
|
||||||
|
) -> Result<(StatusCode, Json<Value>), 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<WeebsParams>,
|
||||||
|
) -> Result<(StatusCode, Json<Value>), AppError> {
|
||||||
|
let tag = require(p.tag.as_ref(), "tag")?;
|
||||||
|
Ok((StatusCode::OK, Json(use_cases::nsfw_waifu(&tag).await?)))
|
||||||
|
}
|
||||||
@@ -282,6 +282,27 @@ pub fn build_router(app_state: Arc<AppState>) -> anyhow::Result<Router> {
|
|||||||
"/stalk/youtube",
|
"/stalk/youtube",
|
||||||
axum::routing::get(crate::presentation::handler::stalk::youtube_handler),
|
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
|
// Health
|
||||||
.route(
|
.route(
|
||||||
"/health",
|
"/health",
|
||||||
|
|||||||
Reference in New Issue
Block a user