feat(search): add logging, rate limiting, and genre ID support

Enhance the ITunesProvider by adding logging with detailed caller information, implementing rate limiting to prevent API abuse, and supporting genre IDs for more precise searches. Also, update the User-Agent string and improve error handling and logging throughout the search and charts functionality.
This commit is contained in:
2025-03-30 00:14:24 +08:00
parent bd270a09d0
commit 0e04abf4b8
5 changed files with 78 additions and 24 deletions

4
go.mod
View File

@@ -5,8 +5,12 @@ go 1.23
require github.com/mmcdole/gofeed v1.3.0 require github.com/mmcdole/gofeed v1.3.0
require ( require (
github.com/benbjohnson/clock v1.3.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
golang.org/x/sys v0.5.0 // indirect
) )
require ( require (

12
go.sum
View File

@@ -2,6 +2,8 @@ github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0g
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= 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 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -23,15 +25,23 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0=
go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 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 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 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.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 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
@@ -39,6 +49,8 @@ golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"gitea.malaihome.work/ans/podcast-search-go/model"
"gitea.malaihome.work/ans/podcast-search-go/search" "gitea.malaihome.work/ans/podcast-search-go/search"
) )
@@ -11,12 +12,12 @@ func main() {
provider := search.NewITunesProvider() provider := search.NewITunesProvider()
// podcast, err := provider.SearchPodcasts("golang", &search.SearchOptions{Country: "us", Language: "en-us"}) // podcast, err := provider.SearchPodcasts("golang", &search.SearchOptions{Country: "us", Language: "en-us"})
podcast, err := provider.Charts(&search.ChartsOptions{Country: "us", Language: "en-us", Limit: 10}) podcast, err := provider.Charts(&search.ChartsOptions{Country: "us", GenreID: model.GetGenreID("en-US", "Art"), Limit: 3})
if err != nil { if err != nil {
panic(err) panic(err)
} }
// print podcast to json // print podcast to json
fmt.Printf("podcast: %+v\n", podcast) fmt.Printf("podcast count: %+v\n", len(podcast.Items))
} }

View File

@@ -108,3 +108,13 @@ func GetGenreByLanguage(language string) map[string]string {
} }
return m return m
} }
func GetGenreID(language string, genre string) string {
for genreId, v := range Genres {
genreOffical, ok := v[language]
if ok && genreOffical == genre {
return genreId
}
}
return ""
}

View File

