Skip to main content

novel_api/sfacg/
mod.rs

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