Files
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

250 lines
7.0 KiB
Go

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