novel_api/ciweimao/
utils.rs

1use std::sync::{OnceLock as SyncOnceCell, RwLock};
2
3use chrono::Utc;
4use chrono_tz::Asia::Shanghai;
5use rand::Rng;
6use rand::distr::Alphanumeric;
7use reqwest::Response;
8use serde::Serialize;
9use serde::de::DeserializeOwned;
10use sonic_rs::{JsonValueMutTrait, Value};
11use tokio::sync::OnceCell;
12use url::{Url, form_urlencoded};
13
14use super::Config;
15use crate::{CiweimaoClient, Error, HTTPClient, NovelDB};
16
17impl CiweimaoClient {
18    const APP_NAME: &'static str = "ciweimao";
19    const HOST: &'static str = "https://app.hbooker.com";
20
21    pub(crate) const OK: &'static str = "100000";
22    pub(crate) const LOGIN_EXPIRED: &'static str = "200100";
23    pub(crate) const NOT_FOUND: &'static str = "320001";
24    pub(crate) const ALREADY_SIGNED_IN: &'static str = "340001";
25
26    pub(crate) const APP_VERSION: &'static str = "2.9.337";
27    pub(crate) const DEVICE_TOKEN: &'static str = "ciweimao_";
28
29    const USER_AGENT: &'static str =
30        "Android com.kuangxiangciweimao.novel 2.9.337,google, sdk_gphone64_arm64, 31, 12";
31    const USER_AGENT_RSS: &'static str =
32        "Dalvik/2.1.0 (Linux; U; Android 12; sdk_gphone64_arm64 Build/SE1A.220203.002.A1)";
33
34    const AES_KEY: &'static str = "zG2nSeEfSHfvTCHy5LCcqtBbQehKNLXn";
35    const HMAC_KEY: &'static str = "a90f3731745f1c30ee77cb13fc00005a";
36    const SIGNATURES: &'static str =
37        const_format::concatcp!(CiweimaoClient::HMAC_KEY, "CkMxWNB666");
38
39    /// Create a ciweimao client
40    pub async fn new() -> Result<Self, Error> {
41        let config: Option<Config> = crate::load_config_file(CiweimaoClient::APP_NAME)?;
42
43        Ok(Self {
44            proxy: None,
45            no_proxy: false,
46            cert_path: None,
47            client: OnceCell::new(),
48            client_rss: OnceCell::new(),
49            db: OnceCell::new(),
50            config: RwLock::new(config),
51        })
52    }
53
54    #[must_use]
55    pub(crate) fn try_account(&self) -> String {
56        if self.has_token() {
57            self.config
58                .read()
59                .unwrap()
60                .as_ref()
61                .unwrap()
62                .account
63                .to_string()
64        } else {
65            String::default()
66        }
67    }
68
69    #[must_use]
70    pub(crate) fn try_login_token(&self) -> String {
71        if self.has_token() {
72            self.config
73                .read()
74                .unwrap()
75                .as_ref()
76                .unwrap()
77                .login_token
78                .to_string()
79        } else {
80            String::default()
81        }
82    }
83
84    #[must_use]
85    pub(crate) fn has_token(&self) -> bool {
86        self.config.read().unwrap().is_some()
87    }
88
89    pub(crate) fn save_token(&self, config: Config) {
90        *self.config.write().unwrap() = Some(config);
91    }
92
93    pub(crate) async fn db(&self) -> Result<&NovelDB, Error> {
94        self.db
95            .get_or_try_init(|| async { NovelDB::new(CiweimaoClient::APP_NAME).await })
96            .await
97    }
98
99    pub(crate) async fn client(&self) -> Result<&HTTPClient, Error> {
100        self.client
101            .get_or_try_init(|| async {
102                HTTPClient::builder()
103                    .app_name(CiweimaoClient::APP_NAME)
104                    .user_agent(CiweimaoClient::USER_AGENT.to_string())
105                    // 因为 HTTP response body 是加密的,所以压缩是没有意义的
106                    .allow_compress(false)
107                    .maybe_proxy(self.proxy.clone())
108                    .no_proxy(self.no_proxy)
109                    .maybe_cert_path(self.cert_path.clone())
110                    .build()
111                    .await
112            })
113            .await
114    }
115
116    async fn client_rss(&self) -> Result<&HTTPClient, Error> {
117        self.client_rss
118            .get_or_try_init(|| async {
119                HTTPClient::builder()
120                    .app_name(CiweimaoClient::APP_NAME)
121                    .user_agent(CiweimaoClient::USER_AGENT_RSS.to_string())
122                    .maybe_proxy(self.proxy.clone())
123                    .no_proxy(self.no_proxy)
124                    .maybe_cert_path(self.cert_path.clone())
125                    .build()
126                    .await
127            })
128            .await
129    }
130
131    pub(crate) async fn get_query<T, E, R>(&self, url: T, query: E) -> Result<R, Error>
132    where
133        T: AsRef<str>,
134        E: Serialize,
135        R: DeserializeOwned,
136    {
137        let response = self
138            .client()
139            .await?
140            .get(CiweimaoClient::HOST.to_string() + url.as_ref())
141            .query(&query)
142            .send()
143            .await?;
144        crate::check_status(
145            response.status(),
146            format!("HTTP request failed: `{}`", url.as_ref()),
147        )?;
148
149        Ok(sonic_rs::from_slice(&response.bytes().await?)?)
150    }
151
152    pub(crate) async fn post<T, E, R>(&self, url: T, form: E) -> Result<R, Error>
153    where
154        T: AsRef<str>,
155        E: Serialize,
156        R: DeserializeOwned,
157    {
158        let response = self
159            .client()
160            .await?
161            .post(CiweimaoClient::HOST.to_string() + url.as_ref())
162            .form(&self.append_param(&form)?)
163            .send()
164            .await?;
165        crate::check_status(
166            response.status(),
167            format!("HTTP request failed: `{}`", url.as_ref()),
168        )?;
169
170        let bytes = response.bytes().await?;
171        let bytes = crate::aes_256_cbc_no_iv_base64_decrypt(CiweimaoClient::get_aes_key(), &bytes)?;
172
173        Ok(sonic_rs::from_slice(&bytes)?)
174    }
175
176    pub(crate) async fn get_rss(&self, url: &Url) -> Result<Response, Error> {
177        let response = self.client_rss().await?.get(url.clone()).send().await?;
178        crate::check_status(response.status(), format!("HTTP request failed: `{url}`"))?;
179
180        Ok(response)
181    }
182
183    fn append_param<T>(&self, query: T) -> Result<Value, Error>
184    where
185        T: Serialize,
186    {
187        let mut value = sonic_rs::to_value(&query)?;
188        let object = value.as_object_mut().unwrap();
189
190        object.insert("app_version", CiweimaoClient::APP_VERSION);
191        object.insert("device_token", CiweimaoClient::DEVICE_TOKEN);
192
193        let rand_str = CiweimaoClient::get_rand_str();
194        object.insert("rand_str", &rand_str);
195
196        let p = self.hmac(&rand_str)?;
197        object.insert("p", &p);
198
199        if self.has_token() {
200            object.insert("account", &self.try_account());
201            object.insert("login_token", &self.try_login_token());
202        }
203        Ok(value)
204    }
205
206    #[must_use]
207    fn get_aes_key() -> &'static [u8] {
208        static AES_KEY: SyncOnceCell<Vec<u8>> = SyncOnceCell::new();
209        AES_KEY
210            .get_or_init(|| crate::sha256(CiweimaoClient::AES_KEY.as_bytes()))
211            .as_ref()
212    }
213
214    #[must_use]
215    fn get_rand_str() -> String {
216        let utc_now = Utc::now();
217        let shanghai_now = utc_now.with_timezone(&Shanghai);
218
219        let rand_str: String = rand::rng()
220            .sample_iter(&Alphanumeric)
221            .take(12)
222            .map(|c| char::from(c).to_lowercase().to_string())
223            .collect();
224
225        format!("{}{}", shanghai_now.format("%M%S"), rand_str)
226    }
227
228    fn hmac(&self, rand_str: &str) -> Result<String, Error> {
229        let msg: String = form_urlencoded::Serializer::new(String::new())
230            .append_pair("account", &self.try_account())
231            .append_pair("app_version", CiweimaoClient::APP_VERSION)
232            .append_pair("rand_str", rand_str)
233            .append_pair("signatures", CiweimaoClient::SIGNATURES)
234            .finish();
235
236        crate::hmac_sha256_base64(CiweimaoClient::HMAC_KEY, msg.as_bytes())
237    }
238
239    pub(crate) fn do_shutdown(&self) -> Result<(), Error> {
240        if self.has_token() {
241            crate::save_config_file(
242                CiweimaoClient::APP_NAME,
243                self.config.write().unwrap().take(),
244            )?;
245        } else {
246            tracing::info!("No data can be saved to the configuration file");
247        }
248
249        Ok(())
250    }
251}
252
253impl Drop for CiweimaoClient {
254    fn drop(&mut self) {
255        if let Err(err) = self.do_shutdown() {
256            tracing::error!("Fail to save config file: `{err}`");
257        }
258    }
259}
260
261pub(crate) fn check_response_success(code: String, tip: Option<String>) -> Result<(), Error> {
262    if code != CiweimaoClient::OK {
263        Err(Error::NovelApi(format!(
264            "{} request failed, code: `{code}`, msg: `{}`",
265            CiweimaoClient::APP_NAME,
266            tip.unwrap().trim()
267        )))
268    } else {
269        Ok(())
270    }
271}
272
273pub(crate) fn check_already_signed_in(code: &str) -> bool {
274    code == CiweimaoClient::ALREADY_SIGNED_IN
275}