Files
goyomi/sources/all/akuma/akuma.go
T
achmad 316ae2f9db 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.
2026-05-13 23:11:26 +07:00

339 lines
9.0 KiB
Go

// 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())
}