Début du projet
This commit is contained in:
commit
e7d4e607f6
23 files changed
+4855
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
data
|
||||||
|
logs
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
.DS_Store
|
||||||
+19
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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`);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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.
|
||||||
@@ -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 };
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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'))
|
||||||
|
);
|
||||||
Generated
+2986
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||