初始提交:短剧库源码(含转码优化 ultrafast)
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user