generated by Claude-3.7-Sonnet

This commit is contained in:
2025-03-28 22:27:08 +08:00
commit fdde34e1f0
13 changed files with 1581 additions and 0 deletions

87
README.md Normal file
View File

@@ -0,0 +1,87 @@
# Podcast Search for Go
这是一个用于搜索播客和解析播客RSS订阅的Go库。支持iTunes和PodcastIndex目录以及章节和转录等新功能。
## 功能
- 通过iTunes和PodcastIndex搜索播客
- 解析播客RSS订阅
- 获取播客剧集详情
- 支持播客章节和转录
## 安装
```bash
go get github.com/amugofjava/podcast_search/golang
```
## 使用方法
### 搜索播客
```go
package main
import (
"fmt"
"github.com/amugofjava/podcast_search/golang/search"
)
func main() {
// 创建iTunes搜索提供者
searchProvider := search.NewITunesProvider()
// 搜索播客
result, err := searchProvider.SearchPodcasts("golang", nil)
if err != nil {
fmt.Printf("搜索错误: %v\n", err)
return
}
// 打印结果
fmt.Printf("找到 %d 个播客\n", result.ResultCount)
for _, item := range result.Items {
fmt.Printf("标题: %s\n", item.Title)
fmt.Printf("作者: %s\n", item.Author)
fmt.Printf("Feed URL: %s\n\n", item.FeedURL)
}
}
```
### 加载播客订阅
```go
package main
import (
"fmt"
"github.com/amugofjava/podcast_search/golang/model"
)
func main() {
// 加载播客订阅
podcast, err := model.LoadFeed("https://feeds.simplecast.com/XA_851k3")
if err != nil {
fmt.Printf("加载订阅错误: %v\n", err)
return
}
// 打印播客信息
fmt.Printf("标题: %s\n", podcast.Title)
fmt.Printf("描述: %s\n", podcast.Description)
fmt.Printf("剧集数量: %d\n\n", len(podcast.Episodes))
// 打印剧集信息
for i, episode := range podcast.Episodes {
if i >= 3 {
break // 只显示前3个剧集
}
fmt.Printf("剧集: %s\n", episode.Title)
fmt.Printf("发布日期: %s\n\n", episode.PublicationDate)
}
}
```
## 许可证
此库使用MIT许可证。详情请参阅LICENSE文件。

17
go.mod Normal file
View File

@@ -0,0 +1,17 @@
module gitea.malaihome.work/ans/podcast_search
go 1.23
require github.com/mmcdole/gofeed v1.3.0
require (
github.com/PuerkitoBio/goquery v1.8.0 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/stretchr/testify v1.8.4 // indirect
golang.org/x/net v0.4.0 // indirect
golang.org/x/text v0.5.0 // indirect
)

37
go.sum Normal file
View File

@@ -0,0 +1,37 @@
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

21
main.go Normal file
View File

@@ -0,0 +1,21 @@
package main
import (
"fmt"
"gitea.malaihome.work/ans/podcast_search/search"
)
func main() {
provider := search.NewITunesProvider()
podcast, err := provider.SearchPodcasts("golang", &search.SearchOptions{Country: "us", Language: "en-us"})
if err != nil {
panic(err)
}
// print podcast to json
fmt.Printf("podcast: %+v\n", podcast)
}

40
model/apple_podcast.go Normal file
View File

@@ -0,0 +1,40 @@
// Package model 提供播客和剧集的数据模型
package model
import (
"time"
)
// Podcast 表示从Apple Podcasts API获取的播客信息
type ApplePodcast struct {
WrapperType string `json:"wrapperType,omitempty"`
Kind string `json:"kind,omitempty"`
CollectionID int `json:"collectionId,omitempty"`
TrackID int `json:"trackId,omitempty"`
ArtistName string `json:"artistName,omitempty"`
CollectionName string `json:"collectionName,omitempty"`
TrackName string `json:"trackName,omitempty"`
CollectionCensoredName string `json:"collectionCensoredName,omitempty"`
TrackCensoredName string `json:"trackCensoredName,omitempty"`
CollectionViewURL string `json:"collectionViewUrl,omitempty"`
FeedURL string `json:"feedUrl,omitempty"`
TrackViewURL string `json:"trackViewUrl,omitempty"`
ArtworkURL30 string `json:"artworkUrl30,omitempty"`
ArtworkURL60 string `json:"artworkUrl60,omitempty"`
ArtworkURL100 string `json:"artworkUrl100,omitempty"`
CollectionPrice float64 `json:"collectionPrice,omitempty"`
TrackPrice float64 `json:"trackPrice,omitempty"`
CollectionHDPrice float64 `json:"collectionHdPrice,omitempty"`
ReleaseDate time.Time `json:"releaseDate,omitempty"`
CollectionExplicitness string `json:"collectionExplicitness,omitempty"`
TrackExplicitness string `json:"trackExplicitness,omitempty"`
TrackCount int `json:"trackCount,omitempty"`
TrackTimeMillis int `json:"trackTimeMillis,omitempty"`
Country string `json:"country,omitempty"`
Currency string `json:"currency,omitempty"`
PrimaryGenreName string `json:"primaryGenreName,omitempty"`
ContentAdvisoryRating string `json:"contentAdvisoryRating,omitempty"`
ArtworkURL600 string `json:"artworkUrl600,omitempty"`
GenreIDs []string `json:"genreIds,omitempty"`
Genres []string `json:"genres,omitempty"`
}

