novel-api 0.19.1

Novel APIs from various sources
Documentation
use std::sync::{OnceLock as SyncOnceCell, RwLock};

use chrono::Utc;
use chrono_tz::Asia::Shanghai;
use hex_simd::AsciiCase;
use rand::Rng;
use rand::distributions::Alphanumeric;
use reqwest::Response;
use serde::Serialize;
use serde::de::DeserializeOwned;
use sonic_rs::{JsonValueMutTrait, Value};
use tokio::sync::OnceCell;
use url::{Url, form_urlencoded};

use super::Config;
use crate::{CiweimaoClient, Error, HTTPClient, NovelDB};

impl CiweimaoClient {
    const APP_NAME: &'static str = "ciweimao";

    pub(crate) const OK: &'static str = "100000";
    pub(crate) const LOGIN_EXPIRED: &'static str = "200100";
    pub(crate) const NOT_FOUND: &'static str = "320001";
    pub(crate) const ALREADY_SIGNED_IN: &'static str = "340001";
    pub(crate) const NEED_TO_UPGRADE_VERSION: &'static str = "310017";

    pub(crate) const APP_VERSION: &'static str = "2.9.362";
    pub(crate) const DEVICE_TOKEN: &'static str = "ciweimao_";

    const USER_AGENT: &'static str =
        "Android  com.kuangxiangciweimao.novel.c  2.9.362, Xiaomi, 24030PN60G, 34, 14";
    const USER_AGENT_RSS: &'static str =
        "Dalvik/2.1.0 (Linux; U; Android 14; 24030PN60G Build/UKQ1.231003.002)";

    const AES_KEY: &'static str = "sD6doAOcW7hm7iaeK6UlcdtAIWlZGlBr";
    const HMAC_KEY: &'static str = "a90f3731745f1c30ee77cb13fc00005a";
    const SIGNATURES: &'static str =
        const_format::concatcp!(CiweimaoClient::HMAC_KEY, "CkMxWNB666");
    const PUBLIC_KEY: &'static str = "-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxX5AMAGSDhTxsIEahC5t
Jxypy8qyPijOT2rsMhuUDvENtWpl4axsfLRpD1AlghzBSpNgi1idyZ/OtJFvZsjj
+drdEO7rCzxMBOlZdw79Gwo06QFSD8JL8X4f49YcGl2+LI5d0KBY2wXdh7urEHQC
xLK/Lxu9e9ADHXzY26tpCJyvF5LITKZPnzYjGt4fhCEhuoPoeVlJdRAMmGeoRZQ/
DeRTSAQ1iS3HqalTYRcM4AIiLumivk3vpz8RFsTT0SCKX0zgFRwxkC8pya9/Ls7j
ALth10rUJTac7fv/801DM6ybAW3IqLgFFUucOwyUF2opRB5AHdoUaa5h4Hb6vwRl
tQIDAQAB
-----END PUBLIC KEY-----";

    /// Create a ciweimao client
    pub async fn new() -> Result<Self, Error> {
        let config: Option<Config> = crate::load_config_file(CiweimaoClient::APP_NAME)?;

        Ok(Self {
            proxy: None,
            no_proxy: false,
            cert_path: None,
            client: OnceCell::new(),
            client_rss: OnceCell::new(),
            db: OnceCell::new(),
            config: RwLock::new(config),
        })
    }

    #[must_use]
    pub(crate) fn try_account(&self) -> String {
        if self.has_token() {
            self.config
                .read()
                .unwrap()
                .as_ref()
                .unwrap()
                .account
                .to_string()
        } else {
            String::default()
        }
    }

    #[must_use]
    pub(crate) fn try_login_token(&self) -> String {
        if self.has_token() {
            self.config
                .read()
                .unwrap()
                .as_ref()
                .unwrap()
                .login_token
                .to_string()
        } else {
            String::default()
        }
    }

    #[must_use]
    fn reader_id(&self) -> Option<u32> {
        if self.has_token() {
            Some(self.config.read().unwrap().as_ref().unwrap().reader_id)
        } else {
            None
        }
    }

    #[must_use]
    pub(crate) fn has_token(&self) -> bool {
        self.config.read().unwrap().is_some()
    }

    pub(crate) fn save_token(&self, config: Config) {
        *self.config.write().unwrap() = Some(config);
    }

    pub(crate) async fn db(&self) -> Result<&NovelDB, Error> {
        self.db
            .get_or_try_init(|| async { NovelDB::new(CiweimaoClient::APP_NAME).await })
            .await
    }

    pub(crate) async fn client(&self) -> Result<&HTTPClient, Error> {
        self.client
            .get_or_try_init(|| async {
                HTTPClient::builder()
                    .app_name(CiweimaoClient::APP_NAME)
                    .user_agent(CiweimaoClient::USER_AGENT.to_string())
                    // 因为 HTTP response body 是加密的,所以压缩是没有意义的
                    .allow_compress(false)
                    .maybe_proxy(self.proxy.clone())
                    .no_proxy(self.no_proxy)
                    .maybe_cert_path(self.cert_path.clone())
                    .retry_url(Url::parse(self.get_host())?)
                    .build()
                    .await
            })
            .await
    }

    async fn client_rss(&self) -> Result<&HTTPClient, Error> {
        self.client_rss
            .get_or_try_init(|| async {
                HTTPClient::builder()
                    .app_name(CiweimaoClient::APP_NAME)
                    .user_agent(CiweimaoClient::USER_AGENT_RSS.to_string())
                    .maybe_proxy(self.proxy.clone())
                    .no_proxy(self.no_proxy)
                    .maybe_cert_path(self.cert_path.clone())
                    .build()
                    .await
            })
            .await
    }

