ca609ccae7
Ports bases from previous session: util (shared helpers), bakkin, fmreader, foolslide, gigaviewer, gmanga, grouple, guya, heancms, hentaihand, kemono, madara, madtheme, mangadventure, mangahub, mangathemesia, mangaworld, mmrcms, senkuro, wpcomics.
398 lines
9.9 KiB
Go
398 lines
9.9 KiB
Go
// 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"
|
|
"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 *httpclient.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 := httpclient.NewClient(httpclient.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 {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := s.client.Do(req)
|
|
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
|
|
}
|