350 lines
8.2 KiB
Go
350 lines
8.2 KiB
Go
// Package search 提供播客搜索功能
|
||
package search
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"time"
|
||
|
||
"gitea.malaihome.work/ans/podcast-search-go/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.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 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 []model.ApplePodcast `json:"results"`
|
||
// }
|
||
|
||
// 创建搜索结果
|
||
result := &model.SearchResult2{
|
||
Items: []model.ApplePodcast{},
|
||
ProcessedTime: time.Now(),
|
||
}
|
||
|
||
if err := json.Unmarshal(body, &result); err != nil {
|
||
return nil, fmt.Errorf("解析响应失败: %w", err)
|
||
}
|
||
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)
|
||
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)[:3000])
|
||
// fmt.Printf("body: %s\n", string(body))
|
||
|
||
var chartResult model.ChartResult
|
||
if err := json.Unmarshal(body, &chartResult); err != nil {
|
||
return nil, fmt.Errorf("解析响应失败: %w", err)
|
||
}
|
||
|
||
// 创建搜索结果
|
||
result := &model.SearchResult2{
|
||
Items: make([]model.ApplePodcast, 0),
|
||
}
|
||
|
||
// 解析结果
|
||
if chartResult.Feed.Entry != nil {
|
||
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)
|
||
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
|
||
}
|
||
fmt.Printf("lookupBody: %s\n", string(lookupBody))
|
||
|
||
var tempResult model.LookupResult
|
||
if err := json.Unmarshal(lookupBody, &tempResult); err != nil {
|
||
continue
|
||
}
|
||
|
||
if tempResult.ResultCount > 0 && len(tempResult.Items) > 0 {
|
||
result.Items = append(result.Items, tempResult.Items[0])
|
||
}
|
||
}
|
||
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 options.Genre != "" {
|
||
if genreID, ok := p.GenresMap[options.Genre]; ok && genreID != -1 {
|
||
buf += "/genre=" + strconv.Itoa(genreID)
|
||
}
|
||
}
|
||
|
||
// 添加限制内容设置
|
||
buf += "/explicit="
|
||
if options.Explicit {
|
||
buf += "true"
|
||
} else {
|
||
buf += "false"
|
||
}
|
||
|
||
// 添加JSON格式
|
||
buf += "/json"
|
||
|
||
return buf
|
||
}
|