Files
podcast-search-go/search/itunes_search.go
T
ansandClaude Fable 5 6e41fae8a1 feat: SearchPodcasts 支持 context 透传并修复 resp nil 解引用
与 Charts/LoadFeed 同型:新增 SearchPodcastsContext(原方法转发
context.Background() 保持兼容);resp.StatusCode 日志移到 err 检查后,
消除网络错误/ctx 取消时的 nil 解引用 panic。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 09:51:36 +08:00

392 lines
10 KiB
Go

// Package search 提供播客搜索功能
package search
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
"runtime"
"strconv"
"strings"
"time"
"gitea.malai.tech/ans/podcast-search-go/model"
"github.com/sirupsen/logrus"
"go.uber.org/ratelimit"
)
// SearchProvider 定义搜索提供者接口
type SearchProvider interface {
// SearchPodcasts 搜索播客
SearchPodcasts(term string, options *SearchOptions) (*model.SearchResult, error)
// Charts 获取热门播客列表
Charts(options *ChartsOptions) (*model.SearchResult, error)
// Genres 获取可用的播客流派列表
Genres() []string
}
// ITunesProvider 实现iTunes搜索提供者
type ITunesProvider struct {
// 搜索API端点
SearchAPIEndpoint string
// Feed API端点
FeedAPIEndpoint string
// 流派映射
GenresMap map[string]int
// HTTP客户端
Client *http.Client
Limiter ratelimit.Limiter
Logger *logrus.Logger
}
// NewITunesProvider 创建新的iTunes搜索提供者
func NewITunesProvider() *ITunesProvider {
// 初始化日志
// 启用调用者信息记录
Logger := logrus.New()
Logger.SetReportCaller(true)
Logger.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
TimestampFormat: "2006-01-02 15:04:05.000",
CallerPrettyfier: func(f *runtime.Frame) (string, string) {
s := strings.Split(f.Function, ".")
funcname := s[len(s)-1]
_, filename := path.Split(f.File)
return funcname, fmt.Sprintf("%s:%d", filename, f.Line)
},
})
return &ITunesProvider{
SearchAPIEndpoint: "https://itunes.apple.com/search",
FeedAPIEndpoint: "https://itunes.apple.com",
GenresMap: initITunesGenres(),
Client: &http.Client{Timeout: 20 * time.Second},
Limiter: ratelimit.New(20, ratelimit.Per(time.Minute)), // 20 RPM
Logger: Logger,
}
}
// initITunesGenres 初始化iTunes流派映射
func initITunesGenres() map[string]int {
return map[string]int{
"": -1,
"Arts": 1301,
"Business": 1321,
"Comedy": 1303,
"Education": 1304,
"Fiction": 1483,
"Government": 1511,
"Health & Fitness": 1512,
"History": 1487,
"Kids & Family": 1305,
"Leisure": 1502,
"Music": 1301,
"News": 1489,
"Religion & Spirituality": 1314,
"Science": 1533,
"Society & Culture": 1324,
"Sports": 1545,
"TV & Film": 1309,
"Technology": 1318,
"True Crime": 1488,
}
}
// SearchOptions 搜索选项
type SearchOptions struct {
// 国家
Country string
// 流派
GenreID string
// 属性
Attribute string
// 限制结果数量
Limit int
// 语言
Language string
// 是否包含限制内容
Explicit bool
}
// ChartsOptions 热门播客选项
type ChartsOptions struct {
// 国家
Country string
// 语言
Language string
// 限制结果数量
Limit int
// 是否包含限制内容
Explicit bool
// 流派
GenreID string
// 额外的查询参数
QueryParams map[string]string
}
// SearchPodcasts 搜索播客(无取消语义,等价于 SearchPodcastsContext(context.Background(), ...))。
func (p *ITunesProvider) SearchPodcasts(term string, options *SearchOptions) (*model.SearchResult2, error) {
return p.SearchPodcastsContext(context.Background(), term, options)
}
// SearchPodcastsContext 搜索播客,请求挂在调用方 ctx 上:ctx 取消/超时即中断。
func (p *ITunesProvider) SearchPodcastsContext(ctx context.Context, term string, options *SearchOptions) (*model.SearchResult2, error) {
if term == "" {
return nil, fmt.Errorf("搜索词不能为空")
}
// 构建查询参数
params := url.Values{}
params.Add("term", term)
params.Add("entity", "podcast")
params.Add("media", "podcast")
if options != nil {
if options.Country != "" {
params.Add("country", options.Country)
}
if len(options.GenreID) > 0 {
params.Add("genreId", options.GenreID)
}
if options.Attribute != "" {
params.Add("attribute", options.Attribute)
}
if options.Limit > 0 {
params.Add("limit", strconv.Itoa(options.Limit))
}
if options.Language != "" {
params.Add("language", options.Language)
}
if options.Explicit {
params.Add("explicit", "true")
} else {
params.Add("explicit", "false")
}
}
// 构建URL
url := fmt.Sprintf("%s?%s", p.SearchAPIEndpoint, params.Encode())
p.Logger.Infof("url: %s", url)
// 发送请求
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
// 设置User-Agent
req.Header.Set("User-Agent", "podcast-search-go/0.1")
// 发送请求
p.Limiter.Take()
resp, err := p.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("发送请求失败: %w", err)
}
defer resp.Body.Close()
p.Logger.Infof("response code: %d", resp.StatusCode)
// 检查响应状态
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API请求失败,状态码: %d", resp.StatusCode)
}
// 读取响应内容
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
// 创建搜索结果
result := &model.SearchResult2{
Items: []model.ApplePodcast{},
ProcessedTime: time.Now(),
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
p.Logger.Infof("search result, got %d items", len(result.Items))
result.ResultCount = len(result.Items)
result.Successful = true
return result, nil
}
// Charts 获取热门播客列表(无取消语义,等价于 ChartsContext(context.Background(), options))。
func (p *ITunesProvider) Charts(options *ChartsOptions) (*model.SearchResult2, error) {
return p.ChartsContext(context.Background(), options)
}
// ChartsContext 获取热门播客列表,榜单抓取与逐项 lookup 的全部请求都挂在调用方 ctx 上:
// ctx 取消即中断当前请求并停止剩余 lookup。
// 可选参数包括国家、语言、结果数量限制、是否包含限制内容和流派
// 由于热门播客列表以feed形式返回,为了与SearchResult兼容,需要解析feed并获取每个项目的底层结果
// 这会导致每个结果都有一个HTTP调用,鉴于热门播客列表更新不频繁,建议客户端缓存结果
func (p *ITunesProvider) ChartsContext(ctx context.Context, options *ChartsOptions) (*model.SearchResult2, error) {
// 构建URL
url := p.buildChartsURL(options)
p.Logger.Infof("url: %s", url)
// 发送请求
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
// 设置User-Agent
req.Header.Set("User-Agent", "podcast-search-go/0.1")
// 发送请求
p.Limiter.Take()
resp, err := p.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("发送请求失败: %w", err)
}
defer resp.Body.Close()
p.Logger.Infof("response code: %d", resp.StatusCode)
// 检查响应状态
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API请求失败,状态码: %d", resp.StatusCode)
}
// 读取响应内容
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
var chartResult model.ChartResult
if err := json.Unmarshal(body, &chartResult); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
p.Logger.Infof("chart result, got %d items", len(chartResult.Feed.Entry))
// 创建搜索结果
result := &model.SearchResult2{
Items: make([]model.ApplePodcast, 0),
}
// 解析结果
if chartResult.Feed.Entry != nil {
cnt := 1
for _, entry := range chartResult.Feed.Entry {
// 调用方取消:停止剩余 lookup,立即返回。
if ctx.Err() != nil {
return nil, ctx.Err()
}
// 获取播客ID
id := entry.ID.Attributes.ID
if id == "" {
continue
}
// 为每个播客获取详细信息
lookupURL := fmt.Sprintf("%s/lookup?id=%s", p.FeedAPIEndpoint, id)
p.Logger.Infof("%03d lookup url: %s", cnt, lookupURL)
cnt++
lookupReq, err := http.NewRequestWithContext(ctx, "GET", lookupURL, nil)
if err != nil {
continue
}
lookupReq.Header.Set("User-Agent", "podcast-search-go/0.1")
p.Limiter.Take()
lookupResp, err := p.Client.Do(lookupReq)
if err != nil {
continue
}
p.Logger.Infof("lookup response code: %d", lookupResp.StatusCode)
if lookupResp.StatusCode != http.StatusOK {
lookupResp.Body.Close()
continue
}
lookupBody, err := io.ReadAll(lookupResp.Body)
lookupResp.Body.Close()
if err != nil {
continue
}
var lookupResult model.LookupResult
if err := json.Unmarshal(lookupBody, &lookupResult); err != nil {
continue
}
if lookupResult.ResultCount > 0 && len(lookupResult.Items) > 0 {
p.Logger.Infof("lookup result, collection id: %d, collection name: %s", lookupResult.Items[0].CollectionID, lookupResult.Items[0].CollectionName)
lookupResult.Items[0].Summary = entry.Summary.Label
result.Items = append(result.Items, lookupResult.Items[0])
} else {
p.Logger.Infof("lookup result, no result")
}
}
result.ResultCount = len(result.Items)
}
// result.ResultCount = len(result.Items)
return result, nil
}
// Genres 返回可用的播客流派列表
func (p *ITunesProvider) Genres() []string {
genres := make([]string, 0, len(p.GenresMap))
for genre := range p.GenresMap {
if genre != "" {
genres = append(genres, genre)
}
}
return genres
}
// buildChartsURL 构建热门播客URL
func (p *ITunesProvider) buildChartsURL(options *ChartsOptions) string {
buf := p.FeedAPIEndpoint
// 添加国家代码
if options.Country != "" {
buf += "/" + options.Country
} else {
buf += "/us"
}
// 添加RSS路径和限制
buf += "/rss/toppodcasts/limit="
if options.Limit > 0 {
buf += strconv.Itoa(options.Limit)
} else {
buf += "200"
}
// 添加流派
if len(options.GenreID) > 0 {
buf += "/genre=" + options.GenreID
}
// 添加限制内容设置
buf += "/explicit="
if options.Explicit {
buf += "true"
} else {
buf += "false"
}
// 添加JSON格式
buf += "/json"
return buf
}