// 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/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;需要指定 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 }