// Package comiciviewer implements the ComiciViewer Japanese manga base. // GET {base}/ranking/manga for popular; API viewer for pages (requires login). package comiciviewer import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" "github.com/PuerkitoBio/goquery" "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) get(ctx context.Context, rawURL string) (*goquery.Document, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, err } req.Header.Set("Referer", s.cfg.BaseURL+"/") resp, err := s.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("comiciviewer: HTTP %d", resp.StatusCode) } return goquery.NewDocumentFromReader(resp.Body) } func srcsetToURL(s string) string { // "//cdn.example.com/img.jpg 1x" → "https://cdn.example.com/img.jpg" if idx := strings.Index(s, " "); idx >= 0 { s = s[:idx] } if strings.HasPrefix(s, "//") { return "https:" + s } return s } func mangaFromElement(el *goquery.Selection) source.SManga { m := source.SManga{} el.Find("a").First().Each(func(_ int, a *goquery.Selection) { m.URL, _ = a.Attr("href") }) m.Title = strings.TrimSpace(el.Find(".title-text").Text()) el.Find("source").First().Each(func(_ int, e *goquery.Selection) { if v, ok := e.Attr("data-srcset"); ok { m.ThumbnailURL = srcsetToURL(v) } }) return m } func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { doc, err := s.get(context.Background(), s.base()+"/ranking/manga") if err != nil { return source.MangasPage{}, err } var mangas []source.SManga doc.Find("div.ranking-box-vertical, div.ranking-box-vertical-top3").Each(func(_ int, el *goquery.Selection) { m := mangaFromElement(el) if m.URL != "" { mangas = append(mangas, m) } }) return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil } func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { doc, err := s.get(context.Background(), s.base()+"/category/manga") if err != nil { return source.MangasPage{}, err } var mangas []source.SManga doc.Find("div.category-box-vertical").Each(func(_ int, el *goquery.Selection) { m := mangaFromElement(el) if m.URL != "" { mangas = append(mangas, m) } }) hasNext := doc.Find("li.mode-paging-active + li > a").Length() > 0 return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil } func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { doc, err := s.get(context.Background(), fmt.Sprintf("%s/search?keyword=%s", s.base(), query)) if err != nil { return source.MangasPage{}, err } var mangas []source.SManga doc.Find("div.manga-store-item").Each(func(_ int, el *goquery.Selection) { m := source.SManga{} el.Find("a.c-ms-clk-article").First().Each(func(_ int, a *goquery.Selection) { m.URL, _ = a.Attr("href") }) m.Title = strings.TrimSpace(el.Find("h2.manga-title").Text()) el.Find("source").First().Each(func(_ int, e *goquery.Selection) { if v, ok := e.Attr("data-srcset"); ok { m.ThumbnailURL = srcsetToURL(v) } }) if m.URL != "" { mangas = append(mangas, m) } }) hasNext := doc.Find("li.mode-paging-active + li > a").Length() > 0 return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil } func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, manga.URL)) if err != nil { return manga, err } result := source.SManga{URL: manga.URL} // title: last span in h1.series-h-title doc.Find("h1.series-h-title span").Last().Each(func(_ int, el *goquery.Selection) { result.Title = strings.TrimSpace(el.Text()) }) if result.Title == "" { result.Title = manga.Title } result.Author = strings.TrimSpace(doc.Find("div.series-h-author").Text()) result.Description = strings.TrimSpace(doc.Find("div.series-h-description, p.series-description").Text()) var genres []string doc.Find("a.series-h-tag-link").Each(func(_ int, el *goquery.Selection) { t := strings.TrimPrefix(strings.TrimSpace(el.Text()), "#") if t != "" { genres = append(genres, t) } }) result.Genre = strings.Join(genres, ", ") doc.Find("div.series-h-img source").First().Each(func(_ int, e *goquery.Selection) { if v, ok := e.Attr("data-srcset"); ok { result.ThumbnailURL = srcsetToURL(v) } }) return result, nil } func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { chURL := fmt.Sprintf("%s%s/list?s=1", s.base(), manga.URL) doc, err := s.get(context.Background(), chURL) if err != nil { return nil, err } var chapters []source.SChapter doc.Find("li.episode-item, .chapter-list li").Each(func(_ int, el *goquery.Selection) { ch := source.SChapter{} el.Find("a").First().Each(func(_ int, a *goquery.Selection) { ch.URL, _ = a.Attr("href") ch.Name = strings.TrimSpace(a.Find(".episode-title, .title-text").Text()) if ch.Name == "" { ch.Name = strings.TrimSpace(a.Text()) } }) el.Find("time").First().Each(func(_ int, e *goquery.Selection) { dt := e.AttrOr("datetime", e.Text()) ch.DateUpload = util.ParseAbsoluteDate(dt, "2006-01-02 15:04:05") }) if ch.URL != "" { chapters = append(chapters, ch) } }) return chapters, nil } func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { doc, err := s.get(context.Background(), util.AbsURL(s.cfg.BaseURL, chapter.URL)) if err != nil { return nil, err } viewer := doc.Find("#comici-viewer") if viewer.Length() == 0 { return nil, fmt.Errorf("comiciviewer: WebView login required to read this chapter") } viewerID := viewer.AttrOr("comici-viewer-id", "") memberJWT := viewer.AttrOr("data-member-jwt", "") if viewerID == "" { return nil, fmt.Errorf("comiciviewer: could not find viewer ID") } // Step 1: get total pages apiURL := fmt.Sprintf("%s/book/contentsInfo?comici-viewer-id=%s&user-id=%s&page-from=0&page-to=1", s.base(), viewerID, memberJWT) pages, err := s.fetchViewerPages(apiURL, viewerID, memberJWT) if err != nil { return nil, err } return pages, nil } type viewerResponse struct { TotalPages int `json:"totalPages"` Result []struct { ImageURL string `json:"imageUrl"` Sort int `json:"sort"` Scramble string `json:"scramble"` } `json:"result"` } func (s *Source) fetchViewerPages(initialURL, viewerID, jwt string) ([]source.Page, error) { req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, initialURL, nil) if err != nil { return nil, err } req.Header.Set("Referer", s.cfg.BaseURL+"/") resp, err := s.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var vr viewerResponse if err := json.Unmarshal(body, &vr); err != nil { return nil, err } if vr.TotalPages == 0 { return nil, fmt.Errorf("comiciviewer: no pages found") } // fetch all pages allURL := fmt.Sprintf("%s/book/contentsInfo?comici-viewer-id=%s&user-id=%s&page-from=0&page-to=%d", s.base(), viewerID, jwt, vr.TotalPages) req2, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, allURL, nil) req2.Header.Set("Referer", s.cfg.BaseURL+"/") resp2, err := s.client.Do(req2) if err != nil { return nil, err } defer resp2.Body.Close() body2, _ := io.ReadAll(resp2.Body) var vr2 viewerResponse if err := json.Unmarshal(body2, &vr2); err != nil { return nil, err } pages := make([]source.Page, len(vr2.Result)) for i, p := range vr2.Result { imgURL := p.ImageURL if p.Scramble != "" { imgURL += "#" + p.Scramble } pages[i] = source.Page{Index: p.Sort, 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 }