// Package comicaso implements the Comicaso manga base. // Single static JSON index at {base}/wp-content/static/manga/index.json; paginated client-side. package comicaso import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "sync" "goyomi/internal/httpclient/flare" "goyomi/internal/source" "goyomi/sources/base/util" ) const pageSize = 20 type Config struct { Name string BaseURL string Lang string } type Source struct { cfg Config client *flare.Client id int64 mu sync.Mutex mangaList []mangaDTO } type mangaDTO struct { Slug string `json:"slug"` Title string `json:"title"` Thumbnail string `json:"thumbnail"` Status string `json:"status"` Genres []string `json:"genres"` UpdatedAt int64 `json:"updated_at"` MangaDate int64 `json:"manga_date"` } type mangaDetailDTO struct { Slug string `json:"slug"` Title string `json:"title"` Thumbnail string `json:"thumbnail"` Synopsis string `json:"synopsis"` Author string `json:"author"` Artist string `json:"artist"` Status string `json:"status"` Genres []string `json:"genres"` Chapters []chapterDTO `json:"chapters"` } type chapterDTO struct { Slug string `json:"slug"` Title string `json:"title"` Date int64 `json:"date"` } type tokenResponseDTO struct { Tokens map[string]string `json:"tokens"` Expire int64 `json:"expire"` } func New(cfg Config) *Source { 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, "/") } 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("Referer", s.cfg.BaseURL+"/") resp, err := s.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("comicaso: HTTP %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) return json.Unmarshal(body, out) } func (s *Source) getMangaList(ctx context.Context) ([]mangaDTO, error) { s.mu.Lock() defer s.mu.Unlock() if s.mangaList != nil { return s.mangaList, nil } var list []mangaDTO if err := s.getJSON(ctx, s.base()+"/wp-content/static/manga/index.json", &list); err != nil { return nil, err } s.mangaList = list return list, nil } func toSManga(m mangaDTO) source.SManga { return source.SManga{ URL: m.Slug, Title: m.Title, ThumbnailURL: m.Thumbnail, Genre: strings.Join(m.Genres, ", "), Status: util.StatusFromString(m.Status), } } func (s *Source) paginate(list []mangaDTO, page int) source.MangasPage { start := (page - 1) * pageSize if start >= len(list) { return source.MangasPage{} } end := start + pageSize if end > len(list) { end = len(list) } mangas := make([]source.SManga, end-start) for i, m := range list[start:end] { mangas[i] = toSManga(m) } return source.MangasPage{Mangas: mangas, HasNextPage: end < len(list)} } func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { list, err := s.getMangaList(context.Background()) if err != nil { return source.MangasPage{}, err } return s.paginate(list, page), nil } func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { list, err := s.getMangaList(context.Background()) if err != nil { return source.MangasPage{}, err } // sort by updated_at desc (already sorted by server usually, just use as-is) return s.paginate(list, page), nil } func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { list, err := s.getMangaList(context.Background()) if err != nil { return source.MangasPage{}, err } q := strings.ToLower(query) var filtered []mangaDTO for _, m := range list { if strings.Contains(strings.ToLower(m.Title), q) { filtered = append(filtered, m) } } return s.paginate(filtered, page), nil } func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { var detail mangaDetailDTO if err := s.getJSON(context.Background(), fmt.Sprintf("%s/wp-content/static/manga/%s.json", s.base(), manga.URL), &detail); err != nil { return manga, err } return source.SManga{ URL: manga.URL, Title: detail.Title, Author: detail.Author, Artist: detail.Artist, Description: detail.Synopsis, Genre: strings.Join(detail.Genres, ", "), Status: util.StatusFromString(detail.Status), ThumbnailURL: detail.Thumbnail, }, nil } func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { var detail mangaDetailDTO if err := s.getJSON(context.Background(), fmt.Sprintf("%s/wp-content/static/manga/%s.json", s.base(), manga.URL), &detail); err != nil { return nil, err } chapters := make([]source.SChapter, len(detail.Chapters)) for i, ch := range detail.Chapters { chapters[i] = source.SChapter{ URL: fmt.Sprintf("/komik/%s/%s/", manga.URL, ch.Slug), Name: ch.Title, DateUpload: ch.Date * 1000, } } return chapters, nil } func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { chapterURL := util.AbsURL(s.cfg.BaseURL, chapter.URL) // acquire token for this chapter URL tokenURL := s.base() + "/wp-json/wp/v2/token" body, _ := json.Marshal(map[string]any{"urls": []string{chapterURL}}) req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, tokenURL, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Referer", s.cfg.BaseURL+"/") resp, err := s.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) var tokenResp tokenResponseDTO pageURL := chapterURL if json.Unmarshal(raw, &tokenResp) == nil { if tok, ok := tokenResp.Tokens[chapterURL]; ok && tok != "" { pageURL = chapterURL + "?t=" + tok } } // GET page list JSON var pages []struct { URL string `json:"url"` } if err := s.getJSON(context.Background(), pageURL, &pages); err != nil { return nil, err } result := make([]source.Page, len(pages)) for i, p := range pages { result[i] = source.Page{Index: i, ImageURL: util.AbsURL(s.cfg.BaseURL, p.URL)} } return result, nil } func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } func (s *Source) GetFilterList() []source.Filter { return nil }