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 000000000..4f542a40f Binary files /dev/null and b/sources/es.mangaoni/res/icon.png differ diff --git a/sources/es.mangaoni/res/settings.json b/sources/es.mangaoni/res/settings.json new file mode 100644 index 000000000..a4e42a7bc --- /dev/null +++ b/sources/es.mangaoni/res/settings.json @@ -0,0 +1,16 @@ +[ + { + "type": "group", + "title": "Contenido", + "footer": "Oculta las series +18 en Populares, en la sección Populares del inicio y al usar filtros. No afecta a las últimas actualizaciones ni a la búsqueda por título.", + "items": [ + { + "type": "switch", + "key": "hideNsfw", + "title": "Ocultar contenido +18", + "default": false, + "refreshes": ["content", "listings"] + } + ] + } +] diff --git a/sources/es.mangaoni/res/source.json b/sources/es.mangaoni/res/source.json new file mode 100644 index 000000000..70fc42e5c --- /dev/null +++ b/sources/es.mangaoni/res/source.json @@ -0,0 +1,24 @@ +{ + "info": { + "id": "es.mangaoni", + "name": "MangaOni", + "version": 1, + "urls": ["https://manga-oni.com", "https://www.manga-oni.com"], + "contentRating": 1, + "minAppVersion": "0.8.3", + "languages": ["es"] + }, + "listings": [ + { + "id": "Popular", + "name": "Populares" + }, + { + "id": "Latest", + "name": "Últimas actualizaciones" + } + ], + "config": { + "hidesFiltersWhileSearching": true + } +} diff --git a/sources/es.mangaoni/src/helpers.rs b/sources/es.mangaoni/src/helpers.rs new file mode 100644 index 000000000..71674f3fb --- /dev/null +++ b/sources/es.mangaoni/src/helpers.rs @@ -0,0 +1,94 @@ +use aidoku::{ + Result, + alloc::{String, Vec}, + imports::net::Request, + prelude::*, +}; + +pub const BASE_URL: &str = "https://manga-oni.com"; + +/// The first path segment of a series url. A series only loads under its own type. +const SERIES_TYPES: [&str; 5] = ["manga", "manhwa", "manhua", "oneshot", "novela"]; + +/// Splits an absolute or relative url into its path segments. +pub fn path_segments(url: &str) -> 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()) +}