403 lines
9.9 KiB
Go
Executable File
403 lines
9.9 KiB
Go
Executable File
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) |