@@ -7,10 +7,15 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"path"
"runtime"
"strconv" "strconv"
"strings"
"time" "time"
"gitea.malaihome.work/ans/podcast-search-go/model" "gitea.malaihome.work/ans/podcast-search-go/model"
"github.com/sirupsen/logrus"
"go.uber.org/ratelimit"
) )
// SearchProvider 定义搜索提供者接口 // SearchProvider 定义搜索提供者接口
@@ -32,16 +37,35 @@ type ITunesProvider struct {
// 流派映射 // 流派映射
GenresMap map[string]int GenresMap map[string]int
// HTTP客户端 // HTTP客户端
Client *http.Client Client *http.Client
Limiter ratelimit.Limiter
Logger *logrus.Logger
} }
// NewITunesProvider 创建新的iTunes搜索提供者 // NewITunesProvider 创建新的iTunes搜索提供者
func NewITunesProvider() *ITunesProvider { func NewITunesProvider() *ITunesProvider {
// 初始化日志
// 启用调用者信息记录
Logger := logrus.New()
Logger.SetReportCaller(true)
Logger.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
TimestampFormat: "2006-01-02 15:04:05.000",
CallerPrettyfier: func(f *runtime.Frame) (string, string) {
s := strings.Split(f.Function, ".")
funcname := s[len(s)-1]
_, filename := path.Split(f.File)
return funcname, fmt.Sprintf("%s:%d", filename, f.Line)
},
})
return &ITunesProvider{ return &ITunesProvider{
SearchAPIEndpoint: "https://itunes.apple.com/search", SearchAPIEndpoint: "https://itunes.apple.com/search",
FeedAPIEndpoint: "https://itunes.apple.com", FeedAPIEndpoint: "https://itunes.apple.com",
GenresMap: initITunesGenres(), GenresMap: initITunesGenres(),
Client: &http.Client{Timeout: 20 * time.Second}, Client: &http.Client{Timeout: 20 * time.Second},
Limiter: ratelimit.New(20, ratelimit.Per(time.Minute)), // 20 RPM
Logger: Logger,
} }
} }
@@ -76,7 +100,7 @@ type SearchOptions struct {
// 国家 // 国家
Country string Country string
// 流派 // 流派
Genre string GenreID string
// 属性 // 属性
Attribute string Attribute string
// 限制结果数量 // 限制结果数量
@@ -98,7 +122,7 @@ type ChartsOptions struct {
// 是否包含限制内容 // 是否包含限制内容
Explicit bool Explicit bool
// 流派 // 流派
Genre string GenreID string
// 额外的查询参数 // 额外的查询参数
QueryParams map[string]string QueryParams map[string]string
} }
@@ -120,10 +144,8 @@ func (p *ITunesProvider) SearchPodcasts(term string, options *SearchOptions) (*m
params.Add("country", options.Country) params.Add("country", options.Country)
} }
if options.Genre != "" { if len(options.GenreID) > 0 {
if genreID, ok := p.GenresMap[options.Genre]; ok && genreID != -1 { params.Add("genreId", options.GenreID)
params.Add("genreId", strconv.Itoa(genreID))
}
} }
if options.Attribute != "" { if options.Attribute != "" {
@@ -139,15 +161,15 @@ func (p *ITunesProvider) SearchPodcasts(term string, options *SearchOptions) (*m
} }
if options.Explicit { if options.Explicit {
params.Add("explicit", "Yes") params.Add("explicit", "true")
} else { } else {
params.Add("explicit", "No") params.Add("explicit", "false")
} }
} }
// 构建URL // 构建URL
url := fmt.Sprintf("%s?%s", p.SearchAPIEndpoint, params.Encode()) url := fmt.Sprintf("%s?%s", p.SearchAPIEndpoint, params.Encode())
// fmt.Printf("url: %s\n", url) p.Logger.Infof("url: %s", url)
// 发送请求 // 发送请求
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest("GET", url, nil)
@@ -156,10 +178,12 @@ func (p *ITunesProvider) SearchPodcasts(term string, options *SearchOptions) (*m
} }
// 设置User-Agent // 设置User-Agent
req.Header.Set("User-Agent", "PodcastSearch-Go/0.1") req.Header.Set("User-Agent", "podcast-search-go/0.1")
// 发送请求 // 发送请求
p.Limiter.Take()
resp, err := p.Client.Do(req) resp, err := p.Client.Do(req)
p.Logger.Infof("response code: %d", resp.StatusCode)
if err != nil { if err != nil {
return nil, fmt.Errorf("发送请求失败: %w", err) return nil, fmt.Errorf("发送请求失败: %w", err)
} }
@@ -175,7 +199,6 @@ func (p *ITunesProvider) SearchPodcasts(term string, options *SearchOptions) (*m
if err != nil { if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err) return nil, fmt.Errorf("读取响应失败: %w", err)
} }
// fmt.Printf("body: %s\n", string(body))
// 创建搜索结果 // 创建搜索结果
result := &model.SearchResult2{ result := &model.SearchResult2{
@@ -186,6 +209,7 @@ func (p *ITunesProvider) SearchPodcasts(term string, options *SearchOptions) (*m
if err := json.Unmarshal(body, &result); err != nil { if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err) return nil, fmt.Errorf("解析响应失败: %w", err)
} }
p.Logger.Infof("search result, got %d items", len(result.Items))
result.ResultCount = len(result.Items) result.ResultCount = len(result.Items)
result.Successful = true result.Successful = true
@@ -199,7 +223,7 @@ func (p *ITunesProvider) SearchPodcasts(term string, options *SearchOptions) (*m
func (p *ITunesProvider) Charts(options *ChartsOptions) (*model.SearchResult2, error) { func (p *ITunesProvider) Charts(options *ChartsOptions) (*model.SearchResult2, error) {
// 构建URL // 构建URL
url := p.buildChartsURL(options) url := p.buildChartsURL(options)
// fmt.Printf("url: %s\n", url) p.Logger.Infof("url: %s", url)
// 发送请求 // 发送请求
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest("GET", url, nil)
@@ -208,10 +232,13 @@ func (p *ITunesProvider) Charts(options *ChartsOptions) (*model.SearchResult2, e
} }
// 设置User-Agent // 设置User-Agent
req.Header.Set("User-Agent", "PodcastSearch-Go/0.1") req.Header.Set("User-Agent", "podcast-search-go/0.1")
// 发送请求 // 发送请求
p.Limiter.Take()
resp, err := p.Client.Do(req) resp, err := p.Client.Do(req)
p.Logger.Infof("response code: %d", resp.StatusCode)
if err != nil { if err != nil {
return nil, fmt.Errorf("发送请求失败: %w", err) return nil, fmt.Errorf("发送请求失败: %w", err)
} }
@@ -227,13 +254,12 @@ func (p *ITunesProvider) Charts(options *ChartsOptions) (*model.SearchResult2, e
if err != nil { if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err) 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 var chartResult model.ChartResult
if err := json.Unmarshal(body, &chartResult); err != nil { if err := json.Unmarshal(body, &chartResult); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err) return nil, fmt.Errorf("解析响应失败: %w", err)
} }
p.Logger.Infof("chart result, got %d items", len(chartResult.Feed.Entry))
// 创建搜索结果 // 创建搜索结果
result := &model.SearchResult2{ result := &model.SearchResult2{
@@ -252,13 +278,16 @@ func (p *ITunesProvider) Charts(options *ChartsOptions) (*model.SearchResult2, e
// 为每个播客获取详细信息 // 为每个播客获取详细信息
lookupURL := fmt.Sprintf("%s/lookup?id=%s", p.FeedAPIEndpoint, id) lookupURL := fmt.Sprintf("%s/lookup?id=%s", p.FeedAPIEndpoint, id)
p.Logger.Infof("lookup url: %s", lookupURL)
lookupReq, err := http.NewRequest("GET", lookupURL, nil) lookupReq, err := http.NewRequest("GET", lookupURL, nil)
if err != nil { if err != nil {
continue continue
} }
lookupReq.Header.Set("User-Agent", "PodcastSearch-Go/0.1") req.Header.Set("User-Agent", "podcast-search-go/0.1")
p.Limiter.Take()
lookupResp, err := p.Client.Do(lookupReq) lookupResp, err := p.Client.Do(lookupReq)
p.Logger.Infof("lookup response code: %d", lookupResp.StatusCode)
if err != nil { if err != nil {
continue continue
} }
@@ -273,12 +302,12 @@ func (p *ITunesProvider) Charts(options *ChartsOptions) (*model.SearchResult2, e
if err != nil { if err != nil {
continue continue
} }
// fmt.Printf("lookupBody: %s\n", string(lookupBody))
var lookupResult model.LookupResult var lookupResult model.LookupResult
if err := json.Unmarshal(lookupBody, &lookupResult); err != nil { if err := json.Unmarshal(lookupBody, &lookupResult); err != nil {
continue continue
} }
p.Logger.Infof("lookup result, collection id: %d, collection name: %s", lookupResult.Items[0].CollectionID, lookupResult.Items[0].CollectionName)
if lookupResult.ResultCount > 0 && len(lookupResult.Items) > 0 { if lookupResult.ResultCount > 0 && len(lookupResult.Items) > 0 {
lookupResult.Items[0].Summary = entry.Summary.Label lookupResult.Items[0].Summary = entry.Summary.Label
@@ -323,10 +352,8 @@ func (p *ITunesProvider) buildChartsURL(options *ChartsOptions) string {
} }
// 添加流派 // 添加流派
if options.Genre != "" { if len(options.GenreID) > 0 {
if genreID, ok := p.GenresMap[options.Genre]; ok && genreID != -1 { buf += "/genre=" + options.GenreID
buf += "/genre=" + strconv.Itoa(genreID)
}
} }
// 添加限制内容设置 // 添加限制内容设置