package manga_providers import ( "cmp" "fmt" "net/url" hibikemanga "seanime/internal/extension/hibike/manga" "seanime/internal/util" "seanime/internal/util/comparison" "slices" "strings" "time" "github.com/imroc/req/v3" "github.com/rs/zerolog" ) type ( ComicK struct { Url string Client *req.Client logger *zerolog.Logger } ComicKResultItem struct { ID int `json:"id"` HID string `json:"hid"` Slug string `json:"slug"` Title string `json:"title"` Country string `json:"country"` Rating string `json:"rating"` BayesianRating string `json:"bayesian_rating"` RatingCount int `json:"rating_count"` FollowCount int `json:"follow_count"` Description string `json:"desc"` Status int `json:"status"` LastChapter float64 `json:"last_chapter"` TranslationCompleted bool `json:"translation_completed"` ViewCount int `json:"view_count"` ContentRating string `json:"content_rating"` Demographic int `json:"demographic"` UploadedAt string `json:"uploaded_at"` Genres []int `json:"genres"` CreatedAt string `json:"created_at"` UserFollowCount int `json:"user_follow_count"` Year int `json:"year"` MuComics struct { Year int `json:"year"` } `json:"mu_comics"` MdTitles []struct { Title string `json:"title"` } `json:"md_titles"` MdCovers []struct { W int `json:"w"` H int `json:"h"` B2Key string `json:"b2key"` } `json:"md_covers"` Highlight string `json:"highlight"` } ) func NewComicK(logger *zerolog.Logger) *ComicK { client := req.C(). SetUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"). SetTimeout(60 * time.Second). EnableInsecureSkipVerify(). ImpersonateSafari() return &ComicK{ Url: "https://api.comick.fun", Client: client, logger: logger, } } // DEVNOTE: Each chapter ID is a unique string provided by ComicK func (c *ComicK) GetSettings() hibikemanga.Settings { return hibikemanga.Settings{ SupportsMultiScanlator: false, SupportsMultiLanguage: false, } } func (c *ComicK) Search(opts hibikemanga.SearchOptions) ([]*hibikemanga.SearchResult, error) { searchUrl := fmt.Sprintf("%s/v1.0/search?q=%s&limit=25&page=1", c.Url, url.QueryEscape(opts.Query)) if opts.Year != 0 { searchUrl += fmt.Sprintf("&from=%d&to=%d", opts.Year, opts.Year) } c.logger.Debug().Str("searchUrl", searchUrl).Msg("comick: Searching manga") var data []*ComicKResultItem resp, err := c.Client.R(). SetSuccessResult(&data). Get(searchUrl) if err != nil { c.logger.Error().Err(err).Msg("comick: Failed to send request") return nil, fmt.Errorf("failed to send request: %w", err) } if !resp.IsSuccessState() { c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed") return nil, fmt.Errorf("failed to reach API: status %s", resp.Status) } results := make([]*hibikemanga.SearchResult, 0) for _, result := range data { // Skip fan-colored manga if strings.Contains(result.Slug, "fan-colored") { continue } var coverURL string if len(result.MdCovers) > 0 && result.MdCovers[0].B2Key != "" { coverURL = "https://meo.comick.pictures/" + result.MdCovers[0].B2Key } altTitles := make([]string, len(result.MdTitles)) for j, title := range result.MdTitles { altTitles[j] = title.Title } // DEVNOTE: We don't compare to alt titles because ComicK's synonyms aren't good compRes, _ := comparison.FindBestMatchWithSorensenDice(&opts.Query, []*string{&result.Title}) results = append(results, &hibikemanga.SearchResult{ ID: result.HID, Title: cmp.Or(result.Title, result.Slug), Synonyms: altTitles, Image: coverURL, Year: result.Year, SearchRating: compRes.Rating, Provider: ComickProvider, }) } if len(results) == 0 { c.logger.Warn().Msg("comick: No results found") return nil, ErrNoChapters } c.logger.Info().Int("count", len(results)).Msg("comick: Found results") return results, nil } func (c *ComicK) FindChapters(id string) ([]*hibikemanga.ChapterDetails, error) { ret := make([]*hibikemanga.ChapterDetails, 0) c.logger.Debug().Str("mangaId", id).Msg("comick: Fetching chapters") uri := fmt.Sprintf("%s/comic/%s/chapters?lang=en&page=0&limit=1000000&chap-order=1", c.Url, id) var data struct { Chapters []*ComicChapter `json:"chapters"` } resp, err := c.Client.R(). SetSuccessResult(&data). Get(uri) if err != nil { c.logger.Error().Err(err).Msg("comick: Failed to send request") return nil, fmt.Errorf("failed to send request: %w", err) } if !resp.IsSuccessState() { c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed") return nil, fmt.Errorf("failed to decode response: status %s", resp.Status) } chapters := make([]*hibikemanga.ChapterDetails, 0) chaptersMap := make(map[string]*hibikemanga.ChapterDetails) count := 0 for _, chapter := range data.Chapters { if chapter.Chap == "" || chapter.Lang != "en" { continue } title := "Chapter " + chapter.Chap + " " if title == "" { if chapter.Title == "" { title = "Oneshot" } else { title = chapter.Title } } title = strings.TrimSpace(title) prev, ok := chaptersMap[chapter.Chap] rating := chapter.UpCount - chapter.DownCount if !ok || rating > prev.Rating { if !ok { count++ } chaptersMap[chapter.Chap] = &hibikemanga.ChapterDetails{ Provider: ComickProvider, ID: chapter.HID, Title: title, Index: uint(count), URL: fmt.Sprintf("%s/chapter/%s", c.Url, chapter.HID), Chapter: chapter.Chap, Rating: rating, UpdatedAt: chapter.UpdatedAt, } } } for _, chapter := range chaptersMap { chapters = append(chapters, chapter) } // Sort chapters by index slices.SortStableFunc(chapters, func(i, j *hibikemanga.ChapterDetails) int { return cmp.Compare(i.Index, j.Index) }) ret = append(ret, chapters...) if len(ret) == 0 { c.logger.Warn().Msg("comick: No chapters found") return nil, ErrNoChapters } c.logger.Info().Int("count", len(ret)).Msg("comick: Found chapters") return ret, nil } func (c *ComicK) FindChapterPages(id string) ([]*hibikemanga.ChapterPage, error) { ret := make([]*hibikemanga.ChapterPage, 0) c.logger.Debug().Str("chapterId", id).Msg("comick: Finding chapter pages") uri := fmt.Sprintf("%s/chapter/%s", c.Url, id) var data struct { Chapter *ComicChapter `json:"chapter"` } resp, err := c.Client.R(). SetHeader("User-Agent", util.GetRandomUserAgent()). SetSuccessResult(&data). Get(uri) if err != nil { c.logger.Error().Err(err).Msg("comick: Failed to send request") return nil, fmt.Errorf("failed to send request: %w", err) } if !resp.IsSuccessState() { c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed") return nil, fmt.Errorf("failed to decode response: status %s", resp.Status) } if data.Chapter == nil { c.logger.Error().Msg("comick: Chapter not found") return nil, fmt.Errorf("chapter not found") } for index, image := range data.Chapter.MdImages { ret = append(ret, &hibikemanga.ChapterPage{ Provider: ComickProvider, URL: fmt.Sprintf("https://meo.comick.pictures/%s", image.B2Key), Index: index, Headers: make(map[string]string), }) } if len(ret) == 0 { c.logger.Warn().Msg("comick: No pages found") return nil, ErrNoPages } c.logger.Info().Int("count", len(ret)).Msg("comick: Found pages") return ret, nil } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// type Comic struct { ID int `json:"id"` HID string `json:"hid"` Title string `json:"title"` Country string `json:"country"` Status int `json:"status"` Links struct { AL string `json:"al"` AP string `json:"ap"` BW string `json:"bw"` KT string `json:"kt"` MU string `json:"mu"` AMZ string `json:"amz"` CDJ string `json:"cdj"` EBJ string `json:"ebj"` MAL string `json:"mal"` RAW string `json:"raw"` } `json:"links"` LastChapter interface{} `json:"last_chapter"` ChapterCount int `json:"chapter_count"` Demographic int `json:"demographic"` Hentai bool `json:"hentai"` UserFollowCount int `json:"user_follow_count"` FollowRank int `json:"follow_rank"` CommentCount int `json:"comment_count"` FollowCount int `json:"follow_count"` Description string `json:"desc"` Parsed string `json:"parsed"` Slug string `json:"slug"` Mismatch interface{} `json:"mismatch"` Year int `json:"year"` BayesianRating interface{} `json:"bayesian_rating"` RatingCount int `json:"rating_count"` ContentRating string `json:"content_rating"` TranslationCompleted bool `json:"translation_completed"` RelateFrom []interface{} `json:"relate_from"` Mies interface{} `json:"mies"` MdTitles []struct { Title string `json:"title"` } `json:"md_titles"` MdComicMdGenres []struct { MdGenres struct { Name string `json:"name"` Type interface{} `json:"type"` Slug string `json:"slug"` Group string `json:"group"` } `json:"md_genres"` } `json:"md_comic_md_genres"` MuComics struct { LicensedInEnglish interface{} `json:"licensed_in_english"` MuComicCategories []struct { MuCategories struct { Title string `json:"title"` Slug string `json:"slug"` } `json:"mu_categories"` PositiveVote int `json:"positive_vote"` NegativeVote int `json:"negative_vote"` } `json:"mu_comic_categories"` } `json:"mu_comics"` MdCovers []struct { Vol interface{} `json:"vol"` W int `json:"w"` H int `json:"h"` B2Key string `json:"b2key"` } `json:"md_covers"` Iso6391 string `json:"iso639_1"` LangName string `json:"lang_name"` LangNative string `json:"lang_native"` } type ComicChapter struct { ID int `json:"id"` Chap string `json:"chap"` Title string `json:"title"` Vol string `json:"vol,omitempty"` Lang string `json:"lang"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` UpCount int `json:"up_count"` DownCount int `json:"down_count"` GroupName []string `json:"group_name"` HID string `json:"hid"` MdImages []struct { Name string `json:"name"` W int `json:"w"` H int `json:"h"` S int `json:"s"` B2Key string `json:"b2key"` } `json:"md_images"` }