Files
goyomi/sources/all/comikey/comikey.go
T
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

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