From f8bdd7f08f70840f307d7ed2c47c55216e25d567 Mon Sep 17 00:00:00 2001 From: malai Date: Fri, 7 Aug 2026 11:51:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20feed=20=E5=9B=9E=E6=BA=90=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=8C=87=E5=AE=9A=20User-Agent=EF=BC=88FeedOptions?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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 --- model/podcast.go | 63 ++++++++++++++++++++++++++++++++++++++----- model/podcast_test.go | 58 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/model/podcast.go b/model/podcast.go index 838f075..a9d5af3 100644 --- a/model/podcast.go +++ b/model/podcast.go @@ -63,30 +63,79 @@ type Podcast struct { Medium Medium `json:"medium,omitempty"` } +// DefaultUserAgent 是 FeedOptions 未指定 UserAgent 时回源 RSS 所发的 User-Agent。 +// +// **新调用方请显式传自己的 UA,不要依赖这个默认值。** 它自称 Chrome/135(2025-04 当时的 +// 稳定版,此后无人维护、已落后十几个大版本且会继续失真),而 Go 的 TLS 指纹与真 Chrome +// 对不上——「自称一个没人在跑的旧版浏览器 + 指纹不是浏览器」是比诚实标识更强的异常信号。 +// 播客托管商普遍对 "AppName/version" 形态友好(OP3 / Podtrac 按此做客户端统计)。 +// 尤其当你还要对同一批资源做可达性探测时,回源与探测的 UA 必须逐字节一致,否则打标不可信。 +// +// 保留它而不换成中性标识,是因为唯一的约束已经实测清楚,而这个值恰好满足: +// **默认 UA 不得是 HTTP 库的出厂标识,也不得为空**。实测 feed.xyzfm.space(小宇宙)对 +// "Gofeed/1.0"(gofeed 出厂值,即 ec79b8b 之前本函数实际发出的 UA)与 "Go-http-client/1.1" +// 直接 403,响应头写明 `X-Tengine-Error: denied by UA ACL = blacklist`;而 Chrome、 +// "txpodcast/1.0"、"podcast-search-go/0.1" 等一律放行。换言之当年那次改动挡住的是 +// 「爬虫库默认 UA 黑名单」,不是「非浏览器」——换成中性 AppName/version 同样安全, +// 只是本次改动的命题是「让 UA 可配置」而非「换 UA」,故不夹带。 +const DefaultUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36" + +// defaultFeedTimeout 是 FeedOptions.Timeout <= 0 时的回源超时。 +const defaultFeedTimeout = 30 * time.Second + +// FeedOptions 控制回源 RSS 的行为。零值可用:不显式设代理、30 秒超时、UA 取 DefaultUserAgent。 +// 请始终用字段名初始化(后续版本可能追加字段)。 +type FeedOptions struct { + // Proxy 代理地址,如 "http://127.0.0.1:7890";空表示不显式设代理 + // (此时走 http.DefaultTransport,仍会读 HTTP_PROXY/HTTPS_PROXY 环境变量)。 + Proxy string + + // Timeout 单次回源的总超时;<= 0 时用 defaultFeedTimeout。 + Timeout time.Duration + + // UserAgent 请求头;空时回落 DefaultUserAgent。 + // 空串不会被原样发出:gofeed 无条件 Header.Set("User-Agent", …),而 net/http 见到空值 + // 会把整条头省掉,而「不发 UA」比任何 UA 都更像 bot(实测有源站对无 UA 请求直接 403)。 + UserAgent string +} + // LoadFeed 从URL加载播客RSS订阅,timeout为超时时间,如果timeout<=0则使用默认值30秒。 -// 需要透传取消时请用 LoadFeedContext。 +// 需要透传取消时请用 LoadFeedContext;需要指定 User-Agent 等选项时请用 LoadFeedWithOptions。 func LoadFeed(feedURL, proxy string, timeout time.Duration) (*Podcast, error) { return LoadFeedContext(context.Background(), feedURL, proxy, timeout) } // LoadFeedContext 同 LoadFeed,但请求生命周期挂在调用方 ctx 上:ctx 取消/超时即中断下载。 +// 需要指定 User-Agent 等选项时请用 LoadFeedWithOptions。 func LoadFeedContext(ctx context.Context, feedURL, proxy string, timeout time.Duration) (*Podcast, error) { + return LoadFeedWithOptions(ctx, feedURL, FeedOptions{Proxy: proxy, Timeout: timeout}) +} + +// LoadFeedWithOptions 从 URL 加载播客 RSS 订阅,行为由 opts 决定;请求生命周期挂在调用方 +// ctx 上:ctx 取消/超时即中断下载。LoadFeed / LoadFeedContext 是它的两个便捷包装。 +func LoadFeedWithOptions(ctx context.Context, feedURL string, opts FeedOptions) (*Podcast, error) { + timeout := opts.Timeout if timeout <= 0 { - timeout = 30 * time.Second + timeout = defaultFeedTimeout + } + userAgent := opts.UserAgent + if userAgent == "" { + userAgent = DefaultUserAgent } ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() fp := gofeed.NewParser() - fp.UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36" + fp.UserAgent = userAgent fp.Client = &http.Client{} // setup proxy - if proxy != "" { - proxyURL, err := url.Parse(proxy) + if opts.Proxy != "" { + proxyURL, err := url.Parse(opts.Proxy) if err != nil { - logrus.WithError(err).Errorf("Failed to parse proxy url: %s", proxy) + logrus.WithError(err).Errorf("Failed to parse proxy url: %s", opts.Proxy) return nil, err } fp.Client = &http.Client{ @@ -94,7 +143,7 @@ func LoadFeedContext(ctx context.Context, feedURL, proxy string, timeout time.Du Proxy: http.ProxyURL(proxyURL), }, } - logrus.Infof("Using proxy: %s", proxy) + logrus.Infof("Using proxy: %s", opts.Proxy) } feed, err := fp.ParseURLWithContext(feedURL, ctx) diff --git a/model/podcast_test.go b/model/podcast_test.go index 5c61d1c..64165d5 100644 --- a/model/podcast_test.go +++ b/model/podcast_test.go @@ -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(`t`)) + })) + 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(`t`)) + })) + 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(`t`)) + })) + defer srv.Close() + + // 0 若被原样当成超时,请求会立刻 context deadline exceeded。 + _, err := LoadFeedWithOptions(context.Background(), srv.URL, FeedOptions{}) + assert.NoError(t, err) +}