Files
goyomi/sources/all/asmhentai/asmhentai.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

239 lines
7.1 KiB
Go

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