node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View File

@@ -0,0 +1,66 @@
package manga_providers
import (
"bytes"
"image/jpeg"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestConvertPDFToImages(t *testing.T) {
start := time.Now()
doc, err := fitz.New("")
require.NoError(t, err)
defer doc.Close()
images := make(map[int][]byte, doc.NumPage())
// Load images into memory
for n := 0; n < doc.NumPage(); n++ {
img, err := doc.Image(n)
if err != nil {
panic(err)
}
var buf bytes.Buffer
err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpeg.DefaultQuality})
if err != nil {
panic(err)
}
images[n] = buf.Bytes()
}
end := time.Now()
t.Logf("Converted %d pages in %f seconds", len(images), end.Sub(start).Seconds())
for n, imgData := range images {
t.Logf("Page %d: %d bytes", n, len(imgData))
}
//tmpDir, err := os.MkdirTemp(os.TempDir(), "manga_test_")
//require.NoError(t, err)
//if len(images) > 0 {
// // Write the first image to a file for verification
// firstImagePath := tmpDir + "/page_0.jpg"
// err = os.WriteFile(firstImagePath, images[0], 0644)
// require.NoError(t, err)
// t.Logf("First image written to: %s", firstImagePath)
//}
//
//time.Sleep(1 * time.Minute)
//
//t.Cleanup(func() {
// // Clean up the temporary directory
// err := os.RemoveAll(tmpDir)
// if err != nil {
// t.Logf("Failed to remove temp directory: %v", err)
// } else {
// t.Logf("Temporary directory removed: %s", tmpDir)
// }
//})
}

View File

@@ -0,0 +1,82 @@
package manga_providers
import (
"github.com/rs/zerolog"
"net/http"
"seanime/internal/util"
"time"
)
type (
Template struct {
Url string
Client *http.Client
UserAgent string
logger *zerolog.Logger
}
)
func NewTemplate(logger *zerolog.Logger) *Template {
c := &http.Client{
Timeout: 60 * time.Second,
}
c.Transport = util.AddCloudFlareByPass(c.Transport)
return &Template{
Url: "https://XXXXXX.com",
Client: c,
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3",
logger: logger,
}
}
func (mp *Template) Search(opts SearchOptions) ([]*SearchResult, error) {
results := make([]*SearchResult, 0)
mp.logger.Debug().Str("query", opts.Query).Msg("XXXXXX: Searching manga")
// code
if len(results) == 0 {
mp.logger.Error().Str("query", opts.Query).Msg("XXXXXX: No results found")
return nil, ErrNoResults
}
mp.logger.Info().Int("count", len(results)).Msg("XXXXXX: Found results")
return results, nil
}
func (mp *Template) FindChapters(id string) ([]*ChapterDetails, error) {
ret := make([]*ChapterDetails, 0)
mp.logger.Debug().Str("mangaId", id).Msg("XXXXXX: Finding chapters")
// code
if len(ret) == 0 {
mp.logger.Error().Str("mangaId", id).Msg("XXXXXX: No chapters found")
return nil, ErrNoChapters
}
mp.logger.Info().Int("count", len(ret)).Msg("XXXXXX: Found chapters")
return ret, nil
}
func (mp *Template) FindChapterPages(id string) ([]*ChapterPage, error) {
ret := make([]*ChapterPage, 0)
mp.logger.Debug().Str("chapterId", id).Msg("XXXXXX: Finding chapter pages")
// code
if len(ret) == 0 {
mp.logger.Error().Str("chapterId", id).Msg("XXXXXX: No pages found")
return nil, ErrNoPages
}
mp.logger.Info().Int("count", len(ret)).Msg("XXXXXX: Found pages")
return ret, nil
}

View File

@@ -0,0 +1,127 @@
package manga_providers
import (
"github.com/stretchr/testify/assert"
"seanime/internal/util"
"testing"
)
func TestXXXXXX_Search(t *testing.T) {
tests := []struct {
name string
query string
}{
{
name: "Boku no Kokoro no Yabai Yatsu",
query: "Boku no Kokoro no Yabai Yatsu",
},
}
provider := NewXXXXXX(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
searchRes, err := provider.Search(SearchOptions{
Query: tt.query,
})
if assert.NoError(t, err, "provider.Search() error") {
assert.NotEmpty(t, searchRes, "search result is empty")
for _, res := range searchRes {
t.Logf("Title: %s", res.Title)
t.Logf("\tID: %s", res.ID)
t.Logf("\tYear: %d", res.Year)
t.Logf("\tImage: %s", res.Image)
t.Logf("\tProvider: %s", res.Provider)
t.Logf("\tSearchRating: %f", res.SearchRating)
t.Logf("\tSynonyms: %v", res.Synonyms)
t.Log("--------------------------------------------------")
}
}
})
}
}
func TestXXXXXX_FindChapters(t *testing.T) {
tests := []struct {
name string
id string
atLeast int
}{
{
name: "The Dangers in My Heart",
id: "",
atLeast: 141,
},
}
provider := NewXXXXXX(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
chapters, err := provider.FindChapters(tt.id)
if assert.NoError(t, err, "provider.FindChapters() error") {
assert.NotEmpty(t, chapters, "chapters is empty")
assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected")
for _, chapter := range chapters {
t.Logf("Title: %s", chapter.Title)
t.Logf("\tSlug: %s", chapter.ID)
t.Logf("\tURL: %s", chapter.URL)
t.Logf("\tIndex: %d", chapter.Index)
t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt)
t.Log("--------------------------------------------------")
}
}
})
}
}
func TestXXXXXX_FindChapterPages(t *testing.T) {
tests := []struct {
name string
chapterId string
}{
{
name: "The Dangers in My Heart",
chapterId: "", // Chapter 1
},
}
provider := NewXXXXXX(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pages, err := provider.FindChapterPages(tt.chapterId)
if assert.NoError(t, err, "provider.FindChapterPages() error") {
assert.NotEmpty(t, pages, "pages is empty")
for _, page := range pages {
t.Logf("Index: %d", page.Index)
t.Logf("\tURL: %s", page.URL)
t.Log("--------------------------------------------------")
}
}
})
}
}

View File

