ca609ccae7
Ports bases from previous session: util (shared helpers), bakkin, fmreader, foolslide, gigaviewer, gmanga, grouple, guya, heancms, hentaihand, kemono, madara, madtheme, mangadventure, mangahub, mangathemesia, mangaworld, mmrcms, senkuro, wpcomics.
244 lines
7.2 KiB
Go
244 lines
7.2 KiB
Go
// Package mangahub implements the MangaHub GraphQL base.
|
|
// Cookie acquisition + GraphQL POST to {api}/graphql.
|
|
package mangahub
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"goyomi/internal/httpclient"
|
|
"goyomi/internal/source"
|
|
"goyomi/sources/base/util"
|
|
)
|
|
|
|
type Config struct {
|
|
Name string
|
|
BaseURL string
|
|
APIURL string
|
|
Lang string
|
|
}
|
|
|
|
type Source struct {
|
|
cfg Config
|
|
client *httpclient.Client
|
|
id int64
|
|
}
|
|
|
|
func New(cfg Config) *Source {
|
|
if cfg.APIURL == "" {
|
|
cfg.APIURL = "https://api.mghubcdn.com"
|
|
}
|
|
c := httpclient.NewClient(httpclient.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) SupportsLatest() bool { return true }
|
|
|
|
type gqlRequest struct {
|
|
Query string `json:"query"`
|
|
Variables map[string]any `json:"variables"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
func (s *Source) gql(ctx context.Context, query string, vars map[string]any, out any) error {
|
|
body, _ := json.Marshal(gqlRequest{Query: query, Variables: vars})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.APIURL+"/graphql", bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("x-mhub-access", "auto")
|
|
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)
|
|
}
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var wrapper struct {
|
|
Data json.RawMessage `json:"data"`
|
|
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)
|
|
}
|
|
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
|
|
}
|
|
}`
|
|
|
|
func (s *Source) fetchMangaList(ctx context.Context, page int, x string) (source.MangasPage, error) {
|
|
var result struct {
|
|
Search struct {
|
|
Rows []mangaDTO `json:"rows"`
|
|
Count int `json:"count"`
|
|
} `json:"search"`
|
|
}
|
|
vars := map[string]any{"x": x, "genre": "all", "page": (page - 1) * 12}
|
|
if err := s.gql(ctx, searchMangaQuery, 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) GetPopularManga(page int) (source.MangasPage, error) {
|
|
return s.fetchMangaList(context.Background(), page, "POPULAR")
|
|
}
|
|
|
|
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
|
|
return s.fetchMangaList(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) 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 {
|
|
return manga, err
|
|
}
|
|
m := result.Manga
|
|
return source.SManga{
|
|
URL: manga.URL,
|
|
Title: m.Title,
|
|
Author: m.Author,
|
|
Description: m.Description,
|
|
Genre: m.Genres,
|
|
Status: util.StatusFromString(m.Status),
|
|
ThumbnailURL: m.Image,
|
|
}, nil
|
|
}
|
|
|
|
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 } } }`
|
|
var result struct {
|
|
Manga struct {
|
|
ID int `json:"id"`
|
|
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 {
|
|
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
|
|
}
|
|
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"),
|
|
}
|
|
}
|
|
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")
|
|
}
|
|
slug := parts[1]
|
|
chNum := parts[2]
|
|
const q = `query getPages($x: String, $n: Float) { chapter(x: $x, n: $n) { pages } }`
|
|
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 {
|
|
return nil, err
|
|
}
|
|
var images []string
|
|
if err := json.Unmarshal([]byte(result.Chapter.Pages), &images); err != nil {
|
|
return nil, err
|
|
}
|
|
pages := make([]source.Page, len(images))
|
|
for i, img := range images {
|
|
pages[i] = source.Page{Index: i, ImageURL: img}
|
|
}
|
|
return pages, nil
|
|
}
|
|
|
|
func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil }
|
|
func (s *Source) GetFilterList() []source.Filter { return nil }
|