Files
goyomi/sources/base/spicytheme/spicytheme.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

402 lines
9.4 KiB
Go
Executable File

package spicytheme
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"goyomi/internal/httpclient/flare"
"goyomi/internal/source"
)
type Config struct {
Name string
BaseURL string
APIBaseURL string
Lang string
}
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 }
type MangaDTO struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Image string `json:"image"`
Description string `json:"description"`
Status string `json:"status"`
Genres []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"genders"`
}
type FilterResponseDTO struct {
Data []MangaDTO `json:"data"`
Pagination struct {
CurrentPage int `json:"currentPage"`
LastPage int `json:"lastPage"`
Total int `json:"total"`
} `json:"pagination"`
}
type SeriesResponseDTO struct {
Series struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Image string `json:"image"`
Description string `json:"description"`
Status string `json:"status"`
Origin string `json:"origin"`
Genres []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"genders"`
Chapters []ChapterDTO `json:"chapters"`
} `json:"series"`
}
type ChapterDTO struct {
ID string `json:"id"`
Number string `json:"number"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
}
type PagesResponseDTO struct {
Pages struct {
RawImages json.RawMessage `json:"rawImages"`
} `json:"pages"`
}
func (s *Source) filterURL(page int, orderBy string) string {
u, _ := url.Parse(s.cfg.APIBaseURL + "/filtrar")
q := u.Query()
q.Set("page", fmt.Sprintf("%d", page))
q.Set("limit", "12")
q.Set("orderBy", orderBy)
q.Set("sort", "desc")
q.Set("gendersId", "")
q.Set("origin", "")
q.Set("state", "")
q.Set("loading", "true")
u.RawQuery = q.Encode()
return u.String()
}
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
return s.fetchList(page, "id_popular")
}
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
return s.fetchList(page, "id_latest")
}
func (s *Source) fetchList(page int, orderBy string) (source.MangasPage, error) {
url := s.filterURL(page, orderBy)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
return source.MangasPage{}, err
}
s.setHeaders(req)
resp, err := s.client.Do(req)
if err != nil {
return source.MangasPage{}, err
}
defer resp.Body.Close()
var result FilterResponseDTO
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return source.MangasPage{}, err
}
mangas := make([]source.SManga, len(result.Data))
for i, d := range result.Data {
mangas[i] = s.mangaFromDTO(d)
}
hasNext := result.Pagination.CurrentPage < result.Pagination.LastPage
return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil
}
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
if query != "" {
if len(query) < 2 {
return source.MangasPage{}, fmt.Errorf("escribe al menos 2 caracteres para buscar")
}
u, _ := url.Parse(s.cfg.APIBaseURL + "/home/buscar")
q := u.Query()
q.Set("query", query)
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u.String(), nil)
if err != nil {
return source.MangasPage{}, err
}
s.setHeaders(req)
resp, err := s.client.Do(req)
if err != nil {
return source.MangasPage{}, err
}
defer resp.Body.Close()
var result []MangaDTO
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return source.MangasPage{}, err
}
mangas := make([]source.SManga, len(result))
for i, d := range result {
mangas[i] = s.mangaFromDTO(d)
}
return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil
}
orderBy := "id_latest"
for _, f := range filters {
if sel, ok := f.(*source.SelectFilter); ok && len(sel.Values) > sel.Selected {
orderBy = sel.Values[sel.Selected]
}
}
return s.fetchList(page, orderBy)
}
func (s *Source) mangaFromDTO(d MangaDTO) source.SManga {
genres := make([]string, len(d.Genres))
for i, g := range d.Genres {
genres[i] = g.Name
}
status := 0
switch d.Status {
case "ongoing":
status = 1
case "completed":
status = 2
case "hiatus":
status = 5
case "cancelled":
status = 6
}
return source.SManga{
URL: d.Slug,
Title: d.Name,
ThumbnailURL: d.Image,
Description: d.Description,
Genre: joinStrings(genres, ", "),
Status: status,
}
}
func joinStrings(ss []string, sep string) string {
if len(ss) == 0 {
return ""
}
result := ss[0]
for i := 1; i < len(ss); i++ {
result += sep + ss[i]
}
return result
}
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
url := s.cfg.APIBaseURL + "/serie/" + manga.URL
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
return manga, err
}
s.setHeaders(req)
resp, err := s.client.Do(req)
if err != nil {
return manga, err
}
defer resp.Body.Close()
var result SeriesResponseDTO
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return manga, err
}
m := s.mangaFromDTO(MangaDTO{
ID: result.Series.ID,
Slug: result.Series.Slug,
Name: result.Series.Name,
Image: result.Series.Image,
Description: result.Series.Description,
Status: result.Series.Status,
Genres: result.Series.Genres,
})
m.Initialized = true
m.URL = manga.URL
return m, nil
}
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
url := s.cfg.APIBaseURL + "/serie/" + manga.URL
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
return nil, err
}
s.setHeaders(req)
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result SeriesResponseDTO
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
chapters := make([]source.SChapter, len(result.Series.Chapters))
dateLayout := "2006-01-02T15:04:05.000Z"
for i, ch := range result.Series.Chapters {
dateUpload := int64(0)
if t, err := time.Parse(dateLayout, ch.CreatedAt); err == nil {
dateUpload = t.UnixMilli()
}
chapters[i] = source.SChapter{
URL: result.Series.Slug + "/" + ch.Number,
Name: "Chapter " + ch.Number + " - " + ch.Name,
DateUpload: dateUpload,
}
}
return chapters, nil
}
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
url := s.cfg.APIBaseURL + "/serie/" + chapter.URL + "/"
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
return nil, err
}
s.setHeaders(req)
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result PagesResponseDTO
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
var images []string
if err := json.Unmarshal(result.Pages.RawImages, &images); err != nil {
return nil, err
}
pages := make([]source.Page, len(images))
for i, img := range images {
pages[i] = source.Page{Index: i, ImageURL: img}
}
return pages, nil
}
func (s *Source) GetImageURL(page source.Page) (string, error) {
return page.ImageURL, nil
}
func (s *Source) GetFilterList() []source.Filter {
filters := make([]source.Filter, 0)
filters = append(filters, &source.TextFilter{FilterName: "Los filtros no se aplican a la búsqueda por texto"})
filters = append(filters, &source.SelectFilter{
FilterName: "Ordenar por",
Values: []string{"id_latest", "id_popular", "views", "release"},
Selected: 0,
})
filters = append(filters, &source.SelectFilter{
FilterName: "Origen",
Values: []string{"", "jp", "kr", "cn", "other"},
Selected: 0,
})
filters = append(filters, &source.SelectFilter{
FilterName: "Género",
Values: []string{"", "1", "2", "3", "4", "5", "6", "7", "8"},
Selected: 0,
})
filters = append(filters, &source.SelectFilter{
FilterName: "Estado",
Values: []string{"", "ongoing", "completed", "hiatus", "cancelled"},
Selected: 0,
})
return filters
}
func (s *Source) setHeaders(req *http.Request) {
req.Header.Set("Referer", s.cfg.BaseURL+"/")
req.Header.Set("Origin", s.cfg.BaseURL)
req.Header.Set("Accept", "application/json")
}
func (s *Source) fetchJSON(url string, out any) error {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
return err
}
s.setHeaders(req)
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d for %s", resp.StatusCode, url)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
return json.Unmarshal(body, out)
}
var _ source.CatalogueSource = (*Source)(nil)