@@ -0,0 +1,376 @@
package manga_providers
import (
"cmp"
"fmt"
"net/url"
hibikemanga "seanime/internal/extension/hibike/manga"
"seanime/internal/util"
"seanime/internal/util/comparison"
"slices"
"strings"
"time"
"github.com/imroc/req/v3"
"github.com/rs/zerolog"
)
type (
ComicK struct {
Url string
Client *req.Client
logger *zerolog.Logger
}
ComicKResultItem struct {
ID int `json:"id"`
HID string `json:"hid"`
Slug string `json:"slug"`
Title string `json:"title"`
Country string `json:"country"`
Rating string `json:"rating"`
BayesianRating string `json:"bayesian_rating"`
RatingCount int `json:"rating_count"`
FollowCount int `json:"follow_count"`
Description string `json:"desc"`
Status int `json:"status"`
LastChapter float64 `json:"last_chapter"`
TranslationCompleted bool `json:"translation_completed"`
ViewCount int `json:"view_count"`
ContentRating string `json:"content_rating"`
Demographic int `json:"demographic"`
UploadedAt string `json:"uploaded_at"`
Genres []int `json:"genres"`
CreatedAt string `json:"created_at"`
UserFollowCount int `json:"user_follow_count"`
Year int `json:"year"`
MuComics struct {
Year int `json:"year"`
} `json:"mu_comics"`
MdTitles []struct {
Title string `json:"title"`
} `json:"md_titles"`
MdCovers []struct {
W int `json:"w"`
H int `json:"h"`
B2Key string `json:"b2key"`
} `json:"md_covers"`
Highlight string `json:"highlight"`
}
)
func NewComicK(logger *zerolog.Logger) *ComicK {
client := req.C().
SetUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36").
SetTimeout(60 * time.Second).
EnableInsecureSkipVerify().
ImpersonateSafari()
return &ComicK{
Url: "https://api.comick.fun",
Client: client,
logger: logger,
}
}
// DEVNOTE: Each chapter ID is a unique string provided by ComicK
func (c *ComicK) GetSettings() hibikemanga.Settings {
return hibikemanga.Settings{
SupportsMultiScanlator: false,
SupportsMultiLanguage: false,
}
}
func (c *ComicK) Search(opts hibikemanga.SearchOptions) ([]*hibikemanga.SearchResult, error) {
searchUrl := fmt.Sprintf("%s/v1.0/search?q=%s&limit=25&page=1", c.Url, url.QueryEscape(opts.Query))
if opts.Year != 0 {
searchUrl += fmt.Sprintf("&from=%d&to=%d", opts.Year, opts.Year)
}
c.logger.Debug().Str("searchUrl", searchUrl).Msg("comick: Searching manga")
var data []*ComicKResultItem
resp, err := c.Client.R().
SetSuccessResult(&data).
Get(searchUrl)
if err != nil {
c.logger.Error().Err(err).Msg("comick: Failed to send request")
return nil, fmt.Errorf("failed to send request: %w", err)
}
if !resp.IsSuccessState() {
c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed")
return nil, fmt.Errorf("failed to reach API: status %s", resp.Status)
}
results := make([]*hibikemanga.SearchResult, 0)
for _, result := range data {
// Skip fan-colored manga
if strings.Contains(result.Slug, "fan-colored") {
continue
}
var coverURL string
if len(result.MdCovers) > 0 && result.MdCovers[0].B2Key != "" {
coverURL = "https://meo.comick.pictures/" + result.MdCovers[0].B2Key
}
altTitles := make([]string, len(result.MdTitles))
for j, title := range result.MdTitles {
altTitles[j] = title.Title
}
// DEVNOTE: We don't compare to alt titles because ComicK's synonyms aren't good
compRes, _ := comparison.FindBestMatchWithSorensenDice(&opts.Query, []*string{&result.Title})
results = append(results, &hibikemanga.SearchResult{
ID: result.HID,
Title: cmp.Or(result.Title, result.Slug),
Synonyms: altTitles,
Image: coverURL,
Year: result.Year,
SearchRating: compRes.Rating,
Provider: ComickProvider,
})
}
if len(results) == 0 {
c.logger.Warn().Msg("comick: No results found")
return nil, ErrNoChapters
}
c.logger.Info().Int("count", len(results)).Msg("comick: Found results")
return results, nil
}
func (c *ComicK) FindChapters(id string) ([]*hibikemanga.ChapterDetails, error) {
ret := make([]*hibikemanga.ChapterDetails, 0)
c.logger.Debug().Str("mangaId", id).Msg("comick: Fetching chapters")
uri := fmt.Sprintf("%s/comic/%s/chapters?lang=en&page=0&limit=1000000&chap-order=1", c.Url, id)
var data struct {
Chapters []*ComicChapter `json:"chapters"`
}
resp, err := c.Client.R().
SetSuccessResult(&data).
Get(uri)
if err != nil {
c.logger.Error().Err(err).Msg("comick: Failed to send request")
return nil, fmt.Errorf("failed to send request: %w", err)
}
if !resp.IsSuccessState() {
c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed")
return nil, fmt.Errorf("failed to decode response: status %s", resp.Status)
}
chapters := make([]*hibikemanga.ChapterDetails, 0)
chaptersMap := make(map[string]*hibikemanga.ChapterDetails)
count := 0
for _, chapter := range data.Chapters {
if chapter.Chap == "" || chapter.Lang != "en" {
continue
}
title := "Chapter " + chapter.Chap + " "
if title == "" {
if chapter.Title == "" {
title = "Oneshot"
} else {
title = chapter.Title
}
}
title = strings.TrimSpace(title)
prev, ok := chaptersMap[chapter.Chap]
rating := chapter.UpCount - chapter.DownCount
if !ok || rating > prev.Rating {
if !ok {
count++
}
chaptersMap[chapter.Chap] = &hibikemanga.ChapterDetails{
Provider: ComickProvider,
ID: chapter.HID,
Title: title,
Index: uint(count),
URL: fmt.Sprintf("%s/chapter/%s", c.Url, chapter.HID),
Chapter: chapter.Chap,
Rating: rating,
UpdatedAt: chapter.UpdatedAt,
}
}
}
for _, chapter := range chaptersMap {
chapters = append(chapters, chapter)
}
// Sort chapters by index
slices.SortStableFunc(chapters, func(i, j *hibikemanga.ChapterDetails) int {
return cmp.Compare(i.Index, j.Index)
})
ret = append(ret, chapters...)
if len(ret) == 0 {
c.logger.Warn().Msg("comick: No chapters found")
return nil, ErrNoChapters
}
c.logger.Info().Int("count", len(ret)).Msg("comick: Found chapters")
return ret, nil
}
func (c *ComicK) FindChapterPages(id string) ([]*hibikemanga.ChapterPage, error) {
ret := make([]*hibikemanga.ChapterPage, 0)
c.logger.Debug().Str("chapterId", id).Msg("comick: Finding chapter pages")
uri := fmt.Sprintf("%s/chapter/%s", c.Url, id)
var data struct {
Chapter *ComicChapter `json:"chapter"`
}
resp, err := c.Client.R().
SetHeader("User-Agent", util.GetRandomUserAgent()).
SetSuccessResult(&data).
Get(uri)
if err != nil {
c.logger.Error().Err(err).Msg("comick: Failed to send request")
return nil, fmt.Errorf("failed to send request: %w", err)
}
if !resp.IsSuccessState() {
c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed")
return nil, fmt.Errorf("failed to decode response: status %s", resp.Status)
}
if data.Chapter == nil {
c.logger.Error().Msg("comick: Chapter not found")
return nil, fmt.Errorf("chapter not found")
}
for index, image := range data.Chapter.MdImages {
ret = append(ret, &hibikemanga.ChapterPage{
Provider: ComickProvider,
URL: fmt.Sprintf("https://meo.comick.pictures/%s", image.B2Key),
Index: index,
Headers: make(map[string]string),
})
}
if len(ret) == 0 {
c.logger.Warn().Msg("comick: No pages found")
return nil, ErrNoPages
}
c.logger.Info().Int("count", len(ret)).Msg("comick: Found pages")
return ret, nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
type Comic struct {
ID int `json:"id"`
HID string `json:"hid"`
Title string `json:"title"`
Country string `json:"country"`
Status int `json:"status"`
Links struct {
AL string `json:"al"`
AP string `json:"ap"`
BW string `json:"bw"`
KT string `json:"kt"`
MU string `json:"mu"`
AMZ string `json:"amz"`
CDJ string `json:"cdj"`
EBJ string `json:"ebj"`
MAL string `json:"mal"`
RAW string `json:"raw"`
} `json:"links"`
LastChapter interface{} `json:"last_chapter"`
ChapterCount int `json:"chapter_count"`
Demographic int `json:"demographic"`
Hentai bool `json:"hentai"`
UserFollowCount int `json:"user_follow_count"`
FollowRank int `json:"follow_rank"`
CommentCount int `json:"comment_count"`
FollowCount int `json:"follow_count"`
Description string `json:"desc"`
Parsed string `json:"parsed"`
Slug string `json:"slug"`
Mismatch interface{} `json:"mismatch"`
Year int `json:"year"`
BayesianRating interface{} `json:"bayesian_rating"`
RatingCount int `json:"rating_count"`
ContentRating string `json:"content_rating"`
TranslationCompleted bool `json:"translation_completed"`
RelateFrom []interface{} `json:"relate_from"`
Mies interface{} `json:"mies"`
MdTitles []struct {
Title string `json:"title"`
} `json:"md_titles"`
MdComicMdGenres []struct {
MdGenres struct {
Name string `json:"name"`
Type interface{} `json:"type"`
Slug string `json:"slug"`
Group string `json:"group"`
} `json:"md_genres"`
} `json:"md_comic_md_genres"`
MuComics struct {
LicensedInEnglish interface{} `json:"licensed_in_english"`
MuComicCategories []struct {
MuCategories struct {
Title string `json:"title"`
Slug string `json:"slug"`
} `json:"mu_categories"`
PositiveVote int `json:"positive_vote"`
NegativeVote int `json:"negative_vote"`
} `json:"mu_comic_categories"`
} `json:"mu_comics"`
MdCovers []struct {
Vol interface{} `json:"vol"`
W int `json:"w"`
H int `json:"h"`
B2Key string `json:"b2key"`
} `json:"md_covers"`
Iso6391 string `json:"iso639_1"`
LangName string `json:"lang_name"`
LangNative string `json:"lang_native"`
}
type ComicChapter struct {
ID int `json:"id"`
Chap string `json:"chap"`
Title string `json:"title"`
Vol string `json:"vol,omitempty"`
Lang string `json:"lang"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
UpCount int `json:"up_count"`
DownCount int `json:"down_count"`
GroupName []string `json:"group_name"`
HID string `json:"hid"`
MdImages []struct {
Name string `json:"name"`
W int `json:"w"`
H int `json:"h"`
S int `json:"s"`
B2Key string `json:"b2key"`
} `json:"md_images"`
}

View File

@@ -0,0 +1,249 @@
package manga_providers
import (
"cmp"
"fmt"
"net/url"
hibikemanga "seanime/internal/extension/hibike/manga"
"seanime/internal/util"
"seanime/internal/util/comparison"
"slices"
"strings"
"time"
"github.com/imroc/req/v3"
"github.com/rs/zerolog"
)
type (
ComicKMulti struct {
Url string
Client *req.Client
logger *zerolog.Logger
}
)
func NewComicKMulti(logger *zerolog.Logger) *ComicKMulti {
client := req.C().
SetUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36").
SetTimeout(60 * time.Second).
EnableInsecureSkipVerify().
ImpersonateSafari()
return &ComicKMulti{
Url: "https://api.comick.fun",
Client: client,
logger: logger,
}
}
// DEVNOTE: Each chapter ID is a unique string provided by ComicK
func (c *ComicKMulti) GetSettings() hibikemanga.Settings {
return hibikemanga.Settings{
SupportsMultiScanlator: true,
SupportsMultiLanguage: true,
}
}
func (c *ComicKMulti) Search(opts hibikemanga.SearchOptions) ([]*hibikemanga.SearchResult, error) {
c.logger.Debug().Str("query", opts.Query).Msg("comick: Searching manga")
searchUrl := fmt.Sprintf("%s/v1.0/search?q=%s&limit=25&page=1", c.Url, url.QueryEscape(opts.Query))
if opts.Year != 0 {
searchUrl += fmt.Sprintf("&from=%d&to=%d", opts.Year, opts.Year)
}
var data []*ComicKResultItem
resp, err := c.Client.R().
SetSuccessResult(&data).
Get(searchUrl)
if err != nil {
c.logger.Error().Err(err).Msg("comick: Failed to send request")
return nil, fmt.Errorf("failed to send request: %w", err)
}
if !resp.IsSuccessState() {
c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed")
return nil, fmt.Errorf("failed to reach API: status %s", resp.Status)
}
results := make([]*hibikemanga.SearchResult, 0)
for _, result := range data {
// Skip fan-colored manga
if strings.Contains(result.Slug, "fan-colored") {
continue
}
var coverURL string
if len(result.MdCovers) > 0 && result.MdCovers[0].B2Key != "" {
coverURL = "https://meo.comick.pictures/" + result.MdCovers[0].B2Key
}
altTitles := make([]string, len(result.MdTitles))
for j, title := range result.MdTitles {
altTitles[j] = title.Title
}
// DEVNOTE: We don't compare to alt titles because ComicK's synonyms aren't good
compRes, _ := comparison.FindBestMatchWithSorensenDice(&opts.Query, []*string{&result.Title})
results = append(results, &hibikemanga.SearchResult{
ID: result.HID,
Title: cmp.Or(result.Title, result.Slug),
Synonyms: altTitles,
Image: coverURL,
Year: result.Year,
SearchRating: compRes.Rating,
Provider: ComickProvider,
})
}
if len(results) == 0 {
c.logger.Warn().Msg("comick: No results found")
return nil, ErrNoChapters
}
c.logger.Info().Int("count", len(results)).Msg("comick: Found results")
return results, nil
}
func (c *ComicKMulti) FindChapters(id string) ([]*hibikemanga.ChapterDetails, error) {
ret := make([]*hibikemanga.ChapterDetails, 0)
// c.logger.Debug().Str("mangaId", id).Msg("comick: Fetching chapters")
uri := fmt.Sprintf("%s/comic/%s/chapters?page=0&limit=1000000&chap-order=1", c.Url, id)
c.logger.Debug().Str("mangaId", id).Str("uri", uri).Msg("comick: Fetching chapters")
var data struct {
Chapters []*ComicChapter `json:"chapters"`
}
resp, err := c.Client.R().
SetSuccessResult(&data).
Get(uri)
if err != nil {
c.logger.Error().Err(err).Msg("comick: Failed to send request")
return nil, fmt.Errorf("failed to send request: %w", err)
}
if !resp.IsSuccessState() {
c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed")
return nil, fmt.Errorf("failed to decode response: status %s", resp.Status)
}
chapters := make([]*hibikemanga.ChapterDetails, 0)
chaptersCountMap := make(map[string]int)
for _, chapter := range data.Chapters {
if chapter.Chap == "" {
continue
}
title := "Chapter " + chapter.Chap + " "
if title == "" {
if chapter.Title == "" {
title = "Oneshot"
} else {
title = chapter.Title
}
}
title = strings.TrimSpace(title)
groupName := ""
if len(chapter.GroupName) > 0 {
groupName = chapter.GroupName[0]
}
count, ok := chaptersCountMap[groupName]
if !ok {
chaptersCountMap[groupName] = 0
count = 0
}
chapters = append(chapters, &hibikemanga.ChapterDetails{
Provider: ComickProvider,
ID: chapter.HID,
Title: title,
Language: chapter.Lang,
Index: uint(count),
URL: fmt.Sprintf("%s/chapter/%s", c.Url, chapter.HID),
Chapter: chapter.Chap,
Scanlator: groupName,
Rating: 0,
UpdatedAt: chapter.UpdatedAt,
})
chaptersCountMap[groupName]++
}
// Sort chapters by index
slices.SortStableFunc(chapters, func(i, j *hibikemanga.ChapterDetails) int {
return cmp.Compare(i.Index, j.Index)
})
ret = append(ret, chapters...)
if len(ret) == 0 {
c.logger.Warn().Msg("comick: No chapters found")
return nil, ErrNoChapters
}
c.logger.Info().Int("count", len(ret)).Msg("comick: Found chapters")
return ret, nil
}
func (c *ComicKMulti) FindChapterPages(id string) ([]*hibikemanga.ChapterPage, error) {
ret := make([]*hibikemanga.ChapterPage, 0)
c.logger.Debug().Str("chapterId", id).Msg("comick: Finding chapter pages")
uri := fmt.Sprintf("%s/chapter/%s", c.Url, id)
var data struct {
Chapter *ComicChapter `json:"chapter"`
}
resp, err := c.Client.R().
SetHeader("User-Agent", util.GetRandomUserAgent()).
SetSuccessResult(&data).
Get(uri)
if err != nil {
c.logger.Error().Err(err).Msg("comick: Failed to send request")
return nil, fmt.Errorf("failed to send request: %w", err)
}
if !resp.IsSuccessState() {
c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed")
return nil, fmt.Errorf("failed to decode response: status %s", resp.Status)
}
if data.Chapter == nil {
c.logger.Error().Msg("comick: Chapter not found")
return nil, fmt.Errorf("chapter not found")
}
for index, image := range data.Chapter.MdImages {
ret = append(ret, &hibikemanga.ChapterPage{
Provider: ComickProvider,
URL: fmt.Sprintf("https://meo.comick.pictures/%s", image.B2Key),
Index: index,
Headers: make(map[string]string),
})
}
if len(ret) == 0 {
c.logger.Warn().Msg("comick: No pages found")
return nil, ErrNoPages
}
c.logger.Info().Int("count", len(ret)).Msg("comick: Found pages")
return ret, nil
}

View File

@@ -0,0 +1,224 @@
package manga_providers
import (
"github.com/stretchr/testify/assert"
hibikemanga "seanime/internal/extension/hibike/manga"
"seanime/internal/util"
"testing"
)
func TestComicK_Search(t *testing.T) {
tests := []struct {
name string
query string
}{
{
name: "One Piece",
query: "One Piece",
},
{
name: "Jujutsu Kaisen",
query: "Jujutsu Kaisen",
},
{
name: "Komi-san wa, Komyushou desu",
query: "Komi-san wa, Komyushou desu",
},
{
name: "Boku no Kokoro no Yabai Yatsu",
query: "Boku no Kokoro no Yabai Yatsu",
},
}
comick := NewComicK(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
searchRes, err := comick.Search(hibikemanga.SearchOptions{
Query: tt.query,
})
if assert.NoError(t, err, "comick.Search() error") {
assert.NotEmpty(t, searchRes, "search result is empty")
for _, res := range searchRes {
t.Logf("Title: %s", res.Title)
t.Logf("\tID: %s", res.ID)
t.Logf("\tYear: %d", res.Year)
t.Logf("\tImage: %s", res.Image)
t.Logf("\tProvider: %s", res.Provider)
t.Logf("\tSearchRating: %f", res.SearchRating)
t.Logf("\tSynonyms: %v", res.Synonyms)
t.Log("--------------------------------------------------")
}
}
})
}
}
func TestComicK_FindChapters(t *testing.T) {
tests := []struct {
name string
id string
atLeast int
}{
{
name: "Jujutsu Kaisen",
id: "TA22I5O7",
atLeast: 250,
},
{
name: "Komi-san wa, Komyushou desu",
id: "K_Dn8VW7",
atLeast: 250,
},
{
name: "Boku no Kokoro no Yabai Yatsu",
id: "pYN47sZm",
atLeast: 141,
},
}
comick := NewComicK(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
chapters, err := comick.FindChapters(tt.id)
if assert.NoError(t, err, "comick.FindChapters() error") {
assert.NotEmpty(t, chapters, "chapters is empty")
assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected")
for _, chapter := range chapters {
t.Logf("Title: %s", chapter.Title)
t.Logf("\tSlug: %s", chapter.ID)
t.Logf("\tURL: %s", chapter.URL)
t.Logf("\tIndex: %d", chapter.Index)
t.Logf("\tChapter: %s", chapter.Chapter)
t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt)
t.Log("--------------------------------------------------")
}
}
})
}
}
func TestComicKMulti_FindChapters(t *testing.T) {
tests := []struct {
name string
id string
atLeast int
}{
{
name: "Jujutsu Kaisen",
id: "TA22I5O7",
atLeast: 250,
},
{
name: "Komi-san wa, Komyushou desu",
id: "K_Dn8VW7",
atLeast: 250,
},
{
name: "Boku no Kokoro no Yabai Yatsu",
id: "pYN47sZm",
atLeast: 141,
},
}
comick := NewComicKMulti(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
chapters, err := comick.FindChapters(tt.id)
if assert.NoError(t, err, "comick.FindChapters() error") {
assert.NotEmpty(t, chapters, "chapters is empty")
assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected")
for _, chapter := range chapters {
t.Logf("Title: %s", chapter.Title)
t.Logf("\tLanguage: %s", chapter.Language)
t.Logf("\tScanlator: %s", chapter.Scanlator)
t.Logf("\tSlug: %s", chapter.ID)
t.Logf("\tURL: %s", chapter.URL)
t.Logf("\tIndex: %d", chapter.Index)
t.Logf("\tChapter: %s", chapter.Chapter)
t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt)
t.Log("--------------------------------------------------")
}
}
})
}
}
func TestComicK_FindChapterPages(t *testing.T) {
tests := []struct {
name string
id string
index uint
}{
{
name: "Jujutsu Kaisen",
id: "TA22I5O7",
index: 258,
},
}
comick := NewComicK(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
chapters, err := comick.FindChapters(tt.id)
if assert.NoError(t, err, "comick.FindChapters() error") {
assert.NotEmpty(t, chapters, "chapters is empty")
var chapterInfo *hibikemanga.ChapterDetails
for _, chapter := range chapters {
if chapter.Index == tt.index {
chapterInfo = chapter
break
}
}
if assert.NotNil(t, chapterInfo, "chapter not found") {
pages, err := comick.FindChapterPages(chapterInfo.ID)
if assert.NoError(t, err, "comick.FindChapterPages() error") {
assert.NotEmpty(t, pages, "pages is empty")
for _, page := range pages {
t.Logf("Index: %d", page.Index)
t.Logf("\tURL: %s", page.URL)
t.Log("--------------------------------------------------")
}
}
}
}
})
}
}

View File

@@ -0,0 +1,14 @@
package manga_providers
import "strings"
// GetNormalizedChapter returns a normalized chapter string.
// e.g. "0001" -> "1"
func GetNormalizedChapter(chapter string) string {
// Trim padding zeros
unpaddedChStr := strings.TrimLeft(chapter, "0")
if unpaddedChStr == "" {
unpaddedChStr = "0"
}
return unpaddedChStr
}

View File

@@ -0,0 +1,556 @@
package manga_providers
import (
"archive/zip"
"bytes"
"fmt"
// "image/jpeg"
"io"
"os"
"path/filepath"
hibikemanga "seanime/internal/extension/hibike/manga"
"seanime/internal/util/comparison"
"slices"
"strconv"
"strings"
"sync"
// "github.com/gen2brain/go-fitz"
"github.com/rs/zerolog"
"github.com/samber/lo"
)
const (
LocalServePath = "{{manga-local-assets}}"
)
type Local struct {
dir string // Directory to scan for manga
logger *zerolog.Logger
mu sync.Mutex
currentChapterPath string
currentZipCloser io.Closer
currentPages map[string]*loadedPage
}
type loadedPage struct {
buf []byte
page *hibikemanga.ChapterPage
}
// chapterEntry represents a potential chapter file or directory found during scanning
type chapterEntry struct {
RelativePath string // Path relative to manga root (e.g., "mangaID/chapter1.cbz" or "mangaID/vol1/ch1.cbz")
IsDir bool // Whether this entry is a directory
}
func NewLocal(dir string, logger *zerolog.Logger) hibikemanga.Provider {
_ = os.MkdirAll(dir, 0755)
return &Local{
dir: dir,
logger: logger,
currentPages: make(map[string]*loadedPage),
}
}
func (p *Local) GetSettings() hibikemanga.Settings {
return hibikemanga.Settings{
SupportsMultiScanlator: false,
SupportsMultiLanguage: false,
}
}
func (p *Local) SetSourceDirectory(dir string) {
if dir != "" {
p.dir = dir
}
}
func (p *Local) getAllManga() (res []*hibikemanga.SearchResult, err error) {
if p.dir == "" {
return make([]*hibikemanga.SearchResult, 0), nil
}
entries, err := os.ReadDir(p.dir)
if err != nil {
return nil, err
}
res = make([]*hibikemanga.SearchResult, 0)
for _, entry := range entries {
if entry.IsDir() {
res = append(res, &hibikemanga.SearchResult{
ID: entry.Name(),
Title: entry.Name(),
Provider: LocalProvider,
})
}
}
return res, nil
}
func (p *Local) Search(opts hibikemanga.SearchOptions) (res []*hibikemanga.SearchResult, err error) {
res = make([]*hibikemanga.SearchResult, 0)
all, err := p.getAllManga()
if err != nil {
return nil, err
}
if opts.Query == "" {
return all, nil
}
allTitles := make([]*string, len(all))
for i, manga := range all {
allTitles[i] = &manga.Title
}
compRes := comparison.CompareWithLevenshteinCleanFunc(&opts.Query, allTitles, cleanMangaTitle)
var bestMatch *comparison.LevenshteinResult
for _, res := range compRes {
if bestMatch == nil || res.Distance < bestMatch.Distance {
bestMatch = res
}
}
if bestMatch == nil {
return res, nil
}
if bestMatch.Distance > 3 {
// If the best match is too far away, return no results
return res, nil
}
manga, ok := lo.Find(all, func(manga *hibikemanga.SearchResult) bool {
return manga.Title == *bestMatch.Value
})
if !ok {
return res, nil
}
res = append(res, manga)
return res, nil
}
func cleanMangaTitle(title string) string {
title = strings.TrimSpace(title)
// Remove some characters to make comparison easier
title = strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '!' || r == '"' || r == '<' || r == '>' || r == '|' || r == ',' {
return rune(0)
}
return r
}, title)
return title
}
// FindChapters scans the manga series directory and returns the chapters.
// Supports nested folder structures up to 2 levels deep.
//
// Example:
//
// Series title/
// ├── Chapter 1/
// │ ├── image_1.ext
// │ └── image_n.ext
// ├── Chapter 2.pdf
// └── Ch 1-10/
// ├── Ch 1/
// └── Ch 2/
func (p *Local) FindChapters(mangaID string) (res []*hibikemanga.ChapterDetails, err error) {
if p.dir == "" {
return make([]*hibikemanga.ChapterDetails, 0), nil
}
mangaPath := filepath.Join(p.dir, mangaID)
p.logger.Trace().Str("mangaPath", mangaPath).Msg("manga: Finding local chapters")
// Collect all potential chapter entries up to 2 levels deep
chapterEntries, err := p.collectChapterEntries(mangaPath, mangaID, 0)
if err != nil {
return nil, err
}
res = make([]*hibikemanga.ChapterDetails, 0)
// Go through all collected entries.
for _, entry := range chapterEntries {
scannedEntry, ok := scanChapterFilename(filepath.Base(entry.RelativePath))
if !ok {
continue
}
if len(scannedEntry.Chapter) != 1 {
// Handle one-shots (no chapter number and only one entry)
if len(scannedEntry.Chapter) == 0 && len(chapterEntries) == 1 {
chapterTitle := "Chapter 1"
if scannedEntry.ChapterTitle != "" {
chapterTitle += " - " + scannedEntry.ChapterTitle
}
res = append(res, &hibikemanga.ChapterDetails{
Provider: LocalProvider,
ID: filepath.ToSlash(entry.RelativePath), // ID is the relative filepath, e.g. "/series/chapter_1.cbz" or "/series/vol1/ch1.cbz"
URL: "",
Title: chapterTitle,
Chapter: "1",
Index: 0, // placeholder, will be set later
LocalIsPDF: scannedEntry.IsPDF,
})
} else if len(scannedEntry.Chapter) == 2 {
// Handle combined chapters (e.g. "Chapter 1-2")
chapterTitle := "Chapter " + cleanChapter(scannedEntry.Chapter[0]) + "-" + cleanChapter(scannedEntry.Chapter[1])
if scannedEntry.ChapterTitle != "" {
chapterTitle += " - " + scannedEntry.ChapterTitle
}
res = append(res, &hibikemanga.ChapterDetails{
Provider: LocalProvider,
ID: filepath.ToSlash(entry.RelativePath), // ID is the relative filepath, e.g. "/series/chapter_1.cbz" or "/series/vol1/ch1.cbz"
URL: "",
Title: chapterTitle,
// Use the last chapter number as the chapter for progress tracking
Chapter: cleanChapter(scannedEntry.Chapter[1]),
Index: 0, // placeholder, will be set later
LocalIsPDF: scannedEntry.IsPDF,
})
}
continue
}
ch := cleanChapter(scannedEntry.Chapter[0])
chapterTitle := "Chapter " + ch
if scannedEntry.ChapterTitle != "" {
chapterTitle += " - " + scannedEntry.ChapterTitle
}
res = append(res, &hibikemanga.ChapterDetails{
Provider: LocalProvider,
ID: filepath.ToSlash(entry.RelativePath), // ID is the relative filepath, e.g. "/series/chapter_1.cbz" or "/series/vol1/ch1.cbz"
URL: "",
Title: chapterTitle,
Chapter: ch,
Index: 0, // placeholder, will be set later
LocalIsPDF: scannedEntry.IsPDF,
})
}
// sort by chapter number (ascending)
slices.SortFunc(res, func(a, b *hibikemanga.ChapterDetails) int {
chA, _ := strconv.ParseFloat(a.Chapter, 64)
chB, _ := strconv.ParseFloat(b.Chapter, 64)
return int(chA - chB)
})
// set the indexes
for i, chapter := range res {
chapter.Index = uint(i)
}
return res, nil
}
// collectChapterEntries walks the directory tree up to maxDepth levels deep and collects
// all potential chapter files and directories.
func (p *Local) collectChapterEntries(currentPath, mangaID string, currentDepth int) (entries []*chapterEntry, err error) {
const maxDepth = 2
if currentDepth > maxDepth {
return entries, nil
}
dirEntries, err := os.ReadDir(currentPath)
if err != nil {
return nil, err
}
entries = make([]*chapterEntry, 0)
for _, entry := range dirEntries {
entryPath := filepath.Join(currentPath, entry.Name())
// Calculate relative path from manga root
var relativePath string
if currentDepth == 0 {
// At manga root level
relativePath = filepath.Join(mangaID, entry.Name())
} else {
// Get the relative part from current path
relativeFromManga, err := filepath.Rel(filepath.Join(p.dir, mangaID), entryPath)
if err != nil {
continue
}
relativePath = filepath.Join(mangaID, relativeFromManga)
}
if entry.IsDir() {
// Check if this directory contains only images (making it a chapter directory)
isImageDirectory, _ := p.isImageOnlyDirectory(entryPath)
if isImageDirectory {
// Directory contains only images, treat it as a chapter
entries = append(entries, &chapterEntry{
RelativePath: relativePath,
IsDir: true,
})
} else if currentDepth < maxDepth {
// Directory doesn't contain only images, recursively scan subdirectories
subEntries, err := p.collectChapterEntries(entryPath, mangaID, currentDepth+1)
if err != nil {
continue
}
// If subdirectory contains chapters, add them
if len(subEntries) > 0 {
entries = append(entries, subEntries...)
} else {
// If no sub-chapters found, treat directory itself as potential chapter
entries = append(entries, &chapterEntry{
RelativePath: relativePath,
IsDir: true,
})
}
} else {
// At max depth, treat directory as potential chapter
entries = append(entries, &chapterEntry{
RelativePath: relativePath,
IsDir: true,
})
}
} else {
// File entry - check if it's a potential chapter file
ext := strings.ToLower(filepath.Ext(entry.Name()))
if ext == ".cbz" || ext == ".cbr" || ext == ".pdf" || ext == ".zip" {
entries = append(entries, &chapterEntry{
RelativePath: relativePath,
IsDir: false,
})
}
}
}
return entries, nil
}
// isImageOnlyDirectory checks if a directory contains only image files (no subdirectories or other files)
func (p *Local) isImageOnlyDirectory(dirPath string) (bool, error) {
entries, err := os.ReadDir(dirPath)
if err != nil {
return false, err
}
if len(entries) == 0 {
return false, nil
}
hasImages := false
for _, entry := range entries {
if entry.IsDir() {
return false, nil
}
if isFileImage(entry.Name()) {
hasImages = true
} else {
return false, nil
}
}
return hasImages, nil
}
// "0001" -> "1", "0" -> "0"
func cleanChapter(ch string) string {
if ch == "" {
return ""
}
if ch == "0" {
return "0"
}
if strings.HasPrefix(ch, "0") {
return strings.TrimLeft(ch, "0")
}
return ch
}
// FindChapterPages will extract the images
func (p *Local) FindChapterPages(id string) (ret []*hibikemanga.ChapterPage, err error) {
if p.dir == "" {
return make([]*hibikemanga.ChapterPage, 0), nil
}
// id = filepath
// e.g. "series/chapter_1.cbz"
fullpath := filepath.Join(p.dir, id) // e.g. "/collection/series/chapter_1.cbz"
// Prefix with {{manga-local-assets}} to signal the client that this is a local file
// e.g. "{{manga-local-assets}}/series/chapter_1.cbz/image_1.jpg"
formatUrl := func(fileName string) string {
return filepath.ToSlash(filepath.Join(LocalServePath, id, fileName))
}
ext := filepath.Ext(fullpath)
// Close the current pages
if p.currentZipCloser != nil {
_ = p.currentZipCloser.Close()
}
for _, loadedPage := range p.currentPages {
loadedPage.buf = nil
}
p.currentPages = make(map[string]*loadedPage)
p.currentZipCloser = nil
p.currentChapterPath = fullpath
switch ext {
case ".zip", ".cbz":
r, err := zip.OpenReader(fullpath)
if err != nil {
return nil, err
}
defer r.Close()
for _, f := range r.File {
if !isFileImage(f.Name) {
continue
}
page, err := f.Open()
if err != nil {
return nil, fmt.Errorf("failed to open page: %w", err)
}
buf, err := io.ReadAll(page)
if err != nil {
return nil, fmt.Errorf("failed to read page: %w", err)
}
p.currentPages[strings.ToLower(f.Name)] = &loadedPage{
buf: buf,
page: &hibikemanga.ChapterPage{
Provider: LocalProvider,
URL: formatUrl(f.Name),
Index: 0, // placeholder, will be set later
Buf: buf,
},
}
}
case ".pdf":
// doc, err := fitz.New(fullpath)
// if err != nil {
// return nil, fmt.Errorf("failed to open PDF file: %w", err)
// }
// defer doc.Close()
// // Load images into memory
// for n := 0; n < doc.NumPage(); n++ {
// img, err := doc.Image(n)
// if err != nil {
// panic(err)
// }
// var buf bytes.Buffer
// err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpeg.DefaultQuality})
// if err != nil {
// panic(err)
// }
// p.currentPages[fmt.Sprintf("page_%d.jpg", n)] = &loadedPage{
// buf: buf.Bytes(),
// page: &hibikemanga.ChapterPage{
// Provider: LocalProvider,
// URL: formatUrl(fmt.Sprintf("page_%d.jpg", n)),
// Index: n,
// },
// }
// }
default:
// If it's a directory of images
stat, err := os.Stat(fullpath)
if err != nil {
return nil, fmt.Errorf("failed to stat file: %w", err)
}
if !stat.IsDir() {
return nil, fmt.Errorf("file is not a directory: %s", fullpath)
}
entries, err := os.ReadDir(fullpath)
if err != nil {
return nil, fmt.Errorf("failed to read directory: %w", err)
}
for _, entry := range entries {
if !isFileImage(entry.Name()) {
continue
}
page, err := os.Open(filepath.Join(fullpath, entry.Name()))
if err != nil {
return nil, fmt.Errorf("failed to open page: %w", err)
}
buf, err := io.ReadAll(page)
if err != nil {
return nil, fmt.Errorf("failed to read page: %w", err)
}
p.currentPages[strings.ToLower(entry.Name())] = &loadedPage{
buf: buf,
page: &hibikemanga.ChapterPage{
Provider: LocalProvider,
URL: formatUrl(entry.Name()),
Index: 0, // placeholder, will be set later
Buf: buf,
},
}
}
}
type pageStruct struct {
Number float64
LoadedPage *loadedPage
}
pages := make([]*pageStruct, 0)
// Parse and order the pages
for _, loadedPage := range p.currentPages {
scannedPage, ok := parsePageFilename(filepath.Base(loadedPage.page.URL))
if !ok {
continue
}
pages = append(pages, &pageStruct{
Number: scannedPage.Number,
LoadedPage: loadedPage,
})
}
// Sort pages
slices.SortFunc(pages, func(a, b *pageStruct) int {
return strings.Compare(filepath.Base(a.LoadedPage.page.URL), filepath.Base(b.LoadedPage.page.URL))
})
ret = make([]*hibikemanga.ChapterPage, 0)
for idx, pageStruct := range pages {
pageStruct.LoadedPage.page.Index = idx
ret = append(ret, pageStruct.LoadedPage.page)
}
return ret, nil
}
func (p *Local) ReadPage(path string) (ret io.ReadCloser, err error) {
// e.g. path = "/series/chapter_1.cbz/image_1.jpg"
// If the pages are already in memory, return them
if len(p.currentPages) > 0 {
page, ok := p.currentPages[strings.ToLower(filepath.Base(path))]
if ok {
return io.NopCloser(bytes.NewReader(page.buf)), nil // Return the page
}
}
return nil, fmt.Errorf("page not found: %s", path)
}

View File

@@ -0,0 +1,823 @@
package manga_providers
import (
"path/filepath"
"slices"
"strconv"
"strings"
"unicode"
)
type ScannedChapterFile struct {
Chapter []string // can be a single chapter or a range of chapters
MangaTitle string // typically comes before the chapter number
ChapterTitle string // typically comes after the chapter number
Volume []string // typically comes after the chapter number
IsPDF bool
}
type TokenType int
const (
TokenUnknown TokenType = iota
TokenText
TokenNumber
TokenKeyword
TokenSeparator
TokenEnclosed
TokenFileExtension
)
// Token represents a parsed token from the filename
type Token struct {
Type TokenType
Value string
Position int
IsChapter bool
IsVolume bool
}
// Lexer handles the tokenization of the filename
type Lexer struct {
input string
position int
tokens []Token
currentToken int
}
var ChapterKeywords = []string{
"ch", "chp", "chapter", "chap", "c",
}
var VolumeKeywords = []string{
"v", "vol", "volume",
}
var SeparatorChars = []rune{
' ', '-', '_', '.', '[', ']', '(', ')', '{', '}', '~',
}
var ImageExtensions = map[string]struct{}{
".png": {},
".jpg": {},
".jpeg": {},
".gif": {},
".webp": {},
".bmp": {},
".tiff": {},
".tif": {},
}
// NewLexer creates a new lexer instance
func NewLexer(input string) *Lexer {
return &Lexer{
input: strings.TrimSpace(input),
tokens: make([]Token, 0),
currentToken: 0,
}
}
// Tokenize breaks down the input into tokens
func (l *Lexer) Tokenize() []Token {
l.position = 0
l.tokens = make([]Token, 0)
for l.position < len(l.input) {
if l.isWhitespace(l.current()) {
l.skipWhitespace()
continue
}
if l.isEnclosedStart(l.current()) {
l.readEnclosed()
continue
}
if l.isSeparator(l.current()) {
l.readSeparator()
continue
}
if l.isDigit(l.current()) {
l.readNumber()
continue
}
if l.isLetter(l.current()) {
l.readText()
continue
}
// Skip unknown characters
l.position++
}
l.classifyTokens()
return l.tokens
}
// current returns the current character
func (l *Lexer) current() rune {
if l.position >= len(l.input) {
return 0
}
return rune(l.input[l.position])
}
// peek returns the next character without advancing
func (l *Lexer) peek() rune {
if l.position+1 >= len(l.input) {
return 0
}
return rune(l.input[l.position+1])
}
// advance moves to the next character
func (l *Lexer) advance() {
l.position++
}
// isWhitespace checks if character is whitespace
func (l *Lexer) isWhitespace(r rune) bool {
return r == ' ' || r == '\t' || r == '\n' || r == '\r'
}
// isSeparator checks if character is a separator
func (l *Lexer) isSeparator(r rune) bool {
for _, sep := range SeparatorChars {
if r == sep {
return true
}
}
return false
}
// isEnclosedStart checks if character starts an enclosed section
func (l *Lexer) isEnclosedStart(r rune) bool {
return r == '[' || r == '(' || r == '{'
}
// isDigit checks if character is a digit
func (l *Lexer) isDigit(r rune) bool {
return r >= '0' && r <= '9'
}
// isLetter checks if character is a letter
func (l *Lexer) isLetter(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
}
// skipWhitespace skips all whitespace characters
func (l *Lexer) skipWhitespace() {
for l.position < len(l.input) && l.isWhitespace(l.current()) {
l.advance()
}
}
// readEnclosed reads content within brackets/parentheses
func (l *Lexer) readEnclosed() {
start := l.position
openChar := l.current()
var closeChar rune
switch openChar {
case '[':
closeChar = ']'
case '(':
closeChar = ')'
case '{':
closeChar = '}'
default:
l.advance()
return
}
l.advance() // Skip opening character
startContent := l.position
for l.position < len(l.input) && l.current() != closeChar {
l.advance()
}
if l.position < len(l.input) {
content := l.input[startContent:l.position]
l.advance() // Skip closing character
// Only add if content is meaningful
if len(strings.TrimSpace(content)) > 0 {
l.addToken(TokenEnclosed, content, start)
}
}
}
// readSeparator reads separator characters
func (l *Lexer) readSeparator() {
start := l.position
value := string(l.current())
l.advance()
l.addToken(TokenSeparator, value, start)
}
// readNumber reads numeric values (including decimals)
func (l *Lexer) readNumber() {
start := l.position
for l.position < len(l.input) && (l.isDigit(l.current()) || l.current() == '.') {
// Stop if we hit a file extension
if l.current() == '.' && l.position+1 < len(l.input) {
// Check if this is followed by common file extensions
remaining := l.input[l.position+1:]
if strings.HasPrefix(remaining, "cbz") || strings.HasPrefix(remaining, "cbr") ||
strings.HasPrefix(remaining, "pdf") || strings.HasPrefix(remaining, "epub") {
break
}
}
l.advance()
}
value := l.input[start:l.position]
l.addToken(TokenNumber, value, start)
}
// readText reads alphabetic text
func (l *Lexer) readText() {
start := l.position
for l.position < len(l.input) && (l.isLetter(l.current()) || l.isDigit(l.current())) {
l.advance()
}
value := l.input[start:l.position]
lowerValue := strings.ToLower(value) // Use lowercase for keyword checking
// Check if this might be a concatenated keyword that continues with a decimal
if l.startsWithKeyword(lowerValue) && l.position < len(l.input) && l.current() == '.' {
// Look ahead to see if there are more digits after the decimal
tempPos := l.position + 1
if tempPos < len(l.input) && l.isDigit(rune(l.input[tempPos])) {
// Read the decimal part
l.advance() // consume the '.'
for l.position < len(l.input) && l.isDigit(l.current()) {
l.advance()
}
// Update value to include decimal part
value = l.input[start:l.position]
lowerValue = strings.ToLower(value)
}
}
// Check for concatenated keywords like "ch001", "c001", "chp001", "c12.5"
if l.containsKeywordPrefix(lowerValue) {
l.splitKeywordAndNumber(lowerValue, value, start) // Pass both versions
} else {
l.addToken(TokenText, value, start) // Use original case
}
}
// startsWithKeyword checks if text starts with any known keyword
func (l *Lexer) startsWithKeyword(text string) bool {
for _, keyword := range ChapterKeywords {
if strings.HasPrefix(text, keyword) {
return true
}
}
for _, keyword := range VolumeKeywords {
if strings.HasPrefix(text, keyword) {
return true
}
}
return false
}
// containsKeywordPrefix checks if text starts with a known keyword
func (l *Lexer) containsKeywordPrefix(text string) bool {
chKeywords := ChapterKeywords
// Sort by length descending to match longer keywords first
slices.SortFunc(chKeywords, func(a, b string) int {
return len(b) - len(a) // Sort by length descending
})
for _, keyword := range ChapterKeywords {
if strings.HasPrefix(text, keyword) && len(text) > len(keyword) {
remaining := text[len(keyword):]
// Check if remaining part is numeric (including decimals)
if len(remaining) == 0 {
return false
}
return l.isValidNumberPart(remaining)
}
}
for _, keyword := range VolumeKeywords {
if strings.HasPrefix(text, keyword) && len(text) > len(keyword) {
remaining := text[len(keyword):]
// Check if remaining part is numeric (including decimals)
if len(remaining) == 0 {
return false
}
return l.isValidNumberPart(remaining)
}
}
return false
}
// isValidNumberPart checks if string is valid number (including decimals)
func (l *Lexer) isValidNumberPart(s string) bool {
if len(s) == 0 {
return false
}
// Don't allow starting with decimal
if s[0] == '.' {
return false
}
hasDecimal := false
for _, r := range s {
if r == '.' {
if hasDecimal {
return false // Multiple decimals not allowed
}
hasDecimal = true
} else if !l.isDigit(r) {
return false
}
}
return true
}
// splitKeywordAndNumber splits concatenated keyword and number tokens
func (l *Lexer) splitKeywordAndNumber(lowerText, originalText string, position int) {
for _, keyword := range ChapterKeywords {
if strings.HasPrefix(lowerText, keyword) && len(lowerText) > len(keyword) {
// Use original case for the keyword part
originalKeyword := originalText[:len(keyword)]
l.addKeywordToken(originalKeyword, position, true, false)
// Extract number part (keeping original case/formatting)
numberPart := originalText[len(keyword):]
l.addToken(TokenNumber, numberPart, position+len(keyword))
return
}
}
for _, keyword := range VolumeKeywords {
if strings.HasPrefix(lowerText, keyword) && len(lowerText) > len(keyword) {
// Use original case for the keyword part
originalKeyword := originalText[:len(keyword)]
l.addKeywordToken(originalKeyword, position, false, true)
// Extract number part (keeping original case/formatting)
numberPart := originalText[len(keyword):]
l.addToken(TokenNumber, numberPart, position+len(keyword))
return
}
}
}
// addKeywordToken adds a keyword token with flags
func (l *Lexer) addKeywordToken(value string, position int, isChapter, isVolume bool) {
l.tokens = append(l.tokens, Token{
Type: TokenKeyword,
Value: value,
Position: position,
IsChapter: isChapter,
IsVolume: isVolume,
})
}
// addToken adds a token to the list
func (l *Lexer) addToken(tokenType TokenType, value string, position int) {
l.tokens = append(l.tokens, Token{
Type: tokenType,
Value: value,
Position: position,
})
}
// classifyTokens identifies chapter and volume keywords
func (l *Lexer) classifyTokens() {
for i := range l.tokens {
token := &l.tokens[i]
// Check for chapter keywords (case insensitive)
lowerValue := strings.ToLower(token.Value)
for _, keyword := range ChapterKeywords {
if lowerValue == keyword {
token.Type = TokenKeyword
token.IsChapter = true
break
}
}
// Check for volume keywords (case insensitive)
for _, keyword := range VolumeKeywords {
if lowerValue == keyword {
token.Type = TokenKeyword
token.IsVolume = true
break
}
}
// Check for file extensions
if strings.Contains(lowerValue, "pdf") || strings.Contains(lowerValue, "cbz") ||
strings.Contains(lowerValue, "cbr") || strings.Contains(lowerValue, "epub") {
token.Type = TokenFileExtension
}
}
}
// Parser handles the semantic analysis of tokens
type Parser struct {
tokens []Token
result *ScannedChapterFile
}
// NewParser creates a new parser instance
func NewParser(tokens []Token) *Parser {
return &Parser{
tokens: tokens,
result: &ScannedChapterFile{
Chapter: make([]string, 0),
Volume: make([]string, 0),
},
}
}
// Parse performs semantic analysis on the tokens
func (p *Parser) Parse() *ScannedChapterFile {
p.extractChapters()
p.extractVolumes()
p.extractTitles()
p.checkPDF()
return p.result
}
// extractChapters finds and extracts chapter numbers
func (p *Parser) extractChapters() {
for i, token := range p.tokens {
if token.IsChapter {
// Look for numbers after chapter keyword
for j := i + 1; j < len(p.tokens) && j < i+3; j++ {
nextToken := p.tokens[j]
if nextToken.Type == TokenNumber {
p.addChapterNumber(nextToken.Value)
break
} else if nextToken.Type == TokenSeparator {
continue
} else {
break
}
}
} else if token.Type == TokenNumber && !token.IsVolume {
// Standalone number might be a chapter
if p.isLikelyChapterNumber(token, i) {
p.addChapterNumber(token.Value)
}
}
}
// Handle ranges by looking for dash-separated numbers
p.handleChapterRanges()
}
// handleChapterRanges processes chapter ranges like "1-2" or "001-002"
func (p *Parser) handleChapterRanges() {
for i := 0; i < len(p.tokens)-2; i++ {
if p.tokens[i].Type == TokenNumber &&
p.tokens[i+1].Type == TokenSeparator && p.tokens[i+1].Value == "-" &&
p.tokens[i+2].Type == TokenNumber {
// Check if first number is already a chapter
firstIsChapter := false
for _, ch := range p.result.Chapter {
if ch == p.tokens[i].Value {
firstIsChapter = true
break
}
}
if firstIsChapter {
// Add the second number as a chapter too
p.result.Chapter = append(p.result.Chapter, p.tokens[i+2].Value)
}
}
}
}
// extractVolumes finds and extracts volume numbers
func (p *Parser) extractVolumes() {
for i, token := range p.tokens {
if token.IsVolume {
// Look for numbers after volume keyword
for j := i + 1; j < len(p.tokens) && j < i+3; j++ {
nextToken := p.tokens[j]
if nextToken.Type == TokenNumber {
p.result.Volume = append(p.result.Volume, nextToken.Value)
break
} else if nextToken.Type == TokenSeparator {
continue
} else {
break
}
}
}
}
}
// extractTitles finds manga title and chapter title
func (p *Parser) extractTitles() {
// Find first chapter keyword or number position
chapterPos := -1
for i, token := range p.tokens {
if token.IsChapter || (token.Type == TokenNumber && p.isLikelyChapterNumber(token, i)) {
chapterPos = i
break
}
}
if chapterPos > 0 {
// Everything before chapter is likely manga title
titleParts := make([]string, 0)
for i := 0; i < chapterPos; i++ {
token := p.tokens[i]
if token.Type == TokenText && !token.IsVolume && !p.isIgnoredToken(token) {
titleParts = append(titleParts, token.Value)
} else if token.Type == TokenNumber && p.isNumberInTitle(token, i, chapterPos) {
// Include numbers that are part of the title (but not volume indicators)
titleParts = append(titleParts, token.Value)
}
}
if len(titleParts) > 0 {
p.result.MangaTitle = strings.Join(titleParts, " ")
}
// Look for chapter title after chapter number
p.extractChapterTitle(chapterPos)
} else {
// No clear chapter indicator, check if this is a "number - title" pattern
if len(p.result.Chapter) > 0 && p.hasChapterTitlePattern() {
p.extractChapterTitleFromPattern()
} else {
// Treat most text as manga title
p.extractFallbackTitle()
}
}
}
// hasChapterTitlePattern checks for "number - title" pattern
func (p *Parser) hasChapterTitlePattern() bool {
for i := 0; i < len(p.tokens)-2; i++ {
if p.tokens[i].Type == TokenNumber &&
p.tokens[i+1].Type == TokenSeparator && p.tokens[i+1].Value == "-" &&
i+2 < len(p.tokens) && p.tokens[i+2].Type == TokenText {
return true
}
}
return false
}
// extractChapterTitleFromPattern extracts title from "number - title" pattern
func (p *Parser) extractChapterTitleFromPattern() {
for i := 0; i < len(p.tokens)-2; i++ {
if p.tokens[i].Type == TokenNumber &&
p.tokens[i+1].Type == TokenSeparator && p.tokens[i+1].Value == "-" {
// Collect text after the dash
titleParts := make([]string, 0)
for j := i + 2; j < len(p.tokens); j++ {
token := p.tokens[j]
if token.Type == TokenText && !p.isIgnoredToken(token) {
titleParts = append(titleParts, token.Value)
} else if token.Type == TokenFileExtension {
break
}
}
if len(titleParts) > 0 {
p.result.ChapterTitle = strings.Join(titleParts, " ")
}
break
}
}
}
// extractFallbackTitle extracts title when no clear chapter indicators
func (p *Parser) extractFallbackTitle() {
titleParts := make([]string, 0)
for _, token := range p.tokens {
if token.Type == TokenText && !p.isIgnoredToken(token) {
titleParts = append(titleParts, token.Value)
}
}
if len(titleParts) > 0 {
p.result.MangaTitle = strings.Join(titleParts, " ")
}
}
// addChapterNumber adds a chapter number, handling ranges
func (p *Parser) addChapterNumber(value string) {
// Check for range indicators in the surrounding tokens
if strings.Contains(value, "-") {
parts := strings.Split(value, "-")
for _, part := range parts {
if part != "" {
p.result.Chapter = append(p.result.Chapter, strings.TrimSpace(part))
}
}
} else {
p.result.Chapter = append(p.result.Chapter, value)
}
}
// isLikelyChapterNumber determines if a number token is likely a chapter
func (p *Parser) isLikelyChapterNumber(token Token, position int) bool {
// If we already have chapters from keywords, be more strict
if len(p.result.Chapter) > 0 {
return false
}
// Check context - numbers at the start of filename are likely chapters
if position < 3 {
return true
}
// Check if preceded by common patterns
if position > 0 {
prevToken := p.tokens[position-1]
if prevToken.Type == TokenSeparator && (prevToken.Value == "-" || prevToken.Value == " ") {
return true
}
}
return false
}
// isNumberInTitle determines if a number token should be part of the title
func (p *Parser) isNumberInTitle(token Token, position int, chapterPos int) bool {
// Don't include numbers that are right before the chapter position
if position == chapterPos-1 {
return false
}
// Check if this number looks like it's associated with volume
if position > 0 {
prevToken := p.tokens[position-1]
if prevToken.IsVolume {
return false // This number belongs to volume
}
}
// Small numbers (like 05, 2) that appear early in the title are likely part of title
if position < 5 {
if val := token.Value; len(val) <= 2 {
// Check if this number looks like part of a title (e.g., "Title 05")
return true
}
}
return false
}
// isIgnoredToken checks if token should be ignored in titles
func (p *Parser) isIgnoredToken(token Token) bool {
ignoredWords := []string{"digital", "group", "scan", "scans", "team", "raw", "raws"}
for _, word := range ignoredWords {
if token.Value == word {
return true
}
}
// Check for version indicators that shouldn't be in volume
if strings.HasPrefix(token.Value, "v") && len(token.Value) > 1 {
remaining := token.Value[1:]
// If it's just "v" + digit, it might be version, not volume
if len(remaining) > 0 && remaining[0] >= '0' && remaining[0] <= '9' {
// Check context - if preceded by a number, it's likely a version
return true
}
}
return false
}
// checkPDF sets the PDF flag if file is a PDF
func (p *Parser) checkPDF() {
for _, token := range p.tokens {
if token.Type == TokenFileExtension && strings.Contains(token.Value, "pdf") {
p.result.IsPDF = true
break
}
}
}
// scanChapterFilename scans the filename and returns a chapter entry if it is a chapter.
func scanChapterFilename(filename string) (res *ScannedChapterFile, ok bool) {
// Create lexer and tokenize
lexer := NewLexer(filename)
tokens := lexer.Tokenize()
// Create parser and parse
parser := NewParser(tokens)
res = parser.Parse()
return res, true
}
func isFileImage(filename string) bool {
ext := strings.ToLower(filepath.Ext(filename))
_, ok := ImageExtensions[ext]
return ok
}
// extractChapterTitle finds chapter title after chapter number
func (p *Parser) extractChapterTitle(startPos int) {
// Skip to after chapter number
numberPos := -1
for i := startPos; i < len(p.tokens); i++ {
if p.tokens[i].Type == TokenNumber {
numberPos = i
break
}
}
if numberPos == -1 {
return
}
// Look for dash separator followed by text
for i := numberPos + 1; i < len(p.tokens); i++ {
token := p.tokens[i]
if token.Type == TokenSeparator && token.Value == "-" {
// Found dash, collect text after it
titleParts := make([]string, 0)
for j := i + 1; j < len(p.tokens); j++ {
nextToken := p.tokens[j]
if nextToken.Type == TokenText && !p.isIgnoredToken(nextToken) {
titleParts = append(titleParts, nextToken.Value)
} else if nextToken.Type == TokenFileExtension {
break
}
}
if len(titleParts) > 0 {
p.result.ChapterTitle = strings.Join(titleParts, " ")
}
break
}
}
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
type ScannedPageFile struct {
Number float64
Filename string
Ext string
}
func parsePageFilename(filename string) (res *ScannedPageFile, ok bool) {
res = &ScannedPageFile{
Filename: filename,
}
filename = strings.ToLower(filename)
res.Ext = filepath.Ext(filename)
filename = strings.TrimSuffix(filename, res.Ext)
if len(filename) == 0 {
return res, false
}
// Find number at the start
// check if first rune is a digit
numStr := ""
if !unicode.IsDigit(rune(filename[0])) {
// walk until non-digit
for i := 0; i < len(filename); i++ {
if !unicode.IsDigit(rune(filename[i])) && rune(filename[i]) != '.' {
break
}
numStr += string(filename[i])
}
if len(numStr) > 0 {
res.Number, _ = strconv.ParseFloat(numStr, 64)
return res, true
}
}
// walk until first digit
numStr = ""
firstDigitIdx := strings.IndexFunc(filename, unicode.IsDigit)
if firstDigitIdx != -1 {
numStr += string(filename[firstDigitIdx])
// walk until first non-digit or end
for i := firstDigitIdx + 1; i < len(filename); i++ {
if !unicode.IsDigit(rune(filename[i])) && rune(filename[i]) != '.' {
break
}
numStr += string(filename[i])
}
if len(numStr) > 0 {
res.Number, _ = strconv.ParseFloat(numStr, 64)
return res, true
}
}
return res, false
}

View File

@@ -0,0 +1,483 @@
package manga_providers
import (
"fmt"
"slices"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestScanChapterFilename(t *testing.T) {
tests := []struct {
filename string
expectedChapter []string
expectedMangaTitle string
expectedChapterTitle string
expectedVolume []string
}{
{
filename: "1.cbz",
expectedChapter: []string{"1"},
expectedMangaTitle: "",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "2.5.pdf",
expectedChapter: []string{"2.5"},
expectedMangaTitle: "",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Chapter 5.5.pdf",
expectedChapter: []string{"5.5"},
expectedMangaTitle: "",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "ch 1.cbz",
expectedChapter: []string{"1"},
expectedMangaTitle: "",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "ch 1.5-2.cbz",
expectedChapter: []string{"1.5", "2"},
expectedMangaTitle: "",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Some title Chapter 1.cbz",
expectedChapter: []string{"1"},
expectedMangaTitle: "Some title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Chapter 23 The Fanatics.pdf",
expectedChapter: []string{"23"},
expectedMangaTitle: "The Fanatics",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "chapter_1.cbz",
expectedChapter: []string{"1"},
expectedMangaTitle: "",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "1 - Some title.cbz",
expectedChapter: []string{"1"},
expectedMangaTitle: "",
expectedChapterTitle: "Some title",
expectedVolume: []string{},
},
{
filename: "30 - Some title.cbz",
expectedChapter: []string{"30"},
expectedMangaTitle: "",
expectedChapterTitle: "Some title",
expectedVolume: []string{},
},
{
filename: "[Group] Manga Title - c001 [123456].cbz",
expectedChapter: []string{"001"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "[Group] Manga Title - c12.5 [654321].cbz",
expectedChapter: []string{"12.5"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "[Group] Manga Title 05 - ch10.cbz",
expectedChapter: []string{"10"},
expectedMangaTitle: "Manga Title 05",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "[Group] Manga Title - ch10.cbz",
expectedChapter: []string{"10"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "[Group] Manga Title - ch_11.cbz",
expectedChapter: []string{"11"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "[Group] Manga Title - ch-12.cbz",
expectedChapter: []string{"12"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title v01 c001.cbz",
expectedChapter: []string{"001"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{"01"},
},
{
filename: "Manga Title v01 c001.5.cbz",
expectedChapter: []string{"001.5"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{"01"},
},
{
filename: "Manga Title - 003.cbz",
expectedChapter: []string{"003"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 003.5.cbz",
expectedChapter: []string{"003.5"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 3.5 (Digital).cbz",
expectedChapter: []string{"3.5"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 10 (Digital) [Group].cbz",
expectedChapter: []string{"10"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - chp_15.cbz",
expectedChapter: []string{"15"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - chp-16.cbz",
expectedChapter: []string{"16"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - chp17.cbz",
expectedChapter: []string{"17"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - chp 18.cbz",
expectedChapter: []string{"18"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 001 (v2).cbz",
expectedChapter: []string{"001"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 001v2.cbz",
expectedChapter: []string{"001"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{"2"},
},
{
filename: "Manga Title - 001 [v2].cbz",
expectedChapter: []string{"001"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 001 [Digital] [v2].cbz",
expectedChapter: []string{"001"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 001-002.cbz",
expectedChapter: []string{"001", "002"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 001-001.5.cbz",
expectedChapter: []string{"001", "001.5"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 1-2.cbz",
expectedChapter: []string{"1", "2"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 1.5-2.cbz",
expectedChapter: []string{"1.5", "2"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 1 (Sample).cbz",
expectedChapter: []string{"1"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 1 (Preview).cbz",
expectedChapter: []string{"1"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 1 (Special Edition).cbz",
expectedChapter: []string{"1"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 1 (Digital) (Official).cbz",
expectedChapter: []string{"1"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 1.cbz",
expectedChapter: []string{"1"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 1.0.cbz",
expectedChapter: []string{"1.0"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 01.cbz",
expectedChapter: []string{"01"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 01.5.cbz",
expectedChapter: []string{"01.5"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 001.cbz",
expectedChapter: []string{"001"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - 001.5.cbz",
expectedChapter: []string{"001.5"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - ch001.cbz",
expectedChapter: []string{"001"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - ch001.5.cbz",
expectedChapter: []string{"001.5"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - ch_001.cbz",
expectedChapter: []string{"001"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - ch_001.5.cbz",
expectedChapter: []string{"001.5"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - ch-001.cbz",
expectedChapter: []string{"001"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - ch-001.5.cbz",
expectedChapter: []string{"001.5"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - chp001.cbz",
expectedChapter: []string{"001"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - chp_001.cbz",
expectedChapter: []string{"001"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - chp_001.5.cbz",
expectedChapter: []string{"001.5"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - chp-001.cbz",
expectedChapter: []string{"001"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
{
filename: "Manga Title - chp-001.5.cbz",
expectedChapter: []string{"001.5"},
expectedMangaTitle: "Manga Title",
expectedChapterTitle: "",
expectedVolume: []string{},
},
}
for _, tt := range tests {
t.Run(tt.filename, func(t *testing.T) {
res, ok := scanChapterFilename(tt.filename)
if !ok {
t.Errorf("Failed to scan chapter filename: %s", tt.filename)
}
require.Equalf(t, tt.expectedChapter, res.Chapter, "Expected chapter '%v' for '%s' but got '%v'", tt.expectedChapter, tt.filename, res.Chapter)
require.Equalf(t, tt.expectedMangaTitle, res.MangaTitle, "Expected manga title '%v' for '%s' but got '%v'", tt.expectedMangaTitle, tt.filename, res.MangaTitle)
require.Equalf(t, tt.expectedChapterTitle, res.ChapterTitle, "Expected chapter title '%v' for '%s' but got '%v'", tt.expectedChapterTitle, tt.filename, res.ChapterTitle)
require.Equalf(t, tt.expectedVolume, res.Volume, "Expected volume '%v' for '%s' but got '%v'", tt.expectedVolume, tt.filename, res.Volume)
})
}
}
func TestPageSorting(t *testing.T) {
tests := []struct {
expectedOrder []string
}{
{
expectedOrder: []string{"1149-000.jpg", "1149-001.jpg", "1149-002.jpg", "1149-019.jpg", "1149-019b.jpg", "1149-020.jpg"},
},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%v", tt.expectedOrder), func(t *testing.T) {
newSlice := tt.expectedOrder
slices.SortFunc(newSlice, func(a, b string) int {
return strings.Compare(a, b)
})
for i, filename := range tt.expectedOrder {
require.Equalf(t, filename, newSlice[i], "Expected order '%v' for '%s' but got '%v'", tt.expectedOrder, tt.expectedOrder[i], filename)
}
})
}
}
func TestParsePageFilename(t *testing.T) {
tests := []struct {
filename string
expected float64
}{
{
filename: "1.jpg",
expected: 1,
},
{
filename: "1.5.jpg",
expected: 1.5,
},
{
filename: "Page 001.jpg",
expected: 1,
},
{
filename: "1.55.jpg",
expected: 1.55,
},
{
filename: "2.5 -.jpg",
expected: 2.5,
},
{
filename: "page_27.jpg",
expected: 27,
},
}
for _, tt := range tests {
t.Run(tt.filename, func(t *testing.T) {
res, ok := parsePageFilename(tt.filename)
if !ok {
t.Errorf("Failed to parse page filename: %s", tt.filename)
}
require.Equalf(t, tt.expected, res.Number, "Expected number '%v' for '%s' but got '%v'", tt.expected, tt.filename, res.Number)
})
}
}

View File

@@ -0,0 +1,388 @@
package manga_providers
import (
"cmp"
"fmt"
"net/url"
hibikemanga "seanime/internal/extension/hibike/manga"
"seanime/internal/util"
"seanime/internal/util/comparison"
"slices"
"strings"
"time"
"github.com/imroc/req/v3"
"github.com/rs/zerolog"
)
type (
Mangadex struct {
Url string
BaseUrl string
UserAgent string
Client *req.Client
logger *zerolog.Logger
}
MangadexManga struct {
ID string `json:"id"`
Type string `json:"type"`
Attributes MangadexMangeAttributes
Relationships []MangadexMangaRelationship `json:"relationships"`
}
MangadexMangeAttributes struct {
AltTitles []map[string]string `json:"altTitles"`
Title map[string]string `json:"title"`
Year int `json:"year"`
}
MangadexMangaRelationship struct {
ID string `json:"id"`
Type string `json:"type"`
Related string `json:"related"`
Attributes map[string]interface{} `json:"attributes"`
}
MangadexErrorResponse struct {
ID string `json:"id"`
Status string `json:"status"`
Code string `json:"code"`
Title string `json:"title"`
Detail string `json:"detail"`
}
MangadexChapterData struct {
ID string `json:"id"`
Attributes MangadexChapterAttributes `json:"attributes"`
}
MangadexChapterAttributes struct {
Title string `json:"title"`
Volume string `json:"volume"`
Chapter string `json:"chapter"`
UpdatedAt string `json:"updatedAt"`
}
)
// DEVNOTE: Each chapter ID is a unique string provided by Mangadex
func NewMangadex(logger *zerolog.Logger) *Mangadex {
client := req.C().
SetUserAgent(util.GetRandomUserAgent()).
SetTimeout(60 * time.Second).
EnableInsecureSkipVerify().
ImpersonateChrome()
return &Mangadex{
Url: "https://api.mangadex.org",
BaseUrl: "https://mangadex.org",
Client: client,
UserAgent: util.GetRandomUserAgent(),
logger: logger,
}
}
func (md *Mangadex) GetSettings() hibikemanga.Settings {
return hibikemanga.Settings{
SupportsMultiScanlator: false,
SupportsMultiLanguage: false,
}
}
func (md *Mangadex) Search(opts hibikemanga.SearchOptions) ([]*hibikemanga.SearchResult, error) {
ret := make([]*hibikemanga.SearchResult, 0)
retManga := make([]*MangadexManga, 0)
for i := range 1 {
uri := fmt.Sprintf("%s/manga?title=%s&limit=25&offset=%d&order[relevance]=desc&contentRating[]=safe&contentRating[]=suggestive&includes[]=cover_art", md.Url, url.QueryEscape(opts.Query), 25*i)
var data struct {
Data []*MangadexManga `json:"data"`
}
resp, err := md.Client.R().
SetHeader("Referer", "https://google.com").
SetSuccessResult(&data).
Get(uri)
if err != nil {
md.logger.Error().Err(err).Msg("mangadex: Failed to send request")
return nil, err
}
if !resp.IsSuccessState() {
md.logger.Error().Str("status", resp.Status).Msg("mangadex: Request failed")
return nil, fmt.Errorf("failed to decode response: status %s", resp.Status)
}
retManga = append(retManga, data.Data...)
}
for _, manga := range retManga {
var altTitles []string
for _, title := range manga.Attributes.AltTitles {
altTitle, ok := title["en"]
if ok {
altTitles = append(altTitles, altTitle)
}
altTitle, ok = title["jp"]
if ok {
altTitles = append(altTitles, altTitle)
}
altTitle, ok = title["ja"]
if ok {
altTitles = append(altTitles, altTitle)
}
}
t := getTitle(manga.Attributes)
var img string
for _, relation := range manga.Relationships {
if relation.Type == "cover_art" {
fn, ok := relation.Attributes["fileName"].(string)
if ok {
img = fmt.Sprintf("%s/covers/%s/%s.512.jpg", md.BaseUrl, manga.ID, fn)
} else {
img = fmt.Sprintf("%s/covers/%s/%s.jpg.512.jpg", md.BaseUrl, manga.ID, relation.ID)
}
}
}
format := strings.ToUpper(manga.Type)
if format == "ADAPTATION" {
format = "MANGA"
}
compRes, _ := comparison.FindBestMatchWithSorensenDice(&opts.Query, []*string{&t})
result := &hibikemanga.SearchResult{
ID: manga.ID,
Title: t,
Synonyms: altTitles,
Image: img,
Year: manga.Attributes.Year,
SearchRating: compRes.Rating,
Provider: string(MangadexProvider),
}
ret = append(ret, result)
}
if len(ret) == 0 {
md.logger.Error().Msg("mangadex: No results found")
return nil, ErrNoResults
}
md.logger.Info().Int("count", len(ret)).Msg("mangadex: Found results")
return ret, nil
}
func (md *Mangadex) FindChapters(id string) ([]*hibikemanga.ChapterDetails, error) {
ret := make([]*hibikemanga.ChapterDetails, 0)
md.logger.Debug().Str("mangaId", id).Msg("mangadex: Finding chapters")
for page := 0; page <= 1; page++ {
uri := fmt.Sprintf("%s/manga/%s/feed?limit=500&translatedLanguage%%5B%%5D=en&includes[]=scanlation_group&includes[]=user&order[volume]=desc&order[chapter]=desc&offset=%d&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic", md.Url, id, 500*page)
var data struct {
Result string `json:"result"`
Errors []MangadexErrorResponse `json:"errors"`
Data []MangadexChapterData `json:"data"`
}
resp, err := md.Client.R().
SetSuccessResult(&data).
Get(uri)
if err != nil {
md.logger.Error().Err(err).Msg("mangadex: Failed to send request")
return nil, err
}
if !resp.IsSuccessState() {
md.logger.Error().Str("status", resp.Status).Msg("mangadex: Request failed")
return nil, fmt.Errorf("failed to decode response: status %s", resp.Status)
}
if data.Result == "error" {
md.logger.Error().Str("error", data.Errors[0].Title).Str("detail", data.Errors[0].Detail).Msg("mangadex: Could not find chapters")
return nil, fmt.Errorf("could not find chapters: %s", data.Errors[0].Detail)
}
slices.Reverse(data.Data)
chapterMap := make(map[string]*hibikemanga.ChapterDetails)
idx := uint(len(ret))
for _, chapter := range data.Data {
if chapter.Attributes.Chapter == "" {
continue
}
title := "Chapter " + fmt.Sprintf("%s", chapter.Attributes.Chapter) + " "
if _, ok := chapterMap[chapter.Attributes.Chapter]; ok {
continue
}
chapterMap[chapter.Attributes.Chapter] = &hibikemanga.ChapterDetails{
ID: chapter.ID,
Title: title,
Index: idx,
Chapter: chapter.Attributes.Chapter,
UpdatedAt: chapter.Attributes.UpdatedAt,
Provider: string(MangadexProvider),
}
idx++
}
chapters := make([]*hibikemanga.ChapterDetails, 0, len(chapterMap))
for _, chapter := range chapterMap {
chapters = append(chapters, chapter)
}
slices.SortStableFunc(chapters, func(i, j *hibikemanga.ChapterDetails) int {
return cmp.Compare(i.Index, j.Index)
})
if len(chapters) > 0 {
ret = append(ret, chapters...)
} else {
break
}
}
if len(ret) == 0 {
md.logger.Error().Msg("mangadex: No chapters found")
return nil, ErrNoChapters
}
md.logger.Info().Int("count", len(ret)).Msg("mangadex: Found chapters")
return ret, nil
}
func (md *Mangadex) FindChapterPages(id string) ([]*hibikemanga.ChapterPage, error) {
ret := make([]*hibikemanga.ChapterPage, 0)
md.logger.Debug().Str("chapterId", id).Msg("mangadex: Finding chapter pages")
uri := fmt.Sprintf("%s/at-home/server/%s", md.Url, id)
var data struct {
BaseUrl string `json:"baseUrl"`
Chapter struct {
Hash string `json:"hash"`
Data []string `json:"data"`
}
}
resp, err := md.Client.R().
SetHeader("User-Agent", util.GetRandomUserAgent()).
SetSuccessResult(&data).
Get(uri)
if err != nil {
md.logger.Error().Err(err).Msg("mangadex: Failed to get chapter pages")
return nil, err
}
if !resp.IsSuccessState() {
md.logger.Error().Str("status", resp.Status).Msg("mangadex: Request failed")
return nil, fmt.Errorf("failed to decode response: status %s", resp.Status)
}
for i, page := range data.Chapter.Data {
ret = append(ret, &hibikemanga.ChapterPage{
Provider: string(MangadexProvider),
URL: fmt.Sprintf("%s/data/%s/%s", data.BaseUrl, data.Chapter.Hash, page),
Index: i,
Headers: map[string]string{
"Referer": "https://mangadex.org",
},
})
}
if len(ret) == 0 {
md.logger.Error().Msg("mangadex: No pages found")
return nil, ErrNoPages
}
md.logger.Info().Int("count", len(ret)).Msg("mangadex: Found pages")
return ret, nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func getTitle(attributes MangadexMangeAttributes) string {
altTitles := attributes.AltTitles
title := attributes.Title
enTitle := title["en"]
if enTitle != "" {
return enTitle
}
var enAltTitle string
for _, altTitle := range altTitles {
if value, ok := altTitle["en"]; ok {
enAltTitle = value
break
}
}
if enAltTitle != "" && util.IsMostlyLatinString(enAltTitle) {
return enAltTitle
}
// Check for other language titles
if jaRoTitle, ok := title["ja-ro"]; ok {
return jaRoTitle
}
if jpRoTitle, ok := title["jp-ro"]; ok {
return jpRoTitle
}
if jpTitle, ok := title["jp"]; ok {
return jpTitle
}
if jaTitle, ok := title["ja"]; ok {
return jaTitle
}
if koTitle, ok := title["ko"]; ok {
return koTitle
}
// Check alt titles for other languages
for _, altTitle := range altTitles {
if value, ok := altTitle["ja-ro"]; ok {
return value
}
}
for _, altTitle := range altTitles {
if value, ok := altTitle["jp-ro"]; ok {
return value
}
}
for _, altTitle := range altTitles {
if value, ok := altTitle["jp"]; ok {
return value
}
}
for _, altTitle := range altTitles {
if value, ok := altTitle["ja"]; ok {
return value
}
}
for _, altTitle := range altTitles {
if value, ok := altTitle["ko"]; ok {
return value
}
}
return ""
}

View File

@@ -0,0 +1,153 @@
package manga_providers
import (
"github.com/stretchr/testify/assert"
hibikemanga "seanime/internal/extension/hibike/manga"
"seanime/internal/util"
"testing"
)
func TestMangadex_Search(t *testing.T) {
tests := []struct {
name string
query string
}{
{
name: "One Piece",
query: "One Piece",
},
{
name: "Jujutsu Kaisen",
query: "Jujutsu Kaisen",
},
{
name: "Boku no Kokoro no Yabai Yatsu",
query: "Boku no Kokoro no Yabai Yatsu",
},
}
mangadex := NewMangadex(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
searchRes, err := mangadex.Search(hibikemanga.SearchOptions{
Query: tt.query,
})
if assert.NoError(t, err, "mangadex.Search() error") {
assert.NotEmpty(t, searchRes, "search result is empty")
for _, res := range searchRes {
t.Logf("Title: %s", res.Title)
t.Logf("\tID: %s", res.ID)
t.Logf("\tYear: %d", res.Year)
t.Logf("\tImage: %s", res.Image)
t.Logf("\tProvider: %s", res.Provider)
t.Logf("\tSearchRating: %f", res.SearchRating)
t.Logf("\tSynonyms: %v", res.Synonyms)
t.Log("--------------------------------------------------")
}
}
})
}
}
func TestMangadex_FindChapters(t *testing.T) {
tests := []struct {
name string
id string
atLeast int
}{
//{
// name: "One Piece",
// id: "One-Piece",
// atLeast: 1100,
//},
{
name: "Jujutsu Kaisen",
id: "c52b2ce3-7f95-469c-96b0-479524fb7a1a",
atLeast: 250,
},
{
name: "The Dangers in My Heart",
id: "3df1a9a3-a1be-47a3-9e90-9b3e55b1d0ac",
atLeast: 141,
},
}
mangadex := NewMangadex(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
chapters, err := mangadex.FindChapters(tt.id)
if assert.NoError(t, err, "mangadex.FindChapters() error") {
assert.NotEmpty(t, chapters, "chapters is empty")
assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected")
for _, chapter := range chapters {
t.Logf("Title: %s", chapter.Title)
t.Logf("\tSlug: %s", chapter.ID)
t.Logf("\tURL: %s", chapter.URL)
t.Logf("\tIndex: %d", chapter.Index)
t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt)
t.Log("--------------------------------------------------")
}
}
})
}
}
func TestMangadex_FindChapterPages(t *testing.T) {
tests := []struct {
name string
id string
chapterId string
}{
{
name: "The Dangers in My Heart",
id: "3df1a9a3-a1be-47a3-9e90-9b3e55b1d0ac",
chapterId: "5145ea39-be4b-4bf9-81e7-4f90961db857", // Chapter 1
},
{
name: "Kagurabachi",
id: "",
chapterId: "9c9652fc-10d2-40b3-9382-16fb072d3068", // Chapter 1
},
}
mangadex := NewMangadex(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pages, err := mangadex.FindChapterPages(tt.chapterId)
if assert.NoError(t, err, "mangadex.FindChapterPages() error") {
assert.NotEmpty(t, pages, "pages is empty")
for _, page := range pages {
t.Logf("Index: %d", page.Index)
t.Logf("\tURL: %s", page.URL)
t.Log("--------------------------------------------------")
}
}
})
}
}

View File

@@ -0,0 +1,220 @@
package manga_providers
import (
"fmt"
"net/url"
hibikemanga "seanime/internal/extension/hibike/manga"
"seanime/internal/util"
"seanime/internal/util/comparison"
"strings"
"sync"
"time"
"github.com/gocolly/colly"
"github.com/imroc/req/v3"
"github.com/rs/zerolog"
)
// DEVNOTE: Shelved due to WAF captcha
type (
Mangafire struct {
Url string
Client *req.Client
UserAgent string
logger *zerolog.Logger
}
)
func NewMangafire(logger *zerolog.Logger) *Mangafire {
client := req.C().
SetUserAgent(util.GetRandomUserAgent()).
SetTimeout(60 * time.Second).
EnableInsecureSkipVerify().
ImpersonateChrome()
return &Mangafire{
Url: "https://mangafire.to",
Client: client,
UserAgent: util.GetRandomUserAgent(),
logger: logger,
}
}
func (mf *Mangafire) GetSettings() hibikemanga.Settings {
return hibikemanga.Settings{
SupportsMultiScanlator: false,
SupportsMultiLanguage: false,
}
}
func (mf *Mangafire) Search(opts hibikemanga.SearchOptions) ([]*hibikemanga.SearchResult, error) {
results := make([]*hibikemanga.SearchResult, 0)
mf.logger.Debug().Str("query", opts.Query).Msg("mangafire: Searching manga")
yearStr := ""
if opts.Year > 0 {
yearStr = fmt.Sprintf("&year=%%5B%%5D=%d", opts.Year)
}
uri := fmt.Sprintf("%s/filter?keyword=%s%s&sort=recently_updated", mf.Url, url.QueryEscape(opts.Query), yearStr)
c := colly.NewCollector(
colly.UserAgent(util.GetRandomUserAgent()),
)
c.WithTransport(mf.Client.Transport)
type ToVisit struct {
ID string
Title string
Image string
}
toVisit := make([]ToVisit, 0)
c.OnHTML("main div.container div.original div.unit", func(e *colly.HTMLElement) {
id := e.ChildAttr("a", "href")
if len(toVisit) >= 15 || id == "" {
return
}
title := ""
e.ForEachWithBreak("div.info a", func(i int, e *colly.HTMLElement) bool {
if i == 0 && e.Text != "" {
title = strings.TrimSpace(e.Text)
return false
}
return true
})
obj := ToVisit{
ID: id,
Title: title,
Image: e.ChildAttr("img", "src"),
}
if obj.Title != "" && obj.ID != "" {
toVisit = append(toVisit, obj)
}
})
err := c.Visit(uri)
if err != nil {
mf.logger.Error().Err(err).Msg("mangafire: Failed to visit")
return nil, err
}
wg := sync.WaitGroup{}
wg.Add(len(toVisit))
for _, v := range toVisit {
go func(tv ToVisit) {
defer wg.Done()
c2 := colly.NewCollector(
colly.UserAgent(mf.UserAgent),
)
c2.WithTransport(mf.Client.Transport)
result := &hibikemanga.SearchResult{
Provider: MangafireProvider,
}
// Synonyms
c2.OnHTML("main div#manga-page div.info h6", func(e *colly.HTMLElement) {
parts := strings.Split(e.Text, "; ")
for i, v := range parts {
parts[i] = strings.TrimSpace(v)
}
syn := strings.Join(parts, "")
if syn != "" {
result.Synonyms = append(result.Synonyms, syn)
}
})
// Year
c2.OnHTML("main div#manga-page div.meta", func(e *colly.HTMLElement) {
if result.Year != 0 || e.Text == "" {
return
}
parts := strings.Split(e.Text, "Published: ")
if len(parts) < 2 {
return
}
parts2 := strings.Split(parts[1], " to")
if len(parts2) < 2 {
return
}
result.Year = util.StringToIntMust(strings.TrimSpace(parts2[0]))
})
result.ID = tv.ID
result.Title = tv.Title
result.Image = tv.Image
err := c2.Visit(fmt.Sprintf("%s/%s", mf.Url, tv.ID))
if err != nil {
mf.logger.Error().Err(err).Str("id", tv.ID).Msg("mangafire: Failed to visit manga page")
return
}
// Comparison
compTitles := []*string{&result.Title}
for _, syn := range result.Synonyms {
if !util.IsMostlyLatinString(syn) {
continue
}
compTitles = append(compTitles, &syn)
}
compRes, _ := comparison.FindBestMatchWithSorensenDice(&opts.Query, compTitles)
result.SearchRating = compRes.Rating
results = append(results, result)
}(v)
}
wg.Wait()
if len(results) == 0 {
mf.logger.Error().Str("query", opts.Query).Msg("mangafire: No results found")
return nil, ErrNoResults
}
mf.logger.Info().Int("count", len(results)).Msg("mangafire: Found results")
return results, nil
}
func (mf *Mangafire) FindChapters(id string) ([]*hibikemanga.ChapterDetails, error) {
ret := make([]*hibikemanga.ChapterDetails, 0)
mf.logger.Debug().Str("mangaId", id).Msg("mangafire: Finding chapters")
// code
if len(ret) == 0 {
mf.logger.Error().Str("mangaId", id).Msg("mangafire: No chapters found")
return nil, ErrNoChapters
}
mf.logger.Info().Int("count", len(ret)).Msg("mangafire: Found chapters")
return ret, nil
}
func (mf *Mangafire) FindChapterPages(id string) ([]*hibikemanga.ChapterPage, error) {
ret := make([]*hibikemanga.ChapterPage, 0)
mf.logger.Debug().Str("chapterId", id).Msg("mangafire: Finding chapter pages")
// code
if len(ret) == 0 {
mf.logger.Error().Str("chapterId", id).Msg("mangafire: No pages found")
return nil, ErrNoPages
}
mf.logger.Info().Int("count", len(ret)).Msg("mangafire: Found pages")
return ret, nil
}

View File

@@ -0,0 +1,132 @@
package manga_providers
import (
"github.com/stretchr/testify/assert"
hibikemanga "seanime/internal/extension/hibike/manga"
"seanime/internal/util"
"testing"
)
func TestMangafire_Search(t *testing.T) {
tests := []struct {
name string
query string
}{
{
name: "Boku no Kokoro no Yabai Yatsu",
query: "Boku no Kokoro no Yabai Yatsu",
},
{
name: "Dangers in My Heart",
query: "Dangers in My Heart",
},
}
provider := NewMangafire(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
searchRes, err := provider.Search(hibikemanga.SearchOptions{
Query: tt.query,
})
if assert.NoError(t, err, "provider.Search() error") {
assert.NotEmpty(t, searchRes, "search result is empty")
for _, res := range searchRes {
t.Logf("Title: %s", res.Title)
t.Logf("\tID: %s", res.ID)
t.Logf("\tYear: %d", res.Year)
t.Logf("\tImage: %s", res.Image)
t.Logf("\tProvider: %s", res.Provider)
t.Logf("\tSearchRating: %f", res.SearchRating)
t.Logf("\tSynonyms: %v", res.Synonyms)
t.Log("--------------------------------------------------")
}
}
})
}
}
func TestMangafire_FindChapters(t *testing.T) {
tests := []struct {
name string
id string
atLeast int
}{
{
name: "The Dangers in My Heart",
id: "/manga/boku-no-kokoro-no-yabai-yatsu.vv882",
atLeast: 141,
},
}
provider := NewMangafire(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
chapters, err := provider.FindChapters(tt.id)
if assert.NoError(t, err, "provider.FindChapters() error") {
assert.NotEmpty(t, chapters, "chapters is empty")
assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected")
for _, chapter := range chapters {
t.Logf("Title: %s", chapter.Title)
t.Logf("\tSlug: %s", chapter.ID)
t.Logf("\tURL: %s", chapter.URL)
t.Logf("\tIndex: %d", chapter.Index)
t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt)
t.Log("--------------------------------------------------")
}
}
})
}
}
//func TestMangafire_FindChapterPages(t *testing.T) {
//
// tests := []struct {
// name string
// chapterId string
// }{
// {
// name: "The Dangers in My Heart",
// chapterId: "", // Chapter 1
// },
// }
//
// provider := NewMangafire(util.NewLogger())
//
// for _, tt := range tests {
//
// t.Run(tt.name, func(t *testing.T) {
//
// pages, err := provider.FindChapterPages(tt.chapterId)
// if assert.NoError(t, err, "provider.FindChapterPages() error") {
// assert.NotEmpty(t, pages, "pages is empty")
//
// for _, page := range pages {
// t.Logf("Index: %d", page.Index)
// t.Logf("\tURL: %s", page.URL)
// t.Log("--------------------------------------------------")
// }
// }
//
// })
//
// }
//
//}

View File

@@ -0,0 +1,302 @@
package manga_providers
import (
"bytes"
"fmt"
"net/url"
hibikemanga "seanime/internal/extension/hibike/manga"
"seanime/internal/util"
"seanime/internal/util/comparison"
"slices"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/imroc/req/v3"
"github.com/rs/zerolog"
)
type (
Manganato struct {
Url string
Client *req.Client
logger *zerolog.Logger
}
ManganatoSearchResult struct {
ID string `json:"id"`
Name string `json:"name"`
NameUnsigned string `json:"nameunsigned"`
LastChapter string `json:"lastchapter"`
Image string `json:"image"`
Author string `json:"author"`
StoryLink string `json:"story_link"`
}
)
func NewManganato(logger *zerolog.Logger) *Manganato {
client := req.C().
SetUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36").
SetTimeout(60 * time.Second).
EnableInsecureSkipVerify().
ImpersonateSafari()
return &Manganato{
Url: "https://natomanga.com",
Client: client,
logger: logger,
}
}
func (mp *Manganato) GetSettings() hibikemanga.Settings {
return hibikemanga.Settings{
SupportsMultiScanlator: false,
SupportsMultiLanguage: false,
}
}
func (mp *Manganato) Search(opts hibikemanga.SearchOptions) (ret []*hibikemanga.SearchResult, err error) {
ret = make([]*hibikemanga.SearchResult, 0)
mp.logger.Debug().Str("query", opts.Query).Msg("manganato: Searching manga")
q := opts.Query
q = strings.ReplaceAll(q, " ", "_")
q = strings.ToLower(q)
q = strings.TrimSpace(q)
q = url.QueryEscape(q)
uri := fmt.Sprintf("https://natomanga.com/search/story/%s", q)
resp, err := mp.Client.R().
SetHeader("User-Agent", util.GetRandomUserAgent()).
Get(uri)
if err != nil {
mp.logger.Error().Err(err).Str("uri", uri).Msg("manganato: Failed to send request")
return nil, err
}
if !resp.IsSuccessState() {
mp.logger.Error().Str("status", resp.Status).Str("uri", uri).Msg("manganato: Request failed")
return nil, fmt.Errorf("failed to fetch search results: status %s", resp.Status)
}
bodyBytes := resp.Bytes()
//mp.logger.Debug().Str("body", string(bodyBytes)).Msg("manganato: Response body")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(bodyBytes))
if err != nil {
mp.logger.Error().Err(err).Msg("manganato: Failed to parse HTML")
return nil, err
}
doc.Find("div.story_item").Each(func(i int, s *goquery.Selection) {
defer func() {
if r := recover(); r != nil {
}
}()
result := &hibikemanga.SearchResult{
Provider: string(ManganatoProvider),
}
href, exists := s.Find("a").Attr("href")
if !exists {
return
}
if !strings.HasPrefix(href, "https://natomanga.com/") &&
!strings.HasPrefix(href, "https://www.natomanga.com/") &&
!strings.HasPrefix(href, "https://www.chapmanganato.com/") &&
!strings.HasPrefix(href, "https://chapmanganato.com/") {
return
}
result.ID = href
splitHref := strings.Split(result.ID, "/")
if strings.Contains(href, "chapmanganato") {
result.ID = "chapmanganato$"
} else {
result.ID = "manganato$"
}
if len(splitHref) > 4 {
result.ID += splitHref[4]
}
result.Title = s.Find("h3.story_name").Text()
result.Title = strings.TrimSpace(result.Title)
result.Image, _ = s.Find("img").Attr("src")
compRes, _ := comparison.FindBestMatchWithSorensenDice(&opts.Query, []*string{&result.Title})
result.SearchRating = compRes.Rating
ret = append(ret, result)
})
if len(ret) == 0 {
mp.logger.Error().Str("query", opts.Query).Msg("manganato: No results found")
return nil, ErrNoResults
}
mp.logger.Info().Int("count", len(ret)).Msg("manganato: Found results")
return ret, nil
}
func (mp *Manganato) FindChapters(id string) (ret []*hibikemanga.ChapterDetails, err error) {
ret = make([]*hibikemanga.ChapterDetails, 0)
mp.logger.Debug().Str("mangaId", id).Msg("manganato: Finding chapters")
splitId := strings.Split(id, "$")
if len(splitId) != 2 {
mp.logger.Error().Str("mangaId", id).Msg("manganato: Invalid manga ID")
return nil, ErrNoChapters
}
uri := ""
if splitId[0] == "manganato" {
uri = fmt.Sprintf("https://natomanga.com/manga/%s", splitId[1])
} else if splitId[0] == "chapmanganato" {
uri = fmt.Sprintf("https://chapmanganato.com/manga/%s", splitId[1])
}
resp, err := mp.Client.R().
SetHeader("User-Agent", util.GetRandomUserAgent()).
Get(uri)
if err != nil {
mp.logger.Error().Err(err).Str("uri", uri).Msg("manganato: Failed to send request")
return nil, err
}
if !resp.IsSuccessState() {
mp.logger.Error().Str("status", resp.Status).Str("uri", uri).Msg("manganato: Request failed")
return nil, fmt.Errorf("failed to fetch chapters: status %s", resp.Status)
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
mp.logger.Error().Err(err).Msg("manganato: Failed to parse HTML")
return nil, err
}
doc.Find(".chapter-list .row").Each(func(i int, s *goquery.Selection) {
defer func() {
if r := recover(); r != nil {
}
}()
name := s.Find("a").Text()
if strings.HasPrefix(name, "Vol.") {
split := strings.Split(name, " ")
name = strings.Join(split[1:], " ")
}
chStr := strings.TrimSpace(strings.Split(name, " ")[1])
chStr = strings.TrimSuffix(chStr, ":")
href, exists := s.Find("a").Attr("href")
if !exists {
return
}
hrefParts := strings.Split(href, "/")
if len(hrefParts) < 6 {
return
}
chapterId := hrefParts[5]
chapter := &hibikemanga.ChapterDetails{
Provider: string(ManganatoProvider),
ID: splitId[1] + "$" + chapterId,
URL: href,
Title: strings.TrimSpace(name),
Chapter: chStr,
}
ret = append(ret, chapter)
})
slices.Reverse(ret)
for i, chapter := range ret {
chapter.Index = uint(i)
}
if len(ret) == 0 {
mp.logger.Error().Str("mangaId", id).Msg("manganato: No chapters found")
return nil, ErrNoChapters
}
mp.logger.Info().Int("count", len(ret)).Msg("manganato: Found chapters")
return ret, nil
}
func (mp *Manganato) FindChapterPages(id string) (ret []*hibikemanga.ChapterPage, err error) {
ret = make([]*hibikemanga.ChapterPage, 0)
mp.logger.Debug().Str("chapterId", id).Msg("manganato: Finding chapter pages")
splitId := strings.Split(id, "$")
if len(splitId) != 2 {
mp.logger.Error().Str("chapterId", id).Msg("manganato: Invalid chapter ID")
return nil, ErrNoPages
}
uri := fmt.Sprintf("https://natomanga.com/manga/%s/%s", splitId[0], splitId[1])
resp, err := mp.Client.R().
SetHeader("User-Agent", util.GetRandomUserAgent()).
SetHeader("Referer", "https://natomanga.com/").
Get(uri)
if err != nil {
mp.logger.Error().Err(err).Str("uri", uri).Msg("manganato: Failed to send request")
return nil, err
}
if !resp.IsSuccessState() {
mp.logger.Error().Str("status", resp.Status).Str("uri", uri).Msg("manganato: Request failed")
return nil, fmt.Errorf("failed to fetch chapter pages: status %s", resp.Status)
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
mp.logger.Error().Err(err).Msg("manganato: Failed to parse HTML")
return nil, err
}
doc.Find(".container-chapter-reader img").Each(func(i int, s *goquery.Selection) {
defer func() {
if r := recover(); r != nil {
}
}()
src, exists := s.Attr("src")
if !exists || src == "" {
return
}
page := &hibikemanga.ChapterPage{
Provider: string(ManganatoProvider),
URL: src,
Index: len(ret),
Headers: map[string]string{
"Referer": "https://natomanga.com/",
},
}
ret = append(ret, page)
})
if len(ret) == 0 {
mp.logger.Error().Str("chapterId", id).Msg("manganato: No pages found")
return nil, ErrNoPages
}
mp.logger.Info().Int("count", len(ret)).Msg("manganato: Found pages")
return ret, nil
}

View File

@@ -0,0 +1,130 @@
package manga_providers
import (
hibikemanga "seanime/internal/extension/hibike/manga"
"seanime/internal/util"
"testing"
"github.com/stretchr/testify/assert"
)
func TestManganato_Search(t *testing.T) {
tests := []struct {
name string
query string
}{
{
name: "Boku no Kokoro no Yabai Yatsu",
query: "Boku no Kokoro no Yabai Yatsu",
},
}
provider := NewManganato(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
searchRes, err := provider.Search(hibikemanga.SearchOptions{
Query: tt.query,
})
if assert.NoError(t, err, "provider.Search() error") {
assert.NotEmpty(t, searchRes, "search result is empty")
for _, res := range searchRes {
t.Logf("Title: %s", res.Title)
t.Logf("\tID: %s", res.ID)
t.Logf("\tYear: %d", res.Year)
t.Logf("\tImage: %s", res.Image)
t.Logf("\tProvider: %s", res.Provider)
t.Logf("\tSearchRating: %f", res.SearchRating)
t.Logf("\tSynonyms: %v", res.Synonyms)
t.Log("--------------------------------------------------")
}
}
})
}
}
func TestManganato_FindChapters(t *testing.T) {
tests := []struct {
name string
id string
atLeast int
}{
{
name: "The Dangers in My Heart",
id: "manganato$boku-no-kokoro-no-yabai-yatsu",
atLeast: 141,
},
}
provider := NewManganato(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
chapters, err := provider.FindChapters(tt.id)
if assert.NoError(t, err, "provider.FindChapters() error") {
assert.NotEmpty(t, chapters, "chapters is empty")
assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected")
for _, chapter := range chapters {
t.Logf("Title: %s", chapter.Title)
t.Logf("\tID: %s", chapter.ID)
t.Logf("\tChapter: %s", chapter.Chapter)
t.Logf("\tURL: %s", chapter.URL)
t.Logf("\tIndex: %d", chapter.Index)
t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt)
t.Log("--------------------------------------------------")
}
}
})
}
}
func TestManganato_FindChapterPages(t *testing.T) {
tests := []struct {
name string
chapterId string
}{
{
name: "The Dangers in My Heart",
chapterId: "boku-no-kokoro-no-yabai-yatsu$chapter-20", // Chapter 20
},
}
provider := NewManganato(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pages, err := provider.FindChapterPages(tt.chapterId)
if assert.NoError(t, err, "provider.FindChapterPages() error") {
assert.NotEmpty(t, pages, "pages is empty")
for _, page := range pages {
t.Logf("Index: %d", page.Index)
t.Logf("\tURL: %s", page.URL)
t.Log("--------------------------------------------------")
}
}
})
}
}

View File

@@ -0,0 +1,235 @@
package manga_providers
import (
"fmt"
"net/url"
hibikemanga "seanime/internal/extension/hibike/manga"
"seanime/internal/util"
"seanime/internal/util/comparison"
"slices"
"strconv"
"strings"
"time"
"github.com/gocolly/colly"
"github.com/imroc/req/v3"
"github.com/rs/zerolog"
)
type (
Mangapill struct {
Url string
Client *req.Client
UserAgent string
logger *zerolog.Logger
}
)
func NewMangapill(logger *zerolog.Logger) *Mangapill {
client := req.C().
SetUserAgent(util.GetRandomUserAgent()).
SetTimeout(60 * time.Second).
EnableInsecureSkipVerify().
ImpersonateChrome()
return &Mangapill{
Url: "https://mangapill.com",
Client: client,
UserAgent: util.GetRandomUserAgent(),
logger: logger,
}
}
// DEVNOTE: Unique ID
// Each chapter ID has this format: {number}${slug} -- e.g. 6502-10004000$gokurakugai-chapter-4
// The chapter ID is split by the $ character to reconstruct the chapter URL for subsequent requests
func (mp *Mangapill) GetSettings() hibikemanga.Settings {
return hibikemanga.Settings{
SupportsMultiScanlator: false,
SupportsMultiLanguage: false,
}
}
func (mp *Mangapill) Search(opts hibikemanga.SearchOptions) (ret []*hibikemanga.SearchResult, err error) {
ret = make([]*hibikemanga.SearchResult, 0)
mp.logger.Debug().Str("query", opts.Query).Msg("mangapill: Searching manga")
uri := fmt.Sprintf("%s/search?q=%s", mp.Url, url.QueryEscape(opts.Query))
c := colly.NewCollector(
colly.UserAgent(mp.UserAgent),
)
c.WithTransport(mp.Client.Transport)
c.OnHTML("div.container div.my-3.justify-end > div", func(e *colly.HTMLElement) {
defer func() {
if r := recover(); r != nil {
}
}()
result := &hibikemanga.SearchResult{
Provider: string(MangapillProvider),
}
result.ID = strings.Split(e.ChildAttr("a", "href"), "/manga/")[1]
result.ID = strings.Replace(result.ID, "/", "$", -1)
title := e.DOM.Find("div > a > div.mt-3").Text()
result.Title = strings.TrimSpace(title)
altTitles := e.DOM.Find("div > a > div.text-xs.text-secondary").Text()
if altTitles != "" {
result.Synonyms = []string{strings.TrimSpace(altTitles)}
}
compTitles := []*string{&result.Title}
if len(result.Synonyms) > 0 {
compTitles = append(compTitles, &result.Synonyms[0])
}
compRes, _ := comparison.FindBestMatchWithSorensenDice(&opts.Query, compTitles)
result.SearchRating = compRes.Rating
result.Image = e.ChildAttr("a img", "data-src")
yearStr := e.DOM.Find("div > div.flex > div").Eq(1).Text()
year, err := strconv.Atoi(strings.TrimSpace(yearStr))
if err != nil {
result.Year = 0
} else {
result.Year = year
}
ret = append(ret, result)
})
err = c.Visit(uri)
if err != nil {
mp.logger.Error().Err(err).Msg("mangapill: Failed to visit")
return nil, err
}
// code
if len(ret) == 0 {
mp.logger.Error().Str("query", opts.Query).Msg("mangapill: No results found")
return nil, ErrNoResults
}
mp.logger.Info().Int("count", len(ret)).Msg("mangapill: Found results")
return ret, nil
}
func (mp *Mangapill) FindChapters(id string) (ret []*hibikemanga.ChapterDetails, err error) {
ret = make([]*hibikemanga.ChapterDetails, 0)
mp.logger.Debug().Str("mangaId", id).Msg("mangapill: Finding chapters")
uriId := strings.Replace(id, "$", "/", -1)
uri := fmt.Sprintf("%s/manga/%s", mp.Url, uriId)
c := colly.NewCollector(
colly.UserAgent(mp.UserAgent),
)
c.WithTransport(mp.Client.Transport)
c.OnHTML("div.container div.border-border div#chapters div.grid-cols-1 a", func(e *colly.HTMLElement) {
defer func() {
if r := recover(); r != nil {
}
}()
chapter := &hibikemanga.ChapterDetails{
Provider: MangapillProvider,
}
chapter.ID = strings.Split(e.Attr("href"), "/chapters/")[1]
chapter.ID = strings.Replace(chapter.ID, "/", "$", -1)
chapter.Title = strings.TrimSpace(e.Text)
splitTitle := strings.Split(chapter.Title, "Chapter ")
if len(splitTitle) < 2 {
return
}
chapter.Chapter = splitTitle[1]
ret = append(ret, chapter)
})
err = c.Visit(uri)
if err != nil {
mp.logger.Error().Err(err).Msg("mangapill: Failed to visit")
return nil, err
}
if len(ret) == 0 {
mp.logger.Error().Str("mangaId", id).Msg("mangapill: No chapters found")
return nil, ErrNoChapters
}
slices.Reverse(ret)
for i, chapter := range ret {
chapter.Index = uint(i)
}
mp.logger.Info().Int("count", len(ret)).Msg("mangapill: Found chapters")
return ret, nil
}
func (mp *Mangapill) FindChapterPages(id string) (ret []*hibikemanga.ChapterPage, err error) {
ret = make([]*hibikemanga.ChapterPage, 0)
mp.logger.Debug().Str("chapterId", id).Msg("mangapill: Finding chapter pages")
uriId := strings.Replace(id, "$", "/", -1)
uri := fmt.Sprintf("%s/chapters/%s", mp.Url, uriId)
c := colly.NewCollector(
colly.UserAgent(mp.UserAgent),
)
c.WithTransport(mp.Client.Transport)
c.OnHTML("chapter-page", func(e *colly.HTMLElement) {
defer func() {
if r := recover(); r != nil {
}
}()
page := &hibikemanga.ChapterPage{}
page.URL = e.DOM.Find("div picture img").AttrOr("data-src", "")
if page.URL == "" {
return
}
indexStr := e.DOM.Find("div[data-summary] > div").Text()
index, _ := strconv.Atoi(strings.Split(strings.Split(indexStr, "page ")[1], "/")[0])
page.Index = index - 1
page.Headers = map[string]string{
"Referer": "https://mangapill.com/",
}
ret = append(ret, page)
})
err = c.Visit(uri)
if err != nil {
mp.logger.Error().Err(err).Msg("mangapill: Failed to visit")
return nil, err
}
if len(ret) == 0 {
mp.logger.Error().Str("chapterId", id).Msg("mangapill: No pages found")
return nil, ErrNoPages
}
mp.logger.Info().Int("count", len(ret)).Msg("mangapill: Found pages")
return ret, nil
}

View File

@@ -0,0 +1,128 @@
package manga_providers
import (
"github.com/stretchr/testify/assert"
hibikemanga "seanime/internal/extension/hibike/manga"
"seanime/internal/util"
"testing"
)
func TestMangapill_Search(t *testing.T) {
tests := []struct {
name string
query string
}{
{
name: "Boku no Kokoro no Yabai Yatsu",
query: "Boku no Kokoro no Yabai Yatsu",
},
}
provider := NewMangapill(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
searchRes, err := provider.Search(hibikemanga.SearchOptions{
Query: tt.query,
})
if assert.NoError(t, err, "provider.Search() error") {
assert.NotEmpty(t, searchRes, "search result is empty")
for _, res := range searchRes {
t.Logf("Title: %s", res.Title)
t.Logf("\tID: %s", res.ID)
t.Logf("\tYear: %d", res.Year)
t.Logf("\tImage: %s", res.Image)
t.Logf("\tProvider: %s", res.Provider)
t.Logf("\tSearchRating: %f", res.SearchRating)
t.Logf("\tSynonyms: %v", res.Synonyms)
t.Log("--------------------------------------------------")
}
}
})
}
}
func TestMangapill_FindChapters(t *testing.T) {
tests := []struct {
name string
id string
atLeast int
}{
{
name: "The Dangers in My Heart",
id: "5232$boku-no-kokoro-no-yabai-yatsu",
atLeast: 141,
},
}
provider := NewMangapill(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
chapters, err := provider.FindChapters(tt.id)
if assert.NoError(t, err, "provider.FindChapters() error") {
assert.NotEmpty(t, chapters, "chapters is empty")
assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected")
for _, chapter := range chapters {
t.Logf("Title: %s", chapter.Title)
t.Logf("\tSlug: %s", chapter.ID)
t.Logf("\tURL: %s", chapter.URL)
t.Logf("\tIndex: %d", chapter.Index)
t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt)
t.Log("--------------------------------------------------")
}
}
})
}
}
func TestMangapill_FindChapterPages(t *testing.T) {
tests := []struct {
name string
chapterId string
}{
{
name: "The Dangers in My Heart",
chapterId: "5232-10001000$boku-no-kokoro-no-yabai-yatsu-chapter-1", // Chapter 1
},
}
provider := NewMangapill(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pages, err := provider.FindChapterPages(tt.chapterId)
if assert.NoError(t, err, "provider.FindChapterPages() error") {
assert.NotEmpty(t, pages, "pages is empty")
for _, page := range pages {
t.Logf("Index: %d", page.Index)
t.Logf("\tURL: %s", page.URL)
t.Log("--------------------------------------------------")
}
}
})
}
}

View File

@@ -0,0 +1,19 @@
package manga_providers
import "errors"
const (
WeebCentralProvider = "weebcentral"
MangadexProvider string = "mangadex"
ComickProvider string = "comick"
MangapillProvider string = "mangapill"
ManganatoProvider string = "manganato"
MangafireProvider string = "mangafire"
LocalProvider string = "local-manga"
)
var (
ErrNoResults = errors.New("no results found")
ErrNoChapters = errors.New("no chapters found")
ErrNoPages = errors.New("no pages found")
)

View File

@@ -0,0 +1,8 @@
package manga_providers
import util "seanime/internal/util/proxies"
func GetImageByProxy(url string, headers map[string]string) ([]byte, error) {
ip := &util.ImageProxy{}
return ip.GetImage(url, headers)
}

View File

@@ -0,0 +1,309 @@
package manga_providers
import (
"errors"
"fmt"
"net/url"
"regexp"
hibikemanga "seanime/internal/extension/hibike/manga"
"seanime/internal/util"
"seanime/internal/util/comparison"
"slices"
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/imroc/req/v3"
"github.com/rs/zerolog"
)
// WeebCentral implements the manga provider for WeebCentral
// It uses goquery to scrape search results, chapter lists, and chapter pages.
type WeebCentral struct {
Url string
UserAgent string
Client *req.Client
logger *zerolog.Logger
}
// NewWeebCentral initializes and returns a new WeebCentral provider instance.
func NewWeebCentral(logger *zerolog.Logger) *WeebCentral {
client := req.C().
SetUserAgent(util.GetRandomUserAgent()).
SetTimeout(60 * time.Second).
EnableInsecureSkipVerify().
ImpersonateChrome()
return &WeebCentral{
Url: "https://weebcentral.com",
UserAgent: util.GetRandomUserAgent(),
Client: client,
logger: logger,
}
}
func (w *WeebCentral) GetSettings() hibikemanga.Settings {
return hibikemanga.Settings{
SupportsMultiScanlator: false,
SupportsMultiLanguage: false,
}
}
func (w *WeebCentral) Search(opts hibikemanga.SearchOptions) ([]*hibikemanga.SearchResult, error) {
w.logger.Debug().Str("query", opts.Query).Msg("weebcentral: Searching manga")
searchUrl := fmt.Sprintf("%s/search/simple?location=main", w.Url)
form := url.Values{}
form.Set("text", opts.Query)
resp, err := w.Client.R().
SetContentType("application/x-www-form-urlencoded").
SetHeader("HX-Request", "true").
SetHeader("HX-Trigger", "quick-search-input").
SetHeader("HX-Trigger-Name", "text").
SetHeader("HX-Target", "quick-search-result").
SetHeader("HX-Current-URL", w.Url+"/").
SetBody(form.Encode()).
Post(searchUrl)
if err != nil {
w.logger.Error().Err(err).Msg("weebcentral: Failed to send search request")
return nil, err
}
if !resp.IsSuccessState() {
w.logger.Error().Str("status", resp.Status).Msg("weebcentral: Search request failed")
return nil, fmt.Errorf("search request failed: status %s", resp.Status)
}
body := resp.String()
doc, err := goquery.NewDocumentFromReader(strings.NewReader(body))
if err != nil {
w.logger.Error().Err(err).Msg("weebcentral: Failed to parse search HTML")
return nil, err
}
var searchResults []*hibikemanga.SearchResult
doc.Find("#quick-search-result > div > a").Each(func(i int, s *goquery.Selection) {
link, exists := s.Attr("href")
if !exists {
return
}
title := strings.TrimSpace(s.Find(".flex-1").Text())
var image string
if s.Find("source").Length() > 0 {
image, _ = s.Find("source").Attr("srcset")
} else if s.Find("img").Length() > 0 {
image, _ = s.Find("img").Attr("src")
}
// Extract manga id from link assuming the format contains '/series/{id}/'
idPart := ""
parts := strings.Split(link, "/series/")
if len(parts) > 1 {
subparts := strings.Split(parts[1], "/")
idPart = subparts[0]
}
if idPart == "" {
return
}
titleCopy := title
titles := []*string{&titleCopy}
compRes, ok := comparison.FindBestMatchWithSorensenDice(&opts.Query, titles)
if !ok || compRes.Rating < 0.6 {
return
}
searchResults = append(searchResults, &hibikemanga.SearchResult{
ID: idPart,
Title: title,
Synonyms: []string{},
Year: 0,
Image: image,
Provider: WeebCentralProvider,
SearchRating: compRes.Rating,
})
})
if len(searchResults) == 0 {
w.logger.Error().Msg("weebcentral: No search results found")
return nil, errors.New("no results found")
}
w.logger.Info().Int("count", len(searchResults)).Msg("weebcentral: Found search results")
return searchResults, nil
}
func (w *WeebCentral) FindChapters(mangaId string) ([]*hibikemanga.ChapterDetails, error) {
w.logger.Debug().Str("mangaId", mangaId).Msg("weebcentral: Fetching chapters")
chapterUrl := fmt.Sprintf("%s/series/%s/full-chapter-list", w.Url, mangaId)
resp, err := w.Client.R().
SetHeader("HX-Request", "true").
SetHeader("HX-Target", "chapter-list").
SetHeader("HX-Current-URL", fmt.Sprintf("%s/series/%s", w.Url, mangaId)).
SetHeader("Referer", fmt.Sprintf("%s/series/%s", w.Url, mangaId)).
Get(chapterUrl)
if err != nil {
w.logger.Error().Err(err).Msg("weebcentral: Failed to fetch chapter list")
return nil, err
}
if !resp.IsSuccessState() {
w.logger.Error().Str("status", resp.Status).Msg("weebcentral: Chapter list request failed")
return nil, fmt.Errorf("chapter list request failed: status %s", resp.Status)
}
body := resp.String()
doc, err := goquery.NewDocumentFromReader(strings.NewReader(body))
if err != nil {
w.logger.Error().Err(err).Msg("weebcentral: Failed to parse chapter list HTML")
return nil, err
}
var chapters []*hibikemanga.ChapterDetails
volumeCounter := 1
lastChapterNumber := 9999.0
chapterRegex := regexp.MustCompile("(\\d+(?:\\.\\d+)?)")
doc.Find("div.flex.items-center").Each(func(i int, s *goquery.Selection) {
a := s.Find("a")
chapterUrl, exists := a.Attr("href")
if !exists {
return
}
chapterTitle := strings.TrimSpace(a.Find("span.grow > span").First().Text())
var chapterNumber string
var parsedChapterNumber float64
match := chapterRegex.FindStringSubmatch(chapterTitle)
if len(match) > 1 {
chapterNumber = w.cleanChapterNumber(match[1])
if num, err := strconv.ParseFloat(chapterNumber, 64); err == nil {
parsedChapterNumber = num
}
} else {
chapterNumber = ""
}
if parsedChapterNumber > lastChapterNumber {
volumeCounter++
}
if parsedChapterNumber != 0 {
lastChapterNumber = parsedChapterNumber
}
// Extract chapter id from the URL assuming format contains '/chapters/{id}'
chapterId := ""
parts := strings.Split(chapterUrl, "/chapters/")
if len(parts) > 1 {
chapterId = parts[1]
}
chapters = append(chapters, &hibikemanga.ChapterDetails{
ID: chapterId,
URL: chapterUrl,
Title: chapterTitle,
Chapter: chapterNumber,
Index: uint(i),
Provider: WeebCentralProvider,
})
})
if len(chapters) == 0 {
w.logger.Error().Msg("weebcentral: No chapters found")
return nil, errors.New("no chapters found")
}
slices.Reverse(chapters)
for i := range chapters {
chapters[i].Index = uint(i)
}
w.logger.Info().Int("count", len(chapters)).Msg("weebcentral: Found chapters")
return chapters, nil
}
func (w *WeebCentral) FindChapterPages(chapterId string) ([]*hibikemanga.ChapterPage, error) {
url := fmt.Sprintf("%s/chapters/%s/images?is_prev=False&reading_style=long_strip", w.Url, chapterId)
resp, err := w.Client.R().
SetHeader("HX-Request", "true").
SetHeader("HX-Current-URL", fmt.Sprintf("%s/chapters/%s", w.Url, chapterId)).
SetHeader("Referer", fmt.Sprintf("%s/chapters/%s", w.Url, chapterId)).
Get(url)
if err != nil {
w.logger.Error().Err(err).Msg("weebcentral: Failed to fetch chapter pages")
return nil, err
}
if !resp.IsSuccessState() {
w.logger.Error().Str("status", resp.Status).Msg("weebcentral: Chapter pages request failed")
return nil, fmt.Errorf("chapter pages request failed: status %s", resp.Status)
}
body := resp.String()
doc, err := goquery.NewDocumentFromReader(strings.NewReader(body))
if err != nil {
w.logger.Error().Err(err).Msg("weebcentral: Failed to parse chapter pages HTML")
return nil, err
}
var pages []*hibikemanga.ChapterPage
totalImgs := doc.Find("img").Length()
doc.Find("section.flex-1 img").Each(func(i int, s *goquery.Selection) {
imageUrl, exists := s.Attr("src")
if !exists || imageUrl == "" {
return
}
pages = append(pages, &hibikemanga.ChapterPage{
URL: imageUrl,
Index: i,
Headers: map[string]string{"Referer": w.Url},
Provider: WeebCentralProvider,
})
})
if len(pages) == 0 && totalImgs > 0 {
doc.Find("img").Each(func(i int, s *goquery.Selection) {
imageUrl, exists := s.Attr("src")
if !exists || imageUrl == "" {
return
}
pages = append(pages, &hibikemanga.ChapterPage{
URL: imageUrl,
Index: i,
Headers: map[string]string{"Referer": w.Url},
Provider: WeebCentralProvider,
})
})
}
if len(pages) == 0 {
w.logger.Error().Msg("weebcentral: No pages found")
return nil, errors.New("no pages found")
}
w.logger.Info().Int("count", len(pages)).Msg("weebcentral: Found chapter pages")
return pages, nil
}
func (w *WeebCentral) cleanChapterNumber(chapterStr string) string {
cleaned := strings.TrimLeft(chapterStr, "0")
if cleaned == "" {
return "0"
}
return cleaned
}

View File

@@ -0,0 +1,162 @@
package manga_providers
import (
"seanime/internal/util"
"testing"
"github.com/stretchr/testify/assert"
hibikemanga "seanime/internal/extension/hibike/manga"
)
func TestWeebCentral_Search(t *testing.T) {
tests := []struct {
name string
query string
}{
{
name: "One Piece",
query: "One Piece",
},
{
name: "Jujutsu Kaisen",
query: "Jujutsu Kaisen",
},
}
weebcentral := NewWeebCentral(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
searchRes, err := weebcentral.Search(hibikemanga.SearchOptions{
Query: tt.query,
})
if assert.NoError(t, err, "weebcentral.Search() error") {
assert.NotEmpty(t, searchRes, "search result is empty")
for _, res := range searchRes {
t.Logf("Title: %s", res.Title)
t.Logf("\tID: %s", res.ID)
t.Logf("\tYear: %d", res.Year)
t.Logf("\tImage: %s", res.Image)
t.Logf("\tProvider: %s", res.Provider)
t.Logf("\tSearchRating: %f", res.SearchRating)
t.Logf("\tSynonyms: %v", res.Synonyms)
t.Log("--------------------------------------------------")
}
}
})
}
}
func TestWeebCentral_FindChapters(t *testing.T) {
tests := []struct {
name string
id string
atLeast int
}{
{
name: "One Piece",
id: "01J76XY7E9FNDZ1DBBM6PBJPFK",
atLeast: 1100,
},
{
name: "Jujutsu Kaisen",
id: "01J76XYCERXE60T7FKXVCCAQ0H",
atLeast: 250,
},
}
weebcentral := NewWeebCentral(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
chapters, err := weebcentral.FindChapters(tt.id)
if assert.NoError(t, err, "weebcentral.FindChapters() error") {
assert.NotEmpty(t, chapters, "chapters is empty")
assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected")
for _, chapter := range chapters {
t.Logf("Title: %s", chapter.Title)
t.Logf("\tSlug: %s", chapter.ID)
t.Logf("\tURL: %s", chapter.URL)
t.Logf("\tIndex: %d", chapter.Index)
t.Logf("\tChapter: %s", chapter.Chapter)
t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt)
t.Log("--------------------------------------------------")
}
}
})
}
}
func TestWeebCentral_FindChapterPages(t *testing.T) {
tests := []struct {
name string
id string
index uint
}{
{
name: "One Piece",
id: "01J76XY7E9FNDZ1DBBM6PBJPFK",
index: 1110,
},
{
name: "Jujutsu Kaisen",
id: "01J76XYCERXE60T7FKXVCCAQ0H",
index: 0,
},
}
weebcentral := NewWeebCentral(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
chapters, err := weebcentral.FindChapters(tt.id)
if assert.NoError(t, err, "weebcentral.FindChapters() error") {
assert.NotEmpty(t, chapters, "chapters is empty")
var chapterInfo *hibikemanga.ChapterDetails
for _, chapter := range chapters {
if chapter.Index == tt.index {
chapterInfo = chapter
break
}
}
if assert.NotNil(t, chapterInfo, "chapter not found") {
pages, err := weebcentral.FindChapterPages(chapterInfo.ID)
if assert.NoError(t, err, "weebcentral.FindChapterPages() error") {
assert.NotEmpty(t, pages, "pages is empty")
for _, page := range pages {
t.Logf("Index: %d", page.Index)
t.Logf("\tURL: %s", page.URL)
t.Log("--------------------------------------------------")
}
}
}
}
})
}
}