118
model/podcast.go Normal file
View File

@@ -0,0 +1,118 @@
// Package model 提供播客和剧集的数据模型
package model
import (
"errors"
"fmt"
"github.com/mmcdole/gofeed"
)
// Podcast 表示一个播客及其剧集
type Podcast struct {
// 播客的唯一标识符
GUID string `json:"guid,omitempty"`
// 播客的URL
URL string `json:"url,omitempty"`
// 播客的链接
Link string `json:"link,omitempty"`
// 播客的标题
Title string `json:"title,omitempty"`
// 播客的描述
Description string `json:"description,omitempty"`
// 播客的图片URL
Image string `json:"image,omitempty"`
// 版权信息
Copyright string `json:"copyright,omitempty"`
// 是否锁定,表示此订阅是否可以被导入到其他平台
Locked *Locked `json:"locked,omitempty"`
// 资金支持信息
Funding []Funding `json:"funding,omitempty"`
// 人员信息
Persons []Person `json:"persons,omitempty"`
// 价值交换信息
Value []Value `json:"value,omitempty"`
// 阻止标签
Block []Block `json:"block,omitempty"`
// 剧集列表
Episodes []Episode `json:"episodes,omitempty"`
// 远程项目
RemoteItems []RemoteItem `json:"remote_items,omitempty"`
// 媒体类型
Medium Medium `json:"medium,omitempty"`
}
// LoadFeed 从URL加载播客RSS订阅
func LoadFeed(url string) (*Podcast, error) {
fp := gofeed.NewParser()
feed, err := fp.ParseURL(url)
if err != nil {
return nil, fmt.Errorf("解析播客订阅失败: %w", err)
}
if feed == nil {
return nil, errors.New("解析播客订阅失败: 空订阅")
}
podcast := &Podcast{
Title: feed.Title,
Description: feed.Description,
Link: feed.Link,
Image: feed.Image.URL,
Copyright: feed.Copyright,
Episodes: make([]Episode, 0),
Funding: make([]Funding, 0),
Persons: make([]Person, 0),
Value: make([]Value, 0),
Block: make([]Block, 0),
Medium: MediumPodcast,
}
// 解析剧集
for _, item := range feed.Items {
episode := Episode{
GUID: item.GUID,
Title: item.Title,
Description: item.Description,
Link: item.Link,
PublicationDate: item.PublishedParsed,
Author: item.Author.Name,
}
// 解析媒体URL
if len(item.Enclosures) > 0 {
episode.ContentURL = item.Enclosures[0].URL
}
// 解析剧集图片
if item.Image != nil && item.Image.URL != "" {
episode.ImageURL = item.Image.URL
}
// 解析时长
if item.ITunesExt != nil && item.ITunesExt.Duration != "" {
// 简单处理,实际应该解析时间格式
durationSec := 0
fmt.Sscanf(item.ITunesExt.Duration, "%d", &durationSec)
episode.Duration = durationSec
}
podcast.Episodes = append(podcast.Episodes, episode)
}
return podcast, nil
}

45
model/search.go Normal file
View File

