356 lines
8.6 KiB
Go
Executable File
356 lines
8.6 KiB
Go
Executable File
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
|
|
} |