generated by Claude-3.7-Sonnet
This commit is contained in:
480
search/itunes_search.go
Normal file
480
search/itunes_search.go
Normal file
@@ -0,0 +1,480 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user