@@ -0,0 +1,45 @@
// Package model 提供播客和剧集的数据模型
package model
import (
"time"
)
// Item 表示搜索结果中的一个播客项目
type Item struct {
// 播客的集合ID
CollectionID int `json:"collectionId,omitempty"`
// 播客的标题
Title string `json:"title,omitempty"`
// 播客的作者
Author string `json:"author,omitempty"`
// 播客的Feed URL
FeedURL string `json:"feedUrl,omitempty"`
// 播客的小尺寸封面图URL
ArtworkURL string `json:"artworkUrl,omitempty"`
// 播客的大尺寸封面图URL
ArtworkURL600 string `json:"artworkUrl600,omitempty"`
// 播客的流派
Genres []string `json:"genres,omitempty"`
}
// SearchResult 表示播客搜索的结果
type SearchResult struct {
// 结果数量
ResultCount int `json:"resultCount,omitempty"`
// 搜索是否成功
Successful bool `json:"successful,omitempty"`
// 搜索结果项目列表
Items []Item `json:"items,omitempty"`
// 处理时间
ProcessedTime time.Time `json:"processedTime,omitempty"`
}

57
model/transcript.go Normal file
View File

@@ -0,0 +1,57 @@
// Package model 提供播客和剧集的数据模型
package model
import (
"time"
)
// TranscriptFormat 表示转录文件的格式
type TranscriptFormat string
const (
// TranscriptFormatJSON 表示JSON格式的转录
TranscriptFormatJSON TranscriptFormat = "json"
// TranscriptFormatSubrip 表示SRT格式的转录
TranscriptFormatSubrip TranscriptFormat = "subrip"
// TranscriptFormatUnsupported 表示不支持的格式
TranscriptFormatUnsupported TranscriptFormat = "unsupported"
)
// TranscriptURL 表示播客中包含的转录URL
type TranscriptURL struct {
// 转录的URL
URL string `json:"url,omitempty"`
// 转录的类型json或srt
Type TranscriptFormat `json:"type,omitempty"`
// 转录的语言
Language string `json:"language,omitempty"`
// 如果设置为captions表示这是一个闭合字幕文件
Rel string `json:"rel,omitempty"`
}
// Transcript 表示一个转录
type Transcript struct {
// 字幕列表
Subtitles []Subtitle `json:"subtitles,omitempty"`
}
// Subtitle 表示转录中的一行
type Subtitle struct {
// 字幕的索引(顺序)
Index int `json:"index,omitempty"`
// 字幕的开始时间
Start time.Duration `json:"start,omitempty"`
// 字幕的结束时间
End time.Duration `json:"end,omitempty"`
// 字幕的文本内容
Data string `json:"data,omitempty"`
// 发言人(可选)
Speaker string `json:"speaker,omitempty"`
}

150
model/types.go Normal file
View File

