280 lines
8.5 KiB
Go
Executable File
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 }
|