diff --git a/sources/base/guya/guya.go b/sources/base/guya/guya.go index e025561..84789a8 100755 --- a/sources/base/guya/guya.go +++ b/sources/base/guya/guya.go @@ -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 } diff --git a/sources/base/iken/iken.go b/sources/base/iken/iken.go index af6b25e..ebd0124 100755 --- a/sources/base/iken/iken.go +++ b/sources/base/iken/iken.go @@ -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:] diff --git a/sources/base/kemono/kemono.go b/sources/base/kemono/kemono.go index 1c1e97f..1804e9f 100755 --- a/sources/base/kemono/kemono.go +++ b/sources/base/kemono/kemono.go @@ -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 diff --git a/sources/base/madtheme/madtheme.go b/sources/base/madtheme/madtheme.go index 1da9ab6..1f5343b 100755 --- a/sources/base/madtheme/madtheme.go +++ b/sources/base/madtheme/madtheme.go @@ -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 } diff --git a/sources/base/mangahub/mangahub.go b/sources/base/mangahub/mangahub.go index 5f24d88..688367e 100755 --- a/sources/base/mangahub/mangahub.go +++ b/sources/base/mangahub/mangahub.go @@ -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) }