360 lines
12 KiB
Rust
360 lines
12 KiB
Rust
use crate::shared::types::entities::anime::*;
|
|
use crate::shared::utils::parse_html;
|
|
use crate::shared::utils::scraping::{
|
|
attr, attr_from, attr_from_or, extract_slug, selector, text, text_from_or,
|
|
};
|
|
use scraper::{Html, Selector};
|
|
|
|
// ============================================================================
|
|
// SELECTORS
|
|
// ============================================================================
|
|
|
|
/// Common selectors used across anime parsing
|
|
pub struct AnimeSelectors {
|
|
pub item: Selector,
|
|
pub title: Selector,
|
|
pub link: Selector,
|
|
pub img: Selector,
|
|
pub episode: Selector,
|
|
pub score: Selector,
|
|
pub status: Selector,
|
|
pub genre: Selector,
|
|
pub rating: Selector,
|
|
pub type_sel: Selector,
|
|
pub season: Selector,
|
|
pub desc: Selector,
|
|
}
|
|
|
|
impl AnimeSelectors {
|
|
pub fn new() -> Result<Self, String> {
|
|
Ok(Self {
|
|
item: selector("article.bs").ok_or("Invalid selector: article.bs")?,
|
|
title: selector(".tt h2").ok_or("Invalid selector: .tt h2")?,
|
|
link: selector("a").ok_or("Invalid selector: a")?,
|
|
img: selector("img").ok_or("Invalid selector: img")?,
|
|
episode: selector(".epx").ok_or("Invalid selector: .epx")?,
|
|
score: selector(".numscore").ok_or("Invalid selector: .numscore")?,
|
|
status: selector(".status").ok_or("Invalid selector: .status")?,
|
|
genre: selector(".genres a").ok_or("Invalid selector: .genres a")?,
|
|
rating: selector(".score").ok_or("Invalid selector: .score")?,
|
|
type_sel: selector(".typez").ok_or("Invalid selector: .typez")?,
|
|
season: selector(".season").ok_or("Invalid selector: .season")?,
|
|
desc: selector(".data .typez").ok_or("Invalid selector: .data .typez")?,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Default for AnimeSelectors {
|
|
fn default() -> Self {
|
|
Self::new().expect("Valid CSS selectors")
|
|
}
|
|
}
|
|
|
|
// Global lazy static instance for selectors to avoid reallocation per parse
|
|
use once_cell::sync::Lazy;
|
|
static ANIME_SELECTORS: Lazy<Result<AnimeSelectors, String>> =
|
|
Lazy::new(|| AnimeSelectors::new().map_err(|e| format!("Failed to create selectors: {}", e)));
|
|
|
|
// ============================================================================
|
|
// HELPER FUNCTIONS
|
|
// ============================================================================
|
|
|
|
/// Extract poster URL from an element, checking both src and data-src attributes
|
|
pub fn extract_poster(element: &scraper::ElementRef, img_selector: &Selector) -> String {
|
|
element
|
|
.select(img_selector)
|
|
.next()
|
|
.and_then(|e| attr(&e, "src").or(attr(&e, "data-src")))
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
// ============================================================================
|
|
// ANIME PARSERS
|
|
// ============================================================================
|
|
|
|
/// Parse ongoing anime items from HTML
|
|
pub fn parse_ongoing_anime(
|
|
html: &str,
|
|
) -> Result<Vec<OngoingAnimeItem>, Box<dyn std::error::Error + Send + Sync>> {
|
|
let document = parse_html(html);
|
|
let selectors = ANIME_SELECTORS.as_ref().map_err(|e| e.clone())?;
|
|
let mut items = Vec::new();
|
|
|
|
for element in document.select(&selectors.item) {
|
|
let title = text_from_or(&element, &selectors.title, "");
|
|
if title.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let href = attr_from_or(&element, &selectors.link, "href", "");
|
|
let slug = extract_slug(&href);
|
|
let poster = extract_poster(&element, &selectors.img);
|
|
let current_episode = text_from_or(&element, &selectors.episode, "N/A");
|
|
let anime_url = attr_from_or(&element, &selectors.link, "href", "");
|
|
|
|
items.push(OngoingAnimeItem {
|
|
title,
|
|
slug,
|
|
poster,
|
|
current_episode,
|
|
anime_url,
|
|
});
|
|
}
|
|
|
|
Ok(items)
|
|
}
|
|
|
|
/// Parse ongoing anime items with score from HTML
|
|
pub fn parse_ongoing_anime_with_score(
|
|
html: &str,
|
|
) -> Result<Vec<OngoingAnimeItemWithScore>, Box<dyn std::error::Error + Send + Sync>> {
|
|
let document = parse_html(html);
|
|
let selectors = ANIME_SELECTORS.as_ref().map_err(|e| e.clone())?;
|
|
let mut items = Vec::new();
|
|
|
|
for element in document.select(&selectors.item) {
|
|
let title = text_from_or(&element, &selectors.title, "");
|
|
if title.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let poster = extract_poster(&element, &selectors.img);
|
|
let score = text_from_or(&element, &selectors.score, "N/A");
|
|
let anime_url = attr_from_or(&element, &selectors.link, "href", "");
|
|
let slug = extract_slug(&anime_url);
|
|
|
|
items.push(OngoingAnimeItemWithScore {
|
|
title,
|
|
slug,
|
|
poster,
|
|
score,
|
|
anime_url,
|
|
});
|
|
}
|
|
|
|
Ok(items)
|
|
}
|
|
|
|
/// Parse complete anime items from HTML
|
|
pub fn parse_complete_anime(
|
|
html: &str,
|
|
) -> Result<Vec<CompleteAnimeItem>, Box<dyn std::error::Error + Send + Sync>> {
|
|
let document = parse_html(html);
|
|
let selectors = ANIME_SELECTORS.as_ref().map_err(|e| e.clone())?;
|
|
let mut items = Vec::new();
|
|
|
|
for element in document.select(&selectors.item) {
|
|
let title = text_from_or(&element, &selectors.title, "");
|
|
if title.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let href = attr_from_or(&element, &selectors.link, "href", "");
|
|
let slug = extract_slug(&href);
|
|
let poster = extract_poster(&element, &selectors.img);
|
|
let episode_count = text_from_or(&element, &selectors.episode, "N/A");
|
|
let anime_url = attr_from_or(&element, &selectors.link, "href", "");
|
|
|
|
items.push(CompleteAnimeItem {
|
|
title,
|
|
slug,
|
|
poster,
|
|
episode_count,
|
|
anime_url,
|
|
});
|
|
}
|
|
|
|
Ok(items)
|
|
}
|
|
|
|
/// Parse latest anime items from HTML
|
|
pub fn parse_latest_anime(
|
|
html: &str,
|
|
) -> Result<Vec<LatestAnimeItem>, Box<dyn std::error::Error + Send + Sync>> {
|
|
let document = parse_html(html);
|
|
let selectors = ANIME_SELECTORS.as_ref().map_err(|e| e.clone())?;
|
|
let mut items = Vec::new();
|
|
|
|
for element in document.select(&selectors.item) {
|
|
let title = text_from_or(&element, &selectors.title, "");
|
|
if title.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let poster = extract_poster(&element, &selectors.img);
|
|
let current_episode = text_from_or(&element, &selectors.episode, "N/A");
|
|
let score = text_from_or(&element, &selectors.score, "N/A");
|
|
let anime_url = attr_from_or(&element, &selectors.link, "href", "");
|
|
let slug = extract_slug(&anime_url);
|
|
|
|
items.push(LatestAnimeItem {
|
|
title,
|
|
slug,
|
|
poster,
|
|
current_episode,
|
|
score,
|
|
anime_url,
|
|
});
|
|
}
|
|
|
|
Ok(items)
|
|
}
|
|
|
|
/// Parse search results from HTML
|
|
pub fn parse_search_anime(
|
|
html: &str,
|
|
) -> Result<Vec<SearchAnimeItem>, Box<dyn std::error::Error + Send + Sync>> {
|
|
let document = parse_html(html);
|
|
let selectors = ANIME_SELECTORS.as_ref().map_err(|e| e.clone())?;
|
|
let mut items = Vec::new();
|
|
|
|
for element in document.select(&selectors.item) {
|
|
let title = text_from_or(&element, &selectors.title, "");
|
|
if title.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let href = attr_from(&element, &selectors.link, "href").unwrap_or_default();
|
|
let slug = extract_slug(&href);
|
|
let poster = extract_poster(&element, &selectors.img);
|
|
let description = text_from_or(&element, &selectors.desc, "");
|
|
let anime_url = attr_from_or(&element, &selectors.link, "href", "");
|
|
let genres = element.select(&selectors.genre).map(|e| text(&e)).collect();
|
|
let rating = text_from_or(&element, &selectors.rating, "");
|
|
let r#type = text_from_or(&element, &selectors.type_sel, "");
|
|
let season = text_from_or(&element, &selectors.season, "");
|
|
|
|
items.push(SearchAnimeItem {
|
|
title,
|
|
slug,
|
|
poster,
|
|
description,
|
|
anime_url,
|
|
genres,
|
|
rating,
|
|
r#type,
|
|
season,
|
|
});
|
|
}
|
|
|
|
Ok(items)
|
|
}
|
|
|
|
/// Parse genre-filtered anime items from HTML
|
|
pub fn parse_genre_anime(
|
|
html: &str,
|
|
) -> Result<Vec<GenreAnimeItem>, Box<dyn std::error::Error + Send + Sync>> {
|
|
let document = parse_html(html);
|
|
let selectors = ANIME_SELECTORS.as_ref().map_err(|e| e.clone())?;
|
|
let mut items = Vec::new();
|
|
|
|
for element in document.select(&selectors.item) {
|
|
let title = text_from_or(&element, &selectors.title, "");
|
|
if title.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let poster = extract_poster(&element, &selectors.img);
|
|
let score = text_from_or(&element, &selectors.score, "N/A");
|
|
let status = text_from_or(&element, &selectors.status, "Unknown");
|
|
let anime_url = attr_from_or(&element, &selectors.link, "href", "");
|
|
let slug = extract_slug(&anime_url);
|
|
|
|
items.push(GenreAnimeItem {
|
|
title,
|
|
slug,
|
|
poster,
|
|
score,
|
|
status,
|
|
anime_url,
|
|
});
|
|
}
|
|
|
|
Ok(items)
|
|
}
|
|
|
|
// ============================================================================
|
|
// PAGINATION PARSERS
|
|
// ============================================================================
|
|
|
|
/// Parse pagination from HTML document
|
|
pub fn parse_pagination(document: &Html, current_page: u32) -> Result<Pagination, String> {
|
|
let pagination_selector =
|
|
selector(".pagination .page-numbers:not(.next)").ok_or("Invalid selector")?;
|
|
let next_selector = selector(".pagination .next").ok_or("Invalid selector")?;
|
|
|
|
let last_visible_page = document
|
|
.select(&pagination_selector)
|
|
.next_back()
|
|
.and_then(|e| text(&e).trim().parse::<u32>().ok())
|
|
.unwrap_or(current_page);
|
|
|
|
let has_next_page = document.select(&next_selector).next().is_some();
|
|
let next_page = if has_next_page {
|
|
Some(current_page + 1)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let has_previous_page = current_page > 1;
|
|
let previous_page = if has_previous_page {
|
|
Some(current_page - 1)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Ok(Pagination {
|
|
current_page,
|
|
last_visible_page,
|
|
has_next_page,
|
|
next_page,
|
|
has_previous_page,
|
|
previous_page,
|
|
})
|
|
}
|
|
|
|
/// Parse pagination with string-based page numbers (for search results)
|
|
pub fn parse_pagination_with_string(
|
|
document: &Html,
|
|
current_page: u32,
|
|
) -> Result<PaginationWithStringPages, String> {
|
|
let pagination_selector =
|
|
selector(".pagination .page-numbers:not(.next)").ok_or("Invalid selector")?;
|
|
let next_selector = selector(".pagination .next").ok_or("Invalid selector")?;
|
|
|
|
let last_visible_page = document
|
|
.select(&pagination_selector)
|
|
.last()
|
|
.and_then(|e| text(&e).trim().parse::<u32>().ok())
|
|
.unwrap_or(current_page);
|
|
|
|
let has_next_page = document.select(&next_selector).next().is_some();
|
|
|
|
let next_page = if has_next_page {
|
|
document
|
|
.select(&next_selector)
|
|
.next()
|
|
.and_then(|e| attr(&e, "href"))
|
|
.and_then(|href| href.split("/page/").nth(1).map(|s| s.to_string()))
|
|
.and_then(|s| s.split('/').next().map(|s| s.to_string()))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let has_previous_page = current_page > 1;
|
|
let previous_page = if has_previous_page {
|
|
Some((current_page - 1).to_string())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Ok(PaginationWithStringPages {
|
|
current_page,
|
|
last_visible_page,
|
|
has_next_page,
|
|
next_page,
|
|
has_previous_page,
|
|
previous_page,
|
|
})
|
|
}
|