package stalkercms import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/PuerkitoBio/goquery" "goyomi/internal/httpclient/flare" "goyomi/internal/source" ) type Config struct { Name string BaseURL string Lang string PopularMangaPath string LatestUpdatesLoadMorePath string } type Source struct { cfg Config client *flare.Client id int64 } func New(cfg Config) *Source { if cfg.PopularMangaPath == "" { cfg.PopularMangaPath = "/manga/todos/" } if cfg.Lang == "" { cfg.Lang = "pt-BR" } c := flare.NewClient(flare.WithRateLimit(2, 1)) 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 } type LoadMoreReleasesDTO struct { HTML string `json:"html"` HasNext bool `json:"hasNext"` NextPage int `json:"nextPage"` } type SearchResult struct { Title string `json:"title"` URL string `json:"url"` Image string `json:"image"` Description string `json:"description"` } type SearchDTO struct { Results []SearchResult `json:"results"` } func (s *Source) parseStatus(status string) int { switch strings.TrimSpace(strings.ToLower(status)) { case "em andamento": return 1 case "concluído": return 2 case "hiato": return 5 case "cancelado": return 6 } return 0 } func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { doc, err := s.fetchDoc(fmt.Sprintf("%s%s?page=%d", s.cfg.BaseURL, cfg.PopularMangaPath, page)) if err != nil { return source.MangasPage{}, err } mangas := s.mangaListFromDoc(doc) hasNext := doc.Find(".page-link[aria-label=Próxima]:not(disabled)").Length() > 0 return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil } func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { if page > 1 && s.cfg.LatestUpdatesLoadMorePath != "" { url := fmt.Sprintf("%s%s?page=%d", s.cfg.BaseURL, s.cfg.LatestUpdatesLoadMorePath, page) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) if err != nil { return source.MangasPage{}, err } resp, err := s.client.Do(req) if err != nil { return source.MangasPage{}, err } defer resp.Body.Close() var dto LoadMoreReleasesDTO if err := json.NewDecoder(resp.Body).Decode(&dto); err != nil { return source.MangasPage{}, err } doc, err := goquery.NewDocumentFromReader(strings.NewReader(dto.HTML)) if err != nil { return source.MangasPage{}, err } mangas := s.mangaListFromDoc(doc) return source.MangasPage{Mangas: mangas, HasNextPage: dto.HasNext}, nil } doc, err := s.fetchDoc(s.cfg.BaseURL) if err != nil { return source.MangasPage{}, err } mangas := s.mangaListFromDoc(doc) hasNext := true if s.cfg.LatestUpdatesLoadMorePath != "" { hasNext = doc.Find("#load-more-btn").Length() > 0 } else { hasNext = doc.Find(".page-link[aria-label=Próxima]:not(disabled)").Length() > 0 } return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil } func (s *Source) mangaListFromDoc(doc *goquery.Document) []source.SManga { mangas := make([]source.SManga, 0) doc.Find(".comics-grid a.comic-card-link, div.manga-card-simple").Each(func(_ int, sel *goquery.Selection) { title := sel.Find("h3").Text() img := sel.Find("img") thumb := img.AttrOr("abs:src", "") if thumb == "" { href := sel.AttrOr("abs:href", "") if a := sel.Find("a"); a.Length() > 0 { href = a.AttrOr("abs:href", "") } mangas = append(mangas, source.SManga{ URL: href, Title: strings.TrimSpace(title), ThumbnailURL: thumb, }) } else { href := sel.AttrOr("abs:href", "") if a := sel.Find("a"); a.Length() > 0 { href = a.AttrOr("abs:href", "") } mangas = append(mangas, source.SManga{ URL: href, Title: strings.TrimSpace(title), ThumbnailURL: thumb, }) } }) return mangas } func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { if query == "" { return source.MangasPage{}, nil } u, _ := url.Parse(s.cfg.BaseURL + "/search/live-search/") q := u.Query() q.Set("q", query) u.RawQuery = q.Encode() req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u.String(), nil) if err != nil { return source.MangasPage{}, err } resp, err := s.client.Do(req) if err != nil { return source.MangasPage{}, err } defer resp.Body.Close() var result SearchDTO if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return source.MangasPage{}, err } mangas := make([]source.SManga, len(result.Results)) for i, r := range result.Results { mangas[i] = source.SManga{ URL: r.URL, Title: r.Title, ThumbnailURL: r.Image, Description: r.Description, } } return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil } func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { doc, err := s.fetchDoc(manga.URL) if err != nil { return manga, err } title := doc.Find("h1").Text() thumb := doc.Find(".sidebar-cover-image img").AttrOr("abs:src", "") desc := doc.Find(".manga-description").Text() genre := doc.Find("a.genre-tag").Map(func(_ int, sel *goquery.Selection) string { return sel.Text() }) statusText := doc.Find(".status-tag").Text() status := s.parseStatus(statusText) manga.Title = strings.TrimSpace(title) manga.ThumbnailURL = thumb manga.Description = strings.TrimSpace(desc) manga.Genre = joinStrings(genre, ", ") manga.Status = status manga.Initialized = true return manga, nil } func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { chapters := make([]source.SChapter, 0) page := 1 for { mangaURL := manga.URL if strings.Contains(mangaURL, "?") { mangaURL += "&page=" + fmt.Sprintf("%d", page) } else { mangaURL += "?page=" + fmt.Sprintf("%d", page) } doc, err := s.fetchDoc(mangaURL) if err != nil { break } doc.Find(".chapter-item-list a.chapter-link").Each(func(_ int, sel *goquery.Selection) { name := sel.Find(".chapter-number").Text() dateText := sel.Find(".chapter-date").Text() href := sel.AttrOr("abs:href", "") var dateUpload int64 dateFormat := "02/01/2006" if t, err := time.Parse(dateFormat, strings.TrimSpace(dateText)); err == nil { dateUpload = t.UnixMilli() } chapters = append(chapters, source.SChapter{ URL: href, Name: strings.TrimSpace(name), DateUpload: dateUpload, }) }) if doc.Find(".page-link[aria-label=Próxima]:not(disabled)").Length() == 0 { break } page++ } return chapters, nil } func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { doc, err := s.fetchDoc(chapter.URL) if err != nil { return nil, err } pages := make([]source.Page, 0) doc.Find(".chapter-image-canvas").Each(func(i int, sel *goquery.Selection) { imgURL := sel.AttrOr("data-src-url", "") pages = append(pages, 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 } func (s *Source) fetchDoc(rawURL string) (*goquery.Document, error) { req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil) if err != nil { return nil, err } resp, err := s.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() html, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return goquery.NewDocumentFromReader(strings.NewReader(string(html))) } func joinStrings(ss []string, sep string) string { if len(ss) == 0 { return "" } result := ss[0] for i := 1; i < len(ss); i++ { result += sep + ss[i] } return result } var cfg = struct { PopularMangaPath string }{} func init() { cfg.PopularMangaPath = "/manga/todos/" } var _ source.CatalogueSource = (*Source)(nil)