新增 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>
175 lines
6.1 KiB
Go
175 lines
6.1 KiB
Go
// 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
|
||
}
|