package app import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "regexp" "strconv" "strings" "time" ) var sortMetadataMetric = regexp.MustCompile(`(?i)^\d+(?:\.\d+)?(?:亿|万|千|w|k|m|b)?\+?(?:次播放|次观看|人看过|热度|播放|观看|次)?\+?$`) func hasSortMetric(value string) bool { return sortMetadataMetric.MatchString(strings.NewReplacer(",", "", ",", "", " ", "", "\t", "").Replace(value)) } func hasCompleteSortMetadata(drama Drama) bool { if providerReleaseDate(drama.OnlineDate) == "" || !hasSortMetric(drama.Views) { return false } switch dramaProvider(drama) { case sourceHongguo, sourceHuangdou: return hasSortMetric(drama.Heat) case sourceHuangguoAI, sourceHuangguoVideo: return true } return false } func providerReleaseDate(value string) string { value = strings.TrimSpace(value) if stamp, err := time.Parse(time.RFC3339Nano, value); err == nil { value = stamp.In(providerChinaTime).Format("2006-01-02") } date := normalizeDate(value) if parsed, err := time.Parse("2006-01-02", date); err != nil || parsed.Year() < 2000 || parsed.Year() > 2100 { return "" } return date } func providerTimestampDate(value string) string { stamp, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) if err != nil || stamp <= 0 { return "" } if stamp > 100_000_000_000 { stamp /= 1000 } date := time.Unix(stamp, 0).In(providerChinaTime) if date.Year() < 2000 || date.Year() > 2100 { return "" } return date.Format("2006-01-02") } func (d *Downloader) fetchDramaSortMetadata(ctx context.Context, drama Drama) (Drama, error) { source, id, ok := splitProviderDramaID(drama.ID) patch := Drama{ID: drama.ID, Source: source, SourceID: id} if !ok { return patch, errors.New("无效的剧集 ID") } needCover := needsHongguoCoverAddress(drama) if hasCompleteSortMetadata(drama) && !huangguoNeedsContentMetadata(drama) && !needCover && !needsHuangdouVIPMetadata(drama) { return patch, nil } switch source { case sourceHongguo: if !hongguoNumericID.MatchString(id) { return patch, errors.New("无效的红果剧集 ID") } var failures []error if providerReleaseDate(drama.OnlineDate) == "" { body, err := d.fetchProviderText(ctx, hongguoBaseURL+"/detail?series_id="+url.QueryEscape(id), hongguoBaseURL+"/") if err == nil { var web Drama web, err = parseHongguoSortDetail(body, id) patch = mergeDramaMetadata(web, patch) } if err != nil { failures = append(failures, err) } } needMetrics := !hasSortMetric(drama.Heat) || !hasSortMetric(drama.Views) if !needMetrics && (!needCover || bestDramaCover(patch) != "") { return patch, errors.Join(failures...) } result, appErr := d.hongguoAppRequest(ctx, http.MethodPost, "/novel/player/video_detail/v1/", nil, map[string]any{"series_id": id}) if appErr == nil { row := nestedMap(result, "data", "video_data") if mapString(row, "series_id_str", "series_id") != id { appErr = errors.New("红果详情返回了其他剧集") } else { patch.Title = mapString(row, "series_title", "series_name") if needMetrics { patch.Heat = hongguoHeat(row) patch.Views = normalizeViews(mapString(row, "series_play_cnt", "play_cnt")) } if needCover { if cover := hongguoCoverAddress(mapString(row, "series_cover", "cover")); cover != "" { patch.Cover, patch.CoverURL = cover, cover } } } } if appErr != nil { failures = append(failures, appErr) } return patch, errors.Join(failures...) case sourceHuangdou: if !rankingSourceID.MatchString(id) { return patch, errors.New("无效的黄豆剧集 ID") } row, err := d.huangdouDetail(ctx, id) if err != nil { return patch, err } patch.VIP = huangdouVIPFlag(row) patch.Title = mapString(row, "name", "title") patch.OnlineDate = providerReleaseDate(mapString(row, "issue_date")) patch.Views = normalizeViews(mapString(row, "click")) patch.Heat = mapString(row, "hot_rate") if !hasSortMetric(patch.Heat) && !hasSortMetric(drama.Heat) { keyword := firstNonEmpty(patch.Title, drama.DisplayTitle()) var decoded any if err := newHuangdouAPIClient(d).call(ctx, "/drama/list", map[string]any{"keywords": keyword, "page": "1", "page_size": "50"}, &decoded); err != nil { return patch, err } matched := false for _, item := range huangdouList(decoded) { if strings.TrimPrefix(mapString(item, "id", "drama_id"), "rp_") == id { matched = true patch.Heat = mapString(item, "hot_rate") break } } if !matched { return patch, errors.New("黄豆热度查询未返回所请求剧集,可稍后重试") } } return patch, nil case sourceHuangguoAI, sourceHuangguoVideo: pageURL, err := huangguoSortDetailURL(source, id) if err != nil { return patch, err } body, err := d.fetchProviderText(ctx, pageURL, providerRefererForURL(pageURL, "")) if err != nil { return patch, err } return parseHuangguoSortDetail(body, pageURL, patch) } return patch, errors.New("该站源暂无资料补齐接口") } func huangguoSortDetailURL(source, id string) (string, error) { if source == sourceHuangguoVideo { parts := strings.Split(id, "/") if len(parts) == 1 { parts = []string{"series", id} } if len(parts) != 2 || (parts[0] != "series" && parts[0] != "video") || !rankingSourceID.MatchString(parts[1]) { return "", errors.New("无效的黄果剧集 ID") } return huangguoVideoBaseURL + "/" + parts[0] + "/" + url.PathEscape(parts[1]), nil } if source != sourceHuangguoAI || !rankingSourceID.MatchString(id) { return "", errors.New("无效的黄果剧集 ID") } return huangguoAIBaseURL + "/detail/" + url.PathEscape(id) + "/", nil } func parseHongguoSortDetail(body, id string) (Drama, error) { row := nestedMap(routerLoaderMap(parseRouterData(body), "detail_page", "detail_"), "seriesDetail") if mapString(row, "series_id", "series_id_str") != id || mapString(row, "series_name", "series_title") == "" { return Drama{}, errors.New("红果网页详情与请求剧集不符") } patch := hongguoDramaFromAny(row, "") patch.Title = mapString(row, "series_name", "series_title") patch.Name = patch.Title patch.OnlineDate = providerTimestampDate(mapString(row, "first_visible_time")) if cover := hongguoCoverAddress(mapString(row, "series_cover", "cover")); cover != "" { patch.Cover, patch.CoverURL = cover, cover } return patch, nil } func parseHuangguoSortDetail(body, pageURL string, patch Drama) (Drama, error) { type entry struct { Type string `json:"@type"` ID string `json:"@id"` URL string `json:"url"` Name string `json:"name"` Published string `json:"datePublished"` Uploaded string `json:"uploadDate"` Image any `json:"image"` Thumbnail any `json:"thumbnailUrl"` } expected, _ := url.Parse(pageURL) matched := false for _, block := range rankingJSONLD.FindAllStringSubmatch(body, -1) { var graph struct { Graph []entry `json:"@graph"` entry } if json.Unmarshal([]byte(block[1]), &graph) != nil { continue } for _, row := range append(graph.Graph, graph.entry) { if row.Type != "WebPage" && row.Type != "VideoObject" && row.Type != "TVSeries" && row.Type != "Movie" { continue } actual, err := url.Parse(firstNonEmpty(row.URL, row.ID)) if err != nil || actual.Host != "" && !strings.EqualFold(actual.Host, expected.Host) && providerSourceForURL(actual.String()) != patch.Source || strings.TrimRight(actual.Path, "/") != strings.TrimRight(expected.Path, "/") || row.Name == "" || patch.Source == sourceHuangguoAI && huangguoTitleNeedsRepair(row.Name, patch.SourceID) { continue } matched = true patch.Title = row.Name if address := firstNonEmpty(providerCoverAddress(row.Image, pageURL), providerCoverAddress(row.Thumbnail, pageURL)); address != "" { patch.Cover, patch.CoverURL = address, address } if date := providerReleaseDate(firstNonEmpty(row.Uploaded, row.Published)); date != "" && (patch.OnlineDate == "" || row.Type == "VideoObject") { patch.OnlineDate = date } } } if !matched { return patch, fmt.Errorf("黄果详情没有返回所请求剧集的元数据") } if bestDramaCover(patch) == "" { for _, tag := range coverMetaTag.FindAllString(body, -1) { if strings.ToLower(extractAttr(tag, "property", "name")) == "og:image" { if address := providerCoverAddress(extractAttr(tag, "content"), pageURL); address != "" { patch.Cover, patch.CoverURL = address, address break } } } } markup := huangguoNonContent.ReplaceAllString(body, "") metaBlock := huangguoClassBlock(markup, "hg-web-detail__meta") meta := cleanText(metaBlock) patch.Views = normalizeViews(firstMatchText(reViewsText, meta)) if patch.OnlineDate == "" && strings.Contains(meta, "上线") { patch.OnlineDate = providerReleaseDate(firstMatchText(reDateText, meta)) } if patch.Source == sourceHuangguoAI { episode := firstNonEmpty(extractAttr(metaBlock, "data-ep-base"), meta) if count := episodeIndex(episode, 0); count > 0 { patch.TotalEpisode, patch.EpisodeCount = count, count } } return patch, nil }