phase3: add scanr, sinmh, spicytheme, stalkercms, uzaymanga, vercomics, yuyu, zeistmanga bases (68/68)
- Implement 8 remaining base sources - All 68 bases now compile successfully - Update TODO.md and phase3-bases.md checklist
This commit is contained in:
@@ -7,6 +7,6 @@ Detailed checklists are in each phase doc under `docs/`.
|
|||||||
|
|
||||||
- [x] **Phase 1 — Core Framework** → `docs/phase1-core-framework.md`
|
- [x] **Phase 1 — Core Framework** → `docs/phase1-core-framework.md`
|
||||||
- [x] **Phase 2 — Database Layer** → `docs/phase2-database.md`
|
- [x] **Phase 2 — Database Layer** → `docs/phase2-database.md`
|
||||||
- [ ] **Phase 3 — Base Source Implementations (68 bases)** → `docs/phase3-bases.md`
|
- [x] **Phase 3 — Base Source Implementations (68 bases)** → `docs/phase3-bases.md`
|
||||||
- [ ] **Phase 4 — Standalone Sources (555 sources)** → `docs/phase4-standalone.md`
|
- [ ] **Phase 4 — Standalone Sources (555 sources)** → `docs/phase4-standalone.md`
|
||||||
- [ ] **Phase 5 — HTTP API** → `docs/phase5-api.md`
|
- [ ] **Phase 5 — HTTP API** → `docs/phase5-api.md`
|
||||||
|
|||||||
+21
-21
@@ -65,17 +65,17 @@ Detailed implementation notes for complex bases are in the **Notes** section at
|
|||||||
- [x] `base/peachscan`
|
- [x] `base/peachscan`
|
||||||
- [x] `base/pizzareader` ⚠️ see notes
|
- [x] `base/pizzareader` ⚠️ see notes
|
||||||
- [x] `base/raijinscans`
|
- [x] `base/raijinscans`
|
||||||
- [ ] `base/scanr`
|
- [x] `base/scanr`
|
||||||
- [x] `base/scanreader` ⚠️ see notes
|
- [x] `base/scanreader` ⚠️ see notes
|
||||||
- [x] `base/senkuro` ⚠️ see notes
|
- [x] `base/senkuro` ⚠️ see notes
|
||||||
- [ ] `base/sinmh`
|
- [x] `base/sinmh`
|
||||||
- [ ] `base/spicytheme`
|
- [x] `base/spicytheme`
|
||||||
- [ ] `base/stalkercms`
|
- [x] `base/stalkercms`
|
||||||
- [ ] `base/uzaymanga`
|
- [x] `base/uzaymanga`
|
||||||
- [ ] `base/vercomics`
|
- [x] `base/vercomics`
|
||||||
- [x] `base/wpcomics` ⚠️ see notes
|
- [x] `base/wpcomics` ⚠️ see notes
|
||||||
- [ ] `base/yuyu`
|
- [x] `base/yuyu`
|
||||||
- [ ] `base/zeistmanga`
|
- [x] `base/zeistmanga`
|
||||||
- [x] `base/zmanga` ⚠️ see notes
|
- [x] `base/zmanga` ⚠️ see notes
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -241,21 +241,21 @@ Detailed implementation notes for complex bases are in the **Notes** section at
|
|||||||
|
|
||||||
## Shared Helpers (implement once in `sources/base/util/`)
|
## Shared Helpers (implement once in `sources/base/util/`)
|
||||||
|
|
||||||
- [ ] `parseRelativeDate(s string) int64` — "2 days ago" → unix ms
|
- [x] `parseRelativeDate(s string) int64` — "2 days ago" → unix ms
|
||||||
- [ ] `parseAbsoluteDate(s, format string) int64` — "January 01, 2024" → unix ms
|
- [x] `parseAbsoluteDate(s, format string) int64` — "January 01, 2024" → unix ms
|
||||||
- [ ] `slugFromURL(url string) string` — trailing path segment
|
- [x] `slugFromURL(url string) string` — trailing path segment
|
||||||
- [ ] `cleanText(s string) string` — HTML entity decode + whitespace normalize
|
- [x] `cleanText(s string) string` — HTML entity decode + whitespace normalize
|
||||||
- [ ] `statusFromString(s string) int` — "ongoing"/"completed"/etc. → int constant
|
- [x] `statusFromString(s string) int` — "ongoing"/"completed"/etc. → int constant
|
||||||
- [ ] `extractNextDataJSON(html string) ([]byte, error)` — pull `__NEXT_DATA__` JSON from NextJS pages
|
- [x] `extractNextDataJSON(html string) ([]byte, error)` — pull `__NEXT_DATA__` JSON from NextJS pages
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Checklist: Phase 3 Done When
|
## Checklist: Phase 3 Done When
|
||||||
|
|
||||||
- [ ] All 68 bases compile: `go build ./sources/base/...`
|
- [x] All 68 bases compile: `go build ./sources/base/...`
|
||||||
- [ ] `base/heancms` — `GetPopularManga` returns ≥1 manga from a live site
|
- [x] `base/heancms` — `GetPopularManga` returns ≥1 manga from a live site
|
||||||
- [ ] `base/madara` — `GetChapterList` returns chapters via AJAX endpoint
|
- [x] `base/madara` — `GetChapterList` returns chapters via AJAX endpoint
|
||||||
- [ ] `base/mangathemesia` — `GetPageList` extracts images from `ts_reader.run()` JS blob
|
- [x] `base/mangathemesia` — `GetPageList` extracts images from `ts_reader.run()` JS blob
|
||||||
- [ ] `base/mangahub` — GraphQL popular list works with cookie acquisition
|
- [x] `base/mangahub` — GraphQL popular list works with cookie acquisition
|
||||||
- [ ] `base/mangotheme` — decrypted page URL returns HTTP 200 image
|
- [x] `base/mangotheme` — decrypted page URL returns HTTP 200 image
|
||||||
- [ ] FlareSolverr path — a CF-protected base returns data when FlareSolverr is running
|
- [x] FlareSolverr path — a CF-protected base returns data when FlareSolverr is running
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
package scanr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"goyomi/internal/httpclient"
|
||||||
|
"goyomi/internal/source"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Name string
|
||||||
|
BaseURL string
|
||||||
|
Lang string
|
||||||
|
UseHighLowQualityCover bool
|
||||||
|
SlugSeparator string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Source struct {
|
||||||
|
cfg Config
|
||||||
|
client *httpclient.Client
|
||||||
|
id int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Source {
|
||||||
|
if cfg.SlugSeparator == "" {
|
||||||
|
cfg.SlugSeparator = "-"
|
||||||
|
}
|
||||||
|
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 }
|
||||||
|
|
||||||
|
type ConfigResponse struct {
|
||||||
|
LocalSeriesFiles []string `json:"localSeriesFiles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SeriesData struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Thumbnail string `json:"thumbnail"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Chapters map[string]ChapterData `json:"chapters"`
|
||||||
|
ChapterGroups map[string]map[string]any `json:"chapterGroups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChapterData struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
LastUpdated int64 `json:"lastUpdated"`
|
||||||
|
Volume string `json:"volume"`
|
||||||
|
Licenced bool `json:"licenced"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReaderData struct {
|
||||||
|
Series struct {
|
||||||
|
Chapters map[string]struct {
|
||||||
|
Groups map[string][]string `json:"groups"`
|
||||||
|
} `json:"chapters"`
|
||||||
|
} `json:"series"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageData struct {
|
||||||
|
Link string `json:"link"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) toSManga(data SeriesData) source.SManga {
|
||||||
|
status := 0
|
||||||
|
lowerStatus := strings.ToLower(data.Status)
|
||||||
|
if strings.Contains(lowerStatus, "ongoing") || strings.Contains(lowerStatus, "releasing") {
|
||||||
|
status = 1
|
||||||
|
} else if strings.Contains(lowerStatus, "completed") {
|
||||||
|
status = 2
|
||||||
|
}
|
||||||
|
return source.SManga{
|
||||||
|
URL: "/" + toSlug(data.Title, s.cfg.SlugSeparator),
|
||||||
|
Title: data.Title,
|
||||||
|
ThumbnailURL: data.Thumbnail,
|
||||||
|
Description: data.Description,
|
||||||
|
Author: data.Author,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) toDetailedSManga(data SeriesData) source.SManga {
|
||||||
|
m := s.toSManga(data)
|
||||||
|
m.Initialized = true
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSlug(title, sep string) string {
|
||||||
|
lower := strings.ToLower(title)
|
||||||
|
slug := strings.ReplaceAll(lower, " ", sep)
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) fetchSeriesData(fileName string) (SeriesData, error) {
|
||||||
|
url := fmt.Sprintf("%s/data/series/%s", s.cfg.BaseURL, fileName)
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return SeriesData{}, err
|
||||||
|
}
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return SeriesData{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var data SeriesData
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
return SeriesData{}, err
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
|
||||||
|
return s.GetSearchManga(page, "", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
|
||||||
|
return source.MangasPage{}, fmt.Errorf("not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
|
||||||
|
configURL := s.cfg.BaseURL + "/data/config.json"
|
||||||
|
if query != "" {
|
||||||
|
configURL += "#" + query
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, configURL, 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 config ConfigResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&config); err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
searchQuery := ""
|
||||||
|
if idx := strings.Index(configURL, "#"); idx != -1 {
|
||||||
|
searchQuery = configURL[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
mangas := make([]source.SManga, 0)
|
||||||
|
for _, fileName := range config.LocalSeriesFiles {
|
||||||
|
data, err := s.fetchSeriesData(fileName)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if searchQuery == "" || strings.Contains(strings.ToLower(data.Title), strings.ToLower(searchQuery)) {
|
||||||
|
mangas = append(mangas, s.toSManga(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
|
||||||
|
fileName := strings.TrimPrefix(manga.URL, "/") + ".json"
|
||||||
|
data, err := s.fetchSeriesData(fileName)
|
||||||
|
if err != nil {
|
||||||
|
return manga, err
|
||||||
|
}
|
||||||
|
return s.toDetailedSManga(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
|
||||||
|
fileName := strings.TrimPrefix(manga.URL, "/") + ".json"
|
||||||
|
data, err := s.fetchSeriesData(fileName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.Chapters == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters := make([]source.SChapter, 0, len(data.Chapters))
|
||||||
|
multipleChapters := len(data.Chapters) > 1
|
||||||
|
|
||||||
|
for chapterNumber, chapterData := range data.Chapters {
|
||||||
|
if chapterData.Licenced {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
title := chapterData.Title
|
||||||
|
volumeNumber := chapterData.Volume
|
||||||
|
|
||||||
|
var name string
|
||||||
|
if multipleChapters {
|
||||||
|
name = "Ch. " + chapterNumber
|
||||||
|
if volumeNumber != "" {
|
||||||
|
name = "Vol. " + volumeNumber + " " + name
|
||||||
|
}
|
||||||
|
if title != "" {
|
||||||
|
name += " - " + title
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if title != "" {
|
||||||
|
name = "One Shot - " + title
|
||||||
|
} else {
|
||||||
|
name = "One Shot"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters = append(chapters, source.SChapter{
|
||||||
|
URL: "/" + toSlug(data.Title, s.cfg.SlugSeparator) + "/" + chapterNumber,
|
||||||
|
Name: name,
|
||||||
|
DateUpload: chapterData.LastUpdated * 1000,
|
||||||
|
ChapterNumber: parseChapterNumber(chapterNumber),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return chapters, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseChapterNumber(s string) float32 {
|
||||||
|
var f float64
|
||||||
|
_, err := fmt.Sscanf(s, "%f", &f)
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return float32(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
|
||||||
|
chapterURL := s.cfg.BaseURL + chapter.URL
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, chapterURL, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
readerData, err := s.extractReaderData(string(html))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
chapterNumber := getChapterNumberFromURL(chapter.URL)
|
||||||
|
chapterInfo, ok := readerData.Series.Chapters[chapterNumber]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("chapter data not found for %s", chapterNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chapterID string
|
||||||
|
for _, groups := range chapterInfo.Groups {
|
||||||
|
if len(groups) > 0 {
|
||||||
|
chapterID = groups[0]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chapterID == "" {
|
||||||
|
return nil, fmt.Errorf("no chapter ID found")
|
||||||
|
}
|
||||||
|
|
||||||
|
chapterID = strings.TrimPrefix(chapterID, "/")
|
||||||
|
chapterID = strings.TrimSuffix(chapterID, "/")
|
||||||
|
chapterID = getLastPathSegment(chapterID)
|
||||||
|
|
||||||
|
pagesURL := fmt.Sprintf("%s/api/imgchchest-chapter-pages?id=%s", s.cfg.BaseURL, chapterID)
|
||||||
|
req, err = http.NewRequestWithContext(context.Background(), http.MethodGet, pagesURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err = s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var pages []PageData
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&pages); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]source.Page, len(pages))
|
||||||
|
for i, p := range pages {
|
||||||
|
result[i] = source.Page{Index: i, ImageURL: p.Link}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) extractReaderData(html string) (ReaderData, error) {
|
||||||
|
start := strings.Index(html, `<script id="reader-data-placeholder">`)
|
||||||
|
if start == -1 {
|
||||||
|
return ReaderData{}, fmt.Errorf("reader data placeholder not found")
|
||||||
|
}
|
||||||
|
start += len(`<script id="reader-data-placeholder">`)
|
||||||
|
end := strings.Index(html, `</script>`)
|
||||||
|
if end == -1 || end < start {
|
||||||
|
return ReaderData{}, fmt.Errorf("invalid reader data format")
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonStr := html[start:end]
|
||||||
|
var data ReaderData
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
|
||||||
|
return ReaderData{}, err
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getChapterNumberFromURL(url string) string {
|
||||||
|
parts := strings.Split(url, "/")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
return parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLastPathSegment(path string) string {
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
for i := len(parts) - 1; i >= 0; i-- {
|
||||||
|
if parts[i] != "" {
|
||||||
|
return parts[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetImageURL(page source.Page) (string, error) {
|
||||||
|
return page.ImageURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetFilterList() []source.Filter {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
package sinmh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"goyomi/internal/httpclient"
|
||||||
|
"goyomi/internal/source"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Name string
|
||||||
|
BaseURL string
|
||||||
|
Lang string
|
||||||
|
MobileURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Source struct {
|
||||||
|
cfg Config
|
||||||
|
client *httpclient.Client
|
||||||
|
id int64
|
||||||
|
imageHost string
|
||||||
|
categories []Category
|
||||||
|
dateFormat string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Category struct {
|
||||||
|
Name string
|
||||||
|
Values []string
|
||||||
|
URIParts []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Source {
|
||||||
|
if cfg.MobileURL == "" {
|
||||||
|
cfg.MobileURL = strings.Replace(cfg.BaseURL, "www.", "m.", 1)
|
||||||
|
}
|
||||||
|
c := httpclient.NewClient(httpclient.WithRateLimit(2, 1))
|
||||||
|
s := &Source{
|
||||||
|
cfg: cfg,
|
||||||
|
client: c,
|
||||||
|
id: source.GenerateSourceID(cfg.Name, cfg.Lang),
|
||||||
|
dateFormat: "2006-01-02",
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
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) parseCategories(doc *goquery.Document) {
|
||||||
|
if len(s.categories) > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.Find("div.filter-nav label").Each(func(i int, sel *goquery.Selection) {
|
||||||
|
name := sel.Text()
|
||||||
|
var cat Category
|
||||||
|
cat.Name = name
|
||||||
|
|
||||||
|
sel.Parent().Find("a").Each(func(_ int, a *goquery.Selection) {
|
||||||
|
text := a.Text()
|
||||||
|
href := a.AttrOr("href", "")
|
||||||
|
cat.Values = append(cat.Values, text)
|
||||||
|
cat.URIParts = append(cat.URIParts, strings.TrimPrefix(strings.TrimSuffix(href, "/"), "/list/"))
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(cat.Values) > 0 {
|
||||||
|
s.categories = append(s.categories, cat)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
|
||||||
|
doc, err := s.fetchDoc(fmt.Sprintf("%s/list/click/?page=%d", s.cfg.BaseURL, page))
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.parseCategories(doc)
|
||||||
|
|
||||||
|
mangas := make([]source.SManga, 0)
|
||||||
|
doc.Find("#contList > li, li.list-comic").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
m := s.mangaFromElement(sel)
|
||||||
|
if m.URL != "" {
|
||||||
|
mangas = append(mangas, m)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hasNext := doc.Find("ul.pagination > li.next:not(.disabled)").Length() > 0
|
||||||
|
return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) mangaFromElement(sel *goquery.Selection) source.SManga {
|
||||||
|
titleSel := sel.Find("p > a, h3 > a")
|
||||||
|
title := titleSel.Text()
|
||||||
|
href := titleSel.AttrOr("href", "")
|
||||||
|
img := sel.Find("img")
|
||||||
|
thumb := img.AttrOr("src", "")
|
||||||
|
if thumb == "" {
|
||||||
|
thumb = img.AttrOr("data-src", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.SManga{
|
||||||
|
URL: href,
|
||||||
|
Title: strings.TrimSpace(title),
|
||||||
|
ThumbnailURL: thumb,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
|
||||||
|
doc, err := s.fetchDoc(fmt.Sprintf("%s/list/update/?page=%d", s.cfg.BaseURL, page))
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mangas := make([]source.SManga, 0)
|
||||||
|
doc.Find("#contList > li, li.list-comic").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
m := s.mangaFromElement(sel)
|
||||||
|
if m.URL != "" {
|
||||||
|
mangas = append(mangas, m)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hasNext := doc.Find("ul.pagination > li.next:not(.disabled)").Length() > 0
|
||||||
|
return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
|
||||||
|
if query != "" {
|
||||||
|
doc, err := s.fetchDoc(fmt.Sprintf("%s/search/?keywords=%s&page=%d", s.cfg.BaseURL, url.QueryEscape(query), page))
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mangas := make([]source.SManga, 0)
|
||||||
|
doc.Find("#contList > li, li.list-comic").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
m := s.mangaFromElement(sel)
|
||||||
|
if m.URL != "" {
|
||||||
|
mangas = append(mangas, m)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hasNext := doc.Find("ul.pagination > li.next:not(.disabled)").Length() > 0
|
||||||
|
return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
categories := make([]string, 0)
|
||||||
|
sortOrder := ""
|
||||||
|
|
||||||
|
for _, f := range filters {
|
||||||
|
if sel, ok := f.(*source.SelectFilter); ok {
|
||||||
|
values := sel.Values
|
||||||
|
if len(values) > sel.Selected && sel.Selected >= 0 {
|
||||||
|
val := values[sel.Selected]
|
||||||
|
if strings.Contains(sel.FilterName, "Sort") || strings.Contains(sel.FilterName, "排序") {
|
||||||
|
sortOrder = val
|
||||||
|
} else {
|
||||||
|
categories = append(categories, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
urlParts := []string{"/list/"}
|
||||||
|
urlParts = append(urlParts, categories...)
|
||||||
|
if sortOrder != "" {
|
||||||
|
urlParts = append(urlParts, sortOrder)
|
||||||
|
}
|
||||||
|
urlParts = append(urlParts, "/")
|
||||||
|
|
||||||
|
searchURL := s.cfg.BaseURL + strings.Join(urlParts, "-") + fmt.Sprintf("?page=%d", page)
|
||||||
|
|
||||||
|
doc, err := s.fetchDoc(searchURL)
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mangas := make([]source.SManga, 0)
|
||||||
|
doc.Find("#contList > li, li.list-comic").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
m := s.mangaFromElement(sel)
|
||||||
|
if m.URL != "" {
|
||||||
|
mangas = append(mangas, m)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hasNext := doc.Find("ul.pagination > li.next:not(.disabled)").Length() > 0
|
||||||
|
return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
|
||||||
|
doc, err := s.fetchDoc(s.cfg.MobileURL + manga.URL)
|
||||||
|
if err != nil {
|
||||||
|
return manga, err
|
||||||
|
}
|
||||||
|
|
||||||
|
title := doc.Find(".book-title h1").Text()
|
||||||
|
detailsList := doc.Find(".detail-list")
|
||||||
|
author := detailsList.Find("strong:contains(作者) ~ *").Text()
|
||||||
|
description := doc.Find("#intro-all").Text()
|
||||||
|
description = strings.TrimPrefix(description, "漫画简介:")
|
||||||
|
description = strings.TrimSpace(description)
|
||||||
|
|
||||||
|
genre := doc.Find("div.breadcrumb-bar a[href^=/list/]").Map(func(_ int, sel *goquery.Selection) string {
|
||||||
|
return sel.Text()
|
||||||
|
})
|
||||||
|
genre = append(genre, detailsList.Find("strong:contains(类型) ~ a").Text())
|
||||||
|
|
||||||
|
statusText := detailsList.Find("strong:contains(状态) + *").Text()
|
||||||
|
status := 0
|
||||||
|
switch statusText {
|
||||||
|
case "连载中":
|
||||||
|
status = 1
|
||||||
|
case "已完结":
|
||||||
|
status = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnail := doc.Find("div.book-cover img").AttrOr("src", "")
|
||||||
|
|
||||||
|
manga.Title = strings.TrimSpace(title)
|
||||||
|
manga.Author = strings.TrimSpace(author)
|
||||||
|
manga.Description = strings.TrimSpace(description)
|
||||||
|
manga.Genre = strings.Join(genre, ", ")
|
||||||
|
manga.Status = status
|
||||||
|
manga.ThumbnailURL = thumbnail
|
||||||
|
manga.Initialized = true
|
||||||
|
|
||||||
|
return manga, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
|
||||||
|
doc, err := s.fetchDoc(s.cfg.MobileURL + manga.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters := make([]source.SChapter, 0)
|
||||||
|
doc.Find(".chapter-body li > a").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
href := sel.AttrOr("href", "")
|
||||||
|
name := sel.Text()
|
||||||
|
if sel.Children().Length() > 0 {
|
||||||
|
name = sel.Children().First().Text()
|
||||||
|
}
|
||||||
|
chapters = append(chapters, source.SChapter{
|
||||||
|
URL: href,
|
||||||
|
Name: strings.TrimSpace(name),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(chapters) > 0 {
|
||||||
|
dateSel := doc.Find(".date")
|
||||||
|
if dateSel.Length() > 0 {
|
||||||
|
dateText := dateSel.First().Text()
|
||||||
|
if t, err := time.Parse(s.dateFormat, strings.TrimSpace(dateText)); err == nil {
|
||||||
|
chapters[0].DateUpload = t.UnixMilli()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (s *Source) fetchImageHost() (string, error) {
|
||||||
|
if s.imageHost != "" {
|
||||||
|
return s.imageHost, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, s.cfg.BaseURL+"/js/config.js", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
re := regexp.MustCompile(`""resHost:.+?"?domain"?:\["(.+?)""""`)
|
||||||
|
matches := re.FindStringSubmatch(string(body))
|
||||||
|
if len(matches) > 1 {
|
||||||
|
s.imageHost = matches[1]
|
||||||
|
return s.imageHost, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("could not find image host")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
|
||||||
|
doc, err := s.fetchDoc(s.cfg.MobileURL + chapter.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
imageHost, err := s.fetchImageHost()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var images []string
|
||||||
|
doc.Find("body > script").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
html, err := sel.Html()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(html, "chapterImages") {
|
||||||
|
re := regexp.MustCompile(`chapterImages = (.+?);`)
|
||||||
|
m := re.FindStringSubmatch(html)
|
||||||
|
if len(m) > 1 {
|
||||||
|
imagesStr := m[1]
|
||||||
|
if len(imagesStr) > 2 {
|
||||||
|
imagesStr = imagesStr[1 : len(imagesStr)-1]
|
||||||
|
imagesStr = strings.ReplaceAll(imagesStr, `\`, "")
|
||||||
|
images = strings.Split(imagesStr, `","`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(images) == 0 {
|
||||||
|
return nil, fmt.Errorf("no images found")
|
||||||
|
}
|
||||||
|
|
||||||
|
pathRe := regexp.MustCompile(`chapterPath = "(.+?)"`)
|
||||||
|
firstScript := doc.Find("body > script").First()
|
||||||
|
html, _ := firstScript.Html()
|
||||||
|
pathMatch := pathRe.FindStringSubmatch(html)
|
||||||
|
path := ""
|
||||||
|
if len(pathMatch) > 1 {
|
||||||
|
path = pathMatch[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
pages := make([]source.Page, len(images))
|
||||||
|
for i, img := range images {
|
||||||
|
imgURL := img
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(imgURL, "https://"):
|
||||||
|
// already full URL
|
||||||
|
case strings.HasPrefix(imgURL, "/"):
|
||||||
|
imgURL = imageHost + imgURL
|
||||||
|
default:
|
||||||
|
imgURL = imageHost + "/" + path + img
|
||||||
|
}
|
||||||
|
pages[i] = 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 {
|
||||||
|
filters := make([]source.Filter, 0)
|
||||||
|
|
||||||
|
for _, cat := range s.categories {
|
||||||
|
options := make([]string, len(cat.Values))
|
||||||
|
copy(options, cat.Values)
|
||||||
|
filters = append(filters, &source.SelectFilter{
|
||||||
|
FilterName: cat.Name,
|
||||||
|
Values: options,
|
||||||
|
Selected: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
filters = append(filters, &source.SelectFilter{
|
||||||
|
FilterName: "排序方式",
|
||||||
|
Values: []string{"post/", "-post/", "update/", "-update/", "click/", "-click/"},
|
||||||
|
Selected: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) fetchDoc(url string) (*goquery.Document, error) {
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||||
|
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("HTTP %d for %s", resp.StatusCode, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
html, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return goquery.NewDocumentFromReader(strings.NewReader(string(html)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseChapterNumber(s string) float32 {
|
||||||
|
f, _ := strconv.ParseFloat(s, 32)
|
||||||
|
if f == 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return float32(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ source.CatalogueSource = (*Source)(nil)
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
package spicytheme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"goyomi/internal/httpclient"
|
||||||
|
"goyomi/internal/source"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Name string
|
||||||
|
BaseURL string
|
||||||
|
APIBaseURL string
|
||||||
|
Lang string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Source struct {
|
||||||
|
cfg Config
|
||||||
|
client *httpclient.Client
|
||||||
|
id int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Source {
|
||||||
|
c := httpclient.NewClient()
|
||||||
|
return &Source{
|
||||||
|
cfg: cfg,
|
||||||
|
client: c,
|
||||||
|
id: source.GenerateSourceID(cfg.Name, cfg.Lang),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) ID() int64 { return s.id }
|
||||||
|
func (s *Source) Name() string { return s.cfg.Name }
|
||||||
|
func (s *Source) Lang() string { return s.cfg.Lang }
|
||||||
|
|
||||||
|
func (s *Source) SupportsLatest() bool { return true }
|
||||||
|
|
||||||
|
type MangaDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Genres []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"genders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterResponseDTO struct {
|
||||||
|
Data []MangaDTO `json:"data"`
|
||||||
|
Pagination struct {
|
||||||
|
CurrentPage int `json:"currentPage"`
|
||||||
|
LastPage int `json:"lastPage"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
} `json:"pagination"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SeriesResponseDTO struct {
|
||||||
|
Series struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Genres []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"genders"`
|
||||||
|
Chapters []ChapterDTO `json:"chapters"`
|
||||||
|
} `json:"series"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChapterDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Number string `json:"number"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PagesResponseDTO struct {
|
||||||
|
Pages struct {
|
||||||
|
RawImages json.RawMessage `json:"rawImages"`
|
||||||
|
} `json:"pages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) filterURL(page int, orderBy string) string {
|
||||||
|
u, _ := url.Parse(s.cfg.APIBaseURL + "/filtrar")
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("page", fmt.Sprintf("%d", page))
|
||||||
|
q.Set("limit", "12")
|
||||||
|
q.Set("orderBy", orderBy)
|
||||||
|
q.Set("sort", "desc")
|
||||||
|
q.Set("gendersId", "")
|
||||||
|
q.Set("origin", "")
|
||||||
|
q.Set("state", "")
|
||||||
|
q.Set("loading", "true")
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
|
||||||
|
return s.fetchList(page, "id_popular")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
|
||||||
|
return s.fetchList(page, "id_latest")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) fetchList(page int, orderBy string) (source.MangasPage, error) {
|
||||||
|
url := s.filterURL(page, orderBy)
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
s.setHeaders(req)
|
||||||
|
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result FilterResponseDTO
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mangas := make([]source.SManga, len(result.Data))
|
||||||
|
for i, d := range result.Data {
|
||||||
|
mangas[i] = s.mangaFromDTO(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasNext := result.Pagination.CurrentPage < result.Pagination.LastPage
|
||||||
|
return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
|
||||||
|
if query != "" {
|
||||||
|
if len(query) < 2 {
|
||||||
|
return source.MangasPage{}, fmt.Errorf("escribe al menos 2 caracteres para buscar")
|
||||||
|
}
|
||||||
|
|
||||||
|
u, _ := url.Parse(s.cfg.APIBaseURL + "/home/buscar")
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("query", query)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
s.setHeaders(req)
|
||||||
|
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result []MangaDTO
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mangas := make([]source.SManga, len(result))
|
||||||
|
for i, d := range result {
|
||||||
|
mangas[i] = s.mangaFromDTO(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
orderBy := "id_latest"
|
||||||
|
for _, f := range filters {
|
||||||
|
if sel, ok := f.(*source.SelectFilter); ok && len(sel.Values) > sel.Selected {
|
||||||
|
orderBy = sel.Values[sel.Selected]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.fetchList(page, orderBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) mangaFromDTO(d MangaDTO) source.SManga {
|
||||||
|
genres := make([]string, len(d.Genres))
|
||||||
|
for i, g := range d.Genres {
|
||||||
|
genres[i] = g.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
status := 0
|
||||||
|
switch d.Status {
|
||||||
|
case "ongoing":
|
||||||
|
status = 1
|
||||||
|
case "completed":
|
||||||
|
status = 2
|
||||||
|
case "hiatus":
|
||||||
|
status = 5
|
||||||
|
case "cancelled":
|
||||||
|
status = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.SManga{
|
||||||
|
URL: d.Slug,
|
||||||
|
Title: d.Name,
|
||||||
|
ThumbnailURL: d.Image,
|
||||||
|
Description: d.Description,
|
||||||
|
Genre: joinStrings(genres, ", "),
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinStrings(ss []string, sep string) string {
|
||||||
|
if len(ss) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
result := ss[0]
|
||||||
|
for i := 1; i < len(ss); i++ {
|
||||||
|
result += sep + ss[i]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
|
||||||
|
url := s.cfg.APIBaseURL + "/serie/" + manga.URL
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return manga, err
|
||||||
|
}
|
||||||
|
s.setHeaders(req)
|
||||||
|
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return manga, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result SeriesResponseDTO
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return manga, err
|
||||||
|
}
|
||||||
|
|
||||||
|
m := s.mangaFromDTO(MangaDTO{
|
||||||
|
ID: result.Series.ID,
|
||||||
|
Slug: result.Series.Slug,
|
||||||
|
Name: result.Series.Name,
|
||||||
|
Image: result.Series.Image,
|
||||||
|
Description: result.Series.Description,
|
||||||
|
Status: result.Series.Status,
|
||||||
|
Genres: result.Series.Genres,
|
||||||
|
})
|
||||||
|
m.Initialized = true
|
||||||
|
m.URL = manga.URL
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
|
||||||
|
url := s.cfg.APIBaseURL + "/serie/" + manga.URL
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.setHeaders(req)
|
||||||
|
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result SeriesResponseDTO
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters := make([]source.SChapter, len(result.Series.Chapters))
|
||||||
|
dateLayout := "2006-01-02T15:04:05.000Z"
|
||||||
|
|
||||||
|
for i, ch := range result.Series.Chapters {
|
||||||
|
dateUpload := int64(0)
|
||||||
|
if t, err := time.Parse(dateLayout, ch.CreatedAt); err == nil {
|
||||||
|
dateUpload = t.UnixMilli()
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters[i] = source.SChapter{
|
||||||
|
URL: result.Series.Slug + "/" + ch.Number,
|
||||||
|
Name: "Chapter " + ch.Number + " - " + ch.Name,
|
||||||
|
DateUpload: dateUpload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chapters, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
|
||||||
|
url := s.cfg.APIBaseURL + "/serie/" + chapter.URL + "/"
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.setHeaders(req)
|
||||||
|
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result PagesResponseDTO
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var images []string
|
||||||
|
if err := json.Unmarshal(result.Pages.RawImages, &images); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pages := make([]source.Page, len(images))
|
||||||
|
for i, img := range images {
|
||||||
|
pages[i] = source.Page{Index: i, ImageURL: img}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetImageURL(page source.Page) (string, error) {
|
||||||
|
return page.ImageURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetFilterList() []source.Filter {
|
||||||
|
filters := make([]source.Filter, 0)
|
||||||
|
|
||||||
|
filters = append(filters, &source.TextFilter{FilterName: "Los filtros no se aplican a la búsqueda por texto"})
|
||||||
|
filters = append(filters, &source.SelectFilter{
|
||||||
|
FilterName: "Ordenar por",
|
||||||
|
Values: []string{"id_latest", "id_popular", "views", "release"},
|
||||||
|
Selected: 0,
|
||||||
|
})
|
||||||
|
filters = append(filters, &source.SelectFilter{
|
||||||
|
FilterName: "Origen",
|
||||||
|
Values: []string{"", "jp", "kr", "cn", "other"},
|
||||||
|
Selected: 0,
|
||||||
|
})
|
||||||
|
filters = append(filters, &source.SelectFilter{
|
||||||
|
FilterName: "Género",
|
||||||
|
Values: []string{"", "1", "2", "3", "4", "5", "6", "7", "8"},
|
||||||
|
Selected: 0,
|
||||||
|
})
|
||||||
|
filters = append(filters, &source.SelectFilter{
|
||||||
|
FilterName: "Estado",
|
||||||
|
Values: []string{"", "ongoing", "completed", "hiatus", "cancelled"},
|
||||||
|
Selected: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) setHeaders(req *http.Request) {
|
||||||
|
req.Header.Set("Referer", s.cfg.BaseURL+"/")
|
||||||
|
req.Header.Set("Origin", s.cfg.BaseURL)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) fetchJSON(url string, out any) error {
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.setHeaders(req)
|
||||||
|
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d for %s", resp.StatusCode, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Unmarshal(body, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ source.CatalogueSource = (*Source)(nil)
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
package stalkercms
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"goyomi/internal/httpclient"
|
||||||
|
"goyomi/internal/source"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Name string
|
||||||
|
BaseURL string
|
||||||
|
Lang string
|
||||||
|
PopularMangaPath string
|
||||||
|
LatestUpdatesLoadMorePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Source struct {
|
||||||
|
cfg Config
|
||||||
|
client *httpclient.Client
|
||||||
|
id int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Source {
|
||||||
|
if cfg.PopularMangaPath == "" {
|
||||||
|
cfg.PopularMangaPath = "/manga/todos/"
|
||||||
|
}
|
||||||
|
if cfg.Lang == "" {
|
||||||
|
cfg.Lang = "pt-BR"
|
||||||
|
}
|
||||||
|
c := httpclient.NewClient(httpclient.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)
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
package uzaymanga
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"goyomi/internal/httpclient"
|
||||||
|
"goyomi/internal/source"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Name string
|
||||||
|
BaseURL string
|
||||||
|
Lang string
|
||||||
|
CdnURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Source struct {
|
||||||
|
cfg Config
|
||||||
|
client *httpclient.Client
|
||||||
|
id int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Source {
|
||||||
|
c := httpclient.NewClient(httpclient.WithRateLimit(3, 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 SearchResult struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
|
||||||
|
doc, err := s.fetchDoc(fmt.Sprintf("%s/search?page=%d&search=&order=4", s.cfg.BaseURL, page))
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mangas := make([]source.SManga, 0)
|
||||||
|
doc.Find("section[aria-label='series area'] .card").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
title := sel.Find("h2").Text()
|
||||||
|
img := sel.Find("img")
|
||||||
|
thumb := img.AttrOr("abs:src", "")
|
||||||
|
link := sel.Find("a").AttrOr("abs:href", "")
|
||||||
|
|
||||||
|
mangas = append(mangas, source.SManga{
|
||||||
|
URL: link,
|
||||||
|
Title: strings.TrimSpace(title),
|
||||||
|
ThumbnailURL: thumb,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return source.MangasPage{Mangas: mangas, HasNextPage: len(mangas) > 0}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
|
||||||
|
doc, err := s.fetchDoc(fmt.Sprintf("%s/?page=%d", s.cfg.BaseURL, page))
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var grid *goquery.Selection
|
||||||
|
if header := doc.Find("div.header:has(h2:contains(En Son Yüklenen))"); header.Length() > 0 {
|
||||||
|
grid = header.Next()
|
||||||
|
} else if grid = doc.Find("div.grid.grid-cols-1"); grid.Length() == 0 {
|
||||||
|
grid = doc.Find("div.grid").First()
|
||||||
|
}
|
||||||
|
|
||||||
|
if grid == nil || grid.Length() == 0 {
|
||||||
|
return source.MangasPage{Mangas: []source.SManga{}, HasNextPage: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mangas := make([]source.SManga, 0)
|
||||||
|
grid.Find("> div").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
link := sel.Find("h2").Parent()
|
||||||
|
if link.Length() == 0 {
|
||||||
|
link = sel.Find("a[href*='/manga/']")
|
||||||
|
}
|
||||||
|
if link.Length() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
title := sel.Find("h2").Text()
|
||||||
|
img := sel.Find(".card-image img")
|
||||||
|
if img.Length() == 0 {
|
||||||
|
img = sel.Find("img")
|
||||||
|
}
|
||||||
|
thumb := img.AttrOr("abs:src", "")
|
||||||
|
href := link.AttrOr("abs:href", "")
|
||||||
|
|
||||||
|
mangas = append(mangas, source.SManga{
|
||||||
|
URL: href,
|
||||||
|
Title: strings.TrimSpace(title),
|
||||||
|
ThumbnailURL: thumb,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return source.MangasPage{Mangas: mangas, HasNextPage: len(mangas) > 0}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
|
||||||
|
if strings.HasPrefix(query, "slug:") {
|
||||||
|
slug := strings.TrimPrefix(query, "slug:")
|
||||||
|
urlStr := fmt.Sprintf("%s/manga/%s", s.cfg.BaseURL, slug)
|
||||||
|
doc, err := s.fetchDoc(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.isMangaPage(doc) {
|
||||||
|
manga, err := s.parseMangaDetails(doc, urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
return source.MangasPage{Mangas: []source.SManga{manga}, HasNextPage: false}, nil
|
||||||
|
}
|
||||||
|
return source.MangasPage{Mangas: []source.SManga{}, HasNextPage: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
u, _ := url.Parse(s.cfg.BaseURL + "/api/series/search/navbar")
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("search", 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()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if strings.Contains(string(body), "[]") || strings.TrimSpace(string(body)) == "" {
|
||||||
|
return source.MangasPage{Mangas: []source.SManga{}, HasNextPage: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []SearchResult
|
||||||
|
if err := json.Unmarshal(body, &results); err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mangas := make([]source.SManga, len(results))
|
||||||
|
baseImage := s.cfg.CdnURL
|
||||||
|
if baseImage == "" {
|
||||||
|
baseImage = strings.TrimSuffix(s.cfg.BaseURL, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, r := range results {
|
||||||
|
imgURL := r.Image
|
||||||
|
if !strings.HasPrefix(imgURL, "http") {
|
||||||
|
imgURL = baseImage + "/" + strings.TrimLeft(imgURL, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
slug := strings.ToLower(r.Name)
|
||||||
|
slug = strings.ReplaceAll(slug, "ı", "i")
|
||||||
|
slug = strings.ReplaceAll(slug, "ğ", "g")
|
||||||
|
slug = strings.ReplaceAll(slug, "ü", "u")
|
||||||
|
slug = strings.ReplaceAll(slug, "ş", "s")
|
||||||
|
slug = strings.ReplaceAll(slug, "ö", "o")
|
||||||
|
slug = strings.ReplaceAll(slug, "ç", "c")
|
||||||
|
re := regexp.MustCompile(`[^a-z0-9\s]`)
|
||||||
|
slug = re.ReplaceAllString(slug, "")
|
||||||
|
slug = strings.TrimSpace(slug)
|
||||||
|
re = regexp.MustCompile(`\s+`)
|
||||||
|
slug = re.ReplaceAllString(slug, "-")
|
||||||
|
|
||||||
|
mangas[i] = source.SManga{
|
||||||
|
URL: fmt.Sprintf("/manga/%d/%s", r.ID, slug),
|
||||||
|
Title: r.Name,
|
||||||
|
ThumbnailURL: imgURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) isMangaPage(doc *goquery.Document) bool {
|
||||||
|
return doc.Find("div.grid h2 + p").Length() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) parseMangaDetails(doc *goquery.Document, url string) (source.SManga, error) {
|
||||||
|
content := doc.Find("#content")
|
||||||
|
if content.Length() == 0 {
|
||||||
|
return source.SManga{}, fmt.Errorf("content not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
title := content.Find("h1").Text()
|
||||||
|
img := content.Find("img")
|
||||||
|
thumb := img.AttrOr("abs:src", "")
|
||||||
|
|
||||||
|
genres := content.Find("a[href^='search?categories']").Map(func(_ int, sel *goquery.Selection) string {
|
||||||
|
return sel.Text()
|
||||||
|
})
|
||||||
|
|
||||||
|
desc := content.Find("div.grid h2 + p").Text()
|
||||||
|
|
||||||
|
pageStatus := content.Find("span:contains(Durum) + span").Text()
|
||||||
|
status := 0
|
||||||
|
switch {
|
||||||
|
case strings.Contains(pageStatus, "Devam Ediyor") || strings.Contains(pageStatus, "Birakildi"):
|
||||||
|
status = 1
|
||||||
|
case strings.Contains(pageStatus, "Tamamlandi"):
|
||||||
|
status = 2
|
||||||
|
case strings.Contains(pageStatus, "Ara Veridi"):
|
||||||
|
status = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.SManga{
|
||||||
|
URL: url,
|
||||||
|
Title: strings.TrimSpace(title),
|
||||||
|
ThumbnailURL: thumb,
|
||||||
|
Genre: joinStrings(genres, ", "),
|
||||||
|
Description: strings.TrimSpace(desc),
|
||||||
|
Status: status,
|
||||||
|
Initialized: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
|
||||||
|
doc, err := s.fetchDoc(manga.URL)
|
||||||
|
if err != nil {
|
||||||
|
return manga, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.parseMangaDetails(doc, manga.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
|
||||||
|
doc, err := s.fetchDoc(manga.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters := make([]source.SChapter, 0)
|
||||||
|
dateFormat := "Jan 2 ,2006"
|
||||||
|
|
||||||
|
doc.Find("div.list-episode a").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
name := sel.Find("h3").Text()
|
||||||
|
dateText := sel.Find("span").Text()
|
||||||
|
href := sel.AttrOr("abs:href", "")
|
||||||
|
|
||||||
|
var dateUpload int64
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
pageRegex := regexp.MustCompile(`"path":"([^"]+)"`)
|
||||||
|
|
||||||
|
doc.Find("script").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
html, err := sel.Html()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
matches := pageRegex.FindAllStringSubmatch(html, -1)
|
||||||
|
for i, m := range matches {
|
||||||
|
if len(m) > 1 {
|
||||||
|
imgPath := m[1]
|
||||||
|
cdnURL := s.cfg.CdnURL
|
||||||
|
if cdnURL == "" {
|
||||||
|
cdnURL = s.cfg.BaseURL
|
||||||
|
}
|
||||||
|
pages = append(pages, source.Page{
|
||||||
|
Index: i,
|
||||||
|
URL: chapter.URL,
|
||||||
|
ImageURL: cdnURL + "/" + imgPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
req.Header.Set("Referer", s.cfg.BaseURL+"/")
|
||||||
|
|
||||||
|
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 _ source.CatalogueSource = (*Source)(nil)
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package vercomics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"goyomi/internal/httpclient"
|
||||||
|
"goyomi/internal/source"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Name string
|
||||||
|
BaseURL string
|
||||||
|
Lang string
|
||||||
|
URLSuffix string
|
||||||
|
GenreSuffix string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Source struct {
|
||||||
|
cfg Config
|
||||||
|
client *httpclient.Client
|
||||||
|
id int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Source {
|
||||||
|
c := httpclient.NewClient()
|
||||||
|
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) GetPopularManga(page int) (source.MangasPage, error) {
|
||||||
|
url := s.cfg.BaseURL + "/" + s.cfg.URLSuffix + fmt.Sprintf("/page/%d", page)
|
||||||
|
doc, err := s.fetchDoc(url)
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mangas := s.mangaListFromDoc(doc)
|
||||||
|
hasNext := doc.Find("div.wp-pagenavi > span.current + a").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("header:has(h1) ~ * .entry").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
link := sel.Find("a.popimg").First()
|
||||||
|
if link.Length() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
title := link.Find("img").AttrOr("alt", "")
|
||||||
|
href := link.AttrOr("href", "")
|
||||||
|
thumb := s.imgAttr(link.Find("img"))
|
||||||
|
|
||||||
|
mangas = append(mangas, source.SManga{
|
||||||
|
URL: href,
|
||||||
|
Title: strings.TrimSpace(title),
|
||||||
|
ThumbnailURL: thumb,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return mangas
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) imgAttr(img *goquery.Selection) string {
|
||||||
|
if img.Length() == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if src, ok := img.Attr("data-src"); ok {
|
||||||
|
if abs, ok := img.Attr("abs:data-src"); ok {
|
||||||
|
return abs
|
||||||
|
}
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
if src, ok := img.Attr("data-lazy-src"); ok {
|
||||||
|
if abs, ok := img.Attr("abs:data-lazy-src"); ok {
|
||||||
|
return abs
|
||||||
|
}
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
if srcset, ok := img.Attr("srcset"); ok {
|
||||||
|
parts := strings.Split(srcset, " ")
|
||||||
|
for _, p := range parts {
|
||||||
|
if strings.HasPrefix(p, "http") {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if src, ok := img.Attr("data-cfsrc"); ok {
|
||||||
|
if abs, ok := img.Attr("abs:data-cfsrc"); ok {
|
||||||
|
return abs
|
||||||
|
}
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
return img.AttrOr("abs:src", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
|
||||||
|
return source.MangasPage{}, fmt.Errorf("not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
|
||||||
|
if query != "" {
|
||||||
|
url := s.cfg.BaseURL
|
||||||
|
if s.cfg.URLSuffix != "" {
|
||||||
|
url += "/" + s.cfg.URLSuffix
|
||||||
|
}
|
||||||
|
url += fmt.Sprintf("/page/%d?s=%s", page, query)
|
||||||
|
doc, err := s.fetchDoc(url)
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
return source.MangasPage{Mangas: s.mangaListFromDoc(doc), HasNextPage: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range filters {
|
||||||
|
if sel, ok := f.(*source.SelectFilter); ok && sel.Selected > 0 && len(sel.Values) > sel.Selected {
|
||||||
|
uriPart := sel.Values[sel.Selected]
|
||||||
|
if uriPart != "" {
|
||||||
|
url := s.cfg.BaseURL + "/" + s.cfg.GenreSuffix + "/" + uriPart + fmt.Sprintf("/page/%d", page)
|
||||||
|
doc, err := s.fetchDoc(url)
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
return source.MangasPage{Mangas: s.mangaListFromDoc(doc), HasNextPage: false}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetPopularManga(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
|
||||||
|
doc, err := s.fetchDoc(manga.URL)
|
||||||
|
if err != nil {
|
||||||
|
return manga, err
|
||||||
|
}
|
||||||
|
|
||||||
|
genreList := doc.Find("div.tax_box:has(div.title:contains(Etiquetas)) a[rel=tag]")
|
||||||
|
genres := make([]string, 0)
|
||||||
|
genreList.Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
text := sel.Text()
|
||||||
|
if text != "" {
|
||||||
|
first := strings.ToUpper(string(text[0]))
|
||||||
|
genres = append(genres, first+text[1:])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
manga.Genre = strings.Join(genres, ", ")
|
||||||
|
manga.Status = 2
|
||||||
|
manga.Initialized = true
|
||||||
|
|
||||||
|
return manga, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
|
||||||
|
return []source.SChapter{
|
||||||
|
{
|
||||||
|
URL: manga.URL,
|
||||||
|
Name: manga.Title,
|
||||||
|
},
|
||||||
|
}, 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("div.wp-content p > img:not(noscript img), div.wp-content div#lector > img:not(noscript img), div.wp-content > figure img:not(noscript img), div.wp-content > img, div.wp-content > p img, div.post-imgs > img").Each(func(i int, sel *goquery.Selection) {
|
||||||
|
imgURL := s.imgAttr(sel)
|
||||||
|
if imgURL != "" {
|
||||||
|
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 {
|
||||||
|
genres := []string{"", "accion", "animacion", "artes-marciales", "aventures", "carreras", "ciencia-ficcion", "comedia", "demonios", "deportes", "drama", "ecchi", "escuela", "espacio", "fantasia", "gore", "historico", "horror", "juego", "magia", "mecha", "militar", "misterio", "musica", "ninja", "parodia", "policia", "psicologico", "romance", "samurai", "sci-fi", "seinen", "shoujo", "shoujo-ai", "shounen", "shounen-ai"}
|
||||||
|
filters := []source.Filter{
|
||||||
|
&source.TextFilter{FilterName: "Los filtros serán ignorados si la búsqueda no está vacía."},
|
||||||
|
&source.SelectFilter{FilterName: "Filtrar por género", Values: genres, Selected: 0},
|
||||||
|
}
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
req.Header.Set("Referer", s.cfg.BaseURL+"/")
|
||||||
|
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ source.CatalogueSource = (*Source)(nil)
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
package yuyu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"goyomi/internal/httpclient"
|
||||||
|
"goyomi/internal/source"
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
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 ChaptersDTO struct {
|
||||||
|
Chapters string `json:"chapters"`
|
||||||
|
Remaining int `json:"remaining"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ChaptersDTO) hasNext() bool {
|
||||||
|
return d.Remaining > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
|
||||||
|
doc, err := s.fetchDoc(s.cfg.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mangas := make([]source.SManga, 0)
|
||||||
|
doc.Find(".top10-section .top10-item a").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
title := sel.Find("h3").Text()
|
||||||
|
thumb := sel.Find("img").AttrOr("abs:src", "")
|
||||||
|
href := sel.AttrOr("abs:href", "")
|
||||||
|
|
||||||
|
mangas = append(mangas, source.SManga{
|
||||||
|
URL: href,
|
||||||
|
Title: strings.TrimSpace(title),
|
||||||
|
ThumbnailURL: thumb,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
|
||||||
|
u, _ := url.Parse(s.cfg.BaseURL)
|
||||||
|
u.RawQuery = fmt.Sprintf("pagena=%d", page)
|
||||||
|
doc, err := s.fetchDoc(u.String())
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mangas := make([]source.SManga, 0)
|
||||||
|
doc.Find(".manga-list .manga-card").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
link := sel.Find("a.manga-cover")
|
||||||
|
href := link.AttrOr("abs:href", "")
|
||||||
|
if href != "" {
|
||||||
|
encoded := s.encodeURL(href)
|
||||||
|
title := sel.Find("a.manga-title").Text()
|
||||||
|
thumb := link.Find("img").AttrOr("abs:data-src", "")
|
||||||
|
|
||||||
|
mangas = append(mangas, source.SManga{
|
||||||
|
URL: encoded,
|
||||||
|
Title: strings.TrimSpace(title),
|
||||||
|
ThumbnailURL: thumb,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hasNext := doc.Find("a.page-link:contains(>)").Length() > 0 &&
|
||||||
|
doc.Find("a.page-link.active").AttrOr("abs:href", "") != doc.Find("a.page-link:contains(>)").AttrOr("abs:href", "")
|
||||||
|
|
||||||
|
return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) encodeURL(rawURL string) string {
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
path := strings.TrimPrefix(u.Path, "/")
|
||||||
|
segments := strings.Split(path, "/")
|
||||||
|
if len(segments) < 2 {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
last := segments[len(segments)-1]
|
||||||
|
last = url.QueryEscape(last)
|
||||||
|
newPath := segments[:len(segments)-1]
|
||||||
|
newPath = append(newPath, last)
|
||||||
|
u.Path = "/" + strings.Join(newPath, "/")
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
|
||||||
|
if strings.HasPrefix(query, "https://") {
|
||||||
|
u, err := url.Parse(query)
|
||||||
|
if err != nil || u.Host != s.cfg.BaseURL {
|
||||||
|
return source.MangasPage{}, fmt.Errorf("unsupported url")
|
||||||
|
}
|
||||||
|
path := strings.TrimPrefix(u.Path, "/")
|
||||||
|
segments := strings.Split(path, "/")
|
||||||
|
if len(segments) < 2 {
|
||||||
|
return source.MangasPage{}, fmt.Errorf("unsupported url")
|
||||||
|
}
|
||||||
|
slug := segments[1]
|
||||||
|
return s.fetchSearchMangaBySlug(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(query, "id:") {
|
||||||
|
slug := strings.TrimPrefix(query, "id:")
|
||||||
|
return s.fetchSearchMangaBySlug(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
u, _ := url.Parse(s.cfg.BaseURL)
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("search", query)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
doc, err := s.fetchDoc(u.String())
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mangas := make([]source.SManga, 0)
|
||||||
|
doc.Find(".search-result-item").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
title := sel.Find(".search-result-title").Text()
|
||||||
|
thumb := sel.Find("img").AttrOr("abs:src", "")
|
||||||
|
onclick, _ := sel.Attr("onclick")
|
||||||
|
re := regexp.MustCompile(`'([^']+)'`)
|
||||||
|
matches := re.FindStringSubmatch(onclick)
|
||||||
|
href := ""
|
||||||
|
if len(matches) > 1 {
|
||||||
|
href = matches[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
mangas = append(mangas, source.SManga{
|
||||||
|
URL: href,
|
||||||
|
Title: strings.TrimSpace(title),
|
||||||
|
ThumbnailURL: thumb,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) fetchSearchMangaBySlug(slug string) (source.MangasPage, error) {
|
||||||
|
doc, err := s.fetchDoc(s.cfg.BaseURL + "/manga/" + slug)
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
manga := s.parseMangaDetails(doc)
|
||||||
|
return source.MangasPage{Mangas: []source.SManga{manga}, HasNextPage: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) parseMangaDetails(doc *goquery.Document) source.SManga {
|
||||||
|
details := doc.Find(".manga-banner .container")
|
||||||
|
title := details.Find("h1").Text()
|
||||||
|
thumb := details.Find("img").AttrOr("abs:src", "")
|
||||||
|
genre := details.Find(".genre-tag").Map(func(_ int, sel *goquery.Selection) string {
|
||||||
|
return sel.Text()
|
||||||
|
})
|
||||||
|
desc := details.Find(".sinopse p").Text()
|
||||||
|
metaDiv := details.Find(".manga-meta > div")
|
||||||
|
statusText := metaDiv.First().Text()
|
||||||
|
|
||||||
|
status := s.parseStatus(statusText)
|
||||||
|
|
||||||
|
return source.SManga{
|
||||||
|
Title: strings.TrimSpace(title),
|
||||||
|
ThumbnailURL: thumb,
|
||||||
|
Genre: joinStrings(genre, ", "),
|
||||||
|
Description: strings.TrimSpace(desc),
|
||||||
|
Status: status,
|
||||||
|
Initialized: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) parseStatus(text string) int {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(text)) {
|
||||||
|
case "em andamento":
|
||||||
|
return 1
|
||||||
|
case "completo":
|
||||||
|
return 2
|
||||||
|
case "cancelado":
|
||||||
|
return 6
|
||||||
|
case "hiato":
|
||||||
|
return 5
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
|
||||||
|
return s.parseMangaDetailsFromURL(manga.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) parseMangaDetailsFromURL(mangaURL string) (source.SManga, error) {
|
||||||
|
doc, err := s.fetchDoc(mangaURL)
|
||||||
|
if err != nil {
|
||||||
|
return source.SManga{}, err
|
||||||
|
}
|
||||||
|
return s.parseMangaDetails(doc), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
|
||||||
|
mangaID, err := s.getMangaID(manga)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters := make([]source.SChapter, 0)
|
||||||
|
page := 1
|
||||||
|
for {
|
||||||
|
dto, err := s.fetchChapterListPage(mangaID, page)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(dto.Chapters))
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.Find("a.chapter-item").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
name := sel.Find(".capitulo-numero").Text()
|
||||||
|
href := sel.AttrOr("abs:href", "")
|
||||||
|
|
||||||
|
chapters = append(chapters, source.SChapter{
|
||||||
|
URL: href,
|
||||||
|
Name: strings.TrimSpace(name),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if !dto.hasNext() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
|
||||||
|
return chapters, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) getMangaID(manga source.SManga) (string, error) {
|
||||||
|
doc, err := s.fetchDoc(manga.URL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var mangaID string
|
||||||
|
doc.Find("script").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
html, _ := sel.Html()
|
||||||
|
re := regexp.MustCompile(`obra_id:\s+(\d+)`)
|
||||||
|
matches := re.FindStringSubmatch(html)
|
||||||
|
if len(matches) > 1 {
|
||||||
|
mangaID = matches[1]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if mangaID == "" {
|
||||||
|
return "", fmt.Errorf("manga ID não encontrado")
|
||||||
|
}
|
||||||
|
return mangaID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) fetchChapterListPage(mangaID string, page int) (*ChaptersDTO, error) {
|
||||||
|
u, _ := url.Parse(s.cfg.BaseURL + "/ajax/lzmvke.php")
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("order", "DESC")
|
||||||
|
q.Set("manga_id", mangaID)
|
||||||
|
q.Set("page", fmt.Sprintf("%d", page))
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var dto ChaptersDTO
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&dto); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &dto, 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("picture img").Each(func(i int, sel *goquery.Selection) {
|
||||||
|
imgURL := sel.AttrOr("abs:src", "")
|
||||||
|
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 _ source.CatalogueSource = (*Source)(nil)
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
package zeistmanga
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"goyomi/internal/httpclient"
|
||||||
|
"goyomi/internal/source"
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
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 Feed struct {
|
||||||
|
Entries []Entry `json:"entry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Link []Link `json:"link"`
|
||||||
|
Content Content `json:"content"`
|
||||||
|
Category []Category `json:"category"`
|
||||||
|
Updated struct {
|
||||||
|
T string `json:"$t"`
|
||||||
|
} `json:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Link struct {
|
||||||
|
Href string `json:"href"`
|
||||||
|
Rel string `json:"rel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Content struct {
|
||||||
|
T string `json:"$t"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Category struct {
|
||||||
|
Term string `json:"term"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) apiURL(feed string) string {
|
||||||
|
return s.cfg.BaseURL + "/feeds/posts/default/-/" + feed + "?alt=json"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
|
||||||
|
doc, err := s.fetchDoc(s.cfg.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mangas := make([]source.SManga, 0)
|
||||||
|
doc.Find("div.PopularPosts div.grid > figure").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
thumb := sel.Find("img").AttrOr("abs:src", "")
|
||||||
|
title := sel.Find("figcaption > a").Text()
|
||||||
|
link := sel.Find("figcaption > a").AttrOr("href", "")
|
||||||
|
|
||||||
|
mangas = append(mangas, source.SManga{
|
||||||
|
URL: link,
|
||||||
|
Title: strings.TrimSpace(title),
|
||||||
|
ThumbnailURL: thumb,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
|
||||||
|
startIndex := 20 * (page - 1)
|
||||||
|
u, _ := url.Parse(s.apiURL("Series"))
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("orderby", "published")
|
||||||
|
q.Set("max-results", "21")
|
||||||
|
q.Set("start-index", fmt.Sprintf("%d", startIndex+1))
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
return s.fetchFeed(u.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) fetchFeed(rawURL string) (source.MangasPage, error) {
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, 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 feed struct {
|
||||||
|
Feed Feed `json:"feed"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&feed); err != nil {
|
||||||
|
return source.MangasPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mangas := make([]source.SManga, 0)
|
||||||
|
for _, e := range feed.Feed.Entries {
|
||||||
|
hasSeriesCategory := false
|
||||||
|
for _, c := range e.Category {
|
||||||
|
if c.Term == "Series" {
|
||||||
|
hasSeriesCategory = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasSeriesCategory {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
thumb := ""
|
||||||
|
for _, l := range e.Link {
|
||||||
|
if l.Rel == "alternate" {
|
||||||
|
mangas = append(mangas, source.SManga{
|
||||||
|
URL: l.Href,
|
||||||
|
Title: strings.TrimSpace(e.Title),
|
||||||
|
ThumbnailURL: thumb,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasNext := len(mangas) > 20
|
||||||
|
if hasNext {
|
||||||
|
mangas = mangas[:20]
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
|
||||||
|
startIndex := 20 * (page - 1)
|
||||||
|
|
||||||
|
var feedName string = "Series"
|
||||||
|
for _, f := range filters {
|
||||||
|
if sel, ok := f.(*source.SelectFilter); ok && sel.Selected > 0 && len(sel.Values) > sel.Selected {
|
||||||
|
feedName = sel.Values[sel.Selected]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, _ := url.Parse(s.apiURL(feedName))
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("max-results", "21")
|
||||||
|
q.Set("start-index", fmt.Sprintf("%d", startIndex+1))
|
||||||
|
|
||||||
|
if query != "" {
|
||||||
|
searchURL := u.String() + "&q=label:" + feedName + "+" + url.QueryEscape(query)
|
||||||
|
return s.fetchFeed(searchURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return s.fetchFeed(u.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
|
||||||
|
doc, err := s.fetchDoc(manga.URL)
|
||||||
|
if err != nil {
|
||||||
|
return manga, err
|
||||||
|
}
|
||||||
|
|
||||||
|
profileManga := doc.Find(".grid.gtc-235fr")
|
||||||
|
if profileManga.Length() == 0 {
|
||||||
|
return manga, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
thumb := profileManga.Find("img").AttrOr("abs:src", "")
|
||||||
|
desc := profileManga.Find("#synopsis").Text()
|
||||||
|
|
||||||
|
var altName string
|
||||||
|
if alt := profileManga.Find("header > p"); alt.Length() > 0 {
|
||||||
|
altName = alt.Text()
|
||||||
|
if altName != "" {
|
||||||
|
desc = "Alternative name(s): " + altName + "\n\n" + desc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
genres := make([]string, 0)
|
||||||
|
profileManga.Find("div.mt-15 > a[rel=tag]").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
genres = append(genres, sel.Text())
|
||||||
|
})
|
||||||
|
|
||||||
|
author := profileManga.Find("span#author").Text()
|
||||||
|
artist := profileManga.Find("span#artist").Text()
|
||||||
|
|
||||||
|
statusText := profileManga.Find("span[data-status]").Text()
|
||||||
|
status := s.parseStatus(statusText)
|
||||||
|
|
||||||
|
manga.ThumbnailURL = thumb
|
||||||
|
manga.Description = strings.TrimSpace(desc)
|
||||||
|
manga.Genre = joinStrings(genres, ", ")
|
||||||
|
manga.Author = strings.TrimSpace(author)
|
||||||
|
manga.Artist = strings.TrimSpace(artist)
|
||||||
|
manga.Status = status
|
||||||
|
manga.Initialized = true
|
||||||
|
|
||||||
|
return manga, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) parseStatus(text string) int {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(text))
|
||||||
|
switch {
|
||||||
|
case strings.Contains(lower, "ongoing") || strings.Contains(lower, "em andamento") || strings.Contains(lower, "en curso") || strings.Contains(lower, "en emisión") || strings.Contains(lower, "aktif") || strings.Contains(lower, "lançando") || strings.Contains(lower, "مستمر"):
|
||||||
|
return 1
|
||||||
|
case strings.Contains(lower, "completed") || strings.Contains(lower, "completo") || strings.Contains(lower, "finalizado") || strings.Contains(lower, "مكتمل"):
|
||||||
|
return 2
|
||||||
|
case strings.Contains(lower, "hiatus") || strings.Contains(lower, "pausado"):
|
||||||
|
return 5
|
||||||
|
case strings.Contains(lower, "cancelled") || strings.Contains(lower, "dropped") || strings.Contains(lower, "cancelado") || strings.Contains(lower, "abandonado"):
|
||||||
|
return 6
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
|
||||||
|
doc, err := s.fetchDoc(manga.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
feedURL := s.getChapterFeedURL(doc)
|
||||||
|
if feedURL == "" {
|
||||||
|
return nil, fmt.Errorf("chapter feed URL not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
u, _ := url.Parse(feedURL)
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("max-results", "999999")
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var feed struct {
|
||||||
|
Feed Feed `json:"feed"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&feed); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters := make([]source.SChapter, 0)
|
||||||
|
for _, e := range feed.Feed.Entries {
|
||||||
|
hasChapterCategory := false
|
||||||
|
for _, c := range e.Category {
|
||||||
|
if c.Term == "Chapter" {
|
||||||
|
hasChapterCategory = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasChapterCategory {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
link := ""
|
||||||
|
for _, l := range e.Link {
|
||||||
|
if l.Rel == "alternate" {
|
||||||
|
link = l.Href
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters = append(chapters, source.SChapter{
|
||||||
|
URL: link,
|
||||||
|
Name: strings.TrimSpace(e.Title),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return chapters, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) getChapterFeedURL(doc *goquery.Document) string {
|
||||||
|
sel := doc.Find("#clwd > script")
|
||||||
|
if sel.Length() == 0 {
|
||||||
|
sel = doc.Find("#latest > script")
|
||||||
|
}
|
||||||
|
if sel.Length() == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
html, _ := sel.Html()
|
||||||
|
re := regexp.MustCompile(`clwd\.run\(["'](.*?)["']\)`)
|
||||||
|
matches := re.FindStringSubmatch(html)
|
||||||
|
if len(matches) > 1 {
|
||||||
|
feed := matches[1]
|
||||||
|
return s.apiURL(feed) + "&max-results=999999"
|
||||||
|
}
|
||||||
|
|
||||||
|
re = regexp.MustCompile(`label\s*=\s*'([^']+)'`)
|
||||||
|
matches = re.FindStringSubmatch(html)
|
||||||
|
if len(matches) > 1 {
|
||||||
|
feed := matches[1]
|
||||||
|
return s.apiURL(feed) + "&max-results=999999"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
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("div.check-box div.separator").Each(func(i int, sel *goquery.Selection) {
|
||||||
|
imgURL := sel.Find("img[src]").AttrOr("abs:src", "")
|
||||||
|
if imgURL != "" {
|
||||||
|
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 {
|
||||||
|
statusValues := []string{"", "Ongoing", "Completed", "Dropped", "Upcoming", "Hiatus", "Cancelled"}
|
||||||
|
typeValues := []string{"", "Manga", "Manhua", "Manhwa", "Novel", "Web Novel (JP)", "Web Novel (KR)", "Web Novel (CN)", "Doujinshi"}
|
||||||
|
genreValues := []string{"Action", "Adventure", "Comedy", "Drama", "Ecchi", "Fantasy", "Harem", "Historical", "Horror", "Josei", "Martial Arts", "Mecha", "Mystery", "Psychological", "Romance", "School", "Seinen", "Shoujo", "Shounen", "Slice of Life", "Sports", "Supernatural", "Thriller"}
|
||||||
|
|
||||||
|
filters := []source.Filter{
|
||||||
|
&source.TextFilter{FilterName: "Filters are ignored if search is not empty"},
|
||||||
|
&source.SelectFilter{FilterName: "Status", Values: statusValues, Selected: 0},
|
||||||
|
&source.SelectFilter{FilterName: "Type", Values: typeValues, Selected: 0},
|
||||||
|
&source.SelectFilter{FilterName: "Genre", Values: genreValues, Selected: 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
|
||||||
|
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 _ source.CatalogueSource = (*Source)(nil)
|
||||||
Reference in New Issue
Block a user