Files
goyomi/sources/base/heancms/heancms.go
T
achmad ca609ccae7 phase3: implement first 20 base sources + shared util
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.
2026-05-10 22:15:11 +07:00

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
}