feat: feed 回源支持指定 User-Agent(FeedOptions)
新增 LoadFeedWithOptions(ctx, feedURL, FeedOptions{Proxy, Timeout, UserAgent}),
LoadFeed / LoadFeedContext 签名一字不改、降级为它的包装——库自身 main.go 与四个
既有测试的 5 处调用因此零改动。
动机:调用方要把「回源 feed」和「可达性探测」用同一个 UA 发出去,两者视角不一致
则打标不可信;而此前 UA 写死在函数体里、无从覆盖。
空 UA 不原样发出而是回落默认:gofeed 无条件 Header.Set("User-Agent", …),net/http
见到空值会把整条头省掉,而不发 UA 比发任何 UA 都更容易被拒(实测有源站对无 UA
请求直接 403)。
默认值保持原样并提为导出常量 DefaultUserAgent。顺带把它的 doc comment 从「不知道
当年为什么设成 Chrome」改成实测得出的可检验规则:约束是「不得是 HTTP 库出厂 UA、
不得为空」,而非「必须是浏览器」——feed.xyzfm.space 对 "Gofeed/1.0"(ec79b8b 之前
本函数实际发出的值)与 "Go-http-client/1.1" 返回 403 且响应头写明
X-Tengine-Error: denied by UA ACL = blacklist,而 Chrome、txpodcast/1.0、
podcast-search-go/0.1 一律放行。
补 UA 断言测试(此前四个测试从未断言过 UA):显式 UA 逐字节发出、空串回落默认、
旧签名仍发默认、timeout<=0 仍回落 30s。已验证注入回归时会红。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -92,3 +93,60 @@ func TestLoadFeed_EmptyFeed(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "解析播客订阅失败")
|
||||
}
|
||||
|
||||
// UA 是这个库唯一会影响「能不能抓到」的可配项:实测有源站按 UA 黑名单直接 403
|
||||
// (feed.xyzfm.space 对 gofeed 出厂的 "Gofeed/1.0" 返回 denied by UA ACL = blacklist),
|
||||
// 且无 UA 的请求比任何 UA 都更容易被拒。故这三条都必须钉死在测试里。
|
||||
func TestLoadFeedWithOptions_UserAgent(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
give string
|
||||
want string
|
||||
}{
|
||||
{"显式指定的 UA 逐字节发出", "txpodcast/1.0", "txpodcast/1.0"},
|
||||
{"空串回落默认而不是发空头", "", DefaultUserAgent},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// buffered chan 而非裸变量:handler 在另一个 goroutine 里写,-race 会告警。
|
||||
got := make(chan string, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got <- r.Header.Get("User-Agent")
|
||||
_, _ = w.Write([]byte(`<rss version="2.0"><channel><title>t</title></channel></rss>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := LoadFeedWithOptions(context.Background(), srv.URL,
|
||||
FeedOptions{UserAgent: tc.give, Timeout: 10 * time.Second})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tc.want, <-got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 旧签名必须继续发默认 UA:它是 LoadFeedWithOptions 的包装,委托时若漏传就会退化成空头。
|
||||
func TestLoadFeedContext_KeepsDefaultUserAgent(t *testing.T) {
|
||||
got := make(chan string, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got <- r.Header.Get("User-Agent")
|
||||
_, _ = w.Write([]byte(`<rss version="2.0"><channel><title>t</title></channel></rss>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := LoadFeedContext(context.Background(), srv.URL, "", 10*time.Second)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, DefaultUserAgent, <-got)
|
||||
}
|
||||
|
||||
// timeout <= 0 回落 30 秒的既有语义不能因为改走 FeedOptions 而丢失。
|
||||
func TestLoadFeedWithOptions_ZeroTimeoutFallsBack(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`<rss version="2.0"><channel><title>t</title></channel></rss>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 0 若被原样当成超时,请求会立刻 context deadline exceeded。
|
||||
_, err := LoadFeedWithOptions(context.Background(), srv.URL, FeedOptions{})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user