novel_api/ciweimao/
utils.rs

1use std::sync::{OnceLock as SyncOnceCell, RwLock};
2
3use chrono::Utc;
4use chrono_tz::Asia::Shanghai;
5use hex_simd::AsciiCase;
6use rand::Rng;
7use rand::distributions::Alphanumeric;
8use reqwest::Response;
9use serde::Serialize;
10use serde::de::DeserializeOwned;
11use sonic_rs::{JsonValueMutTrait, Value};
12use tokio::sync::OnceCell;
13use url::{Url, form_urlencoded};
14
15use super::Config;
16use crate::{CiweimaoClient, Error, HTTPClient, NovelDB};
17
18impl CiweimaoClient {
19    const APP_NAME: &'static str = "ciweimao";
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    pub(crate) const NEED_TO_UPGRADE_VERSION: &'static str = "310017";
26
27    pub(crate) const APP_VERSION: &'static str = "2.9.355";
28    pub(crate) const DEVICE_TOKEN: &'static str = "ciweimao_";
29
30    const USER_AGENT: &'static str =
31        "Android  com.kuangxiangciweimao.novel.c  2.9.355, Xiaomi, 24030PN60G, 34, 14";
32    const USER_AGENT_RSS: &'static str =
33        "Dalvik/2.1.0 (Linux; U; Android 14; 24030PN60G Build/UKQ1.231003.002)";
34
35    const AES_KEY: &'static str = "sD6doAOcW7hm7iaeK6UlcdtAIWlZGlBr";
36    const HMAC_KEY: &'static str = "a90f3731745f1c30ee77cb13fc00005a";
37    const SIGNATURES: &'static str =
38        const_format::concatcp!(CiweimaoClient::HMAC_KEY, "CkMxWNB666");
39    const PUBLIC_KEY: &'static str = "-----BEGIN PUBLIC KEY-----
40MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxX5AMAGSDhTxsIEahC5t
41Jxypy8qyPijOT2rsMhuUDvENtWpl4axsfLRpD1AlghzBSpNgi1idyZ/OtJFvZsjj
42+drdEO7rCzxMBOlZdw79Gwo06QFSD8JL8X4f49YcGl2+LI5d0KBY2wXdh7urEHQC
43xLK/Lxu9e9ADHXzY26tpCJyvF5LITKZPnzYjGt4fhCEhuoPoeVlJdRAMmGeoRZQ/
44DeRTSAQ1iS3HqalTYRcM4AIiLumivk3vpz8RFsTT0SCKX0zgFRwxkC8pya9/Ls7j
45ALth10rUJTac7fv/801DM6ybAW3IqLgFFUucOwyUF2opRB5AHdoUaa5h4Hb6vwRl
46tQIDAQAB
47-----END PUBLIC KEY-----";
48
49    /// Create a ciweimao client
50    pub async fn new() -> Result<Self, Error> {
51        let config: Option<Config> = crate::load_config_file(CiweimaoClient::APP_NAME)?;
52
53        Ok(Self {
54            proxy: None,
55            no_proxy: false,
56            cert_path: None,
57            client: OnceCell::new(),
58            client_rss: OnceCell::new(),
59            db: OnceCell::new(),
60            config: RwLock::new(config),
61        })
62    }
63
64    #[must_use]
65    pub(crate) fn try_account(&self) -> String {
66        if self.has_token() {
67            self.config
68                .read()
69                .unwrap()
70                .as_ref()
71                .unwrap()
72                .account
73                .to_string()
74        } else {
75            String::default()
76        }
77    }
78
79    #[must_use]
80    pub(crate) fn try_login_token(&self) -> String {
81        if self.has_token() {
82            self.config
83                .read()
84                .unwrap()
85                .as_ref()
86                .unwrap()
87                .login_token
88                .to_string()
89        } else {
90            String::default()
91        }
92    }
93
94    #[must_use]
95    fn reader_id(&self) -> Option<u32> {
96        if self.has_token() {
97            Some(self.config.read().unwrap().as_ref().unwrap().reader_id)
98        } else {
99            None
100        }
101    }
102
103    #[must_use]
104    pub(crate) fn has_token(&self) -> bool {
105        self.config.read().unwrap().is_some()
106    }
107
108    pub(crate) fn save_token(&self, config: Config) {
109        *self.config.write().unwrap() = Some(config);
110    }
111
112    pub(crate) async fn db(&self) -> Result<&NovelDB, Error> {
113        self.db
114            .get_or_try_init(|| async { NovelDB::new(CiweimaoClient::APP_NAME).await })
115            .await
116    }
117
118    pub(crate) async fn client(&self) -> Result<&HTTPClient, Error> {
119        self.client
120            .get_or_try_init(|| async {
121                HTTPClient::builder()
122                    .app_name(CiweimaoClient::APP_NAME)
123                    .user_agent(CiweimaoClient::USER_AGENT.to_string())
124                    // 因为 HTTP response body 是加密的,所以压缩是没有意义的
125                    .allow_compress(false)
126                    .maybe_proxy(self.proxy.clone())
127                    .no_proxy(self.no_proxy)
128                    .maybe_cert_path(self.cert_path.clone())
129                    .retry_host(self.get_host())
130                    .build()
131                    .await
132            })
133            .await
134    }
135
136    async fn client_rss(&self) -> Result<&HTTPClient, Error> {
137        self.client_rss
138            .get_or_try_init(|| async {
139                HTTPClient::builder()
140                    .app_name(CiweimaoClient::APP_NAME)
141                    .user_agent(CiweimaoClient::USER_AGENT_RSS.to_string())
142                    .maybe_proxy(self.proxy.clone())
143                    .no_proxy(self.no_proxy)
144                    .maybe_cert_path(self.cert_path.clone())
145                    .build()
146                    .await
147            })
148            .await
149    }
150
151    pub(crate) async fn get_query<T, E, R>(&self, url: T, query: E) -> Result<R, Error>
152    where
153        T: AsRef<str>,
154        E: Serialize,
155        R: DeserializeOwned,
156    {
157        let response = self
158            .client()
159            .await?
160            .get(self.get_host().to_string() + url.as_ref())
161            .query(&query)
162            .send()
163            .await?;
164        crate::check_status(
165            response.status(),
166            format!("HTTP request failed: `{}`", url.as_ref()),
167        )?;
168
169        Ok(sonic_rs::from_slice(&response.bytes().await?)?)
170    }
171
172    pub(crate) async fn post<T, E, R>(&self, url: T, form: E) -> Result<R, Error>
173    where
174        T: AsRef<str>,
175        E: Serialize,
176        R: DeserializeOwned,
177    {
178        let mut count = 0;
179
180        let response = loop {
181            let response = self
182                .client()
183                .await?
184                .post(self.get_host().to_string() + url.as_ref())
185                .header("charsets", "utf-8")
186                .form(&self.append_param(&form)?)
187                .send()
188                .await;
189
190            if let Ok(response) = response {
191                break response;
192            } else {
193                tracing::info!(
194                    "HTTP request failed: `{}`, retry, number of times: `{}`",
195                    response.as_ref().unwrap_err(),
196                    count + 1
197                );
198
199                count += 1;
200                if count > 3 {
201                    response?;
202                }
203            }
204        };
205
206        crate::check_status(
207            response.status(),
208            format!("HTTP request failed: `{}`", url.as_ref()),
209        )?;
210
211        let bytes = response.bytes().await?;
212        let bytes = crate::aes_256_cbc_no_iv_base64_decrypt(CiweimaoClient::get_aes_key(), &bytes)?;
213
214        Ok(sonic_rs::from_slice(&bytes)?)
215    }
216
217    pub(crate) async fn get_rss(&self, url: &Url) -> Result<Response, Error> {
218        let response = self.client_rss().await?.get(url.clone()).send().await?;
219        crate::check_status(response.status(), format!("HTTP request failed: `{url}`"))?;
220
221        Ok(response)
222    }
223
224    fn append_param<T>(&self, query: T) -> Result<Value, Error>
225    where
226        T: Serialize,
227    {
228        let mut value = sonic_rs::to_value(&query)?;
229        let object = value.as_object_mut().unwrap();
230
231        object.insert("app_version", CiweimaoClient::APP_VERSION);
232        object.insert("device_token", CiweimaoClient::DEVICE_TOKEN);
233
234        let rand_str = CiweimaoClient::get_rand_str();
235        object.insert("rand_str", &rand_str);
236
237        let p = self.hmac(&rand_str)?;
238        object.insert("p", &p);
239
240        if self.has_token() {
241            object.insert("account", &self.try_account());
242            object.insert("login_token", &self.try_login_token());
243        }
244        Ok(value)
245    }
246
247    #[must_use]
248    fn get_host(&self) -> &'static str {
249        if let Some(reader_id) = self.reader_id() {
250            let last_char = reader_id.to_string().chars().last().unwrap();
251
252            if ('1'..='5').contains(&last_char) {
253                return "https://app1.happybooker.cn";
254            }
255        }
256
257        "https://app1.hbooker.com"
258    }
259
260    #[must_use]
261    fn get_aes_key() -> &'static [u8] {
262        static AES_KEY: SyncOnceCell<Vec<u8>> = SyncOnceCell::new();
263        AES_KEY
264            .get_or_init(|| crate::sha256(CiweimaoClient::AES_KEY.as_bytes()))
265            .as_ref()
266    }
267
268    #[must_use]
269    pub(crate) fn hashvalue(&self, timestamp: u128) -> String {
270        crate::md5_hex(
271            crate::aes_256_cbc_no_iv_base64_encrypt(
272                CiweimaoClient::get_aes_key(),
273                format!("{}{timestamp}", self.try_account()),
274            ),
275            AsciiCase::Lower,
276        )
277    }
278
279    #[must_use]
280    fn get_rand_str() -> String {
281        let utc_now = Utc::now();
282        let shanghai_now = utc_now.with_timezone(&Shanghai);
283
284        let rand_str: String = rand::thread_rng()
285            .sample_iter(&Alphanumeric)
286            .take(12)
287            .map(|c| char::from(c).to_lowercase().to_string())
288            .collect();
289
290        format!("{}{}", shanghai_now.format("%M%S"), rand_str)
291    }
292
293    fn hmac(&self, rand_str: &str) -> Result<String, Error> {
294        let msg: String = form_urlencoded::Serializer::new(String::new())
295            .append_pair("account", &self.try_account())
296            .append_pair("app_version", CiweimaoClient::APP_VERSION)
297            .append_pair("rand_str", rand_str)
298            .append_pair("signatures", CiweimaoClient::SIGNATURES)
299            .finish();
300
301        crate::hmac_sha256_base64(CiweimaoClient::HMAC_KEY, msg.as_bytes())
302    }
303
304    pub(crate) fn rsa_encrypt(plaintext: &str) -> Result<String, Error> {
305        crate::rsa_base64_encrypt(CiweimaoClient::PUBLIC_KEY, plaintext)
306    }
307
308    pub(crate) fn do_shutdown(&self) -> Result<(), Error> {
309        if self.has_token() {
310            crate::save_config_file(
311                CiweimaoClient::APP_NAME,
312                self.config.write().unwrap().take(),
313            )?;
314        } else {
315            tracing::info!("No data can be saved to the configuration file");
316        }
317
318        Ok(())
319    }
320}
321
322impl Drop for CiweimaoClient {
323    fn drop(&mut self) {
324        if let Err(err) = self.do_shutdown() {
325            tracing::error!("Fail to save config file: `{err}`");
326        }
327    }
328}
329
330pub(crate) fn check_response_success(code: String, tip: Option<String>) -> Result<(), Error> {
331    if code != CiweimaoClient::OK {
332        Err(Error::NovelApi(format!(
333            "{} request failed, code: `{code}`, msg: `{}`",
334            CiweimaoClient::APP_NAME,
335            tip.unwrap().trim()
336        )))
337    } else {
338        Ok(())
339    }
340}
341
342pub(crate) fn check_already_signed_in(code: &str) -> bool {
343    code == CiweimaoClient::ALREADY_SIGNED_IN
344}