diff --git a/sources/zh.noyacg/Cargo.lock b/sources/zh.noyacg/Cargo.lock index 94a12ec30..f333fb4d5 100644 --- a/sources/zh.noyacg/Cargo.lock +++ b/sources/zh.noyacg/Cargo.lock @@ -5,7 +5,7 @@ version = 4 [[package]] name = "aidoku" version = "0.3.0" -source = "git+https://github.com/Aidoku/aidoku-rs.git#b0818704a5487778999bcd11239fa4c7a44da6ab" +source = "git+https://github.com/Aidoku/aidoku-rs.git#ae6485e3c14ecd1fd5497d170eae884c68d04a87" dependencies = [ "euclid", "hashbrown", @@ -22,7 +22,7 @@ dependencies = [ [[package]] name = "aidoku-test" version = "1.0.0" -source = "git+https://github.com/Aidoku/aidoku-rs.git#b0818704a5487778999bcd11239fa4c7a44da6ab" +source = "git+https://github.com/Aidoku/aidoku-rs.git#ae6485e3c14ecd1fd5497d170eae884c68d04a87" dependencies = [ "quote", "syn", diff --git a/sources/zh.noyacg/res/filters.json b/sources/zh.noyacg/res/filters.json index 4f86e8a29..ae6d8e9a9 100644 --- a/sources/zh.noyacg/res/filters.json +++ b/sources/zh.noyacg/res/filters.json @@ -4,16 +4,14 @@ "id": "sort", "title": "排序", "options": ["最新內容", "最近上載", "最多瀏覽", "最多收藏", "最高評分"], - "ids": ["new", "upload", "views", "favorites", "rating"], - "default": "new" + "ids": ["new", "upload", "views", "favorites", "rating"] }, { "type": "select", "id": "leaderboard", "title": "榜單", "options": ["不使用", "閱讀日榜", "閱讀週榜", "閱讀月榜", "收藏日榜", "收藏週榜", "收藏月榜"], - "ids": ["", "read:day", "read:week", "read:moon", "fav:day", "fav:week", "fav:moon"], - "default": "" + "ids": ["", "read:day", "read:week", "read:moon", "fav:day", "fav:week", "fav:moon"] }, { "type": "multi-select", diff --git a/sources/zh.noyacg/res/settings.json b/sources/zh.noyacg/res/settings.json index d89d133e9..9e8f2b531 100644 --- a/sources/zh.noyacg/res/settings.json +++ b/sources/zh.noyacg/res/settings.json @@ -49,7 +49,6 @@ "title": "分流伺服器", "values": ["noymanga.com", "noyteam.online", "noy.asia"], "titles": ["伺服器 1", "伺服器 2", "伺服器 3"], - "default": "noy.asia", "refreshes": ["content"] } ] diff --git a/sources/zh.noyacg/res/source.json b/sources/zh.noyacg/res/source.json index 87332bbea..a99e33471 100644 --- a/sources/zh.noyacg/res/source.json +++ b/sources/zh.noyacg/res/source.json @@ -2,12 +2,12 @@ "info": { "id": "zh.noyacg", "name": "NoyAcg", - "version": 2, + "version": 3, "languages": [ "zh" ], "contentRating": 1, - "url": "https://beta.noyteam.online" + "url": "https://noymanga.com" }, "listings": [ { @@ -19,8 +19,8 @@ "name": "完結" }, { - "id": "proportion", - "name": "精選" + "id": "random", + "name": "隨機" }, { "id": "favorite", @@ -28,7 +28,7 @@ } ], "config": { - "hidesFiltersWhileSearching": false, + "hidesFiltersWhileSearching": true, "supportsTagSearch": true, "supportsAuthorSearch": true } diff --git a/sources/zh.noyacg/src/auth.rs b/sources/zh.noyacg/src/auth.rs index d63da1b91..c471e0ac0 100644 --- a/sources/zh.noyacg/src/auth.rs +++ b/sources/zh.noyacg/src/auth.rs @@ -1,77 +1,53 @@ use aidoku::{ Result, - alloc::String, + alloc::{String, string::ToString}, + helpers::uri::QueryParameters, imports::defaults::{DefaultValue, defaults_get, defaults_set}, - imports::net::Request, imports::std::current_date, prelude::*, }; -use crate::helpers::{build_form_body, get_base_url}; +use crate::helpers::{api_post, fetch_json, get_adult_mode}; use crate::models::{LoginResp, SigninRecordResp}; -const CREDENTIALS_KEY: &str = "credentials"; -const JUST_LOGGED_IN_KEY: &str = "justLoggedIn"; +const USERNAME_KEY: &str = "login.username"; +const PASSWORD_KEY: &str = "login.password"; const LAST_SIGNIN_DAY_KEY: &str = "lastSigninDay"; const UTC8_OFFSET: i64 = 28800; const SECS_PER_DAY: i64 = 86400; -pub fn store_credentials(username: &str, password: &str) { - let value = format!("{}\n{}", username, password); - defaults_set(CREDENTIALS_KEY, DefaultValue::String(value)); -} - -pub fn get_credentials() -> Option<(String, String)> { - let raw = defaults_get::(CREDENTIALS_KEY).filter(|v| !v.is_empty())?; - let (user, pass) = raw.split_once('\n')?; - Some((user.into(), pass.into())) -} - -pub fn clear_credentials() { - defaults_set(CREDENTIALS_KEY, DefaultValue::Null); - defaults_set(JUST_LOGGED_IN_KEY, DefaultValue::Null); -} - pub fn is_logged_in() -> bool { - defaults_get::(CREDENTIALS_KEY) - .filter(|v| !v.is_empty()) - .is_some() -} - -pub fn set_just_logged_in() { - defaults_set(JUST_LOGGED_IN_KEY, DefaultValue::Bool(true)); + defaults_get::(USERNAME_KEY).is_some() } -pub fn is_just_logged_in() -> bool { - defaults_get::(JUST_LOGGED_IN_KEY).unwrap_or(false) -} - -pub fn clear_just_logged_in() { - defaults_set(JUST_LOGGED_IN_KEY, DefaultValue::Null); -} - -pub fn do_login(username: &str, password: &str) -> Result { - let base_url = get_base_url(); - let body = build_form_body(&[("user", username), ("pass", password)]); - let resp: LoginResp = Request::post(format!("{base_url}/api/login"))? - .header("Content-Type", "application/x-www-form-urlencoded") - .header("Referer", &format!("{base_url}/")) - .body(body.as_bytes()) - .json_owned()?; +pub fn login(username: &str, password: &str) -> Result { + let mut body = QueryParameters::new(); + body.push("user", Some(username)); + body.push("pass", Some(password)); + let resp: LoginResp = + api_post("/api/login", &body.to_string(), get_adult_mode())?.json_owned()?; Ok(resp.status.as_deref() == Some("ok")) } -pub fn ensure_session() -> Result<()> { - let Some((username, password)) = get_credentials() else { - return Ok(()); +pub fn relogin() -> Result<()> { + let (Some(username), Some(password)) = ( + defaults_get::(USERNAME_KEY), + defaults_get::(PASSWORD_KEY), + ) else { + bail!("請先登入以檢視內容"); }; - if !do_login(&username, &password)? { - clear_credentials(); + if !login(&username, &password)? { bail!("登入已過期,請重新登入"); } Ok(()) } +pub fn logout() { + if let Ok(request) = api_post("/api/logout", "", get_adult_mode()) { + _ = request.send(); + } +} + fn today_utc8() -> String { let day = (current_date() + UTC8_OFFSET) / SECS_PER_DAY; format!("{day}") @@ -88,16 +64,9 @@ pub fn try_daily_signin() { if defaults_get::(LAST_SIGNIN_DAY_KEY).as_deref() == Some(today.as_str()) { return; } - let base_url = get_base_url(); - let referer = format!("{base_url}/"); - // check server record first to avoid a redundant sign request - let Ok(req) = Request::post(format!("{base_url}/api/v4/signin/record")) else { - return; - }; - let Ok(record) = req - .header("Content-Type", "application/x-www-form-urlencoded") - .header("Referer", &referer) - .json_owned::() + let adult = get_adult_mode(); + let Ok(record) = + fetch_json::(|| api_post("/api/v4/signin/record", "", adult)) else { return; }; @@ -105,15 +74,10 @@ pub fn try_daily_signin() { defaults_set(LAST_SIGNIN_DAY_KEY, DefaultValue::String(today)); return; } - let Ok(req) = Request::post(format!("{base_url}/api/v4/signin/sign")) else { + let Ok(request) = api_post("/api/v4/signin/sign", "", adult) else { return; }; - if req - .header("Content-Type", "application/x-www-form-urlencoded") - .header("Referer", &referer) - .send() - .is_ok() - { + if request.send().is_ok() { defaults_set(LAST_SIGNIN_DAY_KEY, DefaultValue::String(today)); } } diff --git a/sources/zh.noyacg/src/filters.rs b/sources/zh.noyacg/src/filters.rs index 022ef6b26..ea34e96aa 100644 --- a/sources/zh.noyacg/src/filters.rs +++ b/sources/zh.noyacg/src/filters.rs @@ -1,11 +1,10 @@ use aidoku::{ Filter, MultiSelectFilter, alloc::{String, Vec}, - prelude::format, }; use serde::Deserialize; -use crate::helpers::{build_form_body, get_base_url, post_with_form}; +use crate::helpers::{api_post, fetch_json}; #[derive(Deserialize)] struct BigTagResp { @@ -47,14 +46,6 @@ pub fn build_tag_filter(adult_mode: &str) -> Filter { } fn fetch_bigtaglist(adult_mode: &str) -> aidoku::Result> { - let base_url = get_base_url(); - let body = build_form_body(&[]); - let resp: BigTagResp = post_with_form( - &format!("{base_url}/api/bigtaglist"), - &body, - &format!("{base_url}/"), - adult_mode, - )? - .json_owned()?; + let resp: BigTagResp = fetch_json(|| api_post("/api/bigtaglist", "", adult_mode))?; Ok(resp.data.unwrap_or_default()) } diff --git a/sources/zh.noyacg/src/helpers.rs b/sources/zh.noyacg/src/helpers.rs index d5a4a4552..d43bfdd9f 100644 --- a/sources/zh.noyacg/src/helpers.rs +++ b/sources/zh.noyacg/src/helpers.rs @@ -1,11 +1,14 @@ use aidoku::{ Result, alloc::{String, Vec, string::ToString}, - helpers::uri::encode_uri_component, imports::{defaults::defaults_get, net::Request}, prelude::format, }; -use core::fmt::Write; +use serde::de::DeserializeOwned; + +use crate::{USER_AGENT, auth}; + +const LOGIN_REQUIRED: &[u8] = br#"{"status":"login"}"#; pub fn format_names(raw: &str) -> Option { if raw.is_empty() { @@ -48,17 +51,62 @@ pub fn split_tags(raw: &str) -> Vec { .collect() } -pub fn get_adult_mode() -> String { - let selected = defaults_get::>("adult_mode").unwrap_or_default(); - let has_sfw = selected.iter().any(|s| s == "false"); - let has_nsfw = selected.iter().any(|s| s == "true"); - match (has_sfw, has_nsfw) { - (true, true) => "both".to_string(), - (false, true) => "true".to_string(), - _ => "false".to_string(), +pub fn parse_chapter_name(name: String) -> (Option, Option, Option) { + let (machine, rest) = match name.strip_prefix("機翻") { + Some(rest) => (true, rest), + None => (false, name.as_str()), + }; + let rest = rest.strip_prefix('第').unwrap_or(rest); + let digits = rest + .find(|c: char| !c.is_ascii_digit() && c != '.') + .unwrap_or(rest.len()); + let (number, rest) = rest.split_at(digits); + let Ok(number) = number.parse::() else { + return (None, None, Some(name)); + }; + let (is_range, rest) = match rest.strip_prefix('-') { + Some(rest) => (true, rest.trim_start_matches(|c: char| c.is_ascii_digit())), + None => (false, rest), + }; + let mut chars = rest.chars(); + let is_volume = match chars.next() { + Some('話' | '话' | '回') => false, + Some('卷' | '冊') => true, + None if name.starts_with('第') => false, + _ => return (None, None, Some(name)), + }; + let (chapter, volume) = if is_volume { + (None, Some(number)) + } else { + (Some(number), None) + }; + // ranges keep the full name, since the first number alone hides the rest + if is_range { + return (chapter, volume, Some(name)); + } + let suffix = chars.as_str().trim(); + let title = match (machine, suffix.is_empty()) { + (false, true) => None, + (false, false) => Some(suffix.into()), + (true, true) => Some("機翻".into()), + (true, false) => Some(format!("機翻 {suffix}")), + }; + (chapter, volume, title) +} + +pub fn adult_mode(selected: &[String]) -> &'static str { + let has = |value: &str| selected.iter().any(|s| s == value); + match (has("false"), has("true")) { + (true, true) => "both", + (false, true) => "true", + _ => "false", } } +pub fn get_adult_mode() -> &'static str { + adult_mode(&defaults_get::>("adult_mode").unwrap_or_default()) +} + pub fn extract_manga_id(url: &str) -> Option { let marker = "/manga/"; let (_, tail) = url.split_once(marker)?; @@ -76,13 +124,13 @@ pub fn extract_reader_path(url: &str) -> Option { Some(path.into()) } -pub fn get_server_domain() -> String { +fn get_server_domain() -> String { defaults_get::("server") .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "noy.asia".to_string()) + .unwrap_or_else(|| "noymanga.com".to_string()) } -pub fn get_base_url() -> String { +fn get_base_url() -> String { format!("https://api.{}", get_server_domain()) } @@ -90,24 +138,25 @@ pub fn get_img_base() -> String { format!("https://img.{}", get_server_domain()) } -pub fn build_form_body(params: &[(&str, &str)]) -> String { - let mut out = String::new(); - for (k, v) in params { - if v.is_empty() { - continue; - } - if !out.is_empty() { - out.push('&'); - } - let _ = write!(out, "{k}={}", encode_uri_component(v)); - } - out +pub fn api_get(path: &str, adult: &str) -> Result { + Ok(Request::get(format!("{}{path}", get_base_url()))? + .header("User-Agent", USER_AGENT) + .header("allow-adult", adult)) } -pub fn post_with_form(url: &str, body: &str, referer: &str, adult: &str) -> Result { - Ok(Request::post(url)? +pub fn api_post(path: &str, body: &str, adult: &str) -> Result { + Ok(Request::post(format!("{}{path}", get_base_url()))? + .header("User-Agent", USER_AGENT) .header("Content-Type", "application/x-www-form-urlencoded") - .header("Referer", referer) .header("allow-adult", adult) - .body(body.as_bytes())) + .body(body)) +} + +pub fn fetch_json(request: impl Fn() -> Result) -> Result { + let mut data = request()?.data()?; + if data == LOGIN_REQUIRED { + auth::relogin()?; + data = request()?.data()?; + } + Ok(serde_json::from_slice(&data)?) } diff --git a/sources/zh.noyacg/src/home.rs b/sources/zh.noyacg/src/home.rs index d528957fa..00a56d1c3 100644 --- a/sources/zh.noyacg/src/home.rs +++ b/sources/zh.noyacg/src/home.rs @@ -1,220 +1,55 @@ use aidoku::{ - Home, HomeComponent, HomeComponentValue, HomeLayout, Link, Listing, ListingKind, Manga, - MangaWithChapter, Result, - alloc::Vec, - imports::net::{Request, RequestError, Response}, - prelude::*, + Home, HomeComponent, HomeComponentValue, HomeLayout, Listing, Result, alloc::Vec, prelude::*, }; -use crate::helpers::{build_form_body, get_adult_mode, get_base_url, post_with_form}; -use crate::models::ListingResp; +use crate::helpers::{api_post, fetch_json, get_adult_mode, get_img_base}; +use crate::models::HomeResp; use crate::{NoyAcg, auth}; impl Home for NoyAcg { fn get_home(&self) -> Result { - auth::ensure_session()?; auth::try_daily_signin(); - if !auth::is_logged_in() { - bail!("請先登入以檢視內容"); - } - let adult = get_adult_mode(); - let base_url = get_base_url(); - let referer = format!("{base_url}/"); - - let views_body = build_form_body(&[("page", "1"), ("sort", "views")]); - let new_body = build_form_body(&[("page", "1"), ("sort", "new")]); - let read_day_body = build_form_body(&[("type", "day"), ("page", "1")]); - let read_week_body = build_form_body(&[("type", "week"), ("page", "1")]); - let read_month_body = build_form_body(&[("type", "moon"), ("page", "1")]); - - let responses: Vec> = Request::send_all([ - post_with_form( - &format!("{base_url}/api/b1/booklist"), - &views_body, - &referer, - &adult, - )?, - post_with_form( - &format!("{base_url}/api/b1/booklist"), - &new_body, - &referer, - &adult, - )?, - post_with_form( - &format!("{base_url}/api/readLeaderboard"), - &read_day_body, - &referer, - &adult, - )?, - post_with_form( - &format!("{base_url}/api/readLeaderboard"), - &read_week_body, - &referer, - &adult, - )?, - post_with_form( - &format!("{base_url}/api/readLeaderboard"), - &read_month_body, - &referer, - &adult, - )?, - Request::post(format!("{base_url}/api/v4/book/random"))? - .header("Referer", &referer) - .header("allow-adult", &adult), - ]); - - let mut iter = responses.into_iter(); - let views_resp = iter.next(); - let new_resp = iter.next(); - let read_day_resp = iter.next(); - let read_week_resp = iter.next(); - let read_month_resp = iter.next(); - let random_resp = iter.next(); + let home: HomeResp = + fetch_json(|| api_post("/api/home", "v=3&stream_all=1", get_adult_mode()))?; - let mut components: Vec = Vec::new(); - - if let Some(manga) = views_resp.and_then(parse_listing_full) { - let entries: Vec = manga.into_iter().take(10).collect(); - if !entries.is_empty() { - components.push(HomeComponent { - title: Some("熱門推薦".into()), - subtitle: None, - value: HomeComponentValue::BigScroller { - entries, - auto_scroll_interval: Some(8.0), - }, - }); - } + let img_base = get_img_base(); + let mut components = Vec::new(); + if !home.tags.is_empty() { + components.push(HomeComponent { + title: Some("標籤推薦".into()), + value: HomeComponentValue::Filters(home.tags.into_iter().map(Into::into).collect()), + ..Default::default() + }); } - - if let Some(entries) = new_resp.and_then(parse_listing_chapter) { + for (title, entries, listing_id) in [ + ("今日閱讀榜", home.read_day, Some("read:day")), + ("今日收藏榜", home.fav_day, Some("fav:day")), + ("高質榜", home.proportion, Some("proportion")), + ("收藏推薦", home.fs, None), + ] { + if entries.is_empty() { + continue; + } components.push(HomeComponent { - title: Some("最新內容".into()), - subtitle: None, - value: HomeComponentValue::MangaChapterList { - page_size: Some(4), - entries, - listing: Some(Listing { - id: "latest".into(), - name: "更新".into(), - kind: ListingKind::Default, + title: Some(title.into()), + value: HomeComponentValue::Scroller { + entries: entries + .into_iter() + .map(|m| m.into_basic_manga(&img_base).into()) + .collect(), + listing: listing_id.map(|id| Listing { + id: id.into(), + name: title.into(), + ..Default::default() }), }, + ..Default::default() }); } - if let Some(manga) = random_resp.and_then(parse_listing_random) { - let entries: Vec = manga.into_iter().map(Into::into).collect(); - if !entries.is_empty() { - components.push(HomeComponent { - title: Some("隨機推薦".into()), - subtitle: None, - value: HomeComponentValue::Scroller { - entries, - listing: None, - }, - }); - } - } - - if let Some(manga) = read_day_resp.and_then(parse_listing_basic) { - let entries: Vec = manga.into_iter().map(Into::into).collect(); - if !entries.is_empty() { - components.push(HomeComponent { - title: Some("日閱讀榜".into()), - subtitle: None, - value: HomeComponentValue::Scroller { - entries, - listing: Some(Listing { - id: "leaderboard:day".into(), - name: "日閱讀榜".into(), - kind: ListingKind::Default, - }), - }, - }); - } - } - - if let Some(manga) = read_week_resp.and_then(parse_listing_basic) { - let entries: Vec = manga.into_iter().map(Into::into).collect(); - if !entries.is_empty() { - components.push(HomeComponent { - title: Some("週閱讀榜".into()), - subtitle: None, - value: HomeComponentValue::Scroller { - entries, - listing: Some(Listing { - id: "leaderboard:week".into(), - name: "週閱讀榜".into(), - kind: ListingKind::Default, - }), - }, - }); - } - } - - if let Some(manga) = read_month_resp.and_then(parse_listing_basic) { - let entries: Vec = manga.into_iter().map(Into::into).collect(); - if !entries.is_empty() { - components.push(HomeComponent { - title: Some("月閱讀榜".into()), - subtitle: None, - value: HomeComponentValue::Scroller { - entries, - listing: Some(Listing { - id: "leaderboard:moon".into(), - name: "月閱讀榜".into(), - kind: ListingKind::Default, - }), - }, - }); - } - } - if components.is_empty() { bail!("無法取得資料,請嘗試切換分流伺服器"); } Ok(HomeLayout { components }) } } - -fn parse_listing_full(result: core::result::Result) -> Option> { - let resp: ListingResp = result.ok()?.get_json_owned().ok()?; - Some(resp.info?.into_iter().map(Into::into).collect()) -} - -fn parse_listing_basic(result: core::result::Result) -> Option> { - let resp: ListingResp = result.ok()?.get_json_owned().ok()?; - Some( - resp.info? - .into_iter() - .map(|m| m.into_basic_manga()) - .collect(), - ) -} - -fn parse_listing_chapter( - result: core::result::Result, -) -> Option> { - let resp: ListingResp = result.ok()?.get_json_owned().ok()?; - let entries = resp.into_manga_chapter_list(); - if entries.is_empty() { - None - } else { - Some(entries) - } -} - -fn parse_listing_random( - result: core::result::Result, -) -> Option> { - let resp: ListingResp = result.ok()?.get_json_owned().ok()?; - // random api returns items in "data" instead of "info" - Some( - resp.data - .or(resp.info)? - .into_iter() - .map(|m| m.into_basic_manga()) - .collect(), - ) -} diff --git a/sources/zh.noyacg/src/lib.rs b/sources/zh.noyacg/src/lib.rs index ed9f647e4..484560eae 100644 --- a/sources/zh.noyacg/src/lib.rs +++ b/sources/zh.noyacg/src/lib.rs @@ -4,9 +4,16 @@ use aidoku::{ FilterValue, ImageRequestProvider, Listing, ListingProvider, Manga, MangaPageResult, NotificationHandler, Page, PageContent, PageContext, Result, Source, alloc::{String, Vec, string::ToString, vec}, + helpers::uri::QueryParameters, imports::net::Request, prelude::*, }; +use helpers::*; +use models::*; + +const WEB_URL: &str = "https://noymanga.com"; +// the api and image servers reject requests without the official app's user agent +const USER_AGENT: &str = "NoyAcg/3.0"; mod auth; mod filters; @@ -14,10 +21,6 @@ mod helpers; mod home; mod models; -use auth::ensure_session; -use helpers::*; -use models::*; - struct NoyAcg; impl Source for NoyAcg { @@ -31,73 +34,61 @@ impl Source for NoyAcg { page: i32, filters: Vec, ) -> Result { - ensure_session()?; - let keyword = query.unwrap_or_default(); - let (sort, leaderboard, tag, finished, author_query, rating_override) = - Self::parse_filters(&filters); - let adult = rating_override.unwrap_or_else(get_adult_mode); - - if !author_query.is_empty() { - return finish_search( - self.do_search(author_query, "author", sort, &adult, page)?, - page, - ); - } - - if !tag.is_empty() { - return finish_search(self.do_search(&tag, "tag", sort, &adult, page)?, page); - } - - if !keyword.is_empty() { - if page <= 1 - && let Some(result) = self.try_id_lookup(&keyword, &adult)? + if let Some(query) = query { + let adult = get_adult_mode(); + if page == 1 + && let Some(result) = try_id_lookup(&query, adult)? { return Ok(result); } - let tag_resp = self.do_search(&keyword, "tag", sort, &adult, page)?; - if !tag_resp.entries.is_empty() { - return Ok(tag_resp); - } - return finish_search( - self.do_search(&keyword, "default", sort, &adult, page)?, - page, - ); + // keyword search ignores sort and finished, like the site's default mode + return do_search(&query, "default", None, None, adult, page); } - let base_url = get_base_url(); - let referer = format!("{base_url}/"); - let page_str = page.to_string(); - - if !leaderboard.is_empty() { - let (endpoint, lb_type) = if let Some(t) = leaderboard.strip_prefix("read:") { - ("readLeaderboard", t) - } else if let Some(t) = leaderboard.strip_prefix("fav:") { - ("favLeaderboard", t) - } else { - ("readLeaderboard", leaderboard) - }; - let body = build_form_body(&[("type", lb_type), ("page", &page_str)]); - return post_form_listing( - &format!("{base_url}/api/{endpoint}"), - &body, - &referer, - &adult, - page, - ); + let mut sort = None; + let mut finished = None; + let mut leaderboard = None; + let mut tag = None; + let mut author = None; + let mut adult = None; + for filter in filters { + match filter { + FilterValue::Text { id, value } if id == "author" && !value.is_empty() => { + author = Some(value); + } + FilterValue::Select { id, value } => match id.as_str() { + "sort" => sort = Some(value), + "leaderboard" if !value.is_empty() => leaderboard = Some(value), + "genre" => tag = Some(value), + _ => {} + }, + FilterValue::MultiSelect { id, included, .. } => match id.as_str() { + "tag" => tag = Some(included.join(" ")), + "finished" if included.len() == 1 => finished = included.into_iter().next(), + "rating" => adult = Some(adult_mode(&included)), + _ => {} + }, + _ => {} + } } + let adult = adult.unwrap_or_else(get_adult_mode); + let sort = sort.as_deref().unwrap_or("new"); + let finished = finished.as_deref(); - let body = build_form_body(&[("page", &page_str), ("sort", sort), ("finished", &finished)]); - let result = post_form_listing( - &format!("{base_url}/api/b1/booklist"), - &body, - &referer, - &adult, - page, - )?; - if page <= 1 && result.entries.is_empty() && !auth::is_logged_in() { - bail!("請先登入以檢視內容"); + if let Some(author) = author { + return do_search(&author, "author", Some(sort), finished, adult, page); + } + if let Some(tag) = tag { + return do_search(&tag, "tag", Some(sort), finished, adult, page); + } + if let Some(id) = leaderboard { + return get_listing(&id, adult, page); } - Ok(result) + let body = match finished { + Some(finished) => format!("page={page}&sort={sort}&finished={finished}"), + None => format!("page={page}&sort={sort}"), + }; + fetch_listing("/api/b1/booklist", &body, adult, page) } fn get_manga_update( @@ -106,44 +97,31 @@ impl Source for NoyAcg { needs_details: bool, needs_chapters: bool, ) -> Result { - ensure_session()?; - let base_url = get_base_url(); - let mut resp: BookDetailResp = - Request::get(format!("{base_url}/api/v4/book/{}", manga.key))? - .header("Referer", &format!("{base_url}/")) - .header("allow-adult", &get_adult_mode()) - .json_owned()?; + let mut resp = fetch_detail(&manga.key, get_adult_mode())?; if needs_chapters { manga.chapters = Some(resp.take_chapters(&manga.key)); } if needs_details { - manga.copy_from(resp.into_manga(&manga.key)); + let details = resp + .into_manga(&manga.key) + .ok_or_else(|| error!("無法取得漫畫資料"))?; + manga.copy_from(details); } Ok(manga) } fn get_page_list(&self, _manga: Manga, chapter: Chapter) -> Result> { - ensure_session()?; let (manga_id, chapter_id) = match chapter.key.split_once('/') { Some((mid, cid)) => (mid, Some(cid)), None => (chapter.key.as_str(), None), }; - let base_url = get_base_url(); - let detail: BookDetailResp = Request::get(format!("{base_url}/api/v4/book/{manga_id}"))? - .header("Referer", &format!("{base_url}/")) - .header("allow-adult", &get_adult_mode()) - .json_owned()?; - - let count = match chapter_id { - Some(cid) => detail.find_chapter_page_count(cid).unwrap_or(0), - None => detail.page_count(), - }; - if count == 0 { - bail!("無法取得頁面資料"); - } + let count = fetch_detail(manga_id, get_adult_mode())? + .page_count(chapter_id) + .filter(|&count| count > 0) + .ok_or_else(|| error!("無法取得頁面資料"))?; let img_base = get_img_base(); Ok((1..=count) @@ -157,67 +135,7 @@ impl Source for NoyAcg { impl ListingProvider for NoyAcg { fn get_manga_list(&self, listing: Listing, page: i32) -> Result { - ensure_session()?; - let adult = get_adult_mode(); - let base_url = get_base_url(); - let referer = format!("{base_url}/"); - let page_str = page.to_string(); - - let (url, body) = match listing.id.as_str() { - "latest" => ( - format!("{base_url}/api/b1/booklist"), - build_form_body(&[("page", &page_str), ("sort", "new")]), - ), - "completed" => ( - format!("{base_url}/api/b1/booklist"), - build_form_body(&[("page", &page_str), ("sort", "new"), ("finished", "true")]), - ), - "proportion" => ( - format!("{base_url}/api/proportion"), - build_form_body(&[("page", &page_str)]), - ), - "favorite" => { - if !auth::is_logged_in() { - bail!("請先登入以使用收藏功能"); - } - let body = build_form_body(&[("page", &page_str)]); - let resp: FavoritesResp = post_with_form( - &format!("{base_url}/api/v4/favorites/get"), - &body, - &referer, - &adult, - )? - .json_owned()?; - let result = resp.into_page_result(page); - if page <= 1 && result.entries.is_empty() { - bail!("呢度乜都冇"); - } - return Ok(result); - } - id if id.starts_with("leaderboard:") => { - let lb_type = &id["leaderboard:".len()..]; - ( - format!("{base_url}/api/readLeaderboard"), - build_form_body(&[("type", lb_type), ("page", &page_str)]), - ) - } - id if id.starts_with("fav_leaderboard:") => { - let lb_type = &id["fav_leaderboard:".len()..]; - ( - format!("{base_url}/api/favLeaderboard"), - build_form_body(&[("type", lb_type), ("page", &page_str)]), - ) - } - "random" => { - let resp: ListingResp = Request::post(format!("{base_url}/api/v4/book/random"))? - .header("Referer", &referer) - .header("allow-adult", &adult) - .json_owned()?; - return Ok(resp.into_random_result()); - } - _ => bail!("未知的列表類型"), - }; - post_form_listing(&url, &body, &referer, &adult, page) + get_listing(&listing.id, get_adult_mode(), page) } } @@ -227,10 +145,14 @@ impl DeepLinkHandler for NoyAcg { return Ok(Some(DeepLinkResult::Manga { key })); } if let Some(path) = extract_reader_path(&url) { - if let Some((manga_key, _chapter_key)) = path.split_once('/') { + if let Some((manga_key, chapter_key)) = path.split_once('/') { return Ok(Some(DeepLinkResult::Chapter { manga_key: manga_key.into(), - key: path, + key: if chapter_key == "0" { + manga_key.into() + } else { + path + }, })); } return Ok(Some(DeepLinkResult::Manga { key: path })); @@ -241,189 +163,118 @@ impl DeepLinkHandler for NoyAcg { impl ImageRequestProvider for NoyAcg { fn get_image_request(&self, url: String, _context: Option) -> Result { - Ok(Request::get(url)?.header("Referer", &format!("{}/", get_base_url()))) + Ok(Request::get(url)?.header("User-Agent", USER_AGENT)) } } impl BasicLoginHandler for NoyAcg { - fn handle_basic_login(&self, key: String, username: String, password: String) -> Result { - if key != "login" { - bail!("登入入口無效"); - } - if username.is_empty() || password.is_empty() { - return Ok(false); - } - let ok = auth::do_login(&username, &password)?; - if ok { - auth::store_credentials(&username, &password); - auth::set_just_logged_in(); - } - Ok(ok) + fn handle_basic_login(&self, _key: String, username: String, password: String) -> Result { + auth::login(&username, &password) } } impl NotificationHandler for NoyAcg { fn handle_notification(&self, notification: String) { - if notification.as_str() == "login" { - if auth::is_just_logged_in() { - auth::clear_just_logged_in(); - } else { - auth::clear_credentials(); - } + if notification == "login" && !auth::is_logged_in() { + auth::logout(); } } } impl DynamicFilters for NoyAcg { fn get_dynamic_filters(&self) -> Result> { - let adult_mode = get_adult_mode(); - Ok(vec![filters::build_tag_filter(&adult_mode)]) + Ok(vec![filters::build_tag_filter(get_adult_mode())]) } } -impl NoyAcg { - fn parse_filters( - filters: &[FilterValue], - ) -> (&str, &str, String, String, &str, Option) { - let mut sort = "new"; - let mut leaderboard = ""; - let mut tag = String::new(); - let mut finished = String::new(); - let mut author_query = ""; - let mut rating_override: Option = None; - for filter in filters { - match filter { - FilterValue::Text { id, value } if id == "author" && !value.is_empty() => { - author_query = value; - } - FilterValue::Select { id, value } if !value.is_empty() => match id.as_str() { - "sort" => sort = value, - "leaderboard" => leaderboard = value, - "tag" | "genre" => tag = value.clone(), - _ => {} - }, - FilterValue::MultiSelect { id, included, .. } - if (id == "tag" || id == "genre") && !included.is_empty() => - { - tag = included.join(" "); - } - FilterValue::MultiSelect { id, included, .. } if id == "finished" => { - if included.len() == 1 { - finished = included[0].clone(); - } - } - FilterValue::MultiSelect { id, included, .. } if id == "rating" => { - if !included.is_empty() { - let has_sfw = included.iter().any(|s| s == "false"); - let has_nsfw = included.iter().any(|s| s == "true"); - rating_override = Some(match (has_sfw, has_nsfw) { - (true, true) => "both".into(), - (false, true) => "true".into(), - _ => "false".into(), - }); - } - } - _ => {} - } +fn get_listing(id: &str, adult: &str, page: i32) -> Result { + let (path, body) = match id { + "latest" => ("/api/b1/booklist", format!("page={page}&sort=new")), + "completed" => ( + "/api/b1/booklist", + format!("page={page}&sort=new&finished=true"), + ), + "proportion" => ("/api/proportion", format!("page={page}")), + "favorite" => ("/api/v4/favorites/get", format!("page={page}")), + "random" => ("/api/v4/book/random", String::new()), + id => { + let (path, period) = if let Some(period) = id.strip_prefix("read:") { + ("/api/readLeaderboard", period) + } else if let Some(period) = id.strip_prefix("fav:") { + ("/api/favLeaderboard", period) + } else { + bail!("未知的列表類型"); + }; + (path, format!("type={period}&page={page}")) } - ( - sort, - leaderboard, - tag, - finished, - author_query, - rating_override, - ) - } + }; + fetch_listing(path, &body, adult, page) +} - fn do_search( - &self, - value: &str, - mode: &str, - sort: &str, - adult: &str, - page: i32, - ) -> Result { - let search_sort = match sort { +fn do_search( + value: &str, + mode: &str, + sort: Option<&str>, + finished: Option<&str>, + adult: &str, + page: i32, +) -> Result { + let mut body = QueryParameters::new(); + body.push("value", Some(value)); + body.push("page", Some(&page.to_string())); + body.push("type", Some("book")); + body.push("mode", Some(mode)); + if let Some(sort) = sort { + let sort = match sort { "new" | "upload" => "time", - "views" => "read", other => other, }; - - let base_url = get_base_url(); - let body = build_form_body(&[ - ("value", value), - ("page", &page.to_string()), - ("type", "book"), - ("mode", mode), - ("sort", search_sort), - ]); - - let resp: SearchResp = post_with_form( - &format!("{base_url}/api/v4/search/fetch"), - &body, - &format!("{base_url}/"), - adult, - )? - .json_owned()?; - - Ok(resp.into_page_result(page)) + body.push("sort", Some(sort)); } - - fn try_id_lookup(&self, query: &str, adult: &str) -> Result> { - let trimmed = query.trim(); - let key: String = if trimmed.chars().all(|ch| ch.is_ascii_digit()) { - trimmed.into() - } else if let Some(id) = extract_manga_id(trimmed) { - id - } else { - return Ok(None); - }; - - let base_url = get_base_url(); - let resp: BookDetailResp = Request::get(format!("{base_url}/api/v4/book/{key}"))? - .header("Referer", &format!("{base_url}/")) - .header("allow-adult", adult) - .json_owned()?; - - let is_deleted = resp - .book - .as_ref() - .and_then(|b| b.info.as_ref()) - .is_some_and(|m| m.is_deleted()); - if is_deleted { - bail!("呢度乜都冇"); - } - - let manga = resp.into_manga(&key); - if manga.title.is_empty() { - return Ok(None); - } - Ok(Some(MangaPageResult { - has_next_page: false, - entries: vec![manga], - })) + if finished.is_some() { + body.push("finished", finished); } + let body = body.to_string(); + let resp: SearchResp = fetch_json(|| api_post("/api/v4/search/fetch", &body, adult))?; + Ok(resp.into_page_result(page)) } -fn finish_search(result: MangaPageResult, page: i32) -> Result { - if page <= 1 && result.entries.is_empty() { - bail!("呢度乜都冇"); - } - Ok(result) +// the official app opens `NID` queries as a book; links to a book work the same way +fn try_id_lookup(query: &str, adult: &str) -> Result> { + let nid = query + .split_at_checked(3) + .filter(|(prefix, id)| { + prefix.eq_ignore_ascii_case("nid") + && !id.is_empty() + && id.bytes().all(|b| b.is_ascii_digit()) + }) + .map(|(_, id)| id.into()); + let Some(key) = nid.or_else(|| extract_manga_id(query)) else { + return Ok(None); + }; + // unknown ids return the deleted placeholder book + let entries = fetch_detail(&key, adult)? + .book + .and_then(|b| b.info) + .filter(|m| !m.is_deleted()) + .map(|m| vec![m.into_basic_manga(&get_img_base())]) + .unwrap_or_default(); + Ok(Some(MangaPageResult { + entries, + ..Default::default() + })) } -fn post_form_listing( - url: &str, - body: &str, - referer: &str, - adult: &str, - page: i32, -) -> Result { - let resp: ListingResp = post_with_form(url, body, referer, adult)?.json_owned()?; +fn fetch_listing(path: &str, body: &str, adult: &str, page: i32) -> Result { + let resp: ListingResp = fetch_json(|| api_post(path, body, adult))?; Ok(resp.into_page_result(page)) } +fn fetch_detail(id: &str, adult: &str) -> Result { + let path = format!("/api/v4/book/{id}?comment=false"); + fetch_json(|| api_get(&path, adult)) +} + register_source!( NoyAcg, Home, diff --git a/sources/zh.noyacg/src/models.rs b/sources/zh.noyacg/src/models.rs index 7a69f2799..b3f3fab93 100644 --- a/sources/zh.noyacg/src/models.rs +++ b/sources/zh.noyacg/src/models.rs @@ -1,11 +1,12 @@ use aidoku::{ - Chapter, ContentRating, Manga, MangaPageResult, MangaStatus, MangaWithChapter, Viewer, - alloc::{String, Vec, string::ToString, vec}, + Chapter, ContentRating, Manga, MangaPageResult, MangaStatus, Viewer, + alloc::{String, Vec, collections::BTreeMap, string::ToString, vec}, prelude::format, }; use serde::Deserialize; -use crate::helpers::{format_names, get_base_url, get_img_base, split_tags}; +use crate::WEB_URL; +use crate::helpers::{format_names, get_img_base, parse_chapter_name, split_tags}; const PAGE_SIZE: i32 = 20; const DELETED_TITLE: &str = "已刪除的内容"; @@ -22,233 +23,125 @@ pub struct SigninRecordResp { #[derive(Deserialize)] pub struct ListingResp { - pub info: Option>, - pub data: Option>, - pub len: Option, + #[serde(alias = "info")] + data: Option>, + #[serde(alias = "len")] + count: Option, } impl ListingResp { pub fn into_page_result(self, page: i32) -> MangaPageResult { - let entries = self - .info - .map(|list| list.into_iter().map(|m| m.into_basic_manga()).collect()) - .unwrap_or_default(); - let total = self.len.unwrap_or(0); - MangaPageResult { - has_next_page: page * PAGE_SIZE < total, - entries, - } - } - - pub fn into_manga_chapter_list(self) -> Vec { - let Some(list) = self.info else { - return Vec::new(); - }; let img_base = get_img_base(); - list.into_iter() - .map(|m| { - let key = m.id.to_string(); - let cover = Some(format!("{img_base}/{key}/m1.webp")); - let status = m.manga_status(); - let content_rating = m.content_rating(); - // chapter name is only available via per-book detail, so use author name as subtitle instead - let author_name = format_names(&m.author); - MangaWithChapter { - manga: Manga { - title: m.name, - cover, - status, - content_rating, - key: key.clone(), - ..Default::default() - }, - chapter: Chapter { - key, - title: author_name, - date_uploaded: Some(m.time), - ..Default::default() - }, - } - }) - .collect() - } - - pub fn into_random_result(self) -> MangaPageResult { - let entries = self + let entries: Vec = self .data - .or(self.info) - .map(|list| list.into_iter().map(|m| m.into_basic_manga()).collect()) - .unwrap_or_default(); + .unwrap_or_default() + .into_iter() + .map(|m| m.into_basic_manga(&img_base)) + .collect(); + // `len` ignores some filters (e.g. `finished=false`), so also stop at an empty page MangaPageResult { - has_next_page: false, + has_next_page: !entries.is_empty() && page * PAGE_SIZE < self.count.unwrap_or(0), entries, } } } +#[derive(Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct HomeResp { + pub tags: Vec, + pub read_day: Vec, + pub fav_day: Vec, + pub proportion: Vec, + pub fs: Vec, +} + #[derive(Deserialize)] #[serde(rename_all = "PascalCase")] pub struct ListingManga { #[serde(rename = "Bid")] - pub id: i64, - pub mode: i32, + id: i64, + mode: i32, #[serde(rename = "Bookname")] - pub name: String, - pub description: String, - pub author: String, + name: String, + description: String, + author: String, ptag: String, otag: String, - pub time: i64, - pub len: i32, - pub status: i32, + time: i64, + len: i32, + status: i32, #[serde(default)] - pub adult: i32, + adult: i32, } impl ListingManga { - pub fn tags(&self) -> Vec { - let mut tags = split_tags(&self.ptag); - for tag in split_tags(&self.otag) { - if !tags.contains(&tag) { - tags.push(tag); - } - } - tags - } - - pub fn content_rating(&self) -> ContentRating { - if self.adult == 1 { - ContentRating::NSFW - } else { - ContentRating::Suggestive - } - } - - pub fn is_finished(&self) -> bool { - self.mode == 0 || self.status == 1 - } - - pub fn manga_status(&self) -> MangaStatus { - if self.is_finished() { - MangaStatus::Completed - } else { - MangaStatus::Ongoing - } - } - pub fn is_deleted(&self) -> bool { self.name == DELETED_TITLE } - pub fn into_basic_manga(self) -> Manga { + pub fn into_basic_manga(self, img_base: &str) -> Manga { let key = self.id.to_string(); - let img_base = get_img_base(); Manga { cover: Some(format!("{img_base}/{key}/m1.webp")), - status: self.manga_status(), - content_rating: self.content_rating(), + status: manga_status(self.mode, self.status), + content_rating: content_rating(self.adult), title: self.name, key, ..Default::default() } } -} -impl From for Manga { - fn from(m: ListingManga) -> Self { - let key = m.id.to_string(); - let img_base = get_img_base(); - let tags = m.tags(); - let status = m.manga_status(); - let content_rating = m.content_rating(); - let authors = format_names(&m.author).map(|a| vec![a]); - let description = if m.description.is_empty() { - None - } else { - Some(m.description) - }; - Manga { - cover: Some(format!("{img_base}/{key}/m1.webp")), - status, - content_rating, - title: m.name, - authors, - description, - tags: Some(tags), - key, - ..Default::default() - } - } -} - -#[derive(Deserialize)] -pub struct FavoritesResp { - pub data: Option>, - pub count: Option, -} - -impl FavoritesResp { - pub fn into_page_result(self, page: i32) -> MangaPageResult { - let entries = self - .data - .map(|list| list.into_iter().map(|m| m.into_basic_manga()).collect()) - .unwrap_or_default(); - let total = self.count.unwrap_or(0); - MangaPageResult { - has_next_page: page * PAGE_SIZE < total, - entries, + fn tags(&self) -> Vec { + let mut tags = split_tags(&self.ptag); + for tag in split_tags(&self.otag) { + if !tags.contains(&tag) { + tags.push(tag); + } } + tags } } #[derive(Deserialize)] pub struct SearchResp { - pub data: Option>, - pub count: Option, + data: Option>, + count: Option, } impl SearchResp { pub fn into_page_result(self, page: i32) -> MangaPageResult { - let entries = self - .data - .map(|list| list.into_iter().map(Into::into).collect()) - .unwrap_or_default(); - let total = self.count.unwrap_or(0); + let img_base = get_img_base(); MangaPageResult { - has_next_page: page * PAGE_SIZE < total, - entries, + entries: self + .data + .unwrap_or_default() + .into_iter() + .map(|m| m.into_basic_manga(&img_base)) + .collect(), + has_next_page: page * PAGE_SIZE < self.count.unwrap_or(0), } } } #[derive(Deserialize)] -pub struct SearchManga { - pub id: i64, - pub name: String, - pub mode: Option, - pub status: Option, +struct SearchManga { + id: i64, + name: String, + mode: Option, + status: Option, #[serde(default)] - pub adult: Option, + adult: Option, } -impl From for Manga { - fn from(m: SearchManga) -> Self { - let key = m.id.to_string(); - let img_base = get_img_base(); - let is_finished = m.mode.unwrap_or(1) == 0 || m.status.unwrap_or(0) == 1; +impl SearchManga { + fn into_basic_manga(self, img_base: &str) -> Manga { + let key = self.id.to_string(); Manga { cover: Some(format!("{img_base}/{key}/m1.webp")), - title: m.name, - status: if is_finished { - MangaStatus::Completed - } else { - MangaStatus::Ongoing - }, - content_rating: if m.adult.unwrap_or(0) == 1 { - ContentRating::NSFW - } else { - ContentRating::Suggestive - }, + title: self.name, + status: manga_status(self.mode.unwrap_or(1), self.status.unwrap_or(0)), + content_rating: content_rating(self.adult.unwrap_or(0)), key, ..Default::default() } @@ -258,7 +151,7 @@ impl From for Manga { #[derive(Deserialize)] pub struct BookDetailResp { pub book: Option, - pub chapters: Option, + chapters: Option, } #[derive(Deserialize)] @@ -267,135 +160,123 @@ pub struct BookWrapper { } #[derive(Deserialize)] -pub struct ChaptersWrapper { - pub categories: Option>, - pub data: Option, +struct ChaptersWrapper { + categories: Option>, + data: Option>>, } #[derive(Deserialize)] -pub struct Category { - pub id: i64, - pub name: String, +struct Category { + id: i64, + name: String, } #[derive(Deserialize)] -pub struct ChapterEntry { - pub id: i64, - pub name: String, - pub count: i32, - pub sort: Option, - pub created_at: Option, +struct ChapterEntry { + id: i64, + name: String, + #[serde(default)] + count: i32, + created_at: Option, } impl BookDetailResp { - pub fn into_manga(self, key: &str) -> Manga { - let Some(m) = self.book.and_then(|b| b.info) else { - return Manga { - key: key.into(), - ..Default::default() - }; - }; + pub fn into_manga(self, key: &str) -> Option { + let m = self.book?.info?; let tags = m.tags(); - let status = m.manga_status(); - let content_rating = m.content_rating(); - let authors = format_names(&m.author).map(|a| vec![a]); - let description = if m.description.is_empty() { - None - } else { - Some(m.description) - }; - let base_url = get_base_url(); - let img_base = get_img_base(); - Manga { + Some(Manga { key: key.into(), - title: m.name, - authors, - description, - url: Some(format!("{base_url}/manga/{key}")), - tags: Some(tags), - cover: Some(format!("{img_base}/{key}/m1.webp")), - status, - content_rating, + cover: Some(format!("{}/{key}/m1.webp", get_img_base())), + url: Some(format!("{WEB_URL}/manga/{key}")), + authors: format_names(&m.author).map(|a| vec![a]), + description: (!m.description.is_empty()).then_some(m.description), + tags: (!tags.is_empty()).then_some(tags), + status: manga_status(m.mode, m.status), + content_rating: content_rating(m.adult), // api has no reading direction field; site only hosts manga so RTL is assumed viewer: Viewer::RightToLeft, + title: m.name, ..Default::default() - } + }) } pub fn take_chapters(&mut self, manga_key: &str) -> Vec { - let Some(wrapper) = self.chapters.take() else { - return self.single_chapter_vec(manga_key); - }; - - let Some(categories) = wrapper.categories.filter(|c| !c.is_empty()) else { - return self.single_chapter_vec(manga_key); - }; - - let Some(serde_json::Value::Object(mut data_map)) = wrapper.data else { - return self.single_chapter_vec(manga_key); - }; - - let num_categories = categories.len(); - categories - .iter() - .rev() - .enumerate() - .flat_map(|(cat_idx, category)| { - let cat_id = category.id.to_string(); - let entries: Vec = data_map - .remove(&cat_id) - .and_then(|v| serde_json::from_value(v).ok()) - .unwrap_or_default(); - - let volume = (num_categories > 1).then(|| (num_categories - cat_idx) as f32); - - entries.into_iter().rev().map(move |entry| Chapter { - key: format!("{manga_key}/{}", entry.id), - title: Some(format!("{}({}P)", entry.name, entry.count)), - chapter_number: entry.sort.map(|s| s as f32), - volume_number: volume, - scanlators: Some(vec![category.name.clone()]), - date_uploaded: entry.created_at, - ..Default::default() + if let Some(ChaptersWrapper { + categories: Some(categories), + data: Some(mut data), + }) = self.chapters.take() + && !categories.is_empty() + { + return categories + .iter() + .rev() + .flat_map(|category| { + // extras are numbered on their own, so their numbers would clash with the main chapters + let is_extra = category.name.contains("番外"); + data.remove(&category.id) + .unwrap_or_default() + .into_iter() + .rev() + .map(move |entry| { + let (chapter_number, volume_number, title) = if is_extra { + (None, None, Some(entry.name)) + } else { + parse_chapter_name(entry.name) + }; + Chapter { + key: format!("{manga_key}/{}", entry.id), + title, + chapter_number, + volume_number, + scanlators: Some(vec![category.name.clone()]), + date_uploaded: entry.created_at, + ..Default::default() + } + }) }) - }) - .collect() - } - - pub fn page_count(&self) -> i32 { - self.book - .as_ref() - .and_then(|b| b.info.as_ref()) - .map(|info| info.len) - .unwrap_or(0) - } - - fn single_chapter_vec(&self, manga_key: &str) -> Vec { + .collect(); + } let Some(info) = self.book.as_ref().and_then(|b| b.info.as_ref()) else { return Vec::new(); }; vec![Chapter { key: manga_key.into(), - title: Some(format!("單章節({}P)", info.len)), chapter_number: Some(1.0), date_uploaded: Some(info.time), ..Default::default() }] } - pub fn find_chapter_page_count(&self, chapter_id: &str) -> Option { - let wrapper = self.chapters.as_ref()?; - let data = wrapper.data.as_ref()?; - wrapper.categories.as_ref()?.iter().find_map(|category| { - let cat_id = category.id.to_string(); - data.get(&cat_id)?.as_array()?.iter().find_map(|entry| { - let id = entry.get("id")?.as_i64()?.to_string(); - if id == chapter_id { - entry.get("count")?.as_i64().map(|c| c as i32) - } else { - None - } - }) - }) + pub fn page_count(&self, chapter_id: Option<&str>) -> Option { + match chapter_id { + Some(id) => { + let id: i64 = id.parse().ok()?; + self.chapters + .as_ref()? + .data + .as_ref()? + .values() + .flatten() + .find(|entry| entry.id == id) + .map(|entry| entry.count) + } + None => self.book.as_ref()?.info.as_ref().map(|info| info.len), + } + } +} + +fn manga_status(mode: i32, status: i32) -> MangaStatus { + if mode == 0 || status == 1 { + MangaStatus::Completed + } else { + MangaStatus::Ongoing + } +} + +fn content_rating(adult: i32) -> ContentRating { + if adult == 1 { + ContentRating::NSFW + } else { + ContentRating::Suggestive } }