novel_api/sfacg/
mod.rs

1mod structure;
2mod utils;
3
4use std::io::Cursor;
5use std::path::PathBuf;
6
7use chrono::{DateTime, Utc};
8use chrono_tz::Asia::Shanghai;
9use chrono_tz::Tz;
10use image::{DynamicImage, ImageReader};
11use tokio::sync::OnceCell;
12use url::Url;
13
14use self::structure::*;
15use crate::{
16    Category, ChapterInfo, Client, Comment, CommentType, ContentInfo, ContentInfos, Error,
17    FindImageResult, FindTextResult, HTTPClient, LongComment, NovelDB, NovelInfo, Options,
18    ShortComment, Tag, UserInfo, VolumeInfo, VolumeInfos, WordCountRange,
19};
20
21/// Sfacg client, use it to access Apis
22#[must_use]
23pub struct SfacgClient {
24    proxy: Option<Url>,
25    no_proxy: bool,
26    cert_path: Option<PathBuf>,
27
28    client: OnceCell<HTTPClient>,
29    client_rss: OnceCell<HTTPClient>,
30
31    db: OnceCell<NovelDB>,
32}
33
34impl Client for SfacgClient {
35    fn proxy(&mut self, proxy: Url) {
36        self.proxy = Some(proxy);
37    }
38
39    fn no_proxy(&mut self) {
40        self.no_proxy = true;
41    }
42
43    fn cert(&mut self, cert_path: PathBuf) {
44        self.cert_path = Some(cert_path);
45    }
46
47    async fn shutdown(&self) -> Result<(), Error> {
48        self.client().await?.save_cookies()
49    }
50
51    async fn add_cookie(&self, cookie_str: &str, url: &Url) -> Result<(), Error> {
52        self.client().await?.add_cookie(cookie_str, url)
53    }
54
55    async fn log_in(&self, username: String, password: Option<String>) -> Result<(), Error> {
56        assert!(!username.is_empty());
57        assert!(password.is_some());
58
59        let password = password.unwrap();
60
61        let response: GenericResponse = self
62            .post("/sessions", LogInRequest { username, password })
63            .await?;
64        response.status.check()?;
65
66        Ok(())
67    }
68
69    async fn logged_in(&self) -> Result<bool, Error> {
70        let response: GenericResponse = self.get("/user").await?;
71
72        if response.status.unauthorized() {
73            Ok(false)
74        } else {
75            response.status.check()?;
76            Ok(true)
77        }
78    }
79
80    async fn user_info(&self) -> Result<UserInfo, Error> {
81        let response: UserInfoResponse = self.get("/user").await?;
82        response.status.check()?;
83        let data = response.data.unwrap();
84
85        Ok(UserInfo {
86            nickname: data.nick_name.trim().to_string(),
87            avatar: Some(data.avatar),
88        })
89    }
90
91    async fn money(&self) -> Result<u32, Error> {
92        let response: MoneyResponse = self.get("/user/money").await?;
93        response.status.check()?;
94        let data = response.data.unwrap();
95
96        Ok(data.fire_money_remain + data.coupons_remain)
97    }
98
99    async fn sign_in(&self) -> Result<(), Error> {
100        let now: DateTime<Tz> = Utc::now().with_timezone(&Shanghai);
101
102        let response: GenericResponse = self
103            .put(
104                "/user/newSignInfo",
105                SignRequest {
106                    sign_date: now.format("%Y-%m-%d").to_string(),
107                },
108            )
109            .await?;
110        if response.status.already_signed_in() {
111            tracing::info!("{}", response.status.msg.unwrap().trim())
112        } else {
113            response.status.check()?;
114        }
115
116        Ok(())
117    }
118
119    async fn bookshelf_infos(&self) -> Result<Vec<u32>, Error> {
120        let response: BookshelfInfoResponse = self
121            .get_query("/user/Pockets", BookshelfInfoRequest { expand: "novels" })
122            .await?;
123        response.status.check()?;
124        let data = response.data.unwrap();
125
126        let mut result = Vec::with_capacity(32);
127        for info in data {
128            if info.expand.is_some() {
129                let novels = info.expand.unwrap().novels;
130
131                if novels.is_some() {
132                    for novel_info in novels.unwrap() {
133                        result.push(novel_info.novel_id);
134                    }
135                }
136            }
137        }
138
139        Ok(result)
140    }
141
142    async fn novel_info(&self, id: u32) -> Result<Option<NovelInfo>, Error> {
143        assert!(id > 0 && id <= i32::MAX as u32);
144
145        let response: NovelInfoResponse = self
146            .get_query(
147                format!("/novels/{id}"),
148                NovelInfoRequest {
149                    expand: "intro,typeName,sysTags",
150                },
151            )
152            .await?;
153        if response.status.not_found() {
154            return Ok(None);
155        }
156        response.status.check()?;
157        let data = response.data.unwrap();
158
159        let category = Category {
160            id: Some(data.type_id),
161            parent_id: None,
162            name: data.expand.type_name.trim().to_string(),
163        };
164
165        let novel_info = NovelInfo {
166            id,
167            name: data.novel_name.trim().to_string(),
168            author_name: data.author_name.trim().to_string(),
169            cover_url: Some(data.novel_cover),
170            introduction: super::parse_multi_line(data.expand.intro),
171            word_count: SfacgClient::parse_word_count(data.char_count),
172            is_vip: Some(data.sign_status == "VIP"),
173            is_finished: Some(data.is_finish),
174            create_time: Some(data.add_time),
175            update_time: Some(data.last_update_time),
176            category: Some(category),
177            tags: self.parse_tags(data.expand.sys_tags).await?,
178        };
179
180        Ok(Some(novel_info))
181    }
182
183    async fn comments(
184        &self,
185        id: u32,
186        comment_type: CommentType,
187        need_replies: bool,
188        page: u16,
189        size: u16,
190    ) -> Result<Option<Vec<Comment>>, Error> {
191        assert!(id <= i32::MAX as u32);
192
193        match comment_type {
194            CommentType::Short => self.do_short_comments(id, need_replies, page, size).await,
195            CommentType::Long => self.do_long_comments(id, need_replies, page, size).await,
196        }
197    }
198
199    async fn volume_infos(&self, id: u32) -> Result<Option<VolumeInfos>, Error> {
200        assert!(id <= i32::MAX as u32);
201
202        let response: VolumeInfosResponse = self.get(format!("/novels/{id}/dirs")).await?;
203
204        if response.status.not_available() {
205            return Ok(None);
206        }
207
208        response.status.check()?;
209        let data = response.data.unwrap();
210
211        let mut volumes = VolumeInfos::with_capacity(8);
212        for volume in data.volume_list {
213            let mut volume_info = VolumeInfo {
214                id: volume.volume_id,
215                title: volume.title.trim().to_string(),
216                chapter_infos: Vec::with_capacity(volume.chapter_list.len()),
217            };
218
219            for chapter in volume.chapter_list {
220                let chapter_info = ChapterInfo {
221                    novel_id: Some(chapter.novel_id),
222                    id: chapter.chap_id,
223                    title: chapter.title.trim().to_string(),
224                    word_count: Some(chapter.char_count),
225                    create_time: Some(chapter.add_time),
226                    update_time: chapter.update_time,
227                    is_vip: Some(chapter.is_vip),
228                    price: Some(chapter.need_fire_money),
229                    payment_required: Some(chapter.need_fire_money != 0),
230                    is_valid: None,
231                };
232
233                volume_info.chapter_infos.push(chapter_info);
234            }
235
236            volumes.push(volume_info);
237        }
238
239        Ok(Some(volumes))
240    }
241
242    async fn content_infos(&self, info: &ChapterInfo) -> Result<ContentInfos, Error> {
243        let content;
244
245        match self.db().await?.find_text(info).await? {
246            FindTextResult::Ok(str) => {
247                content = str;
248            }
249            other => {
250                let response: ContentInfosResponse = self
251                    .get_query(
252                        format!("/Chaps/{}", info.id),
253                        ContentInfosRequest {
254                            expand: "content,isContentEncrypted",
255                        },
256                    )
257                    .await?;
258                response.status.check()?;
259                let data = response.data.unwrap();
260
261                if data.expand.is_content_encrypted {
262                    content = SfacgClient::convert(data.expand.content);
263                } else {
264                    content = data.expand.content;
265                }
266
267                if content.trim().is_empty() {
268                    return Err(Error::NovelApi(String::from("Content is empty")));
269                }
270
271                match other {
272                    FindTextResult::None => self.db().await?.insert_text(info, &content).await?,
273                    FindTextResult::Outdate => self.db().await?.update_text(info, &content).await?,
274                    FindTextResult::Ok(_) => (),
275                }
276            }
277        }
278
279        let mut content_infos = ContentInfos::with_capacity(128);
280        for line in content
281            .lines()
282            .map(|line| line.trim())
283            .filter(|line| !line.is_empty())
284        {
285            if line.starts_with("[img") {
286                match SfacgClient::parse_image_url(line) {
287                    Ok(url) => content_infos.push(ContentInfo::Image(url)),
288                    Err(err) => tracing::error!("{err}"),
289                }
290            } else {
291                content_infos.push(ContentInfo::Text(line.to_string()));
292            }
293        }
294
295        Ok(content_infos)
296    }
297
298    async fn order_chapter(&self, info: &ChapterInfo) -> Result<(), Error> {
299        let response: GenericResponse = self
300            .post(
301                &format!("/novels/{}/orderedchaps", info.novel_id.unwrap()),
302                OrderRequest {
303                    order_all: false,
304                    auto_order: false,
305                    chap_ids: vec![info.id],
306                    order_type: "readOrder",
307                },
308            )
309            .await?;
310        if response.status.already_ordered() {
311            tracing::info!("{}", response.status.msg.unwrap().trim())
312        } else {
313            response.status.check()?;
314        }
315
316        Ok(())
317    }
318
319    async fn order_novel(&self, id: u32, _: &VolumeInfos) -> Result<(), Error> {
320        assert!(id > 0 && id <= i32::MAX as u32);
321
322        let response: GenericResponse = self
323            .post(
324                &format!("/novels/{id}/orderedchaps",),
325                OrderRequest {
326                    order_all: true,
327                    auto_order: false,
328                    chap_ids: vec![],
329                    order_type: "readOrder",
330                },
331            )
332            .await?;
333        if response.status.already_ordered() {
334            tracing::info!("{}", response.status.msg.unwrap().trim())
335        } else {
336            response.status.check()?;
337        }
338
339        Ok(())
340    }
341
342    async fn image(&self, url: &Url) -> Result<DynamicImage, Error> {
343        match self.db().await?.find_image(url).await? {
344            FindImageResult::Ok(image) => Ok(image),
345            FindImageResult::None => {
346                let response = self.get_rss(url).await?;
347                let bytes = response.bytes().await?;
348
349                let image = ImageReader::new(Cursor::new(&bytes))
350                    .with_guessed_format()?
351                    .decode()?;
352
353                self.db().await?.insert_image(url, bytes).await?;
354
355                Ok(image)
356            }
357        }
358    }
359
360    async fn categories(&self) -> Result<&Vec<Category>, Error> {
361        static CATEGORIES: OnceCell<Vec<Category>> = OnceCell::const_new();
362
363        CATEGORIES
364            .get_or_try_init(|| async {
365                let response: CategoryResponse = self.get("/noveltypes").await?;
366                response.status.check()?;
367                let data = response.data.unwrap();
368
369                let mut result = Vec::with_capacity(8);
370                for tag_data in data {
371                    result.push(Category {
372                        id: Some(tag_data.type_id),
373                        parent_id: None,
374                        name: tag_data.type_name.trim().to_string(),
375                    });
376                }
377
378                result.sort_unstable_by_key(|x| x.id.unwrap());
379
380                Ok(result)
381            })
382            .await
383    }
384
385    async fn tags(&self) -> Result<&Vec<Tag>, Error> {
386        static TAGS: OnceCell<Vec<Tag>> = OnceCell::const_new();
387
388        TAGS.get_or_try_init(|| async {
389            let response: TagResponse = self.get("/novels/0/sysTags").await?;
390            response.status.check()?;
391            let data = response.data.unwrap();
392
393            let mut result = Vec::with_capacity(64);
394            for tag_data in data {
395                result.push(Tag {
396                    id: Some(tag_data.sys_tag_id),
397                    name: tag_data.tag_name.trim().to_string(),
398                });
399            }
400
401            // Tag that have been removed, but can still be used
402            result.push(Tag {
403                id: Some(74),
404                name: "百合".to_string(),
405            });
406
407            result.sort_unstable_by_key(|x| x.id.unwrap());
408
409            Ok(result)
410        })
411        .await
412    }
413
414    async fn search_infos(
415        &self,
416        option: &Options,
417        page: u16,
418        size: u16,
419    ) -> Result<Option<Vec<u32>>, Error> {
420        assert!(size <= 50, "The maximum number of items per page is 50");
421
422        if option.keyword.is_some() {
423            self.do_search_with_keyword(option, page, size).await
424        } else {
425            self.do_search_without_keyword(option, page, size).await
426        }
427    }
428
429    fn has_this_type_of_comments(comment_type: CommentType) -> bool {
430        match comment_type {
431            CommentType::Short => true,
432            CommentType::Long => true,
433        }
434    }
435}
436
437impl SfacgClient {
438    async fn do_short_comments(
439        &self,
440        id: u32,
441        need_replies: bool,
442        page: u16,
443        size: u16,
444    ) -> Result<Option<Vec<Comment>>, Error> {
445        assert!(size <= 50);
446
447        let response: CommentResponse = self
448            .get_query(
449                format!("/novels/{id}/Cmts"),
450                ShortCommentRequest {
451                    page,
452                    size,
453                    r#type: "clear",
454                    sort: "smart",
455                },
456            )
457            .await?;
458        response.status.check()?;
459        let data = response.data.unwrap();
460
461        if data.is_empty() {
462            return Ok(None);
463        }
464
465        let mut result = Vec::with_capacity(data.len());
466
467        for comment in data {
468            let Some(content) = super::parse_multi_line(comment.content) else {
469                continue;
470            };
471
472            result.push(Comment::Short(ShortComment {
473                id: comment.comment_id,
474                user: UserInfo {
475                    nickname: comment.display_name.trim().to_string(),
476                    avatar: Some(comment.avatar),
477                },
478                content,
479                create_time: Some(comment.create_time),
480                like_count: Some(comment.fav_count),
481                replies: if need_replies && comment.reply_num > 0 {
482                    self.comment_replies(comment.comment_id, CommentType::Short, comment.reply_num)
483                        .await?
484                } else {
485                    None
486                },
487            }));
488        }
489
490        Ok(Some(result))
491    }
492
493    async fn do_long_comments(
494        &self,
495        id: u32,
496        need_replies: bool,
497        page: u16,
498        size: u16,
499    ) -> Result<Option<Vec<Comment>>, Error> {
500        assert!(size <= 20);
501
502        let response: CommentResponse = self
503            .get_query(
504                format!("/novels/{id}/lcmts"),
505                LongCommentRequest {
506                    page,
507                    size,
508                    charlen: 140,
509                    sort: "addtime",
510                },
511            )
512            .await?;
513        response.status.check()?;
514        let data = response.data.unwrap();
515
516        if data.is_empty() {
517            return Ok(None);
518        }
519
520        let mut result = Vec::with_capacity(data.len());
521
522        for comment in data {
523            result.push(Comment::Long(LongComment {
524                id: comment.comment_id,
525                user: UserInfo {
526                    nickname: comment.display_name.trim().to_string(),
527                    avatar: Some(comment.avatar),
528                },
529                title: comment.title.unwrap().trim().to_string(),
530                content: self.long_comment_content(comment.comment_id).await?,
531                create_time: Some(comment.create_time),
532                like_count: Some(comment.fav_count),
533                replies: if need_replies && comment.reply_num > 0 {
534                    self.comment_replies(comment.comment_id, CommentType::Long, comment.reply_num)
535                        .await?
536                } else {
537                    None
538                },
539            }));
540        }
541
542        Ok(Some(result))
543    }
544
545    async fn long_comment_content(&self, comment_id: u32) -> Result<Vec<String>, Error> {
546        let response: LongCommentContentResponse = self.get(format!("/lcmts/{comment_id}")).await?;
547        response.status.check()?;
548        let data = response.data.unwrap();
549
550        Ok(super::parse_multi_line(data.content).unwrap())
551    }
552
553    async fn comment_replies(
554        &self,
555        comment_id: u32,
556        comment_type: CommentType,
557        total: u16,
558    ) -> Result<Option<Vec<ShortComment>>, Error> {
559        let url = match comment_type {
560            CommentType::Short => format!("/cmts/{comment_id}/replys"),
561            CommentType::Long => format!("/lcmts/{comment_id}/replys"),
562        };
563
564        let mut page = 0;
565        let size = 50;
566        let total_page = if total % size == 0 {
567            total / size
568        } else {
569            total / size + 1
570        };
571        let mut reply_list = Vec::with_capacity(total as usize);
572
573        while page < total_page {
574            let response: ReplyResponse = self.get_query(&url, ReplyRequest { page, size }).await?;
575            response.status.check()?;
576            let data = response.data.unwrap();
577
578            for reply in data {
579                let Some(content) = super::parse_multi_line(reply.content) else {
580                    continue;
581                };
582
583                reply_list.push(ShortComment {
584                    id: reply.reply_id,
585                    user: UserInfo {
586                        nickname: reply.display_name.trim().to_string(),
587                        avatar: Some(reply.avatar),
588                    },
589                    content,
590                    create_time: Some(reply.create_time),
591                    like_count: None,
592                    replies: None,
593                });
594            }
595
596            page += 1;
597        }
598
599        if reply_list.is_empty() {
600            Ok(None)
601        } else {
602            reply_list.sort_unstable_by_key(|x| x.create_time.unwrap());
603            reply_list.dedup();
604            Ok(Some(reply_list))
605        }
606    }
607
608    async fn do_search_with_keyword(
609        &self,
610        option: &Options,
611        page: u16,
612        size: u16,
613    ) -> Result<Option<Vec<u32>>, Error> {
614        // 0 连载中
615        // 1 已完结
616        // -1 不限
617        let is_finish = if option.is_finished.is_none() {
618            -1
619        } else if *option.is_finished.as_ref().unwrap() {
620            1
621        } else {
622            0
623        };
624
625        // -1 不限
626        let update_days = if option.update_days.is_none() {
627            -1
628        } else {
629            option.update_days.unwrap() as i8
630        };
631
632        let response: SearchResponse = self
633            .get_query(
634                "/search/novels/result/new",
635                SearchRequest {
636                    q: option.keyword.as_ref().unwrap().to_string(),
637                    is_finish,
638                    update_days,
639                    systagids: SfacgClient::tag_ids(&option.tags),
640                    page,
641                    size,
642                    // hot 人气最高
643                    // update 最新更新
644                    // marknum 收藏最高
645                    // ticket 月票最多
646                    // charcount 更新最多
647                    sort: "hot",
648                    expand: "sysTags",
649                },
650            )
651            .await?;
652        response.status.check()?;
653        let data = response.data.unwrap();
654
655        if data.novels.is_empty() {
656            return Ok(None);
657        }
658
659        let mut result = Vec::new();
660        let sys_tags = self.tags().await?;
661
662        for novel_info in data.novels {
663            let mut tag_ids = vec![];
664
665            for tag in novel_info.expand.sys_tags {
666                if let Some(sys_tag) = sys_tags.iter().find(|x| x.id.unwrap() == tag.sys_tag_id) {
667                    tag_ids.push(sys_tag.id.unwrap());
668                }
669            }
670
671            if SfacgClient::match_category(option, novel_info.type_id)
672                && SfacgClient::match_excluded_tags(option, tag_ids)
673                && SfacgClient::match_vip(option, &novel_info.sign_status)
674                && SfacgClient::match_word_count(option, novel_info.char_count)
675            {
676                result.push(novel_info.novel_id);
677            }
678        }
679
680        Ok(Some(result))
681    }
682
683    async fn do_search_without_keyword(
684        &self,
685        option: &Options,
686        page: u16,
687        size: u16,
688    ) -> Result<Option<Vec<u32>>, Error> {
689        let mut category_id = 0;
690        if option.category.is_some() {
691            category_id = option.category.as_ref().unwrap().id.unwrap();
692        }
693
694        // -1 不限
695        let updatedays = if option.update_days.is_none() {
696            -1
697        } else {
698            option.update_days.unwrap() as i8
699        };
700
701        let isfinish = SfacgClient::bool_to_str(&option.is_finished);
702        let isfree = SfacgClient::bool_to_str(&option.is_vip.as_ref().map(|x| !x));
703
704        let systagids = SfacgClient::tag_ids(&option.tags);
705        let notexcludesystagids = SfacgClient::tag_ids(&option.excluded_tags);
706
707        let mut charcountbegin = 0;
708        let mut charcountend = 0;
709
710        if option.word_count.is_some() {
711            match option.word_count.as_ref().unwrap() {
712                WordCountRange::Range(range) => {
713                    charcountbegin = range.start;
714                    charcountend = range.end;
715                }
716                WordCountRange::RangeFrom(range_from) => charcountbegin = range_from.start,
717                WordCountRange::RangeTo(range_to) => charcountend = range_to.end,
718            }
719        }
720
721        let response: NovelsResponse = self
722            .get_query(
723                format!("/novels/{category_id}/sysTags/novels"),
724                NovelsRequest {
725                    charcountbegin,
726                    charcountend,
727                    isfinish,
728                    isfree,
729                    systagids,
730                    notexcludesystagids,
731                    updatedays,
732                    page,
733                    size,
734                    // latest 最新更新
735                    // viewtimes 人气最高
736                    // bookmark 收藏最高
737                    // ticket 月票最多
738                    // charcount 更新最多
739                    sort: "viewtimes",
740                },
741            )
742            .await?;
743        response.status.check()?;
744        let data = response.data.unwrap();
745
746        if data.is_empty() {
747            return Ok(None);
748        }
749
750        let mut result = Vec::new();
751        for novel_data in data {
752            result.push(novel_data.novel_id);
753        }
754
755        Ok(Some(result))
756    }
757
758    fn parse_word_count(word_count: i32) -> Option<u32> {
759        // Some novels have negative word counts
760        if word_count <= 0 {
761            None
762        } else {
763            Some(word_count as u32)
764        }
765    }
766
767    async fn parse_tags(&self, tag_list: Vec<NovelInfoSysTag>) -> Result<Option<Vec<Tag>>, Error> {
768        let sys_tags = self.tags().await?;
769
770        let mut result = Vec::new();
771        for tag in tag_list {
772            let id = tag.sys_tag_id;
773            let name = tag.tag_name.trim().to_string();
774
775            // Remove non-system tags
776            if sys_tags.iter().any(|sys_tag| sys_tag.id.unwrap() == id) {
777                result.push(Tag { id: Some(id), name });
778            } else {
779                tracing::info!("This tag is not a system tag and is ignored: {name}");
780            }
781        }
782
783        if result.is_empty() {
784            Ok(None)
785        } else {
786            result.sort_unstable_by_key(|x| x.id.unwrap());
787            Ok(Some(result))
788        }
789    }
790
791    fn parse_image_url(line: &str) -> Result<Url, Error> {
792        let begin = line.find("http");
793        let end = line.find("[/img]");
794
795        if begin.is_none() || end.is_none() {
796            return Err(Error::NovelApi(format!(
797                "Image URL format is incorrect: {line}"
798            )));
799        }
800
801        let begin = begin.unwrap();
802        let end = end.unwrap();
803
804        let url = line
805            .chars()
806            .skip(begin)
807            .take(end - begin)
808            .collect::<String>()
809            .trim()
810            .to_string();
811
812        match Url::parse(&url) {
813            Ok(url) => Ok(url),
814            Err(error) => Err(Error::NovelApi(format!(
815                "Image URL parse failed: {error}, content: {line}"
816            ))),
817        }
818    }
819
820    fn bool_to_str(flag: &Option<bool>) -> &'static str {
821        if flag.is_some() {
822            if *flag.as_ref().unwrap() { "is" } else { "not" }
823        } else {
824            "both"
825        }
826    }
827
828    fn tag_ids(tags: &Option<Vec<Tag>>) -> Option<String> {
829        tags.as_ref().map(|tags| {
830            tags.iter()
831                .map(|tag| tag.id.unwrap().to_string())
832                .collect::<Vec<String>>()
833                .join(",")
834        })
835    }
836
837    fn match_vip(option: &Options, sign_status: &str) -> bool {
838        if option.is_vip.is_none() {
839            return true;
840        }
841
842        if *option.is_vip.as_ref().unwrap() {
843            sign_status == "VIP"
844        } else {
845            sign_status != "VIP"
846        }
847    }
848
849    fn match_excluded_tags(option: &Options, tag_ids: Vec<u16>) -> bool {
850        if option.excluded_tags.is_none() {
851            return true;
852        }
853
854        tag_ids.iter().all(|id| {
855            !option
856                .excluded_tags
857                .as_ref()
858                .unwrap()
859                .iter()
860                .any(|tag| tag.id.unwrap() == *id)
861        })
862    }
863
864    fn match_category(option: &Options, category_id: u16) -> bool {
865        if option.category.is_none() {
866            return true;
867        }
868
869        let category = option.category.as_ref().unwrap();
870        category.id.unwrap() == category_id
871    }
872
873    fn match_word_count(option: &Options, word_count: i32) -> bool {
874        if option.word_count.is_none() {
875            return true;
876        }
877
878        if word_count <= 0 {
879            return true;
880        }
881
882        let word_count = word_count as u32;
883        match option.word_count.as_ref().unwrap() {
884            WordCountRange::Range(range) => {
885                if word_count >= range.start && word_count < range.end {
886                    return true;
887                }
888            }
889            WordCountRange::RangeFrom(range_from) => {
890                if word_count >= range_from.start {
891                    return true;
892                }
893            }
894            WordCountRange::RangeTo(rang_to) => {
895                if word_count < rang_to.end {
896                    return true;
897                }
898            }
899        }
900
901        false
902    }
903}