    pub(crate) async fn get_query<T, E, R>(&self, url: T, query: E) -> Result<R, Error>
    where
        T: AsRef<str>,
        E: Serialize,
        R: DeserializeOwned,
    {
        let response = self
            .client()
            .await?
            .get(self.get_host().to_string() + url.as_ref())
            .query(&query)
            .send()
            .await?;
        crate::check_status(
            response.status(),
            format!("HTTP request failed: `{}`", url.as_ref()),
        )?;

        Ok(sonic_rs::from_slice(&response.bytes().await?)?)
    }

    pub(crate) async fn post<T, E, R>(&self, url: T, form: E) -> Result<R, Error>
    where
        T: AsRef<str>,
        E: Serialize,
        R: DeserializeOwned,
    {
        let mut count = 0;

        let response = loop {
            let response = self
                .client()
                .await?
                .post(self.get_host().to_string() + url.as_ref())
                .header("charsets", "utf-8")
                .form(&self.append_param(&form)?)
                .send()
                .await;

            if let Ok(response) = response {
                break response;
            } else {
                tracing::info!(
                    "HTTP request failed: `{}`, retry, number of times: `{}`",
                    response.as_ref().unwrap_err(),
                    count + 1
                );

                count += 1;
                if count > 3 {
                    response?;
                }
            }
        };

        crate::check_status(
            response.status(),
            format!("HTTP request failed: `{}`", url.as_ref()),
        )?;

        let bytes = response.bytes().await?;
        let bytes = crate::aes_256_cbc_no_iv_base64_decrypt(CiweimaoClient::get_aes_key(), &bytes)?;

        Ok(sonic_rs::from_slice(&bytes)?)
    }

    pub(crate) async fn get_rss(&self, url: &Url) -> Result<Response, Error> {
        let response = self.client_rss().await?.get(url.clone()).send().await?;
        crate::check_status(response.status(), format!("HTTP request failed: `{url}`"))?;

        Ok(response)
    }

    fn append_param<T>(&self, query: T) -> Result<Value, Error>
    where
        T: Serialize,
    {
        let mut value = sonic_rs::to_value(&query)?;
        let object = value.as_object_mut().unwrap();

        object.insert("app_version", CiweimaoClient::APP_VERSION);
        object.insert("device_token", CiweimaoClient::DEVICE_TOKEN);

        let rand_str = CiweimaoClient::get_rand_str();
        object.insert("rand_str", &rand_str);

        let p = self.hmac(&rand_str)?;
        object.insert("p", &p);

        if self.has_token() {
            object.insert("account", &self.try_account());
            object.insert("login_token", &self.try_login_token());
        }
        Ok(value)
    }

    #[must_use]
    fn get_host(&self) -> &'static str {
        if let Some(reader_id) = self.reader_id() {
            let last_char = reader_id.to_string().chars().last().unwrap();

            if ('1'..='5').contains(&last_char) {
                return "https://app1.happybooker.cn";
            }
        }

        "https://app1.hbooker.com"
    }

    #[must_use]
    fn get_aes_key() -> &'static [u8] {
        static AES_KEY: SyncOnceCell<Vec<u8>> = SyncOnceCell::new();
        AES_KEY
            .get_or_init(|| crate::sha256(CiweimaoClient::AES_KEY.as_bytes()))
            .as_ref()
    }

    #[must_use]
    pub(crate) fn hashvalue(&self, timestamp: u128) -> String {
        crate::md5_hex(
            crate::aes_256_cbc_no_iv_base64_encrypt(
                CiweimaoClient::get_aes_key(),
                format!("{}{timestamp}", self.try_account()),
            ),
            AsciiCase::Lower,
        )
    }

    #[must_use]
    fn get_rand_str() -> String {
        let utc_now = Utc::now();
        let shanghai_now = utc_now.with_timezone(&Shanghai);

        let rand_str: String = rand::thread_rng()
            .sample_iter(&Alphanumeric)
            .take(12)
            .map(|c| char::from(c).to_lowercase().to_string())
            .collect();

        format!("{}{}", shanghai_now.format("%M%S"), rand_str)
    }

    fn hmac(&self, rand_str: &str) -> Result<String, Error> {
        let msg: String = form_urlencoded::Serializer::new(String::new())
            .append_pair("account", &self.try_account())
            .append_pair("app_version", CiweimaoClient::APP_VERSION)
            .append_pair("rand_str", rand_str)
            .append_pair("signatures", CiweimaoClient::SIGNATURES)
            .finish();

        crate::hmac_sha256_base64(CiweimaoClient::HMAC_KEY, msg.as_bytes())
    }

    pub(crate) fn rsa_encrypt(plaintext: &str) -> Result<String, Error> {
        crate::rsa_base64_encrypt(CiweimaoClient::PUBLIC_KEY, plaintext)
    }

    pub(crate) fn do_shutdown(&self) -> Result<(), Error> {
        if self.has_token() {
            crate::save_config_file(
                CiweimaoClient::APP_NAME,
                self.config.write().unwrap().take(),
            )?;
        } else {
            tracing::info!("No data can be saved to the configuration file");
        }

        Ok(())
    }
}

impl Drop for CiweimaoClient {
    fn drop(&mut self) {
        if let Err(err) = self.do_shutdown() {
            tracing::error!("Fail to save config file: `{err}`");
        }
    }
}

pub(crate) fn check_response_success(code: String, tip: Option<String>) -> Result<(), Error> {
    if code != CiweimaoClient::OK {
        Err(Error::NovelApi(format!(
            "{} request failed, code: `{code}`, msg: `{}`",
            CiweimaoClient::APP_NAME,
            tip.unwrap().trim()
        )))
    } else {
        Ok(())
    }
}

pub(crate) fn check_already_signed_in(code: &str) -> bool {
    code == CiweimaoClient::ALREADY_SIGNED_IN
}