From bb728b2fce525be2b1a4de605d43f9eb77a23808 Mon Sep 17 00:00:00 2001 From: kantan <31490942+kkantan@users.noreply.github.com> Date: Tue, 7 Jul 2026 04:55:05 +0900 Subject: [PATCH] fix(multi.mangafire): update for site rewrite --- sources/multi.mangafire/Cargo.toml | 12 +- sources/multi.mangafire/res/filters.json | 242 ++++++++++--- sources/multi.mangafire/res/source.json | 16 +- sources/multi.mangafire/src/helpers.rs | 45 +++ sources/multi.mangafire/src/home.rs | 189 +++------- sources/multi.mangafire/src/lib.rs | 431 ++++++++--------------- sources/multi.mangafire/src/models.rs | 138 +++++++- sources/multi.mangafire/src/settings.rs | 5 +- sources/multi.mangafire/src/vrf.rs | 231 ------------ 9 files changed, 575 insertions(+), 734 deletions(-) create mode 100644 sources/multi.mangafire/src/helpers.rs delete mode 100644 sources/multi.mangafire/src/vrf.rs diff --git a/sources/multi.mangafire/Cargo.toml b/sources/multi.mangafire/Cargo.toml index d4c86a7a1..00522a4e1 100644 --- a/sources/multi.mangafire/Cargo.toml +++ b/sources/multi.mangafire/Cargo.toml @@ -3,19 +3,19 @@ name = "mangafire" version = "0.1.0" edition = "2024" +[lib] +crate-type = ["cdylib"] + [dependencies] aidoku = { git = "https://github.com/Aidoku/aidoku-rs.git", version = "0.3.0", features = ["json"] } -base64 = { version = "0.22.1", default-features = false, features = ["alloc"] } -serde = { version = "1.0.188", default-features = false, features = ["derive", "alloc"] } -serde_json = { version = "1.0.105", default-features = false, features = ["alloc"] } +base64 = { version = "0.22", default-features = false, features = ["alloc"] } +serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] } +serde_json = { version = "1.0", default-features = false, features = ["alloc"] } [dev-dependencies] aidoku = { git = "https://github.com/Aidoku/aidoku-rs.git", features = ["test"] } aidoku-test = { git = "https://github.com/Aidoku/aidoku-rs.git" } -[lib] -crate-type = ["cdylib"] - [profile.dev] panic = "abort" diff --git a/sources/multi.mangafire/res/filters.json b/sources/multi.mangafire/res/filters.json index bd55a2852..3331e0611 100644 --- a/sources/multi.mangafire/res/filters.json +++ b/sources/multi.mangafire/res/filters.json @@ -5,21 +5,29 @@ "title": "Author", "placeholder": "Author name" }, + { + "type": "text", + "id": "artist", + "title": "Artist", + "placeholder": "Artist name" + }, { "type": "sort", "title": "Sort", "canAscend": false, "options": [ - "Most relevance", - "Recently updated", + "Best match", + "Latest update", "Recently added", - "Release date", - "Trending", - "Name A-Z", - "Scores", - "MAL scores", - "Most viewed", - "Most favourited" + "Title (A–Z)", + "Title (Z–A)", + "Year (newest)", + "Year (oldest)", + "Highest rated", + "Most viewed · 7 days", + "Most viewed · 30 days", + "Most viewed · all time", + "Most followed" ], "default": { "index": 1, @@ -28,58 +36,25 @@ }, { "type": "multi-select", - "id": "type", + "id": "types[]", "title": "Type", - "options": [ - "Manga", - "One-Shot", - "Doujinshi", - "Novel", - "Manhwa", - "Manhua" - ], - "ids": ["manga", "one_shot", "doujinshi", "novel", "manhwa", "manhua"] + "options": ["Manga", "Manhwa", "Manhua", "Other"], + "ids": ["manga", "manhwa", "manhua", "other"] }, { "type": "multi-select", - "id": "status[]", - "title": "Status", - "options": [ - "Completed", - "Releasing", - "On Hiatus", - "Discontinued", - "Not Yet Published" - ], - "ids": ["completed", "releasing", "on_hiatus", "discontinued", "info"] - }, - { - "type": "select", - "id": "minchap", - "title": "Length", - "options": [ - ">= 1 chapters", - ">= 3 chapters", - ">= 5 chapters", - ">= 10 chapters", - ">= 20 chapters", - ">= 30 chapters", - ">= 50 chapters" - ], - "ids": ["1", "3", "5", "10", "20", "30", "50"] - }, - { - "type": "multi-select", - "id": "genre[]", - "title": "Genre", + "id": "genres", + "title": "Genres", "isGenre": true, "canExclude": true, "options": [ "Action", + "Adult", "Adventure", "Avant Garde", "Boys Love", "Comedy", + "Crime", "Demons", "Drama", "Ecchi", @@ -87,19 +62,25 @@ "Girls Love", "Gourmet", "Harem", + "Hentai", + "Historical", "Horror", "Isekai", "Iyashikei", "Josei", "Kids", "Magic", + "Magical Girls", "Mahou Shoujo", "Martial Arts", + "Mature", "Mecha", + "Medical", "Military", "Music", "Mystery", "Parody", + "Philosophical", "Psychological", "Reverse Harem", "Romance", @@ -109,20 +90,26 @@ "Shoujo", "Shounen", "Slice of Life", + "Smut", "Space", "Sports", "Super Power", + "Superhero", "Supernatural", "Suspense", "Thriller", - "Vampire" + "Tragedy", + "Vampire", + "Wuxia" ], "ids": [ "1", + "268929", "78", "3", "4", "5", + "268921", "77", "6", "7", @@ -130,19 +117,25 @@ "9", "10", "11", + "268930", + "268922", "530", "13", "531", "15", "532", "539", + "268923", "533", "534", + "268931", "19", + "268924", "535", "21", "22", "23", + "268925", "536", "25", "26", @@ -152,22 +145,161 @@ "30", "31", "538", + "268932", "33", "34", "75", + "268926", "76", "37", "38", - "39" + "268927", + "39", + "268928" + ] + }, + { + "type": "select", + "id": "genres_mode", + "title": "Genre inclusion mode", + "options": ["All", "Any"], + "ids": ["and", "or"], + "default": "and", + "hideFromHeader": true + }, + { + "type": "multi-select", + "id": "theme_ids[]", + "title": "Themes", + "isGenre": true, + "canExclude": false, + "options": [ + "Aliens", + "Animals", + "Cooking", + "Crossdressing", + "Delinquents", + "Demons", + "Genderswap", + "Ghosts", + "Gyaru", + "Harem", + "Incest", + "Loli", + "Mafia", + "Magic", + "Martial Arts", + "Military", + "Monster Girls", + "Monsters", + "Music", + "Ninja", + "Office Workers", + "Police", + "Post-Apocalyptic", + "Reincarnation", + "Reverse Harem", + "Samurai", + "School Life", + "Shota", + "Supernatural", + "Survival", + "Time Travel", + "Traditional Games", + "Vampires", + "Video Games", + "Villainess", + "Virtual Reality", + "Zombies" + ], + "ids": [ + "268933", + "268934", + "268935", + "268936", + "268937", + "268938", + "268939", + "268940", + "268941", + "268942", + "268943", + "268944", + "268945", + "268946", + "268947", + "268948", + "268949", + "268950", + "268951", + "268952", + "268953", + "268954", + "268955", + "268956", + "268957", + "268958", + "268959", + "268960", + "268961", + "268962", + "268963", + "268964", + "268965", + "268966", + "268967", + "268968", + "268969" ] }, { "type": "select", - "id": "genre_mode", - "title": "Genre selection mode", - "options": ["AND", "OR"], + "id": "theme_mode", + "title": "Theme inclusion mode", + "options": ["All", "Any"], "ids": ["and", "or"], - "default": "or", + "default": "and", "hideFromHeader": true + }, + { + "type": "multi-select", + "id": "demographics[]", + "title": "Demographic", + "isGenre": true, + "canExclude": false, + "options": ["Josei", "Seinen", "Shoujo", "Shounen"], + "ids": ["268919", "268920", "268917", "268918"] + }, + { + "type": "multi-select", + "id": "statuses[]", + "title": "Status", + "options": [ + "Releasing", + "Finished", + "On Hiatus", + "Discontinued", + "Not Yet Released" + ], + "ids": [ + "releasing", + "finished", + "on_hiatus", + "discontinued", + "not_yet_released" + ] + }, + { + "type": "range", + "id": "year", + "title": "Release year", + "min": 0, + "decimal": false + }, + { + "type": "text", + "id": "minchap", + "title": "Minimum chapters", + "placeholder": "Any" } ] diff --git a/sources/multi.mangafire/res/source.json b/sources/multi.mangafire/res/source.json index db534109b..6c02bc08a 100644 --- a/sources/multi.mangafire/res/source.json +++ b/sources/multi.mangafire/res/source.json @@ -2,21 +2,13 @@ "info": { "id": "multi.mangafire", "name": "MangaFire", - "version": 5, + "version": 6, "url": "https://mangafire.to", "contentRating": 1, "languages": ["en", "es", "es-419", "fr", "ja", "pt", "pt-BR"], "minAppVersion": "0.7.1" }, - "listings": [ - { - "id": "Newest" - }, - { - "id": "Updated" - }, - { - "id": "Added" - } - ] + "config": { + "breakingChangeVersion": 6 + } } diff --git a/sources/multi.mangafire/src/helpers.rs b/sources/multi.mangafire/src/helpers.rs new file mode 100644 index 000000000..1895e4230 --- /dev/null +++ b/sources/multi.mangafire/src/helpers.rs @@ -0,0 +1,45 @@ +use crate::BASE_URL; +use crate::models::{ApiEntity, ApiTagsResponse}; +use aidoku::{ + Result, + alloc::{ + string::{String, ToString}, + vec::Vec, + }, + helpers::uri::encode_uri_component, + imports::net::Request, + prelude::*, +}; + +pub fn find_tag_id(keyword: &str, tag_type: &str) -> Result> { + let response = Request::get(format!( + "{BASE_URL}/api/tags?keyword={}", + encode_uri_component(keyword) + ))? + .header("Accept", "application/json") + .header("Referer", &format!("{BASE_URL}/browse")) + .send()? + .get_json::()?; + + Ok(response + .data + .iter() + .find(|tag| tag.tag_type == tag_type && tag.name.eq_ignore_ascii_case(keyword)) + .or_else(|| response.data.iter().find(|tag| tag.tag_type == tag_type)) + .map(|tag| tag.id.to_string())) +} + +pub fn entity_titles(entities: Vec) -> Vec { + entities.into_iter().map(|entity| entity.title).collect() +} + +pub fn api_tags(genres: Option>, themes: Option>) -> Vec { + let mut tags = Vec::new(); + if let Some(genres) = genres { + tags.extend(entity_titles(genres)); + } + if let Some(themes) = themes { + tags.extend(entity_titles(themes)); + } + tags +} diff --git a/sources/multi.mangafire/src/home.rs b/sources/multi.mangafire/src/home.rs index a06eb45a4..ffd847489 100644 --- a/sources/multi.mangafire/src/home.rs +++ b/sources/multi.mangafire/src/home.rs @@ -1,11 +1,12 @@ +use crate::models::{ApiManga, ApiResponse}; use crate::{BASE_URL, MangaFire}; -use aidoku::helpers::string::StripPrefixOrSelf; -use aidoku::imports::html::Document; -use aidoku::{Chapter, Link}; use aidoku::{ - Home, HomeComponent, HomeLayout, HomePartialResult, Manga, MangaWithChapter, Result, - alloc::{Vec, vec}, - imports::{net::Request, std::send_partial_result}, + Chapter, Home, HomeComponent, HomeLayout, HomePartialResult, Manga, MangaWithChapter, Result, + alloc::vec, + imports::{ + net::{Request, RequestError, Response}, + std::send_partial_result, + }, prelude::*, }; @@ -15,155 +16,69 @@ impl Home for MangaFire { send_partial_result(&HomePartialResult::Layout(HomeLayout { components: vec![ HomeComponent { - title: None, - subtitle: None, - value: aidoku::HomeComponentValue::empty_big_scroller(), - }, - HomeComponent { - title: Some("Most Viewed".into()), - subtitle: None, + title: Some("Trending".into()), + subtitle: Some("Trending Now".into()), value: aidoku::HomeComponentValue::empty_scroller(), }, HomeComponent { - title: Some("Recently Updated".into()), - subtitle: None, + title: Some("Latest Updates".into()), + subtitle: Some("Fresh Chapters".into()), value: aidoku::HomeComponentValue::empty_manga_chapter_list(), }, - HomeComponent { - title: Some("New Release".into()), - subtitle: None, - value: aidoku::HomeComponentValue::empty_scroller(), - }, ], })); - let html = Request::get(format!("{BASE_URL}/home"))?.html()?; - - fn parse_scroller_entries(html: &Document, section_selector: &str) -> Vec { - html.select_first(section_selector) - .and_then(|section| { - section - .select(".swiper-wrapper > .swiper-slide") - .map(|els| { - els.filter_map(|el| { - Some( - Manga { - key: el - .select_first("a")? - .attr("href")? - .strip_prefix_or_self(BASE_URL) - .into(), - title: el - .select_first("a > span")? - .text() - .unwrap_or_default(), - cover: el - .select_first(".poster img") - .and_then(|img| img.attr("src")), - ..Default::default() - } - .into(), - ) - }) - .collect() - }) - }) - .unwrap_or_default() - } + let responses: [core::result::Result; 2] = Request::send_all([ + Request::get(format!( + "{BASE_URL}/api/top-titles?type=trending&days=1&limit=30" + ))?, + Request::get(format!( + "{BASE_URL}/api/titles?order%5Bchapter_updated_at%5D=desc&hot=1&page=1&limit=30" + ))?, + ]) + .try_into() + .expect("requests vec length should be 2"); + let [trending_res, latest_res] = responses; let components = vec![ HomeComponent { - title: None, - subtitle: None, - value: aidoku::HomeComponentValue::BigScroller { - entries: html - .select(".trending > .swiper-wrapper > .swiper-slide") - .map(|els| { - els.filter_map(|el| { - let link = el.select_first(".info > .above > a")?; - let key = link.attr("href")?.strip_prefix_or_self(BASE_URL).into(); - Some(Manga { - key, - title: link.text().unwrap_or_default(), - cover: el.select_first("img").and_then(|img| img.attr("src")), - description: el - .select_first(".info > .below > span") - .and_then(|el| el.text()), - tags: el - .select(".info > .below a") - .map(|els| els.filter_map(|el| el.text()).collect()), - ..Default::default() - }) - }) - .collect() - }) - .unwrap_or_default(), - auto_scroll_interval: Some(10.0), - }, - }, - HomeComponent { - title: Some("Most Viewed".into()), - subtitle: None, + title: Some("Trending".into()), + subtitle: Some("Trending Now".into()), value: aidoku::HomeComponentValue::Scroller { - entries: parse_scroller_entries(&html, "#most-viewed"), + entries: trending_res? + .get_json::>() + .map(|json| { + json.items + .into_iter() + .map(Manga::from) + .map(Into::into) + .collect() + })?, listing: None, }, }, HomeComponent { - title: Some("Recently Updated".into()), - subtitle: None, + title: Some("Latest Updates".into()), + subtitle: Some("Fresh Chapters".into()), value: aidoku::HomeComponentValue::MangaChapterList { - page_size: Some(12), - entries: html - .select("section > .tab-content > .original > .unit") - .map(|els| { - els.filter_map(|el| { - let chapter = el.select_first("ul.content > li")?; - Some(MangaWithChapter { - manga: Manga { - key: el - .select_first("a")? - .attr("href")? - .strip_prefix_or_self(BASE_URL) - .into(), - title: el - .select_first(".info a") - .and_then(|el| el.text()) - .unwrap_or_default(), - cover: el - .select_first(".poster img") - .and_then(|img| img.attr("src")), - ..Default::default() - }, - chapter: Chapter { - key: el - .select_first("a")? - .attr("href")? - .strip_prefix_or_self(BASE_URL) - .into(), - chapter_number: chapter - .select_first("a > span") - .and_then(|el| el.own_text()) - .and_then(|el| { - el.strip_prefix("Chap ") - .and_then(|s| s.parse().ok()) - }), - // date_uploaded: (), // todo: parse relative dates - ..Default::default() - }, + page_size: Some(5), + entries: latest_res? + .get_json::>() + .map(|json| { + json.items + .into_iter() + .map(|item| { + let chapter_number = item.latest_chapter; + MangaWithChapter { + manga: item.into(), + chapter: Chapter { + chapter_number, + ..Default::default() + }, + } }) - }) - .collect() - }) - .unwrap_or_default(), - listing: None, - }, - }, - HomeComponent { - title: Some("New Release".into()), - subtitle: None, - value: aidoku::HomeComponentValue::Scroller { - entries: parse_scroller_entries(&html, ".swiper.completed"), + .collect() + })?, listing: None, }, }, diff --git a/sources/multi.mangafire/src/lib.rs b/sources/multi.mangafire/src/lib.rs index 843ccca45..d1832ad0b 100644 --- a/sources/multi.mangafire/src/lib.rs +++ b/sources/multi.mangafire/src/lib.rs @@ -1,26 +1,20 @@ #![no_std] use aidoku::{ Chapter, ContentRating, DeepLinkHandler, DeepLinkResult, FilterValue, ImageRequestProvider, - Listing, ListingProvider, Manga, MangaPageResult, MangaStatus, Page, PageContent, PageContext, - Result, Source, Viewer, - alloc::{String, Vec, string::ToString, vec}, - helpers::uri::{QueryParameters, encode_uri_component}, - imports::{ - error::AidokuError, - html::{Document, Html}, - net::Request, - std::{current_date, parse_date, send_partial_result}, - }, + Manga, MangaPageResult, MangaStatus, Page, PageContext, Result, Source, Viewer, + alloc::{String, Vec, string::ToString}, + helpers::uri::QueryParameters, + imports::{html::Html, net::Request, std::send_partial_result}, prelude::*, }; +mod helpers; mod home; mod models; mod settings; -mod vrf; +use helpers::*; use models::*; -use vrf::VrfGenerator; const BASE_URL: &str = "https://mangafire.to"; @@ -39,101 +33,90 @@ impl Source for MangaFire { ) -> Result { let mut qs = QueryParameters::new(); - let mut author = None; - // parse filters for filter in filters { match filter { FilterValue::Text { id, value } => match id.as_str() { - "author" => { - author = Some(encode_uri_component(value.to_lowercase().replace(' ', "-"))); + "author" | "artist" => { + if let Some(tag) = find_tag_id(&value, id.as_str())? { + qs.push( + if id == "author" { + "authors[]" + } else { + "artists[]" + }, + Some(&tag), + ); + } } - _ => return Err(AidokuError::Message("Invalid text filter id".into())), + "minchap" => qs.push("min_chap", Some(&value)), + _ => bail!("Invalid text filter id"), }, FilterValue::Sort { index, .. } => { - let value = match index { - 0 => "most_relevance", - 1 => "recently_updated", - 2 => "recently_added", - 3 => "release_date", - 4 => "trending", - 5 => "title_az", - 6 => "scores", - 7 => "mal_scores", - 8 => "most_viewed", - 9 => "most_favourited", - _ => return Err(AidokuError::Message("Invalid sort filter index".into())), + let (key, value) = match index { + 0 => ("order[relevance]", "desc"), + 1 => ("order[chapter_updated_at]", "desc"), + 2 => ("order[created_at]", "desc"), + 3 => ("order[title]", "asc"), + 4 => ("order[title]", "desc"), + 5 => ("order[year]", "desc"), + 6 => ("order[year]", "asc"), + 7 => ("order[score]", "desc"), + 8 => ("order[views_7d]", "desc"), + 9 => ("order[views_30d]", "desc"), + 10 => ("order[views_total]", "desc"), + 11 => ("order[follows_total]", "desc"), + _ => bail!("Invalid sort filter index"), }; - qs.push("sort", Some(value)); + qs.push(key, Some(value)); } FilterValue::MultiSelect { id, included, excluded, - } => { - for option in included { - qs.push(&id, Some(&option)); + } => match id.as_str() { + "genres" => { + for option in included { + qs.push("genres_in[]", Some(&option)); + } + for option in excluded { + qs.push("genres_ex[]", Some(&option)); + } } - for option in excluded { - qs.push(&id, Some(&format!("-{option}"))); + _ => { + for option in included { + qs.push(&id, Some(&option)); + } + } + }, + FilterValue::Select { id, value } => qs.push(&id, Some(&value)), + FilterValue::Range { from, to, .. } => { + if let Some(from) = from { + qs.push_encoded("year_from", Some(&from.to_string())); + } + if let Some(to) = to { + qs.push_encoded("year_to", Some(&to.to_string())); } - } - FilterValue::Select { id, value } => { - qs.push(&id, Some(&value)); } _ => {} } } - if let Some(query) = query { - qs.push("keyword", Some(&query)); - - let vrf = VrfGenerator::generate(&query); - qs.push("vrf", Some(&vrf)); - } - - let url = if let Some(author) = author { - format!( - "{BASE_URL}/author/{author}\ - ?page={page}\ - &{qs}" - ) - } else { - format!( - "{BASE_URL}/filter\ - ?page={page}\ - &{qs}", - ) - }; - - let mut entries = Vec::new(); - let mut has_next_page = false; - - let langs = settings::get_languages()?; - for lang in langs { - let html = Request::get(format!("{url}&language%5B%5D={lang}"))? - .header("Referer", &format!("{BASE_URL}/")) - .html()?; - let result = parse_manga_page(&html); - entries.extend(result.entries); - has_next_page = result.has_next_page || has_next_page; + if query.is_some() { + qs.push("keyword", query.as_deref()); } + qs.push_encoded("page", Some(&page.to_string())); + qs.push_encoded("limit", Some("50")); - // remove duplicates - let mut seen = Vec::new(); - entries.retain(|item| { - if seen.contains(&item.key) { - false - } else { - seen.push(item.key.clone()); - true - } - }); - - Ok(MangaPageResult { - entries, - has_next_page, - }) + Request::get(format!("{BASE_URL}/api/titles?{qs}"))? + .header("Accept", "application/json") + .header("Referer", &format!("{BASE_URL}/")) + .send()? + .get_json::>() + .map(|response| MangaPageResult { + entries: response.items.into_iter().map(Manga::from).collect(), + has_next_page: response.meta.is_some_and(|meta| meta.has_next), + }) } fn get_manga_update( @@ -142,69 +125,58 @@ impl Source for MangaFire { needs_details: bool, needs_chapters: bool, ) -> Result { - let manga_url = format!("{BASE_URL}{}", manga.key); + if manga.key.starts_with("/manga") { + bail!("Migrate this title to update details.") + } if needs_details { - let html = Request::get(&manga_url)?.html()?; - - let content = html - .select_first(".main-inner:not(.manga-bottom)") - .ok_or(error!("manga details element missing"))?; - let meta = html - .select_first(".meta") - .ok_or(error!("metadata element missing"))?; - - manga.title = content - .select_first("h1") - .and_then(|e| e.text()) - .unwrap_or_default(); - manga.cover = content - .select_first(".poster img") - .and_then(|e| e.attr("src")); - manga.authors = meta - .select_first("span:contains(Author:) + span") - .and_then(|e| e.text()) - .map(|txt| vec![txt]); - manga.description = html - .select_first("#synopsis .modal-content") - .and_then(|e| e.text()); - manga.url = Some(manga_url.clone()); - manga.tags = meta - .select_first("span:contains(Genres:) + span") - .and_then(|e| e.text()) - .map(|txt| txt.split(',').map(|s| s.trim().to_string()).collect()); - manga.status = content - .select_first(".info > p") - .and_then(|e| e.text()) - .map(|txt| match txt.to_lowercase().as_str() { - "releasing" => MangaStatus::Ongoing, - "completed" => MangaStatus::Completed, - "on_hiatus" => MangaStatus::Hiatus, - "discontinued" => MangaStatus::Cancelled, - _ => MangaStatus::Unknown, - }) - .unwrap_or_default(); + let details = Request::get(format!("{BASE_URL}/api/titles/{}", manga.key))? + .header("Accept", "application/json") + .header("Referer", &format!("{BASE_URL}/")) + .send()? + .get_json::()? + .data; + + manga.title = details.title; + manga.cover = details + .poster + .and_then(|poster| poster.large.or(poster.medium).or(poster.small)); + manga.authors = details.authors.map(entity_titles); + manga.artists = details.artists.map(entity_titles); + manga.description = details.synopsis_html.and_then(|html| { + Html::parse_fragment(&html) + .ok() + .and_then(|doc| doc.select_first("body").and_then(|body| body.text())) + }); + manga.url = Some(format!("{BASE_URL}{}", manga.key)); + manga.tags = Some(api_tags(details.genres, details.themes)); + manga.status = match details.status.as_deref() { + Some("releasing") => MangaStatus::Ongoing, + Some("finished") => MangaStatus::Completed, + Some("on_hiatus") => MangaStatus::Hiatus, + Some("discontinued") => MangaStatus::Cancelled, + _ => MangaStatus::Unknown, + }; manga.content_rating = manga .tags .as_ref() .map(|tags| { - if tags.iter().any(|tag| tag == "Ecchi") { + if tags + .iter() + .any(|tag| matches!(tag.as_str(), "Adult" | "Mature" | "Smut")) + { + ContentRating::NSFW + } else if tags.iter().any(|tag| tag == "Ecchi") { ContentRating::Suggestive } else { ContentRating::Unknown } }) .unwrap_or_default(); - manga.viewer = content - .select_first(".info > .min-info > a") - .and_then(|e| e.text()) - .map(|txt| match txt.as_str() { - "Manhua" => Viewer::Webtoon, - "Manhwa" => Viewer::Webtoon, - "Manga" => Viewer::RightToLeft, - _ => Viewer::RightToLeft, - }) - .unwrap_or(Viewer::RightToLeft); + manga.viewer = match details.manga_type.as_deref() { + Some("manhua" | "manhwa") => Viewer::Webtoon, + _ => Viewer::RightToLeft, + }; if needs_chapters { send_partial_result(&manga); @@ -212,72 +184,37 @@ impl Source for MangaFire { } if needs_chapters { - let manga_id = manga_url - .rsplit('.') - .next() - .map(|s| s.trim().to_string()) - .ok_or(error!("missing manga id"))?; - let mut chapters = Vec::new(); - let languages = settings::get_languages()?; for lang in &languages { - let ajax_manga_url = format!("{BASE_URL}/ajax/manga/{manga_id}/chapter/{lang}"); - let manga_list = Request::get(&ajax_manga_url)? - .send()? - .get_json::>() - .map(|response| Html::parse_fragment(&response.result))?? - .select("li") - .ok_or(error!("failed manga_list select"))?; - - let vrf = VrfGenerator::generate(&format!("{manga_id}@chapter@{lang}")); - let ajax_read_url = - format!("{BASE_URL}/ajax/read/{manga_id}/chapter/{lang}?vrf={vrf}"); - let read_list = Request::get(&ajax_read_url)? - .send()? - .get_json::>() - .map(|response| { - Html::parse_fragment_with_url(&response.result.html, BASE_URL) - })?? - .select("ul a") - .ok_or(error!("failed read_list select"))?; - - chapters.extend(manga_list.zip(read_list).filter_map(|(m, r)| { - let link = r.select_first("a")?; - let key = format!("chapter/{}", r.attr("data-id")?); - - let url = link.attr("abs:href")?; - - let number = m.attr("data-number")?; - if number != r.attr("data-number")? { - return None; + let mut page = 1; + loop { + let mut qs = QueryParameters::new(); + qs.push_encoded("language", Some(lang)); + qs.push_encoded("sort", Some("number")); + qs.push_encoded("order", Some("desc")); + qs.push_encoded("page", Some(&page.to_string())); + qs.push_encoded("limit", Some("200")); + + let response = + Request::get(format!("{BASE_URL}/api/titles/{}/chapters?{qs}", manga.key))? + .header("Accept", "application/json") + .header("Referer", &format!("{BASE_URL}/")) + .send()? + .get_json::>()?; + + chapters.extend( + response + .items + .into_iter() + .map(|chapter| chapter.into_chapter(&manga.key, lang)), + ); + + if !response.meta.is_some_and(|meta| meta.has_next) { + break; } - - let title = link.text().map(|title| { - let prefix = format!("Chap {number}:"); - if title.starts_with(&prefix) { - title[prefix.len()..].trim().to_string() - } else { - title - } - }); - let date_uploaded = m - .select("span") - .and_then(|els| els.get(1)) - .and_then(|el| el.text()) - .and_then(|txt| parse_date(txt, "MMM dd, yyyy")) - .unwrap_or_else(current_date); // fallback for relative dates - - Some(Chapter { - key, - title, - chapter_number: number.parse::().ok(), - date_uploaded: Some(date_uploaded), - url: Some(url), - language: Some(lang.clone()), - ..Default::default() - }) - })); + page += 1; + } } if languages.len() > 1 { chapters.sort_by_key(|c| core::cmp::Reverse(c.chapter_number.map(|n| n as i32))); @@ -290,41 +227,12 @@ impl Source for MangaFire { } fn get_page_list(&self, _manga: Manga, chapter: Chapter) -> Result> { - let vrf = VrfGenerator::generate(&chapter.key.replace("/", "@")); // chapter/id -> chapter@id - let ajax_url = format!("{BASE_URL}/ajax/read/{}?vrf={vrf}", chapter.key); - - Request::get(&ajax_url)? - .send()? - .get_json::>() - .map(|response| { - response - .result - .images - .iter() - .filter_map(|img| { - let url = img.first()?.as_str()?; - Some(Page { - content: PageContent::url(url), - ..Default::default() - }) - }) - .collect() - }) - } -} - -impl ListingProvider for MangaFire { - fn get_manga_list(&self, listing: Listing, page: i32) -> Result { - let url = match listing.id.as_str() { - "Newest" => format!("{BASE_URL}/newest?page={page}"), - "Updated" => format!("{BASE_URL}/updated?page={page}"), - "Added" => format!("{BASE_URL}/added?page={page}"), - _ => bail!("Invalid listing ID"), - }; - let html = Request::get(url)? + Request::get(format!("{BASE_URL}/api/chapters/{}", chapter.key))? + .header("Accept", "application/json") .header("Referer", &format!("{BASE_URL}/")) - .html()?; - Ok(parse_manga_page(&html)) + .send()? + .get_json::() + .map(|response| response.data.pages.into_iter().map(Page::from).collect()) } } @@ -340,21 +248,17 @@ impl DeepLinkHandler for MangaFire { return Ok(None); }; - const MANGA_PATH: &str = "/manga"; - const CHAPTER_PATH: &str = "/read"; + const TITLE_PATH: &str = "/title/"; - if path.starts_with(MANGA_PATH) { - // ex: https://mangafire.to/manga/one-piecee.dkw - Ok(Some(DeepLinkResult::Manga { key: path.into() })) - } else if let Some(remaining_path) = path.strip_prefix(CHAPTER_PATH) { - // ex: https://mangafire.to/read/one-piecee.dkw/en/chapter-1 - let end = remaining_path - .find('/') - // get the second slash - .and_then(|i| remaining_path[i + 1..].find('/').map(|j| i + 1 + j)) - .unwrap_or(remaining_path.len()); - let key = format!("{MANGA_PATH}{}", &remaining_path[..end]); - // can't get chapter key due to missing data-id + if let Some(path) = path.strip_prefix(TITLE_PATH) { + // ex: https://mangafire.to/title/dkw-one-piece -> dkw + // ex: https://mangafire.to/title/pm666-haimiya-senpai-is-scary-but-cute/7511141-chapter-34-en -> pm666 + let key = path + .split(['-', '/']) + .next() + .filter(|hid| !hid.is_empty()) + .map(String::from) + .ok_or(error!("Missing manga hid"))?; Ok(Some(DeepLinkResult::Manga { key })) } else { Ok(None) @@ -362,37 +266,4 @@ impl DeepLinkHandler for MangaFire { } } -fn parse_manga_page(html: &Document) -> MangaPageResult { - MangaPageResult { - entries: html - .select(".original.card-lg .unit .inner") - .map(|els| { - els.filter_map(|element| { - let title_element = element.select_first(".info > a")?; - let title = title_element.own_text().unwrap_or_default(); - let url = title_element.attr("abs:href")?; - let key = url.strip_prefix(BASE_URL).map(String::from)?; - let cover = element.select_first("img")?.attr("abs:src"); - Some(Manga { - key, - title, - cover, - ..Default::default() - }) - }) - .collect() - }) - .unwrap_or_default(), - has_next_page: html - .select_first(".page-item.active + .page-item .page-link") - .is_some(), - } -} - -register_source!( - MangaFire, - Home, - ListingProvider, - ImageRequestProvider, - DeepLinkHandler -); +register_source!(MangaFire, Home, ImageRequestProvider, DeepLinkHandler); diff --git a/sources/multi.mangafire/src/models.rs b/sources/multi.mangafire/src/models.rs index edd43d63c..8a0568a11 100644 --- a/sources/multi.mangafire/src/models.rs +++ b/sources/multi.mangafire/src/models.rs @@ -1,18 +1,134 @@ -use aidoku::alloc::{String, Vec}; +use aidoku::{ + Chapter, Manga, Page, PageContent, + alloc::{String, Vec, format, string::ToString}, +}; use serde::Deserialize; -use serde_json::Value; -#[derive(Default, Deserialize, Debug, Clone)] -pub struct AjaxResponse { - pub result: T, +#[derive(Deserialize)] +pub struct ApiResponse { + pub items: Vec, + pub meta: Option, } -#[derive(Default, Deserialize, Debug, Clone)] -pub struct AjaxRead { - pub html: String, +#[derive(Deserialize)] +pub struct ApiMeta { + #[serde(rename = "hasNext")] + pub has_next: bool, } -#[derive(Default, Deserialize, Debug, Clone)] -pub struct AjaxPageList { - pub images: Vec>, +#[derive(Deserialize)] +pub struct ApiManga { + pub hid: String, + pub slug: Option, + pub title: String, + pub poster: Option, + #[serde(rename = "latestChapter")] + pub latest_chapter: Option, +} + +impl From for Manga { + fn from(value: ApiManga) -> Self { + Manga { + key: value.hid, + title: value.title, + cover: value + .poster + .and_then(|poster| poster.large.or(poster.medium).or(poster.small)), + ..Default::default() + } + } +} + +#[derive(Deserialize)] +pub struct ApiPoster { + pub small: Option, + pub medium: Option, + pub large: Option, +} + +#[derive(Deserialize)] +pub struct ApiDetailsResponse { + pub data: ApiMangaDetails, +} + +#[derive(Deserialize)] +pub struct ApiMangaDetails { + // pub hid: String, + // pub slug: Option, + pub title: String, + #[serde(rename = "type")] + pub manga_type: Option, + pub status: Option, + pub poster: Option, + #[serde(rename = "synopsisHtml")] + pub synopsis_html: Option, + pub authors: Option>, + pub artists: Option>, + pub genres: Option>, + pub themes: Option>, +} + +#[derive(Deserialize)] +pub struct ApiEntity { + pub title: String, +} + +#[derive(Deserialize)] +pub struct ApiChapter { + pub id: i32, + pub number: f32, + pub name: Option, + #[serde(rename = "createdAt")] + pub created_at: Option, +} + +impl ApiChapter { + pub fn into_chapter(self, manga_key: &str, lang: &str) -> Chapter { + Chapter { + key: self.id.to_string(), + title: self.name.filter(|name| !name.is_empty()), + chapter_number: Some(self.number), + date_uploaded: self.created_at, + url: Some(format!("{manga_key}/{}", self.id)), + language: Some(lang.to_string()), + ..Default::default() + } + } +} + +#[derive(Deserialize)] +pub struct ApiPagesResponse { + pub data: ApiChapterPages, +} + +#[derive(Deserialize)] +pub struct ApiChapterPages { + pub pages: Vec, +} + +#[derive(Deserialize)] +pub struct ApiPage { + pub url: String, +} + +impl From for Page { + fn from(value: ApiPage) -> Self { + Page { + content: PageContent::url(value.url), + ..Default::default() + } + } +} + +#[derive(Deserialize)] +pub struct ApiTagsResponse { + pub data: Vec, +} + +#[derive(Deserialize)] +pub struct ApiTag { + pub id: i32, + pub name: String, + #[serde(rename = "type")] + pub tag_type: String, } diff --git a/sources/multi.mangafire/src/settings.rs b/sources/multi.mangafire/src/settings.rs index efccefad2..3d6363360 100644 --- a/sources/multi.mangafire/src/settings.rs +++ b/sources/multi.mangafire/src/settings.rs @@ -1,7 +1,8 @@ use aidoku::{ Result, alloc::{string::String, vec::Vec}, - imports::{defaults::defaults_get, error::AidokuError}, + imports::defaults::defaults_get, + prelude::*, }; // settings keys @@ -19,5 +20,5 @@ pub fn get_languages() -> Result> { }) .collect() }) - .ok_or(AidokuError::message("Unable to fetch languages")) + .ok_or(error!("Unable to fetch languages")) } diff --git a/sources/multi.mangafire/src/vrf.rs b/sources/multi.mangafire/src/vrf.rs deleted file mode 100644 index 497208e31..000000000 --- a/sources/multi.mangafire/src/vrf.rs +++ /dev/null @@ -1,231 +0,0 @@ -// original script by @Trung0246 on github -// updated with keys from @podimium on discord -// based on https://github.com/keiyoushi/extensions-source/blob/5a08b6078384a90ed23cc084d8bfc7b6f8397f07/src/all/mangafire/src/eu/kanade/tachiyomi/extension/all/mangafire/VrfGenerator.kt -use aidoku::{ - alloc::{string::String, vec, vec::Vec}, - helpers::uri::encode_uri_component, -}; -use base64::{Engine, engine::general_purpose}; - -pub struct VrfGenerator; - -impl VrfGenerator { - fn atob(data: &str) -> Vec { - general_purpose::STANDARD.decode(data).unwrap() - } - - fn btoa(data: &[u8]) -> String { - general_purpose::STANDARD.encode(data) - } - - fn rc4(key: &[u8], input: &[u8]) -> Vec { - let mut s: Vec = (0..=255).collect(); - let mut j = 0usize; - - // KSA - for i in 0..256 { - j = (j + s[i] as usize + key[i % key.len()] as usize) & 0xFF; - s.swap(i, j); - } - - // PRGA - let mut output = vec![0u8; input.len()]; - let mut i = 0usize; - j = 0usize; - for (y, &inp) in input.iter().enumerate() { - i = (i + 1) & 0xFF; - j = (j + s[i] as usize) & 0xFF; - s.swap(i, j); - let k = s[(s[i] as usize + s[j] as usize) & 0xFF]; - output[y] = inp ^ k; - } - output - } - - fn transform( - input: &[u8], - init_seed_bytes: &[u8], - prefix_key_bytes: &[u8], - prefix_len: usize, - schedule: &[fn(u8) -> u8], - ) -> Vec { - let mut out = Vec::new(); - for i in 0..input.len() { - if i < prefix_len { - out.push(prefix_key_bytes[i]); - } - let transformed = schedule[i % 10](input[i] ^ init_seed_bytes[i % 32]); - out.push(transformed); - } - out - } - - pub fn generate(input: &str) -> String { - let rc4_keys: [&str; 5] = [ - // "u8cBwTi1CM4XE3BkwG5Ble3AxWgnhKiXD9Cr279yNW0=", - "FgxyJUQDPUGSzwbAq/ToWn4/e8jYzvabE+dLMb1XU1o=", - // "t00NOJ/Fl3wZtez1xU6/YvcWDoXzjrDHJLL2r/IWgcY=", - "CQx3CLwswJAnM1VxOqX+y+f3eUns03ulxv8Z+0gUyik=", - // "S7I+968ZY4Fo3sLVNH/ExCNq7gjuOHjSRgSqh6SsPJc=", - "fAS+otFLkKsKAJzu3yU+rGOlbbFVq+u+LaS6+s1eCJs=", - // "7D4Q8i8dApRj6UWxXbIBEa1UqvjI+8W0UvPH9talJK8=", - "Oy45fQVK9kq9019+VysXVlz1F9S1YwYKgXyzGlZrijo=", - // "0JsmfWZA1kwZeWLk5gfV5g41lwLL72wHbam5ZPfnOVE=", - "aoDIdXezm2l3HrcnQdkPJTDT8+W6mcl2/02ewBHfPzg=", - ]; - - let seeds32: [&str; 5] = [ - // "pGjzSCtS4izckNAOhrY5unJnO2E1VbrU+tXRYG24vTo=", - "yH6MXnMEcDVWO/9a6P9W92BAh1eRLVFxFlWTHUqQ474=", - // "dFcKX9Qpu7mt/AD6mb1QF4w+KqHTKmdiqp7penubAKI=", - "RK7y4dZ0azs9Uqz+bbFB46Bx2K9EHg74ndxknY9uknA=", - // "owp1QIY/kBiRWrRn9TLN2CdZsLeejzHhfJwdiQMjg3w=", - "rqr9HeTQOg8TlFiIGZpJaxcvAaKHwMwrkqojJCpcvoc=", - // "H1XbRvXOvZAhyyPaO68vgIUgdAHn68Y6mrwkpIpEue8=", - "/4GPpmZXYpn5RpkP7FC/dt8SXz7W30nUZTe8wb+3xmU=", - // "2Nmobf/mpQ7+Dxq1/olPSDj3xV8PZkPbKaucJvVckL0=", - "wsSGSBXKWA9q1oDJpjtJddVxH+evCfL5SO9HZnUDFU8=", - ]; - - let prefix_keys: [&str; 5] = [ - // "Rowe+rg/0g==", - "l9PavRg=", - // "8cULcnOMJVY8AA==", - "Ml2v7ag1Jg==", - // "n2+Og2Gth8Hh", - "i/Va0UxrbMo=", - // "aRpvzH+yoA==", - "WFjKAHGEkQM=", - // "ZB4oBi0=", - "5Rr27rWd", - ]; - - let schedule_0: [fn(u8) -> u8; 10] = [ - |c| c.wrapping_sub(223), - |c| c.rotate_right(4), - |c| c.rotate_right(4), - |c| c.wrapping_add(234), - |c| c.rotate_right(7), - |c| c.rotate_right(2), - |c| c.rotate_right(7), - |c| c.wrapping_sub(223), - |c| c.rotate_right(7), - |c| c.rotate_right(6), - ]; - let schedule_1: [fn(u8) -> u8; 10] = [ - |c| c.wrapping_add(19), - |c| c.rotate_right(7), - |c| c.wrapping_add(19), - |c| c.rotate_right(6), - |c| c.wrapping_add(19), - |c| c.rotate_right(1), - |c| c.wrapping_add(19), - |c| c.rotate_right(6), - |c| c.rotate_right(7), - |c| c.rotate_right(4), - ]; - let schedule_2: [fn(u8) -> u8; 10] = [ - |c| c.wrapping_sub(223), - |c| c.rotate_right(1), - |c| c.wrapping_add(19), - |c| c.wrapping_sub(223), - |c| c.rotate_left(2), - |c| c.wrapping_sub(223), - |c| c.wrapping_add(19), - |c| c.rotate_left(1), - |c| c.rotate_left(2), - |c| c.rotate_left(1), - ]; - let schedule_3: [fn(u8) -> u8; 10] = [ - |c| c.wrapping_add(19), - |c| c.rotate_left(1), - |c| c.rotate_left(1), - |c| c.rotate_right(1), - |c| c.wrapping_add(234), - |c| c.rotate_left(1), - |c| c.wrapping_sub(223), - |c| c.rotate_left(6), - |c| c.rotate_left(4), - |c| c.rotate_left(1), - ]; - let schedule_4: [fn(u8) -> u8; 10] = [ - |c| c.rotate_right(1), - |c| c.rotate_left(1), - |c| c.rotate_left(6), - |c| c.rotate_right(1), - |c| c.rotate_left(2), - |c| c.rotate_right(4), - |c| c.rotate_left(1), - |c| c.rotate_left(1), - |c| c.wrapping_sub(223), - |c| c.rotate_left(2), - ]; - - let input = encode_uri_component(input); - let mut bytes = input.as_bytes().to_vec(); - - bytes = Self::rc4(&Self::atob(rc4_keys[0]), &bytes); - bytes = Self::transform( - &bytes, - &Self::atob(seeds32[0]), - &Self::atob(prefix_keys[0]), - Self::atob(prefix_keys[0]).len(), - &schedule_0, - ); - bytes = Self::rc4(&Self::atob(rc4_keys[1]), &bytes); - bytes = Self::transform( - &bytes, - &Self::atob(seeds32[1]), - &Self::atob(prefix_keys[1]), - Self::atob(prefix_keys[1]).len(), - &schedule_1, - ); - bytes = Self::rc4(&Self::atob(rc4_keys[2]), &bytes); - bytes = Self::transform( - &bytes, - &Self::atob(seeds32[2]), - &Self::atob(prefix_keys[2]), - Self::atob(prefix_keys[2]).len(), - &schedule_2, - ); - bytes = Self::rc4(&Self::atob(rc4_keys[3]), &bytes); - bytes = Self::transform( - &bytes, - &Self::atob(seeds32[3]), - &Self::atob(prefix_keys[3]), - Self::atob(prefix_keys[3]).len(), - &schedule_3, - ); - bytes = Self::rc4(&Self::atob(rc4_keys[4]), &bytes); - bytes = Self::transform( - &bytes, - &Self::atob(seeds32[4]), - &Self::atob(prefix_keys[4]), - Self::atob(prefix_keys[4]).len(), - &schedule_4, - ); - - let mut encoded = Self::btoa(&bytes); - encoded = encoded.replace("+", "-").replace("/", "_").replace("=", ""); - encoded - } -} - -#[cfg(test)] -mod test { - use super::VrfGenerator; - use aidoku_test::aidoku_test; - - #[aidoku_test] - fn test_vrf() { - assert_eq!( - VrfGenerator::generate("67890@ The quick brown fox jumps over the lazy dog @12345"), - // "ZBYeRCjYBk0tkZnKW4kTuWBYw-81e-csvu6v17UY4zchviixt67VJ\ - // _tjpFEsOXB-a8X4ZFpDoDbPq8ms-7IyN95vmLVdP5vWSoTAl4ZbIB\ - // E8xijci8emrkdEYmArOPMUq5KAc3KEabUzHkNwjBtwvs0fQR7nDpI" - "5fcaUfZo7rW1-Z3vTEvXO5sJBfP2zuTM2NIVmftpuGhYgy8c-Yl92\ - uQOuxzYksgVMUWKu7h-Pt5_6c0KZ2c1BpRQwVCIkRycge1pensQ__\ - YViJZddxqB5PvElml6UdQ1h4w8kCFftPUYNoSHTqNBX0HfFg" - ) - } -}