Files
noteflow/routes/rss.routes.js
T
Claude 9748ecb1bb Feature: Amélioration système RSS et OpenRouter
Backend:
- Récupération des vrais modèles depuis l'API OpenRouter
- Table rss_summaries pour stocker les résumés générés
- Limitation à 5 articles RSS au lieu de 10
- Route GET /api/rss/summaries pour récupérer les résumés
- Sauvegarde automatique des résumés en base de données

Frontend:
- Affichage conditionnel: résumés si activé, sinon articles
- Fonction renderRssSummaries() avec conversion Markdown→HTML
- Formatage du résumé (titres, listes, gras, italique)
- CSS complet pour .rss-summary avec typography
- Bouton résumer: crée note ou recharge bloc selon config

Comportement:
- Si rss_summary_enabled=1: affiche les 5 derniers résumés
- Sinon: affiche les 5 derniers articles
- Résumés formatés avec Markdown dans le bloc RSS
2025-11-11 17:03:44 +00:00

375 lines
12 KiB
JavaScript

// Routes de gestion des flux RSS
const express = require('express');
const router = express.Router();
const Parser = require('rss-parser');
const axios = require('axios');
const { getAll, getOne, runQuery } = require('../config/database');
const { authenticateToken, requireAdmin } = require('../middleware/auth');
const logger = require('../config/logger');
const parser = new Parser();
// Routes admin (gestion des flux)
router.use(authenticateToken);
/**
* GET /api/rss/feeds
* Liste tous les flux RSS
*/
router.get('/feeds', requireAdmin, async (req, res) => {
try {
const feeds = await getAll('SELECT * FROM rss_feeds ORDER BY created_at DESC');
res.json(feeds || []);
} catch (error) {
logger.error('Erreur lors de la récupération des flux RSS:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
/**
* POST /api/rss/feeds
* Ajouter un nouveau flux RSS
*/
router.post('/feeds', requireAdmin, async (req, res) => {
try {
const { url } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL requise' });
}
// Vérifier si le flux existe déjà
const existing = await getOne('SELECT id FROM rss_feeds WHERE url = ?', [url]);
if (existing) {
return res.status(409).json({ error: 'Ce flux existe déjà' });
}
// Tester le flux
try {
const feed = await parser.parseURL(url);
// Ajouter le flux
const result = await runQuery(
'INSERT INTO rss_feeds (url, title, description) VALUES (?, ?, ?)',
[url, feed.title || url, feed.description || '']
);
logger.info(`Flux RSS ajouté: ${feed.title} (${url})`);
res.json({
id: result.id,
url,
title: feed.title || url,
description: feed.description || '',
enabled: 1
});
} catch (parseError) {
logger.error('Erreur lors du parsing du flux RSS:', parseError);
return res.status(400).json({ error: 'Impossible de parser ce flux RSS. Vérifiez l\'URL.' });
}
} catch (error) {
logger.error('Erreur lors de l\'ajout du flux RSS:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
/**
* PUT /api/rss/feeds/:id
* Mettre à jour un flux RSS (activer/désactiver)
*/
router.put('/feeds/:id', requireAdmin, async (req, res) => {
try {
const { enabled } = req.body;
await runQuery(
'UPDATE rss_feeds SET enabled = ? WHERE id = ?',
[enabled ? 1 : 0, req.params.id]
);
logger.info(`Flux RSS ${enabled ? 'activé' : 'désactivé'}: ${req.params.id}`);
res.json({ message: 'Flux mis à jour avec succès' });
} catch (error) {
logger.error('Erreur lors de la mise à jour du flux RSS:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
/**
* DELETE /api/rss/feeds/:id
* Supprimer un flux RSS
*/
router.delete('/feeds/:id', requireAdmin, async (req, res) => {
try {
await runQuery('DELETE FROM rss_feeds WHERE id = ?', [req.params.id]);
logger.info(`Flux RSS supprimé: ${req.params.id}`);
res.json({ message: 'Flux supprimé avec succès' });
} catch (error) {
logger.error('Erreur lors de la suppression du flux RSS:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
/**
* POST /api/rss/fetch
* Récupérer tous les articles des flux actifs
*/
router.post('/fetch', requireAdmin, async (req, res) => {
try {
const feeds = await getAll('SELECT * FROM rss_feeds WHERE enabled = 1');
let totalArticles = 0;
for (const feed of feeds) {
try {
const parsedFeed = await parser.parseURL(feed.url);
// Mettre à jour les infos du flux
await runQuery(
'UPDATE rss_feeds SET title = ?, description = ?, last_fetched_at = CURRENT_TIMESTAMP WHERE id = ?',
[parsedFeed.title || feed.url, parsedFeed.description || '', feed.id]
);
// Ajouter les articles
for (const item of parsedFeed.items) {
try {
// Vérifier si l'article existe déjà
const existing = await getOne('SELECT id FROM rss_articles WHERE link = ?', [item.link]);
if (!existing) {
await runQuery(
'INSERT INTO rss_articles (feed_id, title, link, description, pub_date, content) VALUES (?, ?, ?, ?, ?, ?)',
[
feed.id,
item.title || '',
item.link || '',
item.contentSnippet || item.description || '',
item.pubDate || item.isoDate || new Date().toISOString(),
item.content || item['content:encoded'] || ''
]
);
totalArticles++;
}
} catch (articleError) {
// Ignorer les articles en double ou invalides
logger.warn(`Erreur lors de l'ajout d'un article: ${articleError.message}`);
}
}
} catch (feedError) {
logger.error(`Erreur lors du fetch du flux ${feed.url}:`, feedError);
}
}
logger.info(`${totalArticles} nouveaux articles récupérés`);
res.json({ message: `${totalArticles} nouveaux articles récupérés` });
} catch (error) {
logger.error('Erreur lors du fetch des flux RSS:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
/**
* GET /api/rss/articles
* Récupérer les 5 derniers articles
*/
router.get('/articles', async (req, res) => {
try {
const articles = await getAll(`
SELECT
a.id, a.title, a.link, a.description, a.pub_date, a.content,
f.title as feed_title, f.url as feed_url
FROM rss_articles a
JOIN rss_feeds f ON a.feed_id = f.id
ORDER BY a.pub_date DESC
LIMIT 5
`);
res.json(articles || []);
} catch (error) {
logger.error('Erreur lors de la récupération des articles RSS:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
/**
* POST /api/rss/summarize
* Générer un résumé des 5 derniers articles avec OpenRouter
*/
router.post('/summarize', requireAdmin, async (req, res) => {
try {
// Récupérer les paramètres
const apiKey = await getOne('SELECT value FROM settings WHERE key = ?', ['openrouter_api_key']);
const model = await getOne('SELECT value FROM settings WHERE key = ?', ['openrouter_model']);
const prompt = await getOne('SELECT value FROM settings WHERE key = ?', ['rss_summary_prompt']);
if (!apiKey || !apiKey.value) {
return res.status(400).json({ error: 'Clé API OpenRouter non configurée' });
}
// Récupérer les 5 derniers articles
const articles = await getAll(`
SELECT
a.title, a.description, a.link, a.pub_date,
f.title as feed_title
FROM rss_articles a
JOIN rss_feeds f ON a.feed_id = f.id
ORDER BY a.pub_date DESC
LIMIT 5
`);
if (articles.length === 0) {
return res.status(400).json({ error: 'Aucun article à résumer' });
}
// Construire le prompt
const defaultPrompt = prompt?.value || `Tu es un assistant spécialisé dans la synthèse d'actualités. Analyse les articles suivants et crée un résumé structuré et informatif.
Pour chaque article, identifie:
- Le sujet principal
- Les points clés
- L'importance de l'information
Ensuite, génère un résumé global qui:
1. Regroupe les thèmes communs
2. Hiérarchise les informations par importance
3. Présente une vue d'ensemble claire et concise
4. Utilise un style professionnel mais accessible
Format de sortie en Markdown avec des sections claires.`;
const articlesText = articles.map((a, i) =>
`## Article ${i + 1}: ${a.title}\n**Source**: ${a.feed_title}\n**Date**: ${new Date(a.pub_date).toLocaleDateString('fr-FR')}\n**Description**: ${a.description}\n**Lien**: ${a.link}\n`
).join('\n---\n');
const fullPrompt = `${defaultPrompt}\n\n# Articles à résumer:\n\n${articlesText}`;
// Appeler OpenRouter
const selectedModel = model?.value || 'openai/gpt-3.5-turbo';
logger.info(`Génération du résumé avec le modèle ${selectedModel}...`);
const response = await axios.post(
'https://openrouter.ai/api/v1/chat/completions',
{
model: selectedModel,
messages: [
{
role: 'user',
content: fullPrompt
}
]
},
{
headers: {
'Authorization': `Bearer ${apiKey.value}`,
'HTTP-Referer': 'https://noteflow.app',
'X-Title': 'NoteFlow RSS Summarizer',
'Content-Type': 'application/json'
},
timeout: 60000
}
);
const summary = response.data.choices[0].message.content;
// Sauvegarder le résumé en base de données
await runQuery(
'INSERT INTO rss_summaries (summary, model, articles_count) VALUES (?, ?, ?)',
[summary, selectedModel, articles.length]
);
logger.info('Résumé généré et sauvegardé avec succès');
res.json({
summary,
model: selectedModel,
articles_count: articles.length
});
} catch (error) {
logger.error('Erreur lors de la génération du résumé:', error);
if (error.response) {
logger.error('Réponse OpenRouter:', error.response.data);
return res.status(error.response.status).json({
error: 'Erreur API OpenRouter',
details: error.response.data
});
}
res.status(500).json({ error: 'Erreur serveur' });
}
});
/**
* GET /api/rss/summaries
* Récupérer les 5 derniers résumés
*/
router.get('/summaries', async (req, res) => {
try {
const summaries = await getAll(`
SELECT id, summary, model, articles_count, created_at
FROM rss_summaries
ORDER BY created_at DESC
LIMIT 5
`);
res.json(summaries || []);
} catch (error) {
logger.error('Erreur lors de la récupération des résumés:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
/**
* GET /api/rss/models
* Récupérer la liste des modèles OpenRouter depuis l'API
*/
router.get('/models', requireAdmin, async (req, res) => {
try {
// Récupérer les modèles depuis l'API OpenRouter
const response = await axios.get('https://openrouter.ai/api/v1/models', {
headers: {
'HTTP-Referer': 'https://noteflow.app',
'X-Title': 'NoteFlow'
},
timeout: 10000
});
// Transformer les données en format simplifié
const models = response.data.data.map(model => ({
id: model.id,
name: model.name || model.id,
provider: model.id.split('/')[0] || 'Unknown',
context_length: model.context_length,
pricing: model.pricing
}));
// Trier par provider puis par nom
models.sort((a, b) => {
if (a.provider !== b.provider) {
return a.provider.localeCompare(b.provider);
}
return a.name.localeCompare(b.name);
});
res.json(models);
} catch (error) {
logger.error('Erreur lors de la récupération des modèles:', error);
// Fallback sur une liste de modèles populaires si l'API échoue
const fallbackModels = [
{ id: 'openai/gpt-4-turbo', name: 'GPT-4 Turbo', provider: 'openai' },
{ id: 'openai/gpt-3.5-turbo', name: 'GPT-3.5 Turbo', provider: 'openai' },
{ id: 'anthropic/claude-3-opus', name: 'Claude 3 Opus', provider: 'anthropic' },
{ id: 'anthropic/claude-3-sonnet', name: 'Claude 3 Sonnet', provider: 'anthropic' },
{ id: 'google/gemini-pro', name: 'Gemini Pro', provider: 'google' },
{ id: 'meta-llama/llama-3-70b-instruct', name: 'Llama 3 70B', provider: 'meta-llama' },
{ id: 'mistralai/mistral-large', name: 'Mistral Large', provider: 'mistralai' }
];
res.json(fallbackModels);
}
});
module.exports = router;