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"
|
||||
}
|
||||
}
|
||||