Files
box-box/internal/news/refresh.go

142 lines
3.3 KiB
Go

package news
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/AmanTahiliani/box-box/internal/store"
)
const DefaultTTL = 30 * time.Minute
// Store is the storage surface needed by feed refreshes.
type Store interface {
UpsertNewsSource(store.NewsSource) error
UpsertNewsItem(store.NewsItem) error
}
// RefreshOptions configures one local news refresh run.
type RefreshOptions struct {
Sources []Source
Client *http.Client
TTL time.Duration
DryRun bool
Now func() time.Time
Progress io.Writer
}
// RefreshResult summarizes one local news refresh run.
type RefreshResult struct {
SourcesFetched int
SourcesFailed int
ItemsFetched int
ItemsUpserted int
}
// Refresh fetches RSS/Atom sources and stores normalized, URL-deduped items.
func Refresh(ctx context.Context, st Store, opts RefreshOptions) (RefreshResult, error) {
if st == nil && !opts.DryRun {
return RefreshResult{}, errors.New("news refresh: store is required")
}
if len(opts.Sources) == 0 {
opts.Sources = DefaultSources
}
if opts.Client == nil {
opts.Client = &http.Client{Timeout: 10 * time.Second}
}
if opts.TTL <= 0 {
opts.TTL = DefaultTTL
}
now := func() time.Time { return time.Now().UTC() }
if opts.Now != nil {
now = func() time.Time { return opts.Now().UTC() }
}
var result RefreshResult
var failures []string
for _, source := range opts.Sources {
fetchedAt := now()
expiresAt := fetchedAt.Add(opts.TTL)
if !opts.DryRun {
if err := st.UpsertNewsSource(store.NewsSource{
Source: source.ID,
Name: source.Name,
FeedURL: source.URL,
Category: source.Category,
Enabled: true,
UpdatedAt: fetchedAt,
}); err != nil {
return result, err
}
}
items, err := Fetch(ctx, opts.Client, source)
if err != nil {
result.SourcesFailed++
failures = append(failures, fmt.Sprintf("%s: %v", source.ID, err))
progressf(opts.Progress, "news: %s failed: %v\n", source.ID, err)
continue
}
result.SourcesFetched++
result.ItemsFetched += len(items)
progressf(opts.Progress, "news: %s fetched %d items\n", source.ID, len(items))
if opts.DryRun {
continue
}
if err := st.UpsertNewsSource(store.NewsSource{
Source: source.ID,
Name: source.Name,
FeedURL: source.URL,
Category: source.Category,
Enabled: true,
FetchedAt: &fetchedAt,
ExpiresAt: &expiresAt,
UpdatedAt: fetchedAt,
}); err != nil {
return result, err
}
for _, item := range items {
item.FetchedAt = fetchedAt
publishedAt := timePtr(item.PublishedAt)
if err := st.UpsertNewsItem(store.NewsItem{
URL: item.URL,
Source: item.Source,
Title: item.Title,
PublishedAt: publishedAt,
Summary: item.Summary,
Category: item.Category,
FetchedAt: item.FetchedAt,
}); err != nil {
return result, err
}
result.ItemsUpserted++
}
}
if len(failures) > 0 {
return result, fmt.Errorf("news refresh completed with %d source failure(s): %s", len(failures), strings.Join(failures, "; "))
}
return result, nil
}
func progressf(w io.Writer, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, format, args...)
}
func timePtr(v time.Time) *time.Time {
if v.IsZero() {
return nil
}
t := v.UTC()
return &t
}