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