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:
achmad
2026-05-11 07:48:15 +07:00
parent 4568edd32d
commit 15d9d4225c
10 changed files with 2910 additions and 22 deletions
+1 -1
View File
@@ -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 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 5 — HTTP API**`docs/phase5-api.md`
+21 -21
View File
@@ -65,17 +65,17 @@ Detailed implementation notes for complex bases are in the **Notes** section at
- [x] `base/peachscan`
- [x] `base/pizzareader` ⚠️ see notes
- [x] `base/raijinscans`
- [ ] `base/scanr`
- [x] `base/scanr`
- [x] `base/scanreader` ⚠️ see notes
- [x] `base/senkuro` ⚠️ see notes
- [ ] `base/sinmh`
- [ ] `base/spicytheme`
- [ ] `base/stalkercms`
- [ ] `base/uzaymanga`
- [ ] `base/vercomics`
- [x] `base/sinmh`
- [x] `base/spicytheme`
- [x] `base/stalkercms`
- [x] `base/uzaymanga`
- [x] `base/vercomics`
- [x] `base/wpcomics` ⚠️ see notes
- [ ] `base/yuyu`
- [ ] `base/zeistmanga`
- [x] `base/yuyu`
- [x] `base/zeistmanga`
- [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/`)
- [ ] `parseRelativeDate(s string) int64` — "2 days ago" → unix ms
- [ ] `parseAbsoluteDate(s, format string) int64` — "January 01, 2024" → unix ms
- [ ] `slugFromURL(url string) string` — trailing path segment
- [ ] `cleanText(s string) string` — HTML entity decode + whitespace normalize
- [ ] `statusFromString(s string) int` — "ongoing"/"completed"/etc. → int constant
- [ ] `extractNextDataJSON(html string) ([]byte, error)` — pull `__NEXT_DATA__` JSON from NextJS pages
- [x] `parseRelativeDate(s string) int64` — "2 days ago" → unix ms
- [x] `parseAbsoluteDate(s, format string) int64` — "January 01, 2024" → unix ms
- [x] `slugFromURL(url string) string` — trailing path segment
- [x] `cleanText(s string) string` — HTML entity decode + whitespace normalize
- [x] `statusFromString(s string) int` — "ongoing"/"completed"/etc. → int constant
- [x] `extractNextDataJSON(html string) ([]byte, error)` — pull `__NEXT_DATA__` JSON from NextJS pages
---
## Checklist: Phase 3 Done When
- [ ] All 68 bases compile: `go build ./sources/base/...`
- [ ] `base/heancms``GetPopularManga` returns ≥1 manga from a live site
- [ ] `base/madara``GetChapterList` returns chapters via AJAX endpoint
- [ ] `base/mangathemesia``GetPageList` extracts images from `ts_reader.run()` JS blob
- [ ] `base/mangahub` — GraphQL popular list works with cookie acquisition
- [ ] `base/mangotheme` — decrypted page URL returns HTTP 200 image
- [ ] FlareSolverr path — a CF-protected base returns data when FlareSolverr is running
- [x] All 68 bases compile: `go build ./sources/base/...`
- [x] `base/heancms``GetPopularManga` returns ≥1 manga from a live site
- [x] `base/madara``GetChapterList` returns chapters via AJAX endpoint
- [x] `base/mangathemesia``GetPageList` extracts images from `ts_reader.run()` JS blob
- [x] `base/mangahub` — GraphQL popular list works with cookie acquisition
- [x] `base/mangotheme` — decrypted page URL returns HTTP 200 image
- [x] FlareSolverr path — a CF-protected base returns data when FlareSolverr is running
+356
View File
@@ -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
}
+425
View File
@@ -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)
+402
View File
@@ -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)
+340
View File
@@ -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)
+355
View File
@@ -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)
+231
View File
@@ -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)
+376
View File
@@ -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)
+403
View File
@@ -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)