phase3: implement first 20 base sources + shared util
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.
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
// Package kemono implements the Kemono Party base.
|
||||
// GET {base}/api/v1/creators → creator list
|
||||
// GET {base}/api/v1/{service}/{creator}/posts?o={offset} → paginated posts
|
||||
package kemono
|
||||
|
||||
import (
|
||||
"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
|
||||
Lang string
|
||||
}
|
||||
|
||||
type creatorDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Service string `json:"service"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
type postDTO struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
User string `json:"user"`
|
||||
Service string `json:"service"`
|
||||
Added string `json:"added"`
|
||||
Attachments []struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
} `json:"attachments"`
|
||||
File struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
} `json:"file"`
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
cfg Config
|
||||
client *httpclient.Client
|
||||
id int64
|
||||
}
|
||||
|
||||
func New(cfg Config) *Source {
|
||||
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) getJSON(ctx context.Context, rawURL string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("kemono: HTTP %d for %s", resp.StatusCode, rawURL)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(body, out)
|
||||
}
|
||||
|
||||
func (s *Source) base() string { return strings.TrimRight(s.cfg.BaseURL, "/") }
|
||||
|
||||
func (s *Source) creatorToSManga(c creatorDTO) source.SManga {
|
||||
icon := c.Icon
|
||||
if icon != "" && !strings.HasPrefix(icon, "http") {
|
||||
icon = s.base() + "/data" + icon
|
||||
}
|
||||
return source.SManga{
|
||||
URL: fmt.Sprintf("/%s/user/%s", c.Service, c.ID),
|
||||
Title: c.Name,
|
||||
ThumbnailURL: icon,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
|
||||
var creators []creatorDTO
|
||||
if err := s.getJSON(context.Background(), s.base()+"/api/v1/creators", &creators); err != nil {
|
||||
return source.MangasPage{}, err
|
||||
}
|
||||
// page 1 returns all; no actual pagination
|
||||
mangas := make([]source.SManga, 0, len(creators))
|
||||
for _, c := range creators {
|
||||
mangas = append(mangas, s.creatorToSManga(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) {
|
||||
mp, err := s.GetPopularManga(1)
|
||||
if err != nil {
|
||||
return source.MangasPage{}, err
|
||||
}
|
||||
// Client-side filter
|
||||
q := strings.ToLower(query)
|
||||
var matched []source.SManga
|
||||
for _, m := range mp.Mangas {
|
||||
if strings.Contains(strings.ToLower(m.Title), q) {
|
||||
matched = append(matched, m)
|
||||
}
|
||||
}
|
||||
return source.MangasPage{Mangas: matched, HasNextPage: false}, nil
|
||||
}
|
||||
|
||||
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
|
||||
return manga, nil
|
||||
}
|
||||
|
||||
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
|
||||
// URL: /{service}/user/{id}
|
||||
parts := strings.Split(strings.Trim(manga.URL, "/"), "/")
|
||||
if len(parts) < 3 {
|
||||
return nil, fmt.Errorf("kemono: invalid manga URL %s", manga.URL)
|
||||
}
|
||||
service := parts[0]
|
||||
creatorID := parts[2]
|
||||
|
||||
var all []source.SChapter
|
||||
offset := 0
|
||||
const limit = 50
|
||||
for {
|
||||
apiURL := fmt.Sprintf("%s/api/v1/%s/user/%s/posts?o=%d", s.base(), service, creatorID, offset)
|
||||
var posts []postDTO
|
||||
if err := s.getJSON(context.Background(), apiURL, &posts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range posts {
|
||||
all = append(all, source.SChapter{
|
||||
URL: fmt.Sprintf("/%s/user/%s/post/%s", service, creatorID, p.ID),
|
||||
Name: p.Title,
|
||||
DateUpload: util.ParseAbsoluteDate(p.Added, "2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
if len(posts) < limit {
|
||||
break
|
||||
}
|
||||
offset += limit
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
|
||||
// URL: /{service}/user/{creatorID}/post/{postID}
|
||||
parts := strings.Split(strings.Trim(chapter.URL, "/"), "/")
|
||||
if len(parts) < 5 {
|
||||
return nil, fmt.Errorf("kemono: invalid chapter URL %s", chapter.URL)
|
||||
}
|
||||
service := parts[0]
|
||||
creatorID := parts[2]
|
||||
postID := parts[4]
|
||||
|
||||
apiURL := fmt.Sprintf("%s/api/v1/%s/user/%s/post/%s", s.base(), service, creatorID, postID)
|
||||
var post postDTO
|
||||
if err := s.getJSON(context.Background(), apiURL, &post); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pages []source.Page
|
||||
idx := 0
|
||||
if post.File.Path != "" {
|
||||
imgURL := post.File.Path
|
||||
if !strings.HasPrefix(imgURL, "http") {
|
||||
imgURL = s.base() + "/data" + imgURL
|
||||
}
|
||||
pages = append(pages, source.Page{Index: idx, ImageURL: imgURL})
|
||||
idx++
|
||||
}
|
||||
for _, att := range post.Attachments {
|
||||
imgURL := att.Path
|
||||
if !strings.HasPrefix(imgURL, "http") {
|
||||
imgURL = s.base() + "/data" + imgURL
|
||||
}
|
||||
pages = append(pages, source.Page{Index: idx, ImageURL: imgURL})
|
||||
idx++
|
||||
}
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil }
|
||||
func (s *Source) GetFilterList() []source.Filter { return nil }
|
||||
Reference in New Issue
Block a user