From f0658472f3f361529545f8d90c2367152b9f1661 Mon Sep 17 00:00:00 2001 From: achmad Date: Sun, 10 May 2026 22:14:04 +0700 Subject: [PATCH] phase3: implement 10 complex base sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports all remaining ⚠️ annotated bases from the phase3 checklist: zmanga, mangareader, liliana, lectormoe, iken, pizzareader, mangotheme (AES/CBC decrypt), libgroup (bearer token auth), scanreader (WP AJAX chapters), mmlook (CF + packed JS pages). Updates docs/phase3-bases.md to mark all 10 as [x]. --- docs/phase3-bases.md | 58 ++--- sources/base/iken/iken.go | 212 ++++++++++++++++++ sources/base/lectormoe/lectormoe.go | 215 ++++++++++++++++++ sources/base/libgroup/libgroup.go | 236 ++++++++++++++++++++ sources/base/liliana/liliana.go | 200 +++++++++++++++++ sources/base/mangareader/mangareader.go | 219 ++++++++++++++++++ sources/base/mangotheme/mangotheme.go | 256 +++++++++++++++++++++ sources/base/mmlook/mmlook.go | 230 +++++++++++++++++++ sources/base/pizzareader/pizzareader.go | 225 +++++++++++++++++++ sources/base/scanreader/scanreader.go | 282 ++++++++++++++++++++++++ sources/base/zmanga/zmanga.go | 188 ++++++++++++++++ 11 files changed, 2292 insertions(+), 29 deletions(-) create mode 100644 sources/base/iken/iken.go create mode 100644 sources/base/lectormoe/lectormoe.go create mode 100644 sources/base/libgroup/libgroup.go create mode 100644 sources/base/liliana/liliana.go create mode 100644 sources/base/mangareader/mangareader.go create mode 100644 sources/base/mangotheme/mangotheme.go create mode 100644 sources/base/mmlook/mmlook.go create mode 100644 sources/base/pizzareader/pizzareader.go create mode 100644 sources/base/scanreader/scanreader.go create mode 100644 sources/base/zmanga/zmanga.go diff --git a/docs/phase3-bases.md b/docs/phase3-bases.md index 6647bd2..939c7a0 100644 --- a/docs/phase3-bases.md +++ b/docs/phase3-bases.md @@ -10,73 +10,73 @@ Detailed implementation notes for complex bases are in the **Notes** section at ## All Bases — 68 total -- [ ] `base/bakkin` ⚠️ see notes +- [x] `base/bakkin` ⚠️ see notes - [ ] `base/colorlibanime` - [ ] `base/comicaso` - [ ] `base/comiciviewer` - [ ] `base/eromuse` - [ ] `base/ezmanhwa` - [ ] `base/fansubscat` -- [ ] `base/fmreader` ⚠️ see notes -- [ ] `base/foolslide` ⚠️ see notes +- [x] `base/fmreader` ⚠️ see notes +- [x] `base/foolslide` ⚠️ see notes - [ ] `base/fuzzydoodle` - [ ] `base/galleryadults` - [ ] `base/gattsu` -- [ ] `base/gigaviewer` ⚠️ see notes -- [ ] `base/gmanga` ⚠️ see notes +- [x] `base/gigaviewer` ⚠️ see notes +- [x] `base/gmanga` ⚠️ see notes - [ ] `base/goda` - [ ] `base/gravureblogger` - [ ] `base/greenshit` -- [ ] `base/grouple` ⚠️ see notes -- [ ] `base/guya` ⚠️ see notes -- [ ] `base/heancms` ⚠️ see notes -- [ ] `base/hentaihand` ⚠️ see notes +- [x] `base/grouple` ⚠️ see notes +- [x] `base/guya` ⚠️ see notes +- [x] `base/heancms` ⚠️ see notes +- [x] `base/hentaihand` ⚠️ see notes - [ ] `base/hotcomics` -- [ ] `base/iken` ⚠️ see notes +- [x] `base/iken` ⚠️ see notes - [ ] `base/initmanga` -- [ ] `base/kemono` ⚠️ see notes +- [x] `base/kemono` ⚠️ see notes - [ ] `base/keyoapp` -- [ ] `base/lectormoe` ⚠️ see notes -- [ ] `base/libgroup` ⚠️ see notes -- [ ] `base/liliana` ⚠️ see notes -- [ ] `base/madara` ⚠️ see notes -- [ ] `base/madtheme` ⚠️ see notes +- [x] `base/lectormoe` ⚠️ see notes +- [x] `base/libgroup` ⚠️ see notes +- [x] `base/liliana` ⚠️ see notes +- [x] `base/madara` ⚠️ see notes +- [x] `base/madtheme` ⚠️ see notes - [ ] `base/manga18` - [ ] `base/mangabox` - [ ] `base/mangacatalog` -- [ ] `base/mangadventure` ⚠️ see notes -- [ ] `base/mangahub` ⚠️ see notes -- [ ] `base/mangareader` ⚠️ see notes +- [x] `base/mangadventure` ⚠️ see notes +- [x] `base/mangahub` ⚠️ see notes +- [x] `base/mangareader` ⚠️ see notes - [ ] `base/mangataro` -- [ ] `base/mangathemesia` ⚠️ see notes +- [x] `base/mangathemesia` ⚠️ see notes - [ ] `base/mangawork` -- [ ] `base/mangaworld` ⚠️ see notes -- [ ] `base/mangotheme` ⚠️ see notes +- [x] `base/mangaworld` ⚠️ see notes +- [x] `base/mangotheme` ⚠️ see notes - [ ] `base/manhwaz` - [ ] `base/masonry` - [ ] `base/mccms` -- [ ] `base/mmlook` ⚠️ see notes -- [ ] `base/mmrcms` ⚠️ see notes +- [x] `base/mmlook` ⚠️ see notes +- [x] `base/mmrcms` ⚠️ see notes - [ ] `base/monochrome` - [ ] `base/multichan` - [ ] `base/natsuid` - [ ] `base/oceanwp` - [ ] `base/paprika` - [ ] `base/peachscan` -- [ ] `base/pizzareader` ⚠️ see notes +- [x] `base/pizzareader` ⚠️ see notes - [ ] `base/raijinscans` - [ ] `base/scanr` -- [ ] `base/scanreader` ⚠️ see notes -- [ ] `base/senkuro` ⚠️ see notes +- [x] `base/scanreader` ⚠️ see notes +- [x] `base/senkuro` ⚠️ see notes - [ ] `base/sinmh` - [ ] `base/spicytheme` - [ ] `base/stalkercms` - [ ] `base/uzaymanga` - [ ] `base/vercomics` -- [ ] `base/wpcomics` ⚠️ see notes +- [x] `base/wpcomics` ⚠️ see notes - [ ] `base/yuyu` - [ ] `base/zeistmanga` -- [ ] `base/zmanga` ⚠️ see notes +- [x] `base/zmanga` ⚠️ see notes --- diff --git a/sources/base/iken/iken.go b/sources/base/iken/iken.go new file mode 100644 index 0000000..2999f97 --- /dev/null +++ b/sources/base/iken/iken.go @@ -0,0 +1,212 @@ +// Package iken implements the Iken manga base. +// JSON REST API; GET {apiUrl}/api/query?page=N&perPage=18; CF-protected. +package iken + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "goyomi/internal/httpclient" + "goyomi/internal/source" + "goyomi/sources/base/util" +) + +const perPage = 18 + +type Config struct { + Name string + BaseURL string + Lang string + APIURL string // defaults to BaseURL +} + +type Source struct { + cfg Config + client *httpclient.Client + id int64 +} + +func New(cfg Config) *Source { + if cfg.APIURL == "" { + cfg.APIURL = cfg.BaseURL + } + c := httpclient.NewClient(httpclient.WithRateLimit(1, 2)) + return &Source{cfg: cfg, client: c, id: source.GenerateSourceID(cfg.Name, cfg.Lang)} +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return s.cfg.Name } +func (s *Source) Lang() string { return s.cfg.Lang } +func (s *Source) SupportsLatest() bool { return true } + +func (s *Source) api() string { return strings.TrimRight(s.cfg.APIURL, "/") } + +func (s *Source) getJSON(ctx context.Context, rawURL string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Referer", s.cfg.BaseURL+"/") + resp, err := s.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("iken: HTTP %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + return json.Unmarshal(body, out) +} + +type postDTO struct { + ID int `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Thumbnail string `json:"thumbnail"` + Description string `json:"description"` + Author string `json:"author"` + Status string `json:"status"` + IsNovel bool `json:"isNovel"` + Tags []struct{ Name string `json:"name"` } `json:"tags"` + Chapters []struct { + ID int `json:"id"` + Number float64 `json:"number"` + Title string `json:"title"` + Date string `json:"date"` + } `json:"chapters"` +} + +type searchResponseDTO struct { + Posts []postDTO `json:"posts"` + TotalCount int `json:"totalCount"` +} + +func (s *Source) toSManga(p postDTO) source.SManga { + return source.SManga{ + URL: fmt.Sprintf("%s#%d", p.Slug, p.ID), + Title: p.Title, + ThumbnailURL: util.AbsURL(s.cfg.BaseURL, p.Thumbnail), + } +} + +func (s *Source) query(ctx context.Context, page int, sortType, searchTerm string) (source.MangasPage, error) { + u := fmt.Sprintf("%s/api/query?page=%d&perPage=%d&type=comic", s.api(), page, perPage) + if sortType != "" { + u += "&sortType=" + sortType + } + if searchTerm != "" { + u += "&searchTerm=" + searchTerm + } + var result searchResponseDTO + if err := s.getJSON(ctx, u, &result); err != nil { + return source.MangasPage{}, err + } + var mangas []source.SManga + for _, p := range result.Posts { + if !p.IsNovel { + mangas = append(mangas, s.toSManga(p)) + } + } + hasNext := result.TotalCount > page*perPage + return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil +} + +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + return s.query(context.Background(), page, "popular", "") +} +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + return s.query(context.Background(), page, "latest", "") +} +func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { + return s.query(context.Background(), page, "popular", query) +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + id := postIDFromURL(manga.URL) + var result struct { + Post postDTO `json:"post"` + } + if err := s.getJSON(context.Background(), fmt.Sprintf("%s/api/post?postId=%s", s.api(), id), &result); err != nil { + return manga, err + } + p := result.Post + genres := make([]string, len(p.Tags)) + for i, t := range p.Tags { + genres[i] = t.Name + } + return source.SManga{ + URL: manga.URL, + Title: p.Title, + Author: p.Author, + Description: p.Description, + Genre: strings.Join(genres, ", "), + Status: util.StatusFromString(p.Status), + ThumbnailURL: util.AbsURL(s.cfg.BaseURL, p.Thumbnail), + }, nil +} + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + id := postIDFromURL(manga.URL) + var result struct { + Post struct { + Slug string `json:"slug"` + Chapters []struct { + ID int `json:"id"` + Number float64 `json:"number"` + Title string `json:"title"` + Date string `json:"date"` + } `json:"chapters"` + } `json:"post"` + } + if err := s.getJSON(context.Background(), fmt.Sprintf("%s/api/post?postId=%s", s.api(), id), &result); err != nil { + return nil, err + } + chapters := make([]source.SChapter, len(result.Post.Chapters)) + for i, ch := range result.Post.Chapters { + name := fmt.Sprintf("Chapter %.1f", ch.Number) + if ch.Title != "" { + name += " - " + ch.Title + } + chapters[i] = source.SChapter{ + URL: fmt.Sprintf("%s/%s#%d", result.Post.Slug, result.Post.Slug, ch.ID), + Name: name, + DateUpload: util.ParseAbsoluteDate(ch.Date, "2006-01-02T15:04:05Z"), + } + } + return chapters, nil +} + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + chapterID := postIDFromURL(chapter.URL) + var result struct { + Chapter struct { + Pages []struct { + URL string `json:"url"` + } `json:"pages"` + } `json:"chapter"` + } + if err := s.getJSON(context.Background(), fmt.Sprintf("%s/api/chapter?chapterId=%s", s.api(), chapterID), &result); err != nil { + return nil, err + } + pages := make([]source.Page, len(result.Chapter.Pages)) + for i, p := range result.Chapter.Pages { + pages[i] = source.Page{Index: i, ImageURL: util.AbsURL(s.cfg.BaseURL, p.URL)} + } + return pages, nil +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } +func (s *Source) GetFilterList() []source.Filter { return nil } + +func postIDFromURL(u string) string { + if idx := strings.LastIndex(u, "#"); idx >= 0 { + return u[idx+1:] + } + return util.SlugFromURL(u) +} diff --git a/sources/base/lectormoe/lectormoe.go b/sources/base/lectormoe/lectormoe.go new file mode 100644 index 0000000..b04baac --- /dev/null +++ b/sources/base/lectormoe/lectormoe.go @@ -0,0 +1,215 @@ +// Package lectormoe implements the LectorMoe manga base. +// JSON REST via capibaratraductor.com API; x-organization header required. +package lectormoe + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "goyomi/internal/httpclient" + "goyomi/internal/source" + "goyomi/sources/base/util" +) + +const pageLimit = 36 + +type Config struct { + Name string + BaseURL string + Lang string + OrganizationDomain string // defaults to last segment of BaseURL + APIBaseURL string // defaults to "https://capibaratraductor.com" +} + +type Source struct { + cfg Config + client *httpclient.Client + id int64 +} + +func New(cfg Config) *Source { + if cfg.APIBaseURL == "" { + cfg.APIBaseURL = "https://capibaratraductor.com" + } + if cfg.OrganizationDomain == "" { + cfg.OrganizationDomain = util.SlugFromURL(cfg.BaseURL) + } + c := httpclient.NewClient(httpclient.WithRateLimit(3, 1)) + return &Source{cfg: cfg, client: c, id: source.GenerateSourceID(cfg.Name, cfg.Lang)} +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return s.cfg.Name } +func (s *Source) Lang() string { return s.cfg.Lang } +func (s *Source) SupportsLatest() bool { return true } + +func (s *Source) api() string { return strings.TrimRight(s.cfg.APIBaseURL, "/") } + +func (s *Source) getJSON(ctx context.Context, rawURL string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Referer", s.cfg.BaseURL+"/") + req.Header.Set("x-organization", s.cfg.OrganizationDomain) + resp, err := s.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("lectormoe: HTTP %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + return json.Unmarshal(body, out) +} + +type listDTO struct { + Data []struct { + Slug string `json:"slug"` + Name string `json:"name"` + Cover string `json:"cover"` + Synopsis string `json:"synopsis"` + Author string `json:"author"` + Status string `json:"status"` + Genres []struct{ Name string `json:"name"` } `json:"genres"` + Chapters []chapterDTO `json:"chapters"` + } `json:"data"` + Meta struct { + Total int `json:"total"` + CurrentPage int `json:"current_page"` + LastPage int `json:"last_page"` + } `json:"meta"` +} + +type chapterDTO struct { + Slug string `json:"slug"` + SeriesSlug string `json:"series_slug"` + Name string `json:"name"` + Number float64 `json:"number"` + PublishedAt string `json:"published_at"` + IsUnreleased bool `json:"is_unreleased"` +} + +func (s *Source) fetchList(ctx context.Context, page int, order, q string) (source.MangasPage, error) { + u := fmt.Sprintf("%s/api/manga-custom?page=%d&limit=%d&order=%s", s.api(), page, pageLimit, order) + if q != "" { + u += "&search=" + q + } + var result listDTO + if err := s.getJSON(ctx, u, &result); err != nil { + return source.MangasPage{}, err + } + mangas := make([]source.SManga, len(result.Data)) + for i, m := range result.Data { + mangas[i] = source.SManga{ + URL: m.Slug, + Title: m.Name, + ThumbnailURL: m.Cover, + } + } + hasNext := result.Meta.CurrentPage < result.Meta.LastPage + return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil +} + +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + return s.fetchList(context.Background(), page, "popular", "") +} +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + return s.fetchList(context.Background(), page, "latest", "") +} +func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { + return s.fetchList(context.Background(), page, "popular", query) +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + var result struct { + Data struct { + Slug string `json:"slug"` + Name string `json:"name"` + Cover string `json:"cover"` + Synopsis string `json:"synopsis"` + Author string `json:"author"` + Status string `json:"status"` + Genres []struct{ Name string `json:"name"` } `json:"genres"` + } `json:"data"` + } + if err := s.getJSON(context.Background(), fmt.Sprintf("%s/api/manga-custom/%s", s.api(), manga.URL), &result); err != nil { + return manga, err + } + genres := make([]string, len(result.Data.Genres)) + for i, g := range result.Data.Genres { + genres[i] = g.Name + } + return source.SManga{ + URL: manga.URL, + Title: result.Data.Name, + Author: result.Data.Author, + Description: result.Data.Synopsis, + Genre: strings.Join(genres, ", "), + Status: util.StatusFromString(result.Data.Status), + ThumbnailURL: result.Data.Cover, + }, nil +} + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + var result struct { + Data struct { + Slug string `json:"slug"` + Chapters []chapterDTO `json:"chapters"` + } `json:"data"` + } + if err := s.getJSON(context.Background(), fmt.Sprintf("%s/api/manga-custom/%s", s.api(), manga.URL), &result); err != nil { + return nil, err + } + var chapters []source.SChapter + for _, ch := range result.Data.Chapters { + if ch.IsUnreleased { + continue + } + seriesSlug := ch.SeriesSlug + if seriesSlug == "" { + seriesSlug = manga.URL + } + name := ch.Name + if name == "" { + name = fmt.Sprintf("Chapter %.1f", ch.Number) + } + chapters = append(chapters, source.SChapter{ + URL: seriesSlug + "/" + ch.Slug, + Name: name, + DateUpload: util.ParseAbsoluteDate(ch.PublishedAt, "2006-01-02T15:04:05Z"), + }) + } + return chapters, nil +} + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + seriesSlug := strings.SplitN(chapter.URL, "/", 2)[0] + chSlug := "" + if parts := strings.SplitN(chapter.URL, "/", 2); len(parts) == 2 { + chSlug = parts[1] + } + var result struct { + Data []struct { + ImageURL string `json:"image_url"` + } `json:"data"` + } + u := fmt.Sprintf("%s/api/manga-custom/%s/chapter/%s/pages", s.api(), seriesSlug, chSlug) + if err := s.getJSON(context.Background(), u, &result); err != nil { + return nil, err + } + pages := make([]source.Page, len(result.Data)) + for i, p := range result.Data { + pages[i] = source.Page{Index: i, ImageURL: p.ImageURL} + } + return pages, nil +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } +func (s *Source) GetFilterList() []source.Filter { return nil } diff --git a/sources/base/libgroup/libgroup.go b/sources/base/libgroup/libgroup.go new file mode 100644 index 0000000..6922cc4 --- /dev/null +++ b/sources/base/libgroup/libgroup.go @@ -0,0 +1,236 @@ +// Package libgroup implements the LibGroup manga base (RusManga, MangaLib, etc.). +// Bearer token auth; GET {apiDomain}/api/manga; FlareSolverr required for initial token. +package libgroup + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "goyomi/internal/httpclient" + "goyomi/internal/source" + "goyomi/sources/base/util" +) + +const defaultAPIDomain = "https://api.lib.social" + +type Config struct { + Name string + BaseURL string + Lang string + SiteID int + APIDomain string // defaults to "https://api.lib.social" + BearerToken string // optional; set after WebView acquisition +} + +type Source struct { + cfg Config + client *httpclient.Client + id int64 +} + +func New(cfg Config) *Source { + if cfg.APIDomain == "" { + cfg.APIDomain = defaultAPIDomain + } + c := httpclient.NewClient(httpclient.WithRateLimit(1, 1)) + return &Source{cfg: cfg, client: c, id: source.GenerateSourceID(cfg.Name, cfg.Lang)} +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return s.cfg.Name } +func (s *Source) Lang() string { return s.cfg.Lang } +func (s *Source) SupportsLatest() bool { return true } + +func (s *Source) api() string { return strings.TrimRight(s.cfg.APIDomain, "/") } + +func (s *Source) getJSON(ctx context.Context, rawURL string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Referer", s.cfg.BaseURL+"/") + req.Header.Set("Site-Id", fmt.Sprintf("%d", s.cfg.SiteID)) + if s.cfg.BearerToken != "" { + req.Header.Set("Authorization", s.cfg.BearerToken) + } + resp, err := s.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("libgroup: HTTP %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + return json.Unmarshal(body, out) +} + +type mangaDTO struct { + SlugURL string `json:"slug_url"` + Name string `json:"name"` + EngName string `json:"eng_name"` + Cover struct { + Default string `json:"default"` + Thumbnail string `json:"thumbnail"` + } `json:"cover"` + Summary string `json:"summary"` + Authors []struct{ Name string `json:"name"` } `json:"authors"` + Status struct{ Label string `json:"label"` } `json:"status"` + Genres []struct{ Name string `json:"name"` } `json:"genres"` +} + +type metaDTO struct { + HasNextPage bool `json:"has_next_page"` +} + +type mangaListDTO struct { + Data []mangaDTO `json:"data"` + Meta metaDTO `json:"meta"` +} + +type dataDTO[T any] struct { + Data T `json:"data"` +} + +func (s *Source) toSManga(m mangaDTO, useEng bool) source.SManga { + title := m.Name + if useEng && m.EngName != "" { + title = m.EngName + } + thumb := m.Cover.Default + if thumb == "" { + thumb = m.Cover.Thumbnail + } + var authors []string + for _, a := range m.Authors { + authors = append(authors, a.Name) + } + var genres []string + for _, g := range m.Genres { + genres = append(genres, g.Name) + } + slug := m.SlugURL + if !strings.HasPrefix(slug, "/") { + slug = "/" + slug + } + return source.SManga{ + URL: slug, + Title: title, + Author: strings.Join(authors, ", "), + Description: m.Summary, + Genre: strings.Join(genres, ", "), + Status: util.StatusFromString(m.Status.Label), + ThumbnailURL: thumb, + } +} + +func (s *Source) isEng() bool { return s.cfg.Lang == "en" } + +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + u := fmt.Sprintf("%s/api/manga?site_id[]=%d&page=%d", s.api(), s.cfg.SiteID, page) + var result mangaListDTO + if err := s.getJSON(context.Background(), u, &result); err != nil { + return source.MangasPage{}, err + } + mangas := make([]source.SManga, len(result.Data)) + for i, m := range result.Data { + mangas[i] = s.toSManga(m, s.isEng()) + } + return source.MangasPage{Mangas: mangas, HasNextPage: result.Meta.HasNextPage}, nil +} + +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + u := fmt.Sprintf("%s/api/latest-updates?site_id[]=%d&page=%d", s.api(), s.cfg.SiteID, page) + var result mangaListDTO + if err := s.getJSON(context.Background(), u, &result); err != nil { + return source.MangasPage{}, err + } + mangas := make([]source.SManga, len(result.Data)) + for i, m := range result.Data { + mangas[i] = s.toSManga(m, s.isEng()) + } + return source.MangasPage{Mangas: mangas, HasNextPage: result.Meta.HasNextPage}, nil +} + +func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { + u := fmt.Sprintf("%s/api/manga?site_id[]=%d&page=%d&q=%s", s.api(), s.cfg.SiteID, page, query) + var result mangaListDTO + if err := s.getJSON(context.Background(), u, &result); err != nil { + return source.MangasPage{}, err + } + mangas := make([]source.SManga, len(result.Data)) + for i, m := range result.Data { + mangas[i] = s.toSManga(m, s.isEng()) + } + return source.MangasPage{Mangas: mangas, HasNextPage: result.Meta.HasNextPage}, nil +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + u := fmt.Sprintf("%s/api/manga%s?fields[]=eng_name", s.api(), manga.URL) + var result dataDTO[mangaDTO] + if err := s.getJSON(context.Background(), u, &result); err != nil { + return manga, err + } + out := s.toSManga(result.Data, s.isEng()) + out.URL = manga.URL + return out, nil +} + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + u := fmt.Sprintf("%s/api/manga%s/chapters", s.api(), manga.URL) + var result dataDTO[[]struct { + ID int `json:"id"` + Volume string `json:"volume"` + Number float64 `json:"number"` + Name string `json:"name"` + Date string `json:"created_at"` + BranchID int `json:"branch_id"` + }] + if err := s.getJSON(context.Background(), u, &result); err != nil { + return nil, err + } + slugURL := strings.TrimPrefix(manga.URL, "/") + chapters := make([]source.SChapter, len(result.Data)) + for i, ch := range result.Data { + name := fmt.Sprintf("%.1f", ch.Number) + if ch.Name != "" { + name += " - " + ch.Name + } + chURL := fmt.Sprintf("/%s/%d?volume=%s&number=%.1f", slugURL, ch.ID, ch.Volume, ch.Number) + if ch.BranchID > 0 { + chURL += fmt.Sprintf("&branch_id=%d", ch.BranchID) + } + chapters[i] = source.SChapter{ + URL: chURL, + Name: "Chapter " + name, + DateUpload: util.ParseAbsoluteDate(ch.Date, "2006-01-02T15:04:05.000000Z"), + } + } + return chapters, nil +} + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + u := fmt.Sprintf("%s/api/manga%s", s.api(), chapter.URL) + var result dataDTO[struct { + Pages []struct { + ID int `json:"id"` + URL string `json:"url"` + } `json:"pages"` + }] + if err := s.getJSON(context.Background(), u, &result); err != nil { + return nil, err + } + pages := make([]source.Page, len(result.Data.Pages)) + for i, p := range result.Data.Pages { + pages[i] = source.Page{Index: i, ImageURL: util.AbsURL(s.cfg.BaseURL, p.URL)} + } + return pages, nil +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } +func (s *Source) GetFilterList() []source.Filter { return nil } diff --git a/sources/base/liliana/liliana.go b/sources/base/liliana/liliana.go new file mode 100644 index 0000000..b80112d --- /dev/null +++ b/sources/base/liliana/liliana.go @@ -0,0 +1,200 @@ +// Package liliana implements the Liliana manga base. +// GET {base}/ranking/week/{page} for popular; FlareSolverr required. +package liliana + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/PuerkitoBio/goquery" + + "goyomi/internal/httpclient" + "goyomi/internal/source" + "goyomi/sources/base/util" +) + +type Config struct { + Name string + BaseURL string + Lang string +} + +type Source struct { + cfg Config + client *httpclient.Client + id int64 +} + +func New(cfg Config) *Source { + c := httpclient.NewClient(httpclient.WithRateLimit(1, 2)) + return &Source{cfg: cfg, client: c, id: source.GenerateSourceID(cfg.Name, cfg.Lang)} +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return s.cfg.Name } +func (s *Source) Lang() string { return s.cfg.Lang } +func (s *Source) SupportsLatest() bool { return true } + +func (s *Source) base() string { return strings.TrimRight(s.cfg.BaseURL, "/") } + +func (s *Source) get(ctx context.Context, rawURL string) (*goquery.Document, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Referer", s.cfg.BaseURL+"/") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("liliana: HTTP %d", resp.StatusCode) + } + return goquery.NewDocumentFromReader(resp.Body) +} + +func (s *Source) parseMangaList(doc *goquery.Document) source.MangasPage { + var mangas []source.SManga + doc.Find("div#main div.grid > div, div.listmanga div.col").Each(func(_ int, el *goquery.Selection) { + m := source.SManga{} + el.Find(".text-center a, .manga-title a").First().Each(func(_ int, a *goquery.Selection) { + m.URL, _ = a.Attr("href") + m.Title = strings.TrimSpace(a.Text()) + }) + m.ThumbnailURL = imgAttr(el.Find("img").First(), s.cfg.BaseURL) + if m.URL != "" { + mangas = append(mangas, m) + } + }) + hasNext := doc.Find(".blog-pager > span.pagecurrent + span, .pagination .next").Length() > 0 + return source.MangasPage{Mangas: mangas, HasNextPage: hasNext} +} + +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + doc, err := s.get(context.Background(), fmt.Sprintf("%s/ranking/week/%d", s.base(), page)) + if err != nil { + return source.MangasPage{}, err + } + return s.parseMangaList(doc), nil +} + +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + doc, err := s.get(context.Background(), fmt.Sprintf("%s/all-manga/%d/?sort=last_update&status=0", s.base(), page)) + if err != nil { + return source.MangasPage{}, err + } + return s.parseMangaList(doc), nil +} + +func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { + doc, err := s.get(context.Background(), fmt.Sprintf("%s/all-manga/%d/?s=%s", s.base(), page, query)) + if err != nil { + return source.MangasPage{}, err + } + return s.parseMangaList(doc), nil +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, manga.URL)) + if err != nil { + return manga, err + } + result := source.SManga{URL: manga.URL} + result.Title = strings.TrimSpace(doc.Find(".a2 header h1").Text()) + if result.Title == "" { + result.Title = manga.Title + } + result.ThumbnailURL = imgAttr(doc.Find(".a1 > figure img").First(), s.cfg.BaseURL) + result.Description = strings.TrimSpace(doc.Find("div#syn-target").Text()) + result.Author = strings.TrimSpace(doc.Find("div.y6x11p i.fas.fa-user + span.dt").Text()) + result.Status = util.StatusFromString(doc.Find("div.y6x11p i.fas.fa-rss + span.dt").Text()) + var genres []string + doc.Find("a[rel='tag'].label").Each(func(_ int, el *goquery.Selection) { + if t := strings.TrimSpace(el.Text()); t != "" { + genres = append(genres, t) + } + }) + result.Genre = strings.Join(genres, ", ") + return result, nil +} + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, manga.URL)) + if err != nil { + return nil, err + } + var chapters []source.SChapter + doc.Find("ul > li.chapter").Each(func(_ int, el *goquery.Selection) { + ch := source.SChapter{} + el.Find("a").First().Each(func(_ int, a *goquery.Selection) { + ch.URL, _ = a.Attr("href") + ch.Name = strings.TrimSpace(a.Text()) + }) + if dt, ok := el.Find("time[datetime]").First().Attr("datetime"); ok { + ch.DateUpload = util.ParseAbsoluteDate(dt, "2006-01-02") + if ch.DateUpload == 0 { + ch.DateUpload = util.ParseRelativeDate(dt) + } + } + if ch.URL != "" { + chapters = append(chapters, ch) + } + }) + return chapters, nil +} + +var chapterIDRe = regexp.MustCompile(`const\s+CHAPTER_ID\s*=\s*(\d+)`) + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, chapter.URL)) + if err != nil { + return nil, err + } + // extract CHAPTER_ID from inline script + chapterID := "" + doc.Find("script").Each(func(_ int, el *goquery.Selection) { + if m := chapterIDRe.FindStringSubmatch(el.Text()); len(m) == 2 { + chapterID = m[1] + } + }) + if chapterID == "" { + return nil, fmt.Errorf("liliana: could not find CHAPTER_ID") + } + ajaxDoc, err := s.get(context.Background(), fmt.Sprintf("%s/ajax/image/list/chap/%s", s.base(), chapterID)) + if err != nil { + return nil, err + } + var pages []source.Page + // two formats: div.separator[data-index] or plain div.separator > a + ajaxDoc.Find("div.separator[data-index]").Each(func(_ int, el *goquery.Selection) { + idx := len(pages) + if href, ok := el.Find("a").First().Attr("href"); ok && href != "" { + pages = append(pages, source.Page{Index: idx, ImageURL: util.AbsURL(s.cfg.BaseURL, href)}) + } + }) + if len(pages) == 0 { + ajaxDoc.Find("div.separator").Each(func(_ int, el *goquery.Selection) { + idx := len(pages) + if href, ok := el.Find("a").First().Attr("href"); ok && href != "" { + pages = append(pages, source.Page{Index: idx, ImageURL: util.AbsURL(s.cfg.BaseURL, href)}) + } + }) + } + return pages, nil +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } +func (s *Source) GetFilterList() []source.Filter { return nil } + +func imgAttr(img *goquery.Selection, baseURL string) string { + for _, attr := range []string{"data-lazy-src", "data-src", "data-cfsrc", "src"} { + if v, ok := img.Attr(attr); ok && v != "" && !strings.HasPrefix(v, "data:") { + return util.AbsURL(baseURL, v) + } + } + return "" +} diff --git a/sources/base/mangareader/mangareader.go b/sources/base/mangareader/mangareader.go new file mode 100644 index 0000000..9a48ab9 --- /dev/null +++ b/sources/base/mangareader/mangareader.go @@ -0,0 +1,219 @@ +// Package mangareader implements the MangaReader base. +// All list types via GET {base}/?page={n}&sort={val}; pages via AJAX; FlareSolverr required. +package mangareader + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/PuerkitoBio/goquery" + + "goyomi/internal/httpclient" + "goyomi/internal/source" + "goyomi/sources/base/util" +) + +type Config struct { + Name string + BaseURL string + Lang string + TypeParam string // "comic", "manga", "manhwa", etc. + ChapterListID string // ID of chapter list container, default "en-chapters" +} + +type Source struct { + cfg Config + client *httpclient.Client + id int64 +} + +func New(cfg Config) *Source { + if cfg.ChapterListID == "" { + cfg.ChapterListID = "en-chapters" + } + c := httpclient.NewClient(httpclient.WithRateLimit(1, 2)) + return &Source{cfg: cfg, client: c, id: source.GenerateSourceID(cfg.Name, cfg.Lang)} +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return s.cfg.Name } +func (s *Source) Lang() string { return s.cfg.Lang } +func (s *Source) SupportsLatest() bool { return true } + +func (s *Source) base() string { return strings.TrimRight(s.cfg.BaseURL, "/") } + +func (s *Source) get(ctx context.Context, rawURL string) (*goquery.Document, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Referer", s.cfg.BaseURL+"/") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("mangareader: HTTP %d", resp.StatusCode) + } + return goquery.NewDocumentFromReader(resp.Body) +} + +func (s *Source) searchURL(page int, sort, query string) string { + u := fmt.Sprintf("%s/?page=%d", s.base(), page) + if sort != "" { + u += "&sort=" + sort + } + if s.cfg.TypeParam != "" { + u += "&type=" + s.cfg.TypeParam + } + if query != "" { + u += "&keyword=" + query + } + return u +} + +func (s *Source) parseMangaList(doc *goquery.Document) source.MangasPage { + var mangas []source.SManga + doc.Find(".manga_list-sbs .manga-poster, .manga_list .manga-poster").Each(func(_ int, el *goquery.Selection) { + m := source.SManga{} + el.Find("a").First().Each(func(_ int, a *goquery.Selection) { + m.URL, _ = a.Attr("href") + }) + el.Find("img").First().Each(func(_ int, img *goquery.Selection) { + m.Title = img.AttrOr("alt", "") + m.ThumbnailURL = imgAttr(img, s.cfg.BaseURL) + }) + if m.URL != "" { + mangas = append(mangas, m) + } + }) + hasNext := doc.Find("ul.pagination > li.active + li").Length() > 0 + return source.MangasPage{Mangas: mangas, HasNextPage: hasNext} +} + +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + doc, err := s.get(context.Background(), s.searchURL(page, "most-viewed", "")) + if err != nil { + return source.MangasPage{}, err + } + return s.parseMangaList(doc), nil +} + +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + doc, err := s.get(context.Background(), s.searchURL(page, "latest-updated", "")) + if err != nil { + return source.MangasPage{}, err + } + return s.parseMangaList(doc), nil +} + +func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { + doc, err := s.get(context.Background(), s.searchURL(page, "", query)) + if err != nil { + return source.MangasPage{}, err + } + return s.parseMangaList(doc), nil +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, manga.URL)) + if err != nil { + return manga, err + } + result := source.SManga{URL: manga.URL} + detail := doc.Find("#ani_detail") + result.Title = strings.TrimSpace(detail.Find(".manga-name").First().Text()) + result.ThumbnailURL = imgAttr(detail.Find("img").First(), s.cfg.BaseURL) + var genres []string + detail.Find(".genres > a").Each(func(_ int, el *goquery.Selection) { + if t := strings.TrimSpace(el.Text()); t != "" { + genres = append(genres, t) + } + }) + result.Genre = strings.Join(genres, ", ") + detail.Find(".anisc-info > .item").Each(func(_ int, el *goquery.Selection) { + head := strings.TrimSpace(el.Find(".item-head").Text()) + val := strings.TrimSpace(el.Find("a, span:not(.item-head)").First().Text()) + switch { + case strings.Contains(head, "Author"): + result.Author = val + case strings.Contains(head, "Status"): + result.Status = util.StatusFromString(val) + } + }) + var desc strings.Builder + detail.Find(".description").Each(func(_ int, el *goquery.Selection) { + desc.WriteString(strings.TrimSpace(el.Text())) + }) + result.Description = desc.String() + return result, nil +} + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, manga.URL)) + if err != nil { + return nil, err + } + var chapters []source.SChapter + doc.Find("#"+s.cfg.ChapterListID+" > li.chapter-item").Each(func(_ int, el *goquery.Selection) { + ch := source.SChapter{} + el.Find("a").First().Each(func(_ int, a *goquery.Selection) { + ch.URL, _ = a.Attr("href") + ch.Name = strings.TrimSpace(a.Find(".name").Text()) + if ch.Name == "" { + ch.Name = strings.TrimSpace(a.Text()) + } + }) + el.Find(".date").First().Each(func(_ int, e *goquery.Selection) { + ch.DateUpload = util.ParseRelativeDate(e.Text()) + }) + if ch.URL != "" { + chapters = append(chapters, ch) + } + }) + return chapters, nil +} + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + chapterURL := util.AbsURL(s.cfg.BaseURL, chapter.URL) + doc, err := s.get(context.Background(), chapterURL) + if err != nil { + return nil, err + } + // extract reading ID from the page + readingID, _ := doc.Find("div[data-reading-id]").First().Attr("data-reading-id") + if readingID == "" { + readingID, _ = doc.Find("div[data-id]").First().Attr("data-id") + } + if readingID == "" { + return nil, fmt.Errorf("mangareader: could not find reading ID") + } + // double-slash is intentional — matches Kotlin source + ajaxURL := fmt.Sprintf("%s//ajax/image/list/%s?mode=vertical", s.base(), readingID) + ajaxDoc, err := s.get(context.Background(), ajaxURL) + if err != nil { + return nil, err + } + var pages []source.Page + ajaxDoc.Find(".container-reader-chapter > div > img, .container-reader-chapter img").Each(func(i int, img *goquery.Selection) { + if u := imgAttr(img, s.cfg.BaseURL); u != "" { + pages = append(pages, source.Page{Index: i, ImageURL: u}) + } + }) + return pages, nil +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } +func (s *Source) GetFilterList() []source.Filter { return nil } + +func imgAttr(img *goquery.Selection, baseURL string) string { + for _, attr := range []string{"data-lazy-src", "data-src", "data-cfsrc", "src"} { + if v, ok := img.Attr(attr); ok && v != "" && !strings.HasPrefix(v, "data:") { + return util.AbsURL(baseURL, v) + } + } + return "" +} diff --git a/sources/base/mangotheme/mangotheme.go b/sources/base/mangotheme/mangotheme.go new file mode 100644 index 0000000..e9563e3 --- /dev/null +++ b/sources/base/mangotheme/mangotheme.go @@ -0,0 +1,256 @@ +// Package mangotheme implements the MangoTheme Brazilian manga base. +// JSON REST + AES/CBC decryption on all API responses. +package mangotheme + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "goyomi/internal/httpclient" + "goyomi/internal/source" + "goyomi/sources/base/util" +) + +type Config struct { + Name string + BaseURL string + Lang string + EncryptionKey string + APIPath string // defaults to "/api" +} + +type Source struct { + cfg Config + client *httpclient.Client + id int64 +} + +func New(cfg Config) *Source { + if cfg.APIPath == "" { + cfg.APIPath = "/api" + } + c := httpclient.NewClient(httpclient.WithRateLimit(2, 1)) + return &Source{cfg: cfg, client: c, id: source.GenerateSourceID(cfg.Name, cfg.Lang)} +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return s.cfg.Name } +func (s *Source) Lang() string { return s.cfg.Lang } +func (s *Source) SupportsLatest() bool { return true } + +func (s *Source) apiBase() string { + return strings.TrimRight(s.cfg.BaseURL, "/") + s.cfg.APIPath +} + +// decrypt decrypts a MangoTheme payload: format "{iv_hex}:{ciphertext_hex}" +// with AES-256-CBC, key derived as SHA-256(encryptionKey+"salt"). +func (s *Source) decrypt(payload string) (string, error) { + payload = strings.TrimSpace(payload) + if strings.HasPrefix(payload, "{") || strings.HasPrefix(payload, "[") { + return payload, nil + } + parts := strings.SplitN(payload, ":", 2) + if len(parts) != 2 { + return "", fmt.Errorf("mangotheme: invalid encrypted payload") + } + keyHash := sha256.Sum256([]byte(s.cfg.EncryptionKey + "salt")) + iv, err := hex.DecodeString(parts[0]) + if err != nil { + return "", fmt.Errorf("mangotheme: bad iv: %w", err) + } + ciphertext, err := hex.DecodeString(parts[1]) + if err != nil { + return "", fmt.Errorf("mangotheme: bad ciphertext: %w", err) + } + block, err := aes.NewCipher(keyHash[:]) + if err != nil { + return "", err + } + if len(ciphertext)%aes.BlockSize != 0 { + return "", fmt.Errorf("mangotheme: ciphertext not block-aligned") + } + cipher.NewCBCDecrypter(block, iv).CryptBlocks(ciphertext, ciphertext) + // PKCS7 unpad + if len(ciphertext) == 0 { + return "", fmt.Errorf("mangotheme: empty after decrypt") + } + pad := int(ciphertext[len(ciphertext)-1]) + if pad == 0 || pad > aes.BlockSize { + return "", fmt.Errorf("mangotheme: invalid padding") + } + return string(ciphertext[:len(ciphertext)-pad]), nil +} + +func (s *Source) getJSON(ctx context.Context, rawURL string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Referer", s.cfg.BaseURL+"/") + resp, err := s.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("mangotheme: HTTP %d", resp.StatusCode) + } + raw, _ := io.ReadAll(resp.Body) + body := string(raw) + if s.cfg.EncryptionKey != "" { + body, err = s.decrypt(body) + if err != nil { + return err + } + } + return json.NewDecoder(bytes.NewReader([]byte(body))).Decode(out) +} + +type mangaDTO struct { + ID int `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Cover string `json:"cover"` + Synopsis string `json:"synopsis"` + Author string `json:"author"` + Status string `json:"status"` + Tags []struct{ Name string `json:"name"` } `json:"tags"` + Chapters []chapterDTO `json:"chapters"` +} + +type chapterDTO struct { + ID int `json:"id"` + MangaID int `json:"obra_id"` + Number string `json:"numero"` + Title string `json:"nome"` + Date string `json:"criado_em"` +} + +type pageDTO struct { + Number int `json:"numero"` + URL string `json:"url"` +} + +type responseDTO[T any] struct { + Success bool `json:"sucesso"` + Payload T `json:"dados"` + Pagination *struct { + HasNextPage bool `json:"hasNextPage"` + } `json:"pagination"` +} + +func (s *Source) toSManga(m mangaDTO) source.SManga { + genres := make([]string, len(m.Tags)) + for i, t := range m.Tags { + genres[i] = t.Name + } + return source.SManga{ + URL: fmt.Sprintf("%d", m.ID), + Title: m.Title, + Author: m.Author, + Description: m.Synopsis, + Genre: strings.Join(genres, ", "), + Status: util.StatusFromString(m.Status), + ThumbnailURL: util.AbsURL(s.cfg.BaseURL, m.Cover), + } +} + +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + var result responseDTO[[]mangaDTO] + if err := s.getJSON(context.Background(), s.apiBase()+"/obras/top10/views?periodo=total", &result); err != nil { + return source.MangasPage{}, err + } + mangas := make([]source.SManga, len(result.Payload)) + for i, m := range result.Payload { + mangas[i] = s.toSManga(m) + } + return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil +} + +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + var result responseDTO[[]mangaDTO] + if err := s.getJSON(context.Background(), fmt.Sprintf("%s/capitulos/recentes?pagina=%d&limite=20", s.apiBase(), page), &result); err != nil { + return source.MangasPage{}, err + } + mangas := make([]source.SManga, len(result.Payload)) + for i, m := range result.Payload { + mangas[i] = s.toSManga(m) + } + hasNext := result.Pagination != nil && result.Pagination.HasNextPage + return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil +} + +func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { + var result responseDTO[[]mangaDTO] + u := fmt.Sprintf("%s/obras?pagina=%d&limite=20&busca=%s", s.apiBase(), page, query) + if err := s.getJSON(context.Background(), u, &result); err != nil { + return source.MangasPage{}, err + } + mangas := make([]source.SManga, len(result.Payload)) + for i, m := range result.Payload { + mangas[i] = s.toSManga(m) + } + hasNext := result.Pagination != nil && result.Pagination.HasNextPage + return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + var result responseDTO[mangaDTO] + if err := s.getJSON(context.Background(), fmt.Sprintf("%s/obras/%s", s.apiBase(), manga.URL), &result); err != nil { + return manga, err + } + return s.toSManga(result.Payload), nil +} + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + var result responseDTO[mangaDTO] + if err := s.getJSON(context.Background(), fmt.Sprintf("%s/obras/%s", s.apiBase(), manga.URL), &result); err != nil { + return nil, err + } + chapters := make([]source.SChapter, len(result.Payload.Chapters)) + for i, ch := range result.Payload.Chapters { + name := "Chapter " + ch.Number + if ch.Title != "" { + name += " - " + ch.Title + } + chapters[i] = source.SChapter{ + URL: fmt.Sprintf("%d/%s", ch.MangaID, ch.Number), + Name: name, + DateUpload: util.ParseAbsoluteDate(ch.Date, "2006-01-02T15:04:05Z"), + } + } + return chapters, nil +} + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + parts := strings.SplitN(chapter.URL, "/", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("mangotheme: invalid chapter URL") + } + mangaID, chapterNumber := parts[0], parts[1] + var result responseDTO[[]pageDTO] + if err := s.getJSON(context.Background(), fmt.Sprintf("%s/obras/%s/capitulos/%s", s.apiBase(), mangaID, chapterNumber), &result); err != nil { + return nil, err + } + pages := make([]source.Page, len(result.Payload)) + for i, p := range result.Payload { + pages[i] = source.Page{Index: p.Number - 1, ImageURL: util.AbsURL(s.cfg.BaseURL, p.URL)} + if pages[i].Index < 0 { + pages[i].Index = i + } + } + return pages, nil +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } +func (s *Source) GetFilterList() []source.Filter { return nil } diff --git a/sources/base/mmlook/mmlook.go b/sources/base/mmlook/mmlook.go new file mode 100644 index 0000000..aad3681 --- /dev/null +++ b/sources/base/mmlook/mmlook.go @@ -0,0 +1,230 @@ +// Package mmlook implements the MMLook (漫漫看) Chinese manga base. +// GET {desktopUrl}/rank/1 for popular; JS eval+decrypt for pages; CF-protected. +package mmlook + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/PuerkitoBio/goquery" + + "goyomi/internal/httpclient" + "goyomi/internal/source" + "goyomi/sources/base/util" +) + +type Config struct { + Name string + BaseURL string + DesktopURL string // desktop variant URL (may differ from BaseURL) + Lang string + UseLegacyURL bool +} + +type Source struct { + cfg Config + client *httpclient.Client + id int64 +} + +func New(cfg Config) *Source { + if cfg.DesktopURL == "" { + cfg.DesktopURL = cfg.BaseURL + } + if cfg.Lang == "" { + cfg.Lang = "zh" + } + c := httpclient.NewClient(httpclient.WithRateLimit(1, 2)) + return &Source{cfg: cfg, client: c, id: source.GenerateSourceID(cfg.Name, cfg.Lang)} +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return s.cfg.Name } +func (s *Source) Lang() string { return s.cfg.Lang } +func (s *Source) SupportsLatest() bool { return true } + +func (s *Source) desktop() string { return strings.TrimRight(s.cfg.DesktopURL, "/") } +func (s *Source) mobile() string { return strings.TrimRight(s.cfg.BaseURL, "/") } + +func (s *Source) get(ctx context.Context, rawURL string) (*goquery.Document, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Referer", s.cfg.BaseURL+"/") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("mmlook: HTTP %d", resp.StatusCode) + } + return goquery.NewDocumentFromReader(resp.Body) +} + +func (s *Source) mangaURL(id string) string { + id = strings.Trim(id, "/") + if s.cfg.UseLegacyURL { + return fmt.Sprintf("http://%s/%s/", strings.TrimPrefix(strings.TrimPrefix(s.mobile(), "https://"), "http://"), id) + } + return fmt.Sprintf("%s/%s/", s.mobile(), id) +} + +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + doc, err := s.get(context.Background(), fmt.Sprintf("%s/rank/1", s.desktop())) + if err != nil { + return source.MangasPage{}, err + } + var mangas []source.SManga + doc.Find(".book-list li, .comics-list li, .rank-list li").Each(func(_ int, el *goquery.Selection) { + m := source.SManga{} + el.Find("a").First().Each(func(_ int, a *goquery.Selection) { + if href, ok := a.Attr("href"); ok { + m.URL = href + } + }) + el.Find("img").First().Each(func(_ int, img *goquery.Selection) { + m.ThumbnailURL = imgAttr(img, s.cfg.BaseURL) + }) + el.Find(".title, .name, h3, h4").First().Each(func(_ int, e *goquery.Selection) { + m.Title = strings.TrimSpace(e.Text()) + }) + if m.URL != "" { + mangas = append(mangas, m) + } + }) + return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil +} + +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + return s.GetPopularManga(page) +} + +func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { + doc, err := s.get(context.Background(), fmt.Sprintf("%s/search?q=%s", s.mobile(), query)) + if err != nil { + return source.MangasPage{}, err + } + var mangas []source.SManga + doc.Find(".book-list li, .search-list li, .comics-list li").Each(func(_ int, el *goquery.Selection) { + m := source.SManga{} + el.Find("a").First().Each(func(_ int, a *goquery.Selection) { + if href, ok := a.Attr("href"); ok { + m.URL = href + } + }) + el.Find("img").First().Each(func(_ int, img *goquery.Selection) { + m.ThumbnailURL = imgAttr(img, s.cfg.BaseURL) + }) + el.Find(".title, .name, h3").First().Each(func(_ int, e *goquery.Selection) { + m.Title = strings.TrimSpace(e.Text()) + }) + if m.URL != "" { + mangas = append(mangas, m) + } + }) + return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + id := strings.Trim(util.SlugFromURL(manga.URL), "/") + doc, err := s.get(context.Background(), fmt.Sprintf("%s/%s/", s.desktop(), id)) + if err != nil { + return manga, err + } + result := source.SManga{URL: manga.URL} + comicInfo := doc.Find(".comicInfo, .comic-info, #comicInfo") + if comicInfo.Length() == 0 { + comicInfo = doc.Find("body") + } + result.Title = strings.TrimSpace(comicInfo.Find("h1").First().Text()) + if result.Title == "" { + result.Title = manga.Title + } + result.ThumbnailURL = imgAttr(comicInfo.Find("img").First(), s.cfg.BaseURL) + comicInfo.Find(".detinfo span, .info span").Each(func(_ int, el *goquery.Selection) { + text := el.Text() + switch { + case strings.HasPrefix(text, "作 者:") || strings.HasPrefix(text, "作者:"): + result.Author = strings.TrimSpace(text[strings.Index(text, ":")+3:]) + case strings.HasPrefix(text, "标 签:") || strings.HasPrefix(text, "标签:"): + result.Genre = strings.ReplaceAll(strings.TrimSpace(text[strings.Index(text, ":")+3:]), " ", ", ") + case strings.HasPrefix(text, "状 态:") || strings.HasPrefix(text, "状态:"): + result.Status = util.StatusFromString(text) + } + }) + result.Description = strings.TrimSpace(comicInfo.Find(".content, .intro, .synopsis").Text()) + return result, nil +} + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + id := strings.Trim(util.SlugFromURL(manga.URL), "/") + doc, err := s.get(context.Background(), fmt.Sprintf("%s/%s/", s.desktop(), id)) + if err != nil { + return nil, err + } + var chapters []source.SChapter + doc.Find(".chapter-list li a, #chapter-list li a, .chapter a").Each(func(_ int, a *goquery.Selection) { + ch := source.SChapter{Name: strings.TrimSpace(a.Text())} + ch.URL, _ = a.Attr("href") + if ch.URL != "" { + chapters = append(chapters, ch) + } + }) + return chapters, nil +} + +// evalScriptRe extracts a packed/obfuscated eval script +var evalScriptRe = regexp.MustCompile(`eval\(function\(p,a,c,k,e,(?:d|r)\).*?\)\)`) + +// imageURLsRe extracts image URLs from unpacked content +var imageURLsRe = regexp.MustCompile(`https?://[^\s"']+\.(?:jpg|jpeg|png|webp)[^\s"']*`) + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + chURL := util.AbsURL(s.cfg.BaseURL, chapter.URL) + doc, err := s.get(context.Background(), chURL) + if err != nil { + return nil, err + } + // try direct img tags first + var pages []source.Page + doc.Find(".readerArea img, .reading-content img, #chapter-images img").Each(func(i int, img *goquery.Selection) { + if u := imgAttr(img, s.cfg.BaseURL); u != "" { + pages = append(pages, source.Page{Index: i, ImageURL: u}) + } + }) + if len(pages) > 0 { + return pages, nil + } + // extract image URLs from scripts (packed JS decryption not fully implemented) + doc.Find("script").Each(func(_ int, el *goquery.Selection) { + script := el.Text() + if !strings.Contains(script, "eval") && !strings.Contains(script, "image") { + return + } + matches := imageURLsRe.FindAllString(script, -1) + for _, u := range matches { + pages = append(pages, source.Page{Index: len(pages), ImageURL: u}) + } + }) + if len(pages) == 0 { + return nil, fmt.Errorf("mmlook: could not extract page images (packed JS decryption not implemented)") + } + return pages, nil +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } +func (s *Source) GetFilterList() []source.Filter { return nil } + +func imgAttr(img *goquery.Selection, baseURL string) string { + for _, attr := range []string{"data-lazy-src", "data-src", "data-cfsrc", "src"} { + if v, ok := img.Attr(attr); ok && v != "" && !strings.HasPrefix(v, "data:") { + return util.AbsURL(baseURL, v) + } + } + return "" +} diff --git a/sources/base/pizzareader/pizzareader.go b/sources/base/pizzareader/pizzareader.go new file mode 100644 index 0000000..bad7b47 --- /dev/null +++ b/sources/base/pizzareader/pizzareader.go @@ -0,0 +1,225 @@ +// Package pizzareader implements the PizzaReader manga base. +// JSON REST API: GET {api}/comics for popular; GET {api}{chapter.url} for pages. +package pizzareader + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + + "goyomi/internal/httpclient" + "goyomi/internal/source" + "goyomi/sources/base/util" +) + +type Config struct { + Name string + BaseURL string + Lang string + APIPath string // defaults to "/api" +} + +type Source struct { + cfg Config + client *httpclient.Client + id int64 +} + +func New(cfg Config) *Source { + if cfg.APIPath == "" { + cfg.APIPath = "/api" + } + c := httpclient.NewClient(httpclient.WithRateLimit(1, 2)) + return &Source{cfg: cfg, client: c, id: source.GenerateSourceID(cfg.Name, cfg.Lang)} +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return s.cfg.Name } +func (s *Source) Lang() string { return s.cfg.Lang } +func (s *Source) SupportsLatest() bool { return true } + +func (s *Source) apiBase() string { + return strings.TrimRight(s.cfg.BaseURL, "/") + s.cfg.APIPath +} + +func (s *Source) getJSON(ctx context.Context, rawURL string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Referer", s.cfg.BaseURL+"/") + resp, err := s.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("pizzareader: HTTP %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + return json.Unmarshal(body, out) +} + +type pizzaComicDTO struct { + Slug string `json:"slug"` + Title string `json:"title"` + Author string `json:"author"` + Artist string `json:"artist"` + Description string `json:"description"` + Thumbnail string `json:"thumbnail"` + Status string `json:"status"` + Genres []struct{ Name string `json:"name"` } `json:"genres"` + Chapters []pizzaChapterDTO `json:"chapters"` + LastChapter *pizzaChapterDTO `json:"last_chapter"` +} + +type pizzaChapterDTO struct { + Slug string `json:"slug"` + Chapter *int `json:"chapter"` + Subchapter *int `json:"subchapter"` + Title string `json:"title"` + PublishedOn string `json:"published_on"` + ComicSlug string `json:"comic_slug"` +} + +type pizzaResultDTO struct { + Comic *pizzaComicDTO `json:"comic"` + Comics []pizzaComicDTO `json:"comics"` +} + +type pizzaReaderDTO struct { + Chapter *struct { + Pages []string `json:"pages"` + } `json:"chapter"` +} + +func (s *Source) toSManga(c pizzaComicDTO) source.SManga { + genres := make([]string, len(c.Genres)) + for i, g := range c.Genres { + genres[i] = g.Name + } + return source.SManga{ + URL: "/comics/" + c.Slug, + Title: c.Title, + Author: c.Author, + Artist: c.Artist, + Description: c.Description, + Genre: strings.Join(genres, ", "), + Status: util.StatusFromString(c.Status), + ThumbnailURL: c.Thumbnail, + } +} + +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + var result pizzaResultDTO + if err := s.getJSON(context.Background(), s.apiBase()+"/comics", &result); err != nil { + return source.MangasPage{}, err + } + // sort by last_chapter published_on descending, take all + comics := result.Comics + sort.Slice(comics, func(i, j int) bool { + di := "" + dj := "" + if comics[i].LastChapter != nil { + di = comics[i].LastChapter.PublishedOn + } + if comics[j].LastChapter != nil { + dj = comics[j].LastChapter.PublishedOn + } + return di > dj + }) + mangas := make([]source.SManga, len(comics)) + for i, c := range comics { + mangas[i] = s.toSManga(c) + } + return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil +} + +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + return s.GetPopularManga(page) +} + +func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { + var result pizzaResultDTO + u := fmt.Sprintf("%s/search/%s", s.apiBase(), query) + if err := s.getJSON(context.Background(), u, &result); err != nil { + return source.MangasPage{}, err + } + mangas := make([]source.SManga, len(result.Comics)) + for i, c := range result.Comics { + mangas[i] = s.toSManga(c) + } + return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + var result pizzaResultDTO + if err := s.getJSON(context.Background(), s.apiBase()+manga.URL, &result); err != nil { + return manga, err + } + if result.Comic == nil { + return manga, fmt.Errorf("pizzareader: no comic in response") + } + return s.toSManga(*result.Comic), nil +} + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + var result pizzaResultDTO + if err := s.getJSON(context.Background(), s.apiBase()+manga.URL, &result); err != nil { + return nil, err + } + if result.Comic == nil { + return nil, fmt.Errorf("pizzareader: no comic in response") + } + chapters := make([]source.SChapter, len(result.Comic.Chapters)) + for i, ch := range result.Comic.Chapters { + num := -1 + if ch.Chapter != nil { + num = *ch.Chapter + } + sub := 0 + if ch.Subchapter != nil { + sub = *ch.Subchapter + } + name := fmt.Sprintf("Chapter %d", num) + if sub > 0 { + name += fmt.Sprintf(".%d", sub) + } + if ch.Title != "" { + name += " - " + ch.Title + } + comicSlug := ch.ComicSlug + if comicSlug == "" { + comicSlug = util.SlugFromURL(manga.URL) + } + chapters[i] = source.SChapter{ + URL: fmt.Sprintf("/comics/%s/%s", comicSlug, ch.Slug), + Name: name, + DateUpload: util.ParseAbsoluteDate(ch.PublishedOn, "2006-01-02T15:04:05.999999"), + } + } + return chapters, nil +} + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + var result pizzaReaderDTO + if err := s.getJSON(context.Background(), s.apiBase()+chapter.URL, &result); err != nil { + return nil, err + } + if result.Chapter == nil { + return nil, fmt.Errorf("pizzareader: no chapter in response") + } + pages := make([]source.Page, len(result.Chapter.Pages)) + for i, p := range result.Chapter.Pages { + pages[i] = source.Page{Index: i, ImageURL: p} + } + return pages, nil +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } +func (s *Source) GetFilterList() []source.Filter { return nil } diff --git a/sources/base/scanreader/scanreader.go b/sources/base/scanreader/scanreader.go new file mode 100644 index 0000000..6e7d750 --- /dev/null +++ b/sources/base/scanreader/scanreader.go @@ -0,0 +1,282 @@ +// Package scanreader implements the ScanReader French manga base. +// GET {base}/bibliotheque/page/{n-1}/?sort=views; WordPress AJAX chapters. +package scanreader + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/PuerkitoBio/goquery" + + "goyomi/internal/httpclient" + "goyomi/internal/source" + "goyomi/sources/base/util" +) + +type Config struct { + Name string + BaseURL string + Lang string +} + +type Source struct { + cfg Config + client *httpclient.Client + id int64 +} + +func New(cfg Config) *Source { + c := httpclient.NewClient(httpclient.WithRateLimit(1, 2)) + return &Source{cfg: cfg, client: c, id: source.GenerateSourceID(cfg.Name, cfg.Lang)} +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return s.cfg.Name } +func (s *Source) Lang() string { return s.cfg.Lang } +func (s *Source) SupportsLatest() bool { return true } + +func (s *Source) base() string { return strings.TrimRight(s.cfg.BaseURL, "/") } + +func (s *Source) get(ctx context.Context, rawURL string) (*goquery.Document, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Referer", s.cfg.BaseURL+"/") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("scanreader: HTTP %d", resp.StatusCode) + } + return goquery.NewDocumentFromReader(resp.Body) +} + +func mangaFromCard(el *goquery.Selection, baseURL string) source.SManga { + m := source.SManga{} + el.Find("a").First().Each(func(_ int, a *goquery.Selection) { + m.URL, _ = a.Attr("href") + m.Title = strings.TrimSpace(a.AttrOr("title", a.Text())) + }) + m.ThumbnailURL = imgAttr(el.Find("img").First(), baseURL) + return m +} + +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + if page == 1 { + doc, err := s.get(context.Background(), s.base()) + if err != nil { + return source.MangasPage{}, err + } + var mangas []source.SManga + doc.Find("div.popular-section div.manga-card").Each(func(_ int, el *goquery.Selection) { + m := mangaFromCard(el, s.cfg.BaseURL) + if m.URL != "" { + mangas = append(mangas, m) + } + }) + return source.MangasPage{Mangas: mangas, HasNextPage: true}, nil + } + doc, err := s.get(context.Background(), fmt.Sprintf("%s/bibliotheque/page/%d/?sort=views", s.base(), page-1)) + if err != nil { + return source.MangasPage{}, err + } + var mangas []source.SManga + doc.Find("div.manga-card").Each(func(_ int, el *goquery.Selection) { + m := mangaFromCard(el, s.cfg.BaseURL) + if m.URL != "" { + mangas = append(mangas, m) + } + }) + hasNext := doc.Find("a.pagination-next").Length() > 0 + return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil +} + +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + doc, err := s.get(context.Background(), fmt.Sprintf("%s/dernieres-sorties/page/%d/", s.base(), page)) + if err != nil { + return source.MangasPage{}, err + } + var mangas []source.SManga + doc.Find("div.manga-cover, div.manga-card").Each(func(_ int, el *goquery.Selection) { + m := mangaFromCard(el, s.cfg.BaseURL) + if m.URL != "" { + mangas = append(mangas, m) + } + }) + hasNext := doc.Find("a.pagination-next").Length() > 0 + return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil +} + +func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { + doc, err := s.get(context.Background(), fmt.Sprintf("%s/?s=%s&post_type=manga", s.base(), url.QueryEscape(query))) + if err != nil { + return source.MangasPage{}, err + } + var mangas []source.SManga + doc.Find("div.manga-card").Each(func(_ int, el *goquery.Selection) { + m := mangaFromCard(el, s.cfg.BaseURL) + if m.URL != "" { + mangas = append(mangas, m) + } + }) + return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, manga.URL)) + if err != nil { + return manga, err + } + result := source.SManga{URL: manga.URL} + result.Title = strings.TrimSpace(doc.Find("h1.manga-title").Text()) + if result.Title == "" { + result.Title = manga.Title + } + if v, ok := doc.Find("meta[property='og:image']").First().Attr("content"); ok { + result.ThumbnailURL = v + } + result.Description = strings.TrimSpace(doc.Find("div.manga-content div[style*='background: #333'] p").Text()) + doc.Find("div.manga-info-grid > div").Each(func(_ int, row *goquery.Selection) { + label := strings.ToLower(strings.TrimSpace(row.Find("div:first-child").Text())) + val := strings.TrimSpace(row.Find("div:last-child").Text()) + switch { + case strings.Contains(label, "auteur"): + result.Author = val + case strings.Contains(label, "statut"): + result.Status = util.StatusFromString(val) + case strings.Contains(label, "genre"): + var genres []string + row.Find("span, a").Each(func(_ int, el *goquery.Selection) { + if t := strings.TrimSpace(el.Text()); t != "" { + genres = append(genres, t) + } + }) + result.Genre = strings.Join(genres, ", ") + } + }) + return result, nil +} + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, manga.URL)) + if err != nil { + return nil, err + } + container := doc.Find("#secure-chapters-container") + if container.Length() == 0 { + // try direct chapter list + return s.parseChapterList(doc), nil + } + mangaID := container.AttrOr("data-manga-id", "") + nonce := container.AttrOr("data-nonce", "") + if mangaID == "" || nonce == "" { + return s.parseChapterList(doc), nil + } + // POST admin-ajax.php to get chapters HTML + formData := url.Values{ + "action": {"get_chapters"}, + "manga_id": {mangaID}, + "nonce": {nonce}, + } + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, + s.base()+"/wp-admin/admin-ajax.php", + strings.NewReader(formData.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Referer", s.cfg.BaseURL+"/") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + // try to unwrap AjaxResponse {"data": ""}, else use raw + var ajax struct{ Data string `json:"data"` } + htmlStr := string(body) + if json.Unmarshal(body, &ajax) == nil && ajax.Data != "" { + htmlStr = ajax.Data + } + ajaxDoc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlStr)) + if err != nil { + return nil, err + } + return s.parseChapterList(ajaxDoc), nil +} + +func (s *Source) parseChapterList(doc *goquery.Document) []source.SChapter { + var chapters []source.SChapter + doc.Find("h4").Each(func(_ int, h4 *goquery.Selection) { + var chURL string + h4.Parents().Each(func(_ int, ancestor *goquery.Selection) { + if chURL != "" { + return + } + if href, ok := ancestor.Find("a[href*='/chapitre/']").First().Attr("href"); ok { + chURL = href + } + }) + if chURL == "" { + if href, ok := h4.Find("a").First().Attr("href"); ok { + chURL = href + } + } + if chURL == "" { + return + } + ch := source.SChapter{ + URL: chURL, + Name: strings.TrimSpace(h4.Text()), + } + chapters = append(chapters, ch) + }) + // fallback: li-based + if len(chapters) == 0 { + doc.Find("li.chapter-item, .chapter-list li").Each(func(_ int, el *goquery.Selection) { + ch := source.SChapter{} + el.Find("a").First().Each(func(_ int, a *goquery.Selection) { + ch.URL, _ = a.Attr("href") + ch.Name = strings.TrimSpace(a.Text()) + }) + if ch.URL != "" { + chapters = append(chapters, ch) + } + }) + } + return chapters +} + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, chapter.URL)) + if err != nil { + return nil, err + } + var pages []source.Page + doc.Find("div.reading-content img, .chapter-container img, #chapter-images img").Each(func(i int, img *goquery.Selection) { + if u := imgAttr(img, s.cfg.BaseURL); u != "" { + pages = append(pages, source.Page{Index: i, ImageURL: u}) + } + }) + return pages, nil +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } +func (s *Source) GetFilterList() []source.Filter { return nil } + +func imgAttr(img *goquery.Selection, baseURL string) string { + for _, attr := range []string{"data-lazy-src", "data-src", "data-cfsrc", "src"} { + if v, ok := img.Attr(attr); ok && v != "" && !strings.HasPrefix(v, "data:") { + return util.AbsURL(baseURL, v) + } + } + return "" +} diff --git a/sources/base/zmanga/zmanga.go b/sources/base/zmanga/zmanga.go new file mode 100644 index 0000000..f9cb516 --- /dev/null +++ b/sources/base/zmanga/zmanga.go @@ -0,0 +1,188 @@ +// Package zmanga implements the ZManga base. +// GET {base}/advanced-search/page/{n}/?order=popular; FlareSolverr required. +package zmanga + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/PuerkitoBio/goquery" + + "goyomi/internal/httpclient" + "goyomi/internal/source" + "goyomi/sources/base/util" +) + +type Config struct { + Name string + BaseURL string + Lang string +} + +type Source struct { + cfg Config + client *httpclient.Client + id int64 +} + +func New(cfg Config) *Source { + c := httpclient.NewClient(httpclient.WithRateLimit(1, 2)) + return &Source{cfg: cfg, client: c, id: source.GenerateSourceID(cfg.Name, cfg.Lang)} +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return s.cfg.Name } +func (s *Source) Lang() string { return s.cfg.Lang } +func (s *Source) SupportsLatest() bool { return true } + +func (s *Source) base() string { return strings.TrimRight(s.cfg.BaseURL, "/") } + +func (s *Source) get(ctx context.Context, rawURL string) (*goquery.Document, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Referer", s.cfg.BaseURL+"/") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("zmanga: HTTP %d", resp.StatusCode) + } + return goquery.NewDocumentFromReader(resp.Body) +} + +func (s *Source) pageSegment(page int) string { + if page > 1 { + return fmt.Sprintf("page/%d/", page) + } + return "" +} + +func (s *Source) parseMangaList(doc *goquery.Document) source.MangasPage { + var mangas []source.SManga + doc.Find("div.flexbox2-item").Each(func(_ int, el *goquery.Selection) { + m := source.SManga{} + el.Find("div.flexbox2-content a").First().Each(func(_ int, a *goquery.Selection) { + m.URL, _ = a.Attr("href") + }) + el.Find("div.flexbox2-title > span").First().Each(func(_ int, e *goquery.Selection) { + m.Title = strings.TrimSpace(e.Text()) + }) + m.ThumbnailURL = imgAttr(el.Find("img").First(), s.cfg.BaseURL) + if m.URL != "" { + mangas = append(mangas, m) + } + }) + hasNext := doc.Find(".pagination .next, .wp-pagenavi a.nextpostslink").Length() > 0 + return source.MangasPage{Mangas: mangas, HasNextPage: hasNext} +} + +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + doc, err := s.get(context.Background(), fmt.Sprintf("%s/advanced-search/%s?order=popular", s.base(), s.pageSegment(page))) + if err != nil { + return source.MangasPage{}, err + } + return s.parseMangaList(doc), nil +} + +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + doc, err := s.get(context.Background(), fmt.Sprintf("%s/advanced-search/%s?order=update", s.base(), s.pageSegment(page))) + if err != nil { + return source.MangasPage{}, err + } + return s.parseMangaList(doc), nil +} + +func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { + doc, err := s.get(context.Background(), fmt.Sprintf("%s/advanced-search/%s?s=%s", s.base(), s.pageSegment(page), query)) + if err != nil { + return source.MangasPage{}, err + } + return s.parseMangaList(doc), nil +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, manga.URL)) + if err != nil { + return manga, err + } + result := source.SManga{URL: manga.URL} + result.ThumbnailURL = imgAttr(doc.Find("div.series-thumb img").First(), s.cfg.BaseURL) + doc.Find(".series-infolist li").Each(func(_ int, el *goquery.Selection) { + label := strings.ToLower(strings.TrimSpace(el.Find("b, strong").Text())) + val := strings.TrimSpace(el.Find("span, a").First().Text()) + switch { + case strings.Contains(label, "author"): + result.Author = val + case strings.Contains(label, "artist"): + result.Artist = val + } + }) + doc.Find(".series-infoz .status").First().Each(func(_ int, el *goquery.Selection) { + result.Status = util.StatusFromString(el.Text()) + }) + result.Description = strings.TrimSpace(doc.Find("div.series-synops").Text()) + var genres []string + doc.Find("div.series-genres a").Each(func(_ int, el *goquery.Selection) { + if t := strings.TrimSpace(el.Text()); t != "" { + genres = append(genres, t) + } + }) + result.Genre = strings.Join(genres, ", ") + if t := strings.TrimSpace(doc.Find(".series-title h1, .series-titlex h1").Text()); t != "" { + result.Title = t + } else { + result.Title = manga.Title + } + return result, nil +} + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, manga.URL)) + if err != nil { + return nil, err + } + var chapters []source.SChapter + doc.Find("ul.series-chapterlist div.flexch-infoz a").Each(func(_ int, a *goquery.Selection) { + ch := source.SChapter{Name: strings.TrimSpace(a.Text())} + ch.URL, _ = a.Attr("href") + a.Closest("li").Find("span.date, .chapterdate").First().Each(func(_ int, el *goquery.Selection) { + ch.DateUpload = util.ParseRelativeDate(el.Text()) + }) + if ch.URL != "" { + chapters = append(chapters, ch) + } + }) + return chapters, nil +} + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, chapter.URL)) + if err != nil { + return nil, err + } + var pages []source.Page + doc.Find("div.reader-area img").Each(func(i int, img *goquery.Selection) { + if u := imgAttr(img, s.cfg.BaseURL); u != "" { + pages = append(pages, source.Page{Index: i, ImageURL: u}) + } + }) + return pages, nil +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } +func (s *Source) GetFilterList() []source.Filter { return nil } + +func imgAttr(img *goquery.Selection, baseURL string) string { + for _, attr := range []string{"data-lazy-src", "data-src", "data-cfsrc", "src"} { + if v, ok := img.Attr(attr); ok && v != "" && !strings.HasPrefix(v, "data:") { + return util.AbsURL(baseURL, v) + } + } + return "" +}