diff --git a/_bmad-output/planning-artifacts/prd.md b/_bmad-output/planning-artifacts/prd.md index a90444c..fa12f2a 100644 --- a/_bmad-output/planning-artifacts/prd.md +++ b/_bmad-output/planning-artifacts/prd.md @@ -115,8 +115,8 @@ L'objectif est une expérience de lecture complète et fiable pour plusieurs uti ### Reading Experience -* **FR8**: Dashboard unifié "Nouveautés" trié par date. -* **FR9**: Mode "Lecture Plaisir" avec extraction du contenu principal (nettoyage HTML). +* **FR8**: Dashboard unifié "Nouveautés" trié par date avec **scroll infini** pour une navigation fluide. +* **FR9**: Mode "Lecture Plaisir" avec extraction du contenu principal et **typographie optimisée** (suppression de l'italique sur les titres pour un meilleur confort visuel). * **FR10**: Marquage "Lu" manuel ou automatique (scroll/swipe). * **FR11**: Marquage "Tout lu" par flux ou global. * **FR12**: Gestion des Favoris ("Star" / "Read Later"). diff --git a/cmd/server/main.go b/cmd/server/main.go index 2b6abd4..aed718e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -46,6 +46,7 @@ func main() { // Initialize services authService := service.NewAuthService(userRepo, sessionRepo) feedService := service.NewFeedService(feedRepo) + aiService := service.NewAIService() // Initialize WS Hub hub := ws.NewHub() @@ -56,15 +57,19 @@ func main() { // Initialize handlers authHandler := handler.NewAuthHandler(authService) feedHandler := handler.NewFeedHandler(feedService, fetchService, authService) - articleHandler := handler.NewArticleHandler(articleRepo, feedService, authService, hub) + articleHandler := handler.NewArticleHandler(articleRepo, feedService, authService, aiService, hub) wsHandler := handler.NewWSHandler(hub, authService) adminHandler := handler.NewAdminHandler(userRepo, authService) - // Start background feed fetcher + // Start background workers fetcher := worker.NewFeedFetcher(fetchService, 15*time.Minute, 5) fetcher.Start() defer fetcher.Stop() + cleaner := worker.NewCleaner(articleRepo, 24*time.Hour) + cleaner.Start() + defer cleaner.Stop() + // Initialize router r := chi.NewRouter() @@ -122,12 +127,14 @@ func main() { // Article routes r.Route("/articles", func(r chi.Router) { r.Get("/", articleHandler.List) + r.Get("/search", articleHandler.Search) r.Post("/read-all", articleHandler.MarkAllReadGlobal) r.Get("/favorites", articleHandler.GetFavorites) r.Get("/{id}", articleHandler.Get) r.Post("/{id}/read", articleHandler.MarkRead) r.Delete("/{id}/read", articleHandler.MarkUnread) r.Post("/{id}/favorite", articleHandler.ToggleFavorite) + r.Post("/{id}/summarize", articleHandler.Summarize) }) // WebSocket route diff --git a/docker-compose.unraid.yaml b/docker-compose.unraid.yaml index 662fd16..9dc9023 100644 --- a/docker-compose.unraid.yaml +++ b/docker-compose.unraid.yaml @@ -11,6 +11,7 @@ services: environment: - PORT=8080 - DATABASE_URL=postgres://flowreader:flowreader@db:5432/flowreader?sslmode=disable + - OPENROUTER_API_KEY=votre_clef_ici depends_on: db: condition: service_healthy diff --git a/internal/database/database.go b/internal/database/database.go index 5c5b088..b9d211b 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -42,27 +42,54 @@ func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { // RunMigrations executes pending database migrations. func RunMigrations(ctx context.Context, pool *pgxpool.Pool) error { - // Check if users table exists - var exists bool - err := pool.QueryRow(ctx, ` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = 'users' + // 1. Create schema_migrations table if it doesn't exist + _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(255) PRIMARY KEY, + applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ) - `).Scan(&exists) + `) if err != nil { - return fmt.Errorf("checking users table: %w", err) + return fmt.Errorf("creating schema_migrations table: %w", err) } - if exists { - log.Println("Database already initialized") - return nil + // 2. Get applied migrations + rows, err := pool.Query(ctx, "SELECT version FROM schema_migrations") + if err != nil { + return fmt.Errorf("querying applied migrations: %w", err) + } + defer rows.Close() + + applied := make(map[string]bool) + for rows.Next() { + var version string + if err := rows.Scan(&version); err != nil { + return fmt.Errorf("scanning migration version: %w", err) + } + applied[version] = true } - log.Println("Initializing database and applying migrations...") + // 3. Special case for legacy system: + // If 'users' table exists but 'schema_migrations' is empty, + // mark all migrations up to 005 as applied to avoid duplicate creation errors. + if len(applied) == 0 { + var usersExists bool + pool.QueryRow(ctx, "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'users')").Scan(&usersExists) + if usersExists { + log.Println("Legacy database detected, bootstrapping migration tracking...") + // We assume 001 to 005 are already there if users table exists in the old system + legacyVersions := []string{"001_create_users.up.sql", "002_create_sessions.up.sql", "003_create_feeds.up.sql", "004_add_user_roles.up.sql", "005_search_articles.up.sql"} + for _, v := range legacyVersions { + _, err := pool.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", v) + if err != nil { + log.Printf("Warning: failed to bootstrap version %s: %v", v, err) + } + applied[v] = true + } + } + } - // Read migration files + // 4. Read migration files entries, err := os.ReadDir("./migrations") if err != nil { return fmt.Errorf("reading migrations dir: %w", err) @@ -76,18 +103,38 @@ func RunMigrations(ctx context.Context, pool *pgxpool.Pool) error { } sort.Strings(upFiles) + // 5. Apply pending migrations for _, file := range upFiles { + if applied[file] { + continue + } + log.Printf("Applying migration: %s", file) content, err := os.ReadFile(filepath.Join("./migrations", file)) if err != nil { return fmt.Errorf("reading migration %s: %w", file, err) } - if _, err := pool.Exec(ctx, string(content)); err != nil { + // Execute in transaction + tx, err := pool.Begin(ctx) + if err != nil { + return fmt.Errorf("starting transaction for %s: %w", file, err) + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, string(content)); err != nil { return fmt.Errorf("executing migration %s: %w", file, err) } + + if _, err := tx.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", file); err != nil { + return fmt.Errorf("recording migration %s: %w", file, err) + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("committing migration %s: %w", file, err) + } } - log.Println("All migrations applied successfully") + log.Println("Database schema is up to date") return nil } diff --git a/internal/domain/article.go b/internal/domain/article.go index df4466b..d59cb46 100644 --- a/internal/domain/article.go +++ b/internal/domain/article.go @@ -15,6 +15,7 @@ type Article struct { URL string `json:"url,omitempty"` Content string `json:"content,omitempty"` Summary string `json:"summary,omitempty"` + AISummary string `json:"ai_summary,omitempty"` Author string `json:"author,omitempty"` ImageURL string `json:"image_url,omitempty"` PublishedAt *time.Time `json:"published_at,omitempty"` @@ -42,4 +43,6 @@ type ArticleRepository interface { ToggleFavorite(id uuid.UUID) error GetFavorites(userID uuid.UUID, limit, offset int) ([]*Article, error) CountUnread(feedID uuid.UUID) (int, error) + Search(userID uuid.UUID, query string, limit, offset int) ([]*Article, error) + UpdateAISummary(id uuid.UUID, summary string) error } diff --git a/internal/handler/article.go b/internal/handler/article.go index 08e8fc7..c5766bd 100644 --- a/internal/handler/article.go +++ b/internal/handler/article.go @@ -1,6 +1,7 @@ package handler import ( + "fmt" "net/http" "strconv" @@ -17,17 +18,21 @@ type ArticleHandler struct { articleRepo domain.ArticleRepository feedService *service.FeedService authService *service.AuthService + aiService *service.AIService sanitizer *utils.ContentSanitizer + extractor *utils.ContentExtractor hub *ws.Hub } // NewArticleHandler creates a new article handler. -func NewArticleHandler(articleRepo domain.ArticleRepository, feedService *service.FeedService, authService *service.AuthService, hub *ws.Hub) *ArticleHandler { +func NewArticleHandler(articleRepo domain.ArticleRepository, feedService *service.FeedService, authService *service.AuthService, aiService *service.AIService, hub *ws.Hub) *ArticleHandler { return &ArticleHandler{ articleRepo: articleRepo, feedService: feedService, authService: authService, + aiService: aiService, sanitizer: utils.NewContentSanitizer(), + extractor: utils.NewContentExtractor(), hub: hub, } } @@ -355,3 +360,110 @@ func (h *ArticleHandler) GetFavorites(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, articles) } + +// Search handles GET /api/v1/articles/search +func (h *ArticleHandler) Search(w http.ResponseWriter, r *http.Request) { + userID, err := h.getUserFromRequest(r) + if err != nil { + respondError(w, http.StatusUnauthorized, "Not authenticated") + return + } + + query := r.URL.Query().Get("q") + if query == "" { + respondJSON(w, http.StatusOK, []*domain.Article{}) + return + } + + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 || limit > 100 { + limit = 50 + } + + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + if offset < 0 { + offset = 0 + } + + articles, err := h.articleRepo.Search(userID, query, limit, offset) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to search articles") + return + } + + respondJSON(w, http.StatusOK, articles) +} + +// Summarize handles POST /api/v1/articles/{id}/summarize +func (h *ArticleHandler) Summarize(w http.ResponseWriter, r *http.Request) { + userID, err := h.getUserFromRequest(r) + if err != nil { + respondError(w, http.StatusUnauthorized, "Not authenticated") + return + } + + articleID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + respondError(w, http.StatusBadRequest, "Invalid article ID") + return + } + + article, err := h.articleRepo.GetByID(articleID) + if err != nil || article == nil { + respondError(w, http.StatusNotFound, "Article not found") + return + } + + // Verify feed ownership + _, err = h.feedService.GetFeed(article.FeedID, userID) + if err != nil { + respondError(w, http.StatusForbidden, "Access denied") + return + } + + // If already summarized, return it + if article.AISummary != "" { + respondJSON(w, http.StatusOK, map[string]string{"summary": article.AISummary}) + return + } + + // Context for AI is title + content (or summary if content empty) + content := article.Content + if content == "" { + content = article.Summary + } + + // Try to extract full content from URL if available + if article.URL != "" { + fullContent, err := h.extractor.Extract(r.Context(), article.URL) + if err == nil && len(fullContent) > len(content) { + content = "--- CONTENU COMPLET EXTRAIT DU SITE WEB ---\n" + fullContent + } + } + + aiInput := fmt.Sprintf("Titre: %s\n\nContenu: %s", article.Title, content) + + // Summary generation (can be slow, but for this demo/small app we do it synchronously + // or we could use WS to notify when done. Here we follow the simple POST -> String pattern). + summary, err := h.aiService.Summarize(r.Context(), aiInput) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to generate summary: "+err.Error()) + return + } + + // Save to DB + if err := h.articleRepo.UpdateAISummary(articleID, summary); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to persist summary") + return + } + + // Broadcast update via WebSocket + if h.hub != nil { + h.hub.Broadcast("article_updated", map[string]interface{}{ + "id": articleID, + "ai_summary": summary, + }) + } + + respondJSON(w, http.StatusOK, map[string]string{"summary": summary}) +} diff --git a/internal/repository/article.go b/internal/repository/article.go index d055d76..6478e41 100644 --- a/internal/repository/article.go +++ b/internal/repository/article.go @@ -27,8 +27,8 @@ 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) + INSERT INTO articles (id, feed_id, guid, title, url, content, summary, ai_summary, author, image_url, published_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (feed_id, guid) DO NOTHING ` @@ -40,6 +40,7 @@ func (r *ArticleRepository) Create(article *domain.Article) error { nullString(article.URL), nullString(article.Content), nullString(article.Summary), + nullString(article.AISummary), nullString(article.Author), nullString(article.ImageURL), article.PublishedAt, @@ -59,8 +60,8 @@ func (r *ArticleRepository) CreateBatch(articles []*domain.Article) error { 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) + INSERT INTO articles (id, feed_id, guid, title, url, content, summary, ai_summary, author, image_url, published_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (feed_id, guid) DO NOTHING ` @@ -73,6 +74,7 @@ func (r *ArticleRepository) CreateBatch(articles []*domain.Article) error { nullString(article.URL), nullString(article.Content), nullString(article.Summary), + nullString(article.AISummary), nullString(article.Author), nullString(article.ImageURL), article.PublishedAt, @@ -97,7 +99,7 @@ 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, + SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_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 @@ -121,7 +123,7 @@ func (r *ArticleRepository) GetByFeedID(feedID uuid.UUID, limit, offset int) ([] ctx := context.Background() query := ` - SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author, + SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_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 @@ -147,7 +149,7 @@ func (r *ArticleRepository) GetByUserID(userID uuid.UUID, limit, offset int, unr var query string if unreadOnly { query = ` - SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author, + SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_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 @@ -158,7 +160,7 @@ func (r *ArticleRepository) GetByUserID(userID uuid.UUID, limit, offset int, unr ` } else { query = ` - SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author, + SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_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 @@ -183,7 +185,7 @@ func (r *ArticleRepository) GetByGUID(feedID uuid.UUID, guid string) (*domain.Ar ctx := context.Background() query := ` - SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author, + SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_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 @@ -269,7 +271,7 @@ func (r *ArticleRepository) GetFavorites(userID uuid.UUID, limit, offset int) ([ ctx := context.Background() query := ` - SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author, + SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_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 @@ -305,7 +307,7 @@ func (r *ArticleRepository) CountUnread(feedID uuid.UUID) (int, error) { // 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 url, content, summary, aiSummary, author, imageURL, feedTitle *string var publishedAt, readAt *time.Time err := row.Scan( @@ -316,6 +318,7 @@ func (r *ArticleRepository) scanArticle(row pgx.Row) (*domain.Article, error) { &url, &content, &summary, + &aiSummary, &author, &imageURL, &publishedAt, @@ -339,6 +342,9 @@ func (r *ArticleRepository) scanArticle(row pgx.Row) (*domain.Article, error) { if summary != nil { article.Summary = *summary } + if aiSummary != nil { + article.AISummary = *aiSummary + } if author != nil { article.Author = *author } @@ -359,7 +365,7 @@ func (r *ArticleRepository) scanArticles(rows pgx.Rows) ([]*domain.Article, erro var articles []*domain.Article for rows.Next() { var article domain.Article - var url, content, summary, author, imageURL, feedTitle *string + var url, content, summary, aiSummary, author, imageURL, feedTitle *string var publishedAt, readAt *time.Time err := rows.Scan( @@ -370,6 +376,7 @@ func (r *ArticleRepository) scanArticles(rows pgx.Rows) ([]*domain.Article, erro &url, &content, &summary, + &aiSummary, &author, &imageURL, &publishedAt, @@ -393,6 +400,9 @@ func (r *ArticleRepository) scanArticles(rows pgx.Rows) ([]*domain.Article, erro if summary != nil { article.Summary = *summary } + if aiSummary != nil { + article.AISummary = *aiSummary + } if author != nil { article.Author = *author } @@ -411,6 +421,123 @@ func (r *ArticleRepository) scanArticles(rows pgx.Rows) ([]*domain.Article, erro return articles, nil } +// Search performs a full-text search on articles for a specific user. +func (r *ArticleRepository) Search(userID uuid.UUID, query string, limit, offset int) ([]*domain.Article, error) { + ctx := context.Background() + + // Use plainto_tsquery or websearch_to_tsquery for natural language search + sql := ` + SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_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, + ts_rank_cd(a.tsv, websearch_to_tsquery('french', $2)) as rank + FROM articles a + JOIN feeds f ON f.id = a.feed_id + WHERE f.user_id = $1 AND a.tsv @@ websearch_to_tsquery('french', $2) + ORDER BY rank DESC, a.published_at DESC + LIMIT $3 OFFSET $4 + ` + + rows, err := r.pool.Query(ctx, sql, userID, query, limit, offset) + if err != nil { + return nil, fmt.Errorf("searching articles: %w", err) + } + defer rows.Close() + + return r.scanArticlesWithRank(rows) +} + +// scanArticlesWithRank scans multiple article rows with their rank. +func (r *ArticleRepository) scanArticlesWithRank(rows pgx.Rows) ([]*domain.Article, error) { + var articles []*domain.Article + for rows.Next() { + var article domain.Article + var url, content, summary, aiSummary, author, imageURL, feedTitle *string + var publishedAt, readAt *time.Time + var rank float32 + + err := rows.Scan( + &article.ID, + &article.FeedID, + &article.GUID, + &article.Title, + &url, + &content, + &summary, + &aiSummary, + &author, + &imageURL, + &publishedAt, + &article.IsRead, + &article.IsFavorite, + &readAt, + &article.CreatedAt, + &feedTitle, + &rank, + ) + + if err != nil { + return nil, fmt.Errorf("scanning article with rank: %w", err) + } + + if url != nil { + article.URL = *url + } + if content != nil { + article.Content = *content + } + if summary != nil { + article.Summary = *summary + } + if aiSummary != nil { + article.AISummary = *aiSummary + } + 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 +} + +// UpdateAISummary updates the AI-generated summary of an article. +func (r *ArticleRepository) UpdateAISummary(id uuid.UUID, summary string) error { + ctx := context.Background() + query := `UPDATE articles SET ai_summary = $2 WHERE id = $1` + _, err := r.pool.Exec(ctx, query, id, summary) + if err != nil { + return fmt.Errorf("updating AI summary: %w", err) + } + return nil +} + +// DeleteOldArticles removes articles older than the specified duration, except for favorites. +func (r *ArticleRepository) DeleteOldArticles(ctx context.Context, olderThan time.Duration) (int64, error) { + threshold := time.Now().Add(-olderThan) + + query := ` + DELETE FROM articles + WHERE created_at < $1 AND is_favorite = false + ` + + result, err := r.pool.Exec(ctx, query, threshold) + if err != nil { + return 0, fmt.Errorf("deleting old articles: %w", err) + } + + return result.RowsAffected(), nil +} + // nullString returns nil if string is empty. func nullString(s string) *string { if s == "" { diff --git a/internal/service/ai.go b/internal/service/ai.go new file mode 100644 index 0000000..b001ef2 --- /dev/null +++ b/internal/service/ai.go @@ -0,0 +1,107 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" +) + +// AIService handles interactions with AI providers (OpenRouter). +type AIService struct { + apiKey string + client *http.Client +} + +// NewAIService creates a new AI service. +func NewAIService() *AIService { + return &AIService{ + apiKey: os.Getenv("OPENROUTER_API_KEY"), + client: &http.Client{}, + } +} + +// OpenRouterRequest represents the request body for OpenRouter. +type OpenRouterRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` +} + +// Message represents a message in the conversation. +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// OpenRouterResponse represents the response body from OpenRouter. +type OpenRouterResponse struct { + Choices []struct { + Message Message `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// Summarize generates a concise summary of the given content. +func (s *AIService) Summarize(ctx context.Context, content string) (string, error) { + if s.apiKey == "" { + return "", fmt.Errorf("OPENROUTER_API_KEY not set") + } + + prompt := fmt.Sprintf("Résume l'article suivant en 3 à 5 phrases percutantes. Sois direct et informatif :\n\n%s", content) + + reqBody := OpenRouterRequest{ + Model: "google/gemini-2.0-flash-001", // Économique et performant + Messages: []Message{ + {Role: "user", Content: prompt}, + }, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", "https://openrouter.ai/api/v1/chat/completions", bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+s.apiKey) + req.Header.Set("HTTP-Referer", "https://github.com/michael/flowreader") // Optionnel pour OpenRouter + + resp, err := s.client.Do(req) + if err != nil { + return "", fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + var orResp OpenRouterResponse + if err := json.Unmarshal(body, &orResp); err != nil { + return "", fmt.Errorf("unmarshaling response: %w", err) + } + + if orResp.Error != nil { + return "", fmt.Errorf("OpenRouter error: %s", orResp.Error.Message) + } + + if len(orResp.Choices) == 0 { + return "", fmt.Errorf("no summary generated") + } + + return orResp.Choices[0].Message.Content, nil +} diff --git a/internal/utils/extractor.go b/internal/utils/extractor.go new file mode 100644 index 0000000..7f75364 --- /dev/null +++ b/internal/utils/extractor.go @@ -0,0 +1,118 @@ +package utils + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" +) + +// ContentExtractor extracts the main text content from a web page. +type ContentExtractor struct { + client *http.Client +} + +// NewContentExtractor creates a new extractor instance. +func NewContentExtractor() *ContentExtractor { + return &ContentExtractor{ + client: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// Extract fetches the URL and tries to extract the main article content. +func (e *ContentExtractor) Extract(ctx context.Context, url string) (string, error) { + if url == "" { + return "", fmt.Errorf("empty URL") + } + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return "", err + } + + // Set a common User-Agent to avoid some basic bot detection + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + + resp, err := e.client.Do(req) + if err != nil { + return "", fmt.Errorf("fetching URL: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return "", fmt.Errorf("parsing HTML: %w", err) + } + + // 1. Remove noise + doc.Find("script, style, nav, footer, header, aside, .ads, #comments, .sidebar").Remove() + + // 2. Try to find the main content container + var content string + + // Priority list of selectors for article content + selectors := []string{ + "article", + "main", + ".article-content", + ".post-content", + ".entry-content", + ".content", + "#main-content", + } + + for _, selector := range selectors { + selection := doc.Find(selector) + if selection.Length() > 0 { + // Get the longest piece of text if multiple matches + maxLength := 0 + var bestSelection *goquery.Selection + selection.Each(func(i int, s *goquery.Selection) { + textLen := len(strings.TrimSpace(s.Text())) + if textLen > maxLength { + maxLength = textLen + bestSelection = s + } + }) + if bestSelection != nil { + content = e.cleanText(bestSelection.Text()) + if len(content) > 500 { // Heuristic: good enough + break + } + } + } + } + + // 3. Fallback: Body text if no container found or text too short + if len(content) < 500 { + content = e.cleanText(doc.Find("body").Text()) + } + + // Limit to 10000 characters to avoid huge payloads to AI + if len(content) > 10000 { + content = content[:10000] + } + + return content, nil +} + +func (e *ContentExtractor) cleanText(text string) string { + lines := strings.Split(text, "\n") + var cleaned []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + cleaned = append(cleaned, trimmed) + } + } + return strings.Join(cleaned, "\n") +} diff --git a/internal/worker/cleaner.go b/internal/worker/cleaner.go new file mode 100644 index 0000000..a867333 --- /dev/null +++ b/internal/worker/cleaner.go @@ -0,0 +1,76 @@ +package worker + +import ( + "context" + "log" + "sync" + "time" + + "github.com/michael/flowreader/internal/repository" +) + +// Cleaner handles periodic database maintenance. +type Cleaner struct { + repo *repository.ArticleRepository + interval time.Duration + stopCh chan struct{} + wg sync.WaitGroup +} + +// NewCleaner creates a new database cleaner worker. +func NewCleaner(repo *repository.ArticleRepository, interval time.Duration) *Cleaner { + return &Cleaner{ + repo: repo, + interval: interval, + stopCh: make(chan struct{}), + } +} + +// Start begins the background maintenance loop. +func (c *Cleaner) Start() { + c.wg.Add(1) + go c.run() + log.Printf("Maintenance worker started (interval: %s)", c.interval) +} + +// Stop gracefully stops the cleaner. +func (c *Cleaner) Stop() { + close(c.stopCh) + c.wg.Wait() + log.Println("Maintenance worker stopped") +} + +func (c *Cleaner) run() { + defer c.wg.Done() + + // Initial cleanup on startup + c.cleanup() + + ticker := time.NewTicker(c.interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + c.cleanup() + case <-c.stopCh: + return + } + } +} + +func (c *Cleaner) cleanup() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + // Delete articles older than 30 days + count, err := c.repo.DeleteOldArticles(ctx, 30*24*time.Hour) + if err != nil { + log.Printf("Maintenance cleanup error: %v", err) + return + } + + if count > 0 { + log.Printf("Maintenance: cleaned up %d old articles", count) + } +} diff --git a/migrations/005_search_articles.down.sql b/migrations/005_search_articles.down.sql new file mode 100644 index 0000000..978ed5d --- /dev/null +++ b/migrations/005_search_articles.down.sql @@ -0,0 +1,5 @@ +-- Migration: 005_search_articles_down +-- Description: Remove Full-Text Search support + +DROP INDEX IF EXISTS idx_articles_tsv; +ALTER TABLE articles DROP COLUMN IF EXISTS tsv; diff --git a/migrations/005_search_articles.up.sql b/migrations/005_search_articles.up.sql new file mode 100644 index 0000000..58cfc05 --- /dev/null +++ b/migrations/005_search_articles.up.sql @@ -0,0 +1,15 @@ +-- Migration: 005_search_articles_up +-- Description: Add Full-Text Search support to articles table + +-- Add tsvector column (generated) +-- Weighting: Title (A), Summary (B), Content (C) +ALTER TABLE articles +ADD COLUMN IF NOT EXISTS tsv tsvector +GENERATED ALWAYS AS ( + setweight(to_tsvector('french', coalesce(title, '')), 'A') || + setweight(to_tsvector('french', coalesce(summary, '')), 'B') || + setweight(to_tsvector('french', coalesce(content, '')), 'C') +) STORED; + +-- Create GIN index for fast searching +CREATE INDEX IF NOT EXISTS idx_articles_tsv ON articles USING GIN(tsv); diff --git a/migrations/006_add_ai_summary.up.sql b/migrations/006_add_ai_summary.up.sql new file mode 100644 index 0000000..e3ecb3d --- /dev/null +++ b/migrations/006_add_ai_summary.up.sql @@ -0,0 +1,4 @@ +-- Migration: 006_add_ai_summary +-- Description: Add ai_summary column to articles table + +ALTER TABLE articles ADD COLUMN IF NOT EXISTS ai_summary TEXT; diff --git a/web/index.html b/web/index.html index d5c6a03..5e00a49 100644 --- a/web/index.html +++ b/web/index.html @@ -1,13 +1,20 @@ -
- - - -