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

231 lines
7.5 KiB
Go
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package mmlook implements the MMLook (漫漫看) Chinese manga base.
// GET {desktopUrl}/rank/1 for popular; JS eval+decrypt for pages; CF-protected.
package mmlook
import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
"github.com/PuerkitoBio/goquery"
"goyomi/internal/httpclient"
"goyomi/internal/source"
"goyomi/sources/base/util"
)
type Config struct {
Name string
BaseURL string
DesktopURL string // desktop variant URL (may differ from BaseURL)
Lang string
UseLegacyURL bool
}
type Source struct {
cfg Config
client *httpclient.Client
id int64
}
func New(cfg Config) *Source {
if cfg.DesktopURL == "" {
cfg.DesktopURL = cfg.BaseURL
}
if cfg.Lang == "" {
cfg.Lang = "zh"
}
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) desktop() string { return strings.TrimRight(s.cfg.DesktopURL, "/") }
func (s *Source) mobile() 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("mmlook: HTTP %d", resp.StatusCode)
}
return goquery.NewDocumentFromReader(resp.Body)
}
func (s *Source) mangaURL(id string) string {
id = strings.Trim(id, "/")
if s.cfg.UseLegacyURL {
return fmt.Sprintf("http://%s/%s/", strings.TrimPrefix(strings.TrimPrefix(s.mobile(), "https://"), "http://"), id)
}
return fmt.Sprintf("%s/%s/", s.mobile(), id)
}
func (s *Source) GetPopularManga(page int) (source.MangasPage, error) {
doc, err := s.get(context.Background(), fmt.Sprintf("%s/rank/1", s.desktop()))
if err != nil {
return source.MangasPage{}, err
}
var mangas []source.SManga
doc.Find(".book-list li, .comics-list li, .rank-list li").Each(func(_ int, el *goquery.Selection) {
m := source.SManga{}
el.Find("a").First().Each(func(_ int, a *goquery.Selection) {
if href, ok := a.Attr("href"); ok {
m.URL = href
}
})
el.Find("img").First().Each(func(_ int, img *goquery.Selection) {
m.ThumbnailURL = imgAttr(img, s.cfg.BaseURL)
})
el.Find(".title, .name, h3, h4").First().Each(func(_ int, e *goquery.Selection) {
m.Title = strings.TrimSpace(e.Text())
})
if m.URL != "" {
mangas = append(mangas, m)
}
})
return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil
}
func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) {
return s.GetPopularManga(page)
}
func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
doc, err := s.get(context.Background(), fmt.Sprintf("%s/search?q=%s", s.mobile(), query))
if err != nil {
return source.MangasPage{}, err
}
var mangas []source.SManga
doc.Find(".book-list li, .search-list li, .comics-list li").Each(func(_ int, el *goquery.Selection) {
m := source.SManga{}
el.Find("a").First().Each(func(_ int, a *goquery.Selection) {
if href, ok := a.Attr("href"); ok {
m.URL = href
}
})
el.Find("img").First().Each(func(_ int, img *goquery.Selection) {
m.ThumbnailURL = imgAttr(img, s.cfg.BaseURL)
})
el.Find(".title, .name, h3").First().Each(func(_ int, e *goquery.Selection) {
m.Title = strings.TrimSpace(e.Text())
})
if m.URL != "" {
mangas = append(mangas, m)
}
})
return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil
}
func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) {
id := strings.Trim(util.SlugFromURL(manga.URL), "/")
doc, err := s.get(context.Background(), fmt.Sprintf("%s/%s/", s.desktop(), id))
if err != nil {
return manga, err
}
result := source.SManga{URL: manga.URL}
comicInfo := doc.Find(".comicInfo, .comic-info, #comicInfo")
if comicInfo.Length() == 0 {
comicInfo = doc.Find("body")
}
result.Title = strings.TrimSpace(comicInfo.Find("h1").First().Text())
if result.Title == "" {
result.Title = manga.Title
}
result.ThumbnailURL = imgAttr(comicInfo.Find("img").First(), s.cfg.BaseURL)
comicInfo.Find(".detinfo span, .info span").Each(func(_ int, el *goquery.Selection) {
text := el.Text()
switch {
case strings.HasPrefix(text, "作 者:") || strings.HasPrefix(text, "作者:"):
result.Author = strings.TrimSpace(text[strings.Index(text, "")+3:])
case strings.HasPrefix(text, "标 签:") || strings.HasPrefix(text, "标签:"):
result.Genre = strings.ReplaceAll(strings.TrimSpace(text[strings.Index(text, "")+3:]), " ", ", ")
case strings.HasPrefix(text, "状 态:") || strings.HasPrefix(text, "状态:"):
result.Status = util.StatusFromString(text)
}
})
result.Description = strings.TrimSpace(comicInfo.Find(".content, .intro, .synopsis").Text())
return result, nil
}
func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) {
id := strings.Trim(util.SlugFromURL(manga.URL), "/")
doc, err := s.get(context.Background(), fmt.Sprintf("%s/%s/", s.desktop(), id))
if err != nil {
return nil, err
}
var chapters []source.SChapter
doc.Find(".chapter-list li a, #chapter-list li a, .chapter a").Each(func(_ int, a *goquery.Selection) {
ch := source.SChapter{Name: strings.TrimSpace(a.Text())}
ch.URL, _ = a.Attr("href")
if ch.URL != "" {
chapters = append(chapters, ch)
}
})
return chapters, nil
}
// evalScriptRe extracts a packed/obfuscated eval script
var evalScriptRe = regexp.MustCompile(`eval\(function\(p,a,c,k,e,(?:d|r)\).*?\)\)`)
// imageURLsRe extracts image URLs from unpacked content
var imageURLsRe = regexp.MustCompile(`https?://[^\s"']+\.(?:jpg|jpeg|png|webp)[^\s"']*`)
func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) {
chURL := util.AbsURL(s.cfg.BaseURL, chapter.URL)
doc, err := s.get(context.Background(), chURL)
if err != nil {
return nil, err
}
// try direct img tags first
var pages []source.Page
doc.Find(".readerArea img, .reading-content img, #chapter-images img").Each(func(i int, img *goquery.Selection) {
if u := imgAttr(img, s.cfg.BaseURL); u != "" {
pages = append(pages, source.Page{Index: i, ImageURL: u})
}
})
if len(pages) > 0 {
return pages, nil
}
// extract image URLs from scripts (packed JS decryption not fully implemented)
doc.Find("script").Each(func(_ int, el *goquery.Selection) {
script := el.Text()
if !strings.Contains(script, "eval") && !strings.Contains(script, "image") {
return
}
matches := imageURLsRe.FindAllString(script, -1)
for _, u := range matches {
pages = append(pages, source.Page{Index: len(pages), ImageURL: u})
}
})
if len(pages) == 0 {
return nil, fmt.Errorf("mmlook: could not extract page images (packed JS decryption not implemented)")
}
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 imgAttr(img *goquery.Selection, baseURL string) string {
for _, attr := range []string{"data-lazy-src", "data-src", "data-cfsrc", "src"} {
if v, ok := img.Attr(attr); ok && v != "" && !strings.HasPrefix(v, "data:") {
return util.AbsURL(baseURL, v)
}
}
return ""
}