package app import ( "bytes" "context" "crypto/aes" "crypto/cipher" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) type Tab struct { ID string Name string } type legacyTabList []Tab func (tabs *legacyTabList) UnmarshalJSON(raw []byte) error { raw = bytes.TrimSpace(raw) if len(raw) > 0 && raw[0] == '[' { return json.Unmarshal(raw, (*[]Tab)(tabs)) } var wrapped struct { List []Tab `json:"list"` } if err := json.Unmarshal(raw, &wrapped); err != nil { return err } *tabs = wrapped.List return nil } type apiEnvelope struct { Code json.RawMessage `json:"code"` Msg string `json:"msg"` Hash json.RawMessage `json:"hash"` Data json.RawMessage `json:"data"` } func apiHashEnabled(raw json.RawMessage) bool { if len(raw) == 0 || string(raw) == "null" { return false } var b bool if err := json.Unmarshal(raw, &b); err == nil { return b } var s string if err := json.Unmarshal(raw, &s); err == nil { s = strings.TrimSpace(strings.ToLower(s)) return s != "" && s != "false" && s != "0" && s != "null" } return true } type listResponse struct { List []Drama `json:"list"` } type detailResponse struct { Title string `json:"title"` Name string `json:"name"` Chapters []Chapter `json:"chapters"` } func pkcs7Pad(src []byte, blockSize int) []byte { pad := blockSize - len(src)%blockSize return append(src, bytes.Repeat([]byte{byte(pad)}, pad)...) } func pkcs7Unpad(src []byte, blockSize int) ([]byte, error) { if len(src) == 0 || len(src)%blockSize != 0 { return nil, errors.New("invalid pkcs7 length") } pad := int(src[len(src)-1]) if pad == 0 || pad > blockSize || pad > len(src) { return nil, errors.New("invalid pkcs7 padding") } for _, v := range src[len(src)-pad:] { if int(v) != pad { return nil, errors.New("invalid pkcs7 padding bytes") } } return src[:len(src)-pad], nil } func encryptParam(obj any, key, iv []byte) (string, error) { plain, err := json.Marshal(obj) if err != nil { return "", err } block, err := aes.NewCipher(key) if err != nil { return "", err } padded := pkcs7Pad(plain, block.BlockSize()) out := make([]byte, len(padded)) cipher.NewCBCEncrypter(block, iv).CryptBlocks(out, padded) return base64.StdEncoding.EncodeToString(out), nil } func sha256Bytes(b []byte) []byte { h := sha256.Sum256(b) return h[:] } func decryptResponse(dataStr string, interfaceKey []byte) ([]byte, error) { o, err := base64.StdEncoding.DecodeString(strings.TrimSpace(dataStr)) if err != nil { return nil, err } if len(o) < 12 { return nil, errors.New("encrypted payload too short") } i := o[:12] r := append(append([]byte{}, interfaceKey...), i...) l := len(r) >> 1 s := sha256Bytes(r)[8:24] c := append(append([]byte{}, s...), r[:l]...) a := append(append([]byte{}, r[l:]...), s...) d := sha256Bytes(c) f := sha256Bytes(a) m := append(append(append([]byte{}, d[:8]...), f[8:24]...), d[24:]...) p := append(append(append([]byte{}, f[:4]...), d[12:20]...), f[28:]...) g := o[12:] block, err := aes.NewCipher(m) if err != nil { return nil, err } if len(g)%block.BlockSize() != 0 { return nil, errors.New("ciphertext is not block aligned") } plain := make([]byte, len(g)) cipher.NewCBCDecrypter(block, p).CryptBlocks(plain, g) return pkcs7Unpad(plain, block.BlockSize()) } func (d *Downloader) fetchAPI(ctx context.Context, apiPath string, params any, out any) error { for attempt := 0; attempt < 2; attempt++ { access, err := d.legacyCredentials(ctx) if err != nil { return err } payload, err := d.legacyRequest(ctx, http.MethodGet, apiPath, params, access) var apiErr *legacyAPIError if attempt == 0 && access.Anonymous && errors.As(err, &apiErr) && apiErr.code == "5005" { d.invalidateLegacyToken(access) continue } if err != nil { return err } return json.Unmarshal(payload, out) } return errors.New("黄果访客登录已失效,请稍后重试") } type legacyAPIError struct{ code, message string } func (err *legacyAPIError) Error() string { return fmt.Sprintf("黄果 API 错误 %s: %s", err.code, err.message) } func (d *Downloader) legacyRequest(ctx context.Context, method, apiPath string, params any, access legacyAccess) ([]byte, error) { var lastErr error attempts := d.cfg.Retries if attempts < 1 { attempts = 1 } for attempt := 1; attempt <= attempts; attempt++ { if attempt > 1 { select { case <-time.After(time.Duration(attempt) * time.Second): case <-ctx.Done(): return nil, ctx.Err() } } fullPath := apiPath var requestBody io.Reader if params != nil { enc, err := encryptParam(params, []byte(access.ParamKey), []byte(access.ParamIV)) if err != nil { return nil, err } if method == http.MethodGet { sep := "?" if strings.Contains(fullPath, "?") { sep = "&" } fullPath += sep + "data=" + url.QueryEscape(enc) } else { body, _ := json.Marshal(map[string]string{"data": enc}) requestBody = bytes.NewReader(body) } } base, err := d.apiEndpoint(ctx) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, method, base+fullPath, requestBody) if err != nil { return nil, err } if access.Token != "" { req.Header.Set("Authorization", access.Token) } if requestBody != nil { req.Header.Set("Content-Type", "application/json") } req.Header.Set("Accept", "application/json") req.Header.Set("temp", "test") req.Header.Set("X-User-Agent", access.userAgent()) req.Header.Set("Origin", legacyFrontendURL) req.Header.Set("Referer", legacyFrontendURL+"/") req.Header.Set("User-Agent", userAgent) resp, err := d.doCatalogRequest(req) if err != nil { lastErr = publicError(err) var backoff *requestBackoff if errors.As(err, &backoff) || ctx.Err() != nil { return nil, lastErr } d.resetAPIEndpoint(base) continue } body, readErr := io.ReadAll(io.LimitReader(resp.Body, providerMaxBodyBytes+1)) _ = resp.Body.Close() if readErr != nil { lastErr = readErr continue } if len(body) > providerMaxBodyBytes { return nil, errors.New("API 响应过大") } if resp.StatusCode < 200 || resp.StatusCode >= 300 { lastErr = d.catalogResponseError(req, resp, body) if resp.StatusCode >= 400 && resp.StatusCode < 500 { return nil, lastErr } continue } var env apiEnvelope if err := json.Unmarshal(body, &env); err != nil { lastErr = err continue } if code := strings.Trim(string(env.Code), `"`); code != "" && code != "null" && code != "200" && code != "0" { message := firstNonEmpty(env.Msg, "请检查访问配置或刷新登录凭证") if access.Token != "" { message = strings.ReplaceAll(message, access.Token, "[已隐藏]") } return nil, &legacyAPIError{code: truncate(code, 20), message: truncate(message, 160)} } payload := env.Data if apiHashEnabled(env.Hash) && len(env.Data) > 0 { var encrypted string if err := json.Unmarshal(env.Data, &encrypted); err != nil { lastErr = err continue } plain, err := decryptResponse(encrypted, []byte(access.InterfaceKey)) if err != nil { lastErr = err continue } payload = plain } if len(payload) == 0 || string(payload) == "null" { return nil, errors.New("黄果 API 未返回数据,请检查登录凭证或接口是否变更") } return payload, nil } return nil, lastErr } func (d *Downloader) fetchCloudFrontDramas(ctx context.Context) ([]Drama, error) { var activeTabs legacyTabList if err := d.fetchAPI(ctx, "/api/app/playlet-tab/all", nil, &activeTabs); err != nil { return nil, err } if len(activeTabs) == 0 { return nil, errors.New("黄果 API 未返回可用分类") } sortTypes := []string{"0", "1", "6"} seen := map[string]bool{} var unique []Drama var errs []string for _, sortType := range sortTypes { for _, tab := range activeTabs { if tab.ID == "" { continue } for page := 1; page <= d.cfg.MaxPagesPerSort; page++ { if err := ctx.Err(); err != nil { return unique, err } var lr listResponse err := d.fetchAPI(ctx, "/api/app/playlet/home/tab/"+tab.ID, map[string]string{ "pageNumber": strconv.Itoa(page), "pageSize": strconv.Itoa(d.cfg.PageSize), "tabSortType": sortType, }, &lr) if err != nil { msg := fmt.Sprintf("频道[%s] 排序[%s] 第%d页失败: %v", tab.Name, sortType, page, err) fmt.Printf(" %s\n", msg) errs = append(errs, msg) break } if len(lr.List) == 0 { break } added := 0 var batch []Drama for _, item := range lr.List { if item.ID == "" || seen[item.ID] { continue } item.ChannelName = tab.Name item.Source = "cloudfront" item.CategoryName = firstNonEmpty(item.CategoryName, item.CategoryNameSnake, item.Category, tab.Name) seen[item.ID] = true unique = append(unique, item) batch = append(batch, item) added++ } reportLibraryProgress(ctx, "cloudfront", batch, nil, false) if added == 0 || len(lr.List) < d.cfg.PageSize { break } } } } if len(errs) > 0 { return unique, errors.New(strings.Join(errs, "\n")) } return unique, nil }