Files
goyomi/sources/base/comiciviewer/comiciviewer.go
T
2026-05-11 06:48:23 +00:00

280 lines
8.5 KiB
Go
Executable File

// 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 }