Files
scraper/src/infrastructure/repository/otakudesu.rs
T
asepharyana e6d75edf33
Deploy Scraper / build-and-deploy (push) Canceled after 0s
fix(anime): repair genre & latest endpoints for the new otakudesu layout
The current otakudesu site changed paths and card layout, breaking two APIs:

1. genre pages: singular /genre/{slug}/page/{n}/ 301-redirects to a dead
   otakudesu.io placeholder → 0 items. Fix: plural /genres/{slug}/page/{n}/.
   Also re-target parse_genre_anime_document from the retired .venz ul li /
   .thumbz h2.jdlflm layout to the new .col-anime card layout
   (.col-anime-title a, .col-anime-eps, .col-anime-rating, .col-anime-cover img).

2. latest: /latest-anime/ was removed (301 → otakudesu.io). The homepage IS
   the latest-episodes feed (.venz ul li with .thumbz h2.jdlflm/.epz), so
   fetch_latest_anime_page now uses base_url() for page 1 and /page/N/ for
   later pages instead of the dead path.
2026-08-31 20:59:20 +07:00

229 lines
8.1 KiB
Rust

//! Otakudesu anime scraping repository.
use async_trait::async_trait;
use tracing::{info, warn};
use crate::domain::entity::anime::{
AnimeData, AnimeDetailData, AnimeFullData, CompleteAnimeListItem, Genre, GenreAnimeItem,
LatestAnimeItem, OngoingAnimeListItem, Pagination, SearchAnimeItem,
};
use crate::domain::error::ScrapingError;
use crate::domain::repository::ScrapingRepository;
use crate::infrastructure::repository::parsers::otakudesu_parser;
use crate::infrastructure::scraping::html_fetcher::fetch_html_with_retry;
use crate::infrastructure::scraping::proxy_fetch::fetch_with_proxy;
use crate::infrastructure::scraping::retry::{default_backoff, retry, retry_all};
const OTAKUDESU_BASE_URL: &str = "https://otakudesu.cloud";
pub struct OtakudesuRepository;
impl OtakudesuRepository {
pub fn new() -> Self {
Self
}
fn base_url(&self) -> String {
"https://otakudesu.cloud".to_string()
}
pub fn index_urls(&self) -> (String, String) {
let base = self.base_url();
(
format!("{}/ongoing-anime/", base),
format!("{}/complete-anime/", base),
)
}
pub fn genres_url(&self) -> String {
format!("{}/genre-list/", self.base_url())
}
pub fn detail_url(&self, slug: &str) -> String {
format!("{}/anime/{}", OTAKUDESU_BASE_URL, slug)
}
pub fn page_url(&self, category: &str, page: &str) -> String {
format!("{}/{}/page/{}/", OTAKUDESU_BASE_URL, category, page)
}
pub fn search_url(&self, query: &str, page: &str) -> String {
if page == "1" {
format!("{}/search/{}/", self.base_url(), query)
} else {
format!("{}/search/{}/page/{}/", self.base_url(), query, page)
}
}
pub fn genre_page_url(&self, genre_slug: &str, page: &str) -> String {
// Current otakudesu uses the plural `/genres/{slug}/` path (archive
// layout). The singular `/genre/{slug}/page/{page}/` path 301-redirects
// to otakudesu.io (a 404 placeholder), which made genre pages return 0.
format!("{}/genres/{}/page/{}/", self.base_url(), genre_slug, page)
}
pub fn full_episode_url(&self, slug: &str) -> String {
format!("{}/episode/{}", OTAKUDESU_BASE_URL, slug)
}
}
#[async_trait]
impl ScrapingRepository for OtakudesuRepository {
async fn fetch_html(&self, url: &str) -> Result<String, ScrapingError> {
fetch_html_with_retry(url).await
}
}
impl OtakudesuRepository {
pub async fn fetch_anime_index(&self) -> Result<AnimeData, ScrapingError> {
let (ongoing_url, complete_url) = self.index_urls();
let (ongoing_html, complete_html) = tokio::join!(
self.fetch_html(&ongoing_url),
self.fetch_html(&complete_url)
);
let ongoing_html = ongoing_html?;
let complete_html = complete_html?;
let ongoing_anime = tokio::task::spawn_blocking(move || {
otakudesu_parser::parse_ongoing_anime(&ongoing_html)
})
.await
.map_err(|e| ScrapingError::Parse(e.to_string()))??;
let complete_anime = tokio::task::spawn_blocking(move || {
otakudesu_parser::parse_complete_anime(&complete_html)
})
.await
.map_err(|e| ScrapingError::Parse(e.to_string()))??;
Ok(AnimeData {
ongoing_anime,
complete_anime,
})
}
pub async fn fetch_genres(&self) -> Result<Vec<Genre>, ScrapingError> {
let html = self.fetch_html(&self.genres_url()).await?;
tokio::task::spawn_blocking(move || otakudesu_parser::parse_genres(&html))
.await
.map_err(|e| ScrapingError::Parse(e.to_string()))?
}
pub async fn fetch_anime_detail(&self, slug: &str) -> Result<AnimeDetailData, ScrapingError> {
let url = self.detail_url(slug);
let html = self.fetch_with_proxy_retry(&url).await?;
tokio::task::spawn_blocking(move || otakudesu_parser::parse_anime_detail_document(&html))
.await
.map_err(|e| ScrapingError::Parse(e.to_string()))?
}
pub async fn fetch_complete_anime_page(
&self,
slug: &str,
) -> Result<(Vec<CompleteAnimeListItem>, Pagination), ScrapingError> {
let url = self.page_url("complete-anime", slug);
let html = self.fetch_html(&url).await?;
let slug_owned = slug.to_string();
tokio::task::spawn_blocking(move || otakudesu_parser::parse_anime_page(&html, &slug_owned))
.await
.map_err(|e| ScrapingError::Parse(e.to_string()))?
}
pub async fn fetch_ongoing_anime_page(
&self,
slug: &str,
) -> Result<(Vec<OngoingAnimeListItem>, Pagination), ScrapingError> {
let url = self.page_url("ongoing-anime", slug);
let html = self.fetch_html(&url).await?;
let slug_owned = slug.to_string();
tokio::task::spawn_blocking(move || {
otakudesu_parser::parse_ongoing_anime_document(&html, &slug_owned)
})
.await
.map_err(|e| ScrapingError::Parse(e.to_string()))?
}
pub async fn fetch_latest_anime_page(
&self,
slug: &str,
) -> Result<(Vec<LatestAnimeItem>, Pagination), ScrapingError> {
// The site's `/latest-anime/` path was removed (301 → otakudesu.io
// placeholder). The homepage IS the latest-episodes page (`.venz ul li`
// with `.thumbz h2.jdlflm`, `.epz`), paginated via WordPress `/page/N/`.
let url = if slug == "1" {
format!("{}/", self.base_url())
} else {
format!("{}/page/{}/", self.base_url(), slug)
};
let html = self.fetch_html(&url).await?;
let slug_owned = slug.to_string();
tokio::task::spawn_blocking(move || {
otakudesu_parser::parse_latest_anime_document(&html, &slug_owned)
})
.await
.map_err(|e| ScrapingError::Parse(e.to_string()))?
}
pub async fn fetch_search_anime_page(
&self,
slug: &str,
page: &str,
) -> Result<(Vec<SearchAnimeItem>, Pagination), ScrapingError> {
let url = self.search_url(slug, page);
let html = self.fetch_html(&url).await?;
let page_owned = page.to_string();
tokio::task::spawn_blocking(move || {
otakudesu_parser::parse_search_anime_document(&html, &page_owned)
})
.await
.map_err(|e| ScrapingError::Parse(e.to_string()))?
}
pub async fn fetch_genre_anime_page(
&self,
genre_slug: &str,
page: &str,
) -> Result<(Vec<GenreAnimeItem>, Pagination), ScrapingError> {
let url = self.genre_page_url(genre_slug, page);
let html = self.fetch_html(&url).await?;
let page_owned = page.to_string();
tokio::task::spawn_blocking(move || {
otakudesu_parser::parse_genre_anime_document(&html, &page_owned)
})
.await
.map_err(|e| ScrapingError::Parse(e.to_string()))?
}
pub async fn fetch_anime_full(&self, slug: &str) -> Result<AnimeFullData, ScrapingError> {
let url = self.full_episode_url(slug);
let html = self.fetch_html(&url).await?;
let slug_owned = slug.to_string();
tokio::task::spawn_blocking(move || {
otakudesu_parser::parse_anime_full_document(&html, &slug_owned)
})
.await
.map_err(|e| ScrapingError::Parse(e.to_string()))?
}
async fn fetch_with_proxy_retry(&self, url: &str) -> Result<String, ScrapingError> {
let backoff = default_backoff();
let url_owned = url.to_string();
let fetch_op = || async {
info!("Fetching URL: {}", url_owned);
match fetch_with_proxy(&url_owned).await {
Ok(response) => {
info!("Successfully fetched URL: {}", url_owned);
Ok(response.data)
}
Err(e) => {
warn!("Failed to fetch URL: {}, error: {:?}", url_owned, e);
Err(ScrapingError::Http(format!("Proxy fetch failed: {}", e)))
}
}
};
retry(backoff, retry_all, fetch_op)
.await
.map_err(|e| ScrapingError::Http(e.to_string()))
}
}