package app import ( "encoding/json" "errors" "fmt" "net/url" "regexp" "strconv" "strings" ) var rankingScriptTags = regexp.MustCompile(`(?is)]*>`) var rankingJSONLD = regexp.MustCompile(`(?is)]*type=["']application/ld\+json["'][^>]*>(.*?)`) var rankingSourceID = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,100}$`) type hongguoRankingContent struct { Success bool `json:"isSuccess"` Rows []struct { ID string `json:"id"` SeriesID string `json:"seriesId"` Rank int `json:"rank"` Title string `json:"title"` Heat string `json:"heatText"` Score string `json:"scoreText"` Tags []string `json:"tags"` Description string `json:"description"` EpisodeIDs []string `json:"episodeVids"` Cover any `json:"cover"` } `json:"rankList"` Pagination struct { Page int `json:"pageNum"` TotalPages int `json:"totalPages"` } `json:"pagination"` } func parseHongguoRanking(body string, board rankingBoard, page int) (rankingPage, error) { failure := errors.New("红果榜单格式或分页已变化,请稍后重试") loaderKey := "rank_" + board.path + "/page" loader := nestedMap(parseRouterData(body), "loaderData", loaderKey) if mapString(loader, "rankKey") != board.upstreamKey || mapString(loader, "pageNum") != strconv.Itoa(page) { return rankingPage{}, failure } var content hongguoRankingContent if inline := nestedMap(loader, "content"); len(inline) > 0 { raw, err := json.Marshal(inline) if err != nil || json.Unmarshal(raw, &content) != nil { return rankingPage{}, failure } } else if merged, found := parseHongguoMergeLoader(body, loaderKey); found { content = merged } else { for _, tag := range rankingScriptTags.FindAllString(body, -1) { if extractAttr(tag, "data-fn-name") != "r" || extractAttr(tag, "data-script-src") != "modern-run-router-data-fn" { continue } var args []json.RawMessage if json.Unmarshal([]byte(extractAttr(tag, "data-fn-args")), &args) != nil || len(args) != 3 { continue } var route, field string if json.Unmarshal(args[0], &route) != nil || json.Unmarshal(args[1], &field) != nil || route != loaderKey || field != "content" { continue } if json.Unmarshal(args[2], &content) != nil { return rankingPage{}, failure } break } } if !content.Success || content.Rows == nil || content.Pagination.Page != page || content.Pagination.TotalPages < page || content.Pagination.TotalPages > 500 { return rankingPage{}, failure } result := rankingPage{Items: make([]rankingItem, 0, len(content.Rows)), TotalPages: content.Pagination.TotalPages, HasMore: page < content.Pagination.TotalPages, UpdatedText: mapString(loader, "updatedText")} seen := map[string]bool{} previous := (page - 1) * 20 for _, row := range content.Rows { id := firstNonEmpty(row.SeriesID, row.ID) if !hongguoNumericID.MatchString(id) || row.ID != "" && row.SeriesID != "" && row.ID != row.SeriesID || strings.TrimSpace(row.Title) == "" || row.Rank <= previous || row.Rank > page*20 || seen[id] { return rankingPage{}, failure } seen[id], previous = true, row.Rank drama := Drama{ID: providerDramaID(sourceHongguo, id), Source: sourceHongguo, SourceID: id, Title: row.Title, Name: row.Title, Desc: row.Description, Intro: row.Description, Tags: row.Tags, Heat: row.Heat, Score: strings.TrimPrefix(row.Score, "评分"), ChannelName: "红果"} if cover := hongguoCoverAddress(coverPathFromAny(row.Cover)); cover != "" { drama.Cover, drama.CoverURL = cover, cover } if len(row.Tags) > 0 { drama.CategoryName = row.Tags[0] } if len(row.EpisodeIDs) > 0 { drama.TotalEpisode = len(row.EpisodeIDs) } result.Items = append(result.Items, rankingItem{Rank: row.Rank, Drama: drama, Metric: row.Heat}) } if len(result.Items) == 0 && result.HasMore { return rankingPage{}, failure } return result, nil } func parseHuangdouRanking(decoded any, page int) (rankingPage, error) { data := huangdouDataMap(decoded) rows, valid := data["list"].([]any) if !valid { return rankingPage{}, errors.New("黄豆未返回有效榜单") } if len(rows) > 20 { return rankingPage{}, errors.New("黄豆榜单分页格式已变化") } result := rankingPage{Items: make([]rankingItem, 0, len(rows)), HasMore: len(rows) == 20} seen := map[string]bool{} for index, value := range rows { row, valid := value.(map[string]any) id := strings.TrimPrefix(firstNonEmpty(mapString(row, "id"), mapString(row, "drama_id")), "rp_") title := mapString(row, "name", "title", "t") if !valid || !rankingSourceID.MatchString(id) || strings.TrimSpace(title) == "" || seen[id] { return rankingPage{}, errors.New("黄豆榜单包含无效或重复条目") } seen[id] = true remark := firstNonEmpty(mapString(row, "update_label"), mapString(row, "corner")) drama := Drama{VIP: huangdouVIPFlag(row), ID: providerDramaID(sourceHuangdou, id), Source: sourceHuangdou, SourceID: id, Title: title, Name: title, ChannelName: "黄豆", CategoryName: mapString(row, "category"), TotalEpisode: mapString(row, "episode_count"), Remark: remark, ReleaseStatus: releaseStatusFromRemark(remark), Heat: mapString(row, "hot_rate"), Views: normalizeViews(mapString(row, "click")), OnlineDate: providerReleaseDate(mapString(row, "issue_date"))} metric := "" if drama.Heat != "" { metric = drama.Heat + "热度" } result.Items = append(result.Items, rankingItem{Rank: (page-1)*20 + index + 1, Drama: drama, Metric: metric}) } return result, nil } func parseHuangguoRanking(body string, board rankingBoard) (rankingPage, error) { failure := errors.New("黄果未返回有效榜单,可能是页面结构变化") for _, match := range rankingJSONLD.FindAllStringSubmatch(body, -1) { var schema struct { Graph []struct { Type string `json:"@type"` ID string `json:"@id"` Items []struct { Position int `json:"position"` Name string `json:"name"` URL string `json:"url"` } `json:"itemListElement"` } `json:"@graph"` } if json.Unmarshal([]byte(match[1]), &schema) != nil { continue } for _, graph := range schema.Graph { if graph.Type != "ItemList" || !strings.HasSuffix(graph.ID, "/ranks/"+board.path+"/#itemlist") { continue } if len(graph.Items) == 0 || len(graph.Items) > 100 { return rankingPage{}, failure } result := rankingPage{Items: make([]rankingItem, 0, len(graph.Items)), TotalPages: 1} seen, previous := map[string]bool{}, 0 for _, item := range graph.Items { parsed, err := url.Parse(item.URL) if err != nil || !strings.HasPrefix(parsed.Path, "/detail/") { return rankingPage{}, failure } id := strings.TrimSuffix(strings.TrimPrefix(parsed.Path, "/detail/"), "/") if !rankingSourceID.MatchString(id) || strings.TrimSpace(item.Name) == "" || item.Position <= previous || seen[id] { return rankingPage{}, failure } seen[id], previous = true, item.Position drama := Drama{ID: providerDramaID(sourceHuangguoAI, id), Source: sourceHuangguoAI, SourceID: id, Title: item.Name, Name: item.Name} result.Items = append(result.Items, rankingItem{Rank: item.Position, Drama: drama}) } return result, nil } } return rankingPage{}, fmt.Errorf("%s: %w", board.Name, failure) }