// Package iken implements the Iken manga base. // JSON REST API; GET {apiUrl}/api/query?page=N&perPage=18. package iken import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "goyomi/internal/httpclient/flare" "goyomi/internal/source" "goyomi/sources/base/util" ) const perPage = 18 type Config struct { Name string BaseURL string Lang string APIURL string // defaults to BaseURL } type Source struct { cfg Config client *flare.Client id int64 } func New(cfg Config) *Source { if cfg.APIURL == "" { cfg.APIURL = cfg.BaseURL } 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) api() string { return strings.TrimRight(s.cfg.APIURL, "/") } 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") 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("iken: HTTP %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if isHTML(body) { summary := string(body) if len(summary) > 120 { summary = summary[:120] } return fmt.Errorf("iken: expected JSON, got HTML: %s", summary) } return json.Unmarshal(body, out) } func isHTML(body []byte) bool { t := bytes.TrimSpace(body) return bytes.HasPrefix(t, []byte("<")) && !bytes.HasPrefix(t, []byte("{")) && !bytes.HasPrefix(t, []byte("[")) } type postDTO struct { ID int `json:"id"` Slug string `json:"slug"` PostTitle string `json:"postTitle"` FeaturedImage string `json:"featuredImage"` PostContent string `json:"postContent"` Author string `json:"author"` SeriesStatus string `json:"seriesStatus"` IsNovel bool `json:"isNovel"` Genres []struct{ Name string `json:"name"` } `json:"genres"` } type searchResponseDTO struct { Posts []postDTO `json:"posts"` TotalCount int `json:"totalCount"` } func (s *Source) toSManga(p postDTO) source.SManga { return source.SManga{ URL: fmt.Sprintf("%s#%d", p.Slug, p.ID), Title: p.PostTitle, ThumbnailURL: util.AbsURL(s.cfg.BaseURL, p.FeaturedImage), } } func (s *Source) query(ctx context.Context, page int, sortType, searchTerm string) (source.MangasPage, error) { u := fmt.Sprintf("%s/api/query?page=%d&perPage=%d&type=comic", s.api(), page, perPage) if sortType != "" { u += "&sortType=" + sortType } if searchTerm != "" { u += "&searchTerm=" + searchTerm } var result searchResponseDTO if err := s.getJSON(ctx, u, &result); err != nil { return source.MangasPage{}, err } var mangas []source.SManga for _, p := range result.Posts { if !p.IsNovel { mangas = append(mangas, s.toSManga(p)) } } hasNext := result.TotalCount > page*perPage return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil } func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { return s.query(context.Background(), page, "popular", "") } func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { return s.query(context.Background(), page, "latest", "") } func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { return s.query(context.Background(), page, "popular", query) } func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { id := postIDFromURL(manga.URL) var result struct { Post postDTO `json:"post"` } if err := s.getJSON(context.Background(), fmt.Sprintf("%s/api/post?postId=%s", s.api(), id), &result); err != nil { return manga, err } p := result.Post genres := make([]string, len(p.Genres)) for i, t := range p.Genres { genres[i] = t.Name } return source.SManga{ URL: manga.URL, Title: p.PostTitle, Author: p.Author, Description: p.PostContent, Genre: strings.Join(genres, ", "), Status: ikenStatus(p.SeriesStatus), ThumbnailURL: util.AbsURL(s.cfg.BaseURL, p.FeaturedImage), }, nil } func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { id := postIDFromURL(manga.URL) var result struct { Post struct { Slug string `json:"slug"` Chapters []struct { ID int `json:"id"` Number float64 `json:"number"` Title string `json:"title"` Date string `json:"date"` } `json:"chapters"` } `json:"post"` } if err := s.getJSON(context.Background(), fmt.Sprintf("%s/api/post?postId=%s", s.api(), id), &result); err != nil { return nil, err } chapters := make([]source.SChapter, len(result.Post.Chapters)) for i, ch := range result.Post.Chapters { name := fmt.Sprintf("Chapter %.1f", ch.Number) if ch.Title != "" { name += " - " + ch.Title } chapters[i] = source.SChapter{ URL: fmt.Sprintf("%s/%s#%d", result.Post.Slug, result.Post.Slug, ch.ID), Name: name, DateUpload: util.ParseAbsoluteDate(ch.Date, "2006-01-02T15:04:05Z"), } } return chapters, nil } func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { chapterID := postIDFromURL(chapter.URL) var result struct { Chapter struct { Pages []struct { URL string `json:"url"` } `json:"pages"` } `json:"chapter"` } if err := s.getJSON(context.Background(), fmt.Sprintf("%s/api/chapter?chapterId=%s", s.api(), chapterID), &result); err != nil { return nil, err } pages := make([]source.Page, len(result.Chapter.Pages)) for i, p := range result.Chapter.Pages { pages[i] = source.Page{Index: i, ImageURL: util.AbsURL(s.cfg.BaseURL, p.URL)} } return pages, nil } func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } func (s *Source) GetFilterList() []source.Filter { return nil } func ikenStatus(s string) int { switch s { case "ONGOING", "COMING_SOON": return source.StatusOngoing case "COMPLETED": return source.StatusCompleted case "CANCELLED", "DROPPED": return source.StatusCancelled default: return source.StatusUnknown } } func postIDFromURL(u string) string { if idx := strings.LastIndex(u, "#"); idx >= 0 { return u[idx+1:] } return util.SlugFromURL(u) }