Skip to main content

novel_api/ciyuanji/
mod.rs

1mod structure;
2mod utils;
3
4use std::io::Cursor;
5use std::path::PathBuf;
6use std::sync::RwLock;
7
8use chrono::{Duration, Local, NaiveDateTime, TimeZone};
9use chrono_tz::Asia::Shanghai;
10use image::{DynamicImage, ImageReader};
11use serde::{Deserialize, Serialize};
12use tokio::sync::OnceCell;
13use url::Url;
14
15use self::structure::*;
16use crate::{
17    Category, ChapterInfo, Client, Comment, CommentType, ContentInfo, ContentInfos, Error,
18    FindImageResult, FindTextResult, HTTPClient, NovelDB, NovelInfo, Options, ShortComment, Tag,
19    UserInfo, VolumeInfo, VolumeInfos, WordCountRange,
20};
21
22#[must_use]
23#[derive(Serialize, Deserialize)]
24pub(crate) struct Config {
25    token: String,
26}
27
28/// Ciyuanji client, use it to access Apis
29#[must_use]
30pub struct CiyuanjiClient {
31    proxy: Option<Url>,
32    no_proxy: bool,
33    cert_path: Option<PathBuf>,
34
35    client: OnceCell<HTTPClient>,
36    client_rss: OnceCell<HTTPClient>,
37
38    db: OnceCell<NovelDB>,
39
40    config: RwLock<Option<Config>>,
41}
42
43impl Client for CiyuanjiClient {
44    fn proxy(&mut self, proxy: Url) {
45        self.proxy = Some(proxy);
46    }
47
48    fn no_proxy(&mut self) {
49        self.no_proxy = true;
50    }
51
52    fn cert(&mut self, cert_path: PathBuf) {
53        self.cert_path = Some(cert_path);
54    }
55
56    async fn shutdown(&self) -> Result<(), Error> {
57        self.client().await?.save_cookies()?;
58        self.do_shutdown()?;
59        Ok(())
60    }
61
62    async fn add_cookie(&self, cookie_str: &str, url: &Url) -> Result<(), Error> {
63        self.client().await?.add_cookie(cookie_str, url)
64    }
65
66    async fn log_in(&self, username: String, password: Option<String>) -> Result<(), Error> {
67        assert!(!username.is_empty());
68        assert!(password.is_none());
69
70        let response: GenericResponse = self
71            .post(
72                "/login/getPhoneCode",
73                PhoneCodeRequest {
74                    phone: username.clone(),
75                    // always 1
76                    sms_type: "1",
77                },
78            )
79            .await?;
80        utils::check_response_success(response.code, response.msg, response.ok)?;
81
82        let response: LoginResponse = self
83            .post(
84                "/login/phone",
85                LoginRequest {
86                    phone: username,
87                    phone_code: crate::input("Please enter SMS verification code")?,
88                },
89            )
90            .await?;
91        utils::check_response_success(response.code, response.msg, response.ok)?;
92
93        self.save_token(Config {
94            token: response.data.unwrap().user_info.unwrap().token,
95        });
96
97        Ok(())
98    }
99
100    async fn logged_in(&self) -> Result<bool, Error> {
101        if !self.has_token() {
102            return Ok(false);
103        }
104
105        let response: GenericResponse = self.get("/user/getUserInfo").await?;
106
107        if response.code == CiyuanjiClient::FAILED {
108            Ok(false)
109        } else {
110            utils::check_response_success(response.code, response.msg, response.ok)?;
111            Ok(true)
112        }
113    }
114
115    async fn user_info(&self) -> Result<UserInfo, Error> {
116        let response: UserInfoResponse = self.get("/user/getUserInfo").await?;
117        utils::check_response_success(response.code, response.msg, response.ok)?;
118        let cm_user = response.data.unwrap().cm_user.unwrap();
119
120        let user_info = UserInfo {
121            nickname: cm_user.nick_name.trim().to_string(),
122            avatar: Some(cm_user.img_url),
123        };
124
125        Ok(user_info)
126    }
127
128    async fn money(&self) -> Result<u32, Error> {
129        let response: MoneyResponse = self.get("/account/getAccountByUser").await?;
130        utils::check_response_success(response.code, response.msg, response.ok)?;
131        let account_info = response.data.unwrap().account_info.unwrap();
132
133        Ok(account_info.currency_balance + account_info.coupon_balance)
134    }
135
136    async fn sign_in(&self) -> Result<(), Error> {
137        let response: GenericResponse = self.post("/sign/sign", EmptyRequest {}).await?;
138        if utils::check_already_signed_in(&response.code, &response.msg) {
139            tracing::info!("{}", CiyuanjiClient::ALREADY_SIGNED_IN_MSG);
140        } else {
141            utils::check_response_success(response.code, response.msg, response.ok)?;
142        }
143
144        Ok(())
145    }
146
147    async fn bookshelf_infos(&self) -> Result<Vec<u32>, Error> {
148        let response: BookSelfResponse = self
149            .get_query(
150                "/bookrack/getUserBookRackList",
151                BookSelfRequest {
152                    page_no: 1,
153                    page_size: 9999,
154                    // 1 阅读
155                    // 2 更新
156                    rank_type: 1,
157                },
158            )
159            .await?;
160        utils::check_response_success(response.code, response.msg, response.ok)?;
161
162        let mut result = Vec::new();
163
164        for item in response.data.unwrap().book_rack_list.unwrap() {
165            result.push(item.book_id);
166        }
167
168        Ok(result)
169    }
170
171    async fn novel_info(&self, id: u32) -> Result<Option<NovelInfo>, Error> {
172        assert!(id > 0);
173
174        let response: BookDetailResponse = self
175            .get_query(
176                "/book/getBookDetail",
177                BookDetailRequest {
178                    book_id: id.to_string(),
179                },
180            )
181            .await?;
182        utils::check_response_success(response.code, response.msg, response.ok)?;
183        let data = response.data.unwrap();
184
185        if data.book.is_none() {
186            return Ok(None);
187        }
188
189        let book = data.book.unwrap();
190
191        // 该书不存在
192        if book.book_id == 0 {
193            return Ok(None);
194        }
195
196        let category = if let Some(second_classify) = book.second_classify {
197            Some(Category {
198                id: Some(second_classify),
199                parent_id: Some(book.first_classify.unwrap()),
200                name: format!(
201                    "{}-{}",
202                    book.first_classify_name.unwrap().trim(),
203                    book.second_classify_name.unwrap().trim()
204                ),
205            })
206        } else if let Some(first_classify) = book.first_classify {
207            Some(Category {
208                id: Some(first_classify),
209                parent_id: None,
210                name: book.first_classify_name.unwrap().trim().to_string(),
211            })
212        } else {
213            None
214        };
215
216        let novel_info = NovelInfo {
217            id,
218            name: book.book_name.unwrap().trim().to_string(),
219            author_name: book.author_name.unwrap().trim().to_string(),
220            cover_url: book.img_url,
221            introduction: super::parse_multi_line(book.notes.unwrap()),
222            word_count: CiyuanjiClient::parse_word_count(book.word_count),
223            is_vip: Some(book.is_vip.unwrap() == "1"),
224            is_finished: Some(book.end_state.unwrap() == "1"),
225            create_time: None,
226            update_time: book.latest_update_time,
227            category,
228            tags: self.parse_tags(book.tag_list.unwrap()).await?,
229        };
230
231        Ok(Some(novel_info))
232    }
233
234    async fn comments(
235        &self,
236        id: u32,
237        comment_type: CommentType,
238        need_replies: bool,
239        page: u16,
240        size: u16,
241    ) -> Result<Option<Vec<Comment>>, Error> {
242        assert!(matches!(comment_type, CommentType::Short));
243
244        let response: PostListResponse = self
245            .get_query(
246                "/apppost/getAppPostListByCircleId",
247                PostListRequest {
248                    circle_id: id.to_string(),
249                    circle_type: 1,
250                    page_no: page + 1,
251                    page_size: size,
252                    // 2 最新
253                    // 3 最热
254                    rank_type: 2,
255                },
256            )
257            .await?;
258        utils::check_response_success(response.code, response.msg, response.ok)?;
259        let data = response.data.unwrap().list.unwrap();
260
261        if data.is_empty() {
262            return Ok(None);
263        }
264
265        let mut result = Vec::with_capacity(data.len());
266
267        for post in data {
268            if need_replies {
269                if let Some(post) = self.post_detail(post.post_id).await? {
270                    result.push(Comment::Short(post));
271                }
272            } else {
273                let Some(content) = super::parse_multi_line(post.post_content) else {
274                    continue;
275                };
276
277                result.push(Comment::Short(ShortComment {
278                    id: post.post_id,
279                    user: UserInfo {
280                        nickname: post.nick_name.trim().to_string(),
281                        avatar: Some(post.user_img_url),
282                    },
283                    content,
284                    create_time: Some(post.create_time),
285                    like_count: Some(post.thumb_num),
286                    replies: None,
287                }));
288            }
289        }
290
291        Ok(Some(result))
292    }
293
294    async fn volume_infos(&self, id: u32) -> Result<Option<VolumeInfos>, Error> {
295        let response: ChapterListResponse = self
296            .get_query(
297                "/chapter/getChapterListByBookId",
298                VolumeInfosRequest {
299                    // 1 正序
300                    // 2 倒序
301                    sort_type: "1",
302                    page_no: "1",
303                    page_size: "9999",
304                    book_id: id.to_string(),
305                },
306            )
307            .await?;
308        utils::check_response_success(response.code, response.msg, response.ok)?;
309
310        let mut volumes = VolumeInfos::new();
311
312        let mut last_volume_id = 0;
313        let book_chapter = response.data.unwrap().book_chapter.unwrap();
314
315        let book_id = book_chapter.book_id;
316
317        if let Some(chapter_list) = book_chapter.chapter_list {
318            for chapter in chapter_list {
319                let volume_title = chapter.title.unwrap_or_default().trim().to_string();
320
321                if chapter.volume_id != last_volume_id {
322                    last_volume_id = chapter.volume_id;
323
324                    volumes.push(VolumeInfo {
325                        id: chapter.volume_id,
326                        title: volume_title.clone(),
327                        chapter_infos: Vec::new(),
328                    });
329                }
330
331                let last_volume_title = &mut volumes.last_mut().unwrap().title;
332                if last_volume_title.is_empty() && !volume_title.is_empty() {
333                    *last_volume_title = volume_title;
334                }
335
336                let chapter_info = ChapterInfo {
337                    novel_id: Some(book_id),
338                    id: chapter.chapter_id,
339                    title: chapter.chapter_name.trim().to_string(),
340                    is_vip: Some(chapter.is_fee == "1"),
341                    // 去除小数部分
342                    price: Some(chapter.price.parse::<f64>().unwrap() as u16),
343                    payment_required: Some(
344                        chapter.is_fee == "1" && chapter.is_buy == "0",
345                    ),
346                    is_valid: None,
347                    word_count: /*CiyuanjiClient::parse_word_count(chapter.word_count)*/Some(chapter.word_count),
348                    create_time: Some(chapter.publish_time),
349                    update_time: None,
350                };
351
352                volumes.last_mut().unwrap().chapter_infos.push(chapter_info);
353            }
354        }
355
356        Ok(Some(volumes))
357    }
358
359    async fn content_infos(&self, info: &ChapterInfo) -> Result<ContentInfos, Error> {
360        let mut content;
361
362        match self.db().await?.find_text(info).await? {
363            FindTextResult::Ok(str) => {
364                content = str;
365            }
366            other => {
367                let response: ContentResponse = self
368                    .get_query(
369                        "/chapter/getChapterContent",
370                        ContentRequest {
371                            book_id: info.novel_id.unwrap().to_string(),
372                            chapter_id: info.id.to_string(),
373                        },
374                    )
375                    .await?;
376                utils::check_response_success(response.code, response.msg, response.ok)?;
377                let chapter = response.data.unwrap().chapter.unwrap();
378
379                content = crate::des_ecb_base64_decrypt(
380                    CiyuanjiClient::DES_KEY,
381                    chapter.content.replace('\n', ""),
382                )?;
383
384                if content.trim().is_empty() {
385                    return Err(Error::NovelApi(String::from("Content is empty")));
386                }
387
388                if chapter.img_list.as_ref().is_some_and(|x| !x.is_empty()) {
389                    let mut content_lines: Vec<_> =
390                        content.lines().map(|x| x.to_string()).collect();
391
392                    for img in chapter.img_list.as_ref().unwrap() {
393                        let image_str = format!("[img]{}[/img]", img.img_url);
394
395                        // 被和谐章节可能图片依旧存在,但是对应的段落已经被删除
396                        if img.paragraph_index > content_lines.len() {
397                            tracing::warn!(
398                                "The paragraph index of the image is greater than the number of paragraphs: {} > {}, in {}({}), image will be inserted at the end",
399                                img.paragraph_index,
400                                content_lines.len(),
401                                info.title,
402                                info.id
403                            );
404
405                            content_lines.push(image_str);
406                        } else {
407                            content_lines.insert(img.paragraph_index, image_str);
408                        }
409                    }
410
411                    content = content_lines.join("\n");
412                }
413
414                match other {
415                    FindTextResult::None => self.db().await?.insert_text(info, &content).await?,
416                    FindTextResult::Outdate => self.db().await?.update_text(info, &content).await?,
417                    FindTextResult::Ok(_) => (),
418                }
419            }
420        }
421
422        let mut content_infos = ContentInfos::new();
423        for line in content
424            .lines()
425            .map(|line| line.trim())
426            .filter(|line| !line.is_empty())
427        {
428            if line.starts_with("[img") {
429                if let Some(url) = CiyuanjiClient::parse_image_url(line) {
430                    content_infos.push(ContentInfo::Image(url));
431                }
432            } else {
433                content_infos.push(ContentInfo::Text(line.to_string()));
434            }
435        }
436
437        Ok(content_infos)
438    }
439
440    async fn content_infos_multiple(
441        &self,
442        infos: &[ChapterInfo],
443    ) -> Result<Vec<ContentInfos>, Error> {
444        let mut result = Vec::new();
445
446        for info in infos {
447            result.push(self.content_infos(info).await?);
448        }
449
450        Ok(result)
451    }
452
453    async fn order_chapter(&self, info: &ChapterInfo) -> Result<(), Error> {
454        let response: GenericResponse = self
455            .post(
456                "/order/consume",
457                OrderChapterRequest {
458                    // always 2
459                    view_type: "2",
460                    // always 1
461                    consume_type: "1",
462                    book_id: info.novel_id.unwrap().to_string(),
463                    product_id: info.id.to_string(),
464                    buy_count: "1",
465                },
466            )
467            .await?;
468        if utils::check_already_ordered(&response.code, &response.msg) {
469            tracing::info!("{}", CiyuanjiClient::ALREADY_ORDERED_MSG);
470        } else {
471            utils::check_response_success(response.code, response.msg, response.ok)?;
472        }
473
474        Ok(())
475    }
476
477    async fn order_novel(&self, id: u32, _: &VolumeInfos) -> Result<(), Error> {
478        assert!(id > 0);
479
480        let response: GenericResponse = self
481            .post(
482                "/order/consume",
483                OrderNovelRequest {
484                    // always 2
485                    view_type: "2",
486                    // always 1
487                    consume_type: "1",
488                    book_id: id.to_string(),
489                    is_all_unpaid: "1",
490                },
491            )
492            .await?;
493        if utils::check_already_ordered(&response.code, &response.msg) {
494            tracing::info!("{}", CiyuanjiClient::ALREADY_ORDERED_MSG);
495        } else {
496            utils::check_response_success(response.code, response.msg, response.ok)?;
497        }
498
499        Ok(())
500    }
501
502    async fn image(&self, url: &Url) -> Result<DynamicImage, Error> {
503        match self.db().await?.find_image(url).await? {
504            FindImageResult::Ok(image) => Ok(image),
505            FindImageResult::None => {
506                let response = self.get_rss(url).await?;
507                let bytes = response.bytes().await?;
508
509                let image = ImageReader::new(Cursor::new(&bytes))
510                    .with_guessed_format()?
511                    .decode()?;
512
513                self.db().await?.insert_image(url, bytes).await?;
514
515                Ok(image)
516            }
517        }
518    }
519
520    async fn categories(&self) -> Result<&Vec<Category>, Error> {
521        static CATEGORIES: OnceCell<Vec<Category>> = OnceCell::const_new();
522
523        CATEGORIES
524            .get_or_try_init(|| async {
525                let mut result = Vec::with_capacity(32);
526
527                // 1 男生
528                // 2 漫画
529                // 4 女生
530                self.get_categories("1", &mut result).await?;
531                self.get_categories("4", &mut result).await?;
532
533                result.sort_unstable_by_key(|x| x.id.unwrap());
534                result.dedup();
535                Ok(result)
536            })
537            .await
538    }
539
540    async fn tags(&self) -> Result<&Vec<Tag>, Error> {
541        static TAGS: OnceCell<Vec<Tag>> = OnceCell::const_new();
542
543        TAGS.get_or_try_init(|| async {
544            let mut result = Vec::with_capacity(64);
545
546            self.get_tags(1, &mut result).await?;
547            self.get_tags(4, &mut result).await?;
548
549            result.push(Tag {
550                id: Some(17),
551                name: String::from("无限流"),
552            });
553            result.push(Tag {
554                id: Some(19),
555                name: String::from("后宫"),
556            });
557            result.push(Tag {
558                id: Some(26),
559                name: String::from("变身"),
560            });
561            result.push(Tag {
562                id: Some(30),
563                name: String::from("百合"),
564            });
565            result.push(Tag {
566                id: Some(96),
567                name: String::from("变百"),
568            });
569            result.push(Tag {
570                id: Some(127),
571                name: String::from("性转"),
572            });
573            result.push(Tag {
574                id: Some(249),
575                name: String::from("吸血鬼"),
576            });
577            result.push(Tag {
578                id: Some(570),
579                name: String::from("纯百"),
580            });
581            result.push(Tag {
582                id: Some(1431),
583                name: String::from("复仇"),
584            });
585            result.push(Tag {
586                id: Some(1512),
587                name: String::from("魔幻"),
588            });
589            result.push(Tag {
590                id: Some(5793),
591                name: String::from("少女"),
592            });
593
594            result.sort_unstable_by_key(|x| x.id.unwrap());
595            result.dedup();
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        if option.keyword.is_some() {
608            self.do_search_with_keyword(option, page, size).await
609        } else {
610            self.do_search_without_keyword(option, page, size).await
611        }
612    }
613
614    fn has_this_type_of_comments(comment_type: CommentType) -> bool {
615        match comment_type {
616            CommentType::Short => true,
617            CommentType::Long => false,
618        }
619    }
620}
621
622impl CiyuanjiClient {
623    pub async fn post_detail(&self, post_id: u32) -> Result<Option<ShortComment>, Error> {
624        let response: PostDetailResponse = self
625            .get_query(
626                "/apppost/getApppostDetail",
627                PostDetailRequest {
628                    post_id,
629                    page_no: 1,
630                    page_size: 9999,
631                    // 2 最新
632                    // 3 最热
633                    rank_type: 2,
634                },
635            )
636            .await?;
637        utils::check_response_success(response.code, response.msg, response.ok)?;
638
639        let post_detail = response.data.unwrap();
640        let Some(content) = super::parse_multi_line(post_detail.post_content) else {
641            return Ok(None);
642        };
643
644        let replies = if !post_detail.list.is_empty() {
645            let mut replies = Vec::with_capacity(post_detail.list.len());
646
647            for reply in post_detail.list {
648                let Some(content) = super::parse_multi_line(reply.reply_content) else {
649                    continue;
650                };
651
652                replies.push(ShortComment {
653                    id: reply.reply_id,
654                    user: UserInfo {
655                        nickname: reply.nick_name.trim().to_string(),
656                        avatar: Some(reply.user_img_url),
657                    },
658                    content,
659                    create_time: Some(reply.create_time),
660                    like_count: Some(reply.thumb_num),
661                    replies: if reply.reply_num == 0 {
662                        None
663                    } else {
664                        self.reply_list(reply.reply_id, reply.reply_num).await?
665                    },
666                });
667            }
668
669            if replies.is_empty() {
670                None
671            } else {
672                replies.sort_unstable_by_key(|x| x.create_time.unwrap());
673                replies.dedup();
674                Some(replies)
675            }
676        } else {
677            None
678        };
679
680        Ok(Some(ShortComment {
681            id: post_id,
682            user: UserInfo {
683                nickname: post_detail.nick_name.trim().to_string(),
684                avatar: Some(post_detail.user_img_url),
685            },
686            content,
687            create_time: Some(post_detail.create_time),
688            like_count: Some(post_detail.thumb_num),
689            replies,
690        }))
691    }
692
693    pub async fn reply_list(
694        &self,
695        reply_id: u32,
696        reply_num: u16,
697    ) -> Result<Option<Vec<ShortComment>>, Error> {
698        let response: ReplyListResponse = self
699            .get_query(
700                "/reply/getReplyList",
701                ReplyListRequest {
702                    reply_id,
703                    page_size: reply_num,
704                },
705            )
706            .await?;
707        utils::check_response_success(response.code, response.msg, response.ok)?;
708
709        let mut result = Vec::with_capacity(reply_num as usize);
710
711        for reply in response.data.unwrap().list.unwrap() {
712            let Some(mut content) = super::parse_multi_line(reply.reply_content) else {
713                continue;
714            };
715
716            if let Some(last_nick_name) = reply.last_nick_name {
717                content
718                    .first_mut()
719                    .unwrap()
720                    .insert_str(0, &format!("回复@{last_nick_name}  "));
721            }
722
723            result.push(ShortComment {
724                id: reply.reply_id,
725                user: UserInfo {
726                    nickname: reply.nick_name.trim().to_string(),
727                    avatar: Some(reply.user_img_url),
728                },
729                content,
730                create_time: Some(reply.create_time),
731                like_count: Some(reply.thumb_num),
732                replies: None,
733            });
734        }
735
736        if result.is_empty() {
737            Ok(None)
738        } else {
739            result.sort_unstable_by_key(|x| x.create_time.unwrap());
740            result.dedup();
741            Ok(Some(result))
742        }
743    }
744
745    async fn do_search_with_keyword(
746        &self,
747        option: &Options,
748        page: u16,
749        size: u16,
750    ) -> Result<Option<Vec<u32>>, Error> {
751        let (start_word, end_word) = CiyuanjiClient::to_word(option);
752        let (first_classify, _) = CiyuanjiClient::to_classify_ids(option);
753
754        let response: SearchBookListResponse = self
755            .get_query(
756                "/book/searchBookList",
757                SearchBookListRequest {
758                    page_no: page + 1,
759                    page_size: size,
760                    // 0 按推荐
761                    // 1 按人气
762                    // 2 按销量
763                    // 3 按更新
764                    rank_type: "0",
765                    keyword: option.keyword.as_ref().unwrap().to_string(),
766                    is_fee: CiyuanjiClient::to_is_fee(option),
767                    end_state: CiyuanjiClient::to_end_state(option),
768                    start_word,
769                    end_word,
770                    classify_ids: first_classify,
771                },
772            )
773            .await?;
774        utils::check_response_success(response.code, response.msg, response.ok)?;
775        let es_book_list = response.data.unwrap().es_book_list.unwrap();
776
777        if es_book_list.is_empty() {
778            return Ok(None);
779        }
780
781        let mut result = Vec::new();
782        let sys_tags = self.tags().await?;
783
784        for novel_info in es_book_list {
785            let mut tag_ids = Vec::new();
786
787            if let Some(tag_name) = novel_info.tag_name {
788                let tag_names: Vec<_> = tag_name
789                    .split(',')
790                    .map(|x| x.trim().to_string())
791                    .filter(|x| !x.is_empty())
792                    .collect();
793
794                for tag_name in tag_names {
795                    if let Some(tag) = sys_tags.iter().find(|x| x.name == tag_name) {
796                        tag_ids.push(tag.id.unwrap());
797                    }
798                }
799            }
800
801            if CiyuanjiClient::match_update_days(option, novel_info.latest_update_time)
802                && CiyuanjiClient::match_tags(option, &tag_ids)
803                && CiyuanjiClient::match_excluded_tags(option, &tag_ids)
804                && CiyuanjiClient::match_category(
805                    option,
806                    novel_info.first_classify,
807                    novel_info.second_classify,
808                )
809            {
810                result.push(novel_info.book_id);
811            }
812        }
813
814        Ok(Some(result))
815    }
816
817    async fn do_search_without_keyword(
818        &self,
819        option: &Options,
820        page: u16,
821        size: u16,
822    ) -> Result<Option<Vec<u32>>, Error> {
823        let (start_word, end_word) = CiyuanjiClient::to_word(option);
824        let (first_classify, second_classify) = CiyuanjiClient::to_classify_ids(option);
825
826        let response: BookListResponse = self
827            .get_query(
828                "/book/getBookListByParams",
829                BookListRequest {
830                    page_no: page + 1,
831                    page_size: size,
832                    // 1 人气最高
833                    // 2 订阅最多
834                    // 3 最近更新
835                    // 4 最近上架
836                    // 6 最近新书
837                    rank_type: "1",
838                    first_classify,
839                    second_classify,
840                    start_word,
841                    end_word,
842                    is_fee: CiyuanjiClient::to_is_fee(option),
843                    end_state: CiyuanjiClient::to_end_state(option),
844                },
845            )
846            .await?;
847        utils::check_response_success(response.code, response.msg, response.ok)?;
848        let book_list = response.data.unwrap().book_list.unwrap();
849
850        if book_list.is_empty() {
851            return Ok(None);
852        }
853
854        let mut result = Vec::new();
855        for novel_info in book_list {
856            let mut tag_ids = Vec::new();
857            if let Some(tag_list) = novel_info.tag_list {
858                for tags in tag_list {
859                    tag_ids.push(tags.tag_id);
860                }
861            }
862
863            if CiyuanjiClient::match_update_days(option, novel_info.latest_update_time)
864                && CiyuanjiClient::match_tags(option, &tag_ids)
865                && CiyuanjiClient::match_excluded_tags(option, &tag_ids)
866            {
867                result.push(novel_info.book_id);
868            }
869        }
870
871        Ok(Some(result))
872    }
873
874    fn to_end_state(option: &Options) -> Option<String> {
875        option.is_finished.map(|x| {
876            if x {
877                String::from("1")
878            } else {
879                String::from("2")
880            }
881        })
882    }
883
884    fn to_is_fee(option: &Options) -> Option<String> {
885        option.is_vip.map(|x| {
886            if x {
887                String::from("1")
888            } else {
889                String::from("0")
890            }
891        })
892    }
893
894    fn to_word(option: &Options) -> (Option<String>, Option<String>) {
895        let mut start_word = None;
896        let mut end_word = None;
897
898        if let Some(word_count) = &option.word_count {
899            match word_count {
900                WordCountRange::Range(range) => {
901                    start_word = Some(range.start.to_string());
902                    end_word = Some(range.end.to_string());
903                }
904                WordCountRange::RangeFrom(range_from) => {
905                    start_word = Some(range_from.start.to_string())
906                }
907                WordCountRange::RangeTo(range_to) => end_word = Some(range_to.end.to_string()),
908            }
909        }
910
911        (start_word, end_word)
912    }
913
914    fn to_classify_ids(option: &Options) -> (Option<String>, Option<String>) {
915        let mut first_classify = None;
916        let mut second_classify = None;
917
918        if let Some(category) = &option.category {
919            if category.parent_id.is_some() {
920                first_classify = category.parent_id.map(|x| x.to_string());
921                second_classify = category.id.map(|x| x.to_string());
922            } else {
923                first_classify = category.id.map(|x| x.to_string());
924            }
925        }
926
927        (first_classify, second_classify)
928    }
929
930    fn match_update_days(option: &Options, update_time: Option<NaiveDateTime>) -> bool {
931        if option.update_days.is_none() || update_time.is_none() {
932            return true;
933        }
934
935        let other_time = Shanghai.from_local_datetime(&update_time.unwrap()).unwrap()
936            + Duration::try_days(*option.update_days.as_ref().unwrap() as i64).unwrap();
937
938        Local::now() <= other_time
939    }
940
941    fn match_category(
942        option: &Options,
943        first_classify: Option<u16>,
944        second_classify: Option<u16>,
945    ) -> bool {
946        if option.category.is_none() {
947            return true;
948        }
949
950        let category = option.category.as_ref().unwrap();
951
952        if category.parent_id.is_some() {
953            category.id == second_classify && category.parent_id == first_classify
954        } else {
955            category.id == first_classify
956        }
957    }
958
959    fn match_tags(option: &Options, tag_ids: &[u16]) -> bool {
960        if option.tags.is_none() {
961            return true;
962        }
963
964        option
965            .tags
966            .as_ref()
967            .unwrap()
968            .iter()
969            .all(|tag| tag_ids.contains(tag.id.as_ref().unwrap()))
970    }
971
972    fn match_excluded_tags(option: &Options, tag_ids: &[u16]) -> bool {
973        if option.excluded_tags.is_none() {
974            return true;
975        }
976
977        tag_ids.iter().all(|id| {
978            !option
979                .excluded_tags
980                .as_ref()
981                .unwrap()
982                .iter()
983                .any(|tag| tag.id.unwrap() == *id)
984        })
985    }
986
987    fn parse_word_count(word_count: i32) -> Option<u32> {
988        // Some novels have negative word counts, e.g. 9326
989        if word_count <= 0 {
990            None
991        } else {
992            Some(word_count as u32)
993        }
994    }
995
996    async fn parse_tags(&self, tag_list: Vec<BookTag>) -> Result<Option<Vec<Tag>>, Error> {
997        let sys_tags = self.tags().await?;
998
999        let mut result = Vec::new();
1000        for tag in tag_list {
1001            let name = tag.tag_name.trim().to_string();
1002
1003            // Remove non-system tags
1004            if sys_tags.iter().any(|item| item.name == name) {
1005                result.push(Tag {
1006                    id: Some(tag.tag_id),
1007                    name,
1008                });
1009            } else {
1010                tracing::info!(
1011                    "This tag is not a system tag and is ignored: {name}({})",
1012                    tag.tag_id
1013                );
1014            }
1015        }
1016
1017        if result.is_empty() {
1018            Ok(None)
1019        } else {
1020            result.sort_unstable_by_key(|x| x.id.unwrap());
1021            Ok(Some(result))
1022        }
1023    }
1024
1025    fn parse_image_url(line: &str) -> Option<Url> {
1026        let begin = line.find("http").unwrap();
1027        let end = line.find("[/img]").unwrap();
1028
1029        let url = line
1030            .chars()
1031            .skip(begin)
1032            .take(end - begin)
1033            .collect::<String>()
1034            .trim()
1035            .to_string();
1036
1037        match Url::parse(&url) {
1038            Ok(url) => Some(url),
1039            Err(error) => {
1040                tracing::error!("Image URL parse failed: {error}, content: {line}");
1041                None
1042            }
1043        }
1044    }
1045
1046    async fn get_tags(&self, book_type: u16, result: &mut Vec<Tag>) -> Result<(), Error> {
1047        let response: TagsResponse = self
1048            .get_query(
1049                "/tag/getAppTagList",
1050                TagsRequest {
1051                    page_no: 1,
1052                    page_size: 99,
1053                    book_type,
1054                },
1055            )
1056            .await?;
1057        utils::check_response_success(response.code, response.msg, response.ok)?;
1058
1059        for tag in response.data.unwrap().list.unwrap() {
1060            result.push(Tag {
1061                id: Some(tag.tag_id),
1062                name: tag.tag_name.trim().to_string(),
1063            });
1064        }
1065
1066        Ok(())
1067    }
1068
1069    async fn get_categories(
1070        &self,
1071        book_type: &'static str,
1072        result: &mut Vec<Category>,
1073    ) -> Result<(), Error> {
1074        let response: CategoryResponse = self
1075            .get_query(
1076                "/classify/getBookClassifyListByParams",
1077                CategoryRequest {
1078                    page_no: 1,
1079                    page_size: 99,
1080                    book_type,
1081                },
1082            )
1083            .await?;
1084        utils::check_response_success(response.code, response.msg, response.ok)?;
1085
1086        for category in response.data.unwrap().classify_list.unwrap() {
1087            let basic_id = category.classify_id;
1088            let basic_name = category.classify_name.trim().to_string();
1089
1090            for child_category in category.child_list {
1091                result.push(Category {
1092                    id: Some(child_category.classify_id),
1093                    parent_id: Some(basic_id),
1094                    name: format!("{basic_name}-{}", child_category.classify_name.trim()),
1095                });
1096            }
1097
1098            result.push(Category {
1099                id: Some(basic_id),
1100                parent_id: None,
1101                name: basic_name,
1102            });
1103        }
1104
1105        Ok(())
1106    }
1107}