package app import ( "bytes" "compress/gzip" "context" "crypto/aes" "crypto/cipher" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) const ( huangdouBaseURL = "https://tideember.cc" huangdouVersion = "2.0.0" huangdouDeviceType = "web" huangdouPlatformKey = "7961beb44246e3012ce228d6b5ced05a" ) type huangdouAPIClient struct { d *Downloader host string sessionID string deviceID string } func newHuangdouAPIClient(d *Downloader) *huangdouAPIClient { id := randomHex(16) if id == "" { id = strconv.FormatInt(time.Now().UnixNano(), 16) } host := d.providerBaseURL(sourceHuangdou) d.providerMu.Lock() if preferred := d.providerHosts[sourceHuangdou]; preferred != "" { host = preferred } d.providerMu.Unlock() return &huangdouAPIClient{d: d, host: host, sessionID: id, deviceID: id} } func (d *Downloader) fetchHuangdouDramas(ctx context.Context) ([]Drama, error) { client := newHuangdouAPIClient(d) pages := d.cfg.MaxPagesPerSort if pages <= 0 { pages = 1 } pageSize := d.cfg.PageSize if pageSize <= 0 || pageSize > 100 { pageSize = 50 } seen := map[string]bool{} var out []Drama var lastErr error for page := 1; page <= pages; page++ { var decoded any if err := client.call(ctx, "/drama/list", map[string]any{"page": strconv.Itoa(page), "page_size": strconv.Itoa(pageSize)}, &decoded); err != nil { lastErr = err break } items := huangdouList(decoded) if len(items) == 0 { break } added := 0 var batch []Drama for _, item := range items { dr := huangdouDramaFromMap(item) if dr.ID == "" || seen[dr.ID] { continue } seen[dr.ID] = true out = append(out, dr) batch = append(batch, dr) added++ } reportLibraryProgress(ctx, sourceHuangdou, batch, nil, false) if added == 0 || len(items) < pageSize { break } } if len(out) == 0 && lastErr == nil { lastErr = errors.New("黄豆未返回剧库数据,请检查站点访问权限或接口变化") } return out, lastErr } func (d *Downloader) fetchHuangdouChapters(ctx context.Context, sourceID string) (string, []Chapter, error) { sourceID = strings.TrimPrefix(strings.TrimSpace(sourceID), "rp_") data, err := d.huangdouDetail(ctx, sourceID) if err != nil { return "", nil, err } title := firstNonEmpty(mapString(data, "name", "title", "t"), sourceID) episodes := huangdouList(data["episodes"]) count := atoiDefault(firstNonEmpty(mapString(data, "episode_count"), mapString(data, "free_episodes")), len(episodes)) if count <= 0 { count = len(episodes) } if count <= 0 || count > 10000 { return title, nil, fmt.Errorf("黄豆返回了无效集数: %d", count) } chapters := make([]Chapter, 0, count) if len(episodes) > 0 { for i, ep := range episodes { seq := atoiDefault(firstNonEmpty(mapString(ep, "seq"), mapString(ep, "episode"), mapString(ep, "ep")), i+1) if seq <= 0 { seq = i + 1 } titleText := firstNonEmpty(mapString(ep, "name", "title"), fmt.Sprintf("第%d集", seq)) chapters = append(chapters, Chapter{VIP: huangdouEpisodeVIP(data, ep, seq), ID: providerChapterID(sourceHuangdou, sourceID, strconv.Itoa(seq)), Source: sourceHuangdou, Title: titleText, CurrentEpisode: rawEpisode(seq)}) } } else { for i := 1; i <= count; i++ { chapters = append(chapters, Chapter{VIP: huangdouEpisodeVIP(data, nil, i), ID: providerChapterID(sourceHuangdou, sourceID, strconv.Itoa(i)), Source: sourceHuangdou, Title: fmt.Sprintf("第%d集", i), CurrentEpisode: rawEpisode(i)}) } } sortProviderChapters(chapters) return title, uniqueChapters(chapters), nil } func (d *Downloader) resolveHuangdouPlayURL(ctx context.Context, client *huangdouAPIClient, sourceID string, seq int) (string, error) { media, err := d.resolveHuangdouPlayback(ctx, client, sourceID, seq) return media.URL, err } func (d *Downloader) resolveHuangdouPlayback(ctx context.Context, client *huangdouAPIClient, sourceID string, seq int) (providerMedia, error) { var decoded any if err := client.call(ctx, "/drama/play", map[string]any{"id": sourceID, "seq": strconv.Itoa(seq)}, &decoded); err != nil { return providerMedia{}, err } data := huangdouDataMap(decoded) media := firstNonEmpty(mapString(data, "m3u8"), mapString(data, "url"), mapString(data, "play_url"), mapString(data, "playUrl")) if huangdouPreviewOnly(data, media, client.host) { return providerMedia{}, &huangdouAPIError{code: "preview", message: "黄豆仅提供试看,未取得该集正片;不会把试看内容当作完整分集"} } if media == "" { media = fmt.Sprintf("%s/api/drama/hls/%s/%d/play.m3u8?line=free", client.host, url.PathEscape(sourceID), seq) playlist, err := d.fetchProviderText(ctx, media, client.host+"/home") if err != nil || !strings.HasPrefix(strings.TrimSpace(playlist), "#EXTM3U") { return providerMedia{}, errors.New("黄豆未提供有效的播放地址,备用播放列表也不可用") } } media = resolveProviderURL(client.host+"/", media) if !isProviderHTTPMediaURL(media) { return providerMedia{}, errors.New("黄豆播放地址不是 HTTP/HTTPS URL") } result := providerMedia{URL: media, Referer: client.host + "/home"} if rawKey := mapString(data, "hls_key"); rawKey != "" { key, err := hex.DecodeString(rawKey) if err != nil || len(key) != aes.BlockSize { return providerMedia{}, errors.New("黄豆返回了无效的 HLS 密钥") } result.HLSKey = key } if duration, err := strconv.ParseFloat(mapString(data, "duration"), 64); err == nil && duration > 0 { result.Duration = time.Duration(duration * float64(time.Second)) } return result, nil } func (c *huangdouAPIClient) call(ctx context.Context, path string, data map[string]any, out *any) error { retries := c.d.cfg.Retries if retries < 1 { retries = 1 } var lastErr error for attempt := 0; attempt < retries; attempt++ { if err := ctx.Err(); err != nil { return err } if attempt > 0 { select { case <-time.After(time.Duration(attempt) * time.Second): case <-ctx.Done(): return ctx.Err() } } lastErr = c.callOnce(ctx, path, data, out) if lastErr == nil { return nil } var apiErr *huangdouAPIError if errors.As(lastErr, &apiErr) { return lastErr } if c.host == huangdouBaseURL && c.d.cfg.HuangdouURL == "" { c.host = "https://xqjurgek.top" } } return lastErr } type huangdouAPIError struct { code string message string } func (failure *huangdouAPIError) Error() string { if failure.code != "preview" && isHuangdouAccessError(failure) { return failure.message + "(站点权限限制,已停止自动重试;切换代理不会解锁)" } return failure.message } func isHuangdouAccessError(err error) bool { var failure *huangdouAPIError if !errors.As(err, &failure) { return false } switch failure.code { case "813004", "813005", "813006", "813103", "preview": return true default: return false } } func huangdouPreviewOnly(data map[string]any, media, host string) bool { switch strings.ToLower(mapString(data, "is_preview", "isPreview")) { case "true", "1", "y": return true } if preview := mapString(data, "preview_m3u8", "preview_url"); preview != "" && (media == "" || resolveProviderURL(host+"/", media) == resolveProviderURL(host+"/", preview)) { return true } parsed, err := url.Parse(media) if err != nil { return false } path := strings.ToLower(parsed.Path) return path == "preview.mp4" || path == "preview.m3u8" || strings.HasSuffix(path, "/preview.mp4") || strings.HasSuffix(path, "/preview.m3u8") } func (c *huangdouAPIClient) callOnce(ctx context.Context, path string, data map[string]any, out *any) error { path = "/" + strings.TrimLeft(path, "/") rid := uuidLike() key, err := huangdouKey(rid) if err != nil { return err } iv := make([]byte, aes.BlockSize) if _, err := rand.Read(iv); err != nil { return err } plain, err := json.Marshal(map[string]any{"token": "", "deviceId": c.deviceID, "data": data}) if err != nil { return err } var gz bytes.Buffer zw := gzip.NewWriter(&gz) if _, err := zw.Write(plain); err != nil { return err } if err := zw.Close(); err != nil { return err } cipherText, err := aesCBCEncrypt(gz.Bytes(), key, iv) if err != nil { return err } body := append(append([]byte{}, iv...), cipherText...) req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.host, "/")+"/api"+path, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36") req.Header.Set("Accept", "*/*") req.Header.Set("Origin", c.host) req.Header.Set("Referer", c.host+"/home") req.Header.Set("Content-Type", "application/octet-stream") req.Header.Set("version", huangdouVersion) req.Header.Set("deviceType", huangdouDeviceType) req.Header.Set("requestId", rid) req.Header.Set("sessionId", c.sessionID) resp, err := c.d.doPreparedCatalogRequest(req, 20*time.Second, func(prepared *http.Request) { timestamp := strconv.FormatInt(time.Now().Unix(), 10) signRaw := "Dart|" + c.sessionID + "|" + rid + "|" + timestamp + "|" + path signSum := sha256.Sum256([]byte(signRaw)) prepared.Header.Set("time", timestamp) prepared.Header.Set("sign", hex.EncodeToString(signSum[:])+"-"+timestamp) }) if err != nil { return err } defer resp.Body.Close() blob, err := io.ReadAll(io.LimitReader(resp.Body, providerMaxBodyBytes+1)) if err != nil { return err } if len(blob) > providerMaxBodyBytes { return fmt.Errorf("huangdou response exceeds %d bytes", providerMaxBodyBytes) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("huangdou HTTP %d: %s", resp.StatusCode, truncate(string(blob), 200)) } decoded, err := huangdouDecode(blob, key) if err != nil { return err } if envelope, ok := decoded.(map[string]any); ok { if status := mapString(envelope, "status"); status != "" && status != "y" { return &huangdouAPIError{code: mapString(envelope, "errorCode", "error_code", "code"), message: "黄豆接口拒绝请求: " + firstNonEmpty(mapString(envelope, "msg", "message", "error"), status)} } } c.d.providerMu.Lock() c.d.providerHosts[sourceHuangdou] = c.host c.d.providerMu.Unlock() if out != nil { *out = decoded } return nil } func huangdouKey(rid string) ([]byte, error) { clean := strings.ReplaceAll(rid, "-", "") b, err := hex.DecodeString(clean) if err != nil { return nil, err } mac := hmac.New(sha256.New, []byte(huangdouPlatformKey)) _, _ = mac.Write(b) return mac.Sum(nil), nil } func huangdouDecode(blob, key []byte) (any, error) { if len(blob) < aes.BlockSize*2 { var direct any if err := json.Unmarshal(blob, &direct); err == nil { return direct, nil } return nil, fmt.Errorf("huangdou response too short") } plain, err := aesCBCDecrypt(blob[aes.BlockSize:], key, blob[:aes.BlockSize]) if err != nil { var direct any if jsonErr := json.Unmarshal(blob, &direct); jsonErr == nil { return direct, nil } return nil, err } if len(plain) >= 2 && plain[0] == 0x1f && plain[1] == 0x8b { zr, err := gzip.NewReader(bytes.NewReader(plain)) if err != nil { return nil, err } plain, err = io.ReadAll(io.LimitReader(zr, providerMaxBodyBytes+1)) _ = zr.Close() if err != nil { return nil, err } if len(plain) > providerMaxBodyBytes { return nil, errors.New("黄豆解压后响应过大") } } var decoded any decoder := json.NewDecoder(bytes.NewReader(plain)) decoder.UseNumber() if err := decoder.Decode(&decoded); err != nil { return nil, err } return decoded, nil } func aesCBCEncrypt(plain, key, iv []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, err } padded := pkcs7Pad(plain, block.BlockSize()) out := make([]byte, len(padded)) cipher.NewCBCEncrypter(block, iv).CryptBlocks(out, padded) return out, nil } func aesCBCDecrypt(cipherText, key, iv []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, err } if len(cipherText)%block.BlockSize() != 0 { return nil, fmt.Errorf("huangdou ciphertext is not block aligned") } out := make([]byte, len(cipherText)) cipher.NewCBCDecrypter(block, iv).CryptBlocks(out, cipherText) return pkcs7Unpad(out, block.BlockSize()) } func huangdouDramaFromMap(item map[string]any) Drama { sourceID := strings.TrimPrefix(firstNonEmpty(mapString(item, "id"), mapString(item, "drama_id")), "rp_") if sourceID == "" { return Drama{} } title := firstNonEmpty(mapString(item, "name", "title", "t"), sourceID) cover := firstNonEmpty(mapString(item, "img_y", "img_x", "img", "cover", "pic")) remark := firstNonEmpty(mapString(item, "update_label"), mapString(item, "corner")) releaseStatus := releaseStatusFromRemark(remark) if remark == "" { if eps := mapString(item, "episode_count"); eps != "" { remark = "全" + eps + "集" } } category := firstNonEmpty(mapString(item, "category", "category_name", "categoryName"), "未分类") return Drama{VIP: huangdouVIPFlag(item), ID: providerDramaID(sourceHuangdou, sourceID), Source: sourceHuangdou, SourceID: sourceID, Title: title, Name: title, Desc: firstNonEmpty(mapString(item, "description"), mapString(item, "summary")), Intro: firstNonEmpty(mapString(item, "description"), mapString(item, "summary")), Cover: cover, CoverURL: cover, CategoryName: category, ChannelName: "tideember.cc", Remark: remark, TotalEpisode: mapString(item, "episode_count"), EpisodeCount: mapString(item, "episode_count"), Tags: mapStringSlice(item, "tags"), ReleaseStatus: releaseStatus, Heat: mapString(item, "hot_rate"), Views: normalizeViews(mapString(item, "click")), OnlineDate: providerReleaseDate(mapString(item, "issue_date"))} } func huangdouDataMap(v any) map[string]any { m, _ := v.(map[string]any) if data, ok := m["data"].(map[string]any); ok { return data } return m } func huangdouList(v any) []map[string]any { switch x := v.(type) { case []any: out := make([]map[string]any, 0, len(x)) for _, item := range x { if m, ok := item.(map[string]any); ok { out = append(out, m) } } return out case []map[string]any: return x case map[string]any: for _, key := range []string{"list", "items", "data"} { if list := huangdouList(x[key]); len(list) > 0 { return list } } } return nil } func atoiDefault(s string, fallback int) int { if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil { return n } return fallback } func randomHex(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "" } return hex.EncodeToString(b) } func uuidLike() string { raw := randomHex(16) if len(raw) != 32 { return raw } return raw[:8] + "-" + raw[8:12] + "-" + raw[12:16] + "-" + raw[16:20] + "-" + raw[20:] }