Files
podcast-search-go/search/itunes_search.go
2025-03-28 22:27:08 +08:00

481 lines
11 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"
"strconv"
"time"
"gitea.malaihome.work/ans/podcast_search/model"
)
// 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
}
// NewITunesProvider 创建新的iTunes搜索提供者
func NewITunesProvider() *ITunesProvider {
return &ITunesProvider{
SearchAPIEndpoint: "https://itunes.apple.com/search",
FeedAPIEndpoint: "https://itunes.apple.com",
GenresMap: initITunesGenres(),
Client: &http.Client{Timeout: 20 * time.Second},
}
}
// 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
// 流派
Genre string
// 属性
Attribute string
// 限制结果数量
Limit int
// 语言
Language string
// 是否包含限制内容
Explicit bool
}
// ChartsOptions 热门播客选项
type ChartsOptions struct {
// 国家
Country string
// 语言
Language string
// 限制结果数量
Limit int
// 是否包含限制内容
Explicit bool
// 流派
Genre string
// 额外的查询参数
QueryParams map[string]string
}
// SearchPodcasts 搜索播客
func (p *ITunesProvider) SearchPodcasts(term string, options *SearchOptions) (*model.SearchResult, 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 options.Genre != "" {
if genreID, ok := p.GenresMap[options.Genre]; ok && genreID != -1 {
params.Add("genreId", strconv.Itoa(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", "Yes")
} else {
params.Add("explicit", "No")
}
}
// 构建URL
url := fmt.Sprintf("%s?%s", p.SearchAPIEndpoint, params.Encode())
fmt.Printf("url: %s\n", url)
// 发送请求
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
// 设置User-Agent
req.Header.Set("User-Agent", "PodcastSearch-Go/0.1")
// 发送请求
resp, err := p.Client.Do(req)
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)
}
fmt.Printf("body: %s\n", string(body))
// 解析JSON响应
var response struct {
ResultCount int `json:"resultCount"`
Results []map[string]interface{} `json:"results"`
}
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
// 创建搜索结果
result := &model.SearchResult{
ResultCount: response.ResultCount,
Successful: true,
Items: make([]model.Item, 0, len(response.Results)),
ProcessedTime: time.Now(),
}
// 解析结果
for _, itemData := range response.Results {
item := model.Item{}
// 提取基本信息
if v, ok := itemData["collectionId"]; ok {
if id, ok := v.(float64); ok {
item.CollectionID = int(id)
}
}
if v, ok := itemData["collectionName"]; ok {
if name, ok := v.(string); ok {
item.Title = name
}
}
if v, ok := itemData["artistName"]; ok {
if name, ok := v.(string); ok {
item.Author = name
}
}
if v, ok := itemData["feedUrl"]; ok {
if url, ok := v.(string); ok {
item.FeedURL = url
}
}
if v, ok := itemData["artworkUrl100"]; ok {
if url, ok := v.(string); ok {
item.ArtworkURL = url
}
}
if v, ok := itemData["artworkUrl600"]; ok {
if url, ok := v.(string); ok {
item.ArtworkURL600 = url
}
}
if v, ok := itemData["genres"]; ok {
if genres, ok := v.([]interface{}); ok {
for _, g := range genres {
if genre, ok := g.(string); ok {
item.Genres = append(item.Genres, genre)
}
}
}
}
result.Items = append(result.Items, item)
}
return result, nil
}
// Charts 获取热门播客列表
// 可选参数包括国家、语言、结果数量限制、是否包含限制内容和流派
// 由于热门播客列表以feed形式返回为了与SearchResult兼容需要解析feed并获取每个项目的底层结果
// 这会导致每个结果都有一个HTTP调用鉴于热门播客列表更新不频繁建议客户端缓存结果
func (p *ITunesProvider) Charts(options *ChartsOptions) (*model.SearchResult, error) {
// 构建URL
url := p.buildChartsURL(options)
// 发送请求
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
// 设置User-Agent
req.Header.Set("User-Agent", "PodcastSearch-Go/0.1")
// 发送请求
resp, err := p.Client.Do(req)
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)
}
// 解析JSON响应
var response struct {
Feed struct {
Entry []map[string]interface{} `json:"entry"`
} `json:"feed"`
}
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
// 创建搜索结果
result := &model.SearchResult{
Successful: true,
Items: make([]model.Item, 0),
ProcessedTime: time.Now(),
}
// 解析结果
if response.Feed.Entry != nil {
for _, entry := range response.Feed.Entry {
// 获取播客ID
var id string
if idObj, ok := entry["id"]; ok {
if idMap, ok := idObj.(map[string]interface{}); ok {
if attrs, ok := idMap["attributes"]; ok {
if attrsMap, ok := attrs.(map[string]interface{}); ok {
if imID, ok := attrsMap["im:id"]; ok {
id = fmt.Sprintf("%v", imID)
}
}
}
}
}
if id == "" {
continue
}
// 获取播客标题
// 不再使用title变量直接在需要时获取
if titleObj, ok := entry["title"]; ok {
// 移除未使用的titleMap变量
if _, ok := titleObj.(map[string]interface{}); ok {
// 处理标题信息,如果需要使用
}
}
// 为每个播客获取详细信息
lookupURL := fmt.Sprintf("%s/lookup?id=%s", p.FeedAPIEndpoint, id)
lookupReq, err := http.NewRequest("GET", lookupURL, nil)
if err != nil {
continue
}
lookupReq.Header.Set("User-Agent", "PodcastSearch-Go/0.1")
lookupResp, err := p.Client.Do(lookupReq)
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 lookupResponse struct {
ResultCount int `json:"resultCount"`
Results []map[string]interface{} `json:"results"`
}
if err := json.Unmarshal(lookupBody, &lookupResponse); err != nil {
continue
}
if lookupResponse.ResultCount > 0 && len(lookupResponse.Results) > 0 {
itemData := lookupResponse.Results[0]
item := model.Item{}
// 提取基本信息
if v, ok := itemData["collectionId"]; ok {
if id, ok := v.(float64); ok {
item.CollectionID = int(id)
}
}
if v, ok := itemData["collectionName"]; ok {
if name, ok := v.(string); ok {
item.Title = name
}
}
if v, ok := itemData["artistName"]; ok {
if name, ok := v.(string); ok {
item.Author = name
}
}
if v, ok := itemData["feedUrl"]; ok {
if url, ok := v.(string); ok {
item.FeedURL = url
}
}
if v, ok := itemData["artworkUrl100"]; ok {
if url, ok := v.(string); ok {
item.ArtworkURL = url
}
}
if v, ok := itemData["artworkUrl600"]; ok {
if url, ok := v.(string); ok {
item.ArtworkURL600 = url
}
}
if v, ok := itemData["genres"]; ok {
if genres, ok := v.([]interface{}); ok {
for _, g := range genres {
if genre, ok := g.(string); ok {
item.Genres = append(item.Genres, genre)
}
}
}
}
result.Items = append(result.Items, item)
}
}
}
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 += "20"
}
// 添加流派
if options.Genre != "" {
if genreID, ok := p.GenresMap[options.Genre]; ok && genreID != -1 {
buf += "/genre=" + strconv.Itoa(genreID)
}
}
// 添加限制内容设置
buf += "/explicit="
if options.Explicit {
buf += "Yes"
} else {
buf += "No"
}
// 添加JSON格式
buf += "/json"
return buf
}