fix(base): update guya, iken, kemono, madtheme, mangahub wrappers

- guya: align JSON structs with current API (seriesListEntry, seriesDetail),
  use Cover/Author/Artist fields, fix chapter date parsing via release_date
- iken: update DTO fields to match API (postTitle, featuredImage, etc.),
  add JSON-vs-HTML detection, map seriesStatus to source.Status constants
- kemono: set Accept: text/css header for DDOS-Guard bypass
- madtheme: use .book-detailed-item selector, fix pagination detection,
  use 'updated_at' sort for latest updates
- mangahub: check cookie jar in addition to Set-Cookie headers, add
  retry-once logic for API key expiry (matching Kotlin interceptor)
This commit is contained in:
achmad
2026-05-14 13:23:42 +07:00
parent 6953aa7833
commit aa697af25f
5 changed files with 185 additions and 63 deletions
+67 -25
View File
@@ -20,17 +20,37 @@ type Config struct {
Lang string Lang string
} }
type seriesEntry struct { // seriesListEntry is the per-entry format in GET /api/get_all_series/
Title string `json:"title"` // The outer JSON keys are the manga titles.
CoverVol string `json:"cover_vol"` type seriesListEntry struct {
Groups map[string]string `json:"groups"` Author string `json:"author"`
Chapters map[string]chapterEntry `json:"chapters"` Artist string `json:"artist"`
Description string `json:"description"`
Slug string `json:"slug"`
Cover string `json:"cover"`
Groups map[string]string `json:"groups"`
LastUpdated int64 `json:"last_updated"`
}
// seriesDetail is the format returned by GET /api/series/{slug}/
type seriesDetail struct {
Title string `json:"title"`
Author string `json:"author"`
Artist string `json:"artist"`
Description string `json:"description"`
Slug string `json:"slug"`
Cover string `json:"cover"`
Groups map[string]string `json:"groups"`
Chapters map[string]chapterEntry `json:"chapters"`
LastUpdated int64 `json:"last_updated"`
} }
type chapterEntry struct { type chapterEntry struct {
Title string `json:"title"` Volume string `json:"volume"`
Date int64 `json:"date"` Title string `json:"title"`
Groups map[string][]string `json:"groups"` Folder string `json:"folder"`
Groups map[string][]string `json:"groups"`
ReleaseDate map[string]int64 `json:"release_date"`
} }
type Source struct { type Source struct {
@@ -51,7 +71,7 @@ func (s *Source) SupportsLatest() bool { return true }
func (s *Source) base() string { return strings.TrimRight(s.cfg.BaseURL, "/") } func (s *Source) base() string { return strings.TrimRight(s.cfg.BaseURL, "/") }
func (s *Source) getAllSeries(ctx context.Context) (map[string]seriesEntry, error) { func (s *Source) getAllSeries(ctx context.Context) (map[string]seriesListEntry, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.base()+"/api/get_all_series/", nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.base()+"/api/get_all_series/", nil)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -68,21 +88,27 @@ func (s *Source) getAllSeries(ctx context.Context) (map[string]seriesEntry, erro
if err != nil { if err != nil {
return nil, err return nil, err
} }
var result map[string]seriesEntry var result map[string]seriesListEntry
if err := json.Unmarshal(body, &result); err != nil { if err := json.Unmarshal(body, &result); err != nil {
return nil, err return nil, err
} }
return result, nil return result, nil
} }
func (s *Source) toSManga(slug string, entry seriesEntry) source.SManga { func (s *Source) toSManga(title string, entry seriesListEntry) source.SManga {
thumb := "" thumb := ""
if entry.CoverVol != "" { if entry.Cover != "" {
thumb = fmt.Sprintf("%s/media/manga/%s/volume-covers/%s", s.base(), slug, entry.CoverVol) thumb = entry.Cover
if !strings.HasPrefix(thumb, "http") {
thumb = s.base() + "/" + thumb
}
} }
return source.SManga{ return source.SManga{
URL: fmt.Sprintf("/reader/series/%s/", slug), URL: fmt.Sprintf("/reader/series/%s/", entry.Slug),
Title: entry.Title, Title: title,
Artist: entry.Artist,
Author: entry.Author,
Description: entry.Description,
ThumbnailURL: thumb, ThumbnailURL: thumb,
} }
} }
@@ -96,8 +122,8 @@ func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
return source.MangasPage{}, err return source.MangasPage{}, err
} }
var mangas []source.SManga var mangas []source.SManga
for slug, entry := range series { for title, entry := range series {
mangas = append(mangas, s.toSManga(slug, entry)) mangas = append(mangas, s.toSManga(title, entry))
} }
return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil
} }
@@ -134,11 +160,22 @@ func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
} }
defer resp.Body.Close() defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
var entry seriesEntry var detail seriesDetail
if err := json.Unmarshal(body, &entry); err != nil { if err := json.Unmarshal(body, &detail); err != nil {
return manga, err return manga, err
} }
return s.toSManga(slug, entry), nil result := source.SManga{
URL: manga.URL,
Title: detail.Title,
Artist: detail.Artist,
Author: detail.Author,
Description: detail.Description,
ThumbnailURL: detail.Cover,
}
if result.ThumbnailURL != "" && !strings.HasPrefix(result.ThumbnailURL, "http") {
result.ThumbnailURL = s.base() + "/" + result.ThumbnailURL
}
return result, nil
} }
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
@@ -151,27 +188,32 @@ func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error)
} }
defer resp.Body.Close() defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
var entry seriesEntry var detail seriesDetail
if err := json.Unmarshal(body, &entry); err != nil { if err := json.Unmarshal(body, &detail); err != nil {
return nil, err return nil, err
} }
var chapters []source.SChapter var chapters []source.SChapter
for chNum, ch := range entry.Chapters { for chNum, ch := range detail.Chapters {
name := "Chapter " + chNum name := "Chapter " + chNum
if ch.Title != "" { if ch.Title != "" {
name += " - " + ch.Title name += " - " + ch.Title
} }
dateUpload := int64(0)
for _, ts := range ch.ReleaseDate {
if ts > dateUpload {
dateUpload = ts
}
}
chapters = append(chapters, source.SChapter{ chapters = append(chapters, source.SChapter{
URL: fmt.Sprintf("%s/reader/series/%s/%s/", s.base(), slug, chNum), URL: fmt.Sprintf("%s/reader/series/%s/%s/", s.base(), slug, chNum),
Name: name, Name: name,
DateUpload: ch.Date * 1000, DateUpload: dateUpload * 1000,
}) })
} }
return chapters, nil return chapters, nil
} }
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
// Pages are served as images at known paths; minimal impl returns empty
return nil, nil return nil, nil
} }
+44 -24
View File
@@ -1,8 +1,9 @@
// Package iken implements the Iken manga base. // Package iken implements the Iken manga base.
// JSON REST API; GET {apiUrl}/api/query?page=N&perPage=18; CF-protected. // JSON REST API; GET {apiUrl}/api/query?page=N&perPage=18.
package iken package iken
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -61,25 +62,31 @@ func (s *Source) getJSON(ctx context.Context, rawURL string, out any) error {
return fmt.Errorf("iken: HTTP %d", resp.StatusCode) return fmt.Errorf("iken: HTTP %d", resp.StatusCode)
} }
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
if isHTML(body) {
summary := string(body)
if len(summary) > 120 {
summary = summary[:120]
}
return fmt.Errorf("iken: expected JSON, got HTML: %s", summary)
}
return json.Unmarshal(body, out) return json.Unmarshal(body, out)
} }
func isHTML(body []byte) bool {
t := bytes.TrimSpace(body)
return bytes.HasPrefix(t, []byte("<")) && !bytes.HasPrefix(t, []byte("{")) && !bytes.HasPrefix(t, []byte("["))
}
type postDTO struct { type postDTO struct {
ID int `json:"id"` ID int `json:"id"`
Slug string `json:"slug"` Slug string `json:"slug"`
Title string `json:"title"` PostTitle string `json:"postTitle"`
Thumbnail string `json:"thumbnail"` FeaturedImage string `json:"featuredImage"`
Description string `json:"description"` PostContent string `json:"postContent"`
Author string `json:"author"` Author string `json:"author"`
Status string `json:"status"` SeriesStatus string `json:"seriesStatus"`
IsNovel bool `json:"isNovel"` IsNovel bool `json:"isNovel"`
Tags []struct{ Name string `json:"name"` } `json:"tags"` Genres []struct{ Name string `json:"name"` } `json:"genres"`
Chapters []struct {
ID int `json:"id"`
Number float64 `json:"number"`
Title string `json:"title"`
Date string `json:"date"`
} `json:"chapters"`
} }
type searchResponseDTO struct { type searchResponseDTO struct {
@@ -90,8 +97,8 @@ type searchResponseDTO struct {
func (s *Source) toSManga(p postDTO) source.SManga { func (s *Source) toSManga(p postDTO) source.SManga {
return source.SManga{ return source.SManga{
URL: fmt.Sprintf("%s#%d", p.Slug, p.ID), URL: fmt.Sprintf("%s#%d", p.Slug, p.ID),
Title: p.Title, Title: p.PostTitle,
ThumbnailURL: util.AbsURL(s.cfg.BaseURL, p.Thumbnail), ThumbnailURL: util.AbsURL(s.cfg.BaseURL, p.FeaturedImage),
} }
} }
@@ -136,18 +143,18 @@ func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
return manga, err return manga, err
} }
p := result.Post p := result.Post
genres := make([]string, len(p.Tags)) genres := make([]string, len(p.Genres))
for i, t := range p.Tags { for i, t := range p.Genres {
genres[i] = t.Name genres[i] = t.Name
} }
return source.SManga{ return source.SManga{
URL: manga.URL, URL: manga.URL,
Title: p.Title, Title: p.PostTitle,
Author: p.Author, Author: p.Author,
Description: p.Description, Description: p.PostContent,
Genre: strings.Join(genres, ", "), Genre: strings.Join(genres, ", "),
Status: util.StatusFromString(p.Status), Status: ikenStatus(p.SeriesStatus),
ThumbnailURL: util.AbsURL(s.cfg.BaseURL, p.Thumbnail), ThumbnailURL: util.AbsURL(s.cfg.BaseURL, p.FeaturedImage),
}, nil }, nil
} }
@@ -204,6 +211,19 @@ func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil }
func (s *Source) GetFilterList() []source.Filter { return nil } func (s *Source) GetFilterList() []source.Filter { return nil }
func ikenStatus(s string) int {
switch s {
case "ONGOING", "COMING_SOON":
return source.StatusOngoing
case "COMPLETED":
return source.StatusCompleted
case "CANCELLED", "DROPPED":
return source.StatusCancelled
default:
return source.StatusUnknown
}
}
func postIDFromURL(u string) string { func postIDFromURL(u string) string {
if idx := strings.LastIndex(u, "#"); idx >= 0 { if idx := strings.LastIndex(u, "#"); idx >= 0 {
return u[idx+1:] return u[idx+1:]
+1
View File
@@ -66,6 +66,7 @@ func (s *Source) getJSON(ctx context.Context, rawURL string, out any) error {
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("Accept", "text/css")
resp, err := s.client.Do(req) resp, err := s.client.Do(req)
if err != nil { if err != nil {
return err return err
+3 -8
View File
@@ -67,7 +67,7 @@ func (s *Source) searchURL(page int, q, sort string) string {
func (s *Source) parseMangaList(doc *goquery.Document) source.MangasPage { func (s *Source) parseMangaList(doc *goquery.Document) source.MangasPage {
var mangas []source.SManga var mangas []source.SManga
doc.Find("div.book-item, div.item, div.manga-item").Each(func(_ int, el *goquery.Selection) { doc.Find(".book-detailed-item").Each(func(_ int, el *goquery.Selection) {
m := source.SManga{} m := source.SManga{}
el.Find("a").First().Each(func(_ int, a *goquery.Selection) { el.Find("a").First().Each(func(_ int, a *goquery.Selection) {
if href, ok := a.Attr("href"); ok { if href, ok := a.Attr("href"); ok {
@@ -75,11 +75,6 @@ func (s *Source) parseMangaList(doc *goquery.Document) source.MangasPage {
} }
m.Title = strings.TrimSpace(a.AttrOr("title", "")) m.Title = strings.TrimSpace(a.AttrOr("title", ""))
}) })
if m.Title == "" {
el.Find("div.title, h3, h2").First().Each(func(_ int, e *goquery.Selection) {
m.Title = strings.TrimSpace(e.Text())
})
}
el.Find("img").First().Each(func(_ int, img *goquery.Selection) { el.Find("img").First().Each(func(_ int, img *goquery.Selection) {
m.ThumbnailURL = imgAttr(img, s.cfg.BaseURL) m.ThumbnailURL = imgAttr(img, s.cfg.BaseURL)
}) })
@@ -87,7 +82,7 @@ func (s *Source) parseMangaList(doc *goquery.Document) source.MangasPage {
mangas = append(mangas, m) mangas = append(mangas, m)
} }
}) })
hasNext := doc.Find(".next, .pagination .next, a[rel=next]").Length() > 0 hasNext := doc.Find(".paginator > a.active + a:not([rel=next])").Length() > 0
return source.MangasPage{Mangas: mangas, HasNextPage: hasNext} return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}
} }
@@ -100,7 +95,7 @@ func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
} }
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
doc, err := s.get(context.Background(), s.searchURL(page, "", "latest")) doc, err := s.get(context.Background(), s.searchURL(page, "", "updated_at"))
if err != nil { if err != nil {
return source.MangasPage{}, err return source.MangasPage{}, err
} }
+70 -6
View File
@@ -67,8 +67,10 @@ func (s *Source) src() string {
} }
// ensureAccessKey fetches the mhub_access cookie by loading a chapter page, // ensureAccessKey fetches the mhub_access cookie by loading a chapter page,
// caching it for 10 minutes. // caching it for 10 minutes. It checks both the response Set-Cookie header
func (s *Source) ensureAccessKey(ctx context.Context) (string, error) { // and the client's cookie jar (matching the Kotlin implementation which reads
// cookies from the jar via client.cookieJar.loadForRequest()).
func (s *Source) ensureAccessKey(ctx context.Context, refreshURL string) (string, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -77,22 +79,37 @@ func (s *Source) ensureAccessKey(ctx context.Context) (string, error) {
return s.accessKey, nil return s.accessKey, nil
} }
urls := []string{ // Try the jar first — a previous request may have stored the cookie.
if v := s.client.Cookie("mhub_access", s.cfg.BaseURL); v != "" {
s.accessKey = v
s.refreshed = now
return s.accessKey, nil
}
chapterURLs := []string{
s.cfg.BaseURL + "/chapter/martial-peak/chapter-1000", s.cfg.BaseURL + "/chapter/martial-peak/chapter-1000",
s.cfg.BaseURL + "/chapter/martial-peak/chapter-1000?reloadKey=1", s.cfg.BaseURL + "/chapter/martial-peak/chapter-1000?reloadKey=1",
} }
for _, u := range urls { // If a specific refresh URL was provided (e.g. from a failing GraphQL
// request's manga URL), prefer it over the fallback.
if refreshURL != "" {
chapterURLs = append([]string{refreshURL, refreshURL + "?reloadKey=1"}, chapterURLs...)
}
referer := s.cfg.BaseURL + "/manga/martial-peak"
for _, u := range chapterURLs {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Set("Referer", s.cfg.BaseURL+"/manga/martial-peak") req.Header.Set("Referer", referer)
resp, err := s.client.Do(req) resp, err := s.client.Do(req)
if err != nil { if err != nil {
return "", err return "", err
} }
resp.Body.Close() resp.Body.Close()
// Check Set-Cookie headers (fixed by doFS() now propagating FS headers).
for _, ck := range resp.Header.Values("set-cookie") { for _, ck := range resp.Header.Values("set-cookie") {
if m := apiKeyRe.FindStringSubmatch(ck); len(m) == 2 && m[1] != "" { if m := apiKeyRe.FindStringSubmatch(ck); len(m) == 2 && m[1] != "" {
s.accessKey = m[1] s.accessKey = m[1]
@@ -100,6 +117,13 @@ func (s *Source) ensureAccessKey(ctx context.Context) (string, error) {
return s.accessKey, nil return s.accessKey, nil
} }
} }
// Also check the jar — FS cookies were fed into it and may include
// mhub_access even when the response headers don't.
if v := s.client.Cookie("mhub_access", s.cfg.BaseURL); v != "" {
s.accessKey = v
s.refreshed = now
return s.accessKey, nil
}
} }
return "", fmt.Errorf("mangahub: mhub_access cookie not found") return "", fmt.Errorf("mangahub: mhub_access cookie not found")
} }
@@ -113,7 +137,13 @@ func (s *Source) invalidateKey() {
// gql sends a raw GraphQL query string (no variables — uses direct interpolation // gql sends a raw GraphQL query string (no variables — uses direct interpolation
// matching the Kotlin implementation) and unmarshals data into out. // matching the Kotlin implementation) and unmarshals data into out.
func (s *Source) gql(ctx context.Context, query string, out any) error { func (s *Source) gql(ctx context.Context, query string, out any) error {
key, err := s.ensureAccessKey(ctx) return s.gqlRetryOnce(ctx, query, out, "")
}
// gqlRetryOnce is like gql but retries once on API key errors, matching the
// Kotlin interceptor pattern that refreshes the key and retries the request.
func (s *Source) gqlRetryOnce(ctx context.Context, query string, out any, refreshURL string) error {
key, err := s.ensureAccessKey(ctx, refreshURL)
if err != nil { if err != nil {
return err return err
} }
@@ -158,6 +188,40 @@ func (s *Source) gql(ctx context.Context, query string, out any) error {
lower := strings.ToLower(msg) lower := strings.ToLower(msg)
if strings.Contains(lower, "rate") || strings.Contains(lower, "api key") { if strings.Contains(lower, "rate") || strings.Contains(lower, "api key") {
s.invalidateKey() s.invalidateKey()
// Retry once with a refreshed key, like Kotlin's interceptor.
key2, err2 := s.ensureAccessKey(ctx, refreshURL)
if err2 != nil {
return fmt.Errorf("mangahub: %s (key refresh failed: %v)", msg, err2)
}
req2, _ := http.NewRequestWithContext(ctx, http.MethodPost,
s.cfg.APIURL+"/graphql", strings.NewReader(string(payload)))
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("Accept", "application/json")
req2.Header.Set("Origin", s.cfg.BaseURL)
req2.Header.Set("Referer", s.cfg.BaseURL+"/")
req2.Header.Set("x-mhub-access", key2)
resp2, err2 := s.client.Do(req2)
if err2 != nil {
return err2
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
return fmt.Errorf("mangahub: HTTP %d (after key refresh)", resp2.StatusCode)
}
raw2, _ := io.ReadAll(resp2.Body)
var wrapper2 struct {
Data json.RawMessage `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err2 := json.Unmarshal(raw2, &wrapper2); err2 != nil {
return err2
}
if len(wrapper2.Errors) > 0 {
return fmt.Errorf("mangahub: %s (after key refresh)", wrapper2.Errors[0].Message)
}
return json.Unmarshal(wrapper2.Data, out)
} }
return fmt.Errorf("mangahub: %s", msg) return fmt.Errorf("mangahub: %s", msg)
} }