mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-21 22:28:05 +02:00
feat(story-2.2): add feed parser with gofeed and article repository
This commit is contained in:
1 parent
1249e1bd42
commit
12f0c6879f
5 files changed
+646
-2
No files matched your search
@@ -45,8 +45,8 @@ development_status:
|
|||||||
|
|
||||||
# Epic 2: Feed Core Engine
|
# Epic 2: Feed Core Engine
|
||||||
epic-2: in-progress
|
epic-2: in-progress
|
||||||
2-1-feed-model-add-feed-by-url: review
|
2-1-feed-model-add-feed-by-url: done
|
||||||
2-2-feed-metadata-auto-discovery: backlog
|
2-2-feed-metadata-auto-discovery: review
|
||||||
2-3-opml-import: backlog
|
2-3-opml-import: backlog
|
||||||
2-4-feed-management-crud: backlog
|
2-4-feed-management-crud: backlog
|
||||||
2-5-background-feed-fetcher: backlog
|
2-5-background-feed-fetcher: backlog
|
||||||
|
|||||||
@@ -8,11 +8,19 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/PuerkitoBio/goquery v1.8.0 // indirect
|
||||||
|
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/mmcdole/gofeed v1.3.0 // indirect
|
||||||
|
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
golang.org/x/crypto v0.17.0 // indirect
|
golang.org/x/crypto v0.17.0 // indirect
|
||||||
|
golang.org/x/net v0.10.0 // indirect
|
||||||
golang.org/x/sync v0.1.0 // indirect
|
golang.org/x/sync v0.1.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/text v0.14.0 // indirect
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// Package parser provides RSS/Atom feed parsing functionality.
|
||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/michael/flowreader/internal/domain"
|
||||||
|
"github.com/mmcdole/gofeed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeedParser handles RSS/Atom feed parsing.
|
||||||
|
type FeedParser struct {
|
||||||
|
client *http.Client
|
||||||
|
parser *gofeed.Parser
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFeedParser creates a new feed parser.
|
||||||
|
func NewFeedParser() *FeedParser {
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FeedParser{
|
||||||
|
client: client,
|
||||||
|
parser: gofeed.NewParser(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsedFeed contains the parsed feed data.
|
||||||
|
type ParsedFeed struct {
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
SiteURL string
|
||||||
|
ImageURL string
|
||||||
|
Articles []*domain.Article
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse fetches and parses a feed URL.
|
||||||
|
func (p *FeedParser) Parse(ctx context.Context, feedURL string, feedID uuid.UUID) (*ParsedFeed, error) {
|
||||||
|
// Create request with context
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "FlowReader/1.0 (RSS Reader)")
|
||||||
|
|
||||||
|
// Fetch the feed
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching feed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the feed
|
||||||
|
feed, err := p.parser.Parse(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing feed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract metadata
|
||||||
|
parsed := &ParsedFeed{
|
||||||
|
Title: feed.Title,
|
||||||
|
Description: feed.Description,
|
||||||
|
}
|
||||||
|
|
||||||
|
if feed.Link != "" {
|
||||||
|
parsed.SiteURL = feed.Link
|
||||||
|
}
|
||||||
|
|
||||||
|
if feed.Image != nil && feed.Image.URL != "" {
|
||||||
|
parsed.ImageURL = feed.Image.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert items to articles
|
||||||
|
for _, item := range feed.Items {
|
||||||
|
article := &domain.Article{
|
||||||
|
ID: uuid.New(),
|
||||||
|
FeedID: feedID,
|
||||||
|
GUID: getGUID(item),
|
||||||
|
Title: item.Title,
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.Link != "" {
|
||||||
|
article.URL = item.Link
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.Content != "" {
|
||||||
|
article.Content = item.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.Description != "" {
|
||||||
|
article.Summary = item.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.Author != nil {
|
||||||
|
article.Author = item.Author.Name
|
||||||
|
} else if len(item.Authors) > 0 {
|
||||||
|
article.Author = item.Authors[0].Name
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.Image != nil && item.Image.URL != "" {
|
||||||
|
article.ImageURL = item.Image.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.PublishedParsed != nil {
|
||||||
|
article.PublishedAt = item.PublishedParsed
|
||||||
|
} else if item.UpdatedParsed != nil {
|
||||||
|
article.PublishedAt = item.UpdatedParsed
|
||||||
|
}
|
||||||
|
|
||||||
|
article.CreatedAt = time.Now()
|
||||||
|
|
||||||
|
parsed.Articles = append(parsed.Articles, article)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getGUID returns a unique identifier for the feed item.
|
||||||
|
func getGUID(item *gofeed.Item) string {
|
||||||
|
if item.GUID != "" {
|
||||||
|
return item.GUID
|
||||||
|
}
|
||||||
|
if item.Link != "" {
|
||||||
|
return item.Link
|
||||||
|
}
|
||||||
|
return item.Title // Last resort fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/michael/flowreader/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ArticleRepository implements domain.ArticleRepository using PostgreSQL.
|
||||||
|
type ArticleRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewArticleRepository creates a new article repository.
|
||||||
|
func NewArticleRepository(pool *pgxpool.Pool) *ArticleRepository {
|
||||||
|
return &ArticleRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new article into the database.
|
||||||
|
func (r *ArticleRepository) Create(article *domain.Article) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
query := `
|
||||||
|
INSERT INTO articles (id, feed_id, guid, title, url, content, summary, author, image_url, published_at, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
ON CONFLICT (feed_id, guid) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := r.pool.Exec(ctx, query,
|
||||||
|
article.ID,
|
||||||
|
article.FeedID,
|
||||||
|
article.GUID,
|
||||||
|
article.Title,
|
||||||
|
nullString(article.URL),
|
||||||
|
nullString(article.Content),
|
||||||
|
nullString(article.Summary),
|
||||||
|
nullString(article.Author),
|
||||||
|
nullString(article.ImageURL),
|
||||||
|
article.PublishedAt,
|
||||||
|
article.CreatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating article: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBatch inserts multiple articles into the database.
|
||||||
|
func (r *ArticleRepository) CreateBatch(articles []*domain.Article) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
batch := &pgx.Batch{}
|
||||||
|
query := `
|
||||||
|
INSERT INTO articles (id, feed_id, guid, title, url, content, summary, author, image_url, published_at, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
ON CONFLICT (feed_id, guid) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
for _, article := range articles {
|
||||||
|
batch.Queue(query,
|
||||||
|
article.ID,
|
||||||
|
article.FeedID,
|
||||||
|
article.GUID,
|
||||||
|
article.Title,
|
||||||
|
nullString(article.URL),
|
||||||
|
nullString(article.Content),
|
||||||
|
nullString(article.Summary),
|
||||||
|
nullString(article.Author),
|
||||||
|
nullString(article.ImageURL),
|
||||||
|
article.PublishedAt,
|
||||||
|
article.CreatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := r.pool.SendBatch(ctx, batch)
|
||||||
|
defer results.Close()
|
||||||
|
|
||||||
|
for range articles {
|
||||||
|
if _, err := results.Exec(); err != nil {
|
||||||
|
return fmt.Errorf("batch insert: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID retrieves an article by its ID.
|
||||||
|
func (r *ArticleRepository) GetByID(id uuid.UUID) (*domain.Article, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
|
||||||
|
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
||||||
|
f.title as feed_title
|
||||||
|
FROM articles a
|
||||||
|
JOIN feeds f ON f.id = a.feed_id
|
||||||
|
WHERE a.id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
article, err := r.scanArticle(r.pool.QueryRow(ctx, query, id))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("getting article by ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return article, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByFeedID retrieves articles for a specific feed.
|
||||||
|
func (r *ArticleRepository) GetByFeedID(feedID uuid.UUID, limit, offset int) ([]*domain.Article, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
|
||||||
|
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
||||||
|
f.title as feed_title
|
||||||
|
FROM articles a
|
||||||
|
JOIN feeds f ON f.id = a.feed_id
|
||||||
|
WHERE a.feed_id = $1
|
||||||
|
ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx, query, feedID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying articles: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return r.scanArticles(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByUserID retrieves articles for all user's feeds.
|
||||||
|
func (r *ArticleRepository) GetByUserID(userID uuid.UUID, limit, offset int, unreadOnly bool) ([]*domain.Article, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var query string
|
||||||
|
if unreadOnly {
|
||||||
|
query = `
|
||||||
|
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
|
||||||
|
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
||||||
|
f.title as feed_title
|
||||||
|
FROM articles a
|
||||||
|
JOIN feeds f ON f.id = a.feed_id
|
||||||
|
WHERE f.user_id = $1 AND a.is_read = false
|
||||||
|
ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
`
|
||||||
|
} else {
|
||||||
|
query = `
|
||||||
|
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
|
||||||
|
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
||||||
|
f.title as feed_title
|
||||||
|
FROM articles a
|
||||||
|
JOIN feeds f ON f.id = a.feed_id
|
||||||
|
WHERE f.user_id = $1
|
||||||
|
ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx, query, userID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying articles: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return r.scanArticles(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByGUID retrieves an article by its GUID within a feed.
|
||||||
|
func (r *ArticleRepository) GetByGUID(feedID uuid.UUID, guid string) (*domain.Article, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
|
||||||
|
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
||||||
|
f.title as feed_title
|
||||||
|
FROM articles a
|
||||||
|
JOIN feeds f ON f.id = a.feed_id
|
||||||
|
WHERE a.feed_id = $1 AND a.guid = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
article, err := r.scanArticle(r.pool.QueryRow(ctx, query, feedID, guid))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("getting article by GUID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return article, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAsRead marks an article as read.
|
||||||
|
func (r *ArticleRepository) MarkAsRead(id uuid.UUID) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
query := `UPDATE articles SET is_read = true, read_at = $2 WHERE id = $1`
|
||||||
|
_, err := r.pool.Exec(ctx, query, id, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marking as read: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAsUnread marks an article as unread.
|
||||||
|
func (r *ArticleRepository) MarkAsUnread(id uuid.UUID) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
query := `UPDATE articles SET is_read = false, read_at = NULL WHERE id = $1`
|
||||||
|
_, err := r.pool.Exec(ctx, query, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marking as unread: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAllAsRead marks all articles in a feed as read.
|
||||||
|
func (r *ArticleRepository) MarkAllAsRead(feedID uuid.UUID) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
query := `UPDATE articles SET is_read = true, read_at = $2 WHERE feed_id = $1 AND is_read = false`
|
||||||
|
_, err := r.pool.Exec(ctx, query, feedID, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marking all as read: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleFavorite toggles the favorite status of an article.
|
||||||
|
func (r *ArticleRepository) ToggleFavorite(id uuid.UUID) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
query := `UPDATE articles SET is_favorite = NOT is_favorite WHERE id = $1`
|
||||||
|
_, err := r.pool.Exec(ctx, query, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("toggling favorite: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFavorites retrieves favorited articles for a user.
|
||||||
|
func (r *ArticleRepository) GetFavorites(userID uuid.UUID, limit, offset int) ([]*domain.Article, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
|
||||||
|
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
||||||
|
f.title as feed_title
|
||||||
|
FROM articles a
|
||||||
|
JOIN feeds f ON f.id = a.feed_id
|
||||||
|
WHERE f.user_id = $1 AND a.is_favorite = true
|
||||||
|
ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx, query, userID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying favorites: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return r.scanArticles(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUnread counts unread articles for a feed.
|
||||||
|
func (r *ArticleRepository) CountUnread(feedID uuid.UUID) (int, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
query := `SELECT COUNT(*) FROM articles WHERE feed_id = $1 AND is_read = false`
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err := r.pool.QueryRow(ctx, query, feedID).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("counting unread: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanArticle scans a single article row.
|
||||||
|
func (r *ArticleRepository) scanArticle(row pgx.Row) (*domain.Article, error) {
|
||||||
|
var article domain.Article
|
||||||
|
var url, content, summary, author, imageURL, feedTitle *string
|
||||||
|
var publishedAt, readAt *time.Time
|
||||||
|
|
||||||
|
err := row.Scan(
|
||||||
|
&article.ID,
|
||||||
|
&article.FeedID,
|
||||||
|
&article.GUID,
|
||||||
|
&article.Title,
|
||||||
|
&url,
|
||||||
|
&content,
|
||||||
|
&summary,
|
||||||
|
&author,
|
||||||
|
&imageURL,
|
||||||
|
&publishedAt,
|
||||||
|
&article.IsRead,
|
||||||
|
&article.IsFavorite,
|
||||||
|
&readAt,
|
||||||
|
&article.CreatedAt,
|
||||||
|
&feedTitle,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if url != nil {
|
||||||
|
article.URL = *url
|
||||||
|
}
|
||||||
|
if content != nil {
|
||||||
|
article.Content = *content
|
||||||
|
}
|
||||||
|
if summary != nil {
|
||||||
|
article.Summary = *summary
|
||||||
|
}
|
||||||
|
if author != nil {
|
||||||
|
article.Author = *author
|
||||||
|
}
|
||||||
|
if imageURL != nil {
|
||||||
|
article.ImageURL = *imageURL
|
||||||
|
}
|
||||||
|
if feedTitle != nil {
|
||||||
|
article.FeedTitle = *feedTitle
|
||||||
|
}
|
||||||
|
article.PublishedAt = publishedAt
|
||||||
|
article.ReadAt = readAt
|
||||||
|
|
||||||
|
return &article, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanArticles scans multiple article rows.
|
||||||
|
func (r *ArticleRepository) scanArticles(rows pgx.Rows) ([]*domain.Article, error) {
|
||||||
|
var articles []*domain.Article
|
||||||
|
for rows.Next() {
|
||||||
|
var article domain.Article
|
||||||
|
var url, content, summary, author, imageURL, feedTitle *string
|
||||||
|
var publishedAt, readAt *time.Time
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&article.ID,
|
||||||
|
&article.FeedID,
|
||||||
|
&article.GUID,
|
||||||
|
&article.Title,
|
||||||
|
&url,
|
||||||
|
&content,
|
||||||
|
&summary,
|
||||||
|
&author,
|
||||||
|
&imageURL,
|
||||||
|
&publishedAt,
|
||||||
|
&article.IsRead,
|
||||||
|
&article.IsFavorite,
|
||||||
|
&readAt,
|
||||||
|
&article.CreatedAt,
|
||||||
|
&feedTitle,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning article: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if url != nil {
|
||||||
|
article.URL = *url
|
||||||
|
}
|
||||||
|
if content != nil {
|
||||||
|
article.Content = *content
|
||||||
|
}
|
||||||
|
if summary != nil {
|
||||||
|
article.Summary = *summary
|
||||||
|
}
|
||||||
|
if author != nil {
|
||||||
|
article.Author = *author
|
||||||
|
}
|
||||||
|
if imageURL != nil {
|
||||||
|
article.ImageURL = *imageURL
|
||||||
|
}
|
||||||
|
if feedTitle != nil {
|
||||||
|
article.FeedTitle = *feedTitle
|
||||||
|
}
|
||||||
|
article.PublishedAt = publishedAt
|
||||||
|
article.ReadAt = readAt
|
||||||
|
|
||||||
|
articles = append(articles, &article)
|
||||||
|
}
|
||||||
|
|
||||||
|
return articles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullString returns nil if string is empty.
|
||||||
|
func nullString(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/michael/flowreader/internal/domain"
|
||||||
|
"github.com/michael/flowreader/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchService handles feed fetching and article ingestion.
|
||||||
|
type FetchService struct {
|
||||||
|
feedRepo domain.FeedRepository
|
||||||
|
articleRepo domain.ArticleRepository
|
||||||
|
parser *parser.FeedParser
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFetchService creates a new fetch service.
|
||||||
|
func NewFetchService(feedRepo domain.FeedRepository, articleRepo domain.ArticleRepository) *FetchService {
|
||||||
|
return &FetchService{
|
||||||
|
feedRepo: feedRepo,
|
||||||
|
articleRepo: articleRepo,
|
||||||
|
parser: parser.NewFeedParser(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchFeed fetches a single feed and updates articles.
|
||||||
|
func (s *FetchService) FetchFeed(ctx context.Context, feedID uuid.UUID) error {
|
||||||
|
feed, err := s.feedRepo.GetByID(feedID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting feed: %w", err)
|
||||||
|
}
|
||||||
|
if feed == nil {
|
||||||
|
return fmt.Errorf("feed not found: %s", feedID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the feed
|
||||||
|
parsed, err := s.parser.Parse(ctx, feed.URL, feed.ID)
|
||||||
|
if err != nil {
|
||||||
|
// Update feed with error
|
||||||
|
s.feedRepo.UpdateFetchStatus(feed.ID, time.Now(), err.Error())
|
||||||
|
return fmt.Errorf("parsing feed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update feed metadata
|
||||||
|
feed.Title = parsed.Title
|
||||||
|
feed.Description = parsed.Description
|
||||||
|
feed.SiteURL = parsed.SiteURL
|
||||||
|
feed.ImageURL = parsed.ImageURL
|
||||||
|
if err := s.feedRepo.Update(feed); err != nil {
|
||||||
|
log.Printf("Warning: failed to update feed metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert new articles (ON CONFLICT DO NOTHING handles duplicates)
|
||||||
|
if len(parsed.Articles) > 0 {
|
||||||
|
if err := s.articleRepo.CreateBatch(parsed.Articles); err != nil {
|
||||||
|
return fmt.Errorf("creating articles: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark fetch as successful
|
||||||
|
s.feedRepo.UpdateFetchStatus(feed.ID, time.Now(), "")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchAllPending fetches all feeds that need updating.
|
||||||
|
func (s *FetchService) FetchAllPending(ctx context.Context, concurrency int) (int, error) {
|
||||||
|
feeds, err := s.feedRepo.GetFeedsToFetch(100)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("getting feeds to fetch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(feeds) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple sequential fetch for now (Story 2.5 will add worker pool)
|
||||||
|
fetchedCount := 0
|
||||||
|
for _, feed := range feeds {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fetchedCount, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.FetchFeed(ctx, feed.ID); err != nil {
|
||||||
|
log.Printf("Error fetching feed %s: %v", feed.URL, err)
|
||||||
|
} else {
|
||||||
|
fetchedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchedCount, nil
|
||||||
|
}
|
||||||
Reference in new issue
Block a user