@@ -0,0 +1,150 @@
// Package model 提供播客和剧集的数据模型
package model
import (
"time"
)
// Medium 表示媒体类型
type Medium string
const (
// MediumPodcast 表示播客媒体类型
MediumPodcast Medium = "podcast"
// MediumMusic 表示音乐媒体类型
MediumMusic Medium = "music"
// MediumAudiobook 表示有声书媒体类型
MediumAudiobook Medium = "audiobook"
)
// Locked 表示播客是否锁定
type Locked struct {
// 是否锁定
Locked bool `json:"locked,omitempty"`
// 锁定的所有者
Owner string `json:"owner,omitempty"`
}
// Funding 表示资金支持信息
type Funding struct {
// 资金支持URL
URL string `json:"url,omitempty"`
// 资金支持文本
Text string `json:"text,omitempty"`
}
// Person 表示人员信息
type Person struct {
// 人员名称
Name string `json:"name,omitempty"`
// 人员角色
Role string `json:"role,omitempty"`
// 人员组
Group string `json:"group,omitempty"`
// 人员头像URL
Img string `json:"img,omitempty"`
// 人员URL
Href string `json:"href,omitempty"`
}
// Value 表示价值交换信息
type Value struct {
// 类型
Type string `json:"type,omitempty"`
// 方法
Method string `json:"method,omitempty"`
// 建议
Suggested string `json:"suggested,omitempty"`
// 接收者
Recipients []ValueRecipient `json:"recipients,omitempty"`
}
// ValueRecipient 表示价值接收者
type ValueRecipient struct {
// 名称
Name string `json:"name,omitempty"`
// 类型
Type string `json:"type,omitempty"`
// 地址
Address string `json:"address,omitempty"`
// 分割
Split float64 `json:"split,omitempty"`
// 自定义键
CustomKey string `json:"customKey,omitempty"`
// 自定义值
CustomValue string `json:"customValue,omitempty"`
}
// Block 表示阻止标签
type Block struct {
// 阻止的ID
ID string `json:"id,omitempty"`
// 阻止的URL
URL string `json:"url,omitempty"`
}
// Episode 表示播客剧集
type Episode struct {
// 剧集的唯一标识符
GUID string `json:"guid,omitempty"`
// 剧集的标题
Title string `json:"title,omitempty"`
// 剧集的描述
Description string `json:"description,omitempty"`
// 剧集的链接
Link string `json:"link,omitempty"`
// 剧集的发布日期
PublicationDate *time.Time `json:"publicationDate,omitempty"`
// 剧集的作者
Author string `json:"author,omitempty"`
// 剧集的内容URL
ContentURL string `json:"contentUrl,omitempty"`
// 剧集的图片URL
ImageURL string `json:"imageUrl,omitempty"`
// 剧集的时长(秒)
Duration int `json:"duration,omitempty"`
// 剧集的转录URL列表
TranscriptURLs []TranscriptURL `json:"transcriptUrls,omitempty"`
}
// RemoteItem 表示远程项目
type RemoteItem struct {
// 远程项目的唯一标识符
GUID string `json:"guid,omitempty"`
// 远程项目的标题
Title string `json:"title,omitempty"`
// 远程项目的描述
Description string `json:"description,omitempty"`
// 远程项目的链接
Link string `json:"link,omitempty"`
// 远程项目的发布日期
PublicationDate *time.Time `json:"publicationDate,omitempty"`
// 远程项目的作者
Author string `json:"author,omitempty"`
// 远程项目的内容URL
ContentURL string `json:"contentUrl,omitempty"`
// 远程项目的图片URL
ImageURL string `json:"imageUrl,omitempty"`
// 远程项目的时长(秒)
Duration int `json:"duration,omitempty"`
// 远程项目的转录URL列表
TranscriptURLs []TranscriptURL `json:"transcriptUrls,omitempty"`
}

480
search/itunes_search.go Normal file
View 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
}

View File

