rssd/poller/poller.go
2026-05-03 20:30:37 -04:00

119 lines
2.8 KiB
Go

package poller
import (
"fmt"
"io"
"net/http"
"time"
"github.com/mmcdole/gofeed"
)
// EnclosureInfo carries an enclosure URL and its associated item's metadata.
type EnclosureInfo struct {
URL string
Title string // episode title, if available
PublishedAt time.Time // zero value means no date available
}
// FeedResult holds the result of polling a single feed.
type FeedResult struct {
URL string
NewEnclosures []EnclosureInfo
LastModified string
ETag string
NotModified bool
Error error
}
// Fetcher abstracts the HTTP fetch + parse cycle for a single feed.
type Fetcher struct {
httpClient *http.Client
parser *gofeed.Parser
}
// NewFetcher creates a new Fetcher with sensible defaults.
func NewFetcher() *Fetcher {
return &Fetcher{
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
parser: gofeed.NewParser(),
}
}
// Poll fetches and parses an RSS/Atom feed, returning new enclosure info.
// lastModified and etag are used for conditional requests (If-None-Match / If-Modified-Since).
// Returns a FeedResult with NewEnclosures populated only if the feed changed.
func (f *Fetcher) Poll(url, lastModified, etag string) (*FeedResult, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
if etag != "" {
req.Header.Set("If-None-Match", etag)
}
if lastModified != "" {
req.Header.Set("If-Modified-Since", lastModified)
}
resp, err := f.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch %s: %w", url, err)
}
defer resp.Body.Close()
// Handle 304 Not Modified — feed hasn't changed.
if resp.StatusCode == http.StatusNotModified {
return &FeedResult{
URL: url,
NotModified: true,
}, nil
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch %s: HTTP %d", url, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body %s: %w", url, err)
}
feed, err := f.parser.ParseString(string(body))
if err != nil {
return nil, fmt.Errorf("parse feed %s: %w", url, err)
}
// Extract enclosure info from feed items.
var enclosures []EnclosureInfo
for _, item := range feed.Items {
for _, enc := range item.Enclosures {
if enc.URL != "" {
encInfo := EnclosureInfo{URL: enc.URL, Title: item.Title}
if item.PublishedParsed != nil {
encInfo.PublishedAt = *item.PublishedParsed
}
enclosures = append(enclosures, encInfo)
}
}
}
result := &FeedResult{
URL: url,
NewEnclosures: enclosures,
NotModified: false,
}
// Capture cache headers for the next conditional request.
if v := resp.Header.Get("Last-Modified"); v != "" {
result.LastModified = v
}
if v := resp.Header.Get("ETag"); v != "" {
result.ETag = v
}
return result, nil
}