f0658472f3
Ports all remaining ⚠️ annotated bases from the phase3 checklist:
zmanga, mangareader, liliana, lectormoe, iken, pizzareader,
mangotheme (AES/CBC decrypt), libgroup (bearer token auth),
scanreader (WP AJAX chapters), mmlook (CF + packed JS pages).
Updates docs/phase3-bases.md to mark all 10 as [x].
226 lines
6.2 KiB
Go
226 lines
6.2 KiB
Go
// Package pizzareader implements the PizzaReader manga base.
|
|
// JSON REST API: GET {api}/comics for popular; GET {api}{chapter.url} for pages.
|
|
package pizzareader
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
|
|
"goyomi/internal/httpclient"
|
|
"goyomi/internal/source"
|
|
"goyomi/sources/base/util"
|
|
)
|
|
|
|
type Config struct {
|
|
Name string
|
|
BaseURL string
|
|
Lang string
|
|
APIPath string // defaults to "/api"
|
|
}
|
|
|
|
type Source struct {
|
|
cfg Config
|
|
client *httpclient.Client
|
|
id int64
|
|
}
|
|
|
|
func New(cfg Config) *Source {
|
|
if cfg.APIPath == "" {
|
|
cfg.APIPath = "/api"
|
|
}
|
|
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 }
|
|
|
|
func (s *Source) apiBase() string {
|
|
return strings.TrimRight(s.cfg.BaseURL, "/") + s.cfg.APIPath
|
|
}
|
|
|
|
func (s *Source) getJSON(ctx context.Context, rawURL string, out any) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("Referer", s.cfg.BaseURL+"/")
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("pizzareader: HTTP %d", resp.StatusCode)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return json.Unmarshal(body, out)
|
|
}
|
|
|
|
type pizzaComicDTO struct {
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Author string `json:"author"`
|
|
Artist string `json:"artist"`
|
|
Description string `json:"description"`
|
|
Thumbnail string `json:"thumbnail"`
|
|
Status string `json:"status"`
|
|
Genres []struct{ Name string `json:"name"` } `json:"genres"`
|
|
Chapters []pizzaChapterDTO `json:"chapters"`
|
|
LastChapter *pizzaChapterDTO `json:"last_chapter"`
|
|
}
|
|
|
|
type pizzaChapterDTO struct {
|
|
Slug string `json:"slug"`
|
|
Chapter *int `json:"chapter"`
|
|
Subchapter *int `json:"subchapter"`
|
|
Title string `json:"title"`
|
|
PublishedOn string `json:"published_on"`
|
|
ComicSlug string `json:"comic_slug"`
|
|
}
|
|
|
|
type pizzaResultDTO struct {
|
|
Comic *pizzaComicDTO `json:"comic"`
|
|
Comics []pizzaComicDTO `json:"comics"`
|
|
}
|
|
|
|
type pizzaReaderDTO struct {
|
|
Chapter *struct {
|
|
Pages []string `json:"pages"`
|
|
} `json:"chapter"`
|
|
}
|
|
|
|
func (s *Source) toSManga(c pizzaComicDTO) source.SManga {
|
|
genres := make([]string, len(c.Genres))
|
|
for i, g := range c.Genres {
|
|
genres[i] = g.Name
|
|
}
|
|
return source.SManga{
|
|
URL: "/comics/" + c.Slug,
|
|
Title: c.Title,
|
|
Author: c.Author,
|
|
Artist: c.Artist,
|
|
Description: c.Description,
|
|
Genre: strings.Join(genres, ", "),
|
|
Status: util.StatusFromString(c.Status),
|
|
ThumbnailURL: c.Thumbnail,
|
|
}
|
|
}
|
|
|
|
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
|
|
var result pizzaResultDTO
|
|
if err := s.getJSON(context.Background(), s.apiBase()+"/comics", &result); err != nil {
|
|
return source.MangasPage{}, err
|
|
}
|
|
// sort by last_chapter published_on descending, take all
|
|
comics := result.Comics
|
|
sort.Slice(comics, func(i, j int) bool {
|
|
di := ""
|
|
dj := ""
|
|
if comics[i].LastChapter != nil {
|
|
di = comics[i].LastChapter.PublishedOn
|
|
}
|
|
if comics[j].LastChapter != nil {
|
|
dj = comics[j].LastChapter.PublishedOn
|
|
}
|
|
return di > dj
|
|
})
|
|
mangas := make([]source.SManga, len(comics))
|
|
for i, c := range comics {
|
|
mangas[i] = s.toSManga(c)
|
|
}
|
|
return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil
|
|
}
|
|
|
|
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
|
|
return s.GetPopularManga(page)
|
|
}
|
|
|
|
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
|
|
var result pizzaResultDTO
|
|
u := fmt.Sprintf("%s/search/%s", s.apiBase(), query)
|
|
if err := s.getJSON(context.Background(), u, &result); err != nil {
|
|
return source.MangasPage{}, err
|
|
}
|
|
mangas := make([]source.SManga, len(result.Comics))
|
|
for i, c := range result.Comics {
|
|
mangas[i] = s.toSManga(c)
|
|
}
|
|
return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil
|
|
}
|
|
|
|
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
|
|
var result pizzaResultDTO
|
|
if err := s.getJSON(context.Background(), s.apiBase()+manga.URL, &result); err != nil {
|
|
return manga, err
|
|
}
|
|
if result.Comic == nil {
|
|
return manga, fmt.Errorf("pizzareader: no comic in response")
|
|
}
|
|
return s.toSManga(*result.Comic), nil
|
|
}
|
|
|
|
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
|
|
var result pizzaResultDTO
|
|
if err := s.getJSON(context.Background(), s.apiBase()+manga.URL, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
if result.Comic == nil {
|
|
return nil, fmt.Errorf("pizzareader: no comic in response")
|
|
}
|
|
chapters := make([]source.SChapter, len(result.Comic.Chapters))
|
|
for i, ch := range result.Comic.Chapters {
|
|
num := -1
|
|
if ch.Chapter != nil {
|
|
num = *ch.Chapter
|
|
}
|
|
sub := 0
|
|
if ch.Subchapter != nil {
|
|
sub = *ch.Subchapter
|
|
}
|
|
name := fmt.Sprintf("Chapter %d", num)
|
|
if sub > 0 {
|
|
name += fmt.Sprintf(".%d", sub)
|
|
}
|
|
if ch.Title != "" {
|
|
name += " - " + ch.Title
|
|
}
|
|
comicSlug := ch.ComicSlug
|
|
if comicSlug == "" {
|
|
comicSlug = util.SlugFromURL(manga.URL)
|
|
}
|
|
chapters[i] = source.SChapter{
|
|
URL: fmt.Sprintf("/comics/%s/%s", comicSlug, ch.Slug),
|
|
Name: name,
|
|
DateUpload: util.ParseAbsoluteDate(ch.PublishedOn, "2006-01-02T15:04:05.999999"),
|
|
}
|
|
}
|
|
return chapters, nil
|
|
}
|
|
|
|
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
|
|
var result pizzaReaderDTO
|
|
if err := s.getJSON(context.Background(), s.apiBase()+chapter.URL, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
if result.Chapter == nil {
|
|
return nil, fmt.Errorf("pizzareader: no chapter in response")
|
|
}
|
|
pages := make([]source.Page, len(result.Chapter.Pages))
|
|
for i, p := range result.Chapter.Pages {
|
|
pages[i] = source.Page{Index: i, ImageURL: p}
|
|
}
|
|
return pages, nil
|
|
}
|
|
|
|
func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil }
|
|
func (s *Source) GetFilterList() []source.Filter { return nil }
|