Files
goyomi/sources/base/natsuid/natsuid.go
T
achmad e17de903b2 refactor: use flare client for sources with cloudflareClient in Kotlin
- Batch 5: mangawork, mangotheme, mccms, multichan → flare (Kotlin uses cloudflareClient)
- Batch 6: natsuid → flare (Kotlin uses cloudflareClient)
- Batch 7: gravureblogger → flare (Kotlin uses cloudflareClient)

All verified against Kotlin multisrc reference.
2026-05-13 21:57:34 +07:00

337 lines
8.8 KiB
Go
Executable File

// Package natsuid implements the Natsuid manga base.
// WP-based site; uses nonce-authenticated multipart POST for search/browse; WP JSON API for details.
package natsuid
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"goyomi/internal/httpclient/flare"
"goyomi/internal/source"
"goyomi/sources/base/util"
)
type Config struct {
Name string
BaseURL string
Lang string
PostType string // WP post type slug, e.g. "manga"
}
type Source struct {
cfg Config
client *flare.Client
id int64
}
func New(cfg Config) *Source {
if cfg.PostType == "" {
cfg.PostType = "manga"
}
c := flare.NewClient(flare.WithRateLimit(1, 2))
return &Source{cfg: cfg, client: c, id: source.GenerateSourceID(cfg.Name, cfg.Lang)}
}
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 true }
func (s *Source) base() string { return strings.TrimRight(s.cfg.BaseURL, "/") }
type mangaURL struct {
ID int `json:"id"`
Slug string `json:"slug"`
}
func encodeMangaURL(id int, slug string) string {
b, _ := json.Marshal(mangaURL{ID: id, Slug: slug})
return string(b)
}
func decodeMangaURL(u string) (mangaURL, error) {
var m mangaURL
return m, json.Unmarshal([]byte(u), &m)
}
func (s *Source) getNonce(ctx context.Context) (string, error) {
u := s.base() + "/wp-admin/admin-ajax.php?type=search_form&action=get_nonce"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", err
}
resp, err := s.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return "", err
}
nonce := doc.Find("input[name=search_nonce]").AttrOr("value", "")
return nonce, nil
}
func (s *Source) postSearch(ctx context.Context, page int, sort string, query string) (*goquery.Document, error) {
nonce, err := s.getNonce(ctx)
if err != nil {
return nil, err
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("nonce", nonce)
_ = mw.WriteField("page", fmt.Sprintf("%d", page))
_ = mw.WriteField("sort", sort)
_ = mw.WriteField("genre", "[]")
_ = mw.WriteField("genre_exclude", "[]")
_ = mw.WriteField("author", "[]")
_ = mw.WriteField("status", "[]")
if query != "" {
_ = mw.WriteField("search", query)
}
mw.Close()
u := s.base() + "/wp-admin/admin-ajax.php?action=advanced_search"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, &buf)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", mw.FormDataContentType())
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("natsuid: HTTP %d", resp.StatusCode)
}
return goquery.NewDocumentFromReader(resp.Body)
}
func (s *Source) parseMangaList(doc *goquery.Document) source.MangasPage {
var mangas []source.SManga
doc.Find("article.manga-card, div.manga-card, div.card-manga").Each(func(_ int, el *goquery.Selection) {
a := el.Find("a").First()
if a.Length() == 0 {
return
}
href := a.AttrOr("href", "")
if href == "" {
return
}
slug := util.SlugFromURL(strings.TrimRight(href, "/"))
m := source.SManga{
Title: strings.TrimSpace(el.Find("h2, h3, .manga-title, .title").First().Text()),
}
if m.Title == "" {
m.Title = strings.TrimSpace(a.AttrOr("title", ""))
}
if img := el.Find("img").First(); img.Length() > 0 {
m.ThumbnailURL = util.AbsURL(s.cfg.BaseURL, img.AttrOr("src", img.AttrOr("data-src", "")))
}
// We don't have WP post ID yet; store slug-only temporarily
m.URL = encodeMangaURL(0, slug)
if m.URL != "" && m.Title != "" {
mangas = append(mangas, m)
}
})
hasNext := doc.Find(".next, a[rel=next], .pagination .next").Length() > 0
return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}
}
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
doc, err := s.postSearch(context.Background(), page, "popular", "")
if err != nil {
return source.MangasPage{}, err
}
return s.parseMangaList(doc), nil
}
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
doc, err := s.postSearch(context.Background(), page, "updated", "")
if err != nil {
return source.MangasPage{}, err
}
return s.parseMangaList(doc), nil
}
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
doc, err := s.postSearch(context.Background(), page, "popular", query)
if err != nil {
return source.MangasPage{}, err
}
return s.parseMangaList(doc), nil
}
type wpPost struct {
ID int `json:"id"`
Slug string `json:"slug"`
Title struct {
Rendered string `json:"rendered"`
} `json:"title"`
Content struct {
Rendered string `json:"rendered"`
} `json:"content"`
Excerpt struct {
Rendered string `json:"rendered"`
} `json:"excerpt"`
Embedded struct {
FeaturedMedia [][]struct {
SourceURL string `json:"source_url"`
} `json:"wp:featuredmedia"`
} `json:"_embedded"`
}
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
mu, err := decodeMangaURL(manga.URL)
if err != nil {
return manga, err
}
var u string
if mu.ID > 0 {
u = fmt.Sprintf("%s/wp-json/wp/v2/%s/%d?_embed", s.base(), s.cfg.PostType, mu.ID)
} else {
u = fmt.Sprintf("%s/wp-json/wp/v2/%s?slug=%s&_embed", s.base(), s.cfg.PostType, mu.Slug)
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u, nil)
if err != nil {
return manga, err
}
resp, err := s.client.Do(req)
if err != nil {
return manga, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if mu.ID > 0 {
var post wpPost
if err := json.Unmarshal(body, &post); err != nil {
return manga, err
}
return s.postToManga(manga.URL, post), nil
}
var posts []wpPost
if err := json.Unmarshal(body, &posts); err != nil || len(posts) == 0 {
return manga, err
}
return s.postToManga(manga.URL, posts[0]), nil
}
func (s *Source) postToManga(originalURL string, post wpPost) source.SManga {
m := source.SManga{
URL: originalURL,
Title: util.CleanText(post.Title.Rendered),
}
if len(post.Embedded.FeaturedMedia) > 0 && len(post.Embedded.FeaturedMedia[0]) > 0 {
m.ThumbnailURL = post.Embedded.FeaturedMedia[0][0].SourceURL
}
desc := util.CleanText(post.Excerpt.Rendered)
if desc == "" {
desc = util.CleanText(post.Content.Rendered)
}
m.Description = desc
return m
}
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
mu, err := decodeMangaURL(manga.URL)
if err != nil {
return nil, err
}
pageURL := fmt.Sprintf("%s/%s/%s/", s.base(), s.cfg.PostType, mu.Slug)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, pageURL, nil)
if err != nil {
return nil, err
}
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, err
}
var chapters []source.SChapter
doc.Find("div a:has(time)").Each(func(_ int, el *goquery.Selection) {
href := el.AttrOr("href", "")
if href == "" {
return
}
name := strings.TrimSpace(el.Find("span, div").Not("time").First().Text())
if name == "" {
name = strings.TrimSpace(el.Text())
}
var ts int64
if t := el.Find("time").First(); t.Length() > 0 {
dt := t.AttrOr("datetime", t.Text())
ts = parseDate(strings.TrimSpace(dt))
}
chapters = append(chapters, source.SChapter{
URL: href,
Name: name,
DateUpload: ts,
})
})
return chapters, nil
}
func parseDate(s string) int64 {
formats := []string{
time.RFC3339,
"2006-01-02T15:04:05",
"2006-01-02",
"January 2, 2006",
}
for _, f := range formats {
if t, err := time.Parse(f, s); err == nil {
return t.UnixMilli()
}
}
return util.ParseRelativeDate(s)
}
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, util.AbsURL(s.cfg.BaseURL, chapter.URL), nil)
if err != nil {
return nil, err
}
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, err
}
var pages []source.Page
doc.Find("main .relative section > img").Each(func(i int, img *goquery.Selection) {
u := img.AttrOr("src", img.AttrOr("data-src", ""))
if u != "" {
pages = append(pages, source.Page{Index: i, ImageURL: util.AbsURL(s.cfg.BaseURL, 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 }