Files
djk/internal/app/provider_huangguo.go
T

1701 lines
50 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
const (
sourceHuangguoAI = "huangguoai"
sourceHuangguoVideo = "huangguo-video"
sourceHuangdou = "huangdou"
sourceHongguo = "hongguo"
providerMaxBodyBytes = 20 * 1024 * 1024
providerTimeout = 12 * time.Second
)
var (
huangguoAIBaseURL = "https://huangguoai.com"
huangguoAIBaseMirrors = []string{"https://huangguoai.com", "https://ttvoij.ediayikma.cc", "https://thu.ediayikma.cc", "https://pku.ediayikma.cc", "https://fdu.ediayikma.cc", "https://thu.agdkczeyx.cc"}
huangguoVideoBaseURL = "https://huangguo.video"
huangguoAISlugs = []string{"recommend", "newest", "ai-duanju", "ai-manju", "ai-huanlian", "ai-mogai", "ranks/hot"}
huangguoAITypeNames = map[string]string{"recommend": "精选推荐", "newest": "最近上新", "ai-duanju": "AI成人短剧", "ai-manju": "AI成人漫剧", "ai-huanlian": "AI换脸", "ai-mogai": "AI魔改", "ranks/hot": "排行榜"}
reDetailHref = regexp.MustCompile(`(?is)<a\b[^>]*href=["']([^"']*/detail/([^"'/?#]+)[^"']*)["'][^>]*>.*?</a>`)
reDramaCardStart = regexp.MustCompile(`(?is)<div\b[^>]*class=["'][^"']*\bhg-drama-card\b[^"']*["'][^>]*>`)
reVideoCard = regexp.MustCompile(`(?is)<article\b[^>]*class=["'][^"']*\bvideo-card\b[^"']*["'][^>]*>.*?</article>`)
reAnyArticle = regexp.MustCompile(`(?is)<article\b[^>]*>.*?</article>`)
reHuangguoLink = regexp.MustCompile(`(?is)<a\b[^>]*href=["']([^"']*/(?:series|video)/([^"'/?#]+)[^"']*)["'][^>]*>.*?</a>`)
reAIEpisodeLink = regexp.MustCompile(`(?is)<a\b[^>]*href=["']([^"']*/video/[^"']+)["'][^>]*>.*?</a>`)
reTitleTag = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
reH1Tag = regexp.MustCompile(`(?is)<h1[^>]*>(.*?)</h1>`)
reDescMeta = regexp.MustCompile(`(?is)<meta\b[^>]*(?:name|property)=["'](?:description|og:description)["'][^>]*content=["']([^"']+)["'][^>]*>`)
reEpisodeNumber = regexp.MustCompile(`(?i)(?:第\s*0*(\d+)\s*(?:集|话|期)|(?:更新至|共|全)\s*0*(\d+)\s*(?:集|话|期)|(?:episode|ep)\s*#?\s*0*(\d+))`)
reResolution = regexp.MustCompile(`(?i)RESOLUTION\s*=\s*(\d+)x(\d+)`)
reBandwidth = regexp.MustCompile(`(?i)(?:AVERAGE-)?BANDWIDTH\s*=\s*(\d+)`)
reDataHLS = regexp.MustCompile(`(?is)data-hls=["']([^"']+)["']`)
reDataPlaySrc = regexp.MustCompile(`(?is)data-play-src=["']([^"']+)["']`)
reMediaFieldValue = regexp.MustCompile(`(?is)["']?(?:videoSrc|videoUrl|playUrl|src|url)["']?\s*[:=]\s*["']([^"']+\.(?:m3u8|mp4)(?:[^"']*)?)["']`)
reDateText = regexp.MustCompile(`\d{4}-\d{1,2}-\d{1,2}`)
reViewsText = regexp.MustCompile(`(?i)[0-9]+(?:\.[0-9]+)?\s*[w万]?\s*次播放`)
reScoreText = regexp.MustCompile(`[0-9]+(?:\.[0-9]+)?\s*分`)
)
type providerEpisode struct {
Key string
Title string
URL string
Index int
HLS string
}
func providerDramaID(source, sourceID string) string {
return source + ":" + strings.TrimSpace(sourceID)
}
func providerChapterID(source, sourceID, chapterKey string) string {
return source + ":" + strings.TrimSpace(sourceID) + ":" + strings.TrimSpace(chapterKey)
}
func splitProviderDramaID(id string) (source, sourceID string, ok bool) {
source, sourceID, ok = strings.Cut(strings.TrimSpace(id), ":")
source = canonicalProviderSource(source)
if !ok || strings.TrimSpace(sourceID) == "" || !isHuangguoProviderSource(source) {
return "", "", false
}
return source, strings.TrimSpace(sourceID), true
}
func isHuangguoProviderSource(source string) bool {
switch canonicalProviderSource(source) {
case sourceHuangguoAI, sourceHuangguoVideo, sourceHuangdou, sourceHongguo:
return true
default:
return false
}
}
func canonicalProviderSource(source string) string {
switch strings.ToLower(strings.TrimSpace(source)) {
case "huangguo", "huangguoai", "huangguoai.com":
return sourceHuangguoAI
case "huangguo-video", "huangguo.video":
return sourceHuangguoVideo
case "huangdou", "tideember.cc", "xqjurgek.top":
return sourceHuangdou
case "hongguo", "hongguoduanju.com":
return sourceHongguo
default:
return strings.TrimSpace(source)
}
}
func (d *Downloader) fetchHuangguoProviders(ctx context.Context, seen map[string]bool) []Drama {
var out []Drama
providers := []struct {
name string
fn func(context.Context) ([]Drama, error)
}{
{name: sourceHuangguoAI, fn: d.fetchHuangguoAIDramas},
{name: sourceHuangguoVideo, fn: d.fetchHuangguoVideoDramas},
{name: sourceHuangdou, fn: d.fetchHuangdouDramas},
{name: sourceHongguo, fn: d.fetchHongguoDramas},
}
for _, provider := range providers {
dramas, err := provider.fn(ctx)
if err != nil {
fmt.Printf(" provider[%s] 获取失败: %v\n", provider.name, err)
continue
}
for _, dr := range dramas {
if dr.ID == "" || seen[dr.ID] {
continue
}
seen[dr.ID] = true
out = append(out, dr)
}
}
return out
}
func (d *Downloader) fetchHuangguoAIDramas(ctx context.Context) ([]Drama, error) {
type aiPage struct {
url string
referer string
category string
jsonAPI bool
}
maxPages := d.cfg.MaxPagesPerSort
if maxPages <= 0 {
maxPages = 1
}
pages := []aiPage{{url: huangguoAIBaseURL + "/", referer: huangguoAIBaseURL + "/", category: "首页"}}
for _, slug := range huangguoAISlugs {
categoryURL := huangguoAIListURL(slug, 1)
category := huangguoAITypeName(slug)
pages = append(pages, aiPage{url: categoryURL, referer: huangguoAIBaseURL + "/", category: category})
if !strings.HasPrefix(slug, "ai-") {
continue
}
for page := 1; page <= maxPages; page++ {
apiURL := fmt.Sprintf("%s/api/videos/category/%s?sort=hot&page=%d&size=24", strings.TrimRight(huangguoAIBaseURL, "/"), url.PathEscape(slug), page)
pages = append(pages, aiPage{url: apiURL, referer: categoryURL, category: category, jsonAPI: true})
}
}
type result struct {
items []Drama
err error
}
ch := make(chan result, len(pages))
semaphore := make(chan struct{}, 6)
for _, page := range pages {
page := page
go func() {
select {
case semaphore <- struct{}{}:
defer func() { <-semaphore }()
case <-ctx.Done():
ch <- result{err: ctx.Err()}
return
}
pageCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
body, err := d.fetchProviderText(pageCtx, page.url, page.referer)
if err != nil {
ch <- result{err: fmt.Errorf("%s: %w", page.url, err)}
return
}
if page.jsonAPI {
ch <- result{items: parseHuangguoAIJSONCards([]byte(body), page.url, page.category)}
return
}
ch <- result{items: parseHuangguoAIDramaCards(body, page.url, page.category)}
}()
}
index := map[string]int{}
var out []Drama
var lastErr error
for range pages {
res := <-ch
if res.err != nil {
lastErr = res.err
continue
}
reportLibraryProgress(ctx, sourceHuangguoAI, res.items, nil, false)
for _, dr := range res.items {
if dr.ID == "" {
continue
}
if pos, ok := index[dr.ID]; ok {
out[pos] = mergeDramaMetadata(out[pos], dr)
continue
}
index[dr.ID] = len(out)
out = append(out, dr)
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].DisplayTitle() < out[j].DisplayTitle() })
if len(out) == 0 && lastErr == nil {
lastErr = fmt.Errorf("黄果页面未解析到视频,可能是站点结构改变或访问验证")
}
return out, lastErr
}
func mergeDramaMetadata(base, extra Drama) Drama {
if base.VIP == nil && extra.VIP != nil {
value := *extra.VIP
base.VIP = &value
}
if extra.SortMetadata != nil && (base.SortMetadata == nil || extra.SortMetadata.CheckedAt.After(base.SortMetadata.CheckedAt)) {
base.SortMetadata = extra.SortMetadata
}
if strings.TrimSpace(base.ID) == "" {
base.ID = extra.ID
}
if strings.TrimSpace(base.Source) == "" {
base.Source = extra.Source
}
if strings.TrimSpace(base.SourceID) == "" {
base.SourceID = extra.SourceID
}
if strings.TrimSpace(base.Title) == "" {
base.Title = extra.Title
}
if strings.TrimSpace(base.Name) == "" {
base.Name = extra.Name
}
if strings.TrimSpace(base.Desc) == "" {
base.Desc = extra.Desc
}
if strings.TrimSpace(base.Intro) == "" {
base.Intro = extra.Intro
}
if coverPathFromAny(base.Cover) == "" {
base.Cover = extra.Cover
}
if coverPathFromAny(base.CoverURL) == "" {
base.CoverURL = extra.CoverURL
}
if valueEmpty(base.TotalEpisode) {
base.TotalEpisode = extra.TotalEpisode
}
if valueEmpty(base.EpisodeCount) {
base.EpisodeCount = extra.EpisodeCount
}
if strings.TrimSpace(base.ChannelName) == "" {
base.ChannelName = extra.ChannelName
}
if strings.TrimSpace(base.CategoryName) == "" || (base.CategoryName == "首页" && extra.CategoryName != "") {
base.CategoryName = extra.CategoryName
}
if strings.TrimSpace(base.Remark) == "" || base.Remark == "在线观看" {
base.Remark = extra.Remark
}
if strings.TrimSpace(base.Score) == "" {
base.Score = extra.Score
}
if strings.TrimSpace(base.Views) == "" {
base.Views = extra.Views
}
if strings.TrimSpace(base.Heat) == "" {
base.Heat = extra.Heat
}
if strings.TrimSpace(base.OnlineDate) == "" {
base.OnlineDate = extra.OnlineDate
}
if len(base.Tags) == 0 {
base.Tags = extra.Tags
}
if base.ReleaseStatus == "" || base.ReleaseStatus == "unknown" {
base.ReleaseStatus = extra.ReleaseStatus
}
if dramaProvider(base) == sourceHuangguoAI && base.ID == extra.ID {
_, sourceID, _ := splitProviderDramaID(base.ID)
if title := firstHuangguoTitle(sourceID, base.Title, base.Name, extra.Title, extra.Name); title != "" {
base.Title, base.Name = title, title
}
}
return base
}
func valueEmpty(v any) bool {
if v == nil {
return true
}
s := strings.TrimSpace(fmt.Sprint(v))
return s == "" || s == "0" || s == "<nil>"
}
func huangguoAIListURL(slug string, page int) string {
base := strings.TrimRight(huangguoAIBaseURL, "/")
if page < 1 {
page = 1
}
switch slug {
case "recommend":
return fmt.Sprintf("%s/recommend/%d/", base, page)
case "newest":
return fmt.Sprintf("%s/newest/%d/", base, page)
case "ranks/hot":
return base + "/ranks/hot/"
default:
return fmt.Sprintf("%s/%s/", base, strings.Trim(slug, "/"))
}
}
func huangguoAITypeName(slug string) string {
if name := huangguoAITypeNames[slug]; name != "" {
return name
}
return slug
}
func (d *Downloader) fetchHuangguoVideoDramas(ctx context.Context) ([]Drama, error) {
seen := map[string]bool{}
var out []Drama
var lastErr error
add := func(items []Drama) {
reportLibraryProgress(ctx, sourceHuangguoVideo, items, nil, false)
for _, dr := range items {
if dr.ID == "" || seen[dr.ID] {
continue
}
seen[dr.ID] = true
out = append(out, dr)
}
}
pages := []string{strings.TrimRight(huangguoVideoBaseURL, "/") + "/videos"}
for category := 1; category <= 4; category++ {
pages = append(pages, fmt.Sprintf("%s/videos?category=%d", strings.TrimRight(huangguoVideoBaseURL, "/"), category))
}
for _, pageURL := range pages {
body, err := d.fetchProviderText(ctx, pageURL, huangguoVideoBaseURL+"/")
if err != nil {
lastErr = err
var backoff *requestBackoff
if errors.As(err, &backoff) || ctx.Err() != nil {
break
}
continue
}
items := parseHuangguoVideoCards(body, pageURL)
if len(items) == 0 && len(out) == 0 {
lastErr = errors.New("黄果视频页面没有可识别的剧集内容,请检查站点是否返回验证页或页面结构已变化")
break
}
add(items)
}
if len(out) == 0 && lastErr != nil {
return nil, lastErr
}
sort.SliceStable(out, func(i, j int) bool { return out[i].DisplayTitle() < out[j].DisplayTitle() })
return out, lastErr
}
func (d *Downloader) GetHuangguoChapters(ctx context.Context, source, sourceID string) (string, []Chapter, error) {
switch canonicalProviderSource(source) {
case sourceHuangguoAI:
return d.fetchHuangguoAIChapters(ctx, sourceID)
case sourceHuangguoVideo:
return d.fetchHuangguoVideoChapters(ctx, sourceID)
case sourceHuangdou:
return d.fetchHuangdouChapters(ctx, sourceID)
case sourceHongguo:
return d.fetchHongguoChapters(ctx, sourceID)
default:
return "", nil, fmt.Errorf("unsupported provider source: %s", source)
}
}
func (d *Downloader) fetchHuangguoAIChapters(ctx context.Context, sourceID string) (string, []Chapter, error) {
sourceID = strings.Trim(strings.TrimSpace(sourceID), "/")
sourceID = strings.TrimPrefix(sourceID, "detail/")
if sourceID == "" {
return "", nil, fmt.Errorf("empty huangguoai sourceID")
}
detailURL := strings.TrimRight(huangguoAIBaseURL, "/") + "/detail/" + url.PathEscape(sourceID) + "/"
body, err := d.fetchProviderText(ctx, detailURL, huangguoAIBaseURL+"/")
if err != nil {
return "", nil, err
}
title := firstNonEmpty(extractPageTitle(body), titleNearDetail(body, sourceID), "短剧")
episodes := parseHuangguoAIEpisodes(body, detailURL, sourceID)
if len(episodes) == 0 {
if media := parseAIVideoURL(body, detailURL); media != "" {
episodes = []providerEpisode{{Key: "1", Title: title, URL: detailURL, Index: 1, HLS: media}}
}
}
if len(episodes) == 0 {
return title, nil, nil
}
var chapters []Chapter
for _, ep := range episodes {
mediaURL := ep.HLS
idx := ep.Index
if idx <= 0 {
idx = len(chapters) + 1
}
chapterTitle := strings.TrimSpace(ep.Title)
if chapterTitle == "" {
chapterTitle = fmt.Sprintf("第%d集", idx)
}
key := ep.Key
if key == "" {
key = strconv.Itoa(idx)
}
chapters = append(chapters, Chapter{ID: providerChapterID(sourceHuangguoAI, sourceID, key), Source: sourceHuangguoAI, Title: chapterTitle, VideoURL: mediaURL, PageURL: ep.URL, CurrentEpisode: rawEpisode(idx)})
}
sortProviderChapters(chapters)
return title, uniqueChapters(chapters), nil
}
func (d *Downloader) fetchHuangguoVideoChapters(ctx context.Context, sourceID string) (string, []Chapter, error) {
sourceID = strings.Trim(strings.TrimSpace(sourceID), "/")
if sourceID == "" {
return "", nil, fmt.Errorf("empty huangguo-video sourceID")
}
detailPath := sourceID
if !strings.HasPrefix(detailPath, "series/") && !strings.HasPrefix(detailPath, "video/") {
detailPath = "series/" + detailPath
}
detailURL := strings.TrimRight(huangguoVideoBaseURL, "/") + "/" + detailPath
body, err := d.fetchProviderText(ctx, detailURL, huangguoVideoBaseURL+"/")
if err != nil {
return "", nil, err
}
title := firstNonEmpty(extractPageTitle(body), "短剧")
var episodes []providerEpisode
if strings.HasPrefix(detailPath, "video/") {
if hls := parseDataHLS(body, detailURL); hls != "" {
episodes = []providerEpisode{{Key: "1", Title: title, URL: detailURL, Index: 1, HLS: hls}}
}
} else {
episodes = parseHuangguoVideoEpisodes(body, detailURL)
}
if len(episodes) == 0 {
if hls := parseDataHLS(body, detailURL); hls != "" {
episodes = []providerEpisode{{Key: "1", Title: title, URL: detailURL, Index: 1, HLS: hls}}
}
}
var chapters []Chapter
for _, ep := range episodes {
hlsURL := ep.HLS
idx := ep.Index
if idx <= 0 {
idx = len(chapters) + 1
}
chapterTitle := strings.TrimSpace(ep.Title)
if chapterTitle == "" {
chapterTitle = fmt.Sprintf("第%d集", idx)
}
key := ep.Key
if key == "" {
key = strconv.Itoa(idx)
}
chapters = append(chapters, Chapter{ID: providerChapterID(sourceHuangguoVideo, sourceID, key), Source: sourceHuangguoVideo, Title: chapterTitle, VideoURL: hlsURL, PageURL: ep.URL, CurrentEpisode: rawEpisode(idx)})
}
sortProviderChapters(chapters)
return title, uniqueChapters(chapters), nil
}
func (d *Downloader) fetchProviderText(ctx context.Context, rawURL, referer string) (string, error) {
retries := d.cfg.Retries
if retries <= 0 {
retries = 3
}
if referer == "" {
referer = rawURL
}
candidates := d.providerURLCandidates(rawURL)
var lastErr error
tried := 0
for _, candidate := range candidates {
if err := ctx.Err(); err != nil {
return "", err
}
attempts := retries
if len(candidates) > 1 {
attempts = 1
}
for attempt := 1; attempt <= attempts; attempt++ {
tried++
if attempt > 1 {
select {
case <-time.After(time.Duration(attempt) * time.Second):
case <-ctx.Done():
return "", ctx.Err()
}
}
timeout := providerTimeout
if len(candidates) > 1 {
timeout = 5 * time.Second
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, candidate, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", userAgent)
if providerSourceForURL(rawURL) != "" {
req.Header.Set("Referer", providerRefererForURL(candidate, referer))
} else {
req.Header.Set("Referer", referer)
}
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
resp, err := d.doCatalogRequestWithTimeout(req, timeout)
if err != nil {
lastErr = err
var backoff *requestBackoff
if errors.As(err, &backoff) {
break
}
continue
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, providerMaxBodyBytes+1))
closeErr := resp.Body.Close()
if readErr != nil {
lastErr = readErr
continue
}
if closeErr != nil {
lastErr = closeErr
continue
}
if len(body) > providerMaxBodyBytes {
lastErr = fmt.Errorf("response exceeds %d bytes", providerMaxBodyBytes)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || catalogResponseBlockReason(resp, body) != "" {
lastErr = d.catalogResponseError(req, resp, body)
if catalogResponseBlockReason(resp, body) != "" || resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != 408 {
break
}
continue
}
if source := providerSourceForURL(rawURL); source != "" {
effectiveURL := req.URL
if resp.Request != nil {
effectiveURL = resp.Request.URL
}
d.providerMu.Lock()
d.providerHosts[source] = effectiveURL.Scheme + "://" + effectiveURL.Host
d.providerMu.Unlock()
}
rememberPlaybackResponseURL(ctx, rawURL, resp)
return string(body), nil
}
}
if len(candidates) > 1 && lastErr != nil {
return "", fmt.Errorf("黄果来源请求失败:已尝试 %d 个域名,最后错误:%w", tried, lastErr)
}
return "", lastErr
}
func providerURLCandidates(rawURL string) []string {
u, err := url.Parse(rawURL)
if err != nil || u.Hostname() != "huangguoai.com" {
return []string{rawURL}
}
out := make([]string, 0, len(huangguoAIBaseMirrors)+8)
seen := map[string]bool{}
add := func(candidate string) {
if candidate == "" || seen[candidate] {
return
}
seen[candidate] = true
out = append(out, candidate)
}
for i, mirror := range huangguoAIBaseMirrors {
if i == 1 {
for _, generated := range generatedHuangguoAIMirrors(rawURL, 8) {
add(rehostProviderURL(u, generated))
}
}
add(rehostProviderURL(u, mirror))
}
if len(out) == 0 {
return []string{rawURL}
}
return out
}
func rehostProviderURL(u *url.URL, mirror string) string {
mu, err := url.Parse(mirror)
if err != nil || mu.Host == "" {
return ""
}
copyURL := *u
copyURL.Scheme = mu.Scheme
copyURL.Host = mu.Host
return copyURL.String()
}
func generatedHuangguoAIMirrors(seed string, n int) []string {
if n <= 0 {
return nil
}
out := make([]string, 0, n)
seen := map[string]bool{}
for i := 0; len(out) < n && i < n*4; i++ {
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", seed, i)))
hexText := fmt.Sprintf("%x", sum[:])
sub := strings.TrimLeft(hexText[:6], "0123456789")
if len(sub) < 4 {
sub = hexText[6:12]
}
if len(sub) > 6 {
sub = sub[:6]
}
if len(sub) < 3 || seen[sub] {
continue
}
seen[sub] = true
out = append(out, "https://"+sub+".ediayikma.cc")
}
return out
}
func providerRefererForURL(candidate, fallback string) string {
if u, err := url.Parse(candidate); err == nil && u.Scheme != "" && u.Host != "" {
return u.Scheme + "://" + u.Host + "/"
}
return fallback
}
func parseHuangguoAIDramaCards(rawHTML, pageURL, category string) []Drama {
positions := map[string]int{}
var out []Drama
add := func(dr Drama) {
if dr.ID == "" || dr.SourceID == "" {
return
}
if position, found := positions[dr.SourceID]; found {
out[position] = mergeDramaMetadata(out[position], dr)
return
}
positions[dr.SourceID] = len(out)
out = append(out, dr)
}
markup := huangguoNonContent.ReplaceAllString(rawHTML, "")
for _, block := range splitHuangguoAICardBlocks(markup) {
sourceID := cleanID(firstNonEmpty(extractAttr(block, "data-track-id"), detailIDFromString(extractAttr(block, "href"))))
if sourceID == "" {
if m := reDetailHref.FindStringSubmatch(block); len(m) > 2 {
sourceID = cleanID(m[2])
}
}
if sourceID == "" {
continue
}
title := huangguoCardTitle(block, sourceID)
cover := resolveProviderURL(pageURL, firstNonEmpty(extractAttr(block, "data-src"), extractAttr(block, "data-original"), extractAttr(block, "src")))
desc := firstNonEmpty(extractByClassText(block, "hg-drama-card__desc"), extractDescription(block))
score := firstNonEmpty(extractByClassText(block, "hg-drama-card__score"), firstMatchText(reScoreText, block))
episodeBlock := huangguoClassBlock(block, "hg-drama-card__episode")
episode := firstNonEmpty(extractAttr(episodeBlock, "data-ep-base"), cleanText(episodeBlock))
badge := extractByClassText(block, "hg-drama-card__badge")
remark := strings.TrimSpace(strings.Join(nonEmptyStrings(badge, episode), " "))
if remark == "" {
remark = "在线观看"
}
views := normalizeViews(firstNonEmpty(firstMatchText(reViewsText, block), extractByClassText(block, "hg-drama-card__views"), extractByClassText(block, "hg-drama-card__play")))
online := normalizeDate(firstMatchText(reDateText, block))
tags := extractTags(block)
typeName := firstNonEmpty(extractAttr(block, "data-track-type-name"), category)
if title == "" {
title = sourceID
}
dr := Drama{ID: providerDramaID(sourceHuangguoAI, sourceID), Source: sourceHuangguoAI, SourceID: sourceID, Title: title, Name: title, Desc: desc, Intro: desc, Cover: cover, CoverURL: cover, CategoryName: typeName, ChannelName: "huangguoai.com", Remark: remark, Score: score, Views: views, OnlineDate: online, Tags: tags}
if n := episodeIndex(episode, 0); n > 0 {
dr.TotalEpisode = n
dr.EpisodeCount = n
}
add(dr)
}
for _, m := range reDetailHref.FindAllStringSubmatchIndex(markup, -1) {
if len(m) < 6 || m[4] < 0 || m[5] < 0 {
continue
}
sourceID := cleanID(markup[m[4]:m[5]])
block := markup[m[0]:m[1]]
title := firstHuangguoTitle(sourceID, huangguoCardTitle(block, sourceID), cleanText(block))
if title == "" {
continue
}
cover := resolveProviderURL(pageURL, extractAttr(block, "data-src", "data-original", "src"))
add(Drama{ID: providerDramaID(sourceHuangguoAI, sourceID), Source: sourceHuangguoAI, SourceID: sourceID, Title: title, Name: title, Cover: cover, CoverURL: cover, CategoryName: category, ChannelName: "huangguoai.com"})
}
for _, dr := range parseHuangguoAIJSONLDDramas(rawHTML, category) {
add(dr)
}
return out
}
func splitHuangguoAICardBlocks(raw string) []string {
starts := reDramaCardStart.FindAllStringIndex(raw, -1)
blocks := make([]string, 0, len(starts))
for i, m := range starts {
end := len(raw)
if i+1 < len(starts) {
end = starts[i+1][0]
}
blocks = append(blocks, huangguoElementBlock(raw[:end], m[0], m[1], "div"))
}
return blocks
}
func parseHuangguoAIJSONLDDramas(raw, category string) []Drama {
var out []Drama
seen := map[string]bool{}
for _, block := range regexp.MustCompile(`(?is)<script[^>]*type=["']application/ld\+json["'][^>]*>(.*?)</script>`).FindAllStringSubmatch(raw, -1) {
if len(block) < 2 {
continue
}
var value any
if json.Unmarshal([]byte(html.UnescapeString(block[1])), &value) != nil {
continue
}
var walk func(any)
walk = func(v any) {
switch x := v.(type) {
case []any:
for _, item := range x {
walk(item)
}
case map[string]any:
name, _ := x["name"].(string)
itemURL, _ := x["url"].(string)
id := detailIDFromString(itemURL)
if id != "" && !huangguoTitleNeedsRepair(name, id) && !seen[id] {
seen[id] = true
title := cleanText(name)
out = append(out, Drama{ID: providerDramaID(sourceHuangguoAI, id), Source: sourceHuangguoAI, SourceID: id, Title: title, Name: title, CategoryName: category, ChannelName: "huangguoai.com"})
}
for _, child := range x {
walk(child)
}
}
}
walk(value)
}
return out
}
func parseHuangguoAIJSONCards(raw []byte, pageURL, category string) []Drama {
var decoded any
if err := json.Unmarshal(raw, &decoded); err != nil {
return nil
}
seen := map[string]bool{}
var out []Drama
var walk func(any)
walk = func(v any) {
switch x := v.(type) {
case []any:
for _, item := range x {
walk(item)
}
case map[string]any:
if dr, ok := dramaFromAIMap(x, pageURL, category); ok {
if !seen[dr.SourceID] {
seen[dr.SourceID] = true
out = append(out, dr)
}
return
}
for _, child := range x {
walk(child)
}
}
}
walk(decoded)
return out
}
func dramaFromAIMap(m map[string]any, pageURL, category string) (Drama, bool) {
sourceID := firstNonEmpty(mapString(m, "id"), mapString(m, "videoId"), mapString(m, "video_id"), mapString(m, "vid"), mapString(m, "slug"))
if sourceID == "" {
for _, v := range m {
if s, ok := v.(string); ok {
if id := detailIDFromString(s); id != "" {
sourceID = id
break
}
}
}
}
if detailID := detailIDFromString(sourceID); detailID != "" {
sourceID = detailID
}
sourceID = cleanID(sourceID)
if sourceID == "" {
return Drama{}, false
}
title := firstNonEmpty(mapString(m, "title"), mapString(m, "name"), mapString(m, "videoTitle"), mapString(m, "video_title"), mapString(m, "videoName"), mapString(m, "video_name"))
if huangguoTitleNeedsRepair(title, sourceID) {
return Drama{}, false
}
cover := resolveProviderURL(pageURL, firstNonEmpty(mapString(m, "cover"), mapString(m, "coverUrl"), mapString(m, "cover_url"), mapString(m, "coverImage"), mapString(m, "cover_image"), mapString(m, "image"), mapString(m, "imageUrl"), mapString(m, "thumbnail"), mapString(m, "poster"), mapString(m, "posterUrl")))
desc := firstNonEmpty(mapString(m, "desc"), mapString(m, "description"), mapString(m, "intro"), mapString(m, "summary"))
eps := huangguoMapEpisodes(m)
remark := firstNonEmpty(mapString(m, "vod_remarks"), mapString(m, "remark"), mapString(m, "remarks"))
if remark == "" && eps != "" {
if strings.EqualFold(mapString(m, "is_finished"), "true") || mapString(m, "is_finished") == "1" {
remark = "全" + eps + "集"
} else {
remark = "更新至 " + eps + " 集"
}
}
score := firstNonEmpty(mapString(m, "score"), mapString(m, "rating"))
if score != "" && !strings.Contains(score, "分") {
score += "分"
}
views := normalizeViews(firstNonEmpty(mapString(m, "views"), mapString(m, "view_count"), mapString(m, "viewCount"), mapString(m, "view_num"), mapString(m, "viewNum"), mapString(m, "views_text"), mapString(m, "view_count_text"), mapString(m, "play_count"), mapString(m, "playCount"), mapString(m, "play_num"), mapString(m, "playNum"), mapString(m, "play_count_text"), mapString(m, "hits"), mapString(m, "hit_count"), mapString(m, "plays"), mapString(m, "play")))
online := providerReleaseDate(firstNonEmpty(mapString(m, "onlineDate"), mapString(m, "online_date"), mapString(m, "online_time"), mapString(m, "onlineTime"), mapString(m, "publish_date"), mapString(m, "publishDate"), mapString(m, "publish_time"), mapString(m, "publishTime"), mapString(m, "release_date"), mapString(m, "releaseDate")))
tags := mapStringSlice(m, "tags", "tag", "categories", "category")
return Drama{ID: providerDramaID(sourceHuangguoAI, sourceID), Source: sourceHuangguoAI, SourceID: sourceID, Title: title, Name: title, Desc: desc, Intro: desc, Cover: cover, CoverURL: cover, TotalEpisode: eps, EpisodeCount: eps, CategoryName: category, ChannelName: "huangguoai.com", Remark: remark, Score: score, Views: views, OnlineDate: online, Tags: tags}, true
}
func parseHuangguoVideoCards(rawHTML, pageURL string) []Drama {
blocks := reVideoCard.FindAllString(rawHTML, -1)
if len(blocks) == 0 {
for _, block := range reAnyArticle.FindAllString(rawHTML, -1) {
if strings.Contains(strings.ToLower(block), "video-card") {
blocks = append(blocks, block)
}
}
}
seen := map[string]bool{}
var out []Drama
for _, block := range blocks {
link := reHuangguoLink.FindStringSubmatch(block)
if len(link) < 3 {
continue
}
path, sourceID := huangguoVideoSourceID(link[1])
if sourceID == "" || seen[sourceID] {
continue
}
seen[sourceID] = true
title := firstNonEmpty(extractClosestAttr(block, 0, len(block), "title", "alt", "aria-label"), cleanText(block))
cover := resolveProviderURL(pageURL, extractClosestAttr(block, 0, len(block), "data-src", "data-original", "src"))
category := "video"
if strings.HasPrefix(path, "series/") {
category = "series"
}
desc := firstNonEmpty(extractClosestAttr(block, 0, len(block), "data-description", "description"), extractDescription(block))
out = append(out, Drama{ID: providerDramaID(sourceHuangguoVideo, sourceID), Source: sourceHuangguoVideo, SourceID: sourceID, Title: title, Name: title, Desc: desc, Intro: desc, Cover: cover, CoverURL: cover, CategoryName: category, ChannelName: "huangguo.video", Remark: firstNonEmpty(extractByClassText(block, "bg-black/55"), extractByClassText(block, "text-gold-dim"))})
}
return out
}
func parseHuangguoAIEpisodes(rawHTML, pageURL, sourceID string) []providerEpisode {
matches := reAIEpisodeLink.FindAllStringSubmatchIndex(rawHTML, -1)
seen := map[string]bool{}
var episodes []providerEpisode
usedIndex := map[int]bool{}
for _, m := range matches {
if len(m) < 4 || m[2] < 0 || m[3] < 0 {
continue
}
href := rawHTML[m[2]:m[3]]
fullURL := resolveProviderURL(pageURL, href)
linkHTML := rawHTML[m[0]:m[1]]
title := firstNonEmpty(extractAttr(linkHTML, "title", "aria-label"), cleanText(linkHTML))
key := aiEpisodeKey(href, sourceID)
if key == "" {
key = fmt.Sprintf("auto-%d", len(episodes)+1)
}
if seen[key] || seen[fullURL] {
continue
}
seen[key] = true
seen[fullURL] = true
idx := episodeIndex(title, 0)
if idx <= 0 {
idx = len(episodes) + 1
}
idx = nextUnusedEpisodeIndex(idx, usedIndex)
usedIndex[idx] = true
episodes = append(episodes, providerEpisode{Key: key, Title: title, URL: fullURL, Index: idx})
}
sortProviderEpisodes(episodes)
return episodes
}
func parseHuangguoVideoEpisodes(rawHTML, pageURL string) []providerEpisode {
blocks := reVideoCard.FindAllString(rawHTML, -1)
if len(blocks) == 0 {
blocks = reHuangguoLink.FindAllString(rawHTML, -1)
}
seen := map[string]bool{}
var episodes []providerEpisode
for _, block := range blocks {
link := reHuangguoLink.FindStringSubmatch(block)
if len(link) < 2 {
continue
}
path, key := huangguoVideoSourceID(link[1])
if key == "" || !strings.HasPrefix(path, "video/") {
continue
}
fullURL := resolveProviderURL(pageURL, link[1])
if seen[key] || seen[fullURL] {
continue
}
seen[key] = true
seen[fullURL] = true
title := firstNonEmpty(extractAttr(block, "title"), extractAttr(block, "alt"), cleanText(block))
episodes = append(episodes, providerEpisode{Key: key, Title: title, URL: fullURL, Index: episodeIndex(title, len(episodes)+1), HLS: parseDataHLS(block, pageURL)})
}
sortProviderEpisodes(episodes)
return episodes
}
func parseAIVideoURL(rawHTML, pageURL string) string {
if media := parseAIVideoInitialData(rawHTML, pageURL); media != "" {
return media
}
if m := reDataPlaySrc.FindStringSubmatch(rawHTML); len(m) > 1 {
if media := normalizeProviderMediaURL(pageURL, m[1]); media != "" {
return media
}
}
for _, m := range reMediaFieldValue.FindAllStringSubmatch(rawHTML, -1) {
if len(m) > 1 {
if media := normalizeProviderMediaURL(pageURL, m[1]); media != "" {
return media
}
}
}
return ""
}
func parseAIVideoInitialData(rawHTML, pageURL string) string {
re := regexp.MustCompile(`(?is)<script\b[^>]*id=["']videoInitialData["'][^>]*>(.*?)</script>`)
m := re.FindStringSubmatch(rawHTML)
if len(m) < 2 {
return ""
}
var data map[string]any
if err := json.Unmarshal([]byte(html.UnescapeString(m[1])), &data); err != nil {
return ""
}
if media := normalizeProviderMediaURL(pageURL, mapString(data, "videoSrc", "videoUrl", "playUrl")); media != "" {
return media
}
eps, _ := data["epPlaySrcs"].(map[string]any)
if len(eps) == 0 {
return ""
}
preferred := firstNonEmpty(mapString(data, "ep", "episode"))
if m := regexp.MustCompile(`/ep-(\d+)(?:/|$)`).FindStringSubmatch(pageURL); len(m) > 1 {
preferred = m[1]
}
if preferred != "" {
if media := normalizeProviderMediaURL(pageURL, fmt.Sprint(eps[preferred])); media != "" {
return media
}
}
keys := make([]string, 0, len(eps))
for key := range eps {
keys = append(keys, key)
}
sort.SliceStable(keys, func(i, j int) bool { return episodeIndex(keys[i], i+1) < episodeIndex(keys[j], j+1) })
for _, key := range keys {
if media := normalizeProviderMediaURL(pageURL, fmt.Sprint(eps[key])); media != "" {
return media
}
}
return ""
}
func parseDataHLS(rawHTML, pageURL string) string {
if m := reDataHLS.FindStringSubmatch(rawHTML); len(m) > 1 {
return normalizeProviderMediaURL(pageURL, m[1])
}
return ""
}
func (d *Downloader) resolveHuangguoVideoHLS(ctx context.Context, hlsURL, referer string) string {
if strings.HasSuffix(strings.ToLower(strings.SplitN(hlsURL, "?", 2)[0]), ".mp4") {
return hlsURL
}
body, err := d.fetchProviderText(ctx, hlsURL, referer)
if err != nil {
return hlsURL
}
if best := selectBestM3U8Variant(body, hlsURL); best != "" {
return best
}
return hlsURL
}
func selectBestM3U8Variant(master, masterURL string) string {
playlistURL, err := url.Parse(masterURL)
if err != nil {
return ""
}
lines := strings.Split(master, "\n")
type variant struct {
url string
height int
bandwidth int
order int
}
var variants []variant
pending := variant{}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "#EXT-X-STREAM-INF") {
pending = variant{}
if m := reResolution.FindStringSubmatch(trimmed); len(m) > 2 {
pending.height, _ = strconv.Atoi(m[2])
}
if m := reBandwidth.FindStringSubmatch(trimmed); len(m) > 1 {
pending.bandwidth, _ = strconv.Atoi(m[1])
}
continue
}
if strings.HasPrefix(trimmed, "#") {
continue
}
if pending.height > 0 || pending.bandwidth > 0 {
reference, err := url.Parse(trimmed)
if err != nil {
pending = variant{}
continue
}
pending.url = playlistURL.ResolveReference(reference).String()
if !isProviderHTTPMediaURL(pending.url) {
pending = variant{}
continue
}
pending.order = len(variants)
variants = append(variants, pending)
pending = variant{}
}
}
if len(variants) == 0 {
return ""
}
sort.SliceStable(variants, func(i, j int) bool {
if variants[i].height != variants[j].height {
return variants[i].height > variants[j].height
}
if variants[i].bandwidth != variants[j].bandwidth {
return variants[i].bandwidth > variants[j].bandwidth
}
return variants[i].order < variants[j].order
})
return variants[0].url
}
func sortProviderEpisodes(episodes []providerEpisode) {
sort.SliceStable(episodes, func(i, j int) bool {
if episodes[i].Index != episodes[j].Index {
return episodes[i].Index < episodes[j].Index
}
return episodes[i].Key < episodes[j].Key
})
}
func sortProviderChapters(chapters []Chapter) {
sort.SliceStable(chapters, func(i, j int) bool {
ei := chapterEpisodeNumber(chapters[i], i+1)
ej := chapterEpisodeNumber(chapters[j], j+1)
if ei != ej {
return ei < ej
}
return chapters[i].ID < chapters[j].ID
})
}
func chapterEpisodeNumber(ch Chapter, fallback int) int {
if len(ch.CurrentEpisode) > 0 && string(ch.CurrentEpisode) != "null" {
var n int
if err := json.Unmarshal(ch.CurrentEpisode, &n); err == nil && n > 0 {
return n
}
var s string
if err := json.Unmarshal(ch.CurrentEpisode, &s); err == nil {
return episodeIndex(s, fallback)
}
}
return episodeIndex(ch.Title, fallback)
}
func rawEpisode(n int) json.RawMessage {
if n <= 0 {
n = 1
}
return json.RawMessage(strconv.Itoa(n))
}
func episodeIndex(s string, fallback int) int {
if m := reEpisodeNumber.FindStringSubmatch(s); len(m) > 1 {
for _, group := range m[1:] {
if group == "" {
continue
}
if n, err := strconv.Atoi(group); err == nil && n > 0 {
return n
}
}
}
return fallback
}
func nextUnusedEpisodeIndex(preferred int, used map[int]bool) int {
if preferred <= 0 {
preferred = 1
}
if !used[preferred] {
return preferred
}
for i := 1; ; i++ {
if !used[i] {
return i
}
}
}
func aiEpisodeKey(href, sourceID string) string {
u, err := url.Parse(html.UnescapeString(href))
path := href
if err == nil {
path = u.Path
}
path = strings.Trim(strings.TrimPrefix(path, "/video/"), "/")
parts := strings.Split(path, "/")
if len(parts) > 1 && parts[0] == sourceID {
return cleanID(parts[len(parts)-1])
}
if len(parts) == 1 && parts[0] == sourceID {
return ""
}
return cleanID(strings.Join(parts, "-"))
}
func huangguoVideoSourceID(href string) (path, sourceID string) {
u, err := url.Parse(html.UnescapeString(href))
if err == nil {
path = strings.Trim(u.Path, "/")
} else {
path = strings.Trim(href, "/")
}
parts := strings.Split(path, "/")
for i := 0; i+1 < len(parts); i++ {
if parts[i] == "series" || parts[i] == "video" {
code := cleanID(parts[i+1])
if code == "" {
return "", ""
}
return parts[i] + "/" + code, parts[i] + "/" + code
}
}
return "", ""
}
func detailIDFromString(s string) string {
u, err := url.Parse(html.UnescapeString(s))
path := s
if err == nil {
path = u.Path
}
parts := strings.Split(strings.Trim(path, "/"), "/")
for i := 0; i+1 < len(parts); i++ {
if parts[i] == "detail" {
return cleanID(parts[i+1])
}
}
return ""
}
func cleanID(s string) string {
s = strings.TrimSpace(html.UnescapeString(s))
s = strings.Trim(s, " /\t\r\n\"'")
if q := strings.IndexAny(s, "?#"); q >= 0 {
s = s[:q]
}
return s
}
func extractPageTitle(rawHTML string) string {
if m := reH1Tag.FindStringSubmatch(rawHTML); len(m) > 1 {
if t := cleanText(m[1]); t != "" {
return t
}
}
if m := regexp.MustCompile(`(?is)<meta\b[^>]*property=["']og:title["'][^>]*content=["']([^"']+)["'][^>]*>`).FindStringSubmatch(rawHTML); len(m) > 1 {
if t := strings.TrimSpace(html.UnescapeString(m[1])); t != "" {
return t
}
}
if m := reTitleTag.FindStringSubmatch(rawHTML); len(m) > 1 {
return cleanText(m[1])
}
return ""
}
func extractDescription(rawHTML string) string {
if m := reDescMeta.FindStringSubmatch(rawHTML); len(m) > 1 {
return strings.TrimSpace(html.UnescapeString(m[1]))
}
return ""
}
func extractAttr(block string, names ...string) string {
for _, name := range names {
re := regexp.MustCompile(`(?is)\b` + regexp.QuoteMeta(name) + `\s*=\s*["']([^"']+)["']`)
if m := re.FindStringSubmatch(block); len(m) > 1 {
return strings.TrimSpace(html.UnescapeString(m[1]))
}
}
return ""
}
func extractByClassText(raw, className string) string {
if className == "" {
return ""
}
re := regexp.MustCompile(`(?is)<[^>]+class=["'][^"']*` + regexp.QuoteMeta(className) + `[^"']*["'][^>]*>(.*?)</[^>]+>`)
if m := re.FindStringSubmatch(raw); len(m) > 1 {
return cleanText(m[1])
}
return ""
}
func firstMatchText(re *regexp.Regexp, raw string) string {
if m := re.FindString(raw); m != "" {
return strings.TrimSpace(m)
}
return ""
}
func extractTags(raw string) []string {
re := regexp.MustCompile(`(?is)<[^>]+class=["'][^"']*hg-tag[^"']*["'][^>]*>(.*?)</[^>]+>`)
seen := map[string]bool{}
var out []string
for _, m := range re.FindAllStringSubmatch(raw, -1) {
if len(m) < 2 {
continue
}
v := cleanText(m[1])
if v != "" && !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
func normalizeDate(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
matches := reDateText.FindAllString(s, -1)
for _, m := range matches {
parts := strings.Split(m, "-")
if len(parts) != 3 {
continue
}
y, yErr := strconv.Atoi(parts[0])
mo, moErr := strconv.Atoi(parts[1])
d, dErr := strconv.Atoi(parts[2])
if yErr == nil && moErr == nil && dErr == nil && y >= 2000 && y <= 2100 && mo >= 1 && mo <= 12 && d >= 1 && d <= 31 {
return fmt.Sprintf("%04d-%02d-%02d", y, mo, d)
}
}
return ""
}
func normalizeViews(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if strings.Contains(s, "次播放") {
return s
}
if strings.ContainsAny(strings.ToLower(s), "w万") {
return s + "次播放"
}
n, err := strconv.ParseFloat(s, 64)
if err != nil || n <= 0 {
return s
}
return strconv.FormatFloat(n, 'f', -1, 64) + "次播放"
}
func cleanText(s string) string {
s = regexp.MustCompile(`(?is)<script\b.*?</script>`).ReplaceAllString(s, " ")
s = regexp.MustCompile(`(?is)<style\b.*?</style>`).ReplaceAllString(s, " ")
s = regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(s, " ")
s = html.UnescapeString(s)
s = spaceChars.ReplaceAllString(s, " ")
return strings.TrimSpace(s)
}
func contextBlock(s string, start, end, padding int) string {
if start < 0 {
start = 0
}
if end < start {
end = start
}
lo := start - padding
if lo < 0 {
lo = 0
}
hi := end + padding
if hi > len(s) {
hi = len(s)
}
return s[lo:hi]
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func nonEmptyStrings(values ...string) []string {
var out []string
for _, v := range values {
if strings.TrimSpace(v) != "" {
out = append(out, strings.TrimSpace(v))
}
}
return out
}
func mapString(m map[string]any, keys ...string) string {
for _, key := range keys {
if v, ok := m[key]; ok {
switch x := v.(type) {
case string:
if text := strings.TrimSpace(x); text != "" {
return text
}
case bool:
return strconv.FormatBool(x)
case float64:
if x == float64(int64(x)) {
return strconv.FormatInt(int64(x), 10)
}
return strconv.FormatFloat(x, 'f', -1, 64)
case int:
return strconv.Itoa(x)
case json.Number:
return x.String()
}
}
}
return ""
}
func mapStringSlice(m map[string]any, keys ...string) []string {
seen := map[string]bool{}
var out []string
add := func(s string) {
s = strings.TrimSpace(s)
if s != "" && !seen[s] {
seen[s] = true
out = append(out, s)
}
}
for _, key := range keys {
v, ok := m[key]
if !ok {
continue
}
switch x := v.(type) {
case string:
for _, part := range strings.FieldsFunc(x, func(r rune) bool { return r == ',' || r == '/' || r == ',' || r == '、' }) {
add(part)
}
case []any:
for _, item := range x {
add(fmt.Sprint(item))
}
}
}
return out
}
func extractClosestAttr(raw string, start, end int, names ...string) string {
if start < 0 {
start = 0
}
if end < start {
end = start
}
if end > len(raw) {
end = len(raw)
}
for _, value := range []string{raw[start:end], contextBlock(raw, start, end, 600)} {
if value == "" {
continue
}
if attr := extractAttr(value, names...); attr != "" {
return attr
}
}
return ""
}
func titleNearDetail(raw, sourceID string) string {
needle := "/detail/" + sourceID
idx := strings.Index(raw, needle)
if idx < 0 {
return ""
}
return extractByClassText(contextBlock(raw, idx, idx+len(needle), 1800), "hg-drama-card__title")
}
func resolveProviderURL(baseURL, ref string) string {
ref = strings.TrimSpace(html.UnescapeString(ref))
if ref == "" {
return ""
}
ref = strings.ReplaceAll(ref, `\/`, `/`)
if strings.HasPrefix(ref, "//") {
return "https:" + ref
}
u, err := url.Parse(ref)
if err != nil {
return ref
}
if u.IsAbs() {
if u.Scheme == "http" {
u.Scheme = "https"
}
return u.String()
}
base, err := url.Parse(baseURL)
if err != nil {
return ref
}
resolved := base.ResolveReference(u)
if resolved.Scheme == "http" {
resolved.Scheme = "https"
}
return resolved.String()
}
func normalizeProviderMediaURL(pageURL, raw string) string {
raw = strings.TrimSpace(html.UnescapeString(raw))
raw = strings.Trim(raw, " \t\r\n\"'")
if raw == "" {
return ""
}
if unquoted, err := strconv.Unquote("\"" + strings.ReplaceAll(raw, "\"", "\\\"") + "\""); err == nil {
raw = unquoted
}
raw = strings.ReplaceAll(raw, `\/`, `/`)
resolved := resolveProviderURL(pageURL, raw)
if !strings.Contains(strings.ToLower(resolved), ".m3u8") && !strings.Contains(strings.ToLower(resolved), ".mp4") {
return ""
}
return resolved
}
func isHuangguoProviderChapter(ch Chapter) bool {
return isHuangguoProviderSource(ch.Source) && isProviderHTTPMediaURL(ch.VideoURL)
}
func isProviderHTTPMediaURL(raw string) bool {
parsed, err := url.Parse(strings.TrimSpace(raw))
return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Hostname() != "" && parsed.User == nil
}
func (d *Downloader) downloadHuangguoProviderMediaWithProgress(ctx context.Context, task Task, callback func(DownloadProgress)) error {
if ok, size := existingGood(task.OutPath, d.cfg.SkipBytes); ok {
if callback != nil {
callback(DownloadProgress{Percent: 100, DownloadedBytes: size, TotalBytes: size, Phase: "completed"})
}
return nil
}
if callback != nil {
callback(DownloadProgress{Phase: "preparing"})
}
ffmpeg, err := d.ensureFFmpeg(ctx)
if err != nil {
return err
}
partPath := strings.TrimSuffix(task.OutPath, filepath.Ext(task.OutPath)) + ".part.mp4"
_ = os.Remove(partPath)
defer os.Remove(partPath)
if err := os.MkdirAll(filepath.Dir(task.OutPath), 0o755); err != nil {
return err
}
progress := newDownloadProgressState(partPath, task.OutPath, task.Chapter.MediaSize, callback)
progress.report("resolving", true)
retries := d.cfg.Retries
if retries <= 0 {
retries = 3
}
var lastErr error
for attempt := 1; attempt <= retries; attempt++ {
if err := ctx.Err(); err != nil {
return err
}
if attempt > 1 {
_ = os.Remove(partPath)
progress = newDownloadProgressState(partPath, task.OutPath, task.Chapter.MediaSize, callback)
progress.report("retrying", true)
select {
case <-time.After(time.Duration(attempt*2) * time.Second):
case <-ctx.Done():
return ctx.Err()
}
}
progress.report("resolving", true)
media, err := d.resolveProviderMedia(ctx, task)
if err != nil {
lastErr = publicError(err)
if isHuangdouAccessError(err) {
return lastErr
}
continue
}
media, err = d.selectDownloadQuality(ctx, task, media)
if err != nil {
lastErr = publicError(err)
continue
}
if len(media.CENCKey) != 0 && (len(media.CENCKey) != 16 || media.Playlist != "") {
return errors.New("CENC 媒体的密钥或格式无效")
}
progress.setMediaTotal(media.Duration)
proxy, err := d.newHLSProxy(ctx, media, nil)
if err != nil {
return err
}
inputURL := proxy.root
origin, _ := url.Parse(media.Referer)
headers := fmt.Sprintf("User-Agent: %s\r\nReferer: %s\r\n", userAgent, media.Referer)
if origin != nil && origin.Host != "" {
headers += "Origin: " + origin.Scheme + "://" + origin.Host + "\r\n"
}
cmdCtx, cancel := context.WithTimeout(ctx, 30*time.Minute)
args := []string{
"-hide_banner", "-loglevel", "error", "-nostats",
"-rw_timeout", "20000000",
"-protocol_whitelist", "http,https,tcp,tls,crypto,httpproxy",
"-headers", headers,
}
if media.Playlist != "" {
args = append(args, "-allowed_extensions", "ALL")
}
if len(media.CENCKey) > 0 {
args = append(args, "-decryption_key", hex.EncodeToString(media.CENCKey))
}
args = append(args,
"-i", inputURL,
"-c", "copy",
"-progress", "pipe:1",
"-f", "mp4",
"-y", partPath,
)
cmd := ffmpegMediaCommand(cmdCtx, ffmpeg, args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
if proxy != nil {
proxy.Close()
}
cancel()
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
if proxy != nil {
proxy.Close()
}
cancel()
return err
}
if err := cmd.Start(); err != nil {
if proxy != nil {
proxy.Close()
}
cancel()
lastErr = err
continue
}
progress.report("downloading", true)
stderrDone := make(chan string, 1)
go func() {
var b cappedStringWriter
b.limit = 64 * 1024
_, _ = io.Copy(&b, stderr)
stderrDone <- b.String()
}()
progressDone := make(chan struct{})
go func() {
defer close(progressDone)
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
key, val, ok := strings.Cut(scanner.Text(), "=")
if ok && (key == "out_time_us" || key == "out_time_ms") {
if micros, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64); err == nil && micros >= 0 {
progress.setMediaElapsed(time.Duration(micros) * time.Microsecond)
progress.report("downloading", false)
}
}
}
}()
tickerDone := make(chan struct{})
go func() {
defer close(tickerDone)
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
progress.report("downloading", false)
case <-progressDone:
return
}
}
}()
waitErr := cmd.Wait()
<-progressDone
<-tickerDone
stderrText := <-stderrDone
commandErr := cmdCtx.Err()
cancel()
var proxyErr error
if proxy != nil {
proxyErr = proxy.Err()
proxy.Close()
}
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
if commandErr != nil {
lastErr = commandErr
_ = os.Remove(partPath)
continue
}
if proxyErr != nil {
lastErr = proxyErr
continue
}
if waitErr != nil {
lastErr = fmt.Errorf("ffmpeg failed: %v %s", waitErr, truncate(stderrText, 1000))
_ = os.Remove(partPath)
continue
}
st, err := os.Stat(partPath)
if err != nil {
lastErr = err
continue
}
if st.Size() < 100*1024 {
lastErr = fmt.Errorf("downloaded file too small: %d bytes", st.Size())
_ = os.Remove(partPath)
continue
}
if err := os.Rename(partPath, task.OutPath); err != nil {
lastErr = err
continue
}
progress.report("completed", true)
return nil
}
return lastErr
}
func providerReferer(source string) string {
if source == sourceHuangguoVideo {
return huangguoVideoBaseURL + "/"
}
return huangguoAIBaseURL + "/"
}