Files
metatube-server/provider/fanza/fanza.go
T
2022-12-15 23:13:04 +08:00

546 lines
16 KiB
Go
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 fanza
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"path"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/antchfx/htmlquery"
"github.com/gocolly/colly/v2"
"golang.org/x/net/html"
"github.com/javtube/metatube-sdk-go/common/comparer"
"github.com/javtube/metatube-sdk-go/common/number"
"github.com/javtube/metatube-sdk-go/common/parser"
"github.com/javtube/metatube-sdk-go/model"
"github.com/javtube/metatube-sdk-go/provider"
"github.com/javtube/metatube-sdk-go/provider/internal/imhelper"
"github.com/javtube/metatube-sdk-go/provider/internal/scraper"
)
var (
_ provider.MovieProvider = (*FANZA)(nil)
_ provider.MovieSearcher = (*FANZA)(nil)
)
const (
Name = "FANZA"
Priority = 1000 + 1
)
const (
baseURL = "https://www.dmm.co.jp/"
baseDigitalURL = "https://www.dmm.co.jp/digital/"
baseMonoURL = "https://www.dmm.co.jp/mono/"
searchURL = "https://www.dmm.co.jp/search/=/searchstr=%s/limit=120/sort=date/"
movieDigitalVideoAURL = "https://www.dmm.co.jp/digital/videoa/-/detail/=/cid=%s/"
movieDigitalVideoCURL = "https://www.dmm.co.jp/digital/videoc/-/detail/=/cid=%s/"
movieDigitalAnimeURL = "https://www.dmm.co.jp/digital/anime/-/detail/=/cid=%s/"
movieDigitalNikkatsuURL = "https://www.dmm.co.jp/digital/nikkatsu/-/detail/=/cid=%s/"
movieMonoDVDURL = "https://www.dmm.co.jp/mono/dvd/-/detail/=/cid=%s/"
movieMonoAnimeURL = "https://www.dmm.co.jp/mono/anime/-/detail/=/cid=%s/"
)
type FANZA struct {
*scraper.Scraper
}
func New() *FANZA {
return &FANZA{
Scraper: scraper.NewDefaultScraper(Name, baseURL, Priority,
scraper.WithCookies(baseURL, []*http.Cookie{
{Name: "age_check_done", Value: "1"},
})),
}
}
func (fz *FANZA) NormalizeID(id string) string {
return strings.ToLower(id) /* FANZA uses lowercase ID */
}
func (fz *FANZA) GetMovieInfoByID(id string) (info *model.MovieInfo, err error) {
homepages := []string{
fmt.Sprintf(movieMonoDVDURL, id),
fmt.Sprintf(movieDigitalVideoAURL, id),
fmt.Sprintf(movieDigitalVideoCURL, id),
fmt.Sprintf(movieDigitalAnimeURL, id),
fmt.Sprintf(movieMonoAnimeURL, id),
fmt.Sprintf(movieDigitalNikkatsuURL, id),
}
if regexp.MustCompile(`(?i)[a-z]+00\d{3,}`).MatchString(id) {
// might be digital videoa url, try it first.
homepages[0], homepages[1] = homepages[1], homepages[0]
}
for _, homepage := range homepages {
if info, err = fz.GetMovieInfoByURL(homepage); err == nil && info.Valid() {
return
}
}
return nil, provider.ErrInfoNotFound
}
func (fz *FANZA) ParseIDFromURL(rawURL string) (id string, err error) {
homepage, err := url.Parse(rawURL)
if err != nil {
return
}
if sub := regexp.MustCompile(`/cid=(.*?)/`).
FindStringSubmatch(homepage.Path); len(sub) == 2 {
id = fz.NormalizeID(sub[1])
}
return
}
func (fz *FANZA) GetMovieInfoByURL(rawURL string) (info *model.MovieInfo, err error) {
id, err := fz.ParseIDFromURL(rawURL)
if err != nil {
return
}
info = &model.MovieInfo{
Provider: fz.Name(),
Homepage: rawURL,
Actors: []string{},
PreviewImages: []string{},
Genres: []string{},
}
c := fz.ClonedCollector()
// Homepage
c.OnRequest(func(r *colly.Request) {
info.Homepage = r.URL.String()
})
// Title
c.OnXML(`//*[@id="title"]`, func(e *colly.XMLElement) {
info.Title = strings.TrimSpace(e.Text)
})
// Thumb
c.OnXML(fmt.Sprintf(`//*[@id="package-src-%s"]`, id), func(e *colly.XMLElement) {
info.ThumbURL = e.Request.AbsoluteURL(e.Attr("src"))
})
// Cover
c.OnXML(fmt.Sprintf(`//*[@id="%s"]`, id), func(e *colly.XMLElement) {
info.CoverURL = e.Request.AbsoluteURL(PreviewSrc(e.Attr("href")))
})
// Fields
c.OnXML(`//tr`, func(e *colly.XMLElement) {
switch e.ChildText(`.//td[1]`) {
case "品番:":
info.ID = e.ChildText(`.//td[2]`)
info.Number = ParseNumber(info.ID)
case "シリーズ:":
info.Series = strings.Trim(e.ChildText(`.//td[2]`), "-")
case "メーカー:":
info.Maker = strings.Trim(e.ChildText(`.//td[2]`), "-")
case "レーベル:":
info.Label = strings.Trim(e.ChildText(`.//td[2]`), "-")
case "ジャンル:":
info.Genres = e.ChildTexts(`.//td[2]/a`)
case "名前:":
info.Actors = e.ChildTexts(`.//td[2]`)
case "平均評価:":
info.Score = fz.parseScoreFromURL(e.ChildAttr(`.//td[2]/img`, "src"))
case "収録時間:":
info.Runtime = parser.ParseRuntime(e.ChildText(`.//td[2]`))
case "監督:":
info.Director = strings.Trim(e.ChildText(`.//td[2]`), "-")
case "配信開始日:", "商品発売日:", "発売日:", "貸出開始日:":
if time.Time(info.ReleaseDate).IsZero() {
info.ReleaseDate = parser.ParseDate(e.ChildText(`.//td[2]`))
}
}
})
// Actors (ajax)
hasAPerformer := false
c.OnXML(`//a[@id="a_performer"]`, func(e *colly.XMLElement) {
hasAPerformer = true
})
// Actors (regular)
c.OnXML(`//span[@id="performer"]`, func(e *colly.XMLElement) {
parseActors(e.DOM.(*html.Node), (*[]string)(&info.Actors))
})
// JSON
c.OnXML(`//script[@type="application/ld+json"]`, func(e *colly.XMLElement) {
data := struct {
Name string `json:"name"`
Image string `json:"image"`
Description string `json:"description"`
Sku string `json:"sku"`
SubjectOf struct {
ContentUrl string `json:"contentUrl"`
// EmbedUrl string `json:"embedUrl"`
Genre []string `json:"genre"`
} `json:"subjectOf"`
AggregateRating struct {
RatingValue string `json:"ratingValue"`
} `json:"aggregateRating"`
}{ /* assign default values */
Name: info.Title,
Image: info.ThumbURL,
Description: info.Summary,
Sku: info.ID,
}
if json.Unmarshal([]byte(e.Text), &data) == nil {
info.ID = data.Sku
info.Number = ParseNumber(data.Sku)
info.Title = data.Name
info.Summary = data.Description
info.ThumbURL = e.Request.AbsoluteURL(data.Image)
if len(data.SubjectOf.Genre) > 0 {
info.Genres = data.SubjectOf.Genre
}
if data.AggregateRating.RatingValue != "" {
info.Score = parser.ParseScore(data.AggregateRating.RatingValue)
}
if data.SubjectOf.ContentUrl != "" {
info.PreviewVideoURL = data.SubjectOf.ContentUrl
}
}
})
// Title (fallback)
c.OnXML(`//meta[@property="og:title"]`, func(e *colly.XMLElement) {
if info.Title != "" {
return
}
info.Title = e.Attr("content")
})
// Summary (fallback)
c.OnXML(`//div[@class="mg-b20 lh4"]`, func(e *colly.XMLElement) {
if info.Summary != "" {
return
}
var summary string
if summary = strings.TrimSpace(e.ChildText(`.//p[@class="mg-b20"]`)); summary != "" {
// nop
} else if summary = strings.TrimSpace(e.ChildText(`.//p`)); summary != "" {
// nop
} else {
summary = strings.TrimSpace(e.Text)
}
info.Summary = summary
})
// Summary (incomplete fallback)
c.OnXML(`//meta[@property="og:description"]`, func(e *colly.XMLElement) {
if info.Summary != "" {
return
}
info.Summary = e.Attr("content")
})
// Thumb (fallback)
c.OnXML(`//*[@id="sample-video"]//img`, func(e *colly.XMLElement) {
if info.ThumbURL != "" {
return // ignore if not empty.
}
if !strings.HasPrefix(e.Attr("id"), "package-src") {
return // probably not our img.
}
info.ThumbURL = e.Request.AbsoluteURL(e.Attr("src"))
})
// Cover (fallback)
c.OnXML(`//*[@id="sample-video"]//a[@name="package-image"]`, func(e *colly.XMLElement) {
if info.CoverURL != "" {
return // ignore if not empty.
}
if href := e.Attr("href"); strings.HasSuffix(href, ".jpg") {
info.CoverURL = e.Request.AbsoluteURL(href)
}
})
// Thumb (fallback again)
c.OnXML(`//meta[@property="og:image"]`, func(e *colly.XMLElement) {
if info.ThumbURL != "" {
return // ignore if not empty.
}
info.ThumbURL = e.Request.AbsoluteURL(e.Attr("content"))
})
// Preview Video
c.OnXML(`//*[@id="detail-sample-movie"]/div/a`, func(e *colly.XMLElement) {
d := c.Clone()
d.OnXML(`//iframe`, func(e *colly.XMLElement) {
d.OnResponse(func(r *colly.Response) {
if resp := regexp.MustCompile(`const args = (\{.+});`).FindSubmatch(r.Body); len(resp) == 2 {
data := struct {
Bitrates []struct {
Bitrate int `json:"bitrate"`
Src string `json:"src"`
} `json:"bitrates"`
}{}
if json.Unmarshal(resp[1], &data) == nil && len(data.Bitrates) > 0 {
sort.SliceStable(data.Bitrates, func(i, j int) bool {
return data.Bitrates[i].Bitrate < data.Bitrates[j].Bitrate
})
info.PreviewVideoURL = e.Request.AbsoluteURL(data.Bitrates[len(data.Bitrates)-1].Src)
}
}
})
d.Visit(e.Request.AbsoluteURL(e.Attr("src")))
})
d.Visit(e.Request.AbsoluteURL(regexp.MustCompile(`/(.+)/`).
FindString(e.Attr("onclick"))))
})
// Preview Video (VR)
c.OnXML(`//*[@id="detail-sample-vr-movie"]/div/a`, func(e *colly.XMLElement) {
d := c.Clone()
d.OnResponse(func(r *colly.Response) {
sub := regexp.MustCompile(`var sampleUrl = "(.+?)";`).FindSubmatch(r.Body)
if len(sub) == 2 {
info.PreviewVideoURL = e.Request.AbsoluteURL(string(sub[1]))
}
})
d.Visit(e.Request.AbsoluteURL(regexp.MustCompile(`/(.+)/`).
FindString(e.Attr("onclick"))))
})
// Preview Images
c.OnXML(`//*[@id="sample-image-block"]/a`, func(e *colly.XMLElement) {
info.PreviewImages = append(info.PreviewImages,
e.Request.AbsoluteURL(PreviewSrc(e.ChildAttr(`.//img`, "src"))))
})
// Final (images)
c.OnScraped(func(_ *colly.Response) {
if info.CoverURL == "" {
// try to convert thumb url to cover url.
info.CoverURL = PreviewSrc(info.ThumbURL)
}
})
// Final (big thumb image)
c.OnScraped(func(_ *colly.Response) {
if info.BigThumbURL != "" /* big thumb already exist */ ||
info.ThumbURL == "" /* thumb url is empty */ ||
len(info.PreviewImages) == 0 /* no preview images */ {
return
}
if !strings.Contains(info.Homepage, "/digital/videoa") &&
!strings.Contains(info.Homepage, "/mono/dvd") {
// must be VideoA or DVD videos.
return
}
if imhelper.Similar(info.ThumbURL, info.PreviewImages[0], nil) {
// the first preview image is a big thumb image.
info.BigThumbURL = info.PreviewImages[0]
info.PreviewImages = info.PreviewImages[1:]
return
}
})
// Final (actors)
c.OnScraped(func(r *colly.Response) {
if !hasAPerformer {
return
}
n, innerErr := htmlquery.Parse(bytes.NewReader(r.Body))
if innerErr != nil {
return
}
n = htmlquery.FindOne(n, `//script[contains(text(),"a#a_performer")]/text()`)
if n == nil {
return
}
if ss := regexp.MustCompile(`url:\s*'(.+?)',`).
FindStringSubmatch(n.Data); len(ss) == 2 && strings.TrimSpace(ss[1]) != "" {
d := c.Clone()
d.OnXML(`/` /* root */, func(e *colly.XMLElement) {
var actors []string
parseActors(e.DOM.(*html.Node), &actors)
if len(actors) > 0 {
info.Actors = actors // replace with new actors.
}
})
d.Visit(r.Request.AbsoluteURL(ss[1]))
}
})
err = c.Visit(info.Homepage)
return
}
func (fz *FANZA) NormalizeKeyword(keyword string) string {
if number.IsSpecial(keyword) {
return ""
}
return strings.ToLower(keyword) /* FANZA prefers lowercase */
}
func (fz *FANZA) SearchMovie(keyword string) ([]*model.MovieSearchResult, error) {
if strings.Contains(keyword, "-") {
if results, err := fz.searchMovie(strings.Replace(keyword,
/* FANZA cannot search hyphened number */
"-", "00", 1) +
/* Add a `#` sign to distinguish 001 style number */
"#"); err == nil && len(results) > 0 {
return results, nil
}
}
// fallback to normal dvd search.
return fz.searchMovie(strings.Replace(keyword, "-", "", 1))
}
func (fz *FANZA) searchMovie(keyword string) (results []*model.MovieSearchResult, err error) {
defer func() {
if err == nil && len(results) > 0 {
r := regexp.MustCompile(`(?i)([A-Z]+)0*([1-9]*)`)
x := r.ReplaceAllString(keyword, "${1}${2}")
sort.SliceStable(results, func(i, j int) bool {
a := r.ReplaceAllString(results[i].ID, "${1}${2}")
b := r.ReplaceAllString(results[j].ID, "${1}${2}")
return comparer.Compare(a, x) >= comparer.Compare(b, x)
})
}
}()
c := fz.ClonedCollector()
c.OnXML(`//*[@id="list"]/li`, func(e *colly.XMLElement) {
homepage := e.Request.AbsoluteURL(e.ChildAttr(`.//p[@class="tmb"]/a`, "href"))
if !strings.HasPrefix(homepage, baseDigitalURL) && !strings.HasPrefix(homepage, baseMonoURL) {
return // ignore other contents.
}
id, _ := fz.ParseIDFromURL(homepage) // ignore error.
thumb := e.ChildAttr(`.//p[@class="tmb"]/a/span[1]/img`, "src")
if re := regexp.MustCompile(`(p[a-z]\.)jpg`); re.MatchString(thumb) {
thumb = re.ReplaceAllString(thumb, "ps.jpg")
}
var releaseDate string
rate := e.ChildText(`.//p[@class="rate"]`)
if re := regexp.MustCompile(`(配信日|発売日|貸出日):\s*`); re.MatchString(rate) {
releaseDate = re.ReplaceAllString(rate, "")
rate = "" // reset rate.
}
results = append(results, &model.MovieSearchResult{
ID: id,
Number: ParseNumber(id),
Title: e.ChildAttr(`.//p[@class="tmb"]/a/span[1]/img`, "alt"),
Provider: fz.Name(),
Homepage: homepage,
ThumbURL: e.Request.AbsoluteURL(thumb),
CoverURL: e.Request.AbsoluteURL(PreviewSrc(thumb)),
Score: parser.ParseScore(rate /* float or a dash (-) */),
ReleaseDate: parser.ParseDate(releaseDate /* 発売日:2022/07/21 */),
})
})
err = c.Visit(fmt.Sprintf(searchURL, url.QueryEscape(keyword)))
return
}
func parseActors(n *html.Node, texts *[]string) {
if n.Type == html.TextNode {
// custom trim function.
if text := strings.Trim(strings.TrimSpace(n.Data), "-/"); text != "" {
*texts = append(*texts, text)
}
}
for n := n.FirstChild; n != nil; n = n.NextSibling {
// handle `id="a_performer"` situation.
if n.Data == "a" {
for _, attr := range n.Attr {
if attr.Key == "id" && attr.Val == "a_performer" {
goto next
}
}
}
parseActors(n, texts)
next:
continue
}
}
func (fz *FANZA) parseScoreFromURL(s string) float64 {
u, err := url.Parse(s)
if err != nil {
return 0
}
gif := path.Base(u.Path)
ext := path.Ext(gif)
n := gif[:len(gif)-len(ext)]
score, _ := strconv.ParseFloat(n, 64)
if score > 5.0 {
// Fix scores for mono/anime.
// e.g.: https://review.dmm.com/web/images/pc/45.gif
score = score / 10.0
}
return score
}
// ParseNumber parses FANZA-formatted id to general ID.
func ParseNumber(s string) string {
if ss := regexp.MustCompile(`([A-Z]+)(\d+)`).FindStringSubmatch(strings.ToUpper(s)); len(ss) >= 3 {
n, _ := strconv.Atoi(ss[2])
return fmt.Sprintf("%s-%03d", ss[1], n)
}
return s
}
// PreviewSrc maximize the preview image.
// Ref: https://digstatic.dmm.com/js/digital/preview_jquery.js#652
// JS Code:
// // 画像パスの正規化
// function preview_src(src)
//
// {
// if (src.match(/(p[a-z]\.)jpg/)) {
// return src.replace(RegExp.$1, 'pl.');
// } else if (src.match(/consumer_game/)) {
// return src.replace('js-','-');
// } else if (src.match(/js\-([0-9]+)\.jpg$/)) {
// return src.replace('js-','jp-');
// } else if (src.match(/ts\-([0-9]+)\.jpg$/)) {
// return src.replace('ts-','tl-');
// } else if (src.match(/(\-[0-9]+\.)jpg$/)) {
// return src.replace(RegExp.$1, 'jp' + RegExp.$1);
// } else {
// return src.replace('-','jp-');
// }
// }
func PreviewSrc(s string) string {
if re := regexp.MustCompile(`(p[a-z]\.)jpg`); re.MatchString(s) {
return re.ReplaceAllString(s, "pl.jpg")
} else if re = regexp.MustCompile(`consumer_game`); re.MatchString(s) {
return strings.ReplaceAll(s, "js-", "-")
} else if re = regexp.MustCompile(`js-(\d+)\.jpg$`); re.MatchString(s) {
return strings.ReplaceAll(s, "js-", "jp-")
} else if re = regexp.MustCompile(`ts-(\d+)\.jpg$`); re.MatchString(s) {
return strings.ReplaceAll(s, "ts-", "tl-")
} else if re = regexp.MustCompile(`(-\d+\.)jpg$`); re.MatchString(s) {
return re.ReplaceAllString(s, "jp${1}jpg")
} else {
return strings.ReplaceAll(s, "-", "jp-")
}
}
func init() {
provider.RegisterMovieFactory(Name, New)
}