feat(weebs): add anime/manga info, whatanime, waifu endpoints
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:
asepharyana
2026-09-01 19:35:32 +07:00
parent 242e3498fe
commit 091a68bd55
7 changed files with 316 additions and 0 deletions
+1
View File
@@ -5,3 +5,4 @@ pub mod komik;
pub mod misc;
pub mod proxy;
pub mod stalk;
pub mod weebs;
+35
View File
@@ -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)
}
+1
View File
@@ -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;
+192
View File
@@ -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
}
+1
View File
@@ -6,3 +6,4 @@ pub mod komik;
pub mod misc;
pub mod proxy;
pub mod stalk;
pub mod weebs;
+65
View File
@@ -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?)))
}
+21
View File
@@ -282,6 +282,27 @@ pub fn build_router(app_state: Arc<AppState>) -> anyhow::Result<Router> {
"/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",