Files
djk/internal/app/provider_hongguo.go
T

361 lines
12 KiB
Go

package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"regexp"
"slices"
"sort"
"strconv"
"strings"
"time"
)
const hongguoBaseURL = "https://hongguoduanju.com"
func (d *Downloader) fetchHongguoDramas(ctx context.Context) ([]Drama, error) {
initialized := hongguoCatalogInitialized(d.hongguoCatalogSnapshot())
dramas, appErr := d.fetchHongguoAppCatalog(ctx)
if len(dramas) > 0 || appErr == nil && initialized {
return dramas, appErr
}
if ctx.Err() != nil {
return dramas, ctx.Err()
}
webDramas, webErr := d.fetchHongguoWebDramas(ctx)
if len(webDramas) > 0 && webErr == nil {
return webDramas, nil
}
return webDramas, errors.Join(appErr, webErr)
}
func (d *Downloader) fetchHongguoWebDramas(ctx context.Context) ([]Drama, error) {
routes := []struct {
path string
name string
}{
{path: "real-drama", name: "真人剧"},
{path: "comic-drama", name: "漫剧"},
{path: "ai-drama", name: "AI剧"},
{path: "comic", name: "动漫"},
}
type routeResult struct {
dramas []Drama
err error
}
results := make(chan routeResult, len(routes))
for _, route := range routes {
route := route
go func() {
dramas, err := d.fetchHongguoRoute(ctx, route.path, route.name)
results <- routeResult{dramas: dramas, err: err}
}()
}
seen := map[string]bool{}
var out []Drama
var failures []error
for range routes {
result := <-results
if result.err != nil {
failures = append(failures, result.err)
}
for _, drama := range result.dramas {
if drama.ID != "" && !seen[drama.ID] {
seen[drama.ID] = true
out = append(out, drama)
}
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].DisplayTitle() < out[j].DisplayTitle() })
return out, errors.Join(failures...)
}
func (d *Downloader) fetchHongguoRoute(ctx context.Context, route, category string) ([]Drama, error) {
pages := d.cfg.MaxPagesPerSort
if pages < 1 {
pages = 1
}
var dramas []Drama
seen := map[string]bool{}
for page := 1; page <= pages; page++ {
items, totalPages, err := d.fetchHongguoCategoryPage(ctx, route+"?page="+strconv.Itoa(page), category)
if err != nil {
return dramas, fmt.Errorf("%s: %w", category, err)
}
if totalPages > pages {
pages = totalPages
if pages > 500 {
pages = 500
}
}
reportLibraryProgress(ctx, sourceHongguo, items, nil, false)
added := 0
for _, drama := range items {
if !seen[drama.ID] {
seen[drama.ID] = true
dramas = append(dramas, drama)
added++
}
}
if added == 0 || len(items) < 24 {
break
}
}
return dramas, nil
}
func (d *Downloader) fetchHongguoCategory(ctx context.Context, route, category string) ([]Drama, error) {
items, _, err := d.fetchHongguoCategoryPage(ctx, route, category)
return items, err
}
func (d *Downloader) fetchHongguoCategoryPage(ctx context.Context, route, category string) ([]Drama, int, error) {
body, err := d.fetchProviderText(ctx, hongguoBaseURL+"/category/"+route, hongguoBaseURL+"/")
if err != nil {
return nil, 0, err
}
data := parseRouterData(body)
page := routerLoaderMap(data, "category_page", "category_$")
if len(page) == 0 || page["isSuccess"] == false {
return nil, 0, errors.New("红果分类数据不可用,可能是页面结构或访问权限变化")
}
items := anyList(page["recommendList"])
out := make([]Drama, 0, len(items))
for _, item := range items {
if dr := hongguoDramaFromAny(item, category); dr.ID != "" {
out = append(out, dr)
}
}
pages, _ := strconv.Atoi(mapString(nestedMap(page, "pagination"), "totalPages"))
return out, pages, nil
}
func (d *Downloader) fetchHongguoChapters(ctx context.Context, sourceID string) (string, []Chapter, error) {
sourceID = strings.TrimPrefix(strings.TrimSpace(sourceID), "hg-series-v1:")
if sourceID == "" {
return "", nil, fmt.Errorf("empty hongguo sourceID")
}
entry, appErr := d.hongguoAppDetail(ctx, sourceID)
if appErr == nil {
return entry.Drama.DisplayTitle(), append([]Chapter(nil), entry.Chapters...), nil
}
if ctx.Err() != nil {
return "", nil, ctx.Err()
}
title, chapters, webErr := d.fetchHongguoWebChapters(ctx, sourceID)
if webErr == nil {
return title, chapters, nil
}
return "", nil, fmt.Errorf("App 详情失败: %v;网页详情失败: %w", publicError(appErr), webErr)
}
func (d *Downloader) fetchHongguoWebChapters(ctx context.Context, sourceID string) (string, []Chapter, error) {
body, err := d.fetchProviderText(ctx, hongguoBaseURL+"/detail?series_id="+url.QueryEscape(sourceID), hongguoBaseURL+"/")
if err != nil {
return "", nil, err
}
data := parseRouterData(body)
page := routerLoaderMap(data, "detail_page", "detail_")
detail, _ := page["seriesDetail"].(map[string]any)
if len(detail) == 0 {
return "", nil, fmt.Errorf("hongguo detail is empty")
}
title := firstNonEmpty(mapString(detail, "series_name", "series_title", "name"), sourceID)
vids := anyList(detail["vid_list"])
chapters := make([]Chapter, 0, len(vids))
for i, v := range vids {
vid := strings.TrimSpace(fmt.Sprint(v))
if vid == "" || vid == "<nil>" {
continue
}
idx := i + 1
chapters = append(chapters, Chapter{ID: providerChapterID(sourceHongguo, sourceID, vid), Source: sourceHongguo, Title: fmt.Sprintf("第%d集", idx), VideoURL: "hongguo-cenc://" + vid, CurrentEpisode: rawEpisode(idx)})
}
if len(chapters) == 0 {
return title, nil, errors.New("红果详情没有返回剧集 ID")
}
return title, chapters, nil
}
func parseRouterData(raw string) map[string]any {
idx := regexp.MustCompile(`(?s)(?:window\.)?_ROUTER_DATA\s*=\s*`).FindStringIndex(raw)
if idx == nil {
return nil
}
var data map[string]any
dec := json.NewDecoder(strings.NewReader(raw[idx[1]:]))
dec.UseNumber()
if err := dec.Decode(&data); err != nil {
return nil
}
return data
}
func (d *Downloader) resolveHongguoMedia(ctx context.Context, task Task) (providerMedia, error) {
source, seriesID, ok := splitProviderDramaID(task.DramaID)
if !ok || source != sourceHongguo {
return providerMedia{}, fmt.Errorf("红果章节缺少剧 ID,请刷新剧库后重新加入队列")
}
videoID := strings.TrimPrefix(task.Chapter.VideoURL, "hongguo-cenc://")
if !hongguoNumericID.MatchString(videoID) || !hongguoNumericID.MatchString(seriesID) {
return providerMedia{}, fmt.Errorf("红果章节 ID 无效,请重新获取章节")
}
media, nativeErr := d.resolveHongguoAppMedia(ctx, videoID)
if nativeErr == nil {
return media, nil
}
if err := ctx.Err(); err != nil {
return providerMedia{}, err
}
media, pageErr := d.resolveHongguoWebMedia(ctx, seriesID, videoID)
if pageErr == nil {
return media, nil
}
if err := ctx.Err(); err != nil {
return providerMedia{}, err
}
media, apiErr := d.resolveHongguoPlaybackAPI(ctx, seriesID, videoID)
if apiErr == nil {
return media, nil
}
return providerMedia{}, fmt.Errorf("红果 App 取流失败:%v;网页取流失败:%v;备用取流失败:%w", publicError(nativeErr), publicError(pageErr), apiErr)
}
func (d *Downloader) resolveHongguoWebMedia(ctx context.Context, seriesID, videoID string) (providerMedia, error) {
pageURL := hongguoBaseURL + "/player/" + url.PathEscape(seriesID) + "/" + url.PathEscape(videoID)
body, err := d.fetchProviderText(ctx, pageURL, hongguoBaseURL+"/")
if err != nil {
return providerMedia{}, err
}
page := routerLoaderMap(parseRouterData(body), "player_", "player_page")
if mapString(page, "vid") != videoID || mapString(page, "series_id") != seriesID {
return providerMedia{}, fmt.Errorf("红果未返回所请求的剧集,可能仅允许网页试看;请在站点确认该集的访问权限")
}
info, _ := page["video_player_info"].(map[string]any)
mediaURL := mapString(info, "main_url")
if !isProviderHTTPMediaURL(mediaURL) {
return providerMedia{}, fmt.Errorf("红果该集未提供公开播放地址,可能需要登录或 App 授权;不会将试看集冒充该集下载")
}
duration, _ := strconv.ParseFloat(mapString(info, "duration"), 64)
return providerMedia{URL: mediaURL, Referer: d.providerBaseURL(sourceHongguo) + "/", Duration: time.Duration(duration * float64(time.Second))}, nil
}
func hongguoCoverAddress(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || len(value) > 8192 {
continue
}
parsed, err := url.Parse(value)
if err == nil && validImageURL(parsed) {
return value
}
}
return ""
}
func hongguoDramaFromAny(v any, category string) Drama {
m, ok := v.(map[string]any)
if !ok {
return Drama{}
}
vd, _ := m["video_data"].(map[string]any)
if len(vd) == 0 {
vd = m
}
sourceID := firstNonEmpty(mapString(vd, "series_id_str", "series_id"), mapString(m, "series_id_str", "series_id"), mapString(vd, "keyword"), mapString(m, "keyword"))
if !hongguoNumericID.MatchString(sourceID) {
return Drama{}
}
title := firstNonEmpty(mapString(vd, "series_title", "series_name", "title"), mapString(m, "series_name", "name"), sourceID)
cover := firstNonEmpty(mapString(vd, "series_cover", "cover"), mapString(m, "series_cover"))
intro := firstNonEmpty(mapString(vd, "series_intro", "video_desc"), mapString(m, "series_intro"))
count := firstNonEmpty(mapString(vd, "episode_cnt"), mapString(m, "episode_cnt"))
remark := firstNonEmpty(mapString(vd, "episode_right_text"), mapString(m, "episode_right_text"))
releaseStatus := releaseStatusFromRemark(remark)
if mapString(vd, "series_status") == "1" {
releaseStatus = "finished"
} else if mapString(vd, "series_status") == "0" {
releaseStatus = "ongoing"
}
if remark == "" && count != "" {
remark = "共" + count + "集"
}
tags := mapStringSlice(vd, "tags")
for _, value := range anyList(vd["category_list"]) {
item, _ := value.(map[string]any)
if name := mapString(item, "name"); name != "" && !slices.Contains(tags, name) {
tags = append(tags, name)
}
}
var categories []struct {
Name string `json:"name"`
}
if json.Unmarshal([]byte(mapString(vd, "category_schema")), &categories) == nil {
for _, item := range categories {
if item.Name != "" && !slices.Contains(tags, item.Name) {
tags = append(tags, item.Name)
}
}
}
genre := mapString(vd, "category_name", "categoryName", "category")
if genre == "" && len(tags) > 0 {
genre = tags[0]
}
return Drama{ID: providerDramaID(sourceHongguo, sourceID), Source: sourceHongguo, SourceID: sourceID, Title: title, Name: title, Desc: intro, Intro: intro, Cover: cover, CoverURL: cover, CategoryName: firstNonEmpty(genre, category), ChannelName: "红果", Remark: remark, TotalEpisode: count, EpisodeCount: count, Tags: tags, ReleaseStatus: releaseStatus, Score: mapString(vd, "score"), Views: mapString(vd, "series_play_cnt", "play_cnt"), Heat: hongguoHeat(vd), OnlineDate: hongguoOnlineDate(vd, time.Now())}
}
func nestedMap(v any, keys ...string) map[string]any {
cur, _ := v.(map[string]any)
for _, key := range keys {
if cur == nil {
return nil
}
cur, _ = cur[key].(map[string]any)
}
return cur
}
func routerLoaderMap(data map[string]any, names ...string) map[string]any {
loader, _ := data["loaderData"].(map[string]any)
for _, name := range names {
if page, _ := loader[name].(map[string]any); len(page) > 0 {
return page
}
}
for key, value := range loader {
for _, name := range names {
if strings.TrimSuffix(name, "$") != "" && strings.HasPrefix(key, strings.TrimSuffix(name, "$")) {
if page, _ := value.(map[string]any); len(page) > 0 {
return page
}
}
}
}
return nil
}
func anyList(v any) []any {
switch x := v.(type) {
case []any:
return x
case map[string]any:
for _, key := range []string{"list", "items", "data"} {
if out := anyList(x[key]); len(out) > 0 {
return out
}
}
}
return nil
}
func cloneValues(v url.Values) url.Values {
out := url.Values{}
for key, values := range v {
out[key] = append([]string(nil), values...)
}
return out
}