// Package mangataro implements the MangaTaro manga base. // WP JSON API (browse/details) + custom auth endpoints with MD5 token (chapters/pages); CF-protected. package mangataro import ( "bytes" "context" "crypto/md5" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "goyomi/internal/httpclient" "goyomi/internal/source" "goyomi/sources/base/util" ) type Config struct { Name string BaseURL string Lang string } type Source struct { cfg Config client *httpclient.Client id int64 } func New(cfg Config) *Source { 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 } func (s *Source) base() string { return strings.TrimRight(s.cfg.BaseURL, "/") } func (s *Source) doGet(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+"/") 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("mangataro: HTTP %d for %s", resp.StatusCode, rawURL) } body, _ := io.ReadAll(resp.Body) return json.Unmarshal(body, out) } func (s *Source) doPost(ctx context.Context, rawURL string, payload any, out any) error { body, err := json.Marshal(payload) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Referer", s.cfg.BaseURL+"/") req.Header.Set("Content-Type", "application/json") 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("mangataro: HTTP %d for %s", resp.StatusCode, rawURL) } respBody, _ := io.ReadAll(resp.Body) return json.Unmarshal(respBody, out) } // mangaURLDTO is stored as the manga.URL field (JSON-encoded). type mangaURLDTO struct { ID string `json:"id"` Slug string `json:"slug"` } func encodeMangaURL(id, slug string) string { b, _ := json.Marshal(mangaURLDTO{ID: id, Slug: slug}) return string(b) } func decodeMangaURL(raw string) mangaURLDTO { var dto mangaURLDTO _ = json.Unmarshal([]byte(raw), &dto) return dto } // Search/browse DTOs type searchPayload struct { Page int `json:"page"` Search string `json:"search"` Years string `json:"years"` Genres string `json:"genres"` Types string `json:"types"` Statuses string `json:"statuses"` Sort string `json:"sort"` GenreMatchMode string `json:"genreMatchMode"` } type browseManga struct { ID string `json:"id"` URL string `json:"url"` // slug Title string `json:"title"` Cover string `json:"cover"` Type string `json:"type"` Description string `json:"description"` Status string `json:"status"` } func (s *Source) browse(ctx context.Context, page int, search, sort string) (source.MangasPage, error) { payload := searchPayload{ Page: page, Search: search, Years: "[]", Genres: "[]", Types: "[]", Statuses: "[]", Sort: sort, GenreMatchMode: "and", } var items []browseManga if err := s.doPost(ctx, s.base()+"/wp-json/manga/v1/load", payload, &items); err != nil { return source.MangasPage{}, err } var mangas []source.SManga for _, item := range items { if item.Type == "Novel" || item.URL == "" { continue } mangas = append(mangas, source.SManga{ URL: encodeMangaURL(item.ID, item.URL), Title: unescapeHTML(item.Title), ThumbnailURL: item.Cover, Description: unescapeHTML(item.Description), Status: parseStatus(item.Status), }) } // hasNextPage: Kotlin checks data.size == 24 hasNext := len(items) == 24 return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil } func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { return s.browse(context.Background(), page, "", "popular_desc") } func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { return s.browse(context.Background(), page, "", "post_desc") } func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { return s.browse(context.Background(), page, query, "popular_desc") } // Manga details DTOs (WP JSON API) type mangaDetails struct { ID int `json:"id"` Slug string `json:"slug"` Title rendered `json:"title"` Content rendered `json:"content"` Type string `json:"type"` Embedded embedded `json:"_embedded"` } type rendered struct { Rendered string `json:"rendered"` } type embedded struct { FeaturedMedia []thumbnail `json:"wp:featuredmedia"` Terms [][]term `json:"wp:term"` } func (e embedded) getTerms(taxonomy string) []string { for _, group := range e.Terms { if len(group) > 0 && group[0].Taxonomy == taxonomy { names := make([]string, len(group)) for i, t := range group { names[i] = t.Name } return names } } return nil } type thumbnail struct { URL string `json:"source_url"` } type term struct { Name string `json:"name"` Taxonomy string `json:"taxonomy"` } func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { dto := decodeMangaURL(manga.URL) if dto.ID == "" { return manga, fmt.Errorf("mangataro: cannot decode manga URL: %s", manga.URL) } u := fmt.Sprintf("%s/wp-json/wp/v2/manga/%s?_embed", s.base(), dto.ID) var data mangaDetails if err := s.doGet(context.Background(), u, &data); err != nil { return manga, err } result := source.SManga{URL: manga.URL} result.URL = encodeMangaURL(fmt.Sprint(data.ID), data.Slug) result.Title = unescapeHTML(data.Title.Rendered) result.Description = plainText(data.Content.Rendered) tags := data.Embedded.getTerms("post_tag") genreSet := make(map[string]bool) for _, t := range tags { genreSet[t] = true } knownTypes := []string{"Manhwa", "Manhua", "Manga"} hasKnown := false for _, kt := range knownTypes { if genreSet[kt] { hasKnown = true break } } if !hasKnown && data.Type != "" { genreSet[data.Type] = true } var genres []string for g := range genreSet { genres = append(genres, g) } result.Genre = strings.Join(genres, ", ") result.Author = strings.Join(data.Embedded.getTerms("manga_author"), ", ") if len(data.Embedded.FeaturedMedia) > 0 { result.ThumbnailURL = data.Embedded.FeaturedMedia[0].URL } result.Status = manga.Status // preserved from browse return result, nil } // Chapter list DTOs type chapterList struct { Chapters []chapter `json:"chapters"` } type chapter struct { URL string `json:"url"` Chapter string `json:"chapter"` Title *string `json:"title"` Date string `json:"date"` GroupName *string `json:"group_name"` Language string `json:"language"` } func md5Token(timestamp int64) string { date := time.Unix(timestamp, 0).UTC().Format("2006-01-02") input := fmt.Sprintf("%dmng_ch_%s", timestamp, date) sum := md5.Sum([]byte(input)) return fmt.Sprintf("%x", sum)[:16] } func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { dto := decodeMangaURL(manga.URL) if dto.ID == "" { return nil, fmt.Errorf("mangataro: cannot decode manga URL: %s", manga.URL) } ts := time.Now().Unix() token := md5Token(ts) u, _ := url.Parse(s.base() + "/auth/manga-chapters") q := u.Query() q.Set("manga_id", dto.ID) q.Set("offset", "0") q.Set("limit", "9999") q.Set("order", "DESC") q.Set("_t", token) u.RawQuery = q.Encode() var data chapterList if err := s.doGet(context.Background(), u.String(), &data); err != nil { return nil, err } placeholders := map[string]bool{"": true, "N/A": true, "—": true} var chapters []source.SChapter for _, ch := range data.Chapters { if !strings.EqualFold(ch.Language, s.cfg.Lang) { continue } name := "Chapter " + ch.Chapter if ch.Title != nil && !placeholders[*ch.Title] { name += ": " + unescapeHTML(*ch.Title) } chURL := ch.URL if !strings.HasPrefix(chURL, "http") { chURL = s.base() + chURL } chapters = append(chapters, source.SChapter{ URL: chURL, Name: name, DateUpload: util.ParseRelativeDate(ch.Date), }) } return chapters, nil } // Pages DTO type pagesDTO struct { Images []string `json:"images"` } func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { // chapterId = last path segment of chapter URL, after last "-" chapterURL := chapter.URL if !strings.HasPrefix(chapterURL, "http") { chapterURL = s.base() + chapterURL } parsed, err := url.Parse(chapterURL) if err != nil { return nil, err } segs := strings.Split(strings.TrimRight(parsed.Path, "/"), "/") lastSeg := segs[len(segs)-1] chapterID := lastSeg if idx := strings.LastIndex(lastSeg, "-"); idx >= 0 { chapterID = lastSeg[idx+1:] } u := fmt.Sprintf("%s/auth/chapter-content?chapter_id=%s", s.base(), chapterID) var data pagesDTO if err := s.doGet(context.Background(), u, &data); err != nil { return nil, err } pages := make([]source.Page, len(data.Images)) for i, img := range data.Images { pages[i] = source.Page{Index: i, ImageURL: img} } return pages, nil } func parseStatus(s string) int { switch strings.ToLower(s) { case "ongoing": return source.StatusOngoing case "completed", "complete": return source.StatusCompleted case "hiatus", "on hold", "on-hold": return source.StatusHiatus case "cancelled", "canceled": return source.StatusCancelled } return source.StatusUnknown } func unescapeHTML(s string) string { // Basic HTML entity unescaping r := strings.NewReplacer( "&", "&", "<", "<", ">", ">", """, `"`, "'", "'", "'", "'", ) prev := "" for prev != s { prev = s s = r.Replace(s) } return s } // plainText strips HTML tags from a string. func plainText(html string) string { // Quick approximation: remove tags var b strings.Builder inTag := false for _, r := range html { switch { case r == '<': inTag = true case r == '>': inTag = false case !inTag: b.WriteRune(r) } } return strings.TrimSpace(b.String()) } func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } func (s *Source) GetFilterList() []source.Filter { return nil }