Files
goyomi/sources/base/greenshit/greenshit.go
T

409 lines
11 KiB
Go

// Package greenshit implements the GreenShit manga base.
// JSON REST API with Bearer token auth: POST {api}/auth/login → token → scan-id header.
package greenshit
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
"github.com/PuerkitoBio/goquery"
"goyomi/internal/httpclient"
"goyomi/internal/source"
)
type Config struct {
Name string
BaseURL string
APIURL string
CDNApiURL string // CDN base for thumbnails (with width)
CDNUrl string // CDN base for page images
Lang string
ScanID string
DefaultGenreID string // default: "1"
LimitPerPage string // default: "26"
Email string
Password string
}
type Source struct {
cfg Config
client *httpclient.Client
id int64
mu sync.Mutex
cachedToken string
tokenExpiry int64
}
func New(cfg Config) *Source {
if cfg.DefaultGenreID == "" {
cfg.DefaultGenreID = "1"
}
if cfg.LimitPerPage == "" {
cfg.LimitPerPage = "26"
}
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) api() string { return strings.TrimRight(s.cfg.APIURL, "/") }
type loginRequestDTO struct {
Login string `json:"login"`
Senha string `json:"senha"`
TipoUsuario string `json:"tipo_usuario"`
}
type loginResponseDTO struct {
AccessToken string `json:"access_token"`
Token string `json:"token"`
ExpiresIn int64 `json:"expires_in"`
}
func (s *Source) getToken(ctx context.Context) string {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().UnixMilli()
if s.cachedToken != "" && now < s.tokenExpiry {
return s.cachedToken
}
if s.cfg.Email == "" || s.cfg.Password == "" {
return ""
}
body, _ := json.Marshal(loginRequestDTO{
Login: s.cfg.Email,
Senha: s.cfg.Password,
TipoUsuario: "email",
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.api()+"/auth/login", bytes.NewReader(body))
if err != nil {
return ""
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("scan-id", s.cfg.ScanID)
resp, err := s.client.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var lr loginResponseDTO
if json.Unmarshal(raw, &lr) != nil {
return ""
}
tok := lr.AccessToken
if tok == "" {
tok = lr.Token
}
s.cachedToken = tok
exp := lr.ExpiresIn
if exp == 0 {
exp = 3600
}
s.tokenExpiry = time.Now().UnixMilli() + exp*1000
return tok
}
func (s *Source) doGet(ctx context.Context, url string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("scan-id", s.cfg.ScanID)
if tok := s.getToken(ctx); tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("greenshit: HTTP %d for %s", resp.StatusCode, url)
}
body, _ := io.ReadAll(resp.Body)
return json.Unmarshal(body, out)
}
// JSON DTOs
type listDTO struct {
Obras []mangaDTO `json:"obras"`
CurrentPage int `json:"pagina_atual"`
TotalPages int `json:"paginas"`
}
func (l listDTO) hasNext() bool { return l.TotalPages > l.CurrentPage }
type mangaDTO struct {
ID int `json:"obr_id"`
Name string `json:"obr_nome"`
Description *string `json:"obr_descricao"`
Image *string `json:"obr_imagem"`
Tags []tagDTO `json:"tags"`
Status *statusDTO `json:"status"`
ScanID int `json:"scan_id"`
Chapters []chapterSimpleDTO `json:"capitulos"`
}
type tagDTO struct {
Name string `json:"tag_nome"`
}
type statusDTO struct {
Name string `json:"stt_nome"`
}
type chapterSimpleDTO struct {
ID int `json:"cap_id"`
Name string `json:"cap_nome"`
Number float32 `json:"cap_numero"`
CreatedAt string `json:"cap_criado_em"`
}
type chapterDetailDTO struct {
ID int `json:"cap_id"`
Name string `json:"cap_nome"`
Number float32 `json:"cap_numero"`
Pages []pageSrcDTO `json:"cap_paginas"`
Manga *mangaDTO `json:"obra"`
}
type pageSrcDTO struct {
Src string `json:"src"`
Mime *string `json:"mime"`
}
var (
normalizeSlashRe = regexp.MustCompile(`(?:[^:])/{2,}`)
)
func normalizeSlashes(u string) string {
// Replace consecutive slashes (not after colon) with single slash.
return normalizeSlashRe.ReplaceAllStringFunc(u, func(m string) string {
return string(m[0]) + "/"
})
}
func buildImageURL(path, src string, base string) string {
if src == "" {
return ""
}
if strings.HasPrefix(src, "http") {
return src
}
isWpLike := strings.HasPrefix(src, "uploads/") || strings.HasPrefix(src, "wp-content/") ||
strings.HasPrefix(src, "manga_") || strings.HasPrefix(src, "WP-manga")
if isWpLike {
switch {
case strings.HasPrefix(src, "manga_"):
return normalizeSlashes(base + "/wp-content/uploads/WP-manga/data/" + src)
case strings.HasPrefix(src, "WP-manga"):
return normalizeSlashes(base + "/wp-content/uploads/" + src)
case strings.HasPrefix(src, "uploads/"):
return normalizeSlashes(base + "/wp-content/" + src)
case strings.HasPrefix(src, "wp-content/"):
return normalizeSlashes(base + "/" + src)
default:
return normalizeSlashes(base + "/wp-content/uploads/WP-manga/data/" + strings.TrimLeft(src, "/"))
}
}
safePath := strings.Trim(strings.ReplaceAll(path, "//", "/"), "/")
safeSrc := strings.Trim(strings.ReplaceAll(src, "//", "/"), "/")
return normalizeSlashes(base + "/" + safePath + "/" + safeSrc)
}
func (s *Source) toSManga(m mangaDTO) source.SManga {
var tags []string
for _, t := range m.Tags {
if t.Name != "" {
tags = append(tags, t.Name)
}
}
thumbnail := ""
if m.Image != nil {
thumbnail = buildImageURL(
fmt.Sprintf("/scans/%d/obras/%d/", m.ScanID, m.ID),
*m.Image,
s.cfg.CDNApiURL,
)
}
sm := source.SManga{
URL: fmt.Sprintf("/obra/%d", m.ID),
Title: m.Name,
ThumbnailURL: thumbnail,
Genre: strings.Join(tags, ", "),
}
if m.Description != nil {
desc := *m.Description
if doc, err := goquery.NewDocumentFromReader(strings.NewReader(desc)); err == nil {
sm.Description = doc.Text()
} else {
sm.Description = desc
}
}
if m.Status != nil {
switch strings.ToLower(m.Status.Name) {
case "em andamento", "ativo":
sm.Status = source.StatusOngoing
case "concluído", "concluido":
sm.Status = source.StatusCompleted
case "hiato":
sm.Status = source.StatusHiatus
case "cancelado":
sm.Status = source.StatusCancelled
}
}
return sm
}
func (s *Source) extractID(mangaURL string) string {
// URL format: /obra/{id}
parts := strings.Split(strings.Trim(mangaURL, "/"), "/")
if len(parts) >= 2 {
return parts[len(parts)-1]
}
return mangaURL
}
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
u := fmt.Sprintf("%s/obras/ranking?tipo=visualizacoes_geral&limite=%s&pagina=%d&gen_id=%s",
s.api(), s.cfg.LimitPerPage, page, s.cfg.DefaultGenreID)
var dto listDTO
if err := s.doGet(context.Background(), u, &dto); err != nil {
return source.MangasPage{}, err
}
mangas := make([]source.SManga, len(dto.Obras))
for i, m := range dto.Obras {
mangas[i] = s.toSManga(m)
}
return source.MangasPage{Mangas: mangas, HasNextPage: dto.hasNext()}, nil
}
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
u := fmt.Sprintf("%s/obras/atualizacoes?pagina=%d&limite=%s&gen_id=%s",
s.api(), page, s.cfg.LimitPerPage, s.cfg.DefaultGenreID)
var dto listDTO
if err := s.doGet(context.Background(), u, &dto); err != nil {
return source.MangasPage{}, err
}
mangas := make([]source.SManga, len(dto.Obras))
for i, m := range dto.Obras {
mangas[i] = s.toSManga(m)
}
return source.MangasPage{Mangas: mangas, HasNextPage: dto.hasNext()}, nil
}
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
u := fmt.Sprintf("%s/obras/search?limite=%s&pagina=%d&obr_nome=%s",
s.api(), s.cfg.LimitPerPage, page, query)
var dto listDTO
if err := s.doGet(context.Background(), u, &dto); err != nil {
return source.MangasPage{}, err
}
mangas := make([]source.SManga, len(dto.Obras))
for i, m := range dto.Obras {
mangas[i] = s.toSManga(m)
}
return source.MangasPage{Mangas: mangas, HasNextPage: dto.hasNext()}, nil
}
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
id := s.extractID(manga.URL)
var dto mangaDTO
if err := s.doGet(context.Background(), fmt.Sprintf("%s/obras/%s", s.api(), id), &dto); err != nil {
return manga, err
}
result := s.toSManga(dto)
result.URL = manga.URL
return result, nil
}
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
id := s.extractID(manga.URL)
var dto mangaDTO
if err := s.doGet(context.Background(), fmt.Sprintf("%s/obras/%s", s.api(), id), &dto); err != nil {
return nil, err
}
seen := map[string]bool{}
var chapters []source.SChapter
for _, ch := range dto.Chapters {
u := fmt.Sprintf("/capitulo/%d", ch.ID)
if seen[u] {
continue
}
seen[u] = true
chapters = append(chapters, source.SChapter{
URL: u,
Name: ch.Name,
DateUpload: parseDate(ch.CreatedAt),
})
}
// sort descending by chapter number (already from API)
return chapters, nil
}
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
// URL format: /capitulo/{id}
chID := strings.TrimPrefix(chapter.URL, "/capitulo/")
var dto chapterDetailDTO
if err := s.doGet(context.Background(), fmt.Sprintf("%s/capitulos/%s", s.api(), chID), &dto); err != nil {
return nil, err
}
obraID := 0
scanID := 0
chapterNum := "0"
if dto.Manga != nil {
obraID = dto.Manga.ID
scanID = dto.Manga.ScanID
if dto.Number != 0 {
numStr := fmt.Sprintf("%g", dto.Number)
chapterNum = numStr
}
}
pages := make([]source.Page, len(dto.Pages))
for i, p := range dto.Pages {
path := fmt.Sprintf("/scans/%d/obras/%d/capitulos/%s/", scanID, obraID, chapterNum)
pages[i] = source.Page{
Index: i,
ImageURL: buildImageURL(path, p.Src, s.cfg.CDNUrl),
}
}
return pages, nil
}
func parseDate(s string) int64 {
if s == "" {
return 0
}
formats := []string{
"2006-01-02T15:04:05.000Z",
"2006-01-02T15:04:05Z",
"2006-01-02",
}
for _, f := range formats {
if t, err := time.Parse(f, s); err == nil {
return t.UnixMilli()
}
}
return 0
}
func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil }
func (s *Source) GetFilterList() []source.Filter { return nil }