feat: implement phase 4 batch — 54 base-class wrapper sources

Add 8 all/ sources (7 Masonry, 1 Madara) and 38 en/ sources spanning
Madara, MangaThemesia, MadTheme, Keyoapp, and Guya bases, plus 8 earlier
all/ standalone sources from the previous session (ahottie, akuma,
allporncomicsco, asmhentai, baobua, beauty3600000, buondua, comicfury,
comicgrowl, comicklive, comicsvalley, comikey, commitstrip, coomer).

Also annotates phase4-standalone.md with base-class tags for 43 additional
unimplemented en/ sources identified in a full scan.
This commit is contained in:
achmad
2026-05-13 23:11:26 +07:00
parent e17de903b2
commit 316ae2f9db
62 changed files with 4027 additions and 104 deletions
+221
View File
@@ -0,0 +1,221 @@
package ahottie
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"goyomi/internal/httpclient"
"goyomi/internal/registry"
"goyomi/internal/source"
)
type Source struct {
cfg Config
client *httpclient.Client
id int64
}
type Config struct {
Name string
BaseURL string
Lang string
}
func New() *Source {
c := httpclient.NewClient(httpclient.WithRateLimit(1, 2))
return &Source{
cfg: Config{
Name: "AHottie",
BaseURL: "https://ahottie.top",
Lang: "all",
},
client: c,
id: source.GenerateSourceID("AHottie", "all"),
}
}
func (s *Source) ID() int64 { return s.id }
func (s *Source) Name() string { return s.cfg.Name }
func (s *Source) Lang() string { return s.cfg.Lang }
func (s *Source) SupportsLatest() bool { return false }
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.base()+"/")
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("ahottie: HTTP %d", resp.StatusCode)
}
return goquery.NewDocumentFromReader(resp.Body)
}
func (s *Source) parseMangas(doc *goquery.Document) source.MangasPage {
var mangas []source.SManga
doc.Find("#main > div > div").Each(func(_ int, el *goquery.Selection) {
link := el.Find("a").First()
href := link.AttrOr("href", "")
if href == "" {
return
}
titleEl := el.Find("h2")
if titleEl.Length() == 0 {
return
}
m := source.SManga{
URL: href,
Title: strings.TrimSpace(titleEl.Text()),
}
if img := el.Find(".relative img").First(); img.Length() > 0 {
m.ThumbnailURL = img.AttrOr("src", "")
}
var genres []string
el.Find(".flex a").Each(func(_ int, a *goquery.Selection) {
if t := strings.TrimSpace(a.Text()); t != "" {
genres = append(genres, t)
}
})
m.Genre = strings.Join(genres, ", ")
if m.URL != "" && m.Title != "" {
mangas = append(mangas, m)
}
})
hasNext := doc.Find("a[rel=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?page=%d", s.base(), page))
if err != nil {
return source.MangasPage{}, err
}
return s.parseMangas(doc), nil
}
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
return source.MangasPage{}, fmt.Errorf("ahottie: latest updates not supported")
}
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
if strings.HasPrefix(query, "http") {
doc, err := s.get(context.Background(), query)
if err != nil {
return source.MangasPage{}, err
}
m := s.parseMangaDetails(doc)
m.URL = strings.TrimPrefix(query, s.base())
return source.MangasPage{Mangas: []source.SManga{m}, HasNextPage: false}, nil
}
doc, err := s.get(context.Background(), fmt.Sprintf("%s/search?kw=%s&page=%d", s.base(), query, page))
if err != nil {
return source.MangasPage{}, err
}
if doc.Find("h1").Length() > 0 && doc.Find("div.pl-3 > a").Length() > 0 {
m := s.parseMangaDetails(doc)
m.URL = strings.TrimPrefix(doc.Find("div.pl-3 > a").First().AttrOr("href", ""), s.base())
return source.MangasPage{Mangas: []source.SManga{m}, HasNextPage: false}, nil
}
return s.parseMangas(doc), nil
}
func (s *Source) parseMangaDetails(doc *goquery.Document) source.SManga {
result := source.SManga{}
titleEl := doc.Find("h1").First()
if titleEl.Length() > 0 {
result.Title = strings.TrimSpace(titleEl.Text())
}
var genres []string
doc.Find("div.pl-3 > a").Each(func(_ int, a *goquery.Selection) {
if t := strings.TrimSpace(a.Text()); t != "" {
genres = append(genres, t)
}
})
result.Genre = strings.Join(genres, ", ")
result.Status = source.StatusUnknown
return result
}
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
doc, err := s.get(context.Background(), s.base()+manga.URL)
if err != nil {
return manga, err
}
result := s.parseMangaDetails(doc)
result.URL = manga.URL
if result.Title == "" {
result.Title = manga.Title
}
return result, nil
}
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
doc, err := s.get(context.Background(), s.base()+manga.URL)
if err != nil {
return nil, err
}
timeEl := doc.Find("time").First()
var date int64
if dateStr := strings.TrimSpace(timeEl.Text()); dateStr != "" {
t, err := time.Parse("2006-01-02", dateStr)
if err == nil {
date = t.UnixMilli()
}
}
canonical := doc.Find("link[rel=canonical]").First()
href := canonical.AttrOr("href", "")
if href == "" {
return nil, fmt.Errorf("ahottie: chapter link not found")
}
return []source.SChapter{
{
URL: strings.TrimPrefix(href, s.base()),
Name: "GALLERY",
DateUpload: date,
},
}, nil
}
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
var pages []source.Page
doc, err := s.get(context.Background(), s.base()+chapter.URL)
if err != nil {
return nil, err
}
for {
doc.Find("#main img.block").Each(func(i int, img *goquery.Selection) {
src := img.AttrOr("src", "")
if src != "" {
pages = append(pages, source.Page{Index: len(pages), ImageURL: src})
}
})
nextURL := doc.Find("a[rel=next]").First().AttrOr("href", "")
if nextURL == "" {
break
}
doc, err = s.get(context.Background(), nextURL)
if err != nil {
break
}
}
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 init() {
registry.Register(New())
}
+339
View File
@@ -0,0 +1,339 @@
// Package akuma implements the Akuma manga base.
// FlareSolverr + CSRF token interceptor; POST with form for list; single chapter per manga.
package akuma
import (
"context"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"goyomi/internal/httpclient/flare"
"goyomi/internal/registry"
"goyomi/internal/source"
)
type Source struct {
cfg Config
client *flare.Client
id int64
nextHash string
csrfToken string
}
type Config struct {
Name string
BaseURL string
Lang string
}
func New() *Source {
c := flare.NewClient(flare.WithRateLimit(2, 1))
return &Source{
cfg: Config{
Name: "Akuma",
BaseURL: "https://akuma.moe",
Lang: "all",
},
client: c,
id: source.GenerateSourceID("Akuma", "all"),
}
}
func (s *Source) ID() int64 { return s.id }
func (s *Source) Name() string { return s.cfg.Name }
func (s *Source) Lang() string { return s.cfg.Lang }
func (s *Source) SupportsLatest() bool { return false }
func (s *Source) base() string { return strings.TrimRight(s.cfg.BaseURL, "/") }
var shortenTitleRe = regexp.MustCompile(`(\[[^]]*]|[({][^)}]*[)}])`)
func (s *Source) shortenTitle(t string) string {
return shortenTitleRe.ReplaceAllString(t, "")
}
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.base()+"/")
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("akuma: HTTP %d", resp.StatusCode)
}
return goquery.NewDocumentFromReader(resp.Body)
}
func (s *Source) post(ctx context.Context, rawURL string, body string) (*goquery.Document, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, strings.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", s.base()+"/")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
if s.csrfToken != "" {
req.Header.Set("X-CSRF-TOKEN", s.csrfToken)
}
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == 419 {
s.csrfToken = ""
return s.post(ctx, rawURL, body)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("akuma: HTTP %d", resp.StatusCode)
}
return goquery.NewDocumentFromReader(resp.Body)
}
func (s *Source) getCSRF() error {
if s.csrfToken != "" {
return nil
}
doc, err := s.get(context.Background(), s.base())
if err != nil {
return err
}
token := doc.Find("meta[name*=csrf-token]").AttrOr("content", "")
if token == "" {
return fmt.Errorf("akuma: CSRF token not found")
}
s.csrfToken = token
return nil
}
func (s *Source) parseMangas(doc *goquery.Document) source.MangasPage {
if strings.Contains(doc.Text(), "Max keywords of 3 exceeded.") {
return source.MangasPage{Mangas: []source.SManga{}, HasNextPage: false}
}
if strings.Contains(doc.Text(), "Max keywords of 8 exceeded.") {
return source.MangasPage{Mangas: []source.SManga{}, HasNextPage: false}
}
var mangas []source.SManga
doc.Find(".post-loop li").Each(func(_ int, el *goquery.Selection) {
link := el.Find("a").First()
href := link.AttrOr("href", "")
if href == "" {
return
}
title := strings.TrimSpace(el.Find(".overlay-title").Text())
title = strings.ReplaceAll(title, "\"", "")
m := source.SManga{
URL: href,
Title: s.shortenTitle(title),
}
if img := el.Find("img").First(); img.Length() > 0 {
m.ThumbnailURL = img.AttrOr("src", "")
}
if m.URL != "" && m.Title != "" {
mangas = append(mangas, m)
}
})
nextLink := doc.Find(".page-item a[rel*=next]").First()
nextURL := nextLink.AttrOr("href", "")
if nextURL != "" {
s.nextHash = extractCursor(nextURL)
} else {
s.nextHash = ""
}
return source.MangasPage{Mangas: mangas, HasNextPage: s.nextHash != ""}
}
var cursorRe = regexp.MustCompile(`cursor=([^&]+)`)
func extractCursor(url string) string {
m := cursorRe.FindStringSubmatch(url)
if m != nil {
return m[1]
}
return ""
}
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
if err := s.getCSRF(); err != nil {
return source.MangasPage{}, err
}
if page == 1 {
s.nextHash = ""
}
url := s.base()
if s.nextHash != "" {
url += "?cursor=" + s.nextHash
}
doc, err := s.post(context.Background(), url, "view=3")
if err != nil {
return source.MangasPage{}, err
}
return s.parseMangas(doc), nil
}
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
return source.MangasPage{}, fmt.Errorf("akuma: latest updates not supported")
}
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
if strings.HasPrefix(query, "https://") {
url := fmt.Sprintf("%s/g/%s", s.base(), extractID(query))
doc, err := s.get(context.Background(), url)
if err != nil {
return source.MangasPage{}, err
}
m := s.parseMangaDetails(doc)
return source.MangasPage{Mangas: []source.SManga{m}, HasNextPage: false}, nil
}
if strings.HasPrefix(query, "id:") {
id := strings.TrimPrefix(query, "id:")
doc, err := s.get(context.Background(), fmt.Sprintf("%s/g/%s", s.base(), id))
if err != nil {
return source.MangasPage{}, err
}
m := s.parseMangaDetails(doc)
m.URL = "/g/" + id
return source.MangasPage{Mangas: []source.SManga{m}, HasNextPage: false}, nil
}
if err := s.getCSRF(); err != nil {
return source.MangasPage{}, err
}
url := s.base()
if page > 1 && s.nextHash != "" {
url += "?cursor=" + s.nextHash
}
q := query
doc, err := s.post(context.Background(), url, "view=3&q="+q)
if err != nil {
return source.MangasPage{}, err
}
return s.parseMangas(doc), nil
}
func extractID(url string) string {
parts := strings.Split(strings.TrimSuffix(url, "/"), "/")
if len(parts) > 0 {
return parts[len(parts)-1]
}
return url
}
func (s *Source) parseMangaDetails(doc *goquery.Document) source.SManga {
title := doc.Find(".entry-title").Text()
title = strings.ReplaceAll(title, "\"", "")
result := source.SManga{
Title: s.shortenTitle(title),
}
if img := doc.Find(".img-thumbnail").First(); img.Length() > 0 {
result.ThumbnailURL = img.AttrOr("src", "")
}
var authors []string
doc.Find(".group~.value").Each(func(_ int, el *goquery.Selection) {
if t := strings.TrimSpace(el.Text()); t != "" {
authors = append(authors, t)
}
})
result.Author = strings.Join(authors, ", ")
var genres []string
doc.Find(".male~.value").Each(func(_ int, el *goquery.Selection) {
if t := strings.TrimSpace(el.Text()); t != "" {
genres = append(genres, t+" ♂")
}
})
doc.Find(".female~.value").Each(func(_ int, el *goquery.Selection) {
if t := strings.TrimSpace(el.Text()); t != "" {
genres = append(genres, t+" ♀")
}
})
doc.Find(".other~.value").Each(func(_ int, el *goquery.Selection) {
if t := strings.TrimSpace(el.Text()); t != "" {
genres = append(genres, t+" ◊")
}
})
result.Genre = strings.Join(genres, ", ")
result.Status = source.StatusUnknown
return result
}
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
doc, err := s.get(context.Background(), s.base()+manga.URL)
if err != nil {
return manga, err
}
result := s.parseMangaDetails(doc)
result.URL = manga.URL
if result.Title == "" {
result.Title = manga.Title
}
return result, nil
}
var dateRe = regexp.MustCompile(`(\d{4}-\d{2}-\d{2} \d{2}:\d{2})`)
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
doc, err := s.get(context.Background(), s.base()+manga.URL)
if err != nil {
return nil, err
}
m := dateRe.FindStringSubmatch(doc.Text())
var date int64
if m != nil {
t, err := time.Parse("2006-01-02 15:04", m[1])
if err == nil {
date = t.UnixMilli()
}
}
return []source.SChapter{
{
URL: manga.URL + "/1",
Name: "Chapter",
DateUpload: date,
},
}, nil
}
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
doc, err := s.get(context.Background(), s.base()+strings.TrimSuffix(chapter.URL, "/1"))
if err != nil {
return nil, err
}
total := 1
doc.Find(".nav-select option").Each(func(_ int, el *goquery.Selection) {
if v, err := strconv.Atoi(el.AttrOr("value", "")); err == nil && v > total {
total = v
}
})
if total == 1 {
src := doc.Find(".entry-content img").First().AttrOr("src", "")
if src != "" {
return []source.Page{{Index: 0, ImageURL: src}}, nil
}
return nil, fmt.Errorf("akuma: no images found")
}
baseURL := strings.TrimSuffix(s.base()+chapter.URL, "/1")
pages := make([]source.Page, total)
for i := 1; i <= total; i++ {
pages[i-1] = source.Page{Index: i - 1, ImageURL: fmt.Sprintf("%s/%d", baseURL, 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 }
func init() {
registry.Register(New())
}
@@ -0,0 +1,21 @@
package allporncomicsco
import (
"goyomi/internal/registry"
base "goyomi/sources/base/madara"
)
func New() *base.Source {
return base.New(base.Config{
Name: "AllPornComics.co",
BaseURL: "https://allporncomics.co",
Lang: "all",
MangaSubString: "comic",
UseNewChapterEndpoint: false,
PopularMangaURLSelector: "h3 > a:not([target=_self]):last-of-type",
})
}
func init() {
registry.Register(New())
}
+238
View File
@@ -0,0 +1,238 @@
// Package asmhentai implements the AsmHentai source (GalleryAdults-style).
// Multi-language: en/english, ja/japanese, zh/chinese, all (multi).
// Page list may require a POST to /inc/api.php for galleries with many pages.
package asmhentai
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/PuerkitoBio/goquery"
"goyomi/internal/httpclient/flare"
"goyomi/internal/registry"
"goyomi/internal/source"
)
const siteURL = "https://asmhentai.com"
type Source struct {
name string
lang string
mangaLang string // e.g. "english", "japanese", "" for all
client *flare.Client
id int64
}
func newSource(name, lang, mangaLang string) *Source {
return &Source{
name: name,
lang: lang,
mangaLang: mangaLang,
client: flare.NewClient(flare.WithRateLimit(1, 2)),
id: source.GenerateSourceID(name, lang),
}
}
func (s *Source) ID() int64 { return s.id }
func (s *Source) Name() string { return s.name }
func (s *Source) Lang() string { return s.lang }
func (s *Source) SupportsLatest() bool { return s.mangaLang != "" }
func (s *Source) langPath() string {
if s.mangaLang != "" {
return "language/" + s.mangaLang + "/"
}
return ""
}
func (s *Source) get(ctx context.Context, rawURL string) (*goquery.Document, error) {
resp, err := s.client.Get(ctx, rawURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("asmhentai: HTTP %d", resp.StatusCode)
}
return goquery.NewDocumentFromReader(resp.Body)
}
func imgAttr(img *goquery.Selection) string {
for _, attr := range []string{"data-lazy-src", "data-src", "src"} {
if v := img.AttrOr(attr, ""); v != "" && !strings.HasPrefix(v, "data:") {
return v
}
}
return ""
}
func thumbnailToFull(u string) string {
ext := u[strings.LastIndex(u, "."):]
return strings.Replace(u, "t"+ext, ext, 1)
}
func (s *Source) parsePage(doc *goquery.Document) source.MangasPage {
var mangas []source.SManga
doc.Find(".preview_item").Each(func(_ int, el *goquery.Selection) {
href := el.Find(".image a").First().AttrOr("href", "")
if href == "" {
return
}
m := source.SManga{URL: href}
m.ThumbnailURL = imgAttr(el.Find(".image img").First())
m.Title = strings.TrimSpace(el.Find(".caption").Text())
if m.Title == "" {
m.Title = strings.TrimSpace(el.Find("h2, h3").First().Text())
}
if m.URL != "" && m.Title != "" {
mangas = append(mangas, m)
}
})
hasNext := doc.Find(".next.page-numbers, a[aria-label=Next]").Length() > 0
return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}
}
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
u := fmt.Sprintf("%s/%spopular/?page=%d", siteURL, s.langPath(), page)
doc, err := s.get(context.Background(), u)
if err != nil {
return source.MangasPage{}, err
}
return s.parsePage(doc), nil
}
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
u := fmt.Sprintf("%s/%s?page=%d", siteURL, s.langPath(), page)
doc, err := s.get(context.Background(), u)
if err != nil {
return source.MangasPage{}, err
}
return s.parsePage(doc), nil
}
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
u := fmt.Sprintf("%s/search/?q=%s&page=%d", siteURL, url.QueryEscape(query), page)
doc, err := s.get(context.Background(), u)
if err != nil {
return source.MangasPage{}, err
}
return s.parsePage(doc), nil
}
func extractTags(info *goquery.Selection, tag string) string {
var items []string
info.Find(".tags").Each(func(_ int, tags *goquery.Selection) {
if !strings.Contains(tags.Text(), tag+":") {
return
}
tags.Find(".tag_list a").Each(func(_ int, a *goquery.Selection) {
t := strings.TrimSpace(a.Find(".tag").Text())
if t == "" {
t = strings.TrimSpace(a.Text())
}
if t != "" {
items = append(items, t)
}
})
})
return strings.Join(items, ", ")
}
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
rawURL := manga.URL
if !strings.HasPrefix(rawURL, "http") {
rawURL = siteURL + rawURL
}
doc, err := s.get(context.Background(), rawURL)
if err != nil {
return manga, err
}
result := source.SManga{URL: manga.URL, Status: source.StatusCompleted}
info := doc.Find(".book_page")
result.Title = strings.TrimSpace(info.Find("h1").Text())
if result.Title == "" {
result.Title = manga.Title
}
result.ThumbnailURL = imgAttr(info.Find(".cover img").First())
result.Genre = extractTags(info, "Tags")
result.Author = extractTags(info, "Artists")
return result, nil
}
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
return []source.SChapter{{URL: manga.URL, Name: "Chapter"}}, nil
}
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
rawURL := chapter.URL
if !strings.HasPrefix(rawURL, "http") {
rawURL = siteURL + rawURL
}
doc, err := s.get(context.Background(), rawURL)
if err != nil {
return nil, err
}
var pages []source.Page
doc.Find(".preview_thumb img").Each(func(_ int, img *goquery.Selection) {
if u := imgAttr(img); u != "" {
pages = append(pages, source.Page{Index: len(pages), ImageURL: thumbnailToFull(u)})
}
})
// POST for remaining pages when gallery has more than the initially loaded count.
tPagesStr := doc.Find("input#t_pages").AttrOr("value", "")
tPages, _ := strconv.Atoi(tPagesStr)
if tPages > len(pages) && tPages > 0 {
loadID := doc.Find("input#load_id").AttrOr("value", "")
loadDir := doc.Find("input#load_dir").AttrOr("value", "")
csrfToken := doc.Find("meta[name=csrf-token]").AttrOr("content", "")
form := url.Values{
"id": {loadID},
"dir": {loadDir},
"visible_pages": {strconv.Itoa(len(pages))},
"t_pages": {tPagesStr},
"type": {"2"},
}
if csrfToken != "" {
form.Set("_token", csrfToken)
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost,
siteURL+"/inc/api.php", strings.NewReader(form.Encode()))
if err == nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("Referer", rawURL)
resp, err := s.client.Do(req)
if err == nil {
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
if extraDoc, err := goquery.NewDocumentFromReader(resp.Body); err == nil {
extraDoc.Find(".preview_thumb img").Each(func(_ int, img *goquery.Selection) {
if u := imgAttr(img); u != "" {
pages = append(pages, source.Page{Index: len(pages), ImageURL: thumbnailToFull(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 init() {
registry.Register(newSource("AsmHentai", "all", ""))
registry.Register(newSource("AsmHentai (English)", "en", "english"))
registry.Register(newSource("AsmHentai (Japanese)", "ja", "japanese"))
registry.Register(newSource("AsmHentai (Chinese)", "zh", "chinese"))
}
+250
View File
@@ -0,0 +1,250 @@
// Package baobua implements the BaoBua adult photo gallery source.
// FlareSolverr required; no full-text search; category filter; recursive page pagination.
package baobua
import (
"context"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"goyomi/internal/httpclient/flare"
"goyomi/internal/registry"
"goyomi/internal/source"
)
const siteURL = "https://baobua.net"
var wpComRe = regexp.MustCompile(`^https://i\d+\.wp\.com/`)
var categoryNames = []string{
"All", "Ao-yem", "Asia", "Beauty", "Bikini", "China", "Cosplay",
"Japan", "Nude", "Sexy", "Top", "Tattoo", "Vietnam",
}
var categorySlugs = []string{
"", "Ao-yem", "Asia", "beauty", "Bikini", "China", "Cosplay",
"Japan", "Nude", "Sexy", "Top", "tattoo", "Vietnam",
}
type Source struct {
client *flare.Client
id int64
}
func New() *Source {
return &Source{
client: flare.NewClient(flare.WithRateLimit(3, 1)),
id: source.GenerateSourceID("BaoBua", "all"),
}
}
func (s *Source) ID() int64 { return s.id }
func (s *Source) Name() string { return "BaoBua" }
func (s *Source) Lang() string { return "all" }
func (s *Source) SupportsLatest() bool { return false }
func normalizeImageURL(u string) string {
if wpComRe.MatchString(u) {
u = wpComRe.ReplaceAllString(u, "https://")
u = strings.Replace(u, "?w=640", "", 1)
}
return u
}
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", siteURL+"/")
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("baobua: HTTP %d", resp.StatusCode)
}
return goquery.NewDocumentFromReader(resp.Body)
}
func (s *Source) parseMangas(doc *goquery.Document) source.MangasPage {
var mangas []source.SManga
doc.Find(".product-item").Each(func(_ int, el *goquery.Selection) {
href := el.Find("a").First().AttrOr("href", "")
if href == "" {
return
}
parsed, err := url.Parse(href)
if err != nil {
return
}
title := strings.TrimSpace(el.Find(".product-title").Text())
if title == "" {
return
}
m := source.SManga{URL: parsed.Path, Title: title}
if src := el.Find("img.product-imgreal").First().AttrOr("src", ""); src != "" {
m.ThumbnailURL = normalizeImageURL(src)
}
mangas = append(mangas, m)
})
hasNext := doc.Find(".pagination-custom .nextPage").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/?page=%d", siteURL, page))
if err != nil {
return source.MangasPage{}, err
}
return s.parseMangas(doc), nil
}
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
return source.MangasPage{}, fmt.Errorf("baobua: latest not supported")
}
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
// Direct URL lookup
if strings.Contains(query, "baobua.net") {
parsed, err := url.Parse(query)
if err != nil {
return source.MangasPage{}, err
}
doc, err := s.get(context.Background(), query)
if err != nil {
return source.MangasPage{}, err
}
if doc.Find(".product-item").Length() == 0 && doc.Find(".article-body").Length() > 0 {
m := s.parseMangaDetailsDoc(doc)
m.URL = parsed.Path
return source.MangasPage{Mangas: []source.SManga{m}, HasNextPage: false}, nil
}
return s.parseMangas(doc), nil
}
// Category filter
for _, f := range filters {
if sf, ok := f.(*source.SelectFilter); ok && sf.FilterName == "Category" {
idx := sf.Selected
if idx > 0 && idx < len(categorySlugs) {
u := fmt.Sprintf("%s/category/%s/?page=%d", siteURL, categorySlugs[idx], page)
doc, err := s.get(context.Background(), u)
if err != nil {
return source.MangasPage{}, err
}
return s.parseMangas(doc), nil
}
}
}
if strings.TrimSpace(query) != "" {
return source.MangasPage{}, fmt.Errorf("baobua: full-text search is not supported")
}
return s.GetPopularManga(page)
}
func (s *Source) parseMangaDetailsDoc(doc *goquery.Document) source.SManga {
m := source.SManga{Status: source.StatusCompleted}
m.Title = strings.TrimSpace(
doc.Find(".product-title, h1, .article-title, .post-title").First().Text(),
)
if src := doc.Find("img.product-imgreal, .article-body img").First().AttrOr("src", ""); src != "" {
m.ThumbnailURL = normalizeImageURL(src)
}
var genres []string
doc.Find(".article-tags a").Each(func(_ int, a *goquery.Selection) {
if t := strings.TrimSpace(a.Text()); t != "" {
genres = append(genres, t)
}
})
m.Genre = strings.Join(genres, ", ")
return m
}
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
doc, err := s.get(context.Background(), siteURL+manga.URL)
if err != nil {
return manga, err
}
result := s.parseMangaDetailsDoc(doc)
result.URL = manga.URL
if result.Title == "" {
result.Title = manga.Title
}
return result, nil
}
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
doc, err := s.get(context.Background(), siteURL+manga.URL)
if err != nil {
return nil, err
}
chapterURL := manga.URL
if canonical := doc.Find("link[rel=canonical]").First(); canonical.Length() > 0 {
if href := canonical.AttrOr("href", ""); href != "" {
if parsed, err := url.Parse(href); err == nil {
chapterURL = parsed.Path
}
}
}
var date int64
if dateStr := strings.TrimSpace(doc.Find(".article-date-comment .date").Text()); dateStr != "" {
// "Mon Jan 02 2006" or "Mon Jan 2 2006"
for _, layout := range []string{"Mon Jan 02 2006", "Mon Jan 2 2006"} {
if t, err := time.Parse(layout, dateStr); err == nil {
date = t.UnixMilli()
break
}
}
}
return []source.SChapter{{URL: chapterURL, Name: "Gallery", DateUpload: date}}, nil
}
func (s *Source) recursivePages(rawURL string, offset int) ([]source.Page, error) {
doc, err := s.get(context.Background(), rawURL)
if err != nil {
return nil, err
}
var pages []source.Page
doc.Find(".article-body img").Each(func(_ int, img *goquery.Selection) {
src := img.AttrOr("src", "")
if src == "" {
src = img.AttrOr("data-src", "")
}
if src != "" {
pages = append(pages, source.Page{Index: offset + len(pages), ImageURL: normalizeImageURL(src)})
}
})
nextURL := doc.Find("a.page-numbers:contains(Next)").First().AttrOr("href", "")
if nextURL != "" {
extra, err := s.recursivePages(nextURL, offset+len(pages))
if err == nil {
pages = append(pages, extra...)
}
}
return pages, nil
}
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
return s.recursivePages(siteURL+chapter.URL, 0)
}
func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil }
func (s *Source) GetFilterList() []source.Filter {
return []source.Filter{
&source.SelectFilter{FilterName: "Category", Values: categoryNames},
}
}
func init() {
registry.Register(New())
}
+319
View File
@@ -0,0 +1,319 @@
// Package beauty3600000 implements the "3600000 Beauty" source.
// Uses WordPress REST API (wp-json/wp/v2); FlareSolverr required.
// Manga URL = post ID (string). Thumbnail extracted from post content HTML.
package beauty3600000
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"goyomi/internal/httpclient/flare"
"goyomi/internal/registry"
"goyomi/internal/source"
)
const (
siteURL = "https://3600000.xyz"
apiBase = "wp-json/wp/v2"
perPage = 100
)
type postDTO struct {
ID int `json:"id"`
Link string `json:"link"`
Title struct {
Rendered string `json:"rendered"`
} `json:"title"`
Content struct {
Rendered string `json:"rendered"`
} `json:"content"`
Date string `json:"date"`
}
type termDTO struct {
ID int `json:"id"`
Name string `json:"name"`
}
var dateFormat = func() *time.Location {
loc, _ := time.LoadLocation("UTC")
return loc
}()
func parseDate(s string) int64 {
t, err := time.ParseInLocation("2006-01-02T15:04:05", s, dateFormat)
if err != nil {
return 0
}
return t.UnixMilli()
}
// Category IDs from the Kotlin source.
var categoryNames = []string{
"Any", "Aidol", "China", "Chinese", "Cosplay", "Gravure",
"Japan", "Korea", "Magazine", "Photobook", "Thailand",
"Uncategorized", "Western",
}
var categoryIDs = []string{
"", "6", "3293", "5", "4", "7", "3291", "2128", "9", "10", "8", "1", "11",
}
type Source struct {
client *flare.Client
id int64
}
func New() *Source {
return &Source{
client: flare.NewClient(flare.WithRateLimit(1, 1)),
id: source.GenerateSourceID("3600000 Beauty", "all"),
}
}
func (s *Source) ID() int64 { return s.id }
func (s *Source) Name() string { return "3600000 Beauty" }
func (s *Source) Lang() string { return "all" }
func (s *Source) SupportsLatest() bool { return false }
func (s *Source) apiURL(path string, params url.Values) string {
u := siteURL + "/" + apiBase + "/" + path
if len(params) > 0 {
u += "?" + params.Encode()
}
return u
}
func (s *Source) getJSON(ctx context.Context, rawURL string) ([]byte, http.Header, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, nil, err
}
req.Header.Set("Referer", siteURL+"/")
resp, err := s.client.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("beauty3600000: HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
return body, resp.Header, err
}
func postToManga(p postDTO) source.SManga {
title := p.Title.Rendered
m := source.SManga{
URL: strconv.Itoa(p.ID),
Title: title,
Status: source.StatusCompleted,
}
// Thumbnail from first img in content HTML.
doc, err := goquery.NewDocumentFromReader(strings.NewReader(p.Content.Rendered))
if err == nil {
m.ThumbnailURL = doc.Find("img").First().AttrOr("src", "")
}
return m
}
func (s *Source) parsePosts(body []byte, headers http.Header, currentPage int) (source.MangasPage, error) {
var posts []postDTO
if err := json.Unmarshal(body, &posts); err != nil {
return source.MangasPage{}, fmt.Errorf("beauty3600000: %w", err)
}
mangas := make([]source.SManga, 0, len(posts))
for _, p := range posts {
mangas = append(mangas, postToManga(p))
}
totalPages, _ := strconv.Atoi(headers.Get("X-WP-TotalPages"))
return source.MangasPage{Mangas: mangas, HasNextPage: currentPage < totalPages}, nil
}
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
params := url.Values{
"page": {strconv.Itoa(page)},
"per_page": {strconv.Itoa(perPage)},
}
body, headers, err := s.getJSON(context.Background(), s.apiURL("posts", params))
if err != nil {
return source.MangasPage{}, err
}
return s.parsePosts(body, headers, page)
}
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
return source.MangasPage{}, fmt.Errorf("beauty3600000: latest not supported")
}
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
// URL-based lookup: extract post ID or slug.
if parsed, err := url.Parse(query); err == nil && parsed.Host != "" {
params := url.Values{}
if id := parsed.Query().Get("p"); id != "" {
params.Set("include", id)
} else if segments := strings.Split(strings.Trim(parsed.Path, "/"), "/"); len(segments) > 0 {
slug := segments[len(segments)-1]
slug = strings.TrimSuffix(slug, ".html")
if slug != "" {
params.Set("slug", slug)
}
}
if len(params) > 0 {
body, headers, err := s.getJSON(context.Background(), s.apiURL("posts", params))
if err != nil {
return source.MangasPage{}, err
}
return s.parsePosts(body, headers, 1)
}
}
params := url.Values{
"page": {strconv.Itoa(page)},
"per_page": {strconv.Itoa(perPage)},
}
// Apply filters.
for _, f := range filters {
if sf, ok := f.(*source.SelectFilter); ok && sf.FilterName == "Category" {
if sf.Selected > 0 && sf.Selected < len(categoryIDs) {
params.Set("categories", categoryIDs[sf.Selected])
goto doSearch
}
}
}
if query != "" {
params.Set("search", query)
}
doSearch:
body, headers, err := s.getJSON(context.Background(), s.apiURL("posts", params))
if err != nil {
return source.MangasPage{}, err
}
return s.parsePosts(body, headers, page)
}
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
params := url.Values{}
if _, err := strconv.Atoi(manga.URL); err == nil {
// Numeric ID — direct path lookup.
body, _, err := s.getJSON(context.Background(), s.apiURL("posts/"+manga.URL, nil))
if err != nil {
return manga, err
}
var p postDTO
if err := json.Unmarshal(body, &p); err != nil {
return manga, err
}
result := postToManga(p)
result.Genre = s.fetchGenre(p.ID)
result.URL = manga.URL
return result, nil
}
// Slug-based.
params.Set("slug", strings.Trim(manga.URL, "/"))
body, _, err := s.getJSON(context.Background(), s.apiURL("posts", params))
if err != nil {
return manga, err
}
var posts []postDTO
if err := json.Unmarshal(body, &posts); err != nil || len(posts) == 0 {
return manga, err
}
result := postToManga(posts[0])
result.Genre = s.fetchGenre(posts[0].ID)
result.URL = manga.URL
return result, nil
}
func (s *Source) fetchGenre(postID int) string {
var terms []string
for _, termType := range []string{"categories", "tags"} {
params := url.Values{"post": {strconv.Itoa(postID)}}
body, _, err := s.getJSON(context.Background(), s.apiURL(termType, params))
if err != nil {
continue
}
var list []termDTO
if json.Unmarshal(body, &list) != nil {
continue
}
for _, t := range list {
if t.Name != "" {
terms = append(terms, t.Name)
}
}
}
return strings.Join(terms, ", ")
}
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
var body []byte
var err error
if _, parseErr := strconv.Atoi(manga.URL); parseErr == nil {
body, _, err = s.getJSON(context.Background(), s.apiURL("posts/"+manga.URL, nil))
} else {
params := url.Values{"slug": {strings.Trim(manga.URL, "/")}}
body, _, err = s.getJSON(context.Background(), s.apiURL("posts", params))
if err == nil {
var posts []postDTO
if json.Unmarshal(body, &posts) == nil && len(posts) > 0 {
body, _ = json.Marshal(posts[0])
}
}
}
if err != nil {
return nil, err
}
var p postDTO
if err := json.Unmarshal(body, &p); err != nil {
return nil, err
}
return []source.SChapter{{
URL: strconv.Itoa(p.ID),
Name: "Gallery",
DateUpload: parseDate(p.Date),
}}, nil
}
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
body, _, err := s.getJSON(context.Background(), s.apiURL("posts/"+chapter.URL, nil))
if err != nil {
return nil, err
}
var p postDTO
if err := json.Unmarshal(body, &p); err != nil {
return nil, err
}
doc, err := goquery.NewDocumentFromReader(strings.NewReader(p.Content.Rendered))
if err != nil {
return nil, err
}
var pages []source.Page
doc.Find("img").Each(func(i int, img *goquery.Selection) {
if src := img.AttrOr("src", ""); src != "" {
pages = append(pages, source.Page{Index: i, ImageURL: src})
}
})
return pages, nil
}
func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil }
func (s *Source) GetFilterList() []source.Filter {
return []source.Filter{
&source.SelectFilter{FilterName: "Category", Values: categoryNames},
}
}
func init() {
registry.Register(New())
}
+249
View File
@@ -0,0 +1,249 @@
// Package buondua implements the BuonDua photo gallery source.
// FlareSolverr required; multi-page galleries split into one chapter per page.
// Popular: /hot; Latest: /; Search: /?search= or /tag/{id}.
package buondua
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"goyomi/internal/httpclient/flare"
"goyomi/internal/registry"
"goyomi/internal/source"
)
const siteURL = "https://buondua.com"
// date format used in .article-info > small
var dateFormat = "15:04 02-01-2006"
type Source struct {
client *flare.Client
id int64
}
func New() *Source {
return &Source{
client: flare.NewClient(flare.WithRateLimit(10, 1)),
id: source.GenerateSourceID("Buon Dua", "all"),
}
}
func (s *Source) ID() int64 { return s.id }
func (s *Source) Name() string { return "Buon Dua" }
func (s *Source) Lang() string { return "all" }
func (s *Source) SupportsLatest() bool { return true }
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", siteURL+"/")
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("buondua: HTTP %d", resp.StatusCode)
}
return goquery.NewDocumentFromReader(resp.Body)
}
func (s *Source) offset(page int) int { return 20 * (page - 1) }
func (s *Source) parseMangas(doc *goquery.Document) source.MangasPage {
var mangas []source.SManga
doc.Find(".blog > div").Each(func(_ int, el *goquery.Selection) {
link := el.Find(".item-content .item-link").First()
href := link.AttrOr("href", "")
if href == "" {
return
}
parsed, err := url.Parse(href)
if err != nil {
return
}
m := source.SManga{
URL: parsed.Path,
Title: strings.TrimSpace(link.Text()),
}
if img := el.Find("img").First(); img.Length() > 0 {
m.ThumbnailURL = img.AttrOr("src", "")
}
if m.URL != "" && m.Title != "" {
mangas = append(mangas, m)
}
})
hasNext := doc.Find(".pagination-next:not([disabled])").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/hot?start=%d", siteURL, s.offset(page)))
if err != nil {
return source.MangasPage{}, err
}
return s.parseMangas(doc), nil
}
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
doc, err := s.get(context.Background(), fmt.Sprintf("%s/?start=%d", siteURL, s.offset(page)))
if err != nil {
return source.MangasPage{}, err
}
return s.parseMangas(doc), nil
}
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
var rawURL string
if query != "" {
rawURL = fmt.Sprintf("%s/?search=%s&start=%d", siteURL, url.QueryEscape(query), s.offset(page))
} else {
// Tag text filter.
for _, f := range filters {
if tf, ok := f.(*source.TextFilter); ok && tf.FilterName == "Tag ID" && tf.Text != "" {
rawURL = fmt.Sprintf("%s/tag/%s&start=%d", siteURL, url.PathEscape(tf.Text), s.offset(page))
break
}
}
}
if rawURL == "" {
return s.GetPopularManga(page)
}
doc, err := s.get(context.Background(), rawURL)
if err != nil {
return source.MangasPage{}, err
}
return s.parseMangas(doc), nil
}
func cleanTitle(t string) string {
// Strip " - ( Page N / M )" suffix
if i := strings.Index(t, " - ( Page "); i != -1 {
return strings.TrimSpace(t[:i])
}
return strings.TrimSpace(t)
}
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
doc, err := s.get(context.Background(), siteURL+manga.URL)
if err != nil {
return manga, err
}
result := source.SManga{URL: manga.URL, Status: source.StatusUnknown}
if h := doc.Find(".article-header").First(); h.Length() > 0 {
result.Title = cleanTitle(h.Text())
}
if result.Title == "" {
result.Title = manga.Title
}
// Build description from article info + download links + password.
var descParts []string
if info := strings.TrimSpace(strings.ReplaceAll(doc.Find(".article-info > strong").Text(), "Buondua", "")); info != "" {
descParts = append(descParts, info)
}
var dlLinks []string
doc.Find(".article-links a[href]").Each(func(_ int, a *goquery.Selection) {
href := a.AttrOr("href", "")
text := strings.TrimSpace(a.Text())
if href != "" && text != "" {
dlLinks = append(dlLinks, fmt.Sprintf("[%s](%s)", text, href))
}
})
if len(dlLinks) > 0 {
descParts = append(descParts, strings.Join(dlLinks, "\n"))
}
if pw := strings.TrimSpace(doc.Find("code").Text()); pw != "" {
descParts = append(descParts, pw)
}
result.Description = strings.Join(descParts, "\n\n")
var genres []string
doc.Find(".article-tags .tags > .tag").Each(func(_ int, el *goquery.Selection) {
t := strings.TrimPrefix(strings.TrimSpace(el.Text()), "#")
if 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(), siteURL+manga.URL)
if err != nil {
return nil, err
}
// Date from .article-info > small
var date int64
if dateStr := strings.TrimSpace(doc.Find(".article-info > small").First().Text()); dateStr != "" {
if t, err := time.Parse(dateFormat, dateStr); err == nil {
date = t.UnixMilli()
}
}
// Max page from last pagination-next link's "page" query param.
maxPage := 1
doc.Find("nav.pagination:first-of-type a.pagination-next").Each(func(_ int, a *goquery.Selection) {
href := a.AttrOr("href", "")
if parsed, err := url.Parse(href); err == nil {
if p, err := strconv.Atoi(parsed.Query().Get("page")); err == nil && p > maxPage {
maxPage = p
}
}
})
baseURL := siteURL + manga.URL
chapters := make([]source.SChapter, maxPage)
for i := maxPage; i >= 1; i-- {
chURL := baseURL
if i > 1 {
chURL = fmt.Sprintf("%s?page=%d", baseURL, i)
}
chapters[maxPage-i] = source.SChapter{
URL: strings.TrimPrefix(chURL, siteURL),
Name: fmt.Sprintf("Page %d", i),
DateUpload: date,
}
}
return chapters, nil
}
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
doc, err := s.get(context.Background(), siteURL+chapter.URL)
if err != nil {
return nil, err
}
var pages []source.Page
doc.Find(".article-fulltext img").Each(func(i int, img *goquery.Selection) {
src := img.AttrOr("src", "")
if src == "" {
src = img.AttrOr("data-src", "")
}
if src != "" {
pages = append(pages, source.Page{Index: i, ImageURL: src})
}
})
return pages, nil
}
func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil }
func (s *Source) GetFilterList() []source.Filter {
return []source.Filter{
&source.TextFilter{FilterName: "Tag ID"},
}
}
func init() {
registry.Register(New())
}
+366
View File
@@ -0,0 +1,366 @@
// Package comicfury implements the Comic Fury webcomic hosting source.
// Multi-language factory. Search-based popular (sort=popularity) and latest (sort=lastupdate).
// Chapter list scraped from /read/{comicUrl}/archive; supports hierarchical chapters-in-chapters.
// FlareSolverr used (matches Kotlin cloudflareClient).
package comicfury
import (
"context"
"fmt"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"goyomi/internal/httpclient/flare"
"goyomi/internal/registry"
"goyomi/internal/source"
)
const siteURL = "https://comicfury.com"
type Source struct {
name string
lang string
siteLang string // used in search query
client *flare.Client
id int64
}
func newSource(name, lang, siteLang string) *Source {
return &Source{
name: name,
lang: lang,
siteLang: siteLang,
client: flare.NewClient(flare.WithRateLimit(1, 2)),
id: source.GenerateSourceID(name, lang),
}
}
func (s *Source) ID() int64 { return s.id }
func (s *Source) Name() string { return s.name }
func (s *Source) Lang() string { return s.lang }
func (s *Source) SupportsLatest() bool { return true }
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", siteURL+"/")
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("comicfury: HTTP %d", resp.StatusCode)
}
return goquery.NewDocumentFromReader(resp.Body)
}
func (s *Source) searchURL(page int, query, sort string, filters []source.Filter) string {
params := url.Values{
"query": {query},
"page": {strconv.Itoa(page)},
"language": {s.siteLang},
"sort": {sort},
}
for _, f := range filters {
switch sf := f.(type) {
case *source.TextFilter:
if sf.FilterName == "Tags" && sf.Text != "" {
params.Set("tags", sf.Text)
}
case *source.SelectFilter:
switch sf.FilterName {
case "Sort By":
params.Set("sort", strconv.Itoa(sf.Selected))
case "Last Updated":
params.Set("lastupdate", strconv.Itoa(sf.Selected))
case "Violence":
params.Set("fv", strconv.Itoa(sf.Selected))
case "Frontal Nudity":
params.Set("fn", strconv.Itoa(sf.Selected))
case "Strong Language":
params.Set("fl", strconv.Itoa(sf.Selected))
case "Sexual Content":
params.Set("fs", strconv.Itoa(sf.Selected))
}
case *source.CheckboxFilter:
if sf.FilterName == "Comic Completed" {
completed := 1
if sf.State {
completed = 0
}
params.Set("completed", strconv.Itoa(completed))
}
}
}
return siteURL + "/search.php?" + params.Encode()
}
func (s *Source) parseSearch(doc *goquery.Document) source.MangasPage {
var mangas []source.SManga
doc.Find("div.webcomic-result").Each(func(_ int, el *goquery.Selection) {
link := el.Find("div.webcomic-result-avatar a").First()
href := link.AttrOr("href", "")
title := el.Find("div.webcomic-result-title").First().AttrOr("title", "")
thumb := el.Find("div.webcomic-result-avatar a img").First().AttrOr("src", "")
if href == "" || title == "" {
return
}
mangas = append(mangas, source.SManga{URL: href, Title: title, ThumbnailURL: thumb})
})
hasNext := doc.Find("div.search-next-page").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, "", "1", nil))
if err != nil {
return source.MangasPage{}, err
}
return s.parseSearch(doc), nil
}
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
doc, err := s.get(context.Background(), s.searchURL(page, "", "2", nil))
if err != nil {
return source.MangasPage{}, err
}
return s.parseSearch(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, "0", filters))
if err != nil {
return source.MangasPage{}, err
}
return s.parseSearch(doc), nil
}
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
rawURL := manga.URL
if !strings.HasPrefix(rawURL, "http") {
rawURL = siteURL + rawURL
}
doc, err := s.get(context.Background(), rawURL)
if err != nil {
return manga, err
}
result := source.SManga{URL: manga.URL}
desDiv := doc.Find("div.description-tags")
result.Description = strings.TrimSpace(desDiv.Parent().Clone().Find("*").Remove().End().Text())
var genres []string
desDiv.Children().Each(func(_ int, el *goquery.Selection) {
if t := strings.TrimSpace(el.Text()); t != "" {
genres = append(genres, t)
}
})
result.Genre = strings.Join(genres, ", ")
var authors []string
doc.Find("a.authorname").Each(func(_ int, el *goquery.Selection) {
if t := strings.TrimSpace(el.Text()); t != "" {
authors = append(authors, t)
}
})
result.Author = strings.Join(authors, ", ")
if result.Title == "" {
result.Title = manga.Title
}
return result, nil
}
// comicURL extracts the comic slug from a manga URL like /comicprofile.php?url=slug.
func comicURL(mangaURL string) string {
parsed, err := url.Parse(mangaURL)
if err != nil {
return mangaURL
}
if u := parsed.Query().Get("url"); u != "" {
return u
}
// Fallback: use last path segment.
segments := strings.Split(strings.Trim(parsed.Path, "/"), "/")
if len(segments) > 0 {
return segments[len(segments)-1]
}
return mangaURL
}
// parseDate handles formats like "4th March 2023 12:00 PM", "4 March 2023", "March 4 2023".
var dateOrdinalRe = regexp.MustCompile(`(?i)(\d+)(st|nd|rd|th)`)
func parseDate(s string) int64 {
s = dateOrdinalRe.ReplaceAllString(s, "$1")
s = strings.ReplaceAll(s, ",", "")
s = strings.TrimSpace(s)
formats := []string{
"2 January 2006 3:04 PM",
"2 January 2006",
"January 2 2006",
}
for _, f := range formats {
if t, err := time.Parse(f, s); err == nil {
return t.UnixMilli()
}
}
return 0
}
// collectChapters follows pagination from a given archive page.
func (s *Source) collectChapters(startDoc *goquery.Document) ([]source.SChapter, error) {
var chapters []source.SChapter
doc := startDoc
for {
doc.Find("a:has(div.archive-comic)").Each(func(_ int, a *goquery.Selection) {
href := a.AttrOr("href", "")
if href == "" {
return
}
parsed, _ := url.Parse(href)
chURL := parsed.Path
if parsed.RawQuery != "" {
chURL += "?" + parsed.RawQuery
}
name := strings.TrimSpace(a.Find(".archive-comic-title").Text())
if name == "" {
name = "Chapter"
}
date := parseDate(strings.TrimSpace(a.Find(".archive-comic-date").Text()))
chapters = append(chapters, source.SChapter{URL: chURL, Name: name, DateUpload: date})
})
nextPage := doc.Find("span.vfpagecurrent + a.vfpage").First()
nextHref := nextPage.AttrOr("href", "")
if nextHref == "" {
break
}
nextURL := nextHref
if !strings.HasPrefix(nextURL, "http") {
nextURL = siteURL + nextURL
}
next, err := s.get(context.Background(), nextURL)
if err != nil {
break
}
doc = next
}
return chapters, nil
}
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
slug := comicURL(manga.URL)
archiveURL := fmt.Sprintf("%s/read/%s/archive", siteURL, slug)
doc, err := s.get(context.Background(), archiveURL)
if err != nil {
return nil, err
}
var chapters []source.SChapter
archiveLinks := doc.Find("a:has(div.archive-chapter)")
if archiveLinks.Length() > 0 {
// Has parent chapters — fetch each sub-archive.
var fetchErr error
archiveLinks.Each(func(_ int, a *goquery.Selection) {
if fetchErr != nil {
return
}
href := a.AttrOr("href", "")
if href == "" {
return
}
if !strings.HasPrefix(href, "http") {
href = siteURL + href
}
subDoc, err := s.get(context.Background(), href)
if err != nil {
fetchErr = err
return
}
sub, err := s.collectChapters(subDoc)
if err != nil {
fetchErr = err
return
}
chapters = append(chapters, sub...)
})
if fetchErr != nil {
return nil, fetchErr
}
} else {
chapters, err = s.collectChapters(doc)
if err != nil {
return nil, err
}
}
// Number and reverse (oldest first → chapter_number ascending).
for i := range chapters {
chapters[i].ChapterNumber = float32(i)
}
// Reverse so newest is first in list.
for i, j := 0, len(chapters)-1; i < j; i, j = i+1, j-1 {
chapters[i], chapters[j] = chapters[j], chapters[i]
}
return chapters, nil
}
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
rawURL := chapter.URL
if !strings.HasPrefix(rawURL, "http") {
rawURL = siteURL + rawURL
}
doc, err := s.get(context.Background(), rawURL)
if err != nil {
return nil, err
}
var pages []source.Page
doc.Find("div.is--comic-page div.is--image-segment div img").Each(func(i int, img *goquery.Selection) {
if src := img.AttrOr("src", ""); src != "" {
pages = append(pages, source.Page{Index: i, URL: rawURL, ImageURL: src})
}
})
return pages, nil
}
func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil }
func (s *Source) GetFilterList() []source.Filter {
return []source.Filter{
&source.TextFilter{FilterName: "Tags"},
&source.SelectFilter{FilterName: "Sort By", Values: []string{"Relevance", "Popularity", "Last Update"}},
&source.SelectFilter{FilterName: "Last Updated", Values: []string{"All Time", "This Week", "This Month", "This Year", "Completed Only"}},
&source.CheckboxFilter{FilterName: "Comic Completed"},
&source.SelectFilter{FilterName: "Violence", Values: []string{"None / Minimal", "Violent Content", "Gore / Graphic"}, Selected: 2},
&source.SelectFilter{FilterName: "Frontal Nudity", Values: []string{"None", "Occasional", "Frequent"}, Selected: 2},
&source.SelectFilter{FilterName: "Strong Language", Values: []string{"None", "Occasional", "Frequent"}, Selected: 2},
&source.SelectFilter{FilterName: "Sexual Content", Values: []string{"No Sexual Content", "Sexual Situations", "Strong Sexual Themes"}, Selected: 2},
}
}
func init() {
instances := []struct{ name, lang, siteLang string }{
{"Comic Fury", "all", "all"},
{"Comic Fury", "en", "en"},
{"Comic Fury", "es", "es"},
{"Comic Fury", "pt-BR", "pt"},
{"Comic Fury", "de", "de"},
{"Comic Fury", "fr", "fr"},
{"Comic Fury", "it", "it"},
{"Comic Fury", "pl", "pl"},
{"Comic Fury", "ja", "ja"},
{"Comic Fury", "zh", "zh"},
{"Comic Fury", "ru", "ru"},
{"Comic Fury", "fi", "fi"},
{"Comic Fury", "other", "other"},
{"Comic Fury (No Text)", "other", "notext"},
}
for _, inst := range instances {
registry.Register(newSource(inst.name, inst.lang, inst.siteLang))
}
}
+18
View File
@@ -0,0 +1,18 @@
package comicgrowl
import (
"goyomi/internal/registry"
base "goyomi/sources/base/comiciviewer"
)
func New() *base.Source {
return base.New(base.Config{
Name: "Comic Growl",
BaseURL: "https://comic-growl.com",
Lang: "all",
})
}
func init() {
registry.Register(New())
}
+426
View File
@@ -0,0 +1,426 @@
// Package comicklive implements the Comick (Unoriginal) source (comick.live / comick.art).
// Multi-language. Popular via /api/comics/top (6 virtual pages); latest via /api/chapters/latest.
// Search is cursor-based (/api/search). Details and pages scraped from HTML (#comic-data / #sv-data).
package comicklive
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/PuerkitoBio/goquery"
"goyomi/internal/httpclient/flare"
"goyomi/internal/registry"
"goyomi/internal/source"
)
const baseURL = "https://comick.live"
// DTO types
type browseComic struct {
Thumbnail string `json:"default_thumbnail"`
Slug string `json:"slug"`
Title string `json:"title"`
}
func (b browseComic) toSManga() source.SManga {
return source.SManga{URL: b.Slug, Title: b.Title, ThumbnailURL: b.Thumbnail}
}
type dataList struct {
Data []browseComic `json:"data"`
}
type searchResp struct {
Data []browseComic `json:"data"`
NextCursor string `json:"next_cursor"`
}
type comicDTO struct {
Title string `json:"title"`
Slug string `json:"slug"`
Thumbnail string `json:"default_thumbnail"`
Status int `json:"status"`
TranslationCompleted bool `json:"translation_completed"`
Artists []struct {
Name string `json:"name"`
} `json:"artists"`
Authors []struct {
Name string `json:"name"`
} `json:"authors"`
Desc string `json:"desc"`
ContentRating string `json:"content_rating"`
Country string `json:"country"`
Genres []struct {
Genre struct {
Name string `json:"name"`
} `json:"md_genres"`
} `json:"md_comic_md_genres"`
Titles []struct {
Title string `json:"title"`
} `json:"md_titles"`
}
type chapterListResp struct {
Data []chapterDTO `json:"data"`
Pagination struct {
Page int `json:"current_page"`
LastPage int `json:"last_page"`
} `json:"pagination"`
}
type chapterDTO struct {
HID string `json:"hid"`
Chap string `json:"chap"`
Vol string `json:"vol"`
Lang string `json:"lang"`
Title string `json:"title"`
CreatedAt string `json:"created_at"`
Groups []string `json:"group_name"`
}
type pageListDTO struct {
Chapter struct {
Images []struct {
URL string `json:"url"`
} `json:"images"`
} `json:"chapter"`
}
// Source
type Source struct {
lang string
siteLang string
client *flare.Client
id int64
mu sync.Mutex
cursor string
}
func newSource(lang, siteLang string) *Source {
name := "Comick (Unoriginal)"
return &Source{
lang: lang,
siteLang: siteLang,
client: flare.NewClient(flare.WithRateLimit(1, 2)),
id: source.GenerateSourceID(name, lang),
}
}
func (s *Source) ID() int64 { return s.id }
func (s *Source) Name() string { return "Comick (Unoriginal)" }
func (s *Source) Lang() string { return s.lang }
func (s *Source) SupportsLatest() bool { return true }
func (s *Source) get(ctx context.Context, rawURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Referer", 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("comicklive: HTTP %d for %s", resp.StatusCode, rawURL)
}
return io.ReadAll(resp.Body)
}
func (s *Source) getDoc(ctx context.Context, rawURL string) (*goquery.Document, error) {
body, err := s.get(ctx, rawURL)
if err != nil {
return nil, err
}
return goquery.NewDocumentFromReader(strings.NewReader(string(body)))
}
// Popular uses 6 virtual pages cycling through top-comics queries.
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
if page < 1 || page > 6 {
return source.MangasPage{}, nil
}
days := []int{7, 30, 90}[(page-1)%3]
topType := "follow"
if page > 3 {
topType = "most_follow_new"
}
u := fmt.Sprintf("%s/api/comics/top?days=%d&type=%s", baseURL, days, topType)
body, err := s.get(context.Background(), u)
if err != nil {
return source.MangasPage{}, err
}
var resp dataList
if err := json.Unmarshal(body, &resp); err != nil {
return source.MangasPage{}, err
}
mangas := make([]source.SManga, 0, len(resp.Data))
for _, c := range resp.Data {
mangas = append(mangas, c.toSManga())
}
return source.MangasPage{Mangas: mangas, HasNextPage: page < 6}, nil
}
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
u := fmt.Sprintf("%s/api/chapters/latest?order=new&page=%d", baseURL, page)
body, err := s.get(context.Background(), u)
if err != nil {
return source.MangasPage{}, err
}
var resp dataList
if err := json.Unmarshal(body, &resp); err != nil {
return source.MangasPage{}, err
}
mangas := make([]source.SManga, 0, len(resp.Data))
for _, c := range resp.Data {
mangas = append(mangas, c.toSManga())
}
return source.MangasPage{Mangas: mangas, HasNextPage: len(resp.Data) == 100}, nil
}
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
if page == 1 {
s.mu.Lock()
s.cursor = ""
s.mu.Unlock()
}
params := url.Values{
"type": {"comic"},
"showAll": {"false"},
"exclude_mylist": {"false"},
"order_by": {"created_at"},
"order_direction": {"desc"},
}
if query != "" {
if len(strings.TrimSpace(query)) < 3 {
return source.MangasPage{}, fmt.Errorf("comicklive: query must be at least 3 characters")
}
params.Set("q", strings.TrimSpace(query))
}
s.mu.Lock()
cur := s.cursor
s.mu.Unlock()
if page > 1 && cur != "" {
params.Set("cursor", cur)
}
u := baseURL + "/api/search?" + params.Encode()
body, err := s.get(context.Background(), u)
if err != nil {
return source.MangasPage{}, err
}
var resp searchResp
if err := json.Unmarshal(body, &resp); err != nil {
return source.MangasPage{}, err
}
s.mu.Lock()
s.cursor = resp.NextCursor
s.mu.Unlock()
mangas := make([]source.SManga, 0, len(resp.Data))
for _, c := range resp.Data {
mangas = append(mangas, c.toSManga())
}
return source.MangasPage{Mangas: mangas, HasNextPage: resp.NextCursor != ""}, nil
}
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
doc, err := s.getDoc(context.Background(), baseURL+"/comic/"+manga.URL)
if err != nil {
return manga, err
}
raw := doc.Find("#comic-data").Text()
if raw == "" {
return manga, fmt.Errorf("comicklive: #comic-data not found")
}
var data comicDTO
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return manga, err
}
result := source.SManga{URL: manga.URL}
result.Title = data.Title
result.ThumbnailURL = data.Thumbnail
result.Status = comickStatus(data.Status, data.TranslationCompleted)
var authors []string
for _, a := range data.Authors {
authors = append(authors, a.Name)
}
result.Author = strings.Join(authors, ", ")
var artists []string
for _, a := range data.Artists {
artists = append(artists, a.Name)
}
result.Artist = strings.Join(artists, ", ")
// Description: strip HTML tags.
descDoc, _ := goquery.NewDocumentFromReader(strings.NewReader(data.Desc))
desc := strings.TrimSpace(descDoc.Text())
if len(data.Titles) > 0 {
var alt []string
for _, t := range data.Titles {
if t.Title != "" {
alt = append(alt, "- "+t.Title)
}
}
if len(alt) > 0 {
desc += "\n\nAlternative Titles:\n" + strings.Join(alt, "\n")
}
}
result.Description = desc
var genres []string
switch data.Country {
case "jp":
genres = append(genres, "Manga")
case "cn":
genres = append(genres, "Manhua")
case "ko":
genres = append(genres, "Manhwa")
}
switch data.ContentRating {
case "suggestive":
genres = append(genres, "Content Rating: Suggestive")
case "erotica":
genres = append(genres, "Content Rating: Erotica")
}
for _, g := range data.Genres {
if g.Genre.Name != "" {
genres = append(genres, g.Genre.Name)
}
}
result.Genre = strings.Join(genres, ", ")
return result, nil
}
func comickStatus(status int, translationCompleted bool) int {
switch status {
case 1:
return source.StatusOngoing
case 2:
if translationCompleted {
return source.StatusCompleted
}
return source.StatusOngoing
case 3:
return source.StatusCancelled
case 4:
return source.StatusHiatus
}
return source.StatusUnknown
}
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
var chapters []chapterDTO
page := 1
for {
u := fmt.Sprintf("%s/api/comics/%s/chapter-list?lang=%s&page=%d", baseURL, manga.URL, s.siteLang, page)
body, err := s.get(context.Background(), u)
if err != nil {
return nil, err
}
var resp chapterListResp
if err := json.Unmarshal(body, &resp); err != nil {
return nil, err
}
chapters = append(chapters, resp.Data...)
if resp.Pagination.Page >= resp.Pagination.LastPage {
break
}
page++
}
result := make([]source.SChapter, 0, len(chapters))
for _, ch := range chapters {
chURL := fmt.Sprintf("/comic/%s/%s-chapter-%s-%s", manga.URL, ch.HID, ch.Chap, ch.Lang)
name := buildChapterName(ch)
result = append(result, source.SChapter{
URL: chURL,
Name: name,
DateUpload: parseComickDate(ch.CreatedAt),
Scanlator: strings.Join(ch.Groups, ", "),
})
}
return result, nil
}
func buildChapterName(ch chapterDTO) string {
var b strings.Builder
if ch.Vol != "" {
b.WriteString("Vol. ")
b.WriteString(ch.Vol)
b.WriteString(" ")
}
b.WriteString("Ch. ")
b.WriteString(ch.Chap)
if ch.Title != "" {
b.WriteString(": ")
b.WriteString(ch.Title)
}
return b.String()
}
func parseComickDate(s string) int64 {
// "2024-01-15T10:30:00.123456Z" — try RFC3339Nano then RFC3339.
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.000000Z"} {
if t, err := time.Parse(layout, s); err == nil {
return t.UnixMilli()
}
}
return 0
}
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
rawURL := chapter.URL
if !strings.HasPrefix(rawURL, "http") {
rawURL = baseURL + rawURL
}
doc, err := s.getDoc(context.Background(), rawURL)
if err != nil {
return nil, err
}
raw := doc.Find("#sv-data").Text()
if raw == "" {
return nil, fmt.Errorf("comicklive: #sv-data not found")
}
var data pageListDTO
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return nil, err
}
pages := make([]source.Page, 0, len(data.Chapter.Images))
for i, img := range data.Chapter.Images {
pages = append(pages, source.Page{Index: i, ImageURL: img.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 init() {
langs := []struct{ lang, site string }{
{"en", "en"}, {"ru", "ru"}, {"vi", "vi"}, {"fr", "fr"},
{"pl", "pl"}, {"id", "id"}, {"tr", "tr"}, {"it", "it"},
{"es", "es"}, {"uk", "uk"}, {"de", "de"}, {"ko", "ko"},
{"th", "th"}, {"ro", "ro"}, {"ms", "ms"}, {"ja", "ja"},
{"sv", "sv"}, {"no", "no"},
}
for _, l := range langs {
registry.Register(newSource(l.lang, l.site))
}
}
+20
View File
@@ -0,0 +1,20 @@
package comicsvalley
import (
"goyomi/internal/registry"
base "goyomi/sources/base/madara"
)
func New() *base.Source {
return base.New(base.Config{
Name: "Comics Valley",
BaseURL: "https://comicsvalley.com",
Lang: "all",
MangaSubString: "comics-new",
UseNewChapterEndpoint: true,
})
}
func init() {
registry.Register(New())
}
+381
View File
@@ -0,0 +1,381 @@
// Package comikey implements the Comikey manga/webtoon source.
// Popular/latest/search: HTML scraping. Details: #comic JSON script tag.
// Chapters: Gundam API (gundam.comikey.net) with optional auth token.
// GetPageList: requires WebView DRM — not supported in the Go port; returns error.
package comikey
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"goyomi/internal/httpclient/flare"
"goyomi/internal/registry"
"goyomi/internal/source"
)
const gundamURL = "https://gundam.comikey.net"
type comikeyComic struct {
Link string `json:"link"`
Name string `json:"name"`
Author []struct{ Name string `json:"name"` } `json:"author"`
Artist []struct{ Name string `json:"name"` } `json:"artist"`
Tags []struct{ Name string `json:"name"` } `json:"tags"`
Description string `json:"description"`
Excerpt string `json:"excerpt"`
Format int `json:"format"`
FullCover string `json:"full_cover"`
UpdateStatus int `json:"update_status"`
UpdateText string `json:"update_text"`
}
type comikeyEpisodeResp struct {
Episodes []comikeyEpisode `json:"episodes"`
}
type comikeyEpisode struct {
ID string `json:"id"`
Number float32 `json:"number"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
ReleasedAt string `json:"releasedAt"`
FinalPrice int `json:"finalPrice"`
Owned bool `json:"owned"`
}
func (e comikeyEpisode) readable() bool { return e.FinalPrice == 0 || e.Owned }
type Source struct {
name string
baseURL string
lang string
client *flare.Client
id int64
}
func newSource(lang, name, baseURL string) *Source {
return &Source{
name: name,
baseURL: strings.TrimRight(baseURL, "/"),
lang: lang,
client: flare.NewClient(flare.WithRateLimit(3, 1)),
id: source.GenerateSourceID(name, lang),
}
}
func (s *Source) ID() int64 { return s.id }
func (s *Source) Name() string { return s.name }
func (s *Source) Lang() string { return s.lang }
func (s *Source) SupportsLatest() bool { return true }
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.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("comikey: HTTP %d", resp.StatusCode)
}
return goquery.NewDocumentFromReader(resp.Body)
}
func (s *Source) parseList(doc *goquery.Document) source.MangasPage {
var mangas []source.SManga
doc.Find("div.series-listing[data-view=list] > ul > li").Each(func(_ int, el *goquery.Selection) {
link := el.Find("div.series-data span.title a").First()
href := link.AttrOr("href", "")
title := strings.TrimSpace(link.Text())
if href == "" || title == "" {
return
}
parsed, _ := url.Parse(href)
m := source.SManga{URL: parsed.RequestURI(), Title: title}
m.ThumbnailURL = el.Find("div.image picture img").First().AttrOr("src", "")
var genres []string
el.Find("ul.category-listing li a").Each(func(_ int, a *goquery.Selection) {
if t := strings.TrimSpace(a.Text()); t != "" {
genres = append(genres, t)
}
})
m.Genre = strings.Join(genres, ", ")
mangas = append(mangas, m)
})
hasNext := doc.Find("ul.pagination li.next-page:not(.disabled)").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/comics/?order=-views&page=%d", s.baseURL, page))
if err != nil {
return source.MangasPage{}, err
}
return s.parseList(doc), nil
}
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
doc, err := s.get(context.Background(), fmt.Sprintf("%s/comics/?page=%d", s.baseURL, page))
if err != nil {
return source.MangasPage{}, err
}
return s.parseList(doc), nil
}
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
params := url.Values{}
if page > 1 {
params.Set("page", fmt.Sprint(page))
}
if len(query) >= 2 {
params.Set("q", query)
}
u := s.baseURL + "/comics/?"
if len(params) > 0 {
u += params.Encode()
}
doc, err := s.get(context.Background(), u)
if err != nil {
return source.MangasPage{}, err
}
return s.parseList(doc), nil
}
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
doc, err := s.get(context.Background(), s.baseURL+manga.URL)
if err != nil {
return manga, err
}
raw := doc.Find("script#comic").First().Text()
if raw == "" {
return manga, fmt.Errorf("comikey: #comic script not found")
}
var data comikeyComic
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return manga, err
}
result := source.SManga{URL: manga.URL}
result.Title = data.Name
result.ThumbnailURL = s.baseURL + data.FullCover
var authors []string
for _, a := range data.Author {
authors = append(authors, a.Name)
}
result.Author = strings.Join(authors, ", ")
var artists []string
for _, a := range data.Artist {
artists = append(artists, a.Name)
}
result.Artist = strings.Join(artists, ", ")
result.Description = strings.TrimSpace(`"` + data.Excerpt + `"` + "\n\n" + data.Description)
var genres []string
for _, t := range data.Tags {
genres = append(genres, t.Name)
}
switch data.Format {
case 0:
genres = append(genres, "Comic")
case 1:
genres = append(genres, "Manga")
case 2:
genres = append(genres, "Webtoon")
}
result.Genre = strings.Join(genres, ", ")
result.Status = comikeyStatus(data.UpdateStatus, data.UpdateText)
return result, nil
}
func comikeyStatus(status int, updateText string) int {
switch {
case status == 1:
return source.StatusCompleted
case status == 3:
return source.StatusHiatus
case status >= 4 && status <= 14:
return source.StatusOngoing
case status == 0:
ut := strings.ToLower(updateText)
if strings.HasPrefix(ut, "toda") {
return source.StatusOngoing
}
if strings.HasPrefix(ut, "em pausa") || strings.HasPrefix(ut, "hiato") {
return source.StatusHiatus
}
}
return source.StatusUnknown
}
// pathSegments splits a URL path like "/comics/overlord/76/" into ["comics","overlord","76"].
func pathSegments(mangaURL string) []string {
return strings.FieldsFunc(mangaURL, func(r rune) bool { return r == '/' })
}
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
doc, err := s.get(context.Background(), s.baseURL+manga.URL)
if err != nil {
return nil, err
}
segs := pathSegments(manga.URL)
if len(segs) < 3 {
return nil, fmt.Errorf("comikey: unexpected manga URL format: %s", manga.URL)
}
mangaSlug := segs[1] // e.g. "overlord"
mangaID := segs[2] // e.g. "76"
// Parse comic data to determine format (manga vs webtoon/episode).
chapterPrefix := "chapter"
if raw := doc.Find("script#comic").First().Text(); raw != "" {
var data comikeyComic
if json.Unmarshal([]byte(raw), &data) == nil && data.Format == 2 {
chapterPrefix = "episode"
}
}
// Extract gundam token if present.
gundamToken := ""
doc.Find("script").Each(func(_ int, el *goquery.Selection) {
if strings.Contains(el.Text(), "GUNDAM.token") {
t := el.Text()
if idx := strings.Index(t, `= "`); idx >= 0 {
t = t[idx+3:]
if end := strings.Index(t, `";`); end >= 0 {
gundamToken = t[:end]
}
}
}
})
// Build gundam API URL.
var apiURL string
if gundamToken != "" {
apiURL = fmt.Sprintf("%s/comic/%s/episodes?language=%s&token=%s",
gundamURL, mangaID, strings.ToLower(s.lang), url.QueryEscape(gundamToken))
} else {
apiURL = fmt.Sprintf("%s/comic.public/%s/episodes?language=%s",
gundamURL, mangaID, strings.ToLower(s.lang))
}
body, err := s.getAPIJSON(context.Background(), apiURL)
if err != nil {
return nil, err
}
var resp comikeyEpisodeResp
if err := json.Unmarshal(body, &resp); err != nil {
return nil, err
}
now := time.Now().UnixMilli()
var chapters []source.SChapter
for _, ep := range resp.Episodes {
if !ep.readable() {
continue
}
date := parseComikeyDate(ep.ReleasedAt)
if date > now {
continue
}
chURL := fmt.Sprintf("/read/%s/%s", mangaSlug, makeEpisodeSlug(ep, chapterPrefix, s.lang))
name := ep.Title
if ep.Subtitle != "" {
name += ": " + ep.Subtitle
}
chapters = append(chapters, source.SChapter{
URL: chURL,
Name: name,
ChapterNumber: ep.Number,
DateUpload: date,
})
}
// Reverse to newest-first.
for i, j := 0, len(chapters)-1; i < j; i, j = i+1, j-1 {
chapters[i], chapters[j] = chapters[j], chapters[i]
}
return chapters, nil
}
func makeEpisodeSlug(ep comikeyEpisode, prefix, lang string) string {
parts := strings.SplitN(ep.ID, "-", 2)
e4pid := ep.ID
if len(parts) == 2 {
e4pid = parts[1]
}
locPrefix := prefix
if prefix == "chapter" && lang != "en" {
switch lang {
case "es":
locPrefix = "capitulo-espanol"
case "pt-BR":
locPrefix = "capitulo-portugues"
case "fr":
locPrefix = "chapitre-francais"
case "id":
locPrefix = "bab-bahasa"
}
}
numStr := fmt.Sprintf("%g", ep.Number)
numStr = strings.ReplaceAll(numStr, ".", "-")
return fmt.Sprintf("%s/%s-%s/", e4pid, locPrefix, numStr)
}
func parseComikeyDate(s string) int64 {
t, err := time.Parse("2006-01-02T15:04:05Z", s)
if err != nil {
return 0
}
return t.UnixMilli()
}
func (s *Source) getAPIJSON(ctx context.Context, rawURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Referer", s.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("comikey: gundam API HTTP %d", resp.StatusCode)
}
buf := make([]byte, 0, 4096)
tmp := make([]byte, 4096)
for {
n, err := resp.Body.Read(tmp)
if n > 0 {
buf = append(buf, tmp[:n]...)
}
if err != nil {
break
}
}
return buf, nil
}
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
return nil, fmt.Errorf("comikey: page list requires WebView/DRM — not supported in the Go port")
}
func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil }
func (s *Source) GetFilterList() []source.Filter { return nil }
func init() {
registry.Register(newSource("en", "Comikey", "https://comikey.com"))
registry.Register(newSource("es", "Comikey", "https://comikey.com"))
registry.Register(newSource("id", "Comikey", "https://comikey.com"))
registry.Register(newSource("pt-BR", "Comikey", "https://comikey.com"))
registry.Register(newSource("pt-BR", "Comikey Brasil", "https://br.comikey.com"))
}
+223
View File
@@ -0,0 +1,223 @@
// Package commitstrip implements the Commit Strip webcomic source.
// Two language instances (en, fr). Popular is a static list of per-year entries
// (2012 → current year). Chapters scraped from paginated year archives; one page per strip.
package commitstrip
import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"goyomi/internal/httpclient"
"goyomi/internal/registry"
"goyomi/internal/source"
)
const siteURL = "https://www.commitstrip.com"
var (
dateRe = regexp.MustCompile(`\d{4}/\d{2}/\d{2}`)
pageRe = regexp.MustCompile(`\d+`)
)
type Source struct {
name string
lang string
siteLang string
client *httpclient.Client
id int64
}
func newSource(lang, siteLang string) *Source {
return &Source{
name: "Commit Strip",
lang: lang,
siteLang: siteLang,
client: httpclient.NewClient(httpclient.WithRateLimit(2, 1)),
id: source.GenerateSourceID("Commit Strip", lang),
}
}
func (s *Source) ID() int64 { return s.id }
func (s *Source) Name() string { return s.name }
func (s *Source) Lang() string { return s.lang }
func (s *Source) SupportsLatest() bool { return false }
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
}
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("commitstrip: HTTP %d", resp.StatusCode)
}
return goquery.NewDocumentFromReader(resp.Body)
}
func currentYear() int { return time.Now().Year() }
func (s *Source) thumbnail() string {
if s.lang == "fr" {
return "https://i.imgur.com/I7ps9zS.jpg"
}
return "https://i.imgur.com/HODJlt9.jpg"
}
func (s *Source) author() string {
if s.lang == "fr" {
return "Thomas Gx"
}
return "Mark Nightingale"
}
func (s *Source) summary(year int) string {
note := fmt.Sprintf("\n\nNote: This entry includes all the chapters published in %d", year)
if s.lang == "fr" {
return "Le blog qui raconte la vie des codeurs" + note
}
return "The blog relating the daily life of web agency developers." + note
}
func (s *Source) makeYearManga(year int) source.SManga {
status := source.StatusOngoing
if year != currentYear() {
status = source.StatusCompleted
}
return source.SManga{
URL: fmt.Sprintf("/%s/%d", s.siteLang, year),
Title: fmt.Sprintf("Commit Strip (%d)", year),
ThumbnailURL: s.thumbnail(),
Author: s.author(),
Artist: "Etienne Issartial",
Status: status,
Description: s.summary(year),
}
}
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
if page > 1 {
return source.MangasPage{}, nil
}
cur := currentYear()
mangas := make([]source.SManga, 0, cur-2011)
for y := cur; y >= 2012; y-- {
mangas = append(mangas, s.makeYearManga(y))
}
return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil
}
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
return source.MangasPage{}, fmt.Errorf("commitstrip: latest not supported")
}
func (s *Source) GetSearchManga(page int, query string, _ []source.Filter) (source.MangasPage, error) {
all, _ := s.GetPopularManga(1)
if query == "" {
return all, nil
}
q := strings.ToLower(query)
var matched []source.SManga
for _, m := range all.Mangas {
if strings.Contains(strings.ToLower(m.Title), q) {
matched = append(matched, m)
}
}
return source.MangasPage{Mangas: matched, HasNextPage: false}, nil
}
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
return manga, nil
}
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
yearURL := siteURL + manga.URL
doc, err := s.get(context.Background(), yearURL)
if err != nil {
return nil, err
}
// Find total pages from ".wp-pagenavi .pages" text (e.g. "Page 1 of 12").
totalPages := 1
if pagesText := doc.Find(".wp-pagenavi .pages").First().Text(); pagesText != "" {
matches := pageRe.FindAllString(pagesText, -1)
if len(matches) >= 2 {
fmt.Sscanf(matches[len(matches)-1], "%d", &totalPages)
}
}
var chapters []source.SChapter
collect := func(d *goquery.Document) {
d.Find(".excerpt a").Each(func(_ int, a *goquery.Selection) {
href := a.AttrOr("href", "")
if href == "" {
return
}
chURL := strings.TrimPrefix(href, siteURL)
name := strings.TrimSpace(a.Find("span").Text())
if name == "" {
name = strings.TrimSpace(a.Text())
}
var date int64
if m := dateRe.FindString(chURL); m != "" {
if t, err := time.Parse("2006/01/02", m); err == nil {
date = t.UnixMilli()
}
}
chapters = append(chapters, source.SChapter{URL: chURL, Name: name, DateUpload: date})
})
}
collect(doc)
for pg := 2; pg <= totalPages; pg++ {
pageDoc, err := s.get(context.Background(), fmt.Sprintf("%s/page/%d", yearURL, pg))
if err != nil {
break
}
collect(pageDoc)
}
// Deduplicate and assign chapter numbers.
seen := make(map[string]bool)
var unique []source.SChapter
for _, ch := range chapters {
if !seen[ch.URL] {
seen[ch.URL] = true
unique = append(unique, ch)
}
}
total := len(unique)
for i := range unique {
unique[i].ChapterNumber = float32(total - i)
}
return unique, nil
}
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
doc, err := s.get(context.Background(), siteURL+chapter.URL)
if err != nil {
return nil, err
}
src := doc.Find(".entry-content p img").First().AttrOr("src", "")
if src == "" {
return nil, fmt.Errorf("commitstrip: image not found")
}
return []source.Page{{Index: 0, ImageURL: src}}, nil
}
func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil }
func (s *Source) GetFilterList() []source.Filter { return nil }
func init() {
registry.Register(newSource("en", "en"))
registry.Register(newSource("fr", "fr"))
}
+18
View File
@@ -0,0 +1,18 @@
package coomer
import (
"goyomi/internal/registry"
base "goyomi/sources/base/kemono"
)
func New() *base.Source {
return base.New(base.Config{
Name: "Coomer",
BaseURL: "https://coomer.st",
Lang: "all",
})
}
func init() {
registry.Register(New())
}
+16
View File
@@ -0,0 +1,16 @@
package elitebabes
import (
"goyomi/internal/registry"
base "goyomi/sources/base/masonry"
)
func New() *base.Source {
return base.New(base.Config{
Name: "Elite Babes",
BaseURL: "https://www.elitebabes.com",
Lang: "all",
})
}
func init() { registry.Register(New()) }
+16
View File
@@ -0,0 +1,16 @@
package femjoyhunter
import (
"goyomi/internal/registry"
base "goyomi/sources/base/masonry"
)
func New() *base.Source {
return base.New(base.Config{
Name: "Femjoy Hunter",
BaseURL: "https://www.femjoyhunter.com",
Lang: "all",
})
}
func init() { registry.Register(New()) }
+16
View File
@@ -0,0 +1,16 @@
package ftvhunter
import (
"goyomi/internal/registry"
base "goyomi/sources/base/masonry"
)
func New() *base.Source {
return base.New(base.Config{
Name: "FTV Hunter",
BaseURL: "https://www.ftvhunter.com",
Lang: "all",
})
}
func init() { registry.Register(New()) }
+16
View File
@@ -0,0 +1,16 @@
package joymiihub
import (
"goyomi/internal/registry"
base "goyomi/sources/base/masonry"
)
func New() *base.Source {
return base.New(base.Config{
Name: "Joymii Hub",
BaseURL: "https://www.joymiihub.com",
Lang: "all",
})
}
func init() { registry.Register(New()) }
+17
View File
@@ -0,0 +1,17 @@
package mangacrazy
import (
"goyomi/internal/registry"
base "goyomi/sources/base/madara"
)
func New() *base.Source {
return base.New(base.Config{
Name: "MangaCrazy",
BaseURL: "https://mangacrazy.net",
Lang: "all",
UseNewChapterEndpoint: true,
})
}
func init() { registry.Register(New()) }
+16
View File
@@ -0,0 +1,16 @@
package metarthunter
import (
"goyomi/internal/registry"
base "goyomi/sources/base/masonry"
)
func New() *base.Source {
return base.New(base.Config{
Name: "Metart Hunter",
BaseURL: "https://www.metarthunter.com",
Lang: "all",
})
}
func init() { registry.Register(New()) }
@@ -0,0 +1,16 @@
package playmatehunter
import (
"goyomi/internal/registry"
base "goyomi/sources/base/masonry"
)
func New() *base.Source {
return base.New(base.Config{
Name: "Playmate Hunter",
BaseURL: "https://pmatehunter.com",
Lang: "all",
})
}
func init() { registry.Register(New()) }
+16
View File
@@ -0,0 +1,16 @@
package xarthunter
import (
"goyomi/internal/registry"
base "goyomi/sources/base/masonry"
)
func New() *base.Source {
return base.New(base.Config{
Name: "XArt Hunter",
BaseURL: "https://www.xarthunter.com",
Lang: "all",
})
}
func init() { registry.Register(New()) }