generated by Claude-3.7-Sonnet
This commit is contained in:
87
README.md
Normal file
87
README.md
Normal 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
17
go.mod
Normal 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
37
go.sum
Normal 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
21
main.go
Normal 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
40
model/apple_podcast.go
Normal 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
118
model/podcast.go
Normal 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
45
model/search.go
Normal 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
57
model/transcript.go
Normal 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
150
model/types.go
Normal 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
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
|
||||
}
|
||||
331
search/itunes_search_test.go
Normal file
331
search/itunes_search_test.go
Normal 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
91
utils/json_parser.go
Normal 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
107
utils/srt_parser.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user