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:
ans
2026-08-07 11:51:23 +08:00
co-authored by Claude Fable 5
parent 6e41fae8a1
commit f8bdd7f08f
2 changed files with 114 additions and 7 deletions
+58
View File
@@ -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)
}