Files
podcast-search-go/search/itunes_search.go
2025-03-30 01:07:21 +08:00

376 lines
9.2 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 search 提供播客搜索功能
package search
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
"runtime"
"strconv"
"strings"
"time"
"gitea.malaihome.work/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 获取热门播客列表
// 可选参数包括国家、语言、结果数量限制、是否包含限制内容和流派
// 由于热门播客列表以feed形式返回为了与SearchResult兼容需要解析feed并获取每个项目的底层结果
// 这会导致每个结果都有一个HTTP调用鉴于热门播客列表更新不频繁建议客户端缓存结果
func (p *ITunesProvider) Charts(options *ChartsOptions) (*model.SearchResult2, error) {
// 构建URL
url := p.buildChartsURL(options)
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)
}
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 {
// 获取播客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.NewRequest("GET", lookupURL, nil)
if err != nil {
continue
}
req.Header.Set("User-Agent", "podcast-search-go/0.1")
p.Limiter.Take()
lookupResp, err := p.Client.Do(lookupReq)
p.Logger.Infof("lookup response code: %d", lookupResp.StatusCode)
if err != nil {
continue
}
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
}