all repos — llm_aggregator @ 13547fc860356e4f64bfa097f7681fdd90f33df7

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

internal/aggregator/aggregator.go (view raw)

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