316ae2f9db
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.
427 lines
11 KiB
Go
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))
|
|
}
|
|
}
|