Skip to main content

novel_api/ciweimao/
mod.rs

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