316ae2f9db
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.
339 lines
9.0 KiB
Go
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())
|
|
} |