Files
achmad 316ae2f9db feat: implement phase 4 batch — 54 base-class wrapper sources
Add 8 all/ sources (7 Masonry, 1 Madara) and 38 en/ sources spanning
Madara, MangaThemesia, MadTheme, Keyoapp, and Guya bases, plus 8 earlier
all/ standalone sources from the previous session (ahottie, akuma,
allporncomicsco, asmhentai, baobua, beauty3600000, buondua, comicfury,
comicgrowl, comicklive, comicsvalley, comikey, commitstrip, coomer).

Also annotates phase4-standalone.md with base-class tags for 43 additional
unimplemented en/ sources identified in a full scan.
2026-05-13 23:11:26 +07:00

427 lines
11 KiB
Go

// 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))
}
}