novel_api/ciweimao/
mod.rs

1mod server;
2mod structure;
3mod utils;
4
5use std::io::Cursor;
6use std::path::PathBuf;
7use std::slice;
8use std::sync::RwLock;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use chrono::{Duration, Local, NaiveDateTime, TimeZone};
12use chrono_tz::Asia::Shanghai;
13use hashbrown::HashMap;
14use image::{DynamicImage, ImageReader};
15use scraper::{Html, Selector};
16use serde::{Deserialize, Serialize};
17use tokio::sync::OnceCell;
18use url::Url;
19
20use self::structure::*;
21use crate::{
22    Category, ChapterInfo, Client, Comment, CommentType, ContentInfo, ContentInfos, Error,
23    FindImageResult, FindTextResult, HTTPClient, LongComment, NovelDB, NovelInfo, Options,
24    ShortComment, Tag, UserInfo, VolumeInfo, VolumeInfos, WordCountRange,
25};
26
27#[must_use]
28#[derive(Serialize, Deserialize)]
29pub(crate) struct Config {
30    account: String,
31    login_token: String,
32    reader_id: u32,
33}
34
35/// Ciweimao client, use it to access Apis
36#[must_use]
37pub struct CiweimaoClient {
38    proxy: Option<Url>,
39    no_proxy: bool,
40    cert_path: Option<PathBuf>,
41
42    client: OnceCell<HTTPClient>,
43    client_rss: OnceCell<HTTPClient>,
44
45    db: OnceCell<NovelDB>,
46
47    config: RwLock<Option<Config>>,
48}
49
50impl Client for CiweimaoClient {
51    fn proxy(&mut self, proxy: Url) {
52        self.proxy = Some(proxy);
53    }
54
55    fn no_proxy(&mut self) {
56        self.no_proxy = true;
57    }
58
59    fn cert(&mut self, cert_path: PathBuf) {
60        self.cert_path = Some(cert_path);
61    }
62
63    async fn shutdown(&self) -> Result<(), Error> {
64        self.client().await?.save_cookies()?;
65        self.do_shutdown()?;
66        Ok(())
67    }
68
69    async fn add_cookie(&self, cookie_str: &str, url: &Url) -> Result<(), Error> {
70        self.client().await?.add_cookie(cookie_str, url)
71    }
72
73    async fn log_in(&self, username: String, password: Option<String>) -> Result<(), Error> {
74        assert!(!username.is_empty());
75        assert!(password.is_some());
76
77        let password = password.unwrap();
78
79        let config = match self.verify_type(&username).await? {
80            VerifyType::None => {
81                tracing::info!("No verification required");
82                self.no_verification_login(username, password).await?
83            }
84            VerifyType::Geetest => {
85                tracing::info!("Verify with Geetest");
86                self.geetest_login(username, password).await?
87            }
88            VerifyType::VerifyCode => {
89                tracing::info!("Verify with SMS verification code");
90                self.sms_login(username, password).await?
91            }
92        };
93
94        self.save_token(config);
95
96        Ok(())
97    }
98
99    async fn logged_in(&self) -> Result<bool, Error> {
100        if !self.has_token() {
101            return Ok(false);
102        }
103
104        let response: GenericResponse = self.post("/reader/get_my_info", EmptyRequest {}).await?;
105
106        if response.code == CiweimaoClient::LOGIN_EXPIRED {
107            Ok(false)
108        } else {
109            utils::check_response_success(response.code, response.tip)?;
110            Ok(true)
111        }
112    }
113
114    async fn user_info(&self) -> Result<UserInfo, Error> {
115        let response: UserInfoResponse = self.post("/reader/get_my_info", EmptyRequest {}).await?;
116        utils::check_response_success(response.code, response.tip)?;
117        let reader_info = response.data.unwrap().reader_info;
118
119        let user_info = UserInfo {
120            nickname: reader_info.reader_name.trim().to_string(),
121            avatar: reader_info.avatar_url,
122        };
123
124        Ok(user_info)
125    }
126
127    async fn money(&self) -> Result<u32, Error> {
128        let response: PropInfoResponse =
129            self.post("/reader/get_prop_info", EmptyRequest {}).await?;
130        utils::check_response_success(response.code, response.tip)?;
131        let prop_info = response.data.unwrap().prop_info;
132
133        Ok(prop_info.rest_hlb.parse()?)
134    }
135
136    async fn sign_in(&self) -> Result<(), Error> {
137        let response: GenericResponse = self
138            .post(
139                "/reader/get_task_bonus_with_sign_recommend",
140                SignRequest {
141                    // always 1, from `/task/get_all_task_list`
142                    task_type: 1,
143                },
144            )
145            .await?;
146        if utils::check_already_signed_in(&response.code) {
147            tracing::info!("{}", response.tip.unwrap().trim());
148        } else {
149            utils::check_response_success(response.code, response.tip)?;
150        }
151
152        Ok(())
153    }
154
155    async fn bookshelf_infos(&self) -> Result<Vec<u32>, Error> {
156        let shelf_ids = self.shelf_list().await?;
157        let mut result = Vec::new();
158
159        for shelf_id in shelf_ids {
160            let response: BookshelfResponse = self
161                .post(
162                    "/bookshelf/get_shelf_book_list_new",
163                    BookshelfRequest {
164                        shelf_id,
165                        count: 9999,
166                        page: 0,
167                        order: "last_read_time",
168                    },
169                )
170                .await?;
171            utils::check_response_success(response.code, response.tip)?;
172
173            for novel_info in response.data.unwrap().book_list {
174                result.push(novel_info.book_info.book_id.parse()?);
175            }
176        }
177
178        Ok(result)
179    }
180
181    async fn novel_info(&self, id: u32) -> Result<Option<NovelInfo>, Error> {
182        assert!(id > 0);
183
184        let response: NovelInfoResponse = self
185            .post("/book/get_info_by_id", NovelInfoRequest { book_id: id })
186            .await?;
187        if response.code == CiweimaoClient::NOT_FOUND {
188            return Ok(None);
189        }
190        utils::check_response_success(response.code, response.tip)?;
191
192        let data = response.data.unwrap().book_info;
193        let novel_info = NovelInfo {
194            id,
195            name: data.book_name.trim().to_string(),
196            author_name: data.author_name.trim().to_string(),
197            cover_url: data.cover,
198            introduction: super::parse_multi_line(data.description),
199            word_count: Some(data.total_word_count.parse()?),
200            is_vip: Some(data.is_paid),
201            is_finished: Some(data.up_status),
202            create_time: data.newtime,
203            update_time: Some(data.uptime),
204            category: self.parse_category(data.category_index).await?,
205            tags: self.parse_tags(data.tag_list).await?,
206        };
207
208        Ok(Some(novel_info))
209    }
210
211    async fn comments(
212        &self,
213        id: u32,
214        comment_type: CommentType,
215        need_replies: bool,
216        page: u16,
217        size: u16,
218    ) -> Result<Option<Vec<Comment>>, Error> {
219        let r#type = match comment_type {
220            // 1 讨论
221            // 2 长评
222            CommentType::Short => 1,
223            CommentType::Long => 2,
224        };
225
226        let response: ReviewResponse = self
227            .post(
228                "/book/get_review_list",
229                ReviewRequest {
230                    book_id: id,
231                    r#type,
232                    page,
233                    count: size,
234                },
235            )
236            .await?;
237        utils::check_response_success(response.code, response.tip)?;
238        let review_list = response.data.unwrap().review_list;
239
240        if review_list.is_empty() {
241            return Ok(None);
242        }
243
244        let mut result = Vec::with_capacity(review_list.len());
245
246        match comment_type {
247            CommentType::Short => {
248                for review in review_list {
249                    // 部分评论为空白字符
250                    let Some(content) = super::parse_multi_line(review.review_content) else {
251                        continue;
252                    };
253
254                    let review_id: u32 = review.review_id.parse()?;
255                    // 评论数量可能为负数,e.g. 100089784
256                    let comment_amount = review.comment_amount.parse::<u16>().unwrap_or(0);
257
258                    let replies = if need_replies && comment_amount > 0 {
259                        self.review_comment(review_id, comment_amount).await?
260                    } else {
261                        None
262                    };
263
264                    let comment = ShortComment {
265                        id: review_id,
266                        user: UserInfo {
267                            nickname: review.reader_info.reader_name.trim().to_string(),
268                            avatar: review.reader_info.avatar_url,
269                        },
270                        content,
271                        create_time: Some(review.ctime),
272                        like_count: Some(review.like_amount.parse()?),
273                        replies,
274                    };
275
276                    result.push(Comment::Short(comment));
277                }
278            }
279            CommentType::Long => {
280                for review in review_list {
281                    let Some(content) = super::parse_multi_line(review.review_content) else {
282                        continue;
283                    };
284
285                    let review_id: u32 = review.review_id.parse()?;
286                    let comment_amount: u16 = review.comment_amount.parse()?;
287
288                    let replies = if need_replies && comment_amount > 0 {
289                        self.review_comment(review_id, comment_amount).await?
290                    } else {
291                        None
292                    };
293
294                    let comment = LongComment {
295                        id: review_id,
296                        user: UserInfo {
297                            nickname: review.reader_info.reader_name.trim().to_string(),
298                            avatar: review.reader_info.avatar_url,
299                        },
300                        title: review.title.trim().to_string(),
301                        content,
302                        create_time: Some(review.ctime),
303                        like_count: Some(review.like_amount.parse()?),
304                        replies,
305                    };
306
307                    result.push(Comment::Long(comment));
308                }
309            }
310        }
311
312        Ok(Some(result))
313    }
314
315    async fn volume_infos(&self, id: u32) -> Result<Option<VolumeInfos>, Error> {
316        let response: VolumesResponse = self
317            .post(
318                "/chapter/get_updated_chapter_by_division_new",
319                VolumesRequest { book_id: id },
320            )
321            .await?;
322        utils::check_response_success(response.code, response.tip)?;
323        let chapter_list = response.data.unwrap().chapter_list;
324
325        let chapter_prices = self.chapter_prices(id).await?;
326
327        let mut volume_infos = VolumeInfos::new();
328        for item in chapter_list {
329            let mut volume_info = VolumeInfo {
330                id: item.division_id.parse()?,
331                title: item.division_name.trim().to_string(),
332                chapter_infos: Vec::new(),
333            };
334
335            for chapter in item.chapter_list {
336                let chapter_id: u32 = chapter.chapter_id.parse()?;
337                let price = chapter_prices.get(&chapter_id).copied();
338                let mut is_valid = true;
339
340                // e.g. 该章节未审核通过
341                if price.is_none() {
342                    tracing::info!("Price not found: {chapter_id}");
343                    is_valid = false;
344                }
345
346                let chapter_info = ChapterInfo {
347                    novel_id: Some(id),
348                    id: chapter_id,
349                    title: chapter.chapter_title.trim().to_string(),
350                    word_count: Some(chapter.word_count.parse()?),
351                    // mtime 应为更新时间,但是 Android 端存在 bug,似乎导致 mtime 为创建时间
352                    create_time: Some(chapter.mtime),
353                    update_time: None,
354                    is_vip: Some(chapter.is_paid),
355                    price,
356                    payment_required: Some(!chapter.auth_access),
357                    is_valid: Some(chapter.is_valid && is_valid),
358                };
359
360                volume_info.chapter_infos.push(chapter_info);
361            }
362
363            volume_infos.push(volume_info);
364        }
365
366        Ok(Some(volume_infos))
367    }
368
369    async fn content_infos(&self, info: &ChapterInfo) -> Result<ContentInfos, Error> {
370        Ok(self
371            .content_infos_multiple(slice::from_ref(info))
372            .await?
373            .remove(0))
374    }
375
376    async fn content_infos_multiple(
377        &self,
378        infos: &[ChapterInfo],
379    ) -> Result<Vec<ContentInfos>, Error> {
380        let mut contents = HashMap::new();
381        let mut need_to_download = Vec::new();
382
383        for info in infos {
384            match self.db().await?.find_text(info).await? {
385                FindTextResult::Ok(content) => {
386                    contents.insert(info.id, content);
387                }
388                _ => {
389                    need_to_download.push(info.id);
390                }
391            }
392        }
393
394        if !need_to_download.is_empty() {
395            self.check_download_cpt().await?;
396
397            let cmd = self.chapter_cmd(&need_to_download).await?;
398            let key = crate::sha256(cmd.as_bytes());
399
400            let response: ChapsResponse = self
401                .post(
402                    "/chapter/download_cpt",
403                    ChapsRequest {
404                        chapter_id: itertools::join(&need_to_download, ","),
405                        chapter_command: cmd,
406                    },
407                )
408                .await?;
409            utils::check_response_success(response.code, response.tip)?;
410
411            let chapter_infos =
412                simdutf8::basic::from_utf8(&crate::aes_256_cbc_no_iv_base64_decrypt(
413                    key,
414                    &response.data.unwrap().chapter_infos,
415                )?)?
416                .to_string();
417            let chapter_infos: Vec<ChapsInfo> = sonic_rs::from_str(&chapter_infos)?;
418            if chapter_infos.len() != need_to_download.len() {
419                return Err(Error::NovelApi(String::from(
420                    "The number of chapter downloads is insufficient",
421                )));
422            }
423
424            for (index, id) in need_to_download.iter().enumerate() {
425                let content = chapter_infos[index].txt_content.clone();
426                if content.trim().is_empty() {
427                    return Err(Error::NovelApi(String::from("Content is empty")));
428                }
429
430                contents.insert(*id, content);
431            }
432
433            for info in infos {
434                match self.db().await?.find_text(info).await? {
435                    FindTextResult::Ok(_) => (),
436                    other => match other {
437                        FindTextResult::None => {
438                            self.db()
439                                .await?
440                                .insert_text(info, contents.get(&info.id).unwrap())
441                                .await?
442                        }
443                        FindTextResult::Outdate => {
444                            self.db()
445                                .await?
446                                .update_text(info, contents.get(&info.id).unwrap())
447                                .await?
448                        }
449                        FindTextResult::Ok(_) => (),
450                    },
451                }
452            }
453        }
454
455        let mut result = Vec::new();
456        for info in infos {
457            let mut content_infos = ContentInfos::new();
458            for line in contents
459                .get(&info.id)
460                .unwrap()
461                .lines()
462                .map(|line| line.trim())
463                .filter(|line| !line.is_empty())
464            {
465                if line.starts_with("<img") {
466                    if let Some(url) = CiweimaoClient::parse_image_url(line) {
467                        content_infos.push(ContentInfo::Image(url));
468                    }
469                } else {
470                    content_infos.push(ContentInfo::Text(line.to_string()));
471                }
472            }
473
474            result.push(content_infos);
475        }
476
477        Ok(result)
478    }
479
480    async fn order_chapter(&self, info: &ChapterInfo) -> Result<(), Error> {
481        let response: GenericResponse = self
482            .post(
483                "/chapter/buy",
484                OrderChapterRequest {
485                    chapter_id: info.id.to_string(),
486                },
487            )
488            .await?;
489        utils::check_response_success(response.code, response.tip)?;
490
491        Ok(())
492    }
493
494    async fn order_novel(&self, id: u32, infos: &VolumeInfos) -> Result<(), Error> {
495        assert!(id > 0);
496
497        let mut chapter_id_list = Vec::new();
498        for volume in infos {
499            for chapter in &volume.chapter_infos {
500                if chapter.payment_required() {
501                    chapter_id_list.push(chapter.id.to_string());
502                }
503            }
504        }
505        if chapter_id_list.is_empty() {
506            return Ok(());
507        }
508
509        let chapter_id_list = sonic_rs::json!(chapter_id_list).to_string();
510
511        let response: GenericResponse = self
512            .post("/chapter/buy_multi", OrderNovelRequest { chapter_id_list })
513            .await?;
514        utils::check_response_success(response.code, response.tip)?;
515
516        Ok(())
517    }
518
519    async fn image(&self, url: &Url) -> Result<DynamicImage, Error> {
520        match self.db().await?.find_image(url).await? {
521            FindImageResult::Ok(image) => Ok(image),
522            FindImageResult::None => {
523                let response = self.get_rss(url).await?;
524                let bytes = response.bytes().await?;
525
526                let image = ImageReader::new(Cursor::new(&bytes))
527                    .with_guessed_format()?
528                    .decode()?;
529
530                self.db().await?.insert_image(url, bytes).await?;
531
532                Ok(image)
533            }
534        }
535    }
536
537    async fn categories(&self) -> Result<&Vec<Category>, Error> {
538        static CATEGORIES: OnceCell<Vec<Category>> = OnceCell::const_new();
539
540        CATEGORIES
541            .get_or_try_init(|| async {
542                let response: CategoryResponse =
543                    self.post("/meta/get_meta_data", EmptyRequest {}).await?;
544                utils::check_response_success(response.code, response.tip)?;
545
546                let mut result = Vec::new();
547                for category in response.data.unwrap().category_list {
548                    for category_detail in category.category_detail {
549                        result.push(Category {
550                            id: Some(category_detail.category_index.parse()?),
551                            parent_id: None,
552                            name: category_detail.category_name.trim().to_string(),
553                        });
554                    }
555                }
556
557                result.sort_unstable_by_key(|x| x.id.unwrap());
558
559                Ok(result)
560            })
561            .await
562    }
563
564    async fn tags(&self) -> Result<&Vec<Tag>, Error> {
565        static TAGS: OnceCell<Vec<Tag>> = OnceCell::const_new();
566
567        TAGS.get_or_try_init(|| async {
568            let response: TagResponse = self
569                .post("/book/get_official_tag_list", EmptyRequest {})
570                .await?;
571            utils::check_response_success(response.code, response.tip)?;
572
573            let mut result = Vec::new();
574            for tag in response.data.unwrap().official_tag_list {
575                result.push(Tag {
576                    id: None,
577                    name: tag.tag_name.trim().to_string(),
578                });
579            }
580
581            result.push(Tag {
582                id: None,
583                name: String::from("橘子"),
584            });
585            result.push(Tag {
586                id: None,
587                name: String::from("变身"),
588            });
589            result.push(Tag {
590                id: None,
591                name: String::from("性转"),
592            });
593            result.push(Tag {
594                id: None,
595                name: String::from("纯百"),
596            });
597
598            Ok(result)
599        })
600        .await
601    }
602
603    async fn search_infos(
604        &self,
605        option: &Options,
606        page: u16,
607        size: u16,
608    ) -> Result<Option<Vec<u32>>, Error> {
609        let mut category_index = 0;
610        if option.category.is_some() {
611            category_index = option.category.as_ref().unwrap().id.unwrap();
612        }
613
614        let mut tags = Vec::new();
615        if option.tags.is_some() {
616            for tag in option.tags.as_ref().unwrap() {
617                tags.push(sonic_rs::json!({
618                    "tag": tag.name,
619                    "filter": "1"
620                }));
621            }
622        }
623
624        let is_paid = option.is_vip.map(|is_vip| if is_vip { 1 } else { 0 });
625
626        let up_status = option
627            .is_finished
628            .map(|is_finished| if is_finished { 1 } else { 0 });
629
630        let mut filter_word = None;
631        if option.word_count.is_some() {
632            match option.word_count.as_ref().unwrap() {
633                WordCountRange::RangeTo(range_to) => {
634                    if range_to.end < 30_0000 {
635                        filter_word = Some(1);
636                    }
637                }
638                WordCountRange::Range(range) => {
639                    if range.start >= 30_0000 && range.end < 50_0000 {
640                        filter_word = Some(2);
641                    } else if range.start >= 50_0000 && range.end < 100_0000 {
642                        filter_word = Some(3);
643                    } else if range.start >= 100_0000 && range.end < 200_0000 {
644                        filter_word = Some(4);
645                    }
646                }
647                WordCountRange::RangeFrom(range_from) => {
648                    if range_from.start >= 200_0000 {
649                        filter_word = Some(5);
650                    }
651                }
652            }
653        }
654
655        let mut filter_uptime = None;
656        if option.update_days.is_some() {
657            let update_days = *option.update_days.as_ref().unwrap();
658
659            if update_days <= 3 {
660                filter_uptime = Some(1)
661            } else if update_days <= 7 {
662                filter_uptime = Some(2)
663            } else if update_days <= 15 {
664                filter_uptime = Some(3)
665            } else if update_days <= 30 {
666                filter_uptime = Some(4)
667            }
668        }
669
670        let order = if option.keyword.is_some() {
671            // When using keyword search, many irrelevant items will appear in the search results
672            // If you use sorting, you will not be able to obtain the target items
673            None
674        } else {
675            // 人气排序
676            Some("week_click")
677        };
678
679        let response: SearchResponse = self
680            .post(
681                "/bookcity/get_filter_search_book_list",
682                SearchRequest {
683                    count: size,
684                    page,
685                    order,
686                    category_index,
687                    tags: sonic_rs::json!(tags).to_string(),
688                    key: option.keyword.clone(),
689                    is_paid,
690                    up_status,
691                    filter_uptime,
692                    filter_word,
693                },
694            )
695            .await?;
696        utils::check_response_success(response.code, response.tip)?;
697
698        let book_list = response.data.unwrap().book_list;
699        if book_list.is_empty() {
700            return Ok(None);
701        }
702
703        let mut result = Vec::new();
704        let sys_tags = self.tags().await?;
705
706        for novel_info in book_list {
707            let mut tag_names = Vec::new();
708            for tag in novel_info.tag_list {
709                if let Some(sys_tag) = sys_tags.iter().find(|x| x.name == tag.tag_name.trim()) {
710                    tag_names.push(sys_tag.name.clone());
711                }
712            }
713
714            if CiweimaoClient::match_update_days(option, novel_info.uptime)
715                && CiweimaoClient::match_excluded_tags(option, tag_names)
716                && CiweimaoClient::match_word_count(option, novel_info.total_word_count.parse()?)
717            {
718                result.push(novel_info.book_id.parse()?);
719            }
720        }
721
722        Ok(Some(result))
723    }
724
725    fn has_this_type_of_comments(comment_type: CommentType) -> bool {
726        match comment_type {
727            CommentType::Short => true,
728            CommentType::Long => true,
729        }
730    }
731}
732
733#[must_use]
734enum VerifyType {
735    None,
736    Geetest,
737    VerifyCode,
738}
739
740impl CiweimaoClient {
741    async fn review_comment(
742        &self,
743        review_id: u32,
744        comment_amount: u16,
745    ) -> Result<Option<Vec<ShortComment>>, Error> {
746        let response: ReviewCommentResponse = self
747            .post(
748                "/book/get_review_comment_list",
749                ReviewCommentRequest {
750                    review_id,
751                    page: 0,
752                    count: comment_amount,
753                },
754            )
755            .await?;
756        utils::check_response_success(response.code, response.tip)?;
757        let review_comment_list = response.data.unwrap().review_comment_list;
758
759        let mut result = Vec::with_capacity(review_comment_list.len());
760
761        for comment in review_comment_list {
762            let Some(content) = super::parse_multi_line(comment.comment_content) else {
763                continue;
764            };
765
766            let replies = if comment.review_comment_reply_list.is_empty() {
767                None
768            } else if
769            // 返回的最大数量似乎为 3 个
770            comment.review_comment_reply_list.len() <= 2 {
771                let mut result = Vec::with_capacity(2);
772
773                for reply in comment.review_comment_reply_list {
774                    let Some(content) = super::parse_multi_line(reply.reply_content) else {
775                        continue;
776                    };
777
778                    result.push(ShortComment {
779                        id: reply.reply_id.parse()?,
780                        user: UserInfo {
781                            nickname: reply.reader_info.reader_name.trim().to_string(),
782                            avatar: reply.reader_info.avatar_url,
783                        },
784                        content,
785                        create_time: Some(reply.ctime),
786                        like_count: None,
787                        replies: None,
788                    })
789                }
790
791                if result.is_empty() {
792                    None
793                } else {
794                    result.sort_unstable_by_key(|x| x.create_time.unwrap());
795                    result.dedup();
796                    Some(result)
797                }
798            } else {
799                self.review_comment_reply(comment.comment_id.parse()?)
800                    .await?
801            };
802
803            result.push(ShortComment {
804                id: comment.comment_id.parse()?,
805                user: UserInfo {
806                    nickname: comment.reader_info.reader_name.trim().to_string(),
807                    avatar: comment.reader_info.avatar_url,
808                },
809                content,
810                create_time: Some(comment.ctime),
811                like_count: None,
812                replies,
813            });
814        }
815
816        if result.is_empty() {
817            Ok(None)
818        } else {
819            result.sort_unstable_by_key(|x| x.create_time.unwrap());
820            result.dedup();
821            Ok(Some(result))
822        }
823    }
824
825    async fn review_comment_reply(
826        &self,
827        comment_id: u32,
828    ) -> Result<Option<Vec<ShortComment>>, Error> {
829        let response: ReviewCommentReplyResponse = self
830            .post(
831                "/book/get_review_comment_reply_list",
832                ReviewCommentReplyRequest {
833                    comment_id,
834                    page: 0,
835                    count: 9999,
836                },
837            )
838            .await?;
839        utils::check_response_success(response.code, response.tip)?;
840
841        let mut result = Vec::with_capacity(4);
842
843        for reply in response.data.unwrap().review_comment_reply_list {
844            let Some(content) = super::parse_multi_line(reply.reply_content) else {
845                continue;
846            };
847
848            let comment = ShortComment {
849                id: reply.reply_id.parse()?,
850                user: UserInfo {
851                    nickname: reply.reader_info.reader_name.trim().to_string(),
852                    avatar: reply.reader_info.avatar_url,
853                },
854                content,
855                create_time: Some(reply.ctime),
856                like_count: None,
857                replies: None,
858            };
859
860            result.push(comment);
861        }
862
863        if result.is_empty() {
864            Ok(None)
865        } else {
866            result.sort_unstable_by_key(|x| x.create_time.unwrap());
867            result.dedup();
868            Ok(Some(result))
869        }
870    }
871
872    async fn verify_type<T>(&self, username: T) -> Result<VerifyType, Error>
873    where
874        T: AsRef<str>,
875    {
876        let response: UseGeetestResponse = self
877            .post(
878                "/signup/use_geetest",
879                UseGeetestRequest {
880                    login_name: username.as_ref().to_string(),
881                },
882            )
883            .await?;
884        utils::check_response_success(response.code, response.tip)?;
885
886        let need_use_geetest = response.data.unwrap().need_use_geetest;
887        if need_use_geetest == "0" {
888            Ok(VerifyType::None)
889        } else if need_use_geetest == "1" {
890            Ok(VerifyType::Geetest)
891        } else if need_use_geetest == "2" {
892            Ok(VerifyType::VerifyCode)
893        } else {
894            unreachable!("The value range of need_use_geetest is 0..=2");
895        }
896    }
897
898    async fn no_verification_login(
899        &self,
900        username: String,
901        password: String,
902    ) -> Result<Config, Error> {
903        let response: LoginResponse = self
904            .post(
905                "/signup/login",
906                LoginRequest {
907                    login_name: username.clone(),
908                    passwd: CiweimaoClient::rsa_encrypt(&password)?,
909                    sign: CiweimaoClient::rsa_encrypt(&format!("{username}_{password}"))?,
910                },
911            )
912            .await?;
913        utils::check_response_success(response.code, response.tip)?;
914
915        let data = response.data.unwrap();
916
917        Ok(Config {
918            account: data.reader_info.account,
919            login_token: data.login_token,
920            reader_id: data.reader_info.reader_id.parse().unwrap(),
921        })
922    }
923
924    async fn geetest_login(&self, username: String, password: String) -> Result<Config, Error> {
925        let info = self.geetest_info(&username).await?;
926        let geetest_challenge = info.challenge.clone();
927
928        let validate = if info.success == 1 {
929            server::run_geetest(info).await?
930        } else {
931            geetest_challenge.clone()
932        };
933
934        let response: LoginResponse = self
935            .post(
936                "/signup/login",
937                LoginCaptchaRequest {
938                    login_name: username.clone(),
939                    passwd: CiweimaoClient::rsa_encrypt(&password)?,
940                    sign: CiweimaoClient::rsa_encrypt(&format!("{username}_{password}"))?,
941                    geetest_seccode: validate.clone() + "|jordan",
942                    geetest_validate: validate,
943                    geetest_challenge,
944                },
945            )
946            .await?;
947        utils::check_response_success(response.code, response.tip)?;
948
949        let data = response.data.unwrap();
950
951        Ok(Config {
952            account: data.reader_info.account,
953            login_token: data.login_token,
954            reader_id: data.reader_info.reader_id.parse().unwrap(),
955        })
956    }
957
958    async fn geetest_info<T>(&self, username: T) -> Result<GeetestInfoResponse, Error>
959    where
960        T: AsRef<str>,
961    {
962        let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
963
964        let response: GeetestInfoResponse = self
965            .get_query(
966                "/signup/geetest_first_register",
967                GeetestInfoRequest {
968                    t: timestamp,
969                    user_id: username.as_ref().to_string(),
970                },
971            )
972            .await?;
973
974        Ok(response)
975    }
976
977    async fn sms_login(&self, username: String, password: String) -> Result<Config, Error> {
978        let timestamp = SystemTime::now()
979            .duration_since(UNIX_EPOCH)
980            .unwrap()
981            .as_millis();
982
983        let response: SendVerifyCodeResponse = self
984            .post(
985                "/signup/send_verify_code",
986                SendVerifyCodeRequest {
987                    login_name: username.clone(),
988                    timestamp,
989                    // always 5
990                    verify_type: 5,
991                    hashvalue: self.hashvalue(timestamp),
992                },
993            )
994            .await?;
995        utils::check_response_success(response.code, response.tip)?;
996
997        let response: LoginResponse = self
998            .post(
999                "/signup/login",
1000                LoginSMSRequest {
1001                    login_name: username.clone(),
1002                    passwd: CiweimaoClient::rsa_encrypt(&password)?,
1003                    sign: CiweimaoClient::rsa_encrypt(&format!("{username}_{password}"))?,
1004                    to_code: response.data.unwrap().to_code,
1005                    ver_code: crate::input("Please enter SMS verification code")?,
1006                },
1007            )
1008            .await?;
1009        utils::check_response_success(response.code, response.tip)?;
1010
1011        let data = response.data.unwrap();
1012
1013        Ok(Config {
1014            account: data.reader_info.account,
1015            login_token: data.login_token,
1016            reader_id: data.reader_info.reader_id.parse().unwrap(),
1017        })
1018    }
1019
1020    async fn shelf_list(&self) -> Result<Vec<u32>, Error> {
1021        let response: ShelfListResponse = self
1022            .post("/bookshelf/get_shelf_list", EmptyRequest {})
1023            .await?;
1024        utils::check_response_success(response.code, response.tip)?;
1025
1026        let mut result = Vec::new();
1027        for shelf in response.data.unwrap().shelf_list {
1028            result.push(shelf.shelf_id.parse()?);
1029        }
1030
1031        Ok(result)
1032    }
1033
1034    async fn chapter_prices(&self, novel_id: u32) -> Result<HashMap<u32, u16>, Error> {
1035        let response: PriceResponse = self
1036            .post(
1037                "/chapter/get_chapter_permission_list",
1038                PriceRequest { book_id: novel_id },
1039            )
1040            .await?;
1041        utils::check_response_success(response.code, response.tip)?;
1042        let chapter_permission_list = response.data.unwrap().chapter_permission_list;
1043
1044        let mut result = HashMap::new();
1045
1046        for item in chapter_permission_list {
1047            result.insert(item.chapter_id.parse()?, item.unit_hlb.parse()?);
1048        }
1049
1050        Ok(result)
1051    }
1052
1053    async fn check_download_cpt(&self) -> Result<(), Error> {
1054        let response: GenericResponse = self
1055            .post("/chapter/check_download_cpt", EmptyRequest {})
1056            .await?;
1057        if response.code == CiweimaoClient::NEED_TO_UPGRADE_VERSION {
1058            // NOTE 当前 success 总是 0,无法正常使用验证码
1059            let _ = self.geetest_info(self.try_account()).await?;
1060        } else {
1061            utils::check_response_success(response.code, response.tip)?;
1062        }
1063
1064        Ok(())
1065    }
1066
1067    async fn chapter_cmd(&self, ids: &[u32]) -> Result<String, Error> {
1068        let response: ChapterCmdResponse = self
1069            .post(
1070                "/chapter/get_chapter_download_cmd",
1071                ChapterCmdRequest {
1072                    chapter_id: itertools::join(ids, ","),
1073                },
1074            )
1075            .await?;
1076        utils::check_response_success(response.code, response.tip)?;
1077
1078        Ok(response.data.unwrap().command)
1079    }
1080
1081    fn match_update_days(option: &Options, update_time: NaiveDateTime) -> bool {
1082        if option.update_days.is_none() {
1083            return true;
1084        }
1085
1086        let other_time = Shanghai.from_local_datetime(&update_time).unwrap()
1087            + Duration::try_days(*option.update_days.as_ref().unwrap() as i64).unwrap();
1088
1089        Local::now() <= other_time
1090    }
1091
1092    fn match_word_count(option: &Options, word_count: u32) -> bool {
1093        if option.word_count.is_none() {
1094            return true;
1095        }
1096
1097        match option.word_count.as_ref().unwrap() {
1098            WordCountRange::RangeTo(range_to) => word_count <= range_to.end,
1099            WordCountRange::Range(range) => range.start <= word_count && word_count <= range.end,
1100            WordCountRange::RangeFrom(range_from) => range_from.start <= word_count,
1101        }
1102    }
1103
1104    fn match_excluded_tags(option: &Options, tag_ids: Vec<String>) -> bool {
1105        if option.excluded_tags.is_none() {
1106            return true;
1107        }
1108
1109        tag_ids.iter().all(|name| {
1110            !option
1111                .excluded_tags
1112                .as_ref()
1113                .unwrap()
1114                .iter()
1115                .any(|tag| tag.name == *name)
1116        })
1117    }
1118
1119    fn parse_url<T>(str: T) -> Option<Url>
1120    where
1121        T: AsRef<str>,
1122    {
1123        let str = str.as_ref();
1124        if str.is_empty() {
1125            return None;
1126        }
1127
1128        match Url::parse(str) {
1129            Ok(url) => Some(url),
1130            Err(error) => {
1131                tracing::error!("Url parse failed: {error}, content: {str}");
1132                None
1133            }
1134        }
1135    }
1136
1137    async fn parse_tags(&self, tag_list: Vec<NovelInfoTag>) -> Result<Option<Vec<Tag>>, Error> {
1138        let sys_tags = self.tags().await?;
1139
1140        let mut result = Vec::new();
1141        for tag in tag_list {
1142            let name = tag.tag_name.trim().to_string();
1143
1144            // Remove non-system tags
1145            if sys_tags.iter().any(|item| item.name == name) {
1146                result.push(Tag { id: None, name });
1147            } else {
1148                tracing::info!("This tag is not a system tag and is ignored: {name}");
1149            }
1150        }
1151
1152        if result.is_empty() {
1153            Ok(None)
1154        } else {
1155            Ok(Some(result))
1156        }
1157    }
1158
1159    async fn parse_category<T>(&self, str: T) -> Result<Option<Category>, Error>
1160    where
1161        T: AsRef<str>,
1162    {
1163        let str = str.as_ref();
1164        if str.is_empty() {
1165            return Ok(None);
1166        }
1167
1168        let categories = self.categories().await?;
1169
1170        match str.parse::<u16>() {
1171            Ok(index) => match categories.iter().find(|item| item.id == Some(index)) {
1172                Some(category) => Ok(Some(category.clone())),
1173                None => {
1174                    tracing::error!("The category index does not exist: {str}");
1175                    Ok(None)
1176                }
1177            },
1178            Err(error) => {
1179                tracing::error!("category_index parse failed: {error}");
1180                Ok(None)
1181            }
1182        }
1183    }
1184
1185    fn parse_image_url<T>(str: T) -> Option<Url>
1186    where
1187        T: AsRef<str>,
1188    {
1189        let str = str.as_ref();
1190        if str.is_empty() {
1191            return None;
1192        }
1193
1194        let fragment = Html::parse_fragment(str);
1195        let selector = Selector::parse("img").unwrap();
1196
1197        let element = fragment.select(&selector).next();
1198        if element.is_none() {
1199            tracing::error!("No `img` element exists: {str}");
1200            return None;
1201        }
1202        let element = element.unwrap();
1203
1204        let url = element.value().attr("src");
1205        if url.is_none() {
1206            tracing::error!("No `src` attribute exists: {str}");
1207            return None;
1208        }
1209        let url = url.unwrap();
1210
1211        CiweimaoClient::parse_url(url.trim())
1212    }
1213}