Files
goyomi/sources/base/scanr/scanr.go
T
2026-05-11 06:48:23 +00:00

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
}