diff --git a/src/rust/en.mangairo/.cargo/config b/src/rust/en.mangairo/.cargo/config new file mode 100644 index 000000000..f4e8c002f --- /dev/null +++ b/src/rust/en.mangairo/.cargo/config @@ -0,0 +1,2 @@ +[build] +target = "wasm32-unknown-unknown" diff --git a/src/rust/en.mangairo/Cargo.lock b/src/rust/en.mangairo/Cargo.lock new file mode 100644 index 000000000..06525e42a --- /dev/null +++ b/src/rust/en.mangairo/Cargo.lock @@ -0,0 +1,100 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aidoku" +version = "0.2.0" +source = "git+https://github.com/Aidoku/aidoku-rs#004bddabade7b24c58cf925b08f90dd093b00c9d" +dependencies = [ + "aidoku_helpers", + "aidoku_imports", + "aidoku_macros", + "aidoku_proc_macros", + "dlmalloc", +] + +[[package]] +name = "aidoku_helpers" +version = "0.1.0" +source = "git+https://github.com/Aidoku/aidoku-rs#004bddabade7b24c58cf925b08f90dd093b00c9d" +dependencies = [ + "aidoku_imports", +] + +[[package]] +name = "aidoku_imports" +version = "0.2.0" +source = "git+https://github.com/Aidoku/aidoku-rs#004bddabade7b24c58cf925b08f90dd093b00c9d" + +[[package]] +name = "aidoku_macros" +version = "0.1.0" +source = "git+https://github.com/Aidoku/aidoku-rs#004bddabade7b24c58cf925b08f90dd093b00c9d" + +[[package]] +name = "aidoku_proc_macros" +version = "0.2.0" +source = "git+https://github.com/Aidoku/aidoku-rs#004bddabade7b24c58cf925b08f90dd093b00c9d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlmalloc" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "203540e710bfadb90e5e29930baf5d10270cec1f43ab34f46f78b147b2de715a" +dependencies = [ + "libc", +] + +[[package]] +name = "libc" +version = "0.2.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" + +[[package]] +name = "mangairo" +version = "0.1.0" +dependencies = [ + "aidoku", +] + +[[package]] +name = "proc-macro2" +version = "1.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" diff --git a/src/rust/en.mangairo/Cargo.toml b/src/rust/en.mangairo/Cargo.toml new file mode 100644 index 000000000..39ff375fa --- /dev/null +++ b/src/rust/en.mangairo/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "mangairo" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[profile.dev] +panic = "abort" + +[profile.release] +panic = "abort" +opt-level = "s" +strip = true +lto = true + +[dependencies] +aidoku = { git = "https://github.com/Aidoku/aidoku-rs", features = ["helpers"] } diff --git a/src/rust/en.mangairo/build.ps1 b/src/rust/en.mangairo/build.ps1 new file mode 100644 index 000000000..803b75bc5 --- /dev/null +++ b/src/rust/en.mangairo/build.ps1 @@ -0,0 +1,27 @@ +function Package-Source { + param ( + [Parameter(Mandatory = $true, Position = 0)] + [String[]]$Name, + + [switch]$Build + ) + $Name | ForEach-Object { + $source = $_ + if ($Build) { + Write-Output "building $source" + cargo +nightly build --release + } + + Write-Output "packaging $source" + New-Item -ItemType Directory -Path target/wasm32-unknown-unknown/release/Payload -Force | Out-Null + Copy-Item res/* target/wasm32-unknown-unknown/release/Payload -ErrorAction SilentlyContinue + Copy-Item sources/$source/res/* target/wasm32-unknown-unknown/release/Payload -ErrorAction SilentlyContinue + Set-Location target/wasm32-unknown-unknown/release + Copy-Item "$source.wasm" Payload/main.wasm + Compress-Archive -Force -DestinationPath "../../../$source.aix" -Path Payload + Remove-Item -Recurse -Force Payload/ + Set-Location ../../.. + } +} + +Package-Source mangairo -Build diff --git a/src/rust/en.mangairo/build.sh b/src/rust/en.mangairo/build.sh new file mode 100755 index 000000000..53ff3a99d --- /dev/null +++ b/src/rust/en.mangairo/build.sh @@ -0,0 +1,6 @@ +cargo +nightly build --release +mkdir -p target/wasm32-unknown-unknown/release/Payload +cp res/* target/wasm32-unknown-unknown/release/Payload +cp target/wasm32-unknown-unknown/release/*.wasm target/wasm32-unknown-unknown/release/Payload/main.wasm +cd target/wasm32-unknown-unknown/release ; zip -r package.aix Payload +mv package.aix ../../../package.aix diff --git a/src/rust/en.mangairo/res/Icon.png b/src/rust/en.mangairo/res/Icon.png new file mode 100644 index 000000000..3475e431c Binary files /dev/null and b/src/rust/en.mangairo/res/Icon.png differ diff --git a/src/rust/en.mangairo/res/filters.json b/src/rust/en.mangairo/res/filters.json new file mode 100644 index 000000000..f562eb671 --- /dev/null +++ b/src/rust/en.mangairo/res/filters.json @@ -0,0 +1,75 @@ +[ + { + "type": "title" + }, + { + "type": "author" + }, + { + "type": "select", + "name": "Sort", + "options": [ + "Latest", + "Newest", + "Hot" + ] + }, + { + "type": "select", + "name": "Status", + "options": [ + "All", + "Ongoing", + "Completed" + ] + }, + { + "type": "select", + "name": "Genre", + "options": [ + "All", + "Action", + "Adult", + "Adventure", + "Comedy", + "Cooking", + "Doujinshi", + "Drama", + "Ecchi", + "Erotica", + "Fantasy", + "Gender bender", + "Harem", + "Historical", + "Horror", + "Isekai", + "Josei", + "Manhua", + "Manhwa", + "Martial arts", + "Mature", + "Mecha", + "Medical", + "Mystery", + "One shot", + "Pornographic", + "Phychological", + "Romance", + "School life", + "Sci fi", + "Seinen", + "Shoujo", + "Shoujo ai", + "Shounen", + "Shounen ai", + "Slice of Life", + "Smut", + "Sports", + "Supernatural", + "Tragedy", + "Webtoons", + "Yaoi", + "Yuri" + ] + } +] diff --git a/src/rust/en.mangairo/res/source.json b/src/rust/en.mangairo/res/source.json new file mode 100644 index 000000000..3a9885776 --- /dev/null +++ b/src/rust/en.mangairo/res/source.json @@ -0,0 +1,28 @@ +{ + "info": { + "id": "en.mangairo", + "lang": "en", + "name": "MangaIro", + "version": 1, + "urls": [ + "https://w.mangairo.com", + "https://chap.mangairo.com" + ], + "nsfw": 1 + }, + "languages": [], + "listings": [ + { + "name": "Latest" + }, + { + "name": "New Releases" + }, + { + "name": "Hot" + }, + { + "name": "Completed" + } + ] +} diff --git a/src/rust/en.mangairo/src/lib.rs b/src/rust/en.mangairo/src/lib.rs new file mode 100644 index 000000000..c42383e33 --- /dev/null +++ b/src/rust/en.mangairo/src/lib.rs @@ -0,0 +1,93 @@ +#![no_std] +use aidoku::{ + error::Result, + prelude::*, + std::{ + net::{HttpMethod, Request}, + String, Vec, + }, + Chapter, DeepLink, Filter, Listing, Manga, MangaPageResult, Page, +}; + +mod parser; +use parser::{BASE_URL, USER_AGENT}; + +#[get_manga_list] +fn get_manga_list(filters: Vec, page: i32) -> Result { + let url = parser::get_filtered_url(filters, page); + // aidoku::prelude::println!("get_manga_list: {}", url); + let html = Request::new(url.as_str(), HttpMethod::Get).html()?; + + let (manga, has_more) = parser::parse_manga_list(html, page); + Ok(MangaPageResult { manga, has_more }) +} + +#[get_manga_details] +fn get_manga_details(manga_id: String) -> Result { + let url = format!("{}", &manga_id); + let html = Request::new(url, HttpMethod::Get).html()?; + parser::parse_manga_details(html, manga_id) +} + +#[get_manga_listing] +fn get_manga_listing(listing: Listing, page: i32) -> Result { + let url = match listing.name.as_str() { + "Latest" => format!("{BASE_URL}/manga-list/type-latest/ctg-all/state-all/page-{page}"), + "New Releases" => format!("{BASE_URL}/manga-list/type-newest/ctg-7/state-all/page-{page}"), + "Hot" => format!("{BASE_URL}/manga-list/type-topview/ctg-all/state-all/page-{page}"), + "Completed" => { + format!("{BASE_URL}/manga-list/type-latest/ctg-all/state-completed/page-{page}") + } + _ => format!("{BASE_URL}/manga-list/type-latest/ctg-all/state-all/page-{page}"), + }; + // aidoku::prelude::println!("get_manga_listing: {}", url); + let html = Request::new(url.as_str(), HttpMethod::Get).html()?; + let (manga, has_more) = parser::parse_manga_list(html, page); + + Ok(MangaPageResult { manga, has_more }) +} + +#[get_chapter_list] +fn get_chapter_list(manga_id: String) -> Result> { + let url = format!("{}", &manga_id); + // aidoku::prelude::println!("get_chapter_list: {}", url); + let html = Request::new(url, HttpMethod::Get).html()?; + parser::get_chapter_list(html) +} + +#[get_page_list] +fn get_page_list(manga_id: String, chapter_id: String) -> Result> { + let url = format!("{}/{}", &manga_id, &chapter_id); + // aidoku::prelude::println!("get_page_list: {}", url); + let html = Request::new(url.as_str(), HttpMethod::Get).html()?; + parser::get_page_list(html) +} + +#[modify_image_request] +fn modify_image_request(request: Request) { + request + .header("Referer", BASE_URL) + .header("User-Agent", USER_AGENT); +} + +#[handle_url] +fn handle_url(url: String) -> Result { + let parsed_manga_id = parser::parse_incoming_url_manga_id(&url); + let parsed_chapter_id = parser::parse_incoming_url_chapter_id(&url); + // aidoku::prelude::println!("handle_url manga id: {:?}", parsed_manga_id); + // aidoku::prelude::println!("handle_url chapter id: {:?}", parsed_chapter_id); + + if let Some(parsed_manga_id) = parsed_manga_id { + Ok(DeepLink { + manga: Some(get_manga_details(parsed_manga_id)?), + chapter: parsed_chapter_id.map(|chapter_id_value| Chapter { + id: chapter_id_value, + ..Default::default() + }), + }) + } else { + Err(aidoku::error::AidokuError { + reason: aidoku::error::AidokuErrorKind::Unimplemented, + }) + } +} diff --git a/src/rust/en.mangairo/src/parser.rs b/src/rust/en.mangairo/src/parser.rs new file mode 100644 index 000000000..dadb9b039 --- /dev/null +++ b/src/rust/en.mangairo/src/parser.rs @@ -0,0 +1,351 @@ +use aidoku::{ + error::Result, helpers::uri::QueryParameters, prelude::*, std::html::Node, std::String, + std::Vec, Chapter, Filter, FilterType, Manga, MangaContentRating, MangaStatus, MangaViewer, + Page, +}; +extern crate alloc; +use alloc::string::ToString; + +pub const BASE_URL: &str = "https://w.mangairo.com"; +pub const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_3_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36"; + +pub fn parse_manga_list(html: Node, page: i32) -> (Vec, bool) { + let mut result: Vec = Vec::new(); + for page in html.select(".story-item").array() { + let obj = page.as_node().expect("node array"); + + let url = obj.select(".story-name a").attr("href").read(); + let id = parse_incoming_url_manga_id(&url); + let title = obj.select(".story-name a ").text().read(); + let cover = obj.select(".story-list-img img").attr("src").read(); + + if let Some(id) = id { + if !id.is_empty() && !title.is_empty() && !cover.is_empty() { + result.push(Manga { + id, + cover, + title, + ..Default::default() + }); + } + } + } + + // Example: 'Total: 38,202 stories' + let total_str: String = html + .select(".quantitychapter") + .text() + .read() + .replace("Total: ", "") + .replace(" stories", "") + .chars() + .filter(|&c| c != ',') + .collect(); + + let has_more = total_str + .parse::() + .map_or(false, |value| value > result.len() as i32 * page); + (result, has_more) +} + +pub fn parse_manga_details(html: Node, id: String) -> Result { + let title = html + .select(".breadcrumbs p span a span") + .last() + .text() + .read(); + let cover = html.select(".avatar").attr("src").read(); + let description = html + .select("div#story_discription p") + .text() + .read() + .trim() + .to_string(); + let status_str = html + .select(".story_info_right li:nth-child(5) a") + .text() + .read() + .to_lowercase(); + + let url = format!("{}", &id); + + let author: String = html + .select(".story_info_right li:nth-child(3) a") + .array() + .map(|tag| String::from(tag.as_node().expect("node array").text().read().trim())) + .collect::>() + .join(", "); + + let categories: Vec = html + .select(".story_info_right .a-h") + .array() + .map(|tag| tag.as_node().expect("node array").text().read()) + .collect(); + + let status = match status_str.as_str() { + "ongoing" => MangaStatus::Ongoing, + "completed" => MangaStatus::Completed, + _ => MangaStatus::Unknown, + }; + + let nsfw = if categories.contains(&String::from("Pornographic")) + || categories.contains(&String::from("Adult")) + || categories.contains(&String::from("Smut")) + || categories.contains(&String::from("Erotica")) + { + MangaContentRating::Nsfw + } else if categories.contains(&String::from("Ecchi")) { + MangaContentRating::Suggestive + } else { + MangaContentRating::Safe + }; + + let viewer = if categories.contains(&String::from("Manhua")) + || categories.contains(&String::from("Manhwa")) + || categories.contains(&String::from("Webtoons")) + { + MangaViewer::Scroll + } else { + MangaViewer::Rtl + }; + + Ok(Manga { + id, + cover, + title, + author, + description, + url, + categories, + status, + nsfw, + viewer, + ..Default::default() + }) +} + +pub fn get_chapter_list(html: Node) -> Result> { + let mut chapters: Vec = Vec::new(); + for chapter in html.select(".chapter_list ul li a").array() { + let obj = chapter.as_node().expect("node array"); + let url = obj.attr("href").read(); + let id = parse_incoming_url_chapter_id(&url); + + if let Some(id) = id { + let chapter = id + .rsplit_once('-') + .and_then(|v| v.1.parse::().ok()) + .unwrap_or(-1.0); + let lang: String = "en".to_string(); + + chapters.push(Chapter { + id, + chapter, + url, + lang, + ..Default::default() + }); + } + } + Ok(chapters) +} + +pub fn get_page_list(html: Node) -> Result> { + let mut pages: Vec = Vec::new(); + + for (i, page) in html.select(".panel-read-story img").array().enumerate() { + let obj = page.as_node().expect("node array"); + let url = obj.attr("src").read(); + + pages.push(Page { + index: i as i32, + url, + ..Default::default() + }); + } + Ok(pages) +} + +pub fn get_filtered_url(filters: Vec, page: i32) -> String { + let mut is_searching = false; + + let mut title_filter = String::new(); + let mut author_filter = String::new(); + let mut sort_filter = -1; + let mut genre_filter = -1; + let mut status_filter = -1; + for filter in filters { + match filter.kind { + FilterType::Title => { + if let Ok(filter) = filter.value.as_string() { + title_filter = urlencode(filter.read().to_lowercase()); + is_searching = true; + } + } + FilterType::Author => { + if let Ok(filter) = filter.value.as_string() { + author_filter = urlencode(filter.read().to_lowercase()); + is_searching = true; + } + } + FilterType::Select => { + if filter.name.as_str() == "Sort" { + sort_filter = filter.value.as_int().unwrap_or(-1); + } + if filter.name.as_str() == "Genre" { + genre_filter = filter.value.as_int().unwrap_or(-1) + } + if filter.name.as_str() == "Status" { + status_filter = filter.value.as_int().unwrap_or(-1) + } + } + _ => continue, + } + } + + let mut search_string = String::new(); + search_string.push_str(&title_filter); + if !search_string.is_empty() && !author_filter.is_empty() { + search_string.push('_'); + } + search_string.push_str(&author_filter); + + if is_searching { + let mut query = QueryParameters::new(); + query.set("page", Some(page.to_string().as_str())); + + return format!("{BASE_URL}/list/search/{search_string}?{query}"); + } + + let sort = match sort_filter { + 0 => "latest", + 1 => "newest", + 2 => "topview", + _ => "latest", + }; + + // Genre + let ctg = match genre_filter { + 0 => "all", // "All", + 1 => "2", // "Action", + 2 => "3", // "Adult", + 3 => "4", // "Adventure", + 4 => "6", // "Comedy", + 5 => "7", // "Cooking", + 6 => "9", // "Doujinshi", + 7 => "10", // "Drama", + 8 => "11", // "Ecchi", + 9 => "48", // "Erotica", + 10 => "12", // "Fantasy", + 11 => "13", // "Gender bender", + 12 => "14", // "Harem", + 13 => "15", // "Historical", + 14 => "16", // "Horror", + 15 => "45", // "Isekai", + 16 => "17", // "Josei", + 17 => "44", // "Manhua", + 18 => "43", // "Manhwa", + 19 => "19", // "Martial arts", + 20 => "20", // "Mature", + 21 => "21", // "Mecha", + 22 => "22", // "Medical", + 23 => "24", // "Mystery", + 24 => "25", // "One shot", + 25 => "47", // "Pornographic", + 26 => "26", // "Phychological", + 27 => "27", // "Romance", + 28 => "28", // "School life", + 29 => "29", // "Sci fi", + 30 => "30", // "Seinen", + 31 => "31", // "Shoujo", + 32 => "32", // "Shoujo ai", + 33 => "33", // "Shounen", + 34 => "34", // "Shounen ai", + 35 => "35", // "Slice of Life", + 36 => "36", // "Smut", + 37 => "37", // "Sports", + 38 => "38", // "Supernatural", + 39 => "39", // "Tragedy", + 40 => "40", // "Webtoons", + 41 => "41", // "Yaoi", + 42 => "42", // "Yuri" + _ => "all", + }; + + // State + let status = match status_filter { + 0 => "all", + 1 => "ongoing", + 2 => "completed", + _ => "all", + }; + + let page = &page.to_string(); + format!("{BASE_URL}/manga-list/type-{sort}/ctg-{ctg}/state-{status}/page-{page}") +} + +pub fn parse_incoming_url_manga_id(url: &str) -> Option { + // https://chap.mangairo.com/story-pn279847 + // https://chap.mangairo.com/story-pn279847/chapter-52 + let mut parts: Vec<&str> = url.split('/').collect(); + // Manga URL as ID because otherwise we cannot differentiate `w` and `chap` + // subdomains for manga + if parts.len() >= 4 { + parts.truncate(4); + } + Some(parts.join("/")) + + // Excludes the base URL from the manga id. + // if parts.len() >= 3 { + // let manga_id = parts[3]; + // return Some(format!("{}", manga_id)); + // } + + // None +} + +pub fn parse_incoming_url_chapter_id(url: &str) -> Option { + // https://chap.mangairo.com/story-pn279847/chapter-52 + let parts: Vec<&str> = url.split('/').collect(); + if parts.len() >= 4 { + let chapter_id = parts[4]; + return Some(format!("{}", chapter_id)); + } + + None +} + +// HELPER FUNCTIONS + +pub fn urlencode(string: String) -> String { + let mut str = string.to_lowercase(); + + let match_a = [ + 'à', 'á', 'ạ', 'ả', 'ã', 'â', 'ầ', 'ấ', 'ậ', 'ẩ', 'ẫ', 'ă', 'ằ', 'ắ', 'ặ', 'ẳ', 'ẵ', + ]; + let match_e = ['è', 'é', 'ẹ', 'ẻ', 'ẽ', 'ê', 'ề', 'ế', 'ệ', 'ể', 'ễ']; + let match_i = ['ì', 'í', 'ị', 'ỉ', 'ĩ']; + let match_o = [ + 'ò', 'ó', 'ọ', 'ỏ', 'õ', 'ô', 'ồ', 'ố', 'ộ', 'ổ', 'ỗ', 'ơ', 'ờ', 'ớ', 'ợ', 'ở', 'ỡ', + ]; + let match_u = ['ù', 'ú', 'ụ', 'ủ', 'ũ', 'ư', 'ừ', 'ứ', 'ự', 'ử', 'ữ']; + let match_y = ['ỳ', 'ý', 'ỵ', 'ỷ', 'ỹ']; + let match_d = "đ"; + let match_symbols = [ + '!', '@', '%', '^', '*', '(', ')', '+', '=', '<', '>', '?', '/', ',', '.', ':', ';', '\'', + ' ', '"', '&', '#', '[', ']', '~', '-', '$', '|', '_', + ]; + + str = str.replace(match_a, "a"); + str = str.replace(match_e, "e"); + str = str.replace(match_i, "i"); + str = str.replace(match_o, "o"); + str = str.replace(match_u, "u"); + str = str.replace(match_y, "y"); + str = str.replace(match_d, "d"); + str = str.replace(match_symbols, "_"); + str = str.replace("__", "_"); + str = str.trim_matches('_').to_string(); + + str +}