Files
goyomi/sources/base/kemono/kemono.go
T
achmad 9a42dd2ab1 refactor: use per-source HTTP client instead of global proxy
- 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)
2026-05-13 09:01:51 +07:00

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 }