all repos — llm_aggregator @ 262b91134ac16b77d435b706d54680bf6cff5de6

A CLI tool to aggregate RSS feeds and summarise them with LLMs

internal/aggregator/aggregator.go (view raw)

  1package aggregator
  2
  3import (
  4	"context"
  5	"fmt"
  6	"io"
  7	"net/http"
  8	"os"
  9	"strings"
 10	"sync"
 11	"time"
 12
 13	"llm_aggregator/internal/progress"
 14
 15	"github.com/PuerkitoBio/goquery"
 16	"github.com/mmcdole/gofeed"
 17	"golang.org/x/sync/errgroup"
 18)
 19
 20// FeedAggregator aggregates articles from multiple RSS feeds.
 21type FeedAggregator struct {
 22	maxArticlesPerFeed int
 23	maxDaysOld         int
 24	maxContentLength   int
 25	client             *http.Client
 26	userAgent          string
 27	progressCtx        *progress.Context
 28}
 29
 30// NewFeedAggregator creates a new FeedAggregator with the specified options.
 31func NewFeedAggregator(maxArticlesPerFeed, maxDaysOld, maxContentLength int) *FeedAggregator {
 32	return NewFeedAggregatorWithProgress(maxArticlesPerFeed, maxDaysOld, maxContentLength, nil)
 33}
 34
 35// NewFeedAggregatorWithProgress creates a new FeedAggregator with progress context.
 36func NewFeedAggregatorWithProgress(maxArticlesPerFeed, maxDaysOld, maxContentLength int, progressCtx *progress.Context) *FeedAggregator {
 37	return &FeedAggregator{
 38		maxArticlesPerFeed: maxArticlesPerFeed,
 39		maxDaysOld:         maxDaysOld,
 40		maxContentLength:   maxContentLength,
 41		client: &http.Client{
 42			Timeout: 30 * time.Second,
 43		},
 44		userAgent:   "LLM-Aggregator/0.1.0 (+https://codeberg.org/maxwelljensen/llm-aggregator)",
 45		progressCtx: progressCtx,
 46	}
 47}
 48
 49// ParseFeedsFromFile parses RSS feeds from a file containing one URL per line.
 50// Feeds are fetched concurrently for improved performance.
 51func (fa *FeedAggregator) ParseFeedsFromFile(filePath string) ([]*Article, error) {
 52	file, err := os.Open(filePath)
 53	if err != nil {
 54		return nil, fmt.Errorf("failed to open feeds file: %w", err)
 55	}
 56	defer file.Close()
 57
 58	content, err := io.ReadAll(file)
 59	if err != nil {
 60		return nil, fmt.Errorf("failed to read feeds file: %w", err)
 61	}
 62
 63	lines := strings.Split(string(content), "\n")
 64	feedURLs := []string{}
 65	for _, line := range lines {
 66		line = strings.TrimSpace(line)
 67		if line != "" && !strings.HasPrefix(line, "#") {
 68			feedURLs = append(feedURLs, line)
 69		}
 70	}
 71
 72	fa.progressCtx.Logf("Found %d feed URLs in %s", len(feedURLs), filePath)
 73
 74	// Use mutex to protect shared state
 75	var mu sync.Mutex
 76	var allArticles []*Article
 77	var feedErrors []string
 78
 79	// Limit concurrency to avoid overwhelming servers
 80	const maxConcurrency = 10
 81	sem := make(chan struct{}, maxConcurrency)
 82
 83	g, _ := errgroup.WithContext(context.Background())
 84	for i, feedURL := range feedURLs {
 85		currentIndex := i
 86		sem <- struct{}{} // Acquire semaphore
 87		g.Go(func() error {
 88			defer func() { <-sem }() // Release semaphore
 89			fa.progressCtx.Logf("Processing feed %d/%d: %s", currentIndex+1, len(feedURLs), feedURL)
 90			feedArticles, err := fa.ParseFeed(feedURL)
 91			if err != nil {
 92				fa.progressCtx.Warningf("Failed to parse feed %s: %v", feedURL, err)
 93				mu.Lock()
 94				feedErrors = append(feedErrors, fmt.Sprintf("%s: %v", feedURL, err))
 95				mu.Unlock()
 96				return nil // Don't return error to continue processing other feeds
 97			}
 98			mu.Lock()
 99			allArticles = append(allArticles, feedArticles...)
100			mu.Unlock()
101			return nil
102		})
103	}
104
105	if err := g.Wait(); err != nil {
106		return nil, fmt.Errorf("failed to process feeds: %w", err)
107	}
108
109	if len(feedErrors) > 0 {
110		fa.progressCtx.Warningf("Encountered %d feed errors: %v", len(feedErrors), feedErrors)
111	}
112
113	return allArticles, nil
114}
115
116// ParseFeed parses a single RSS feed and extracts articles.
117func (fa *FeedAggregator) ParseFeed(feedURL string) ([]*Article, error) {
118	articles := []*Article{}
119
120	fp := gofeed.NewParser()
121	feed, err := fp.ParseURL(feedURL)
122	if err != nil {
123		return nil, fmt.Errorf("failed to parse feed URL %s: %w", feedURL, err)
124	}
125
126	feedTitle := feed.Title
127	if feedTitle == "" {
128		feedTitle = feedURL
129	}
130
131	fa.progressCtx.Logf("Parsing feed: %s (%d entries)", feedTitle, len(feed.Items))
132
133	var cutoffTime time.Time
134	if fa.maxDaysOld > 0 {
135		cutoffTime = time.Now().Add(-time.Duration(fa.maxDaysOld) * 24 * time.Hour)
136	}
137
138	maxItems := min(fa.maxArticlesPerFeed, len(feed.Items))
139
140	for i, item := range feed.Items[:maxItems] {
141		article, err := fa.extractArticle(item, feedTitle, cutoffTime)
142		if err != nil {
143			fa.progressCtx.Warningf("Failed to extract article %d from %s: %v", i, feedURL, err)
144			continue
145		}
146		if article != nil {
147			articles = append(articles, article)
148		}
149	}
150
151	return articles, nil
152}
153
154func (fa *FeedAggregator) extractArticle(item *gofeed.Item, feedTitle string, cutoffTime time.Time) (*Article, error) {
155	// Extract metadata
156	title := item.Title
157	if title == "" {
158		title = "Untitled"
159	}
160
161	link := item.Link
162	if link == "" {
163		return nil, nil
164	}
165
166	// Parse publication date
167	published := fa.parsePublishedDate(item)
168
169	// Check if article is too old
170	if !cutoffTime.IsZero() && !published.IsZero() && published.Before(cutoffTime) {
171		fa.progressCtx.Debugf("Skipping old article: %s (%s)", title, published.Format("2006-01-02"))
172		return nil, nil
173	}
174
175	// Extract author
176	author := ""
177	if item.Author != nil {
178		author = item.Author.Name
179		if author == "" {
180			author = item.Author.Email
181		}
182	}
183	if author == "" && item.DublinCoreExt != nil && len(item.DublinCoreExt.Creator) > 0 {
184		author = item.DublinCoreExt.Creator[0]
185	}
186
187	// Get content
188	content := fa.extractContent(item, link)
189
190	// Truncate content if too long
191	if len(content) > fa.maxContentLength {
192		content = content[:fa.maxContentLength] + "... [truncated]"
193	}
194
195	return &Article{
196		Title:      title,
197		Link:       link,
198		Content:    content,
199		Published:  published,
200		Author:     author,
201		SourceFeed: feedTitle,
202	}, nil
203}
204
205func (fa *FeedAggregator) parsePublishedDate(item *gofeed.Item) time.Time {
206	if item.PublishedParsed != nil {
207		return *item.PublishedParsed
208	}
209	if item.UpdatedParsed != nil {
210		return *item.UpdatedParsed
211	}
212	// Try to parse from string
213	if item.Published != "" {
214		if t, err := time.Parse(time.RFC1123, item.Published); err == nil {
215			return t
216		}
217		if t, err := time.Parse(time.RFC1123Z, item.Published); err == nil {
218			return t
219		}
220		if t, err := time.Parse(time.RFC3339, item.Published); err == nil {
221			return t
222		}
223	}
224	return time.Time{}
225}
226
227func (fa *FeedAggregator) extractContent(item *gofeed.Item, link string) string {
228	// Priority: Content -> Description -> Fetch webpage
229	content := ""
230
231	// Try to get content from item
232	if item.Content != "" {
233		content = item.Content
234	} else if item.Description != "" {
235		content = item.Description
236	}
237
238	// If still no content or very short, fetch webpage
239	if content == "" || len(content) < 100 {
240		fetchedContent, err := fa.fetchWebpageContent(link)
241		if err == nil && fetchedContent != "" {
242			content = fetchedContent
243		}
244	}
245
246	return content
247}
248
249func (fa *FeedAggregator) fetchWebpageContent(url string) (string, error) {
250	req, err := http.NewRequest("GET", url, nil)
251	if err != nil {
252		return "", err
253	}
254	req.Header.Set("User-Agent", fa.userAgent)
255
256	resp, err := fa.client.Do(req)
257	if err != nil {
258		return "", err
259	}
260	defer resp.Body.Close()
261
262	if resp.StatusCode != http.StatusOK {
263		return "", fmt.Errorf("HTTP status %d", resp.StatusCode)
264	}
265
266	doc, err := goquery.NewDocumentFromReader(resp.Body)
267	if err != nil {
268		return "", err
269	}
270
271	// Remove script and style elements
272	doc.Find("script, style, nav, footer, header").Remove()
273
274	// Try to find article content
275	articleSelectors := []string{
276		"article",
277		"main",
278		".article-content",
279		".post-content",
280		".entry-content",
281		"#content",
282		".content",
283	}
284
285	for _, selector := range articleSelectors {
286		articleElement := doc.Find(selector).First()
287		if articleElement.Length() > 0 {
288			text := articleElement.Text()
289			text = strings.Join(strings.Fields(text), " ") // Normalise whitespace
290			if len(text) > 200 {
291				return text, nil
292			}
293		}
294	}
295
296	// Fallback: get all paragraphs
297	paragraphs := doc.Find("p")
298	if paragraphs.Length() > 0 {
299		var texts []string
300		paragraphs.Each(func(i int, s *goquery.Selection) {
301			texts = append(texts, strings.TrimSpace(s.Text()))
302		})
303		text := strings.Join(texts, " ")
304		if len(text) > 200 {
305			return text, nil
306		}
307	}
308
309	// Last resort: get all text
310	text := doc.Text()
311	text = strings.Join(strings.Fields(text), " ")
312	if len(text) > 200 {
313		return text, nil
314	}
315
316	return "", nil
317}