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.
382 lines
11 KiB
Go
382 lines
11 KiB
Go
// Package comikey implements the Comikey manga/webtoon source.
|
|
// Popular/latest/search: HTML scraping. Details: #comic JSON script tag.
|
|
// Chapters: Gundam API (gundam.comikey.net) with optional auth token.
|
|
// GetPageList: requires WebView DRM — not supported in the Go port; returns error.
|
|
package comikey
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/PuerkitoBio/goquery"
|
|
|
|
"goyomi/internal/httpclient/flare"
|
|
"goyomi/internal/registry"
|
|
"goyomi/internal/source"
|
|
)
|
|
|
|
const gundamURL = "https://gundam.comikey.net"
|
|
|
|
type comikeyComic struct {
|
|
Link string `json:"link"`
|
|
Name string `json:"name"`
|
|
Author []struct{ Name string `json:"name"` } `json:"author"`
|
|
Artist []struct{ Name string `json:"name"` } `json:"artist"`
|
|
Tags []struct{ Name string `json:"name"` } `json:"tags"`
|
|
Description string `json:"description"`
|
|
Excerpt string `json:"excerpt"`
|
|
Format int `json:"format"`
|
|
FullCover string `json:"full_cover"`
|
|
UpdateStatus int `json:"update_status"`
|
|
UpdateText string `json:"update_text"`
|
|
}
|
|
|
|
type comikeyEpisodeResp struct {
|
|
Episodes []comikeyEpisode `json:"episodes"`
|
|
}
|
|
|
|
type comikeyEpisode struct {
|
|
ID string `json:"id"`
|
|
Number float32 `json:"number"`
|
|
Title string `json:"title"`
|
|
Subtitle string `json:"subtitle"`
|
|
ReleasedAt string `json:"releasedAt"`
|
|
FinalPrice int `json:"finalPrice"`
|
|
Owned bool `json:"owned"`
|
|
}
|
|
|
|
func (e comikeyEpisode) readable() bool { return e.FinalPrice == 0 || e.Owned }
|
|
|
|
type Source struct {
|
|
name string
|
|
baseURL string
|
|
lang string
|
|
client *flare.Client
|
|
id int64
|
|
}
|
|
|
|
func newSource(lang, name, baseURL string) *Source {
|
|
return &Source{
|
|
name: name,
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
lang: lang,
|
|
client: flare.NewClient(flare.WithRateLimit(3, 1)),
|
|
id: source.GenerateSourceID(name, lang),
|
|
}
|
|
}
|
|
|
|
func (s *Source) ID() int64 { return s.id }
|
|
func (s *Source) Name() string { return s.name }
|
|
func (s *Source) Lang() string { return s.lang }
|
|
func (s *Source) SupportsLatest() bool { return true }
|
|
|
|
func (s *Source) get(ctx context.Context, rawURL string) (*goquery.Document, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Referer", s.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("comikey: HTTP %d", resp.StatusCode)
|
|
}
|
|
return goquery.NewDocumentFromReader(resp.Body)
|
|
}
|
|
|
|
func (s *Source) parseList(doc *goquery.Document) source.MangasPage {
|
|
var mangas []source.SManga
|
|
doc.Find("div.series-listing[data-view=list] > ul > li").Each(func(_ int, el *goquery.Selection) {
|
|
link := el.Find("div.series-data span.title a").First()
|
|
href := link.AttrOr("href", "")
|
|
title := strings.TrimSpace(link.Text())
|
|
if href == "" || title == "" {
|
|
return
|
|
}
|
|
parsed, _ := url.Parse(href)
|
|
m := source.SManga{URL: parsed.RequestURI(), Title: title}
|
|
m.ThumbnailURL = el.Find("div.image picture img").First().AttrOr("src", "")
|
|
var genres []string
|
|
el.Find("ul.category-listing li a").Each(func(_ int, a *goquery.Selection) {
|
|
if t := strings.TrimSpace(a.Text()); t != "" {
|
|
genres = append(genres, t)
|
|
}
|
|
})
|
|
m.Genre = strings.Join(genres, ", ")
|
|
mangas = append(mangas, m)
|
|
})
|
|
hasNext := doc.Find("ul.pagination li.next-page:not(.disabled)").Length() > 0
|
|
return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}
|
|
}
|
|
|
|
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
|
|
doc, err := s.get(context.Background(), fmt.Sprintf("%s/comics/?order=-views&page=%d", s.baseURL, page))
|
|
if err != nil {
|
|
return source.MangasPage{}, err
|
|
}
|
|
return s.parseList(doc), nil
|
|
}
|
|
|
|
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
|
|
doc, err := s.get(context.Background(), fmt.Sprintf("%s/comics/?page=%d", s.baseURL, page))
|
|
if err != nil {
|
|
return source.MangasPage{}, err
|
|
}
|
|
return s.parseList(doc), nil
|
|
}
|
|
|
|
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
|
|
params := url.Values{}
|
|
if page > 1 {
|
|
params.Set("page", fmt.Sprint(page))
|
|
}
|
|
if len(query) >= 2 {
|
|
params.Set("q", query)
|
|
}
|
|
u := s.baseURL + "/comics/?"
|
|
if len(params) > 0 {
|
|
u += params.Encode()
|
|
}
|
|
doc, err := s.get(context.Background(), u)
|
|
if err != nil {
|
|
return source.MangasPage{}, err
|
|
}
|
|
return s.parseList(doc), nil
|
|
}
|
|
|
|
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
|
|
doc, err := s.get(context.Background(), s.baseURL+manga.URL)
|
|
if err != nil {
|
|
return manga, err
|
|
}
|
|
raw := doc.Find("script#comic").First().Text()
|
|
if raw == "" {
|
|
return manga, fmt.Errorf("comikey: #comic script not found")
|
|
}
|
|
var data comikeyComic
|
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
|
return manga, err
|
|
}
|
|
result := source.SManga{URL: manga.URL}
|
|
result.Title = data.Name
|
|
result.ThumbnailURL = s.baseURL + data.FullCover
|
|
var authors []string
|
|
for _, a := range data.Author {
|
|
authors = append(authors, a.Name)
|
|
}
|
|
result.Author = strings.Join(authors, ", ")
|
|
var artists []string
|
|
for _, a := range data.Artist {
|
|
artists = append(artists, a.Name)
|
|
}
|
|
result.Artist = strings.Join(artists, ", ")
|
|
result.Description = strings.TrimSpace(`"` + data.Excerpt + `"` + "\n\n" + data.Description)
|
|
var genres []string
|
|
for _, t := range data.Tags {
|
|
genres = append(genres, t.Name)
|
|
}
|
|
switch data.Format {
|
|
case 0:
|
|
genres = append(genres, "Comic")
|
|
case 1:
|
|
genres = append(genres, "Manga")
|
|
case 2:
|
|
genres = append(genres, "Webtoon")
|
|
}
|
|
result.Genre = strings.Join(genres, ", ")
|
|
result.Status = comikeyStatus(data.UpdateStatus, data.UpdateText)
|
|
return result, nil
|
|
}
|
|
|
|
func comikeyStatus(status int, updateText string) int {
|
|
switch {
|
|
case status == 1:
|
|
return source.StatusCompleted
|
|
case status == 3:
|
|
return source.StatusHiatus
|
|
case status >= 4 && status <= 14:
|
|
return source.StatusOngoing
|
|
case status == 0:
|
|
ut := strings.ToLower(updateText)
|
|
if strings.HasPrefix(ut, "toda") {
|
|
return source.StatusOngoing
|
|
}
|
|
if strings.HasPrefix(ut, "em pausa") || strings.HasPrefix(ut, "hiato") {
|
|
return source.StatusHiatus
|
|
}
|
|
}
|
|
return source.StatusUnknown
|
|
}
|
|
|
|
// pathSegments splits a URL path like "/comics/overlord/76/" into ["comics","overlord","76"].
|
|
func pathSegments(mangaURL string) []string {
|
|
return strings.FieldsFunc(mangaURL, func(r rune) bool { return r == '/' })
|
|
}
|
|
|
|
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
|
|
doc, err := s.get(context.Background(), s.baseURL+manga.URL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
segs := pathSegments(manga.URL)
|
|
if len(segs) < 3 {
|
|
return nil, fmt.Errorf("comikey: unexpected manga URL format: %s", manga.URL)
|
|
}
|
|
mangaSlug := segs[1] // e.g. "overlord"
|
|
mangaID := segs[2] // e.g. "76"
|
|
|
|
// Parse comic data to determine format (manga vs webtoon/episode).
|
|
chapterPrefix := "chapter"
|
|
if raw := doc.Find("script#comic").First().Text(); raw != "" {
|
|
var data comikeyComic
|
|
if json.Unmarshal([]byte(raw), &data) == nil && data.Format == 2 {
|
|
chapterPrefix = "episode"
|
|
}
|
|
}
|
|
|
|
// Extract gundam token if present.
|
|
gundamToken := ""
|
|
doc.Find("script").Each(func(_ int, el *goquery.Selection) {
|
|
if strings.Contains(el.Text(), "GUNDAM.token") {
|
|
t := el.Text()
|
|
if idx := strings.Index(t, `= "`); idx >= 0 {
|
|
t = t[idx+3:]
|
|
if end := strings.Index(t, `";`); end >= 0 {
|
|
gundamToken = t[:end]
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
// Build gundam API URL.
|
|
var apiURL string
|
|
if gundamToken != "" {
|
|
apiURL = fmt.Sprintf("%s/comic/%s/episodes?language=%s&token=%s",
|
|
gundamURL, mangaID, strings.ToLower(s.lang), url.QueryEscape(gundamToken))
|
|
} else {
|
|
apiURL = fmt.Sprintf("%s/comic.public/%s/episodes?language=%s",
|
|
gundamURL, mangaID, strings.ToLower(s.lang))
|
|
}
|
|
|
|
body, err := s.getAPIJSON(context.Background(), apiURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var resp comikeyEpisodeResp
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now().UnixMilli()
|
|
var chapters []source.SChapter
|
|
for _, ep := range resp.Episodes {
|
|
if !ep.readable() {
|
|
continue
|
|
}
|
|
date := parseComikeyDate(ep.ReleasedAt)
|
|
if date > now {
|
|
continue
|
|
}
|
|
chURL := fmt.Sprintf("/read/%s/%s", mangaSlug, makeEpisodeSlug(ep, chapterPrefix, s.lang))
|
|
name := ep.Title
|
|
if ep.Subtitle != "" {
|
|
name += ": " + ep.Subtitle
|
|
}
|
|
chapters = append(chapters, source.SChapter{
|
|
URL: chURL,
|
|
Name: name,
|
|
ChapterNumber: ep.Number,
|
|
DateUpload: date,
|
|
})
|
|
}
|
|
// Reverse to newest-first.
|
|
for i, j := 0, len(chapters)-1; i < j; i, j = i+1, j-1 {
|
|
chapters[i], chapters[j] = chapters[j], chapters[i]
|
|
}
|
|
return chapters, nil
|
|
}
|
|
|
|
func makeEpisodeSlug(ep comikeyEpisode, prefix, lang string) string {
|
|
parts := strings.SplitN(ep.ID, "-", 2)
|
|
e4pid := ep.ID
|
|
if len(parts) == 2 {
|
|
e4pid = parts[1]
|
|
}
|
|
locPrefix := prefix
|
|
if prefix == "chapter" && lang != "en" {
|
|
switch lang {
|
|
case "es":
|
|
locPrefix = "capitulo-espanol"
|
|
case "pt-BR":
|
|
locPrefix = "capitulo-portugues"
|
|
case "fr":
|
|
locPrefix = "chapitre-francais"
|
|
case "id":
|
|
locPrefix = "bab-bahasa"
|
|
}
|
|
}
|
|
numStr := fmt.Sprintf("%g", ep.Number)
|
|
numStr = strings.ReplaceAll(numStr, ".", "-")
|
|
return fmt.Sprintf("%s/%s-%s/", e4pid, locPrefix, numStr)
|
|
}
|
|
|
|
func parseComikeyDate(s string) int64 {
|
|
t, err := time.Parse("2006-01-02T15:04:05Z", s)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return t.UnixMilli()
|
|
}
|
|
|
|
func (s *Source) getAPIJSON(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", s.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("comikey: gundam API HTTP %d", resp.StatusCode)
|
|
}
|
|
buf := make([]byte, 0, 4096)
|
|
tmp := make([]byte, 4096)
|
|
for {
|
|
n, err := resp.Body.Read(tmp)
|
|
if n > 0 {
|
|
buf = append(buf, tmp[:n]...)
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
return buf, nil
|
|
}
|
|
|
|
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
|
|
return nil, fmt.Errorf("comikey: page list requires WebView/DRM — not supported in the Go port")
|
|
}
|
|
|
|
func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil }
|
|
func (s *Source) GetFilterList() []source.Filter { return nil }
|
|
|
|
func init() {
|
|
registry.Register(newSource("en", "Comikey", "https://comikey.com"))
|
|
registry.Register(newSource("es", "Comikey", "https://comikey.com"))
|
|
registry.Register(newSource("id", "Comikey", "https://comikey.com"))
|
|
registry.Register(newSource("pt-BR", "Comikey", "https://comikey.com"))
|
|
registry.Register(newSource("pt-BR", "Comikey Brasil", "https://br.comikey.com"))
|
|
}
|