novel_api/ciyuanji/
mod.rs

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