46f930718c
Based on Kotlin reference verification: - Batch 1: colorlibanime, eromuse, fansubscat → flare - Batch 2: fmreader, galleryadults, greenshit, grouple → flare - Batch 3: heancms, lectormoe → flare Also renamed methods appropriately: - get() for HTML scraping - getJSON() for JSON APIs
393 lines
9.7 KiB
Go
Executable File
393 lines
9.7 KiB
Go
Executable File
// Package heancms implements the HeanCMS multi-source base.
|
|
// API: JSON REST, endpoint at api.{baseURL} by default.
|
|
package heancms
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"goyomi/internal/httpclient/flare"
|
|
"goyomi/internal/source"
|
|
"goyomi/sources/base/util"
|
|
)
|
|
|
|
// Config holds per-source configuration.
|
|
type Config struct {
|
|
Name string
|
|
BaseURL string
|
|
Lang string
|
|
APIURL string // defaults to api.{BaseURL}
|
|
CoverPath string // path prefix for cover images
|
|
CdnURL string // CDN base, defaults to APIURL
|
|
MangaSubDirectory string // e.g. "series" or "manga"
|
|
}
|
|
|
|
type Source struct {
|
|
cfg Config
|
|
client *flare.Client
|
|
id int64
|
|
}
|
|
|
|
func New(cfg Config) *Source {
|
|
if cfg.APIURL == "" {
|
|
parsed, err := url.Parse(cfg.BaseURL)
|
|
if err == nil {
|
|
parsed.Host = "api." + parsed.Host
|
|
cfg.APIURL = parsed.String()
|
|
} else {
|
|
cfg.APIURL = strings.Replace(cfg.BaseURL, "://", "://api.", 1)
|
|
}
|
|
}
|
|
if cfg.CdnURL == "" {
|
|
cfg.CdnURL = cfg.APIURL
|
|
}
|
|
if cfg.MangaSubDirectory == "" {
|
|
cfg.MangaSubDirectory = "series"
|
|
}
|
|
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 }
|
|
|
|
// --- JSON DTOs ---
|
|
|
|
type seriesListDTO struct {
|
|
Data []seriesDTO `json:"data"`
|
|
Meta *seriesMetaDTO `json:"meta"`
|
|
}
|
|
|
|
type seriesMetaDTO struct {
|
|
CurrentPage int `json:"current_page"`
|
|
LastPage int `json:"last_page"`
|
|
}
|
|
|
|
func (m *seriesMetaDTO) hasNextPage() bool {
|
|
if m == nil {
|
|
return false
|
|
}
|
|
return m.CurrentPage < m.LastPage
|
|
}
|
|
|
|
type seriesDTO struct {
|
|
ID int `json:"id"`
|
|
Slug string `json:"series_slug"`
|
|
Author *string `json:"author"`
|
|
Description *string `json:"description"`
|
|
Studio *string `json:"studio"`
|
|
Status *string `json:"status"`
|
|
Thumbnail string `json:"thumbnail"`
|
|
Title string `json:"title"`
|
|
Tags []tagDTO `json:"tags"`
|
|
Seasons []seasonDTO `json:"seasons"`
|
|
}
|
|
|
|
type tagDTO struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type seasonDTO struct {
|
|
Chapters []chapterDTO `json:"chapters"`
|
|
}
|
|
|
|
type chapterDTO struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"chapter_name"`
|
|
Title *string `json:"chapter_title"`
|
|
Slug string `json:"chapter_slug"`
|
|
CreatedAt *string `json:"created_at"`
|
|
Price int `json:"price"`
|
|
}
|
|
|
|
type chapterPayloadDTO struct {
|
|
Data []chapterDTO `json:"data"`
|
|
Meta chapterMetaDTO `json:"meta"`
|
|
}
|
|
|
|
type chapterMetaDTO struct {
|
|
CurrentPage int `json:"current_page"`
|
|
LastPage int `json:"last_page"`
|
|
}
|
|
|
|
func (m chapterMetaDTO) hasNextPage() bool {
|
|
return m.CurrentPage < m.LastPage
|
|
}
|
|
|
|
type pagePayloadDTO struct {
|
|
Chapter struct {
|
|
ChapterData *struct {
|
|
Images []string `json:"images"`
|
|
} `json:"chapter_data"`
|
|
} `json:"chapter"`
|
|
Data []string `json:"data"`
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
var dateLayouts = []string{
|
|
"2006-01-02T15:04:05.999Z07:00",
|
|
"2006-01-02T15:04:05Z07:00",
|
|
"2006-01-02T15:04:05",
|
|
"2006-01-02",
|
|
}
|
|
|
|
func parseDate(s string) int64 {
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
for _, layout := range dateLayouts {
|
|
t, err := time.Parse(layout, s)
|
|
if err == nil {
|
|
return t.UnixMilli()
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (s *Source) thumbnailURL(thumb string) string {
|
|
if thumb == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(thumb, "http") {
|
|
return thumb
|
|
}
|
|
base := strings.TrimRight(s.cfg.CdnURL, "/")
|
|
prefix := ""
|
|
if s.cfg.CoverPath != "" {
|
|
prefix = "/" + strings.Trim(s.cfg.CoverPath, "/")
|
|
}
|
|
return base + prefix + "/" + strings.TrimLeft(thumb, "/")
|
|
}
|
|
|
|
func (s *Source) seriesURL(slug string, id int) string {
|
|
return fmt.Sprintf("/%s/%s#%d", s.cfg.MangaSubDirectory, slug, id)
|
|
}
|
|
|
|
func (s *Source) toSManga(dto seriesDTO) source.SManga {
|
|
genres := make([]string, 0, len(dto.Tags))
|
|
for _, t := range dto.Tags {
|
|
genres = append(genres, t.Name)
|
|
}
|
|
var desc string
|
|
if dto.Description != nil {
|
|
desc = util.CleanText(*dto.Description)
|
|
}
|
|
var author string
|
|
if dto.Author != nil {
|
|
author = *dto.Author
|
|
}
|
|
var artist string
|
|
if dto.Studio != nil {
|
|
artist = *dto.Studio
|
|
}
|
|
var status int
|
|
if dto.Status != nil {
|
|
status = util.StatusFromString(*dto.Status)
|
|
}
|
|
return source.SManga{
|
|
URL: s.seriesURL(dto.Slug, dto.ID),
|
|
Title: dto.Title,
|
|
Author: author,
|
|
Artist: artist,
|
|
Description: desc,
|
|
Genre: strings.Join(genres, ", "),
|
|
Status: status,
|
|
ThumbnailURL: s.thumbnailURL(dto.Thumbnail),
|
|
}
|
|
}
|
|
|
|
func (s *Source) toSChapter(dto chapterDTO, seriesSlug string) source.SChapter {
|
|
name := "Chapter " + dto.Name
|
|
if dto.Title != nil && *dto.Title != "" {
|
|
name += " - " + *dto.Title
|
|
}
|
|
var date int64
|
|
if dto.CreatedAt != nil {
|
|
date = parseDate(*dto.CreatedAt)
|
|
}
|
|
return source.SChapter{
|
|
URL: fmt.Sprintf("/%s/%s/%s", s.cfg.MangaSubDirectory, seriesSlug, dto.Slug),
|
|
Name: name,
|
|
DateUpload: date,
|
|
}
|
|
}
|
|
|
|
// --- API calls ---
|
|
|
|
func (s *Source) getJSON(ctx context.Context, rawURL string, out any) error {
|
|
resp, err := s.client.Get(ctx, rawURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("heancms: HTTP %d for %s", resp.StatusCode, rawURL)
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(body, out)
|
|
}
|
|
|
|
func (s *Source) fetchSeriesList(ctx context.Context, page int, order, query string) (source.MangasPage, error) {
|
|
endpoint := strings.TrimRight(s.cfg.APIURL, "/") + "/series"
|
|
u, _ := url.Parse(endpoint)
|
|
q := u.Query()
|
|
q.Set("page", fmt.Sprintf("%d", page))
|
|
q.Set("order", order)
|
|
q.Set("series_type", "Comic")
|
|
if query != "" {
|
|
q.Set("query_string", query)
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
|
|
var result seriesListDTO
|
|
if err := s.getJSON(ctx, u.String(), &result); err != nil {
|
|
return source.MangasPage{}, err
|
|
}
|
|
mangas := make([]source.SManga, 0, len(result.Data))
|
|
for _, d := range result.Data {
|
|
mangas = append(mangas, s.toSManga(d))
|
|
}
|
|
return source.MangasPage{Mangas: mangas, HasNextPage: result.Meta.hasNextPage()}, nil
|
|
}
|
|
|
|
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
|
|
return s.fetchSeriesList(context.Background(), page, "total_views", "")
|
|
}
|
|
|
|
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
|
|
return s.fetchSeriesList(context.Background(), page, "latest", "")
|
|
}
|
|
|
|
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
|
|
return s.fetchSeriesList(context.Background(), page, "total_views", query)
|
|
}
|
|
|
|
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
|
|
// URL format: /series/{slug}#{id}
|
|
urlStr := manga.URL
|
|
slug := util.SlugFromURL(strings.Split(urlStr, "#")[0])
|
|
apiURL := strings.TrimRight(s.cfg.APIURL, "/") + "/series/" + slug
|
|
|
|
var result seriesDTO
|
|
if err := s.getJSON(context.Background(), apiURL, &result); err != nil {
|
|
return manga, err
|
|
}
|
|
return s.toSManga(result), nil
|
|
}
|
|
|
|
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
|
|
urlStr := manga.URL
|
|
parts := strings.SplitN(urlStr, "#", 2)
|
|
slug := util.SlugFromURL(parts[0])
|
|
|
|
// Try paginated chapter endpoint first
|
|
if len(parts) == 2 {
|
|
seriesID := parts[1]
|
|
return s.fetchChaptersPaginated(context.Background(), slug, seriesID)
|
|
}
|
|
|
|
// Fall back to series detail endpoint
|
|
apiURL := strings.TrimRight(s.cfg.APIURL, "/") + "/series/" + slug
|
|
var result seriesDTO
|
|
if err := s.getJSON(context.Background(), apiURL, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
var chapters []source.SChapter
|
|
for _, season := range result.Seasons {
|
|
for _, ch := range season.Chapters {
|
|
if ch.Price == 0 {
|
|
chapters = append(chapters, s.toSChapter(ch, result.Slug))
|
|
}
|
|
}
|
|
}
|
|
return chapters, nil
|
|
}
|
|
|
|
func (s *Source) fetchChaptersPaginated(ctx context.Context, slug, seriesID string) ([]source.SChapter, error) {
|
|
base := strings.TrimRight(s.cfg.APIURL, "/") + "/chapter/query"
|
|
var all []source.SChapter
|
|
page := 1
|
|
for {
|
|
u, _ := url.Parse(base)
|
|
q := u.Query()
|
|
q.Set("page", fmt.Sprintf("%d", page))
|
|
q.Set("perPage", "1000")
|
|
q.Set("series_id", seriesID)
|
|
u.RawQuery = q.Encode()
|
|
|
|
var payload chapterPayloadDTO
|
|
if err := s.getJSON(ctx, u.String(), &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, ch := range payload.Data {
|
|
if ch.Price == 0 {
|
|
all = append(all, s.toSChapter(ch, slug))
|
|
}
|
|
}
|
|
if !payload.Meta.hasNextPage() {
|
|
break
|
|
}
|
|
page++
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
|
|
// Chapter URL: /series/{slug}/{chapter_slug}
|
|
// API URL: /chapter/{chapter_slug}
|
|
chURL := chapter.URL
|
|
chSlug := util.SlugFromURL(chURL)
|
|
// Replace /series/ with /chapter/
|
|
apiURL := strings.TrimRight(s.cfg.APIURL, "/") + "/chapter/" + chSlug
|
|
|
|
var result pagePayloadDTO
|
|
if err := s.getJSON(context.Background(), apiURL, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var images []string
|
|
if result.Chapter.ChapterData != nil && len(result.Chapter.ChapterData.Images) > 0 {
|
|
images = result.Chapter.ChapterData.Images
|
|
} else {
|
|
images = result.Data
|
|
}
|
|
|
|
pages := make([]source.Page, len(images))
|
|
for i, img := range images {
|
|
imgURL := img
|
|
if !strings.HasPrefix(imgURL, "http") {
|
|
base := strings.TrimRight(s.cfg.CdnURL, "/")
|
|
if s.cfg.CoverPath != "" {
|
|
base += "/" + strings.Trim(s.cfg.CoverPath, "/")
|
|
}
|
|
imgURL = base + "/" + strings.TrimLeft(imgURL, "/")
|
|
}
|
|
pages[i] = source.Page{Index: i, ImageURL: imgURL}
|
|
}
|
|
return pages, nil
|
|
}
|
|
|
|
func (s *Source) GetImageURL(page source.Page) (string, error) {
|
|
return page.ImageURL, nil
|
|
}
|
|
|
|
func (s *Source) GetFilterList() []source.Filter {
|
|
return nil
|
|
}
|