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

340 lines
8.1 KiB
Go
Executable File

package stalkercms
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"goyomi/internal/httpclient/flare"
"goyomi/internal/source"
)
type Config struct {
Name string
BaseURL string
Lang string
PopularMangaPath string
LatestUpdatesLoadMorePath string
}
type Source struct {
cfg Config
client *flare.Client
id int64
}
func New(cfg Config) *Source {
if cfg.PopularMangaPath == "" {
cfg.PopularMangaPath = "/manga/todos/"
}
if cfg.Lang == "" {
cfg.Lang = "pt-BR"
}
c := flare.NewClient(flare.WithRateLimit(2, 1))
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 LoadMoreReleasesDTO struct {
HTML string `json:"html"`
HasNext bool `json:"hasNext"`
NextPage int `json:"nextPage"`
}
type SearchResult struct {
Title string `json:"title"`
URL string `json:"url"`
Image string `json:"image"`
Description string `json:"description"`
}
type SearchDTO struct {
Results []SearchResult `json:"results"`
}
func (s *Source) parseStatus(status string) int {
switch strings.TrimSpace(strings.ToLower(status)) {
case "em andamento":
return 1
case "concluído":
return 2
case "hiato":
return 5
case "cancelado":
return 6
}
return 0
}
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
doc, err := s.fetchDoc(fmt.Sprintf("%s%s?page=%d", s.cfg.BaseURL, cfg.PopularMangaPath, page))
if err != nil {
return source.MangasPage{}, err
}
mangas := s.mangaListFromDoc(doc)
hasNext := doc.Find(".page-link[aria-label=Próxima]:not(disabled)").Length() > 0
return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil
}
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
if page > 1 && s.cfg.LatestUpdatesLoadMorePath != "" {
url := fmt.Sprintf("%s%s?page=%d", s.cfg.BaseURL, s.cfg.LatestUpdatesLoadMorePath, page)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
return source.MangasPage{}, err
}
resp, err := s.client.Do(req)
if err != nil {
return source.MangasPage{}, err
}
defer resp.Body.Close()
var dto LoadMoreReleasesDTO
if err := json.NewDecoder(resp.Body).Decode(&dto); err != nil {
return source.MangasPage{}, err
}
doc, err := goquery.NewDocumentFromReader(strings.NewReader(dto.HTML))
if err != nil {
return source.MangasPage{}, err
}
mangas := s.mangaListFromDoc(doc)
return source.MangasPage{Mangas: mangas, HasNextPage: dto.HasNext}, nil
}
doc, err := s.fetchDoc(s.cfg.BaseURL)
if err != nil {
return source.MangasPage{}, err
}
mangas := s.mangaListFromDoc(doc)
hasNext := true
if s.cfg.LatestUpdatesLoadMorePath != "" {
hasNext = doc.Find("#load-more-btn").Length() > 0
} else {
hasNext = doc.Find(".page-link[aria-label=Próxima]:not(disabled)").Length() > 0
}
return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil
}
func (s *Source) mangaListFromDoc(doc *goquery.Document) []source.SManga {
mangas := make([]source.SManga, 0)
doc.Find(".comics-grid a.comic-card-link, div.manga-card-simple").Each(func(_ int, sel *goquery.Selection) {
title := sel.Find("h3").Text()
img := sel.Find("img")
thumb := img.AttrOr("abs:src", "")
if thumb == "" {
href := sel.AttrOr("abs:href", "")
if a := sel.Find("a"); a.Length() > 0 {
href = a.AttrOr("abs:href", "")
}
mangas = append(mangas, source.SManga{
URL: href,
Title: strings.TrimSpace(title),
ThumbnailURL: thumb,
})
} else {
href := sel.AttrOr("abs:href", "")
if a := sel.Find("a"); a.Length() > 0 {
href = a.AttrOr("abs:href", "")
}
mangas = append(mangas, source.SManga{
URL: href,
Title: strings.TrimSpace(title),
ThumbnailURL: thumb,
})
}
})
return mangas
}
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
if query == "" {
return source.MangasPage{}, nil
}
u, _ := url.Parse(s.cfg.BaseURL + "/search/live-search/")
q := u.Query()
q.Set("q", query)
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u.String(), nil)
if err != nil {
return source.MangasPage{}, err
}
resp, err := s.client.Do(req)
if err != nil {
return source.MangasPage{}, err
}
defer resp.Body.Close()
var result SearchDTO
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return source.MangasPage{}, err
}
mangas := make([]source.SManga, len(result.Results))
for i, r := range result.Results {
mangas[i] = source.SManga{
URL: r.URL,
Title: r.Title,
ThumbnailURL: r.Image,
Description: r.Description,
}
}
return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil
}
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
doc, err := s.fetchDoc(manga.URL)
if err != nil {
return manga, err
}
title := doc.Find("h1").Text()
thumb := doc.Find(".sidebar-cover-image img").AttrOr("abs:src", "")
desc := doc.Find(".manga-description").Text()
genre := doc.Find("a.genre-tag").Map(func(_ int, sel *goquery.Selection) string {
return sel.Text()
})
statusText := doc.Find(".status-tag").Text()
status := s.parseStatus(statusText)
manga.Title = strings.TrimSpace(title)
manga.ThumbnailURL = thumb
manga.Description = strings.TrimSpace(desc)
manga.Genre = joinStrings(genre, ", ")
manga.Status = status
manga.Initialized = true
return manga, nil
}
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
chapters := make([]source.SChapter, 0)
page := 1
for {
mangaURL := manga.URL
if strings.Contains(mangaURL, "?") {
mangaURL += "&page=" + fmt.Sprintf("%d", page)
} else {
mangaURL += "?page=" + fmt.Sprintf("%d", page)
}
doc, err := s.fetchDoc(mangaURL)
if err != nil {
break
}
doc.Find(".chapter-item-list a.chapter-link").Each(func(_ int, sel *goquery.Selection) {
name := sel.Find(".chapter-number").Text()
dateText := sel.Find(".chapter-date").Text()
href := sel.AttrOr("abs:href", "")
var dateUpload int64
dateFormat := "02/01/2006"
if t, err := time.Parse(dateFormat, strings.TrimSpace(dateText)); err == nil {
dateUpload = t.UnixMilli()
}
chapters = append(chapters, source.SChapter{
URL: href,
Name: strings.TrimSpace(name),
DateUpload: dateUpload,
})
})
if doc.Find(".page-link[aria-label=Próxima]:not(disabled)").Length() == 0 {
break
}
page++
}
return chapters, nil
}
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
doc, err := s.fetchDoc(chapter.URL)
if err != nil {
return nil, err
}
pages := make([]source.Page, 0)
doc.Find(".chapter-image-canvas").Each(func(i int, sel *goquery.Selection) {
imgURL := sel.AttrOr("data-src-url", "")
pages = append(pages, source.Page{Index: i, ImageURL: imgURL})
})
return pages, nil
}
func (s *Source) GetImageURL(page source.Page) (string, error) {
return page.ImageURL, nil
}
func (s *Source) GetFilterList() []source.Filter {
return nil
}
func (s *Source) fetchDoc(rawURL string) (*goquery.Document, error) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
html, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return goquery.NewDocumentFromReader(strings.NewReader(string(html)))
}
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
}
var cfg = struct {
PopularMangaPath string
}{}
func init() {
cfg.PopularMangaPath = "/manga/todos/"
}
var _ source.CatalogueSource = (*Source)(nil)