// Package comicklive implements the Comick (Unoriginal) source (comick.live / comick.art). // Multi-language. Popular via /api/comics/top (6 virtual pages); latest via /api/chapters/latest. // Search is cursor-based (/api/search). Details and pages scraped from HTML (#comic-data / #sv-data). package comicklive import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "sync" "time" "github.com/PuerkitoBio/goquery" "goyomi/internal/httpclient/flare" "goyomi/internal/registry" "goyomi/internal/source" ) const baseURL = "https://comick.live" // DTO types type browseComic struct { Thumbnail string `json:"default_thumbnail"` Slug string `json:"slug"` Title string `json:"title"` } func (b browseComic) toSManga() source.SManga { return source.SManga{URL: b.Slug, Title: b.Title, ThumbnailURL: b.Thumbnail} } type dataList struct { Data []browseComic `json:"data"` } type searchResp struct { Data []browseComic `json:"data"` NextCursor string `json:"next_cursor"` } type comicDTO struct { Title string `json:"title"` Slug string `json:"slug"` Thumbnail string `json:"default_thumbnail"` Status int `json:"status"` TranslationCompleted bool `json:"translation_completed"` Artists []struct { Name string `json:"name"` } `json:"artists"` Authors []struct { Name string `json:"name"` } `json:"authors"` Desc string `json:"desc"` ContentRating string `json:"content_rating"` Country string `json:"country"` Genres []struct { Genre struct { Name string `json:"name"` } `json:"md_genres"` } `json:"md_comic_md_genres"` Titles []struct { Title string `json:"title"` } `json:"md_titles"` } type chapterListResp struct { Data []chapterDTO `json:"data"` Pagination struct { Page int `json:"current_page"` LastPage int `json:"last_page"` } `json:"pagination"` } type chapterDTO struct { HID string `json:"hid"` Chap string `json:"chap"` Vol string `json:"vol"` Lang string `json:"lang"` Title string `json:"title"` CreatedAt string `json:"created_at"` Groups []string `json:"group_name"` } type pageListDTO struct { Chapter struct { Images []struct { URL string `json:"url"` } `json:"images"` } `json:"chapter"` } // Source type Source struct { lang string siteLang string client *flare.Client id int64 mu sync.Mutex cursor string } func newSource(lang, siteLang string) *Source { name := "Comick (Unoriginal)" return &Source{ lang: lang, siteLang: siteLang, client: flare.NewClient(flare.WithRateLimit(1, 2)), id: source.GenerateSourceID(name, lang), } } func (s *Source) ID() int64 { return s.id } func (s *Source) Name() string { return "Comick (Unoriginal)" } func (s *Source) Lang() string { return s.lang } func (s *Source) SupportsLatest() bool { return true } func (s *Source) get(ctx context.Context, rawURL string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, err } req.Header.Set("Referer", baseURL+"/") resp, err := s.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("comicklive: HTTP %d for %s", resp.StatusCode, rawURL) } return io.ReadAll(resp.Body) } func (s *Source) getDoc(ctx context.Context, rawURL string) (*goquery.Document, error) { body, err := s.get(ctx, rawURL) if err != nil { return nil, err } return goquery.NewDocumentFromReader(strings.NewReader(string(body))) } // Popular uses 6 virtual pages cycling through top-comics queries. func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { if page < 1 || page > 6 { return source.MangasPage{}, nil } days := []int{7, 30, 90}[(page-1)%3] topType := "follow" if page > 3 { topType = "most_follow_new" } u := fmt.Sprintf("%s/api/comics/top?days=%d&type=%s", baseURL, days, topType) body, err := s.get(context.Background(), u) if err != nil { return source.MangasPage{}, err } var resp dataList if err := json.Unmarshal(body, &resp); err != nil { return source.MangasPage{}, err } mangas := make([]source.SManga, 0, len(resp.Data)) for _, c := range resp.Data { mangas = append(mangas, c.toSManga()) } return source.MangasPage{Mangas: mangas, HasNextPage: page < 6}, nil } func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { u := fmt.Sprintf("%s/api/chapters/latest?order=new&page=%d", baseURL, page) body, err := s.get(context.Background(), u) if err != nil { return source.MangasPage{}, err } var resp dataList if err := json.Unmarshal(body, &resp); err != nil { return source.MangasPage{}, err } mangas := make([]source.SManga, 0, len(resp.Data)) for _, c := range resp.Data { mangas = append(mangas, c.toSManga()) } return source.MangasPage{Mangas: mangas, HasNextPage: len(resp.Data) == 100}, nil } func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { if page == 1 { s.mu.Lock() s.cursor = "" s.mu.Unlock() } params := url.Values{ "type": {"comic"}, "showAll": {"false"}, "exclude_mylist": {"false"}, "order_by": {"created_at"}, "order_direction": {"desc"}, } if query != "" { if len(strings.TrimSpace(query)) < 3 { return source.MangasPage{}, fmt.Errorf("comicklive: query must be at least 3 characters") } params.Set("q", strings.TrimSpace(query)) } s.mu.Lock() cur := s.cursor s.mu.Unlock() if page > 1 && cur != "" { params.Set("cursor", cur) } u := baseURL + "/api/search?" + params.Encode() body, err := s.get(context.Background(), u) if err != nil { return source.MangasPage{}, err } var resp searchResp if err := json.Unmarshal(body, &resp); err != nil { return source.MangasPage{}, err } s.mu.Lock() s.cursor = resp.NextCursor s.mu.Unlock() mangas := make([]source.SManga, 0, len(resp.Data)) for _, c := range resp.Data { mangas = append(mangas, c.toSManga()) } return source.MangasPage{Mangas: mangas, HasNextPage: resp.NextCursor != ""}, nil } func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { doc, err := s.getDoc(context.Background(), baseURL+"/comic/"+manga.URL) if err != nil { return manga, err } raw := doc.Find("#comic-data").Text() if raw == "" { return manga, fmt.Errorf("comicklive: #comic-data not found") } var data comicDTO if err := json.Unmarshal([]byte(raw), &data); err != nil { return manga, err } result := source.SManga{URL: manga.URL} result.Title = data.Title result.ThumbnailURL = data.Thumbnail result.Status = comickStatus(data.Status, data.TranslationCompleted) var authors []string for _, a := range data.Authors { authors = append(authors, a.Name) } result.Author = strings.Join(authors, ", ") var artists []string for _, a := range data.Artists { artists = append(artists, a.Name) } result.Artist = strings.Join(artists, ", ") // Description: strip HTML tags. descDoc, _ := goquery.NewDocumentFromReader(strings.NewReader(data.Desc)) desc := strings.TrimSpace(descDoc.Text()) if len(data.Titles) > 0 { var alt []string for _, t := range data.Titles { if t.Title != "" { alt = append(alt, "- "+t.Title) } } if len(alt) > 0 { desc += "\n\nAlternative Titles:\n" + strings.Join(alt, "\n") } } result.Description = desc var genres []string switch data.Country { case "jp": genres = append(genres, "Manga") case "cn": genres = append(genres, "Manhua") case "ko": genres = append(genres, "Manhwa") } switch data.ContentRating { case "suggestive": genres = append(genres, "Content Rating: Suggestive") case "erotica": genres = append(genres, "Content Rating: Erotica") } for _, g := range data.Genres { if g.Genre.Name != "" { genres = append(genres, g.Genre.Name) } } result.Genre = strings.Join(genres, ", ") return result, nil } func comickStatus(status int, translationCompleted bool) int { switch status { case 1: return source.StatusOngoing case 2: if translationCompleted { return source.StatusCompleted } return source.StatusOngoing case 3: return source.StatusCancelled case 4: return source.StatusHiatus } return source.StatusUnknown } func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { var chapters []chapterDTO page := 1 for { u := fmt.Sprintf("%s/api/comics/%s/chapter-list?lang=%s&page=%d", baseURL, manga.URL, s.siteLang, page) body, err := s.get(context.Background(), u) if err != nil { return nil, err } var resp chapterListResp if err := json.Unmarshal(body, &resp); err != nil { return nil, err } chapters = append(chapters, resp.Data...) if resp.Pagination.Page >= resp.Pagination.LastPage { break } page++ } result := make([]source.SChapter, 0, len(chapters)) for _, ch := range chapters { chURL := fmt.Sprintf("/comic/%s/%s-chapter-%s-%s", manga.URL, ch.HID, ch.Chap, ch.Lang) name := buildChapterName(ch) result = append(result, source.SChapter{ URL: chURL, Name: name, DateUpload: parseComickDate(ch.CreatedAt), Scanlator: strings.Join(ch.Groups, ", "), }) } return result, nil } func buildChapterName(ch chapterDTO) string { var b strings.Builder if ch.Vol != "" { b.WriteString("Vol. ") b.WriteString(ch.Vol) b.WriteString(" ") } b.WriteString("Ch. ") b.WriteString(ch.Chap) if ch.Title != "" { b.WriteString(": ") b.WriteString(ch.Title) } return b.String() } func parseComickDate(s string) int64 { // "2024-01-15T10:30:00.123456Z" — try RFC3339Nano then RFC3339. for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.000000Z"} { if t, err := time.Parse(layout, s); err == nil { return t.UnixMilli() } } return 0 } func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { rawURL := chapter.URL if !strings.HasPrefix(rawURL, "http") { rawURL = baseURL + rawURL } doc, err := s.getDoc(context.Background(), rawURL) if err != nil { return nil, err } raw := doc.Find("#sv-data").Text() if raw == "" { return nil, fmt.Errorf("comicklive: #sv-data not found") } var data pageListDTO if err := json.Unmarshal([]byte(raw), &data); err != nil { return nil, err } pages := make([]source.Page, 0, len(data.Chapter.Images)) for i, img := range data.Chapter.Images { pages = append(pages, source.Page{Index: i, ImageURL: img.URL}) } return pages, nil } func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } func (s *Source) GetFilterList() []source.Filter { return nil } func init() { langs := []struct{ lang, site string }{ {"en", "en"}, {"ru", "ru"}, {"vi", "vi"}, {"fr", "fr"}, {"pl", "pl"}, {"id", "id"}, {"tr", "tr"}, {"it", "it"}, {"es", "es"}, {"uk", "uk"}, {"de", "de"}, {"ko", "ko"}, {"th", "th"}, {"ro", "ro"}, {"ms", "ms"}, {"ja", "ja"}, {"sv", "sv"}, {"no", "no"}, } for _, l := range langs { registry.Register(newSource(l.lang, l.site)) } }