9a42dd2ab1
- Remove global ProxyEnabled() logic from httpclient - Each source now explicitly chooses client at import time: - flare client: for JS-rendering/cloudflare sources - normal httpclient: for REST API sources - Updated 29 base sources based on Kotlin reference (network.cloudflareClient)
207 lines
5.5 KiB
Go
Executable File
207 lines
5.5 KiB
Go
Executable File
// 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/flare"
|
|
"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 *flare.Client
|
|
id int64
|
|
}
|
|
|
|
func New(cfg Config) *Source {
|
|
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) 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 }
|