Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions sources/zh.noyacg/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 2 additions & 4 deletions sources/zh.noyacg/res/filters.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 0 additions & 1 deletion sources/zh.noyacg/res/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@
"title": "分流伺服器",
"values": ["noymanga.com", "noyteam.online", "noy.asia"],
"titles": ["伺服器 1", "伺服器 2", "伺服器 3"],
"default": "noy.asia",
"refreshes": ["content"]
}
]
Expand Down
10 changes: 5 additions & 5 deletions sources/zh.noyacg/res/source.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand All @@ -19,16 +19,16 @@
"name": "完結"
},
{
"id": "proportion",
"name": "精選"
"id": "random",
"name": "隨機"
},
{
"id": "favorite",
"name": "收藏"
}
],
"config": {
"hidesFiltersWhileSearching": false,
"hidesFiltersWhileSearching": true,
"supportsTagSearch": true,
"supportsAuthorSearch": true
}
Expand Down
96 changes: 30 additions & 66 deletions sources/zh.noyacg/src/auth.rs
Original file line number Diff line number Diff line change
@@ -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::<String>(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::<String>(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::<String>(USERNAME_KEY).is_some()
}

pub fn is_just_logged_in() -> bool {
defaults_get::<bool>(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<bool> {
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<bool> {
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::<String>(USERNAME_KEY),
defaults_get::<String>(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}")
Expand All @@ -88,32 +64,20 @@ pub fn try_daily_signin() {
if defaults_get::<String>(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::<SigninRecordResp>()
let adult = get_adult_mode();
let Ok(record) =
fetch_json::<SigninRecordResp>(|| api_post("/api/v4/signin/record", "", adult))
else {
return;
};
if record.today == Some(true) {
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));
}
}
13 changes: 2 additions & 11 deletions sources/zh.noyacg/src/filters.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -47,14 +46,6 @@ pub fn build_tag_filter(adult_mode: &str) -> Filter {
}

fn fetch_bigtaglist(adult_mode: &str) -> aidoku::Result<Vec<BigTag>> {
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())
}
107 changes: 78 additions & 29 deletions sources/zh.noyacg/src/helpers.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
if raw.is_empty() {
Expand Down Expand Up @@ -48,17 +51,62 @@ pub fn split_tags(raw: &str) -> Vec<String> {
.collect()
}

pub fn get_adult_mode() -> String {
let selected = defaults_get::<Vec<String>>("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<f32>, Option<f32>, Option<String>) {
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::<f32>() 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::<Vec<String>>("adult_mode").unwrap_or_default())
}

pub fn extract_manga_id(url: &str) -> Option<String> {
let marker = "/manga/";
let (_, tail) = url.split_once(marker)?;
Expand All @@ -76,38 +124,39 @@ pub fn extract_reader_path(url: &str) -> Option<String> {
Some(path.into())
}

pub fn get_server_domain() -> String {
fn get_server_domain() -> String {
defaults_get::<String>("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())
}

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<Request> {
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<Request> {
Ok(Request::post(url)?
pub fn api_post(path: &str, body: &str, adult: &str) -> Result<Request> {
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<T: DeserializeOwned>(request: impl Fn() -> Result<Request>) -> Result<T> {
let mut data = request()?.data()?;
if data == LOGIN_REQUIRED {
auth::relogin()?;
data = request()?.data()?;
}
Ok(serde_json::from_slice(&data)?)
}
Loading
Loading