@@ -0,0 +1,331 @@
package search
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestSearchPodcasts_EmptyTerm(t *testing.T) {
// 创建iTunes搜索提供者
provider := NewITunesProvider()
// 使用空搜索词调用SearchPodcasts
result, err := provider.SearchPodcasts("", nil)
// 验证返回错误
if err == nil {
t.Error("期望返回错误,但没有")
}
// 验证错误消息
expectedErrMsg := "搜索词不能为空"
if err.Error() != expectedErrMsg {
t.Errorf("期望错误消息为 %q但得到 %q", expectedErrMsg, err.Error())
}
// 验证结果为nil
if result != nil {
t.Errorf("期望结果为nil但得到 %+v", result)
}
}
func TestSearchPodcasts_SuccessfulSearch(t *testing.T) {
// 创建测试服务器
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 验证请求方法
if r.Method != http.MethodGet {
t.Errorf("期望请求方法为GET但得到 %s", r.Method)
}
// 验证请求路径
if r.URL.Path != "/search" {
t.Errorf("期望请求路径为/search但得到 %s", r.URL.Path)
}
// 验证查询参数
query := r.URL.Query()
if query.Get("term") != "golang" {
t.Errorf("期望term参数为golang但得到 %s", query.Get("term"))
}
if query.Get("entity") != "podcast" {
t.Errorf("期望entity参数为podcast但得到 %s", query.Get("entity"))
}
if query.Get("media") != "podcast" {
t.Errorf("期望media参数为podcast但得到 %s", query.Get("media"))
}
// 返回模拟响应
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// 创建模拟响应数据
response := map[string]interface{}{
"resultCount": 2,
"results": []map[string]interface{}{
{
"collectionId": 1234567890,
"collectionName": "Go编程语言",
"artistName": "Go开发者",
"feedUrl": "https://example.com/feed1",
"artworkUrl100": "https://example.com/artwork1_100.jpg",
"artworkUrl600": "https://example.com/artwork1_600.jpg",
"genres": []string{"Technology", "Education"},
},
{
"collectionId": 9876543210,
"collectionName": "Golang讨论",
"artistName": "Golang爱好者",
"feedUrl": "https://example.com/feed2",
"artworkUrl100": "https://example.com/artwork2_100.jpg",
"artworkUrl600": "https://example.com/artwork2_600.jpg",
"genres": []string{"Technology"},
},
},
}
// 将响应数据编码为JSON并写入响应
json.NewEncoder(w).Encode(response)
}))
defer server.Close()
// 创建iTunes搜索提供者并设置测试服务器URL
provider := &ITunesProvider{
SearchAPIEndpoint: server.URL,
FeedAPIEndpoint: "https://itunes.apple.com",
GenresMap: initITunesGenres(),
Client: &http.Client{Timeout: 20 * time.Second},
}
// 调用SearchPodcasts
result, err := provider.SearchPodcasts("golang", nil)
// 验证没有错误
if err != nil {
t.Fatalf("期望没有错误,但得到 %v", err)
}
// 验证结果
if result == nil {
t.Fatal("期望结果不为nil但得到nil")
}
// 验证结果计数
if result.ResultCount != 2 {
t.Errorf("期望结果计数为2但得到 %d", result.ResultCount)
}
// 验证结果成功标志
if !result.Successful {
t.Error("期望结果成功标志为true但得到false")
}
// 验证结果项目数量
if len(result.Items) != 2 {
t.Errorf("期望结果项目数量为2但得到 %d", len(result.Items))
}
// 验证第一个结果项目
item1 := result.Items[0]
if item1.CollectionID != 1234567890 {
t.Errorf("期望第一个项目的CollectionID为1234567890但得到 %d", item1.CollectionID)
}
if item1.Title != "Go编程语言" {
t.Errorf("期望第一个项目的Title为'Go编程语言',但得到 %s", item1.Title)
}
if item1.Author != "Go开发者" {
t.Errorf("期望第一个项目的Author为'Go开发者',但得到 %s", item1.Author)
}
if item1.FeedURL != "https://example.com/feed1" {
t.Errorf("期望第一个项目的FeedURL为'https://example.com/feed1',但得到 %s", item1.FeedURL)
}
if item1.ArtworkURL != "https://example.com/artwork1_100.jpg" {
t.Errorf("期望第一个项目的ArtworkURL为'https://example.com/artwork1_100.jpg',但得到 %s", item1.ArtworkURL)
}
if item1.ArtworkURL600 != "https://example.com/artwork1_600.jpg" {
t.Errorf("期望第一个项目的ArtworkURL600为'https://example.com/artwork1_600.jpg',但得到 %s", item1.ArtworkURL600)
}
if len(item1.Genres) != 2 || item1.Genres[0] != "Technology" || item1.Genres[1] != "Education" {
t.Errorf("期望第一个项目的Genres为[Technology, Education],但得到 %v", item1.Genres)
}
// 验证第二个结果项目
item2 := result.Items[1]
if item2.CollectionID != 9876543210 {
t.Errorf("期望第二个项目的CollectionID为9876543210但得到 %d", item2.CollectionID)
}
if item2.Title != "Golang讨论" {
t.Errorf("期望第二个项目的Title为'Golang讨论',但得到 %s", item2.Title)
}
}
func TestSearchPodcasts_WithOptions(t *testing.T) {
// 创建测试服务器
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 验证查询参数
query := r.URL.Query()
// 验证基本参数
if query.Get("term") != "golang" {
t.Errorf("期望term参数为golang但得到 %s", query.Get("term"))
}
// 验证选项参数
if query.Get("country") != "cn" {
t.Errorf("期望country参数为cn但得到 %s", query.Get("country"))
}
if query.Get("genreId") != "1318" { // Technology的ID
t.Errorf("期望genreId参数为1318但得到 %s", query.Get("genreId"))
}
if query.Get("attribute") != "titleTerm" {
t.Errorf("期望attribute参数为titleTerm但得到 %s", query.Get("attribute"))
}
if query.Get("limit") != "10" {
t.Errorf("期望limit参数为10但得到 %s", query.Get("limit"))
}
if query.Get("language") != "zh-cn" {
t.Errorf("期望language参数为zh-cn但得到 %s", query.Get("language"))
}
if query.Get("explicit") != "No" {
t.Errorf("期望explicit参数为No但得到 %s", query.Get("explicit"))
}
// 返回模拟响应
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// 创建模拟响应数据
response := map[string]interface{}{
"resultCount": 1,
"results": []map[string]interface{}{
{
"collectionId": 1234567890,
"collectionName": "Go编程语言",
"artistName": "Go开发者",
"feedUrl": "https://example.com/feed1",
"artworkUrl100": "https://example.com/artwork1_100.jpg",
"artworkUrl600": "https://example.com/artwork1_600.jpg",
"genres": []string{"Technology"},
},
},
}
// 将响应数据编码为JSON并写入响应
json.NewEncoder(w).Encode(response)
}))
defer server.Close()
// 创建iTunes搜索提供者并设置测试服务器URL
provider := &ITunesProvider{
SearchAPIEndpoint: server.URL,
FeedAPIEndpoint: "https://itunes.apple.com",
GenresMap: initITunesGenres(),
Client: &http.Client{Timeout: 20 * time.Second},
}
// 创建搜索选项
options := &SearchOptions{
Country: "cn",
Genre: "Technology",
Attribute: "titleTerm",
Limit: 10,
Language: "zh-cn",
Explicit: false,
}
// 调用SearchPodcasts
result, err := provider.SearchPodcasts("golang", options)
// 验证没有错误
if err != nil {
t.Fatalf("期望没有错误,但得到 %v", err)
}
// 验证结果
if result == nil {
t.Fatal("期望结果不为nil但得到nil")
}
// 验证结果计数
if result.ResultCount != 1 {
t.Errorf("期望结果计数为1但得到 %d", result.ResultCount)
}
// 验证结果项目数量
if len(result.Items) != 1 {
t.Errorf("期望结果项目数量为1但得到 %d", len(result.Items))
}
}
func TestSearchPodcasts_APIError(t *testing.T) {
// 创建测试服务器,返回错误状态码
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
// 创建iTunes搜索提供者并设置测试服务器URL
provider := &ITunesProvider{
SearchAPIEndpoint: server.URL,
FeedAPIEndpoint: "https://itunes.apple.com",
GenresMap: initITunesGenres(),
Client: &http.Client{Timeout: 20 * time.Second},
}
// 调用SearchPodcasts
result, err := provider.SearchPodcasts("golang", nil)
// 验证返回错误
if err == nil {
t.Error("期望返回错误,但没有")
}
// 验证错误消息包含状态码
expectedErrMsg := "API请求失败状态码: 500"
if err.Error() != expectedErrMsg {
t.Errorf("期望错误消息包含 %q但得到 %q", expectedErrMsg, err.Error())
}
// 验证结果为nil
if result != nil {
t.Errorf("期望结果为nil但得到 %+v", result)
}
}
func TestSearchPodcasts_InvalidJSON(t *testing.T) {
// 创建测试服务器返回无效的JSON
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"resultCount": 1, "results": [invalid json]`))
}))
defer server.Close()
// 创建iTunes搜索提供者并设置测试服务器URL
provider := &ITunesProvider{
SearchAPIEndpoint: server.URL,
FeedAPIEndpoint: "https://itunes.apple.com",
GenresMap: initITunesGenres(),
Client: &http.Client{Timeout: 20 * time.Second},
}
// 调用SearchPodcasts
result, err := provider.SearchPodcasts("golang", nil)
// 验证返回错误
if err == nil {
t.Error("期望返回错误,但没有")
}
// 验证错误消息包含解析失败
if err != nil && err.Error() != "解析响应失败: invalid character 'i' looking for beginning of value" {
t.Errorf("期望错误消息包含解析失败,但得到 %q", err.Error())
}
// 验证结果为nil
if result != nil {
t.Errorf("期望结果为nil但得到 %+v", result)
}
}

91
utils/json_parser.go Normal file
View File

@@ -0,0 +1,91 @@
// Package utils 提供工具函数
package utils
import (
"encoding/json"
"fmt"
"time"
"gitea.malaihome.work/ans/podcast_search/model"
)
// JsonParser 用于解析JSON格式的转录文件
type JsonParser struct{}
// NewJsonParser 创建新的JSON解析器
func NewJsonParser() *JsonParser {
return &JsonParser{}
}
// Parse 解析JSON格式的转录文件
func (p *JsonParser) Parse(jsonData string) (*model.Transcript, error) {
// 解析JSON
var data map[string]interface{}
if err := json.Unmarshal([]byte(jsonData), &data); err != nil {
return nil, fmt.Errorf("解析JSON失败: %w", err)
}
// 创建转录
transcript := &model.Transcript{
Subtitles: make([]model.Subtitle, 0),
}
// 解析版本
_, ok := data["version"].(string)
if !ok {
return nil, fmt.Errorf("无效的JSON格式: 缺少版本信息")
}
// 解析段落
segments, ok := data["segments"].([]interface{})
if !ok {
return nil, fmt.Errorf("无效的JSON格式: 缺少段落信息")
}
// 解析每个段落
for i, segment := range segments {
seg, ok := segment.(map[string]interface{})
if !ok {
continue
}
// 解析开始时间
startTime, ok := seg["startTime"].(float64)
if !ok {
continue
}
// 解析结束时间
endTime, ok := seg["endTime"].(float64)
if !ok {
continue
}
// 解析内容
body, ok := seg["body"].(string)
if !ok {
continue
}
// 解析发言人
speaker := ""
if s, ok := seg["speaker"].(string); ok {
speaker = s
}
// 创建字幕
subtitle := model.Subtitle{
Index: i,
Start: time.Duration(startTime * float64(time.Second)),
End: time.Duration(endTime * float64(time.Second)),
Data: body,
Speaker: speaker,
}
transcript.Subtitles = append(transcript.Subtitles, subtitle)
}
// 我们已经解析了版本信息但Transcript结构体中没有Version字段
// 所以这里只返回解析好的transcript对象
return transcript, nil
}

107
utils/srt_parser.go Normal file
View File

@@ -0,0 +1,107 @@
// Package utils 提供工具函数
package utils
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
"gitea.malaihome.work/ans/podcast_search/model"
)
// SrtParser 用于解析SRT格式的转录文件
type SrtParser struct {
// SRT格式匹配正则表达式
srtMatcher *regexp.Regexp
// 换行符匹配正则表达式
newlineMatcher *regexp.Regexp
}
// NewSrtParser 创建新的SRT解析器
func NewSrtParser() *SrtParser {
return &SrtParser{
srtMatcher: regexp.MustCompile(
`(\d*)\s?((\d{2}):(\d{2}):(\d{2})[,|.](\d{3})) +--> +((\d{2}):(\d{2}):(\d{2})[,|.](\d{3})).*[\r\n]+\s*([^\r\n]+(\n|\r|\n)(?:.*))`,
),
newlineMatcher: regexp.MustCompile(`[\r\n]+`),
}
}
// Parse 解析SRT格式的转录文件
func (p *SrtParser) Parse(srtData string) (*model.Transcript, error) {
matches := p.srtMatcher.FindAllStringSubmatch(srtData, -1)
subtitles := make([]model.Subtitle, 0)
i := 0
for _, match := range matches {
i++
// 索引可能存在也可能不存在
index := i
if match[1] != "" {
parsedIndex, err := strconv.Atoi(match[1])
if err == nil {
index = parsedIndex
}
}
// 解析开始时间
startTimeHours, _ := strconv.Atoi(match[3])
startTimeMinutes, _ := strconv.Atoi(match[4])
startTimeSeconds, _ := strconv.Atoi(match[5])
startTimeMilliseconds, _ := strconv.Atoi(match[6])
// 解析结束时间
endTimeHours, _ := strconv.Atoi(match[8])
endTimeMinutes, _ := strconv.Atoi(match[9])
endTimeSeconds, _ := strconv.Atoi(match[10])
endTimeMilliseconds, _ := strconv.Atoi(match[11])
// 计算开始和结束时间的Duration
startDuration := time.Duration(startTimeHours)*time.Hour +
time.Duration(startTimeMinutes)*time.Minute +
time.Duration(startTimeSeconds)*time.Second +
time.Duration(startTimeMilliseconds)*time.Millisecond
endDuration := time.Duration(endTimeHours)*time.Hour +
time.Duration(endTimeMinutes)*time.Minute +
time.Duration(endTimeSeconds)*time.Second +
time.Duration(endTimeMilliseconds)*time.Millisecond
// 解析文本内容
textLines := p.newlineMatcher.Split(match[12], -1)
text := ""
// 处理文本行
for _, line := range textLines {
if text == "" {
text = line
} else if strings.HasSuffix(text, " ") || strings.HasPrefix(line, " ") {
text += line
} else {
text += " " + line
}
}
// 创建字幕
subtitle := model.Subtitle{
Index: index,
Start: startDuration,
End: endDuration,
Data: strings.TrimSpace(text),
Speaker: "", // SRT格式通常不包含发言人信息
}
subtitles = append(subtitles, subtitle)
}
if len(subtitles) == 0 {
return nil, fmt.Errorf("解析SRT失败: 未找到有效的字幕")
}
return &model.Transcript{
Subtitles: subtitles,
}, nil
}