// 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 搜索播客 func (p *ITunesProvider) SearchPodcasts(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.NewRequest("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) p.Logger.Infof("response code: %d", resp.StatusCode) if err != nil { return nil, fmt.Errorf("发送请求失败: %w", err) } defer resp.Body.Close() // 检查响应状态 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 }