From cdc5d1979c3321dc95572e44d0742fa389666000 Mon Sep 17 00:00:00 2001 From: Camilo Duran Date: Tue, 27 Oct 2026 15:41:58 -0500 Subject: [PATCH] feat: add es.mangaoni --- sources/es.mangaoni/.cargo/config.toml | 5 + sources/es.mangaoni/Cargo.lock | 372 +++++++++++++++++++++++++ sources/es.mangaoni/Cargo.toml | 25 ++ sources/es.mangaoni/res/filters.json | 120 ++++++++ sources/es.mangaoni/res/icon.png | Bin 0 -> 23598 bytes sources/es.mangaoni/res/settings.json | 16 ++ sources/es.mangaoni/res/source.json | 24 ++ sources/es.mangaoni/src/helpers.rs | 94 +++++++ sources/es.mangaoni/src/lib.rs | 313 +++++++++++++++++++++ sources/es.mangaoni/src/parser.rs | 250 +++++++++++++++++ 10 files changed, 1219 insertions(+) create mode 100644 sources/es.mangaoni/.cargo/config.toml create mode 100644 sources/es.mangaoni/Cargo.lock create mode 100644 sources/es.mangaoni/Cargo.toml create mode 100644 sources/es.mangaoni/res/filters.json create mode 100644 sources/es.mangaoni/res/icon.png create mode 100644 sources/es.mangaoni/res/settings.json create mode 100644 sources/es.mangaoni/res/source.json create mode 100644 sources/es.mangaoni/src/helpers.rs create mode 100644 sources/es.mangaoni/src/lib.rs create mode 100644 sources/es.mangaoni/src/parser.rs diff --git a/sources/es.mangaoni/.cargo/config.toml b/sources/es.mangaoni/.cargo/config.toml new file mode 100644 index 000000000..f137b5a99 --- /dev/null +++ b/sources/es.mangaoni/.cargo/config.toml @@ -0,0 +1,5 @@ +[build] +target = "wasm32-unknown-unknown" + +[target.wasm32-unknown-unknown] +runner = "aidoku-test-runner" diff --git a/sources/es.mangaoni/Cargo.lock b/sources/es.mangaoni/Cargo.lock new file mode 100644 index 000000000..f9ece004e --- /dev/null +++ b/sources/es.mangaoni/Cargo.lock @@ -0,0 +1,372 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aidoku" +version = "0.3.0" +source = "git+https://github.com/Aidoku/aidoku-rs.git#ae6485e3c14ecd1fd5497d170eae884c68d04a87" +dependencies = [ + "euclid", + "hashbrown", + "itoa", + "num-traits", + "paste", + "postcard", + "serde", + "serde_json", + "talc", + "thiserror", +] + +[[package]] +name = "aidoku-test" +version = "1.0.0" +source = "git+https://github.com/Aidoku/aidoku-rs.git#ae6485e3c14ecd1fd5497d170eae884c68d04a87" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "mangaoni" +version = "0.1.0" +dependencies = [ + "aidoku", + "aidoku-test", + "base64", + "serde_json", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "talc" +version = "4.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3ae828aa394de34c7de08f522d1b86bd1c182c668d27da69caadda00590f26d" +dependencies = [ + "lock_api", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/sources/es.mangaoni/Cargo.toml b/sources/es.mangaoni/Cargo.toml new file mode 100644 index 000000000..a7af35a04 --- /dev/null +++ b/sources/es.mangaoni/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "mangaoni" +version = "0.1.0" +edition = "2024" + +[dependencies] +aidoku = { git = "https://github.com/Aidoku/aidoku-rs.git", version = "0.3.0", features = ["json"] } +base64 = { version = "0.22", default-features = false, features = ["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" + +[profile.release] +panic = "abort" +opt-level = "s" +strip = true +lto = true diff --git a/sources/es.mangaoni/res/filters.json b/sources/es.mangaoni/res/filters.json new file mode 100644 index 000000000..4caf40a04 --- /dev/null +++ b/sources/es.mangaoni/res/filters.json @@ -0,0 +1,120 @@ +[ + { + "type": "sort", + "id": "filtro", + "title": "Ordenar por", + "canAscend": true, + "options": ["Visitas", "Recientes", "Alfabético"], + "default": { + "index": 0, + "ascending": false + } + }, + { + "type": "select", + "id": "estado", + "title": "Estado", + "options": ["Todos", "En desarrollo", "Completo"], + "ids": ["false", "1", "0"] + }, + { + "type": "select", + "id": "tipo", + "title": "Tipo", + "options": ["Todo", "Mangas", "Manhwas", "One Shot", "Manhuas", "Novelas"], + "ids": ["false", "0", "1", "2", "3", "4"] + }, + { + "type": "select", + "id": "genero", + "title": "Género", + "isGenre": true, + "options": [ + "Todos", + "Comedia", + "Drama", + "Acción", + "Escolar", + "Romance", + "Ecchi", + "Aventura", + "Shōnen", + "Shōjo", + "Deportes", + "Psicológico", + "Fantasía", + "Mecha", + "Gore", + "Yaoi", + "Yuri", + "Misterio", + "Sobrenatural", + "Seinen", + "Ficción", + "Harem", + "Webtoon", + "Histórico", + "Músical", + "Ciencia ficción", + "Shōjo-ai", + "Josei", + "Magia", + "Artes Marciales", + "Horror", + "Demonios", + "Supervivencia", + "Recuentos de la vida", + "Shōnen ai", + "Militar", + "Eroge", + "Isekai" + ], + "ids": [ + "false", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "20", + "21", + "25", + "27", + "30", + "31", + "32", + "33", + "34", + "35", + "36", + "37", + "38", + "39", + "40", + "41", + "42", + "43" + ] + }, + { + "type": "select", + "id": "adulto", + "title": "Contenido +18", + "options": ["Mostrar todo", "Mostrar solo +18", "No mostrar +18"], + "ids": ["false", "1", "0"] + } +] diff --git a/sources/es.mangaoni/res/icon.png b/sources/es.mangaoni/res/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4f542a40ffdb40996d801ffe20550ff0b53429e5 GIT binary patch literal 23598 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 Vec<&str> { + let url = url.split(['?', '#']).next().unwrap_or_default(); + let path = match url.split_once("://") { + Some((_, rest)) => rest + .split_once('/') + .map(|(_, path)| path) + .unwrap_or_default(), + None => url, + }; + path.split('/').filter(|part| !part.is_empty()).collect() +} + +/// Manga keys are the series path, e.g. `/manga/one-piece/`, since the type is +/// part of the url. +pub fn manga_key(url: &str) -> Option { + match path_segments(url).as_slice() { + [kind, slug, ..] if SERIES_TYPES.contains(kind) => Some(format!("/{kind}/{slug}/")), + _ => None, + } +} + +/// Chapter keys are the chapter id, from `/lector/{slug}/{id}/` (or `/leer/` for +/// novels). Some links end in `/cascada/` and some don't, so the id is the stable part. +pub fn chapter_key(url: &str) -> Option { + match path_segments(url).as_slice() { + ["lector" | "leer", _, id, ..] if id.bytes().all(|b| b.is_ascii_digit()) => { + Some((*id).into()) + } + _ => None, + } +} + +/// The reader page of a chapter. Novels use `/leer/`, everything else `/lector/`. +pub fn chapter_url(manga_key: &str, chapter_key: &str) -> String { + let (reader, slug) = match path_segments(manga_key).as_slice() { + ["novela", slug, ..] => ("leer", *slug), + [_, slug, ..] => ("lector", *slug), + _ => ("lector", ""), + }; + format!("{BASE_URL}/{reader}/{slug}/{chapter_key}/") +} + +pub fn directory_url(params: &DirectoryParams, page: i32) -> String { + format!( + "{BASE_URL}/directorio?genero={}&estado={}&filtro={}&tipo={}&adulto={}&orden={}&p={page}", + params.genre, params.status, params.sort, params.kind, params.adult, params.order + ) +} + +/// Query parameters of `/directorio`. `"false"` means "any". +pub struct DirectoryParams<'a> { + pub genre: &'a str, + pub status: &'a str, + pub sort: &'a str, + pub kind: &'a str, + pub adult: &'a str, + pub order: &'a str, +} + +impl DirectoryParams<'_> { + /// Most visited first, the same as the site's directory. + pub fn popular(hide_nsfw: bool) -> Self { + Self { + genre: "false", + status: "false", + sort: "visitas", + kind: "false", + adult: if hide_nsfw { "0" } else { "false" }, + order: "desc", + } + } +} + +/// For chapter deep links: the reader page links back to its series. +pub fn manga_key_from_reader(url: &str) -> Result { + Request::get(url)? + .html()? + .select_first("h1 a.ajp[href]") + .and_then(|link| manga_key(&link.attr("abs:href")?)) + .ok_or_else(|| error!("Serie no encontrada")) +} diff --git a/sources/es.mangaoni/src/lib.rs b/sources/es.mangaoni/src/lib.rs new file mode 100644 index 000000000..9fa1920a1 --- /dev/null +++ b/sources/es.mangaoni/src/lib.rs @@ -0,0 +1,313 @@ +#![no_std] +use aidoku::{ + Chapter, DeepLinkHandler, DeepLinkResult, FilterValue, Home, HomeComponent, HomeComponentValue, + HomeLayout, HomePartialResult, Link, Listing, ListingProvider, Manga, MangaPageResult, + MangaWithChapter, Page, PageContent, Result, Source, + alloc::{String, Vec, vec}, + helpers::uri::encode_uri_component, + imports::{defaults::defaults_get, net::Request, std::send_partial_result}, + prelude::*, +}; + +mod helpers; +mod parser; +use helpers::{ + BASE_URL, DirectoryParams, chapter_key, chapter_url, directory_url, manga_key, + manga_key_from_reader, path_segments, +}; +use parser::{ + has_next_page, image_url, parse_chapters, parse_latest_updates, parse_manga_card, + parse_manga_cards, parse_manga_details, parse_page_urls, +}; + +const HIDE_NSFW_SETTING: &str = "hideNsfw"; + +fn hide_nsfw() -> bool { + defaults_get::(HIDE_NSFW_SETTING).unwrap_or(false) +} + +struct MangaOni; + +impl Source for MangaOni { + fn new() -> Self { + Self + } + + fn get_search_manga_list( + &self, + query: Option, + page: i32, + filters: Vec, + ) -> Result { + // The site's title search doesn't take filters, so they're hidden while + // searching (`hidesFiltersWhileSearching`). + if let Some(query) = query.as_deref().map(str::trim).filter(|q| !q.is_empty()) { + let url = format!( + "{BASE_URL}/buscar?q={}&p={page}", + encode_uri_component(query) + ); + return Ok(parse_manga_cards(&Request::get(url)?.html()?)); + } + + let mut params = DirectoryParams::popular(false); + for filter in &filters { + match filter { + FilterValue::Select { id, value } => match id.as_str() { + "genero" => params.genre = value, + "estado" => params.status = value, + "tipo" => params.kind = value, + "adulto" => params.adult = value, + _ => {} + }, + FilterValue::Sort { + index, ascending, .. + } => { + params.sort = match index { + 1 => "id", + 2 => "nombre", + _ => "visitas", + }; + params.order = if *ascending { "asc" } else { "desc" }; + } + _ => {} + } + } + if hide_nsfw() { + params.adult = "0"; + } + + let url = directory_url(¶ms, page); + Ok(parse_manga_cards(&Request::get(url)?.html()?)) + } + + fn get_manga_update( + &self, + mut manga: Manga, + needs_details: bool, + needs_chapters: bool, + ) -> Result { + // Details and chapters are on the same page. + let html = Request::get(format!("{BASE_URL}{}", manga.key))?.html()?; + + if needs_details { + parse_manga_details(&html, &mut manga); + if needs_chapters { + send_partial_result(&manga); + } + } + + if needs_chapters { + manga.chapters = Some(parse_chapters(&html)); + } + + Ok(manga) + } + + fn get_page_list(&self, manga: Manga, chapter: Chapter) -> Result> { + let url = chapter_url(&manga.key, &chapter.key); + Ok(parse_page_urls(&Request::get(url)?.html()?)? + .into_iter() + .map(|url| Page { + content: PageContent::url(url), + ..Default::default() + }) + .collect()) + } +} + +impl ListingProvider for MangaOni { + fn get_manga_list(&self, listing: Listing, page: i32) -> Result { + match listing.id.as_str() { + "Popular" => { + let url = directory_url(&DirectoryParams::popular(hide_nsfw()), page); + Ok(parse_manga_cards(&Request::get(url)?.html()?)) + } + "Latest" => { + let html = Request::get(format!("{BASE_URL}/recientes?p={page}"))?.html()?; + Ok(MangaPageResult { + entries: parse_latest_updates(&html) + .into_iter() + .map(|(manga, _)| manga) + .collect(), + has_next_page: has_next_page(&html), + }) + } + _ => bail!("Listado no válido: {}", listing.id), + } + } +} + +fn popular_listing() -> Listing { + Listing { + id: "Popular".into(), + name: "Populares".into(), + ..Default::default() + } +} + +fn latest_listing() -> Listing { + Listing { + id: "Latest".into(), + name: "Últimas actualizaciones".into(), + ..Default::default() + } +} + +impl Home for MangaOni { + fn get_home(&self) -> Result { + // Send the empty sections first so the app can show them right away. + send_partial_result(&HomePartialResult::Layout(HomeLayout { + components: vec![ + HomeComponent { + title: Some("Top manga".into()), + subtitle: None, + value: HomeComponentValue::empty_manga_list(), + }, + HomeComponent { + title: Some("Últimas actualizaciones".into()), + subtitle: None, + value: HomeComponentValue::empty_manga_chapter_list(), + }, + HomeComponent { + title: Some("Nuevos".into()), + subtitle: None, + value: HomeComponentValue::empty_scroller(), + }, + HomeComponent { + title: Some("Populares".into()), + subtitle: None, + value: HomeComponentValue::empty_scroller(), + }, + ], + })); + + // The first three sections come from the site's home page. + let html = Request::get(BASE_URL)?.html()?; + + let top: Vec = html + .select("#widget.top li") + .map(|items| { + items + .filter_map(|item| { + let link = item.select_first("a.chart-title[href]")?; + let key = manga_key(&link.attr("abs:href")?)?; + Some( + Manga { + url: Some(format!("{BASE_URL}{key}")), + key, + title: link.text()?, + // The widget uses a small thumbnail; the cover is next to it. + cover: item + .select_first("img") + .and_then(|img| image_url(&img)) + .map(|thumb| thumb.replacen("/thumb.", "/cover.", 1)), + ..Default::default() + } + .into(), + ) + }) + .collect() + }) + .unwrap_or_default(); + send_partial_result(&HomePartialResult::Component(HomeComponent { + title: Some("Top manga".into()), + subtitle: None, + value: HomeComponentValue::MangaList { + ranking: true, + page_size: Some(5), + entries: top, + listing: None, + }, + })); + + let latest = parse_latest_updates(&html) + .into_iter() + .filter_map(|(manga, chapter)| { + Some(MangaWithChapter { + manga, + chapter: chapter?, + }) + }) + .collect(); + send_partial_result(&HomePartialResult::Component(HomeComponent { + title: Some("Últimas actualizaciones".into()), + subtitle: None, + value: HomeComponentValue::MangaChapterList { + page_size: Some(4), + entries: latest, + listing: Some(latest_listing()), + }, + })); + + // "Nuevos" is the `#article-div` section whose heading says so. + let new: Vec = html + .select("#article-div") + .and_then(|mut sections| { + sections.find(|section| { + section + .select_first("h3") + .and_then(|h3| h3.text()) + .is_some_and(|title| title.contains("Nuevos")) + }) + }) + .and_then(|section| section.select("div._135yj")) + .map(|cards| { + cards + .filter_map(|card| parse_manga_card(&card)) + .map(Link::from) + .collect() + }) + .unwrap_or_default(); + send_partial_result(&HomePartialResult::Component(HomeComponent { + title: Some("Nuevos".into()), + subtitle: None, + value: HomeComponentValue::Scroller { + entries: new, + listing: None, + }, + })); + + let url = directory_url(&DirectoryParams::popular(hide_nsfw()), 1); + let popular = parse_manga_cards(&Request::get(url)?.html()?) + .entries + .into_iter() + .map(Link::from) + .collect(); + send_partial_result(&HomePartialResult::Component(HomeComponent { + title: Some("Populares".into()), + subtitle: None, + value: HomeComponentValue::Scroller { + entries: popular, + listing: Some(popular_listing()), + }, + })); + + // Everything was already sent as partial results. + Ok(HomeLayout::default()) + } +} + +impl DeepLinkHandler for MangaOni { + /// Handles: + /// /{manga|manhwa|manhua|oneshot|novela}/{slug}/ -> the series + /// /lector/{slug}/{chapter id}/[cascada/] -> the chapter + /// /leer/{slug}/{chapter id}/ -> the chapter (novels) + fn handle_deep_link(&self, url: String) -> Result> { + if let Some(key) = manga_key(&url) { + return Ok(Some(DeepLinkResult::Manga { key })); + } + + let Some(key) = chapter_key(&url) else { + return Ok(None); + }; + // Reader urls don't include the series type, so read it from the page. + let segments = path_segments(&url); + let reader_url = format!("{BASE_URL}/{}/{}/{key}/", segments[0], segments[1]); + Ok(Some(DeepLinkResult::Chapter { + manga_key: manga_key_from_reader(&reader_url)?, + key, + })) + } +} + +register_source!(MangaOni, ListingProvider, Home, DeepLinkHandler); diff --git a/sources/es.mangaoni/src/parser.rs b/sources/es.mangaoni/src/parser.rs new file mode 100644 index 000000000..6bca24300 --- /dev/null +++ b/sources/es.mangaoni/src/parser.rs @@ -0,0 +1,250 @@ +use aidoku::{ + Chapter, ContentRating, Manga, MangaPageResult, MangaStatus, Result, Viewer, + alloc::{String, Vec}, + imports::{ + html::{Document, Element}, + std::parse_date, + }, + prelude::*, +}; +use base64::{ + Engine, alphabet, + engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}, +}; + +use crate::helpers::{BASE_URL, chapter_key, manga_key, path_segments}; + +/// The site's base64 isn't always padded. +const BASE64: GeneralPurpose = GeneralPurpose::new( + &alphabet::STANDARD, + GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent), +); + +pub fn has_next_page(html: &Document) -> bool { + html.select_first("ul.pagination a[rel=next]").is_some() +} + +/// Series cards from the directory, search results and the home page. +pub fn parse_manga_cards(html: &Document) -> MangaPageResult { + MangaPageResult { + entries: html + .select("#article-div div._135yj") + .map(|cards| cards.filter_map(|card| parse_manga_card(&card)).collect()) + .unwrap_or_default(), + has_next_page: has_next_page(html), + } +} + +pub fn parse_manga_card(card: &Element) -> Option { + // Chapter cards on the home page link to the reader, so they're skipped here. + let key = manga_key(&card.select_first("a[href]")?.attr("abs:href")?)?; + let title = card.select_first("._2NNxg")?.text()?; + Some(Manga { + url: Some(format!("{BASE_URL}{key}")), + key, + title, + cover: card.select_first("img").and_then(|img| image_url(&img)), + ..Default::default() + }) +} + +/// Images are lazy loaded: `src` is a placeholder until `data-src` is copied over. +pub fn image_url(img: &Element) -> Option { + img.attr("abs:data-src") + .filter(|src| !src.is_empty()) + .or_else(|| img.attr("abs:src")) +} + +/// The rows of `/recientes`, or of the "Actualizado" section of the home page. +pub fn parse_latest_updates(html: &Document) -> Vec<(Manga, Option)> { + html.select("div.NRoaY") + .map(|rows| rows.filter_map(|row| parse_latest_update(&row)).collect()) + .unwrap_or_default() +} + +fn parse_latest_update(row: &Element) -> Option<(Manga, Option)> { + let link = row.select_first("a[data-test=latest-update-name]")?; + let key = manga_key(&link.attr("abs:href")?)?; + let manga = Manga { + url: Some(format!("{BASE_URL}{key}")), + key, + title: link.text()?, + cover: row.select_first("img").and_then(|img| image_url(&img)), + ..Default::default() + }; + + let chapter = row.select_first(".e6dEC a[href]").and_then(|link| { + let url = link.attr("abs:href")?; + let (chapter_number, title) = parse_chapter_title(&link.text().unwrap_or_default()); + Some(Chapter { + key: chapter_key(&url)?, + title, + chapter_number, + date_uploaded: row + .select_first(".timeago[datetime]") + .and_then(|date| parse_site_date(&date.attr("datetime")?)), + url: Some(url), + ..Default::default() + }) + }); + + Some((manga, chapter)) +} + +/// Dates look like `2026-10-27 19:29:47` and are in UTC. +fn parse_site_date(date: &str) -> Option { + parse_date(date, "yyyy-MM-dd HH:mm:ss") +} + +/// Splits `Capítulo 243: Aquella Chica` into the number and the rest of the title. +fn parse_chapter_title(text: &str) -> (Option, Option) { + let text = text.trim(); + let Some(rest) = text.strip_prefix("Capítulo") else { + return (None, Some(text.into()).filter(|t: &String| !t.is_empty())); + }; + let rest = rest.trim_start(); + let (number, title) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); + let number = number.trim_end_matches(':'); + let title = title.trim_matches(|c: char| c.is_whitespace() || matches!(c, ':' | '-' | '—')); + ( + number.parse().ok(), + Some(title.into()).filter(|t: &String| !t.is_empty()), + ) +} + +/// Fills in a series from its page. +pub fn parse_manga_details(html: &Document, manga: &mut Manga) { + if let Some(title) = html.select_first("h1").and_then(|h1| h1.text()) { + manga.title = title; + } + if let Some(cover) = html + .select_first(".portada img") + .and_then(|img| img.attr("abs:src")) + { + manga.cover = Some(cover); + } + manga.description = html + .select_first("#sinopsis") + .and_then(|sinopsis| sinopsis.text()) + // Drop the "Sinopsis" heading. + .map(|text| text.trim_start_matches("Sinopsis").trim().into()) + .filter(|text: &String| !text.is_empty()); + + // "Ranking: #9 Autor: Eiichirō Oda Fecha: 1997 Estado: En desarrollo" + let info = html + .select_first("#info-i") + .and_then(|info| info.text()) + .unwrap_or_default(); + manga.authors = info + .split_once("Autor:") + .map(|(_, rest)| rest.split("Fecha:").next().unwrap_or_default()) + .map(|authors| { + authors + .split(',') + .map(str::trim) + .filter(|author| !author.is_empty()) + .map(String::from) + .collect::>() + }) + .filter(|authors| !authors.is_empty()); + + manga.status = match info.split_once("Estado:").map(|(_, status)| status.trim()) { + Some(status) if status.starts_with("En desarrollo") => MangaStatus::Ongoing, + Some(status) if status.starts_with("Finalizado") => MangaStatus::Completed, + _ => MangaStatus::Unknown, + }; + + let tags: Vec = html + .select("#categ a") + .map(|tags| tags.filter_map(|tag| tag.text()).collect()) + .unwrap_or_default(); + + manga.content_rating = if html.select_first("#categ .adulto").is_some() { + ContentRating::NSFW + } else if tags.iter().any(|tag| tag == "Ecchi") { + ContentRating::Suggestive + } else { + ContentRating::Safe + }; + + manga.viewer = match path_segments(&manga.key).first() { + Some(&"manhwa" | &"manhua") => Viewer::Webtoon, + _ if tags.iter().any(|tag| tag == "Webtoon") => Viewer::Webtoon, + Some(&"manga" | &"oneshot") => Viewer::RightToLeft, + _ => Viewer::Unknown, + }; + + manga.tags = Some(tags).filter(|tags| !tags.is_empty()); + manga.url = Some(format!("{BASE_URL}{}", manga.key)); +} + +/// Every chapter is listed on the series page, newest first. +pub fn parse_chapters(html: &Document) -> Vec { + html.select("#c_list a[href]") + .map(|links| { + links + .filter_map(|link| { + let url = link.attr("abs:href")?; + let info = link.select_first("span.timeago"); + let (title_number, title) = parse_chapter_title( + &link + .select_first("h3") + .and_then(|h3| h3.text()) + .unwrap_or_default(), + ); + Some(Chapter { + key: chapter_key(&url)?, + title, + chapter_number: info + .as_ref() + .and_then(|info| info.attr("data-num")?.parse().ok()) + .or(title_number), + date_uploaded: info + .as_ref() + .and_then(|info| parse_site_date(&info.attr("datetime")?)), + // The user who uploaded the chapter, usually the scan group. + scanlators: info + .as_ref() + .and_then(|info| info.attr("data-user")) + .filter(|user| !user.is_empty()) + .map(|user| [user].into()), + url: Some(url), + ..Default::default() + }) + }) + .collect() + }) + .unwrap_or_default() +} + +/// The reader page has `var unicap = ''`, which decodes to +/// `||["001.webp?up=…","002.webp?up=…"]||`. +pub fn parse_page_urls(html: &Document) -> Result> { + let script = html + .select("script") + .and_then(|scripts| { + scripts + .filter_map(|script| script.html()) + .find(|data| data.contains("unicap")) + }) + .ok_or_else(|| error!("No se encontraron las páginas del capítulo"))?; + + let encoded = script + .split_once("unicap") + .and_then(|(_, rest)| rest.split('\'').nth(1)) + .ok_or_else(|| error!("No se encontraron las páginas del capítulo"))?; + let decoded = BASE64 + .decode(encoded) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .ok_or_else(|| error!("No se pudieron leer las páginas del capítulo"))?; + + let mut parts = decoded.split("||"); + let folder = parts.next().unwrap_or_default(); + let files: Vec = parts + .next() + .and_then(|files| serde_json::from_str(files).ok()) + .ok_or_else(|| error!("No se pudieron leer las páginas del capítulo"))?; + + Ok(files.iter().map(|file| format!("{folder}{file}")).collect()) +}