From 094ad1622f06c5f0a1904f956d65afff485548a2 Mon Sep 17 00:00:00 2001 From: kantan <31490942+kkantan@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:40:46 +0900 Subject: [PATCH] feat(ja.soraraw): add home and search filters --- sources/ja.soraraw/Cargo.lock | 104 ++++++------ sources/ja.soraraw/Cargo.toml | 10 +- sources/ja.soraraw/res/filters.json | 16 ++ sources/ja.soraraw/res/source.json | 16 +- sources/ja.soraraw/src/catalogue.rs | 86 ++++++++++ sources/ja.soraraw/src/helpers.rs | 47 ++---- sources/ja.soraraw/src/home.rs | 91 +++++++++++ sources/ja.soraraw/src/lib.rs | 242 ++++++++++++++-------------- sources/ja.soraraw/src/models.rs | 82 ++++++---- sources/ja.soraraw/src/test.rs | 96 +++++------ 10 files changed, 497 insertions(+), 293 deletions(-) create mode 100644 sources/ja.soraraw/res/filters.json create mode 100644 sources/ja.soraraw/src/catalogue.rs create mode 100644 sources/ja.soraraw/src/home.rs diff --git a/sources/ja.soraraw/Cargo.lock b/sources/ja.soraraw/Cargo.lock index 12fefdbee..b876160e0 100644 --- a/sources/ja.soraraw/Cargo.lock +++ b/sources/ja.soraraw/Cargo.lock @@ -4,19 +4,19 @@ version = 4 [[package]] name = "aes" -version = "0.8.4" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ - "cfg-if", "cipher", + "cpubits", "cpufeatures", ] [[package]] name = "aidoku" version = "0.3.0" -source = "git+https://github.com/Aidoku/aidoku-rs.git#1a6bb691dd67c7151fc76fc852fb5a364d325f72" +source = "git+https://github.com/Aidoku/aidoku-rs.git#ae6485e3c14ecd1fd5497d170eae884c68d04a87" dependencies = [ "euclid", "hashbrown", @@ -33,7 +33,7 @@ dependencies = [ [[package]] name = "aidoku-test" version = "1.0.0" -source = "git+https://github.com/Aidoku/aidoku-rs.git#1a6bb691dd67c7151fc76fc852fb5a364d325f72" +source = "git+https://github.com/Aidoku/aidoku-rs.git#ae6485e3c14ecd1fd5497d170eae884c68d04a87" dependencies = [ "quote", "syn 2.0.119", @@ -62,11 +62,11 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "block-buffer" -version = "0.10.4" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -77,15 +77,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cipher" -version = "0.4.4" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "crypto-common", "inout", @@ -100,11 +100,17 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" -version = "0.2.17" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -117,19 +123,18 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "generic-array", - "typenum", + "hybrid-array", ] [[package]] name = "digest" -version = "0.10.7" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer", "crypto-common", @@ -168,16 +173,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "hash32" version = "0.2.1" @@ -214,13 +209,22 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "inout" -version = "0.1.4" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -231,9 +235,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libm" @@ -351,7 +355,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -369,9 +373,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.9" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures", @@ -418,9 +422,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -438,22 +442,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -464,15 +468,9 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "version_check" -version = "0.9.5" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "zmij" diff --git a/sources/ja.soraraw/Cargo.toml b/sources/ja.soraraw/Cargo.toml index 2313b0179..c9f4c7809 100644 --- a/sources/ja.soraraw/Cargo.toml +++ b/sources/ja.soraraw/Cargo.toml @@ -3,20 +3,20 @@ name = "soraraw" version = "0.1.0" edition = "2024" +[lib] +crate-type = ["cdylib"] + [dependencies] -aes = { version = "0.8", default-features = false } +aes = { version = "0.9", default-features = false } aidoku = { git = "https://github.com/Aidoku/aidoku-rs.git", version = "0.3.0", features = ["json"] } serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] } serde_json = { version = "1.0", default-features = false, features = ["alloc"] } -sha2 = { version = "0.10.9", default-features = false } +sha2 = { version = "0.11", default-features = false } [dev-dependencies] aidoku = { git = "https://github.com/Aidoku/aidoku-rs.git", features = ["test", "json"] } aidoku-test = { git = "https://github.com/Aidoku/aidoku-rs.git" } -[lib] -crate-type = ["cdylib"] - [profile.dev] panic = "abort" diff --git a/sources/ja.soraraw/res/filters.json b/sources/ja.soraraw/res/filters.json new file mode 100644 index 000000000..a29760d5b --- /dev/null +++ b/sources/ja.soraraw/res/filters.json @@ -0,0 +1,16 @@ +[ + { + "type": "select", + "id": "content", + "title": "コンテンツ", + "options": ["すべて", "一般", "18+"], + "ids": ["", "no", "yes"] + }, + { + "type": "select", + "id": "status", + "title": "ステータス", + "options": ["すべて", "連載中", "完結"], + "ids": ["", "incomplete", "complete"] + } +] diff --git a/sources/ja.soraraw/res/source.json b/sources/ja.soraraw/res/source.json index ab9bc356d..d64af754a 100644 --- a/sources/ja.soraraw/res/source.json +++ b/sources/ja.soraraw/res/source.json @@ -2,12 +2,10 @@ "info": { "id": "ja.soraraw", "name": "Soraraw", - "version": 6, + "version": 7, "url": "https://soraraw.com", "contentRating": 2, - "languages": [ - "ja" - ], + "languages": ["ja"], "minAppVersion": "0.9" }, "listings": [ @@ -15,14 +13,14 @@ "id": "newest", "name": "新着" }, - { - "id": "rising", - "name": "急上昇" - }, { "id": "trending", "name": "ランキング" }, + { + "id": "rising", + "name": "急上昇" + }, { "id": "lifetime", "name": "殿堂入り" @@ -30,6 +28,6 @@ ], "config": { "supportsAuthorSearch": true, - "hidesFiltersWhileSearching": true + "supportsTagSearch": true } } diff --git a/sources/ja.soraraw/src/catalogue.rs b/sources/ja.soraraw/src/catalogue.rs new file mode 100644 index 000000000..77263a92a --- /dev/null +++ b/sources/ja.soraraw/src/catalogue.rs @@ -0,0 +1,86 @@ +use crate::{BASE_URL, helpers::request, models::*}; +use aidoku::{ + Manga, MangaPageResult, Result, alloc::vec::Vec, imports::std::current_date, prelude::*, +}; + +const SEARCH_RESULT_LIMIT: usize = 50; +const CATALOGUE_PAGE_LIMIT: i32 = 40; // current actual count is 15 +const CATALOGUE_CACHE_SECONDS: i64 = 60 * 60; // 1 hour + +pub struct SearchParams<'a> { + pub query: Option<&'a str>, + pub author: Option<&'a str>, + pub content: Option<&'a str>, + pub status: Option<&'a str>, + pub included: &'a [i64], + pub excluded: &'a [i64], + pub page: i32, +} + +pub struct Catalogue { + pub entries: Vec, + pub fetch_time: i64, +} + +impl Catalogue { + pub fn fetch() -> Result { + let mut entries = Vec::new(); + for page in 1..=CATALOGUE_PAGE_LIMIT { + let response = request(format!("{BASE_URL}/mangas_{page}.json"))?.send()?; + // final page returns 404 to indicate end + if response.status_code() != 200 { + if page == 1 { + bail!( + "the catalogue is unreachable: page 1 answered {}", + response.status_code() + ); + } + break; + } + let catalogue = response.get_json_owned::()?; + entries.extend(catalogue.list); + } + + Ok(Self { + entries, + fetch_time: current_date(), + }) + } + + pub fn is_fresh(&self, now: i64) -> bool { + let elapsed = now - self.fetch_time; + (0..CATALOGUE_CACHE_SECONDS).contains(&elapsed) + } + + pub fn filter(&self, params: SearchParams<'_>) -> MangaPageResult { + let offset = (params.page.max(1) as usize - 1).saturating_mul(SEARCH_RESULT_LIMIT); + let mut matches = self + .entries + .iter() + .filter(|entry| { + params.query.is_none_or(|query| entry.matches(query)) + && params + .author + .is_none_or(|author| entry.matches_author(author)) + && params + .content + .is_none_or(|content| entry.is_adult.as_deref() == Some(content)) + && params + .status + .is_none_or(|status| entry.kind.as_deref() == Some(status)) + && params.included.iter().all(|id| entry.genres.contains(id)) + && params.excluded.iter().all(|id| !entry.genres.contains(id)) + }) + .skip(offset); + let results = matches + .by_ref() + .take(SEARCH_RESULT_LIMIT) + .cloned() + .map(Manga::from) + .collect(); + MangaPageResult { + entries: results, + has_next_page: matches.next().is_some(), + } + } +} diff --git a/sources/ja.soraraw/src/helpers.rs b/sources/ja.soraraw/src/helpers.rs index 35450a48b..6279f6462 100644 --- a/sources/ja.soraraw/src/helpers.rs +++ b/sources/ja.soraraw/src/helpers.rs @@ -1,9 +1,13 @@ +use crate::{ + BASE_URL, HEADER_BYTES, SCRAMBLE_GRID, SCRAMBLE_SECRET, STACKED_PAGE_LIMIT, THUMBNAIL_URL, + USER_AGENT, models::NextData, +}; use aes::{ Aes256, - cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray}, + cipher::{Block, BlockCipherEncrypt, KeyInit}, }; use aidoku::{ - AidokuError, ContentRating, MangaStatus, Result, Viewer, + ContentRating, MangaStatus, Result, Viewer, alloc::{String, Vec, string::ToString, vec}, imports::{html::Html, net::Request}, prelude::*, @@ -11,24 +15,17 @@ use aidoku::{ use serde::de::DeserializeOwned; use sha2::{Digest, Sha256}; -use crate::{ - BASE_URL, HEADER_BYTES, SCRAMBLE_GRID, SCRAMBLE_SECRET, STACKED_PAGE_LIMIT, THUMBNAIL_URL, - USER_AGENT, models::NextData, -}; - const BLOCK_SIZE: usize = 16; pub fn manga_url(slug: &str) -> String { format!("{BASE_URL}/manga/{slug}") } -// chapter paths repeat the slug of their manga, which the url doesn't pub fn chapter_url(manga_slug: &str, path: &str) -> String { let suffix = path.strip_prefix(&format!("{manga_slug}-")).unwrap_or(path); format!("{BASE_URL}/manga/{manga_slug}/{suffix}") } -// both ids the image endpoint takes, so pages never rely on a slug carrying its id pub fn chapter_key(manga_id: i64, chapter_id: i64) -> String { format!("{manga_id}/{chapter_id}") } @@ -46,26 +43,17 @@ pub fn request(url: impl AsRef) -> Result { } pub fn next_data(url: &str) -> Result { - let html = request(url)?.html()?; - // script contents are data nodes rather than text, so `text` would come back empty. `data` is - // what the app implements it with; the test runner only answers `html`, which holds the same - // string for a script tag - let Some(json) = html + let json = request(url)? + .html()? .select_first("script#__NEXT_DATA__") .and_then(|script| script.data().or_else(|| script.html())) - else { - bail!("no page data at {url}"); - }; - + .ok_or_else(|| error!("no page data at {url}"))?; serde_json::from_str::>(&json) .map(|data| data.props.page_props) - .map_err(|error| AidokuError::Message(format!("unexpected page data at {url}: {error}"))) + .map_err(|error| error!("unexpected page data at {url}: {error}")) } -// synopses hold inline markup, which the app doesn't render pub fn strip_html(text: &str) -> String { - // wrapped in an element of its own: reading the text off a bare fragment works in the app but - // not in the test runner, which only ever hands back elements a selector matched Html::parse_fragment(format!("
{text}
")) .ok() .and_then(|document| document.select_first("div")) @@ -187,16 +175,11 @@ pub fn status(kind: Option<&str>) -> MangaStatus { } } -// the `mode` field says how the site's own reader lays a series out, not what kind of comic it -// is: a third of the `vertical` ones are ordinary manga. the overseas genres track the content -// instead, appearing on 644 of 979 `vertical` entries and on 2 of 7021 `horizontal` ones. -// image proportions don't work either, since webtoons here are as often cut into page-shaped -// chunks as into tall strips pub fn viewer<'a>(genre_slugs: impl Iterator) -> Viewer { const OVERSEAS_GENRE: &str = "kaigai-manga"; for slug in genre_slugs { - if slug == OVERSEAS_GENRE || slug.contains("webtoon") { + if slug == OVERSEAS_GENRE || slug.contains("toon") { return Viewer::Webtoon; } } @@ -204,8 +187,6 @@ pub fn viewer<'a>(genre_slugs: impl Iterator) -> Viewer { Viewer::RightToLeft } -// deriving anything further from genres was tried and dropped: names are not unique (41 of the -// 1834 listed are shared), and the suggestive ones are already flagged as adult by the site pub fn content_rating(is_adult: Option<&str>) -> ContentRating { match is_adult { Some("yes") => ContentRating::NSFW, @@ -214,8 +195,6 @@ pub fn content_rating(is_adult: Option<&str>) -> ContentRating { } } -// comparing bytes is safe for utf-8: a multi-byte character can never match part of another one, -// so a byte window that compares equal is always a real substring pub fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool { let needle = needle.as_bytes(); if needle.is_empty() { @@ -295,13 +274,13 @@ pub fn decrypt_path(payload: &str, uuid: &str, secret: &[u8]) -> Option *byte ^= secret[index % secret.len()]; } - let cipher = Aes256::new(GenericArray::from_slice(&key)); + let cipher = Aes256::new_from_slice(&key).ok()?; let mut counter = [0u8; BLOCK_SIZE]; counter.copy_from_slice(&bytes[..BLOCK_SIZE]); let mut path = Vec::with_capacity(bytes.len() - BLOCK_SIZE); for chunk in bytes[BLOCK_SIZE..].chunks(BLOCK_SIZE) { - let mut block = GenericArray::from(counter); + let mut block = Block::::from(counter); cipher.encrypt_block(&mut block); for (byte, mask) in chunk.iter().zip(block.iter()) { path.push(byte ^ mask); diff --git a/sources/ja.soraraw/src/home.rs b/sources/ja.soraraw/src/home.rs new file mode 100644 index 000000000..55d8d6fae --- /dev/null +++ b/sources/ja.soraraw/src/home.rs @@ -0,0 +1,91 @@ +use crate::{ + BASE_URL, Soraraw, + helpers::request, + models::{DataProps, HomeData, MangaEntry, Props, TopList}, +}; +use aidoku::{ + Home, HomeComponent, HomeComponentValue, HomeLayout, Link, Listing, ListingKind, Manga, Result, + alloc::{Vec, vec}, + imports::net::{Request, RequestError, Response}, + prelude::*, +}; + +impl Home for Soraraw { + fn get_home(&self) -> Result { + let responses: [core::result::Result; 3] = Request::send_all([ + request(format!("{BASE_URL}/_next/data/soraraw38/index.json"))?, + request(format!("{BASE_URL}/top/today.json"))?, + request(format!("{BASE_URL}/top/rising.json"))?, + ]) + .try_into() + .expect("three home requests"); + let [home, today, rising] = responses; + let home = home? + .get_json_owned::>>()? + .page_props + .data; + let today = today?.get_json_owned::()?; + let rising = rising?.get_json_owned::()?; + + Ok(HomeLayout { + components: vec![ + HomeComponent { + title: None, + subtitle: None, + value: HomeComponentValue::BigScroller { + entries: home + .hot + .into_iter() + .map(MangaEntry::into_full_manga) + .take(10) + .collect(), + auto_scroll_interval: Some(8.0), + }, + }, + HomeComponent { + title: Some("最新更新".into()), + subtitle: Some("毎日更新の最新マンガ".into()), + value: scroller( + home.results.into_iter().map(Manga::from).collect(), + "newest", + "新着", + ), + }, + HomeComponent { + title: Some("24時間ランキング".into()), + subtitle: Some("今日もっとも読まれた作品".into()), + value: scroller( + today.mangas.into_iter().map(Manga::from).collect(), + "trending", + "ランキング", + ), + }, + HomeComponent { + title: Some("今週急上昇".into()), + subtitle: Some("勢いのある作品".into()), + value: scroller( + rising + .mangas + .into_iter() + .take(20) + .map(Manga::from) + .collect(), + "rising", + "急上昇", + ), + }, + ], + }) + } +} + +fn scroller(entries: Vec, id: &str, name: &str) -> HomeComponentValue { + HomeComponentValue::Scroller { + entries: entries.into_iter().map(Link::from).collect(), + listing: Some(Listing { + id: id.into(), + name: name.into(), + kind: ListingKind::Default, + }), + } +} diff --git a/sources/ja.soraraw/src/lib.rs b/sources/ja.soraraw/src/lib.rs index 3d2570c79..f5cae6989 100644 --- a/sources/ja.soraraw/src/lib.rs +++ b/sources/ja.soraraw/src/lib.rs @@ -1,21 +1,26 @@ #![no_std] use aidoku::{ Chapter, DeepLinkHandler, DeepLinkResult, DynamicFilters, Filter, FilterValue, ImageResponse, - Listing, ListingProvider, Manga, MangaPageResult, Page, PageContent, PageContext, - PageImageProcessor, Result, SelectFilter, Source, + Listing, ListingProvider, Manga, MangaPageResult, MultiSelectFilter, Page, PageContent, + PageContext, PageImageProcessor, Result, Source, alloc::{String, Vec, borrow::Cow, string::ToString, vec}, canvas::{Rect, Transform}, imports::{ canvas::{Canvas, ImageRef}, - std::send_partial_result, + std::{current_date, send_partial_result}, }, prelude::*, }; +use core::cell::RefCell; +mod catalogue; mod helpers; +mod home; mod models; #[cfg(test)] mod test; + +use catalogue::*; use helpers::*; use models::*; @@ -25,10 +30,14 @@ const IMAGE_API_URL: &str = "https://api.mangarawgo.site"; // both hosts answer 403 to a user agent containing "aidoku", such as the test runner's default const USER_AGENT: &str = "Mozilla/5.0 (iPhone; CPU iPhone OS 26_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Version/26.6 Safari/605.1.15"; const DATE_FORMAT: &str = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"; + const PAYLOAD_KEY: &[u8] = b"/fuCkYou!!!"; const PATH_SECRET: &[u8] = b"202508055d0db38bae2e86cc41649f90"; const SCRAMBLE_SECRET: &str = "6a0248ad1ca4208275aed64d336e81595ecb149422a8e621f70e23b9f01b9c1c"; const SCRAMBLE_GRID: u32 = 8; + +// enough to reach the header unless the file leads with a large colour profile +const HEADER_BYTES: usize = 16 * 1024; // a strip holds a handful of images at most, a scanned chapter one per page, so this keeps the // request that measures them off the common case const STRIP_IMAGE_LIMIT: usize = 4; @@ -36,20 +45,17 @@ const STRIP_IMAGE_LIMIT: usize = 4; // this comes from a misread header rather than an image that deep, and slicing on it would hand // the reader hundreds of slivers const STACKED_PAGE_LIMIT: u32 = 64; -// enough to reach the header unless the file leads with a large colour profile -const HEADER_BYTES: usize = 16 * 1024; // the site lists over 1800 genres, sorted by how many series they hold const GENRE_LIMIT: usize = 100; -const SEARCH_RESULT_LIMIT: usize = 50; -// the dump held 13 pages at the time of writing and ends with a 404; this only guards against a -// host that stops answering with one -const CATALOGUE_PAGE_LIMIT: i32 = 40; -struct Soraraw; +#[derive(Default)] +struct Soraraw { + catalogue: RefCell>, +} impl Source for Soraraw { fn new() -> Self { - Self + Self::default() } fn get_search_manga_list( @@ -59,37 +65,68 @@ impl Source for Soraraw { filters: Vec, ) -> Result { let mut author = None; - let mut genre = None; + let mut content = None; + let mut status = None; + let mut selected_genre = None; + let mut included = Vec::new(); + let mut excluded = Vec::new(); for filter in filters { match filter { - // the text field "supportsAuthorSearch" adds to the search filters, which the app - // fills in on its own rather than folding the name into the query FilterValue::Text { id, value } if id == "author" && !value.is_empty() => { author = Some(value) } - FilterValue::Select { id, value } if id == "genre" && !value.is_empty() => { - genre = Some(value) + FilterValue::Select { id, value } if !value.is_empty() => match id.as_str() { + "content" => content = Some(value), + "status" => status = Some(value), + "genre" => selected_genre = Some(value), + _ => bail!("invalid select filter id: `{id}`"), + }, + FilterValue::MultiSelect { + id, + included: selected, + excluded: rejected, + } if id == "genre-id" => { + included.extend(selected); + excluded.extend(rejected); } _ => {} } } - // searching walks the whole catalogue, so it can't be combined with the genre filter; - // "hidesFiltersWhileSearching" in "source.json" keeps the app from offering them together - let (query, author) = (query.as_deref(), author.as_deref()); - if query.is_some() || author.is_some() { - // every match is collected in one go, leaving no page for the app to ask for - return Ok(MangaPageResult { - entries: Self::search_catalogue(query, author)?, - has_next_page: false, - }); + let parse_id = |value: String| { + value + .parse::() + .map_err(|_| error!("invalid genre id: `{value}`")) + }; + let mut included = included + .into_iter() + .map(&parse_id) + .collect::>>()?; + let excluded = excluded + .into_iter() + .map(&parse_id) + .collect::>>()?; + if let Some(name) = selected_genre { + // selecting tag from series page only gives the name, so the id must be fetched + let genres = + request(format!("{BASE_URL}/genres.json"))?.json_owned::>()?; + let id = genres + .iter() + .find(|genre| genre.name == name || genre.slug == name) + .map(|genre| genre.id) + .ok_or_else(|| error!("unknown genre: {name}"))?; + included.push(id); } - - let url = match genre { - Some(genre) => paginated(&format!("{BASE_URL}/genre/{genre}"), page), - None => paginated(&format!("{BASE_URL}/newest"), page), + let params = SearchParams { + query: query.as_deref(), + author: author.as_deref(), + content: content.as_deref(), + status: status.as_deref(), + included: &included, + excluded: &excluded, + page, }; - Self::parse_list(&url) + self.search_catalogue(params) } fn get_manga_update( @@ -121,19 +158,15 @@ impl Source for Soraraw { manga.tags = (!tags.is_empty()).then_some(tags); if needs_chapters { - // the chapter list is parsed out of the same response, but every entry costs a - // date to parse, so the details are handed over before that starts send_partial_result(&manga); } } if needs_chapters { - let manga_id = details.id; - let slug = details.slug; let chapters = details .chapters .into_iter() - .map(|chapter| chapter.into_chapter(manga_id, &slug)) + .map(|chapter| chapter.into_chapter(details.id, &details.slug)) .collect::>(); manga.chapters = Some(chapters); } @@ -145,35 +178,37 @@ impl Source for Soraraw { let Some((manga_id, chapter_id)) = chapter.key.split_once('/') else { bail!("malformed chapter key {}", chapter.key); }; - let Ok(chapter_id) = chapter_id.parse::() else { - bail!("malformed chapter key {}", chapter.key); - }; + let chapter_id = chapter_id + .parse::() + .map_err(|_| error!("malformed chapter key {}", chapter.key))?; // the page list holds the paths of the images encrypted, and the key to them lives on the // chapter page rather than alongside the list - let Some(url) = chapter.url.as_deref() else { - bail!("no url to read the image key of chapter {chapter_id} from"); - }; - let Some(details) = next_data::>(url)?.data.chapter else { - bail!("no chapter data at {url}"); + let details = { + let url = chapter.url.as_deref().ok_or_else(|| { + error!("no url to read the image key of chapter {chapter_id} from") + })?; + next_data::>(url)? + .data + .chapter + .ok_or_else(|| error!("no chapter data at {url}"))? }; let (Some(uuid), Some(host)) = (details.uuid, details.base) else { bail!("chapter {chapter_id} hands out no image key"); }; let scrambled = details.mode.as_deref() == Some("canva2"); - let payload = request(format!("{IMAGE_API_URL}/{manga_id}/{chapter_id}.json"))? - .json_owned::()?; - let Some(json) = deobfuscate(&payload.d, PAYLOAD_KEY) else { - bail!("could not decode the page list of chapter {chapter_id}"); + let images = { + let payload = request(format!("{IMAGE_API_URL}/{manga_id}/{chapter_id}.json"))? + .json_owned::()?; + let json = deobfuscate(&payload.d, PAYLOAD_KEY) + .ok_or_else(|| error!("could not decode the page list of chapter {chapter_id}"))?; + serde_json::from_str::>(&json) + .map_err(|error| error!("unexpected page list for chapter {chapter_id}: {error}"))? }; - let images = serde_json::from_str::>(&json) - .map_err(|error| error!("unexpected page list for chapter {chapter_id}: {error}"))?; let mut urls = Vec::with_capacity(images.len()); for image in images { - // a page that can't be placed or decrypted is not skipped: the chapter would read as - // complete while missing a page, which nothing downstream could tell apart let (Some(order), Some(path)) = ( image.order.as_f32(), decrypt_path(&image.b, &uuid, PATH_SECRET), @@ -183,16 +218,13 @@ impl Source for Soraraw { urls.push((order, format!("{host}/{path}"))); } if urls.is_empty() { - // an empty list is indistinguishable from a failed request once it reaches the app bail!("no pages returned for chapter {chapter_id}"); } // the endpoint returns them in order, but the site sorts them anyway before reading. some // chapters number an inserted page as a fraction, so the order can't be taken as an integer urls.sort_by(|(left, _), (right, _)| left.total_cmp(right)); - // a chapter holding few enough images to be a strip gets measured before it's handed over: - // some of them stack every page into one image, which the reader is handed a slice at a time - let measure = urls.len() <= STRIP_IMAGE_LIMIT; + let possibly_uses_strip_images = urls.len() <= STRIP_IMAGE_LIMIT; let mut pages = Vec::with_capacity(urls.len()); for (_, url) in urls { // the tile plan covers the whole image, so these can't be sliced @@ -205,20 +237,24 @@ impl Source for Soraraw { }); continue; } - let slices = if measure { stacked_page_count(&url) } else { 1 }; - if slices < 2 { + let slice_count = if possibly_uses_strip_images { + stacked_page_count(&url) + } else { + 1 + }; + if slice_count == 1 { pages.push(Page { content: PageContent::url(url), ..Default::default() }); continue; } - for slice in 0..slices { + for slice in 0..slice_count { let mut context = PageContext::new(); context.insert(String::from("slice"), slice.to_string()); - context.insert(String::from("slices"), slices.to_string()); + context.insert(String::from("slices"), slice_count.to_string()); pages.push(Page { - content: PageContent::url_context(url.clone(), context), + content: PageContent::url_context(&url, context), ..Default::default() }); } @@ -229,63 +265,28 @@ impl Source for Soraraw { } impl Soraraw { - // nothing on the site can be queried: "/search?q=" is statically generated and renders a fixed - // batch, the api host answers 500 for every query (`Unknown column 'Manga.number_views' in - // 'ORDER BY'`), and its "/mangas" ignores every parameter tried. that leaves walking the dump - // the browser filters itself, 13 pages of 2000 entries and about 4.7 MB gzipped - fn search_catalogue(query: Option<&str>, author: Option<&str>) -> Result> { - let mut entries = Vec::new(); - - for page in 1..=CATALOGUE_PAGE_LIMIT { - let response = request(format!("{BASE_URL}/mangas_{page}.json"))?.send()?; - // the dump ends with a 404, which is how the site's own search stops walking it - if response.status_code() != 200 { - // the first page is the exception: with nothing walked yet, a dump that can't be - // reached hands back the empty result of a query that matched nothing - if page == 1 { - bail!( - "the catalogue is unreachable: page 1 answered {}", - response.status_code() - ); - } - break; - } - let Ok(catalogue) = response.get_json_owned::() else { - // a page that stopped being json leaves the matches collected before it worth - // returning, except on the first page, which leaves none - if page == 1 { - bail!("could not read catalogue page 1"); - } - break; - }; - - for entry in catalogue.list { - let matched = query.is_none_or(|query| entry.matches(query)) - && author.is_none_or(|author| entry.matches_author(author)); - if !matched { - continue; - } - entries.push(Manga::from(entry)); - if entries.len() >= SEARCH_RESULT_LIMIT { - return Ok(entries); - } - } + fn search_catalogue(&self, params: SearchParams<'_>) -> Result { + let now = current_date(); + let needs_refresh = self + .catalogue + .borrow() + .as_ref() + .is_none_or(|catalogue| !catalogue.is_fresh(now)); + if needs_refresh { + *self.catalogue.borrow_mut() = Some(Catalogue::fetch()?); } - Ok(entries) + let catalogue = self.catalogue.borrow(); + Ok(catalogue + .as_ref() + .expect("catalogue was loaded") + .filter(params)) } fn parse_list(url: &str) -> Result { - let data = next_data::>(url)?.data; - Ok(MangaPageResult { - has_next_page: data - .pagination - .is_some_and(|pagination| pagination.has_next_page()), - entries: data.results.into_iter().map(Manga::from).collect(), - }) + next_data::>(url).map(|d| d.data.into()) } - // a ranking arrives as one json holding every entry, leaving no page for the app to ask for fn parse_top(period: &str) -> Result { let top = request(format!("{BASE_URL}/top/{period}.json"))?.json_owned::()?; if top.mangas.is_empty() { @@ -344,6 +345,7 @@ impl PageImageProcessor for Soraraw { if let Some(seed) = context.get("seed") { return Ok(Self::unscramble(&response.image, seed).unwrap_or(response.image)); } + let number = |key: &str| context.get(key).and_then(|value| value.parse::().ok()); let (Some(slice), Some(slices)) = (number("slice"), number("slices")) else { return Ok(response.image); @@ -376,34 +378,35 @@ impl ListingProvider for Soraraw { fn get_manga_list(&self, listing: Listing, page: i32) -> Result { match listing.id.as_str() { "rising" => Self::parse_top("rising"), - // the id is kept from before the rankings were split by period "trending" => Self::parse_top("last30Days"), "lifetime" => Self::parse_top("lifetime"), - _ => Self::parse_list(&paginated(&format!("{BASE_URL}/newest"), page)), + "newest" => Self::parse_list(&paginated(&format!("{BASE_URL}/newest"), page)), + _ => bail!("invalid listing id: `{}`", listing.id), } } } impl DynamicFilters for Soraraw { - // the genre list is fetched instead of hardcoded, so new genres are picked up automatically fn get_dynamic_filters(&self) -> Result> { let genres = request(format!("{BASE_URL}/genres.json"))?.json_owned::>()?; - let mut options: Vec> = vec![Cow::Borrowed("All")]; - let mut ids: Vec> = vec![Cow::Borrowed("")]; + let mut options: Vec> = Vec::new(); + let mut ids: Vec> = Vec::new(); for genre in genres.into_iter().take(GENRE_LIMIT) { if genre.slug.is_empty() || genre.name.trim().is_empty() { continue; } options.push(String::from(genre.name.trim()).into()); - ids.push(genre.slug.into()); + ids.push(genre.id.to_string().into()); } Ok(vec![ - SelectFilter { - id: "genre".into(), - title: Some("Genre".into()), + MultiSelectFilter { + id: "genre-id".into(), + title: Some("ジャンル".into()), is_genre: true, + can_exclude: true, + uses_tag_style: true, options, ids: Some(ids), ..Default::default() @@ -447,6 +450,7 @@ impl DeepLinkHandler for Soraraw { register_source!( Soraraw, + Home, ListingProvider, DynamicFilters, DeepLinkHandler, diff --git a/sources/ja.soraraw/src/models.rs b/sources/ja.soraraw/src/models.rs index fb052a062..25660a655 100644 --- a/sources/ja.soraraw/src/models.rs +++ b/sources/ja.soraraw/src/models.rs @@ -1,10 +1,3 @@ -use aidoku::{ - Chapter, Manga, Viewer, - alloc::{String, Vec}, - imports::std::parse_date_with_options, -}; -use serde::Deserialize; - use crate::{ DATE_FORMAT, helpers::{ @@ -12,6 +5,12 @@ use crate::{ manga_url, status, strip_html, viewer, }, }; +use aidoku::{ + Chapter, Manga, MangaPageResult, Viewer, + alloc::{String, Vec}, + imports::std::parse_date_with_options, +}; +use serde::Deserialize; #[derive(Deserialize)] pub struct NextData { @@ -29,6 +28,15 @@ pub struct DataProps { pub data: T, } +// "/_next/data/soraraw38/index.json" +#[derive(Deserialize)] +pub struct HomeData { + #[serde(default)] + pub hot: Vec, + #[serde(default)] + pub results: Vec, +} + // "/top/{period}.json", the ranking lists the site fetches from the browser #[derive(Deserialize)] pub struct TopList { @@ -44,6 +52,17 @@ pub struct ListData { pub pagination: Option, } +impl From for MangaPageResult { + fn from(value: ListData) -> Self { + MangaPageResult { + has_next_page: value + .pagination + .is_some_and(|pagination| pagination.has_next_page()), + entries: value.results.into_iter().map(Manga::from).collect(), + } + } +} + #[derive(Deserialize)] pub struct Pagination { pub current_page: i32, @@ -56,7 +75,6 @@ impl Pagination { } } -// listings don't all carry the same fields, so everything but the two a cover needs is optional #[derive(Deserialize)] pub struct MangaEntry { pub name: String, @@ -69,18 +87,28 @@ pub struct MangaEntry { pub is_adult: Option, } +impl MangaEntry { + pub fn into_full_manga(self) -> Manga { + Manga { + url: Some(manga_url(&self.slug)), + key: self.slug, + title: self.name, + cover: cover(self.thumbnail, self.image.as_deref()), + authors: authors(self.author.as_deref()), + status: status(self.kind.as_deref()), + content_rating: content_rating(self.is_adult.as_deref()), + ..Default::default() + } + } +} + impl From for Manga { fn from(value: MangaEntry) -> Self { - // `viewer` is left unset: listings carry no genres, and the reader is picked from those. - // the app fills it in from `get_manga_update` before a chapter can be opened anyway Manga { - cover: cover(value.thumbnail, value.image.as_deref()), - title: value.name.trim().into(), - authors: authors(value.author.as_deref()), url: Some(manga_url(&value.slug)), key: value.slug, - status: status(value.kind.as_deref()), - content_rating: content_rating(value.is_adult.as_deref()), + title: value.name, + cover: cover(value.thumbnail, value.image.as_deref()), ..Default::default() } } @@ -93,12 +121,14 @@ pub struct CataloguePage { pub list: Vec, } -#[derive(Deserialize)] +#[derive(Clone, Deserialize)] pub struct CatalogueEntry { pub name: String, pub slug: String, pub alt_names: Option, pub author: Option, + #[serde(default)] + pub genres: Vec, // the cover file name, called "image" everywhere else pub img: Option, #[serde(rename = "type")] @@ -120,7 +150,6 @@ impl CatalogueEntry { .any(|field| contains_ignore_ascii_case(field, needle)) } - // for the search field "supportsAuthorSearch" enables pub fn matches_author(&self, needle: &str) -> bool { self.author .as_deref() @@ -130,16 +159,11 @@ impl CatalogueEntry { impl From for Manga { fn from(value: CatalogueEntry) -> Self { - // the dump gives genres as bare ids, which would need the genre index to resolve, so the - // reader is left to the details request like it is for the listings Manga { - cover: cover(None, value.img.as_deref()), - title: value.name.trim().into(), - authors: authors(value.author.as_deref()), url: Some(manga_url(&value.slug)), key: value.slug, - status: status(value.kind.as_deref()), - content_rating: content_rating(value.is_adult.as_deref()), + title: value.name, + cover: cover(None, value.img.as_deref()), ..Default::default() } } @@ -198,7 +222,12 @@ impl MangaDetails { .as_ref() .and_then(|data| data.text.as_deref()) .map(strip_html) - .filter(|text| !text.is_empty()) + .filter(|text| { + !text.is_empty() + && !text.starts_with("") + && !text.ends_with("RAW FREE") + && !text.ends_with("raw FREE") + }) else { continue; }; @@ -265,8 +294,6 @@ impl ChapterEntry { .published_at .and_then(|date| parse_date_with_options(date, DATE_FORMAT, "en_US_POSIX", "UTC")), url: Some(chapter_url(manga_slug, &self.path)), - // `language` is deliberately left unset: the source is japanese only, so tagging - // chapters would only expose them to the app's chapter language filter for no benefit ..Default::default() } } @@ -321,6 +348,7 @@ impl Number { // "/genres.json" #[derive(Deserialize)] pub struct GenreEntry { + pub id: i64, pub name: String, pub slug: String, } diff --git a/sources/ja.soraraw/src/test.rs b/sources/ja.soraraw/src/test.rs index 643483502..947433288 100644 --- a/sources/ja.soraraw/src/test.rs +++ b/sources/ja.soraraw/src/test.rs @@ -63,7 +63,9 @@ fn page_slices(pages: &[Page]) -> Vec<(&String, &String, &String)> { #[aidoku_test] fn test_listings() { for id in ["newest", "rising", "trending", "lifetime"] { - let result = Soraraw.get_manga_list(listing(id), 1).expect("listing"); + let result = Soraraw::new() + .get_manga_list(listing(id), 1) + .expect("listing"); assert!(!result.entries.is_empty(), "{id} returned no entries"); let entry = &result.entries[0]; @@ -76,25 +78,18 @@ fn test_listings() { .is_some_and(|cover| cover.starts_with("http")), "{id} entry has no absolute cover" ); - // listings carry the adult flag, so this one doesn't have to wait for the details - // request; `viewer` does, since it is picked from genres the listings don't carry - assert_ne!( - entry.content_rating, - ContentRating::Unknown, - "{id} entry has no content rating" - ); } } // only the paginated listing walks pages; the rankings hand out a single batch #[aidoku_test] fn test_listing_pagination() { - let first = Soraraw + let first = Soraraw::new() .get_manga_list(listing("newest"), 1) .expect("page 1"); assert!(first.has_next_page); - let second = Soraraw + let second = Soraraw::new() .get_manga_list(listing("newest"), 2) .expect("page 2"); assert!(!second.entries.is_empty()); @@ -102,7 +97,7 @@ fn test_listing_pagination() { // the rankings were read off the home page before, which embeds ten of them at most for id in ["rising", "trending", "lifetime"] { - let result = Soraraw.get_manga_list(listing(id), 1).expect(id); + let result = Soraraw::new().get_manga_list(listing(id), 1).expect(id); assert!(!result.has_next_page); assert!( result.entries.len() > 100, @@ -117,7 +112,7 @@ fn test_listing_pagination() { #[aidoku_test] fn test_search() { for query in ["小林さんちのメイドラゴン", "miss kobayashi"] { - let result = Soraraw + let result = Soraraw::new() .get_search_manga_list(Some(String::from(query)), 1, Vec::new()) .expect("search"); let entry = result @@ -127,7 +122,6 @@ fn test_search() { .unwrap_or_else(|| panic!("{query} matched {:?}", titles(&result.entries))); assert_eq!(entry.title, "小林さんちのメイドラゴン"); - assert_eq!(entry.content_rating, ContentRating::NSFW); // catalogue entries name their cover field differently, so it is easy to lose assert!( entry @@ -149,7 +143,7 @@ fn test_search_by_author() { id: String::from("author"), value: String::from("伊藤京介"), }]; - let result = Soraraw + let result = Soraraw::new() .get_search_manga_list(None, 1, filters) .expect("author search"); @@ -186,11 +180,12 @@ fn test_matches_author() { #[aidoku_test] fn test_genre_filter() { + let source = Soraraw::new(); let filters = vec![FilterValue::Select { id: String::from("genre"), value: String::from("akushon"), }]; - let result = Soraraw + let result = source .get_search_manga_list(None, 1, filters) .expect("filtered list"); assert!(!result.entries.is_empty()); @@ -201,7 +196,7 @@ fn test_genre_filter() { id: String::from("genre"), value: String::new(), }]; - let result = Soraraw + let result = source .get_search_manga_list(None, 1, cleared) .expect("cleared filter"); assert!(!result.entries.is_empty()); @@ -209,11 +204,13 @@ fn test_genre_filter() { #[aidoku_test] fn test_dynamic_filters() { - let filters = Soraraw.get_dynamic_filters().expect("dynamic filters"); + let filters = Soraraw::new() + .get_dynamic_filters() + .expect("dynamic filters"); assert_eq!(filters.len(), 1); - let FilterKind::Select { options, ids, .. } = &filters[0].kind else { - panic!("expected a select filter"); + let FilterKind::MultiSelect { options, ids, .. } = &filters[0].kind else { + panic!("expected a multi-select filter"); }; // the site listed over 1800 genres at the time of writing, so the cap is what decides the // count; the lower bound only guards against the list coming back empty or broken @@ -221,9 +218,7 @@ fn test_dynamic_filters() { assert!(options.len() <= GENRE_LIMIT + 1, "the cap is not applied"); let ids = ids.as_ref().expect("genre ids"); assert_eq!(ids.len(), options.len()); - // the first option clears the filter, every other one has to name a genre - assert!(ids[0].is_empty()); - assert!(ids[1..].iter().all(|id| !id.is_empty())); + assert!(ids.iter().all(|id| !id.is_empty())); } #[aidoku_test] @@ -232,7 +227,7 @@ fn test_manga_details() { key: String::from(MANGA_KEY), ..Default::default() }; - let manga = Soraraw + let manga = Soraraw::new() .get_manga_update(manga, true, true) .expect("manga details"); @@ -282,7 +277,7 @@ fn test_null_genres() { key: String::from(NULL_GENRE_KEY), ..Default::default() }; - let manga = Soraraw + let manga = Soraraw::new() .get_manga_update(manga, true, true) .expect("manga details"); @@ -308,7 +303,7 @@ fn test_viewer_and_content_rating() { key: String::from(key), ..Default::default() }; - let manga = Soraraw + let manga = Soraraw::new() .get_manga_update(manga, true, false) .expect("details of a vertical entry"); @@ -321,11 +316,12 @@ fn test_viewer_and_content_rating() { #[aidoku_test] fn test_page_list() { + let source = Soraraw::new(); let manga = Manga { key: String::from(MANGA_KEY), ..Default::default() }; - let mut manga = Soraraw + let mut manga = source .get_manga_update(manga, false, true) .expect("chapters"); // taken rather than cloned; the page request doesn't read the chapter list back @@ -337,7 +333,7 @@ fn test_page_list() { .find(|chapter| chapter.chapter_number == Some(1.0)) .expect("chapter 1"); - let pages = Soraraw.get_page_list(manga, chapter).expect("page list"); + let pages = source.get_page_list(manga, chapter).expect("page list"); // chapter 1 of this series holds 72 pages, and can only ever gain them assert!(pages.len() >= 72, "got {} pages", pages.len()); @@ -371,11 +367,12 @@ fn test_page_list() { // also proves the decrypted path resolved and its header read back #[aidoku_test] fn test_stacked_chapter_is_split() { + let source = Soraraw::new(); let manga = Manga { key: String::from(PAGED_VERTICAL_KEY), ..Default::default() }; - let mut manga = Soraraw + let mut manga = source .get_manga_update(manga, false, true) .expect("chapters"); let chapter = manga @@ -386,7 +383,7 @@ fn test_stacked_chapter_is_split() { .find(|chapter| chapter.chapter_number == Some(70.0)) .expect("chapter 70"); - let pages = Soraraw.get_page_list(manga, chapter).expect("pages"); + let pages = source.get_page_list(manga, chapter).expect("pages"); // 49152 / (1450 × √2) lands on the 24 pages the rest of the series holds one image each let slices = page_slices(&pages); assert_eq!(slices.len(), 24, "got {} pages", slices.len()); @@ -402,11 +399,12 @@ fn test_stacked_chapter_is_split() { // from the chapter list rather than built by hand, since its url carries the key to the paths #[aidoku_test] fn test_stacked_chapter_of_another_series_is_split() { + let source = Soraraw::new(); let manga = Manga { key: String::from(JPG_MANGA_KEY), ..Default::default() }; - let mut manga = Soraraw + let mut manga = source .get_manga_update(manga, false, true) .expect("chapters"); let chapter = manga @@ -417,7 +415,7 @@ fn test_stacked_chapter_of_another_series_is_split() { .find(|chapter| chapter.key == JPG_CHAPTER_KEY) .expect("the jpg chapter"); - let pages = Soraraw.get_page_list(manga, chapter).expect("pages"); + let pages = source.get_page_list(manga, chapter).expect("pages"); let slices = page_slices(&pages); assert_eq!(slices.len(), 21, "got {} pages", slices.len()); } @@ -426,11 +424,12 @@ fn test_stacked_chapter_of_another_series_is_split() { // single page for as long as only the jpeg header was read #[aidoku_test] fn test_stacked_webp_chapter_is_split() { + let source = Soraraw::new(); let manga = Manga { key: String::from(WEBP_MANGA_KEY), ..Default::default() }; - let mut manga = Soraraw + let mut manga = source .get_manga_update(manga, false, true) .expect("chapters"); let chapter = manga @@ -441,7 +440,7 @@ fn test_stacked_webp_chapter_is_split() { .find(|chapter| chapter.chapter_number == Some(120.0)) .expect("chapter 120"); - let pages = Soraraw.get_page_list(manga, chapter).expect("pages"); + let pages = source.get_page_list(manga, chapter).expect("pages"); let slices = page_slices(&pages); assert_eq!(slices.len(), 10, "got {} pages", slices.len()); for (url, _, _) in &slices { @@ -453,11 +452,12 @@ fn test_stacked_webp_chapter_is_split() { // standing its width times √2 divides into one page rather than into slices #[aidoku_test] fn test_short_ordinary_chapter_is_not_split() { + let source = Soraraw::new(); let manga = Manga { key: String::from(SHORT_CHAPTER_KEY), ..Default::default() }; - let mut manga = Soraraw + let mut manga = source .get_manga_update(manga, false, true) .expect("chapters"); let chapter = manga @@ -468,7 +468,7 @@ fn test_short_ordinary_chapter_is_not_split() { .find(|chapter| chapter.chapter_number == Some(206.0)) .expect("chapter 206"); - let pages = Soraraw.get_page_list(manga, chapter).expect("pages"); + let pages = source.get_page_list(manga, chapter).expect("pages"); // only a chapter this short is measured at all, so the bound is what makes the rest mean anything assert!( pages.len() <= STRIP_IMAGE_LIMIT, @@ -486,11 +486,12 @@ fn test_short_ordinary_chapter_is_not_split() { // a chapter numbered "74.2" has to survive the round trip into a page request #[aidoku_test] fn test_decimal_chapter_pages() { + let source = Soraraw::new(); let manga = Manga { key: String::from(MANGA_KEY), ..Default::default() }; - let mut manga = Soraraw + let mut manga = source .get_manga_update(manga, false, true) .expect("chapters"); let chapter = manga @@ -505,7 +506,7 @@ fn test_decimal_chapter_pages() { }) .expect("a decimal chapter"); - let pages = Soraraw + let pages = source .get_page_list(manga, chapter) .expect("decimal chapter pages"); assert!(!pages.is_empty()); @@ -514,11 +515,12 @@ fn test_decimal_chapter_pages() { // the restore itself can't run here: the test runner has no `copy_image` #[aidoku_test] fn test_scrambled_chapter_pages() { + let source = Soraraw::new(); let manga = Manga { key: String::from(MANGA_KEY), ..Default::default() }; - let mut manga = Soraraw + let mut manga = source .get_manga_update(manga, false, true) .expect("chapters"); let chapter = manga @@ -529,7 +531,7 @@ fn test_scrambled_chapter_pages() { .find(|chapter| chapter.key == SCRAMBLED_CHAPTER_KEY) .expect("chapter 77.1"); - let pages = Soraraw.get_page_list(manga, chapter).expect("pages"); + let pages = source.get_page_list(manga, chapter).expect("pages"); assert!(!pages.is_empty()); for page in &pages { let PageContent::Url(url, Some(context)) = &page.content else { @@ -545,7 +547,8 @@ fn test_scrambled_chapter_pages() { #[aidoku_test] fn test_deep_link() { - let manga = Soraraw + let source = Soraraw::new(); + let manga = source .handle_deep_link(String::from( "https://soraraw.com/manga/majo-to-youhei-57539", )) @@ -558,7 +561,7 @@ fn test_deep_link() { ); // shared links carry tracking parameters the key must not pick up - let shared = Soraraw + let shared = source .handle_deep_link(String::from( "https://soraraw.com/manga/majo-to-youhei-57539?utm_source=share", )) @@ -570,7 +573,7 @@ fn test_deep_link() { }) ); - let chapter = Soraraw + let chapter = source .handle_deep_link(String::from( "https://soraraw.com/manga/majo-to-youhei-57539/ch-1", )) @@ -583,12 +586,12 @@ fn test_deep_link() { }) ); - let unknown = Soraraw + let unknown = source .handle_deep_link(String::from("https://soraraw.com/newest")) .expect("unknown deep link"); assert_eq!(unknown, None); - let foreign = Soraraw + let foreign = source .handle_deep_link(String::from( "https://example.com/manga/majo-to-youhei-57539", )) @@ -599,17 +602,18 @@ fn test_deep_link() { // malformed keys can reach the source from a stale library entry, and have to fail loudly #[aidoku_test] fn test_malformed_chapter_key() { + let source = Soraraw::new(); let chapter = Chapter { key: String::from("not-a-key"), ..Default::default() }; - assert!(Soraraw.get_page_list(Manga::default(), chapter).is_err()); + assert!(source.get_page_list(Manga::default(), chapter).is_err()); let chapter = Chapter { key: String::from("57539/not-a-number"), ..Default::default() }; - assert!(Soraraw.get_page_list(Manga::default(), chapter).is_err()); + assert!(source.get_page_list(Manga::default(), chapter).is_err()); } // the payload decoding is pure, so it can be checked without touching the network