409 lines
11 KiB
Go
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 }
|