Skip to main content

novel_api/ciweimao/
utils.rs

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