phase3: add gravureblogger, greenshit, hotcomics, initmanga, keyoapp bases (45/68)
This commit is contained in:
@@ -25,17 +25,17 @@ Detailed implementation notes for complex bases are in the **Notes** section at
|
||||
- [x] `base/gigaviewer` ⚠️ see notes
|
||||
- [x] `base/gmanga` ⚠️ see notes
|
||||
- [x] `base/goda`
|
||||
- [ ] `base/gravureblogger`
|
||||
- [ ] `base/greenshit`
|
||||
- [x] `base/gravureblogger`
|
||||
- [x] `base/greenshit`
|
||||
- [x] `base/grouple` ⚠️ see notes
|
||||
- [x] `base/guya` ⚠️ see notes
|
||||
- [x] `base/heancms` ⚠️ see notes
|
||||
- [x] `base/hentaihand` ⚠️ see notes
|
||||
- [ ] `base/hotcomics`
|
||||
- [x] `base/hotcomics`
|
||||
- [x] `base/iken` ⚠️ see notes
|
||||
- [ ] `base/initmanga`
|
||||
- [x] `base/initmanga`
|
||||
- [x] `base/kemono` ⚠️ see notes
|
||||
- [ ] `base/keyoapp`
|
||||
- [x] `base/keyoapp`
|
||||
- [x] `base/lectormoe` ⚠️ see notes
|
||||
- [x] `base/libgroup` ⚠️ see notes
|
||||
- [x] `base/liliana` ⚠️ see notes
|
||||
|
||||
@@ -15,7 +15,8 @@ require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
)
|
||||
|
||||
@@ -83,6 +83,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -99,6 +101,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -121,6 +125,7 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -141,6 +146,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// Package gravureblogger implements the GravureBlogger base.
|
||||
// Uses Google Blogger JSON feed API: GET {base}/feeds/posts/default?alt=json&...
|
||||
package gravureblogger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"goyomi/internal/httpclient"
|
||||
"goyomi/internal/source"
|
||||
"goyomi/sources/base/util"
|
||||
)
|
||||
|
||||
const maxResults = 30
|
||||
|
||||
type Config struct {
|
||||
Name string
|
||||
BaseURL string
|
||||
Lang string
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
cfg Config
|
||||
client *httpclient.Client
|
||||
id int64
|
||||
}
|
||||
|
||||
func New(cfg Config) *Source {
|
||||
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 false }
|
||||
|
||||
func (s *Source) base() string { return strings.TrimRight(s.cfg.BaseURL, "/") }
|
||||
|
||||
// Blogger JSON DTOs
|
||||
type bloggerDTO struct {
|
||||
Feed bloggerFeedDTO `json:"feed"`
|
||||
}
|
||||
type bloggerFeedDTO struct {
|
||||
Category []bloggerCategoryDTO `json:"category"`
|
||||
Entry []bloggerEntryDTO `json:"entry"`
|
||||
}
|
||||
type bloggerCategoryDTO struct {
|
||||
Term string `json:"term"`
|
||||
}
|
||||
type bloggerEntryDTO struct {
|
||||
Title bloggerTextDTO `json:"title"`
|
||||
Published bloggerTextDTO `json:"published"`
|
||||
Content bloggerTextDTO `json:"content"`
|
||||
Link []bloggerLinkDTO `json:"link"`
|
||||
Category []bloggerCategoryDTO `json:"category"`
|
||||
}
|
||||
type bloggerLinkDTO struct {
|
||||
Rel string `json:"rel"`
|
||||
Href string `json:"href"`
|
||||
}
|
||||
type bloggerTextDTO struct {
|
||||
T string `json:"$t"`
|
||||
}
|
||||
|
||||
func (s *Source) fetchFeed(ctx context.Context, page int, query string) (source.MangasPage, error) {
|
||||
startIndex := maxResults*(page-1) + 1
|
||||
u := fmt.Sprintf("%s/feeds/posts/default?alt=json&max-results=%d&start-index=%d",
|
||||
s.base(), maxResults, startIndex)
|
||||
if query != "" {
|
||||
u += "&q=" + query
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return source.MangasPage{}, err
|
||||
}
|
||||
req.Header.Set("Referer", s.cfg.BaseURL+"/")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return source.MangasPage{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return source.MangasPage{}, fmt.Errorf("gravureblogger: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var data bloggerDTO
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return source.MangasPage{}, err
|
||||
}
|
||||
|
||||
var mangas []source.SManga
|
||||
for _, entry := range data.Feed.Entry {
|
||||
altLink := ""
|
||||
for _, l := range entry.Link {
|
||||
if l.Rel == "alternate" {
|
||||
altLink = l.Href
|
||||
break
|
||||
}
|
||||
}
|
||||
if altLink == "" {
|
||||
continue
|
||||
}
|
||||
// Store relative path + "#published" for date retrieval
|
||||
relURL := util.AbsURL(s.cfg.BaseURL, altLink)
|
||||
mangaURL := relURL + "#" + entry.Published.T
|
||||
|
||||
// Extract thumbnail from HTML content
|
||||
thumbnail := ""
|
||||
if doc, err := goquery.NewDocumentFromReader(strings.NewReader("<body>" + entry.Content.T + "</body>")); err == nil {
|
||||
doc.Find("img").First().Each(func(_ int, img *goquery.Selection) {
|
||||
thumbnail, _ = img.Attr("src")
|
||||
})
|
||||
}
|
||||
|
||||
var genres []string
|
||||
for _, cat := range entry.Category {
|
||||
if cat.Term != "" {
|
||||
genres = append(genres, cat.Term)
|
||||
}
|
||||
}
|
||||
|
||||
mangas = append(mangas, source.SManga{
|
||||
URL: mangaURL,
|
||||
Title: entry.Title.T,
|
||||
ThumbnailURL: thumbnail,
|
||||
Genre: strings.Join(genres, ", "),
|
||||
Status: source.StatusCompleted,
|
||||
})
|
||||
}
|
||||
return source.MangasPage{Mangas: mangas, HasNextPage: len(data.Feed.Entry) == maxResults}, nil
|
||||
}
|
||||
|
||||
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
|
||||
return s.fetchFeed(context.Background(), page, "")
|
||||
}
|
||||
|
||||
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) {
|
||||
return s.fetchFeed(context.Background(), page, query)
|
||||
}
|
||||
|
||||
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
|
||||
// All data already in manga from list; just return as-is.
|
||||
return manga, nil
|
||||
}
|
||||
|
||||
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
|
||||
// URL is "...path#published_date"
|
||||
hash := ""
|
||||
chURL := manga.URL
|
||||
if idx := strings.LastIndex(manga.URL, "#"); idx >= 0 {
|
||||
hash = manga.URL[idx+1:]
|
||||
chURL = manga.URL[:idx]
|
||||
}
|
||||
|
||||
var dateMs int64
|
||||
if hash != "" {
|
||||
// Parse RFC3339 / ISO8601 date
|
||||
if t, err := time.Parse(time.RFC3339Nano, hash); err == nil {
|
||||
dateMs = t.UnixMilli()
|
||||
} else if t, err := time.Parse("2006-01-02", hash[:10]); err == nil {
|
||||
dateMs = t.UnixMilli()
|
||||
}
|
||||
}
|
||||
|
||||
return []source.SChapter{{
|
||||
URL: chURL,
|
||||
Name: "Gallery",
|
||||
DateUpload: dateMs,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet,
|
||||
util.AbsURL(s.cfg.BaseURL, chapter.URL), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Referer", s.cfg.BaseURL+"/")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("gravureblogger: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pages []source.Page
|
||||
doc.Find("div.post-body a:has(> img)").Each(func(i int, a *goquery.Selection) {
|
||||
if u, ok := a.Attr("href"); ok && u != "" {
|
||||
pages = append(pages, source.Page{Index: i, ImageURL: util.AbsURL(s.cfg.BaseURL, u)})
|
||||
}
|
||||
})
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil }
|
||||
func (s *Source) GetFilterList() []source.Filter { return nil }
|
||||
@@ -0,0 +1,408 @@
|
||||
// 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 }
|
||||
@@ -0,0 +1,228 @@
|
||||
// Package hotcomics implements the HotComics manga base.
|
||||
// HTML scraping; popular: GET {base}/en; requires cookie hc_vfs=Y.
|
||||
package hotcomics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"goyomi/internal/httpclient"
|
||||
"goyomi/internal/source"
|
||||
"goyomi/sources/base/util"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Name string
|
||||
BaseURL string
|
||||
Lang string
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
cfg Config
|
||||
client *httpclient.Client
|
||||
id int64
|
||||
}
|
||||
|
||||
func New(cfg Config) *Source {
|
||||
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) base() string { return strings.TrimRight(s.cfg.BaseURL, "/") }
|
||||
|
||||
func (s *Source) get(ctx context.Context, rawURL string) (*goquery.Document, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Referer", s.cfg.BaseURL+"/")
|
||||
// Inject required cookie
|
||||
req.AddCookie(&http.Cookie{Name: "hc_vfs", Value: "Y"})
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("hotcomics: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return goquery.NewDocumentFromReader(resp.Body)
|
||||
}
|
||||
|
||||
func imgAttr(img *goquery.Selection) string {
|
||||
if v, ok := img.Attr("data-src"); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
v, _ := img.Attr("src")
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Source) parseMangaList(doc *goquery.Document) source.MangasPage {
|
||||
var mangas []source.SManga
|
||||
seen := map[string]bool{}
|
||||
doc.Find("li[itemtype*=ComicSeries]:not(.no-comic) > a").Each(func(_ int, a *goquery.Selection) {
|
||||
m := source.SManga{}
|
||||
m.URL, _ = a.Attr("href")
|
||||
if m.URL == "" || seen[m.URL] {
|
||||
return
|
||||
}
|
||||
seen[m.URL] = true
|
||||
a.Find("div.visual img").First().Each(func(_ int, img *goquery.Selection) {
|
||||
m.ThumbnailURL = imgAttr(img)
|
||||
})
|
||||
m.Title = strings.TrimSpace(a.Find("div.main-text > h4.title").Text())
|
||||
if m.Title != "" {
|
||||
mangas = append(mangas, m)
|
||||
}
|
||||
})
|
||||
hasNext := doc.Find("div.pagination a.vnext:not(.disabled)").Length() > 0
|
||||
return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}
|
||||
}
|
||||
|
||||
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
|
||||
doc, err := s.get(context.Background(), s.base()+"/en")
|
||||
if err != nil {
|
||||
return source.MangasPage{}, err
|
||||
}
|
||||
return s.parseMangaList(doc), nil
|
||||
}
|
||||
|
||||
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
|
||||
doc, err := s.get(context.Background(), s.base()+"/en/new")
|
||||
if err != nil {
|
||||
return source.MangasPage{}, err
|
||||
}
|
||||
return s.parseMangaList(doc), nil
|
||||
}
|
||||
|
||||
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
|
||||
var u string
|
||||
if query != "" {
|
||||
u = fmt.Sprintf("%s/en/search?keyword=%s", s.base(), query)
|
||||
} else {
|
||||
u = fmt.Sprintf("%s/en?page=%d", s.base(), page)
|
||||
}
|
||||
doc, err := s.get(context.Background(), u)
|
||||
if err != nil {
|
||||
return source.MangasPage{}, err
|
||||
}
|
||||
return s.parseMangaList(doc), nil
|
||||
}
|
||||
|
||||
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
|
||||
doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, manga.URL))
|
||||
if err != nil {
|
||||
return manga, err
|
||||
}
|
||||
result := source.SManga{URL: manga.URL}
|
||||
result.Title = strings.TrimSpace(doc.Find("h2.episode-title").Text())
|
||||
if result.Title == "" {
|
||||
result.Title = manga.Title
|
||||
}
|
||||
typeBox := doc.Find("p.type_box")
|
||||
result.Author = strings.TrimSpace(
|
||||
strings.TrimPrefix(typeBox.Find("span.writer").Text(), "ⓒ"))
|
||||
var genres []string
|
||||
typeBox.Find("span.type").First().Each(func(_ int, el *goquery.Selection) {
|
||||
for _, g := range strings.Split(el.Text(), "/") {
|
||||
if g = strings.TrimSpace(g); g != "" {
|
||||
genres = append(genres, g)
|
||||
}
|
||||
}
|
||||
})
|
||||
result.Genre = strings.Join(genres, ", ")
|
||||
switch typeBox.Find("span.date").Text() {
|
||||
case "End", "Ende":
|
||||
result.Status = source.StatusCompleted
|
||||
case "":
|
||||
result.Status = source.StatusUnknown
|
||||
default:
|
||||
result.Status = source.StatusOngoing
|
||||
}
|
||||
var descParts []string
|
||||
doc.Find("div.episode-contents header").First().Each(func(_ int, el *goquery.Selection) {
|
||||
if t := strings.TrimSpace(el.Text()); t != "" {
|
||||
descParts = append(descParts, t)
|
||||
}
|
||||
})
|
||||
doc.Find("div.title_content > h2:not(.episode-title)").First().Each(func(_ int, el *goquery.Selection) {
|
||||
if t := strings.TrimSpace(el.Text()); t != "" {
|
||||
descParts = append(descParts, t)
|
||||
}
|
||||
})
|
||||
result.Description = strings.Join(descParts, "\n\n")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
|
||||
doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, manga.URL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var chapters []source.SChapter
|
||||
doc.Find("#tab-chapter a").Each(func(_ int, a *goquery.Selection) {
|
||||
onclick := a.AttrOr("onclick", "")
|
||||
// onclick="popupLogin('/chapter/url')"
|
||||
u := strings.TrimSuffix(strings.TrimPrefix(
|
||||
strings.TrimPrefix(onclick, "popupLogin('"), "popupLogin(\""), "'")
|
||||
u = strings.TrimSuffix(u, "\")")
|
||||
u = strings.TrimSuffix(u, "')")
|
||||
if u == "" || u == onclick {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(a.Find(".cell-num").Text())
|
||||
dateStr := strings.TrimSpace(a.Find(".cell-time").Text())
|
||||
chapters = append(chapters, source.SChapter{
|
||||
URL: u,
|
||||
Name: name,
|
||||
DateUpload: parseDate(dateStr),
|
||||
})
|
||||
})
|
||||
// reverse: oldest first
|
||||
for i, j := 0, len(chapters)-1; i < j; i, j = i+1, j-1 {
|
||||
chapters[i], chapters[j] = chapters[j], chapters[i]
|
||||
}
|
||||
return chapters, nil
|
||||
}
|
||||
|
||||
func parseDate(s string) int64 {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
t, err := time.Parse("Jan 02, 2006", s)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return t.UnixMilli()
|
||||
}
|
||||
|
||||
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
|
||||
doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, chapter.URL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pages []source.Page
|
||||
doc.Find("#viewer-img img").Each(func(i int, img *goquery.Selection) {
|
||||
u := imgAttr(img)
|
||||
if u != "" {
|
||||
if !strings.HasPrefix(u, "http") {
|
||||
u = util.AbsURL(s.cfg.BaseURL, u)
|
||||
}
|
||||
pages = append(pages, source.Page{Index: i, ImageURL: u})
|
||||
}
|
||||
})
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil }
|
||||
func (s *Source) GetFilterList() []source.Filter { return nil }
|
||||
@@ -0,0 +1,522 @@
|
||||
// Package initmanga implements the InitManga manga base.
|
||||
// HTML scraping (UIkit-based); pages use AES-256-CBC with PBKDF2-SHA512 key derivation.
|
||||
package initmanga
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
|
||||
"goyomi/internal/httpclient"
|
||||
"goyomi/internal/source"
|
||||
"goyomi/sources/base/util"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Name string
|
||||
BaseURL string
|
||||
Lang string
|
||||
PopularUrlSlug string // e.g. "manga" or "seri"
|
||||
LatestUrlSlug string // e.g. "manga"
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
cfg Config
|
||||
client *httpclient.Client
|
||||
id int64
|
||||
}
|
||||
|
||||
func New(cfg Config) *Source {
|
||||
if cfg.PopularUrlSlug == "" {
|
||||
cfg.PopularUrlSlug = "seri"
|
||||
}
|
||||
if cfg.LatestUrlSlug == "" {
|
||||
cfg.LatestUrlSlug = cfg.PopularUrlSlug
|
||||
}
|
||||
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) base() string { return strings.TrimRight(s.cfg.BaseURL, "/") }
|
||||
|
||||
func (s *Source) get(ctx context.Context, rawURL string) (*goquery.Document, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Referer", s.cfg.BaseURL+"/")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("initmanga: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return goquery.NewDocumentFromReader(resp.Body)
|
||||
}
|
||||
|
||||
const popularMangaSelector = "div.manga-item-grid > div.uk-panel.uk-position-relative, " +
|
||||
"div.manga-item-grid > div.uk-panel:not(.manga-item-ranking):not(.user-item-info), " +
|
||||
"div.uk-panel.uk-position-relative, " +
|
||||
"div.uk-panel:not(.manga-item-ranking):not(.user-item-info)"
|
||||
|
||||
const nextPageSelector = "head link[rel=next], link[rel=next], " +
|
||||
"ul.uk-pagination li:not(.uk-disabled) a[aria-label=\"Sonraki sayfa\"], " +
|
||||
"a:contains(Sonraki sayfa), a:contains(Next page), a.next"
|
||||
|
||||
func mangaFromElement(el *goquery.Selection, baseURL string) source.SManga {
|
||||
m := source.SManga{}
|
||||
link := el.Find("h3 a, div.uk-overflow-hidden a").First()
|
||||
if link.Length() == 0 {
|
||||
link = el.Find("a").First()
|
||||
}
|
||||
m.URL, _ = link.Attr("href")
|
||||
m.Title = strings.TrimSpace(el.Find("h3").Text())
|
||||
if m.Title == "" {
|
||||
clone := el.Find("a").Clone()
|
||||
clone.Find("span, small").Remove()
|
||||
m.Title = strings.TrimSpace(clone.Text())
|
||||
}
|
||||
imgEl := el.Find("img")
|
||||
if src := imgEl.AttrOr("data-src", ""); src != "" {
|
||||
m.ThumbnailURL = util.AbsURL(baseURL, src)
|
||||
} else if src := imgEl.AttrOr("src", ""); src != "" {
|
||||
m.ThumbnailURL = util.AbsURL(baseURL, src)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *Source) parsePage(doc *goquery.Document) source.MangasPage {
|
||||
var mangas []source.SManga
|
||||
doc.Find(popularMangaSelector).Each(func(_ int, el *goquery.Selection) {
|
||||
m := mangaFromElement(el, s.cfg.BaseURL)
|
||||
if m.URL != "" && m.Title != "" {
|
||||
mangas = append(mangas, m)
|
||||
}
|
||||
})
|
||||
hasNext := doc.Find(nextPageSelector).Length() > 0
|
||||
return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}
|
||||
}
|
||||
|
||||
func (s *Source) fetchListing(slug string, page int) (source.MangasPage, error) {
|
||||
var u string
|
||||
if page == 1 {
|
||||
u = fmt.Sprintf("%s/%s/", s.base(), slug)
|
||||
} else {
|
||||
u = fmt.Sprintf("%s/%s/page/%d/", s.base(), slug, page)
|
||||
}
|
||||
doc, err := s.get(context.Background(), u)
|
||||
if err != nil {
|
||||
return source.MangasPage{}, err
|
||||
}
|
||||
return s.parsePage(doc), nil
|
||||
}
|
||||
|
||||
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
|
||||
return s.fetchListing(s.cfg.PopularUrlSlug, page)
|
||||
}
|
||||
|
||||
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
|
||||
return s.fetchListing(s.cfg.LatestUrlSlug, page)
|
||||
}
|
||||
|
||||
type searchDTO struct {
|
||||
Title *string `json:"title"`
|
||||
URL *string `json:"url"`
|
||||
Thumb *string `json:"thumb"`
|
||||
}
|
||||
|
||||
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
|
||||
u := fmt.Sprintf("%s/wp-json/initlise/v1/search?term=%s&page=%d", s.base(), query, page)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return source.MangasPage{}, err
|
||||
}
|
||||
req.Header.Set("Referer", s.cfg.BaseURL+"/")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return source.MangasPage{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body := make([]byte, 0, 4096)
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, readErr := resp.Body.Read(buf)
|
||||
body = append(body, buf[:n]...)
|
||||
if readErr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
bodyStr := strings.TrimSpace(string(body))
|
||||
if strings.HasPrefix(bodyStr, "<") {
|
||||
// HTML response — parse as list page
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(bodyStr))
|
||||
if err != nil {
|
||||
return source.MangasPage{}, err
|
||||
}
|
||||
return s.parsePage(doc), nil
|
||||
}
|
||||
|
||||
var dtos []searchDTO
|
||||
if err := json.Unmarshal(body, &dtos); err != nil {
|
||||
return source.MangasPage{}, err
|
||||
}
|
||||
var mangas []source.SManga
|
||||
for _, dto := range dtos {
|
||||
m := source.SManga{}
|
||||
if dto.Title != nil {
|
||||
m.Title = strings.TrimSpace(*dto.Title)
|
||||
}
|
||||
if dto.URL != nil {
|
||||
m.URL = *dto.URL
|
||||
}
|
||||
if dto.Thumb != nil {
|
||||
m.ThumbnailURL = *dto.Thumb
|
||||
}
|
||||
if m.URL != "" && m.Title != "" {
|
||||
mangas = append(mangas, m)
|
||||
}
|
||||
}
|
||||
return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil
|
||||
}
|
||||
|
||||
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
|
||||
doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, manga.URL))
|
||||
if err != nil {
|
||||
return manga, err
|
||||
}
|
||||
result := source.SManga{URL: manga.URL}
|
||||
|
||||
descClone := doc.Find("div#manga-description").Clone()
|
||||
descClone.Find("a, span").Remove()
|
||||
result.Description = strings.TrimSpace(descClone.Text())
|
||||
if altName := strings.TrimSpace(doc.Find("span#comic-othername").Text()); altName != "" {
|
||||
result.Description += "\n\nAlternatif Başlık: " + altName
|
||||
}
|
||||
|
||||
var genres []string
|
||||
doc.Find("span.uk-label-contest").Each(func(_ int, el *goquery.Selection) {
|
||||
t := strings.TrimPrefix(strings.TrimSpace(el.Text()), "#")
|
||||
if t != "" {
|
||||
genres = append(genres, t)
|
||||
}
|
||||
})
|
||||
if len(genres) == 0 {
|
||||
doc.Find("div#genre-tags a").Each(func(_ int, el *goquery.Selection) {
|
||||
if t := strings.TrimSpace(el.Text()); t != "" {
|
||||
genres = append(genres, t)
|
||||
}
|
||||
})
|
||||
}
|
||||
result.Genre = strings.Join(genres, ", ")
|
||||
|
||||
result.Author = strings.TrimSpace(doc.Find("div.manga-info-details a").FilterFunction(func(_ int, s *goquery.Selection) bool {
|
||||
parent, _ := s.Parent().Html()
|
||||
return strings.Contains(parent, "Yazar")
|
||||
}).Text())
|
||||
if result.Author == "" {
|
||||
t := doc.Find("div.manga-info-details").FilterFunction(func(_ int, el *goquery.Selection) bool {
|
||||
return strings.Contains(el.Text(), "Yazar")
|
||||
}).Text()
|
||||
result.Author = strings.TrimSpace(strings.SplitN(strings.SplitN(t, "Yazar:", 2)[len(strings.SplitN(t, "Yazar:", 2))-1], "Çizer:", 2)[0])
|
||||
}
|
||||
|
||||
statusText := strings.ToLower(strings.TrimSpace(
|
||||
doc.Find("span#manga-status, div.manga-status-ribbons span.manga-status-ribbon__text").First().Text()))
|
||||
if statusText == "" {
|
||||
t := doc.Find("div.manga-info-details").FilterFunction(func(_ int, el *goquery.Selection) bool {
|
||||
return strings.Contains(el.Text(), "Durum")
|
||||
}).Text()
|
||||
statusText = strings.ToLower(strings.TrimSpace(strings.SplitN(t, "Durum:", 2)[len(strings.SplitN(t, "Durum:", 2))-1]))
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(statusText, "güncel") || strings.Contains(statusText, "devam") || strings.Contains(statusText, "ongoing"):
|
||||
result.Status = source.StatusOngoing
|
||||
case strings.Contains(statusText, "tamamland") || strings.Contains(statusText, "bitti") || strings.Contains(statusText, "completed"):
|
||||
result.Status = source.StatusCompleted
|
||||
case strings.Contains(statusText, "ara ver") || strings.Contains(statusText, "sezon") || strings.Contains(statusText, "hiatus"):
|
||||
result.Status = source.StatusHiatus
|
||||
case strings.Contains(statusText, "bırakıldı") || strings.Contains(statusText, "iptal") || strings.Contains(statusText, "dropped"):
|
||||
result.Status = source.StatusCancelled
|
||||
default:
|
||||
result.Status = source.StatusUnknown
|
||||
}
|
||||
|
||||
for _, sel := range []string{"div.story-cover-wrap img", "div.single-thumb img", "a.story-cover img"} {
|
||||
if img := doc.Find(sel).First(); img.Length() > 0 {
|
||||
if src := img.AttrOr("abs:src", img.AttrOr("src", "")); src != "" {
|
||||
result.ThumbnailURL = util.AbsURL(s.cfg.BaseURL, src)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(doc.Find("h1").First().Text())
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(doc.Find("h2.uk-h3").First().Text())
|
||||
}
|
||||
result.Title = title
|
||||
if result.Title == "" {
|
||||
result.Title = manga.Title
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
|
||||
mangaURL := util.AbsURL(s.cfg.BaseURL, manga.URL)
|
||||
doc, err := s.get(context.Background(), mangaURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var chapters []source.SChapter
|
||||
chapterFromEl := func(el *goquery.Selection) source.SChapter {
|
||||
ch := source.SChapter{}
|
||||
el.Find("a").First().Each(func(_ int, a *goquery.Selection) {
|
||||
ch.URL, _ = a.Attr("href")
|
||||
})
|
||||
rawName := strings.TrimSpace(el.Find("h3").Text())
|
||||
ch.Name = strings.TrimSpace(lastPart(rawName, "–", "-"))
|
||||
if ch.Name == "" {
|
||||
ch.Name = rawName
|
||||
}
|
||||
dateStr := el.Find("time").AttrOr("datetime", "")
|
||||
ch.DateUpload = util.ParseAbsoluteDate(dateStr, "2006-01-02T15:04:05")
|
||||
return ch
|
||||
}
|
||||
|
||||
doc.Find("div.chapter-item").Each(func(_ int, el *goquery.Selection) {
|
||||
if ch := chapterFromEl(el); ch.URL != "" {
|
||||
chapters = append(chapters, ch)
|
||||
}
|
||||
})
|
||||
|
||||
// paginate
|
||||
page := 2
|
||||
for {
|
||||
paginURL := strings.TrimRight(mangaURL, "/") + "/bolum/page/" + fmt.Sprintf("%d", page) + "/"
|
||||
nextDoc, err := s.get(context.Background(), paginURL)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
items := nextDoc.Find("div.chapter-item")
|
||||
if items.Length() == 0 {
|
||||
break
|
||||
}
|
||||
items.Each(func(_ int, el *goquery.Selection) {
|
||||
if ch := chapterFromEl(el); ch.URL != "" {
|
||||
chapters = append(chapters, ch)
|
||||
}
|
||||
})
|
||||
if nextDoc.Find("ul.uk-pagination a[href^=http]").Length() == 0 {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return chapters, nil
|
||||
}
|
||||
|
||||
func lastPart(s string, seps ...string) string {
|
||||
for _, sep := range seps {
|
||||
if idx := strings.LastIndex(s, sep); idx >= 0 {
|
||||
return s[idx+len(sep):]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Regexes for page decryption
|
||||
var (
|
||||
reEncryptedData = regexp.MustCompile(`(?s)var\s+InitMangaEncryptedChapter\s*=\s*(\{.*?\});`)
|
||||
reInitMangaChunk = regexp.MustCompile(`(?s)InitMangaEncryptedChapter\s*=\s*(\{.*?\})`)
|
||||
reDecryptKey = regexp.MustCompile(`["']?decryption_key["']?\s*[:=]\s*["']([^"']+)["']`)
|
||||
reSmartKey = regexp.MustCompile(`(?s)InitMangaData[\s\S]*?decryption_key["']?\s*[:=]\s*["']([^"']+)["']`)
|
||||
)
|
||||
|
||||
type encryptedChapter struct {
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
IV string `json:"iv"`
|
||||
Salt string `json:"salt"`
|
||||
}
|
||||
|
||||
func decryptWithPassphrase(ciphertextB64, passphrase, saltHex, ivHex string) (string, error) {
|
||||
salt, err := hex.DecodeString(saltHex)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iv, err := hex.DecodeString(ivHex)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ct, err := base64.StdEncoding.DecodeString(ciphertextB64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := pbkdf2.Key([]byte(passphrase), salt, 999, 32, sha512.New)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(ct)%aes.BlockSize != 0 {
|
||||
return "", fmt.Errorf("initmanga: ciphertext not block-aligned")
|
||||
}
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
mode.CryptBlocks(ct, ct)
|
||||
// PKCS5 unpad
|
||||
if len(ct) == 0 {
|
||||
return "", fmt.Errorf("initmanga: empty decrypted block")
|
||||
}
|
||||
padLen := int(ct[len(ct)-1])
|
||||
if padLen == 0 || padLen > aes.BlockSize || padLen > len(ct) {
|
||||
return "", fmt.Errorf("initmanga: invalid padding %d", padLen)
|
||||
}
|
||||
return string(ct[:len(ct)-padLen]), nil
|
||||
}
|
||||
|
||||
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
|
||||
doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, chapter.URL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
html, _ := doc.Html()
|
||||
|
||||
// --- Path 1: script[src*=dmFyIElua] base64-encoded data ---
|
||||
var encObj encryptedChapter
|
||||
doc.Find("script[src]").Each(func(_ int, el *goquery.Selection) {
|
||||
if encObj.Ciphertext != "" {
|
||||
return
|
||||
}
|
||||
src := el.AttrOr("src", "")
|
||||
if !strings.Contains(src, "dmFyIElua") {
|
||||
return
|
||||
}
|
||||
b64 := strings.TrimSuffix(
|
||||
strings.SplitN(src, "base64,", 2)[len(strings.SplitN(src, "base64,", 2))-1], "\"")
|
||||
raw, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
decoded := string(raw)
|
||||
// extract InitMangaEncryptedChapter JSON
|
||||
jsonStr := ""
|
||||
if m := reInitMangaChunk.FindStringSubmatch(decoded); len(m) > 1 {
|
||||
jsonStr = m[1]
|
||||
} else if idx := strings.Index(decoded, "InitMangaEncryptedChapter="); idx >= 0 {
|
||||
jsonStr = strings.SplitN(decoded[idx+len("InitMangaEncryptedChapter="):], ";", 2)[0]
|
||||
}
|
||||
if jsonStr == "" {
|
||||
return
|
||||
}
|
||||
json.Unmarshal([]byte(jsonStr), &encObj) //nolint:errcheck
|
||||
})
|
||||
|
||||
// --- Path 2: inline JS var InitMangaEncryptedChapter ---
|
||||
if encObj.Ciphertext == "" {
|
||||
if m := reEncryptedData.FindStringSubmatch(html); len(m) > 1 {
|
||||
json.Unmarshal([]byte(m[1]), &encObj) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
if encObj.Ciphertext != "" {
|
||||
// Find decryption key
|
||||
rawKey := ""
|
||||
// Try script#init-main-js-extra src (base64-encoded)
|
||||
doc.Find("script#init-main-js-extra[src]").First().Each(func(_ int, el *goquery.Selection) {
|
||||
src := el.AttrOr("src", "")
|
||||
if !strings.Contains(src, "base64,") {
|
||||
return
|
||||
}
|
||||
b64 := strings.SplitN(src, "base64,", 2)[1]
|
||||
b64 = strings.TrimSuffix(b64, "\"")
|
||||
if raw, err := base64.StdEncoding.DecodeString(b64); err == nil {
|
||||
if m := reDecryptKey.FindStringSubmatch(string(raw)); len(m) > 1 {
|
||||
rawKey = m[1]
|
||||
}
|
||||
}
|
||||
})
|
||||
if rawKey == "" {
|
||||
if m := reSmartKey.FindStringSubmatch(html); len(m) > 1 {
|
||||
rawKey = m[1]
|
||||
}
|
||||
}
|
||||
if rawKey != "" {
|
||||
passBytes, err := base64.StdEncoding.DecodeString(rawKey)
|
||||
if err == nil {
|
||||
passphrase := string(passBytes)
|
||||
if content, err := decryptWithPassphrase(encObj.Ciphertext, passphrase, encObj.Salt, encObj.IV); err == nil {
|
||||
return parseDecryptedPages(content, s.cfg.BaseURL), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: plain img tags
|
||||
return fallbackPages(doc, s.cfg.BaseURL), nil
|
||||
}
|
||||
|
||||
func parseDecryptedPages(content, baseURL string) []source.Page {
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if strings.HasPrefix(trimmed, "<") {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(trimmed))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var pages []source.Page
|
||||
doc.Find("img").Each(func(i int, img *goquery.Selection) {
|
||||
src := img.AttrOr("data-src", img.AttrOr("src", ""))
|
||||
if src == "" || strings.HasPrefix(src, "data:") {
|
||||
return
|
||||
}
|
||||
pages = append(pages, source.Page{Index: i, ImageURL: util.AbsURL(baseURL, src)})
|
||||
})
|
||||
return pages
|
||||
}
|
||||
// Try JSON array of URLs
|
||||
var srcs []string
|
||||
if err := json.Unmarshal([]byte(trimmed), &srcs); err == nil {
|
||||
pages := make([]source.Page, 0, len(srcs))
|
||||
for i, src := range srcs {
|
||||
switch {
|
||||
case strings.HasPrefix(src, "//"):
|
||||
src = "https:" + src
|
||||
case strings.HasPrefix(src, "/"):
|
||||
src = strings.TrimRight(baseURL, "/") + src
|
||||
}
|
||||
pages = append(pages, source.Page{Index: i, ImageURL: src})
|
||||
}
|
||||
return pages
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fallbackPages(doc *goquery.Document, baseURL string) []source.Page {
|
||||
var pages []source.Page
|
||||
doc.Find("div#chapter-content img[src]").Each(func(i int, img *goquery.Selection) {
|
||||
src := img.AttrOr("src", img.AttrOr("data-src", ""))
|
||||
if src != "" {
|
||||
pages = append(pages, source.Page{Index: i, ImageURL: util.AbsURL(baseURL, src)})
|
||||
}
|
||||
})
|
||||
return pages
|
||||
}
|
||||
|
||||
func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil }
|
||||
func (s *Source) GetFilterList() []source.Filter { return nil }
|
||||
@@ -0,0 +1,325 @@
|
||||
// Package keyoapp implements the Keyoapp manga base.
|
||||
// HTML scraping; popular from homepage; pages via CDN URL extracted from inline JS.
|
||||
package keyoapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"goyomi/internal/httpclient"
|
||||
"goyomi/internal/source"
|
||||
"goyomi/sources/base/util"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Name string
|
||||
BaseURL string
|
||||
Lang string
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
cfg Config
|
||||
client *httpclient.Client
|
||||
id int64
|
||||
}
|
||||
|
||||
func New(cfg Config) *Source {
|
||||
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) base() string { return strings.TrimRight(s.cfg.BaseURL, "/") }
|
||||
|
||||
func (s *Source) get(ctx context.Context, rawURL string) (*goquery.Document, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Referer", s.cfg.BaseURL+"/")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("keyoapp: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return goquery.NewDocumentFromReader(resp.Body)
|
||||
}
|
||||
|
||||
var imgURLRe = regexp.MustCompile(`url\(['"]?([^('")\s]+)`)
|
||||
|
||||
func getImageURL(el *goquery.Selection, baseURL string) string {
|
||||
// Find any descendant with background-image style
|
||||
var u string
|
||||
el.Find("*[style]").Each(func(_ int, s *goquery.Selection) {
|
||||
if u != "" {
|
||||
return
|
||||
}
|
||||
style := s.AttrOr("style", "")
|
||||
if !strings.Contains(style, "background-image") {
|
||||
return
|
||||
}
|
||||
if m := imgURLRe.FindStringSubmatch(style); len(m) > 1 {
|
||||
raw := m[1]
|
||||
// strip w= query that keyoapp uses for thumbnail sizing
|
||||
if parsed, err := url.Parse(raw); err == nil {
|
||||
q := parsed.Query()
|
||||
q.Del("w")
|
||||
parsed.RawQuery = q.Encode()
|
||||
u = parsed.String()
|
||||
} else {
|
||||
u = raw
|
||||
}
|
||||
}
|
||||
})
|
||||
if u != "" {
|
||||
return util.AbsURL(baseURL, u)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func relURL(raw, baseURL string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
base, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
if u.Host == base.Host {
|
||||
return u.Path
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func (s *Source) mangaFromElement(el *goquery.Selection) source.SManga {
|
||||
m := source.SManga{}
|
||||
m.ThumbnailURL = getImageURL(el, s.cfg.BaseURL)
|
||||
el.Find("a[href]").First().Each(func(_ int, a *goquery.Selection) {
|
||||
m.Title = a.AttrOr("title", "")
|
||||
href := a.AttrOr("href", "")
|
||||
m.URL = relURL(href, s.cfg.BaseURL)
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
var popularSelectors = []string{"Popular", "Popularie", "Trending"}
|
||||
|
||||
func popularSelector() string {
|
||||
var parts []string
|
||||
for _, s := range popularSelectors {
|
||||
parts = append(parts, fmt.Sprintf("div:contains(%s) + div .group.overflow-hidden.grid", s))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
|
||||
doc, err := s.get(context.Background(), s.base())
|
||||
if err != nil {
|
||||
return source.MangasPage{}, err
|
||||
}
|
||||
var mangas []source.SManga
|
||||
doc.Find(popularSelector()).Each(func(_ int, el *goquery.Selection) {
|
||||
m := s.mangaFromElement(el)
|
||||
if m.URL != "" && m.Title != "" {
|
||||
mangas = append(mangas, m)
|
||||
}
|
||||
})
|
||||
return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil
|
||||
}
|
||||
|
||||
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
|
||||
doc, err := s.get(context.Background(), s.base()+"/latest/")
|
||||
if err != nil {
|
||||
return source.MangasPage{}, err
|
||||
}
|
||||
var mangas []source.SManga
|
||||
doc.Find("div.grid > div.group").Each(func(_ int, el *goquery.Selection) {
|
||||
m := s.mangaFromElement(el)
|
||||
if m.URL != "" && m.Title != "" {
|
||||
mangas = append(mangas, m)
|
||||
}
|
||||
})
|
||||
return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil
|
||||
}
|
||||
|
||||
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
|
||||
u := fmt.Sprintf("%s/series/?q=%s", s.base(), query)
|
||||
doc, err := s.get(context.Background(), u)
|
||||
if err != nil {
|
||||
return source.MangasPage{}, err
|
||||
}
|
||||
var mangas []source.SManga
|
||||
// Filter client-side by title
|
||||
doc.Find("#searched_series_page > button").Each(func(_ int, el *goquery.Selection) {
|
||||
title := el.AttrOr("title", "")
|
||||
if query != "" && !strings.Contains(strings.ToLower(title), strings.ToLower(query)) {
|
||||
return
|
||||
}
|
||||
m := s.mangaFromElement(el)
|
||||
if m.URL == "" {
|
||||
el.Find("a[href]").First().Each(func(_ int, a *goquery.Selection) {
|
||||
m.URL = relURL(a.AttrOr("href", ""), s.cfg.BaseURL)
|
||||
m.Title = a.AttrOr("title", strings.TrimSpace(a.Text()))
|
||||
})
|
||||
}
|
||||
if m.Title == "" {
|
||||
m.Title = title
|
||||
}
|
||||
if m.URL != "" {
|
||||
mangas = append(mangas, m)
|
||||
}
|
||||
})
|
||||
return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil
|
||||
}
|
||||
|
||||
func parseStatus(s *goquery.Selection) int {
|
||||
if s == nil || s.Length() == 0 {
|
||||
return source.StatusUnknown
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(s.Text())) {
|
||||
case "ongoing":
|
||||
return source.StatusOngoing
|
||||
case "dropped":
|
||||
return source.StatusCancelled
|
||||
case "paused":
|
||||
return source.StatusHiatus
|
||||
case "completed":
|
||||
return source.StatusCompleted
|
||||
default:
|
||||
return source.StatusUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
|
||||
doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, manga.URL))
|
||||
if err != nil {
|
||||
return manga, err
|
||||
}
|
||||
result := source.SManga{URL: manga.URL}
|
||||
|
||||
// Thumbnail from div[class*=photoURL] background-image style
|
||||
result.ThumbnailURL = getImageURL(doc.Find("div[class*=photoURL]").First(), s.cfg.BaseURL)
|
||||
result.Description = strings.TrimSpace(doc.Find("div:containsOwn(Synopsis) ~ div").First().Text())
|
||||
result.Status = parseStatus(doc.Find("div:has(span:containsOwn(Status)) ~ div").First())
|
||||
result.Author = strings.TrimSpace(doc.Find("div:has(span:containsOwn(Author)) ~ div").First().Text())
|
||||
result.Artist = strings.TrimSpace(doc.Find("div:has(span:containsOwn(Artist)) ~ div").First().Text())
|
||||
|
||||
// Title from h1 inside the series header
|
||||
result.Title = strings.TrimSpace(doc.Find("h1").First().Text())
|
||||
if result.Title == "" {
|
||||
result.Title = manga.Title
|
||||
}
|
||||
|
||||
// Genres from grid links
|
||||
var genres []string
|
||||
doc.Find("div.grid:has(>h1) > div > a:not([title='Status'])").Each(func(_ int, a *goquery.Selection) {
|
||||
if t := strings.TrimSpace(a.Text()); t != "" {
|
||||
genres = append(genres, t)
|
||||
}
|
||||
})
|
||||
result.Genre = strings.Join(genres, ", ")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
|
||||
doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, manga.URL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Exclude upcoming and (optionally) paid chapters
|
||||
sel := "#chapters > a:not(:has(.text-sm span))"
|
||||
var chapters []source.SChapter
|
||||
doc.Find(sel).Each(func(_ int, el *goquery.Selection) {
|
||||
ch := source.SChapter{}
|
||||
el.Find("a[href]").First().Each(func(_ int, a *goquery.Selection) {
|
||||
href := a.AttrOr("href", "")
|
||||
ch.URL = relURL(href, s.cfg.BaseURL)
|
||||
})
|
||||
if ch.URL == "" {
|
||||
href := el.AttrOr("href", "")
|
||||
ch.URL = relURL(href, s.cfg.BaseURL)
|
||||
}
|
||||
ch.Name = strings.TrimSpace(el.Find(".text-sm").Text())
|
||||
if dateEl := el.Find(".text-xs").First(); dateEl.Length() > 0 {
|
||||
ch.DateUpload = util.ParseRelativeDate(strings.TrimSpace(dateEl.Text()))
|
||||
if ch.DateUpload == 0 {
|
||||
ch.DateUpload = util.ParseAbsoluteDate(strings.TrimSpace(dateEl.Text()), "Jan 2, 2006")
|
||||
}
|
||||
}
|
||||
if ch.URL != "" {
|
||||
chapters = append(chapters, ch)
|
||||
}
|
||||
})
|
||||
return chapters, nil
|
||||
}
|
||||
|
||||
var (
|
||||
cdnHostRe = regexp.MustCompile("realUrl\\s*=\\s*`[^`]+//([^/`]+)")
|
||||
cdnCleanRe = regexp.MustCompile(`\$\{[^}]*\}`)
|
||||
)
|
||||
|
||||
func getCdnURL(doc *goquery.Document) string {
|
||||
var cdnURL string
|
||||
doc.Find("script").Each(func(_ int, el *goquery.Selection) {
|
||||
if cdnURL != "" {
|
||||
return
|
||||
}
|
||||
html, _ := el.Html()
|
||||
if m := cdnHostRe.FindStringSubmatch(html); len(m) > 1 {
|
||||
host := cdnCleanRe.ReplaceAllString(m[1], "")
|
||||
cdnURL = "https://" + host + "/uploads"
|
||||
}
|
||||
})
|
||||
return cdnURL
|
||||
}
|
||||
|
||||
var oldCdnRe = regexp.MustCompile(`^(https?:)?//cdn\d*\.keyoapp\.com`)
|
||||
|
||||
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
|
||||
doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, chapter.URL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cdnURL := getCdnURL(doc)
|
||||
// Primary: #pages > img[uid] with CDN URL
|
||||
var pages []source.Page
|
||||
if cdnURL != "" {
|
||||
doc.Find("#pages > img[uid]").Each(func(i int, img *goquery.Selection) {
|
||||
uid := img.AttrOr("uid", "")
|
||||
if uid != "" {
|
||||
pages = append(pages, source.Page{Index: i, ImageURL: cdnURL + "/" + uid})
|
||||
}
|
||||
})
|
||||
}
|
||||
if len(pages) > 0 {
|
||||
return pages, nil
|
||||
}
|
||||
// Fallback: old CDN direct src
|
||||
doc.Find("#pages > img").Each(func(i int, img *goquery.Selection) {
|
||||
src := img.AttrOr("src", "")
|
||||
if src == "" {
|
||||
src = img.AttrOr("data-src", "")
|
||||
}
|
||||
if oldCdnRe.MatchString(src) {
|
||||
pages = append(pages, source.Page{Index: i, ImageURL: src})
|
||||
}
|
||||
})
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil }
|
||||
func (s *Source) GetFilterList() []source.Filter { return nil }
|
||||
Reference in New Issue
Block a user