Files
podcast-search-go/model/podcast.go
T
ansandClaude Fable 5 f8bdd7f08f 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>
2026-08-07 11:51:23 +08:00

175 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package model 提供播客和剧集的数据模型
package model
import (
"context"
"fmt"
"net/http"
"net/url"
"time"
"github.com/mmcdole/gofeed"
"github.com/sirupsen/logrus"
)
// 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"`
// 语言
Language string `json:"language,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 []*gofeed.Item `json:"episodes,omitempty"`
// 远程项目
RemoteItems []RemoteItem `json:"remote_items,omitempty"`
// 媒体类型
Medium Medium `json:"medium,omitempty"`
}
// DefaultUserAgent 是 FeedOptions 未指定 UserAgent 时回源 RSS 所发的 User-Agent。
//
// **新调用方请显式传自己的 UA,不要依赖这个默认值。** 它自称 Chrome/1352025-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;需要指定 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 = defaultFeedTimeout
}
userAgent := opts.UserAgent
if userAgent == "" {
userAgent = DefaultUserAgent
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
fp := gofeed.NewParser()
fp.UserAgent = userAgent
fp.Client = &http.Client{}
// setup proxy
if opts.Proxy != "" {
proxyURL, err := url.Parse(opts.Proxy)
if err != nil {
logrus.WithError(err).Errorf("Failed to parse proxy url: %s", opts.Proxy)
return nil, err
}
fp.Client = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURL),
},
}
logrus.Infof("Using proxy: %s", opts.Proxy)
}
feed, err := fp.ParseURLWithContext(feedURL, ctx)
if err != nil {
return nil, fmt.Errorf("解析播客订阅失败: %w", err)
}
imageURL := ""
if feed.Image != nil {
imageURL = feed.Image.URL
}
podcast := &Podcast{
Title: feed.Title,
Description: feed.Description,
Link: feed.Link,
Image: imageURL,
Copyright: feed.Copyright,
Language: feed.Language,
Episodes: feed.Items,
Funding: make([]Funding, 0),
Persons: make([]Person, 0),
Value: make([]Value, 0),
Block: make([]Block, 0),
Medium: MediumPodcast,
}
return podcast, nil
}