novel-api 0.19.1

Novel APIs from various sources
Documentation
use std::sync::RwLock;
use std::time::{SystemTime, UNIX_EPOCH};

use hex_simd::AsciiCase;
use http::HeaderMap;
use reqwest::Response;
use reqwest::header::HeaderValue;
use serde::Serialize;
use serde::de::DeserializeOwned;
use sonic_rs::JsonValueMutTrait;
use tokio::sync::OnceCell;
use url::Url;
use uuid::Uuid;

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

impl CiyuanjiClient {
    const APP_NAME: &'static str = "ciyuanji";
    const HOST: &'static str = "https://api.hwnovel.com/api/ciyuanji/client";

    pub(crate) const OK: &'static str = "200";
    pub(crate) const FAILED: &'static str = "400";
    pub(crate) const ALREADY_SIGNED_IN: &'static str = "410";
    pub(crate) const ALREADY_SIGNED_IN_MSG: &'static str = "今日已签到";
    pub(crate) const ALREADY_ORDERED_MSG: &'static str = "暂无可购买章节";

    const VERSION: &'static str = "3.4.8";
    const PLATFORM: &'static str = "1";
    const CHANNEL: &'static str = "100";

    const USER_AGENT: &'static str = "Mozilla/5.0 (Linux; Android 11; Pixel 4 XL Build/RP1A.200720.009; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/92.0.4515.115 Mobile Safari/537.36";
    const USER_AGENT_RSS: &'static str =
        "Dalvik/2.1.0 (Linux; U; Android 14; sdk_gphone64_arm64 Build/UE1A.230829.050)";

    pub(crate) const DES_KEY: &'static str = "ZUreQN0E";
    const KEY_PARAM: &'static str = "NpkTYvpvhJjEog8Y051gQDHmReY54z5t3F0zSd9QEFuxWGqfC8g8Y4GPuabq0KPdxArlji4dSnnHCARHnkqYBLu7iIw55ibTo18";

    /// Create a ciyuanji client
    pub async fn new() -> Result<Self, Error> {
        let config: Option<Config> = crate::load_config_file(CiyuanjiClient::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_token(&self) -> String {
        if self.has_token() {
            self.config
                .read()
                .unwrap()
                .as_ref()
                .unwrap()
                .token
                .to_string()
        } else {
            String::default()
        }
    }

    #[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(CiyuanjiClient::APP_NAME).await })
            .await
    }

    pub(crate) async fn client(&self) -> Result<&HTTPClient, Error> {
        self.client
            .get_or_try_init(|| async {
                let mut headers = HeaderMap::new();
                headers.insert("version", HeaderValue::from_static(CiyuanjiClient::VERSION));
                headers.insert(
                    "platform",
                    HeaderValue::from_static(CiyuanjiClient::PLATFORM),
                );
                headers.insert("channel", HeaderValue::from_static(CiyuanjiClient::CHANNEL));
                headers.insert(
                    "deviceno",
                    HeaderValue::from_str(
                        &crate::uid().as_simple().to_string().to_lowercase()[0..16],
                    )?,
                );

                HTTPClient::builder()
                    .app_name(CiyuanjiClient::APP_NAME)
                    .user_agent(CiyuanjiClient::USER_AGENT.to_string())
                    .headers(headers)
                    .maybe_proxy(self.proxy.clone())
                    .no_proxy(self.no_proxy)
                    .maybe_cert_path(self.cert_path.clone())
                    .retry_url(Url::parse(CiyuanjiClient::HOST)?)
                    .build()
                    .await
            })
            .await
    }

    pub(crate) async fn client_rss(&self) -> Result<&HTTPClient, Error> {
        self.client_rss
            .get_or_try_init(|| async {
                HTTPClient::builder()
                    .app_name(CiyuanjiClient::APP_NAME)
                    .user_agent(CiyuanjiClient::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<T, R>(&self, url: T) -> Result<R, Error>
    where
        T: AsRef<str>,
        R: DeserializeOwned,
    {
        let response = self
            .client()
            .await?
            .get(CiyuanjiClient::HOST.to_string() + url.as_ref())
            .query(&GenericRequest::new(sonic_rs::json!({}))?)
            .header("token", self.try_token())
            .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 get_query<T, E, R>(&self, url: T, query: E) -> Result<R, Error>
    where
        T: AsRef<str>,
        E: Serialize,
        R: DeserializeOwned,
    {
        let mut count = 0;

        let response = loop {
            let response = self
                .client()
                .await?
                .get(CiyuanjiClient::HOST.to_string() + url.as_ref())
                .query(&GenericRequest::new(&query)?)
                .header("token", self.try_token())
                .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()),
        )?;

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

    pub(crate) async fn post<T, E, R>(&self, url: T, json: E) -> Result<R, Error>
    where
        T: AsRef<str>,
        E: Serialize,
        R: DeserializeOwned,
    {
        let response = self
            .client()
            .await?
            .post(CiyuanjiClient::HOST.to_string() + url.as_ref())
            .json(&GenericRequest::new(json)?)
            .header("token", self.try_token())
            .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 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)
    }

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

        Ok(())
    }
}

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

#[must_use]
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GenericRequest {
    pub param: String,
    pub request_id: String,
    pub sign: String,
    pub timestamp: u128,
}

impl GenericRequest {
    fn new<T>(json: T) -> Result<Self, Error>
    where
        T: Serialize,
    {
        let mut json = sonic_rs::to_value(&json)?;

        let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
        json.as_object_mut()
            .unwrap()
            .insert("timestamp", sonic_rs::json!(timestamp));

        let param = crate::des_ecb_base64_encrypt(CiyuanjiClient::DES_KEY, json.to_string())?;

        let request_id = Uuid::new_v4().as_simple().to_string();

        let sign = crate::md5_hex(
            base64_simd::STANDARD.encode_to_string(format!(
                "param={param}&requestId={request_id}&timestamp={timestamp}&key={}",
                CiyuanjiClient::KEY_PARAM
            )),
            AsciiCase::Upper,
        );

        Ok(Self {
            param,
            request_id,
            sign,
            timestamp,
        })
    }
}

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

pub(crate) fn check_already_signed_in(code: &str, msg: &str) -> bool {
    code == CiyuanjiClient::ALREADY_SIGNED_IN && msg == CiyuanjiClient::ALREADY_SIGNED_IN_MSG
}

pub(crate) fn check_already_ordered(code: &str, msg: &str) -> bool {
    code == CiyuanjiClient::FAILED && msg == CiyuanjiClient::ALREADY_ORDERED_MSG
}