Début du projet

This commit is contained in:
TopScritch committed 2026-10-25 20:05:12 +01:00
commit e7d4e607f6
23 files changed
+4855

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
node_modules
npm-debug.log
.env
.git
.gitignore
README.md
data
logs
+25
View File
@@ -0,0 +1,25 @@
# Configuration de l'agrégateur d'actualités
NODE_ENV=production
PORT=3000
# Authentification administration
ADMIN_USERNAME=admin
ADMIN_PASSWORD=changeme123
# Session
SESSION_SECRET=change-this-to-a-random-secret
# Collecte
COLLECT_INTERVAL=60 # minutes entre chaque collecte automatique
COLLECT_SOURCES=default # 'default' ou liste personnalisée dans config/sources.json
# IA (facultatif - laisser vide pour utiliser uniquement le scraping RSS)
AI_ENABLED=false
AI_MODEL= # ex: gemini-1.5-flash (gratuit selon fournisseur), groq llama-3.1-8b-instant (gratuit selon quota), etc.
AI_API_KEY=
AI_API_URL= # URL personnalisée si nécessaire
AI_SEARCH_ENABLED=false
# Options
AUTO_PUBLISH=false # Mettre automatiquement en 'published' au lieu de 'pending'
LOG_LEVEL=info
+8
View File
@@ -0,0 +1,8 @@
node_modules/
.env
data/
logs/
*.db
*.sqlite
*.sqlite3
.DS_Store
+19
View File
@@ -0,0 +1,19 @@
FROM node:18-alpine
WORKDIR /app
# Installer les dépendances système pour better-sqlite3
RUN apk add --no-cache python3 make g++
COPY package*.json ./
RUN npm ci --only=production
COPY . .
# Créer les répertoires nécessaires
RUN mkdir -p data logs
EXPOSE 3000
CMD ["node", "app/server.js"]
+152
View File
@@ -0,0 +1,152 @@
# Veille Tech - Agrégateur d'actualités informatiques
Agrégateur simple et automatisé d'actualités liées à l'informatique, avec interface publique et administration.
## Fonctionnalités
- **Page publique** : Liste des articles publiés, tri par date (récent/ancien), filtrage par catégorie
- **Collecte automatique** : Récupération depuis des flux RSS toutes les X minutes (configurable)
- **Évitement des doublons** : Vérification par URL et par titre
- **Administration sécurisée** : Authentification par identifiants/mot de passe
- **Gestion des articles** : Valider, masquer, rejeter, publier, supprimer
- **Journal d'activité** : Traçabilité des actions
- **Gestion des sources** : Consultation des sources RSS configurées
- **Dockerisé** : Prêt à tourner dans un conteneur avec volume persistant
## Prérequis
- [Docker](https://www.docker.com/) et [Docker Compose](https://docs.docker.com/compose/) (recommandé)
- Ou [Node.js](https://nodejs.org/) v16+ et npm
## Installation
### Avec Docker (recommandé)
1. Cloner ou copier le projet
2. Copier le fichier d'exemple de configuration
```bash
cp .env.example .env
```
3. Éditer `.env` pour configurer vos identifiants et paramètres
```bash
nano .env # ou votre éditeur préféré
```
4. Lancer avec Docker Compose
```bash
docker-compose up -d
```
5. Accéder au site : http://localhost:3000
### En local (sans Docker)
1. Installer les dépendances
```bash
npm install
```
2. Configurer l'environnement
```bash
cp .env.example .env
```
3. Lancer le serveur
```bash
npm start
```
4. Accéder au site : http://localhost:3000
## Configuration
Éditez le fichier `.env` :
| Variable | Description | Défaut |
|----------|-------------|--------|
| `PORT` | Port du serveur | `3000` |
| `ADMIN_USERNAME` | Identifiant admin | `admin` |
| `ADMIN_PASSWORD` | Mot de passe admin | `changeme123` |
| `SESSION_SECRET` | Clé secrète pour les sessions | À changer en production |
| `COLLECT_INTERVAL` | Intervalle de collecte en minutes | `60` |
| `AUTO_PUBLISH` | Publier automatiquement (`true`/`false`) | `false` |
| `AI_ENABLED` | Activer la collecte IA (facultatif) | `false` |
## Utilisation
### Page publique
- Accédez à `http://localhost:3000`
- Filtrez par catégorie via le menu
- Triez par date (plus récent / plus ancien)
- Cliquez sur "Lire l'article original" pour aller sur la source
### Interface d'administration
- Accédez à `http://localhost:3000/admin`
- Connectez-vous avec vos identifiants (`.env`)
- **En attente** : Articles collectés, à valider
- **Valider et publier** : Peut modifier catégories et résumé avant publication
- **Publier / Masquer / Rejeter / Supprimer** : Actions sur les articles
- **Collecter maintenant** : Déclenche une collecte manuelle
- **Sources** : Liste des flux RSS configurés
- **Journal** : Historique des actions
### Collecteur
Le collecteur fonctionne automatiquement selon `COLLECT_INTERVAL`. Vous pouvez aussi le lancer manuellement :
```bash
npm run collect
```
## Sources
Les sources RSS sont configurées dans `config/sources.json`. Vous pouvez ajouter, supprimer ou désactiver des sources en éditant ce fichier.
## Structure du projet
```
.
├── app/ # Application web (Express, EJS)
│ ├── routes/ # Routes public/admin
│ ├── static/ # CSS, JS statiques
│ ├── templates/ # Vues EJS
│ ├── db.js # Connexion SQLite
│ └── server.js # Serveur principal
├── collector/ # Collecteur RSS
├── config/ # Configuration (sources.json)
├── data/ # Base de données SQLite (volume persistant)
├── migrations/ # Schéma BDD
├── Dockerfile
├── docker-compose.yml
└── README.md
```
## Sécurité
- Authentification obligatoire pour `/admin`
- Sessions sécurisées avec `express-session`
- Mots de passe non exposés dans le code
- Variables sensibles dans `.env` (non commité)
- Validation des URLs et données entrantes
- Protection CSRF basique via sessions
## Dépannage
**La collecte ne fonctionne pas**
- Vérifiez que les flux RSS sont accessibles
- Consultez les logs : `docker-compose logs -f` ou console
- Lancez manuellement : `npm run collect`
**Problème de connexion admin**
- Vérifiez `ADMIN_USERNAME` et `ADMIN_PASSWORD` dans `.env`
- Supprimez le cookie de session si nécessaire
**Base de données corrompue**
- Sauvegardez `data/articles.db`
- Supprimez-le et relancez l'application (sera recréée)
## Évolutions possibles
- Ajout de sources HTML scraping
- Intégration IA pour résumé/catégorisation (modèles gratuits)
- Système de tags avancé
- Recherche plein texte
- API REST
- Pagination
## Licence
Projet développé dans un cadre pédagogique.
+20
View File
@@ -0,0 +1,20 @@
const Database = require('better-sqlite3');
const fs = require('fs');
const path = require('path');
const dataDir = path.join(__dirname, '..', 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = process.env.DB_PATH || path.join(dataDir, 'articles.db');
const db = new Database(dbPath);
function initDb() {
const migrationPath = path.join(__dirname, '..', 'migrations', '001_init.sql');
const sql = fs.readFileSync(migrationPath, 'utf8');
db.exec(sql);
console.log('[DB] Base de données initialisée:', dbPath);
}
module.exports = { db, initDb };
+170
View File
@@ -0,0 +1,170 @@
const express = require('express');
const router = express.Router();
const { db } = require('../db');
const { v4: uuidv4 } = require('uuid');
function requireAuth(req, res, next) {
if (req.session && req.session.authenticated) {
return next();
}
res.redirect('/admin/login');
}
router.get('/login', (req, res) => {
if (req.session && req.session.authenticated) {
return res.redirect('/admin');
}
res.render('admin/login', { error: null });
});
router.post('/login', (req, res) => {
const { username, password } = req.body;
const adminUser = process.env.ADMIN_USERNAME || 'admin';
const adminPass = process.env.ADMIN_PASSWORD || 'changeme123';
if (username === adminUser && password === adminPass) {
console.log("[DEBUG] Connexion OK")
req.session.authenticated = true;
req.session.username = username;
return req.session.save((err) => {
if (err) {
console.error('Session save error:', err);
}
return res.redirect('/admin');
});
}
res.render('admin/login', { error: 'Identifiants incorrects' });
});
router.get('/logout', (req, res) => {
req.session.destroy(() => {
res.redirect('/admin/login');
});
});
router.use(requireAuth);
router.get('/', (req, res) => {
const status = req.query.status || 'all';
const language = req.query.language || 'all';
// Filtre langue identique au site public (repli sur la langue de la source)
const languageExpr = `COALESCE(language, source_language, 'fr')`;
const conditions = [];
const params = [];
if (status !== 'all') {
conditions.push(`status = ?`);
params.push(status);
}
if (language !== 'all') {
conditions.push(`${languageExpr} = ?`);
params.push(language);
}
const where = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '';
const articles = db
.prepare(`SELECT * FROM articles${where} ORDER BY datetime(created_at) DESC`)
.all(...params);
// Stats (filtrées sur la langue sélectionnée)
const stats = {};
['pending', 'published', 'hidden', 'rejected'].forEach(s => {
const statParams = [s];
let statQuery = `SELECT COUNT(*) as c FROM articles WHERE status = ?`;
if (language !== 'all') {
statQuery += ` AND ${languageExpr} = ?`;
statParams.push(language);
}
stats[s] = db.prepare(statQuery).get(...statParams).c;
});
let totalQuery = `SELECT COUNT(*) as c FROM articles`;
const totalParams = [];
if (language !== 'all') {
totalQuery += ` WHERE ${languageExpr} = ?`;
totalParams.push(language);
}
stats.total = db.prepare(totalQuery).get(...totalParams).c;
// Langues disponibles dans l'ensemble des articles
const languages = db
.prepare(`SELECT ${languageExpr} as lang, COUNT(*) as c FROM articles GROUP BY lang ORDER BY c DESC, lang`)
.all();
res.render('admin/dashboard', { articles, status, stats, languages, language });
});
router.post('/article/:id/validate', (req, res) => {
const { id } = req.params;
const { categories, summary } = req.body;
try {
const categoriesJson = categories ? JSON.stringify(categories.split(',').map(c => c.trim()).filter(Boolean)) : null;
db.prepare(`UPDATE articles SET status='published', categories=COALESCE(?, categories), summary=COALESCE(?, summary), updated_at=datetime('now') WHERE id=?`)
.run(categoriesJson, summary || null, id);
db.prepare(`INSERT INTO activity_log (action, article_id, details) VALUES ('validate', ?, 'Article publié')`).run(id);
res.redirect(req.get('referer') || '/admin');
} catch(e) {
console.error(e);
res.redirect('/admin');
}
});
router.post('/article/:id/hide', (req, res) => {
const { id } = req.params;
db.prepare(`UPDATE articles SET status='hidden', updated_at=datetime('now') WHERE id=?`).run(id);
db.prepare(`INSERT INTO activity_log (action, article_id, details) VALUES ('hide', ?, 'Article masqué')`).run(id);
res.redirect(req.get('referer') || '/admin');
});
router.post('/article/:id/reject', (req, res) => {
const { id } = req.params;
db.prepare(`UPDATE articles SET status='rejected', updated_at=datetime('now') WHERE id=?`).run(id);
db.prepare(`INSERT INTO activity_log (action, article_id, details) VALUES ('reject', ?, 'Article rejeté')`).run(id);
res.redirect(req.get('referer') || '/admin');
});
router.post('/article/:id/publish', (req, res) => {
const { id } = req.params;
db.prepare(`UPDATE articles SET status='published', updated_at=datetime('now') WHERE id=?`).run(id);
db.prepare(`INSERT INTO activity_log (action, article_id, details) VALUES ('publish', ?, 'Article republié')`).run(id);
res.redirect(req.get('referer') || '/admin');
});
router.post('/article/:id/delete', (req, res) => {
const { id } = req.params;
db.prepare(`DELETE FROM articles WHERE id=?`).run(id);
db.prepare(`INSERT INTO activity_log (action, article_id, details) VALUES ('delete', ?, 'Article supprimé')`).run(id);
res.redirect(req.get('referer') || '/admin');
});
router.post('/collect', (req, res) => {
const { collect } = require('../../collector/index');
collect().then(() => {
db.prepare(`INSERT INTO activity_log (action, details) VALUES ('collect', 'Collecte manuelle déclenchée')`).run();
res.redirect('/admin?status=pending');
}).catch(err => {
console.error(err);
res.redirect('/admin');
});
});
router.get('/sources', (req, res) => {
const sourcesPath = require('path').join(__dirname, '..', '..', 'config', 'sources.json');
const sources = JSON.parse(require('fs').readFileSync(sourcesPath, 'utf8'));
res.render('admin/sources', { sources });
});
router.get('/log', (req, res) => {
const logs = db.prepare(`SELECT * FROM activity_log ORDER BY datetime(created_at) DESC LIMIT 100`).all();
res.render('admin/log', { logs });
});
module.exports = router;
+53
View File
@@ -0,0 +1,53 @@
const express = require('express');
const router = express.Router();
const { db } = require('../db');
router.get('/', (req, res) => {
const category = req.query.category;
const sort = req.query.sort || 'desc';
const lang = req.query.lang;
let query = `SELECT * FROM articles WHERE status = 'published'`;
const params = [];
if (category) {
query += ` AND categories LIKE ?`;
params.push(`%"${category}"%`);
}
if (lang) {
query += ` AND COALESCE(language, source_language, 'fr') = ?`;
params.push(lang);
}
query += ` ORDER BY datetime(published_at) ${sort === 'asc' ? 'ASC' : 'DESC'}, datetime(created_at) DESC`;
const articles = db.prepare(query).all(...params);
// Récupérer les catégories uniques
let catQuery = `SELECT categories FROM articles WHERE status = 'published' AND categories IS NOT NULL`;
const catParams = [];
if (lang) {
catQuery += ` AND COALESCE(language, source_language, 'fr') = ?`;
catParams.push(lang);
}
const allArticles = db.prepare(catQuery).all(...catParams);
const categoriesSet = new Set();
allArticles.forEach(a => {
try {
const cats = JSON.parse(a.categories);
if (Array.isArray(cats)) {
cats.forEach(c => categoriesSet.add(c));
}
} catch(e) {}
});
const categories = Array.from(categoriesSet).sort();
// Récupérer les langues disponibles
const langRows = db.prepare(`SELECT DISTINCT COALESCE(language, source_language, 'fr') as lang FROM articles WHERE status = 'published' ORDER BY lang`).all();
const languages = langRows.map(r => r.lang).filter(Boolean);
res.render('index', { articles, categories, selectedCategory: category, sort, languages, selectedLang: lang });
});
module.exports = router;
+69
View File
@@ -0,0 +1,69 @@
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const express = require('express');
const session = require('express-session');
const helmet = require('helmet');
const compression = require('compression');
const path = require('path');
const fs = require('fs');
const { initDb } = require('./db');
const publicRoutes = require('./routes/public');
const adminRoutes = require('./routes/admin');
const cron = require('node-cron');
const app = express();
const PORT = process.env.PORT || 3000;
// Initialisation BDD
initDb();
// Middlewares
app.use(helmet({
contentSecurityPolicy: false, // simple pour ce projet
}));
app.use(compression());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'static')));
app.use(session({
secret: process.env.SESSION_SECRET || 'dev-secret-change-me',
resave: false,
saveUninitialized: false,
cookie: {
secure: false,
httpOnly: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000 // 24h
}
}));
// Vues
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'templates'));
// Routes
app.use('/', publicRoutes);
app.use('/admin', adminRoutes);
// Démarrage du collecteur automatique
if (process.env.NODE_ENV !== 'test') {
const interval = parseInt(process.env.COLLECT_INTERVAL || '60');
// Lancer toutes les X minutes
cron.schedule(`*/${interval} * * * *`, () => {
console.log('[CRON] Lancement de la collecte automatique...');
const { collect } = require('../collector/index');
collect().catch(err => console.error('[CRON] Erreur collecte:', err.message));
});
// Lancer une première collecte au démarrage après 5 secondes
setTimeout(() => {
console.log('[STARTUP] Première collecte...');
const { collect } = require('../collector/index');
collect().catch(err => console.error('[STARTUP] Erreur collecte:', err.message));
}, 5000);
}
app.listen(PORT, () => {
console.log(`Serveur démarré sur http://localhost:${PORT}`);
console.log(`Admin: http://localhost:${PORT}/admin`);
});
+412
View File
@@ -0,0 +1,412 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
background-color: #f5f7fa;
line-height: 1.6;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-container {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
width: 100%;
max-width: 400px;
}
.login-container h1 {
color: #667eea;
margin-bottom: 5px;
text-align: center;
}
.login-container h2 {
color: #666;
font-weight: 400;
font-size: 1.2em;
margin-bottom: 30px;
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
color: #333;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 1em;
font-family: inherit;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 0.9em;
font-family: inherit;
transition: opacity 0.2s;
}
.btn:hover {
opacity: 0.9;
}
.btn-primary {
background: #667eea;
color: white;
width: 100%;
padding: 12px;
font-size: 1em;
}
.btn-collect {
background: #4caf50;
color: white;
padding: 6px 14px;
font-size: 0.85em;
}
.btn-publish {
background: #4caf50;
color: white;
}
.btn-success {
background: #4caf50;
color: white;
padding: 6px 12px;
}
.btn-warning {
background: #ff9800;
color: white;
padding: 6px 12px;
}
.btn-danger {
background: #f44336;
color: white;
padding: 6px 12px;
}
.error {
background: #ffebee;
color: #c62828;
padding: 10px;
border-radius: 5px;
margin-bottom: 20px;
text-align: center;
}
.back-link {
text-align: center;
margin-top: 20px;
}
.back-link a {
color: #667eea;
text-decoration: none;
}
.admin-header {
background: #2c3e50;
color: white;
padding: 20px 0;
margin-bottom: 30px;
}
.admin-header h1 {
margin-bottom: 15px;
font-size: 1.8em;
}
.admin-nav {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.admin-nav a {
color: white;
text-decoration: none;
padding: 6px 12px;
background: rgba(255,255,255,0.1);
border-radius: 5px;
transition: background 0.2s;
font-size: 0.9em;
}
.admin-nav a:hover,
.admin-nav a.active {
background: rgba(255,255,255,0.2);
}
.admin-nav .logout {
margin-left: auto;
background: #e74c3c;
}
.filters {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
background: white;
padding: 12px 18px;
border-radius: 5px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.filters label {
font-weight: 500;
color: #333;
}
.filters select {
padding: 7px 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-family: inherit;
font-size: 0.9em;
background: white;
cursor: pointer;
}
.btn-filter {
background: #667eea;
color: white;
padding: 7px 14px;
font-size: 0.85em;
}
.stats {
display: flex;
gap: 15px;
flex-wrap: wrap;
margin-bottom: 30px;
}
.stat-card {
background: white;
padding: 15px 25px;
border-radius: 5px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.articles-admin {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 40px;
}
.article-admin-card {
background: white;
padding: 20px;
border-radius: 5px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.article-admin-header h3 {
color: #333;
margin-bottom: 8px;
font-size: 1.2em;
}
.article-meta {
color: #666;
font-size: 0.85em;
margin-bottom: 10px;
}
.article-meta a {
color: #667eea;
}
.article-admin-summary {
color: #444;
margin: 10px 0;
padding: 10px;
background: #f9f9f9;
border-left: 3px solid #667eea;
}
.validate-form {
margin: 15px 0;
padding: 15px;
background: #f0f8ff;
border-radius: 5px;
}
.form-row {
display: flex;
gap: 10px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.form-row input {
flex: 1;
min-width: 200px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-family: inherit;
}
.article-admin-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-size: 0.75em;
font-weight: 600;
}
.badge-pending {
background: #fff3cd;
color: #856404;
}
.badge-published {
background: #d4edda;
color: #155724;
}
.badge-hidden {
background: #e2e3e5;
color: #383d41;
}
.badge-rejected {
background: #f8d7da;
color: #721c24;
}
.badge-collect {
background: #d1ecf1;
color: #0c5460;
}
.badge-validate {
background: #d4edda;
color: #155724;
}
.badge-hide {
background: #e2e3e5;
color: #383d41;
}
.empty {
text-align: center;
padding: 40px;
background: white;
color: #666;
}
.sources-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 15px;
margin-top: 20px;
}
.source-card {
background: white;
padding: 15px;
border-radius: 5px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.source-card h3 {
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.source-card p {
margin: 5px 0;
font-size: 0.9em;
word-break: break-all;
}
.source-card a {
color: #667eea;
}
.info {
background: #e7f3ff;
padding: 10px 15px;
border-radius: 5px;
margin: 15px 0;
color: #004085;
}
.log-table {
width: 100%;
background: white;
border-collapse: collapse;
margin-top: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.log-table thead {
background: #f8f9fa;
}
.log-table th,
.log-table td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #dee2e6;
font-size: 0.9em;
}
.log-table tbody tr:hover {
background: #f8f9fa;
}
+239
View File
@@ -0,0 +1,239 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
background-color: #f5f7fa;
line-height: 1.6;
color: #333;
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 0 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px 0 30px;
margin-bottom: 40px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.logo {
font-size: 2.5em;
margin-bottom: 5px;
font-weight: 700;
}
.subtitle {
font-size: 1.1em;
opacity: 0.95;
margin-bottom: 25px;
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 15px;
}
.nav a {
color: white;
text-decoration: none;
padding: 8px 16px;
background: rgba(255,255,255,0.2);
border-radius: 20px;
transition: background 0.3s;
font-size: 0.9em;
}
.nav a:hover,
.nav a.active {
background: rgba(255,255,255,0.4);
}
.lang-filter {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.9em;
flex-wrap: wrap;
margin-bottom: 12px;
}
.lang-filter a {
color: white;
text-decoration: none;
padding: 6px 12px;
background: rgba(255,255,255,0.2);
border-radius: 15px;
transition: background 0.3s;
}
.lang-filter a:hover,
.lang-filter a.active {
background: rgba(255,255,255,0.4);
}
.lang-badge {
display: inline-block;
background: rgba(102, 126, 234, 0.1);
color: #667eea;
padding: 2px 6px;
border-radius: 8px;
font-size: 0.75em;
font-weight: 600;
margin-left: 5px;
}
.sort {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.9em;
flex-wrap: wrap;
}
.sort a {
color: white;
text-decoration: none;
padding: 6px 12px;
background: rgba(255,255,255,0.2);
border-radius: 15px;
transition: background 0.3s;
}
.sort a:hover,
.sort a.active {
background: rgba(255,255,255,0.4);
}
.articles {
display: flex;
flex-direction: column;
gap: 25px;
margin-bottom: 40px;
}
.article-card {
background: white;
padding: 25px;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
transition: transform 0.2s, box-shadow 0.2s;
}
.article-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
}
.article-header {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
flex-wrap: wrap;
gap: 10px;
font-size: 0.9em;
}
.article-source {
color: #667eea;
font-weight: 600;
}
.article-date {
color: #666;
}
.article-title {
font-size: 1.5em;
margin-bottom: 12px;
color: #222;
line-height: 1.4;
}
.article-summary {
color: #444;
margin-bottom: 15px;
line-height: 1.7;
}
.article-categories {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 15px;
}
.category-tag {
background: #e8eaf6;
color: #5c6bc0;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8em;
}
.article-link {
color: #667eea;
text-decoration: none;
font-weight: 600;
transition: color 0.2s;
}
.article-link:hover {
color: #764ba2;
text-decoration: underline;
}
.empty {
text-align: center;
padding: 60px 20px;
background: white;
border-radius: 10px;
color: #666;
}
.empty .small {
margin-top: 10px;
font-size: 0.9em;
}
.footer {
text-align: center;
padding: 30px 0;
color: #666;
border-top: 1px solid #e0e0e0;
margin-top: 60px;
}
.footer .small {
margin-top: 5px;
font-size: 0.9em;
}
.footer a {
color: #667eea;
text-decoration: none;
}
@media (max-width: 600px) {
.logo {
font-size: 2em;
}
.article-card {
padding: 18px;
}
.article-title {
font-size: 1.3em;
}
}
+137
View File
@@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tableau de bord - Administration</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<header class="admin-header">
<div class="container">
<h1>Administration - Veille Tech</h1>
<nav class="admin-nav">
<a href="/admin<%= language !== 'all' ? '?language=' + encodeURIComponent(language) : '' %>" class="<%= status === 'all' ? 'active' : '' %>">Tous (<%= stats.total %>)</a>
<a href="/admin?status=pending<%= language !== 'all' ? '&language=' + encodeURIComponent(language) : '' %>" class="<%= status === 'pending' ? 'active' : '' %>">En attente (<%= stats.pending %>)</a>
<a href="/admin?status=published<%= language !== 'all' ? '&language=' + encodeURIComponent(language) : '' %>" class="<%= status === 'published' ? 'active' : '' %>">Publiés (<%= stats.published %>)</a>
<a href="/admin?status=hidden<%= language !== 'all' ? '&language=' + encodeURIComponent(language) : '' %>" class="<%= status === 'hidden' ? 'active' : '' %>">Masqués (<%= stats.hidden %>)</a>
<a href="/admin?status=rejected<%= language !== 'all' ? '&language=' + encodeURIComponent(language) : '' %>" class="<%= status === 'rejected' ? 'active' : '' %>">Rejetés (<%= stats.rejected %>)</a>
<a href="/admin/sources">Sources</a>
<a href="/admin/log">Journal</a>
<form method="POST" action="/admin/collect" style="display:inline">
<button type="submit" class="btn btn-collect">Collecter maintenant</button>
</form>
<a href="/admin/logout" class="logout">Déconnexion</a>
</nav>
</div>
</header>
<main class="container">
<% const langLabel = (l) => l === 'fr' ? 'Français' : l === 'en' ? 'Anglais' : (l || '').toUpperCase(); %>
<form method="GET" action="/admin" class="filters">
<% if (status !== 'all') { %>
<input type="hidden" name="status" value="<%= status %>">
<% } %>
<label for="language">Langue :</label>
<select id="language" name="language" onchange="this.form.submit()">
<option value="all" <%= language === 'all' ? 'selected' : '' %>>Toutes les langues</option>
<% languages.forEach(l => { %>
<option value="<%= l.lang %>" <%= language === l.lang ? 'selected' : '' %>><%= langLabel(l.lang) %> (<%= l.c %>)</option>
<% }); %>
</select>
<noscript><button type="submit" class="btn btn-filter">Filtrer</button></noscript>
</form>
<div class="stats">
<div class="stat-card">En attente: <strong><%= stats.pending %></strong></div>
<div class="stat-card">Publiés: <strong><%= stats.published %></strong></div>
<div class="stat-card">Masqués: <strong><%= stats.hidden %></strong></div>
<div class="stat-card">Rejetés: <strong><%= stats.rejected %></strong></div>
</div>
<% if (articles.length === 0) { %>
<div class="empty">Aucun article dans cette catégorie.</div>
<% } else { %>
<div class="articles-admin">
<% articles.forEach(article => { %>
<div class="article-admin-card">
<div class="article-admin-header">
<div>
<h3><%= article.title %></h3>
<div class="article-meta">
<span>Source: <%= article.source %></span> |
<span>URL: <a href="<%= article.url %>" target="_blank"><%= article.url.substring(0, 60) %>...</a></span> |
<span>Statut: <span class="badge badge-<%= article.status %>"><%= article.status %></span></span> |
<span>Langue: <%= langLabel(article.language || article.source_language || 'fr') %></span> |
<span>Collecté par: <%= article.collected_by %></span>
</div>
</div>
</div>
<% if (article.summary) { %>
<p class="article-admin-summary"><%= article.summary %></p>
<% } %>
<% if (article.status === 'pending') { %>
<form method="POST" action="/admin/article/<%= article.id %>/validate" class="validate-form">
<div class="form-row">
<input type="text" name="categories" placeholder="Catégories (séparées par virgule)"
value="<% try { const cats = JSON.parse(article.categories||'[]'); %><%= cats.join(', ') %><% } catch(e) {} %>">
<input type="text" name="summary" placeholder="Résumé à afficher (optionnel)" value="<%= article.summary || '' %>">
</div>
<button type="submit" class="btn btn-publish">Valider et publier</button>
</form>
<% } %>
<div class="article-admin-actions">
<% if (article.status !== 'published') { %>
<form method="POST" action="/admin/article/<%= article.id %>/publish" style="display:inline">
<button type="submit" class="btn btn-success">Publier</button>
</form>
<% } %>
<% if (article.status !== 'hidden') { %>
<form method="POST" action="/admin/article/<%= article.id %>/hide" style="display:inline">
<button type="submit" class="btn btn-warning">Masquer</button>
</form>
<% } %>
<% if (article.status !== 'rejected') { %>
<form method="POST" action="/admin/article/<%= article.id %>/reject" style="display:inline">
<button type="submit" class="btn btn-danger">Rejeter</button>
</form>
<% } %>
<form method="POST" action="/admin/article/<%= article.id %>/delete" style="display:inline" onsubmit="return confirm('Supprimer définitivement cet article ?')">
<button type="submit" class="btn btn-danger">Supprimer</button>
</form>
</div>
</div>
<% }); %>
</div>
<% } %>
</main>
<script>
// Conserve la position de défilement lors des actions (publier, masquer, rejeter, supprimer)
(function () {
var storageKey = 'admin-scroll:' + location.pathname + location.search;
document.querySelectorAll('form').forEach(function (form) {
if (form.method.toLowerCase() !== 'post') return;
form.addEventListener('submit', function (event) {
if (event.defaultPrevented) return; // ex. suppression annulée
sessionStorage.setItem(storageKey, String(window.scrollY));
});
});
var savedPosition = sessionStorage.getItem(storageKey);
if (savedPosition === null) return;
sessionStorage.removeItem(storageKey);
window.addEventListener('load', function () {
window.scrollTo(0, parseInt(savedPosition, 10) || 0);
});
})();
</script>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Journal d'activité - Administration</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<header class="admin-header">
<div class="container">
<h1>Administration - Veille Tech</h1>
<nav class="admin-nav">
<a href="/admin">Tableau de bord</a>
<a href="/admin/sources">Sources</a>
<a href="/admin/log" class="active">Journal</a>
<a href="/admin/logout" class="logout">Déconnexion</a>
</nav>
</div>
</header>
<main class="container">
<h2>Journal d'activité (100 dernières entrées)</h2>
<table class="log-table">
<thead>
<tr>
<th>Date</th>
<th>Action</th>
<th>Article ID</th>
<th>Détails</th>
</tr>
</thead>
<tbody>
<% logs.forEach(log => { %>
<tr>
<td><%= log.created_at %></td>
<td><span class="badge badge-<%= log.action %>"><%= log.action %></span></td>
<td><%= log.article_id ? log.article_id.substring(0, 8) + '...' : '-' %></td>
<td><%= log.details || '-' %></td>
</tr>
<% }); %>
</tbody>
</table>
</main>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connexion - Administration</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="login-page">
<div class="login-container">
<h1>Administration</h1>
<h2>Veille Tech</h2>
<% if (error) { %>
<div class="error"><%= error %></div>
<% } %>
<form method="POST" action="/admin/login">
<div class="form-group">
<label for="username">Identifiant</label>
<input type="text" id="username" name="username" required autocomplete="username">
</div>
<div class="form-group">
<label for="password">Mot de passe</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary">Se connecter</button>
</form>
<p class="back-link"><a href="/">← Retour au site</a></p>
</div>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sources - Administration</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<header class="admin-header">
<div class="container">
<h1>Administration - Veille Tech</h1>
<nav class="admin-nav">
<a href="/admin">Tableau de bord</a>
<a href="/admin/sources" class="active">Sources</a>
<a href="/admin/log">Journal</a>
<a href="/admin/logout" class="logout">Déconnexion</a>
</nav>
</div>
</header>
<main class="container">
<h2>Sources RSS configurées</h2>
<p class="info">Les sources sont configurées dans <code>config/sources.json</code>. Vous pouvez les modifier directement dans ce fichier.</p>
<div class="sources-list">
<% sources.forEach(source => { %>
<div class="source-card">
<h3><%= source.name %> <span class="badge <%= source.enabled ? 'badge-published' : 'badge-rejected' %>"><%= source.enabled ? 'Activé' : 'Désactivé' %></span></h3>
<p><strong>ID:</strong> <%= source.id %></p>
<p><strong>URL:</strong> <a href="<%= source.url %>" target="_blank"><%= source.url %></a></p>
<p><strong>Type:</strong> <%= source.type %></p>
<p><strong>Catégorie suggérée:</strong> <%= source.category_hint || '-' %></p>
</div>
<% }); %>
</div>
</main>
</body>
</html>
+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agrégateur d'Actualités Informatiques</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="header">
<div class="container">
<h1 class="logo">💻 Veille Tech</h1>
<p class="subtitle">Agrégateur d'actualités informatiques</p>
<% if (typeof languages !== 'undefined' && languages.length > 0) { %>
<div class="lang-filter">
<span>Langue:</span>
<a href="/?sort=<%= sort %><%= selectedCategory ? '&category=' + encodeURIComponent(selectedCategory) : '' %>" class="<%= !selectedLang ? 'active' : '' %>">Toutes</a>
<% languages.forEach(l => { %>
<a href="/?lang=<%= encodeURIComponent(l) %>&sort=<%= sort %><%= selectedCategory ? '&category=' + encodeURIComponent(selectedCategory) : '' %>" class="<%= selectedLang === l ? 'active' : '' %>"><%= l === 'fr' ? 'Français' : l === 'en' ? 'English' : l.toUpperCase() %></a>
<% }); %>
</div>
<% } %>
<nav class="nav">
<a href="/?sort=<%= sort %><%= selectedLang ? '&lang=' + encodeURIComponent(selectedLang) : '' %>" class="<%= !selectedCategory ? 'active' : '' %>">Tous</a>
<% categories.forEach(cat => { %>
<a href="/?category=<%= encodeURIComponent(cat) %>&sort=<%= sort %><%= selectedLang ? '&lang=' + encodeURIComponent(selectedLang) : '' %>" class="<%= selectedCategory === cat ? 'active' : '' %>"><%= cat %></a>
<% }); %>
</nav>
<div class="sort">
<span>Trier par date:</span>
<a href="/?sort=desc<%= selectedCategory ? '&category=' + encodeURIComponent(selectedCategory) : '' %><%= selectedLang ? '&lang=' + encodeURIComponent(selectedLang) : '' %>" class="<%= sort === 'desc' ? 'active' : '' %>">Plus récent</a>
<a href="/?sort=asc<%= selectedCategory ? '&category=' + encodeURIComponent(selectedCategory) : '' %><%= selectedLang ? '&lang=' + encodeURIComponent(selectedLang) : '' %>" class="<%= sort === 'asc' ? 'active' : '' %>">Plus ancien</a>
</div>
</div>
</header>
<main class="container">
<% if (articles.length === 0) { %>
<div class="empty">
<p>Aucun article publié pour le moment.</p>
<p class="small">Les articles en attente doivent être validés depuis l'<a href="/admin">interface d'administration</a>.</p>
</div>
<% } else { %>
<div class="articles">
<% articles.forEach(article => { %>
<article class="article-card">
<div class="article-header">
<span class="article-source"><%= article.source || 'Source inconnue' %> <% if (article.language || article.source_language) { %><span class="lang-badge"><%= (article.language || article.source_language) === 'fr' ? 'FR' : (article.language || article.source_language).toUpperCase() %></span><% } %></span>
<span class="article-date">
<%= article.published_at ? new Date(article.published_at).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' }) : '' %>
</span>
</div>
<h2 class="article-title"><%= article.title %></h2>
<% if (article.summary) { %>
<p class="article-summary"><%= article.summary %></p>
<% } %>
<% if (article.categories) { %>
<div class="article-categories">
<% try {
const cats = JSON.parse(article.categories);
if (Array.isArray(cats)) {
cats.forEach(cat => { %>
<span class="category-tag"><%= cat %></span>
<% });
}
} catch(e) {} %>
</div>
<% } %>
<a href="<%= article.url %>" target="_blank" rel="noopener noreferrer" class="article-link">
Lire l'article original ↗
</a>
</article>
<% }); %>
</div>
<% } %>
</main>
<footer class="footer">
<div class="container">
<p>Veille Tech - Agrégateur d'actualités informatiques</p>
<p class="small"><a href="/admin">Administration</a></p>
</div>
</footer>
</body>
</html>
+98
View File
@@ -0,0 +1,98 @@
# Cahier des charges - Agrégateur d'actualités informatiques
## 1. Objectif du projet
Créer un site web agrégateur d'actualités axé sur l'informatique. L'objectif est de centraliser une sélection d'articles pertinents, organisés par date et par thématique, afin d'offrir une lecture synthétique et structurée.
## 2. Fonctionnalités
### 2.1. Frontend (affichage public)
- **Liste d'articles** : Afficher les articles validés dans l'interface publique.
- **Tri** : Possibilité de trier/filtrer par date de publication et par sujet/catégorie.
- **Fiche article** : Chaque entrée doit afficher au minimum : titre, source (nom/site), date de publication, résumé/description, catégorie(s), lien vers l'article original.
- **Navigation claire** : Interface épurée, responsive, facile à consulter.
- **Pas de re-publiquation intégrale** : Renvoi systématique vers la source originale (lien externe).
### 2.2. Collecte automatisée des articles
- **Scraping ou IA** : Système permettant de récupérer automatiquement des actualités liées à l'informatique.
- **Fréquence configurable** : Exécution à intervalles réguliers (cron/planificateur) paramétrable.
- **Ciblage** : Sélectionner des sources fiables (blogs, médias tech, éditeurs, etc.) avec possibilité de les configurer.
- **Enrichissement** : Normaliser les données (titre, lien, date, résumé, catégorie) avant enregistrement.
- **Filtrage initial** : Éviter les doublons (même URL ou titre très proche) lors de l'import.
### 2.3. Administration
- **Accès protégé** : Interface d'administration sécurisée par authentification (identifiants/mot de passe).
- **Gestion des articles** :
- Lister tous les articles collectés (en attente / publiés / masqués / rejetés)
- Rechercher manuellement de nouveaux articles (déclenchement à la demande)
- Masquer/afficher un article jugé inutile, obsolète ou faux
- Supprimer un article si nécessaire
- Valider un article avant publication ou modifier ses métadonnées (catégorie, résumé)
- **Gestion des sources** (facultatif mais recommandé) : Ajouter/modifier/désactiver les sources utilisées par le collecteur.
- **Journal d'activité** : Tracer les actions (collecte, validation, masquage) pour faciliter le suivi.
### 2.4. Stockage des données
- **Solution autonome** : Utiliser SQLite.
- **Schéma minimal** : Au minimum une table/structure `articles` contenant : `id`, `title`, `url` (unique), `source`, `published_at`, `summary`, `categories` (JSON ou relationnel), `status` (pending/published/hidden/rejected), `created_at`, `updated_at`, `collected_by` (scraper/ai).
- **Persistance** : Les données doivent survivre au redémarrage du conteneur.
## 3. Contraintes techniques
### 3.1. Technologies
- **Frontend** : HTML5, CSS3, JavaScript vanilla (pas de framework lourd imposé). Doit rester simple et compréhensible.
- **Backend** : À choisir de manière pragmatique (Node.js/Express, Python/Flask/FastAPI, ou équivalent). Priorité à la simplicité et à la facilité de reprise.
- **Base de données** : SQLite par défaut (fichier dans un volume Docker).
- **Conteneurisation** : L'ensemble doit tourner dans un conteneur Docker. Fournir un `Dockerfile` et idéalement `docker-compose.yml`.
- **Volume persistant** : Monter un volume pour la base de données et/ou les fichiers de configuration.
### 3.2. Collecte avec IA (si utilisée)
- **Modèles gratuits** : Si un assistant/LLM est utilisé pour la collecte ou le filtrage, privilégier des modèles gratuits et accessibles (avec ou sans clé selon l'offre).
- **Accès web** : Préférer des modèles capables de rechercher sur internet ou utiliser un outil de recherche gratuit en complément.
- **Coûts nuls** : Éviter les APIs payantes obligatoires. Si une clé est nécessaire, la documenter clairement dans `.env.example`.
- **Robustesse** : Avoir un fallback (scraping classique) au cas où l'IA est indisponible.
### 3.3. Qualité et maintenabilité
- **Code simple et commenté** : Code lisible, commentaires en français si souhaité, noms explicites.
- **Documentation** : Fournir un `README.md` détaillant : installation, configuration (.env), lancement (Docker/local), utilisation admin, fonctionnement du collecteur.
- **Sécurité** :
- Authentification pour l'interface admin
- Protection basique contre les actions non autorisées (CSRF/session)
- Ne pas exposer de clés/secrets dans le dépôt
- Valider les URLs et données entrantes
- **Configurabilité** : Paramètres via variables d'environnement (fréquence de collecte, sources, identifiants admin, etc.).
- **Extensibilité** : Architecture simple permettant d'ajouter facilement de nouvelles sources.
## 4. Livrables attendus
- **Code source complet** : Structure claire (frontend, backend, collecteur, config).
- **Dockerisation** : `Dockerfile`, `docker-compose.yml`, `.dockerignore`.
- **Configuration** : `.env.example` avec toutes les variables nécessaires.
- **Base de données** : Schéma/migration automatique au premier démarrage.
- **Documentation** : `README.md` (objectif, prérequis, installation, démarrage, utilisation, dépannage).
- **Interface fonctionnelle** : Page d'accueil publique + interface d'administration.
## 5. Critères d'acceptation
- [ ] Le site s'affiche correctement avec HTML/CSS/JS vanilla.
- [ ] La collecte automatique fonctionne à intervalle régulier et enregistre les articles dans la BDD.
- [ ] Un accès admin permet de valider/masquer/rejeter des articles.
- [ ] Le tri par date et par sujet est fonctionnel sur la page publique.
- [ ] L'application tourne intégralement sous Docker avec un volume persistant.
- [ ] Aucun secret n'est commité dans le dépôt.
- [ ] Le code est commenté et compréhensible pour une reprise ultérieure.
- [ ] Le système évite les doublons d'articles.
- [ ] Si IA utilisée, seuls des modèles gratuits sont privilégiés et documentés.
## 6. Contraintes non-fonctionnelles
- **Simplicité avant complexité** : Privilégier des solutions claires plutôt qu'over-engineered.
- **Léger** : Fonctionnement fluide sans dépendances inutiles.
- **Indépendant** : Ne dépend pas obligatoirement d'un service tiers payant.
- **Portable** : Doit pouvoir tourner sur un poste local ou un petit serveur via Docker.
+115
View File
@@ -0,0 +1,115 @@
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const Parser = require('rss-parser');
const fs = require('fs');
const path = require('path');
const { db } = require('../app/db');
const { v4: uuidv4 } = require('uuid');
const parser = new Parser({
customFields: {
item: [
['content:encoded', 'contentEncoded'],
['dc:creator', 'creator'],
['media:content', 'media']
]
}
});
function normalizeText(text) {
if (!text) return '';
// Supprimer HTML basique
return text.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().substring(0, 500);
}
function getCategoryFromSource(hint) {
if (hint) return [hint];
return ['Tech'];
}
async function collect() {
console.log('[COLLECT] Démarrage de la collecte...');
const sourcesPath = path.join(__dirname, '..', 'config', 'sources.json');
let sources = [];
try {
sources = JSON.parse(fs.readFileSync(sourcesPath, 'utf8'));
} catch(e) {
console.error('[COLLECT] Erreur lecture sources:', e.message);
return;
}
const autoPublish = process.env.AUTO_PUBLISH === 'true';
let totalAdded = 0;
let totalSkipped = 0;
for (const source of sources) {
if (source.enabled === false) continue;
try {
console.log(`[COLLECT] Récupération depuis ${source.name}...`);
const feed = await parser.parseURL(source.url);
for (const item of feed.items || []) {
const url = item.link || item.guid;
if (!url) continue;
// Vérifier doublon par URL
const existing = db.prepare('SELECT id FROM articles WHERE url = ?').get(url);
if (existing) {
totalSkipped++;
continue;
}
// Vérifier doublon par titre très proche (simple)
const title = item.title || '';
if (title) {
const similar = db.prepare('SELECT id FROM articles WHERE LOWER(title) = LOWER(?)').get(title);
if (similar) {
totalSkipped++;
continue;
}
}
const id = uuidv4();
const publishedAt = item.pubDate || item.isoDate || new Date().toISOString();
const summary = normalizeText(item.contentSnippet || item.contentEncoded || item.description || '');
const categories = JSON.stringify(getCategoryFromSource(source.category_hint));
const status = autoPublish ? 'published' : 'pending';
const lang = source.language || 'fr';
try {
db.prepare(`INSERT INTO articles (id, title, url, source, source_language, language, published_at, summary, categories, status, collected_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'rss')`)
.run(id, title, url, source.name, lang, lang, publishedAt, summary, categories, status);
totalAdded++;
} catch(e) {
console.error(`[COLLECT] Erreur insertion:`, e.message);
}
}
// Mettre à jour last_collected_at si table sources existe
try {
db.prepare(`UPDATE sources SET last_collected_at = datetime('now') WHERE id = ?`).run(source.id);
} catch(e) {}
} catch(e) {
console.error(`[COLLECT] Erreur pour ${source.name}:`, e.message);
}
}
console.log(`[COLLECT] Terminé. Ajoutés: ${totalAdded}, Ignorés (doublons): ${totalSkipped}`);
try {
db.prepare(`INSERT INTO activity_log (action, details) VALUES ('collect', ?)`).run(`Collecte automatique: ${totalAdded} ajoutés, ${totalSkipped} ignorés`);
} catch(e) {}
}
if (require.main === module) {
collect().then(() => process.exit(0)).catch(err => {
console.error(err);
process.exit(1);
});
}
module.exports = { collect };
+65
View File
@@ -0,0 +1,65 @@
[
{
"id": "numerama",
"name": "Numerama",
"url": "https://www.numerama.com/feed/",
"type": "rss",
"enabled": true,
"category_hint": "Tech",
"language": "fr"
},
{
"id": "nextink",
"name": "Next.ink",
"url": "https://next.ink/feed/",
"type": "rss",
"enabled": true,
"category_hint": "Tech",
"language": "fr"
},
{
"id": "journaldugeek",
"name": "Journal du Geek",
"url": "https://www.journaldugeek.com/feed/",
"type": "rss",
"enabled": true,
"category_hint": "Tech",
"language": "fr"
},
{
"id": "developpez",
"name": "Developpez.com",
"url": "https://www.developpez.com/index/rss",
"type": "rss",
"enabled": true,
"category_hint": "Dev",
"language": "fr"
},
{
"id": "korben",
"name": "Korben",
"url": "https://korben.info/feed",
"type": "rss",
"enabled": true,
"category_hint": "Tech",
"language": "fr"
},
{
"id": "01net",
"name": "01net",
"url": "https://www.01net.com/feed/",
"type": "rss",
"enabled": true,
"category_hint": "Tech",
"language": "fr"
},
{
"id": "lemondeinfo",
"name": "Le Monde Informatique",
"url": "https://www.lemondeinformatique.fr/flux-rss/thematique/toutes-les-actualites/rss.xml",
"type": "rss",
"enabled": true,
"category_hint": "Tech",
"language": "fr"
}
]
+15
View File
@@ -0,0 +1,15 @@
version: '3.8'
services:
app:
build: .
container_name: veille-tech-aggregator
ports:
- "3000:3000"
volumes:
- ./data:/app/data
- ./config:/app/config
- ./.env:/app/.env
environment:
- NODE_ENV=production
restart: unless-stopped
+37
View File
@@ -0,0 +1,37 @@
-- Initialisation de la base de données
CREATE TABLE IF NOT EXISTS articles (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
source TEXT,
source_language TEXT DEFAULT 'fr',
language TEXT DEFAULT 'fr',
published_at DATETIME,
summary TEXT,
categories TEXT, -- JSON array
status TEXT NOT NULL DEFAULT 'pending', -- pending, published, hidden, rejected
created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now')),
collected_by TEXT DEFAULT 'rss' -- rss, ai
);
CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status);
CREATE INDEX IF NOT EXISTS idx_articles_published_at ON articles(published_at DESC);
CREATE INDEX IF NOT EXISTS idx_articles_url ON articles(url);
CREATE TABLE IF NOT EXISTS sources (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT,
enabled INTEGER DEFAULT 1,
last_collected_at DATETIME,
created_at DATETIME DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL, -- collect, validate, hide, reject, delete, publish
article_id TEXT,
details TEXT,
created_at DATETIME DEFAULT (datetime('now'))
);
+2986
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "tech-news-aggregator",
"version": "1.0.0",
"description": "Agrégateur d'actualités informatiques",
"main": "app/server.js",
"scripts": {
"start": "node app/server.js",
"dev": "NODE_ENV=development nodemon app/server.js",
"collect": "node collector/index.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^9.4.3",
"compression": "^1.7.4",
"dotenv": "^16.4.5",
"ejs": "^3.1.9",
"express": "^4.19.2",
"express-session": "^1.18.0",
"helmet": "^7.1.0",
"node-cron": "^3.0.3",
"rss-parser": "^3.13.0",
"uuid": "^9.0.1"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.