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
}
type seriesEntry struct {
Title string `json:"title"`
CoverVol string `json:"cover_vol"`
Groups map[string]string `json:"groups"`
Chapters map[string]chapterEntry `json:"chapters"`
// seriesListEntry is the per-entry format in GET /api/get_all_series/
// The outer JSON keys are the manga titles.
type seriesListEntry struct {
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"`
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 {
Title string `json:"title"`
Date int64 `json:"date"`
Groups map[string][]string `json:"groups"`
Volume string `json:"volume"`
Title string `json:"title"`
Folder string `json:"folder"`
Groups map[string][]string `json:"groups"`
ReleaseDate map[string]int64 `json:"release_date"`
}
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) 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)
if err != nil {
return nil, err
@@ -68,21 +88,27 @@ func (s *Source) getAllSeries(ctx context.Context) (map[string]seriesEntry, erro
if err != nil {
return nil, err
}
var result map[string]seriesEntry
var result map[string]seriesListEntry
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return result, nil
}
func (s *Source) toSManga(slug string, entry seriesEntry) source.SManga {
func (s *Source) toSManga(title string, entry seriesListEntry) source.SManga {
thumb := ""
if entry.CoverVol != "" {
thumb = fmt.Sprintf("%s/media/manga/%s/volume-covers/%s", s.base(), slug, entry.CoverVol)
if entry.Cover != "" {
thumb = entry.Cover
if !strings.HasPrefix(thumb, "http") {
thumb = s.base() + "/" + thumb
}
}
return source.SManga{
URL: fmt.Sprintf("/reader/series/%s/", slug),
Title: entry.Title,
URL: fmt.Sprintf("/reader/series/%s/", entry.Slug),
Title: title,
Artist: entry.Artist,
Author: entry.Author,
Description: entry.Description,
ThumbnailURL: thumb,
}
}
@@ -96,8 +122,8 @@ func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
return source.MangasPage{}, err
}
var mangas []source.SManga
for slug, entry := range series {
mangas = append(mangas, s.toSManga(slug, entry))
for title, entry := range series {
mangas = append(mangas, s.toSManga(title, entry))
}
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()
body, _ := io.ReadAll(resp.Body)
var entry seriesEntry
if err := json.Unmarshal(body, &entry); err != nil {
var detail seriesDetail
if err := json.Unmarshal(body, &detail); err != nil {
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) {
@@ -151,27 +188,32 @@ func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var entry seriesEntry
if err := json.Unmarshal(body, &entry); err != nil {
var detail seriesDetail
if err := json.Unmarshal(body, &detail); err != nil {
return nil, err
}
var chapters []source.SChapter
for chNum, ch := range entry.Chapters {
for chNum, ch := range detail.Chapters {
name := "Chapter " + chNum
if ch.Title != "" {
name += " - " + ch.Title
}
dateUpload := int64(0)
for _, ts := range ch.ReleaseDate {
if ts > dateUpload {
dateUpload = ts
}
}
chapters = append(chapters, source.SChapter{
URL: fmt.Sprintf("%s/reader/series/%s/%s/", s.base(), slug, chNum),
Name: name,
DateUpload: ch.Date * 1000,
DateUpload: dateUpload * 1000,
})
}
return chapters, nil
}
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
}
+44 -24
View File
@@ -1,8 +1,9 @@
// 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
import (
"bytes"
"context"
"encoding/json"
"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)
}
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)
}
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 {
ID int `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Thumbnail string `json:"thumbnail"`
Description string `json:"description"`
Author string `json:"author"`
Status string `json:"status"`
IsNovel bool `json:"isNovel"`
Tags []struct{ Name string `json:"name"` } `json:"tags"`
Chapters []struct {
ID int `json:"id"`
Number float64 `json:"number"`
Title string `json:"title"`
Date string `json:"date"`
} `json:"chapters"`
ID int `json:"id"`
Slug string `json:"slug"`
PostTitle string `json:"postTitle"`
FeaturedImage string `json:"featuredImage"`
PostContent string `json:"postContent"`
Author string `json:"author"`
SeriesStatus string `json:"seriesStatus"`
IsNovel bool `json:"isNovel"`
Genres []struct{ Name string `json:"name"` } `json:"genres"`
}
type searchResponseDTO struct {
@@ -90,8 +97,8 @@ type searchResponseDTO struct {
func (s *Source) toSManga(p postDTO) source.SManga {
return source.SManga{
URL: fmt.Sprintf("%s#%d", p.Slug, p.ID),
Title: p.Title,
ThumbnailURL: util.AbsURL(s.cfg.BaseURL, p.Thumbnail),
Title: p.PostTitle,
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
}
p := result.Post
genres := make([]string, len(p.Tags))
for i, t := range p.Tags {
genres := make([]string, len(p.Genres))
for i, t := range p.Genres {
genres[i] = t.Name
}
return source.SManga{
URL: manga.URL,
Title: p.Title,
Title: p.PostTitle,
Author: p.Author,
Description: p.Description,
Description: p.PostContent,
Genre: strings.Join(genres, ", "),
Status: util.StatusFromString(p.Status),
ThumbnailURL: util.AbsURL(s.cfg.BaseURL, p.Thumbnail),
Status: ikenStatus(p.SeriesStatus),
ThumbnailURL: util.AbsURL(s.cfg.BaseURL, p.FeaturedImage),
}, 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) 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 {
if idx := strings.LastIndex(u, "#"); idx >= 0 {
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 {
return err
}
req.Header.Set("Accept", "text/css")
resp, err := s.client.Do(req)
if err != nil {
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 {
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{}
el.Find("a").First().Each(func(_ int, a *goquery.Selection) {
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", ""))
})
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) {
m.ThumbnailURL = imgAttr(img, s.cfg.BaseURL)
})
@@ -87,7 +82,7 @@ func (s *Source) parseMangaList(doc *goquery.Document) source.MangasPage {
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}
}
@@ -100,7 +95,7 @@ func (s *Source) GetPopularManga(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 {
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,
// caching it for 10 minutes.
func (s *Source) ensureAccessKey(ctx context.Context) (string, error) {
// caching it for 10 minutes. It checks both the response Set-Cookie header
// 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()
defer s.mu.Unlock()
@@ -77,22 +79,37 @@ func (s *Source) ensureAccessKey(ctx context.Context) (string, error) {
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?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)
if err != nil {
return "", err
}
req.Header.Set("Referer", s.cfg.BaseURL+"/manga/martial-peak")
req.Header.Set("Referer", referer)
resp, err := s.client.Do(req)
if err != nil {
return "", err
}
resp.Body.Close()
// Check Set-Cookie headers (fixed by doFS() now propagating FS headers).
for _, ck := range resp.Header.Values("set-cookie") {
if m := apiKeyRe.FindStringSubmatch(ck); len(m) == 2 && m[1] != "" {
s.accessKey = m[1]
@@ -100,6 +117,13 @@ func (s *Source) ensureAccessKey(ctx context.Context) (string, error) {
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")
}
@@ -113,7 +137,13 @@ func (s *Source) invalidateKey() {
// gql sends a raw GraphQL query string (no variables — uses direct interpolation
// matching the Kotlin implementation) and unmarshals data into out.
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 {
return err
}
@@ -158,6 +188,40 @@ func (s *Source) gql(ctx context.Context, query string, out any) error {
lower := strings.ToLower(msg)
if strings.Contains(lower, "rate") || strings.Contains(lower, "api key") {
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)
}