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.
205 lines
6.2 KiB
Go
205 lines
6.2 KiB
Go
// Package senkuro implements the Senkuro GraphQL base.
|
|
package senkuro
|
|
|
|
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 = strings.TrimRight(cfg.BaseURL, "/") + "/api/graphql"
|
|
}
|
|
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,omitempty"`
|
|
}
|
|
|
|
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, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
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("senkuro: %s", wrapper.Errors[0].Message)
|
|
}
|
|
return json.Unmarshal(wrapper.Data, out)
|
|
}
|
|
|
|
type comicDTO struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Title string `json:"localizedTitle"`
|
|
Cover string `json:"cover"`
|
|
Description string `json:"description"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
const listQuery = `query($page: Int, $perPage: Int, $sort: ComicSort, $q: String) {
|
|
comics(page: $page, perPage: $perPage, sort: $sort, search: $q) {
|
|
data { id slug localizedTitle cover }
|
|
hasNextPage
|
|
}
|
|
}`
|
|
|
|
func (s *Source) fetchList(ctx context.Context, page int, sort, q string) (source.MangasPage, error) {
|
|
vars := map[string]any{"page": page, "perPage": 20, "sort": sort}
|
|
if q != "" {
|
|
vars["q"] = q
|
|
}
|
|
var result struct {
|
|
Comics struct {
|
|
Data []comicDTO `json:"data"`
|
|
HasNextPage bool `json:"hasNextPage"`
|
|
} `json:"comics"`
|
|
}
|
|
if err := s.gql(ctx, listQuery, vars, &result); err != nil {
|
|
return source.MangasPage{}, err
|
|
}
|
|
mangas := make([]source.SManga, len(result.Comics.Data))
|
|
for i, c := range result.Comics.Data {
|
|
mangas[i] = source.SManga{
|
|
URL: fmt.Sprintf("/comics/%s", c.Slug),
|
|
Title: c.Title,
|
|
ThumbnailURL: c.Cover,
|
|
}
|
|
}
|
|
return source.MangasPage{Mangas: mangas, HasNextPage: result.Comics.HasNextPage}, nil
|
|
}
|
|
|
|
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
|
|
return s.fetchList(context.Background(), page, "POPULARITY", "")
|
|
}
|
|
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
|
|
return s.fetchList(context.Background(), page, "LATEST_UPDATE", "")
|
|
}
|
|
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
|
|
return s.fetchList(context.Background(), page, "RELEVANCE", query)
|
|
}
|
|
|
|
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
|
|
slug := util.SlugFromURL(manga.URL)
|
|
const q = `query($slug: String!) { comic(slug: $slug) { id slug localizedTitle cover description status } }`
|
|
var result struct{ Comic comicDTO `json:"comic"` }
|
|
if err := s.gql(context.Background(), q, map[string]any{"slug": slug}, &result); err != nil {
|
|
return manga, err
|
|
}
|
|
c := result.Comic
|
|
return source.SManga{
|
|
URL: manga.URL,
|
|
Title: c.Title,
|
|
Description: c.Description,
|
|
Status: util.StatusFromString(c.Status),
|
|
ThumbnailURL: c.Cover,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
|
|
slug := util.SlugFromURL(manga.URL)
|
|
const q = `query($slug: String!) { comic(slug: $slug) { chapters { id slug number title createdAt } } }`
|
|
var result struct {
|
|
Comic struct {
|
|
Chapters []struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Number float32 `json:"number"`
|
|
Title string `json:"title"`
|
|
CreatedAt string `json:"createdAt"`
|
|
} `json:"chapters"`
|
|
} `json:"comic"`
|
|
}
|
|
if err := s.gql(context.Background(), q, map[string]any{"slug": slug}, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
chapters := make([]source.SChapter, len(result.Comic.Chapters))
|
|
for i, ch := range result.Comic.Chapters {
|
|
name := fmt.Sprintf("Chapter %.1f", ch.Number)
|
|
if ch.Title != "" {
|
|
name += " - " + ch.Title
|
|
}
|
|
chapters[i] = source.SChapter{
|
|
URL: fmt.Sprintf("/comics/%s/%s", slug, ch.Slug),
|
|
Name: name,
|
|
DateUpload: util.ParseAbsoluteDate(ch.CreatedAt, "2006-01-02T15:04:05Z"),
|
|
}
|
|
}
|
|
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("senkuro: invalid chapter URL")
|
|
}
|
|
comicSlug := parts[1]
|
|
chSlug := parts[2]
|
|
const q = `query($comicSlug: String!, $chapterSlug: String!) {
|
|
chapter(comicSlug: $comicSlug, slug: $chapterSlug) { pages { index image } }
|
|
}`
|
|
var result struct {
|
|
Chapter struct {
|
|
Pages []struct {
|
|
Index int `json:"index"`
|
|
Image string `json:"image"`
|
|
} `json:"pages"`
|
|
} `json:"chapter"`
|
|
}
|
|
if err := s.gql(context.Background(), q, map[string]any{"comicSlug": comicSlug, "chapterSlug": chSlug}, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
pages := make([]source.Page, len(result.Chapter.Pages))
|
|
for i, p := range result.Chapter.Pages {
|
|
pages[i] = source.Page{Index: p.Index, ImageURL: p.Image}
|
|
}
|
|
return pages, nil
|
|
}
|
|
|
|
func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil }
|
|
func (s *Source) GetFilterList() []source.Filter { return nil }
|