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 == "" { 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 }