2026-05-25 12:30:21 -04:00
|
|
|
package news
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
2026-05-25 15:16:00 -04:00
|
|
|
"sync"
|
2026-05-25 12:30:21 -04:00
|
|
|
"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 {
|
2026-05-25 15:16:00 -04:00
|
|
|
Sources []Source
|
|
|
|
|
Client *http.Client
|
|
|
|
|
TTL time.Duration
|
|
|
|
|
DryRun bool
|
|
|
|
|
Now func() time.Time
|
|
|
|
|
Progress io.Writer
|
|
|
|
|
EnrichOG bool // fetch og:image and og:description for each new item
|
|
|
|
|
OGParallel int // max concurrent OG fetches (default 5)
|
2026-05-25 12:30:21 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
2026-05-25 15:16:00 -04:00
|
|
|
if opts.OGParallel <= 0 {
|
|
|
|
|
opts.OGParallel = 5
|
|
|
|
|
}
|
2026-05-25 12:30:21 -04:00
|
|
|
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
|
2026-05-25 15:16:00 -04:00
|
|
|
|
|
|
|
|
// Collect all items across sources for OG enrichment.
|
|
|
|
|
var allItems []Item
|
|
|
|
|
|
2026-05-25 12:30:21 -04:00
|
|
|
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 {
|
2026-05-25 15:16:00 -04:00
|
|
|
allItems = append(allItems, items...)
|
2026-05-25 12:30:21 -04:00
|
|
|
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
|
|
|
|
|
}
|
2026-05-25 15:16:00 -04:00
|
|
|
|
|
|
|
|
for i := range items {
|
|
|
|
|
items[i].FetchedAt = fetchedAt
|
|
|
|
|
}
|
|
|
|
|
allItems = append(allItems, items...)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// OG enrichment pass: fetch og:image + og:description for each item.
|
|
|
|
|
if opts.EnrichOG && len(allItems) > 0 {
|
|
|
|
|
progressf(opts.Progress, "news: enriching %d items with OG metadata\n", len(allItems))
|
|
|
|
|
enrichOG(ctx, opts.Client, allItems, opts.OGParallel, opts.Progress)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if opts.DryRun {
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Store all items (with OG data already populated).
|
|
|
|
|
for _, item := range allItems {
|
|
|
|
|
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,
|
|
|
|
|
OGImageURL: item.OGImageURL,
|
|
|
|
|
OGDescription: item.OGDescription,
|
|
|
|
|
}); err != nil {
|
|
|
|
|
return result, err
|
2026-05-25 12:30:21 -04:00
|
|
|
}
|
2026-05-25 15:16:00 -04:00
|
|
|
result.ItemsUpserted++
|
2026-05-25 12:30:21 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 15:16:00 -04:00
|
|
|
// enrichOG fetches og:image and og:description for each item concurrently.
|
|
|
|
|
func enrichOG(ctx context.Context, client *http.Client, items []Item, parallel int, progress io.Writer) {
|
|
|
|
|
sem := make(chan struct{}, parallel)
|
|
|
|
|
var mu sync.Mutex
|
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
|
|
|
|
|
|
ogClient := &http.Client{Timeout: 8 * time.Second}
|
|
|
|
|
if client != nil {
|
|
|
|
|
ogClient = &http.Client{Timeout: 8 * time.Second, Transport: client.Transport}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for i := range items {
|
|
|
|
|
wg.Add(1)
|
|
|
|
|
go func(idx int) {
|
|
|
|
|
defer wg.Done()
|
|
|
|
|
sem <- struct{}{}
|
|
|
|
|
defer func() { <-sem }()
|
|
|
|
|
|
|
|
|
|
imgURL, desc, err := FetchOGMeta(ctx, ogClient, items[idx].URL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
progressf(progress, "news: og fetch failed %s: %v\n", items[idx].URL, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
mu.Lock()
|
|
|
|
|
items[idx].OGImageURL = imgURL
|
|
|
|
|
items[idx].OGDescription = desc
|
|
|
|
|
mu.Unlock()
|
|
|
|
|
}(i)
|
|
|
|
|
}
|
|
|
|
|
wg.Wait()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 12:30:21 -04:00
|
|
|
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
|
|
|
|
|
}
|