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:
+67
-25
@@ -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
@@ -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:]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user