From ff4e845b5c1e873faa3c18d077e12d80b95ca874 Mon Sep 17 00:00:00 2001 From: EliasLd Date: Sat, 19 Sep 2026 13:59:59 +0200 Subject: [PATCH 1/8] feat(mangavyvy): html based catalog scraping utility --- internal/source/mangavyvy/scraper/catalog.go | 51 ++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 internal/source/mangavyvy/scraper/catalog.go diff --git a/internal/source/mangavyvy/scraper/catalog.go b/internal/source/mangavyvy/scraper/catalog.go new file mode 100644 index 0000000..9450fd4 --- /dev/null +++ b/internal/source/mangavyvy/scraper/catalog.go @@ -0,0 +1,51 @@ +package mangavyvyscraper + +import ( + "github.com/EliasLd/goweeb/internal/logger" + "github.com/EliasLd/goweeb/internal/source/common" +) + +type MangaResult struct { + Title string + URL string +} + +func SearchCatalog( + domain string, + query string, + log *logger.Logger, +) ([]MangaResult, error) { + items, err := common.SearchHTMLCatalog( + common.CatalogSearchConfig{ + Domain: domain, + EndpointPath: "/search", + QueryParam: "q", + CardSelector: ".book-list .comic-item", + LinkSelector: `a[href^="/manga/"]`, + TitleSelector: ".comic-title", + }, + query, + log, + ) + if err != nil { + return nil, err + } + + results := make( + []MangaResult, + 0, + len(items), + ) + + for _, item := range items { + results = append( + results, + MangaResult{ + Title: item.Label, + URL: item.Value, + }, + ) + } + + return results, nil +} From 0cbfc5a286172984beab900e2c9692a508e22e3c Mon Sep 17 00:00:00 2001 From: EliasLd Date: Sat, 19 Sep 2026 14:01:33 +0200 Subject: [PATCH 2/8] feat(mangavyvy): base provider implementation --- internal/source/mangavyvy/provider.go | 101 ++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 internal/source/mangavyvy/provider.go diff --git a/internal/source/mangavyvy/provider.go b/internal/source/mangavyvy/provider.go new file mode 100644 index 0000000..eb8b4bd --- /dev/null +++ b/internal/source/mangavyvy/provider.go @@ -0,0 +1,101 @@ +package mangavyvy + +import ( + "fmt" + "strings" + + "github.com/EliasLd/goweeb/internal/logger" + "github.com/EliasLd/goweeb/internal/source/common" + mangavyvyscraper "github.com/EliasLd/goweeb/internal/source/mangavyvy/scraper" + sourcetypes "github.com/EliasLd/goweeb/internal/source/types" +) + +const defaultDomain = "https://mangavyvy.com" + +type Provider struct { + domain string +} + +func New(customDomain string) *Provider { + domain := customDomain + + if domain == "" { + domain = defaultDomain + } + + return &Provider{ + domain: strings.TrimSuffix(domain, "/"), + } +} + +func (p *Provider) Name() string { + return "mangavyvy" +} + +func (p *Provider) Search( + query string, + log *logger.Logger, +) ([]sourcetypes.SearchResult, error) { + results, err := mangavyvyscraper.SearchCatalog( + p.domain, + query, + log, + ) + if err != nil { + return nil, err + } + + out := make( + []sourcetypes.SearchResult, + 0, + len(results), + ) + + for _, result := range results { + out = append( + out, + sourcetypes.SearchResult{ + Title: result.Title, + URL: result.URL, + }, + ) + } + + return out, nil +} + +// Mangavyvy currently exposes a single chapter list per manga. +func (p *Provider) ListScanPaths( + workURL string, + log *logger.Logger, +) ([]common.SelectableItem, error) { + return []common.SelectableItem{ + { + Label: "Mangavyvy", + Value: workURL, + }, + }, nil +} + +//TODO: +// - List Chapters +// - Download Images + +func (p *Provider) ListEntries( + workURL string, + scanPath string, + log *logger.Logger, +) (sourcetypes.Work, []sourcetypes.Entry, error) { + return sourcetypes.Work{}, nil, fmt.Errorf( + "Mangavyvy chapter listing is not implemented yet", + ) +} + +func (p *Provider) GetPageImageURLs( + entryURL string, + log *logger.Logger, +) ([]string, error) { + return nil, fmt.Errorf( + "Mangavyvy page extraction is not implemented yet", + ) +} From 907c82af34ed77ee6937291b08179ec285a0da0a Mon Sep 17 00:00:00 2001 From: EliasLd Date: Sat, 19 Sep 2026 14:02:14 +0200 Subject: [PATCH 3/8] feat(providerRegistration): register mangavyvy --- internal/source/source.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/source/source.go b/internal/source/source.go index 2a371d3..0953dd9 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -8,6 +8,7 @@ import ( "github.com/EliasLd/goweeb/internal/source/animesama" "github.com/EliasLd/goweeb/internal/source/mangadex" "github.com/EliasLd/goweeb/internal/source/mangafreak" + "github.com/EliasLd/goweeb/internal/source/mangavyvy" sourcetypes "github.com/EliasLd/goweeb/internal/source/types" "github.com/EliasLd/goweeb/internal/source/weebcentral" ) @@ -47,6 +48,12 @@ var providers = map[string]providerRegistration{ return mangadex.New(customDomain) }, }, + "mangavyvy": { + Label: "Mangavyvy (English)", + New: func(customDomain string) sourcetypes.Provider { + return mangavyvy.New(customDomain) + }, + }, } func NewSource(name, customDomain string) (sourcetypes.Provider, error) { From 25af80ba06cac5b0ae16c878af6a4dae472fce4f Mon Sep 17 00:00:00 2001 From: EliasLd Date: Sat, 19 Sep 2026 14:17:33 +0200 Subject: [PATCH 4/8] feat(mangavyvy): server side rendered chapter list scraping utility --- internal/source/mangavyvy/scraper/chapters.go | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 internal/source/mangavyvy/scraper/chapters.go diff --git a/internal/source/mangavyvy/scraper/chapters.go b/internal/source/mangavyvy/scraper/chapters.go new file mode 100644 index 0000000..47ed79e --- /dev/null +++ b/internal/source/mangavyvy/scraper/chapters.go @@ -0,0 +1,187 @@ +package mangavyvyscraper + +import ( + "fmt" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/EliasLd/goweeb/internal/logger" + + "github.com/PuerkitoBio/goquery" +) + +type Chapter struct { + Number int + Label string + URL string +} + +func ListChapters( + workURL string, + log *logger.Logger, +) ([]Chapter, error) { + log.Debug( + "Fetching Mangavyvy chapter list: %s\n", + workURL, + ) + + client := &http.Client{ + Timeout: 30 * time.Second, + } + + req, err := http.NewRequest( + http.MethodGet, + workURL, + nil, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to create Mangavyvy chapter request: %w", + err, + ) + } + + req.Header.Set( + "User-Agent", + "goweeb (+https://github.com/EliasLd/goweeb)", + ) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf( + "failed to fetch Mangavyvy chapter list: %w", + err, + ) + } + defer resp.Body.Close() + + log.Debug( + "Mangavyvy chapter list response status: %d\n", + resp.StatusCode, + ) + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf( + "unexpected Mangavyvy chapter list status: %d", + resp.StatusCode, + ) + } + + doc, err := goquery.NewDocumentFromReader( + resp.Body, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to parse Mangavyvy chapter list: %w", + err, + ) + } + + baseURL, err := url.Parse(workURL) + if err != nil { + return nil, fmt.Errorf( + "invalid Mangavyvy work URL: %w", + err, + ) + } + + var chapters []Chapter + + doc.Find("a.list-chapter").Each( + func(_ int, selection *goquery.Selection) { + rawID, ok := selection.Attr("id") + if !ok { + return + } + + numberStr := strings.TrimPrefix( + rawID, + "chapter-", + ) + + if numberStr == rawID || + numberStr == "" { + return + } + + number, err := strconv.Atoi(numberStr) + if err != nil { + log.Debug( + "Skipping unsupported non-integer Mangavyvy chapter: %s\n", + numberStr, + ) + + return + } + + href, ok := selection.Attr("href") + if !ok || + strings.TrimSpace(href) == "" { + return + } + + chapterURL, err := url.Parse( + strings.TrimSpace(href), + ) + if err != nil { + return + } + + chapterURL = baseURL.ResolveReference( + chapterURL, + ) + + label := strings.Join( + strings.Fields( + selection.Find("span"). + First(). + Text(), + ), + " ", + ) + + if label == "" { + label = fmt.Sprintf( + "Chapter %d", + number, + ) + } + + chapters = append( + chapters, + Chapter{ + Number: number, + Label: label, + URL: chapterURL.String(), + }, + ) + }, + ) + + if len(chapters) == 0 { + return nil, fmt.Errorf( + "no Mangavyvy chapters found", + ) + } + + // Mangavyvy renders newest chapters first. + // goweeb expects entries in ascending chapter order. + sort.Slice( + chapters, + func(i, j int) bool { + return chapters[i].Number < + chapters[j].Number + }, + ) + + log.Debug( + "Found %d Mangavyvy chapter(s)\n", + len(chapters), + ) + + return chapters, nil +} From e68d6ff5683103d4cf5d75ecc4dea0d5c2787dfb Mon Sep 17 00:00:00 2001 From: EliasLd Date: Sat, 19 Sep 2026 14:19:12 +0200 Subject: [PATCH 5/8] feat(mangavyvy): implement provider's ListEntries utility --- internal/source/mangavyvy/provider.go | 57 ++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/internal/source/mangavyvy/provider.go b/internal/source/mangavyvy/provider.go index eb8b4bd..bba6427 100644 --- a/internal/source/mangavyvy/provider.go +++ b/internal/source/mangavyvy/provider.go @@ -2,6 +2,8 @@ package mangavyvy import ( "fmt" + "net/url" + "path" "strings" "github.com/EliasLd/goweeb/internal/logger" @@ -16,6 +18,31 @@ type Provider struct { domain string } +func mangaTitleFromURL( + workURL string, +) string { + parsed, err := url.Parse(workURL) + if err != nil { + return "manga" + } + + slug := path.Base( + strings.TrimSuffix(parsed.Path, "/"), + ) + + if slug == "" || + slug == "." || + slug == "/" { + return "manga" + } + + return strings.ReplaceAll( + slug, + "-", + " ", + ) +} + func New(customDomain string) *Provider { domain := customDomain @@ -86,9 +113,35 @@ func (p *Provider) ListEntries( scanPath string, log *logger.Logger, ) (sourcetypes.Work, []sourcetypes.Entry, error) { - return sourcetypes.Work{}, nil, fmt.Errorf( - "Mangavyvy chapter listing is not implemented yet", + chapters, err := mangavyvyscraper.ListChapters( + workURL, + log, + ) + if err != nil { + return sourcetypes.Work{}, nil, err + } + + entries := make( + []sourcetypes.Entry, + 0, + len(chapters), ) + + for _, chapter := range chapters { + entries = append( + entries, + sourcetypes.Entry{ + Number: chapter.Number, + Label: chapter.Label, + URL: chapter.URL, + }, + ) + } + + return sourcetypes.Work{ + Title: mangaTitleFromURL(workURL), + Kind: sourcetypes.ItemChapter, + }, entries, nil } func (p *Provider) GetPageImageURLs( From 7e7fb56b55b3fdf7b55614ffffcb0d2a3578ae3b Mon Sep 17 00:00:00 2001 From: EliasLd Date: Sun, 20 Sep 2026 11:30:50 +0200 Subject: [PATCH 6/8] feat(mangavyvy): Download chapters images following redirections --- internal/source/mangavyvy/scraper/pages.go | 288 +++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 internal/source/mangavyvy/scraper/pages.go diff --git a/internal/source/mangavyvy/scraper/pages.go b/internal/source/mangavyvy/scraper/pages.go new file mode 100644 index 0000000..5f5e400 --- /dev/null +++ b/internal/source/mangavyvy/scraper/pages.go @@ -0,0 +1,288 @@ +package mangavyvyscraper + +import ( + "fmt" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/EliasLd/goweeb/internal/logger" + + "github.com/PuerkitoBio/goquery" +) + +type pageImage struct { + Index int + URL string +} + +func GetPageImageURLs( + entryURL string, + sourceDomain string, + log *logger.Logger, +) ([]string, error) { + log.Debug( + "Fetching Mangavyvy reader page: %s\n", + entryURL, + ) + + client := &http.Client{ + Timeout: 30 * time.Second, + } + + req, err := http.NewRequest( + http.MethodGet, + entryURL, + nil, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to create Mangavyvy reader request: %w", + err, + ) + } + + req.Header.Set( + "User-Agent", + "goweeb (+https://github.com/EliasLd/goweeb)", + ) + + req.Header.Set( + "Accept", + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + ) + + if strings.TrimSpace(sourceDomain) != "" { + req.Header.Set( + "Referer", + strings.TrimRight(sourceDomain, "/")+"/", + ) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf( + "failed to fetch Mangavyvy reader page: %w", + err, + ) + } + defer resp.Body.Close() + + log.Debug( + "Mangavyvy reader response status: %d\n", + resp.StatusCode, + ) + + if resp.Request != nil && + resp.Request.URL != nil { + log.Debug( + "Mangavyvy reader final URL: %s\n", + resp.Request.URL.String(), + ) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf( + "unexpected Mangavyvy reader status: %d", + resp.StatusCode, + ) + } + + doc, err := goquery.NewDocumentFromReader( + resp.Body, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to parse Mangavyvy reader page: %w", + err, + ) + } + + baseURL := resp.Request.URL + + var pages []pageImage + + seen := make(map[string]struct{}) + + doc.Find( + "#carousel .carousel-inner .carousel-item img", + ).Each( + func(position int, selection *goquery.Selection) { + index := position + + if parent := selection.Closest( + ".carousel-item", + ); parent.Length() > 0 { + if rawPage, ok := parent.Attr( + "data-page", + ); ok { + rawPage = strings.TrimSpace( + rawPage, + ) + + if parsedPage, err := strconv.Atoi( + rawPage, + ); err == nil { + index = parsedPage + } + } + } + + imageURL := imageSource( + selection, + ) + + if imageURL == "" { + return + } + + resolvedURL, err := resolveImageURL( + baseURL, + imageURL, + ) + if err != nil { + log.Debug( + "Skipping invalid Mangavyvy image URL: %s\n", + imageURL, + ) + + return + } + + if _, exists := seen[resolvedURL]; exists { + return + } + + seen[resolvedURL] = struct{}{} + + pages = append( + pages, + pageImage{ + Index: index, + URL: resolvedURL, + }, + ) + }, + ) + + if len(pages) == 0 { + finalURL := entryURL + + if resp.Request != nil && + resp.Request.URL != nil { + finalURL = resp.Request.URL.String() + } + + return nil, fmt.Errorf( + "no Mangavyvy page images found at %s", + finalURL, + ) + } + + sort.SliceStable( + pages, + func(i, j int) bool { + return pages[i].Index < + pages[j].Index + }, + ) + + imageURLs := make( + []string, + 0, + len(pages), + ) + + for _, page := range pages { + imageURLs = append( + imageURLs, + page.URL, + ) + } + + log.Debug( + "Found %d Mangavyvy page image(s)\n", + len(imageURLs), + ) + + return imageURLs, nil +} + +func imageSource( + selection *goquery.Selection, +) string { + // Mangavyvy lazy-loads later pages. + // + // data-src always contains the actual image while + // src may contain /web/img/loading.gif. + if value, ok := selection.Attr( + "data-src", + ); ok { + value = strings.TrimSpace(value) + + if value != "" { + return value + } + } + + value, ok := selection.Attr("src") + if !ok { + return "" + } + + value = strings.TrimSpace(value) + + if value == "" || + isPlaceholderImage(value) { + return "" + } + + return value +} + +func isPlaceholderImage( + imageURL string, +) bool { + lower := strings.ToLower(imageURL) + + return strings.Contains( + lower, + "/web/img/loading.gif", + ) || + strings.Contains( + lower, + "/web/img/blank.gif", + ) +} + +func resolveImageURL( + baseURL *url.URL, + rawURL string, +) (string, error) { + parsed, err := url.Parse( + strings.TrimSpace(rawURL), + ) + if err != nil { + return "", err + } + + if baseURL != nil { + parsed = baseURL.ResolveReference( + parsed, + ) + } + + switch parsed.Scheme { + case "http", "https": + default: + return "", fmt.Errorf( + "unsupported image URL scheme: %s", + parsed.Scheme, + ) + } + + return parsed.String(), nil +} From 014d9129f080ebb41bbfb7183ccc566357e4b659 Mon Sep 17 00:00:00 2001 From: EliasLd Date: Sun, 20 Sep 2026 11:31:27 +0200 Subject: [PATCH 7/8] feat(mangavyvy): completing provider contract by implementing GetPageImageURLs --- internal/source/mangavyvy/provider.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/source/mangavyvy/provider.go b/internal/source/mangavyvy/provider.go index bba6427..0f10b28 100644 --- a/internal/source/mangavyvy/provider.go +++ b/internal/source/mangavyvy/provider.go @@ -1,7 +1,6 @@ package mangavyvy import ( - "fmt" "net/url" "path" "strings" @@ -148,7 +147,9 @@ func (p *Provider) GetPageImageURLs( entryURL string, log *logger.Logger, ) ([]string, error) { - return nil, fmt.Errorf( - "Mangavyvy page extraction is not implemented yet", + return mangavyvyscraper.GetPageImageURLs( + entryURL, + p.domain, + log, ) } From 5bb4d6dc6aceb41bc26165b4652d60e6a29412be Mon Sep 17 00:00:00 2001 From: EliasLd Date: Sun, 20 Sep 2026 11:36:27 +0200 Subject: [PATCH 8/8] chore(docs): add mangavyvy to providers table --- README.md | 1 + README_FR.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 03a98a8..fe5ac24 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Currently, it can download manga from the following websites: | ---------- | ------------------------- | | Weebcentral (English) | https://weebcentral.com | | Mangadex (Multilingual) | https://mangadex.org | +| MangaVyvy (English) | https://mangavyvy.com | | MangaFreak (English)| https://ww3.mangafreak.me | | Anime-Sama (French only) | https://anime-sama.pw | diff --git a/README_FR.md b/README_FR.md index 994b325..f604f5a 100644 --- a/README_FR.md +++ b/README_FR.md @@ -14,6 +14,7 @@ Actuellement, il est possible de télécharger des manga depuis les sites suivan | ---------- | ------------------------- | | Weebcentral (Anglais) | https://weebcentral.com | | Mangadex (Plurilingue) | https://mangadex.org | +| MangaVyvy (Anglais) | https://mangavyvy.com | | MangaFreak (Anglais) | https://ww3.mangafreak.me | | Anime-Sama (Français uniquement) | https://anime-sama.pw |