From 97d621d7f1752dfb36da6b80593d83800a3ce350 Mon Sep 17 00:00:00 2001 From: achmad Date: Wed, 13 May 2026 23:25:32 +0700 Subject: [PATCH] fix: correct mangahub base and all wrapper sources - Fix GraphQL x param: was "POPULAR"/"LATEST", must be per-site source ID (e.g. "m01"); order type moved to separate mod param - Add mhub_access cookie acquisition (x-mhub-access header required on all API calls); cached 10 min, retried with ?reloadKey=1 on failure - Fix image URLs: construct as imgx.mghcdn.com/{p}{image} from pages JSON - Fix thumbnail URLs: add thumb.mghcdn.com/ CDN prefix - Fix hasNext: use len(rows)==30 instead of non-existent count field - Fix chapter URL format: /{slug}/chapter-{num} matching Kotlin - Fix page URL parsing to match new chapter URL format - Add artist and alternativeTitle fields to manga details - Fix status parsing: "ongoing"/"completed" string values - Switch from parameterized GQL variables to direct string interpolation - Add MangaSource field to Config; update all 11 wrapper sources with their correct per-site source IDs --- sources/base/mangahub/mangahub.go | 334 ++++++++++++------ sources/en/mangafoxfun/mangafoxfun.go | 7 +- sources/en/mangahereonl/mangahereonl.go | 7 +- sources/en/mangahubio/mangahubio.go | 7 +- sources/en/mangakakalotfun/mangakakalotfun.go | 7 +- sources/en/manganel/manganel.go | 7 +- sources/en/mangaonlinefun/mangaonlinefun.go | 7 +- sources/en/mangapandaonl/mangapandaonl.go | 7 +- sources/en/mangareadersite/mangareadersite.go | 7 +- sources/en/mangatoday/mangatoday.go | 7 +- sources/en/onemangaco/onemangaco.go | 7 +- sources/en/onemangainfo/onemangainfo.go | 7 +- 12 files changed, 270 insertions(+), 141 deletions(-) diff --git a/sources/base/mangahub/mangahub.go b/sources/base/mangahub/mangahub.go index a9f0263..5f24d88 100755 --- a/sources/base/mangahub/mangahub.go +++ b/sources/base/mangahub/mangahub.go @@ -1,5 +1,8 @@ // Package mangahub implements the MangaHub GraphQL base. -// Cookie acquisition + GraphQL POST to {api}/graphql. +// Each mirror site has a per-site source identifier (MangaSource, e.g. "m01") +// passed as the `x` argument in all GraphQL queries. +// API auth requires an `mhub_access` cookie obtained by loading a chapter page +// and sent as the `x-mhub-access` request header. package mangahub import ( @@ -8,62 +11,131 @@ import ( "fmt" "io" "net/http" + "regexp" "strings" + "sync" + "time" "goyomi/internal/httpclient/flare" "goyomi/internal/source" "goyomi/sources/base/util" ) +const ( + baseCDNURL = "https://imgx.mghcdn.com" + thumbCDNURL = "https://thumb.mghcdn.com" + defaultAPIURL = "https://api.mghcdn.com" +) + +var apiKeyRe = regexp.MustCompile(`mhub_access=([^;]+)`) + type Config struct { - Name string - BaseURL string - APIURL string - Lang string + Name string + BaseURL string + APIURL string + Lang string + MangaSource string // per-site source identifier, e.g. "m01", "mh01" } type Source struct { - cfg Config - client *flare.Client - id int64 + cfg Config + client *flare.Client + id int64 + mu sync.Mutex + accessKey string + refreshed int64 } func New(cfg Config) *Source { if cfg.APIURL == "" { - cfg.APIURL = "https://api.mghubcdn.com" + cfg.APIURL = defaultAPIURL } c := flare.NewClient(flare.WithRateLimit(1, 2)) return &Source{cfg: cfg, client: c, id: source.GenerateSourceID(cfg.Name, cfg.Lang)} } -func (s *Source) ID() int64 { return s.id } -func (s *Source) Name() string { return s.cfg.Name } -func (s *Source) Lang() string { return s.cfg.Lang } +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return s.cfg.Name } +func (s *Source) Lang() string { return s.cfg.Lang } func (s *Source) SupportsLatest() bool { return true } -type gqlRequest struct { - Query string `json:"query"` - Variables map[string]any `json:"variables"` +func (s *Source) src() string { + if s.cfg.MangaSource == "" { + return "m01" + } + return s.cfg.MangaSource } -type mangaDTO struct { - ID int `json:"id"` - Slug string `json:"slug"` - Title string `json:"title"` - Image string `json:"image"` - Author string `json:"author"` - Description string `json:"description"` - Status string `json:"status"` - Genres string `json:"genres"` +// 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) { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now().UnixMilli() + if s.accessKey != "" && now-s.refreshed < 10*60*1000 { + return s.accessKey, nil + } + + urls := []string{ + s.cfg.BaseURL + "/chapter/martial-peak/chapter-1000", + s.cfg.BaseURL + "/chapter/martial-peak/chapter-1000?reloadKey=1", + } + for _, u := range urls { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return "", err + } + req.Header.Set("Referer", s.cfg.BaseURL+"/manga/martial-peak") + resp, err := s.client.Do(req) + if err != nil { + return "", err + } + resp.Body.Close() + + for _, ck := range resp.Header.Values("set-cookie") { + if m := apiKeyRe.FindStringSubmatch(ck); len(m) == 2 && m[1] != "" { + s.accessKey = m[1] + s.refreshed = now + return s.accessKey, nil + } + } + } + return "", fmt.Errorf("mangahub: mhub_access cookie not found") } -func (s *Source) gql(ctx context.Context, query string, vars map[string]any, out any) error { - body, _ := json.Marshal(gqlRequest{Query: query, Variables: vars}) - resp, err := s.client.Post(ctx, s.cfg.APIURL+"/graphql", "application/json", strings.NewReader(string(body))) +func (s *Source) invalidateKey() { + s.mu.Lock() + s.accessKey = "" + s.mu.Unlock() +} + +// 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) + if err != nil { + return err + } + + payload, _ := json.Marshal(map[string]string{"query": query}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + s.cfg.APIURL+"/graphql", strings.NewReader(string(payload))) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Origin", s.cfg.BaseURL) + req.Header.Set("Referer", s.cfg.BaseURL+"/") + req.Header.Set("x-mhub-access", key) + + resp, err := s.client.Do(req) if err != nil { return err } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { return fmt.Errorf("mangahub: HTTP %d", resp.StatusCode) } @@ -71,162 +143,208 @@ func (s *Source) gql(ctx context.Context, query string, vars map[string]any, out if err != nil { return err } + var wrapper struct { Data json.RawMessage `json:"data"` - Errors []struct{ Message string `json:"message"` } `json:"errors"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` } if err := json.Unmarshal(raw, &wrapper); err != nil { return err } if len(wrapper.Errors) > 0 { - return fmt.Errorf("mangahub: %s", wrapper.Errors[0].Message) + msg := wrapper.Errors[0].Message + lower := strings.ToLower(msg) + if strings.Contains(lower, "rate") || strings.Contains(lower, "api key") { + s.invalidateKey() + } + return fmt.Errorf("mangahub: %s", msg) } return json.Unmarshal(wrapper.Data, out) } -const searchMangaQuery = `query searchManga($x: XWHERE, $genre: String, $mod: XMOD, $page: Int) { - search(x: $x, genre: $genre, mod: $mod, offset: $page) { - rows { id slug title image } - count - } -}` +type searchRow struct { + Slug string `json:"slug"` + Title string `json:"title"` + Image string `json:"image"` +} -func (s *Source) fetchMangaList(ctx context.Context, page int, x string) (source.MangasPage, error) { +func (s *Source) fetchList(ctx context.Context, page int, mod, query string) (source.MangasPage, error) { + gqlQuery := fmt.Sprintf( + `{search(x: %s, q: %q, genre: "all", mod: %s, offset: %d) {rows{title,slug,image}}}`, + s.src(), query, mod, (page-1)*30, + ) var result struct { Search struct { - Rows []mangaDTO `json:"rows"` - Count int `json:"count"` + Rows []searchRow `json:"rows"` } `json:"search"` } - vars := map[string]any{"x": x, "genre": "all", "page": (page - 1) * 12} - if err := s.gql(ctx, searchMangaQuery, vars, &result); err != nil { + if err := s.gql(ctx, gqlQuery, &result); err != nil { return source.MangasPage{}, err } mangas := make([]source.SManga, len(result.Search.Rows)) for i, m := range result.Search.Rows { mangas[i] = source.SManga{ - URL: fmt.Sprintf("/manga/%s", m.Slug), + URL: "/manga/" + m.Slug, Title: m.Title, - ThumbnailURL: m.Image, + ThumbnailURL: thumbCDNURL + "/" + m.Image, } } - hasNext := (page * 12) < result.Search.Count - return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil + return source.MangasPage{Mangas: mangas, HasNextPage: len(result.Search.Rows) == 30}, nil } func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { - return s.fetchMangaList(context.Background(), page, "POPULAR") + return s.fetchList(context.Background(), page, "POPULAR", "") } func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { - return s.fetchMangaList(context.Background(), page, "LATEST") + return s.fetchList(context.Background(), page, "LATEST", "") } -func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { - const searchQuery = `query searchManga($x: XWHERE, $mod: XMOD, $q: String, $page: Int) { - search(x: $x, mod: $mod, q: $q, offset: $page) { - rows { id slug title image } - count - } -}` - var result struct { - Search struct { - Rows []mangaDTO `json:"rows"` - Count int `json:"count"` - } `json:"search"` - } - vars := map[string]any{"x": "SEARCH", "q": query, "page": (page - 1) * 12} - if err := s.gql(context.Background(), searchQuery, vars, &result); err != nil { - return source.MangasPage{}, err - } - mangas := make([]source.SManga, len(result.Search.Rows)) - for i, m := range result.Search.Rows { - mangas[i] = source.SManga{ - URL: fmt.Sprintf("/manga/%s", m.Slug), - Title: m.Title, - ThumbnailURL: m.Image, - } - } - hasNext := (page * 12) < result.Search.Count - return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil +func (s *Source) GetSearchManga(page int, query string, _ []source.Filter) (source.MangasPage, error) { + return s.fetchList(context.Background(), page, "POPULAR", query) } func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { - slug := util.SlugFromURL(manga.URL) - const q = `query getManga($x: String) { manga(x: $x) { slug title image author description status genres } }` - var result struct{ Manga mangaDTO `json:"manga"` } - if err := s.gql(context.Background(), q, map[string]any{"x": slug}, &result); err != nil { + slug := strings.TrimPrefix(manga.URL, "/manga/") + query := fmt.Sprintf( + `{manga(x: %s, slug: %q) {title,slug,status,image,author,artist,genres,description,alternativeTitle}}`, + s.src(), slug, + ) + var result struct { + Manga struct { + Title string `json:"title"` + Status string `json:"status"` + Image string `json:"image"` + Author string `json:"author"` + Artist string `json:"artist"` + Genres string `json:"genres"` + Description string `json:"description"` + AlternativeTitle string `json:"alternativeTitle"` + } `json:"manga"` + } + if err := s.gql(context.Background(), query, &result); err != nil { return manga, err } m := result.Manga + desc := m.Description + if m.AlternativeTitle != "" { + if desc != "" { + desc += "\n\n" + } + desc += "Alternative Name: " + m.AlternativeTitle + } return source.SManga{ URL: manga.URL, Title: m.Title, Author: m.Author, - Description: m.Description, + Artist: m.Artist, + Description: desc, Genre: m.Genres, - Status: util.StatusFromString(m.Status), - ThumbnailURL: m.Image, + Status: mangahubStatus(m.Status), + ThumbnailURL: thumbCDNURL + "/" + m.Image, }, nil } +func mangahubStatus(s string) int { + switch s { + case "ongoing": + return source.StatusOngoing + case "completed": + return source.StatusCompleted + default: + return source.StatusUnknown + } +} + func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { - slug := util.SlugFromURL(manga.URL) - const q = `query getChapters($x: String) { manga(x: $x) { id chapters { id number title date } } }` + slug := strings.TrimPrefix(manga.URL, "/manga/") + query := fmt.Sprintf( + `{manga(x: %s, slug: %q) {slug,chapters{number,title,date}}}`, + s.src(), slug, + ) var result struct { Manga struct { - ID int `json:"id"` + Slug string `json:"slug"` Chapters []struct { - ID int `json:"id"` Number float32 `json:"number"` Title string `json:"title"` Date string `json:"date"` } `json:"chapters"` } `json:"manga"` } - if err := s.gql(context.Background(), q, map[string]any{"x": slug}, &result); err != nil { + if err := s.gql(context.Background(), query, &result); err != nil { return nil, err } chapters := make([]source.SChapter, len(result.Manga.Chapters)) for i, ch := range result.Manga.Chapters { - name := fmt.Sprintf("Chapter %.1f", ch.Number) - if ch.Title != "" { - name += " - " + ch.Title - } + numStr := formatChNum(ch.Number) chapters[i] = source.SChapter{ - URL: fmt.Sprintf("/manga/%s/%g", slug, ch.Number), - Name: name, - DateUpload: util.ParseAbsoluteDate(ch.Date, "2006-01-02T15:04:05.000Z"), + URL: fmt.Sprintf("/%s/chapter-%s", result.Manga.Slug, numStr), + Name: buildChapterName(ch.Title, numStr), + ChapterNumber: ch.Number, + DateUpload: util.ParseAbsoluteDate(ch.Date, "2006-01-02T15:04:05.000Z"), } } + // API returns ASC; 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 (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { - parts := strings.Split(strings.Trim(chapter.URL, "/"), "/") - if len(parts) < 3 { - return nil, fmt.Errorf("mangahub: invalid chapter URL") +func formatChNum(n float32) string { + if n == float32(int(n)) { + return fmt.Sprintf("%d", int(n)) } - slug := parts[1] - chNum := parts[2] - const q = `query getPages($x: String, $n: Float) { chapter(x: $x, n: $n) { pages } }` + return fmt.Sprintf("%g", n) +} + +func buildChapterName(title, number string) string { + title = strings.TrimSpace(title) + if strings.Contains(title, number) { + return title + } + if title != "" { + return "Chapter " + number + " - " + title + } + return "Chapter " + number +} + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + // URL format: /{slug}/chapter-{number} + trimmed := strings.TrimPrefix(chapter.URL, "/") + parts := strings.SplitN(trimmed, "/", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("mangahub: invalid chapter URL: %s", chapter.URL) + } + slug := parts[0] + numStr := strings.TrimPrefix(parts[1], "chapter-") + var num float64 + fmt.Sscanf(numStr, "%f", &num) + + query := fmt.Sprintf(`{chapter(x: %s, slug: %q, number: %g) {pages}}`, s.src(), slug, num) var result struct { Chapter struct { Pages string `json:"pages"` } `json:"chapter"` } - var num float64 - fmt.Sscanf(chNum, "%f", &num) - if err := s.gql(context.Background(), q, map[string]any{"x": slug, "n": num}, &result); err != nil { + if err := s.gql(context.Background(), query, &result); err != nil { return nil, err } - var images []string - if err := json.Unmarshal([]byte(result.Chapter.Pages), &images); err != nil { - return nil, err + + // pages is a JSON string: {"p": "base/path/", "i": ["img1.jpg", ...]} + var pageData struct { + P string `json:"p"` + I []string `json:"i"` } - pages := make([]source.Page, len(images)) - for i, img := range images { - pages[i] = source.Page{Index: i, ImageURL: img} + if err := json.Unmarshal([]byte(result.Chapter.Pages), &pageData); err != nil { + return nil, fmt.Errorf("mangahub: parsing pages: %w", err) + } + pages := make([]source.Page, len(pageData.I)) + for i, img := range pageData.I { + pages[i] = source.Page{Index: i, ImageURL: baseCDNURL + "/" + pageData.P + img} } return pages, nil } diff --git a/sources/en/mangafoxfun/mangafoxfun.go b/sources/en/mangafoxfun/mangafoxfun.go index cc23568..aa887a7 100644 --- a/sources/en/mangafoxfun/mangafoxfun.go +++ b/sources/en/mangafoxfun/mangafoxfun.go @@ -7,9 +7,10 @@ import ( func New() *base.Source { return base.New(base.Config{ - Name: "MangaFox.fun", - BaseURL: "https://mangafox.fun", - Lang: "en", + Name: "MangaFox.fun", + BaseURL: "https://mangafox.fun", + Lang: "en", + MangaSource: "mf01", }) } diff --git a/sources/en/mangahereonl/mangahereonl.go b/sources/en/mangahereonl/mangahereonl.go index 3ffcd0d..31a4ecb 100644 --- a/sources/en/mangahereonl/mangahereonl.go +++ b/sources/en/mangahereonl/mangahereonl.go @@ -7,9 +7,10 @@ import ( func New() *base.Source { return base.New(base.Config{ - Name: "MangaHere.onl", - BaseURL: "https://mangahere.onl", - Lang: "en", + Name: "MangaHere.onl", + BaseURL: "https://mangahere.onl", + Lang: "en", + MangaSource: "mh01", }) } diff --git a/sources/en/mangahubio/mangahubio.go b/sources/en/mangahubio/mangahubio.go index a67430d..265be3e 100644 --- a/sources/en/mangahubio/mangahubio.go +++ b/sources/en/mangahubio/mangahubio.go @@ -7,9 +7,10 @@ import ( func New() *base.Source { return base.New(base.Config{ - Name: "MangaHub", - BaseURL: "https://mangahub.io", - Lang: "en", + Name: "MangaHub", + BaseURL: "https://mangahub.io", + Lang: "en", + MangaSource: "m01", }) } diff --git a/sources/en/mangakakalotfun/mangakakalotfun.go b/sources/en/mangakakalotfun/mangakakalotfun.go index 3bce25e..edbbcf2 100644 --- a/sources/en/mangakakalotfun/mangakakalotfun.go +++ b/sources/en/mangakakalotfun/mangakakalotfun.go @@ -7,9 +7,10 @@ import ( func New() *base.Source { return base.New(base.Config{ - Name: "Mangakakalot.fun", - BaseURL: "https://mangakakalot.fun", - Lang: "en", + Name: "Mangakakalot.fun", + BaseURL: "https://mangakakalot.fun", + Lang: "en", + MangaSource: "mn01", }) } diff --git a/sources/en/manganel/manganel.go b/sources/en/manganel/manganel.go index df7257e..897b0e8 100644 --- a/sources/en/manganel/manganel.go +++ b/sources/en/manganel/manganel.go @@ -7,9 +7,10 @@ import ( func New() *base.Source { return base.New(base.Config{ - Name: "MangaNel", - BaseURL: "https://manganel.me", - Lang: "en", + Name: "MangaNel", + BaseURL: "https://manganel.me", + Lang: "en", + MangaSource: "mn05", }) } diff --git a/sources/en/mangaonlinefun/mangaonlinefun.go b/sources/en/mangaonlinefun/mangaonlinefun.go index 9f6217f..bd22306 100644 --- a/sources/en/mangaonlinefun/mangaonlinefun.go +++ b/sources/en/mangaonlinefun/mangaonlinefun.go @@ -7,9 +7,10 @@ import ( func New() *base.Source { return base.New(base.Config{ - Name: "MangaOnline.fun", - BaseURL: "https://mangaonline.fun", - Lang: "en", + Name: "MangaOnline.fun", + BaseURL: "https://mangaonline.fun", + Lang: "en", + MangaSource: "m02", }) } diff --git a/sources/en/mangapandaonl/mangapandaonl.go b/sources/en/mangapandaonl/mangapandaonl.go index 05281cf..23cd2f7 100644 --- a/sources/en/mangapandaonl/mangapandaonl.go +++ b/sources/en/mangapandaonl/mangapandaonl.go @@ -7,9 +7,10 @@ import ( func New() *base.Source { return base.New(base.Config{ - Name: "MangaPanda.onl", - BaseURL: "https://mangapanda.onl", - Lang: "en", + Name: "MangaPanda.onl", + BaseURL: "https://mangapanda.onl", + Lang: "en", + MangaSource: "mr02", }) } diff --git a/sources/en/mangareadersite/mangareadersite.go b/sources/en/mangareadersite/mangareadersite.go index c748c3f..328c978 100644 --- a/sources/en/mangareadersite/mangareadersite.go +++ b/sources/en/mangareadersite/mangareadersite.go @@ -7,9 +7,10 @@ import ( func New() *base.Source { return base.New(base.Config{ - Name: "MangaReader.site", - BaseURL: "https://mangareader.site", - Lang: "en", + Name: "MangaReader.site", + BaseURL: "https://mangareader.site", + Lang: "en", + MangaSource: "mr01", }) } diff --git a/sources/en/mangatoday/mangatoday.go b/sources/en/mangatoday/mangatoday.go index 9a190bc..929630e 100644 --- a/sources/en/mangatoday/mangatoday.go +++ b/sources/en/mangatoday/mangatoday.go @@ -7,9 +7,10 @@ import ( func New() *base.Source { return base.New(base.Config{ - Name: "MangaToday", - BaseURL: "https://mangatoday.fun", - Lang: "en", + Name: "MangaToday", + BaseURL: "https://mangatoday.fun", + Lang: "en", + MangaSource: "m03", }) } diff --git a/sources/en/onemangaco/onemangaco.go b/sources/en/onemangaco/onemangaco.go index eaf1486..d19433d 100644 --- a/sources/en/onemangaco/onemangaco.go +++ b/sources/en/onemangaco/onemangaco.go @@ -7,9 +7,10 @@ import ( func New() *base.Source { return base.New(base.Config{ - Name: "1Manga.co", - BaseURL: "https://1manga.co", - Lang: "en", + Name: "1Manga.co", + BaseURL: "https://1manga.co", + Lang: "en", + MangaSource: "mn03", }) } diff --git a/sources/en/onemangainfo/onemangainfo.go b/sources/en/onemangainfo/onemangainfo.go index 9d5df81..09c7c03 100644 --- a/sources/en/onemangainfo/onemangainfo.go +++ b/sources/en/onemangainfo/onemangainfo.go @@ -7,9 +7,10 @@ import ( func New() *base.Source { return base.New(base.Config{ - Name: "OneManga.info", - BaseURL: "https://onemanga.info", - Lang: "en", + Name: "OneManga.info", + BaseURL: "https://onemanga.info", + Lang: "en", + MangaSource: "mh01", }) }