Initial commit: existing turf_saas codebase

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
ML Engineer
2026-04-25 17:18:43 +02:00
commit ed07c8a3d1
137 changed files with 36398 additions and 0 deletions

80
.gitignore vendored Executable file
View File

@@ -0,0 +1,80 @@
# Base de données
turf.db
turf.db.old
*.db
*.db.bak*
# Backups auto
*.backup_*
*.bak
*.bak2
*.broken
*.tmp
*.original
*.fixed
# Données JSON volumineuses
v3_*.json
v4_*.json
v5_*.json
pmu_*.json
scoring_*.json
perf_*.json
agent_chat.json
# Logs et rapports Telegram
*.log
telegram_*.txt
api_log.txt
# Modèles ML entraînés
*.pkl
feature_importance_*.csv
# Archives et binaires
awscliv2.zip
*.zip
*.gz
# Python
__pycache__/
*.pyc
*.pyo
venv/
.env
# Exports
exports/
# Fichiers dashboard temporaires / doublons
dashboard.html.bak*
dashboard_backup*.html
dashboard_new.html
dashboard_secured.py
dashboard_system.html
dashboard_api.py.broken
dashboard_api_fixed.py
dashboard_api_original.py
portal_server.py.original
portail.html.broken*
portail_new.html
portal.html
# Fichiers vitesse doublons
vitesse_api_complete.py
vitesse_api_fixed.py
# Multi-scraper anciennes versions
multi_scraper_v2.py
multi_scraper_v3.py
multi_scraper_v4.py
# Fichiers temporaires de fix
tmp_fix.py
fix_*.py
repair_*.py
patch_*.py
# Données scraping brutes
v3_*.json
v4_*.json

282
ARCHITECTURE_SERVICES.html Executable file
View File

@@ -0,0 +1,282 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Architecture Services H3R7</title>
<style>
@media print {
body { font-size: 12pt; }
.page-break { page-break-before: always; }
}
body { font-family: Arial, sans-serif; margin: 40px; line-height: 1.6; }
h1 { color: #2c3e50; border-bottom: 3px solid #3498db; padding-bottom: 10px; }
h2 { color: #2980b9; margin-top: 30px; }
h3 { color: #16a085; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
th { background: #3498db; color: white; }
tr:nth-child(even) { background: #f9f9f9; }
.service-box { background: #ecf0f1; padding: 15px; margin: 10px 0; border-radius: 8px; border-left: 4px solid #3498db; }
.port { background: #e74c3c; color: white; padding: 2px 8px; border-radius: 4px; font-size: 11px; }
.url { background: #27ae60; color: white; padding: 2px 8px; border-radius: 4px; font-size: 11px; }
pre { background: #2c3e50; color: #ecf0f1; padding: 15px; border-radius: 8px; overflow-x: auto; }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<div style="text-align:center;padding:15px;background:#1a1a2e;">
<img src="H3R7Tech_logo.png" alt="H3R7Tech" style="width:120px;border-radius:10px;">
</div>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<h1>🏗️ Architecture des Services H3R7</h1>
<h2>📋 Vue d'Ensemble des Services</h2>
<table>
<tr>
<th>Service</th>
<th>URL</th>
<th>Port</th>
<th>Description</th>
</tr>
<tr>
<td><strong>Portail Central</strong></td>
<td><span class="url">/</span></td>
<td><span class="port">8768</span></td>
<td>Point d'entrée unique</td>
</tr>
<tr>
<td>Turf Dashboard</td>
<td><span class="url">/turf/</span></td>
<td><span class="port">8765</span></td>
<td>Prédictions hippiques</td>
</tr>
<tr>
<td>Boîte à Idées</td>
<td><span class="url">/turf/idees/</span></td>
<td><span class="port">8765</span></td>
<td>Gestion idées business</td>
</tr>
<tr>
<td>Admin Menu</td>
<td><span class="url">http://178.18.250.53:8766/</span></td>
<td><span class="port">8766</span></td>
<td>Gestion menu restaurant</td>
</tr>
<tr>
<td>Réservation Client</td>
<td><span class="url">http://178.18.250.53:8767/</span></td>
<td><span class="port">8767</span></td>
<td>Réservation tables</td>
</tr>
<tr>
<td>Manager Tables</td>
<td><span class="url">http://178.18.250.53:9090/manager.html</span></td>
<td><span class="port">9090</span></td>
<td>Gestion tables (admin)</td>
</tr>
<tr>
<td>Templates Site</td>
<td><span class="url">http://178.18.250.53:9090/</span></td>
<td><span class="port">9090</span></td>
<td>Templates professionnels</td>
</tr>
</table>
<h2>🏇 TURF - Prédictions Hippiques</h2>
<div class="service-box">
<h3>Dashboard (Port 8765)</h3>
<p><strong>URL:</strong> /turf/</p>
<p><strong>Fonctionnalités:</strong></p>
<ul>
<li>📊 Vue d'ensemble des courses du jour</li>
<li>🏇 Liste des favoris par course</li>
<li>📈 Cotes en temps réel (PMU, ZEturf, Genybet)</li>
<li>🔄 Scraping automatique multi-sources</li>
<li>💾 Sauvegarde en base SQLite</li>
</ul>
</div>
<div class="service-box">
<h3>API Turf</h3>
<p><strong>Endpoints:</strong></p>
<ul>
<li><code>GET /api/today</code> → Courses du jour</li>
<li><code>GET /api</code> → Toutes les données</li>
</ul>
</div>
<h2>💡 BOÎTE À IDÉES</h2>
<div class="service-box">
<h3>Interface (Port 8765)</h3>
<p><strong>URL:</strong> /turf/idees/</p>
<p><strong>Fonctionnalités:</strong></p>
<ul>
<li> Ajouter une idée</li>
<li>✏️ Modifier une idée</li>
<li>🗑️ Supprimer une idée</li>
<li>🔍 Filtrer par catégorie</li>
<li>📊 Indicateur de potentiel</li>
</ul>
</div>
<div class="service-box">
<h3>API Ideas</h3>
<ul>
<li><code>GET /api/ideas</code> → Liste toutes les idées</li>
<li><code>GET /api/ideas/&lt;id&gt;</code> → Récupère une idée</li>
<li><code>POST /api/ideas</code> → Crée une idée</li>
<li><code>PUT /api/ideas/&lt;id&gt;</code> → Met à jour</li>
<li><code>DELETE /api/ideas/&lt;id&gt;</code> → Supprime</li>
</ul>
<p><strong>Auth:</strong> admin:turf2026</p>
</div>
<h2>🍽️ TEMPLATES RESTAURANT</h2>
<table>
<tr>
<th>Template</th>
<th>URL</th>
</tr>
<tr>
<td>Page d'accueil</td>
<td><span class="url">http://178.18.250.53:9090/</span></td>
</tr>
<tr>
<td>Restaurant JSON</td>
<td><span class="url">http://178.18.250.53:9090/template_restaurant_json.html</span></td>
</tr>
<tr>
<td>Boulangerie</td>
<td><span class="url">http://178.18.250.53:9090/template_boulangerie_final.html</span></td>
</tr>
<tr>
<td>Artisan</td>
<td><span class="url">http://178.18.250.53:9090/template_artisan_final.html</span></td>
</tr>
</table>
<h2>📅 SYSTÈME DE RÉSERVATIONS</h2>
<div class="service-box">
<h3>Interface Client (Port 8767)</h3>
<p><strong>URL:</strong> http://178.18.250.53:8767/</p>
<ul>
<li>📅 Sélection date</li>
<li>👥 Nombre de personnes</li>
<li>🕐 Choix du créneau</li>
<li>✅ Confirmation instantanée</li>
</ul>
</div>
<div class="service-box">
<h3>Interface Manager (Port 9090)</h3>
<p><strong>URL:</strong> http://178.18.250.53:9090/manager.html</p>
<ul>
<li>🖥️ Tableau de bord tables</li>
<li>🟢/🔴 Indicateurs Libre/Occupée</li>
<li>👤 Info client</li>
<li>/✅ Réserver/Libérer table</li>
</ul>
</div>
<div class="service-box">
<h3>API Réservations</h3>
<ul>
<li><code>GET /api/available?date=...&people=...</code></li>
<li><code>POST /api/reserve</code></li>
<li><code>GET /api/tables</code></li>
<li><code>POST /api/tables/&lt;id&gt;/occupy</code></li>
<li><code>POST /api/tables/&lt;id&gt;/free</code></li>
</ul>
</div>
<h2>🔧 Configuration Technique</h2>
<table>
<tr>
<th>Paramètre</th>
<th>Valeur</th>
</tr>
<tr>
<td>VPS IP</td>
<td>178.18.250.53</td>
</tr>
<tr>
<td>Authentification</td>
<td>admin:turf2026</td>
</tr>
<tr>
<td>Base de données</td>
<td>turf.db (SQLite)</td>
</tr>
<tr>
<td>Fichier idées</td>
<td>idees.json</td>
</tr>
<tr>
<td>Fichier réservations</td>
<td>reservations.json</td>
</tr>
</table>
<h2>📊 Matrice des Responsabilités</h2>
<table>
<tr>
<th>Service</th>
<th>Port</th>
<th>Type</th>
<th>Données</th>
</tr>
<tr>
<td>Portal</td>
<td><span class="port">8768</span></td>
<td>Web</td>
<td>-</td>
</tr>
<tr>
<td>Turf</td>
<td><span class="port">8765</span></td>
<td>Web/API</td>
<td>turf.db</td>
</tr>
<tr>
<td>Ideas</td>
<td><span class="port">8765</span></td>
<td>Web</td>
<td>idees.json</td>
</tr>
<tr>
<td>Admin Menu</td>
<td><span class="port">8766</span></td>
<td>Web</td>
<td>config_restaurant.json</td>
</tr>
<tr>
<td>Réservation</td>
<td><span class="port">8767</span></td>
<td>Web</td>
<td>reservations.json</td>
</tr>
<tr>
<td>Templates</td>
<td><span class="port">9090</span></td>
<td>Web</td>
<td>-</td>
</tr>
</table>
<hr>
<p style="text-align: center; color: #7f8c8d;">
<em>Document généré le 25/02/2026 - H3R7</em>
</p>
</body>
</html>

183
ARCHITECTURE_SERVICES.md Executable file
View File

@@ -0,0 +1,183 @@
# 🏗️ Architecture des Services H3R7
## Vue d'Ensemble
```
┌─────────────────────────────────────────────────────┐
│ VPS H3R7 │
│ 178.18.250.53 │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────┼─────────────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ PORTAIL CENTRAL │ │ SERVICES API │ │ TEMPLATES SITE │
│ Port 8768 │ │ Ports 8765-8767 │ │ Port 9090 │
│ │ │ │ │ │
│ ┌──────────────┐ │ │ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ 🏇 Turf │ │ │ │ 💡 Idées │ │ │ │ 🍽️ Restaurant│ │
│ │ (8765) │ │ │ │ (8765/idees)│ │ │ │ (JSON) │ │
│ └──────────────┘ │ │ └──────────────┘ │ │ └──────────────┘ │
│ ┌──────────────┐ │ │ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ 💡 Idées │ │ │ │ 📅 Réservation│ │ │ │ 🥖 Boulangerie│ │
│ │ (8765/idees) │ │ │ │ (8767) │ │ │ └──────────────┘ │
│ └──────────────┘ │ │ └──────────────┘ │ │ ┌──────────────┐ │
│ ┌──────────────┐ │ │ ┌──────────────┐ │ │ │ 🔨 Artisan │ │
│ │ ⚙️ Admin Menu │ │ │ │ ⚙️ Admin Menu │ │ │ └──────────────┘ │
│ │ (8766) │ │ │ │ (8766) │ │ │ ┌──────────────┐ │
│ └──────────────┘ │ │ └──────────────┘ │ │ │ 🖥️ Manager │ │
│ ┌──────────────┐ │ │ ┌──────────────┐ │ │ │ (tables) │ │
│ │ 📅 Réservation│ │ │ │ 🎨 Templates │ │ │ └──────────────┘ │
│ │ (8767) │ │ │ │ (9090) │ │ │ │
│ └──────────────┘ │ │ └──────────────┘ │ │ │
│ ┌──────────────┐ │ │ │ │ │ │
│ │ 🎨 Templates │ │ │ │ │ │ │
│ │ (9090) │ │ │ │ │ │ │
│ └──────────────┘ │ │ │ │ │ │
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
```
---
## 📋 Liste des Services
### 🏇 TURF - Prédictions Hippiques
| Service | URL | Description |
|---------|-----|-------------|
| Dashboard | http://178.18.250.53:8765/ | Prédictions turf combinées |
| API Prédictions | http://178.18.250.53:8765/api/today | Données en temps réel |
**Fonctionnalités :**
- Vue d'ensemble des courses du jour
- Liste des favoris par course
- Cotes en temps réel (PMU, ZEturf, Genybet, etc.)
- Scraping automatique multi-sources
- Sauvegarde en base SQLite
---
### 💡 BOÎTE À IDÉES
| Service | URL | Description |
|---------|-----|-------------|
| Interface | http://178.18.250.53:8765/idees/ | CRUD complet |
| API | http://178.18.250.53:8765/api/ideas | Backend JSON |
**Fonctionnalités :**
- Ajouter une idée
- ✏️ Modifier une idée
- 🗑️ Supprimer une idée
- 🔍 Filtrer par catégorie
- 📊 Indicateur de potentiel
---
### 🍽️ TEMPLATES RESTAURANT
| Service | URL | Description |
|---------|-----|-------------|
| Templates | http://178.18.250.53:9090/ | Page d'accueil |
| Restaurant JSON | http://178.18.250.53:9090/template_restaurant_json.html | Menu configurable |
| Boulangerie | http://178.18.250.53:9090/template_boulangerie_final.html | Site boulangerie |
| Artisan | http://178.18.250.53:9090/template_artisan_final.html | Site artisan |
---
### 📅 SYSTÈME DE RÉSERVATIONS
| Service | URL | Description |
|---------|-----|-------------|
| Client | http://178.18.250.53:8767/ | Réservation tables |
| Manager | http://178.18.250.53:9090/manager.html | Gestion tables |
**Fonctionnalités :**
- Réservation en ligne
- Tableau de bord tables
- Indicateurs Libre/Occupée
---
### ⚙️ ADMIN
| Service | Port | Description |
|---------|------|-------------|
| Menu Admin | 8766 | Gestion menu restaurant |
---
## 🔧 Accès Technique
- **VPS :** 178.18.250.53
- **Auth :** admin:turf2026
- **Ports :** 8765, 8766, 8767, 8768, 9090
---
## 📊 Matrice
| Service | Port | Type | Data |
|---------|------|------|------|
| Portal | 8768 | Web | - |
| Turf | 8765 | Web/API | turf.db |
| Ideas | 8765/idees | Web | idees.json |
| Admin Menu | 8766 | Web | config_restaurant.json |
| Réservation | 8767 | Web | reservations.json |
| Templates | 9090 | Web | - |
---
*Document généré le 25/02/2026*
---
## 📧 EMAIL — Resend API
| Service | URL | Description |
|---------|-----|-------------|
| Endpoint | `POST http://localhost:8765/api/send-email` | Envoi d'email via Resend |
| Alias | `POST http://localhost:8765/turf/api/send-email` | |
**Champs requis :** `to`, `subject`, + `html` ou `text`
**Champ optionnel :** `from` (défaut: `H3R7Tech <onboarding@resend.dev>`)
**Clé env :** `RESEND_API` (injectée via systemd)
**Exemple :**
```json
POST /api/send-email
{
"to": "user@example.com",
"subject": "Alerte ROI",
"html": "<p>ROI exceptionnel détecté !</p>"
}
```
**Intégrations :**
- `metrics_alerts.py --email` : envoie le rapport par email
- `turf_scheduler.py` : alerte automatique quotidienne à 21h30 si ROI > 1.0€
---
## 🔍 BRAVE SEARCH
| Service | URL | Description |
|---------|-----|-------------|
| Endpoint | `GET http://localhost:8765/api/brave-search?q=...&count=N` | Recherche web |
| Alias | `GET http://localhost:8765/turf/api/brave-search` | |
**Paramètres :** `q` (requis), `count` (défaut: 10, max: 20), `offset`, `type` (`web` ou `news`)
**Clé env :** `BRAVE_SEARCH_API` (injectée via systemd)
**Exemple :**
```
GET /api/brave-search?q=courses+PMU&count=5&type=news
```
**Intégrations :**
- `portail.html` : zone de recherche Brave Search intégrée dans le portail H3R7Tech
- Zone de recherche avec filtre Web/Actualités, résultats affichés inline
---
*Mis à jour le 25/04/2026 — HRT-18*

272
BUSINESS_PLAN.md Executable file
View File

@@ -0,0 +1,272 @@
# 🐾 H3R7Tech - Business Plan & Services
_Document stratégique: Comment monétiser les outils créés_
---
## 🎯 NOS OUTILS ACTUELS (À VENDRE)
| Outil | Description | Potentiel |
|--------|-------------|-----------|
| **Dépenses Trello** | Gestion personnelle + Trello | Particuliers, micro-entrepreneurs |
| **CRM Simplifié** | Gestion prospects | Artisans, PME locales |
| **Turf Predictions** | Paris hippiques IA | Parieurs, tipsters |
| **Dashboard Turf** | Suivi courses temps réel | Passionnés turf |
---
## 💼 OFFRE DE SERVICES (À PARTIR DE NOVEMBRE)
### Pack "Digital Local"
- **Prix:** 99-199€/mois
- **Client cible:** Artisans, restaurants, commerces locaux
- **Inclus:**
- Site web vitrine (1 page)
- Gestion reservations en ligne
- Page Facebook/Google optimisée
- Support email
### Pack "Visibilité Pro"
- **Prix:** 299-499€/mois
- **Client cible:** PME, cabinets
- **Inclus:**
- Site web complet (5 pages)
- SEO local
- CRM intégré
- Dashboard analytics
### Pack "Agence Digitale"
- **Prix:** Sur devis (500-2000€)
- **Client cible:** Restaurants, hotels, salons
- **Inclus:**
- Site sur-mesure
- Photos/produits
- Réservations complexes
- Maintenance
---
## 🎯 NICHE #1: PRINT ON DEMAND
### Concept
Vendre des produits personnalisés avec vos designs (t-shirts, mugs, stickers, posters)
### Comment ça marche
1. **Tu crées un design** (toi, photos, textes)
2. **Client commande** sur ta boutique
3. **Printful/Teepublic imprime et expédie**
4. **TuTouches la différence** (marge 15-40%)
### Outils nécessaires
| Outil | Rôle | Prix |
|-------|-------|------|
| **Canva** | Créer designs | Gratuit/12€/mois |
| **Printful** | Impression/livraison | Gratuit (marge) |
| **Redbubble** | Marketplace clé en main | Gratuit |
| **Teespring** | T-shirts/hoodies | Gratuit |
| **Etsy** | Boutique directe | 0.20€/article |
### Niche POD rentables en 2026
- 🎨 **Artistes locaux** (villes françaises)
- 🏃 **Sports locaux** (clubs, marathon)
- 🍺 **Hobbies** (brasseries artisanales)
- 🐕 **Animals lovers**
- 👨‍💻 **Dev/tech** (t-shirts geeks)
- 🎮 **Gamers** (esports, streaming)
- 🏠 **Villes/regions** (souvenirs)
### Implementation (Travail à faire)
- [ ] Créer compte Printful
- [ ] Designer 10-20 premiers produits
- [ ] Tester sur Redbubble (gratuit)
- [ ] Lancer boutique Etsy
---
## 🎯 NICHE #2: SERVICES AUX ARTISANS
### Concept
Aider les artisans (plombiers, électriciens, carreleurs) à avoir une présence web
### Problème
- 80% des artisans n'ont pas de site web
- Ils perdent des clients vers les gros acteurs
- Pas le temps de s'en occuper
### Solution (Ce qu'on peut offrir)
| Service | Prix vente | Coût |
|---------|-------------|------|
| Site 1 page | 199€ | 0€ (outil existant) |
| Gestion Google Business | 49€/mois | 0€ |
| Photos produits | 99€ | 50€ (stagiaire) |
| Formation réseaux | 149€ | 0€ |
### Outils à développer
- [ ] Template site artisan (automatisé)
- [ ] Générateur Google Business
- [ ] Dashboard reservations
- [ ] CRM simplifier pour artisans
---
## 🎯 NICHE #3: TURF BETTING SERVICE
### Concept
Vendre des pronostics turf automatisés
### Monetisation possible
| Offre | Prix | Contenu |
|-------|------|---------|
| **Gratuit** | 0€ | 1 prédiction/jour (email) |
| **Essentiel** | 9.90€/mois | 3 prédictions + analyse |
| **Premium** | 29.90€/mois | 5 prédictions + money management |
| **VIP** | 99€/mois | Picks exclusifs + suivi personnalisé |
### Implementation
- [ ] Automatiser提取 des cotes (API PMU)
- [ ] Créer système d'envoi emails (Mailchimp)
- [ ] Page de vente (landing page)
- [ ] Suivi des performances (ROI tracker)
---
## 🎯 NICHE #4: AUTOMATISATION PME
### Concept
Automatiser les tâches repetitives des petites entreprises
### Idées de services
| Service | Prix | Description |
|---------|------|-------------|
| **Scraping pros** | 49-149€ | Extraire annuaires, créer base prospects |
| **Automatisation** | 99-299€ | Zaps, make.com pour TPE |
| **Formation IA** | 149€/jour | Apprendre à utiliser ChatGPT/Claude |
### Croissance
- 5 premiers clients → 10 → 25 → 50
- Prix augmente avec実績 (case studies)
- Recurring income avec maintenance
---
## 🎯 NICHE #5: CRÉATEUR DE CONTENU
### Concept
Aider les influencers/créateurs à gérer leur activité
### Services possibles
| Service | Prix | Description |
|---------|------|-------------|
| **Gestion réseaux** | 299-499€/mois | Posts, stories, community |
| **Edition photos** | 49-99€/mois | Retouche, filters |
| **Montage vidéo** | 99-199€/vidéo | Shorts, Reels |
| **Merch** | 199-499€ | Création produits dérivés |
---
## 📊 PRIORITÉS 2026
### Phase 1: Maintenant → Juin 2026
1.**Print on Demand** (tester, valider niche)
2. 📋 CRM pour artisans (améliorer, vendre)
3. 📋 Turf predictions (automatiser, monétiser)
### Phase 2: Juillet → Novembre 2026
1. 🚀 Lancer service digital local
2. 🚀 Acquérir premiers clients (5-10)
3. 🚀 Boucler système recurring
### Phase 3: Novembre 2026+
1. 🏢 Officialiser structure (EI ou SARL)
2. 🏢 Embauche 1-2 personnes
3. 🏢 Scale services
---
## 🛠️ OUTILS À CRÉER POUR NOTRE BUSINESS
| Outil | Niche | Status |
|-------|-------|--------|
| **Template POD** | Print on Demand | À créer |
| **CRM Artisan** | Services pros | Existe (améliorer) |
| **Email tracker** | Turf | À créer |
| **Landing page gen** |通用 | À créer |
| **Lead scraper** | Services | Existe |
| **Dashboard stats** |通用 | À créer |
---
## 💰 OBJECTIFS CHIFFRÉS
### 2026
| Mois | Revenu objectif |
|------|-----------------|
| Mars | 100€ |
| Avril | 300€ |
| Mai | 500€ |
| Juin | 800€ |
| Juillet | 1000€ |
| Novembre | 2500€ |
### 2027
- Objectif: 5000€/mois
- 2-3 employés
---
## 📈 PAGE WEB BUSINESS
À créer: http://178.18.250.53:8765/h3r7tech-business.html
Contenu:
- Services proposés
- Tarifs
- Portfolio/案例
- Contact
- Blog (partage d'astuces)
---
## 🎯 ACTIONS IMMÉDIATES
### Cette semaine
- [ ] Finaliser page business H3R7Tech
- [ ] Créer 5 designs POD test
- [ ] Améliorer CRM (stats dashboard)
### Ce mois
- [ ] Trouver 1 premier client service digital
- [ ] Lancer 10 produits POD
- [ ] Automatiser predictions turf
### Ce trimestre
- [ ] Valider modèle économique
- [ ] Préparer结构 juridique
- [ ] Recruter premier freelance
---
## 📚 RESSOURCES
### POD
- r/printondemand (Reddit)
- Printful blog
- Redbubble academy
### Services digitaux
- Fiverr, 1Work, Malt (trouver clients)
- Airtable (CRM simple)
- Notion (gestion projet)
### Turf
- Zone-Turf API (scraping)
- Tipsters Facebook groups
---
_Document généré le 27/02/2026 - H3R7Tech 🐾_

200
CARTOGRAPHIE_H3R7TECH.md Executable file
View File

@@ -0,0 +1,200 @@
# 🐾 H3R7Tech - Cartographie des Services & Versions
_Dernière mise à jour: 26 février 2026_
---
## 📋 Contexte du Projet
**Objectif:** Créer une entreprise H3R7Tech pour services B2B (démarchage, marketing, scraping, CRM) + système de paris hippiques.
**Infrastructure:**
- VPS: 178.18.250.53 (Contabo)
- User: h3r7
- Ports clés: 8765-8773
- Git: Gitea (port 3000)
---
## 🏇 Module Turf - Paris Hippiques
### v1.0 (2026-02-21)
- **Dashboard principal** - Port 8765
- API combinée PMU, Boturfers, Zone-Turf, Canalturf
- Prédictions automatiques (3 favoris par course)
- Budget: 6€/course
- Statistiques ROI
### v1.1 (2026-02-22)
- Ajout analyses jockeys/entraineurs
- Comparaison prédictions vs résultats
- Tracking performance
### v1.2 (2026-02-23)
- Interface dashboard redesign
- Exports résultats
- Script automatisation cron
### v1.3 (2026-02-24)
- Intégration cron jobs (10h-13h30)
- Résultats automatiques via API
### v1.4 (2026-02-25)
- Amélioration scraping (Apify fallback)
- Nouvelles sources de cotes
---
## 📊 CRM - Gestion Prospects
### v1.0 (2026-02-25)
- **Port 8770** - CRM Production
- Import 123 prospects (Apify - Artisans Lille)
- Catégories: Restaurant, Boulangerie, Garage
- Statuts: Nouveau, Contacté, RDV, Proposition, Gagné, Perdu
### v1.1 (2026-02-26)
- **Corrections JS** - render(), onclick handlers
- Filtre catégorie ajouté
- Liens Google Maps (onclick)
### v2.0 (2026-02-26)
- **Port 8773** - CRM Candidatures (Kanban)
- Vue Pipeline: À Postuler → En Attente → Entretien 1 → Entretien 2 → Offre → Refus
- Historique contacts
- Rappels automatiques
- Transform prospects → candidatures
---
## 🔧 Services Techniques
### Scraping
| Service | Port | Description |
|---------|------|-------------|
| turf_api | 8765 | API Turf combinée |
| admin_api | 8766 | Interface admin |
| reservations | 8767 | Système réservations |
| portal | 8768 | Portail client |
| depenses | 8769 | Dépenses Trello |
| crm_api | 8770 | API CRM |
| agent_chat | 8771 | Chat IA |
### Scripts
- `multi_scraper_v5.py` - Scraping courses
- `dashboard_api.py` - API dashboard
- `crm_api.py` - API CRM
- `backup.sh` - Backup automatique (3h/jour)
---
## 🌐 Interfaces Web
### Portails
| URL | Description |
|-----|-------------|
| http://178.18.250.53:8765 | Dashboard Turf |
| http://178.18.250.53:8768 | Portail (Tout, Turf, Perso...) |
| http://178.18.250.53:8769 | Dépenses Trello |
| http://178.18.250.53:8770 | CRM Prospects |
| http://178.18.250.53:8772 | CRM Dev |
| http://178.18.250.53:8773 | CRM Candidatures |
| http://178.18.250.53:3000 | Gitea |
### Démarchage
- `SCRIPTS_DEMARCHAGE.html` - Scripts appels
- `liste_appels.md` - 123 prospects qualifiés
---
## 🧑💻 Outils Perso
### Dépenses Trello (v1.0 - 2026-02-27)
- **Port:** 8769
- **URL:** http://178.18.250.53:8769/
- **Description:** Gestion dépenses personnelles avec envoi automatique vers Trello
- **Fonctionnalités:**
- Ajout dépenses avec prénom, date, libellé, montant
- Status: "En attente" → "Envoyé ✅"
- 1 carte Trello par dépense (liste CP FEV 26)
- Format customizable {prenom} - {date} - {libelle} - {montant}€
- Filtre Prénoms configurables
- **Stack +:** Flask Vanilla JS (mobile compatible)
- **Repo:** http://178.18.250.53:3000/admin/Perso
---
## 📁 Fichiers Clés
### Configuration
```
/home/h3r7/turf_scraper/
├── crm_prospects.json # 123 prospects
├── turf.db # Base données courses
├── crm_dashboard.html # CRM prod (v1.1)
├── crm_candidatures.html # CRM kandan (v2.0)
└── backup.sh # Script backup
/home/h3r7/turf_scraper_dev/
├── crm_dashboard.html # CRM dev
└── H3R7Tech_logo.svg # Logo
/home/h3r7/depenses_trello/
├── app.py # API Flask
├── config.json # Configuration
└── templates/index.html # UI
```
### Git
- **Repo:** http://178.18.250.53:3000/admin/h3r7tech
- **Repo Perso:** http://178.18.250.53:3000/admin/Perso
- **master:** Production (stable)
- **dev:** Développement
---
## 📈 Métriques
### CRM
| Métrique | Valeur |
|----------|--------|
| Total prospects | 123 |
| Restaurants | 94 (76%) |
| Boulangeries | 26 (21%) |
| Garages | 3 (2%) |
| Note moyenne | 4.4/5 |
### Turf
| Métrique | Valeur |
|----------|--------|
| Analyses/jour | 5 |
| Budget/course | 6€ |
| Favoris/course | 3 |
---
## 🔜 Prochaines Versions
### v2.1 (Prévu)
- [ ] Améliorer filtrage CRM
- [ ] Export prospects → CSV
- [ ] Intégration email
- [ ] Automatisation rappels
### v2.2 (Prévu)
- [ ] Dashboard stats prospection
- [ ] Scripts personnalisés par catégorie
- [ ] Intégration Google Calendar
---
## 📝 Notes
- **2026-02-21:** Lancement projet
- **2026-02-25:** Import 123 prospects via Apify
- **2026-02-26:** Création CRM Candidatures + corrections
- **2026-02-27:** Création app Dépenses Trello (port 8769), push Gitea
---
_Cartographie générée automatiquement - H3R7Tech 🐾_

157
DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,157 @@
# 💸 Dépenses Trello - Application de Gestion des Dépenses
## 📝 Description
**Dépenses Trello** est une application web de gestion des dépenses personnelles avec synchronisation automatique vers Trello. Créée pour simplifier le suivi des dépenses au quotidien.
---
## ✨ Fonctionnalités
### Gestion des Dépenses
- **Ajout rapide** de dépenses (prénom, date, libellé, montant, catégorie)
- ✏️ **Modification** des dépenses existantes
- 🗑️ **Suppression** de dépenses
- 📤 **Export CSV** pour Excel/comptabilité
- 📥 **Import CSV** pour récupérer des données
- 📄 **Export PDF** pour les relevés
### Catégories Automatiques
- 🚗 **Transport** (essence, gazole, parking)
- 🛒 **Courses** (Carrefour, Lidl, Auchan)
- 🎰 **Loisirs** (loto, bar, café, cinéma)
- 🏠 **Maison** (lumière, loyer, brico)
- ❤️ **Santé** (pharmacie, contrôle technique)
- 📦 **Autre** (divers)
### Graphiques & Statistiques
- 📊 **Bar chart** - visualisation classique
- 🥧 **Camembert** - répartition par catégorie
- 👤 **Par personne** - suivi par membre du foyer
- 📅 **Par mois** - évolution dans le temps
### Budget
- 💰 **Budget mensuel** configurable
- ⚠️ **Alerte** automatique quand le budget est dépassé
### Dépenses Récurrentes
- 🔄 **Loyer**, EDF, téléphone - automatiquement listés
### Synchronisation Trello
- 🟦 **Envoi automatique** des dépenses vers une liste Trello
-**Suivi du statut** (En attente / Envoyé)
---
## 🛠️ Stack Technique
| Composant | Technologie |
|-----------|--------------|
| **Backend** | Python Flask |
| **Base de données** | SQLite |
| **Frontend** | HTML5, Vanilla JavaScript |
| **Graphiques** | Chart.js |
| **PDF** | jsPDF |
| **Hébergement** | VPS (Linux) |
---
## 📋 Spécifications
- **Port**: 8769
- **URL**: http://178.18.250.53:8769/
- **API REST**: /api/depenses, /api/config, /api/budget, /api/recurring
- **Données**: 21+ dépenses en base (extensible)
- **Utilisateurs**: Multi-utilisateurs (via prénom)
---
## 🚀 Installation
```bash
# Cloner le projet
git clone http://178.18.250.53:3000/admin/Perso.git
# Installer les dépendances
pip install flask requests
# Lancer l'application
python app.py
```
---
## 💼 Potentiel Commercial
### Cible
- 👨‍👩‍👧 **Particuliers** gestion budget familial
- 💼 **Auto-entrepreneurs** frais professionnels
- 🏢 **Petites entreprises** suivi dépenses
### Arguments de Vente
1.**Simple** - interface épurée, pas de formation
2.**Complet** - catégories, graphiques, budget, PDF
3.**Automatisé** - synchronisation Trello
4.**Pas d'abonnement** - hébergement propre
5.**Open Source** - customisable
### Prix Recommandés
| Offre | Prix |
|-------|------|
| Usage personnel | 19€ |
| Usage pro | 49€ |
| Installation + config | 29€ |
| Support mensuel | 9€/mois |
---
## 📱 Captures d'Écran
### Page Saisie
- Formulaire rapide avec catégories auto
- Liste des dernières dépenses
- Boutons Envoyer tout / Export
### Page Dashboard
- Graphiques Bar / Camembert
- Filtres Mois / Personne / Catégorie
- Total en temps réel
### Page Config
- Gestion des prénoms
- Personnalisation des catégories
- Configuration Trello (API Key, Token, List ID)
- Budget mensuel
---
## 🔧 Configuration Trello
1. Créer un Power-Up sur https://trello.com/power-ups/admin
2. Générer une API Key
3. Générer un Token (avec permissions write)
4. Copier le List ID cible
5. Coller dans Config
---
## 📝 Roadmap Future
- [ ] Application mobile (PWA)
- [ ] Mode hors-ligne
- [ ] Catégories personnalisées illimitées
- [ ] Rapports mensuels par email
- [ ] Intégration Slack/Discord
- [ ] Multi-devises
---
## 📞 Contact
**Développé par**: H3R7Tech
**Date**: Mars 2026
**Version**: 1.6
---
*Document généré automatiquement - Dépenses Trello*

171
EVOLUTION.md Executable file
View File

@@ -0,0 +1,171 @@
# 📈 Étude d'Évolution - H3R7Tech
_Document de prospective fonctionnalité pour chaque application_
---
## 1. 💸 Dépenses Trello
### État Actuel
- Port: 8769
- Stack: Flask + Vanilla JS
- Fonctionnalités: Ajout dépenses, envoi Trello (1 carte/ligne), status (En attente/Envoyé)
### Évolutions Possibles
| Priorité | Feature | Description | Difficulté |
|----------|---------|-------------|-------------|
| 🔴 Haute | Export CSV | Exporter les dépenses en CSV | Facile |
| 🔴 Haute | Filtres dates | Filtrer par période (mois, semaine) | Facile |
| 🟡 Moyenne | Catégories | Catégories personnalisées (Courses, Essence, Loisirs...) | Moyenne |
| 🟡 Moyenne | Multiple List Trello | Choisir destination Trello par catégorie | Moyenne |
| 🟢 Basse | Rapports mensuels | Synthèse automatique mensuelle | Facile |
| 🟢 Basse | Widget Android | App native Android (Kotlin/Compose) | Difficile |
| 🟢 Basse | Intégration Google Sheets | Sync automatique Sheets | Moyenne |
### Idées Innovantes
- **IA categorization**: Classifier automatiquement les dépenses par libellé
- **Récurrence**: Détecter les dépenses récurrentes (abonnements)
- **Budget alerts**: Alertes si dépasse budget mensuel
---
## 2. 📊 CRM (Port 8770)
### État Actuel
- 126 prospects (Restaurants, Boulangeries, Garages)
- Filtres: Catégorie, Statut
- Lien Google Maps
### Évolutions Possibles
| Priorité | Feature | Description | Difficulté |
|----------|---------|-------------|-------------|
| 🔴 Haute | Pipeline visual | Vue Kanban complète (drag & drop) | Moyenne |
| 🔴 Haute | Historique contacts | Timeline complète interactions | Moyenne |
| 🔴 Haute | Statistiques | Dashboard KPIs (conversion, taux réponse) | Facile |
| 🟡 Moyenne | Import CSV/Excel | Import prospects via fichier | Facile |
| 🟡 Moyenne | Export contacts | Export vCard pour téléphone | Facile |
| 🟡 Moyenne | Scoring auto | Score basé sur critères (taille, activité) | Moyenne |
| 🟢 Basse | AI recommandations | Suggestions prospects à appeler | Difficile |
| 🟢 Basse | Integration WhatsApp | Messages automatisés | Difficile |
### Idées Innovantes
- **Lead scoring ML**: Score prédit bas<61> sur données publiques
- **Auto-enrichissement**: Compléter automatiquement emails/téléphones via API
- **Voice assistant**: Appeler prospects via API téléphone
---
## 3. 🏇 Turf Dashboard (Port 8765)
### État Actuel
- Scraping multi-sources (PMU, Zone-Turf, Canalturf...)
- Prédictions 3 favoris/course
- BDD SQLite historique
### Évolutions Possibles
| Priorité | Feature | Description | Difficulté |
|----------|---------|-------------|-------------|
| 🔴 Haute | Résultats auto | Scrap résultats automatiquement | Moyenne |
| 🔴 Haute | ROI Tracker | Suivi gains/pertes quotidien | Facile |
| 🔴 Haute | Alertes cotes | Notification quand cote évolue | Moyenne |
| 🟡 Moyenne | Comparatif IA | Comparer prédictions vs résultats | Moyenne |
| 🟡 Moyenne | Paris groupés | Générer paris combinés | Facile |
| 🟢 Basse | Telegram betting | Parier via bot Telegram | Difficile |
| 🟢 Basse | Prévisions météo | Météo hippodrome → impact | Facile |
| 🟢 Basse | Historique jockeys | Stats détaillées jockeys/entraineurs | Moyenne |
### Idées Innovantes
- **ML predictions**: Modèle ML entraîné sur données historiques
- **Value betting**: Détecter les cotes "value" vs probabilité réelle
- **Arbitrage**: Détecter opportunités'arbitrage entre bookmakers
---
## 4. 🌐 Portail (Port 8768)
### État Actuel
- Grille d'applications
- Filtres: Turf, Restaurant, Perso, Admin...
### Évolutions Possibles
| Priorité | Feature | Description | Difficulté |
|----------|---------|-------------|-------------|
| 🔴 Haute | Recherche | Barre recherche applications | Facile |
| 🟡 Moyenne | Favoris | Marquer apps favorites | Facile |
| 🟡 Moyenne | Categories personalisées | Créer ses propres catégories | Moyenne |
| 🟢 Basse | Dark/Light mode | Toggle thème | Facile |
| 🟢 Basse | Widget dashboard | Résumé stats sur page d'accueil | Moyenne |
| 🟢 Basse | PWA | Installation comme app native | Moyenne |
### Idées Innovantes
- **Unified search**: Rechercher across toutes les apps (CRM, Turf, Dépenses)
- **Command palette**: Ctrl+K pour lancer快速的 actions
- **Dashboard custom**: Widgets drag & drop
---
## 5. 📬 Agent Chat (Port 8771)
### État Actuel
- Interface chat avec IA
### Évolutions Possibles
| Priorité | Feature | Description | Difficulté |
|----------|---------|-------------|-------------|
| 🔴 Haute | Context CRM | Chat avec contexte prospects | Moyenne |
| 🔴 Haute | Templates | Réponses toutes faites | Facile |
| 🟡 Moyenne | Voice input | Entrée vocale | Moyenne |
| 🟢 Basse | Multi-langues | Support EN/ES/DE | Facile |
---
## 6. 🎯 Priorités Définies
### Court Terme (Cette semaine)
1. 📊 CRM - Dashboard statistiques
2. 💸 Dépenses - Filtres dates
3. 🏇 Turf - Résultats auto
### Moyen Terme (Ce mois)
1. 📊 CRM - Pipeline Kanban complet
2. 💸 Dépenses - Catégories
3. 🌐 Portail - Recherche
### Long Terme (Ce trimestre)
1. 🏇 Turf - ML predictions
2. 📊 CRM - Auto-enrichissement
3. 💸 Dépenses - App native Android
---
## 📋 Matrice Effort/Bénéfice
| App | Feature | Effort | Bénéfice | Score |
|-----|---------|--------|----------|-------|
| CRM | Stats dashboard | 2 | 5 | 10 |
| Dépenses | Filtres dates | 2 | 4 | 8 |
| Turf | ROI Tracker | 2 | 4 | 8 |
| CRM | Pipeline Kanban | 4 | 5 | 20 |
| Portail | Recherche | 2 | 3 | 6 |
| Turf | Alertes cotes | 3 | 4 | 12 |
---
## 🔧 Tech Stack Recommandé
| Besoin | Solution |
|--------|----------|
| Frontend mobile | React Native / Flutter |
| Backend API | FastAPI (Python) |
| Base de données | SQLite (local) + PostgreSQL (prod) |
| ML | scikit-learn / TensorFlow Lite |
| Hosting | VPS actuel + Docker |
---
_Document généré le 27/02/2026 par H3R7Tech 🐾_

694
EVOLUTION_PREDICTIF.md Normal file
View File

@@ -0,0 +1,694 @@
# Évolution du Système Prédictif Turf
## Historique des modifications - 25 Mars 2026 (suite)
### 25 Mars 2026 - Correction du titre du banner ✅
**Problème**: Le banner de la course affichait uniquement "🏇 MARSEILLE Quinté+" au lieu du nom complet de la course.
**Solution**:
- Modification de `combined_api.py` pour utiliser `get_full_race_name()` qui retourne le format complet: "R1 BORELY - 13:55 GRAND NATIONAL DU TROT"
- Mise à jour du dashboard pour afficher le nom complet dans le banner
- Ajustement CSS pour permettre l'affichage du nom complet sans troncature
- Ajout du favicon avec le logo H3R7Tech
**Résultat**: Le banner affiche maintenant "R1 BORELY - 13:55 GRAND NATIONAL DU TROT" avec le type de course "🏇 Quinté+"
**Fichiers modifiés**:
- `combined_api.py` - Utilisation de `get_full_race_name()` dans l'API
- `dashboard.html` - Affichage du nom complet dans le banner
### 25 Mars 2026 - Résultats groupés par course ✅
**Problème**: L'onglet Résultats affichait les positions 1-5 pour chaque course sans séparation, ce qui était incohérent.
**Solution**:
- Modification de l'API pour inclure `race_label` (ex: "R1 BORELY") et `race_type` (ex: "GRAND NATIONAL DU TROT") dans les résultats
- Mise à jour du dashboard pour grouper les résultats par course et afficher un en-tête pour chaque course
**Résultat**: Les résultats affichent maintenant chaque course séparément:
- R1 BORELY - GRAND NATIONAL DU TROT
- R2 CAEN - PRIX D'ACQUEVILLE
**Fichiers modifiés**:
- `combined_api.py` - Ajout des infos de course dans les résultats
- `dashboard.html` - Groupement et affichage par course
---
## Historique des modifications - 25 Mars 2026
---
## 1. Analyse de la Base de Données
### Tables principales et volumes
| Table | Lignes | Description |
|-------|--------|-------------|
| `historical_data` | 5 536 | Données historiques des courses |
| `pmu_courses` | 3 146 | Courses PMU |
| `pmu_reunions` | 480 | Réunions hippiques |
| `predictions` | 739 | Prédictions effectuées |
| `scoring` | 123 | Scores calculés par cheval |
| `results` | 68 | Résultats des courses |
| `performance` | 96 | Performance prédictions vs réels |
| `recommendations` | 32 | Recommandations de paris |
| `weather` | 4 | Données météo |
### Métriques de performance (avant ML)
| Métrique | Valeur |
|----------|--------|
| Taux de réussite global | 20.83% |
| Précision favori (rang 1) | 42.86% |
| Précision top 3 | 50% |
| Résultats remplis (recos) | 0/32 |
---
## 2. Améliorations Effectuées
### 2.1 Court Terme ✅
#### ✅ Script de mise à jour des résultats
- **Fichier**: `update_recommendation_results.py`
- **Fonction**: Compare les recommandations avec les résultats réels
- **Résultat**: 20 recommandations mises à jour (6 GAGNE, 14 PERDU)
- **Taux de réussite**: 30%
#### ✅ Analyse du rang 0
- **Cause**: `canalturf_partants` (356 prédictions) et `canalturf_selections` (165) ont rank 0 par défaut
- **Solution**: Seuls les ranks 1/2/3 constituent les vraies prédictions
### 2.2 Moyen Terme ✅
#### ✅ Entraînement XGBoost
- **Fichier**: `train_xgboost.py`
- **Données**: 4 902 lignes de `historical_data`
- **Features**: 35 features (cote, forme, performances, etc.)
| Modèle | CV AUC | Amélioration vs random |
|--------|--------|----------------------|
| Top 1 (gagnant) | **0.697** | +19.7% |
| Top 3 (placé) | **0.715** | +21.5% |
**Top Features (par importance):**
1. `cote_directe` - La cote est le predictor le plus important
2. `rang_cote` - Classement par cote
3. `implied_prob` - Probabilité implicite (1/cote)
4. `ratio_cote_field` - Ratio cote vs moyenne du field
5. `tx_victoire` - Taux de victoire historique
6. `forme_recente` - Forme récente du cheval
**Fichiers générés:**
- `xgboost_models.pkl` - Modèles entraînés
- `feature_importance_top1.csv`
- `feature_importance_top3.csv`
### 2.3 Intégration Dashboard ✅
#### ✅ Nouvel onglet "Prédictions ML"
- **Fichier**: `dashboard.html`
- **Endpoint**: `/turf/api/ml_predictions`
- **Données affichées**:
- Probabilité Top 1 (%)
- Probabilité Top 3 (%)
- Score ML composite
- Conseil (TOP1 / TOP3 / PASS)
#### ✅ Intégration vitesse
- **Fichier**: `combined_api.py`
- **Endpoint**: `/turf/api/vitesse`
- **Calcul**: Moyenne du `temps_obtenu` dans historical_data
- **Affichage**: Format mm:ss avec nombre de courses
---
## 3. Architecture Actuelle
```
┌─────────────────────────────────────────────────────────┐
│ PORTAL DUCKDNS │
│ https://portal-kolifee.duckdns.org │
│ (Auth: h3r7 / ****) │
└─────────────────────┬───────────────────────────────────┘
┌─────────────┴─────────────┐
│ │
Port 8765 Port 9999
│ │
┌───────▼────────┐ ┌───────▼────────┐
│ combined_api │ │ vitesse_api │
│ - /turf/api │ │ - /api/vitesse │
│ - ML预测 │ │ (obsolète) │
│ - Vitesse │ └──────────────────┘
│ - Scoring │
└───────────────┘
┌───────────────────────────────────┐
│ turf.db │
│ - predictions │
│ - historical_data (4 902 lignes) │
│ - scoring │
│ - results │
│ - recommendations │
└───────────────────────────────────┘
```
---
## 4. Améliorations à Venir
### 4.1 Priorité Haute
#### 🔴 Comparaison Prédictions vs Résultats Réels
- **Problème**: Seulement 96 prédictions evaluées avec résultats
- **Solution**:
- Automatiser le remplissage des résultats dans `performance` table
- Créer script `backtest.py` qui compare prédictions ML vs réels
- Calculer ROI par type de pari (simple gagnant, place, couplé)
- **Métriques à suivre**:
- Précision par rang (base 1, chance 2, outsider 3)
- ROI par type de pari
- Evolution de la précision dans le temps
#### 🔴 Ajouter plus de données historiques
- **Problème**: 4 902 lignes, seulement 364 jours
- **Solution**: Améliorer `improve_historical_data.py` pour charger plus de dates
- **Objectif**: 20 000+ lignes pour meilleur entraînement
- **API à utiliser**: PMU API (https://turfinfo.api.pmu.fr)
#### 🔴 Intégration météo
- **Problème**: Table `weather` n'a que 4 lignes
- **Solution**: Scraping météo quotidien + impact sur prédictions
- **Impact**: Ajustement selon conditions (pluie, vent, température)
- **Sources**: weather.com, openweathermap.org
#### 🔴 Système de "Value Bets"
- **Problème**: Pas de comparaison cote PMU vs cote "juste"
- **Solution**: Estimer cote juste avec le modèle ML, parier si valeur > seuil
- **Formule**: `value = (cote_PMU - cote_juste) / cote_juste`
- **Seuil**: Parier si value > 10%
### 4.2 Priorité Moyenne
#### 🟡 Scraping de Sources Hippiques Externes
- **Problèmes identifiés**:
- Canalturf: prédictions basiques
- Pas d'avis externes intégrés
- **Sources à scraper**:
- **Equidia**: Pronostics experts, commentaires
- **Paris-Turf**: Analyses courses, tiércé
- **Zeturf**: Pronostics communautaire
- **LeTrot**: Spécifique trot attelé
- **Données à récupérer**:
- Avis des experts (auteur, confiance, commentaire)
- Cotes de référence (moyenne de plusieurs sources)
- Statistiques jockeys/entraineurs
#### 🟡 Backtest complet
- **Problème**: Seulement 96 prédictions evaluées
- **Solution**: Automatiser le backtest sur toutes les recommandations
- **Objectif**: ROI réel du modèle
- **Métriques**:
```
- ROI = (gains - mises) / mises
- Yield = gains_esperés / mises
- Hit Rate = paris_gagnés / total_paris
- CLV = Cote_Moyenne - Cote_Fermee
```
#### 🟡 Amélioration du scoring
- **Problème**: Formule actuelle pondérée manuellement
- **Solution**: Utiliser les weights XGBoost pour pondérer le scoring
- **Impact**: Précisions bases: 42.86% → 50%+
#### 🟡 Modèles multi-cibles
- **Solution**: Entraîner modèles séparés pour:
- `model_quinte` - top5 (pour couplés, tiercé, quinté)
- `model_turf` - Discipline spécifique (trot vs plat)
- `model_distance` - Performance par catégorie de distance
- `model_hippodrome` - Performance par hippodrome
#### 🟡 Intégration des performances jockeys/entraineurs
- **Données manquantes**:
- Taux de victoire du jockey
- Taux de réussite de l'entraineur
- Combinaison cheval/jockey historique
- **Solution**: Ajouter tables `jockeys` et `entraineurs`
- **Impact**: +5-10% de précision
### 4.3 Priorité Basse
#### 🟢 Interface admin
- **Solution**: Page pour gérer les prédictions, voir stats
- **Fonction**:
- Activer/désactiver scrapers
- Voir ROI en temps réel
- Historique des prédictions
- Configuration des seuils
#### 🟢 Alertes Telegram/Discord
- **Solution**: Notification quand prediction forte (prob > 50%)
- **Impact**: Réactivité augmentée
- **Format**:
- Cheval recommandé
- Cote actuelle
- Probabilité ML
- Mise suggérée
#### 🟢 Dashboards analytiques
- **Solution**: Graphiques d'évolution
- **Métriques**:
- Précision vs temps
- ROI vs temps
- Meilleurs/wpires chevaux
- Meilleurs/wpires jockeys
---
## 5. Scraping de Sources Externes
### 5.1 Sites à Intégrer
| Site | Données | Difficulté | Priorité |
|------|---------|------------|----------|
| Equidia | Pronostics experts | Moyenne | Haute |
| Paris-Turf | Analyses courses | Moyenne | Haute |
| LeTrot | Spécifique trot | Facile | Moyenne |
| Zeturf | Pronostics communauté | Difficile | Basse |
### 5.2 Données à Récupérer
```python
# Exemple de structure pour données externes
external_data = {
"source": "equidia",
"horse_name": "...",
"expert_name": "...",
"expert_confidence": 0-100,
"comment": "Texte de l'analyse",
"cote_recommandee": 0.0,
"date": "YYYY-MM-DD"
}
```
### 5.3 Intégration dans le modèle
1. **NLP sur les commentaires**:
- Sentiment: POSITIF / NEUTRE / NEGATIF
- Mots-clés: формы,腿部, qualité, etc.
- Score basé sur le texte
2. **Fusion des sources**:
```
score_final =
0.4 * score_ML
+ 0.3 * score_Canalturf
+ 0.2 * score_Equidia
+ 0.1 * score_cote
```
---
## 6. Métriques de Performance à Suivre
### 6.1 Métriques Quotidiennes
| Métrique | Description | Cible |
|----------|-------------|-------|
| `precision_bases` | % bases correctes | > 45% |
| `precision_chances` | % chances correctes | > 30% |
| `precision_outsiders` | % outsiders corrects | > 15% |
| `nb_predictions` | Nombre de prédictions | > 20 |
### 6.2 Métriques Hebdomadaires
| Métrique | Description | Cible |
|----------|-------------|-------|
| `roi_simple_gagnant` | ROI paris gagnant | > 5% |
| `roi_simple_place` | ROI paris placé | > 10% |
| `avg_cote` | Cote moyenne recommandée | - |
| `value_bets` | % de value bets trouvés | > 20% |
### 6.3 Métriques Mensuelles
| Métrique | Description | Cible |
|----------|-------------|-------|
| `roi_global` | ROI global | > 10% |
| `evolution` | Evolution vs mois précédent | positive |
| `meilleur_cheval` | Cheval le plus profitable | - |
| `pire_cheval` | Cheval le moins profitable | - |
---
## 7. Structure de Données Suggérée
### 7.1 Nouvelle Table: `expert_predictions`
```sql
CREATE TABLE expert_predictions (
id INTEGER PRIMARY KEY,
date TEXT NOT NULL,
source TEXT NOT NULL, -- 'equidia', 'paris_turf', etc.
expert_name TEXT,
horse_name TEXT NOT NULL,
horse_number INTEGER,
race_name TEXT,
confidence INTEGER, -- 0-100
sentiment TEXT, -- 'POSITIF', 'NEUTRE', 'NEGATIF'
comment TEXT,
recommended_odds REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### 7.2 Nouvelle Table: `backtest_results`
```sql
CREATE TABLE backtest_results (
id INTEGER PRIMARY KEY,
date TEXT NOT NULL,
race_name TEXT,
horse_name TEXT,
type_pari TEXT, -- 'simple_gagnant', 'simple_place', 'couple'
mise REAL,
Cote REAL,
Resultat TEXT, -- 'GAGNE', 'PERDU'
Gain REAL,
prob_predite REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### 7.3 Nouvelle Table: `jockeys`
```sql
CREATE TABLE jockeys (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
total_races INTEGER,
victories INTEGER,
places INTEGER,
taux_victoire REAL,
taux_place REAL,
last_updated TIMESTAMP
);
```
---
## 8. Plan d'Action Détaillé
### Phase 1: Amélioration Données (Semaine 1-2)
- [ ] Augmenter historical_data à 10 000+ lignes
- [ ] Remplir quotidiennement les résultats
- [ ] Créer script backtest automatique
- [ ] Tableau de bord métriques
### Phase 2: Intégration Sources (Semaine 3-4)
- [ ] Intégrer Equidia API/scraping
- [ ] Ajouter analyse sentiment des commentaires
- [ ] Fusionner scores multi-sources
### Phase 3: Optimisation ML (Semaine 5-6)
- [ ] Réentraîner avec nouvelles features
- [ ] Modèles par discipline
- [ ] Système de value bets
### Phase 4: Production (Semaine 7-8)
- [ ] Déploiement nouveau modèle
- [ ] Alertes Telegram
- [ ] Interface admin
---
## 4.4 Implémenté ✅
#### ✅ Tables de données analytiques (25 Mars 2026)
**Tables créées dans `turf.db`:**
| Table | Description | Colonnes clés |
|-------|-------------|---------------|
| `bet_results` | Historique des paris | date, race_name, type_pari, horse_name, cote, mise, resultat, gain |
| `daily_stats` | Stats quotidiennes | date, total_bets, bets_gagne, mise_totale, gain_total, precision_pct, roi_pct |
| `stats_by_type` | Stats par type de pari | date, type_pari, total_bets, gagne, mise, gain, roi |
| `expert_predictions` | Prédictions externes | date, source, expert_name, horse_name, confidence, sentiment, comment |
**Scripts:**
- `populate_analytics.py` - Remplit les tables depuis recommendations
- `update_recommendation_results.py` - Met à jour les résultats des paris
**API endpoints:**
- `/api/backtest` - Lit depuis `bet_results` et `stats_by_type`
- `/api/stats` - Lit depuis `daily_stats`
---
## 5. Commandes Utiles
```bash
cd /home/h3r7/turf_scraper
# ===== DONNÉES & PRÉDICTIONS =====
# Mettre à jour les résultats des recommandations
python3 update_recommendation_results.py
# Réentraîner le modèle ML
python3 train_xgboost.py
# ===== ANALYTICS =====
# Remplir les tables analytiques (à exécuter après mise à jour résultats)
python3 populate_analytics.py
# ===== API =====
# Redémarrer l'API
pkill -f combined_api
python3 combined_api.py
# Vérifier les logs
tail -f /tmp/combined.log
# ===== BASE DE DONNÉES =====
# Voir les tables analytiques
sqlite3 turf.db "SELECT * FROM daily_stats LIMIT 5;"
sqlite3 turf.db "SELECT * FROM bet_results LIMIT 5;"
sqlite3 turf.db "SELECT * FROM stats_by_type;"
# Statistiques globales
sqlite3 turf.db "SELECT SUM(total_bets) as total, SUM(gain_total) as gains, SUM(mise_totale) as mises FROM daily_stats;"
```
---
## 6. Fichiers Clés
| Fichier | Description |
|---------|-------------|
| `combined_api.py` | API principale (port 8765) |
| `dashboard.html` | Interface frontend avec onglets |
| `train_xgboost.py` | Entraînement ML XGBoost |
| `update_recommendation_results.py` | Mise à jour résultats paris |
| `populate_analytics.py` | Remplit tables analytiques |
| `backtest.py` | Script de calcul du backtest |
| `fetch_results.py` | Récupération résultats 1h après course |
| `xgboost_models.pkl` | Modèles ML entraînés |
| `PREDICTION_MODEL_ANALYSIS.md` | Analyse détaillée du modèle |
| `EVOLUTION_PREDICTIF.md` | Ce document |
---
## 9. Résultats Automatiques
### 9.1 Source des données
Les résultats sont récupérés depuis l'API PMU (`pmu_results.py`) et stockés dans:
- `pmu_partants` - données des partants avec ordre d'arrivée
- `pmu_courses` - informations des courses
### 9.2 Récupération automatique
Le script `fetch_results.py` récupère les résultats:
- Via `pmu_results.py` qui interroge l'API PMU
- 自动每小时 ou à 14h (après les courses)
- Mode scheduler: `python fetch_results.py`
- Mode unique: `python fetch_results.py --once`
### 9.3 Affichage dashboard
L'API affiche automatiquement les résultats:
```python
# Query: top 5 de chaque course française (course 1)
SELECT pp.nom, pp.ordre_arrivee, pp.cote_direct
FROM pmu_partants pp
JOIN pmu_reunions pr ON ...
WHERE pays_code='FRA' AND num_course=1
```
---
## 13. Stratégie de Paris ZE5 (Focus B4/B3)
### 13.1 Objectif
Suite à l'analyse des rapports, le pari le plus rentable est le **ZE5 avec objectif B4/B3** (trouver 4 ou 5 premiers).
### 13.2 Stratégies recommandées
| Stratégie | Description | Mise conseillée | Risque |
|-----------|-------------|------------------|--------|
| **ZE5 B4/B3** | Bases + Chances + 1 Outsider | 3€ | Moyen |
| **ZE5 Conservateur** | Top Favori + 2 Chances | 3€ | Faible |
| **ZE5 Audacieux** | Base + 2 Outsiders | 1€ | Élevé |
### 13.3 Logique
- **B5** (5/5) : ZE5 Ordre/Désordre - Jackpot!
- **B4** (4/5) : Rapport ~20-30€ pour 2€ (fréquent)
- **B3** (3/5) : Rapport ~5-10€ pour 2€ (très fréquent)
### 13.4 Implémentation API
```python
def generate_ze5_recommendations(conn, today):
# Récupérer prédictions: Bases + Chances + Outsiders
# Générer combos optimisés pour B4/B3
return {
'ze5_b4b3': {...},
'ze5_conservateur': {...},
'ze5_audacieux': {...}
}
```
### 13.5 Résultats du 25 Mars 2026
| Pari | Mise | Résultat | Gain |
|------|------|----------|------|
| ZE5 B4/B3 | 3€ | B3 (3/5) | 12€ |
| ZE5 Conservateur | 3€ | B3 (3/5) | 12€ |
| ZE5 Audacieux | 1€ | PERDU | 0€ |
**ROI: +242.9%** 🎯
---
## 14. Suivi des Performances
### 14.1 Données sauvegardées
Les informations suivantes sont enregistrées dans la table `recommendations`:
| Champ | Description |
|--------|-------------|
| `date` | Date de la course |
| `race_name` | Nom de la course |
| `type_pari` | Type de pari (simple_gagnant, ze5_b4b3, etc.) |
| `cheval1` | Cheval(x) sélectionné(s) |
| `numero1` | Numéro du cheval |
| `cote` | Cote du pari |
| `mise` | Mise en euros |
| `gain_potentiel` | Gain potentiel estimé |
| `confiance` | Niveau de confiance (FORTE, BONNE, etc.) |
| `justification` | Explication du choix |
| `resultat` | Résultat (GAGNE, PERDU, B3, B4, B5) |
### 14.2 Script de mise à jour
Le script `update_recommendation_results.py`:
- Récupère les résultats depuis `pmu_partants`
- Évalue chaque pari (ZE5: compte les matches dans top 5)
- Met à jour le champ `resultat`
### 14.3 Calcul du ROI
```sql
SELECT
SUM(mise) as total_mise,
SUM(CASE WHEN resultat='GAGNE' THEN mise * Cote
WHEN resultat='B3' THEN mise * 4
WHEN resultat='B4' THEN mise * 10
ELSE 0 END) as total_gain
FROM recommendations
WHERE date = '2026-03-25';
```
---
## 15. Règles de Sélection des Courses
### 15.1 Critères d'inclusion
| Critère | Description |
|---------|-------------|
| **Pays** | France uniquement (FR) |
| **Données suffisantes** | Minimum 10 partants |
| **Type de course** | Quinté, Trot, Plat, Toutes |
### 15.2 Implémentation API
```python
# Filtre France uniquement
france_condition = """
EXISTS (
SELECT 1 FROM pmu_reunions r
WHERE r.pays_code='FRA'
AND r.date_programme=date
AND (r.hippodrome_long LIKE '%' || race_hippodrome || '%'
OR r.hippodrome_court LIKE '%' || race_hippodrome || '%'
OR r.hippodrome_code LIKE '%' || race_hippodrome || '%')
)
"""
# Minimum 10 partants
min_partants_condition = """
(SELECT COUNT(*) FROM predictions p2
WHERE p2.race_name=predictions.race_name
AND p2.date=predictions.date
AND p2.source='canalturf_partants') >= 10
"""
```
---
## 12. Récupération des Résultats
### 12.1 Règle
Les résultats sont récupérés **1 heure après l'heure de départ** de chaque course.
### 12.2 Données collectées
| Donnée | Description |
|--------|-------------|
| Position | Rang du cheval à l'arrivée |
| Cotes finales | Rapports PMU officiels |
| Gains | Gains potentiels par type de pari |
### 12.3 Script fetch_results.py
```python
import schedule
import time
from datetime import datetime, timedelta
def fetch_daily_results():
"""Récupère les résultats du jour à 14h (1h après dernière course)"""
run_result_fetch()
schedule.every().day.at("14:00").do(fetch_daily_results)
while True:
schedule.run_pending()
time.sleep(60)
```
---
*Document généré le 25 Mars 2026*

BIN
H3R7Tech_logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

44
H3R7Tech_logo.svg Executable file
View File

@@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a1a2e"/>
<stop offset="100%" style="stop-color:#0f0f1a"/>
</linearGradient>
<linearGradient id="circle" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#00d9ff"/>
<stop offset="50%" style="stop-color:#7b2cbf"/>
<stop offset="100%" style="stop-color:#e94560"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="400" height="400" fill="url(#bg)"/>
<!-- Main circle -->
<circle cx="200" cy="200" r="140" fill="url(#circle)"/>
<circle cx="200" cy="200" r="120" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="2"/>
<!-- H3R7 text -->
<text x="200" y="180" text-anchor="middle" font-family="Arial Black, sans-serif" font-size="64" font-weight="bold" fill="white">H3R7</text>
<!-- TECH text -->
<text x="200" y="225" text-anchor="middle" font-family="Arial, sans-serif" font-size="24" font-weight="bold" fill="#00d9ff">TECH</text>
<!-- Tech dots -->
<circle cx="320" cy="100" r="6" fill="#00d9ff"/>
<circle cx="335" cy="125" r="4" fill="#7b2cbf"/>
<circle cx="345" cy="155" r="3" fill="#e94560"/>
<!-- Claw marks -->
<g stroke="#e94560" stroke-width="8" stroke-linecap="round" fill="none">
<!-- Left claw -->
<path d="M 140 280 L 120 310 L 140 320"/>
<!-- Right claw -->
<path d="M 260 280 L 280 310 L 260 320"/>
<!-- Center -->
<line x1="200" y1="290" x2="200" y2="330"/>
</g>
<!-- Shine effect -->
<ellipse cx="160" cy="140" rx="60" ry="30" fill="rgba(255,255,255,0.1)" transform="rotate(-30 160 140)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

85
PLANNING_QUOTIDIEN.md Executable file
View File

@@ -0,0 +1,85 @@
# 📅 Planning Quotidien - H3R7Tech
## Acteurs
| Agent | Rôle |
|-------|------|
| **main** (moi) | Réponses directes, gestion humaine |
| **sub-agents** | Tâches background (analyses, scrap, CRM) |
| **VPS** | Stockage, scraping Python, base de données |
---
## 🏇 HORAIRE TURF
| Heure | Job | Action |
|-------|-----|--------|
| **09h00** | Turf Morning v5 | Scrape cotes matinales, sauvegardes en BDD |
| **10h00** | turf-matin-10h | Analyse + prédictions (3 favoris) |
| **11h00** | turf-matin-11h | 2e analyse, evolution cotes |
| **12h00** | turf-matin-12h | 3e analyse |
| **13h00** | turf-matin-13h / Turf Afternoon | Analyse aprèm +scrape final |
| **13h45** | turf-cotes-13h45 | Cotes finales Quinté+ |
| **14h00** | turf-apres-midi / Sales Appels | Analyses + appels prospects |
| **14h30** | turf-matin-14h30 | Analyse finale, paris finalisés |
| **18h00** | turf-resultats-soir | Vérif résultats courses jour |
| **19h00** | Turf Results Evening | Scrap + comparatif prédictions |
| **21h00** | turf-resultats-final | Bilan complet journée |
---
## 📊 HORAIRE CRM
| Heure | Job | Action |
|-------|-----|--------|
| **08h00** | Mailing - Rapport quotidien | Rapport emails envoyés |
| **09h00** | Scraper - Nouveaux prospects | Scrappe annuaires en ligne |
| **10h00** | Sales - Relances prospects | Relance prospects >7 jours |
| **14h00** | Sales - Appels sortants | Appelle top prospects (score 4-5★) |
| **18h00** | Analyst - Bilan quotidien | KPIs, conversions, recommandations |
---
## 🎯 FLUX DE DONNÉES
```
1. Cron Trigger (heure)
2. Sub-agent wakes up (isolated session)
3. SSH → VPS ou données locales
4. Traitement (scrape/analyse/CRM)
5. Résultat → Telegram (delivery)
```
---
## ⚠️ NOTES
- **web_search/browser**: Non activés pour sub-agents = utilise données locales VPS
- **SSH**: Peut échouer si clé SSH pas configurée
- **Telegram**: Delivery parfois échoue (thread not found) → recharger conversation
---
## 🔧 COMMANDES UTILES
```bash
# Voir jobs cron
cron list
# Lancer un job manuellement
cron run <jobId>
# Voir sessions sub-agents
sessions_list
# Historique d'un sub-agent
sessions_history <sessionKey>
```
---
_Mis à jour: 27/02/2026_

86
POD/README.md Normal file
View File

@@ -0,0 +1,86 @@
# 🛒 H3R7Tech POD Project
Print on Demand automation project.
## 📋 Status
- [ ] Research
- [ ] Designs
- [ ] Platform setup
- [ ] First sale
## 🎯 Goals
1. Automate product creation
2. Scale to 100+ designs
3. Generate 100€/month
## 📦 Platforms
- **Redbubble** - Art, stickers, mugs
- **TeeSpring** - T-shirts, hoodies
- **Printify** (optional) - Premium POD
## 🚀 Quick Start
```bash
# Install dependencies
pip install requests
# Run uploader
python3 pod_uploader.py
```
## 📁 Structure
```
pod_project/
├── README.md # This file
├── pod_uploader.py # Main automation script
├── designs/ # Your designs (.png, .jpg)
├── mockup_prompts.json # AI prompts for mockups
└── pod_upload_data.json # Generated upload data
```
## 💰 Pricing Formula
```
Selling Price = Cost × 2 (minimum)
Margin = Selling Price - Cost
```
## 🔧 Configuration
Edit `pod_uploader.py` to change:
- Platform (redbubble, teespring)
- Products and costs
- Design folder
- Tags
## 📊 Products
| Product | Cost | Price (2x) | Margin |
|---------|------|-------------|--------|
| T-shirt | 12€ | 25€ | 13€ |
| Mug | 5€ | 15€ | 10€ |
| Poster | 4€ | 20€ | 16€ |
| Sticker | 1.5€ | 8€ | 6.5€ |
| Hoodie | 20€ | 45€ | 25€ |
## 🤖 Automation
The script generates:
1. Product data with pricing
2. Titles and descriptions
3. Tags for SEO
4. AI prompts for mockups
## 📝 Next Steps
1. Add designs to `designs/` folder
2. Run `python3 pod_uploader.py`
3. Review generated JSON
4. Upload manually to platform
---
*Generated by H3R7Tech - $(date)*

46
POD/niches_business.html Normal file
View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>💼 Business - Leads Discord</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<style>
:root { --bg-dark: #0d0d1a; --bg-card: #16162a; --bg-input: #1a1a35; --primary: #e94560; --secondary: #7b2cbf; --accent: #00d9ff; --text: #fff; --text-dim: #888; --success: #00ff88; --danger: #ff4757; --warning: #ffc800; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Outfit', sans-serif; background: var(--bg-dark); color: var(--text); padding: 15px; min-height: 100vh; }
h1 { text-align: center; font-size: 1.6em; background: linear-gradient(135deg, var(--primary), var(--secondary)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 15px; }
.card { background: var(--bg-card); border-radius: 15px; padding: 15px; margin-bottom: 15px; }
.dashboard-frame { width: 100%; height: calc(100vh - 180px); border: none; border-radius: 10px; background: #1a1a2e; }
.status-badge { display: inline-block; padding: 4px 10px; border-radius: 15px; font-size: 0.75em; margin-left: 8px; }
.status-new { background: rgba(255,200,0,0.2); color: #ffc800; }
.status-connected { background: rgba(0,255,136,0.2); color: var(--success); }
.status-offline { background: rgba(255,71,87,0.2); color: var(--danger); }
.info-box { background: rgba(0,217,255,0.1); border: 1px solid var(--accent); padding: 12px; border-radius: 10px; margin-bottom: 15px; }
.info-box h3 { color: var(--accent); margin-bottom: 8px; font-size: 0.95em; }
.info-box p { color: var(--text-dim); font-size: 0.85em; }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<h1>💼 Business - Discord Leads</h1>
<div class="card">
<div class="info-box">
<h3>🔗 Connexion Leads Discord</h3>
<p>Le dashboard des leads est accessible ci-dessous. Pour une connexion temps réel, démarrez le bot Discord et le dashboard Flask sur les ports configurés.</p>
</div>
<p style="color: var(--text-dim); font-size: 0.85em; margin-bottom: 10px;">
<span class="status-badge status-connected">● Connecté</span> Dashboard actif sur port 8766
</p>
</div>
<iframe class="dashboard-frame" src="http://10.0.1.1:8766/" sandbox="allow-scripts allow-same-origin"></iframe>
<div style="text-align: center; margin-top: 15px; color: var(--text-dim); font-size: 0.8em;">
<p>H3R7Tech © 2026 - Système de leads automatisé</p>
</div>
</body>
</html>

81
POD/pod_manager.html Normal file
View File

@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>POD Manager - H3R7Tech</title>
<style>
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
min-height: 100vh;
color: #eee;
padding: 80px 20px 20px;
}
.container { max-width: 1200px; margin: 0 auto; }
header { text-align: center; margin-bottom: 40px; }
h1 { font-size: 2.5em; background: linear-gradient(90deg, #00d9ff, #e94560); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.subtitle { color: #888; margin-top: 10px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
.card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
padding: 20px;
transition: all 0.3s;
}
.card:hover { border-color: #00d9ff; transform: translateY(-5px); }
.card h3 { color: #00d9ff; margin-bottom: 10px; }
.card p { color: #8b949e; font-size: 14px; }
.stats { display: flex; gap: 10px; margin-top: 15px; }
.stat { background: #0f3460; padding: 8px 15px; border-radius: 8px; }
.stat-num { color: #00d9ff; font-weight: bold; }
.stat-label { color: #888; font-size: 12px; }
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<div class="container">
<header>
<h1>📦 POD Manager</h1>
<p class="subtitle">Gestion Print on Demand - Designs, produits et ventes</p>
</header>
<div class="grid">
<div class="card">
<h3>🎨 Designs</h3>
<p>Gestion des designs et assets graphiques</p>
<div class="stats">
<div class="stat"><span class="stat-num">12</span><br><span class="stat-label">Actifs</span></div>
<div class="stat"><span class="stat-num">3</span><br><span class="stat-label">Brouillons</span></div>
</div>
</div>
<div class="card">
<h3>👕 Produits</h3>
<p>Catalogue des produits POD configures</p>
<div class="stats">
<div class="stat"><span class="stat-num">45</span><br><span class="stat-label">T-shirts</span></div>
<div class="stat"><span class="stat-num">23</span><br><span class="stat-label">Mugs</span></div>
</div>
</div>
<div class="card">
<h3>💰 Ventes</h3>
<p>Suivi des ventes et revenues</p>
<div class="stats">
<div class="stat"><span class="stat-num">127</span><br><span class="stat-label">Ce mois</span></div>
<div class="stat"><span class="stat-num">2.4k</span><br><span class="stat-label">Revenue</span></div>
</div>
</div>
<div class="card">
<h3>📊 Analytics</h3>
<p>Statistiques et performances</p>
<div class="stats">
<div class="stat"><span class="stat-num">8.2%</span><br><span class="stat-label">Conversion</span></div>
<div class="stat"><span class="stat-num">4.5</span><br><span class="stat-label">Rating</span></div>
</div>
</div>
</div>
</div>
</body>
</html>

179
POD/pod_uploader.py Executable file
View File

@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""
H3R7Tech - POD Automation Script
Upload designs to Redbubble, TeeSpring, or Spreadshop
"""
import os
import json
import time
import random
from datetime import datetime
# Configuration
CONFIG = {
"platform": "redbubble", # redbubble, teespring, spreadshop
"products": [
{"name": "T-shirt", "base_price": 25.00, "cost": 12.00},
{"name": "Mug", "base_price": 15.00, "cost": 5.00},
{"name": "Poster", "base_price": 20.00, "cost": 4.00},
{"name": "Sticker", "base_price": 8.00, "cost": 1.50},
{"name": "Hoodie", "base_price": 45.00, "cost": 20.00},
],
"design_folder": "./designs",
"tags": ["h3r7tech", "tech", "coding", "developer", "python"],
"title_prefix": "H3R7Tech - ",
"description": "Official H3R7Tech merchandise. High quality products.",
}
def load_designs(folder):
"""Load all design files from folder"""
designs = []
if not os.path.exists(folder):
os.makedirs(folder)
print(f"⚠️ Created folder: {folder}")
print(f" Add your designs (.png, .jpg) and run again")
return designs
for f in os.listdir(folder):
if f.lower().endswith(('.png', '.jpg', '.jpeg', '.svg')):
designs.append({
"filename": f,
"path": os.path.join(folder, f),
"name": f.replace('.png', '').replace('.jpg', '').replace('.jpeg', '').replace('_', ' ').replace('-', ' ').title()
})
return designs
def calculate_prices(product, markup=2.0):
"""Calculate selling price based on cost"""
cost = product["cost"]
price = round(cost * markup, 2)
margin = round(price - cost, 2)
return {
"product": product["name"],
"cost": cost,
"price": price,
"margin": margin,
"margin_pct": round((margin / price) * 100, 1)
}
def generate_product_data(design, product):
"""Generate product data for upload"""
title = f"{CONFIG['title_prefix']}{design['name']} - {product['name']}"
tags = CONFIG['tags'] + [design['name'].lower().replace(' ', ''), product['name'].lower()]
return {
"title": title,
"description": f"{CONFIG['description']}\n\nDesign: {design['name']}\nProduct: {product['name']}\n\nTags: {', '.join(tags)}",
"tags": tags[:15], # Most platforms limit tags
"price": calculate_prices(product),
"design": design,
"product_type": product["name"]
}
def create_upload_summary(designs, products):
"""Create summary of what would be uploaded"""
print("\n" + "="*50)
print("📦 POD UPLOAD SUMMARY")
print("="*50)
print(f"Platform: {CONFIG['platform'].upper()}")
print(f"Designs: {len(designs)}")
print(f"Products: {len(products)}")
print(f"Total items to create: {len(designs) * len(products)}")
print()
total_potential_revenue = 0
for design in designs:
print(f"📝 {design['name']}")
for product in products:
pricing = calculate_prices(product)
print(f"{product['name']}: {pricing['price']}€ (marge: {pricing['margin']}€)")
total_potential_revenue += pricing['price']
print()
print(f"💰 Revenu potentiel total: {total_potential_revenue}")
print("="*50)
return {
"designs": len(designs),
"products": len(products),
"total_items": len(designs) * len(products),
"potential_revenue": total_potential_revenue
}
def export_for_upload(designs, products, output_file="pod_upload_data.json"):
"""Export data for manual upload or automation"""
data = {
"generated": datetime.now().isoformat(),
"platform": CONFIG["platform"],
"items": []
}
for design in designs:
for product in products:
item = generate_product_data(design, product)
data["items"].append(item)
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print(f"\n✅ Data exported to: {output_file}")
print(f" Ready for upload or manual process automation")
def create_mockup_description():
"""Generate mockup prompts for AI image generation"""
prompts = []
designs = load_designs(CONFIG["design_folder"])
for design in designs:
for product in CONFIG["products"]:
prompt = f"""Create a product mockup for {product['name']}:
- Design: {design['name']}
- Product: {product['name']}
- Style: Professional, clean, on white background
- Include: Front view of {product['name']} showing the design centered
- Quality: High resolution, photorealistic"""
prompts.append({
"design": design["name"],
"product": product["name"],
"prompt": prompt
})
with open("mockup_prompts.json", "w") as f:
json.dump(prompts, f, indent=2)
print(f"\n✅ Created {len(prompts)} mockup prompts in mockup_prompts.json")
print(" Use these with Midjourney, DALL-E, or Stable Diffusion")
def main():
print("🎨 H3R7Tech POD Automation")
print("="*40)
# Load designs
designs = load_designs(CONFIG["design_folder"])
if not designs:
print("❌ No designs found!")
print(f" Add designs to: {CONFIG['design_folder']}/")
return
print(f"✅ Found {len(designs)} designs")
# Generate summary
summary = create_upload_summary(designs, CONFIG["products"])
# Export data
export_for_upload(designs, CONFIG["products"])
# Generate mockup prompts
create_mockup_description()
print("\n📋 NEXT STEPS:")
print("1. Review pod_upload_data.json")
print("2. Generate mockups using AI (mockup_prompts.json)")
print("3. Upload to platform manually OR use browser automation")
print("4. Monitor sales and adjust pricing")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,360 @@
# Analyse du Modèle Prédictif Turf - Documentation Technique
## 1. Vue d'Ensemble du Système
### 1.1 Architecture Actuelle
Le système de prédiction turf repose sur plusieurs composants:
- **Collecte de données**: Scraping PMU (courses, cotes, résultats)
- **Calcul de scoring**: Agrégation de multiples features par cheval
- **Génération de recommandations**: Types de paris recommandés avec confiance
- **Suivi de performance**: Comparaison prédictions vs résultats réels
### 1.2 Sources de Données
| Source | Tables | Description |
|--------|--------|-------------|
| Canalturf | `predictions`, `odds_history` | Cotes et prédictions externes |
| PMU | `pmu_courses`, `pmu_reunions` | Courses et réunions officielles |
| Interne | `scoring`, `recommendations` | Scores calculés et recommandations |
---
## 2. Analyse des Performances Actuelles
### 2.1 Métriques Globales
| Métrique | Valeur | Interprétation |
|----------|--------|-----------------|
| Total prédictions evaluées | 96 | Échantillon encore réduit |
| Prédictions réussies (hit) | 20 | - |
| **Taux de réussite global** | **20.83%** | En dessous du aléatoire (25% pour top 4) |
| 1er prédit = 1er réel | 1 | 42.86% de précision pour le favori |
| Chevaux dans le top 3 | 14 | 14.58% dans le top 3 |
### 2.2 Analyse par Rang Prédit
| Rang prédit | Total | Hits | Taux de réussite |
|-------------|-------|------|-------------------|
| 0 (non classé) | 69 | 9 | 13.04% |
| 1 (favori) | 7 | 3 | **42.86%** |
| 2 | 10 | 3 | 30.00% |
| 3 | 10 | 5 | **50.00%** |
**Observations:**
- Le rang 3 a le meilleur taux (50%) - possible sur-optimisation
- Les favoris (rang 1) ont 42.86% de réussite
- La catégorie "non classé" (rang 0) représente 71.8% des prédictions
### 2.3 Problemes Identifiés
1. **Volume insuffisant**: Seulement 96 prédictions evaluées
2. **Catégorie 0 trop large**: 69 prédictions non classifiées
3. **Pas de suivi des résultats**: Table `recommendations` n'avait pas de résultats (0/32) → **CORRIGÉ**
4. **Déséquilibre des données**: Majorité de prédictions en rang 0
### 2.4 Analyse du Rang 0 (Résolu)
**Cause racine identifié:**
- `canalturf_partants`: 356 prédictions (tous les partants, rank 0 par défaut)
- `canalturf_selections`: 165 prédictions (sélections supplémentaires, rank 0)
- `canalturf_prono_*`: 198 prédictions avec rank 1/2/3 (vraies prédictions)
**Solution:** Les prédictions avec rank 0 ne doivent PAS être incluses dans l'évaluation de performance.
Seuls les ranks 1 (bases), 2 (chances), 3 (outsiders) constituent les vraie prédictions.
### 2.5 Résultats des Recommandations (Mise à jour)
Après mise à jour automatique:
| Résultat | Count |
|----------|-------|
| GAGNE | 6 |
| PERDU | 14 |
**Taux de réussite: 30%**
**ROI: -70%** (à affiner avec analyse des cotes)
### 2.6 Modèle XGBoost - Résultats
Après entraînement sur 4 902 lignes de données historiques:
| Modèle | CV AUC | Amélioration vs random |
|--------|--------|----------------------|
| Top 1 (gagnant) | **0.697** | +19.7% |
| Top 3 (placé) | **0.715** | +21.5% |
**Top Features (par importance):**
1. `cote_directe` - La cote est le predictor le plus important
2. `rang_cote` - Classement par cote
3. `implied_prob` - Probabilité implicite (1/cote)
4. `ratio_cote_field` - Ratio cote vs moyenne du field
5. `tx_victoire` - Taux de victoire historique
6. `forme_recente` - Forme récente du cheval
**Fichiers générés:**
- `xgboost_models.pkl` - Modèles entraînés
- `feature_importance_top1.csv` - Importance des features pour top1
- `feature_importance_top3.csv` - Importance des features pour top3
---
## 3. Structure des Données
### 3.1 Tables Principales
#### `predictions` (739 lignes)
```sql
-- Prédictions brutes avant scoring
date, race_name, race_hippodrome, race_time,
horse_number, horse_name, odds, prediction_rank, source
```
#### `scoring` (123 lignes)
```sql
-- Score composite calculé par cheval
score = f(score_cote, score_forme, score_victoire, score_place,
score_rk, score_tendance, score_avis)
-- Features individuelles:
score_cote -- Score basé sur la cote
score_forme -- Score basé sur la forme récente
score_victoire -- Taux de victoire historique
score_place -- Taux de placé historique
score_rk -- Classement rank
score_tendance -- Tendance de la cote
score_avis -- Avis de l'entraîneur
```
#### `historical_data` (5 536 lignes)
```sql
-- Données historiques enrichies
date, hippodrome, distance, discipline, allocation
horse_name, horse_number, driver, age, sexe
musique, nb_courses, nb_victoires, nb_places
gains_carriere, gains_annee, reduction_km
avis_entraineur, oeilleres, deferre
cote_directe, cote_reference, est_favori
tx_victoire, tx_place, forme_recente, tendance_forme
ordre_arrivee, top1, top3, top5 -- Variables cibles
```
#### `recommendations` (32 lignes)
```sql
-- Recommandations de paris
type_pari: simple_gagnant, simple_place, couple_gagnant, couple_place
cheval1, numero1, cheval2, numero2
cote, mise, gain_potentiel, confiance, justification
```
### 3.2 Couverture des Données
| Métrique | Valeur |
|----------|--------|
| Période couverte | 2025-03-19 à 2026-03-18 |
| Nombre de jours de courses | 364 |
| Hippodromes différents | 45 |
| Disciplines | 5 (PLAT, TROT, MONTE, HAIES, STEEPLE) |
---
## 4. Modèle de Scoring Actuel
### 4.1 Formule de Scoring
Le score composite est calculé selon:
```
score = score_cote + score_forme + score_victoire + score_place
+ score_rk + score_tendance + score_avis
```
**Features utilisées:**
- `score_cote`: Pondération inverse de la cote (plus petit = plus haut)
- `score_forme`: Basé sur `forme_recente` (0-25 scale)
- `score_victoire`: `tx_victoire` du cheval
- `score_place`: `tx_place` du cheval
- `score_rk`: Classement relatif dans la course
- `score_tendance`: Variation de la cote
- `score_avis`: Valeur textuelle convertie (POSITIF/NEUTRE/NEGATIF)
### 4.2 Limites du Modèle Actuel
1. **Pas depondération apprentissage**: Les poids sont-ils optimaux?
2. **Features statiques**: Pas de features dynamiques (météo, forme vs竞争对手)
3. **Pas de cross-validation**: Pas de validation robuste
4. **Ignorer les correlations**: Interactions entre features non capturées
5. **Pas de feature engineering**: Variables dérivées limitées
---
## 5. Améliorations Proposées
### 5.1 Court Terme (Quick Wins)
#### 5.1.1 Améliorer le Suivi des Résultats
```python
# Ajouter le remplissage automatique des résultats
def update_recommendation_results():
"""Récupérer les résultats officiels et mettre à jour recommendations"""
# 1. Scraping des résultats PMU
# 2. Matching avec recommandations
# 3. Mise à jour du champ resultat
```
#### 5.1.2 Réduire la Catégorie "Non Classé"
- Analyser pourquoi 71.8% des prédictions ont `predicted_rank = 0`
- Implémenter un seuil minimum de confiance
#### 5.1.3 Ajouter Plus de Prédictions
- Automatiser le scraping quotidien
- Cible: minimum 500 prédictions avant analyse
### 5.2 Moyen Terme (Amélioration du Modèle)
#### 5.2.1 Feature Engineering
**Nouvelles features à créer:**
```python
# Features basées sur l'historique
- forme_vs_favori = forme_recente / forme_favori_moyen
- performance_par_distance = tx_victoire_par_distance[dist]
- performance_par_discipline = tx_victoire_par_discipline[disc]
- performance_par_hippodrome = tx_victoire_par_hippodrome[hippo]
- forme_tendance = forme_3_der courses vs forme_10_dernières
# Features dynamiques
- ecart_cote = cote_directe - cote_reference
- momentum = indicateur_tendance * volume_paris
- valeur = (cote - cote_juste_estimee) / cote_juste_estimee
# Features d'interaction
- forme_x_cote = forme_recente * (1/cote)
- age_x_performance = age * tx_victoire
```
#### 5.2.2 Modèle de Machine Learning
**Approche recommandée: XGBoost avec validation croisée**
```python
# Structure de données pour ML
features_ml = [
'tx_victoire', 'tx_place', 'forme_recente',
'age', 'nb_courses', 'cote_directe',
'distance', 'nb_partants', 'est_favori',
'reduction_km', 'gains_annee'
]
target_top1 = 'top1' # 1 si cheval gagné
target_top3 = 'top3' # 1 si cheval placé top 3
# Pipeline ML
1. Séparation train/test (80/20)
2. Cross-validation 5-fold
3. Hyperparameter tuning
4. Feature importance analysis
5. Backtest sur données historiques
```
#### 5.2.3 pondérations Optimales
**Analyse des poids actuels vs optimaux:**
| Feature | Poids actuel | Importance suggèree |
|---------|-------------|---------------------|
| score_cote | 14.7 | Haute (mais non-linéaire) |
| score_forme | 25.0 | Très haute |
| score_victoire | 15.0 | Haute |
| score_place | 15.0 | Moyenne |
| score_rk | 7.0 | Basse |
| score_tendance | 9.3 | Moyenne |
| score_avis | 6.5 | Variable |
### 5.3 Long Terme (Système Avancé)
#### 5.3.1 Modèles Multi-Cibles
| Modèle | Cible | Type de pari |
|--------|-------|--------------|
| `model_top1` | top1 | Simple Gagnant |
| `model_top3` | top3 | Simple Placé |
| `model_quinte` | top5 | Couplé/Quinté |
#### 5.3.2 Système de Cotes Justes
```python
# Estimer la cote "juste" vs cote PMU
cote_juste = f(tx_victoire_model, tx_place_model, dispersion)
# Identifier les "value bets"
value = cote_PMU - cote_juste
if value > seuil:
recommander_pari()
```
#### 5.3.3 Intégration Météo (Non Implémentée)
La table `weather` existe mais contient seulement 4 lignes.
**À implémenter:**
- Scraping météo quotidien
- Impact sur performance (par course)
- Ajustement des prédictions selon conditions
---
## 6. Plan d'Action
### Phase 1: Collecte de Données (Semaine 1-2)
- [ ] Automatiser le scraping quotidien
- [ ] Compléter le remplissage des résultats
- [ ] Atteindre 500+ prédictions évaluées
### Phase 2: Analyse Exploratoire (Semaine 3)
- [ ] Analyse univariée des features
- [ ] Corrélations entre features
- [ ] Identification des patterns
### Phase 3: Modèle ML (Semaine 4-6)
- [ ] Feature engineering
- [ ] Entraînement XGBoost
- [ ] Validation croisée
- [ ] Comparaison avec modèle actuel
### Phase 4: Production (Semaine 7-8)
- [ ] Déploiement nouveau modèle
- [ ] Monitoring des performances
- [ ] Ajustements itératifs
---
## 7. KPIs à Suivre
| KPI | Cible Court Terme | Cible Long Terme |
|-----|-------------------|-------------------|
| Taux hit global | 25% | 35% |
| Précision favori | 45% | 50% |
| Précision top 3 | 40% | 55% |
| ROI recommandations | 0% | 10%+ |
| Volume predictions | 500+ | 2000+ |
---
## 8. Conclusion
Le système actuel montre des bases solides mais nécessite:
1. **Plus de données** pour une analyse statistique robuste
2. **Un suivi rigoureux** des résultats pour mesure de performance
3. **Une évolution vers le ML** pour optimiser les pondérations
Les recommandations immédiates:
1. Compléter le remplissage des résultats (0/32 actuellement)
2. Augmenter le volume de prédictions
3. Passer à un modèle XGBoost avec features enrichies
---
*Document généré le 2026-03-25*
*Source: Analyse de turf.db - turf_scraper*

223
PROJET_TURF.md Executable file
View File

@@ -0,0 +1,223 @@
# 🐾 PROJET TURF AUTOMATISÉ - DOCUMENTATION
## 📅 Date: 23 Février 2026
## Auteur: Claw (AI Assistant)
---
## 🎯 OBJECTIF
Système automatisé d'analyse et de prédiction des courses hippiques françaises pour générer des revenus passifs.
---
## 📊 ÉVOLUTIONS DU JOUR (23/02/2026)
### Améliorations majeures :
1. **Scraper Multi-Sites v4**
- 7 sites scrapés en parallèle
- Temps d'exécution: ~2 secondes
- Sources: Equidia, ZETurf, Canalturf, Boturfers, Zone-Turf, Genybet, RuedesJoueurs
2. **Base de données SQLite**
- Chemin: `/home/h3r7/turf_scraper/turf.db`
- Tables: predictions, results, performance
3. **Sauvegarde temps réel**
- Les prédictions sont sauvegardées instantanément dans la BDD
4. **Performance Tracker (REX)**
- Calcul automatique du hit rate
- Calcul du ROI
- Exemple aujourd'hui: ROI +270%
5. **Cron Jobs Automatisés**
- 09h00: Scrap + Prédictions
- 13h00: Scrap final + Cotes
- 19h00: Résultats + REX
---
## 🏗️ ARCHITECTURE DU SYSTÈME
### Composants :
```
┌─────────────────────────────────────────────┐
│ SCRAPERS (VPS) │
├─────────────────────────────────────────────┤
│ • multi_scraper_v5.py (7 sites) │
│ • horse_detail_scraper.py (fiches chevaux) │
│ • turf_db.py (gestion BDD) │
│ • performance_tracker.py (REX) │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ BASE DE DONNÉES SQLite │
│ /home/h3r7/turf_scraper/turf.db │
├─────────────────────────────────────────────┤
│ • predictions (prédictions) │
│ • results (résultats courses) │
│ • performance (tracking REX) │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ ANALYSE RUNTIME V4 │
│ /home/h3r7/Projet turf/Tutf RUNTIME V4.0 │
├─────────────────────────────────────────────┤
│ • 25 Features ML │
│ • 7 Red Flags │
│ • 11 Boosts │
│ • 10 Malus │
└─────────────────────────────────────────────┘
```
---
## 📁 FICHIERS CRÉÉS
### Sur VPS (`/home/h3r7/turf_scraper/`)
| Fichier | Description |
|---------|-------------|
| `multi_scraper_v5.py` | Scraper principal 7 sites |
| `horse_detail_scraper.py` | Fiches détaillées chevaux |
| `turf_db.py` | Gestion Base de données |
| `performance_tracker.py` | Calcul REX et ROI |
| `turf.db` | Base SQLite |
| `v5_*.json` | Exports JSON |
### Sur le projet RUNTIME V4 (`/home/h3r7/Projet turf/Tutf RUNTIME V4.0/`)
| Fichier | Description |
|---------|-------------|
| `src/ml/features.py` | Feature engineering (14 features) |
| `src/ml/predictor.py` | Modèles ML (RF + XGBoost) |
| `src/core/workflow.py` | Workflow 5 phases |
---
## 🔧 FONCTIONNALITÉS
### 1. Scraping Automatique
**Sources disponibles (7 sites) :**
- ✅ Equidia
- ✅ ZETurf
- ✅ Canalturf
- ✅ Boturfers
- ✅ Zone-Turf
- ✅ Genybet
- ✅ RuedesJoueurs
- ❌ PMU (bloqué)
- ❌ Geny.com (bloqué)
- ❌ Turfomania (bloqué)
**Données récupérées :**
- Cotes
- Pronostics presse
- Performances chevaux
- Résultats
- Pédigrée
- Entraineurs/Jockeys
### 2. Base de données
**Tables :**
```sql
-- Prédictions
CREATE TABLE predictions (
id, date, race_name, race_hippodrome, race_time,
horse_number, horse_name, odds, prediction_rank, source
);
-- Résultats
CREATE TABLE results (
id, date, race_name, race_hippodrome,
position, horse_name, odds
);
-- Performance
CREATE TABLE performance (
id, prediction_date, race_date, horse_name,
predicted_rank, actual_position, hit
);
```
### 3. Analyse (RUNTIME V4)
**Architecture en 5 phases :**
| Phase | Description |
|-------|-------------|
| 1. Collecte | Scraping multi-sources |
| 2. Analyse | Forme, Conditions, Humain, H2H |
| 3. Modélisation | ML Scoring + Value |
| 4. Décision | Sélection + Paris |
| 5. Suivi | ROI + Amélioration |
**Features (14) :**
- Cote, Forme, Jockey win rate, Entraineur win rate
- Distance avg, Victoires, Courses, Taux victoire
**Red Flags (7) :**
- Absence musique
- Distance jamais courue
- Hippodrome inconnu
- Ferrure Da/Dm
- Jockey débutant
- Entraineur faible
- Longue absence
### 4. Automatisation (Cron Jobs)
| Job | Heure | Action |
|-----|-------|--------|
| Turf Morning v5 | 09:00 | Scrap + Prédictions |
| Turf Afternoon v5 | 13:00 | Scrap final |
| Turf Results | 19:00 | Résultats + REX |
---
## 📈 RÉSULTATS
### Exemple du 23/02/2026 (Prix Rose Laurel)
**Prédictions :**
1. EMSILORD (rank 1)
2. PASSIONATA (rank 2)
3. GABISON (rank 3)
**Résultats :**
- PASSIONATA: 1er ✅
- GABISON: 2e ✅
- EMSILORD: 5e
**ROI: +270%** 🎉
---
## 🔄 PROCHAINES ÉTAPES
1. ✅ Scrap 7 sites
2. ✅ BDD SQLite
3. ✅ Cron jobs
4. ⏳ Connecter scraper → RUNTIME V4 ML
5. ⏳ Automatiser les paris
6. ⏳ Améliorer parsing prédictions
---
## 💰 COÛTS
| Service | Prix |
|---------|------|
| minimax-free | 0€ |
| VPS Contabo | 13.4€/mois |
| **Total** | **13.4€/mois** |
---
*Document généré le 23/02/2026 par Claw*

473
PROJET_TURF_COMPLET.md Executable file
View File

@@ -0,0 +1,473 @@
# 🐾 PROJET TURF AUTOMATISÉ - DOCUMENTATION COMPLÈTE
## 📅 Date: 23 Février 2026
## Version: 1.0
## Auteur: Claw (AI Assistant)
---
## 🎯 OBJECTIF DU PROJET
Système automatisé d'analyse et de prédiction des courses hippiques françaises utilisant:
- Scraping multi-sources
- Intelligence Artificielle (RUNTIME V4)
- Base de données pour historique
- Calcul automatique de performance (ROI)
**But final:** Générer des revenus passifs pour payer les abonnements (VPS + LLM)
---
## 📊 ÉVOLutions DU JOUR
### 23/02/2026 - Session de travail
| # | Amélioration | Status |
|---|--------------|--------|
| 1 | Scrap 7 sites (Equidia, ZETurf, Canalturf...) | ✅ |
| 2 | Base SQLite pour historique | ✅ |
| 3 | Sauvegarde temps réel prédictions | ✅ |
| 4 | Performance tracker (REX) | ✅ |
| 5 | Documentation complète | ✅ |
---
## 🏗️ ARCHITECTURE TECHNIQUE
### Vue d'ensemble
```
┌──────────────────────────────────────────────────────────────┐
│ SERVEUR VPS │
│ 178.18.250.53 │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ SCRAPERS │ │ BDD │ │ RUNTIME │ │
│ │ Python │ │ SQLite │ │ V4 │ │
│ │ (Parallel)│ │ │ │ (ML) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └───────────────────┼───────────────────┘ │
│ ↓ │
│ ┌────────────────┐ │
│ │ CRON JOBS │ │
│ │ (Auto) │ │
│ └────────────────┘ │
│ ↓ │
│ ┌────────────────┐ │
│ │ TELEGRAM │ │
│ │ (Alerts) │ │
│ └────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
┌────────────────┐
│ OPENCLAW │
│ (Main Agent) │
└────────────────┘
```
---
## 🔧 COMPOSANTS DÉTAILLÉS
### 1. SCRAPERS
#### 1.1 multi_scraper_v5.py
**Fonction:** Scrape 7 sites en parallèle
**Sites supportés:**
```python
SITES = {
'equidia': ['https://www.equidia.fr/courses', ...],
'zeturf': ['https://www.zeturf.fr/...'],
'canalturf': ['https://www.canalturf.com/...'],
'boturfers': ['https://www.boturfers.fr/...'],
'zone-turf': ['https://www.zone-turf.fr/...'],
'genybet': ['https://www.genybet.fr/...'],
'ruedesjoueurs': ['https://www.ruedesjoueurs.com/...']
}
```
**Technique:**
- ThreadPoolExecutor (10 workers)
- Timeout: 12 secondes
- User-Agent aléatoire
**Sortie:**
- JSON: `/home/h3r7/turf_scraper/v5_*.json`
- SQLite: Table `predictions`
---
#### 1.2 horse_detail_scraper.py
**Fonction:** Récupère les détails de chaque cheval
**Données collectées:**
- Nom, âge, sexe
- Père, Mère (pédigrée)
- Entraineur
- Côte
- Performances récentes
- Gains
- Forme (music)
**Exemple de données:**
```json
{
"horse_name": "PASSIONATA",
"sex_age": "F4",
"father": "GREAT PRETENDER",
"mother": "ELLEN DES MOTTES",
"trainer": "D.BRESSOU",
"cote": 8.1,
"recent_performances": [
"2ème 07/01/2026 - 5/1 PAU",
"2ème 06/12/2025 - 9.8/1 ANGERS"
],
"wins": 0,
"placed": 6,
"total_races": 7,
"earnings": "29745€"
}
```
---
### 2. BASE DE DONNÉES
#### 2.1 Emplacement
`/home/h3r7/turf_scraper/turf.db`
#### 2.2 Schéma
```sql
-- Table des prédictions
CREATE TABLE predictions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL, -- Date course (YYYY-MM-DD)
race_name TEXT, -- Nom course (ex: "Prix Rose Laurel")
race_hippodrome TEXT, -- Hippodrome (ex: "Auteuil")
race_time TEXT, -- Heure départ (ex: "13:55")
horse_number INTEGER, -- Numéro cheval
horse_name TEXT, -- Nom cheval
odds REAL, -- Côte PMU
prediction_rank INTEGER, -- Rang prédiction (1, 2, 3)
source TEXT, -- Source (ex: "Canalturf")
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Table des résultats
CREATE TABLE results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
race_name TEXT,
race_hippodrome TEXT,
position INTEGER, -- Position arrivée (1-5)
horse_name TEXT,
odds REAL, -- Côte rapport
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Table de performance (REX)
CREATE TABLE performance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
prediction_date TEXT, -- Date prédiction
race_date TEXT, -- Date course
horse_name TEXT,
predicted_rank INTEGER, -- Prédiction (1-3)
actual_position INTEGER, -- Résultat réel
hit BOOLEAN, -- TRUE si exact
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
#### 2.3 Requêtes utiles
```python
# Voir prédictions du jour
SELECT * FROM predictions WHERE date = '2026-02-23';
# Voir résultats
SELECT * FROM results WHERE date = '2026-02-23' ORDER BY position;
# Calculer hit rate
SELECT
COUNT(*) as total,
SUM(CASE WHEN hit = 1 THEN 1 ELSE 0 END) as hits,
ROUND(CAST(SUM(CASE WHEN hit = 1 THEN 1 ELSE 0 END) AS FLOAT) / COUNT(*) * 100, 1) as hit_rate
FROM performance;
# Calculer ROI
SELECT SUM(stake) as total_stake, SUM(return) as total_return
FROM performance;
```
---
### 3. PERFORMANCE TRACKER
#### 3.1 Fonction
Le tracker calcule:
- Hit rate (% de bonnes prédictions)
- ROI (Return On Investment)
- Statistiques détaillées
#### 3.2 Exemple de calcul
```
Stake: 2€ par pari × 3 paris = 6€
Résultats:
- PASSIONATA (rank 2): 1er → Return 16.2€ (gagnant 8.1/1)
- GABISON (rank 3): 2e → Return 6€ (placé)
- EMSILORD (rank 1): 5e → Perdu
Calcul:
- Stake: 6€
- Return: 16.20€
- Profit: +10.20€
- ROI: +170%
```
---
### 4. RUNTIME V4 (ML)
#### 4.1 Architecture ML
**Source:** `/home/h3r7/Projet turf/Tutf RUNTIME V4.0/`
**Modèles utilisés:**
- Random Forest
- XGBoost
- LSTM (optionnel)
#### 4.2 Features (14)
| Feature | Description |
|---------|-------------|
| `cote` | Côte PMU (normalisée: 1/(1+odds)) |
| `music_score` | Score de forme basé sur les performances |
| `jockey_win_rate` | Taux de victoire jockey (0-1) |
| `trainer_win_rate` | Taux de victoire entraineur (0-1) |
| `weighted_win_rate` | (victoires + 0.5×places) / courses |
| `days_since_last_race` | Jours depuis dernière course |
| `races_at_distance` | Nb courses à cette distance |
| `races_at_hippodrome` | Nb courses à cet hippodrome |
| `form_trend` | Tendance forme (amélioration/stable/dégradation) |
| `horse_age` | Age cheval |
| `weight_carried` | Poids porté |
| `draw` | Numéro de départ |
| `prize_money` | Gains totaux |
| `consistency` | Régularité |
#### 4.3 Red Flags (7)
| # | Condition | Impact |
|---|----------|--------|
| RF1 | `len(music) < 3` | Confiance × 0.85 |
| RF2 | `races_at_distance == 0` | Confiance × 0.90 |
| RF3 | `races_at_hippodrome == 0` | Confiance × 0.92 |
| RF4 | `ferrure in ['Da','Dm']` + montagne | Confiance × 0.92 |
| RF5 | `jockey_win_rate < 0.05` | Confiance × 0.88 |
| RF6 | `trainer_win_rate < 0.08` | Confiance × 0.90 |
| RF7 | `days_since_last_race > 180` | Confiance × 0.85 |
#### 4.4 Boosts (11)
| Boost | Condition | Bonus |
|-------|-----------|-------|
| B1 | Côte < 5/1 | +10% |
| B2 | 3 dernières courses 1er/2e | +15% |
| B3 | Gagnant à cet hippodrome | +10% |
| B4 | Jockey top 10 | +8% |
| B5 | Entraineur top 10 | +8% |
| B6 | Récent winner (7 jours) | +12% |
| B7 | Distance favorite | +10% |
| B8 | Absent mais bien classé avant | +5% |
| B9 | Déjanté dans les 3 premiers | +10% |
| B10 | Ferrure avantageuse | +5% |
| B11 | Valeur détectée (cote > réelle) | +20% |
#### 4.5 Malus (10)
| Malus | Condition | Impact |
|-------|-----------|--------|
| M1 | Côte > 30/1 | -15% |
| M2 | Plus de 3 courses sans podium | -10% |
| M3 | Jamais gagné à cet hippodrome | -8% |
| M4 | Jockey debutant (<5 courses) | -10% |
| M5 | Mauvaise distance | -12% |
| M6 | Longue absence (>90 jours) | -15% |
| M7 | Chevaux engagés récemment | -5% |
| M8 | Mauvaise musique recente | -10% |
| M9 | Mauvais terrain | -8% |
| M10 | Charge lourde (>70kg) | -10% |
#### 4.6 Workflow (5 Phases)
```
┌─────────────────────────────────────────────────────────────┐
│ PHASE 1: COLLECTE │
│ • Scraping PMU, Geny, CanalTurf │
│ • Validation données │
└──────────────────────────┬──────────────────────────────────┘
┌──────────────────────────┴──────────────────────────────────┐
│ PHASE 2: ANALYSE │
│ • Analyse forme (FormAnalyst) │
│ • Analyse conditions (ConditionsAnalyst) │
│ • Facteur humain (HumanFactorAnalyst) │
│ • Tête-à-tête (H2HAnalyst) │
└──────────────────────────┬──────────────────────────────────┘
┌──────────────────────────┴──────────────────────────────────┐
│ PHASE 3: MODÉLISATION │
│ • ML Scoring (RF + XGBoost) │
│ • Value Detection │
└──────────────────────────┬──────────────────────────────────┘
┌──────────────────────────┴──────────────────────────────────┐
│ PHASE 4: DÉCISION │
│ • Construction sélection │
│ • Optimisation paris │
└──────────────────────────┬──────────────────────────────────┘
┌──────────────────────────┴──────────────────────────────────┐
│ PHASE 5: SUIVI │
│ • Tracking résultats │
│ • Calcul ROI │
│ • Amélioration modèle │
└─────────────────────────────────────────────────────────────┘
```
---
### 5. CRON JOBS
| Job ID | Nom | Heure | Action |
|--------|-----|-------|--------|
| 1 | Turf Morning v5 | 09:00 | Scrap 7 sites → BDD → 3 picks |
| 2 | Turf Afternoon v5 | 13:00 | Scrap final → BDD → cotes |
| 3 | Turf Results | 19:00 | Scrape résultats → REX → ROI |
---
## 📁 STRUCTURE DES FICHIERS
### Sur VPS (`/home/h3r7/`)
```
/home/h3r7/
├── turf_scraper/
│ ├── multi_scraper_v5.py # Scraper principal
│ ├── horse_detail_scraper.py # Fiches chevaux
│ ├── turf_db.py # Gestion BDD
│ ├── performance_tracker.py # Calcul REX
│ ├── turf.db # Base SQLite
│ ├── PROJET_TURF.md # Documentation
│ ├── v5_*.json # Exports JSON
│ └── horses_*.json # Fiches chevaux
└── Projet turf/
└── Tutf RUNTIME V4.0/
├── src/
│ ├── ml/
│ │ ├── features.py # Feature engineering
│ │ ├── predictor.py # Modèles ML
│ │ └── trainer.py # Entraînement
│ ├── core/
│ │ ├── workflow.py # Workflow 5 phases
│ │ ├── orchestrator.py # Orchestration
│ │ └── config.py # Configuration
│ └── scraper/
│ └── ...
└── config.yaml
```
---
## 💰 COÛTS
| Service | Prix | Fréquence |
|---------|------|-----------|
| minimax-free | 0€ | Illimité |
| VPS Contabo | 13.4€/mois | Mensuel |
| **Total** | **13.4€/mois** | - |
---
## 🔄 UTILISATION
### Lancer le scraper manuellement
```bash
ssh h3r7@178.18.250.53
cd /home/h3r7/turf_scraper
python3 multi_scraper_v5.py
```
### Voir les prédictions
```bash
sqlite3 turf.db "SELECT * FROM predictions;"
```
### Calculer le REX
```bash
python3 performance_tracker.py
```
---
## 📈 RÉSULTATS OBTENUS
### 23/02/2026 - Prix Rose Laurel (Auteuil)
| Prédiction | Résultat | Côte | Status |
|------------|----------|------|--------|
| EMSILORD (1) | 5e | 5/1 | ❌ |
| PASSIONATA (2) | 1er | 8.1/1 | ✅ |
| GABISON (3) | 2e | 12/1 | ✅ |
**Hit Rate:** 66% (2/3 dans le top 3)
**ROI:** +270%
---
## 🚀 PROCHAINES ÉTAPES
1. **Court terme:**
- [ ] Améliorer le parsing des prédictions
- [ ] Connecter scraper → RUNTIME V4 ML
- [ ] Automatiser extraction des cotes
2. **Moyen terme:**
- [ ] Automatiser les paris (API PMU)
- [ ] Améliorer les features ML
- [ ] Ajouter plus de sources
3. **Long terme:**
- [ ] Système de paris automatique
- [ ] Apprentissage automatique (feedback loop)
- [ ] Scaling multi-comptes
---
## 📞 SUPPORT
Pour toute question, consulter:
- Documentation: `/home/h3r7/turf_scraper/PROJET_TURF.md`
- Code source: `/home/h3r7/turf_scraper/`
- RUNTIME V4: `/home/h3r7/Projet turf/Tutf RUNTIME V4.0/`
---
*Document généré automatiquement le 23/02/2026 par Claw*
*Version: 1.0*

160
SCRIPTS_DEMARCHAGE.html Executable file
View File

@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Scripts de Démarchage - H3R7</title>
<style>
body { font-family: Arial, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; line-height: 1.6; }
h1 { color: #2c3e50; border-bottom: 3px solid #3498db; padding-bottom: 10px; }
h2 { color: #2980b9; margin-top: 40px; }
h3 { color: #16a085; }
.script-box { background: #ecf0f1; padding: 20px; border-radius: 10px; margin: 15px 0; border-left: 5px solid #3498db; }
.script-box h3 { margin-top: 0; }
.step { background: #fff; padding: 10px 15px; margin: 5px 0; border-radius: 5px; }
.step-num { background: #3498db; color: white; padding: 2px 8px; border-radius: 50%; margin-right: 10px; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
th { background: #3498db; color: white; }
ul { padding-left: 20px; }
li { margin: 8px 0; }
.email { background: #2c3e50; color: #ecf0f1; padding: 15px; border-radius: 8px; font-family: monospace; white-space: pre-wrap; }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<div style="text-align:center;padding:15px;background:#1a1a2e;">
<img src="H3R7Tech_logo.png" alt="H3R7Tech" style="width:120px;border-radius:10px;">
</div>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<h1>📞 Scripts de Démarchage - H3R7Tech Pro</h1>
<h2>🎯 Principes de Base</h2>
<div class="script-box">
<h3>La Règle des 3 "C"</h3>
<ol>
<li><strong>Clarté</strong> : Message simple et direct</li>
<li><strong>Concis</strong> : En 30 secondes maximum</li>
<li><strong>Convaincant</strong> : Proposer une solution</li>
</ol>
</div>
<div class="script-box">
<h3>Structure d'un Appel Réussi</h3>
<ol>
<li><span class="step-num">1</span> <strong>Accroche</strong> (5 sec) : Qui je suis + pourquoi j'appelle</li>
<li><span class="step-num">2</span> <strong>Question découverte</strong> (10 sec) : Comprendre le besoin</li>
<li><span class="step-num">3</span> <strong>Proposition</strong> (10 sec) : La solution + bénéfices</li>
<li><span class="step-num">4</span> <strong>Closing</strong> (5 sec) : Proposer un rendez-vous</li>
</ol>
</div>
<h2>🏪 Script - Artisans</h2>
<div class="script-box">
<h3>🎬 Accroche</h3>
<p><em>"Bonjour [Prénom], je m'appelle [MonNom] de H3R7Tech. J'ai un petit moment ?"</em></p>
<h3>🔍 Découverte</h3>
<p><em>"Je vous appelle car nous aidons les artisans comme vous à trouver de nouveaux clients sur votre secteur. Actuellement, comment trouvez-vous vos nouveaux chantiers ?"</em></p>
<h3>💡 Proposition</h3>
<p><em>"Nous proposons un service de incontournabilité digitale : site web professionnel, prospection ciblée, et gestion de votre présence en ligne. Vous recevez en moyenne [X] appels par mois de clients qui vous cherchent sur Google ?"</em></p>
<h3>🔚 Closing</h3>
<p><em>"Would you be interested in a quick 15-minute call this week to see how we could help you get more customers? Je peux vous proposer [jour] ou [jour], quel moment vous convient ?"</em></p>
</div>
<h2>🥖 Script - Boulangers / Commerces</h2>
<div class="script-box">
<h3>🎬 Accroche</h3>
<p><em>"Bonjour [Prénom], de la part de H3R7Tech. Comment allez-vous aujourd'hui ?"</em></p>
<h3>🔍 Découverte</h3>
<p><em>"Je vous appelle car nous aidons les boulangeries et commerces de proximité à se faire connaître sur internet. Avez-vous un site web actuellement ?"</em></p>
<h3>💡 Proposition</h3>
<p><em>"Nous proposons un site vitrine élégant pour présenter vos produits, vos horaires, et surtout permettre aux clients de vous trouver sur Google. Avec notre formule, vous n'avez rien à gérer, on s'occupe de tout."</em></p>
<h3>🔚 Closing</h3>
<p><em>"Puis-je vous envoyer un exemple par email ? Comme ça vous voyez ce que ça donnerait pour votre boulangerie. Quelle est votre meilleure adresse email ?"</em></p>
</div>
<h2>📧 Scripts - Emails de Prospection</h2>
<h3>Email #1 - Premier Contact</h3>
<div class="email">Objet: Quick question for [Company]
Bonjour [Prénom],
Je m'appelle [MonNom] et je travaille avec H3R7Tech.
Je regardais votre site web [URL] et je voulais vous poser une question rapide :
Avez-vous du mal à trouver de nouveaux clients qualifiés dans votre secteur ?
Si oui, je pense pouvoir vous aider. Nous aidons les professionnels comme vous à être plus visibles en ligne et à automatiser leur prospection.
Avez-vous 2 minutes pour en discuter ?
Cordialement,
[MonNom]
H3R7Tech
Tel: [MonTel]</div>
<h3>Email #2 - Suite</h3>
<div class="email">Objet: Following up on my previous email
Bonjour [Prénom],
Je reviens vers vous suite à mon dernier email car je sais que les artisans/entrepreneurs sont très occupés.
En quelques mots, nous aidons les professionnels à :
✓ Avoir un site web professionnel
✓ Être trouvé sur Google
✓ Automatiser la prospection
Si ça vous intéresse, je suis disponible pour un appel rapide.
Cordialement,
[MonNom]</div>
<h2>📞 Réponses aux Objections</h2>
<table>
<tr><th>Objection</th><th>Réponse</th></tr>
<tr><td>"Je n'ai pas le temps"</td><td>"Je comprends totalement, c'est pourquoi je vous propose un appel de seulement 10 minutes, pendant lequel je ne vous venderai rien."</td></tr>
<tr><td>"C'est trop cher"</td><td>"Notre formule starts à [prix] par mois, et c'est beaucoup moins cher qu'un salarié à temps plein."</td></tr>
<tr><td>"Je ne suis pas interesado"</td><td>"Je respecte ça. Et si je vous disais que c'est gratuit et sans engagement ?"</td></tr>
<tr><td>"J'ai déjà un prestataire"</td><td>"Excellent, ça veut dire que vous êtes conscient de l'importance du digital. Ce que je propose, c'est une seconde opinion gratuite."</td></tr>
</table>
<h2>📋 Checklist - Avant Appel</h2>
<ul>
<li>[ ] Rechercher l'entreprise sur Google</li>
<li>[ ] Visiter leur site web</li>
<li>[ ] Regarder leurs réseaux sociaux</li>
<li>[ ] Préparer 3 points clés à mentionner</li>
<li>[ ] Avoir le CRM ouvert pour prendre des notes</li>
<li>[ ] Sourire avant d'appeler (ça s'entend !)</li>
</ul>
<h2>📊 Suivi des Appels</h2>
<table>
<tr><th>Date</th><th>Entreprise</th><th>Contact</th><th>Score</th><th>Actions Suivantes</th></tr>
<tr><td>25/02</td><td>Boulangerie Dupont</td><td>Jean Dupont</td><td>⭐⭐⭐</td><td>Rappeler semaine pro</td></tr>
<tr><td>25/02</td><td>EcoServices</td><td>Marie Martin</td><td>⭐⭐</td><td>Envoyé email suivi</td></tr>
<tr><td>25/02</td><td>Artisans Rhône</td><td>-</td><td></td><td>Pas répondu</td></tr>
</table>
<hr>
<p style="text-align: center; color: #7f8c8d;"><em>Document généré le 25/02/2026 - H3R7Tech Pro</em></p>
</body>
</html>

178
SCRIPTS_DEMARCHAGE.md Executable file
View File

@@ -0,0 +1,178 @@
# 📞 Scripts de Démarchage - H3R7Tech Pro
## 🎯 Principes de Base
### La Règle des 3 "C"
1. **Clarté** : Message simple et direct
2. **Concis** : En 30 secondes maximum
3. **Convaincant** : Proposer une solution
### Structure d'un Appel Réussi
```
1. Accroche (5 sec) : Qui je suis + pourquoi j'appelle
2. Question découverte (10 sec) : Comprendre le besoin
3. Proposition (10 sec) : La solution + bénéfices
4. Closing (5 sec) : Proposer un rendez-vous
```
---
## 🏪 Script - Artisans (Plombier, Électricien, Macon)
### 🎬 Accroche
> "Bonjour [Prénom], je m'appelle [MonNom] de H3R7Tech. J'ai unpettit moment ?"
### 🔍 Découverte
> "Je vous appelle car nous aidons les artisans comme vous à trouver de nouveaux clients sur votre secteur. Actuellement, comment trouvez-vous vos nouveaux chantiers ?"
### 💡 Proposition
> "Nous proposons un service de incontournabilité digitale : site web professionnel, prospection ciblée, et gestion de votre présence en ligne. Vous recevez en moyenne [X] appels par mois de clients qui vous cherchent sur Google ?"
### 🔚 Closing
> "Would you be interested in a quick 15-minute call this week to see how we could help you get more customers? Je peux vous proposer [jour] ou [jour], quel moment vous convient ?"
---
## 🥖 Script - Boulangers / Commerces Alimentaires
### 🎬 Accroche
> "Bonjour [Prénom], de la part de H3R7Tech. Comment allez-vous aujourd'hui ?"
### 🔍 Découverte
> "Je vous appelle car nous aidons les boulangeries et commerces de proximité à se faire connaître sur internet. Avez-vous un site web actuellement ?"
### 💡 Proposition
> "Nous proposons un site vitrine élégant pour présenter vos produits, vos horaires, et surtout permettre aux clients de vous trouver sur Google. Avec notre formule, vous n'avez rien à gérer, on s'occupe de tout."
### 🔚 Closing
> "Puis-je vous envoyer un exemple par email ? Comme ça vous voyez ce que ça donnerait pour votre boulangerie. Quelle est votre meilleure adresse email ?"
---
## 📊 Script - Professionnels du Marketing
### 🎬 Accroche
> "Bonjour [Prénom], j'ai vu que vous proposiez des services de marketing. Nous, c'est H3R7, on a une expertise en scraping et collecte de données."
### 🔍 Découverte
> "Currently, how do you source your leads and prospect data? Est-ce que vous avez des besoins de veille concurrentielle ou de données qualifiés ?"
### 💡 Proposition
> "Nous avons des outils de scraping puissants qui peuvent extraire des annuaires professionnels, des bases de données sectorielles, ou même la veille tarifaire de vos concurrents. Ça vous permettrait d'avoir des leads qualifiés automatiquement."
### 🔚 Closing
> "Je peux vous faire une démo gratuite de 15 minutes pour vous montrer ce qu'on peut extraire pour votre secteur. On schedul ça pour la semaine prochaine ?"
---
## 📧 Scripts - Emails de Prospection
### Email #1 - Premier Contact (Objet: Quick question for [Company])
```
Objet: Quick question for [Company Name]
Bonjour [Prénom],
Je m'appelle [MonNom] et je travaille avec H3R7Tech.
Je regardais votre site web [URL] et je voulais vous poser une question rapide :
Avez-vous du mal à trouver de nouveaux clients qualifiés dans votre secteur ?
Si oui, je pense pouvoir vous aider. Nous aidons les professionnels comme vous à être plus visibles en ligne et à automatiser leur prospection.
Avez-vous 2 minutes pour en discuter ?
Cordialement,
[MonNom]
H3R7Tech
Tel: [MonTel]
```
### Email #2 - Suite (Objet: Following up on my previous email)
```
Objet: Following up - [Company Name]
Bonjour [Prenom],
Je reviens vers vous suite à mon dernier email car je sais que les artisans/entrepreneurs sont très occupés.
En quelques mots, nous aidons les professionnels à :
✓ Avoir un site web professionnel
✓ Être trouvé sur Google
✓ Automatiser la prospection
Si ça vous intéresse, je suis disponible pour un appel rapide.
Sinon, n'hésitez pas à me répondre pour me dire si ce n'est pas d'actualité.
Cordialement,
[MonNom]
```
### Email #3 - Demande de Rendez-vous (Objet: Call to discuss your marketing)
```
Objet: 15 min call to discuss [Company] growth?
Bonjour [Prénom],
Pour faire simple : je pense pouvoir vous aider à obtenir plus de clients.
Nous avons aidé [X] professionnels cette année à :
- Augmenter leur visibilité en ligne
- Automatiser leur prospection
- Gagner du temps sur leur administratif
Est-ce que 15 minutes cette semaine seraient possibles pour en discuter ?
- [Jour1] à [Heure1]
- [Jour2] à [Heure2]
Sinon, cliquez ici pour réserver votre créneau : [Lien Calendly]
À bientôt,
[MonNom]
```
---
## 📞 Scripts - Réponses aux Objections
### Objection: "Je n'ai pas le temps"
> "Je comprends totalement, c'est pourquoi je vous propose un appel de seulement 10 minutes, pendant lequel je ne vous venderai rien. Je vous pose juste quelques questions pour voir si je peux vous aider. Ça vous va ?"
### Objection: "C'est trop cher"
> "Je comprends. Justement, notre formule starts à [prix] par mois, et c'est beaucoup moins cher qu'un salarié à temps plein. Et surtout, ça vous apporte des clients directement."
### Objection: "Je ne suis pas intéressé"
> "Je respecte ça. Et si je vous disais que c'est gratuit et sans engagement, vous seriez curieux de voir ce qu'on peut faire pour vous ?"
### Objection: "J'ai déjà un prestataire"
> "Excellent, ça veut dire que vous êtes conscient de l'importance du digital. Ce que je vous propose, c'est une seconde opinion gratuite. Comme ça vous avez toutes les cartes en main pour comparer."
---
## 📋 Checklist - Avant Appel
- [ ] Rechercher l'entreprise sur Google
- [ ] Visiter leur site web
- [ ] Regarder leurs réseaux sociaux
- [ ] Préparer 3 points clés à mentionner
- [ ] Avoir le CRM ouvert pour prendre des notes
- [ ] Sourire avant d'appeler (ça s'entend !)
---
## 📊 Suivi des Appels
| Date | Entreprise | Contact | Score | Actions Suivantes |
|------|------------|---------|-------|-------------------|
| 25/02 | Boulangerie Dupont | Jean Dupont | ⭐⭐⭐ | Rappeler semaine pro |
| 25/02 | EcoServices | Marie Martin | ⭐⭐ | Envoyé email suivi |
| 25/02 | Artisans Rhône | - | ⭐ | Pas répondu |
---
*Document généré le 25/02/2026 - H3R7Tech Pro*

Binary file not shown.

172
SUB_AGENTS.md Executable file
View File

@@ -0,0 +1,172 @@
# 🤖 Sub-Agents H3R7Tech - Documentation
## Vue d'Ensemble
Les sub-agents sont des sessions automatisées qui peuvent exécuter des tâches en arrière-plan. Voici l'organisation proposée :
```
┌─────────────────────┐
│ 🌟 H3R7Tech │
│ (Main Session) │
└──────────┬──────────┘
┌──────────────────────┼──────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 🤖 AgentScraper │ │ 🤖 AgentSales │ │ 🤖 AgentMailing │
│ (Sessions) │ │ (Sessions) │ │ (Sessions) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
---
## 🎭 Rôles des Sub-Agents
### 1. 🤖 AgentScraper
**Purpose:** Collecte automatique de données prospects
**Sessions spawnables :**
- `scraper_annuaires` - Extraction PagesJaunes, Mistersuite
- `scraper_concurrents` - Veille tarifaire
- `scraper_social` - Données réseaux sociaux
**Commandes :**
```bash
# Lancer un scraping
sessions_spawn(task="Scraper les plombiers sur Paris", agentId="scraper")
# Via cron
cron add --schedule "0 9 * * *" --task "Scraper annuaires" --session scraper
```
---
### 2. 🤖 AgentSales
**Purpose:** Démarchage automatique et suivi
**Sessions spawnables :**
- `sales_outbound` - Appels sortants
- `sales_follow` - Relances
- `sales_closing` - Fermeture des ventes
**Commandes :**
```bash
# Lancer prospection
sessions_spawn(task="Appeler les 10 premiers prospects du CRM", agentId="sales")
# Follow-up automatique
cron add --schedule "0 10 * * *" --task "Relance prospects" --session sales
```
---
### 3. 🤖 AgentMailing
**Purpose:** Campagnes email automatisées
**Sessions spawnables :**
- `mailing_sequence` - Séquences de prospection
- `mailing_follow` - Relances email
- `mailing_report` - Rapports Campaign
**Commandes :**
```bash
# Envoyer séquence
sessions_spawn(task="Envoyer séquence welcome aux nouveaux prospects", agentId="mailing")
# Rapport quotidien
cron add --schedule "0 8 * * *" --task "Rapport emailing" --session mailing
```
---
### 4. 🤖 AgentSupport
**Purpose:** Relation client et SAV
**Sessions spawnables :**
- `support_responses` - Réponses automatiques
- `support_tickets` - Gestion incidents
---
### 5. 🤖 AgentAnalyst
**Purpose:** Analyses et rapports
**Sessions spawnables :**
- `analyst_stats` - KPIs quotidiens
- `analyst_forecast` - Prédictions business
---
## ⚙️ Configuration des Agents
### Fichier de config agents (agents.json) :
```json
{
"agents": {
"scraper": {
"description": "Collecte de données prospects",
"model": "minimax-m2.1-free",
"system_prompt": "Tu es un expert en scraping et collecte de données. Ta mission est de trouver des prospects qualifiés pour H3R7Tech."
},
"sales": {
"description": "Démarchage et prospection",
"model": "minimax-m2.1-free",
"system_prompt": "Tu es un expert en vente B2B. Tu sais persuasive et clos des deals. Tu travailles pour H3R7Tech."
},
"mailing": {
"description": "Campagnes email",
"model": "minimax-m2.1-free",
"system_prompt": "Tu es un expert en email marketing. Tu sais écrire des emails qui convertissent. Tu travailles pour H3R7Tech."
}
}
}
```
---
## 📅 Planification (Cron Jobs)
| Heure | Agent | Tâche | Fréquence |
|-------|-------|-------|-----------|
| 08:00 | Mailing | Rapport quotidien | Quotidien |
| 09:00 | Scraper | Nouveaux prospects | Quotidien |
| 10:00 | Sales | Relances | Quotidien |
| 14:00 | Sales | Appels sortants | Quotidien |
| 18:00 | Analyst | Bilan quotidien | Quotidien |
---
## 🚀 Utilisation
### Lancer un agent manuellement :
```python
sessions_spawn(
agentId="scraper",
task="Scraper les artisans boulangeries sur Lyon",
label="scraper_lyon"
)
```
### Suivre les sessions :
```bash
sessions_list()
```
### Historique :
```bash
sessions_history(sessionKey="scraper_lyon")
```
---
## 🔐 Sécurité
- **Ne jamais** donner accès aux données clients outside
- **Toujours** valider avant envoi externe
- **Journaliser** toutes les actions
---
*Document généré le 25/02/2026 - H3R7Tech*

127
TUTORIEL_APIFY.md Executable file
View File

@@ -0,0 +1,127 @@
# 🎬 Tutoriel Apify - Extraction Artisans
## Qu'est-ce qu'Apify ?
**Apify** est une plateforme cloud de scraping qui:
- ✅ Contourne les blocages (Cloudflare, anti-bot)
- ✅ Navigateurs headless intégrés
- ✅ Proxy automatique
- ✅ 5$/mois gratuit
---
## Étape 1 : Inscription
1. Aller sur **https://apify.com**
2. Cliquer **"Sign up free"**
3. S'inscrire avec **Google** ou **Email**
4. Valider l'email
---
## Étape 2 : Rechercher un Actor
1. Dans le dashboard, aller sur **"Store"**
2. Rechercher : `google-maps` ou `pagesjaunes`
3. Choisir un actor gratuit
**Recommandés :**
- `quick-reviews-scraper` - Avis Google
- `google-places-scraper` - Places Google
- `web-scraper` - Général
---
## Étape 3 : Configurer le Scraper
Exemple avec **Google Maps Scraper** :
```
URL à scrapper :
https://www.google.com/maps/search/cordonnier/@50.6276,3.0535,13z/data=!3m1!4b1
Paramètres :
- maxResults: 50
- language: fr
- country: fr
```
---
## Étape 4 : Lancer le Scraping
1. Cliquer **"Run"**
2. Patienter (1-5 min)
3. Voir la progression dans **"Run"**
---
## Étape 5 : Exporter les Données
1. Aller dans **"Dataset"**
2. Cliquer **"Export"**
3. Choisir **CSV** ou **JSON**
4. Télécharger
---
## Alternative : Créer son propre Actor
### Code minimal pour Google Maps :
```javascript
// actor.js
const { Actor } = require('apify');
const Apify = require('apify');
Apify.main(async () => {
const input = await Apify.getInput();
const { search, maxResults } = input;
const browser = await Apify.launchPuppeteer();
const page = await browser.newPage();
const url = `https://www.google.com/maps/search/${search}`;
await page.goto(url, { waitUntil: 'networkidle0' });
// Extraire les données
const results = await page.evaluate(() => {
// ... selecteurs CSS
});
await Apify.pushData(results);
await browser.close();
});
```
---
## Intégration CRM
Une fois les données exportées :
1. **Télécharger** le CSV depuis Apify
2. **Aller** sur http://178.18.250.53:8770/
3. **Importer** les données manuellement
---
## 💰 Coûts
| Action | Coût |
|--------|-------|
| Inscription | Gratuit |
| 5$/mois | 1000 requêtes |
| Actor personnalisé | Gratuit |
---
## 🔗 Liens Utiles
- **Dashboard** : https://console.apify.com
- **Store** : https://console.apify.com/store
- **Docs** : https://docs.apify.com
---
*Document généré le 25/02/2026 - H3R7Tech*

188
WORKFLOW_SCRAPING.md Executable file
View File

@@ -0,0 +1,188 @@
# 🏢 H3R7Tech - Workflow Extraction Artisans
## 📋 Résumé
Ce workflow permet d'extraire les coordonnées des artisans d'une ville précise depuis les annuaires professionnels et de les intégrer dans un CRM.
---
## 🏗️ Architecture de la Solution
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ EXTRACTION │────▶│ STOCKAGE │────▶│ CRM │
│ │ │ │ │ │
│ • Pages Jaunes │ │ • Fichier JSON │ │ • Suivi statut │
│ • Google Maps │ │ • Export CSV │ │ • Scoring │
│ • Google Search │ │ • Google Sheets │ │ • Relances │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
---
## 🔧 Méthodes d'Extraction (Gratuites)
### Option 1: Script Python (Ce projet)
```bash
python3 scraper_artisans.py --profession "cordonnier" --ville "lille" --cp "59000"
```
### Option 2: Extension Chrome "Web Scraper"
1. Installer l'extension "Web Scraper" sur Chrome
2. Créer un sitemap pour PagesJaunes
3. Exporter les données en CSV
4. Importer dans le CRM
### Option 3: Google Sheets + ImportXML
```excel
=IMPORTXML("https://www.pagesjaunes.fr/annuaire/lille-59000/cordonnier", "//div[@class='bi-thu__ItemSearchResult']")
```
---
## 📊 Données Extraites
| Champ | Description | Exemple |
|-------|-------------|---------|
| Nom | Nom de l'artisan | Dupont Philippe |
| Entreprise | Nom de l'entreprise | Dupont Cordonnerie |
| Activité | Métier | Cordonnier |
| Adresse | Adresse complète | 45 Rue Nationale, Lille 59000 |
| Téléphone | Numéro | 0320123456 |
| Site web | URL | www.dupont-cordonnerie.fr |
| Note | Note Google | 4.5 |
| Avis | Nombre d'avis | 120 |
---
## 💾 Stockage
### Format JSON (CRM local)
```json
{
"prospects": [
{
"id": 1,
"nom": "Dupont Philippe",
"entreprise": "Dupont Cordonnerie",
"tel": "0320123456",
"secteur": "Cordonnier",
"statut": "nouveau",
"score": 4,
"notes": "Adresse: 45 Rue Nationale...",
"source": "Scraping",
"created": "2026-02-25T20:42:00"
}
]
}
```
### Export CSV (pour Google Sheets)
Le script génère un fichier CSV compatible Google Sheets:
- `exports/prospects_20260225_204200.csv`
---
## 📊 CRM - Suivi des Contacts
### Statuts de suivi
| Statut | Description | Action |
|--------|-------------|--------|
| 🔵 Nouveau | Prospect nouvellement ajouté | À appeler |
| 🟡 Qualifié | Besoin identifié | Proposition |
| 🟣 Proposition | Devis envoyé | En attente |
| 🟢 Gagné | Client acquis | Suivi SAV |
| 🔴 Perdu | Pas de réponse | À relancer |
### Score de qualification (1-5)
| Score | Critères |
|-------|----------|
| ⭐ | Nom uniquement |
| ⭐⭐ | + Téléphone |
| ⭐⭐⭐ | + Note/avis |
| ⭐⭐⭐⭐ | + Site web |
| ⭐⭐⭐⭐⭐ | Complete |
---
## 🚀 Utilisation
### 1. Lancer le scraper
```bash
python3 scraper_artisans.py --profession "cordonnier" --ville "lille" --cp "59000"
```
### 2. Ajouter manuellement
Via l'interface: http://178.18.250.53:8765/scraper_pro.html
### 3. Exporter vers Google Sheets
1.Exporter en CSV depuis le CRM
2.Importer dans Google Sheets
3.Configurer les filtres
---
## 📱 Intégrations Gratuites Recommandées
### CRM Gratuits
| Service | Limite gratuite | Features |
|---------|-----------------|----------|
| HubSpot Free | 1 user, 1M contacts | Complet |
| Airtable | 1,000 rows | Base + Views |
| Notion | Illimité | Database |
| Google Sheets | Illimité | + ImportXML |
### Google Sheets - Configuration
1. Créer un nouveau Sheet
2. Importer le CSV exporté
3. Créer des vues filtrées par statut
4. Ajouter des validations de données
---
## ⚠️ Limitations Techniques
### PagesJaunes / Google Maps
- **Bloquent** les requêtes automatisées
- **Cloudflare** active des CAPTCHA
- **Solution**: Utiliser un navigateur headless (Playwright) avec stealth mode
### Alternatives
- **APIfy**: Service payants avec proxy rotatif
- **ScraperAPI**: Crédits gratuits disponibles
- **Manual**: Ajout manuel via l'interface
---
## 📁 Fichiers du Projet
```
/home/h3r7/turf_scraper/
├── scraper_artisans.py # Script principal
├── crm_prospects.json # Base CRM
├── exports/ # Exports CSV
│ └── prospects_*.csv
└── scraper_pro.html # Interface web
```
---
## 🎯 Checklist de Qualification
- [ ] Téléphone disponible → Appeler
- [ ] Note > 4.0 → Priorité haute
- [ ] Avis > 50 → Qualité confirmée
- [ ] Site web → Demander un devis en ligne
- [ ] Dans la zone → Visite commerciale
---
## 📞 Scripts de Prospection
Voir le document: SCRIPTS_DEMARCHAGE.html
---
*Document généré le 25/02/2026 - H3R7Tech*

38
add_equidia.py Executable file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Add Equidia prediction"""
import sqlite3
from datetime import datetime
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# Add Equidia prediction for today
# From email: "I'M A BELIEVER (7) peut mettre tout le monde d'accord"
# Let's add the top 3 from Equidia analysis
today = "2026-02-24"
race = "Quinte Cagnes-sur-Mer"
# Based on the email, we know I'M A BELIEVER is the favorite
# This is a placeholder - we'd need to parse the full email for complete picks
equidia_picks = [
(today, "equidia", race, "I'M A BELIEVER", 0, 1, 80), # 80% confidence
]
for date, source, race_name, horse, odds, rank, conf in equidia_picks:
c.execute("""
INSERT INTO external_predictions
(date, source, race_name, horse_name, odds, rank, confidence)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (date, source, race_name, horse, odds, rank, conf))
conn.commit()
print("Equidia prediction added!")
# Verify
c.execute("SELECT * FROM external_predictions WHERE source = 'equidia'")
for row in c.fetchall():
print(row)
conn.close()

27
add_grok.py Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env python3
import sqlite3
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# Add yesterday's Grok picks (from email)
picks = [
("2026-02-23", "Quinte Auteuil", "PASSIONATA", 8.1, 1),
("2026-02-23", "Quinte Auteuil", "GABISON", 7.5, 2),
("2026-02-23", "Quinte Auteuil", "EMSILORD", 5.2, 3),
]
for date, race, horse, odds, rank in picks:
c.execute("INSERT INTO grok_predictions (date, race_name, horse_name, odds, rank) VALUES (?, ?, ?, ?, ?)",
(date, race, horse, odds, rank))
conn.commit()
print("OK - Grok predictions added")
# Verify
c.execute("SELECT * FROM grok_predictions")
for row in c.fetchall():
print(row)
conn.close()

29
add_grok_today.py Executable file
View File

@@ -0,0 +1,29 @@
import sqlite3
conn = sqlite3.connect('/home/h3r7/turf_scraper/turf.db')
c = conn.cursor()
today = '2026-02-24'
# Delete old Grok predictions for today
c.execute('DELETE FROM external_predictions WHERE source = ? AND date = ?', ('grok', today))
# Add new Grok predictions from email
grok_picks = [
(7, "I'M A BELIEVER", 1, 85),
(3, 'GRAND BALCON', 2, 70),
(1, 'PRINCE DE MONTFORT', 3, 65),
]
for num, name, rank, conf in grok_picks:
c.execute('''INSERT INTO external_predictions (date, source, race_name, horse_name, horse_number, odds, rank, confidence)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
(today, 'grok', 'Quinte Cagnes-sur-Mer', name, num, 0, rank, conf))
conn.commit()
print('Updated Grok!')
c.execute('SELECT horse_number, horse_name, rank FROM external_predictions WHERE source = ? AND date = ?', ('grok', today))
for r in c.fetchall():
print(f" {r[0]} - {r[1]} (rank {r[2]})")
conn.close()

30
add_jockeys.py Executable file
View File

@@ -0,0 +1,30 @@
import sqlite3
conn = sqlite3.connect('/home/h3r7/turf_scraper/turf.db')
c = conn.cursor()
# Update predictions with jockey names
# From Equidia screenshot:
jockeys = {
7: "T. PICCONE",
1: "C. DEMURO",
3: "A. CRASTUS",
8: "S. PASQUIER",
11: "C. LECOUVRE",
15: "E. HARDOUIN",
16: "M. GRANDIN",
14: "A. LEMAITRE"
}
today = '2026-02-24'
for num, jockey in jockeys.items():
c.execute('UPDATE predictions SET jockey = ? WHERE date = ? AND horse_number = ?',
(jockey, today, num))
conn.commit()
print("Updated!")
c.execute('SELECT horse_number, horse_name, jockey, prediction_rank FROM predictions WHERE date = ? ORDER BY prediction_rank', (today,))
for r in c.fetchall():
print(f" {r[0]} - {r[1]} ({r[2]})")
conn.close()

32
add_odds.py Executable file
View File

@@ -0,0 +1,32 @@
import sqlite3
from datetime import datetime
conn = sqlite3.connect('/home/h3r7/turf_scraper/turf.db')
c = conn.cursor()
# Add odds from scraper data
today = '2026-02-24'
now = datetime.now().strftime('%H:%M')
odds_data = {
7: 4.0, # I'M A BELIEVER
1: 7.3, # PRINCE DE MONTFORT
3: 6.7, # GRAND BALCON
8: 18.0, # PAOLINO
11: 19.0, # INCREMENTAL
15: 13.0, # PRINCESSE SAPHIR
16: 30.0, # GOLD PLAYER
14: 18.0 # WEEMAGATEE
}
for num, odds in odds_data.items():
c.execute('UPDATE predictions SET odds = ?, odds_time = ? WHERE date = ? AND horse_number = ?',
(odds, now, today, num))
conn.commit()
print("Odds updated!")
c.execute('SELECT horse_number, horse_name, odds, odds_time FROM predictions WHERE date = ? ORDER BY prediction_rank', (today,))
for r in c.fetchall():
print(f" {r[0]} - {r[1]}: {r[2]}/1 à {r[3]}")
conn.close()

30
add_preds.py Executable file
View File

@@ -0,0 +1,30 @@
#!/usr/bin/env python3
import sqlite3
conn = sqlite3.connect('/home/h3r7/turf_scraper/turf.db')
c = conn.cursor()
today = '2026-02-24'
# All Canalturf predictions for Quinté
preds = [
(7, "I'M A BELIEVER", 1),
(1, 'PRINCE DE MONTFORT', 2),
(3, 'GRAND BALCON', 3),
(8, 'PAOLINO', 4),
(11, 'INCREMENTAL', 5),
(15, 'PRINCESSE SAPHIR', 6),
(16, 'GOLD PLAYER', 7),
(14, 'WEEMAGATEE', 8),
]
for num, name, rank in preds:
c.execute('INSERT OR REPLACE INTO predictions (date, race_name, horse_number, horse_name, prediction_rank, source) VALUES (?, ?, ?, ?, ?, ?)',
(today, 'Quinte Cagnes-sur-Mer', num, name, rank, 'canalturf'))
conn.commit()
print('Saved!')
c.execute('SELECT horse_number, horse_name, prediction_rank FROM predictions WHERE date = ? AND source = ?', (today, 'canalturf'))
for r in c.fetchall():
print(f' {r[0]} - {r[1]} (rank {r[2]})')
conn.close()

46
add_scheduler_job.py Executable file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env python3
import json
import os
import shutil
import subprocess
JOBS_PATH = "/var/lib/docker/volumes/ow404080wwkkgkgc4oswwssc_openclaw-data/_data/.openclaw/cron/jobs.json"
BACKUP = JOBS_PATH + ".bak"
print("📦 Sauvegarde du fichier jobs.json…")
shutil.copy(JOBS_PATH, BACKUP)
with open(JOBS_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
# Vérifier si le job existe déjà
for job in data["jobs"]:
if job.get("name") == "turf-scheduler-08h":
print(" Le job turf-scheduler-08h existe déjà. Rien à faire.")
exit(0)
print("🛠 Ajout du job turf-scheduler-08h…")
new_job = {
"id": "turf-scheduler-08h",
"name": "turf-scheduler-08h",
"enabled": True,
"schedule": {
"expr": "0 8 * * *"
},
"command": "python3 /home/h3r7/turf_scraper/turf_scheduler.py",
"timeout": 3600,
"retries": 0
}
data["jobs"].append(new_job)
with open(JOBS_PATH, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print("✅ Job ajouté avec succès.")
print("🔄 Redémarrage dOpenClaw…")
subprocess.run(["docker", "restart", "openclaw-ow404080wwkkgkgc4oswwssc"])
print("🎉 Terminé !")

155
agent_chat.html Executable file
View File

@@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Agent Chat - H3R7Tech</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #1a1a2e; color: #eee; padding: 20px; }
.logo { text-align: center; padding: 10px; }
.logo img { width: 80px; border-radius: 10px; }
header { background: linear-gradient(90deg, #e94560, #7b2cbf); padding: 20px; text-align: center; margin: -20px -20px 20px; border-radius: 0 0 20px 20px; }
h1 { font-size: 24px; }
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 20px; }
.stat { background: #16213e; padding: 15px; border-radius: 10px; text-align: center; }
.stat-num { font-size: 24px; font-weight: bold; color: #00d9ff; }
.stat-label { font-size: 11px; color: #888; }
.agents { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
.agent-badge { background: #0f3460; padding: 8px 15px; border-radius: 20px; font-size: 13px; }
.agent-badge .count { background: #00d9ff; color: #000; padding: 2px 8px; border-radius: 10px; margin-left: 5px; }
.chat-container { background: #16213e; border-radius: 15px; height: 500px; overflow-y: auto; padding: 20px; }
.message { padding: 12px 15px; margin: 8px 0; border-radius: 12px; max-width: 85%; }
.message.info { background: #0f3460; margin-left: 0; }
.message.success { background: #1a4d2e; margin-left: 0; border-left: 4px solid #00ff88; }
.message.warning { background: #4d4d1a; margin-left: 0; border-left: 4px solid #ffd700; }
.message.error { background: #4d1a1a; margin-left: 0; border-left: 4px solid #e94560; }
.message .agent { font-size: 11px; color: #00d9ff; font-weight: bold; }
.message .time { font-size: 10px; color: #666; float: right; }
.message .content { margin-top: 5px; font-size: 14px; }
.actions { margin-top: 20px; display: flex; gap: 10px; }
button { padding: 10px 20px; background: #00d9ff; border: none; border-radius: 8px; color: #000; font-weight: bold; cursor: pointer; }
button:hover { background: #00b8d4; }
button.clear { background: #e94560; color: #fff; }
.refresh { float: right; background: #7b2cbf; color: #fff; }
a { color: #00d9ff; }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<div class="logo">
<img src="/turf/H3R7Tech_logo.png" alt="H3R7Tech">
</div>
<header>
<h1>💬 Agent Chat - H3R7Tech</h1>
</header>
<div class="stats" id="stats">
<div class="stat">
<div class="stat-num" id="total">0</div>
<div class="stat-label">Messages</div>
</div>
<div class="stat">
<div class="stat-num" id="agents">0</div>
<div class="stat-label">Agents</div>
</div>
<div class="stat">
<div class="stat-num" id="success">0</div>
<div class="stat-label">Succès</div>
</div>
<div class="stat">
<div class="stat-num" id="errors">0</div>
<div class="stat-label">Erreurs</div>
</div>
</div>
<div class="agents" id="agents-list"></div>
<div class="actions">
<button onclick="loadMessages()" class="refresh">🔄 Rafraîchir</button>
<button onclick="clearMessages()" class="clear">🗑️ Effacer</button>
<a href="/">← Retour Portail</a>
</div>
<h2 style="margin: 20px 0 10px;">📝 Historique</h2>
<div class="chat-container" id="chat"></div>
<script>
const API = '/agent/api';
function loadMessages() {
fetch(API + '/messages?limit=100')
.then(r => r.json())
.then(messages => {
renderMessages(messages);
updateStats(messages);
});
}
function renderMessages(messages) {
const chat = document.getElementById('chat');
if (messages.length === 0) {
chat.innerHTML = '<div style="text-align:center;color:#666;padding:40px;">Aucun message</div>';
return;
}
chat.innerHTML = messages.map(m => {
const time = new Date(m.timestamp).toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
});
return `<div class="message ${m.type}">
<span class="agent">🤖 ${m.agent}</span>
<span class="time">${time}</span>
<div class="content">${m.content}</div>
</div>`;
}).join('');
chat.scrollTop = chat.scrollHeight;
}
function updateStats(messages) {
document.getElementById('total').textContent = messages.length;
const agents = {};
let success = 0, errors = 0;
messages.forEach(m => {
agents[m.agent] = (agents[m.agent] || 0) + 1;
if (m.type === 'success') success++;
if (m.type === 'error') errors++;
});
document.getElementById('agents').textContent = Object.keys(agents).length;
document.getElementById('success').textContent = success;
document.getElementById('errors').textContent = errors;
// Render agent badges
const agentsList = document.getElementById('agents-list');
agentsList.innerHTML = Object.entries(agents).map(([a, c]) =>
`<span class="agent-badge">${a} <span class="count">${c}</span></span>`
).join('');
}
function clearMessages() {
if (!confirm('Effacer tous les messages?')) return;
fetch(API + '/messages', { method: 'DELETE' })
.then(() => loadMessages());
}
// Auto-refresh every 30 seconds
loadMessages();
setInterval(loadMessages, 30000);
</script>
</body>
</html>

82
agent_chat_api.py Executable file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""
Agent Chat API - Communications entre agents H3R7Tech
"""
from flask import Flask, jsonify, request, send_from_directory
from flask_cors import CORS
import json
import os
from datetime import datetime
app = Flask(__name__)
CORS(app)
CHAT_FILE = '/home/h3r7/turf_scraper/agent_chat.json'
def load_chat():
if not os.path.exists(CHAT_FILE):
data = {"messages": []}
with open(CHAT_FILE, 'w') as f:
json.dump(data, f, indent=2)
return data
with open(CHAT_FILE, 'r') as f:
return json.load(f)
def save_chat(data):
with open(CHAT_FILE, 'w') as f:
json.dump(data, f, indent=2)
@app.route('/')
def index():
return send_from_directory('/home/h3r7/turf_scraper', 'agent_chat.html')
@app.route('/api/messages', methods=['GET'])
def get_messages():
data = load_chat()
limit = request.args.get('limit', 50)
return jsonify(data['messages'][-int(limit):])
@app.route('/api/messages', methods=['POST'])
def add_message():
data = load_chat()
req = request.json
message = {
'id': len(data['messages']) + 1,
'agent': req.get('agent', 'system'),
'type': req.get('type', 'info'), # info, warning, error, success
'content': req.get('content', ''),
'timestamp': datetime.now().isoformat()
}
data['messages'].append(message)
save_chat(data)
return jsonify({'success': True, 'message': message})
@app.route('/api/messages', methods=['DELETE'])
def clear_messages():
data = {'messages': []}
save_chat(data)
return jsonify({'success': True})
@app.route('/api/stats', methods=['GET'])
def get_stats():
data = load_chat()
messages = data['messages']
stats = {
'total': len(messages),
'par_agent': {},
'par_type': {}
}
for m in messages:
agent = m.get('agent', 'unknown')
stats['par_agent'][agent] = stats['par_agent'].get(agent, 0) + 1
type_msg = m.get('type', 'info')
stats['par_type'][type_msg] = stats['par_type'].get(type_msg, 0) + 1
return jsonify(stats)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8771, debug=False)

609
agent_ia.html Normal file
View File

@@ -0,0 +1,609 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agent IA</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f0f23; color: #eee; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
header { background: linear-gradient(90deg, #1a1a2e, #0f3460); padding: 10px 20px; border-bottom: 2px solid #00d9ff; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
header h1 { font-size: 18px; background: linear-gradient(90deg, #00d9ff, #e94560); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header .config-link { color: #888; font-size: 12px; text-decoration: none; }
header .config-link:hover { color: #00d9ff; }
.tabs { display: flex; background: #161b22; border-bottom: 1px solid #30363d; flex-shrink: 0; }
.tab { padding: 10px 20px; cursor: pointer; border: none; background: none; color: #888; font-size: 14px; border-bottom: 2px solid transparent; transition: all 0.2s; }
.tab:hover { color: #eee; background: rgba(0,217,255,0.05); }
.tab.active { color: #00d9ff; border-bottom-color: #00d9ff; }
.main { display: flex; flex: 1; overflow: hidden; }
.sidebar { width: 260px; background: #161b22; border-right: 1px solid #30363d; display: flex; flex-direction: column; flex-shrink: 0; }
.sidebar-header { padding: 12px; border-bottom: 1px solid #30363d; }
.new-chat-btn { width: 100%; padding: 8px; background: #00d9ff; color: #0f0f23; border: none; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; transition: background 0.2s; }
.new-chat-btn:hover { background: #00b8d9; }
.session-list { flex: 1; overflow-y: auto; padding: 8px; }
.session-item { padding: 10px 12px; border-radius: 6px; cursor: pointer; margin-bottom: 4px; display: flex; align-items: center; justify-content: space-between; transition: background 0.15s; }
.session-item:hover { background: rgba(0,217,255,0.08); }
.session-item.active { background: rgba(0,217,255,0.15); border-left: 3px solid #00d9ff; }
.session-title { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; }
.session-date { font-size: 10px; color: #555; margin-top: 2px; }
.session-delete { opacity: 0; background: none; border: none; color: #d23232; cursor: pointer; font-size: 14px; padding: 2px 6px; border-radius: 4px; }
.session-item:hover .session-delete { opacity: 1; }
.session-delete:hover { background: rgba(210,50,50,0.2); }
.session-rename { opacity: 0; background: none; border: none; color: #888; cursor: pointer; font-size: 12px; padding: 2px 6px; border-radius: 4px; margin-right: 4px; }
.session-item:hover .session-rename { opacity: 1; }
.session-rename:hover { color: #00d9ff; background: rgba(0,217,255,0.1); }
.session-edit-input { background: #0d1117; border: 1px solid #00d9ff; border-radius: 4px; color: #eee; font-size: 12px; padding: 4px 8px; width: 100%; outline: none; }
.msg-copy { opacity: 0; background: none; border: none; color: #888; cursor: pointer; font-size: 12px; padding: 4px 8px; border-radius: 4px; margin-top: 8px; display: inline-flex; align-items: center; gap: 4px; }
.msg.ai:hover .msg-copy { opacity: 1; }
.msg-copy:hover { color: #00d9ff; background: rgba(0,217,255,0.1); }
.msg-copied { color: #4caf50; font-size: 12px; margin-left: 8px; }
.chat-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
#chat { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
.welcome { text-align: center; color: #555; margin-top: 20vh; }
.welcome h2 { color: #888; margin-bottom: 8px; }
.msg { max-width: 75%; padding: 12px 16px; border-radius: 12px; line-height: 1.6; font-size: 14px; white-space: pre-wrap; word-wrap: break-word; }
.msg.user { align-self: flex-end; background: #0f3460; border: 1px solid #00d9ff; border-bottom-right-radius: 4px; }
.msg.ai { align-self: flex-start; background: #161b22; border: 1px solid #30363d; border-bottom-left-radius: 4px; }
.msg.ai p { margin: 0 0 8px 0; }
.msg.ai p:last-child { margin-bottom: 0; }
.msg.ai code { background: #0d1117; padding: 2px 6px; border-radius: 4px; font-size: 13px; font-family: 'Fira Code', monospace; }
.msg.ai pre { background: #0d1117; padding: 12px; border-radius: 8px; overflow-x: auto; margin: 8px 0; }
.msg.ai pre code { background: none; padding: 0; }
.msg.ai ul, .msg.ai ol { padding-left: 20px; margin: 8px 0; }
.msg.ai li { margin-bottom: 4px; }
.msg.ai table { border-collapse: collapse; margin: 8px 0; }
.msg.ai th, .msg.ai td { border: 1px solid #30363d; padding: 6px 10px; font-size: 13px; }
.msg.ai th { background: #1a1a2e; }
.msg.error { align-self: center; background: rgba(210,50,50,0.2); border: 1px solid #d23232; color: #f88; }
.typing { align-self: flex-start; color: #666; font-style: italic; font-size: 13px; padding: 8px 16px; display: flex; align-items: center; gap: 8px; }
.typing-dots { display: flex; gap: 4px; }
.typing-dot { width: 6px; height: 6px; background: #666; border-radius: 50%; animation: typingBounce 1.4s infinite ease-in-out; }
.typing-dot:nth-child(1) { animation-delay: 0s; }
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typingBounce { 0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; } 40% { transform: scale(1); opacity: 1; } }
.search-bar { padding: 8px 12px; border-bottom: 1px solid #30363d; }
.search-input { width: 100%; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #eee; padding: 8px 10px; font-size: 12px; outline: none; }
.search-input:focus { border-color: #00d9ff; }
.search-results { max-height: 200px; overflow-y: auto; padding: 8px; }
.search-result { padding: 8px; border-radius: 6px; cursor: pointer; margin-bottom: 4px; font-size: 12px; }
.search-result:hover { background: rgba(0,217,255,0.08); }
.search-result-mark { background: rgba(255,215,0,0.2); padding: 2px 4px; border-radius: 3px; }
.export-btn { background: none; border: none; color: #888; cursor: pointer; font-size: 12px; padding: 4px 8px; border-radius: 4px; }
.export-btn:hover { color: #00d9ff; background: rgba(0,217,255,0.1); }
.session-actions { display: flex; gap: 4px; padding: 8px 12px; border-bottom: 1px solid #30363d; }
#input-area { display: flex; gap: 10px; padding: 15px 20px; background: #161b22; border-top: 1px solid #30363d; flex-shrink: 0; }
#input-area textarea { flex: 1; background: #0d1117; border: 1px solid #30363d; border-radius: 8px; color: #eee; padding: 12px; font-size: 14px; resize: none; font-family: inherit; min-height: 48px; max-height: 120px; outline: none; }
#input-area textarea:focus { border-color: #00d9ff; }
#model-select { padding: 8px 12px; border-radius: 8px; border: 1px solid #30363d; background: #161b22; color: #e6edf3; font-size: 14px; cursor: pointer; }
#model-select:hover { border-color: #00d9ff; }
#send-btn { background: #00d9ff; color: #0f0f23; border: none; border-radius: 8px; padding: 0 20px; font-size: 16px; font-weight: bold; cursor: pointer; transition: background 0.2s; }
#send-btn:hover { background: #00b8d9; }
#send-btn:disabled { background: #30363d; color: #666; cursor: not-allowed; }
@media (max-width: 768px) {
.sidebar { display: none; }
.sidebar.open { display: flex; position: fixed; top: 0; left: 0; bottom: 0; z-index: 100; width: 280px; }
.msg { max-width: 90%; }
#input-area { padding: 10px; }
.mobile-menu-btn { display: block !important; }
}
.mobile-menu-btn { display: none; background: none; border: none; color: #eee; font-size: 20px; cursor: pointer; padding: 4px 8px; }
.overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 99; }
.overlay.active { display: block; }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<header>
<div style="display:flex;align-items:center;gap:8px;">
<button class="mobile-menu-btn" onclick="toggleSidebar()">&#9776;</button>
<h1>Agent IA</h1>
</div>
<a href="/agent-ia/config" class="config-link">&#9881; Config</a>
<button class="export-btn" onclick="showExportMenu()" title="Exporter">&#128190; Export</button>
</header>
<div class="tabs" id="tabs"></div>
<div class="overlay" id="overlay" onclick="toggleSidebar()"></div>
<div class="main">
<div class="sidebar" id="sidebar">
<div class="sidebar-header">
<button class="new-chat-btn" onclick="newConversation()">+ Nouvelle conversation</button>
</div>
<div class="search-bar">
<input type="text" class="search-input" id="search-input" placeholder="Rechercher..." oninput="searchMessages(this.value)">
</div>
<div class="search-results" id="search-results" style="display:none;"></div>
<div class="session-list" id="session-list"></div>
</div>
<div class="chat-area">
<div id="chat">
<div class="welcome">
<h2>Bienvenue sur Agent IA</h2>
<p>Sélectionnez un onglet et commencez une conversation</p>
</div>
</div>
<div id="input-area">
<select id="model-select" style="display: none;"><option value="llama-3.1-8b">Llama 3.1 8B</option></select>
<textarea id="user-input" placeholder="Tapez votre message..." rows="1"></textarea>
<button id="send-btn">Envoyer</button>
</div>
</div>
</div>
<script>
const chatEl = document.getElementById('chat');
const input = document.getElementById('user-input');
const sendBtn = document.getElementById('send-btn');
const tabsEl = document.getElementById('tabs');
const sessionListEl = document.getElementById('session-list');
let workflows = [];
let nvidiaModels = {};
let selectedModel = "llama-3.1-8b";
let activeWorkflow = null;
let activeSession = null;
let sessionId = generateSessionId();
function generateSessionId() {
return 'session-' + Math.random().toString(36).substr(2, 9);
}
async function loadWorkflows() {
try {
const resp = await fetch('/api/chat/workflows');
workflows = await resp.json();
if (workflows.length === 0) return;
// Show model select if nvidia-chat is available
const nvidiaWorkflow = workflows.find(w => w.slug === "nvidia-chat");
const modelSelect = document.getElementById("model-select");
if (nvidiaWorkflow) {
modelSelect.style.display = "block";
loadNvidiaModels();
} else {
modelSelect.style.display = "none";
}
renderTabs();
selectWorkflow(workflows[0].slug);
} catch (e) {
console.error('Erreur chargement workflows:', e);
}
}
async function loadNvidiaModels() {
try {
const resp = await fetch("/api/chat/nvidia-models");
nvidiaModels = await resp.json();
const select = document.getElementById("model-select");
select.innerHTML = "";
nvidiaModels.forEach(m => {
const opt = document.createElement("option");
opt.value = m.id;
opt.textContent = m.name;
select.appendChild(opt);
});
} catch (e) {
console.error("Error loading models:", e);
}
}
function renderTabs() {
tabsEl.innerHTML = workflows.map(wf =>
`<button class="tab ${wf.slug === activeWorkflow ? 'active' : ''}" data-slug="${wf.slug}" onclick="selectWorkflow('${wf.slug}')">${wf.name}</button>`
).join('');
}
async function selectWorkflow(slug) {
activeWorkflow = slug;
activeSession = null;
sessionId = generateSessionId();
renderTabs();
renderSessions();
clearChat();
showWelcome();
}
async function renderSessions() {
if (!activeWorkflow) return;
try {
const resp = await fetch(`/api/chat/sessions?workflow=${activeWorkflow}`);
const sessions = await resp.json();
sessionListEl.innerHTML = sessions.map(s => {
const date = new Date(s.updated_at).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
const title = s.title || 'Nouvelle conversation';
const isActive = s.session_id === activeSession;
const sid = s.session_id;
const safeTitle = escHtml(title);
const safeId = escHtml(sid);
return `<div class="session-item ${isActive ? 'active' : ''}" onclick="loadSession('${safeId}')" data-sid="${safeId}">
<div style="flex:1;min-width:0;">
<div class="session-title">${safeTitle}</div>
<div class="session-date">${date}</div>
</div>
<button class="session-rename" onclick="event.stopPropagation();startRename('${safeId}', &quot;${safeTitle}&quot;)" title="Renommer">&#9998;</button>
<button class="session-delete" onclick="event.stopPropagation();deleteSession('${safeId}')" title="Supprimer">&#10005;</button>
</div>`;
}).join('');
} catch (e) {
console.error('Erreur sessions:', e);
}
}
async function loadSession(sid) {
activeSession = sid;
sessionId = sid;
renderSessions();
clearChat();
try {
const resp = await fetch(`/api/chat/history?session_id=${sid}&workflow=${activeWorkflow}`);
const messages = await resp.json();
if (messages.length === 0) {
showWelcome();
return;
}
messages.forEach(m => addMessage(m.content, m.role, false));
scrollToBottom();
} catch (e) {
console.error('Erreur historique:', e);
}
}
async function deleteSession(sid) {
if (!confirm('Supprimer cette conversation ?')) return;
try {
await fetch(`/api/chat/session?session_id=${sid}&workflow=${activeWorkflow}`, { method: 'DELETE' });
if (activeSession === sid) {
activeSession = null;
sessionId = generateSessionId();
clearChat();
showWelcome();
}
renderSessions();
} catch (e) {
console.error('Erreur suppression:', e);
}
}
function newConversation() {
activeSession = null;
sessionId = generateSessionId();
renderSessions();
clearChat();
showWelcome();
input.focus();
}
function clearChat() {
chatEl.innerHTML = '';
}
function showWelcome() {
const wf = workflows.find(w => w.slug === activeWorkflow);
const name = wf ? wf.name : 'Agent IA';
chatEl.innerHTML = `<div class="welcome"><h2>${escHtml(name)}</h2><p>Démarrez une nouvelle conversation</p></div>`;
}
let renamingSession = null;
function addMessage(text, type, scroll = true) {
const existing = chatEl.querySelector('.welcome');
if (existing) existing.remove();
const div = document.createElement('div');
div.className = 'msg ' + type;
if (type === 'ai') {
div.innerHTML = marked.parse(text);
const copyBtn = document.createElement('button');
copyBtn.className = 'msg-copy';
copyBtn.innerHTML = '📋 Copier';
copyBtn.onclick = () => copyToClipboard(text, copyBtn);
div.appendChild(copyBtn);
const excelBtn = document.createElement('button');
excelBtn.className = 'msg-copy';
excelBtn.innerHTML = '📊 Excel';
excelBtn.onclick = () => copyToExcel(text, excelBtn);
div.appendChild(excelBtn);
} else {
div.textContent = text;
}
chatEl.appendChild(div);
if (scroll) scrollToBottom();
}
function addTyping() {
const div = document.createElement('div');
div.className = 'typing';
div.id = 'typing-indicator';
div.innerHTML = '<div class="typing-dots"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div><span>Réflexion en cours...</span>';
chatEl.appendChild(div);
scrollToBottom();
}
function removeTyping() {
const el = document.getElementById('typing-indicator');
if (el) el.remove();
}
function scrollToBottom() {
chatEl.scrollTop = chatEl.scrollHeight;
}
function escHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function getPlainText(html) {
const div = document.createElement('div');
div.innerHTML = html;
return div.textContent || '';
}
function copyToClipboard(text, btn) {
const plainText = getPlainText(text);
navigator.clipboard.writeText(plainText).then(() => {
const originalHtml = btn.innerHTML;
btn.innerHTML = '<span class="msg-copied">Copié!</span>';
setTimeout(() => { btn.innerHTML = originalHtml; }, 1500);
}).catch(err => {
console.error('Erreur copy:', err);
});
}
function copyToExcel(text, btn) {
const div = document.createElement('div');
div.innerHTML = text;
let tsv = '';
const tables = div.querySelectorAll('table');
if (tables.length > 0) {
tables.forEach(table => {
const rows = table.querySelectorAll('tr');
rows.forEach(row => {
const cells = row.querySelectorAll('th, td');
const rowData = Array.from(cells).map(c => c.textContent.trim().replace(/\t/g, ' ').replace(/\n/g, ' '));
tsv += rowData.join('\t') + '\n';
});
});
}
if (tsv) {
navigator.clipboard.writeText(tsv).then(() => {
btn.innerHTML = '<span class="msg-copied">Excel copié!</span>';
setTimeout(() => { btn.innerHTML = '📊 Excel'; }, 1500);
});
}
}
async function renameSession(sid, newTitle) {
if (!newTitle || !newTitle.trim()) {
renderSessions();
return;
}
try {
const resp = await fetch(`/api/chat/session?session_id=${sid}&workflow=${activeWorkflow}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle.trim() })
});
if (resp.ok) {
renderSessions();
} else {
const err = await resp.text();
console.error('Erreur rename:', err);
}
} catch (e) {
console.error('Erreur rename:', e);
}
}
function createRenameInput(sid, currentTitle) {
const input = document.createElement('input');
input.type = 'text';
input.className = 'session-edit-input';
input.value = currentTitle || '';
input.placeholder = 'Titre de la conversation';
input.onkeydown = async (e) => {
if (e.key === 'Enter') {
await renameSession(sid, input.value);
renderSessions();
} else if (e.key === 'Escape') {
renderSessions();
}
};
input.onblur = async () => {
await renameSession(sid, input.value);
renderSessions();
};
return input;
}
function startRename(sid, currentTitle) {
renamingSession = sid;
const items = sessionListEl.querySelectorAll('.session-item');
items.forEach(item => {
if (item.dataset.sid === sid) {
const titleDiv = item.querySelector('.session-title');
titleDiv.innerHTML = '';
titleDiv.appendChild(createRenameInput(sid, currentTitle));
const input = titleDiv.querySelector('input');
if (input) input.focus();
}
});
}
async function sendMessage() {
const text = input.value.trim();
if (!text || !activeWorkflow) return;
addMessage(text, 'user');
input.value = '';
input.style.height = 'auto';
sendBtn.disabled = true;
addTyping();
if (!activeSession) {
activeSession = sessionId;
try {
await fetch('/api/chat/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, workflow: activeWorkflow, title: text.substring(0, 50) })
});
} catch (e) {}
renderSessions();
}
try {
const resp = await fetch(`/webhook/${activeWorkflow}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Session-ID': sessionId },
body: JSON.stringify({
message: text,
model: activeWorkflow === "nvidia-chat" ? selectedModel : undefined
})
});
removeTyping();
if (!resp.ok) {
const err = await resp.text().catch(() => 'Erreur serveur');
addMessage('Erreur: ' + err, 'error');
return;
}
const data = await resp.text();
addMessage(data, 'ai');
} catch (e) {
removeTyping();
addMessage('Erreur de connexion: ' + e.message, 'error');
} finally {
sendBtn.disabled = false;
input.focus();
}
}
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
document.getElementById('overlay').classList.toggle('active');
}
sendBtn.addEventListener('click', sendMessage);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
});
async function searchMessages(query) {
const resultsEl = document.getElementById('search-results');
if (!query || query.length < 2) {
resultsEl.style.display = 'none';
return;
}
resultsEl.style.display = 'block';
resultsEl.innerHTML = '<div style="color:#666;font-size:11px;">Recherche en cours...</div>';
try {
const resp = await fetch(`/api/chat/search?q=${encodeURIComponent(query)}&workflow=${activeWorkflow}`);
const results = await resp.json();
if (results.length === 0) {
resultsEl.innerHTML = '<div style="color:#666;font-size:11px;">Aucun résultat</div>';
return;
}
resultsEl.innerHTML = results.slice(0, 10).map(r => {
const preview = r.content.substring(0, 80).replace(/</g, '&lt;').replace(/>/g, '&gt;');
const markQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const highlighted = preview.replace(new RegExp(markQuery, 'gi'), m => `<span class="search-result-mark">${m}</span>`);
return `<div class="search-result" onclick="loadSession('${r.session_id}');document.getElementById('search-results').style.display='none';">
<div style="color:#888;font-size:10px;">${new Date(r.created_at).toLocaleDateString()}</div>
<div>${highlighted}</div>
</div>`;
}).join('');
} catch (e) {
resultsEl.innerHTML = '<div style="color:#d23232;font-size:11px;">Erreur de recherche</div>';
}
}
async function exportSession(sid, format) {
try {
const resp = await fetch(`/api/chat/history?session_id=${sid}&workflow=${activeWorkflow}`);
const messages = await resp.json();
if (messages.length === 0) {
alert('Aucune conversation à exporter');
return;
}
const session = messages[0].session_id;
let content = '';
let filename = `conversation_${session}_${new Date().toISOString().slice(0,10)}`;
if (format === 'markdown') {
content = `# Conversation\n\n`;
messages.forEach(m => {
content += `## ${m.role === 'user' ? 'Vous' : 'IA'}\n\n${m.content}\n\n---\n\n`;
});
content += `\n*Exporté le ${new Date().toLocaleString()}*`;
downloadFile(content, filename + '.md', 'text/markdown');
} else if (format === 'json') {
content = JSON.stringify({ session_id: session, exported_at: new Date().toISOString(), messages: messages }, null, 2);
downloadFile(content, filename + '.json', 'application/json');
}
} catch (e) {
alert('Erreur export: ' + e.message);
}
}
function downloadFile(content, filename, type) {
const blob = new Blob([content], { type: type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function showExportMenu() {
if (!activeSession) {
alert('Sélectionnez d\'abord une conversation');
return;
}
const choice = prompt('Exporter en:\n1. Markdown\n2. JSON\n\nTapez 1 ou 2:');
if (choice === '1') exportSession(activeSession, 'markdown');
else if (choice === '2') exportSession(activeSession, 'json');
}
// Event listener for model select
document.getElementById("model-select").addEventListener("change", (e) => {
selectedModel = e.target.value;
});
loadWorkflows();
input.focus();
</script>
</body>
</html>

197
agent_ia_config.html Normal file
View File

@@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agent IA - Configuration</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f0f23; color: #eee; min-height: 100vh; }
header { background: linear-gradient(90deg, #1a1a2e, #0f3460); padding: 15px 20px; border-bottom: 2px solid #00d9ff; display: flex; align-items: center; gap: 15px; }
header a { color: #888; text-decoration: none; font-size: 14px; }
header a:hover { color: #00d9ff; }
header h1 { font-size: 18px; background: linear-gradient(90deg, #00d9ff, #e94560); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.container { max-width: 700px; margin: 30px auto; padding: 0 20px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 24px; margin-bottom: 20px; }
.card h2 { font-size: 16px; margin-bottom: 16px; color: #00d9ff; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 13px; color: #888; margin-bottom: 6px; }
.form-group select, .form-group input { width: 100%; padding: 10px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 8px; color: #eee; font-size: 14px; outline: none; }
.form-group select:focus, .form-group input:focus { border-color: #00d9ff; }
.btn { padding: 10px 20px; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
.btn-danger { background: #d23232; color: #fff; }
.btn-danger:hover { background: #b82828; }
.btn-danger:disabled { background: #30363d; color: #666; cursor: not-allowed; }
.info { padding: 12px 16px; background: rgba(0,217,255,0.08); border: 1px solid rgba(0,217,255,0.2); border-radius: 8px; font-size: 13px; color: #aaa; margin-bottom: 16px; }
.success { padding: 12px 16px; background: rgba(40,167,69,0.15); border: 1px solid rgba(40,167,69,0.3); border-radius: 8px; font-size: 13px; color: #6f6; display: none; }
.error { padding: 12px 16px; background: rgba(210,50,50,0.15); border: 1px solid rgba(210,50,50,0.3); border-radius: 8px; font-size: 13px; color: #f88; display: none; }
.stats { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; }
.stat { background: #0d1117; border-radius: 8px; padding: 16px; text-align: center; }
.stat-value { font-size: 24px; font-weight: bold; color: #00d9ff; }
.stat-label { font-size: 11px; color: #666; margin-top: 4px; text-transform: uppercase; }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<header>
<a href="/agent-ia">&#8592; Retour</a>
<h1>Configuration - Gestion de l'historique</h1>
</header>
<div class="container">
<div class="card">
<h2>Statistiques</h2>
<div class="stats" id="stats">
<div class="stat"><div class="stat-value" id="total-sessions">-</div><div class="stat-label">Sessions</div></div>
<div class="stat"><div class="stat-value" id="total-messages">-</div><div class="stat-label">Messages</div></div>
</div>
</div>
<div class="card">
<h2>Supprimer l'historique</h2>
<div class="info">
Cette action supprimera définitivement les messages plus anciens que la période sélectionnée. Les sessions vides seront également supprimées.
</div>
<div class="form-group">
<label>Workflow cible</label>
<select id="workflow-select"></select>
</div>
<div class="form-group">
<label>Période de rétention</label>
<select id="days-select">
<option value="7">7 jours</option>
<option value="14">14 jours</option>
<option value="30" selected>30 jours</option>
<option value="60">60 jours</option>
<option value="90">90 jours</option>
<option value="180">6 mois</option>
<option value="365">1 an</option>
</select>
</div>
<button class="btn btn-danger" id="cleanup-btn" onclick="cleanup()">Supprimer les messages anciens</button>
<div class="success" id="success-msg"></div>
<div class="error" id="error-msg"></div>
</div>
<div class="card">
<h2>Supprimer toutes les conversations</h2>
<div class="info" style="border-color: rgba(210,50,50,0.3); background: rgba(210,50,50,0.08);">
Attention : cette action est irréversible. Toutes les sessions et messages du workflow sélectionné seront supprimés.
</div>
<div class="form-group">
<label>Workflow cible</label>
<select id="workflow-select-all"></select>
</div>
<button class="btn btn-danger" onclick="deleteAll()">Tout supprimer</button>
</div>
</div>
<script>
let workflows = [];
async function loadWorkflows() {
try {
const resp = await fetch('/api/chat/workflows');
workflows = await resp.json();
const opts = workflows.map(w => `<option value="${w.slug}">${w.name}</option>`).join('');
document.getElementById('workflow-select').innerHTML = opts;
document.getElementById('workflow-select-all').innerHTML = opts;
loadStats();
} catch (e) {
console.error('Erreur:', e);
}
}
async function loadStats() {
const wf = document.getElementById('workflow-select').value;
if (!wf) return;
try {
const sessions = await (await fetch(`/api/chat/sessions?workflow=${wf}`)).json();
let totalMsg = 0;
for (const s of sessions) {
const msgs = await (await fetch(`/api/chat/history?session_id=${s.session_id}&workflow=${wf}`)).json();
totalMsg += msgs.length;
}
document.getElementById('total-sessions').textContent = sessions.length;
document.getElementById('total-messages').textContent = totalMsg;
} catch (e) {}
}
async function cleanup() {
const wf = document.getElementById('workflow-select').value;
const days = parseInt(document.getElementById('days-select').value);
const btn = document.getElementById('cleanup-btn');
const successEl = document.getElementById('success-msg');
const errorEl = document.getElementById('error-msg');
successEl.style.display = 'none';
errorEl.style.display = 'none';
btn.disabled = true;
btn.textContent = 'Suppression...';
try {
const resp = await fetch('/api/chat/cleanup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow: wf, days: days })
});
const data = await resp.json();
if (data.error) throw new Error(data.error);
successEl.textContent = `${data.deleted} messages supprimés.`;
successEl.style.display = 'block';
loadStats();
} catch (e) {
errorEl.textContent = 'Erreur: ' + e.message;
errorEl.style.display = 'block';
} finally {
btn.disabled = false;
btn.textContent = 'Supprimer les messages anciens';
}
}
async function deleteAll() {
if (!confirm('Êtes-vous sûr de vouloir supprimer TOUTES les conversations de ce workflow ?')) return;
if (!confirm('Cette action est IRRÉVERSIBLE. Confirmer ?')) return;
const wf = document.getElementById('workflow-select-all').value;
const successEl = document.getElementById('success-msg');
const errorEl = document.getElementById('error-msg');
successEl.style.display = 'none';
errorEl.style.display = 'none';
try {
const sessions = await (await fetch(`/api/chat/sessions?workflow=${wf}`)).json();
let deleted = 0;
for (const s of sessions) {
await fetch(`/api/chat/session?session_id=${s.session_id}&workflow=${wf}`, { method: 'DELETE' });
deleted++;
}
successEl.textContent = `${deleted} sessions supprimées.`;
successEl.style.display = 'block';
loadStats();
} catch (e) {
errorEl.textContent = 'Erreur: ' + e.message;
errorEl.style.display = 'block';
}
}
loadWorkflows();
</script>
</body>
</html>

347
agent_turf.py Executable file
View File

@@ -0,0 +1,347 @@
#!/usr/bin/env python3
"""
Agent Turf Autonome - Prédictions quotidiennes
Usage: python3 agent_turf.py [--dry-run]
"""
import sqlite3
import subprocess
import sys
import json
import os
from datetime import datetime, timedelta
from pathlib import Path
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
LOG_FILE = "/home/h3r7/turf_scraper/logs/agent_turf.log"
VPS_HOST = "10.0.1.1"
def log(msg: str):
"""Log avec timestamp"""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[{timestamp}] {msg}"
print(line)
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
with open(LOG_FILE, "a") as f:
f.write(line + "\n")
def sync_database():
"""Sync base de données depuis VPS"""
log("=== SYNC DATABASE ===")
# Check remote size
cmd = f"sshpass -p 'Cronstadt35*' ssh -o StrictHostKeyChecking=no h3r7@{VPS_HOST} stat -c %s {DB_PATH}"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.returncode != 0:
log(f"ERROR: Cannot connect to VPS")
return False
remote_size = int(result.stdout.strip())
local_size = os.path.getsize(DB_PATH) if os.path.exists(DB_PATH) else 0
if remote_size == local_size:
log(f"Database up to date ({remote_size} bytes)")
return True
log(f"Syncing database (remote: {remote_size}, local: {local_size})...")
cmd = f"sshpass -p 'Cronstadt35*' scp -o StrictHostKeyChecking=no h3r7@{VPS_HOST}:{DB_PATH} {DB_PATH}.tmp"
result = subprocess.run(cmd, shell=True, capture_output=True)
if result.returncode == 0:
os.rename(DB_PATH + ".tmp", DB_PATH)
log(f"Database synced successfully")
return True
else:
log(f"ERROR: Sync failed")
return False
def generate_predictions():
"""Génère les prédictions du jour"""
log("=== GENERATE PREDICTIONS ===")
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
today = datetime.now().strftime("%Y-%m-%d")
# Get today's races
cursor.execute(
"""
SELECT DISTINCT c.date_programme, c.num_reunion, c.num_course,
c.libelle, c.heure_depart_str, r.hippodrome_court
FROM pmu_courses c
JOIN pmu_reunions r ON r.date_programme = c.date_programme AND r.num_reunion = c.num_reunion
WHERE c.date_programme = ? AND r.pays_code = 'FRA'
ORDER BY c.heure_depart_str
""",
(today,),
)
races = cursor.fetchall()
log(f"Found {len(races)} races today")
predictions = []
for race in races:
date, num_reunion, num_course, libelle, heure, hippodrome = race
# Get scoring
cursor.execute(
"""
SELECT horse_name, horse_number, score, cote, rang_scoring
FROM scoring
WHERE date = ? AND race_name LIKE ?
ORDER BY rang_scoring
LIMIT 4
""",
(date, f"%{libelle}%"),
)
scores = cursor.fetchall()
if scores:
pred = {
"race": f"{heure} - {hippodrome} - {libelle}",
"top4": [dict(s) for s in scores],
}
predictions.append(pred)
log(
f" {pred['race'][:50]} -> {', '.join([s['horse_name'] for s in scores[:3]])}"
)
conn.close()
return predictions
def save_recommendations(predictions):
"""Sauvegarde les recommandations en base"""
log("=== SAVE RECOMMENDATIONS ===")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
today = datetime.now().strftime("%Y-%m-%d")
# Clear today's recommendations
cursor.execute("DELETE FROM recommendations WHERE date = ?", (today,))
for pred in predictions:
race_name = pred["race"]
# Add top 4
for i, horse in enumerate(pred["top4"]):
if i < 4: # TOP 4
cursor.execute(
"""
INSERT INTO recommendations
(date, race_name, type_pari, cheval1, numero1, cote, mise, confiance, justification)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
today,
race_name,
f"top{i + 1}",
horse["horse_name"],
horse["horse_number"],
horse["cote"],
2.0,
f"TOP {i + 1}",
f"Score: {horse['score']}, Rang scoring: {horse['rang_scoring']}",
),
)
# Add simple winner (top 1)
top1 = pred["top4"][0]
cursor.execute(
"""
INSERT INTO recommendations
(date, race_name, type_pari, cheval1, numero1, cote, mise, confiance, justification)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
today,
race_name,
"simple_gagnant",
top1["horse_name"],
top1["horse_number"],
top1["cote"],
2.0,
"🔥 FORTE" if top1["score"] > 60 else "✅ BONNE",
f"Score: {top1['score']}, Favorite prediction",
),
)
conn.commit()
conn.close()
log(f"Saved {len(predictions)} race recommendations")
def notify_telegram(predictions):
"""Envoie les prédictions du jour via Telegram"""
import urllib.request
config_path = "/home/h3r7/turf_scraper/config_telegram.json"
if not os.path.exists(config_path):
log("No Telegram config found, skipping notification")
return
try:
with open(config_path, "r") as f:
cfg = json.load(f)
token = cfg.get("token", "")
chat_id = cfg.get("chat_id", "")
if not token or not chat_id:
log("Telegram config missing token or chat_id")
return
except Exception as e:
log(f"Error reading Telegram config: {e}")
return
today = datetime.now().strftime("%d/%m/%Y")
lines = [f"🏇 *Prédictions Turf — {today}*", ""]
if not predictions:
lines.append("Aucune prédiction disponible aujourd'hui.")
else:
for pred in predictions:
race_label = pred.get("race", "Course inconnue")
top4 = pred.get("top4", [])
lines.append(f"📍 *{race_label}*")
for i, horse in enumerate(top4, 1):
name = horse.get("horse_name", "?")
score = horse.get("score", 0)
cote = horse.get("cote", "?")
icon = "🥇" if i == 1 else ("🥈" if i == 2 else ("🥉" if i == 3 else "4"))
lines.append(f" {icon} {name} — score: {score} — cote: {cote}")
lines.append("")
text = "\n".join(lines)
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = json.dumps({
"chat_id": chat_id,
"text": text,
"parse_mode": "Markdown"
}).encode("utf-8")
try:
req = urllib.request.Request(
url,
data=payload,
headers={"Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read().decode("utf-8"))
if result.get("ok"):
log(f"Telegram notification sent ({len(predictions)} races)")
else:
log(f"Telegram API error: {result}")
except Exception as e:
log(f"Error sending Telegram notification: {e}")
def run_backtest_yesterday():
"""Lance le backtest sur les résultats d'hier"""
log("=== RUN BACKTEST ===")
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
# Check if we have results for yesterday
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"""
SELECT COUNT(*) FROM pmu_partants
WHERE date_programme = ? AND ordre_arrivee > 0
""",
(yesterday,),
)
count = cursor.fetchone()[0]
conn.close()
if count > 0:
log(f"Running backtest for {yesterday}...")
# Run backtest script
cmd = f"cd /home/h3r7/turf_scraper && python3 backtest_analyzer.py --date {yesterday}"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.returncode == 0:
log("Backtest completed")
else:
log(f"Backtest failed: {result.stderr}")
else:
log(f"No results for {yesterday}, skipping backtest")
def update_paris_results():
"""Met à jour les paris avec les résultats PMU"""
log("=== UPDATE PARIS RESULTS ===")
today = datetime.now().strftime("%Y-%m-%d")
cmd = (
f"cd /home/h3r7/turf_scraper && python3 update_paris_results.py --date {today}"
)
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.returncode == 0:
log(f"Paris updated: {result.stdout.strip()}")
else:
log(f"Paris update failed: {result.stderr}")
def main():
"""Point d'entrée principal"""
dry_run = "--dry-run" in sys.argv
log("=" * 50)
log("🤖 AGENT TURF AUTONOME - DEMARRAGE")
log("=" * 50)
try:
# 1. Sync database
if not dry_run:
sync_database()
# 2. Generate predictions
predictions = generate_predictions()
# 3. Save recommendations
if not dry_run and predictions:
save_recommendations(predictions)
# 3b. Send Telegram notification
if not dry_run:
notify_telegram(predictions)
# 4. Create paris from recommendations
if not dry_run and predictions:
update_paris_results()
# 5. Run backtest on yesterday
if not dry_run:
run_backtest_yesterday()
log("=" * 50)
log("✅ AGENT TURF - TERMINÉ")
log("=" * 50)
except Exception as e:
log(f"❌ ERREUR: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()

524
analyse_rex.py Executable file
View File

@@ -0,0 +1,524 @@
#!/usr/bin/env python3
"""
Analyse REX - Corrélations et calibration des poids du scoring
Lit historical_data + performance pour améliorer le modèle de prédiction
"""
import sqlite3
import json
import os
import math
from datetime import datetime, timedelta
DB_PATH = os.environ.get("DB_PATH", "/home/h3r7/turf_scraper/turf.db")
# ============================================================
# UTILITAIRES STATISTIQUES
# ============================================================
def moyenne(valeurs):
v = [x for x in valeurs if x is not None]
return sum(v) / len(v) if v else 0
def ecart_type(valeurs):
v = [x for x in valeurs if x is not None]
if len(v) < 2:
return 0
m = moyenne(v)
return math.sqrt(sum((x - m)**2 for x in v) / len(v))
def correlation_pearson(x_list, y_list):
"""Corrélation de Pearson entre deux listes"""
pairs = [(x, y) for x, y in zip(x_list, y_list) if x is not None and y is not None]
if len(pairs) < 5:
return 0
xs = [p[0] for p in pairs]
ys = [p[1] for p in pairs]
mx, my = moyenne(xs), moyenne(ys)
num = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
den = math.sqrt(sum((x - mx)**2 for x in xs) * sum((y - my)**2 for y in ys))
return round(num / den, 4) if den else 0
def taux_top5_par_segment(conn, feature, nb_segments=5):
"""
Découpe une feature en segments et calcule le taux top5 de chaque segment.
Permet de voir si la feature est discriminante.
"""
c = conn.cursor()
c.execute(f"""
SELECT {feature}, top5
FROM historical_data
WHERE {feature} IS NOT NULL AND {feature} > 0
ORDER BY {feature} ASC
""")
rows = c.fetchall()
if len(rows) < nb_segments:
return []
segment_size = len(rows) // nb_segments
segments = []
for i in range(nb_segments):
debut = i * segment_size
fin = debut + segment_size if i < nb_segments - 1 else len(rows)
seg = rows[debut:fin]
vals = [r[0] for r in seg]
top5s = [r[1] for r in seg]
segments.append({
'segment': i + 1,
'min': round(min(vals), 2),
'max': round(max(vals), 2),
'nb': len(seg),
'taux_top5': round(sum(top5s) / len(top5s) * 100, 1)
})
return segments
# ============================================================
# ANALYSES PRINCIPALES
# ============================================================
def analyse_volume(conn):
"""Statistiques générales sur les données disponibles"""
c = conn.cursor()
c.execute("""
SELECT COUNT(DISTINCT date) as jours,
COUNT(*) as lignes,
MIN(date) as debut,
MAX(date) as fin,
AVG(top1) as taux_gagnant,
AVG(top5) as taux_top5
FROM historical_data
""")
row = c.fetchone()
c.execute("SELECT COUNT(DISTINCT race_name) FROM historical_data")
nb_courses = c.fetchone()[0]
c.execute("""
SELECT discipline, COUNT(DISTINCT date) as nb
FROM historical_data
GROUP BY discipline
ORDER BY nb DESC
""")
disciplines = c.fetchall()
return {
'nb_jours': row[0],
'nb_lignes': row[1],
'nb_courses': nb_courses,
'debut': row[2],
'fin': row[3],
'taux_gagnant': round((row[4] or 0) * 100, 1),
'taux_top5': round((row[5] or 0) * 100, 1),
'disciplines': disciplines,
}
def analyse_correlations(conn):
"""
Calcule la corrélation de chaque feature avec top5 et top1.
Features négatives avec top5 = meilleur prédicteur (ex: cote basse → top5 élevé)
"""
c = conn.cursor()
c.execute("""
SELECT cote_directe, cote_reference, reduction_km,
forme_recente, tendance_forme, tx_victoire, tx_place,
gains_carriere, gains_annee, rang_cote, ratio_cote_field,
nb_courses, nb_victoires, age, driver_change,
indicateur_tendance, nb_disq,
top1, top5
FROM historical_data
WHERE cote_directe > 0
""")
rows = c.fetchall()
features = [
'cote_directe', 'cote_reference', 'reduction_km',
'forme_recente', 'tendance_forme', 'tx_victoire', 'tx_place',
'gains_carriere', 'gains_annee', 'rang_cote', 'ratio_cote_field',
'nb_courses', 'nb_victoires', 'age', 'driver_change',
'indicateur_tendance', 'nb_disq'
]
top1_vals = [r[17] for r in rows]
top5_vals = [r[18] for r in rows]
correlations = []
for i, feat in enumerate(features):
feat_vals = [r[i] for r in rows]
corr_top1 = correlation_pearson(feat_vals, top1_vals)
corr_top5 = correlation_pearson(feat_vals, top5_vals)
correlations.append({
'feature': feat,
'corr_top1': corr_top1,
'corr_top5': corr_top5,
'abs_top5': abs(corr_top5),
})
# Trier par corrélation absolue avec top5
correlations.sort(key=lambda x: x['abs_top5'], reverse=True)
return correlations
def analyse_cote(conn):
"""Analyse détaillée de la cote comme prédicteur"""
c = conn.cursor()
# Taux de réussite par tranche de cote
tranches = [
(0, 3, "Très favori (< 3)"),
(3, 6, "Favori (3-6)"),
(6, 10, "Second favori (6-10)"),
(10, 20, "Outsider (10-20)"),
(20, 50, "Longshot (20-50)"),
(50, 999, "Outsider extrême (50+)"),
]
resultats = []
for cmin, cmax, label in tranches:
c.execute("""
SELECT COUNT(*) as nb,
AVG(top1) as taux_gagnant,
AVG(top5) as taux_top5,
AVG(ordre_arrivee) as pos_moy
FROM historical_data
WHERE cote_directe >= ? AND cote_directe < ? AND ordre_arrivee > 0
""", (cmin, cmax))
row = c.fetchone()
if row[0] > 0:
resultats.append({
'tranche': label,
'nb': row[0],
'taux_gagnant': round((row[1] or 0) * 100, 1),
'taux_top5': round((row[2] or 0) * 100, 1),
'position_moy': round(row[3] or 0, 1),
})
return resultats
def analyse_forme(conn):
"""Analyse de la forme récente comme prédicteur"""
c = conn.cursor()
tranches = [
(0, 1.5, "Excellente (< 1.5)"),
(1.5, 3, "Bonne (1.5-3)"),
(3, 5, "Moyenne (3-5)"),
(5, 8, "Mauvaise (5-8)"),
(8, 99, "Très mauvaise (8+)"),
]
resultats = []
for fmin, fmax, label in tranches:
c.execute("""
SELECT COUNT(*) as nb,
AVG(top1) as taux_gagnant,
AVG(top5) as taux_top5
FROM historical_data
WHERE forme_recente >= ? AND forme_recente < ? AND ordre_arrivee > 0
""", (fmin, fmax))
row = c.fetchone()
if row[0] > 0:
resultats.append({
'tranche': label,
'nb': row[0],
'taux_gagnant': round((row[1] or 0) * 100, 1),
'taux_top5': round((row[2] or 0) * 100, 1),
})
return resultats
def analyse_avis_entraineur(conn):
"""Impact de l'avis entraîneur"""
c = conn.cursor()
c.execute("""
SELECT avis_entraineur,
COUNT(*) as nb,
AVG(top1) as taux_gagnant,
AVG(top5) as taux_top5,
AVG(cote_directe) as cote_moy
FROM historical_data
WHERE ordre_arrivee > 0
GROUP BY avis_entraineur
ORDER BY AVG(top5) DESC
""")
rows = c.fetchall()
return [{
'avis': r[0],
'nb': r[1],
'taux_gagnant': round((r[2] or 0) * 100, 1),
'taux_top5': round((r[3] or 0) * 100, 1),
'cote_moy': round(r[4] or 0, 1),
} for r in rows]
def analyse_driver_change(conn):
"""Impact du changement de driver"""
c = conn.cursor()
c.execute("""
SELECT driver_change,
COUNT(*) as nb,
AVG(top1) as taux_gagnant,
AVG(top5) as taux_top5
FROM historical_data
WHERE ordre_arrivee > 0
GROUP BY driver_change
""")
rows = c.fetchall()
return [{
'driver_change': 'Oui' if r[0] else 'Non',
'nb': r[1],
'taux_gagnant': round((r[2] or 0) * 100, 1),
'taux_top5': round((r[3] or 0) * 100, 1),
} for r in rows]
def analyse_top_chevaux(conn):
"""Chevaux les plus performants dans l'historique"""
c = conn.cursor()
c.execute("""
SELECT horse_name,
COUNT(*) as nb_courses,
SUM(top1) as nb_gagnant,
SUM(top5) as nb_top5,
AVG(cote_directe) as cote_moy
FROM historical_data
WHERE ordre_arrivee > 0
GROUP BY horse_name
HAVING COUNT(*) >= 3
ORDER BY AVG(top5) DESC, SUM(top1) DESC
LIMIT 15
""")
rows = c.fetchall()
return [{
'cheval': r[0],
'nb_courses': r[1],
'nb_gagnant': r[2],
'nb_top5': r[3],
'tx_top5': round(r[3] / r[1] * 100, 1),
'cote_moy': round(r[4] or 0, 1),
} for r in rows]
# ============================================================
# CALIBRATION DES POIDS
# ============================================================
def calibrer_poids(correlations):
"""
Recalcule les pondérations du scoring basé sur les corrélations REX.
Les features avec plus forte corrélation absolue reçoivent plus de poids.
"""
# Mapping feature → critère scoring actuel
feature_to_critere = {
'cote_directe': 'cote',
'forme_recente': 'forme',
'tx_victoire': 'tx_victoire',
'tx_place': 'tx_place',
'reduction_km': 'reduction_km',
'tendance_forme': 'tendance',
'rang_cote': 'cote', # corrélé à cote
}
# Agréger les corrélations par critère
criteres = {
'cote': [],
'forme': [],
'tx_victoire': [],
'tx_place': [],
'reduction_km':[],
'tendance': [],
'avis': [0.05], # valeur fixe (avis entraîneur difficile à corréler)
}
for corr in correlations:
critere = feature_to_critere.get(corr['feature'])
if critere and critere in criteres:
criteres[critere].append(corr['abs_top5'])
# Moyenne par critère
scores = {}
for critere, vals in criteres.items():
scores[critere] = moyenne(vals) if vals else 0.01
# Normaliser pour que la somme = 100%
total = sum(scores.values())
poids_calibres = {k: round(v / total * 100, 1) for k, v in scores.items()}
# Poids actuels (référence)
poids_actuels = {
'cote': 20.0,
'forme': 25.0,
'tx_victoire': 15.0,
'tx_place': 15.0,
'reduction_km': 10.0,
'tendance': 10.0,
'avis': 5.0,
}
return {
'poids_actuels': poids_actuels,
'poids_calibres': poids_calibres,
'delta': {k: round(poids_calibres.get(k, 0) - poids_actuels.get(k, 0), 1)
for k in poids_actuels}
}
# ============================================================
# RAPPORT
# ============================================================
def print_rapport(volume, correlations, cote_analyse, forme_analyse,
avis_analyse, driver_analyse, top_chevaux, poids):
print(f"\n{'='*65}")
print(f"📊 ANALYSE REX — {datetime.now().strftime('%d/%m/%Y %H:%M')}")
print(f"{'='*65}")
# Volume
print(f"\n📦 DONNÉES DISPONIBLES")
print(f" Jours : {volume['nb_jours']}")
print(f" Courses : {volume['nb_courses']}")
print(f" Partants : {volume['nb_lignes']}")
print(f" Période : {volume['debut']}{volume['fin']}")
print(f" Taux top5 moyen : {volume['taux_top5']}%")
for disc, nb in volume['disciplines']:
print(f" {disc:<20} : {nb} courses")
# Corrélations
print(f"\n🔗 CORRÉLATIONS FEATURES → TOP5 (triées par force)")
print(f" {'FEATURE':<22} {'CORR TOP5':>10} {'CORR TOP1':>10} {'FORCE'}")
print(f" {''*55}")
for c in correlations[:10]:
force = '●●●' if c['abs_top5'] > 0.15 else '●●' if c['abs_top5'] > 0.08 else ''
direction = '▼ (inverse)' if c['corr_top5'] < 0 else '▲ (direct) '
print(f" {c['feature']:<22} {c['corr_top5']:>10.4f} {c['corr_top1']:>10.4f} {force} {direction}")
# Analyse cote
print(f"\n🎰 TAUX DE RÉUSSITE PAR TRANCHE DE COTE")
print(f" {'TRANCHE':<28} {'NB':>5} {'GAGNANT':>8} {'TOP5':>6} {'POS MOY':>8}")
print(f" {''*60}")
for t in cote_analyse:
print(f" {t['tranche']:<28} {t['nb']:>5} {t['taux_gagnant']:>7.1f}% {t['taux_top5']:>5.1f}% {t['position_moy']:>8.1f}")
# Analyse forme
print(f"\n🏃 TAUX DE RÉUSSITE PAR FORME RÉCENTE")
print(f" {'FORME':<28} {'NB':>5} {'GAGNANT':>8} {'TOP5':>6}")
print(f" {''*50}")
for t in forme_analyse:
print(f" {t['tranche']:<28} {t['nb']:>5} {t['taux_gagnant']:>7.1f}% {t['taux_top5']:>5.1f}%")
# Avis entraîneur
print(f"\n👨‍🏫 IMPACT AVIS ENTRAÎNEUR")
print(f" {'AVIS':<20} {'NB':>5} {'GAGNANT':>8} {'TOP5':>6} {'COTE MOY':>9}")
print(f" {''*52}")
for a in avis_analyse:
print(f" {a['avis']:<20} {a['nb']:>5} {a['taux_gagnant']:>7.1f}% {a['taux_top5']:>5.1f}% {a['cote_moy']:>9.1f}")
# Driver change
print(f"\n🔄 IMPACT CHANGEMENT DE DRIVER")
for d in driver_analyse:
print(f" Changement {d['driver_change']:<4} : {d['nb']} courses · "
f"gagnant {d['taux_gagnant']}% · top5 {d['taux_top5']}%")
# Top chevaux
if top_chevaux:
print(f"\n🏆 TOP CHEVAUX (≥3 courses)")
print(f" {'CHEVAL':<28} {'COURSES':>8} {'GAGNANT':>8} {'TOP5':>6} {'TX TOP5':>8} {'COTE MOY':>9}")
print(f" {''*70}")
for h in top_chevaux[:10]:
print(f" {h['cheval']:<28} {h['nb_courses']:>8} {h['nb_gagnant']:>8} "
f"{h['nb_top5']:>6} {h['tx_top5']:>7.1f}% {h['cote_moy']:>9.1f}")
# Calibration poids
print(f"\n⚖️ CALIBRATION DES POIDS DU SCORING")
print(f" {'CRITÈRE':<16} {'ACTUEL':>8} {'CALIBRÉ':>8} {'DELTA':>8} RECOMMANDATION")
print(f" {''*60}")
for critere in poids['poids_actuels']:
actuel = poids['poids_actuels'][critere]
calibre = poids['poids_calibres'].get(critere, actuel)
delta = poids['delta'].get(critere, 0)
if delta > 2:
reco = "↑ AUGMENTER"
elif delta < -2:
reco = "↓ RÉDUIRE"
else:
reco = "→ OK"
print(f" {critere:<16} {actuel:>7.1f}% {calibre:>7.1f}% {delta:>+7.1f}% {reco}")
nb_jours = volume['nb_jours']
if nb_jours < 30:
print(f"\n⚠️ Données insuffisantes ({nb_jours} jours) — attendez 30+ jours avant d'appliquer la calibration")
elif nb_jours < 100:
print(f"\n✅ Données suffisantes pour calibration Phase 2 ({nb_jours} jours)")
print(f" Recommandation : appliquer les nouveaux poids dans scoring.py")
else:
print(f"\n🚀 Données suffisantes pour ML Phase 3 ({nb_jours} jours)")
print(f" Recommandation : entraîner un modèle XGBoost")
print(f"\n{'='*65}\n")
# ============================================================
# MAIN
# ============================================================
def main():
print(f"\n{'='*65}")
print(f"🧠 ANALYSE REX — {datetime.now().strftime('%d/%m/%Y %H:%M')}")
print(f"{'='*65}\n")
conn = sqlite3.connect(DB_PATH)
# Vérifier les données
c = conn.cursor()
c.execute("SELECT COUNT(*) FROM historical_data")
nb = c.fetchone()[0]
if nb == 0:
print("❌ Aucune donnée dans historical_data.")
print(" Lancez d'abord : python3 historical_loader.py --days 365")
conn.close()
return
print(f"{nb} lignes trouvées dans historical_data\n")
# Analyses
print("📊 Calcul des statistiques...")
volume = analyse_volume(conn)
correlations= analyse_correlations(conn)
cote_analyse= analyse_cote(conn)
forme_analyse= analyse_forme(conn)
avis_analyse= analyse_avis_entraineur(conn)
driver_analyse= analyse_driver_change(conn)
top_chevaux = analyse_top_chevaux(conn)
poids = calibrer_poids(correlations)
conn.close()
# Afficher le rapport
print_rapport(volume, correlations, cote_analyse, forme_analyse,
avis_analyse, driver_analyse, top_chevaux, poids)
# Sauvegarder le rapport JSON
rapport = {
'date': datetime.now().isoformat(),
'volume': volume,
'correlations': correlations,
'cote_analyse': cote_analyse,
'forme_analyse': forme_analyse,
'avis_analyse': avis_analyse,
'driver_analyse': driver_analyse,
'top_chevaux': top_chevaux,
'poids_calibres': poids,
}
turf_dir = os.environ.get('TURF_DIR', '/home/h3r7/turf_scraper')
path = f"{turf_dir}/rex_analyse_{datetime.now().strftime('%Y%m%d')}.json"
with open(path, 'w', encoding='utf-8') as f:
json.dump(rapport, f, indent=2, ensure_ascii=False)
print(f"📁 Rapport sauvegardé : {path}")
if __name__ == "__main__":
main()

423
analytics_reports.py Normal file
View File

@@ -0,0 +1,423 @@
#!/usr/bin/env python3
"""
Analytics et Rapports Automatisés - Turf Scraper
Génère des rapports quotidiens, hebdomadaires et mensuels
"""
import sqlite3
import json
from datetime import datetime, timedelta
from typing import Dict, List, Optional
from pathlib import Path
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
def get_db():
"""Connexion à la base"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def get_daily_report(date: str = None) -> dict:
"""
Génère un rapport quotidien
"""
if date is None:
date = datetime.now().strftime('%Y-%m-%d')
conn = get_db()
c = conn.cursor()
report = {
'date': date,
'generated_at': datetime.now().isoformat(),
'races': {},
'predictions': {},
'results': {}
}
try:
c.execute("""
SELECT id, num_reunion, num_course, libelle, libelle_court,
discipline, distance, heure_depart_str
FROM pmu_courses
WHERE date_programme = ?
ORDER BY num_reunion, num_course
""", (date,))
races = c.fetchall()
report['races']['count'] = len(races)
report['races']['details'] = [dict(r) for r in races[:10]]
c.execute("""
SELECT race_name, race_hippodrome, COUNT(*) as pred_count
FROM predictions
WHERE date = ?
GROUP BY race_name
""", (date,))
preds = c.fetchall()
report['predictions']['count'] = sum(p['pred_count'] for p in preds)
report['predictions']['by_race'] = [dict(p) for p in preds]
c.execute("""
SELECT p.nom as cheval, p.num_pmu as numero, p.ordre_arrivee as position,
p.cote_direct as cote, p.driver, c.libelle as course
FROM pmu_partants p
JOIN pmu_courses c ON p.date_programme = c.date_programme
AND p.num_reunion = c.num_reunion AND p.num_course = c.num_course
WHERE p.date_programme = ? AND p.ordre_arrivee > 0
ORDER BY c.num_reunion, c.num_course, p.ordre_arrivee
""", (date,))
results = c.fetchall()
report['results']['count'] = len(results)
report['results']['details'] = [dict(r) for r in results[:20]]
c.execute("""
SELECT COUNT(*) as total,
SUM(CASE WHEN ordre_arrivee = 1 THEN 1 ELSE 0 END) as wins,
SUM(CASE WHEN ordre_arrivee IN (1,2,3) THEN 1 ELSE 0 END) as podiums,
AVG(CASE WHEN ordre_arrivee > 0 THEN cote_direct ELSE NULL END) as avg_cote
FROM pmu_partants
WHERE date_programme = ? AND ordre_arrivee > 0
""", (date,))
stats = c.fetchone()
total = stats['total'] or 0
wins = stats['wins'] or 0
podiums = stats['podiums'] or 0
report['stats'] = {
'total_partants': total,
'wins': wins,
'podiums': podiums,
'win_rate': round(wins / total * 100, 1) if total > 0 else 0,
'podium_rate': round(podiums / total * 100, 1) if total > 0 else 0,
'avg_cote': round(stats['avg_cote'], 2) if stats['avg_cote'] else 0
}
except Exception as e:
report['error'] = str(e)
finally:
conn.close()
return report
def get_weekly_report(start_date: str = None, end_date: str = None) -> dict:
"""
Génère un rapport hebdomadaire
"""
if end_date is None:
end_date = datetime.now()
else:
end_date = datetime.strptime(end_date, '%Y-%m-%d')
if start_date is None:
start_date = end_date - timedelta(days=7)
else:
start_date = datetime.strptime(start_date, '%Y-%m-%d')
start_str = start_date.strftime('%Y-%m-%d')
end_str = end_date.strftime('%Y-%m-%d')
conn = get_db()
c = conn.cursor()
report = {
'start_date': start_str,
'end_date': end_str,
'generated_at': datetime.now().isoformat()
}
try:
c.execute("""
SELECT COUNT(DISTINCT date_programme) as race_days,
COUNT(DISTINCT id) as total_races
FROM pmu_courses
WHERE date_programme BETWEEN ? AND ?
""", (start_str, end_str))
overview = c.fetchone()
report['overview'] = {
'race_days': overview['race_days'] or 0,
'total_races': overview['total_races'] or 0
}
c.execute("""
SELECT COUNT(*) as total,
SUM(CASE WHEN ordre_arrivee = 1 THEN 1 ELSE 0 END) as wins,
SUM(CASE WHEN ordre_arrivee IN (1,2,3) THEN 1 ELSE 0 END) as podiums,
AVG(CASE WHEN ordre_arrivee > 0 THEN cote_direct ELSE NULL END) as avg_cote
FROM pmu_partants
WHERE date_programme BETWEEN ? AND ? AND ordre_arrivee > 0
""", (start_str, end_str))
stats = c.fetchone()
total = stats['total'] or 0
wins = stats['wins'] or 0
podiums = stats['podiums'] or 0
report['stats'] = {
'total_partants': total,
'wins': wins,
'podiums': podiums,
'win_rate': round(wins / total * 100, 1) if total > 0 else 0,
'podium_rate': round(podiums / total * 100, 1) if total > 0 else 0,
'avg_cote': round(stats['avg_cote'], 2) if stats['avg_cote'] else 0
}
c.execute("""
SELECT driver, COUNT(*) as wins,
AVG(CASE WHEN ordre_arrivee > 0 THEN ordre_arrivee ELSE NULL END) as avg_pos
FROM pmu_partants
WHERE date_programme BETWEEN ? AND ? AND ordre_arrivee = 1
GROUP BY driver
ORDER BY wins DESC
LIMIT 10
""", (start_str, end_str))
report['top_jockeys'] = [dict(r) for r in c.fetchall()]
c.execute("""
SELECT discipline, COUNT(DISTINCT c.id) as races,
COUNT(*) as partants,
SUM(CASE WHEN ordre_arrivee = 1 THEN 1 ELSE 0 END) as wins
FROM pmu_courses c
LEFT JOIN pmu_partants p ON c.date_programme = p.date_programme
AND c.num_reunion = p.num_reunion AND c.num_course = p.num_course
WHERE c.date_programme BETWEEN ? AND ?
GROUP BY discipline
ORDER BY races DESC
""", (start_str, end_str))
report['by_discipline'] = [dict(r) for r in c.fetchall()]
c.execute("""
SELECT date_programme, COUNT(*) as partants,
SUM(CASE WHEN ordre_arrivee = 1 THEN 1 ELSE 0 END) as wins
FROM pmu_partants
WHERE date_programme BETWEEN ? AND ?
GROUP BY date_programme
ORDER BY date_programme
""", (start_str, end_str))
report['by_day'] = [dict(r) for r in c.fetchall()]
except Exception as e:
report['error'] = str(e)
finally:
conn.close()
return report
def get_monthly_report(year: int = None, month: int = None) -> dict:
"""
Génère un rapport mensuel
"""
if year is None:
year = datetime.now().year
if month is None:
month = datetime.now().month
start_date = datetime(year, month, 1)
if month == 12:
end_date = datetime(year + 1, 1, 1) - timedelta(days=1)
else:
end_date = datetime(year, month + 1, 1) - timedelta(days=1)
start_str = start_date.strftime('%Y-%m-%d')
end_str = end_date.strftime('%Y-%m-%d')
conn = get_db()
c = conn.cursor()
report = {
'year': year,
'month': month,
'start_date': start_str,
'end_date': end_str,
'generated_at': datetime.now().isoformat()
}
try:
c.execute("""
SELECT COUNT(DISTINCT date_programme) as race_days,
COUNT(DISTINCT id) as total_races,
COUNT(*) as total_partants
FROM pmu_courses c
LEFT JOIN pmu_partants p ON c.date_programme = p.date_programme
AND c.num_reunion = p.num_reunion AND c.num_course = p.num_course
WHERE c.date_programme BETWEEN ? AND ?
""", (start_str, end_str))
overview = c.fetchone()
report['overview'] = {
'race_days': overview['race_days'] or 0,
'total_races': overview['total_races'] or 0,
'total_partants': overview['total_partants'] or 0
}
c.execute("""
SELECT COUNT(*) as total,
SUM(CASE WHEN ordre_arrivee = 1 THEN 1 ELSE 0 END) as wins,
SUM(CASE WHEN ordre_arrivee IN (1,2,3) THEN 1 ELSE 0 END) as podiums,
AVG(CASE WHEN ordre_arrivee > 0 THEN cote_direct ELSE NULL END) as avg_cote
FROM pmu_partants
WHERE date_programme BETWEEN ? AND ? AND ordre_arrivee > 0
""", (start_str, end_str))
stats = c.fetchone()
total = stats['total'] or 0
wins = stats['wins'] or 0
podiums = stats['podiums'] or 0
report['stats'] = {
'total_partants': total,
'wins': wins,
'podiums': podiums,
'win_rate': round(wins / total * 100, 1) if total > 0 else 0,
'podium_rate': round(podiums / total * 100, 1) if total > 0 else 0,
'avg_cote': round(stats['avg_cote'], 2) if stats['avg_cote'] else 0
}
c.execute("""
SELECT discipline, COUNT(DISTINCT c.id) as races,
SUM(CASE WHEN ordre_arrivee = 1 THEN 1 ELSE 0 END) as wins,
AVG(CASE WHEN ordre_arrivee > 0 THEN ordre_arrivee ELSE NULL END) as avg_pos
FROM pmu_courses c
LEFT JOIN pmu_partants p ON c.date_programme = p.date_programme
AND c.num_reunion = p.num_reunion AND c.num_course = p.num_course
WHERE c.date_programme BETWEEN ? AND ?
GROUP BY discipline
ORDER BY races DESC
LIMIT 20
""", (start_str, end_str))
report['by_discipline'] = [dict(r) for r in c.fetchall()]
c.execute("""
SELECT date_programme, COUNT(*) as partants,
SUM(CASE WHEN ordre_arrivee = 1 THEN 1 ELSE 0 END) as wins
FROM pmu_partants
WHERE date_programme BETWEEN ? AND ?
GROUP BY date_programme
ORDER BY date_programme
""", (start_str, end_str))
report['daily_breakdown'] = [dict(r) for r in c.fetchall()]
except Exception as e:
report['error'] = str(e)
finally:
conn.close()
return report
def format_report_markdown(report: dict, report_type: str = 'daily') -> str:
"""
Formate un rapport en Markdown
"""
lines = []
if report_type == 'daily':
lines.append(f"# 📊 Rapport Quotidien - {report.get('date', 'N/A')}")
lines.append("")
lines.append(f"*Généré le {report.get('generated_at', '')}*")
lines.append("")
if 'error' in report:
lines.append(f"❌ Erreur: {report['error']}")
return "\n".join(lines)
races = report.get('races', {})
stats = report.get('stats', {})
lines.append("## Résumé")
lines.append(f"- 🏇 Courses: {races.get('count', 0)}")
lines.append(f"- 🎯 Partants: {stats.get('total_partants', 0)}")
lines.append(f"- 🏆 Victoires: {stats.get('wins', 0)} ({stats.get('win_rate', 0)}%)")
lines.append(f"- 🥉 Podiums: {stats.get('podiums', 0)} ({stats.get('podium_rate', 0)}%)")
lines.append("")
elif report_type == 'weekly':
lines.append(f"# 📈 Rapport Hebdomadaire")
lines.append(f"**{report.get('start_date', '')}** au **{report.get('end_date', '')}**")
lines.append("")
if 'error' in report:
lines.append(f"❌ Erreur: {report['error']}")
return "\n".join(lines)
overview = report.get('overview', {})
stats = report.get('stats', {})
lines.append("## Résumé")
lines.append(f"- Jours de course: {overview.get('race_days', 0)}")
lines.append(f"- Courses totales: {overview.get('total_races', 0)}")
lines.append(f"- Victoires: {stats.get('wins', 0)} ({stats.get('win_rate', 0)}%)")
lines.append(f"- Podiums: {stats.get('podiums', 0)} ({stats.get('podium_rate', 0)}%)")
lines.append(f"- Cote moyenne: {stats.get('avg_cote', 0)}")
lines.append("")
if report.get('top_jockeys'):
lines.append("## Top Jockeys")
for j in report['top_jockeys'][:5]:
lines.append(f"- {j['driver']}: {j['wins']} victoires")
lines.append("")
elif report_type == 'monthly':
month_name = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
m = report.get('month', 1)
lines.append(f"# 📅 Rapport Mensuel - {month_name[m-1]} {report.get('year', '')}")
lines.append("")
if 'error' in report:
lines.append(f"❌ Erreur: {report['error']}")
return "\n".join(lines)
overview = report.get('overview', {})
stats = report.get('stats', {})
lines.append("## Résumé du Mois")
lines.append(f"- Jours de course: {overview.get('race_days', 0)}")
lines.append(f"- Courses: {overview.get('total_races', 0)}")
lines.append(f"- Partants: {overview.get('total_partants', 0)}")
lines.append(f"- Victoires: {stats.get('wins', 0)} ({stats.get('win_rate', 0)}%)")
lines.append(f"- Podiums: {stats.get('podiums', 0)} ({stats.get('podium_rate', 0)}%)")
lines.append(f"- Cote moyenne: {stats.get('avg_cote', 0)}")
lines.append("")
return "\n".join(lines)
if __name__ == '__main__':
import sys
if len(sys.argv) < 2:
print("Usage: python analytics_reports.py [daily|weekly|monthly] [date]")
sys.exit(1)
report_type = sys.argv[1]
date_arg = sys.argv[2] if len(sys.argv) > 2 else None
if report_type == 'daily':
report = get_daily_report(date_arg)
print(format_report_markdown(report, 'daily'))
elif report_type == 'weekly':
report = get_weekly_report(date_arg)
print(format_report_markdown(report, 'weekly'))
elif report_type == 'monthly':
if date_arg:
year, month = map(int, date_arg.split('-'))
else:
year, month = None, None
report = get_monthly_report(year, month)
print(format_report_markdown(report, 'monthly'))
else:
print(f"Type de rapport inconnu: {report_type}")

308
api_datagouv_reference.html Normal file
View File

@@ -0,0 +1,308 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Data.gouv.fr - Référence</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0d1117; color: #c9d1d9; min-height: 100vh; }
header { background: linear-gradient(90deg, #161b22, #0f3460); padding: 30px; text-align: center; border-bottom: 2px solid #00d9ff; }
header h1 { font-size: 2rem; background: linear-gradient(90deg, #00d9ff, #e94560); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header p { color: #888; margin-top: 10px; }
.container { max-width: 1200px; margin: 0 auto; padding: 30px; }
.endpoint { background: #161b22; border: 1px solid #30363d; border-radius: 12px; margin-bottom: 20px; overflow: hidden; }
.endpoint-header { padding: 20px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; transition: background 0.3s; }
.endpoint-header:hover { background: #1f2937; }
.method { padding: 6px 12px; border-radius: 6px; font-weight: 700; font-size: 0.85rem; margin-right: 15px; }
.method.get { background: rgba(63,185,80,0.2); color: #3fb950; }
.method.post { background: rgba(65,105,225,0.2); color: #6495ed; }
.method.put { background: rgba(210,153,34,0.2); color: #d29922; }
.method.delete { background: rgba(248,81,73,0.2); color: #f85149; }
.method.patch { background: rgba(188,140,200,0.2); color: #bc8cc8; }
.endpoint-path { font-family: 'Monaco', 'Menlo', monospace; color: #e6edf3; font-size: 1rem; }
.endpoint-desc { color: #8b949e; font-size: 0.9rem; margin-top: 5px; }
.toggle { color: #8b949e; font-size: 1.5rem; transition: transform 0.3s; }
.endpoint.active .toggle { transform: rotate(180deg); }
.endpoint-body { display: none; padding: 0 20px 20px; border-top: 1px solid #30363d; }
.endpoint.active .endpoint-body { display: block; }
.section { margin: 15px 0; }
.section h4 { color: #00d9ff; margin-bottom: 10px; font-size: 0.95rem; }
.section p { color: #a0a0a0; font-size: 0.9rem; line-height: 1.6; }
.params { background: #0d1117; border-radius: 8px; padding: 15px; }
.param { display: flex; padding: 8px 0; border-bottom: 1px solid #21262d; }
.param:last-child { border-bottom: none; }
.param-name { color: #ff7b72; font-family: monospace; min-width: 150px; }
.param-type { color: #bc8cc8; font-size: 0.85rem; min-width: 80px; }
.param-desc { color: #8b949e; font-size: 0.85rem; }
.example { background: #0d1117; border-radius: 8px; padding: 15px; margin-top: 15px; }
.example h5 { color: #58a6ff; margin-bottom: 10px; font-size: 0.9rem; }
.example code { font-family: 'Monaco', 'Menlo', monospace; font-size: 0.85rem; color: #e6edf3; word-break: break-all; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 30px; }
.stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 20px; text-align: center; }
.stat-card h3 { color: #00d9ff; font-size: 2rem; }
.stat-card p { color: #8b949e; font-size: 0.9rem; margin-top: 5px; }
.copy-btn { background: #238636; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer; font-size: 0.8rem; }
.copy-btn:hover { background: #2ea043; }
.search-box { margin-bottom: 20px; }
.search-box input { width: 100%; padding: 12px 15px; background: #0d1117; border: 1px solid #30363d; border-radius: 8px; color: #c9d1d9; font-size: 1rem; }
.search-box input:focus { outline: none; border-color: #00d9ff; }
.back-link { display: inline-block; margin: 20px; color: #00d9ff; text-decoration: none; }
.back-link:hover { text-decoration: underline; }
</style>
</head>
<body>
<header>
<h1>📡 API Data.gouv.fr</h1>
<p>Référence complète des endpoints - v1</p>
</header>
<a href="/" class="back-link">← Retour au portail</a>
<div class="container">
<div class="stats" id="stats"></div>
<div class="search-box">
<input type="text" id="searchInput" placeholder="Rechercher un endpoint..." onkeyup="filterEndpoints()">
</div>
<div id="endpoints"></div>
</div>
<script>
const API_BASE = 'https://www.data.gouv.fr/api/1';
const endpoints = [
// === DATASETS ===
{
category: '📊 Datasets',
endpoints: [
{ method: 'GET', path: '/datasets/', desc: 'Liste tous les datasets', params: ['q (string) - Recherche', 'page (int) - Page', 'page_size (int) - Résultats par page', 'sort (string) - Tri (created, last_modified, reuses, views)'] },
{ method: 'GET', path: '/datasets/{id}/', desc: 'Détails d\'un dataset', params: ['id (string) - ID ou slug du dataset'] },
{ method: 'POST', path: '/datasets/', desc: 'Créer un dataset', params: ['title (string)', 'description (string)', 'organization (string)', 'license (string)'], auth: true },
{ method: 'PUT', path: '/datasets/{id}/', desc: 'Modifier un dataset', params: ['id (string)', 'title, description...'], auth: true },
{ method: 'DELETE', path: '/datasets/{id}/', desc: 'Supprimer un dataset', params: ['id (string)'], auth: true },
{ method: 'GET', path: '/datasets/{id}/resources/', desc: 'Liste des ressources d\'un dataset', params: ['id (string)'] },
{ method: 'POST', path: '/datasets/{id}/resources/', desc: 'Ajouter une ressource', params: ['id (string)', 'title, url, format'], auth: true },
{ method: 'GET', path: '/datasets/{id}/issues/', desc: 'Liste des discussions', params: ['id (string)'] },
{ method: 'POST', path: '/datasets/{id}/issues/', desc: 'Créer une discussion', params: ['id (string)', 'title, comment'], auth: true },
{ method: 'GET', path: '/datasets/{id}/community_resources/', desc: 'Ressources communautaires', params: ['id (string)'] },
{ method: 'GET', path: '/datasets/{id}/followers/', desc: 'Abonnés du dataset', params: ['id (string)'] },
{ method: 'POST', path: '/datasets/{id}/followers/', desc: 'S\'abonner au dataset', params: ['id (string)'], auth: true },
{ method: 'DELETE', path: '/datasets/{id}/followers/', desc: 'Se désabonner', params: ['id (string)'], auth: true },
]
},
// === ORGANIZATIONS ===
{
category: '🏢 Organizations',
endpoints: [
{ method: 'GET', path: '/organizations/', desc: 'Liste des organisations', params: ['q (string)', 'page (int)', 'page_size (int)'] },
{ method: 'GET', path: '/organizations/{id}/', desc: 'Détails d\'une organisation', params: ['id (string) - ID ou slug'] },
{ method: 'POST', path: '/organizations/', desc: 'Créer une organisation', params: ['name, description, logo'], auth: true },
{ method: 'PUT', path: '/organizations/{id}/', desc: 'Modifier une organisation', params: ['id (string)'], auth: true },
{ method: 'DELETE', path: '/organizations/{id}/', desc: 'Supprimer une organisation', params: ['id (string)'], auth: true },
{ method: 'GET', path: '/organizations/{id}/datasets/', desc: 'Datasets de l\'organisation', params: ['id (string)'] },
{ method: 'GET', path: '/organizations/{id}/members/', desc: 'Membres de l\'organisation', params: ['id (string)'] },
{ method: 'POST', path: '/organizations/{id}/members/', desc: 'Ajouter un membre', params: ['id (string)', 'user, role'], auth: true },
{ method: 'PUT', path: '/organizations/{id}/members/{user_id}/', desc: 'Modifier un membre', params: ['id (string)', 'user_id (string)', 'role'], auth: true },
{ method: 'DELETE', path: '/organizations/{id}/members/{user_id}/', desc: 'Retirer un membre', params: ['id (string)', 'user_id (string)'], auth: true },
{ method: 'GET', path: '/organizations/{id}/followers/', desc: 'Abonnés de l\'org', params: ['id (string)'] },
]
},
// === USERS ===
{
category: '👤 Utilisateurs',
endpoints: [
{ method: 'GET', path: '/users/{id}/', desc: 'Détails d\'un utilisateur', params: ['id (string) - ID ou slug'] },
{ method: 'GET', path: '/users/{id}/datasets/', desc: 'Datasets de l\'utilisateur', params: ['id (string)'] },
{ method: 'GET', path: '/users/{id}/organizations/', desc: 'Organisations de l\'utilisateur', params: ['id (string)'] },
{ method: 'GET', path: '/users/{id}/reuses/', desc: 'Réutilisations de l\'utilisateur', params: ['id (string)'] },
{ method: 'GET', path: '/me/', desc: 'Profil de l\'utilisateur connecté', params: [], auth: true },
{ method: 'GET', path: '/me/datasets/', desc: 'Mes datasets', params: [], auth: true },
{ method: 'GET', path: '/me/organizations/', desc: 'Mes organisations', params: [], auth: true },
]
},
// === REUSES ===
{
category: '🔄 Réutilisations',
endpoints: [
{ method: 'GET', path: '/reuses/', desc: 'Liste des réutilisations', params: ['q (string)', 'page (int)', 'page_size (int)', 'type (string)'] },
{ method: 'GET', path: '/reuses/{id}/', desc: 'Détails d\'une réutilisation', params: ['id (string) - ID ou slug'] },
{ method: 'POST', path: '/reuses/', desc: 'Créer une réutilisation', params: ['title, description, dataset_id, type'], auth: true },
{ method: 'PUT', path: '/reuses/{id}/', desc: 'Modifier une réutilisation', params: ['id (string)'], auth: true },
{ method: 'DELETE', path: '/reuses/{id}/', desc: 'Supprimer une réutilisation', params: ['id (string)'], auth: true },
{ method: 'GET', path: '/reuses/types/', desc: 'Types de réutilisations', params: [] },
]
},
// === RESOURCES ===
{
category: '📁 Ressources',
endpoints: [
{ method: 'GET', path: '/resources/{id}/', desc: 'Détails d\'une ressource', params: ['id (string) - UUID'] },
{ method: 'PUT', path: '/resources/{id}/', desc: 'Modifier une ressource', params: ['id (string)'], auth: true },
{ method: 'DELETE', path: '/resources/{id}/', desc: 'Supprimer une ressource', params: ['id (string)'], auth: true },
{ method: 'GET', path: '/resources/{id}/download/', desc: 'Télécharger la ressource', params: ['id (string)'] },
{ method: 'POST', path: '/datasets/{id}/upload/', desc: 'Upload un fichier', params: ['id (string)', 'file (multipart)'], auth: true },
]
},
// === HARVEST ===
{
category: '🌾 Moissonnage',
endpoints: [
{ method: 'GET', path: '/harvest/backends/', desc: 'Sources de moissonnage', params: [] },
{ method: 'GET', path: '/harvest/sources/', desc: 'Sources configurées', params: [] },
{ method: 'POST', path: '/harvest/sources/', desc: 'Créer une source', params: ['name, url, backend'], auth: true },
{ method: 'GET', path: '/harvest/sources/{id}/', desc: 'Détails d\'une source', params: ['id (string)'] },
{ method: 'PUT', path: '/harvest/sources/{id}/', desc: 'Modifier une source', params: ['id (string)'], auth: true },
{ method: 'POST', path: '/harvest/sources/{id}/refresh/', desc: 'Relancer le moissonnage', params: ['id (string)'], auth: true },
{ method: 'DELETE', path: '/harvest/sources/{id}/', desc: 'Supprimer une source', params: ['id (string)'], auth: true },
]
},
// === GEO ===
{
category: '🗺️ Géographie',
endpoints: [
{ method: 'GET', path: '/spatial/granularities/', desc: 'Granularités spatiales', params: [] },
{ method: 'GET', path: '/spatial/zones/', desc: 'Zones géographiques', params: ['ids (string)', 'type (string)', 'page (int)'] },
{ method: 'GET', path: '/spatial/zones/{id}/', desc: 'Détails d\'une zone', params: ['id (string)'] },
{ method: 'GET', path: '/spatial/coverage/', desc: 'Couverture spatiale', params: [] },
]
},
// === SEARCH ===
{
category: '🔍 Recherche',
endpoints: [
{ method: 'GET', path: '/search/', desc: 'Recherche globale', params: ['q (string)', 'type (dataset|organization|reuse)', 'tag (string)', 'badge (string)', 'geozone (string)', 'organization (string)', 'owner (string)', 'page (int)', 'page_size (int)', 'sort (string)'] },
]
},
// === LICENSES ===
{
category: '📜 Licences',
endpoints: [
{ method: 'GET', path: '/licenses/', desc: 'Liste des licences', params: [] },
{ method: 'GET', path: '/licenses/{id}/', desc: 'Détails d\'une licence', params: ['id (string)'] },
]
},
// === FREQUENCIES ===
{
category: '⏰ Fréquences',
endpoints: [
{ method: 'GET', path: '/datasets/frequencies/', desc: 'Fréquences de mise à jour', params: [] },
]
},
// === TOPICS ===
{
category: '🏷️ Topics',
endpoints: [
{ method: 'GET', path: '/topics/', desc: 'Liste des topics', params: ['page (int)', 'page_size (int)'] },
{ method: 'GET', path: '/topics/{id}/', desc: 'Détails d\'un topic', params: ['id (string) - ID ou slug'] },
]
},
];
async function loadStats() {
const statsDiv = document.getElementById('stats');
const endpoints = [
{ url: '/datasets/?page_size=1', key: 'datasets' },
{ url: '/organizations/?page_size=1', key: 'organizations' },
{ url: '/reuses/?page_size=1', key: 'reuses' },
];
let statsHtml = '';
for (const e of endpoints) {
try {
const res = await fetch(API_BASE + e.url);
const data = await res.json();
statsHtml += `<div class="stat-card"><h3>${(data.total || 0).toLocaleString()}</h3><p>${e.key}</p></div>`;
} catch (err) {
statsHtml += `<div class="stat-card"><h3>?</h3><p>${e.key}</p></div>`;
}
}
statsDiv.innerHTML = statsHtml;
}
function renderEndpoints(filter = '') {
const container = document.getElementById('endpoints');
let html = '';
endpoints.forEach(cat => {
const filteredEndpoints = cat.endpoints.filter(ep =>
ep.path.toLowerCase().includes(filter.toLowerCase()) ||
ep.desc.toLowerCase().includes(filter.toLowerCase())
);
if (filteredEndpoints.length > 0) {
html += `<h2 style="margin: 30px 0 15px; color: #e6edf3;">${cat.category}</h2>`;
filteredEndpoints.forEach(ep => {
html += `
<div class="endpoint">
<div class="endpoint-header" onclick="toggleEndpoint(this)">
<div>
<span class="method ${ep.method.toLowerCase()}">${ep.method}</span>
<span class="endpoint-path">${ep.path}</span>
<div class="endpoint-desc">${ep.desc}${ep.auth ? ' 🔒' : ''}</div>
</div>
<span class="toggle">▼</span>
</div>
<div class="endpoint-body">
${ep.params.length > 0 ? `
<div class="section">
<h4>Paramètres</h4>
<div class="params">
${ep.params.map(p => {
const [name, ...descParts] = p.split(' - ');
return `<div class="param">
<span class="param-name">${name}</span>
<span class="param-type">${descParts[0] || 'string'}</span>
<span class="param-desc">${descParts.slice(1).join(' - ')}</span>
</div>`;
}).join('')}
</div>
</div>
` : ''}
<div class="section">
<h4>Exemple</h4>
<div class="example">
<h5>Requête</h5>
<code>curl -sL "${API_BASE}${ep.path.replace('{id}', 'example').replace('{user_id}', 'user123')}" | jq</code>
</div>
</div>
</div>
</div>
`;
});
}
});
container.innerHTML = html;
}
function toggleEndpoint(header) {
header.parentElement.classList.toggle('active');
}
function filterEndpoints() {
const query = document.getElementById('searchInput').value;
renderEndpoints(query);
}
loadStats();
renderEndpoints();
</script>
</body>
</html>

336
app.py Normal file
View File

@@ -0,0 +1,336 @@
#!/usr/bin/env python3
from flask import Flask, request, jsonify, redirect, send_file
import sqlite3
import os
import requests
import csv
import io
app = Flask(__name__)
DB_FILE = '/home/h3r7/depenses_trello/depenses.db'
def init_db():
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS depenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
prenom TEXT,
date TEXT,
libelle TEXT,
montant REAL,
status TEXT DEFAULT 'En attente',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)''')
c.execute('''CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT
)''')
c.execute('''CREATE TABLE IF NOT EXISTS prenoms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE
)''')
conn.commit()
conn.close()
def get_db():
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
return conn
init_db()
def parse_date(d):
if not d: return ""
if "/" in d:
parts = d.split("/")
if len(parts) == 3:
return f"{parts[2]}-{parts[1]}-{parts[0]}"
return d
@app.route('/')
def index():
with open('/home/h3r7/depenses_trello/templates/index.html', 'r') as f:
return f.read()
@app.route('/dashboard')
def dashboard():
with open('/home/h3r7/depenses_trello/templates/dashboard.html', 'r') as f:
return f.read()
# API Config
@app.route('/api/config')
def get_config():
conn = get_db()
c = conn.cursor()
# Get prenoms
c.execute('SELECT name FROM prenoms')
prenoms = [row[0] for row in c.fetchall()]
# Get format
c.execute("SELECT value FROM config WHERE key='format'")
format_row = c.fetchone()
format_val = format_row[0] if format_row else '{prenom} - {date} - {libelle} - {montant}'
# Get trello config
c.execute("SELECT value FROM config WHERE key='trello'")
trello_row = c.fetchone()
trello = {}
if trello_row:
import json
trello = json.loads(trello_row[0])
# Get categories
c.execute("SELECT value FROM config WHERE key='categories'")
cat_row = c.fetchone()
categories = cat_row[0].split(',') if cat_row else ['Courses', 'Essence', 'Loisirs', 'Maison', 'Santé', 'Transport', 'Autre']
conn.close()
return jsonify({
'format': format_val,
'prenoms': prenoms,
'categories': categories,
'trello': trello
})
# Add depense
@app.route('/api/add', methods=['POST'])
def add_depense():
conn = get_db()
c = conn.cursor()
c.execute('INSERT INTO depenses (prenom, date, libelle, montant, status, category) VALUES (?, ?, ?, ?, ?, ?)',
(request.form.get('prenom'), parse_date(request.form.get('date', '')),
request.form.get('libelle'), float(request.form.get('montant', 0)), 'En attente', request.form.get('category', 'Autre')))
conn.commit()
conn.close()
return redirect('/?page=saisie')
# Get depenses
@app.route('/api/depenses')
def get_depenses():
conn = get_db()
c = conn.cursor()
c.execute('SELECT id, prenom, date, libelle, montant, status, category FROM depenses ORDER BY date DESC')
rows = c.fetchall()
conn.close()
return jsonify([dict(row) for row in rows])
# Delete depense
@app.route('/api/del/<int:id>')
def delete_depense(id):
conn = get_db()
c = conn.cursor()
c.execute('DELETE FROM depenses WHERE id=?', (id,))
conn.commit()
conn.close()
return redirect('/?page=saisie')
# Clear all
@app.route('/api/clear', methods=['POST'])
def clear_depenses():
conn = get_db()
c = conn.cursor()
c.execute('DELETE FROM depenses')
conn.commit()
conn.close()
return jsonify({'success': True})
# Export CSV
@app.route('/api/export')
def export_csv():
conn = get_db()
c = conn.cursor()
c.execute('SELECT prenom, date, libelle, montant, status FROM depenses ORDER BY date DESC')
rows = c.fetchall()
conn.close()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['prenom', 'date', 'libelle', 'montant', 'status'])
for row in rows:
writer.writerow(row)
output.seek(0)
return send_file(
io.BytesIO(output.getvalue().encode('utf-8')),
mimetype='text/csv',
as_attachment=True,
download_name='depenses_2026.csv'
)
# Import CSV
@app.route('/api/import', methods=['POST'])
def import_csv():
if 'file' not in request.files:
return jsonify({'error': 'Aucun fichier'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'error': 'Fichier vide'}), 400
try:
content = file.read().decode('utf-8')
reader = csv.reader(content.splitlines())
next(reader)
conn = get_db()
c = conn.cursor()
imported = 0
for row in reader:
if len(row) >= 4:
c.execute('INSERT INTO depenses (prenom, date, libelle, montant, status) VALUES (?, ?, ?, ?, ?)',
(row[0].strip(), row[1].strip(), row[2].strip(),
float(row[3]) if row[3] else 0, row[4].strip() if len(row) > 4 else 'En attente'))
imported += 1
conn.commit()
conn.close()
return jsonify({'success': True, 'imported': imported})
except Exception as e:
return jsonify({'error': str(e)}), 400
# Send ONE to Trello
@app.route('/api/trello/send_one/<int:id>', methods=['POST'])
def send_one(id):
conn = get_db()
c = conn.cursor()
c.execute("SELECT value FROM config WHERE key='trello'")
trello_row = c.fetchone()
if not trello_row:
conn.close()
return jsonify({'error': 'Config Trello manquante'}), 400
import json
t = json.loads(trello_row[0])
if not t.get('api_key') or not t.get('token') or not t.get('list_id'):
conn.close()
return jsonify({'error': 'Config Trello incomplète'}), 400
c.execute('SELECT * FROM depenses WHERE id=?', (id,))
d = c.fetchone()
if not d:
conn.close()
return jsonify({'error': 'Dépense non trouvée'}), 404
if d['status'] == 'Envoyé ✅':
conn.close()
return jsonify({'error': 'Déjà envoyé'}), 400
# Generate text
c.execute("SELECT value FROM config WHERE key='format'")
format_row = c.fetchone()
template = format_row[0] if format_row else '{prenom} - {date} - {libelle} - {montant}'
ddate = d['date'] or ''
if ddate:
ddate = '/'.join(ddate.split('-')[::-1])
line = template
line = line.replace('{prenom}', d['prenom'] or '')
line = line.replace('{date}', ddate)
line = line.replace('{libelle}', d['libelle'] or '')
line = line.replace('{montant}', str(d['montant']))
# Send to Trello
url = 'https://api.trello.com/1/cards'
params = {
'key': t.get('api_key'),
'token': t.get('token'),
'idList': t.get('list_id'),
'name': line,
'desc': line
}
try:
r = requests.post(url, params=params)
if r.status_code == 200:
c.execute('UPDATE depenses SET status=? WHERE id=?', ('Envoyé ✅', id))
conn.commit()
conn.close()
return jsonify({'success': True})
conn.close()
return jsonify({'error': r.text}), r.status_code
except Exception as e:
conn.close()
return jsonify({'error': str(e)}), 500
# Generate text (all)
@app.route('/api/generate', methods=['POST'])
def generate_text():
conn = get_db()
c = conn.cursor()
c.execute("SELECT value FROM config WHERE key='format'")
format_row = c.fetchone()
template = format_row[0] if format_row else '{prenom} - {date} - {libelle} - {montant}'
conn.close()
data = request.json
lines = []
for d in data.get('depenses', []):
line = template
ddate = d.get('date', '') or ''
if ddate:
ddate = '/'.join(ddate.split('-')[::-1])
line = line.replace('{prenom}', d.get('prenom', ''))
line = line.replace('{date}', ddate)
line = line.replace('{libelle}', d.get('libelle', ''))
line = line.replace('{montant}', str(d.get('montant', 0)))
lines.append(line)
return jsonify({'text': '\n'.join(lines)})
# Add prenom
@app.route('/api/prenom/add', methods=['POST'])
def add_prenom():
conn = get_db()
c = conn.cursor()
prenom = request.form.get('prenom', '').strip()
if prenom:
try:
c.execute('INSERT INTO prenoms (name) VALUES (?)', (prenom,))
conn.commit()
except:
pass
conn.close()
return redirect('/?page=config')
# Del prenom
@app.route('/api/prenom/del/<int:idx>')
def del_prenom(idx):
conn = get_db()
c = conn.cursor()
c.execute('DELETE FROM prenoms WHERE id=?', (idx+1,))
conn.commit()
conn.close()
return redirect('/?page=config')
# Save format
@app.route('/api/format', methods=['POST'])
def save_format():
conn = get_db()
c = conn.cursor()
format_val = request.form.get('format', '{prenom} - {date} - {libelle} - {montant}')
c.execute('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)', ('format', format_val))
conn.commit()
conn.close()
return redirect('/?page=config')
# Save trello config
@app.route('/api/trello/save', methods=['POST'])
def save_trello():
import json
conn = get_db()
c = conn.cursor()
t = json.dumps({
'api_key': request.form.get('api_key', ''),
'token': request.form.get('token', ''),
'list_id': request.form.get('list_id', '')
})
c.execute('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)', ('trello', t))
conn.commit()
conn.close()
return redirect('/?page=config')
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8769, debug=False)

127
archive_v5_files.py Executable file
View File

@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
Archive les fichiers v5_*.json antérieurs à aujourd'hui
Usage: python3 archive_v5_files.py [--dry-run]
"""
import os
import json
import sqlite3
import shutil
from datetime import datetime, date
from pathlib import Path
TURF_DIR = Path("/home/h3r7/turf_scraper")
ARCHIVE_DIR = TURF_DIR / "archive"
DB_PATH = TURF_DIR / "turf.db"
LOG_FILE = Path("/home/h3r7/logs/archive_v5.log")
def log(message: str):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_entry = f"[{timestamp}] {message}"
print(log_entry)
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(LOG_FILE, "a") as f:
f.write(log_entry + "\n")
def init_archive_table():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""
CREATE TABLE IF NOT EXISTS scraping_archives (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT UNIQUE NOT NULL,
execution_date TEXT NOT NULL,
runtime_sec REAL,
total_pages INTEGER,
file_size_kb INTEGER,
archive_path TEXT,
pages_success INTEGER DEFAULT 0,
pages_error INTEGER DEFAULT 0,
archived_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
archived_by TEXT DEFAULT 'cron_6h'
)
""")
conn.commit()
conn.close()
def extract_metadata(filepath: Path) -> dict:
try:
with open(filepath) as f:
data = json.load(f)
pages_success = sum(1 for p in data.get("pages", []) if p.get("status") == "success")
pages_error = sum(1 for p in data.get("pages", []) if p.get("status") == "error")
return {
"runtime_sec": data.get("runtime_sec"),
"total_pages": data.get("total_pages"),
"pages_success": pages_success,
"pages_error": pages_error
}
except Exception as e:
log(f"Erreur lecture {filepath}: {e}")
return {}
def archive_files(dry_run: bool = False):
today = date.today()
today_str = today.strftime("%Y%m%d")
init_archive_table()
log("=== Début archivage v5 ===")
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
archived_count = 0
for file in sorted(TURF_DIR.glob("v5_*.json")):
filename = file.name
parts = filename.split("_")
if len(parts) < 2:
continue
file_date_str = parts[1]
if file_date_str >= today_str:
continue
metadata = extract_metadata(file)
file_size_kb = file.stat().st_size // 1024
year = file_date_str[:4]
month = file_date_str[4:6]
target_dir = ARCHIVE_DIR / year / month
target_path = target_dir / filename
if not dry_run:
target_dir.mkdir(parents=True, exist_ok=True)
c.execute("""
INSERT OR IGNORE INTO scraping_archives
(filename, execution_date, runtime_sec, total_pages,
file_size_kb, archive_path, pages_success, pages_error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
filename, file_date_str, metadata.get("runtime_sec"),
metadata.get("total_pages"), file_size_kb,
str(target_path), metadata.get("pages_success", 0),
metadata.get("pages_error", 0)
))
shutil.move(str(file), str(target_path))
log(f"Archivé: {filename}{target_path}")
archived_count += 1
if not dry_run:
conn.commit()
conn.close()
log(f"=== Fin archivage: {archived_count} fichiers ===")
return archived_count
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true", help="Simulation sans déplacement")
args = parser.parse_args()
archive_files(dry_run=args.dry_run)

325
backtest.py Normal file
View File

@@ -0,0 +1,325 @@
#!/usr/bin/env python3
"""
Backtest - Analyse des performances des prédictions
Calcule ROI, précision, et métriques par type de pari
"""
import sqlite3
import os
from datetime import datetime, timedelta
from collections import defaultdict
DB_PATH = os.environ.get("DB_PATH", "/home/h3r7/turf_scraper/turf.db")
def get_db():
return sqlite3.connect(DB_PATH)
def calculate_backtest(start_date=None, end_date=None):
"""Calcule les statistiques de backtest"""
conn = get_db()
if not start_date:
start_date = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
# Récupérer toutes les recommandations avec résultats
query = """
SELECT
r.date,
r.race_name,
r.type_pari,
r.cheval1,
r.cheval2,
r.numero1,
r.numero2,
r.cote,
r.mise,
r.resultat,
r.gain_potentiel,
h.position as position_reel
FROM recommendations r
LEFT JOIN results h ON h.date = r.date
AND h.race_name LIKE '%' || SUBSTR(r.race_name, 1, 20) || '%'
AND h.horse_name = r.cheval1
WHERE r.resultat IS NOT NULL
AND r.resultat != ''
AND r.date BETWEEN ? AND ?
"""
cursor = conn.execute(query, (start_date, end_date))
rows = cursor.fetchall()
if not rows:
conn.close()
return {
'summary': {
'total_bets': 0,
'message': 'Aucune donnée disponible pour cette période'
}
}
# Métriques globales
stats = {
'total_bets': len(rows),
'gagne': 0,
'perdu': 0,
'mise_totale': 0,
'gain_total': 0,
'by_type': defaultdict(lambda: {
'count': 0, 'gagne': 0, 'mise': 0, 'gain': 0
})
}
details = []
for row in rows:
date, race_name, type_pari, cheval1, cheval2, numero1, numero2, cote, mise, resultat, gain_potentiel, position = row
mise = float(mise or 1)
cote = float(cote or 1)
stats['mise_totale'] += mise
stats['by_type'][type_pari]['mise'] += mise
if resultat == 'GAGNE':
stats['gagne'] += 1
stats['perdu'] += 0
stats['gain_total'] += mise * cote
stats['by_type'][type_pari]['gagne'] += 1
stats['by_type'][type_pari]['gain'] += mise * cote
result = 'GAGNE'
else:
stats['perdu'] += 1
stats['gain_total'] += 0
result = 'PERDU'
details.append({
'date': date,
'race_name': race_name[:30] if race_name else '',
'type_pari': type_pari,
'cheval': cheval1,
'cote': cote,
'mise': mise,
'resultat': result,
'gain': mise * cote if result == 'GAGNE' else 0
})
# Calculer ROI
roi = 0
if stats['mise_totale'] > 0:
roi = ((stats['gain_total'] - stats['mise_totale']) / stats['mise_totale']) * 100
# Calculer précision
precision = 0
if stats['total_bets'] > 0:
precision = (stats['gagne'] / stats['total_bets']) * 100
# Métriques par type de pari
by_type_results = {}
for pari_type, data in stats['by_type'].items():
pari_roi = 0
if data['mise'] > 0:
pari_roi = ((data['gain'] - data['mise']) / data['mise']) * 100
pari_precision = 0
if data['count'] > 0:
pari_precision = (data['gagne'] / data['count']) * 100
by_type_results[pari_type] = {
'count': data['count'],
'gagne': data['gagne'],
'mise': data['mise'],
'gain': data['gain'],
'roi': round(pari_roi, 1),
'precision': round(pari_precision, 1)
}
# Précision par rang de prédiction
precision_by_rank = get_precision_by_rank(conn, start_date, end_date)
conn.close()
return {
'period': {
'start': start_date,
'end': end_date
},
'summary': {
'total_bets': stats['total_bets'],
'gagne': stats['gagne'],
'perdu': stats['perdu'],
'precision': round(precision, 1),
'mise_totale': round(stats['mise_totale'], 2),
'gain_total': round(stats['gain_total'], 2),
'roi': round(roi, 1)
},
'by_type': by_type_results,
'precision_by_rank': precision_by_rank,
'details': details[-50:] # Last 50 bets
}
def get_precision_by_rank(conn, start_date, end_date):
"""Calcule la précision par rang de prédiction"""
query = """
SELECT
p.prediction_rank,
COUNT(*) as total,
SUM(CASE WHEN p.prediction_rank = r.position THEN 1 ELSE 0 END) as hits,
SUM(CASE WHEN r.position <= 3 THEN 1 ELSE 0 END) as top3
FROM predictions p
INNER JOIN results r ON r.date = p.date
AND r.horse_name = p.horse_name
WHERE p.date BETWEEN ? AND ?
AND p.prediction_rank > 0
AND r.position > 0
GROUP BY p.prediction_rank
ORDER BY p.prediction_rank
"""
cursor = conn.execute(query, (start_date, end_date))
rows = cursor.fetchall()
results = {}
for rank, total, hits, top3 in rows:
precision = (hits / total * 100) if total > 0 else 0
top3_rate = (top3 / total * 100) if total > 0 else 0
results[f'rank_{rank}'] = {
'total': total,
'hits': hits,
'precision': round(precision, 1),
'top3': top3,
'top3_rate': round(top3_rate, 1)
}
return results
def get_daily_stats(days=30):
"""Statistiques quotidiennes sur les N derniers jours"""
conn = get_db()
end_date = datetime.now().strftime('%Y-%m-%d')
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
query = """
SELECT
date,
COUNT(*) as total_bets,
SUM(CASE WHEN resultat = 'GAGNE' THEN 1 ELSE 0 END) as gagne,
SUM(CASE WHEN resultat = 'PERDU' THEN 1 ELSE 0 END) as perdu,
SUM(CASE WHEN resultat = 'GAGNE' THEN mise * cote ELSE 0 END) as gains,
SUM(mise) as mises
FROM recommendations
WHERE date BETWEEN ? AND ?
AND resultat IS NOT NULL
AND resultat != ''
GROUP BY date
ORDER BY date DESC
"""
cursor = conn.execute(query, (start_date, end_date))
rows = cursor.fetchall()
daily = []
for date, total, gagne, perdu, gains, mises in rows:
roi = ((gains - mises) / mises * 100) if mises > 0 else 0
precision = (gagne / total * 100) if total > 0 else 0
daily.append({
'date': date,
'total_bets': total,
'gagne': gagne,
'perdu': perdu,
'precision': round(precision, 1),
'mises': round(mises, 2),
'gains': round(gains, 2),
'roi': round(roi, 1)
})
conn.close()
return daily
def get_weekly_summary():
"""Résumé hebdomadaire"""
conn = get_db()
# Semaine en cours
today = datetime.now()
week_start = today - timedelta(days=today.weekday())
week_start_str = week_start.strftime('%Y-%m-%d')
query = """
SELECT
COUNT(*) as total,
SUM(CASE WHEN resultat = 'GAGNE' THEN 1 ELSE 0 END) as gagne,
SUM(mise) as mise,
SUM(CASE WHEN resultat = 'GAGNE' THEN mise * cote ELSE 0 END) as gain
FROM recommendations
WHERE date >= ? AND resultat IS NOT NULL AND resultat != ''
"""
cursor = conn.execute(query, (week_start_str,))
row = cursor.fetchone()
total, gagne, mise, gain = row
weekly = {
'week_start': week_start_str,
'total_bets': total or 0,
'gagne': gagne or 0,
'mise': round(mise or 0, 2),
'gain': round(gain or 0, 2),
'roi': round(((gain - mise) / mise * 100) if mise and mise > 0 else 0, 1),
'precision': round((gagne / total * 100) if total and total > 0 else 0, 1)
}
# Semaine dernière
last_week_start = week_start - timedelta(days=7)
last_week_end = week_start - timedelta(days=1)
query = """
SELECT
COUNT(*) as total,
SUM(CASE WHEN resultat = 'GAGNE' THEN 1 ELSE 0 END) as gagne,
SUM(mise) as mise,
SUM(CASE WHEN resultat = 'GAGNE' THEN mise * cote ELSE 0 END) as gain
FROM recommendations
WHERE date BETWEEN ? AND ?
AND resultat IS NOT NULL
AND resultat != ''
"""
cursor = conn.execute(query, (last_week_start.strftime('%Y-%m-%d'), last_week_end.strftime('%Y-%m-%d')))
row = cursor.fetchone()
total, gagne, mise, gain = row
weekly['last_week'] = {
'total_bets': total or 0,
'gagne': gagne or 0,
'mise': round(mise or 0, 2),
'gain': round(gain or 0, 2),
'roi': round(((gain - mise) / mise * 100) if mise and mise > 0 else 0, 1),
'precision': round((gagne / total * 100) if total and total > 0 else 0, 1)
}
conn.close()
return weekly
if __name__ == "__main__":
import json
print("="*60)
print("BACKTEST - Analyse des performances")
print("="*60)
# Test
result = calculate_backtest()
print(json.dumps(result, indent=2, default=str))

280
backtest_analyzer.py Normal file
View File

@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""
Backtest Analyzer - Analyse des prédictions vs résultats
"""
import sqlite3
import json
from datetime import datetime
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
def get_connection():
return sqlite3.connect(DB_PATH)
def get_results_for_date(date):
"""Récupère les résultats d'une date (toutes courses confondues)"""
conn = get_connection()
c = conn.execute("""
SELECT course, cheval, position_finale, num_pmu
FROM v_resultats_complets
WHERE date_programme = ? AND position_finale > 0
ORDER BY course, position_finale
""", (date,))
rows = c.fetchall()
conn.close()
# Grouper par course
courses = {}
for row in rows:
course_name = row[0]
if course_name not in courses:
courses[course_name] = []
courses[course_name].append({
'horse': row[1],
'position': row[2],
'numero': row[3]
})
return courses
def get_canalturf_predictions(date):
"""Récupère les prédictions Canalturf par race"""
conn = get_connection()
# Toutes les prédictions pour la date
c = conn.execute("""
SELECT race_name, horse_name, horse_number, source
FROM predictions
WHERE date = ?
""", (date,))
rows = c.fetchall()
conn.close()
# Grouper par race
races = {}
for row in rows:
race_name = row[0] if row[0] else 'UNKNOWN'
if race_name not in races:
races[race_name] = {'bases': [], 'chances': [], 'outsiders': [], 'all': []}
entry = {'horse': row[1], 'numero': row[2]}
races[race_name]['all'].append(entry)
if row[3] == 'canalturf_prono_bases':
races[race_name]['bases'].append(entry)
elif row[3] == 'canalturf_prono_chances':
races[race_name]['chances'].append(entry)
elif row[3] == 'canalturf_prono_outsiders':
races[race_name]['outsiders'].append(entry)
return races
def get_scoring_predictions(date):
"""Récupère les prédictions du scoring par race"""
conn = get_connection()
c = conn.execute("""
SELECT race_name, horse_name, horse_number, score, rang_scoring
FROM scoring
WHERE date = ?
""", (date,))
rows = c.fetchall()
conn.close()
races = {}
for row in rows:
race_name = row[0] if row[0] else 'UNKNOWN'
if race_name not in races:
races[race_name] = []
races[race_name].append({
'horse': row[1],
'numero': row[2],
'score': row[3],
'rang': row[4]
})
return races
def calculate_metrics(predicted, actual):
"""Calcule les métriques pour une course"""
if not predicted or not actual:
return None
metrics = {}
# Top1
pred_top1 = predicted[0]['horse'].upper() if predicted else None
actual_top1 = actual[0]['horse'].upper() if actual else None
metrics['top1_hit'] = pred_top1 == actual_top1
metrics['top1_predicted'] = pred_top1
# Top3
pred_top3 = set([p['horse'].upper() for p in predicted[:3]])
actual_top3 = set([a['horse'].upper() for a in actual[:3]])
metrics['top3_precision'] = len(pred_top3.intersection(actual_top3)) / 3
# Top5
pred_top5 = set([p['horse'].upper() for p in predicted[:5]])
actual_top5 = set([a['horse'].upper() for a in actual[:5]])
metrics['top5_precision'] = len(pred_top5.intersection(actual_top5)) / 5
# ZE2: 2/4
pred_top4 = set([p['horse'].upper() for p in predicted[:4]])
actual_top4 = set([a['horse'].upper() for a in actual[:4]])
metrics['ze2_hit'] = len(pred_top4.intersection(actual_top4)) >= 2
return metrics
def run_backtest():
"""Lance le backtest"""
conn = get_connection()
c = conn.execute("""
SELECT DISTINCT date_programme
FROM v_resultats_complets
WHERE position_finale > 0
ORDER BY date_programme DESC
""")
dates = [row[0] for row in c.fetchall()]
conn.close()
if not dates:
print("Aucune donnée trouvée")
return None
print(f"\n{'='*60}")
print(f"📊 BACKTEST ANALYZER")
print(f"{'='*60}")
print(f"Période: {dates[-1]} au {dates[0]} ({len(dates)} jours)")
all_results = []
stats = {'canalturf': {'top1': 0, 'top3': 0, 'top5': 0, 'ze2': 0, 'total': 0},
'scoring': {'top1': 0, 'top3': 0, 'top5': 0, 'ze2': 0, 'total': 0}}
for date in dates:
results = get_results_for_date(date)
if not results:
continue
canalturf_preds = get_canalturf_predictions(date)
scoring_preds = get_scoring_predictions(date)
for race_name, race_results in results.items():
# Canalturf
if race_name in canalturf_preds:
pred = canalturf_preds[race_name]['all']
m = calculate_metrics(pred, race_results)
if m:
stats['canalturf']['total'] += 1
stats['canalturf']['top1'] += 1 if m['top1_hit'] else 0
stats['canalturf']['top3'] += m['top3_precision']
stats['canalturf']['top5'] += m['top5_precision']
stats['canalturf']['ze2'] += 1 if m['ze2_hit'] else 0
all_results.append({
'date': date,
'race': race_name,
'source': 'canalturf',
'top1_pred': m['top1_predicted'],
'top1_hit': m['top1_hit'],
'ze2_hit': m['ze2_hit'],
})
# Scoring
if race_name in scoring_preds:
pred = scoring_preds[race_name]
m = calculate_metrics(pred, race_results)
if m:
stats['scoring']['total'] += 1
stats['scoring']['top1'] += 1 if m['top1_hit'] else 0
stats['scoring']['top3'] += m['top3_precision']
stats['scoring']['top5'] += m['top5_precision']
stats['scoring']['ze2'] += 1 if m['ze2_hit'] else 0
all_results.append({
'date': date,
'race': race_name,
'source': 'scoring',
'top1_pred': m['top1_predicted'],
'top1_hit': m['top1_hit'],
'ze2_hit': m['ze2_hit'],
})
# Calcul pourcentages
for source in ['canalturf', 'scoring']:
s = stats[source]
if s['total'] > 0:
s['top1_pct'] = round(s['top1'] / s['total'] * 100, 1)
s['top3_pct'] = round(s['top3'] / s['total'] * 100, 1)
s['top5_pct'] = round(s['top5'] / s['total'] * 100, 1)
s['ze2_pct'] = round(s['ze2'] / s['total'] * 100, 1)
return {
'dates': dates,
'results': all_results,
'stats': stats,
'generated_at': datetime.now().isoformat()
}
def print_report(data):
stats = data['stats']
print(f"\n📈 RÉSUMÉ GLOBAL")
print(f"{'='*60}")
for source, label in [('canalturf', 'CANALTURF'), ('scoring', 'SCORING')]:
s = stats[source]
if s['total'] > 0:
print(f"\n{label} ({s['total']} courses analysées):")
print(f" Top1: {s['top1']}/{s['total']} = {s['top1_pct']}%")
print(f" Top3: {s['top3_pct']}%")
print(f" Top5: {s['top5_pct']}%")
print(f" ZE2: {s['ze2']}/{s['total']} = {s['ze2_pct']}%")
def main():
data = run_backtest()
if data:
print_report(data)
# Sauvegarde JSON
with open('/home/h3r7/turf_scraper/backtest_result.json', 'w') as f:
json.dump(data, f, indent=2, default=str)
# Génère markdown
md = f"""---
date: {datetime.now().strftime('%Y-%m-%d')}
tags: [turf, backtest, analyse]
type: recherche
status: active
---
# Backtest - {data['dates'][-1]} au {data['dates'][0]}
> Analyse des prédictions vs résultats officiels PMU (8 jours)
## Résumé Global
| Source | Courses | Top1 | Top3 | Top5 | ZE2 Hit |
|--------|---------|------|------|------|---------|
| Canalturf | {data['stats']['canalturf']['total']} | {data['stats']['canalturf']['top1_pct']}% | {data['stats']['canalturf']['top3_pct']}% | {data['stats']['canalturf']['top5_pct']}% | {data['stats']['canalturf']['ze2_pct']}% |
| Scoring | {data['stats']['scoring']['total']} | {data['stats']['scoring']['top1_pct']}% | {data['stats']['scoring']['top3_pct']}% | {data['stats']['scoring']['top5_pct']}% | {data['stats']['scoring']['ze2_pct']}% |
## Détail
| Date | Course | Source | Top1 Prédit | Hit | ZE2 |
|------|--------|--------|-------------|-----|-----|
"""
for r in data['results']:
md += f"| {r['date']} | {r['race'][:30]}... | {r['source']} | {r['top1_pred'][:20] if r['top1_pred'] else 'N/A'}... | {'' if r['top1_hit'] else ''} | {'' if r['ze2_hit'] else ''} |\n"
md += f"""\n---
*Généré le {datetime.now().strftime('%Y-%m-%d %H:%M')}*
"""
with open('/home/h3r7/turf_scraper/backtest_result.md', 'w') as f:
f.write(md)
print(f"\n💾 Rapports sauvegardés:")
print(f" - /home/h3r7/turf_scraper/backtest_result.json")
print(f" - /home/h3r7/turf_scraper/backtest_result.md")
if __name__ == "__main__":
main()

197
backtest_result.md Normal file
View File

@@ -0,0 +1,197 @@
---
date: 2026-04-25
tags: [turf, backtest, analyse]
type: recherche
status: active
---
# Backtest - 2026-03-31 au 2026-04-24
> Analyse des prédictions vs résultats officiels PMU (8 jours)
## Résumé Global
| Source | Courses | Top1 | Top3 | Top5 | ZE2 Hit |
|--------|---------|------|------|------|---------|
| Canalturf | 153 | 23.5% | 19.2% | 16.2% | 3.9% |
| Scoring | 19 | 10.5% | 33.3% | 45.3% | 63.2% |
## Détail
| Date | Course | Source | Top1 Prédit | Hit | ZE2 |
|------|--------|--------|-------------|-----|-----|
| 2026-04-24 | PRIX CLAUDIA... | canalturf | LARIANO... | ❌ | ❌ |
| 2026-04-24 | PRIX CLAUDIA... | scoring | L'AMOUR SUPREME... | ✅ | ✅ |
| 2026-04-24 | PRIX DU JARDIN DE CORBIERES... | canalturf | NUMERO DE LAUMONT... | ❌ | ❌ |
| 2026-04-24 | PRIX DU PARC DE LA PORTE D'AIX... | canalturf | KATE DU RIL... | ❌ | ❌ |
| 2026-04-24 | PRIX DU PARC DU GRAND SEMINAIR... | canalturf | MACKER D'ERONVILLE... | ❌ | ❌ |
| 2026-04-24 | PRIX MAGINUS... | canalturf | MACHIAVEL BOURBON... | ❌ | ❌ |
| 2026-04-24 | PRIX RENE PALYART - ETRIER 4 A... | canalturf | MHUM FLYNG... | ❌ | ❌ |
| 2026-04-23 | PRIX DE FERRIERES... | canalturf | SENEQUE... | ❌ | ❌ |
| 2026-04-23 | PRIX DE LA COMEDIE FRANCAISE... | canalturf | HURRICANE... | ❌ | ❌ |
| 2026-04-23 | PRIX DES BOUFFES PARISIENS... | canalturf | PAMELA BOUM... | ❌ | ❌ |
| 2026-04-23 | PRIX DU PANTHEON... | canalturf | EPHESUS... | ❌ | ❌ |
| 2026-04-23 | PRIX DU PANTHEON... | scoring | EPHESUS... | ❌ | ✅ |
| 2026-04-23 | PRIX MAURICE ZILBER... | canalturf | MANDANABA... | ✅ | ❌ |
| 2026-04-23 | PRIX NEOVA - THERMOR... | canalturf | LANCELOT DU NORD... | ❌ | ❌ |
| 2026-04-23 | PRIX SAMSUNG - GROHE... | canalturf | JOKER DARLING... | ✅ | ❌ |
| 2026-04-22 | GRAND NATIONAL DU TROT... | scoring | HORCHESTRO... | ❌ | ✅ |
| 2026-04-22 | PRIX COKTAIL JET... | canalturf | LEZZO FIGHTER... | ✅ | ❌ |
| 2026-04-22 | PRIX DE LA ROUTE DE LA DUCHESS... | canalturf | MEFIE TOI... | ✅ | ❌ |
| 2026-04-22 | PRIX DES FRAYOIRS... | canalturf | VICIOUS HARRY... | ❌ | ❌ |
| 2026-04-22 | PRIX DU CHAMP D'ALOUETTE... | canalturf | ZACAPO... | ❌ | ❌ |
| 2026-04-22 | PRIX L'ECLAIREUR-ACTU.FR... | canalturf | JASMIN GEMA... | ❌ | ❌ |
| 2026-04-22 | PRIX OTTO... | canalturf | MINO GALESTE... | ❌ | ❌ |
| 2026-04-22 | PRIX XAVIER HUNAULT... | canalturf | LEADER... | ❌ | ❌ |
| 2026-04-21 | PRIX DE JONZAC... | canalturf | L'AS DE COEUR... | ❌ | ❌ |
| 2026-04-21 | PRIX HAVNIA... | canalturf | NELSON THORIS... | ❌ | ❌ |
| 2026-04-21 | PRIX HOPPER... | canalturf | METRONOMIQUE... | ✅ | ❌ |
| 2026-04-21 | PRIX JUMENT RADIO PARIS... | canalturf | LOUBA... | ❌ | ❌ |
| 2026-04-21 | PRIX LA BATE... | canalturf | TATOO... | ❌ | ❌ |
| 2026-04-21 | PRIX LA BATE... | scoring | PRIAM DU MESNIL... | ❌ | ✅ |
| 2026-04-21 | PRIX MIRIAM... | canalturf | MAHIZ DE GUEZ... | ❌ | ❌ |
| 2026-04-21 | PRIX MODESTIA... | canalturf | MISSING... | ✅ | ❌ |
| 2026-04-21 | PRIX VANILLE... | canalturf | STECASTER... | ❌ | ❌ |
| 2026-04-20 | PRIX BOULANGERIE PATISSERIE FE... | canalturf | KING TRACK... | ✅ | ❌ |
| 2026-04-20 | PRIX DE L'EMPEREUR NAPOLEON II... | canalturf | DUBAIEARTH... | ✅ | ❌ |
| 2026-04-20 | PRIX DE LA CHAPELLE-LA-REINE... | canalturf | SORANO... | ❌ | ❌ |
| 2026-04-20 | PRIX DE LA FORET DE FONTAINEBL... | canalturf | PENTAOUR... | ❌ | ❌ |
| 2026-04-20 | PRIX DE LA FORET DE FONTAINEBL... | scoring | STAR OF ARTABAN... | ❌ | ✅ |
| 2026-04-20 | PRIX DES PINS... | canalturf | CHEVALDOR... | ❌ | ❌ |
| 2026-04-20 | PRIX DES SENTIERS DENECOURT... | canalturf | ETERNEL... | ✅ | ❌ |
| 2026-04-20 | PRIX LYONNET TRAITEUR... | canalturf | KALLISTEE... | ❌ | ❌ |
| 2026-04-20 | PRIX PASCAL LAURENT... | canalturf | MAHARAJA ATOUT... | ✅ | ❌ |
| 2026-04-20 | PRIX REGION AUVERGNE RHONE-ALP... | canalturf | NEW GRASS... | ✅ | ❌ |
| 2026-04-19 | PRIX AMADOU... | canalturf | LEOPARD DU BERLAIS... | ❌ | ❌ |
| 2026-04-19 | PRIX DE CARTHAGE... | canalturf | HAJMAH... | ❌ | ❌ |
| 2026-04-19 | PRIX DE MONFORT... | canalturf | POCA GEN... | ❌ | ✅ |
| 2026-04-19 | PRIX DE MONFORT... | scoring | XILOFONO... | ❌ | ❌ |
| 2026-04-19 | PRIX DU SOUVENIR... | canalturf | SIR TOMMY CEN... | ✅ | ❌ |
| 2026-04-19 | PRIX JEAN STERN... | canalturf | SHANNON MAESTRO... | ❌ | ❌ |
| 2026-04-19 | PRIX KATKO... | canalturf | MORVAN... | ❌ | ❌ |
| 2026-04-19 | PRIX LEON RAMBAUD... | canalturf | THELEME... | ❌ | ❌ |
| 2026-04-18 | PRIX DE L'ATLANTIQUE... | canalturf | JOSH POWER... | ❌ | ✅ |
| 2026-04-18 | PRIX DE L'ATLANTIQUE... | scoring | HARLEY GEMA... | ❌ | ✅ |
| 2026-04-18 | PRIX DE MONPLAISIR... | canalturf | STUNNING ANGEL... | ❌ | ❌ |
| 2026-04-18 | PRIX DU BOIS NOIR... | canalturf | NIAGARA DES MOTTES... | ❌ | ❌ |
| 2026-04-18 | PRIX DU MONT PILAT... | canalturf | RISK OF LOVE... | ❌ | ❌ |
| 2026-04-18 | PRIX DU PRINTEMPS... | canalturf | ENEA... | ❌ | ❌ |
| 2026-04-17 | PRIX ALETHEIA... | canalturf | KARAMBAR... | ❌ | ❌ |
| 2026-04-17 | PRIX ASCHERA... | canalturf | NONEKA... | ❌ | ❌ |
| 2026-04-17 | PRIX ATLAS... | canalturf | NICE SPEED... | ❌ | ❌ |
| 2026-04-17 | PRIX DU PHARE DE CASSIDAIGNE... | canalturf | JOY DU CARNOIS... | ❌ | ❌ |
| 2026-04-17 | PRIX DU PHARE DE SAINTE-MARIE... | canalturf | MAMZELLE D'HERIPRE... | ❌ | ❌ |
| 2026-04-17 | PRIX DU SEMAPHORE DE LA COURON... | canalturf | LUTECIA ELLIS... | ❌ | ❌ |
| 2026-04-17 | PRIX NEPTUNA... | canalturf | KENTUCKY IDEAL... | ❌ | ✅ |
| 2026-04-17 | PRIX NEPTUNA... | scoring | KATCHI QUICK... | ❌ | ✅ |
| 2026-04-17 | PRIX THEOPHILE LALLOUET... | canalturf | IDEALE DU CHENE... | ❌ | ❌ |
| 2026-04-16 | PRIX DE FUMEL... | canalturf | L'ARCHANGE... | ✅ | ❌ |
| 2026-04-16 | PRIX DE LA FONTAINE CARPEAUX... | canalturf | CREW DRAGON... | ❌ | ❌ |
| 2026-04-16 | PRIX DE LA FONTAINE CARPEAUX... | scoring | WIT... | ❌ | ✅ |
| 2026-04-16 | PRIX DU LOUVRE... | canalturf | THE LAST DANCE... | ✅ | ❌ |
| 2026-04-16 | PRIX DU PONT DE FLANDRE... | canalturf | MAZAL DES GLENAN... | ❌ | ❌ |
| 2026-04-16 | PRIX MACHADO... | canalturf | MR LOPE CEN... | ❌ | ❌ |
| 2026-04-15 | PRIX D'ENGHIEN... | canalturf | HEART AND SOUL... | ❌ | ❌ |
| 2026-04-15 | PRIX DE LA COMMUNE DE CORDEMAI... | scoring | JIM D'ALOUETTE... | ❌ | ❌ |
| 2026-04-15 | PRIX DE LA MARE A PIAT... | canalturf | ILTONE... | ✅ | ❌ |
| 2026-04-15 | PRIX DE LA SOCIETE DES COURSES... | canalturf | SPES MILICLO... | ❌ | ❌ |
| 2026-04-15 | PRIX PMU +... | canalturf | JALNA GIRL... | ✅ | ❌ |
| 2026-04-14 | PRIX CHLOE... | canalturf | NOTHINGELSEMATTERS... | ✅ | ❌ |
| 2026-04-14 | PRIX DE ROISSY... | canalturf | PROFUMO DI IENA... | ❌ | ❌ |
| 2026-04-14 | PRIX DE ROISSY... | scoring | REGALIEN... | ❌ | ❌ |
| 2026-04-14 | PRIX EPINARD... | canalturf | SAY SQUIRREL... | ❌ | ❌ |
| 2026-04-14 | PRIX STEPHANIA... | canalturf | LORD FROMENTRO... | ❌ | ❌ |
| 2026-04-13 | PRIX DE LA TAPISSERIE DE BAYEU... | canalturf | LOUIS GAILLARD... | ❌ | ❌ |
| 2026-04-13 | PRIX DES GENETS... | canalturf | STORM DES FLOS... | ❌ | ❌ |
| 2026-04-13 | PRIX DES PLAGES DU DEBARQUEMEN... | canalturf | KENZO D'EVA... | ❌ | ❌ |
| 2026-04-13 | PRIX DU BARRY... | canalturf | FIDELE AU POSTE... | ❌ | ❌ |
| 2026-04-13 | PRIX DU HARAS NATIONAL DU PIN... | canalturf | KETBERNY... | ❌ | ❌ |
| 2026-04-13 | PRIX DU VIVARAIS... | canalturf | MAGIC MARVEL... | ❌ | ❌ |
| 2026-04-13 | PRIX GASTON BRANERE... | canalturf | NO LIMITS STEVE... | ❌ | ✅ |
| 2026-04-13 | PRIX GASTON BRANERE... | scoring | TROUBLEINPARADISE... | ❌ | ❌ |
| 2026-04-13 | PRIX RENE COUETIL... | canalturf | MARBRE ROSE... | ❌ | ❌ |
| 2026-04-12 | PRIX DE FONTAINEBLEAU... | canalturf | NIGHTTIME... | ❌ | ❌ |
| 2026-04-12 | PRIX DE LA GROTTE... | canalturf | GREEN SPIRIT... | ❌ | ❌ |
| 2026-04-12 | PRIX DU COMITE REGIONAL DE L'O... | canalturf | KADOR DES TITHAIS... | ❌ | ❌ |
| 2026-04-12 | PRIX DU PAVILLON ROYAL... | canalturf | ZULU WARRIOR... | ❌ | ❌ |
| 2026-04-12 | PRIX DU PAVILLON ROYAL... | scoring | MOST GLAMOROUS... | ❌ | ❌ |
| 2026-04-12 | PRIX JACQUES LAFFITTE... | canalturf | CASAPUEBLO... | ✅ | ❌ |
| 2026-04-12 | PRIX LECLERC DISTRIBUTION... | canalturf | JOB DE CHOISEL... | ❌ | ❌ |
| 2026-04-12 | PRIX LORD SEYMOUR... | canalturf | MARQUISAT... | ❌ | ❌ |
| 2026-04-12 | PRIX YVES ET JEAN MOYON... | canalturf | MISTRAL D'HERMES... | ✅ | ❌ |
| 2026-04-11 | PRIX DE FAULQUEMONT... | canalturf | MILAN DU BOSQUET... | ❌ | ❌ |
| 2026-04-11 | PRIX DE LA LORRAINE... | canalturf | CHITCHAT... | ❌ | ❌ |
| 2026-04-11 | PRIX DU CHATEAU LA POINTE... | canalturf | ARBUS... | ✅ | ❌ |
| 2026-04-11 | PRIX DU TREMBLAY... | canalturf | PITCH PERFECT... | ✅ | ❌ |
| 2026-04-11 | PRIX EPINARD... | canalturf | MADIGAN... | ❌ | ❌ |
| 2026-04-11 | PRIX HEMINE - ETRIER 3 ANS Q4... | canalturf | NIZZA ROCCA... | ❌ | ❌ |
| 2026-04-11 | PRIX HENRI LEVESQUE... | canalturf | LA FORMIDABLE... | ❌ | ❌ |
| 2026-04-11 | PRIX HENRI LEVESQUE... | scoring | LA FORMIDABLE... | ❌ | ✅ |
| 2026-04-11 | PRIX KRISS II... | canalturf | HM MAJDALLAH... | ✅ | ❌ |
| 2026-04-11 | PRIX ROBERT AUVRAY... | canalturf | L'AS DESBOIS... | ❌ | ❌ |
| 2026-04-10 | PRIX CLARISSE... | canalturf | MARTIN DOL... | ❌ | ❌ |
| 2026-04-10 | PRIX DE LA BARRIERE DE PARIS... | canalturf | NELLY DES CHARMES... | ❌ | ❌ |
| 2026-04-10 | PRIX DE NEGRENEYS... | canalturf | LA SPEZIA DE LOU... | ✅ | ❌ |
| 2026-04-10 | PRIX DES AMIDONNIERS... | canalturf | KROONER D'ORION... | ❌ | ❌ |
| 2026-04-10 | PRIX DU FER A CHEVAL... | canalturf | LOUSTIC... | ❌ | ❌ |
| 2026-04-10 | PRIX DU PONT DES DEMOISELLES... | canalturf | LEWIS D'EVRON... | ✅ | ❌ |
| 2026-04-10 | PRIX ICLEA... | canalturf | IKURO JIEL... | ✅ | ❌ |
| 2026-04-09 | PRIX ALADDIN... | canalturf | RAFFLES CHOICE... | ❌ | ❌ |
| 2026-04-09 | PRIX JASMIN II... | canalturf | SAIN D'ESPRIT... | ✅ | ❌ |
| 2026-04-09 | PRIX LE TOUQUET... | canalturf | MELISSE DU MATHAN... | ✅ | ❌ |
| 2026-04-09 | PRIX OISELEUR... | canalturf | LASCAR D'AIRY... | ❌ | ✅ |
| 2026-04-09 | PRIX OISELEUR... | scoring | CARGO DE NUIT... | ❌ | ❌ |
| 2026-04-08 | GRAND NATIONAL DU TROT... | scoring | ISTER MAN... | ❌ | ✅ |
| 2026-04-08 | PRIX DE CABOURG... | canalturf | MYSTIQUE LOULOU... | ❌ | ❌ |
| 2026-04-08 | PRIX DE MARSEILLE... | canalturf | KOALA... | ❌ | ❌ |
| 2026-04-08 | PRIX DU CAP DE LA HEVE... | canalturf | WAKAI GO... | ❌ | ❌ |
| 2026-04-08 | PRIX DU CAP LEVY... | canalturf | EL PROFESSOR CHOP... | ❌ | ❌ |
| 2026-04-08 | PRIX DU VIEUX MARONNIER... | canalturf | FREJA... | ❌ | ❌ |
| 2026-04-08 | PRIX HENRI CALLIER... | canalturf | LYS GEMA... | ❌ | ❌ |
| 2026-04-07 | PRIX ALBIREO... | canalturf | LOUKY DE BAULON... | ❌ | ❌ |
| 2026-04-07 | PRIX BAVARIA... | canalturf | KINOU DAB... | ❌ | ❌ |
| 2026-04-07 | PRIX DE L'OPERATION OVERLORD... | canalturf | DIVINE CHRISNAT... | ❌ | ❌ |
| 2026-04-07 | PRIX DJEBEL... | canalturf | SAMANGAN... | ❌ | ❌ |
| 2026-04-07 | PRIX IMPRUDENCE... | canalturf | MY HIGHNESS... | ❌ | ❌ |
| 2026-04-07 | PRIX PROSERPINA... | canalturf | NEVERS... | ❌ | ❌ |
| 2026-04-06 | PRIX D'AUMALE... | canalturf | ZARAKHAN... | ✅ | ❌ |
| 2026-04-06 | PRIX DE LA ROCHELLE... | canalturf | CI PPO RA... | ❌ | ✅ |
| 2026-04-06 | PRIX DE LA ROCHELLE... | scoring | PRIAM DU MESNIL... | ❌ | ✅ |
| 2026-04-06 | PRIX DES CADETTES... | canalturf | GAZELLE DU BRIZAIS... | ❌ | ❌ |
| 2026-04-06 | PRIX DURTAIN... | canalturf | NOIRE WULF... | ✅ | ❌ |
| 2026-04-06 | PRIX HYUNDAI GCH CHERBOURG... | canalturf | LETTY DE BEYLEV... | ❌ | ❌ |
| 2026-04-06 | PRIX INFINY HOME... | canalturf | LISA MI FOR CLARA... | ❌ | ❌ |
| 2026-04-06 | PRIX LA NOUBA... | canalturf | SUNDAYS CHILD... | ❌ | ❌ |
| 2026-04-06 | PRIX SUZUKY GCS CHERBOURG... | canalturf | KOREA... | ❌ | ❌ |
| 2026-04-05 | PRIX CINEMA LES ENFANTS DU PAR... | canalturf | LASCAUX... | ✅ | ❌ |
| 2026-04-05 | PRIX D'HARCOURT... | canalturf | FIRST LOOK... | ❌ | ❌ |
| 2026-04-05 | PRIX DES AMIS DE L'HIPPODROME... | canalturf | MISS FRANCE PIYA... | ✅ | ❌ |
| 2026-04-05 | PRIX DU GRAND CHATELET... | canalturf | IPSO FACTO... | ❌ | ❌ |
| 2026-04-05 | PRIX L'ILLIADE... | canalturf | KLASSIC FROMENTRO... | ❌ | ❌ |
| 2026-04-05 | PRIX LA FORCE... | canalturf | ZARAKI... | ❌ | ❌ |
| 2026-04-05 | PRIX ZARKAVA... | canalturf | INDALIMOS... | ❌ | ❌ |
| 2026-04-04 | PRIX CORNELIA... | canalturf | HEURISTIQUE... | ❌ | ❌ |
| 2026-04-04 | PRIX DE BAPAUME... | canalturf | FAHARAJAH ONE... | ❌ | ❌ |
| 2026-04-04 | PRIX DE LA DORDOGNE... | canalturf | GUARDAMI... | ❌ | ❌ |
| 2026-04-04 | PRIX DES CONSEILLERS DE LA FIL... | canalturf | CANDYMAN... | ❌ | ❌ |
| 2026-04-04 | PRIX DU GERS... | canalturf | GHOSTRIDER FONT... | ❌ | ❌ |
| 2026-04-04 | PRIX JEAN PROUVE... | canalturf | KARTHAGE... | ❌ | ❌ |
| 2026-04-04 | PRIX KERJACQUES... | canalturf | KSAR... | ❌ | ❌ |
| 2026-04-04 | PRIX KERJACQUES... | scoring | INEXESS BLEU... | ✅ | ✅ |
| 2026-04-03 | PRIX CEPHENS... | canalturf | KING LOOK... | ❌ | ❌ |
| 2026-04-03 | PRIX LIBRA... | canalturf | LOUSTIC... | ❌ | ❌ |
| 2026-04-03 | PRIX NOBODY DE RETZ... | canalturf | JASPER DU BELLAY... | ❌ | ❌ |
| 2026-04-02 | PRIX ALAIN GRIMAUX... | canalturf | LASCA DE THAIX... | ❌ | ❌ |
| 2026-04-02 | PRIX ALAIN GRIMAUX... | scoring | BLEKOLINA... | ❌ | ❌ |
| 2026-04-02 | PRIX CHAMPAUBERT... | canalturf | MAGIC DES CHARMES... | ✅ | ❌ |
| 2026-04-02 | PRIX DELIA DU POMMEREUX... | canalturf | MARIE LOU DE LARRE... | ✅ | ❌ |
| 2026-04-02 | PRIX WILLIAM ET ALEC HEAD... | canalturf | KAADAM... | ❌ | ❌ |
| 2026-04-01 | PRIX 24H AU TROT... | canalturf | LE CAPORAL... | ✅ | ❌ |
| 2026-04-01 | PRIX BOG FROG... | canalturf | MAHARAJA... | ❌ | ❌ |
| 2026-04-01 | PRIX DES BENJAMINS... | canalturf | NORTHMAN... | ❌ | ❌ |
| 2026-04-01 | PRIX ISUZU - ETABLISSEMENT PET... | canalturf | KORINTO BELLO... | ✅ | ❌ |
| 2026-04-01 | PRIX JACQUES DE VIENNE... | canalturf | NIKO HAS... | ❌ | ❌ |
| 2026-04-01 | PRIX JOURNALISTE... | canalturf | GAME OF STORM... | ✅ | ❌ |
---
*Généré le 2026-04-25 10:30*

376
boite_a_idees.html Executable file
View File

@@ -0,0 +1,376 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>H3R7Tech - Boîte à Idées</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--bg: #0d1117;
--card: #161b22;
--border: #30363d;
--text: #c9d1d9;
--accent: #58a6ff;
--green: #3fb950;
--red: #f85149;
--yellow: #d29922;
--purple: #a371f7;
--orange: #f0883e;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
/* Header */
header { background: linear-gradient(135deg, #1a1a2e, #16213e); padding: 30px 20px; text-align: center; border-bottom: 3px solid var(--accent); margin-bottom: 20px; }
header h1 { font-size: 32px; background: linear-gradient(90deg, var(--accent), var(--purple)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header p { color: #8b949e; margin-top: 10px; }
/* Stats */
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px; margin-bottom: 30px; }
.stat-card { background: var(--card); padding: 20px; border-radius: 12px; border: 1px solid var(--border); text-align: center; transition: transform 0.2s; }
.stat-card:hover { transform: translateY(-3px); }
.stat-card .icon { font-size: 28px; margin-bottom: 10px; }
.stat-card .value { font-size: 36px; font-weight: bold; }
.stat-card .label { font-size: 14px; color: #8b949e; }
.stat-card.total { border-color: var(--accent); }
.stat-card.bloque { border-color: var(--red); }
.stat-card.en-cours { border-color: var(--yellow); }
.stat-card.completed { border-color: var(--green); }
.stat-card.idea { border-color: var(--purple); }
/* Filters */
.filters { background: var(--card); padding: 20px; border-radius: 12px; margin-bottom: 20px; border: 1px solid var(--border); }
.filter-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 15px; }
.filter-row:last-child { margin-bottom: 0; }
.filter-btn { padding: 8px 16px; border: 1px solid var(--border); border-radius: 20px; background: transparent; color: var(--text); cursor: pointer; transition: all 0.2s; font-size: 14px; }
.filter-btn:hover, .filter-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.filter-btn.bloque { border-color: var(--red); color: var(--red); }
.filter-btn.bloque.active { background: var(--red); color: #fff; }
.filter-btn.en-cours { border-color: var(--yellow); color: var(--yellow); }
.filter-btn.en-cours.active { background: var(--yellow); color: #000; }
.filter-btn.completed { border-color: var(--green); color: var(--green); }
.filter-btn.completed.active { background: var(--green); color: #fff; }
.filter-btn.idea { border-color: var(--purple); color: var(--purple); }
.filter-btn.idea.active { background: var(--purple); color: #fff; }
.search-input { flex: 1; min-width: 200px; padding: 10px 15px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; color: var(--text); }
.search-input:focus { outline: none; border-color: var(--accent); }
/* Cards */
.projects-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 20px; }
.project-card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 20px; transition: all 0.3s; }
.project-card:hover { transform: translateY(-3px); box-shadow: 0 10px 30px rgba(0,0,0,0.3); }
.project-card.hidden { display: none; }
.project-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px; }
.project-title { font-size: 18px; font-weight: 600; color: #fff; }
.project-badges { display: flex; gap: 5px; flex-wrap: wrap; }
.badge { padding: 4px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; }
.badge.bloque { background: rgba(248,81,73,0.2); color: var(--red); }
.badge.en-cours { background: rgba(210,153,34,0.2); color: var(--yellow); }
.badge.completed { background: rgba(63,185,80,0.2); color: var(--green); }
.badge.idea { background: rgba(163,113,247,0.2); color: var(--purple); }
.badge.planifie { background: rgba(88,166,255,0.2); color: var(--accent); }
.badge.urgent { background: rgba(248,81,73,0.3); color: var(--red); }
.badge.important { background: rgba(240,136,62,0.3); color: var(--orange); }
.badge.normal { background: rgba(139,148,158,0.2); color: #8b949e; }
.project-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 15px; font-size: 13px; }
.meta-item { display: flex; align-items: center; gap: 8px; }
.meta-item i { color: var(--accent); width: 16px; }
.meta-label { color: #8b949e; }
.progress-container { margin-bottom: 15px; }
.progress-header { display: flex; justify-content: space-between; margin-bottom: 5px; font-size: 12px; }
.progress-bar { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }
.progress-fill { height: 100%; border-radius: 3px; transition: width 0.5s; }
.progress-fill.bloque { background: var(--red); }
.progress-fill.en-cours { background: var(--yellow); }
.progress-fill.completed { background: var(--green); }
.progress-fill.idea { background: var(--purple); }
.progress-fill.planifie { background: var(--accent); }
.project-actions { border-top: 1px solid var(--border); padding-top: 15px; margin-top: 10px; }
.action-row { display: flex; justify-content: space-between; font-size: 13px; }
.action-label { color: #8b949e; }
.action-value { color: var(--text); }
/* Timeline */
.timeline { margin-top: 40px; }
.timeline h2 { color: #fff; margin-bottom: 20px; font-size: 24px; }
.timeline-items { display: flex; flex-direction: column; gap: 15px; }
.timeline-item { display: flex; align-items: center; gap: 15px; padding: 15px; background: var(--card); border-radius: 10px; border-left: 3px solid var(--accent); }
.timeline-date { font-size: 12px; color: #8b949e; min-width: 80px; }
.timeline-content { flex: 1; }
.timeline-priority { padding: 3px 8px; border-radius: 4px; font-size: 11px; }
/* Footer */
footer { text-align: center; padding: 30px; color: #8b949e; border-top: 1px solid var(--border); margin-top: 40px; }
@media (max-width: 768px) {
.projects-grid { grid-template-columns: 1fr; }
.stats { grid-template-columns: repeat(2, 1fr); }
.filter-row { flex-direction: column; }
.search-input { width: 100%; }
}
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<header>
<div class="container">
<h1><i class="fas fa-lightbulb"></i> H3R7Tech - Boîte à Idées</h1>
<p>Tableau de bord global - Suivi de tous les projets</p>
<p style="margin-top:10px; font-size:14px;">Dernière mise à jour: <span id="lastUpdate"></span></p>
</div>
</header>
<div class="container">
<!-- KPI Cards -->
<div class="stats">
<div class="stat-card total">
<div class="icon"><i class="fas fa-layer-group"></i></div>
<div class="value" id="totalCount">0</div>
<div class="label">Total Projets</div>
</div>
<div class="stat-card bloque">
<div class="icon" style="color:var(--red)"><i class="fas fa-ban"></i></div>
<div class="value" id="bloqueCount">0</div>
<div class="label">Bloqués</div>
</div>
<div class="stat-card en-cours">
<div class="icon" style="color:var(--yellow)"><i class="fas fa-spinner"></i></div>
<div class="value" id="encoursCount">0</div>
<div class="label">En Cours</div>
</div>
<div class="stat-card completed">
<div class="icon" style="color:var(--green)"><i class="fas fa-check-circle"></i></div>
<div class="value" id="completedCount">0</div>
<div class="label">Complétés</div>
</div>
<div class="stat-card idea">
<div class="icon" style="color:var(--purple)"><i class="fas fa-rocket"></i></div>
<div class="value" id="ideaCount">0</div>
<div class="label">Idées/À Planifier</div>
</div>
</div>
<!-- Filters -->
<div class="filters">
<div class="filter-row">
<button class="filter-btn active" data-filter="all">Tous</button>
<button class="filter-btn bloque" data-filter="bloque">🔴 Bloqués</button>
<button class="filter-btn en-cours" data-filter="en-cours">🟡 En Cours</button>
<button class="filter-btn planifie" data-filter="planifie">🔵 À Planifier</button>
<button class="filter-btn completed" data-filter="completed">🟢 Complétés</button>
<button class="filter-btn idea" data-filter="idea">💡 Idées</button>
</div>
<div class="filter-row">
<input type="text" class="search-input" id="searchInput" placeholder="🔍 Rechercher un projet...">
</div>
</div>
<!-- Projects Grid -->
<div class="projects-grid" id="projectsGrid">
<!-- Projects will be inserted here by JavaScript -->
</div>
<!-- Timeline -->
<div class="timeline">
<h2><i class="fas fa-calendar-alt"></i> Prochaines Actions</h2>
<div class="timeline-items" id="timelineItems">
<!-- Timeline items will be inserted here -->
</div>
</div>
</div>
<footer>
<p><i class="fas fa-copyright"></i> H3R7Tech - Generated by Claw</p>
<p id="footerDate"></p>
</footer>
<script>
// Project Data
const projects = [
// TURF
{ id: 1, name: "Turf Dashboard", category: "completed", progress: 100, priority: "normal", responsable: "Claw", lastAction: "Dashboard opérationnel", nextAction: "Maintenance", deadline: "" },
{ id: 2, name: "Turf Predictions", category: "bloque", progress: 60, priority: "important", responsable: "VPS", lastAction: "Sites PMU bloquent le scraping", nextAction: "Trouver solution alternative", deadline: "" },
{ id: 3, name: "Turf Cron Jobs", category: "en-cours", progress: 90, priority: "important", responsable: "Claw", lastAction: "Token Telegram mis à jour", nextAction: "Test final", deadline: "" },
// POD
{ id: 4, name: "POD Manager", category: "completed", progress: 100, priority: "normal", responsable: "Claw", lastAction: "Interface HTML créée", nextAction: "Ajout features", deadline: "" },
{ id: 5, name: "POD Niches Guide", category: "completed", progress: 100, priority: "normal", responsable: "Claw", lastAction: "Page niches_business.html", nextAction: "Ajouter designs", deadline: "" },
{ id: 6, name: "PrintMind Agent", category: "completed", progress: 100, priority: "normal", responsable: "Claw", lastAction: "Skill créé et testé", nextAction: "Utiliser pour générer designs", deadline: "" },
{ id: 7, name: "Upload Designs POD", category: "idea", progress: 0, priority: "important", responsable: "h3r7", lastAction: "Design Python ajouté", nextAction: "Uploader sur Redbubble/TeeSpring", deadline: "" },
// CRM & SCRAPING
{ id: 8, name: "CRM System", category: "completed", progress: 100, priority: "normal", responsable: "Claw", lastAction: "CRM opérationnel port 8770", nextAction: "Ajout prospects", deadline: "" },
{ id: 9, name: "Scraper Prospects", category: "bloque", progress: 50, priority: "important", responsable: "VPS", lastAction: "Sites bloquent scraping direct", nextAction: "Utiliser Apify ou autre solution", deadline: "" },
{ id: 10, name: "Agent Scraper", category: "en-cours", progress: 70, priority: "normal", responsable: "Claw", lastAction: "Token corrigé", nextAction: "Tests", deadline: "" },
// BUSINESS
{ id: 11, name: "Agent Sales", category: "en-cours", progress: 70, priority: "normal", responsable: "Claw", lastAction: "Token corrigé", nextAction: "Tests", deadline: "" },
{ id: 12, name: "Agent Mailing", category: "en-cours", progress: 70, priority: "normal", responsable: "Claw", lastAction: "Token corrigé", nextAction: "Configurer Brevo/SendinBlue", deadline: "" },
{ id: 13, name: "Dépenses Trello App", category: "completed", progress: 100, priority: "normal", responsable: "Claw", lastAction: "v1.7 opérationnelle", nextAction: "Vente/Location", deadline: "" },
// INFRA
{ id: 14, name: "VPS Setup", category: "completed", progress: 100, priority: "normal", responsable: "Claw", lastAction: "Services configurés", nextAction: "Monitoring", deadline: "" },
{ id: 15, name: "Backup System", category: "completed", progress: 100, priority: "important", responsable: "Claw", lastAction: "Cron backup quotidien 3h00", nextAction: "Vérification hebdo", deadline: "" },
{ id: 16, name: "Gitea Repos", category: "completed", progress: 100, priority: "normal", responsable: "Claw", lastAction: "h3r7tech + perso configurés", nextAction: "Documentation", deadline: "" },
// DOCS & REGISTRES
{ id: 17, name: "NEXUS Registry", category: "completed", progress: 100, priority: "normal", responsable: "Claw", lastAction: "7 agents actifs documentés", nextAction: "Mise à jour auto", deadline: "" },
{ id: 18, name: "Documentation", category: "en-cours", progress: 60, priority: "normal", responsable: "Claw", lastAction: "DOCUMENTATION.md créée", nextAction: "Compléter", deadline: "" },
// IDÉES
{ id: 19, name: "Email Automation", category: "idea", progress: 0, priority: "normal", responsable: "À définir", lastAction: "En attente", nextAction: "Configurer Brevo", deadline: "" },
{ id: 20, name: "Marketing Automation", category: "idea", progress: 0, priority: "normal", responsable: "À définir", lastAction: "En attente", nextAction: "Study", deadline: "" },
// INFRA - AUTOMATION
{ id: 21, name: "Installation n8n", category: "planifie", progress: 40, priority: "important", responsable: "h3r7", lastAction: "Coolify terminé, ports 5678", nextAction: "Fixer webhook POST", deadline: "" },
{ id: 22, name: "Tests n8n", category: "planifie", progress: 20, priority: "normal", responsable: "Claw", lastAction: "Webhook GET fonctionnel", nextAction: "Tester POST avec Groq", deadline: "" },
{ id: 23, name: "Config MCP n8n", category: "planifie", progress: 0, priority: "normal", responsable: "h3r7", lastAction: "Token reçu, pas encore utilisé", nextAction: "Installer MCP server OpenClaw", deadline: "" },
{ id: 24, name: "HTTPS n8n", category: "planifie", progress: 0, priority: "normal", responsable: "h3r7", lastAction: "En attente", nextAction: "Configurer domain DuckDNS", deadline: "" },
// DOCS
{ id: 25, name: "Doc Installation n8n", category: "completed", progress: 100, priority: "normal", responsable: "Claw", lastAction: "INSTALLATION_N8N_2026-03-02.md créé", nextAction: "Publier", deadline: "" }
];
// Render Functions
function renderProjects() {
const grid = document.getElementById('projectsGrid');
grid.innerHTML = projects.map(p => `
<div class="project-card" data-category="${p.category}" data-priority="${p.priority}" data-name="${p.name.toLowerCase()}">
<div class="project-header">
<div class="project-title">${p.name}</div>
<div class="project-badges">
<span class="badge ${p.category}">${getCategoryLabel(p.category)}</span>
<span class="badge ${p.priority}">${getPriorityLabel(p.priority)}</span>
</div>
</div>
<div class="progress-container">
<div class="progress-header">
<span>Avancement</span>
<span>${p.progress}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill ${p.category}" style="width: ${p.progress}%"></div>
</div>
</div>
<div class="project-meta">
<div class="meta-item">
<i class="fas fa-user"></i>
<span class="meta-label">Responsable:</span> ${p.responsable}
</div>
<div class="meta-item">
<i class="fas fa-calendar"></i>
<span class="meta-label">Deadline:</span> ${p.deadline || 'À définir'}
</div>
</div>
<div class="project-actions">
<div class="action-row">
<span class="action-label">Dernière action:</span>
<span class="action-value">${p.lastAction}</span>
</div>
<div class="action-row">
<span class="action-label">Prochaine:</span>
<span class="action-value">${p.nextAction}</span>
</div>
</div>
</div>
`).join('');
}
function getCategoryLabel(cat) {
const labels = {
'bloque': '🔴 Bloqué',
'en-cours': '🟡 En Cours',
'planifie': '🔵 À Planifier',
'completed': '🟢 Complété',
'idea': '💡 Idée'
};
return labels[cat] || cat;
}
function getPriorityLabel(pri) {
const labels = {
'urgent': '🔥 Urgent',
'important': '⭐ Important',
'normal': '📌 Normal'
};
return labels[pri] || pri;
}
function updateStats() {
const total = projects.length;
const bloque = projects.filter(p => p.category === 'bloque').length;
const encours = projects.filter(p => p.category === 'en-cours').length;
const completed = projects.filter(p => p.category === 'completed').length;
const idea = projects.filter(p => p.category === 'idea' || p.category === 'planifie').length;
document.getElementById('totalCount').textContent = total;
document.getElementById('bloqueCount').textContent = bloque;
document.getElementById('encoursCount').textContent = encours;
document.getElementById('completedCount').textContent = completed;
document.getElementById('ideaCount').textContent = idea;
}
function renderTimeline() {
const urgentProjects = projects
.filter(p => p.category === 'bloque' || p.category === 'en-cours' || p.category === 'planifie')
.sort((a,b) => b.progress - a.progress)
.slice(0, 6);
document.getElementById('timelineItems').innerHTML = urgentProjects.map(p => `
<div class="timeline-item">
<div class="timeline-date">${new Date().toLocaleDateString('fr-FR')}</div>
<div class="timeline-content">
<strong>${p.name}</strong><br>
<span style="color:#8b949e">${p.nextAction}</span>
</div>
<span class="timeline-priority badge ${p.priority}">${getPriorityLabel(p.priority)}</span>
</div>
`).join('');
}
// Filter Functions
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
filterProjects();
});
});
document.getElementById('searchInput').addEventListener('input', filterProjects);
function filterProjects() {
const activeFilter = document.querySelector('.filter-btn.active').dataset.filter;
const search = document.getElementById('searchInput').value.toLowerCase();
document.querySelectorAll('.project-card').forEach(card => {
const category = card.dataset.category;
const name = card.dataset.name;
let show = true;
if (activeFilter !== 'all' && category !== activeFilter) show = false;
if (search && !name.includes(search)) show = false;
card.classList.toggle('hidden', !show);
});
}
// Init
document.getElementById('lastUpdate').textContent = new Date().toLocaleString('fr-FR');
document.getElementById('footerDate').textContent = new Date().toLocaleString('fr-FR');
renderProjects();
updateStats();
renderTimeline();
</script>
</body>
</html>

200
boite_a_idees_dashboard.html Executable file
View File

@@ -0,0 +1,200 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📊 Dépenses Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root { --bg-dark: #0d0d1a; --bg-card: #16162a; --bg-input: #1a1a35; --primary: #e94560; --secondary: #7b2cbf; --accent: #00d9ff; --text: #fff; --text-dim: #888; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Outfit', sans-serif; background: var(--bg-dark); color: var(--text); padding: 15px; }
h1 { text-align: center; font-size: 1.6em; background: linear-gradient(135deg, var(--primary), var(--secondary)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.nav { display: flex; gap: 10px; margin: 15px 0; }
.nav a { flex: 1; padding: 12px; background: var(--bg-card); border-radius: 10px; text-align: center; color: var(--text-dim); text-decoration: none; }
.nav a.active { background: linear-gradient(135deg, var(--primary), var(--secondary)); color: #fff; }
.card { background: var(--bg-card); border-radius: 15px; padding: 15px; margin-bottom: 15px; }
h2 { font-size: 1em; margin-bottom: 12px; color: var(--accent); }
.filter-bar { display: flex; gap: 10px; margin-bottom: 15px; flex-wrap: wrap; }
.filter-chip { padding: 10px 20px; background: var(--bg-input); border-radius: 25px; font-size: 0.9em; cursor: pointer; }
.filter-chip.active { background: var(--primary); color: #fff; }
.chart-container { height: 250px; position: relative; }
.total { text-align: right; font-size: 1.3em; margin: 15px 0; padding: 15px; background: rgba(0,217,255,0.1); border-radius: 10px; }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<h1>📊 Dépenses Dashboard</h1>
<div class="nav">
<a href="/?page=saisie" id="nav-saisie">✏️ Saisie</a>
<a href="dashboard" id="nav-dashboard" class="active">📊 Dashboard</a>
<a href="dashboard?page=config" id="nav-config">⚙️ Config</a>
</div>
<div class="card">
<h2>💰 Total: <span id="total-display">0.00€</span></h2>
</div>
<div class="card">
<h2>📊 Filtres</h2>
<div class="filter-bar" id="filter-bar">
<div class="filter-chip active" onclick="setFilter('month')">Mois</div>
<div class="filter-chip" onclick="setFilter('person')">Personne</div>
<div class="filter-chip" onclick="setFilter('category')">Catégorie</div>
</div>
</div>
<div class="card">
<h2>📈 Graphiques</h2>
<div class="filter-bar" id="chart-filters">
<div class="filter-chip active" onclick="setChartMode('bar')">Bar chart</div>
<div class="filter-chip" onclick="setChartMode('pie')">Camembert</div>
</div>
<div class="chart-container">
<canvas id="mainChart"></canvas>
</div>
</div>
<script>
var depenses = [];
var currentFilter = { type: 'month', value: null };
var chartMode = 'bar';
var chart = null;
function getFilteredData() {
if (!currentFilter.value) return depenses;
if (currentFilter.type === 'month') return depenses.filter(d => d.date && d.date.startsWith(currentFilter.value));
if (currentFilter.type === 'person') return depenses.filter(d => d.prenom === currentFilter.value);
if (currentFilter.type === 'category') return depenses.filter(d => (d.category || 'Autre') === currentFilter.value);
return depenses;
}
function setFilter(type) {
currentFilter.type = type;
currentFilter.value = null;
setChartMode('bar');
var values = [];
if (type === 'month') values = [...new Set(depenses.map(d => d.date.split('-')[0] + '-' + d.date.split('-')[1]))].sort().reverse();
else if (type === 'person') values = [...new Set(depenses.map(d => d.prenom))].sort();
else if (type === 'category') values = [...new Set(depenses.map(d => d.category || 'Autre'))].sort();
currentFilter.value = values[0] || null;
renderFilters();
updateChart();
}
function renderFilters() {
var html = '<div class="filter-chip' + (!currentFilter.value ? ' active' : '') + '" onclick="setFilter(\'' + currentFilter.type + '\')">Tous</div>';
var values = [];
if (currentFilter.type === 'month') values = [...new Set(depenses.map(d => d.date.split('-')[0] + '-' + d.date.split('-')[1]))].sort().reverse();
else if (currentFilter.type === 'person') values = [...new Set(depenses.map(d => d.prenom))].sort();
else if (currentFilter.type === 'category') values = [...new Set(depenses.map(d => d.category || 'Autre'))].sort();
for (var i = 0; i < values.length; i++) {
html += '<div class="filter-chip' + (currentFilter.value === values[i] ? ' active' : '') + '" onclick="currentFilter.value = \'' + values[i] + '\'; renderFilters(); updateChart();">' + values[i] + '</div>';
}
document.getElementById('filter-bar').innerHTML = html;
}
function setChartMode(mode) {
chartMode = mode;
document.getElementById('chart-filters').innerHTML = '<div class="filter-chip' + (mode === 'bar' ? ' active' : '') + '" onclick="setChartMode(\'bar\')">Bar chart</div><div class="filter-chip' + (mode === 'pie' ? ' active' : '') + '" onclick="setChartMode(\'pie\')">Camembert</div>';
updateChart();
}
function updateSummaries() {
var filtered = getFilteredData();
var cats = {};
var pers = {};
filtered.forEach(function(d) {
var c = d.category || 'Autre';
var p = d.prenom || 'Inconnu';
cats[c] = (cats[c] || 0) + (parseFloat(d.montant) || 0);
pers[p] = (pers[p] || 0) + (parseFloat(d.montant) || 0);
});
var catHtml = '<table style="width:100%;text-align:left"><tr><th>Catégorie</th><th>Total</th></tr>';
Object.keys(cats).sort().forEach(function(c) { catHtml += '<tr><td>'+c+'</td><td style="color:var(--primary)">'+cats[c].toFixed(2)+'€</td></tr>'; });
catHtml += '</table>';
document.getElementById("category-summary").innerHTML = catHtml;
var persHtml = '<table style="width:100%;text-align:left"><tr><th>Personne</th><th>Total</th></tr>';
Object.keys(pers).sort().forEach(function(p) { persHtml += '<tr><td>'+p+'</td><td style="color:var(--secondary)">'+pers[p].toFixed(2)+'€</td></tr>'; });
persHtml += '</table>';
document.getElementById("person-summary").innerHTML = persHtml;
}
function updateChart() {
var ctx = document.getElementById('mainChart').getContext('2d');
var filtered = getFilteredData();
var labels = [];
var data = [];
var backgroundColors = [];
if (currentFilter.type === 'month') {
var months = {};
filtered.forEach(function(d) { var m = d.date.split('-')[0] + '-' + d.date.split('-')[1]; months[m] = (months[m] || 0) + (parseFloat(d.montant) || 0); });
Object.keys(months).sort().reverse().forEach(function(m) { labels.push(m); data.push(months[m]); backgroundColors.push('rgba(0, 217, 255, 0.7)'); });
} else if (currentFilter.type === 'person') {
var persons = {};
filtered.forEach(function(d) { var p = d.prenom || 'Inconnu'; persons[p] = (persons[p] || 0) + (parseFloat(d.montant) || 0); });
Object.keys(persons).sort().forEach(function(p) { labels.push(p); data.push(persons[p]); backgroundColors.push('rgba(233, 69, 96, 0.7)'); });
} else if (currentFilter.type === 'category') {
var categories = {};
filtered.forEach(function(d) { var c = d.category || 'Autre'; categories[c] = (categories[c] || 0) + (parseFloat(d.montant) || 0); });
var colors = ['rgba(0, 217, 255, 0.7)', 'rgba(233, 69, 96, 0.7)', 'rgba(123, 44, 191, 0.7)', 'rgba(0, 255, 136, 0.7)', 'rgba(255, 200, 0, 0.7)'];
Object.keys(categories).sort().forEach(function(c, i) { labels.push(c); data.push(categories[c]); backgroundColors.push(colors[i % colors.length]); });
}
var total = filtered.reduce(function(s, d) { return s + (parseFloat(d.montant) || 0); }, 0);
document.getElementById('total-display').textContent = total.toFixed(2) + '€';
if (chart) chart.destroy();
// Category chart
var catCtx = document.getElementById('categoryChart');
if (catCtx) {
var catData = {};
filtered.forEach(function(d) { var c = d.category || 'Autre'; catData[c] = (catData[c] || 0) + (parseFloat(d.montant) || 0); });
var catLabels = Object.keys(catData).sort();
var catValues = catLabels.map(function(c) { return catData[c]; });
var catColors = ['rgba(0, 217, 255, 0.7)', 'rgba(233, 69, 96, 0.7)', 'rgba(123, 44, 191, 0.7)', 'rgba(0, 255, 136, 0.7)', 'rgba(255, 200, 0, 0.7)', 'rgba(255, 99, 132, 0.7)'];
new Chart(catCtx, {
type: 'doughnut',
data: { labels: catLabels, datasets: [{ data: catValues, backgroundColor: catColors }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { color: '#fff' } } } }
});
}
updateSummaries(); chart = new Chart(ctx, {
type: chartMode,
data: { labels: labels, datasets: [{ label: 'Montant (€)', data: data, backgroundColor: backgroundColors, borderColor: backgroundColors.map(c => c.replace('0.7', '1')), borderWidth: 1 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { color: '#fff' } } }, scales: chartMode === 'pie' ? {} : { y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#888' } }, x: { grid: { display: false }, ticks: { color: '#888' } } } }
});
}
async function load() {
try {
var r = await fetch('api/depenses');
depenses = await r.json();
setFilter('month');
} catch (e) {
console.error('Erreur:', e);
document.getElementById('total-display').textContent = 'Erreur de chargement';
}
}
load();
</script>
<div class="card">
<h2>📊 Par Catégorie</h2>
<div class="chart-container" style="height:200px">
<canvas id="categoryChart"></canvas>
</div>
<div id="category-summary"></div>
</div>
<div class="card"><h2>👤 Par Utilisateur</h2><div id="person-summary"></div></div>
</body>
</html>

246
boite_idees.html Executable file
View File

@@ -0,0 +1,246 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>💡 Boîte à Idées - PortailClaw</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 100%);
min-height: 100vh;
color: #eee;
}
header {
background: linear-gradient(90deg, #7b2cbf, #2ec4b6);
padding: 25px 20px;
text-align: center;
}
header h1 { font-size: 36px; margin-bottom: 5px; }
header p { color: rgba(255,255,255,0.8); }
.container { max-width: 1000px; margin: 0 auto; padding: 30px 20px; }
.add-form { background: #16213e; padding: 25px; border-radius: 16px; margin-bottom: 30px; }
.add-form h2 { color: #2ec4b6; margin-bottom: 20px; font-size: 20px; }
.form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 15px; }
.form-group { display: flex; flex-direction: column; }
.form-group label { color: #888; font-size: 12px; margin-bottom: 5px; }
.form-group input, .form-group select, .form-group textarea {
padding: 12px; background: #0f3460; border: 1px solid #333; border-radius: 8px; color: #eee; font-size: 14px;
}
.form-group textarea { min-height: 100px; resize: vertical; }
.btn { padding: 12px 25px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; font-size: 14px; }
.btn-primary { background: #2ec4b6; color: #000; }
.filters { display: flex; gap: 10px; margin-bottom: 25px; flex-wrap: wrap; }
.filter-btn { padding: 8px 16px; background: #16213e; border: none; border-radius: 20px; color: #888; cursor: pointer; font-size: 13px; }
.filter-btn.active { background: #7b2cbf; color: white; }
.ideas-list { display: flex; flex-direction: column; gap: 20px; }
.idea-card { background: #16213e; border-radius: 16px; padding: 25px; border-left: 4px solid #2ec4b6; }
.idea-card.eleve { border-left-color: #00ff88; }
.idea-card.moyen { border-left-color: #ffd700; }
.idea-card.faible { border-left-color: #ff6b6b; }
.idea-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px; flex-wrap: wrap; gap: 10px; }
.idea-title { font-size: 22px; font-weight: bold; }
.badge { padding: 4px 12px; border-radius: 12px; font-size: 12px; }
.badge-cat { background: #7b2cbf; color: white; }
.badge-status { background: #2ec4b6; color: #000; }
.idea-desc { color: #ccc; line-height: 1.7; white-space: pre-wrap; margin-top: 15px; padding-top: 15px; border-top: 1px solid #333; font-size: 14px; }
.back-link { display: inline-block; padding: 10px 20px; color: #888; text-decoration: none; margin-bottom: 20px; }
.back-link:hover { color: #2ec4b6; }
.loading { text-align: center; padding: 40px; color: #888; }
.error { background: #e94560; color: white; padding: 15px; border-radius: 8px; margin-bottom: 20px; }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<header>
<h1>💡 Boîte à Idées</h1>
<p>Tous vos Projets et idées business</p>
</header>
<div class="container">
<a href="/" class="back-link">← Retour au PortailClaw</a>
<div id="error-msg"></div>
<div class="add-form">
<h2> Nouvelle Idée</h2>
<div class="form-row">
<div class="form-group">
<label>Titre</label>
<input type="text" id="idea-title" placeholder="Nom du projet">
</div>
<div class="form-group">
<label>Catégorie</label>
<select id="idea-category">
<option value="tech">Tech & IA</option>
<option value="saas">SaaS</option>
<option value="service">Service</option>
<option value="produit">Produit</option>
<option value="invest">Investissement</option>
</select>
</div>
<div class="form-group">
<label>Sous-catégorie</label>
<input type="text" id="idea-subcategory" placeholder="Ex: IA, Mobile...">
</div>
</div>
<div class="form-group" style="margin-bottom:15px;">
<label>Description</label>
<textarea id="idea-desc" placeholder="Décris ton idée..."></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Statut</label>
<select id="idea-status">
<option value="idee">Idée</option>
<option value="encours">En cours</option>
<option value="teste">Testé</option>
<option value="lance">Lancé</option>
</select>
</div>
<div class="form-group">
<label>Potentiel</label>
<select id="idea-potential">
<option value="faible">Faible</option>
<option value="moyen">Moyen</option>
<option value="eleve">Élevé</option>
</select>
</div>
<div class="form-group">
<label>Revenus (€)</label>
<input type="number" id="idea-revenue" value="0">
</div>
</div>
<button class="btn btn-primary" onclick="addIdea()"> Ajouter l'idée</button>
</div>
<div class="filters">
<button class="filter-btn active" onclick="filterIdeas('all')">Toutes</button>
<button class="filter-btn" onclick="filterIdeas('idee')">Idée</button>
<button class="filter-btn" onclick="filterIdeas('encours')">En cours</button>
<button class="filter-btn" onclick="filterIdeas('teste')">Testé</button>
<button class="filter-btn" onclick="filterIdeas('lance')">Lancé</button>
</div>
<div class="ideas-list" id="ideas-list">
<div class="loading">Chargement des idées...</div>
</div>
</div>
<script>
let ideasData = { categories: [], ideas: [] };
let currentFilter = 'all';
alert("Loading..."); loadIdeas();
function loadIdeas() {
document.getElementById('ideas-list').innerHTML = '<div class="loading">Chargement...</div>';
fetch('/turf/api/ideas', {})
.then(r => {
if (!r.ok) throw new Error('Erreur: ' + r.status);
return r.json();
})
.then(data => {
ideasData = data;
renderIdeas();
})
.catch(err => {
document.getElementById('ideas-list').innerHTML = '<div class="error">Erreur: ' + err.message + '</div>';
});
}
function renderIdeas() {
const list = document.getElementById('ideas-list');
let filtered = ideasData.ideas;
if (currentFilter !== 'all') {
filtered = ideasData.ideas.filter(i => i.status === currentFilter);
}
if (!filtered || filtered.length === 0) {
list.innerHTML = '<div style="text-align:center;color:#666;padding:40px;">Aucune idée pour ce filtre</div>';
return;
}
const catNames = {};
ideasData.categories.forEach(c => catNames[c.id] = c.name);
list.innerHTML = filtered.map(idea => `
<div class="idea-card ${idea.potential || 'moyen'}">
<div class="idea-header">
<span class="idea-title">${idea.title}</span>
<div>
<span class="badge badge-cat">${catNames[idea.category] || idea.category}</span>
<span class="badge badge-status">${idea.status}</span>
</div>
</div>
<div style="color:#888;font-size:13px;">
${idea.subcategory ? '📁 ' + idea.subcategory + ' | ' : ''}
💰 ${idea.revenue || 0}€ | 📅 ${idea.created}
</div>
<div class="idea-desc">${idea.description || ''}</div>
</div>
`).join('');
}
function filterIdeas(status) {
currentFilter = status;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
event.target.classList.add('active');
renderIdeas();
}
function addIdea() {
const idea = {
title: document.getElementById('idea-title').value,
category: document.getElementById('idea-category').value,
subcategory: document.getElementById('idea-subcategory').value,
description: document.getElementById('idea-desc').value,
status: document.getElementById('idea-status').value,
potential: document.getElementById('idea-potential').value,
revenue: parseInt(document.getElementById('idea-revenue').value) || 0
};
if (!idea.title) {
alert('Titre requis!');
return;
}
fetch('/turf/api/ideas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(idea)
})
.then(r => r.json())
.then(data => {
document.getElementById('idea-title').value = '';
document.getElementById('idea-desc').value = '';
document.getElementById('idea-subcategory').value = '';
alert("Loading..."); loadIdeas();
})
.catch(err => {
alert('Erreur: ' + err.message);
});
}
</script>
<script>console.log("Page loaded")</script></body>
</html>

420
calculate_metrics.py Executable file
View File

@@ -0,0 +1,420 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
calculate_metrics.py - Calcul des métriques de performance prédictions vs résultats
Usage:
python3 calculate_metrics.py # Aujourd'hui
python3 calculate_metrics.py --date 2026-04-15 # Date spécifique
python3 calculate_metrics.py --yesterday # Hier
python3 calculate_metrics.py --backfill 30 # Remplir 30 derniers jours
Calculé après 21h (résultats PMU disponibles)
"""
import sqlite3
import json
import argparse
from datetime import datetime, timedelta
from collections import defaultdict
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
# =============================================================================
# SCHÉMA BASE DE DONNÉES
# =============================================================================
METRICS_SCHEMA = """
-- Table principale des métriques par course/source
CREATE TABLE IF NOT EXISTS prediction_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
race_time TEXT,
race_name TEXT,
race_hippodrome TEXT,
source TEXT NOT NULL,
discipline TEXT,
-- Comptages
nb_predictions INTEGER DEFAULT 0,
nb_gagnants INTEGER DEFAULT 0,
nb_places INTEGER DEFAULT 0,
nb_top5 INTEGER DEFAULT 0,
nb_hors_top5 INTEGER DEFAULT 0,
-- Taux
taux_gagnant REAL,
taux_place REAL,
taux_top5 REAL,
-- Rangs
rang_moyen REAL,
ecart_rang_moyen REAL,
-- ROI avec dividendes réels PMU
roi_sg_brut REAL,
roi_sg_net REAL,
roi_sp_brut REAL,
roi_sp_net REAL,
-- Quinté
quinte_5sur5 INTEGER DEFAULT 0,
quinte_4sur5 INTEGER DEFAULT 0,
quinte_3sur5 INTEGER DEFAULT 0,
quinte_2sur5 INTEGER DEFAULT 0,
-- Value
value_bet_score REAL,
top_cote_gagnante REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(date, race_time, source)
);
-- Vue détaillée des performances
CREATE VIEW IF NOT EXISTS v_predictions_performance AS
SELECT
pr.date,
pr.race_time,
pr.race_name,
pr.race_hippodrome,
pr.horse_number,
pr.horse_name,
pr.prediction_rank,
pr.odds AS cote_prediction,
pr.source,
pa.ordre_arrivee,
pa.cote_direct AS cote_finale,
pa.driver,
-- Indicateurs binaires
CASE WHEN pa.ordre_arrivee = 1 THEN 1 ELSE 0 END AS is_gagnant,
CASE WHEN pa.ordre_arrivee <= 3 THEN 1 ELSE 0 END AS is_place,
CASE WHEN pa.ordre_arrivee <= 5 THEN 1 ELSE 0 END AS is_top5,
-- Écarts
ABS(COALESCE(pr.prediction_rank, 99) - COALESCE(pa.ordre_arrivee, 99)) AS ecart_rang,
-- Value réalisée
CASE
WHEN pa.ordre_arrivee = 1 AND pa.cote_direct > pr.odds
THEN ROUND((pa.cote_direct - pr.odds) / pr.odds * 100, 1)
ELSE 0
END AS value_realized
FROM predictions pr
LEFT JOIN pmu_partants pa
ON pa.date_programme = pr.date
AND pa.nom = pr.horse_name;
-- Vue résumé par source (30 jours glissants)
CREATE VIEW IF NOT EXISTS v_metrics_summary_30d AS
SELECT
source,
COUNT(*) as nb_courses,
SUM(nb_predictions) as total_predictions,
SUM(nb_gagnants) as total_gagnants,
SUM(nb_places) as total_places,
SUM(nb_top5) as total_top5,
ROUND(AVG(taux_gagnant), 2) as moy_taux_gagnant,
ROUND(AVG(taux_place), 2) as moy_taux_place,
ROUND(AVG(taux_top5), 2) as moy_taux_top5,
ROUND(AVG(roi_sg_net), 3) as moy_roi_sg,
ROUND(AVG(roi_sp_net), 3) as moy_roi_sp,
ROUND(AVG(ecart_rang_moyen), 2) as moy_ecart_rang,
SUM(quinte_5sur5) as nb_5sur5,
SUM(quinte_4sur5) as nb_4sur5,
SUM(quinte_3sur5) as nb_3sur5,
ROUND(SUM(quinte_5sur5) * 100.0 / NULLIF(COUNT(*), 0), 1) as pct_5sur5,
ROUND(SUM(quinte_4sur5) * 100.0 / NULLIF(COUNT(*), 0), 1) as pct_4sur5
FROM prediction_metrics
WHERE date >= date('now', '-30 days')
GROUP BY source
ORDER BY moy_taux_place DESC;
-- Vue évolution quotidienne
CREATE VIEW IF NOT EXISTS v_metrics_daily AS
SELECT
date,
source,
SUM(nb_predictions) as predictions,
SUM(nb_gagnants) as gagnants,
SUM(nb_places) as places,
SUM(nb_top5) as top5,
ROUND(AVG(taux_gagnant), 2) as taux_gagnant,
ROUND(AVG(taux_place), 2) as taux_place,
ROUND(AVG(roi_sg_net), 3) as roi_sg,
ROUND(AVG(roi_sp_net), 3) as roi_sp,
SUM(quinte_5sur5) as quinte_5sur5,
SUM(quinte_4sur5) as quinte_4sur5
FROM prediction_metrics
GROUP BY date, source
ORDER BY date DESC;
"""
# =============================================================================
# FONCTIONS UTILITAIRES
# =============================================================================
def get_db():
"""Connexion à la base de données"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
"""Initialise les tables et vues"""
conn = get_db()
conn.executescript(METRICS_SCHEMA)
conn.commit()
conn.close()
print("✅ Tables et vues initialisées")
def get_dividende_sg(conn, date, num_reunion, num_course, horse_number):
"""Récupère le dividende Simple Gagnant pour un cheval"""
try:
row = conn.execute("""
SELECT dividende_euro
FROM pmu_rapports
WHERE date_programme = ?
AND num_reunion = ?
AND num_course = ?
AND type_pari = 'SIMPLE_GAGNANT'
AND combinaison = ?
""", (date, num_reunion, num_course, str(horse_number))).fetchone()
return row['dividende_euro'] if row else None
except:
return None
def get_dividende_sp(conn, date, num_reunion, num_course, horse_number):
"""Récupère le dividende Simple Placé pour un cheval"""
try:
row = conn.execute("""
SELECT dividende_euro
FROM pmu_rapports
WHERE date_programme = ?
AND num_reunion = ?
AND num_course = ?
AND type_pari = 'SIMPLE_PLACE'
AND combinaison = ?
""", (date, num_reunion, num_course, str(horse_number))).fetchone()
return row['dividende_euro'] if row else None
except:
return None
# =============================================================================
# CALCUL DES MÉTRIQUES
# =============================================================================
def calculate_course_metrics(conn, date, race_time, race_name, source):
"""Calcule les métriques pour une course/source donnée"""
# Récupérer les prédictions pour cette course/source
preds = conn.execute("""
SELECT
pr.horse_number,
pr.horse_name,
pr.prediction_rank,
pr.odds,
pa.ordre_arrivee,
pa.cote_direct,
pa.num_reunion,
pa.num_course
FROM predictions pr
LEFT JOIN pmu_partants pa
ON pa.date_programme = pr.date
AND pa.nom = pr.horse_name
WHERE pr.date = ?
AND pr.race_time = ?
AND pr.source = ?
""", (date, race_time, source)).fetchall()
if not preds:
return
# Métadonnées
first_pred = preds[0]
hippodrome = conn.execute("""
SELECT race_hippodrome FROM predictions
WHERE date = ? AND race_time = ?
LIMIT 1
""", (date, race_time)).fetchone()
race_hippodrome = hippodrome['race_hippodrome'] if hippodrome else None
num_reunion = first_pred['num_reunion'] if first_pred['num_reunion'] else None
num_course = first_pred['num_course'] if first_pred['num_course'] else None
# Récupérer discipline depuis pmu_courses
discipline = None
if num_reunion and num_course:
disc_row = conn.execute("""
SELECT discipline FROM pmu_courses
WHERE date_programme = ? AND num_reunion = ? AND num_course = ?
""", (date, num_reunion, num_course)).fetchone()
discipline = disc_row['discipline'] if disc_row else None
# Comptages
nb_predictions = len(preds)
nb_gagnants = sum(1 for p in preds if p['ordre_arrivee'] == 1)
nb_places = sum(1 for p in preds if p['ordre_arrivee'] and p['ordre_arrivee'] <= 3)
nb_top5 = sum(1 for p in preds if p['ordre_arrivee'] and p['ordre_arrivee'] <= 5)
nb_hors_top5 = nb_predictions - nb_top5
# Taux
taux_gagnant = round(nb_gagnants / nb_predictions * 100, 2) if nb_predictions > 0 else 0
taux_place = round(nb_places / nb_predictions * 100, 2) if nb_predictions > 0 else 0
taux_top5 = round(nb_top5 / nb_predictions * 100, 2) if nb_predictions > 0 else 0
# Rang moyen
rangs = [p['ordre_arrivee'] for p in preds if p['ordre_arrivee']]
rang_moyen = round(sum(rangs) / len(rangs), 2) if rangs else None
# Écart rang moyen
ecarts = [abs((p['prediction_rank'] or 99) - (p['ordre_arrivee'] or 99)) for p in preds]
ecart_rang_moyen = round(sum(ecarts) / len(ecarts), 2) if ecarts else None
# ROI avec dividendes réels
roi_sg_values = []
roi_sp_values = []
for p in preds:
if p['ordre_arrivee'] == 1 and num_reunion and num_course:
div_sg = get_dividende_sg(conn, date, num_reunion, num_course, p['horse_number'])
if div_sg and div_sg > 0:
roi_sg_values.append(div_sg - 1)
else:
roi_sg_values.append(-1)
elif p['ordre_arrivee'] and p['ordre_arrivee'] > 1:
roi_sg_values.append(-1)
if p['ordre_arrivee'] and p['ordre_arrivee'] <= 3 and num_reunion and num_course:
div_sp = get_dividende_sp(conn, date, num_reunion, num_course, p['horse_number'])
if div_sp and div_sp > 0:
roi_sp_values.append(div_sp - 1)
else:
roi_sp_values.append(-1)
elif p['ordre_arrivee'] and p['ordre_arrivee'] > 3:
roi_sp_values.append(-1)
roi_sg_brut = sum(roi_sg_values) if roi_sg_values else 0
roi_sg_net = round(roi_sg_brut / len(roi_sg_values), 3) if roi_sg_values else 0
roi_sp_brut = sum(roi_sp_values) if roi_sp_values else 0
roi_sp_net = round(roi_sp_brut / len(roi_sp_values), 3) if roi_sp_values else 0
# Quinté (5 chevaux dans le top 5)
quinte_5sur5 = 1 if nb_top5 >= 5 else 0
quinte_4sur5 = 1 if nb_top5 >= 4 else 0
quinte_3sur5 = 1 if nb_top5 >= 3 else 0
quinte_2sur5 = 1 if nb_top5 >= 2 else 0
# Value bet score
value_scores = []
for p in preds:
if p['ordre_arrivee'] == 1 and p['cote_direct'] and p['odds']:
value = (p['cote_direct'] - p['odds']) / p['odds'] * 100
value_scores.append(value)
value_bet_score = round(sum(value_scores) / len(value_scores), 2) if value_scores else 0
# Top cote gagnante
top_cotes = [p['cote_direct'] for p in preds if p['ordre_arrivee'] == 1 and p['cote_direct']]
top_cote_gagnante = max(top_cotes) if top_cotes else None
# Insérer ou mettre à jour
conn.execute("""
INSERT OR REPLACE INTO prediction_metrics (
date, race_time, race_name, race_hippodrome, source, discipline,
nb_predictions, nb_gagnants, nb_places, nb_top5, nb_hors_top5,
taux_gagnant, taux_place, taux_top5,
rang_moyen, ecart_rang_moyen,
roi_sg_brut, roi_sg_net, roi_sp_brut, roi_sp_net,
quinte_5sur5, quinte_4sur5, quinte_3sur5, quinte_2sur5,
value_bet_score, top_cote_gagnante
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
date, race_time, race_name, race_hippodrome, source, discipline,
nb_predictions, nb_gagnants, nb_places, nb_top5, nb_hors_top5,
taux_gagnant, taux_place, taux_top5,
rang_moyen, ecart_rang_moyen,
roi_sg_brut, roi_sg_net, roi_sp_brut, roi_sp_net,
quinte_5sur5, quinte_4sur5, quinte_3sur5, quinte_2sur5,
value_bet_score, top_cote_gagnante
))
def calculate_metrics(date_str):
"""Calcule les métriques pour une date donnée"""
init_db()
conn = get_db()
# Récupérer les courses avec prédictions ET résultats
courses = conn.execute("""
SELECT DISTINCT
pr.date,
pr.race_time,
pr.race_name
FROM predictions pr
JOIN pmu_partants pa
ON pa.date_programme = pr.date
AND pa.nom = pr.horse_name
WHERE pr.date = ?
AND pa.ordre_arrivee IS NOT NULL
""", (date_str,)).fetchall()
if not courses:
print(f"⚠️ Aucune course avec résultats pour {date_str}")
conn.close()
return
sources = [
'canalturf_selections',
'canalturf_prono_bases',
'canalturf_prono_chances',
'canalturf_prono_outsiders',
'canalturf_partants'
]
total_calculated = 0
for course in courses:
for source in sources:
try:
calculate_course_metrics(conn, course['date'], course['race_time'], course['race_name'], source)
total_calculated += 1
except Exception as e:
print(f"⚠️ Erreur {course['race_time']} {source}: {e}")
conn.commit()
conn.close()
print(f"✅ Métriques calculées pour {date_str}: {total_calculated} combinaisons course/source")
def backfill_metrics(days=30):
"""Remplit les métriques sur plusieurs jours"""
print(f"📊 Backfill sur {days} jours...")
for i in range(days):
date = (datetime.now() - timedelta(days=i)).strftime('%Y-%m-%d')
print(f"{date}")
try:
calculate_metrics(date)
except Exception as e:
print(f" ⚠️ Erreur: {e}")
print("✅ Backfill terminé")
# =============================================================================
# POINT D'ENTRÉE
# =============================================================================
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Calcul des métriques de performance")
parser.add_argument("--date", "-d", help="Date YYYY-MM-DD")
parser.add_argument("--yesterday", "-y", action="store_true", help="Calculer hier")
parser.add_argument("--backfill", "-b", type=int, help="Remplir N derniers jours")
args = parser.parse_args()
if args.backfill:
backfill_metrics(args.backfill)
elif args.yesterday:
date_str = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
calculate_metrics(date_str)
elif args.date:
calculate_metrics(args.date)
else:
calculate_metrics(datetime.now().strftime('%Y-%m-%d'))

31
clean_preds.py Executable file
View File

@@ -0,0 +1,31 @@
import sqlite3
conn = sqlite3.connect('/home/h3r7/turf_scraper/turf.db')
c = conn.cursor()
# Delete duplicate predictions for today (keep only latest)
c.execute('DELETE FROM predictions WHERE date = "2026-02-24"')
# Add clean predictions
preds = [
(7, "I'M A BELIEVER", 1),
(1, 'PRINCE DE MONTFORT', 2),
(3, 'GRAND BALCON', 3),
(8, 'PAOLINO', 4),
(11, 'INCREMENTAL', 5),
(15, 'PRINCESSE SAPHIR', 6),
(16, 'GOLD PLAYER', 7),
(14, 'WEEMAGATEE', 8),
]
today = '2026-02-24'
for num, name, rank in preds:
c.execute('INSERT INTO predictions (date, race_name, horse_number, horse_name, prediction_rank, source) VALUES (?, ?, ?, ?, ?, ?)',
(today, 'Quinte Cagnes-sur-Mer', num, name, rank, 'canalturf'))
conn.commit()
print('Cleaned!')
c.execute('SELECT horse_number, horse_name, prediction_rank FROM predictions WHERE date = ?', (today,))
for r in c.fetchall():
print(f" {r[0]} - {r[1]} (rank {r[2]})")
conn.close()

3589
combined_api.py Executable file

File diff suppressed because it is too large Load Diff

79
compare_all.py Executable file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
Compare All Predictions (Our + External Sources)
"""
import sqlite3
from datetime import datetime
from collections import defaultdict
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
def compare_all(date):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
print(f"\n{'='*60}")
print(f"COMPARAISON COMPLÈTE - {date}")
print(f"{'='*60}")
# Our predictions
print("\n[NOTRE SYSTÈME]")
c.execute("SELECT horse_name, odds, prediction_rank FROM predictions WHERE date = ? ORDER BY prediction_rank", (date,))
our_preds = c.fetchall()
for name, odds, rank in our_preds:
print(f" {rank}. {name} ({odds})")
# External predictions grouped by source
c.execute("SELECT DISTINCT source FROM external_predictions WHERE date = ?", (date,))
sources = [r[0] for r in c.fetchall()]
for source in sources:
print(f"\n[{source.upper()}]")
c.execute("SELECT horse_name, odds, rank, confidence FROM external_predictions WHERE date = ? AND source = ? ORDER BY rank", (date, source))
preds = c.fetchall()
for name, odds, rank, conf in preds:
conf_str = f" ({conf}%)" if conf else ""
print(f" {rank}. {name} ({odds}){conf_str}")
# Results
print("\n[RÉSULTATS]")
c.execute("SELECT horse_name, position FROM results WHERE date = ? AND position <= 5", (date,))
results = c.fetchall()
for name, pos in results:
print(f" {pos}. {name}")
# Score
if results:
result_names = [r[0] for r in results]
print(f"\n[SCORE - 3 premiers]")
# Our system
our_hits = sum(1 for p in our_preds if p[0] in result_names[:3])
print(f" Nous: {our_hits}/3")
# Each source
for source in sources:
c.execute("SELECT horse_name FROM external_predictions WHERE date = ? AND source = ? ORDER BY rank", (date, source))
preds = [r[0] for r in c.fetchall()]
hits = sum(1 for p in preds if p in result_names[:3])
print(f" {source}: {hits}/3")
conn.close()
def add_external(date, source, race_name, horse_name, odds, rank, confidence=None):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''
INSERT INTO external_predictions (date, source, race_name, horse_name, odds, rank, confidence)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (date, source, race_name, horse_name, odds, rank, confidence))
conn.commit()
conn.close()
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
compare_all(sys.argv[1])
else:
compare_all(datetime.now().strftime('%Y-%m-%d'))

207
compare_models.py Normal file
View File

@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""
Compare scoring models performance vs actual results
"""
import sqlite3
import sys
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
def get_results(date):
"""Get actual race results"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("""
SELECT race_name, horse_name, position
FROM results
WHERE date = ?
ORDER BY race_name, position
""", (date,))
results = {}
for row in c.fetchall():
race = row['race_name']
if race not in results:
results[race] = []
results[race].append({
'horse': row['horse_name'],
'position': row['position']
})
conn.close()
return results
def get_predictions_v1(date):
"""Get scoring v1 predictions"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("""
SELECT race_name, horse_name, score, rang_scoring
FROM scoring
WHERE date = ? AND scoring_version = 'v1'
ORDER BY race_name, rang_scoring
""", (date,))
results = {}
for row in c.fetchall():
race = row['race_name']
if race not in results:
results[race] = []
results[race].append({
'horse': row['horse_name'],
'score': row['score'],
'rank': row['rang_scoring']
})
conn.close()
return results
def get_predictions_v2(date):
"""Get scoring v2 predictions"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("""
SELECT race_name, horse_name, score, rang_scoring
FROM scoring
WHERE date = ? AND scoring_version = 'v2'
ORDER BY race_name, rang_scoring
""", (date,))
results = {}
for row in c.fetchall():
race = row['race_name']
if race not in results:
results[race] = []
results[race].append({
'horse': row['horse_name'],
'score': row['score'],
'rank': row['rang_scoring']
})
conn.close()
return results
def get_canalturf(date):
"""Get CanalTurf predictions"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("""
SELECT race_name, horse_name, prediction_rank
FROM predictions
WHERE date = ? AND source = 'canalturf_partants'
ORDER BY race_name, prediction_rank
""", (date,))
results = {}
for row in c.fetchall():
race = row['race_name']
if race not in results:
results[race] = []
results[race].append({
'horse': row['horse_name'],
'rank': row['prediction_rank']
})
conn.close()
return results
def calculate_hits(predictions, actual, top_n=3):
"""Calculate hits for top N predictions"""
hits = 0
for race, actual_horses in actual.items():
if race not in predictions:
continue
pred_horses = [h['horse'] for h in predictions[race][:top_n]]
actual_top = [h['horse'] for h in actual_horses[:top_n]]
for p in pred_horses:
if p in actual_top:
hits += 1
return hits
def compare_models(date):
print(f"\n{'='*70}")
print(f"COMPARAISON DES MODÈLES - {date}")
print(f"{'='*70}\n")
# Get data
actual = get_results(date)
v1 = get_predictions_v1(date)
v2 = get_predictions_v2(date)
canal = get_canalturf(date)
if not actual:
print("Aucun résultat trouvé pour cette date")
return
print(f"Courses avec résultats: {len(actual)}")
# Calculate hits for each model
print(f"\n{'MODÈLE':<20} | {'TOP 1':<8} | {'TOP 3':<8} | {'TOP 5':<8}")
print("-" * 50)
# Top 1
v1_hits = calculate_hits(v1, actual, 1)
v2_hits = calculate_hits(v2, actual, 1)
canal_hits = calculate_hits(canal, actual, 1)
total_races = len(actual)
print(f"Scoring V1 | {v1_hits}/{total_races} ({v1_hits*100/total_races:.0f}%) | - | - ")
print(f"Scoring V2 | {v2_hits}/{total_races} ({v2_hits*100/total_races:.0f}%) | - | - ")
print(f"CanalTurf | {canal_hits}/{total_races} ({canal_hits*100/total_races:.0f}%) | - | - ")
# Top 3
v1_hits = calculate_hits(v1, actual, 3)
v2_hits = calculate_hits(v2, actual, 3)
canal_hits = calculate_hits(canal, actual, 3)
print(f"Scoring V1 | - | {v1_hits}/{total_races*3} ({v1_hits*100/(total_races*3):.0f}%) | - ")
print(f"Scoring V2 | - | {v2_hits}/{total_races*3} ({v2_hits*100/(total_races*3):.0f}%) | - ")
print(f"CanalTurf | - | {canal_hits}/{total_races*3} ({canal_hits*100/(total_races*3):.0f}%) | - ")
# Top 5
v1_hits = calculate_hits(v1, actual, 5)
v2_hits = calculate_hits(v2, actual, 5)
canal_hits = calculate_hits(canal, actual, 5)
print(f"Scoring V1 | - | - | {v1_hits}/{total_races*5} ({v1_hits*100/(total_races*5):.0f}%)")
print(f"Scoring V2 | - | - | {v2_hits}/{total_races*5} ({v2_hits*100/(total_races*5):.0f}%)")
print(f"CanalTurf | - | - | {canal_hits}/{total_races*5} ({canal_hits*100/(total_races*5):.0f}%)")
# Detailed per race
print(f"\n{'='*70}")
print("DÉTAIL PAR COURSE")
print(f"{'='*70}")
for race, actual_horses in actual.items():
print(f"\n🏇 {race}")
print(f" Résultat: {' / '.join([h['horse'] for h in actual_horses[:5]])}")
if race in v1:
print(f" V1: {' / '.join([h['horse'] for h in v1[race][:3]])}")
else:
print(f" V1: Pas de prédictions")
if race in v2:
print(f" V2: {' / '.join([h['horse'] for h in v2[race][:3]])}")
else:
print(f" V2: Pas de prédictions")
if race in canal:
print(f" CT: {' / '.join([h['horse'] for h in canal[race][:3]])}")
else:
print(f" CT: Pas de prédictions")
if __name__ == "__main__":
date = sys.argv[1] if len(sys.argv) > 1 else "2026-04-06"
compare_models(date)

68
compare_predictions.py Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""
Compare Grok vs Our predictions
"""
import sqlite3
from datetime import datetime
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
def add_grok_prediction(date, race_name, horse_name, odds, rank):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''
INSERT INTO grok_predictions (date, race_name, horse_name, odds, rank)
VALUES (?, ?, ?, ?, ?)
''', (date, race_name, horse_name, odds, rank))
conn.commit()
conn.close()
print(f"Added: {horse_name}")
def compare_predictions(date):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
print(f"\n{'='*50}")
print(f"COMPARAISON - {date}")
print(f"{'='*50}")
# Our predictions
print("\n[NOS PREDICTIONS]")
c.execute("SELECT horse_name, odds, prediction_rank FROM predictions WHERE date = ? ORDER BY prediction_rank", (date,))
our_preds = c.fetchall()
for name, odds, rank in our_preds:
print(f" {rank}. {name} (cote: {odds})")
# Grok predictions
print("\n[PREDICTIONS GROK]")
c.execute("SELECT horse_name, odds, rank FROM grok_predictions WHERE date = ? ORDER BY rank", (date,))
grok_preds = c.fetchall()
for name, odds, rank in grok_preds:
print(f" {rank}. {name} (cote: {odds})")
# Results (if available)
print("\n[RÉSULTATS]")
c.execute("SELECT horse_name, position FROM results WHERE date = ? AND position <= 5", (date,))
results = c.fetchall()
for name, pos in results:
print(f" {pos}. {name}")
# Comparison
if our_preds and grok_preds and results:
result_names = [r[0] for r in results]
our_hits = sum(1 for p in our_preds if p[0] in result_names[:3])
grok_hits = sum(1 for p in grok_preds if p[0] in result_names[:3])
print(f"\n[SCORE]")
print(f" Nos picks dans les 3 premiers: {our_hits}/3")
print(f" Grok picks dans les 3 premiers: {grok_hits}/3")
conn.close()
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
compare_predictions(sys.argv[1])
else:
compare_predictions(datetime.now().strftime('%Y-%m-%d'))

89
crm_api.py Executable file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
from flask import Flask, jsonify, request, send_from_directory
from flask_cors import CORS
import json
import os
from datetime import datetime
app = Flask(__name__)
CORS(app)
CRM_FILE = '/home/h3r7/turf_scraper/crm_prospects.json'
def load_crm():
if not os.path.exists(CRM_FILE):
data = {'prospects': [], 'last_id': 0}
with open(CRM_FILE, 'w') as f:
json.dump(data, f, indent=2)
return data
with open(CRM_FILE, 'r') as f:
return json.load(f)
def save_crm(data):
with open(CRM_FILE, 'w') as f:
json.dump(data, f, indent=2)
@app.route('/')
def index():
return send_from_directory('/home/h3r7/turf_scraper', 'crm_dashboard.html')
@app.route('/api/prospects', methods=['GET'])
def get_prospects():
data = load_crm()
return jsonify(data)
@app.route('/api/prospects', methods=['POST'])
def add_prospect():
data = load_crm()
req = request.json
# Generate string ID if not provided
prospect_id = req.get('id') or f"prospect_{data['last_id'] + 1}"
data['last_id'] += 1
prospect = {
'id': prospect_id,
'nom': req.get('nom', ''),
'entreprise': req.get('entreprise', ''),
'tel': req.get('tel', ''),
'email': req.get('email', ''),
'secteur': req.get('secteur', ''),
'statut': req.get('statut', 'nouveau'),
'score': req.get('score', 0),
'notes': req.get('notes', ''),
'adresse': req.get('adresse', ''),
'ville': req.get('ville', ''),
'categorie': req.get('categorie', ''),
'created': datetime.now().strftime('%Y-%m-%d')
}
data['prospects'].append(prospect)
save_crm(data)
return jsonify({'success': True, 'prospect': prospect})
@app.route('/api/prospects/<path:prospect_id>', methods=['PUT'])
def update_prospect(prospect_id):
data = load_crm()
req = request.json
for p in data['prospects']:
if str(p['id']) == str(prospect_id):
p.update(req)
save_crm(data)
return jsonify({'success': True, 'prospect': p})
return jsonify({'error': 'Prospect non trouvé'}), 404
@app.route('/api/prospects/<path:prospect_id>', methods=['DELETE'])
def delete_prospect(prospect_id):
data = load_crm()
initial_len = len(data['prospects'])
data['prospects'] = [p for p in data['prospects'] if str(p['id']) != str(prospect_id)]
if len(data['prospects']) < initial_len:
save_crm(data)
return jsonify({'success': True})
return jsonify({'error': 'Prospect non trouvé'}), 404
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8770, debug=False)

395
crm_candidatures.html Executable file
View File

@@ -0,0 +1,395 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>CRM Candidatures</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, sans-serif; background: #1a1a2e; color: #eee; padding: 20px; }
header { background: linear-gradient(90deg, #e94560, #7b2cbf); padding: 20px; text-align: center; margin: -20px -20px 20px; border-radius: 0 0 20px 20px; }
h1 { font-size: 28px; margin-bottom: 10px; }
.subtitle { color: #aaa; font-size: 14px; }
.stats { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; margin-bottom: 20px; }
.stat-card { background: #16213e; padding: 15px; border-radius: 8px; text-align: center; }
.stat-num { font-size: 24px; color: #00d9ff; font-weight: bold; }
.stat-label { color: #aaa; font-size: 12px; }
.kanban { display: grid; grid-template-columns: repeat(6, 1fr); gap: 15px; overflow-x: auto; }
.column { background: #16213e; border-radius: 12px; padding: 15px; min-width: 250px; }
.column-header { font-size: 14px; font-weight: bold; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid; }
.column-a_postuler { border-color: #ffd700; }
.column-en_attente { border-color: #ff9f43; }
.column-entretien_1 { border-color: #00d9ff; }
.column-entretien_2 { border-color: #7b2cbf; }
.column-offre { border-color: #00ff88; }
.column-refus { border-color: #e94560; }
.card { background: #0f3460; padding: 12px; border-radius: 8px; margin-bottom: 10px; cursor: pointer; }
.card:hover { background: #1a4a7a; }
.card-entreprise { font-weight: bold; color: #fff; font-size: 14px; }
.card-poste { color: #00d9ff; font-size: 12px; margin: 5px 0; }
.card-date { color: #888; font-size: 11px; }
.card-rappel { background: #e94560; color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 10px; margin-top: 5px; display: inline-block; }
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000; }
.modal.show { display: flex; justify-content: center; align-items: center; }
.modal-content { background: #16213e; padding: 30px; border-radius: 15px; width: 600px; max-width: 90%; max-height: 80vh; overflow-y: auto; }
.form-group { margin-bottom: 15px; }
.form-group label { display: block; color: #aaa; margin-bottom: 5px; font-size: 12px; }
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 10px; background: #0f3460; border: 1px solid #333; border-radius: 8px; color: #fff; }
.btn { padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
.btn-primary { background: #00d9ff; color: #000; }
.btn-secondary { background: #666; color: #fff; }
.btn-danger { background: #e94560; color: #fff; }
.contact-history { margin-top: 20px; padding-top: 20px; border-top: 1px solid #333; }
.contact-item { background: #0f3460; padding: 10px; border-radius: 8px; margin-bottom: 10px; }
.contact-date { color: #00d9ff; font-size: 11px; }
.contact-type { color: #aaa; font-size: 11px; }
.add-form { background: #16213e; padding: 20px; border-radius: 12px; margin-bottom: 20px; }
.form-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<header>
<h1>🎯 CRM Candidatures</h1>
<div class="subtitle">Suivi de recherche d'emploi - Pipeline</div>
</header>
<div class="stats" id="stats">
<div class="stat-card"><div class="stat-num" id="total">0</div><div class="stat-label">Total</div></div>
<div class="stat-card"><div class="stat-num" id="a_postuler">0</div><div class="stat-label">À Postuler</div></div>
<div class="stat-card"><div class="stat-num" id="en_attente">0</div><div class="stat-label">En Attente</div></div>
<div class="stat-card"><div class="stat-num" id="entretien">0</div><div class="stat-label">Entretiens</div></div>
<div class="stat-card"><div class="stat-num" id="offres">0</div><div class="stat-label">Offres</div></div>
<div class="stat-card"><div class="stat-num" id="refus">0</div><div class="stat-label">Refus</div></div>
</div>
<div class="add-form">
<h2> Nouvelle Candidature</h2>
<div class="form-row">
<input type="text" id="entreprise" placeholder="Entreprise *">
<input type="text" id="poste" placeholder="Poste Recherché *">
<input type="text" id="source" placeholder="Source (LinkedIn, Indeed...)">
</div>
<div class="form-row" style="margin-top:10px">
<input type="url" id="url" placeholder="URL offre">
<input type="number" id="salaire" placeholder="Salaire (€)">
<input type="date" id="date_publication"> Date publication
</div>
<button class="btn btn-primary" style="margin-top:15px" onclick="addCandidature()">Ajouter</button>
</div>
<div class="kanban">
<div class="column column-a_postuler">
<div class="column-header">📝 À Postuler</div>
<div id="a_postuler-list"></div>
</div>
<div class="column column-en_attente">
<div class="column-header">⏳ En Attente</div>
<div id="en_attente-list"></div>
</div>
<div class="column column-entretien_1">
<div class="column-header">🎤 Entretien 1</div>
<div id="entretien_1-list"></div>
</div>
<div class="column column-entretien_2">
<div class="column-header">🎓 Entretien 2</div>
<div id="entretien_2-list"></div>
</div>
<div class="column column-offre">
<div class="column-header">✅ Offre</div>
<div id="offre-list"></div>
</div>
<div class="column column-refus">
<div class="column-header">❌ Refus</div>
<div id="refus-list"></div>
</div>
</div>
<div class="modal" id="modal">
<div class="modal-content">
<h2 id="modal-title">Modifier Candidature</h2>
<input type="hidden" id="cand-id">
<div class="form-group">
<label>Entreprise</label>
<input type="text" id="edit-entreprise">
</div>
<div class="form-group">
<label>Poste</label>
<input type="text" id="edit-poste">
</div>
<div class="form-group">
<label>URL Offre</label>
<input type="url" id="edit-url">
</div>
<div class="form-group">
<label>Salaire</label>
<input type="number" id="edit-salaire">
</div>
<div class="form-group">
<label>Étape Pipeline</label>
<select id="edit-etape">
<option value="a_postuler">À Postuler</option>
<option value="en_attente">En Attente</option>
<option value="entretien_1">Entretien 1</option>
<option value="entretien_2">Entretien 2</option>
<option value="offre">Offre</option>
<option value="refus">Refus</option>
</select>
</div>
<div class="form-group">
<label>Prochain Rappel</label>
<input type="date" id="edit-rappel">
</div>
<div class="form-group">
<label>Notes</label>
<textarea id="edit-notes" rows="3"></textarea>
</div>
<div class="contact-history">
<h3>📞 Historique Contacts</h3>
<div id="contacts-list"></div>
<div class="form-group" style="margin-top:15px">
<input type="text" id="new-contact-type" placeholder="Type (appel, email, rdv)" style="width:30%">
<input type="text" id="new-contact-interlocuteur" placeholder="Interlocuteur" style="width:30%">
<input type="date" id="new-contact-date" style="width:30%">
<textarea id="new-contact-notes" placeholder="Notes contact" rows="2" style="margin-top:5px"></textarea>
<button class="btn btn-primary" style="margin-top:5px" onclick="addContact()">Ajouter Contact</button>
</div>
</div>
<div style="display:flex;gap:10px;margin-top:20px">
<button class="btn btn-primary" onclick="saveCandidature()">Enregistrer</button>
<button class="btn btn-secondary" onclick="closeModal()">Annuler</button>
<button class="btn btn-danger" onclick="deleteCandidature()">Supprimer</button>
</div>
</div>
</div>
<script>
let candidatures = [];
// Load from localStorage or API
function load() {
const stored = localStorage.getItem('candidatures');
if (stored) {
candidatures = JSON.parse(stored);
} else {
// Fetch from API and transform
fetch('api/prospects')
.then(r => r.json())
.then(data => {
// Transform prospects to candidatures
candidatures = data.prospects.map(p => ({
id: p.id,
entreprise: {
nom: p.nom,
adresse: p.adresse,
tel: p.tel,
email: p.email,
categorie: p.categorie
},
poste: {
titre: '',
url: '',
salaire: null,
source: '',
localisation: p.adresse
},
pipeline: {
etape: 'a_postuler',
date_changement: new Date().toISOString().split('T')[0],
historique: []
},
suivi: {
dernier_contact: null,
prochain_rappel: null,
contacts: [],
notes: p.notes || '',
score_confiance: p.score || 0
},
created: p.created,
updated: new Date().toISOString().split('T')[0]
}));
save();
});
}
render();
}
function save() {
localStorage.setItem('candidatures', JSON.stringify(candidatures));
render();
}
function render() {
// Clear columns
['a_postuler', 'en_attente', 'entretien_1', 'entretien_2', 'offre', 'refus'].forEach(etape => {
document.getElementById(etape + '-list').innerHTML = '';
});
// Stats
const stats = { a_postuler: 0, en_attente: 0, entretien_1: 0, entretien_2: 0, offre: 0, refus: 0, total: candidatures.length };
// Render cards
candidatures.forEach((c, i) => {
const etape = c.pipeline.etape;
stats[etape] = (stats[etape] || 0) + 1;
const card = document.createElement('div');
card.className = 'card';
card.onclick = () => openEdit(i);
const today = new Date().toISOString().split('T')[0];
let rappelHtml = '';
if (c.suivi.prochain_rappel && c.suivi.prochain_rappel <= today) {
rappelHtml = '<span class="card-rappel">⚠️ Rappel!</span>';
}
card.innerHTML = `
<div class="card-entreprise">${c.entreprise.nom || '-'}</div>
<div class="card-poste">${c.poste.titre || 'Poste à définir'}</div>
${c.poste.salaire ? '<div class="card-date">💰 ' + c.poste.salaire + '€</div>' : ''}
${rappelHtml}
<div class="card-date">📅 ${c.pipeline.date_changement}</div>
`;
document.getElementById(etape + '-list').appendChild(card);
});
// Update stats
document.getElementById('total').textContent = stats.total;
document.getElementById('a_postuler').textContent = stats.a_postuler;
document.getElementById('en_attente').textContent = stats.en_attente;
document.getElementById('entretien').textContent = (stats.entretien_1 || 0) + (stats.entretien_2 || 0);
document.getElementById('offres').textContent = stats.offre;
document.getElementById('refus').textContent = stats.refus;
}
function addCandidature() {
const entreprise = document.getElementById('entreprise').value;
const poste = document.getElementById('poste').value;
if (!entreprise || !poste) return alert('Entreprise et Poste requis!');
const cand = {
id: 'cand_' + Date.now(),
entreprise: { nom: entreprise },
poste: {
titre: poste,
url: document.getElementById('url').value,
salaire: document.getElementById('salaire').value,
source: document.getElementById('source').value,
date_publication: document.getElementById('date_publication').value
},
pipeline: { etape: 'a_postuler', date_changement: new Date().toISOString().split('T')[0], historique: [] },
suivi: { contacts: [], notes: '' },
created: new Date().toISOString().split('T')[0],
updated: new Date().toISOString().split('T')[0]
};
candidatures.push(cand);
save();
// Clear form
document.getElementById('entreprise').value = '';
document.getElementById('poste').value = '';
document.getElementById('url').value = '';
document.getElementById('salaire').value = '';
}
let currentIdx = null;
function openEdit(idx) {
currentIdx = idx;
const c = candidatures[idx];
document.getElementById('modal-title').textContent = 'Modifier: ' + (c.entreprise.nom || '');
document.getElementById('cand-id').value = c.id;
document.getElementById('edit-entreprise').value = c.entreprise.nom || '';
document.getElementById('edit-poste').value = c.poste.titre || '';
document.getElementById('edit-url').value = c.poste.url || '';
document.getElementById('edit-salaire').value = c.poste.salaire || '';
document.getElementById('edit-etape').value = c.pipeline.etape;
document.getElementById('edit-rappel').value = c.suivi.prochain_rappel || '';
document.getElementById('edit-notes').value = c.suivi.notes || '';
renderContacts(c);
document.getElementById('modal').classList.add('show');
}
function renderContacts(c) {
const list = document.getElementById('contacts-list');
list.innerHTML = (c.suivi.contacts || []).map(contact => `
<div class="contact-item">
<div class="contact-date">${contact.date} - <span class="contact-type">${contact.type}</span></div>
<div>${contact.interlocuteur || ''}</div>
<div style="color:#888;font-size:11px">${contact.notes || ''}</div>
</div>
`).join('');
}
function addContact() {
const c = candidatures[currentIdx];
if (!c.suivi.contacts) c.suivi.contacts = [];
c.suivi.contacts.push({
type: document.getElementById('new-contact-type').value || 'appel',
interlocuteur: document.getElementById('new-contact-interlocuteur').value,
date: document.getElementById('new-contact-date').value || new Date().toISOString().split('T')[0],
notes: document.getElementById('new-contact-notes').value
});
c.suivi.dernier_contact = new Date().toISOString().split('T')[0];
save();
renderContacts(c);
}
function saveCandidature() {
const c = candidatures[currentIdx];
c.entreprise.nom = document.getElementById('edit-entreprise').value;
c.poste.titre = document.getElementById('edit-poste').value;
c.poste.url = document.getElementById('edit-url').value;
c.poste.salaire = document.getElementById('edit-salaire').value;
c.suivi.notes = document.getElementById('edit-notes').value;
c.suivi.prochain_rappel = document.getElementById('edit-rappel').value;
const newEtape = document.getElementById('edit-etape').value;
if (newEtape !== c.pipeline.etape) {
c.pipeline.historique.push({ etape: c.pipeline.etape, date: c.pipeline.date_changement });
c.pipeline.etape = newEtape;
c.pipeline.date_changement = new Date().toISOString().split('T')[0];
}
c.updated = new Date().toISOString().split('T')[0];
save();
closeModal();
}
function deleteCandidature() {
if (!confirm('Supprimer cette candidature?')) return;
candidatures.splice(currentIdx, 1);
save();
closeModal();
}
function closeModal() {
document.getElementById('modal').classList.remove('show');
currentIdx = null;
}
// Initialize
load();
</script>
</body>
</html>

324
crm_dashboard.html Executable file
View File

@@ -0,0 +1,324 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>CRM H3R7 - Gestion Prospects</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #1a1a2e; color: #eee; padding: 20px; }
header { background: linear-gradient(90deg, #e94560, #7b2cbf); padding: 20px; text-align: center; margin: -20px -20px 20px; border-radius: 0 0 20px 20px; }
h1 { font-size: 28px; }
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-bottom: 20px; }
.stat-card { background: #16213e; padding: 20px; border-radius: 12px; text-align: center; }
.stat-num { font-size: 32px; font-weight: bold; color: #00d9ff; }
.stat-label { color: #aaa; font-size: 14px; margin-top: 5px; }
.add-form { background: #16213e; padding: 20px; border-radius: 12px; margin-bottom: 20px; }
.add-form h2 { margin-bottom: 15px; color: #fff; }
.form-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
input, select { width: 100%; padding: 12px; margin: 5px 0; background: #0f3460; border: 1px solid #333; border-radius: 8px; color: #fff; }
button { padding: 12px 20px; background: #00d9ff; border: none; border-radius: 8px; color: #000; font-weight: bold; cursor: pointer; }
button:hover { background: #00b8d4; }
.prospects-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 15px; }
.prospect-card { background: #16213e; padding: 15px; border-radius: 12px; border-left: 4px solid #00d9ff; }
.prospect-card.nouveau { border-left-color: #ffd700; }
.prospect-card.quali { border-left-color: #00d9ff; }
.prospect-card.proposition { border-left-color: #7b2cbf; }
.prospect-card.gagne { border-left-color: #00ff88; }
.prospect-card.perdu { border-left-color: #e94560; }
.prospect-name { font-size: 18px; font-weight: bold; margin-bottom: 5px; }
.prospect-entreprise { color: #aaa; font-size: 14px; margin-bottom: 10px; }
.prospect-info { font-size: 13px; color: #888; margin: 3px 0; }
.prospect-secteur { background: #7b2cbf; padding: 3px 8px; border-radius: 10px; font-size: 11px; display: inline-block; margin-top: 8px; }
.score { float: right; }
.score-star { color: #ffd700; }
.actions { margin-top: 10px; display: flex; gap: 5px; }
.btn-edit { background: #7b2cbf; color: #fff; padding: 5px 10px; border: none; border-radius: 5px; cursor: pointer; font-size: 12px; }
.btn-delete { background: #e94560; color: #fff; padding: 5px 10px; border: none; border-radius: 5px; cursor: pointer; font-size: 12px; }
.filter-bar { margin-bottom: 15px; display: flex; gap: 10px; }
.filter-btn { padding: 8px 15px; background: #0f3460; border: none; border-radius: 8px; color: #fff; cursor: pointer; }
.filter-btn.active { background: #00d9ff; color: #000; }
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); }
.modal.show { display: flex; justify-content: center; align-items: center; }
.modal-content { background: #16213e; padding: 30px; border-radius: 15px; width: 500px; max-width: 90%; }
.modal h2 { margin-bottom: 20px; }
.modal-buttons { display: flex; gap: 10px; margin-top: 20px; }
.btn-cancel { background: #e94560; }
.badge { padding: 3px 8px; border-radius: 5px; font-size: 11px; margin-left: 5px; }
.badge-nouveau { background: #ffd700; color: #000; }
.badge-quali { background: #00d9ff; color: #000; }
.badge-proposition { background: #7b2cbf; color: #fff; }
.badge-gagne { background: #00ff88; color: #000; }
.badge-perdu { background: #e94560; color: #fff; }
a { color: #00d9ff; }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<header>
<h1>📊 CRM H3R7 - Gestion des Prospects</h1>
</header>
<div class="stats" id="stats">
<div class="stat-card">
<div class="stat-num" id="total-prospects">0</div>
<div class="stat-label">Total Prospects</div>
</div>
<div class="stat-card">
<div class="stat-num" id="nouveaux">0</div>
<div class="stat-label">Nouveaux</div>
</div>
<div class="stat-card">
<div class="stat-num" id="qualifies">0</div>
<div class="stat-label">Qualifiés</div>
</div>
<div class="stat-card">
<div class="stat-num" id="gagnes">0</div>
<div class="stat-label">Gagnés</div>
</div>
</div>
<div class="add-form">
<h2> Nouveau Prospect</h2>
<div class="form-row">
<input type="text" id="nom" placeholder="Nom du contact *">
<input type="text" id="entreprise" placeholder="Entreprise *">
<input type="text" id="secteur" placeholder="Secteur (Artisan, Boulanger, Marketing...)">
</div>
<div class="form-row">
<input type="tel" id="tel" placeholder="Téléphone">
<input type="email" id="email" placeholder="Email">
<select id="source">
<option value="">Source ?</option>
<option value="appeler">Appel téléphonique</option>
<option value="email">Email</option>
<option value="site">Site web</option>
<option value="recommandation">Recommandation</option>
<option value="autre">Autre</option>
</select>
</div>
<button onclick="addProspect()">Ajouter le Prospect</button>
</div>
<div class="filter-bar">
<button class="filter-btn active" onclick="filterProspects('tous')">Tous</button>
<button class="filter-btn" onclick="filterProspects('nouveau')">Nouveau</button>
<button class="filter-btn" onclick="filterProspects('qualifie')">Qualifié</button>
<button class="filter-btn" onclick="filterProspects('proposition')">Proposition</button>
<button class="filter-btn" onclick="filterProspects('gagne')">Gagné</button>
<button class="filter-btn" onclick="filterProspects('perdu')">Perdu</button>
<select id="catFilter" onchange="renderProspects()" style="padding:8px;background:#0f3460;color:#fff;border:1px solid #333;border-radius:8px;">
<option value="all">Toutes catégories</option>
<option value="Restaurant">Restaurants</option>
<option value="Boulangerie">Boulangeries</option>
<option value="Garage">Garages</option>
</select>
</div>
<div class="prospects-grid" id="prospects-grid">
Chargement...
</div>
<a href="/">← Retour au Portail</a>
<!-- Modal Edit -->
<div class="modal" id="editModal">
<div class="modal-content">
<h2>✏️ Modifier Prospect</h2>
<input type="hidden" id="edit-id">
<div class="form-row">
<input type="text" id="edit-nom" placeholder="Nom">
<input type="text" id="edit-entreprise" placeholder="Entreprise">
<input type="text" id="edit-secteur" placeholder="Secteur">
</div>
<div class="form-row">
<input type="tel" id="edit-tel" placeholder="Téléphone">
<input type="email" id="edit-email" placeholder="Email">
<select id="edit-statut">
<option value="nouveau">Nouveau</option>
<option value="qualifie">Qualifié</option>
<option value="proposition">Proposition</option>
<option value="gagne">Gagné</option>
<option value="perdu">Perdu</option>
</select>
</div>
<textarea id="edit-notes" placeholder="Notes..." rows="3" style="width:100%;padding:12px;margin:5px 0;background:#0f3460;border:1px solid #333;border-radius:8px;color:#fff;"></textarea>
<div class="form-row">
<select id="edit-score">
<option value="0">Score: 0</option>
<option value="1">Score: ⭐</option>
<option value="2">Score: ⭐⭐</option>
<option value="3">Score: ⭐⭐⭐</option>
<option value="4">Score: ⭐⭐⭐⭐</option>
<option value="5">Score: ⭐⭐⭐⭐⭐</option>
</select>
</div>
<div class="modal-buttons">
<button onclick="saveEdit()">💾 Sauvegarder</button>
<button class="btn-cancel" onclick="closeModal()">Annuler</button>
</div>
</div>
</div>
<script>
const API = '/crm/api/prospects';
let allProspects = [];
let currentFilter = 'tous';
function loadProspects() {
fetch(API)
.then(r => r.json())
.then(data => {
allProspects = data.prospects || [];
updateStats();
renderProspects();
});
}
function updateStats() {
document.getElementById('total-prospects').textContent = allProspects.length;
document.getElementById('nouveaux').textContent = allProspects.filter(p => p.statut === 'nouveau').length;
document.getElementById('qualifies').textContent = allProspects.filter(p => p.statut === 'qualifie' || p.statut === 'proposition').length;
document.getElementById('gagnes').textContent = allProspects.filter(p => p.statut === 'gagne').length;
}
function renderProspects() {
let prospects = allProspects;
// Filter by category
const catFilter = document.getElementById('catFilter').value;
if (catFilter !== 'all') {
prospects = prospects.filter(p => p.categorie === catFilter || p.secteur === catFilter || p.entreprise === catFilter);
}
// Filter by status
if (currentFilter !== 'tous') {
prospects = prospects.filter(p => p.statut === currentFilter);
}
const grid = document.getElementById('prospects-grid');
if (prospects.length === 0) {
grid.innerHTML = '<div style="grid-column:1/-1;text-align:center;color:#666;padding:40px;">Aucun prospect</div>';
return;
}
grid.innerHTML = prospects.map((p, i) => `
<div class="prospect-card ${p.statut}" data-idx="${i}">
<span class="score">${'⭐'.repeat(p.score || 0)}</span>
<div class="prospect-name">${p.nom || ''}</div>
<div class="prospect-entreprise">${p.entreprise || ''}</div>
<div class="prospect-info">📞 ${p.tel || '-'}</div>
<div class="prospect-info">📍 <a href="#" onclick="event.preventDefault(); var addr = this.textContent.replace('📍 ', '').trim(); window.open('https://www.google.com/maps/search/?api=1&query=' + encodeURIComponent(addr), '_blank');" data-idx="${i}" style="color:#00d9ff;text-decoration:none;">${p.adresse || '-'}</a></div>
<div class="prospect-info">✉️ ${p.email || '-'}</div>
<div class="prospect-info">📅 ${p.created ? p.created.substring(0,10) : '-'}</div>
<span class="prospect-secteur">${p.secteur || 'Autre'}</span>
<span class="badge badge-${p.statut}">${p.statut}</span>
<div class="actions">
<button class="btn-edit" onclick="openEdit('${p.id}')">✏️</button>
<button class="btn-delete" onclick="deleteProspect('${p.id}')">🗑️</button>
</div>
</div>
`).join('');
}
function addProspect() {
const nom = document.getElementById('nom').value;
const entreprise = document.getElementById('entreprise').value;
if (!nom || !entreprise) {
alert('Nom et entreprise requis!');
return;
}
fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nom: nom,
entreprise: entreprise,
secteur: document.getElementById('secteur').value,
tel: document.getElementById('tel').value,
email: document.getElementById('email').value,
source: document.getElementById('source').value,
statut: 'nouveau',
score: 0
})
}).then(() => {
// Clear form
document.getElementById('nom').value = '';
document.getElementById('entreprise').value = '';
document.getElementById('secteur').value = '';
document.getElementById('tel').value = '';
document.getElementById('email').value = '';
loadProspects();
});
}
function openEdit(id) {
const p = allProspects.find(x => x.id === id);
if (!p) return;
document.getElementById('edit-id').value = id;
document.getElementById('edit-nom').value = p.nom || '';
document.getElementById('edit-entreprise').value = p.entreprise || '';
document.getElementById('edit-secteur').value = p.secteur || '';
document.getElementById('edit-tel').value = p.tel || '';
document.getElementById('edit-email').value = p.email || '';
document.getElementById('edit-statut').value = p.statut || 'nouveau';
document.getElementById('edit-notes').value = p.notes || '';
document.getElementById('edit-score').value = p.score || 0;
document.getElementById('editModal').classList.add('show');
}
function closeModal() {
document.getElementById('editModal').classList.remove('show');
}
function saveEdit() {
const id = parseInt(document.getElementById('edit-id').value);
fetch(API + '/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nom: document.getElementById('edit-nom').value,
entreprise: document.getElementById('edit-entreprise').value,
secteur: document.getElementById('edit-secteur').value,
tel: document.getElementById('edit-tel').value,
email: document.getElementById('edit-email').value,
statut: document.getElementById('edit-statut').value,
notes: document.getElementById('edit-notes').value,
score: parseInt(document.getElementById('edit-score').value)
})
}).then(() => {
closeModal();
loadProspects();
});
}
function deleteProspect(id) {
if (!confirm('Supprimer ce prospect?')) return;
fetch(API + '/' + id, { method: 'DELETE' })
.then(() => loadProspects());
}
function filterProspects(filter) {
currentFilter = filter;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
event.target.classList.add('active');
renderProspects();
}
loadProspects();
</script>
</body>
</html>

316
crm_dashboard_new.html Executable file
View File

@@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>CRM H3R7 - Gestion Prospects</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #1a1a2e; color: #eee; padding: 20px; }
header { background: linear-gradient(90deg, #e94560, #7b2cbf); padding: 20px; text-align: center; margin: -20px -20px 20px; border-radius: 0 0 20px 20px; }
h1 { font-size: 28px; }
.logo { text-align: center; padding: 10px; }
.logo img { width: 100px; border-radius: 10px; }
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-bottom: 20px; }
.stat-card { background: #16213e; padding: 20px; border-radius: 12px; text-align: center; }
.stat-num { font-size: 32px; font-weight: bold; color: #00d9ff; }
.stat-label { color: #aaa; font-size: 14px; margin-top: 5px; }
.add-form { background: #16213e; padding: 20px; border-radius: 12px; margin-bottom: 20px; }
.add-form h2 { margin-bottom: 15px; color: #fff; }
.form-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
input, select { width: 100%; padding: 12px; margin: 5px 0; background: #0f3460; border: 1px solid #333; border-radius: 8px; color: #fff; }
button { padding: 12px 20px; background: #00d9ff; border: none; border-radius: 8px; color: #000; font-weight: bold; cursor: pointer; }
button:hover { background: #00b8d4; }
.prospects-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 15px; }
.prospect-card { background: #16213e; padding: 15px; border-radius: 12px; border-left: 4px solid #00d9ff; }
.prospect-card.nouveau { border-left-color: #ffd700; }
.prospect-card.qualifie { border-left-color: #00d9ff; }
.prospect-card.proposition { border-left-color: #7b2cbf; }
.prospect-card.gagne { border-left-color: #00ff88; }
.prospect-card.perdu { border-left-color: #e94560; }
.prospect-name { font-size: 18px; font-weight: bold; margin-bottom: 5px; }
.prospect-entreprise { color: #aaa; font-size: 14px; margin-bottom: 10px; }
.prospect-info { font-size: 13px; color: #888; margin: 3px 0; }
.prospect-secteur { background: #7b2cbf; padding: 3px 8px; border-radius: 10px; font-size: 11px; display: inline-block; margin-top: 8px; }
.score { float: right; }
.score-star { color: #ffd700; }
.actions { margin-top: 10px; display: flex; gap: 5px; }
.btn-edit { background: #7b2cbf; color: #fff; padding: 5px 10px; border: none; border-radius: 5px; cursor: pointer; font-size: 12px; }
.btn-delete { background: #e94560; color: #fff; padding: 5px 10px; border: none; border-radius: 5px; cursor: pointer; font-size: 12px; }
.filter-bar { margin-bottom: 15px; display: flex; gap: 10px; }
.filter-btn { padding: 8px 15px; background: #0f3460; border: none; border-radius: 8px; color: #fff; cursor: pointer; }
.filter-btn.active { background: #00d9ff; color: #000; }
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); }
.modal.show { display: flex; justify-content: center; align-items: center; }
.modal-content { background: #16213e; padding: 30px; border-radius: 15px; width: 500px; max-width: 90%; }
.modal h2 { margin-bottom: 20px; }
.modal-buttons { display: flex; gap: 10px; margin-top: 20px; }
.btn-cancel { background: #e94560; }
.badge { padding: 3px 8px; border-radius: 5px; font-size: 11px; margin-left: 5px; }
.badge-nouveau { background: #ffd700; color: #000; }
.badge-qualifie { background: #00d9ff; color: #000; }
.badge-proposition { background: #7b2cbf; color: #fff; }
.badge-gagne { background: #00ff88; color: #000; }
.badge-perdu { background: #e94560; color: #fff; }
a { color: #00d9ff; }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<div class="logo">
<img src="/turf/H3R7Tech_logo.png" alt="H3R7Tech">
</div>
<header>
<h1>📊 CRM H3R7 - Gestion des Prospects</h1>
</header>
<div class="stats" id="stats">
<div class="stat-card">
<div class="stat-num" id="total-prospects">0</div>
<div class="stat-label">Total Prospects</div>
</div>
<div class="stat-card">
<div class="stat-num" id="nouveaux">0</div>
<div class="stat-label">Nouveaux</div>
</div>
<div class="stat-card">
<div class="stat-num" id="qualifies">0</div>
<div class="stat-label">Qualifiés</div>
</div>
<div class="stat-card">
<div class="stat-num" id="gagnes">0</div>
<div class="stat-label">Gagnés</div>
</div>
</div>
<div class="add-form">
<h2> Nouveau Prospect</h2>
<div class="form-row">
<input type="text" id="nom" placeholder="Nom du contact *">
<input type="text" id="entreprise" placeholder="Entreprise *">
<input type="text" id="secteur" placeholder="Secteur (Artisan, Boulanger, Marketing...)">
</div>
<div class="form-row">
<input type="tel" id="tel" placeholder="Téléphone">
<input type="email" id="email" placeholder="Email">
<select id="source">
<option value="">Source ?</option>
<option value="appeler">Appel téléphonique</option>
<option value="email">Email</option>
<option value="site">Site web</option>
<option value="recommandation">Recommandation</option>
<option value="autre">Autre</option>
</select>
</div>
<button onclick="addProspect()">Ajouter le Prospect</button>
</div>
<div class="filter-bar">
<button class="filter-btn active" onclick="filterProspects('tous')">Tous</button>
<button class="filter-btn" onclick="filterProspects('nouveau')">Nouveau</button>
<button class="filter-btn" onclick="filterProspects('qualifie')">Qualifié</button>
<button class="filter-btn" onclick="filterProspects('proposition')">Proposition</button>
<button class="filter-btn" onclick="filterProspects('gagne')">Gagné</button>
<button class="filter-btn" onclick="filterProspects('perdu')">Perdu</button>
</div>
<div class="prospects-grid" id="prospects-grid">
Chargement...
</div>
<a href="/">← Retour au Portail</a>
<!-- Modal Edit -->
<div class="modal" id="editModal">
<div class="modal-content">
<h2>✏️ Modifier Prospect</h2>
<input type="hidden" id="edit-id">
<div class="form-row">
<input type="text" id="edit-nom" placeholder="Nom">
<input type="text" id="edit-entreprise" placeholder="Entreprise">
<input type="text" id="edit-secteur" placeholder="Secteur">
</div>
<div class="form-row">
<input type="tel" id="edit-tel" placeholder="Téléphone">
<input type="email" id="edit-email" placeholder="Email">
<select id="edit-statut">
<option value="nouveau">Nouveau</option>
<option value="qualifie">Qualifié</option>
<option value="proposition">Proposition</option>
<option value="gagne">Gagné</option>
<option value="perdu">Perdu</option>
</select>
</div>
<textarea id="edit-notes" placeholder="Notes..." rows="3" style="width:100%;padding:12px;margin:5px 0;background:#0f3460;border:1px solid #333;border-radius:8px;color:#fff;"></textarea>
<div class="form-row">
<select id="edit-score">
<option value="0">Score: 0</option>
<option value="1">Score: ⭐</option>
<option value="2">Score: ⭐⭐</option>
<option value="3">Score: ⭐⭐⭐</option>
<option value="4">Score: ⭐⭐⭐⭐</option>
<option value="5">Score: ⭐⭐⭐⭐⭐</option>
</select>
</div>
<div class="modal-buttons">
<button onclick="saveEdit()">💾 Sauvegarder</button>
<button class="btn-cancel" onclick="closeModal()">Annuler</button>
</div>
</div>
</div>
<script>
const API = '/crm/api/prospects';
let allProspects = [];
let currentFilter = 'tous';
function loadProspects() {
fetch(API)
.then(r => r.json())
.then(data => {
allProspects = data.prospects || [];
updateStats();
renderProspects();
});
}
function updateStats() {
document.getElementById('total-prospects').textContent = allProspects.length;
document.getElementById('nouveaux').textContent = allProspects.filter(p => p.status === 'nouveau').length;
document.getElementById('qualifies').textContent = allProspects.filter(p => p.status === 'qualifie' || p.status === 'proposition').length;
document.getElementById('gagnes').textContent = allProspects.filter(p => p.status === 'gagne').length;
}
function renderProspects() {
let prospects = allProspects;
if (currentFilter !== 'tous') {
prospects = allProspects.filter(p => p.status === currentFilter);
}
const grid = document.getElementById('prospects-grid');
if (prospects.length === 0) {
grid.innerHTML = '<div style="grid-column:1/-1;text-align:center;color:#666;padding:40px;">Aucun prospect</div>';
return;
}
grid.innerHTML = prospects.map(p => {
return '<div class="prospect-card ' + p.status + '">' +
'<span class="score">' + '⭐'.repeat(p.score || 0) + '</span>' +
'<div class="prospect-name">' + (p.nom || '') + '</div>' +
'<div class="prospect-entreprise">' + (p.entreprise || '') + '</div>' +
'<div class="prospect-info">📞 ' + (p.tel || '-') + '</div>' +
'<div class="prospect-info">✉️ ' + (p.email || '-') + '</div>' +
'<div class="prospect-info">📅 ' + (p.created ? p.created.substring(0,10) : '-') + '</div>' +
'<span class="prospect-secteur">' + (p.secteur || 'Autre') + '</span>' +
'<span class="badge badge-' + p.status + '">' + p.status + '</span>' +
'<div class="actions">' +
'<button class="btn-edit" onclick="openEdit(' + p.id + ')">✏️</button>' +
'<button class="btn-delete" onclick="deleteProspect(' + p.id + ')">🗑️</button>' +
'</div></div>';
}).join('');
}
function addProspect() {
const nom = document.getElementById('nom').value;
const entreprise = document.getElementById('entreprise').value;
if (!nom || !entreprise) {
alert('Nom et entreprise requis!');
return;
}
fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nom: nom,
entreprise: entreprise,
secteur: document.getElementById('secteur').value,
tel: document.getElementById('tel').value,
email: document.getElementById('email').value,
source: document.getElementById('source').value,
statut: 'nouveau',
score: 0
})
}).then(() => {
document.getElementById('nom').value = '';
document.getElementById('entreprise').value = '';
document.getElementById('secteur').value = '';
document.getElementById('tel').value = '';
document.getElementById('email').value = '';
loadProspects();
});
}
function openEdit(id) {
const p = allProspects.find(x => x.id === id);
if (!p) return;
document.getElementById('edit-id').value = id;
document.getElementById('edit-nom').value = p.nom || '';
document.getElementById('edit-entreprise').value = p.entreprise || '';
document.getElementById('edit-secteur').value = p.secteur || '';
document.getElementById('edit-tel').value = p.tel || '';
document.getElementById('edit-email').value = p.email || '';
document.getElementById('edit-statut').value = p.status || 'nouveau';
document.getElementById('edit-notes').value = p.notes || '';
document.getElementById('edit-score').value = p.score || 0;
document.getElementById('editModal').classList.add('show');
}
function closeModal() {
document.getElementById('editModal').classList.remove('show');
}
function saveEdit() {
const id = parseInt(document.getElementById('edit-id').value);
fetch(API + '/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nom: document.getElementById('edit-nom').value,
entreprise: document.getElementById('edit-entreprise').value,
secteur: document.getElementById('edit-secteur').value,
tel: document.getElementById('edit-tel').value,
email: document.getElementById('edit-email').value,
statut: document.getElementById('edit-statut').value,
notes: document.getElementById('edit-notes').value,
score: parseInt(document.getElementById('edit-score').value)
})
}).then(() => {
closeModal();
loadProspects();
});
}
function deleteProspect(id) {
if (!confirm('Supprimer ce prospect?')) return;
fetch(API + '/' + id, { method: 'DELETE' })
.then(() => loadProspects());
}
function filterProspects(filter) {
currentFilter = filter;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
event.target.classList.add('active');
renderProspects();
}
loadProspects();
</script>
</body>
</html>

205
crm_simple.html Executable file
View File

@@ -0,0 +1,205 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>CRM H3R7</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,sans-serif;background:#1a1a2e;color:#eee;padding:20px}
header{background:linear-gradient(90deg,#e94560,#7b2cbf);padding:20px;text-align:center;margin:-20px -20px 20px}
h1{font-size:28px}
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:15px;margin-bottom:20px}
.stat-card{background:#16213e;padding:20px;border-radius:12px;text-align:center}
.stat-num{font-size:32px;color:#00d9ff}
.stat-label{color:#aaa;font-size:14px}
.prospects-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:15px}
.prospect-card{background:#16213e;padding:15px;border-radius:12px;border-left:4px solid #00d9ff}
.prospect-card.nouveau{border-left-color:#ffd700}
.prospect-card.contacte{border-left-color:#00d9ff}
.prospect-card.rdv{border-left-color:#ff6b6b}
.prospect-card.proposition{border-left-color:#7b2cbf}
.prospect-card.gagne{border-left-color:#00ff88}
.prospect-card.perdu{border-left-color:#e94560}
.prospect-name{font-size:18px;font-weight:bold}
.prospect-entreprise{color:#aaa;font-size:14px}
.prospect-info{font-size:13px;color:#888;margin:3px 0}
.actions{margin-top:10px;display:flex;gap:5px}
.btn-edit,.btn-delete{padding:5px 10px;border:none;border-radius:5px;cursor:pointer;font-size:12px}
.btn-edit{background:#7b2cbf;color:#fff}
.btn-delete{background:#e94560;color:#fff}
.filter-bar{margin-bottom:15px;display:flex;gap:10px;flex-wrap:wrap}
.filter-btn{padding:8px 15px;background:#0f3460;border:none;border-radius:8px;color:#fff;cursor:pointer}
.filter-btn.active{background:#00d9ff;color:#000}
select{padding:8px;background:#0f3460;color:#fff;border:1px solid #333;border-radius:8px}
.modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.8);z-index:1000}
.modal.show{display:block}
.modal-content{background:#16213e;padding:30px;border-radius:12px;width:90%;max-width:500px;margin:50px auto}
.form-group{margin-bottom:15px}
.form-group label{display:block;color:#aaa;margin-bottom:5px}
.form-group input,.form-group select,.form-group textarea{width:100%;padding:10px;background:#0f3460;border:1px solid #333;border-radius:8px;color:#fff}
.modal-buttons{display:flex;gap:10px;margin-top:20px}
.modal-buttons button{flex:1;padding:12px;border:none;border-radius:8px;cursor:pointer;font-weight:bold}
.btn-save{background:#00d9ff;color:#000}
.btn-cancel{background:#666;color:#fff}
.btn-delete-modal{background:#e94560;color:#fff}
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<header><h1 id="title">CRM H3R7</h1></header>
<div class="stats">
<div class="stat-card"><div class="stat-num" id="total">0</div><div class="stat-label">Total</div></div>
<div class="stat-card"><div class="stat-num" id="nouveaux">0</div><div class="stat-label">Nouveau</div></div>
<div class="stat-card"><div class="stat-num" id="qualifies">0</div><div class="stat-label">Qualifies</div></div>
<div class="stat-card"><div class="stat-num" id="gagnes">0</div><div class="stat-label">Gagnes</div></div>
</div>
<div class="filter-bar">
<button class="filter-btn active" onclick="filter('tous')">Tous</button>
<button class="filter-btn" onclick="filter('nouveau')">Nouveau</button>
<button class="filter-btn" onclick="filter('contacte')">Contacte</button>
<button class="filter-btn" onclick="filter('rdv')">RDV</button>
<button class="filter-btn" onclick="filter('proposition')">Proposition</button>
<button class="filter-btn" onclick="filter('gagne')">Gagne</button>
<button class="filter-btn" onclick="filter('perdu')">Perdu</button>
<select id="catFilter" onchange="render()">
<option value="all">Toutes categories</option>
<option value="Restaurant">Restaurants</option>
<option value="Boulangerie">Boulangeries</option>
<option value="Garage">Garages</option>
</select>
</div>
<div class="prospects-grid" id="grid"></div>
<div class="modal" id="modal">
<div class="modal-content">
<h2>Modifier le Prospect</h2>
<input type="hidden" id="eid">
<div class="form-group"><label>Nom</label><input type="text" id="enom"></div>
<div class="form-group"><label>Entreprise</label><input type="text" id="eentreprise"></div>
<div class="form-group"><label>Telephone</label><input type="tel" id="etel"></div>
<div class="form-group"><label>Statut</label><select id="estatut">
<option value="nouveau">Nouveau</option>
<option value="contacte">Contacte</option>
<option value="rdv">RDV</option>
<option value="proposition">Proposition</option>
<option value="gagne">Gagne</option>
<option value="perdu">Perdu</option>
</select></div>
<div class="form-group"><label>Date RDV</label><input type="date" id="erdv"></div>
<div class="form-group"><label>Commentaires</label><textarea id="enotes"></textarea></div>
<div class="modal-buttons">
<button class="btn-save" onclick="save()">Enregistrer</button>
<button class="btn-cancel" onclick="closeModal()">Annuler</button>
<button class="btn-delete-modal" onclick="delFromModal()">Supprimer</button>
</div>
</div>
</div>
<script>
var prospects = [];
var currentFilter = 'tous';
var currentCat = 'all';
fetch('api/prospects').then(r=>r.json()).then(function(data){
prospects = data.prospects;
document.getElementById('title').textContent = 'CRM H3R7 - ' + prospects.length + ' Prospects';
render();
});
function filter(f) {
currentFilter = f;
document.querySelectorAll('.filter-btn').forEach(function(b){b.classList.remove('active');});
event.target.classList.add('active');
render();
}
function render() {
currentCat = document.getElementById('catFilter').value;
var filtered = prospects;
if (currentCat !== 'all') {
filtered = filtered.filter(function(p){return p.categorie===currentCat || p.secteur===currentCat || p.entreprise===currentCat;});
}
if (currentFilter !== 'tous') {
filtered = filtered.filter(function(p){return p.statut===currentFilter;});
}
var grid = document.getElementById('grid');
if (!filtered.length) {
grid.innerHTML = '<div style="grid-column:1/-1;text-align:center;color:#666;padding:40px;">Aucun prospect</div>';
return;
}
grid.innerHTML = filtered.map(function(p) {
var rdv = p.rdv ? '<span style="background:#ff6b6b;color:#fff;padding:3px 8px;border-radius:10px;font-size:11px;margin-left:5px;">RDV: '+p.rdv+'</span>' : '';
var notes = p.notes ? '<div class="prospect-info">'+p.notes.substring(0,30)+'</div>' : '';
return '<div class="prospect-card '+(p.statut||'nouveau')+'">' +
'<div class="prospect-name">'+(p.nom||'')+'</div>' +
'<div class="prospect-entreprise">'+(p.entreprise||'')+'</div>' +
'<div class="prospect-info">'+(p.adresse||'-')+'</div>' +
'<div class="prospect-info">'+(p.tel||'-')+'</div>' +
'<div class="prospect-info">'+(p.score||0)+' ('+(p.categorie||'-')+')</div>' +
notes + rdv +
'<div class="actions">' +
'<button class="btn-edit" onclick="edit(\''+p.id+'\')">Modifier</button>' +
'<button class="btn-delete" onclick="del(\''+p.id+'\')">Supprimer</button>' +
'</div></div>';
}).join('');
updateStats();
}
function updateStats() {
document.getElementById('total').textContent = prospects.length;
document.getElementById('nouveaux').textContent = prospects.filter(function(p){return p.statut==='nouveau'}).length;
document.getElementById('qualifies').textContent = prospects.filter(function(p){return p.statut==='contacte' || p.statut==='rdv' || p.statut==='proposition'}).length;
document.getElementById('gagnes').textContent = prospects.filter(function(p){return p.statut==='gagne'}).length;
}
function edit(id) {
var p = prospects.find(function(x){return x.id===id;});
if (!p) return;
document.getElementById('eid').value = id;
document.getElementById('enom').value = p.nom || '';
document.getElementById('eentreprise').value = p.entreprise || '';
document.getElementById('etel').value = p.tel || '';
document.getElementById('estatut').value = p.statut || 'nouveau';
document.getElementById('erdv').value = p.rdv || '';
document.getElementById('enotes').value = p.notes || '';
document.getElementById('modal').classList.add('show');
}
function closeModal() {
document.getElementById('modal').classList.remove('show');
}
function save() {
var id = document.getElementById('eid').value;
var p = prospects.find(function(x){return x.id===id;});
if (!p) return;
p.nom = document.getElementById('enom').value;
p.entreprise = document.getElementById('eentreprise').value;
p.tel = document.getElementById('etel').value;
p.statut = document.getElementById('estatut').value;
p.rdv = document.getElementById('erdv').value;
p.notes = document.getElementById('enotes').value;
fetch('api/prospect/'+id, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(p)
}).then(function(){closeModal(); render();});
}
function del(id) {
if (!confirm('Supprimer ce prospect?')) return;
fetch('api/prospect/'+id, {method: 'DELETE'}).then(function(){
prospects = prospects.filter(function(p){return p.id!==id;});
render();
});
}
function delFromModal() {
var id = document.getElementById('eid').value;
del(id);
closeModal();
}
</script>
</body>
</html>

2464
dashboard.html Normal file

File diff suppressed because one or more lines are too long

1159
dashboard_api.py Executable file

File diff suppressed because it is too large Load Diff

915
datagouv_explorer.html Normal file
View File

@@ -0,0 +1,915 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Data.gouv.fr Explorer Pro</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0d1117; color: #c9d1d9; min-height: 100vh; }
/* Header */
header { background: linear-gradient(90deg, #161b22, #0f3460); padding: 25px; text-align: center; border-bottom: 2px solid #00d9ff; }
header h1 { font-size: 2rem; background: linear-gradient(90deg, #00d9ff, #e94560); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header p { color: #888; margin-top: 5px; }
/* Nav */
.nav-tabs { display: flex; justify-content: center; gap: 5px; padding: 15px; background: #161b22; flex-wrap: wrap; }
.nav-tab { padding: 10px 20px; background: #21262d; border: none; color: #c9d1d9; cursor: pointer; border-radius: 8px 8px 0 0; transition: all 0.3s; font-size: 0.9rem; }
.nav-tab:hover { background: #30363d; }
.nav-tab.active { background: #00d9ff; color: #0d1117; font-weight: 600; }
/* Main container */
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
/* Stats */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px; margin-bottom: 20px; }
.stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 20px; text-align: center; }
.stat-card h3 { font-size: 2.5rem; color: #00d9ff; }
.stat-card p { color: #8b949e; font-size: 0.85rem; margin-top: 5px; }
.stat-card span { font-size: 0.7rem; color: #58a6ff; }
/* Search */
.search-section { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 20px; margin-bottom: 20px; }
.search-box { display: flex; gap: 10px; margin-bottom: 15px; }
.search-box input { flex: 1; padding: 12px 15px; background: #0d1117; border: 1px solid #30363d; border-radius: 8px; color: #c9d1d9; font-size: 1rem; }
.search-box input:focus { outline: none; border-color: #00d9ff; }
.filters-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
select, .filter-chip { padding: 8px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.85rem; cursor: pointer; }
select:hover, .filter-chip:hover { border-color: #00d9ff; }
.filter-chip.active { background: #00d9ff; color: #0d1117; border-color: #00d9ff; }
/* Cards */
.card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 20px; margin-bottom: 15px; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.card-title { font-size: 1.1rem; color: #e6edf3; }
/* Dataset List */
.dataset-list { display: flex; flex-direction: column; gap: 15px; }
.dataset-item { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 20px; transition: all 0.3s; cursor: pointer; }
.dataset-item:hover { border-color: #00d9ff; transform: translateY(-2px); }
.dataset-item h3 { color: #00d9ff; font-size: 1.1rem; margin-bottom: 8px; }
.dataset-item h3 a { color: inherit; text-decoration: none; }
.dataset-item h3 a:hover { text-decoration: underline; }
.dataset-meta { display: flex; gap: 15px; flex-wrap: wrap; margin: 10px 0; font-size: 0.85rem; color: #8b949e; }
.dataset-meta span { display: flex; align-items: center; gap: 5px; }
.dataset-desc { color: #a0a0a0; font-size: 0.9rem; line-height: 1.5; margin: 10px 0; }
/* Tags */
.tags { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 10px; }
.tag { padding: 4px 10px; background: #21262d; border-radius: 20px; font-size: 0.75rem; color: #58a6ff; cursor: pointer; }
.tag:hover { background: #30363d; }
/* Quality Badge */
.quality-badge { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border-radius: 4px; font-size: 0.75rem; }
.quality-high { background: rgba(63,185,80,0.2); color: #3fb950; }
.quality-medium { background: rgba(210,153,34,0.2); color: #d29922; }
.quality-low { background: rgba(248,81,73,0.2); color: #f85149; }
/* Pagination */
.pagination { display: flex; justify-content: center; gap: 10px; margin-top: 20px; }
.pagination button { padding: 10px 20px; background: #21262d; border: 1px solid #30363d; color: #c9d1d9; border-radius: 8px; cursor: pointer; }
.pagination button:hover:not(:disabled) { background: #30363d; }
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
.pagination .active { background: #00d9ff; color: #0d1117; }
/* Tabs Content */
.tab-content { display: none; }
.tab-content.active { display: block; }
/* Grid layouts */
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 20px; }
/* Organizations list */
.org-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 15px; }
.org-item { background: #21262d; border-radius: 8px; padding: 15px; cursor: pointer; transition: all 0.3s; }
.org-item:hover { background: #30363d; }
.org-item img { width: 40px; height: 40px; border-radius: 8px; margin-right: 10px; float: left; }
.org-item h4 { color: #e6edf3; font-size: 0.95rem; }
.org-item p { color: #8b949e; font-size: 0.8rem; }
/* Charts */
.chart-container { background: #161b22; border-radius: 12px; padding: 20px; margin-bottom: 20px; }
/* Loading */
.loading { text-align: center; padding: 40px; color: #8b949e; }
.spinner { border: 3px solid #30363d; border-top-color: #00d9ff; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 0 auto 15px; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Detail View */
.detail-view { display: none; background: #161b22; border-radius: 12px; padding: 25px; margin-bottom: 20px; }
.detail-view.active { display: block; }
.detail-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; }
.detail-header h2 { color: #e6edf3; font-size: 1.5rem; }
.btn { padding: 10px 20px; background: #21262d; border: 1px solid #30363d; color: #c9d1d9; border-radius: 8px; cursor: pointer; text-decoration: none; display: inline-block; }
.btn:hover { background: #30363d; }
.btn-primary { background: #00d9ff; color: #0d1117; border-color: #00d9ff; }
.btn-primary:hover { background: #00b8d9; }
.btn-close { background: #f85149; border-color: #f85149; color: white; }
.detail-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 20px; }
.detail-info { background: #0d1117; border-radius: 8px; padding: 15px; margin-bottom: 15px; }
.detail-info h4 { color: #00d9ff; margin-bottom: 10px; font-size: 0.95rem; }
.detail-info p { color: #a0a0a0; font-size: 0.9rem; line-height: 1.6; }
.detail-meta { display: flex; gap: 20px; flex-wrap: wrap; margin: 10px 0; }
.detail-meta span { color: #8b949e; font-size: 0.85rem; }
/* Resources */
.resource-list { margin-top: 15px; }
.resource-item { display: flex; justify-content: space-between; align-items: center; padding: 12px; background: #0d1117; border-radius: 8px; margin-bottom: 8px; }
.resource-item:hover { background: #161b22; }
.resource-info { flex: 1; }
.resource-info a { color: #58a6ff; text-decoration: none; }
.resource-info a:hover { text-decoration: underline; }
.resource-info small { color: #8b949e; display: block; margin-top: 3px; }
.resource-actions { display: flex; gap: 10px; align-items: center; }
.format-badge { padding: 4px 8px; background: #30363d; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
/* History & Favorites */
.history-list { max-height: 300px; overflow-y: auto; }
.history-item { display: flex; justify-content: space-between; padding: 10px; border-bottom: 1px solid #21262d; cursor: pointer; }
.history-item:hover { background: #21262d; }
.history-item span { color: #8b949e; font-size: 0.85rem; }
/* API Console */
.api-console { background: #0d1117; border-radius: 8px; padding: 15px; font-family: monospace; }
.console-output { background: #000; color: #0f0; padding: 15px; border-radius: 8px; max-height: 300px; overflow-y: auto; font-size: 0.85rem; white-space: pre-wrap; margin-top: 10px; }
/* Back link */
.back-link { display: inline-block; margin-bottom: 15px; color: #00d9ff; text-decoration: none; }
.back-link:hover { text-decoration: underline; }
/* Responsive */
@media (max-width: 768px) {
.detail-grid { grid-template-columns: 1fr; }
.grid-2 { grid-template-columns: 1fr; }
.filters-row { flex-direction: column; }
}
/* Scrollbar */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #161b22; }
::-webkit-scrollbar-thumb { background: #30363d; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #484f58; }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<header>
<h1>📊 Data.gouv.fr Explorer Pro</h1>
<p>Explorez, analysez et exploitez les données ouvertes françaises</p>
</header>
<nav class="nav-tabs">
<button class="nav-tab active" onclick="switchTab('explorer', this)">🔍 Explorer</button>
<button class="nav-tab" onclick="switchTab('organizations', this)">🏢 Organisations</button>
<button class="nav-tab" onclick="switchTab('reuses', this)">🔄 Réutilisations</button>
<button class="nav-tab" onclick="switchTab('topics', this)">🏷️ Topics</button>
<button class="nav-tab" onclick="switchTab('geo', this)">🗺️ Géographie</button>
<button class="nav-tab" onclick="switchTab('analytics', this)">📈 Analytics</button>
<button class="nav-tab" onclick="switchTab('history', this)">📜 Historique</button>
<button class="nav-tab" onclick="switchTab('api', this)">🛠️ API Console</button>
</nav>
<div class="container">
<!-- Stats -->
<div class="stats-grid" id="statsGrid">
<div class="stat-card"><h3 id="statDatasets">...</h3><p>Datasets</p><span>publiés</span></div>
<div class="stat-card"><h3 id="statOrgs">...</h3><p>Organisations</p><span>actives</span></div>
<div class="stat-card"><h3 id="statReuses">...</h3><p>Réutilisations</p><span>créées</span></div>
<div class="stat-card"><h3 id="statResources">...</h3><p>Ressources</p><span>disponibles</span></div>
</div>
<!-- EXPLORER TAB -->
<div class="tab-content active" id="tab-explorer">
<div class="search-section">
<div class="search-box">
<input type="text" id="searchInput" placeholder="Rechercher datasets, tags, organisations..." />
<button class="btn btn-primary" onclick="searchDatasets()">🔍 Rechercher</button>
<button class="btn" onclick="clearSearch()">🧹 Effacer</button>
</div>
<div class="filters-row">
<select id="sortSelect">
<option value="created">Date création</option>
<option value="last_modified" selected>Dernière modif</option>
<option value="reuses">Réutilisations</option>
<option value="views">Vues</option>
<option value="-created">Date création (récent)</option>
<option value="-last_modified">Modif récente</option>
</select>
<select id="pageSizeSelect">
<option value="10">10/page</option>
<option value="20" selected>20/page</option>
<option value="50">50/page</option>
<option value="100">100/page</option>
</select>
<select id="licenseFilter" onchange="searchDatasets()">
<option value="">Toutes licences</option>
<option value="odc-odbl">ODC-ODBL</option>
<option value="odc-by">ODC-BY</option>
<option value="fr-lo">Licence Ouverte</option>
<option value="other-open">Other Open</option>
</select>
<select id="formatFilter" onchange="searchDatasets()">
<option value="">Tous formats</option>
<option value="csv">CSV</option>
<option value="json">JSON</option>
<option value="geojson">GeoJSON</option>
<option value="shp">SHP</option>
<option value="xlsx">Excel</option>
<option value="xml">XML</option>
</select>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">📂 Résultats (<span id="resultsCount">0</span>)</span>
<div>
<button class="btn" onclick="exportResults()">📥 Exporter JSON</button>
</div>
</div>
<div id="datasetsList" class="dataset-list"></div>
<div class="pagination" id="pagination"></div>
</div>
</div>
<!-- ORGANIZATIONS TAB -->
<div class="tab-content" id="tab-organizations">
<div class="search-section">
<div class="search-box">
<input type="text" id="orgSearchInput" placeholder="Rechercher une organisation..." />
<button class="btn btn-primary" onclick="searchOrganizations()">🔍</button>
</div>
</div>
<div class="org-grid" id="orgGrid"></div>
<div class="pagination" id="orgPagination"></div>
</div>
<!-- REUSES TAB -->
<div class="tab-content" id="tab-reuses">
<div class="card">
<div class="card-header">
<span class="card-title">🔄 Réutilisations</span>
</div>
<div id="reusesList" class="dataset-list"></div>
<div class="pagination" id="reusesPagination"></div>
</div>
</div>
<!-- TOPICS TAB -->
<div class="tab-content" id="tab-topics">
<div class="grid-2">
<div class="card">
<h3 class="card-title">🏷️ Topics disponibles</h3>
<div id="topicsList"></div>
</div>
<div class="card">
<h3 class="card-title">📊 Tags populaires</h3>
<div id="popularTags" class="tags"></div>
</div>
</div>
</div>
<!-- GEO TAB -->
<div class="tab-content" id="tab-geo">
<div class="grid-2">
<div class="card">
<h3 class="card-title">🗺️ Granularités spatiales</h3>
<div id="granularitiesList"></div>
</div>
<div class="card">
<h3 class="card-title">📍 Recherche géographique</h3>
<div class="search-box" style="margin-bottom: 15px;">
<input type="text" id="geoSearchInput" placeholder="Ex: France, Paris, Lyon..." />
<button class="btn btn-primary" onclick="searchGeo()">🔍</button>
</div>
<div id="geoResults"></div>
</div>
</div>
</div>
<!-- ANALYTICS TAB -->
<div class="tab-content" id="tab-analytics">
<div class="chart-container">
<h3>📈 Datasets par mois</h3>
<canvas id="chartDatasets"></canvas>
</div>
<div class="grid-2">
<div class="chart-container">
<h3>📊 Top 10 Organisations</h3>
<canvas id="chartOrgs"></canvas>
</div>
<div class="chart-container">
<h3>📁 Formats des ressources</h3>
<canvas id="chartFormats"></canvas>
</div>
</div>
</div>
<!-- HISTORY TAB -->
<div class="tab-content" id="tab-history">
<div class="grid-2">
<div class="card">
<h3 class="card-title">📜 Recherches récentes</h3>
<div id="searchHistory" class="history-list"></div>
<button class="btn" onclick="clearHistory()" style="margin-top: 10px;">🗑️ Effacer</button>
</div>
<div class="card">
<h3 class="card-title">⭐ Favoris</h3>
<div id="favoritesList" class="history-list"></div>
<button class="btn" onclick="saveFavorites()" style="margin-top: 10px;">💾 Sauvegarder</button>
</div>
</div>
</div>
<!-- API CONSOLE TAB -->
<div class="tab-content" id="tab-api">
<div class="card">
<h3 class="card-title">🛠️ Console API</h3>
<div class="search-box">
<select id="apiMethod">
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
<input type="text" id="apiEndpoint" placeholder="/datasets/?page_size=5" style="flex: 1;" />
<button class="btn btn-primary" onclick="testApi()">Exécuter</button>
</div>
<div class="api-console">
<strong>URL:</strong> <span id="consoleUrl"></span>
</div>
<div class="console-output" id="consoleOutput">Prêt...</div>
</div>
</div>
<!-- DETAIL VIEW -->
<div class="detail-view" id="detailView">
<a href="#" class="back-link" onclick="closeDetail(); return false;">← Retour aux résultats</a>
<div id="detailContent"></div>
</div>
</div>
<script>
const API = 'https://www.data.gouv.fr/api/1';
let currentPage = 1;
let currentResults = [];
let charts = {};
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadStats();
loadPopularTags();
loadTopics();
loadGranularities();
loadReuses();
searchDatasets();
loadHistory();
loadFavorites();
document.getElementById('searchInput').addEventListener('keypress', e => {
if (e.key === 'Enter') searchDatasets();
});
});
// Tab switching
function switchTab(tab, btnElement) {
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
if (btnElement) btnElement.classList.add('active');
const tabContent = document.getElementById(`tab-${tab}`);
if (tabContent) tabContent.classList.add('active');
if (tab === 'analytics') setTimeout(loadCharts, 100);
}
// Load global stats
async function loadStats() {
const endpoints = [
{ url: '/datasets/?page_size=1', key: 'statDatasets' },
{ url: '/organizations/?page_size=1', key: 'statOrgs' },
{ url: '/reuses/?page_size=1', key: 'statReuses' },
];
for (const ep of endpoints) {
try {
const res = await fetch(API + ep.url);
const data = await res.json();
const total = data.total ?? data.length ?? 0;
document.getElementById(ep.key).textContent = formatNumber(total);
} catch (e) {
document.getElementById(ep.key).textContent = '?';
}
}
}
// Search datasets
async function searchDatasets(page = 1) {
const query = document.getElementById('searchInput').value.trim();
const sort = document.getElementById('sortSelect').value;
const pageSize = document.getElementById('pageSizeSelect').value;
const license = document.getElementById('licenseFilter').value;
const format = document.getElementById('formatFilter').value;
currentPage = page;
showLoading('datasetsList');
// Save search
if (query) saveSearch(query);
try {
let url;
// Use /search/ endpoint for better results with tags
if (query) {
// Check if it's a tag search (simple word, no special chars)
const isTagSearch = /^[a-z0-9\-_]+$/i.test(query);
if (isTagSearch) {
url = `${API}/datasets/?tag=${encodeURIComponent(query)}&page=${page}&page_size=${pageSize}&sort=${sort}`;
} else {
url = `${API}/search/?q=${encodeURIComponent(query)}&type=dataset&page=${page}&page_size=${pageSize}&sort=${sort}`;
}
} else {
url = `${API}/datasets/?page=${page}&page_size=${pageSize}&sort=${sort}`;
}
if (license) url += `&license=${license}`;
const res = await fetch(url);
const data = await res.json();
// Handle both /datasets/ and /search/ response formats
const results = data.data || data.datasets || [];
const total = data.total ?? results.length ?? 0;
currentResults = results;
document.getElementById('resultsCount').textContent = total.toLocaleString();
displayDatasets(results);
displayPagination(data.page || 1, data.page_size || pageSize, total);
} catch (e) {
document.getElementById('datasetsList').innerHTML = `<div class="error">Erreur: ${e.message}</div>`;
}
}
function displayDatasets(datasets) {
if (!datasets || !datasets.length) {
document.getElementById('datasetsList').innerHTML = '<div class="loading">Aucun résultat</div>';
return;
}
document.getElementById('datasetsList').innerHTML = datasets.map(d => `
<div class="dataset-item" onclick="showDetail('${d.id}')">
<h3><a href="#" onclick="event.stopPropagation(); showDetail('${d.id}'); return false;">${d.title}</a></h3>
<div class="dataset-meta">
<span>🏢 ${d.organization?.name || 'Non spécifié'}</span>
<span>📅 ${formatDate(d.created_at)}</span>
<span>📁 ${d.resources?.length || 0} ressources</span>
${d.metrics?.views ? `<span>👁️ ${formatNumber(d.metrics.views)} vues</span>` : ''}
${d.metrics?.reuses ? `<span>🔄 ${d.metrics.reuses} ré-utilisations</span>` : ''}
</div>
<p class="dataset-desc">${truncate(d.description?.replace(/<[^>]+>/g, '') || 'Pas de description', 200)}</p>
${d.quality?.score ? `<span class="quality-badge ${getQualityClass(d.quality.score)}">⭐ Score: ${(d.quality.score * 100).toFixed(0)}%</span>` : ''}
<div class="tags">
${(d.tags || []).slice(0, 5).map(t => `<span class="tag" onclick="event.stopPropagation(); document.getElementById('searchInput').value='${t}'; searchDatasets();">${t}</span>`).join('')}
</div>
</div>
`).join('');
}
// Dataset detail
async function showDetail(id) {
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.getElementById('detailView').classList.add('active');
document.getElementById('detailContent').innerHTML = '<div class="loading"><div class="spinner"></div>Chargement...</div>';
try {
const res = await fetch(`${API}/datasets/${id}/`);
const d = await res.json();
document.getElementById('detailContent').innerHTML = `
<div class="detail-header">
<h2>${d.title}</h2>
<div>
<button class="btn" onclick="toggleFavorite('${d.id}', '${d.title}')">${isFavorite(d.id) ? '⭐' : '☆'} Favori</button>
<button class="btn btn-primary" onclick="window.open('https://www.data.gouv.fr/datasets/${d.slug}', '_blank')">🌐 Voir sur data.gouv.fr</button>
</div>
</div>
<div class="detail-grid">
<div>
<div class="detail-info">
<h4>📝 Description</h4>
<p>${d.description || 'Pas de description'}</p>
</div>
<div class="detail-info">
<h4>📁 Ressources (${d.resources?.length || 0})</h4>
<div class="resource-list">
${(d.resources || []).map(r => `
<div class="resource-item">
<div class="resource-info">
<a href="${r.url}" target="_blank">${r.title || 'Sans titre'}</a>
<small>${r.description?.substring(0, 100) || ''}</small>
</div>
<div class="resource-actions">
<span class="format-badge">${r.format?.toUpperCase() || 'N/A'}</span>
<span>${r.filesize ? formatBytes(r.filesize) : ''}</span>
<a href="${r.url}" download class="btn">⬇️</a>
</div>
</div>
`).join('') || '<p>Aucune ressource</p>'}
</div>
</div>
${d.temporal_coverage ? `
<div class="detail-info">
<h4>📅 Couverture temporelle</h4>
<p>${d.temporal_coverage.start} - ${d.temporal_coverage.end}</p>
</div>
` : ''}
</div>
<div>
<div class="detail-info">
<h4> Informations</h4>
<div class="detail-meta">
<span>🏢 ${d.organization?.name || 'Non spécifié'}</span>
<span>📅 Créé: ${formatDate(d.created_at)}</span>
<span>🔄 Modifié: ${formatDate(d.last_modified)}</span>
<span>📜 Licence: ${d.license || 'Non spécifiée'}</span>
<span>🔖 Fréquence: ${d.frequency || 'Non spécifiée'}</span>
</div>
</div>
${d.quality ? `
<div class="detail-info">
<h4>✅ Qualité</h4>
<p>Score: ${(d.quality.score * 100).toFixed(0)}%</p>
<div style="margin-top: 10px;">
${d.quality.has_open_format ? '✅ Format ouvert' : '❌ Format non ouvert'}<br>
${d.quality.has_resources ? '✅ Ressources disponibles' : '❌ Pas de ressources'}<br>
${d.quality.dataset_description_quality ? '✅ Description complète' : '⚠️ Description incomplète'}<br>
${d.quality.license ? '✅ Licence définie' : '⚠️ Licence non définie'}
</div>
</div>
` : ''}
${d.spatial ? `
<div class="detail-info">
<h4>🗺️ Zone géographique</h4>
<p>Granularité: ${d.spatial.granularity || 'Non spécifiée'}</p>
</div>
` : ''}
${d.tags?.length ? `
<div class="detail-info">
<h4>🏷️ Tags</h4>
<div class="tags">
${d.tags.map(t => `<span class="tag">${t}</span>`).join('')}
</div>
</div>
` : ''}
${d.metrics ? `
<div class="detail-info">
<h4>📊 Métriques</h4>
<p>👁️ ${formatNumber(d.metrics.views)} vues</p>
<p>⬇️ ${formatNumber(d.metrics.resources_downloads)} téléchargements</p>
<p>🔄 ${d.metrics.reuses} réutilisations</p>
<p>👥 ${d.metrics.followers} abonnés</p>
</div>
` : ''}
</div>
</div>
`;
} catch (e) {
document.getElementById('detailContent').innerHTML = `<div class="error">Erreur: ${e.message}</div>`;
}
}
function closeDetail() {
document.getElementById('detailView').classList.remove('active');
document.getElementById('tab-explorer').classList.add('active');
}
// Organizations
async function searchOrganizations(page = 1) {
const query = document.getElementById('orgSearchInput')?.value || '';
showLoading('orgGrid');
try {
let url = `${API}/organizations/?page=${page}&page_size=24`;
if (query) url += `&q=${encodeURIComponent(query)}`;
const res = await fetch(url);
const data = await res.json();
document.getElementById('orgGrid').innerHTML = data.data.map(o => `
<div class="org-item" onclick="searchDatasetsByOrg('${o.id}', '${o.name}')">
<img src="${o.logo || 'https://via.placeholder.com/40'}" alt="" onerror="this.src='https://via.placeholder.com/40'" />
<h4>${o.name}</h4>
<p>${o.datasets?.length || 0} datasets</p>
</div>
`).join('');
} catch (e) {
document.getElementById('orgGrid').innerHTML = '<div class="error">Erreur</div>';
}
}
async function searchDatasetsByOrg(orgId, orgName) {
document.querySelectorAll('.nav-tab')[0].click();
document.getElementById('searchInput').value = `organization:${orgName}`;
searchDatasets();
}
// Reuses
async function loadReuses(page = 1) {
try {
const res = await fetch(`${API}/reuses/?page=${page}&page_size=20`);
const data = await res.json();
document.getElementById('reusesList').innerHTML = data.data.map(r => `
<div class="dataset-item" onclick="window.open('${r.page}', '_blank')">
<h3>${r.title}</h3>
<div class="dataset-meta">
<span>📅 ${formatDate(r.created_at)}</span>
<span>🏷️ ${r.type}</span>
</div>
<p class="dataset-desc">${truncate(r.description, 150)}</p>
</div>
`).join('');
} catch (e) {
document.getElementById('reusesList').innerHTML = '<div class="error">Erreur</div>';
}
}
// Topics
async function loadTopics() {
try {
const res = await fetch(`${API}/topics/?page_size=50`);
const data = await res.json();
document.getElementById('topicsList').innerHTML = data.data.map(t => `
<div class="dataset-item" onclick="document.getElementById('searchInput').value='topic:${t.id}'; switchTab('explorer', document.querySelectorAll('.nav-tab')[0]); searchDatasets();">
<h3>${t.name}</h3>
<p class="dataset-desc">${t.description || ''}</p>
</div>
`).join('');
} catch (e) {}
}
// Popular tags
async function loadPopularTags() {
try {
const res = await fetch(`${API}/datasets/?page_size=100&sort=-views`);
const data = await res.json();
const tagCounts = {};
data.data.forEach(d => {
(d.tags || []).forEach(t => {
tagCounts[t] = (tagCounts[t] || 0) + 1;
});
});
const sorted = Object.entries(tagCounts).sort((a, b) => b[1] - a[1]).slice(0, 30);
document.getElementById('popularTags').innerHTML = sorted.map(([tag, count]) =>
`<span class="tag" onclick="document.getElementById('searchInput').value='${tag}'; switchTab('explorer', document.querySelectorAll('.nav-tab')[0]); searchDatasets();">${tag} (${count})</span>`
).join('');
} catch (e) {}
}
// Granularities
async function loadGranularities() {
try {
const res = await fetch(`${API}/spatial/granularities/`);
const data = await res.json();
document.getElementById('granularitiesList').innerHTML = data.map(g =>
`<div class="tag" style="padding: 8px 15px; font-size: 0.9rem;">${g}</div>`
).join('');
} catch (e) {}
}
// Charts
async function loadCharts() {
if (charts.datasets) return;
// Datasets chart
try {
const res = await fetch(`${API}/datasets/?page_size=500&sort=-created`);
const data = await res.json();
const months = {};
data.data.forEach(d => {
const month = d.created_at?.substring(0, 7) || 'unknown';
months[month] = (months[month] || 0) + 1;
});
const sortedMonths = Object.entries(months).sort().slice(-12);
charts.datasets = new Chart(document.getElementById('chartDatasets'), {
type: 'line',
data: {
labels: sortedMonths.map(m => m[0]),
datasets: [{ label: 'Datasets', data: sortedMonths.map(m => m[1]), borderColor: '#00d9ff', tension: 0.3, fill: true, backgroundColor: 'rgba(0,217,255,0.1)' }]
},
options: { responsive: true, plugins: { legend: { labels: { color: '#c9d1d9' } } }, scales: { x: { ticks: { color: '#8b949e' }, grid: { color: '#21262d' } }, y: { ticks: { color: '#8b949e' }, grid: { color: '#21262d' } } } }
});
// Formats chart
const formats = {};
data.data.forEach(d => {
(d.resources || []).forEach(r => {
const fmt = r.format || 'other';
formats[fmt] = (formats[fmt] || 0) + 1;
});
});
const sortedFormats = Object.entries(formats).sort((a, b) => b[1] - a[1]).slice(0, 8);
charts.formats = new Chart(document.getElementById('chartFormats'), {
type: 'doughnut',
data: {
labels: sortedFormats.map(f => f[0].toUpperCase()),
datasets: [{ data: sortedFormats.map(f => f[1]), backgroundColor: ['#00d9ff', '#e94560', '#3fb950', '#d29922', '#bc8cc8', '#58a6ff', '#f85149', '#8b949e'] }]
},
options: { responsive: true, plugins: { legend: { position: 'right', labels: { color: '#c9d1d9' } } } }
});
// Orgs chart
const orgs = {};
data.data.forEach(d => {
const name = d.organization?.name || 'Unknown';
orgs[name] = (orgs[name] || 0) + 1;
});
const sortedOrgs = Object.entries(orgs).sort((a, b) => b[1] - a[1]).slice(0, 10);
charts.orgs = new Chart(document.getElementById('chartOrgs'), {
type: 'bar',
data: {
labels: sortedOrgs.map(o => o[0].substring(0, 20)),
datasets: [{ label: 'Datasets', data: sortedOrgs.map(o => o[1]), backgroundColor: '#00d9ff' }]
},
options: { responsive: true, plugins: { legend: { labels: { color: '#c9d1d9' } } }, scales: { x: { ticks: { color: '#8b949e' }, grid: { color: '#21262d' } }, y: { ticks: { color: '#8b949e' }, grid: { color: '#21262d' } } } }
});
} catch (e) {}
}
// History
function saveSearch(query) {
let history = JSON.parse(localStorage.getItem('dgSearchHistory') || '[]');
history = [query, ...history.filter(h => h !== query)].slice(0, 20);
localStorage.setItem('dgSearchHistory', JSON.stringify(history));
loadHistory();
}
function loadHistory() {
const history = JSON.parse(localStorage.getItem('dgSearchHistory') || '[]');
document.getElementById('searchHistory').innerHTML = history.map(h =>
`<div class="history-item" onclick="document.getElementById('searchInput').value='${h}'; switchTab('explorer', document.querySelectorAll('.nav-tab')[0]); searchDatasets();">
<span>🔍 ${h}</span>
<span onclick="event.stopPropagation(); removeFromHistory('${h}')">❌</span>
</div>`
).join('') || '<p style="color:#8b949e;padding:10px;">Aucune recherche récente</p>';
}
function clearHistory() {
localStorage.removeItem('dgSearchHistory');
loadHistory();
}
function removeFromHistory(query) {
let history = JSON.parse(localStorage.getItem('dgSearchHistory') || '[]');
history = history.filter(h => h !== query);
localStorage.setItem('dgSearchHistory', JSON.stringify(history));
loadHistory();
}
// Favorites
function isFavorite(id) {
const favs = JSON.parse(localStorage.getItem('dgFavorites') || '[]');
return favs.some(f => f.id === id);
}
function toggleFavorite(id, title) {
let favs = JSON.parse(localStorage.getItem('dgFavorites') || '[]');
if (isFavorite(id)) {
favs = favs.filter(f => f.id !== id);
} else {
favs.push({ id, title, date: new Date().toISOString() });
}
localStorage.setItem('dgFavorites', JSON.stringify(favs));
loadFavorites();
showDetail(id);
}
function loadFavorites() {
const favs = JSON.parse(localStorage.getItem('dgFavorites') || '[]');
document.getElementById('favoritesList').innerHTML = favs.map(f =>
`<div class="history-item" onclick="showDetail('${f.id}')">
<span>⭐ ${f.title}</span>
<span onclick="event.stopPropagation(); toggleFavorite('${f.id}', '${f.title}')">❌</span>
</div>`
).join('') || '<p style="color:#8b949e;padding:10px;">Aucun favori</p>';
}
// API Console
async function testApi() {
const method = document.getElementById('apiMethod').value;
const endpoint = document.getElementById('apiEndpoint').value;
const url = API + (endpoint.startsWith('/') ? endpoint : '/' + endpoint);
document.getElementById('consoleUrl').textContent = url;
document.getElementById('consoleOutput').textContent = 'Chargement...';
try {
const res = await fetch(url);
const data = await res.json();
document.getElementById('consoleOutput').textContent = JSON.stringify(data, null, 2);
} catch (e) {
document.getElementById('consoleOutput').textContent = 'Erreur: ' + e.message;
}
}
// Export
function exportResults() {
const blob = new Blob([JSON.stringify(currentResults, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `datagouv-export-${Date.now()}.json`;
a.click();
}
function clearSearch() {
document.getElementById('searchInput').value = '';
searchDatasets();
}
function searchGeo() {
const query = document.getElementById('geoSearchInput').value;
if (!query) return;
document.getElementById('searchInput').value = query;
switchTab('explorer', document.querySelectorAll('.nav-tab')[0]);
searchDatasets();
}
// Utilities
function showLoading(id) {
document.getElementById(id).innerHTML = '<div class="loading"><div class="spinner"></div>Chargement...</div>';
}
function displayPagination(page, pageSize, total) {
const totalPages = Math.ceil(total / pageSize);
const container = document.getElementById('pagination');
if (totalPages <= 1) { container.innerHTML = ''; return; }
let html = `<button ${page <= 1 ? 'disabled' : ''} onclick="searchDatasets(${page - 1})">← Précédent</button>`;
for (let i = Math.max(1, page - 2); i <= Math.min(totalPages, page + 2); i++) {
html += `<button class="${i === page ? 'active' : ''}" onclick="searchDatasets(${i})">${i}</button>`;
}
html += `<button ${page >= totalPages ? 'disabled' : ''} onclick="searchDatasets(${page + 1})">Suivant →</button>`;
container.innerHTML = html;
}
function formatNumber(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return n?.toString() || '0';
}
function formatDate(str) {
if (!str) return 'N/A';
return new Date(str).toLocaleDateString('fr-FR');
}
function formatBytes(b) {
if (!b) return '';
const s = ['o', 'Ko', 'Mo', 'Go'];
const i = Math.floor(Math.log(b) / Math.log(1024));
return Math.round(b / Math.pow(1024, i)) + ' ' + s[i];
}
function truncate(str, len) {
if (!str) return '';
str = str.replace(/<[^>]+>/g, '');
return str.length > len ? str.substring(0, len) + '...' : str;
}
function getQualityClass(score) {
if (score >= 0.8) return 'quality-high';
if (score >= 0.5) return 'quality-medium';
return 'quality-low';
}
</script>
</body>
</html>

139
db.py Normal file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Module PostgreSQL pour la persistance des conversations Agent IA."""
import psycopg2
import psycopg2.extras
from contextlib import contextmanager
PG_HOST = "10.0.3.3"
PG_PORT = 5432
PG_DB = "n8n"
PG_USER = "fpNKWWEZnfaVjvWS"
PG_PASS = "MiGvyqnsKWUD7SzKgOoSnUAedUqX3US6"
def _connect():
return psycopg2.connect(host=PG_HOST, port=PG_PORT, dbname=PG_DB, user=PG_USER, password=PG_PASS)
@contextmanager
def get_db():
conn = _connect()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def get_workflows():
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT id, slug, name, webhook_url, description, is_active, mode FROM agent_chat_workflows WHERE is_active = true ORDER BY id;")
return cur.fetchall()
def get_sessions(workflow_slug):
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("""
SELECT s.id, s.session_id, s.title, s.created_at, s.updated_at
FROM agent_chat_sessions s
JOIN agent_chat_workflows w ON s.workflow_id = w.id
WHERE w.slug = %s
ORDER BY s.updated_at DESC;
""", (workflow_slug,))
return cur.fetchall()
def get_messages(session_id, workflow_slug):
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("""
SELECT m.id, m.role, m.content, m.created_at
FROM agent_chat_messages m
JOIN agent_chat_workflows w ON m.workflow_id = w.id
WHERE m.session_id = %s AND w.slug = %s
ORDER BY m.created_at ASC;
""", (session_id, workflow_slug))
return cur.fetchall()
def create_session(session_id, workflow_id, title=None):
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("""
INSERT INTO agent_chat_sessions (session_id, workflow_id, title)
VALUES (%s, %s, %s)
ON CONFLICT (session_id, workflow_id) DO NOTHING;
""", (session_id, workflow_id, title))
def save_message(session_id, workflow_id, role, content):
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("""
INSERT INTO agent_chat_messages (session_id, workflow_id, role, content)
VALUES (%s, %s, %s, %s)
RETURNING id;
""", (session_id, workflow_id, role, content))
msg_id = cur.fetchone()[0]
cur.execute("""
UPDATE agent_chat_sessions SET updated_at = NOW()
WHERE session_id = %s AND workflow_id = %s;
""", (session_id, workflow_id))
return msg_id
def delete_session(session_id, workflow_slug):
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("""
DELETE FROM agent_chat_sessions
WHERE session_id = %s AND workflow_id = (SELECT id FROM agent_chat_workflows WHERE slug = %s);
""", (session_id, workflow_slug))
return cur.rowcount
def rename_session(session_id, workflow_slug, new_title):
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("""
UPDATE agent_chat_sessions
SET title = %s, updated_at = NOW()
WHERE session_id = %s AND workflow_id = (SELECT id FROM agent_chat_workflows WHERE slug = %s);
""", (new_title, session_id, workflow_slug))
return cur.rowcount
def delete_messages_before(days, workflow_slug):
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("""
DELETE FROM agent_chat_messages
WHERE workflow_id = (SELECT id FROM agent_chat_workflows WHERE slug = %s)
AND created_at < NOW() - INTERVAL '%s days';
""", (workflow_slug, days))
deleted = cur.rowcount
cur.execute("""
DELETE FROM agent_chat_sessions s
WHERE s.workflow_id = (SELECT id FROM agent_chat_workflows WHERE slug = %s)
AND NOT EXISTS (SELECT 1 FROM agent_chat_messages m WHERE m.session_id = s.session_id AND m.workflow_id = s.workflow_id);
""", (workflow_slug,))
return deleted
def search_messages(query, workflow_slug, limit=20):
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("""
SELECT DISTINCT m.session_id, m.content, m.role, m.created_at
FROM agent_chat_messages m
WHERE m.workflow_id = (SELECT id FROM agent_chat_workflows WHERE slug = %s)
AND m.content ILIKE %s
ORDER BY m.created_at DESC
LIMIT %s;
""", (workflow_slug, f'%{query}%', limit))
return cur.fetchall()

6
favicon.ico Normal file
View File

@@ -0,0 +1,6 @@
<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>301 Moved</TITLE></HEAD><BODY>
<H1>301 Moved</H1>
The document has moved
<A HREF="https://t1.gstatic.com/faviconV2?client=SOCIAL&amp;type=FAVICON&amp;fallback_opts=TYPE,SIZE,URL&amp;url=http://portal-kolifee.duckdns.org&amp;size=16">here</A>.
</BODY></HTML>

99
fetch_results.py Normal file
View File

@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""
Récupération des résultats des courses 1h après la course
"""
import sqlite3
import subprocess
import schedule
import time
import logging
from datetime import datetime, timedelta
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def should_fetch_results():
"""Vérifie s'il est temps de récupérer les résultats (1h après la dernière course)"""
conn = get_db()
today = datetime.now().strftime('%Y-%m-%d')
c = conn.execute("""
SELECT DISTINCT race_time
FROM predictions
WHERE date=? AND source='canalturf_partants'
ORDER BY race_time DESC
LIMIT 1
""", (today,))
last_race = c.fetchone()
conn.close()
if not last_race or not last_race['race_time']:
return True
try:
race_hour = int(last_race['race_time'].split(':')[0])
race_min = int(last_race['race_time'].split(':')[1])
now = datetime.now()
race_datetime = now.replace(hour=race_hour, minute=race_min, second=0)
if (now - race_datetime).total_seconds() < 3600:
return False
return True
except:
return True
def fetch_and_save_results():
"""Récupère les résultats via pmu_results.py"""
logger.info("=== Récupération des résultats PMU ===")
if not should_fetch_results():
logger.info("Pas encore 1h après les courses")
return
try:
result = subprocess.run(
['python3', '/home/h3r7/turf_scraper/pmu_results.py'],
capture_output=True,
text=True,
timeout=60,
cwd='/home/h3r7/turf_scraper'
)
if result.returncode == 0:
logger.info("✅ Résultats récupérés avec succès")
else:
logger.error(f"❌ Erreur: {result.stderr}")
except Exception as e:
logger.error(f"❌ Exception: {e}")
logger.info("=== Fin récupération résultats ===")
def run_result_fetch():
"""Point d'entrée pour le scheduler"""
fetch_and_save_results()
def start_scheduler():
"""Démarre le scheduler pour récupération automatique"""
logger.info("Démarrage scheduler résultats...")
schedule.every().hour.do(run_result_fetch)
while True:
schedule.run_pending()
time.sleep(60)
if __name__ == '__main__':
import sys
if len(sys.argv) > 1 and sys.argv[1] == '--once':
fetch_and_save_results()
else:
start_scheduler()

134
gemini_agent.html Normal file
View File

@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agent IA - Gemini</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f0f23; color: #eee; height: 100vh; display: flex; flex-direction: column; }
header { background: linear-gradient(90deg, #1a1a2e, #0f3460); padding: 15px 20px; text-align: center; border-bottom: 2px solid #00d9ff; }
header h1 { font-size: 22px; background: linear-gradient(90deg, #00d9ff, #e94560); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header p { color: #888; font-size: 12px; margin-top: 3px; }
#chat { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
.msg { max-width: 75%; padding: 12px 16px; border-radius: 12px; line-height: 1.5; font-size: 14px; white-space: pre-wrap; word-wrap: break-word; }
.msg.user { align-self: flex-end; background: #0f3460; border: 1px solid #00d9ff; border-bottom-right-radius: 4px; }
.msg.ai { align-self: flex-start; background: #161b22; border: 1px solid #30363d; border-bottom-left-radius: 4px; }
.msg.error { align-self: center; background: rgba(210,50,50,0.2); border: 1px solid #d23232; color: #f88; }
.typing { align-self: flex-start; color: #666; font-style: italic; font-size: 13px; padding: 8px 16px; }
#input-area { display: flex; gap: 10px; padding: 15px 20px; background: #161b22; border-top: 1px solid #30363d; }
#input-area textarea { flex: 1; background: #0d1117; border: 1px solid #30363d; border-radius: 8px; color: #eee; padding: 12px; font-size: 14px; resize: none; font-family: inherit; min-height: 48px; max-height: 120px; outline: none; }
#input-area textarea:focus { border-color: #00d9ff; }
#send-btn { background: #00d9ff; color: #0f0f23; border: none; border-radius: 8px; padding: 0 20px; font-size: 16px; font-weight: bold; cursor: pointer; transition: background 0.2s; }
#send-btn:hover { background: #00b8d9; }
#send-btn:disabled { background: #30363d; color: #666; cursor: not-allowed; }
@media (max-width: 600px) {
.msg { max-width: 90%; }
#input-area { padding: 10px; }
}
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<header>
<h1>Agent IA - Google Gemini</h1>
<p>Propulsé par n8n</p>
</header>
<div id="chat"></div>
<div id="input-area">
<textarea id="user-input" placeholder="Tapez votre message..." rows="1"></textarea>
<button id="send-btn">Envoyer</button>
</div>
<script>
const chat = document.getElementById('chat');
const input = document.getElementById('user-input');
const sendBtn = document.getElementById('send-btn');
const WEBHOOK_URL = '/webhook/gemini-agent';
const sessionId = 'session-' + Math.random().toString(36).substr(2, 9);
function addMessage(text, type) {
const div = document.createElement('div');
div.className = 'msg ' + type;
div.textContent = text;
chat.appendChild(div);
chat.scrollTop = chat.scrollHeight;
}
function addTyping() {
const div = document.createElement('div');
div.className = 'typing';
div.id = 'typing-indicator';
div.textContent = 'Gemini r\u00e9fl\u00e9chit...';
chat.appendChild(div);
chat.scrollTop = chat.scrollHeight;
}
function removeTyping() {
const el = document.getElementById('typing-indicator');
if (el) el.remove();
}
async function sendMessage() {
const text = input.value.trim();
if (!text) return;
addMessage(text, 'user');
input.value = '';
input.style.height = 'auto';
sendBtn.disabled = true;
addTyping();
try {
const resp = await fetch(WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Session-ID': sessionId },
body: JSON.stringify({ message: text })
});
removeTyping();
if (!resp.ok) {
const err = await resp.json().catch(() => ({ message: 'Erreur serveur' }));
addMessage('Erreur: ' + (err.message || resp.statusText), 'error');
return;
}
const data = await resp.text();
addMessage(data, 'ai');
} catch (e) {
removeTyping();
addMessage('Erreur de connexion: ' + e.message, 'error');
} finally {
sendBtn.disabled = false;
input.focus();
}
}
sendBtn.addEventListener('click', sendMessage);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
});
input.focus();
</script>
</body>
</html>

291
guide_gitea.html Executable file
View File

@@ -0,0 +1,291 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Guide Git & Gitea - H3R7Tech</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #0d1117; color: #c9d1d9; line-height: 1.6; padding: 20px; max-width: 900px; margin: 0 auto; }
h1 { color: #58a6ff; font-size: 2em; margin-bottom: 10px; border-bottom: 2px solid #30363d; padding-bottom: 15px; }
h2 { color: #58a6ff; margin-top: 30px; margin-bottom: 15px; font-size: 1.5em; }
h3 { color: #8b949e; margin-top: 20px; margin-bottom: 10px; }
code { background: #161b22; padding: 2px 8px; border-radius: 4px; color: #ff7b72; font-family: 'Courier New', monospace; }
pre { background: #161b22; padding: 15px; border-radius: 8px; overflow-x: auto; margin: 15px 0; border: 1px solid #30363d; }
pre code { background: none; padding: 0; color: #c9d1d9; }
.highlight { background: #1f6feb; color: #fff; padding: 2px 8px; border-radius: 4px; }
.warning { background: #3d2e00; border-left: 4px solid #d29922; padding: 15px; margin: 15px 0; border-radius: 0 8px 8px 0; }
.tip { background: #0d2e1f; border-left: 4px solid #3fb950; padding: 15px; margin: 15px 0; border-radius: 0 8px 8px 0; }
ul { margin-left: 20px; }
li { margin: 8px 0; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.nav { background: #161b22; padding: 15px; border-radius: 8px; margin-bottom: 30px; }
.nav a { margin-right: 20px; }
.section { background: #161b22; padding: 20px; border-radius: 12px; margin: 20px 0; border: 1px solid #30363d; }
.badge { display: inline-block; background: #238636; color: #fff; padding: 2px 8px; border-radius: 12px; font-size: 12px; margin-left: 10px; }
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
th, td { border: 1px solid #30363d; padding: 10px; text-align: left; }
th { background: #21262d; color: #58a6ff; }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<div class="nav">
<a href="#init">Init</a>
<a href="#workflow">Workflow</a>
<a href="#commands">Commandes</a>
<a href="#regles">Règles</a>
<a href="#resolution">Résolution</a>
</div>
<h1>🐙 Guide Git & Gitea - H3R7Tech</h1>
<p>Ce guide détaille les bonnes pratiques pour utiliser Git avec Gitea sur le VPS H3R7Tech.</p>
<!-- SECTION: CONFIGURATION -->
<div class="section" id="init">
<h2>⚙️ 1. Configuration Initiale</h2>
<h3>Configurer Git</h3>
<pre><code>git config --global user.name "H3R7"
git config --global user.email "h3r7@tech.local"
git config --global init.defaultBranch master
git config --global pull.rebase false</code></pre>
<h3>Cloner un Repo</h3>
<pre><code># Via HTTPS (recommandé pour automate)
git clone /gitea/admin/h3r7tech.git
# Via SSH (pour developement)
git clone git@178.18.250.53:admin/h3r7tech.git</code></pre>
</div>
<!-- SECTION: WORKFLOW -->
<div class="section" id="workflow">
<h2>🔄 2. Workflow Standard</h2>
<div class="tip">
<strong>💡 Règle d'or:</strong> Toujours travailler sur la branche <code>dev</code> pour les développe­ments, puis fusionner vers <code>master</code> pour la production.
</div>
<h3>Schéma du Workflow</h3>
<pre><code>master (production)
└── dev (développement)
└── feature/xyz (nouvelle功能)
</code></pre>
<h3>Créer une Feature</h3>
<pre><code># 1. Se placer sur dev
git checkout dev
# 2. Mettre à jour dev
git pull origin dev
# 3. Créer une branche feature
git checkout -b feature/nom-feature
# 4. Travailler sur la功能...
git add .
git commit -m "Description claire du changement"
# 5. Pousser sur Gitea
git push origin feature/nom-feature</code></pre>
</div>
<!-- SECTION: COMMANDES -->
<div class="section" id="commands">
<h2>⌨️ 3. Commandes Essentielles</h2>
<table>
<tr>
<th>Action</th>
<th>Commande</th>
</tr>
<tr>
<td>Statut actuel</td>
<td><code>git status</code></td>
</tr>
<tr>
<td>Voir les changements</td>
<td><code>git diff</code></td>
</tr>
<tr>
<td>Ajouter fichiers</td>
<td><code>git add .</code></td>
</tr>
<tr>
<td>Commit avec message</td>
<td><code>git commit -m "message"</code></td>
</tr>
<tr>
<td>Voir historique</td>
<td><code>git log --oneline -10</code></td>
</tr>
<tr>
<td>Basculer branche</td>
<td><code>git checkout nom-branche</code></td>
</tr>
<tr>
<td>Fusionner dev → master</td>
<td><code>git checkout master && git merge dev</code></td>
</tr>
<tr>
<td>Annuler dernier commit</td>
<td><code>git reset --soft HEAD~1</code></td>
</tr>
</table>
</div>
<!-- SECTION: REGLES -->
<div class="section" id="regles">
<h2>📋 4. Règles de Bon Usage</h2>
<h3>Messages de Commit</h3>
<ul>
<li><code>git commit -m "Ajout filtre catégorie CRM"</code></li>
<li><code>git commit -m "Fix bug render() surCRM"</code></li>
<li><code>git commit -m "modif"</code></li>
<li><code>git commit -m "fix"</code></li>
</ul>
<h3>Fréquence de Push</h3>
<div class="tip">
<strong>Push fréquent:</strong> Au moins 1x/jour ou à chaque功能 complète.
</div>
<h3>Protection Branches</h3>
<table>
<tr>
<th>Branche</th>
<th>Règle</th>
</tr>
<tr>
<td><code>master</code></td>
<td>lecture seule, push via merge depuis dev uniquement</td>
</tr>
<tr>
<td><code>dev</code></td>
<td>push autorisé après tests locaux</td>
</tr>
<tr>
<td><code>feature/*</code></td>
<td>push fréquent, supprimée après merge</td>
</tr>
</table>
</div>
<!-- SECTION: RESOLUTION CONFLITS -->
<div class="section" id="resolution">
<h2>🔧 5. Résolution des Conflits</h2>
<div class="warning">
<strong>⚠️ Avant un merge:</strong> Toujours pull la dernière version!
</div>
<h3>En cas de conflit</h3>
<pre><code># 1. Mettre à jour dev
git checkout dev
git pull origin dev
# 2. Fusionner dans votre branche
git checkout feature/ma-branche
git merge dev
# 3. Résoudre les conflits manuellement
# (éditer les fichiers en conflit)
# 4. Ajouter les fichiers résolus
git add .
git commit -m "Résolution conflits merge dev"
git push origin feature/ma-branche</code></pre>
<h3>Méthode Alternative: Rebase</h3>
<pre><code>#Instead de merge, utiliser rebase pour historique propre
git checkout feature/ma-branche
git rebase dev</code></pre>
</div>
<!-- SECTION: PRATIQUE -->
<div class="section" id="pratique">
<h2>📝 6. Exemple Pratique</h2>
<h3>Modifier le CRM sur le VPS</h3>
<pre><code># Connexion SSH
ssh -i /path/to/key h3r7@178.18.250.53
# Aller dans le dossier projet
cd /home/h3r7
# Bas/turf_scraperculer sur dev
git checkout dev
# Mettre à jour
git pull origin dev
# Créer branche pour la功能
git checkout -b fix/crm-filtre
# ... Faire les modifications ...
# Commit
git add crm_dashboard.html
git commit -m "Corrige filtre catégorie CRM"
# Pousser
git push origin fix/crm-filtre</code></pre>
<h3>Créer une Pull Request</h3>
<ol>
<li>Aller sur <a href="/gitea/admin/h3r7tech" target="_blank">Gitea</a></li>
<li>Cliquer sur "Pull Requests" → "New Pull Request"</li>
<li>Sélectionner: <code>fix/crm-filtre</code><code>dev</code></li>
<li>Décrire les changements</li>
<li>Valider le merge après review</li>
</ol>
</div>
<!-- SECTION: OUTILS -->
<div class="section" id="outils">
<h2>🛠️ 7. Accès Rapides</h2>
<table>
<tr>
<th>Service</th>
<th>URL</th>
</tr>
<tr>
<td>Gitea</td>
<td><a href="/gitea/" target="_blank">/gitea/</a></td>
</tr>
<tr>
<td>Repo H3R7Tech</td>
<td><a href="/gitea/admin/h3r7tech" target="_blank">/gitea/admin/h3r7tech</a></td>
</tr>
<tr>
<td>CRM Prod</td>
<td><a href="/crm/" target="_blank">/crm/</a></td>
</tr>
<tr>
<td>CRM Dev</td>
<td><a href="/crm/" target="_blank">/crm/</a></td>
</tr>
<tr>
<td>Dashboard Turf</td>
<td><a href="/turf/" target="_blank">/turf/</a></td>
</tr>
</table>
</div>
<div class="section" style="text-align: center; margin-top: 40px;">
<p style="color: #8b949e;">Guide Git & Gitea - H3R7Tech © 2026</p>
<p style="color: #58a6ff;">🐾 Version 1.0</p>
</div>
</body>
</html>

269
h3r7tech_business.html Executable file
View File

@@ -0,0 +1,269 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>💼 H3R7Tech - Services Digitaux</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-dark: #0d0d1a;
--bg-card: #16162a;
--primary: #e94560;
--secondary: #7b2cbf;
--accent: #00d9ff;
--text: #fff;
--text-dim: #aaa;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Outfit', sans-serif;
background: var(--bg-dark);
color: var(--text);
line-height: 1.6;
}
.header {
background: linear-gradient(135deg, var(--primary), var(--secondary));
padding: 40px 20px;
text-align: center;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.header p {
font-size: 1.2em;
opacity: 0.9;
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
}
.section {
margin: 40px 0;
}
.section h2 {
color: var(--accent);
font-size: 1.8em;
margin-bottom: 20px;
border-bottom: 2px solid var(--secondary);
padding-bottom: 10px;
}
.card {
background: var(--bg-card);
border-radius: 15px;
padding: 20px;
margin-bottom: 15px;
}
.card h3 {
color: var(--primary);
margin-bottom: 10px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 15px;
}
.price {
font-size: 2em;
color: var(--accent);
font-weight: bold;
}
.price span {
font-size: 0.4em;
color: var(--text-dim);
}
.tag {
display: inline-block;
background: var(--secondary);
padding: 4px 12px;
border-radius: 20px;
font-size: 0.8em;
margin: 3px;
}
.tag.red { background: var(--primary); }
.tag.green { background: #00ff88; color: #000; }
.tag.blue { background: var(--accent); color: #000; }
.check {
color: #00ff88;
margin-right: 8px;
}
ul { list-style: none; }
ul li::before { content: "→ "; color: var(--accent); }
.cta {
display: inline-block;
background: linear-gradient(135deg, var(--accent), #0099cc);
color: var(--bg-dark);
padding: 15px 30px;
border-radius: 30px;
text-decoration: none;
font-weight: bold;
margin-top: 20px;
}
.hero {
text-align: center;
padding: 60px 20px;
background: linear-gradient(180deg, rgba(123,44,191,0.2), transparent);
}
.hero h2 {
font-size: 2em;
margin-bottom: 20px;
}
.stats {
display: flex;
justify-content: space-around;
flex-wrap: wrap;
gap: 20px;
margin: 30px 0;
}
.stat {
text-align: center;
padding: 20px;
}
.stat .number {
font-size: 2.5em;
color: var(--accent);
font-weight: bold;
}
.niche-card {
border-left: 4px solid var(--primary);
}
.niche-card.pod { border-color: #00ff88; }
.niche-card.artisan { border-color: var(--accent); }
.niche-card.turf { border-color: var(--primary); }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<div class="header">
<h1>🐾 H3R7Tech</h1>
<p>Solutions digitales pour artisans, entrepreneurs et passionnés</p>
</div>
<div class="container">
<!-- SERVICES -->
<div class="section">
<h2>💼 Nos Services</h2>
<div class="grid">
<div class="card">
<h3>🖨️ Print On Demand</h3>
<p>Création de produits personnalisés (t-shirts, mugs, stickers)</p>
<p class="price">15-40%<span> marge</span></p>
</div>
<div class="card">
<h3>🏪 Site Vitrine Artisan</h3>
<p>Site web 1 page pour artisans et petits commerces</p>
<p class="price">199€<span> one-shot</span></p>
</div>
<div class="card">
<h3>📊 CRM sur Mesure</h3>
<p>Gestion prospects, suivi clients, pipeline</p>
<p class="price">49-99€<span>/mois</span></p>
</div>
<div class="card">
<h3>🏇 Turf Predictions</h3>
<p>Prédictions IA pour les courses hippiques</p>
<p class="price">9.90-99€<span>/mois</span></p>
</div>
</div>
</div>
<!-- NICHE POD -->
<div class="section">
<h2>🎯 Niche #1: Print On Demand</h2>
<div class="card niche-card pod">
<h3>🛍️ Comment ça marche</h3>
<ul>
<li>Tu crées un design</li>
<li>Client commande sur ta boutique</li>
<li>Printful imprime et expédie</li>
<li>Tu touches la marge</li>
</ul>
<div style="margin-top:15px">
<span class="tag green">T-shirts</span>
<span class="tag green">Mugs</span>
<span class="tag green">Stickers</span>
<span class="tag green">Posters</span>
</div>
</div>
</div>
<!-- NICHE ARTISANS -->
<div class="section">
<h2>🎯 Niche #2: Services aux Artisans</h2>
<div class="card niche-card artisan">
<h3>🔧 Le problème</h3>
<p>80% des artisans n'ont pas de site web et perdent des clients.</p>
<h3 style="margin-top:15px">La solution</h3>
<ul>
<li>Site web vitrine (199€)</li>
<li>Gestion Google Business (49€/mois)</li>
<li>Photos produits (99€)</li>
<li>Formation réseaux sociaux (149€)</li>
</ul>
</div>
</div>
<!-- NICHE TURF -->
<div class="section">
<h2>🎯 Niche #3: Turf Betting Service</h2>
<div class="card niche-card turf">
<h3>🏇 Monetisation</h3>
<div class="grid">
<div>
<span class="tag">Gratuit</span>
<p>1 prédiction/jour (email)</p>
</div>
<div>
<span class="tag red">Essentiel 9.90€/mois</span>
<p>3 prédictions + analyse</p>
</div>
<div>
<span class="tag red">Premium 29.90€/mois</span>
<p>5 prédictions + money management</p>
</div>
<div>
<span class="tag red">VIP 99€/mois</span>
<p>Picks exclusifs + suivi</p>
</div>
</div>
</div>
</div>
<!-- OBJECTIFS -->
<div class="section">
<h2>📈 Objectifs 2026</h2>
<div class="stats">
<div class="stat">
<div class="number">100€</div>
<div>Mars</div>
</div>
<div class="stat">
<div class="number">500€</div>
<div>Mai</div>
</div>
<div class="stat">
<div class="number">1000€</div>
<div>Juillet</div>
</div>
<div class="stat">
<div class="number">2500€</div>
<div>Novembre</div>
</div>
</div>
</div>
<!-- CONTACT -->
<div class="section">
<div class="hero">
<h2>🚀 Lançons-nous!</h2>
<p>Tu veux commander un service ou discuter d'un projet?</p>
<a href="mailto:h3r7@tech.local" class="cta">Me contacter</a>
</div>
</div>
</div>
</body>
</html>

501
historical_loader.py Executable file
View File

@@ -0,0 +1,501 @@
#!/usr/bin/env python3
"""
Historical Loader - Récupère 1 an d'historique Quinté+ via API PMU
Sauvegarde toutes les features + résultats en BDD pour entraîner le modèle ML
"""
import requests
import sqlite3
import json
import time
import os
import re
from datetime import datetime, timedelta
DB_PATH = os.environ.get("DB_PATH", "/home/h3r7/turf_scraper/turf.db")
HEADERS = {'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json'}
BASE_URL = "https://turfinfo.api.pmu.fr/rest/client/1/programme"
# ============================================================
# BASE DE DONNÉES
# ============================================================
def init_historical_table():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS historical_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Identifiants course
date TEXT NOT NULL,
race_name TEXT,
hippodrome TEXT,
distance INTEGER,
discipline TEXT,
allocation REAL,
nb_partants INTEGER,
heure TEXT,
-- Identifiants cheval
horse_name TEXT NOT NULL,
horse_number INTEGER,
driver TEXT,
driver_change INTEGER DEFAULT 0,
-- Features cheval
age INTEGER,
sexe TEXT,
musique TEXT,
nb_courses INTEGER,
nb_victoires INTEGER,
nb_places INTEGER,
nb_places_2 INTEGER,
nb_places_3 INTEGER,
gains_carriere REAL,
gains_annee REAL,
gains_victoires REAL,
reduction_km INTEGER,
avis_entraineur TEXT,
oeilleres TEXT,
deferre TEXT,
-- Features marché
cote_directe REAL,
cote_reference REAL,
indicateur_tendance REAL,
est_favori INTEGER DEFAULT 0,
-- Features dérivées (calculées)
tx_victoire REAL,
tx_place REAL,
forme_recente REAL,
tendance_forme REAL,
nb_disq INTEGER,
rang_cote INTEGER,
ratio_cote_field REAL,
-- Résultat officiel (variable cible)
ordre_arrivee INTEGER,
temps_obtenu INTEGER,
top1 INTEGER DEFAULT 0,
top3 INTEGER DEFAULT 0,
top5 INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(date, horse_name, horse_number)
)
''')
# Index pour accélérer les requêtes ML
c.execute('CREATE INDEX IF NOT EXISTS idx_hist_date ON historical_data(date)')
c.execute('CREATE INDEX IF NOT EXISTS idx_hist_top5 ON historical_data(top5)')
c.execute('CREATE INDEX IF NOT EXISTS idx_hist_horse ON historical_data(horse_name)')
conn.commit()
conn.close()
print(f"✅ Table historical_data initialisée : {DB_PATH}")
# ============================================================
# DÉCODEUR MUSIQUE
# ============================================================
def parse_musique(musique):
if not musique:
return {'forme_recente': 99, 'tendance': 0, 'nb_disq': 0}
clean = re.sub(r'\(\d+\)', '', musique)
resultats = re.findall(r'(\d+|D|0)([amphsc]?)', clean)
positions = []
for pos, disc in resultats[:10]:
if pos == 'D':
positions.append(99)
else:
positions.append(int(pos))
if not positions:
return {'forme_recente': 99, 'tendance': 0, 'nb_disq': 0}
nb_disq = positions.count(99)
positions_clean = [p for p in positions if p != 99]
recentes = positions_clean[:3]
forme_recente = sum(recentes) / len(recentes) if recentes else 99
if len(positions_clean) >= 4:
debut = sum(positions_clean[-4:]) / 4
fin = sum(positions_clean[:4]) / 4
tendance = round(debut - fin, 2)
else:
tendance = 0
return {
'forme_recente': round(forme_recente, 2),
'tendance': tendance,
'nb_disq': nb_disq
}
# ============================================================
# API PMU
# ============================================================
def get_programme(date_pmu):
url = f"{BASE_URL}/{date_pmu}/reunions"
r = requests.get(url, headers=HEADERS, timeout=15)
if r.status_code != 200:
return None
return r.json().get('programme', {}).get('reunions', [])
def find_quinte(reunions):
for reunion in reunions:
for course in reunion.get('courses', []):
paris = [p['typePari'] for p in course.get('paris', [])]
libelle = course.get('libelle', '')
if any('QUINTE' in p for p in paris) or 'PARIS-TURF' in libelle:
heure_ts = course.get('heureDepart', 0)
heure = datetime.fromtimestamp(heure_ts/1000).strftime('%H:%M') if heure_ts else '13:55'
return {
'num_reunion': reunion['numOfficiel'],
'num_course': course['numOrdre'],
'libelle': libelle,
'hippodrome': reunion['hippodrome']['libelleCourt'],
'distance': course.get('distance', 0),
'discipline': course.get('discipline', ''),
'allocation': course.get('montantPrix', 0),
'nb_partants': course.get('nombreDeclaresPartants', 0),
'heure': heure,
'arrivee_def': course.get('arriveeDefinitive', False),
}
return None
def get_participants(date_pmu, num_r, num_c):
url = f"{BASE_URL}/{date_pmu}/R{num_r}/C{num_c}/participants"
r = requests.get(url, headers=HEADERS, timeout=15)
if r.status_code != 200:
return []
return r.json().get('participants', [])
# ============================================================
# EXTRACTION FEATURES
# ============================================================
def extract_features(p, course_info, all_participants):
"""Extrait toutes les features d'un partant + résultat"""
musique_stats = parse_musique(p.get('musique', ''))
# Cote directe
rapport_direct = p.get('dernierRapportDirect', {}) or {}
cote_directe = rapport_direct.get('rapport', 0) or 0
est_favori = 1 if rapport_direct.get('favoris', False) else 0
indicateur = rapport_direct.get('nombreIndicateurTendance', 0) or 0
# Cote référence
rapport_ref = p.get('dernierRapportReference', {}) or {}
cote_reference = rapport_ref.get('rapport', 0) or 0
# Stats carrière
nb_courses = p.get('nombreCourses', 0) or 0
nb_victoires = p.get('nombreVictoires', 0) or 0
nb_places = p.get('nombrePlaces', 0) or 0
nb_p2 = p.get('nombrePlacesSecond', 0) or 0
nb_p3 = p.get('nombrePlacesTroisieme', 0) or 0
tx_vic = round(nb_victoires / nb_courses * 100, 2) if nb_courses else 0
tx_place = round(nb_places / nb_courses * 100, 2) if nb_courses else 0
# Gains
gains = p.get('gainsParticipant', {}) or {}
gains_carriere = gains.get('gainsCarriere', 0) or 0
gains_annee = gains.get('gainsAnneeEnCours', 0) or 0
gains_victoires= gains.get('gainsVictoires', 0) or 0
# Rang cote dans le field
all_cotes = sorted([
(x.get('dernierRapportDirect', {}) or {}).get('rapport', 999) or 999
for x in all_participants
])
rang_cote = all_cotes.index(cote_directe) + 1 if cote_directe in all_cotes else 99
# Ratio cote / moyenne field
cotes_valides = [c for c in all_cotes if c < 900]
moy_cote = sum(cotes_valides) / len(cotes_valides) if cotes_valides else 1
ratio_cote = round(cote_directe / moy_cote, 3) if moy_cote else 0
# Résultat
ordre = p.get('ordreArrivee', 0) or 0
top1 = 1 if ordre == 1 else 0
top3 = 1 if 1 <= ordre <= 3 else 0
top5 = 1 if 1 <= ordre <= 5 else 0
return {
# Course
'date': None, # rempli par l'appelant
'race_name': course_info['libelle'],
'hippodrome': course_info['hippodrome'],
'distance': course_info['distance'],
'discipline': course_info['discipline'],
'allocation': course_info['allocation'],
'nb_partants': course_info['nb_partants'],
'heure': course_info['heure'],
# Cheval
'horse_name': p.get('nom', ''),
'horse_number': p.get('numPmu', 0),
'driver': p.get('driver', ''),
'driver_change': 1 if p.get('driverChange', False) else 0,
# Features
'age': p.get('age', 0),
'sexe': p.get('sexe', ''),
'musique': p.get('musique', ''),
'nb_courses': nb_courses,
'nb_victoires': nb_victoires,
'nb_places': nb_places,
'nb_places_2': nb_p2,
'nb_places_3': nb_p3,
'gains_carriere': gains_carriere,
'gains_annee': gains_annee,
'gains_victoires': gains_victoires,
'reduction_km': p.get('reductionKilometrique', 0),
'avis_entraineur': p.get('avisEntraineur', 'NEUTRE'),
'oeilleres': p.get('oeilleres', ''),
'deferre': p.get('deferre', ''),
# Marché
'cote_directe': cote_directe,
'cote_reference': cote_reference,
'indicateur_tendance': indicateur,
'est_favori': est_favori,
# Dérivées
'tx_victoire': tx_vic,
'tx_place': tx_place,
'forme_recente': musique_stats['forme_recente'],
'tendance_forme': musique_stats['tendance'],
'nb_disq': musique_stats['nb_disq'],
'rang_cote': rang_cote,
'ratio_cote_field': ratio_cote,
# Résultat
'ordre_arrivee': ordre,
'temps_obtenu': p.get('tempsObtenu', 0),
'top1': top1,
'top3': top3,
'top5': top5,
}
# ============================================================
# SAUVEGARDE
# ============================================================
def save_batch(rows):
if not rows:
return 0
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
saved = 0
for row in rows:
try:
c.execute('''
INSERT OR IGNORE INTO historical_data
(date, race_name, hippodrome, distance, discipline, allocation, nb_partants, heure,
horse_name, horse_number, driver, driver_change,
age, sexe, musique, nb_courses, nb_victoires, nb_places, nb_places_2, nb_places_3,
gains_carriere, gains_annee, gains_victoires, reduction_km, avis_entraineur,
oeilleres, deferre,
cote_directe, cote_reference, indicateur_tendance, est_favori,
tx_victoire, tx_place, forme_recente, tendance_forme, nb_disq,
rang_cote, ratio_cote_field,
ordre_arrivee, temps_obtenu, top1, top3, top5)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
''', (
row['date'], row['race_name'], row['hippodrome'], row['distance'],
row['discipline'], row['allocation'], row['nb_partants'], row['heure'],
row['horse_name'], row['horse_number'], row['driver'], row['driver_change'],
row['age'], row['sexe'], row['musique'], row['nb_courses'],
row['nb_victoires'], row['nb_places'], row['nb_places_2'], row['nb_places_3'],
row['gains_carriere'], row['gains_annee'], row['gains_victoires'],
row['reduction_km'], row['avis_entraineur'], row['oeilleres'], row['deferre'],
row['cote_directe'], row['cote_reference'], row['indicateur_tendance'], row['est_favori'],
row['tx_victoire'], row['tx_place'], row['forme_recente'],
row['tendance_forme'], row['nb_disq'], row['rang_cote'], row['ratio_cote_field'],
row['ordre_arrivee'], row['temps_obtenu'], row['top1'], row['top3'], row['top5']
))
saved += c.rowcount
except Exception as e:
print(f" ⚠️ {row.get('horse_name','?')}: {e}")
conn.commit()
conn.close()
return saved
# ============================================================
# MAIN
# ============================================================
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--days', type=int, default=365, help='Nombre de jours à récupérer')
parser.add_argument('--start', type=str, default=None, help='Date de début YYYY-MM-DD')
parser.add_argument('--delay', type=float, default=0.5, help='Délai entre requêtes (s)')
args = parser.parse_args()
print(f"\n{'='*60}")
print(f"📚 HISTORICAL LOADER — {datetime.now().strftime('%d/%m/%Y %H:%M')}")
print(f"{'='*60}")
init_historical_table()
# Vérifier ce qui est déjà en BDD
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT COUNT(DISTINCT date) as jours, COUNT(*) as lignes FROM historical_data")
existing = c.fetchone()
c.execute("SELECT MIN(date), MAX(date) FROM historical_data")
date_range = c.fetchone()
conn.close()
print(f"\n📊 Données existantes : {existing[0]} jours · {existing[1]} lignes")
if date_range[0]:
print(f" Période : {date_range[0]}{date_range[1]}")
# Calculer la plage de dates
if args.start:
start_date = datetime.strptime(args.start, '%Y-%m-%d')
else:
start_date = datetime.now() - timedelta(days=args.days)
end_date = datetime.now() - timedelta(days=1) # Hier (résultats definitifs)
total_days = (end_date - start_date).days + 1
print(f"\n📅 Période à charger : {start_date.strftime('%d/%m/%Y')}{end_date.strftime('%d/%m/%Y')} ({total_days} jours)")
print(f"⏱️ Délai entre requêtes : {args.delay}s")
print(f"⏳ Durée estimée : ~{round(total_days * args.delay * 2 / 60, 1)} minutes\n")
# Stats
total_courses = 0
total_partants = 0
total_saved = 0
errors = 0
skipped = 0
current = start_date
day_num = 0
while current <= end_date:
day_num += 1
date_str = current.strftime('%Y-%m-%d')
date_pmu = current.strftime('%d%m%Y')
# Vérifier si déjà chargé
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT COUNT(*) FROM historical_data WHERE date=?", (date_str,))
already = c.fetchone()[0]
conn.close()
if already > 0:
print(f" [{day_num:3}/{total_days}] {date_str} — déjà chargé ({already} partants) ⏭️")
skipped += 1
current += timedelta(days=1)
continue
try:
# Récupérer le programme
reunions = get_programme(date_pmu)
if not reunions:
print(f" [{day_num:3}/{total_days}] {date_str} — pas de programme")
current += timedelta(days=1)
time.sleep(args.delay)
continue
# Trouver le Quinté+
quinte = find_quinte(reunions)
if not quinte:
print(f" [{day_num:3}/{total_days}] {date_str} — pas de Quinté+")
current += timedelta(days=1)
time.sleep(args.delay)
continue
if not quinte['arrivee_def']:
print(f" [{day_num:3}/{total_days}] {date_str} — arrivée non définitive")
current += timedelta(days=1)
time.sleep(args.delay)
continue
time.sleep(args.delay)
# Récupérer les participants
participants = get_participants(date_pmu, quinte['num_reunion'], quinte['num_course'])
if not participants:
print(f" [{day_num:3}/{total_days}] {date_str} — pas de participants")
current += timedelta(days=1)
time.sleep(args.delay)
continue
# Extraire les features
rows = []
for p in participants:
if p.get('statut') not in ('PARTANT', None):
continue
features = extract_features(p, quinte, participants)
features['date'] = date_str
rows.append(features)
# Sauvegarder
saved = save_batch(rows)
total_courses += 1
total_partants += len(rows)
total_saved += saved
winner = next((r['horse_name'] for r in rows if r['top1']), '?')
print(f" [{day_num:3}/{total_days}] {date_str}{quinte['libelle'][:30]:<30} {len(rows)} partants · gagnant: {winner}")
except requests.exceptions.Timeout:
print(f" [{day_num:3}/{total_days}] {date_str} — timeout ⏱️")
errors += 1
time.sleep(2)
except Exception as e:
print(f" [{day_num:3}/{total_days}] {date_str} — erreur: {e}")
errors += 1
time.sleep(1)
current += timedelta(days=1)
time.sleep(args.delay)
# Résumé final
print(f"\n{'='*60}")
print(f"✅ CHARGEMENT TERMINÉ")
print(f"{'='*60}")
print(f" Courses chargées : {total_courses}")
print(f" Partants total : {total_partants}")
print(f" Lignes sauvegardées : {total_saved}")
print(f" Déjà présents : {skipped} jours")
print(f" Erreurs : {errors}")
# Statistiques BDD finale
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT COUNT(DISTINCT date), COUNT(*), AVG(top5) FROM historical_data")
stats = c.fetchone()
conn.close()
print(f"\n📊 BDD HISTORIQUE FINALE :")
print(f" Jours total : {stats[0]}")
print(f" Lignes total : {stats[1]}")
print(f" Taux top5 moy : {round((stats[2] or 0)*100, 1)}%")
print(f"{'='*60}\n")
if __name__ == "__main__":
main()

222
horse_detail_enhanced.py Executable file
View File

@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""
Enhanced Horse Detail Scraper - With ALL factors
Ferrure, Oeillères, Jockey stats, Distance aptitude
"""
import requests
from bs4 import BeautifulSoup
import json
from datetime import datetime
import re
import sqlite3
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8',
}
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
def init_horse_db():
"""Initialize horse detail table"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS horses_details (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT,
horse_name TEXT,
horse_id TEXT,
age INTEGER,
sex TEXT,
father TEXT,
mother TEXT,
trainer TEXT,
jockey TEXT,
last_odds REAL,
wins INTEGER,
placed INTEGER,
total_races INTEGER,
earnings REAL,
form_music TEXT,
ferrure TEXT,
oeilleres TEXT,
recent_form TEXT,
best_distance TEXT,
best_terrain TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
conn.close()
def save_horse(horse_data):
"""Save horse to database"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''
INSERT INTO horses_details (
date, horse_name, horse_id, age, sex, father, mother,
trainer, jockey, last_odds, wins, placed, total_races,
earnings, form_music, ferrure, oeilleres, recent_form,
best_distance, best_terrain
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
datetime.now().strftime('%Y-%m-%d'),
horse_data.get('name'),
horse_data.get('id'),
horse_data.get('age'),
horse_data.get('sex'),
horse_data.get('father'),
horse_data.get('mother'),
horse_data.get('trainer'),
horse_data.get('jockey'),
horse_data.get('odds'),
horse_data.get('wins'),
horse_data.get('placed'),
horse_data.get('total_races'),
horse_data.get('earnings'),
horse_data.get('form_music'),
horse_data.get('ferrure'),
horse_data.get('oeilleres'),
horse_data.get('recent_form'),
horse_data.get('best_distance'),
horse_data.get('best_terrain')
))
conn.commit()
conn.close()
def scrape_horse_detail(url):
"""Scrape full horse details from Canalturf"""
try:
r = requests.get(url, headers=HEADERS, timeout=15)
soup = BeautifulSoup(r.text, 'html.parser')
text = soup.get_text(separator=' | ', strip=True)
data = {
'url': url,
'id': re.search(r'idcheval=(\d+)', url).group(1) if 'idcheval' in url else None,
'source': 'canalturf'
}
# Name
title = soup.find('title')
if title:
data['name'] = title.text.split('-')[0].strip()
# Age/Sex
if 'Sexe/Age' in text:
match = re.search(r'Sexe/Age : ([MF]\d)', text)
if match:
sex_age = match.group(1)
data['sex'] = sex_age[0]
data['age'] = int(sex_age[1:])
# Father/Mother
if 'Père :' in text:
match = re.search(r'Père : ([^|]+)', text)
if match: data['father'] = match.group(1).strip()
if 'Mère :' in text:
match = re.search(r'Mère : ([^|]+)', text)
if match: data['mother'] = match.group(1).strip()
# Trainer
if 'Entraineur' in text:
match = re.search(r'Entraineur : ([^|]+)', text)
if match: data['trainer'] = match.group(1).strip()
# Odds
if 'Cote' in text:
match = re.search(r'(\d+[\.,]\d+)', text)
if match: data['odds'] = float(match.group(1).replace(',', '.'))
# Stats
if 'Victoire' in text:
match = re.search(r'Victoire\(s\) : (\d+)', text)
if match: data['wins'] = int(match.group(1))
if 'Placé' in text:
match = re.search(r'Placé\(s\) : (\d+)', text)
if match: data['placed'] = int(match.group(1))
if 'Course' in text:
match = re.search(r'Course\(s\) : (\d+)', text)
if match: data['total_races'] = int(match.group(1))
# Earnings
if 'Gains' in text:
match = re.search(r'(\d+[\d\s]*)', text)
if match: data['earnings'] = match.group(1).replace(' ', '')
# Form music
if 'Perf.' in text:
match = re.search(r'Perf\. : ([^|]+)', text)
if match: data['form_music'] = match.group(1).strip()
# FERROUR - From zone-turf style data
# Look for indicators in text
ferrure_indicators = {
'Da': 'Déferré Antérieur',
'Dm': 'Déferré Membres',
'Dp': 'Déferré Postérieur',
'DD': 'Déferré des 4',
'': 'Ferré'
}
data['ferrure'] = 'Non détecté'
# OEILLERES
oeillere_indicators = {
'O': 'Oeillères',
'Oa': 'Oeillères australiennes',
'E': 'Élastiques'
}
data['oeilleres'] = 'Non détecté'
# Recent form (last 5 races)
recent = []
for link in soup.select('a[href*="/resultats-PMU/"]')[:5]:
txt = link.get_text(strip=True)
if txt:
recent.append(txt)
data['recent_form'] = ' | '.join(recent) if recent else ''
# Best distance (inferred from performances)
# This would need historical analysis
data['best_distance'] = 'À analyser'
data['best_terrain'] = 'À analyser'
return data
except Exception as e:
return {'url': url, 'error': str(e)}
# Test
if __name__ == "__main__":
init_horse_db()
print("="*50)
print("HORSE DETAIL SCRAPER - ENHANCED")
print("="*50)
# Test with PASSIONATA
url = "https://www.canalturf.com/courses_fiche_cheval.php?idcheval=516052"
horse = scrape_horse_detail(url)
print(f"\nHorse: {horse.get('name')}")
print(f" Age/Sex: {horse.get('sex')}{horse.get('age')}")
print(f" Trainer: {horse.get('trainer')}")
print(f" Odds: {horse.get('odds')}")
print(f" Wins: {horse.get('wins')}, Placed: {horse.get('placed')}, Races: {horse.get('total_races')}")
print(f" Form: {horse.get('form_music')}")
print(f" Ferrure: {horse.get('ferrure')}")
print(f" Oeillères: {horse.get('oeilleres')}")
# Save to DB
save_horse(horse)
print(f"\n✅ Saved to database!")

172
horse_detail_scraper.py Executable file
View File

@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""
Horse Detail Scraper - Get individual horse data for RUNTIME V4
"""
import requests
from bs4 import BeautifulSoup
import json
from datetime import datetime
import re
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8',
}
def get_horse_id_from_url(url):
"""Extract horse ID from Canalturf URL"""
match = re.search(r'idcheval=(\d+)', url)
if match:
return match.group(1)
return None
def scrape_canalturf_horse(horse_url):
"""Scrape horse details from Canalturf"""
try:
r = requests.get(horse_url, headers=HEADERS, timeout=15)
soup = BeautifulSoup(r.text, 'html.parser')
data = {
'url': horse_url,
'source': 'canalturf',
'scraped_at': datetime.now().isoformat(),
'status': 'success'
}
# Get title
title = soup.find('title')
if title:
data['horse_name'] = title.text.split('-')[0].strip()
# Extract all text for parsing
text = soup.get_text(separator=' | ', strip=True)
# PEDIGREE
if 'Père :' in text:
match = re.search(r'Père : ([^|]+)', text)
if match: data['father'] = match.group(1).strip()
if 'Mère :' in text:
match = re.search(r'Mère : ([^|]+)', text)
if match: data['mother'] = match.group(1).strip()
# AGE & SEX
if 'Sexe/Age' in text:
match = re.search(r'Sexe/Age : ([^|]+)', text)
if match: data['sex_age'] = match.group(1).strip()
# ENTRAINEUR
if 'Entraineur' in text:
match = re.search(r'Entraineur : ([^|]+)', text)
if match: data['trainer'] = match.group(1).strip()
# COTE
if 'Cote' in text:
match = re.search(r'Cote.*?(\d+[\.,]\d+)', text)
if match: data['cote'] = match.group(1).replace(',', '.')
# DERNIERES PERFORMANCES
performances = []
for link in soup.select('a[href*="/resultats-PMU/"]'):
perf_text = link.get_text(strip=True)
if perf_text:
performances.append(perf_text)
if performances:
data['recent_performances'] = performances[:10] # Last 10
# STATS
if 'Victoire' in text:
match = re.search(r'Victoire\(s\) : (\d+)', text)
if match: data['wins'] = int(match.group(1))
if 'Placé' in text:
match = re.search(r'Placé\(s\) : (\d+)', text)
if match: data['placed'] = int(match.group(1))
if 'Course' in text:
match = re.search(r'Course\(s\) : (\d+)', text)
if match: data['total_races'] = int(match.group(1))
# GAINS
if 'Gains :' in text:
match = re.search(r'Gains : (\d+[\d\s]*)', text)
if match: data['earnings'] = match.group(1).replace(' ', '').strip()
# MUSIC (Form)
if 'Perf.' in text:
match = re.search(r'Perf\. : ([^|]+)', text)
if match: data['form_music'] = match.group(1).strip()
return data
except Exception as e:
return {
'url': horse_url,
'source': 'canalturf',
'status': 'error',
'error': str(e)
}
def get_race_horses_urls(race_url):
"""Get all horse URLs from a race page"""
try:
r = requests.get(race_url, headers=HEADERS, timeout=15)
soup = BeautifulSoup(r.text, 'html.parser')
horse_urls = []
for link in soup.select('a[href*="fiche_cheval"]'):
href = link.get('href', '')
if 'idcheval' in href:
# Fix double domain issue
href = href.replace('https://www.canalturf.comhttps://www.canalturf.com', 'https://www.canalturf.com')
if not href.startswith('http'):
href = 'https://www.canalturf.com' + href
horse_urls.append(href)
return list(set(horse_urls))
except Exception as e:
print(f"Error getting horse URLs: {e}")
return []
def main():
print("="*50)
print("🐴 HORSE DETAIL SCRAPER - RUNTIME V4")
print("="*50)
# Example: Get horses from Quinté page
quinte_url = "https://www.canalturf.com/courses_quinte.php"
print(f"\n📋 Getting horses from: {quinte_url}")
horse_urls = get_race_horses_urls(quinte_url)
print(f" Found {len(horse_urls)} horses")
# Scrape first 5 as demo
results = []
for i, url in enumerate(horse_urls[:5]):
print(f"\n[{i+1}/{min(5, len(horse_urls))}] Scraping: {url[:60]}...")
data = scrape_canalturf_horse(url)
results.append(data)
if data['status'] == 'success':
print(f"{data.get('horse_name', 'Unknown')}")
else:
print(f"{data.get('error', 'Error')}")
# Save
output = f"/home/h3r7/turf_scraper/horses_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
with open(output, 'w', encoding='utf-8') as f:
json.dump({
'timestamp': datetime.now().isoformat(),
'race_url': quinte_url,
'total_horses': len(horse_urls),
'horses': results
}, f, indent=2, ensure_ascii=False)
print(f"\n{'='*50}")
print(f"✅ Saved to {output}")
print(f"{'='*50}\n")
return results
if __name__ == "__main__":
main()

195
horse_detail_v2.py Executable file
View File

@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""
Enhanced Horse Detail Scraper - v2
Ferrure, Oeillères, Jockey stats
"""
import requests
from bs4 import BeautifulSoup
import json
from datetime import datetime
import re
import sqlite3
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
}
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
def init_horse_db():
"""Initialize horse detail table"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS horses_details (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT,
horse_name TEXT,
horse_id TEXT,
age INTEGER,
sex TEXT,
trainer TEXT,
jockey TEXT,
last_odds REAL,
wins INTEGER,
placed INTEGER,
total_races INTEGER,
earnings REAL,
form_music TEXT,
ferrure TEXT,
oeilleres TEXT,
recent_form TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
conn.close()
def save_horse(horse_data):
"""Save horse to database"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''
INSERT INTO horses_details (
date, horse_name, horse_id, age, sex, trainer, jockey,
last_odds, wins, placed, total_races, earnings,
form_music, ferrure, oeilleres, recent_form
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
datetime.now().strftime('%Y-%m-%d'),
horse_data.get('name'),
horse_data.get('id'),
horse_data.get('age'),
horse_data.get('sex'),
horse_data.get('trainer'),
horse_data.get('jockey'),
horse_data.get('odds'),
horse_data.get('wins'),
horse_data.get('placed'),
horse_data.get('total_races'),
horse_data.get('earnings'),
horse_data.get('form_music'),
horse_data.get('ferrure'),
horse_data.get('oeilleres'),
horse_data.get('recent_form')
))
conn.commit()
conn.close()
print(f"✅ Saved: {horse_data.get('name')}")
def scrape_horse(horse_id):
"""Scrape horse from Canalturf"""
url = f"https://www.canalturf.com/courses_fiche_cheval.php?idcheval={horse_id}"
try:
r = requests.get(url, headers=HEADERS, timeout=15)
soup = BeautifulSoup(r.text, 'html.parser')
text = soup.get_text(separator=' | ', strip=True)
data = {
'url': url,
'id': horse_id,
'name': '',
'age': None,
'sex': '',
'trainer': '',
'jockey': '',
'odds': None,
'wins': 0,
'placed': 0,
'total_races': 0,
'earnings': 0,
'form_music': '',
'ferrure': '',
'oeilleres': '',
'recent_form': ''
}
# Name
title = soup.find('title')
if title:
data['name'] = title.text.split('-')[0].strip()
# Sex/Age - format "F4" or "M5"
if 'Sexe/Age' in text:
match = re.search(r'Sexe/Age\s*:\s*([MF])(\d)', text)
if match:
data['sex'] = match.group(1)
data['age'] = int(match.group(2))
# Trainer
if 'Entraineur' in text:
match = re.search(r'Entraineur\s*:\s*([^|]+)', text)
if match:
data['trainer'] = match.group(1).strip()
# Odds
if 'Cote' in text:
match = re.search(r'(\d+[.,]\d+)', text)
if match:
data['odds'] = float(match.group(1).replace(',', '.'))
# Form/Music
if 'Perf.' in text:
match = re.search(r'Perf\.\s*:\s*([A-Za-z0-9()hsm]+)', text)
if match:
data['form_music'] = match.group(1).strip()
# Stats
# Victoires
if 'Victoire' in text:
match = re.search(r'(\d+)\s*$', text.split('Victoire')[1].split('|')[0] if 'Victoire' in text else '')
# Simplified - look for numbers
for num in re.findall(r'Victoire\(s\)\s*:\s*(\d+)', text):
data['wins'] = int(num)
break
# Recent performances
recent = []
for link in soup.select('a[href*="/resultats-PMU/"]')[:5]:
txt = link.get_text(strip=True)
if txt and len(txt) > 5:
recent.append(txt[:50])
data['recent_form'] = ' | '.join(recent)
return data
except Exception as e:
return {'id': horse_id, 'error': str(e)}
# Test
if __name__ == "__main__":
init_horse_db()
print("="*50)
print("HORSE DETAIL SCRAPER v2 - ENHANCED")
print("="*50)
# Test with PASSIONATA
horse_id = "516052"
horse = scrape_horse(horse_id)
print(f"\n{horse.get('name')}:")
print(f" Age/Sex: {horse.get('sex')}{horse.get('age')}")
print(f" Trainer: {horse.get('trainer')}")
print(f" Odds: {horse.get('odds')}")
print(f" Form: {horse.get('form_music')}")
save_horse(horse)
# Test with EMSILORD
print("\n" + "-"*30)
horse2 = scrape_horse("518372")
print(f"\n{horse2.get('name')}:")
print(f" Age/Sex: {horse2.get('sex')}{horse2.get('age')}")
print(f" Trainer: {horse2.get('trainer')}")
print(f" Odds: {horse2.get('odds')}")
print(f" Form: {horse2.get('form_music')}")
save_horse(horse2)

92
ideas_api.py Executable file
View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""
Ideas Management API
"""
from flask import Flask, jsonify, request, send_file, send_from_directory
import json
import os
from datetime import datetime
app = Flask(__name__)
IDEAS_FILE = '/home/h3r7/boite_a_idees/idees.json'
# Default structure
default_data = {
"categories": [
{"id": "tech", "name": "Tech & IA", "color": "#7b2cbf"},
{"id": "saas", "name": "SaaS", "color": "#2ec4b6"},
{"id": "service", "name": "Service", "color": "#e94560"},
{"id": "produit", "name": "Produit", "color": "#ff9f1c"},
{"id": "invest", "name": "Investissement", "color": "#00d9ff"}
],
"ideas": [
{
"id": 1,
"title": "🤖 Turf Predictor AI",
"category": "saas",
"subcategory": "IA",
"description": "Système automatisé de prédictions hippiques avec IA. 4 sources de données.",
"status": "teste",
"potential": "eleve",
"revenue": 0,
"created": "2026-02-21",
"notes": "Test 24/02: 3/3 placé"
}
],
"next_id": 2
}
def load_data():
if not os.path.exists(IDEAS_FILE):
with open(IDEAS_FILE, 'w') as f:
json.dump(default_data, f, indent=2)
with open(IDEAS_FILE, 'r') as f:
return json.load(f)
def save_data(data):
with open(IDEAS_FILE, 'w') as f:
json.dump(data, f, indent=2)
@app.route('/')
def index():
return send_file('/home/h3r7/turf_scraper/dashboard.html')
@app.route('/api/ideas')
def get_ideas():
return jsonify(load_data())
@app.route('/api/ideas', methods=['POST'])
def add_idea():
data = load_data()
new_idea = request.json
new_idea['id'] = data['next_id']
new_idea['created'] = datetime.now().strftime('%Y-%m-%d')
data['ideas'].append(new_idea)
data['next_id'] += 1
save_data(data)
return jsonify({"success": True, "id": new_idea['id']})
@app.route('/api/ideas/<int:idea_id>', methods=['PUT'])
def update_idea(idea_id):
data = load_data()
for i, idea in enumerate(data['ideas']):
if idea['id'] == idea_id:
data['ideas'][i].update(request.json)
save_data(data)
return jsonify({"success": True})
return jsonify({"error": "Not found"}), 404
@app.route('/api/ideas/<int:idea_id>', methods=['DELETE'])
def delete_idea(idea_id):
data = load_data()
data['ideas'] = [i for i in data['ideas'] if i['id'] != idea_id]
save_data(data)
return jsonify({"success": True})
@app.route('/idees')
def idees():
return send_from_directory('/home/h3r7/turf_scraper', 'idees_final.html')
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8765, debug=False)

182
idees_final.html Executable file
View File

@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>💡 Boîte à Idées</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #1a1a2e; color: #eee; padding: 20px; }
header { background: linear-gradient(90deg, #7b2cbf, #2ec4b6); padding: 20px; text-align: center; margin: -20px -20px 20px; border-radius: 0 0 20px 20px; }
h1 { font-size: 28px; }
.add-btn { background: #2ec4b6; color: #000; padding: 15px; border: none; border-radius: 10px; width: 100%; font-size: 16px; font-weight: bold; cursor: pointer; margin-bottom: 20px; }
.form { background: #16213e; padding: 20px; border-radius: 15px; margin-bottom: 20px; display: none; }
.form.show { display: block; }
input, select, textarea { width: 100%; padding: 12px; margin: 8px 0; background: #0f3460; border: 1px solid #333; border-radius: 8px; color: #fff; }
.btn-row { display: flex; gap: 10px; }
button.submit { flex: 1; padding: 12px; background: #2ec4b6; border: none; border-radius: 8px; color: #000; font-weight: bold; cursor: pointer; }
button.cancel { flex: 1; padding: 12px; background: #e94560; border: none; border-radius: 8px; color: #fff; cursor: pointer; }
.idea { background: #16213e; padding: 15px; border-radius: 12px; margin: 10px 0; border-left: 4px solid #2ec4b6; }
.idea.eleve { border-left-color: #00ff88; }
.idea.moyen { border-left-color: #ffd700; }
.idea.faible { border-left-color: #e94560; }
.idea-title { font-size: 18px; font-weight: bold; margin-bottom: 8px; }
.badge { background: #7b2cbf; padding: 4px 10px; border-radius: 10px; font-size: 12px; margin-right: 5px; }
.badge-status { background: #2ec4b6; color: #000; }
.idea-desc { color: #aaa; margin-top: 10px; font-size: 14px; line-height: 1.5; }
.meta { color: #666; font-size: 12px; margin-top: 10px; }
.error { background: #e94560; padding: 15px; border-radius: 10px; text-align: center; }
a { color: #2ec4b6; text-decoration: none; display: block; text-align: center; margin-top: 20px; }
.edit-btn { background: #7b2cbf; color: #fff; padding: 5px 10px; border: none; border-radius: 5px; cursor: pointer; font-size: 12px; margin-right: 5px; }
.delete-btn { background: #e94560; color: #fff; padding: 5px 10px; border: none; border-radius: 5px; cursor: pointer; font-size: 12px; }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<header><div style="text-align:center;padding:10px;">
<img src="/turf/H3R7Tech_logo.png" alt="H3R7Tech" style="width:80px;border-radius:10px;">
</div>
<h1>💡 Boîte à Idées</h1>
</header>
<button class="add-btn" onclick="toggleForm()"> Nouvelle Idée</button>
<div class="form" id="form">
<input type="hidden" id="idea-id" value="">
<input type="text" id="title" placeholder="Titre *">
<select id="category">
<option value="tech">Tech & IA</option>
<option value="saas">SaaS</option>
<option value="service">Service</option>
<option value="produit">Produit</option>
<option value="invest">Investissement</option>
</select>
<input type="text" id="subcategory" placeholder="Sous-catégorie">
<textarea id="description" placeholder="Description" rows="3"></textarea>
<select id="status">
<option value="idee">Idée</option>
<option value="encours">En cours</option>
<option value="teste">Testé</option>
<option value="lance">Lancé</option>
</select>
<select id="potential">
<option value="moyen">Potentiel Moyen</option>
<option value="eleve">Potentiel Élevé</option>
<option value="faible">Potentiel Faible</option>
</select>
<div class="btn-row">
<button class="submit" id="submit-btn" onclick="saveIdea()">Ajouter</button>
<button class="cancel" onclick="toggleForm()">Annuler</button>
</div>
</div>
<div id="ideas">Chargement...</div>
<a href="/">← Retour au Portail</a>
<script>
const API_URL = '/turf/api/ideas';
function toggleForm() {
document.getElementById('form').classList.toggle('show');
if (!document.getElementById('form').classList.contains('show')) {
resetForm();
}
}
function resetForm() {
document.getElementById('idea-id').value = '';
document.getElementById('title').value = '';
document.getElementById('description').value = '';
document.getElementById('submit-btn').textContent = 'Ajouter';
}
function loadIdeas() {
fetch(API_URL, {})
.then(r => r.json())
.then(data => {
const ideas = data.ideas || [];
const list = document.getElementById('ideas');
if (!ideas || ideas.length === 0) {
list.innerHTML = '<div class="error">Aucune idée</div>';
return;
}
list.innerHTML = ideas.map(i => {
return '<div class="idea ' + (i.potential || 'moyen') + '">' +
'<div class="idea-title">' + (i.title || '') + '</div>' +
'<div><span class="badge">' + (i.category || '') + '</span><span class="badge badge-status">' + (i.status || '') + '</span></div>' +
'<div class="idea-desc">' + (i.description || '') + '</div>' +
'<div class="meta">📅 ' + (i.created || '') + '</div>' +
'<div style="margin-top:10px;">' +
'<button class="edit-btn" onclick="editIdea(' + i.id + ')">✏️ Modifier</button>' +
'<button class="delete-btn" onclick="deleteIdea(' + i.id + ')">🗑️ Supprimer</button>' +
'</div></div>';
}).join('');
})
.catch(e => {
document.getElementById('ideas').innerHTML = '<div class="error">Erreur: ' + e.message + '</div>';
});
}
function editIdea(id) {
fetch(API_URL + '/' + id, {})
.then(r => r.json())
.then(idea => {
document.getElementById('idea-id').value = idea.id;
document.getElementById('title').value = idea.title || '';
document.getElementById('category').value = idea.category || 'tech';
document.getElementById('status').value = idea.status || 'idee';
document.getElementById('potential').value = idea.potential || 'moyen';
document.getElementById('description').value = idea.description || '';
document.getElementById('submit-btn').textContent = 'Mettre à jour';
document.getElementById('form').classList.add('show');
});
}
function deleteIdea(id) {
if (!confirm('Supprimer cette idée?')) return;
fetch(API_URL + '/' + id, {
method: 'DELETE',
}).then(() => loadIdeas());
}
function saveIdea() {
const id = document.getElementById('idea-id').value;
const title = document.getElementById('title').value;
if (!title) { alert('Titre requis!'); return; }
const data = {
title: title,
category: document.getElementById('category').value,
subcategory: document.getElementById('subcategory').value,
description: document.getElementById('description').value,
status: document.getElementById('status').value,
potential: document.getElementById('potential').value
};
const method = id ? 'PUT' : 'POST';
const url = id ? API_URL + '/' + id : API_URL;
fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(r => r.json())
.then(d => {
if (d.success || d.id) {
toggleForm();
loadIdeas();
resetForm();
} else {
alert(d.error || 'Erreur');
}
});
}
// Load ideas on page load
loadIdeas();
</script>
</body>
</html>

123
idees_local.html Executable file
View File

@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>💡 Boîte à Idées</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, sans-serif; background: #1a1a2e; color: #eee; padding: 15px; }
h1 { color: #2ec4b6; text-align: center; margin-bottom: 20px; }
.form { background: #16213e; padding: 20px; border-radius: 12px; margin-bottom: 20px; }
input, select, textarea { width: 100%; padding: 12px; margin: 8px 0; background: #0f3460; border: 1px solid #333; border-radius: 8px; color: #fff; }
button { width: 100%; padding: 14px; background: #2ec4b6; border: none; border-radius: 8px; color: #000; font-weight: bold; margin-top: 10px; }
.idea { background: #16213e; padding: 15px; border-radius: 10px; margin: 10px 0; border-left: 4px solid #2ec4b6; }
.idea.eleve { border-left-color: #00ff88; }
.idea.moyen { border-left-color: #ffd700; }
.idea-title { font-weight: bold; margin-bottom: 5px; }
.idea-meta { color: #888; font-size: 12px; }
.idea-desc { color: #ccc; margin-top: 10px; font-size: 13px; }
.badge { background: #7b2cbf; padding: 3px 10px; border-radius: 10px; font-size: 11px; margin-right: 5px; }
a { color: #888; text-decoration: none; display: block; text-align: center; margin-top: 20px; }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<h1>💡 Boîte à Idées</h1>
<div class="form">
<h3> Nouvelle Idée</h3>
<input type="text" id="title" placeholder="Titre *">
<select id="category">
<option value="tech">Tech & IA</option>
<option value="saas">SaaS</option>
<option value="service">Service</option>
<option value="produit">Produit</option>
<option value="invest">Investissement</option>
</select>
<input type="text" id="subcategory" placeholder="Sous-catégorie">
<textarea id="description" placeholder="Description" rows="3"></textarea>
<select id="status">
<option value="idee">Idée</option>
<option value="encours">En cours</option>
<option value="teste">Testé</option>
<option value="lance">Lancé</option>
</select>
<select id="potential">
<option value="moyen">Potentiel Moyen</option>
<option value="eleve">Potentiel Élevé</option>
<option value="faible">Potentiel Faible</option>
</select>
<button onclick="addIdea()"> Ajouter</button>
</div>
<div id="ideas"></div>
<a href="/">← Retour au Portail</a>
<script>
// Default ideas
const defaultIdeas = [
{id:1, title:"🤖 Turf Predictor AI", category:"saas", subcategory:"IA", description:"Système automatisé de prédictions hippiques avec IA.", status:"teste", potential:"eleve", created:"2026-02-21"},
{id:2, title:"🌐 Template site web pros", category:"tech", subcategory:"Site web", description:"Templates pour artisans, restaurants, boulangeries.", status:"idee", potential:"eleve", created:"2026-02-24"},
{id:3, title:"🤖 Agent IA Support Client", category:"tech", subcategory:"IA", description:"Assistant IA pour gérer les demandes clients.", status:"idee", potential:"eleve", created:"2026-02-24"},
{id:4, title:"📱 App Fitness Collectif", category:"produit", subcategory:"Mobile", description:"Application pour défis fitness entre amis.", status:"idee", potential:"moyen", created:"2026-02-24"},
{id:5, title:"📊 Dashboard Trading Crypto", category:"saas", subcategory:"Finance", description:"Dashboard suivi cryptos avec alerts.", status:"idee", potential:"eleve", created:"2026-02-24"},
{id:6, title:"🔍 Scrapper annuaires", category:"tech", subcategory:"Scraping", description:"Scraper annuaires pour prospecter clients.", status:"idee", potential:"eleve", created:"2026-02-24"}
];
// Load from localStorage or use default
let ideas = JSON.parse(localStorage.getItem('ideas')) || defaultIdeas;
function save() {
localStorage.setItem('ideas', JSON.stringify(ideas));
}
function render() {
const container = document.getElementById('ideas');
container.innerHTML = ideas.map(i => `
<div class="idea ${i.potential}">
<div class="idea-title">${i.title}</div>
<div class="idea-meta">
<span class="badge">${i.category}</span>
<span class="badge" style="background:#2ec4b6;color:#000">${i.status}</span>
📅 ${i.created}
</div>
<div class="idea-desc">${i.description || ''}</div>
</div>
`).join('');
}
function addIdea() {
const title = document.getElementById('title').value;
if (!title) { alert('Titre requis!'); return; }
const newIdea = {
id: Date.now(),
title: title,
category: document.getElementById('category').value,
subcategory: document.getElementById('subcategory').value,
description: document.getElementById('description').value,
status: document.getElementById('status').value,
potential: document.getElementById('potential').value,
created: new Date().toISOString().split('T')[0]
};
ideas.unshift(newIdea);
save();
render();
// Clear form
document.getElementById('title').value = '';
document.getElementById('description').value = '';
document.getElementById('subcategory').value = '';
alert('Idée ajoutée!');
}
render();
</script>
</body>
</html>

49
idees_simple.html Executable file
View File

@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>💡 Boîte à Idées</title>
<style>
* { margin: 0; padding: 0; }
body { font-family: sans-serif; padding: 20px; background: #1a1a2e; color: #fff; }
.card { background: #16213e; padding: 20px; margin: 10px 0; border-radius: 10px; }
h1 { color: #2ec4b6; margin-bottom: 20px; }
.error { background: #e94560; padding: 15px; border-radius: 8px; }
.loading { color: #888; text-align: center; padding: 40px; }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<h1>💡 Boîte à Idées</h1>
<div id="output" class="loading">Chargement...</div>
<script>
const API_URL = '/turf/api/ideas';
console.log('Starting fetch...');
fetch(API_URL, {
mode: 'cors'
})
.then(r => {
console.log('Response status:', r.status);
return r.json();
})
.then(d => {
console.log('Data received:', d);
let html = '<h2>' + d.ideas.length + ' idées</h2>';
d.ideas.forEach(i => {
html += '<div class="card"><b>' + i.title + '</b><br><small>Status: ' + i.status + '</small></div>';
});
document.getElementById('output').innerHTML = html;
})
.catch(e => {
console.error('Error:', e);
document.getElementById('output').innerHTML = '<div class="error">Erreur: ' + e.message + '</div>';
});
</script>
</body>
</html>

110
idees_statiques.html Executable file
View File

@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>💡 Boîte à Idées</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 100%); min-height: 100vh; color: #eee; }
header { background: linear-gradient(90deg, #7b2cbf, #2ec4b6); padding: 25px 20px; text-align: center; }
header h1 { font-size: 32px; }
.container { max-width: 800px; margin: 0 auto; padding: 20px; }
.back { display: inline-block; color: #888; text-decoration: none; margin-bottom: 20px; }
.back:hover { color: #2ec4b6; }
.idea-card { background: #16213e; border-radius: 16px; padding: 20px; margin: 15px 0; border-left: 4px solid #2ec4b6; }
.idea-card.eleve { border-left-color: #00ff88; }
.idea-card.moyen { border-left-color: #ffd700; }
.idea-card.faible { border-left-color: #ff6b6b; }
.idea-title { font-size: 20px; font-weight: bold; margin-bottom: 10px; }
.idea-meta { color: #888; font-size: 13px; margin-bottom: 10px; }
.badge { padding: 4px 12px; border-radius: 12px; font-size: 12px; background: #7b2cbf; color: white; margin-right: 5px; }
.badge-status { background: #2ec4b6; color: #000; }
.idea-desc { color: #ccc; line-height: 1.6; margin-top: 10px; padding-top: 10px; border-top: 1px solid #333; white-space: pre-wrap; font-size: 14px; }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<header>
<h1>💡 Boîte à Idées</h1>
</header>
<div class="container">
<a href="/" class="back">← Retour au Portail</a>
<div class="idea-card eleve">
<div class="idea-title">🤖 Turf Predictor AI</div>
<div class="idea-meta">
<span class="badge">SaaS</span>
<span class="badge badge-status">teste</span>
💰 0€ | 📅 2026-02-21
</div>
<div class="idea-desc">Système automatisé de prédictions hippiques avec IA. 4 sources de données.</div>
</div>
<div class="idea-card eleve">
<div class="idea-title">🌐 Template site web pour les professionnels</div>
<div class="idea-meta">
<span class="badge">Tech & IA</span>
<span class="badge badge-status">idee</span>
💰 0€ | 📅 2026-02-24
</div>
<div class="idea-desc">Créer un template versionné sur github permettant de déployer un site web pour :
- Restaurant (menu, réservations, galerie photos)
- Cordonnier (services, horaires, contact)
- Boulangerie (produits, commander en ligne)
- Artisans (portfolio, devis, contact)
✅ FONCTIONNALITÉS INCLUSES:
🏠 Site Restaurant: Design chic, menu configurable, calendrier réservation, Google Maps, Avis Clients
⚙️ Admin: Modification temps réel du menu, ajouter/supprimer plats
🛠️ TECHNOLOGIE: HTML + CSS + JavaScript, JSON, Python Flask</div>
</div>
<div class="idea-card eleve">
<div class="idea-title">🤖 Agent IA Support Client</div>
<div class="idea-meta">
<span class="badge">Tech & IA</span>
<span class="badge badge-status">idee</span>
💰 0€ | 📅 2026-02-24
</div>
<div class="idea-desc">Assistant IA pour gérer les demandes clients 24/7 sur site e-commerce. Utilise les APIs pour répondre aux questions produits.</div>
</div>
<div class="idea-card moyen">
<div class="idea-title">📱 App Fitness Collectif</div>
<div class="idea-meta">
<span class="badge">Produit</span>
<span class="badge badge-status">idee</span>
💰 0€ | 📅 2026-02-24
</div>
<div class="idea-desc">Application mobile pour défis fitness entre amis/collègues. Défis quotidiens, classements, rewards.</div>
</div>
<div class="idea-card eleve">
<div class="idea-title">📊 Dashboard Trading Crypto</div>
<div class="idea-meta">
<span class="badge">SaaS</span>
<span class="badge badge-status">idee</span>
💰 0€ | 📅 2026-02-24
</div>
<div class="idea-desc">Dashboard de suivi des cryptos avec alerts, analyses techniques automatisées et signaux d'achat/vente.</div>
</div>
<div class="idea-card eleve">
<div class="idea-title">🔍 Scrapper annuaires pour trouver prospects</div>
<div class="idea-meta">
<span class="badge">Tech & IA</span>
<span class="badge badge-status">idee</span>
💰 0€ | 📅 2026-02-24
</div>
<div class="idea-desc">Scraper les annuaires en ligne pour trouver des artisans/commerchants sans site web et les prospecter.
CIBLE: Artisans, Commerces, Services
ANNUAIRES: PagesJaunes, Mappy, Chrono-Facile
TECHNOLOGIE: Python + BeautifulSoup
REVENUS: Fichier prospects 29-49€, Prospection 99-149€</div>
</div>
</div>
</body>
</html>

361
improve_historical_data.py Normal file
View File

@@ -0,0 +1,361 @@
#!/usr/bin/env python3
"""
Improved Historical Data Loader
- Fills gaps in historical data
- Adds more features for better ML
- Supports bulk loading for more training data
"""
import requests
import sqlite3
import json
import time
import os
import re
from datetime import datetime, timedelta
from concurrent.futures import ThreadPoolExecutor, as_completed
import sys
DB_PATH = os.environ.get("DB_PATH", "/home/h3r7/turf_scraper/turf.db")
HEADERS = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', 'Accept': 'application/json'}
BASE_URL = "https://turfinfo.api.pmu.fr/rest/client/1/programme"
def get_db_connection():
return sqlite3.connect(DB_PATH)
def get_missing_dates():
"""Find dates missing from historical_data."""
conn = get_db_connection()
c = conn.cursor()
c.execute("SELECT DISTINCT date FROM historical_data")
existing = set(row[0] for row in c.fetchall())
conn.close()
start_date = datetime(2025, 1, 1)
end_date = datetime(2026, 12, 31)
all_dates = []
current = start_date
while current <= end_date:
date_str = current.strftime('%Y-%m-%d')
if date_str not in existing:
all_dates.append(date_str)
current += timedelta(days=1)
return all_dates
def get_programme(date_pmu):
"""Get program for a given date."""
try:
url = f"{BASE_URL}/{date_pmu}/reunions"
r = requests.get(url, headers=HEADERS, timeout=15)
if r.status_code != 200:
return []
return r.json().get('programme', {}).get('reunions', [])
except Exception as e:
print(f"Error fetching {date_pmu}: {e}")
return []
def find_all_quintes(reunions):
"""Find all courses (not just Quinte+) - more training data."""
courses = []
for reunion in reunions:
for course in reunion.get('courses', []):
paris = [p['typePari'] for p in course.get('paris', [])]
libelle = course.get('libelle', '')
# Skip if no participants
nb_partants = course.get('nombreDeclaresPartants', 0)
if nb_partants < 10:
continue
heure_ts = course.get('heureDepart', 0)
heure = datetime.fromtimestamp(heure_ts/1000).strftime('%H:%M') if heure_ts else '13:55'
courses.append({
'num_reunion': reunion['numOfficiel'],
'num_course': course['numOrdre'],
'libelle': libelle,
'hippodrome': reunion['hippodrome']['libelleCourt'],
'distance': course.get('distance', 0),
'discipline': course.get('discipline', ''),
'allocation': course.get('montantPrix', 0),
'nb_partants': nb_partants,
'heure': heure,
'arrivee_def': course.get('arriveeDefinitive', False),
})
return courses
def get_participants(date_pmu, num_r, num_c):
"""Get participants for a course."""
try:
url = f"{BASE_URL}/{date_pmu}/R{num_r}/C{num_c}/participants"
r = requests.get(url, headers=HEADERS, timeout=15)
if r.status_code != 200:
return []
return r.json().get('participants', [])
except Exception as e:
print(f"Error fetching participants: {e}")
return []
def parse_musique(musique):
"""Parse the musique (form) string."""
if not musique:
return {'forme_recente': 99, 'tendance': 0, 'nb_disq': 0, 'best_pos': 99}
clean = re.sub(r'\(\d+\)', '', musique)
resultats = re.findall(r'(\d+|D|0)([amphsc]?)', clean)
positions = []
for pos, disc in resultats[:10]:
if pos == 'D':
positions.append(99)
else:
positions.append(int(pos))
if not positions:
return {'forme_recente': 99, 'tendance': 0, 'nb_disq': 0, 'best_pos': 99}
nb_disq = positions.count(99)
positions_clean = [p for p in positions if p != 99]
recentes = positions_clean[:3]
forme_recente = sum(recentes) / len(recentes) if recentes else 99
best_pos = min(positions_clean) if positions_clean else 99
if len(positions_clean) >= 4:
debut = sum(positions_clean[-4:]) / 4
fin = sum(positions_clean[:4]) / 4
tendance = round(debut - fin, 2)
else:
tendance = 0
return {
'forme_recente': round(forme_recente, 2),
'tendance': tendance,
'nb_disq': nb_disq,
'best_pos': best_pos
}
def extract_features(p, course_info, all_participants):
"""Extract all features from a participant."""
musique_stats = parse_musique(p.get('musique', ''))
# Odds
rapport_direct = p.get('dernierRapportDirect', {}) or {}
cote_directe = rapport_direct.get('rapport', 0) or 0
est_favori = 1 if rapport_direct.get('favoris', False) else 0
rapport_ref = p.get('dernierRapportReference', {}) or {}
cote_reference = rapport_ref.get('rapport', 0) or 0
# Career stats
nb_courses = p.get('nombreCourses', 0) or 0
nb_victoires = p.get('nombreVictoires', 0) or 0
nb_places = p.get('nombrePlaces', 0) or 0
nb_p2 = p.get('nombrePlacesSecond', 0) or 0
nb_p3 = p.get('nombrePlacesTroisieme', 0) or 0
tx_vic = round(nb_victoires / nb_courses * 100, 2) if nb_courses else 0
tx_place = round(nb_places / nb_courses * 100, 2) if nb_courses else 0
# Earnings
gains = p.get('gainsParticipant', {}) or {}
gains_carriere = gains.get('gainsCarriere', 0) or 0
gains_annee = gains.get('gainsAnneeEnCours', 0) or 0
gains_victoires = gains.get('gainsVictoires', 0) or 0
# Odds rank
all_cotes = sorted([
(x.get('dernierRapportDirect', {}) or {}).get('rapport', 999) or 999
for x in all_participants
])
rang_cote = all_cotes.index(cote_directe) + 1 if cote_directe in all_cotes else 99
cotes_valides = [c for c in all_cotes if c < 900]
moy_cote = sum(cotes_valides) / len(cotes_valides) if cotes_valides else 1
ratio_cote = round(cote_directe / moy_cote, 3) if moy_cote else 0
# Result
ordre = p.get('ordreArrivee', 0) or 0
top1 = 1 if ordre == 1 else 0
top3 = 1 if 1 <= ordre <= 3 else 0
top5 = 1 if 1 <= ordre <= 5 else 0
# Driver/jockey
driver = p.get('driver', {}) or {}
jockey_name = driver.get('nom', '') if driver else ''
if not jockey_name:
jockey_name = p.get('jockey', {}).get('nom', '') if p.get('jockey') else ''
# Equipment
oeilleres = p.get('oeilleres', '')
deferre = p.get('deferre', '')
return {
'date': None,
'race_name': course_info['libelle'],
'hippodrome': course_info['hippodrome'],
'distance': course_info['distance'],
'discipline': course_info['discipline'],
'allocation': course_info['allocation'],
'nb_partants': course_info['nb_partants'],
'heure': course_info['heure'],
'horse_name': p.get('nom', ''),
'horse_number': p.get('numero', 0),
'driver': jockey_name,
'age': p.get('age', 0) or 0,
'sexe': p.get('sexe', 'U'),
'musique': p.get('musique', ''),
'nb_courses': nb_courses,
'nb_victoires': nb_victoires,
'nb_places': nb_places,
'nb_places_2': nb_p2,
'nb_places_3': nb_p3,
'gains_carriere': gains_carriere,
'gains_annee': gains_annee,
'gains_victoires': gains_victoires,
'reduction_km': p.get('reductionKm', 0) or 0,
'avis_entraineur': p.get('avisEntraineur', 'NEUTRE') or 'NEUTRE',
'oeilleres': oeilleres or 'SANS',
'deferre': deferre or 'NON',
'cote_directe': cote_directe,
'cote_reference': cote_reference,
'indicateur_tendance': rapport_direct.get('nombreIndicateurTendance', 0) or 0,
'est_favori': est_favori,
'tx_victoire': tx_vic,
'tx_place': tx_place,
'forme_recente': musique_stats['forme_recente'],
'tendance_forme': musique_stats['tendance'],
'nb_disq': musique_stats['nb_disq'],
'rang_cote': rang_cote,
'ratio_cote_field': ratio_cote,
'ordre_arrivee': ordre,
'temps_obtenu': p.get('tempsObtenu', 0) or 0,
'top1': top1,
'top3': top3,
'top5': top5,
}
def load_date(date_str):
"""Load all course data for a specific date."""
date_pmu = datetime.strptime(date_str, '%Y-%m-%d').strftime('%d%m%Y')
reunions = get_programme(date_pmu)
if not reunions:
return 0
courses = find_all_quintes(reunions)
if not courses:
return 0
total_loaded = 0
conn = get_db_connection()
c = conn.cursor()
for course in courses:
participants = get_participants(date_pmu, course['num_reunion'], course['num_course'])
if not participants:
continue
for p in participants:
try:
features = extract_features(p, course, participants)
features['date'] = date_str
c.execute('''
INSERT OR IGNORE INTO historical_data
(date, race_name, hippodrome, distance, discipline, allocation, nb_partants, heure,
horse_name, horse_number, driver, age, sexe, musique, nb_courses, nb_victoires,
nb_places, nb_places_2, nb_places_3, gains_carriere, gains_annee, gains_victoires,
reduction_km, avis_entraineur, oeilleres, deferre, cote_directe, cote_reference,
indicateur_tendance, est_favori, tx_victoire, tx_place, forme_recente, tendance_forme,
nb_disq, rang_cote, ratio_cote_field, ordre_arrivee, temps_obtenu, top1, top3, top5)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
features['date'], features['race_name'], features['hippodrome'], features['distance'],
features['discipline'], features['allocation'], features['nb_partants'], features['heure'],
features['horse_name'], features['horse_number'], features['driver'], features['age'],
features['sexe'], features['musique'], features['nb_courses'], features['nb_victoires'],
features['nb_places'], features['nb_places_2'], features['nb_places_3'],
features['gains_carriere'], features['gains_annee'], features['gains_victoires'],
features['reduction_km'], features['avis_entraineur'], features['oeilleres'],
features['deferre'], features['cote_directe'], features['cote_reference'],
features['indicateur_tendance'], features['est_favori'], features['tx_victoire'],
features['tx_place'], features['forme_recente'], features['tendance_forme'],
features['nb_disq'], features['rang_cote'], features['ratio_cote_field'],
features['ordre_arrivee'], features['temps_obtenu'], features['top1'],
features['top3'], features['top5']
))
if c.rowcount > 0:
total_loaded += 1
except Exception as e:
print(f" Error loading participant: {e}")
conn.commit()
conn.close()
return total_loaded
def main():
print(f"\n{'='*60}")
print("Improved Historical Data Loader")
print(f"{'='*60}\n")
# Get missing dates
missing = get_missing_dates()
print(f"Found {len(missing)} missing dates")
if not missing:
print("No missing dates to load!")
return
# Load missing dates (limit to avoid timeout)
dates_to_load = missing[:30] # Load max 30 dates at once
print(f"Loading {len(dates_to_load)} dates...\n")
total = 0
for i, date in enumerate(dates_to_load):
print(f"[{i+1}/{len(dates_to_load)}] Loading {date}...", end=" ")
loaded = load_date(date)
print(f"{loaded} rows")
total += loaded
# Rate limiting
if i < len(dates_to_load) - 1:
time.sleep(0.5)
print(f"\n{'='*60}")
print(f"Total loaded: {total} rows")
# Show updated stats
conn = get_db_connection()
c = conn.cursor()
c.execute("SELECT COUNT(*), COUNT(DISTINCT date) FROM historical_data")
count, days = c.fetchone()
c.execute("SELECT MIN(date), MAX(date) FROM historical_data")
min_date, max_date = c.fetchone()
conn.close()
print(f"Total in DB: {count} rows, {days} days")
print(f"Date range: {min_date} to {max_date}")
print(f"{'='*60}\n")
if __name__ == "__main__":
main()

260
llm_cache.py Normal file
View File

@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""
Cache LLM - Turf Scraper
Réduction des appels API par mise en cache des réponses
"""
import json
import hashlib
import os
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, Any
class LLMCache:
"""Cache pour réponses LLM avec expiration"""
def __init__(self, cache_dir: str = None, ttl_hours: int = 24):
"""
Args:
cache_dir: Répertoire pour le cache (défaut: ~/.cache/turf_llm/)
ttl_hours: Time-to-live en heures (défaut: 24h)
"""
if cache_dir is None:
cache_dir = os.path.expanduser("~/.cache/turf_llm")
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.ttl = timedelta(hours=ttl_hours)
def _hash_key(self, key: str) -> str:
"""Génère un hash pour la clé"""
return hashlib.sha256(key.encode()).hexdigest()
def _get_cache_path(self, key: str) -> Path:
"""Retourne le chemin du fichier cache"""
hash_key = self._hash_key(key)
return self.cache_dir / f"{hash_key}.json"
def get(self, key: str) -> Optional[dict]:
"""
Récupère une valeur du cache
Args:
key: Clé de recherche
Returns:
dict avec 'response' et 'timestamp' ou None si expiré/absent
"""
cache_path = self._get_cache_path(key)
if not cache_path.exists():
return None
try:
with open(cache_path, 'r', encoding='utf-8') as f:
data = json.load(f)
cached_time = datetime.fromisoformat(data.get('timestamp', ''))
if datetime.now() - cached_time > self.ttl:
cache_path.unlink()
return None
return data
except (json.JSONDecodeError, ValueError, OSError):
return None
def set(self, key: str, response: Any, metadata: dict = None) -> bool:
"""
Sauvegarde une réponse dans le cache
Args:
key: Clé de recherche
response: Réponse à sauvegarder
metadata: Métadonnées additionnelles
Returns:
True si succès
"""
cache_path = self._get_cache_path(key)
data = {
'key': key,
'response': response,
'timestamp': datetime.now().isoformat(),
'metadata': metadata or {}
}
try:
with open(cache_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return True
except OSError:
return False
def delete(self, key: str) -> bool:
"""Supprime une entrée du cache"""
cache_path = self._get_cache_path(key)
try:
if cache_path.exists():
cache_path.unlink()
return True
except OSError:
pass
return False
def clear(self) -> int:
"""Supprime tout le cache"""
count = 0
for f in self.cache_dir.glob("*.json"):
try:
f.unlink()
count += 1
except OSError:
pass
return count
def clear_expired(self) -> int:
"""Supprime les entrées expirées"""
count = 0
now = datetime.now()
for f in self.cache_dir.glob("*.json"):
try:
with open(f, 'r', encoding='utf-8') as fp:
data = json.load(fp)
cached_time = datetime.fromisoformat(data.get('timestamp', ''))
if now - cached_time > self.ttl:
f.unlink()
count += 1
except (json.JSONDecodeError, ValueError, OSError):
pass
return count
def get_stats(self) -> dict:
"""Retourne des statistiques sur le cache"""
files = list(self.cache_dir.glob("*.json"))
total_size = sum(f.stat().st_size for f in files)
now = datetime.now()
expired = 0
for f in files:
try:
with open(f, 'r', encoding='utf-8') as fp:
data = json.load(fp)
cached_time = datetime.fromisoformat(data.get('timestamp', ''))
if now - cached_time > self.ttl:
expired += 1
except:
pass
return {
'total_entries': len(files),
'total_size_bytes': total_size,
'expired_entries': expired,
'active_entries': len(files) - expired
}
class QuestionCache:
"""Cache spécifique pour les questions SQL"""
def __init__(self, db_path: str = None):
if db_path is None:
db_path = os.path.expanduser("~/.cache/turf_llm/sql_cache.json")
self.cache_file = Path(db_path)
self.cache = self._load()
def _load(self) -> dict:
"""Charge le cache depuis le fichier"""
if self.cache_file.exists():
try:
with open(self.cache_file, 'r', encoding='utf-8') as f:
return json.load(f)
except:
pass
return {}
def _save(self):
"""Sauvegarde le cache"""
try:
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.cache_file, 'w', encoding='utf-8') as f:
json.dump(self.cache, f, indent=2)
except:
pass
def get_sql(self, question: str) -> Optional[str]:
"""Récupère SQL pour une question similaire"""
normalized = question.lower().strip()
if normalized in self.cache:
return self.cache[normalized].get('sql')
for key, value in self.cache.items():
if self._similarity(normalized, key) > 0.7:
return value.get('sql')
return None
def set_sql(self, question: str, sql: str, success: bool = True):
"""Sauvegarde SQL pour une question"""
normalized = question.lower().strip()
self.cache[normalized] = {
'sql': sql,
'success': success,
'timestamp': datetime.now().isoformat(),
'count': self.cache.get(normalized, {}).get('count', 0) + 1
}
self._save()
def _similarity(self, s1: str, s2: str) -> float:
"""Calcule similarité simple entre deux strings"""
words1 = set(s1.split())
words2 = set(s2.split())
if not words1 or not words2:
return 0.0
intersection = len(words1 & words2)
union = len(words1 | words2)
return intersection / union if union > 0 else 0.0
def get_frequent_questions(self, limit: int = 10) -> list:
"""Retourne les questions les plus fréquentes"""
sorted_questions = sorted(
self.cache.items(),
key=lambda x: x[1].get('count', 0),
reverse=True
)
return [q[0] for q in sorted_questions[:limit]]
_global_cache = None
_sql_cache = None
def get_llm_cache() -> LLMCache:
"""Singleton pour le cache global"""
global _global_cache
if _global_cache is None:
_global_cache = LLMCache()
return _global_cache
def get_sql_cache() -> QuestionCache:
"""Singleton pour le cache SQL"""
global _sql_cache
if _sql_cache is None:
_sql_cache = QuestionCache()
return _sql_cache

514
map_visual.html Normal file
View File

@@ -0,0 +1,514 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>H3R7Tech - Architecture Visuelle</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--border: #30363d;
--accent: #58a6ff;
--api: #22c55e;
--db: #3b82f6;
--scraper: #f59e0b;
--core: #a855f7;
--utils: #6b7280;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
height: 100vh;
}
.toolbar {
height: 60px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 20px;
gap: 16px;
}
.logo {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
font-size: 18px;
color: var(--accent);
}
.logo span { color: var(--text-secondary); font-weight: 400; }
.view-tabs {
display: flex;
gap: 8px;
margin-left: 20px;
}
.view-tab {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.view-tab:hover { border-color: var(--accent); color: var(--text-primary); }
.view-tab.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.filters {
display: flex;
gap: 8px;
margin-left: auto;
}
.filter-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.filter-btn:hover { border-color: var(--accent); }
.filter-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
#graph-container {
width: 100%;
height: calc(100vh - 60px);
position: relative;
}
#graph-svg {
width: 100%;
height: 100%;
}
.node {
cursor: pointer;
}
.node circle {
stroke-width: 2px;
transition: all 0.3s;
}
.node:hover circle {
stroke-width: 4px;
filter: brightness(1.2);
}
.node text {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
fill: var(--text-primary);
pointer-events: none;
}
.link {
stroke: var(--border);
stroke-opacity: 0.6;
stroke-width: 1px;
}
.link:hover {
stroke: var(--accent);
stroke-opacity: 1;
}
.sidebar {
position: fixed;
top: 60px;
right: 0;
width: 350px;
height: calc(100vh - 60px);
background: var(--bg-secondary);
border-left: 1px solid var(--border);
padding: 20px;
overflow-y: auto;
transform: translateX(100%);
transition: transform 0.3s;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar h2 {
font-size: 18px;
margin-bottom: 15px;
color: var(--accent);
}
.sidebar .meta {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 15px;
}
.sidebar .section {
margin-bottom: 20px;
}
.sidebar .section-title {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 8px;
text-transform: uppercase;
}
.sidebar .tag {
display: inline-block;
background: var(--bg-tertiary);
border: 1px solid var(--border);
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
margin: 2px;
}
.sidebar .route {
background: rgba(34, 197, 94, 0.1);
border-color: var(--api);
color: var(--api);
}
.stats-panel {
position: fixed;
bottom: 20px;
left: 20px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 15px;
font-size: 12px;
}
.stats-panel .stat-row {
display: flex;
justify-content: space-between;
margin: 5px 0;
}
.stats-panel .stat-value {
font-weight: 600;
}
.legend {
position: fixed;
bottom: 20px;
right: 20px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 15px;
}
.legend-title {
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 10px;
text-transform: uppercase;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
margin: 5px 0;
font-size: 12px;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.tooltip {
position: absolute;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px;
font-size: 12px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
max-width: 300px;
}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<div class="toolbar">
<div class="logo">H3R7<span>Tech</span></div>
<div class="view-tabs">
<button class="view-tab active" data-view="graph">Graphe</button>
<button class="view-tab" data-view="tree">Arborescence</button>
</div>
<div class="filters">
<button class="filter-btn active" data-filter="all">Tout</button>
<button class="filter-btn" data-filter="api">API</button>
<button class="filter-btn" data-filter="db">DB</button>
<button class="filter-btn" data-filter="scraper">Scrapers</button>
</div>
</div>
<div id="graph-container">
<svg id="graph-svg"></svg>
</div>
<div class="sidebar" id="sidebar">
<h2 id="node-title">Selectionnez un fichier</h2>
<div class="meta" id="node-meta"></div>
<div class="section" id="routes-section" style="display:none;">
<div class="section-title">Routes</div>
<div id="node-routes"></div>
</div>
<div class="section" id="functions-section" style="display:none;">
<div class="section-title">Fonctions</div>
<div id="node-functions"></div>
</div>
<div class="section" id="imports-section" style="display:none;">
<div class="section-title">Imports</div>
<div id="node-imports"></div>
</div>
</div>
<div class="stats-panel" id="stats-panel">
<div class="stat-row"><span>Fichiers:</span><span class="stat-value" id="stat-files">0</span></div>
<div class="stat-row"><span>Liens:</span><span class="stat-value" id="stat-links">0</span></div>
<div class="stat-row"><span>API:</span><span class="stat-value" id="stat-api" style="color:var(--api)">0</span></div>
</div>
<div class="legend">
<div class="legend-title">GROUPES</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--api)"></div>API</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--db)"></div>Database</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--scraper)"></div>Scraper</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--core)"></div>Core</div>
</div>
<div class="tooltip" id="tooltip"></div>
<script>
let architectureData = null;
let simulation = null;
let currentFilter = 'all';
const colorMap = {
'api': '#22c55e',
'db': '#3b82f6',
'scraper': '#f59e0b',
'core': '#a855f7',
'utils': '#6b7280'
};
async function loadData() {
try {
const response = await fetch('/architecture.json');
architectureData = await response.json();
initGraph();
updateStats();
} catch (error) {
console.error('Erreur chargement donnees:', error);
document.getElementById('graph-svg').innerHTML = '<text x="50%" y="50%" text-anchor="middle" fill="#8b949e">Erreur de chargement des donnees</text>';
}
}
function initGraph() {
const svg = d3.select('#graph-svg');
const width = window.innerWidth;
const height = window.innerHeight - 60;
svg.attr('viewBox', [0, 0, width, height]);
// Filtrer les donnees
let nodes = architectureData.nodes;
let links = architectureData.links;
if (currentFilter !== 'all') {
const filteredIds = nodes.filter(n => n.group === currentFilter).map(n => n.id);
nodes = nodes.filter(n => n.group === currentFilter ||
links.some(l => (l.source === n.id && filteredIds.includes(l.target)) ||
(l.target === n.id && filteredIds.includes(l.source))));
links = links.filter(l =>
nodes.map(n => n.id).includes(l.source) &&
nodes.map(n => n.id).includes(l.target));
}
// Creer les noeuds pour D3
const nodesData = nodes.map(n => ({
...n,
x: width / 2 + (Math.random() - 0.5) * 400,
y: height / 2 + (Math.random() - 0.5) * 400
}));
const linksData = links.map(l => ({
source: nodesData.find(n => n.id === l.source),
target: nodesData.find(n => n.id === l.target)
})).filter(l => l.source && l.target);
// Simulation force
simulation = d3.forceSimulation(nodesData)
.force('link', d3.forceLink(linksData).id(d => d.id).distance(150))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(40));
// Dessiner les liens
const link = svg.append('g')
.selectAll('line')
.data(linksData)
.enter()
.append('line')
.attr('class', 'link')
.attr('stroke-width', d => Math.sqrt(d.source.dep_count + 1));
// Dessiner les noeuds
const node = svg.append('g')
.selectAll('g')
.data(nodesData)
.enter()
.append('g')
.attr('class', 'node')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended))
.on('click', (event, d) => showNodeDetails(d))
.on('mouseover', showTooltip)
.on('mouseout', hideTooltip);
// Cercles
node.append('circle')
.attr('r', d => Math.max(10, Math.min(30, 10 + d.dep_count * 2)))
.attr('fill', d => colorMap[d.group] || colorMap.core)
.attr('stroke', d => d3.color(colorMap[d.group] || colorMap.core).darker());
// Labels
node.append('text')
.attr('dy', 4)
.attr('dx', d => Math.max(10, Math.min(30, 10 + d.dep_count * 2)) + 5)
.text(d => d.id.replace('.py', ''));
// Mise a jour positions
simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
}
function showNodeDetails(node) {
document.getElementById('sidebar').classList.add('open');
document.getElementById('node-title').textContent = node.id;
document.getElementById('node-meta').innerHTML = `
Groupe: <strong>${node.group}</strong><br>
Taille: <strong>${(node.size / 1024).toFixed(1)} KB</strong><br>
Dependances: <strong>${node.dep_count}</strong>
`;
// Routes
if (node.routes && node.routes.length > 0) {
document.getElementById('routes-section').style.display = 'block';
document.getElementById('node-routes').innerHTML = node.routes
.map(r => `<span class="tag route">${r}</span>`)
.join('');
} else {
document.getElementById('routes-section').style.display = 'none';
}
// Fonctions
if (node.functions && node.functions.length > 0) {
document.getElementById('functions-section').style.display = 'block';
document.getElementById('node-functions').innerHTML = node.functions
.map(f => `<span class="tag">${f}</span>`)
.join('');
} else {
document.getElementById('functions-section').style.display = 'none';
}
// Imports
if (node.imports && node.imports.length > 0) {
document.getElementById('imports-section').style.display = 'block';
document.getElementById('node-imports').innerHTML = node.imports
.map(i => `<span class="tag">${i}</span>`)
.join('');
} else {
document.getElementById('imports-section').style.display = 'none';
}
}
function showTooltip(event, d) {
const tooltip = document.getElementById('tooltip');
tooltip.innerHTML = `<strong>${d.id}</strong><br>${d.group} - ${(d.size/1024).toFixed(1)} KB`;
tooltip.style.left = (event.pageX + 10) + 'px';
tooltip.style.top = (event.pageY - 30) + 'px';
tooltip.style.opacity = 1;
}
function hideTooltip() {
document.getElementById('tooltip').style.opacity = 0;
}
function updateStats() {
if (architectureData) {
document.getElementById('stat-files').textContent = architectureData.stats.total_files;
document.getElementById('stat-links').textContent = architectureData.stats.total_links;
document.getElementById('stat-api').textContent = architectureData.stats.groups.api;
}
}
// Filtres
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentFilter = btn.dataset.filter;
d3.select('#graph-svg').selectAll('*').remove();
if (simulation) simulation.stop();
initGraph();
});
});
// Vues
document.querySelectorAll('.view-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// TODO: Implementer vue arborescence
});
});
// Fermer sidebar
document.getElementById('sidebar').addEventListener('click', (e) => {
if (e.target.id === 'sidebar') {
e.target.classList.remove('open');
}
});
// Resize
window.addEventListener('resize', () => {
if (simulation) {
simulation.force('center', d3.forceCenter(window.innerWidth / 2, (window.innerHeight - 60) / 2));
simulation.alpha(0.3).restart();
}
});
// Init
loadData();
</script>
</body>
</html>

345
metrics_alerts.py Executable file
View File

@@ -0,0 +1,345 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
metrics_alerts.py - Alertes Telegram + Email pour les métriques de performance
Usage:
python3 metrics_alerts.py --daily # Alerte quotidienne (si notable)
python3 metrics_alerts.py --weekly # Rapport hebdomadaire
python3 metrics_alerts.py --test # Test formatage
python3 metrics_alerts.py --daily --email # Alerte + email si ROI > 1.0€
python3 metrics_alerts.py --weekly --email # Rapport hebdo par email
"""
import sqlite3
import argparse
import requests
from datetime import datetime, timedelta
from pathlib import Path
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
TELEGRAM_DIR = "/home/h3r7/turf_scraper"
# Email alert configuration (via combined_api Resend endpoint on port 8765)
COMBINED_API_URL = "http://localhost:8765"
ALERT_EMAIL_TO = "ronanyves26@gmail.com" # destinataire des alertes email
# Seuil ROI pour déclencher une alerte email automatique (euros par mise)
ROI_ALERT_THRESHOLD = 1.0
# =============================================================================
# FONCTIONS UTILITAIRES
# =============================================================================
def get_db():
"""Connexion a la base de donnees"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def save_telegram_message(message, filename):
"""Sauvegarde le message Telegram dans un fichier"""
filepath = Path(TELEGRAM_DIR) / filename
with open(filepath, 'w', encoding='utf-8') as f:
f.write(message)
print("Message Telegram sauvegarde: {}".format(filepath))
return filepath
def send_email_alert(subject, message_text):
"""
Envoie une alerte email via le endpoint /api/send-email de combined_api (port 8765).
Convertit le message texte en HTML style.
Retourne True si envoi reussi, False sinon.
"""
# Convertir le texte brut en HTML lisible
html_lines = []
for line in message_text.splitlines():
stripped = line.strip()
if not stripped:
html_lines.append("<br>")
else:
# Echapper les caracteres HTML basiques
escaped = stripped.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
html_lines.append(
"<p style='margin:3px 0;font-family:monospace;font-size:14px;'>{}</p>".format(escaped)
)
html_body = """
<div style="background:#0f0f23;color:#eee;padding:24px;border-radius:8px;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
max-width:620px;margin:0 auto;">
<h2 style="color:#00d9ff;border-bottom:1px solid #30363d;
padding-bottom:12px;margin-bottom:16px;font-size:20px;">
H3R7Tech — Alerte Performance Turf
</h2>
<div style="margin-top:12px;background:#161b22;padding:16px;border-radius:6px;
border:1px solid #30363d;">
{}
</div>
<hr style="border:none;border-top:1px solid #30363d;margin:20px 0;">
<p style="color:#555;font-size:12px;margin:0;">
Alerte automatique generee par metrics_alerts.py — H3R7Tech<br>
Dashboard: <a href="http://localhost:8765/turf/" style="color:#00d9ff;">Turf Dashboard</a>
</p>
</div>
""".format("".join(html_lines))
try:
resp = requests.post(
"{}/api/send-email".format(COMBINED_API_URL),
json={
"to": ALERT_EMAIL_TO,
"subject": subject,
"html": html_body,
},
timeout=15,
)
if resp.status_code in (200, 201):
result = resp.json()
print("Email envoye a {} — id: {}".format(ALERT_EMAIL_TO, result.get("id", "?")))
return True
else:
try:
data = resp.json()
error_msg = data.get("error", data.get("details", resp.text[:300]))
except Exception:
error_msg = resp.text[:300]
print("Erreur Resend ({}): {}".format(resp.status_code, error_msg))
return False
except Exception as e:
print("Impossible d'envoyer l'email: {}".format(e))
return False
# =============================================================================
# ALERTE QUOTIDIENNE
# =============================================================================
def check_daily_alerts(date_str):
"""Verifie les evenements notables du jour. Retourne (message, has_roi_alert) ou None."""
conn = get_db()
# Recuperer les metriques du jour
metrics = conn.execute("""
SELECT
source,
SUM(nb_predictions) as total_pred,
SUM(nb_gagnants) as total_gagn,
SUM(nb_places) as total_place,
SUM(nb_top5) as total_top5,
AVG(taux_gagnant) as taux_g,
AVG(taux_place) as taux_p,
AVG(roi_sg_net) as roi_sg,
SUM(quinte_5sur5) as q5
FROM prediction_metrics
WHERE date = ?
GROUP BY source
""", (date_str,)).fetchall()
if not metrics:
print("Aucune metrique pour {}".format(date_str))
return None
# Verifier les criteres d'alerte
alerts = []
has_roi_alert = False
for m in metrics:
# Quinte 5/5
if m['q5'] and m['q5'] > 0:
alerts.append("QUINTE 5/5 {}: Quinte 5/5 trouve!".format(m['source']))
# ROI exceptionnel (> seuil ROI_ALERT_THRESHOLD)
if m['roi_sg'] and m['roi_sg'] > ROI_ALERT_THRESHOLD:
alerts.append("ROI ELEVE {}: ROI SG +{:.2f}€/mise".format(m['source'], m['roi_sg']))
has_roi_alert = True
# Taux place excellent
if m['taux_p'] and m['taux_p'] > 70:
alerts.append("TAUX PLACE {}: {:.1f}% place (excellent)".format(m['source'], m['taux_p']))
if not alerts:
print("Aucun evenement notable aujourd'hui")
return None
# Formater le message
date_fmt = datetime.strptime(date_str, '%Y-%m-%d').strftime('%d/%m/%Y')
message = "ALERTE PERFORMANCE\nDate: {}\n{}\n\n{}\n\nDetails: Dashboard Turf".format(
date_fmt,
"=" * 30,
"\n".join(alerts)
)
conn.close()
return message, has_roi_alert
# =============================================================================
# RAPPORT HEBDOMADAIRE
# =============================================================================
def generate_weekly_report():
"""Genere le rapport hebdomadaire de performance. Retourne (message, total_roi_sg)."""
conn = get_db()
# Dates de la semaine
today = datetime.now()
start_date = (today - timedelta(days=7)).strftime('%Y-%m-%d')
end_date = today.strftime('%Y-%m-%d')
# Periode precedente pour comparaison
prev_start = (today - timedelta(days=14)).strftime('%Y-%m-%d')
prev_end = start_date
# Metriques de la semaine
current_metrics = conn.execute("""
SELECT
source,
COUNT(*) as nb_courses,
SUM(nb_predictions) as total_pred,
SUM(nb_gagnants) as total_gagn,
SUM(nb_places) as total_place,
ROUND(AVG(taux_gagnant), 1) as taux_g,
ROUND(AVG(taux_place), 1) as taux_p,
ROUND(SUM(roi_sg_net), 3) as roi_sg_cumul,
ROUND(SUM(roi_sp_net), 3) as roi_sp_cumul,
SUM(quinte_5sur5) as q5,
SUM(quinte_4sur5) as q4
FROM prediction_metrics
WHERE date BETWEEN ? AND ?
GROUP BY source
ORDER BY taux_p DESC
""", (start_date, end_date)).fetchall()
# Metriques semaine precedente
prev_metrics = conn.execute("""
SELECT
source,
ROUND(AVG(taux_gagnant), 1) as taux_g,
ROUND(AVG(taux_place), 1) as taux_p
FROM prediction_metrics
WHERE date BETWEEN ? AND ?
GROUP BY source
""", (prev_start, prev_end)).fetchall()
prev_dict = {m['source']: m for m in prev_metrics}
# Formater le message
start_fmt = datetime.strptime(start_date, '%Y-%m-%d').strftime('%d/%m')
end_fmt = datetime.strptime(end_date, '%Y-%m-%d').strftime('%d/%m')
message = "RAPPORT PERFORMANCE\nSemaine {} - {}\n{}\n\n".format(
start_fmt, end_fmt, "=" * 30
)
# Bilan global
total_roi_sg = sum(m['roi_sg_cumul'] or 0 for m in current_metrics)
total_roi_sp = sum(m['roi_sp_cumul'] or 0 for m in current_metrics)
total_q5 = sum(m['q5'] or 0 for m in current_metrics)
total_q4 = sum(m['q4'] or 0 for m in current_metrics)
# Detail par source
for m in current_metrics:
source_name = m['source'].replace('canalturf_', '').replace('_', ' ').title()
# Comparaison avec semaine precedente
prev = prev_dict.get(m['source'])
diff_g = 0
diff_p = 0
if prev:
diff_g = m['taux_g'] - prev['taux_g']
diff_p = m['taux_p'] - prev['taux_p']
arrow_g = 'hausse' if diff_g > 0 else 'baisse' if diff_g < 0 else 'stable'
arrow_p = 'hausse' if diff_p > 0 else 'baisse' if diff_p < 0 else 'stable'
message += "{}\n Courses: {}\n Taux gagnant: {}% ({})\n Taux place: {}% ({})\n ROI SG: {:+.2f}\n ROI SP: {:+.2f}\n\n".format(
source_name,
m['nb_courses'],
m['taux_g'], arrow_g,
m['taux_p'], arrow_p,
m['roi_sg_cumul'],
m['roi_sp_cumul']
)
# Bilan global
bilan = "Semaine positive" if total_roi_sg > 0 else "Semaine negative"
message += "{}\nQuinte: 5/5 = {} courses | 4/5 = {} courses\n\nBilan global:\n ROI SG cumule: {:+.2f}\n ROI SP cumule: {:+.2f}\n\n{}\n{}".format(
"=" * 30,
total_q5, total_q4,
total_roi_sg, total_roi_sp,
bilan, "=" * 30
)
conn.close()
return message, total_roi_sg
# =============================================================================
# POINT D'ENTREE
# =============================================================================
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Alertes metriques de performance")
parser.add_argument("--daily", "-d", action="store_true", help="Alerte quotidienne")
parser.add_argument("--weekly", "-w", action="store_true", help="Rapport hebdomadaire")
parser.add_argument("--test", "-t", action="store_true", help="Test formatage")
parser.add_argument(
"--email", "-e", action="store_true",
help="Envoyer par email (auto si ROI > {:.1f}€)".format(ROI_ALERT_THRESHOLD)
)
parser.add_argument("--date", help="Date YYYY-MM-DD (defaut: hier)")
args = parser.parse_args()
if args.test:
date_str = args.date or (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
result = check_daily_alerts(date_str)
if result:
msg, has_roi = result
print(msg)
if args.email:
print("\n--- Test envoi email ---")
send_email_alert("[TEST] Alerte Performance Turf — {}".format(date_str), msg)
result_w = generate_weekly_report()
if result_w:
msg_w, roi_w = result_w
print(msg_w)
elif args.daily:
date_str = args.date or (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
result = check_daily_alerts(date_str)
if result:
msg, has_roi = result
filename = "telegram_metrics_daily_{}.txt".format(date_str.replace('-', ''))
save_telegram_message(msg, filename)
# Envoi email si ROI > seuil (automatique) OU si flag --email passe
if args.email or has_roi:
date_fmt = datetime.strptime(date_str, '%Y-%m-%d').strftime('%d/%m/%Y')
subject = "Alerte Turf — ROI exceptionnel {}".format(date_fmt)
sent = send_email_alert(subject, msg)
if not sent:
print("Email non envoye (voir logs ci-dessus)")
elif args.weekly:
result = generate_weekly_report()
if result:
msg, total_roi = result
filename = "telegram_metrics_weekly_{}.txt".format(datetime.now().strftime('%Y%m%d'))
save_telegram_message(msg, filename)
# Envoi email du rapport hebdo si --email active
if args.email:
start_fmt = (datetime.now() - timedelta(days=7)).strftime('%d/%m')
end_fmt = datetime.now().strftime('%d/%m')
roi_status = "positif" if total_roi > 0 else "negatif"
subject = "Rapport Hebdo Turf {}-{} — ROI {} ({:+.2f}€)".format(
start_fmt, end_fmt, roi_status, total_roi
)
sent = send_email_alert(subject, msg)
if not sent:
print("Email non envoye (voir logs ci-dessus)")
else:
parser.print_help()

44
mobile_ideas.html Executable file
View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width">
<title>💡 Idées</title>
<style>
body { font-family: sans-serif; padding: 15px; background: #1a1a2e; color: #fff; }
h1 { color: #2ec4b6; }
.card { background: #16213e; padding: 15px; margin: 10px 0; border-radius: 8px; }
.error { background: #e94560; padding: 15px; }
pre { background: #333; padding: 10px; overflow: auto; }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<h1>💡 Mes Idées</h1>
<div id="status">Chargement...</div>
<div id="result"></div>
<script>
var req = new XMLHttpRequest();
req.open('GET', '/api/ideas', true);
req.onload = function() {
document.getElementById('status').innerText = 'Status: ' + req.status;
if (req.status === 200) {
var data = JSON.parse(req.responseText);
var html = '<p>✅ ' + data.ideas.length + ' idées trouvées</p>';
data.ideas.forEach(function(i) {
html += '<div class="card"><b>' + i.title + '</b><br>📌 ' + i.status + '</div>';
});
document.getElementById('result').innerHTML = html;
}
};
req.onerror = function() {
document.getElementById('status').innerHTML = '<div class="error">Erreur réseau!</div>';
};
req.send();
</script>
</body>
</html>

221
model_optimizer.py Normal file
View File

@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""
Optimisation du modèle de scoring turf
Méthodes pour affiner le modèle avec précision
"""
import sqlite3
import numpy as np
from datetime import datetime, timedelta
from collections import defaultdict
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
def analyze_scoring_accuracy():
"""Analyse la précision actuelle du scoring"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
# Get all results with scoring
c.execute("""
SELECT
r.date,
r.race_name,
r.horse_name as actual_horse,
r.position as actual_position,
s.horse_name as scored_horse,
s.score as scoring_score,
s.rang_scoring as scoring_rank
FROM results r
JOIN scoring s ON r.date = s.date AND r.race_name = s.race_name AND r.horse_name = s.horse_name
WHERE s.scoring_version = 'v2'
ORDER BY r.date, r.race_name
""")
# Calculate hit rates
races = defaultdict(list)
for row in c.fetchall():
races[(row['date'], row['race_name'])].append({
'horse': row['actual_horse'],
'position': row['actual_position'],
'scored_horse': row['scored_horse'],
'score': row['scoring_score'],
'rank': row['scoring_rank']
})
# Calculate metrics
total_races = len(races)
top1_hits = 0
top3_hits = 0
top5_hits = 0
for race_key, horses in races.items():
actual_top1 = [h['horse'] for h in horses if h['position'] == 1]
actual_top3 = [h['horse'] for h in horses if h['position'] <= 3]
actual_top5 = [h['horse'] for h in horses if h['position'] <= 5]
pred_top1 = [h['scored_horse'] for h in horses if h['rank'] == 1]
pred_top3 = [h['scored_horse'] for h in horses if h['rank'] <= 3]
pred_top5 = [h['scored_horse'] for h in horses if h['rank'] <= 5]
if any(p == actual_top1[0] for p in pred_top1 if actual_top1):
top1_hits += 1
if any(p in actual_top3 for p in pred_top3):
top3_hits += 1
if any(p in actual_top5 for p in pred_top5):
top5_hits += 1
conn.close()
print("="*60)
print("ANALYSE PRÉCISION SCORING V2")
print("="*60)
print(f"Total courses analysées: {total_races}")
print(f"Top 1 hit rate: {top1_hits}/{total_races} = {top1_hits*100/total_races:.1f}%")
print(f"Top 3 hit rate: {top3_hits}/{total_races} = {top3_hits*100/total_races:.1f}%")
print(f"Top 5 hit rate: {top5_hits}/{total_races} = {top5_hits*100/total_races:.1f}%")
return races
def analyze_feature_importance():
"""Analyse l'importance des features dans le scoring"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# Get scoring components
c.execute("""
SELECT
AVG(score_cote) as avg_cote,
AVG(score_forme) as avg_forme,
AVG(score_victoire) as avg_victoire,
AVG(score_place) as avg_place,
AVG(score_avis) as avg_avis
FROM scoring
WHERE scoring_version = 'v2'
""")
row = c.fetchone()
print("\n" + "="*60)
print("IMPORTANCE DES COMPOSANTES DU SCORE")
print("="*60)
print(f"Score Côte (odds): {row[0]:.2f}")
print(f"Score Forme: {row[1]:.2f}")
print(f"Score Victoire: {row[2]:.2f}")
print(f"Score Place: {row[3]:.2f}")
print(f"Score Avis: {row[4]:.2f}")
conn.close()
def identify_patterns():
"""Identifie les patterns qui améliorent les prédictions"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
print("\n" + "="*60)
print("PATTERNS IDENTIFIÉS")
print("="*60)
# Position vs Score - correlation
c.execute("""
SELECT
AVG(s.score) as avg_score,
r.position
FROM scoring s
JOIN results r ON s.date = r.date AND s.race_name = r.race_name AND s.horse_name = r.horse_name
WHERE s.scoring_version = 'v2'
GROUP BY r.position
ORDER BY r.position
""")
print("\n1. Score moyen par position:")
for row in c.fetchall():
print(f" Position {row[1]}: Score avg = {row[0]:.1f}")
# Côte vs Position - les faibles cotes gagnent plus souvent?
c.execute("""
SELECT
CASE
WHEN p.odds < 5 THEN 'Favori (<5)'
WHEN p.odds < 10 THEN 'Coef 5-10'
WHEN p.odds < 20 THEN 'Coef 10-20'
ELSE 'Outsider (>20)'
END as category,
COUNT(*) as total,
SUM(CASE WHEN r.position <= 3 THEN 1 ELSE 0 END) as top3,
ROUND(CAST(SUM(CASE WHEN r.position <= 3 THEN 1 ELSE 0 END) AS FLOAT) / COUNT(*) * 100, 1) as pct
FROM predictions p
JOIN results r ON p.date = r.date AND p.race_name = r.race_name AND p.horse_name = r.horse_name
WHERE p.source = 'canalturf_partants'
GROUP BY category
ORDER BY pct DESC
""")
print("\n2. Taux de Top3 par catégorie de cote:")
for row in c.fetchall():
print(f" {row[0]}: {row[3]}% ({row[2]}/{row[1]})")
# Forme du cheval (musique) - les récents gains comptent?
c.execute("""
SELECT
s.forme_recente,
COUNT(*) as total,
SUM(CASE WHEN r.position <= 3 THEN 1 ELSE 0 END) as top3,
ROUND(CAST(SUM(CASE WHEN r.position <= 3 THEN 1 ELSE 0 END) AS FLOAT) / COUNT(*) * 100, 1) as pct
FROM scoring s
JOIN results r ON s.date = r.date AND s.race_name = r.race_name AND s.horse_name = r.horse_name
WHERE s.scoring_version = 'v2' AND s.forme_recente IS NOT NULL
GROUP BY s.forme_recente
HAVING total > 5
ORDER BY pct DESC
LIMIT 10
""")
print("\n3. Forme récente (musique) vs réussite:")
for row in c.fetchall():
print(f" {row[0]}: {row[3]}% ({row[2]}/{row[1]})")
conn.close()
def suggest_improvements():
"""Suggère des améliorations basées sur l'analyse"""
print("\n" + "="*60)
print("AMÉLORATIONS SUGGÉRÉES")
print("="*60)
suggestions = """
1. FEATURE ENGINEERING
- Ajouter: forme sur 5 dernières courses (poids + élevé)
- Ajouter: performance sur même distance/hippodrome
- Ajouter: historique jockey/cheval ensemble
2. POIDS DES COMPOSANTES
- Réduire le poids du score Côte (peu prédictif)
- Augmenter le poids de la forme récente
- Ajouter un component "tendance" (évolution cotes)
3. MACHINE LEARNING
- Passer de scoring linéaire à XGBoost/LogisticRegression
- Utiliser cross-validation pour éviter overfitting
- Ajouter regularization
4. ENSEMBLE
- Combiner scoring V2 + CanalTurf + un modèle ML
- Pondérer selon historique de précision
5. BACKTESTING
- Tester sur 30+ jours de données
- Calculer ROI théorique
- Ajuster seuils de sélection
"""
print(suggestions)
def run_full_analysis():
"""Lance l'analyse complète"""
analyze_scoring_accuracy()
analyze_feature_importance()
identify_patterns()
suggest_improvements()
if __name__ == "__main__":
run_full_analysis()

113
multi_scraper.py Executable file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""
Multi-site turf scraper
Sources: Equidia, ZETurf, Canalturf, Boturfers
"""
import requests
from bs4 import BeautifulSoup
import json
from datetime import datetime
import re
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
def scrape_equidia():
"""Scrape Equidia - résultats détaillés"""
url = "https://www.equidia.fr/courses"
try:
r = requests.get(url, headers=HEADERS, timeout=10)
soup = BeautifulSoup(r.text, 'html.parser')
courses = []
for link in soup.select('a[href*="/courses/2026-"]'):
href = link.get('href', '')
if 'R1C' in href:
courses.append(f"https://www.equidia.fr{href}")
return list(set(courses))[:5]
except Exception as e:
print(f"Equidia error: {e}")
return []
def scrape_zeturf():
"""Scrape ZETurf - cotes"""
url = "https://www.zeturf.fr/fr/courses-du-jour"
try:
r = requests.get(url, headers=HEADERS, timeout=10)
soup = BeautifulSoup(r.text, 'html.parser')
courses = []
for link in soup.select('a[href*="/course-du-jour/"]'):
href = link.get('href', '')
courses.append(f"https://www.zeturf.fr{href}")
return list(set(courses))[:5]
except Exception as e:
print(f"ZETurf error: {e}")
return []
def scrape_canalturf():
"""Scrape Canalturf - pronostics"""
url = "https://www.canalturf.com/courses_chevaux_jour.php"
try:
r = requests.get(url, headers=HEADERS, timeout=10)
soup = BeautifulSoup(r.text, 'html.parser')
links = []
for link in soup.select('a'):
href = link.get('href', '')
if 'quinte' in href.lower():
links.append(f"https://www.canalturf.com{href}")
return list(set(links))[:5]
except Exception as e:
print(f"Canalturf error: {e}")
return []
def scrape_boturfers():
"""Scrape Boturfers - pronostics + stats"""
url = "https://www.boturfers.fr"
try:
r = requests.get(url, headers=HEADERS, timeout=10)
soup = BeautifulSoup(r.text, 'html.parser')
courses = []
for link in soup.select('a[href*="/quinte"]'):
href = link.get('href', '')
courses.append(f"https://www.boturfers.fr{href}")
return list(set(courses))[:5]
except Exception as e:
print(f"Boturfers error: {e}")
return []
def main():
print(f"=== Turf Multi-Scraper {datetime.now().strftime('%Y-%m-%d %H:%M')} ===")
print("\n[1/4] Scraping Equidia...")
equidia_courses = scrape_equidia()
print(f" Found {len(equidia_courses)} courses")
print("\n[2/4] Scraping ZETurf...")
zeturf_courses = scrape_zeturf()
print(f" Found {len(zeturf_courses)} courses")
print("\n[3/4] Scraping Canalturf...")
canalturf_courses = scrape_canalturf()
print(f" Found {len(canalturf_courses)} courses")
print("\n[4/4] Scraping Boturfers...")
boturfers_courses = scrape_boturfers()
print(f" Found {len(boturfers_courses)} courses")
data = {
'timestamp': datetime.now().isoformat(),
'equidia': equidia_courses,
'zeturf': zeturf_courses,
'canalturf': canalturf_courses,
'boturfers': boturfers_courses
}
output_file = f"/home/h3r7/turf_scraper/courses_{datetime.now().strftime('%Y%m%d_%H%M')}.json"
with open(output_file, 'w') as f:
json.dump(data, f, indent=2)
print(f"\n✅ Saved to {output_file}")
return data
if __name__ == "__main__":
main()

699
multi_scraper_v5.py Executable file
View File

@@ -0,0 +1,699 @@
#!/usr/bin/env python3
"""
Turf Scraper v5 - REALTIME DATABASE SAVING
Saves predictions immediately as they're scraped
Parser robuste intégré : canalturf (partants + pronostic + sélections), boturfers (infos course)
"""
import requests
from bs4 import BeautifulSoup
import json
from datetime import datetime
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading
import sqlite3
import re
import os
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8',
}
lock = threading.Lock()
counter = {"total": 0, "done": 0}
# ============== DATABASE FUNCTIONS ==============
def init_db():
"""Initialize database"""
conn = sqlite3.connect(DB_PATH)
conn = sqlite3.connect(DB_PATH)
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS predictions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
race_name TEXT,
race_hippodrome TEXT,
race_time TEXT,
horse_number INTEGER,
horse_name TEXT,
odds REAL,
prediction_rank INTEGER,
source TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
jockey TEXT,
odds_time TEXT,
odds_prev REAL
)
''')
# Ajouter les colonnes jockey/odds_time si elles n'existent pas (migration)
for col, coltype in [("jockey", "TEXT"), ("odds_time", "TEXT"), ("odds_prev", "REAL")]:
try:
c.execute(f"ALTER TABLE predictions ADD COLUMN {col} {coltype}")
except sqlite3.OperationalError:
pass # Colonne déjà présente
c.execute('''
CREATE TABLE IF NOT EXISTS results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
race_name TEXT,
race_hippodrome TEXT,
position INTEGER,
horse_name TEXT,
odds REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
c.execute('''
CREATE TABLE IF NOT EXISTS performance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
prediction_date TEXT,
race_date TEXT,
horse_name TEXT,
predicted_rank INTEGER,
actual_position INTEGER,
hit BOOLEAN,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Table odds_history : historique des cotes intraday
c.execute('''
CREATE TABLE IF NOT EXISTS odds_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
race_name TEXT,
race_hippodrome TEXT,
horse_number INTEGER,
horse_name TEXT,
odds REAL NOT NULL,
scraped_at TEXT NOT NULL,
source TEXT DEFAULT 'canalturf'
)
''')
c.execute('''
CREATE TABLE IF NOT EXISTS race_meta (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
race_name TEXT,
race_hippodrome TEXT,
race_time TEXT,
race_timestamp INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
conn.close()
print(f"✅ DB initialized: {DB_PATH}")
def add_prediction(date, race_name, race_hippodrome, race_time, horse_number, horse_name,
odds, prediction_rank, source, jockey="", odds_time=None):
"""Add a prediction with OR IGNORE to avoid duplicates"""
c = conn.cursor()
c.execute('''
INSERT OR IGNORE INTO predictions
(date, race_name, race_hippodrome, race_time, horse_number, horse_name, odds, prediction_rank, source, jockey, odds_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (date, race_name, race_hippodrome, race_time, horse_number, horse_name,
odds, prediction_rank, source, jockey, odds_time or datetime.now().isoformat()))
c.execute('''
CREATE TABLE IF NOT EXISTS race_meta (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
race_name TEXT,
race_hippodrome TEXT,
race_time TEXT,
race_timestamp INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
conn.close()
def add_result(date, race_name, race_hippodrome, position, horse_name, odds):
"""Add a race result"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''
INSERT INTO results (date, race_name, race_hippodrome, position, horse_name, odds)
VALUES (?, ?, ?, ?, ?, ?)
''', (date, race_name, race_hippodrome, position, horse_name, odds))
c.execute('''
CREATE TABLE IF NOT EXISTS race_meta (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
race_name TEXT,
race_hippodrome TEXT,
race_time TEXT,
race_timestamp INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
conn.close()
# ============== SCRAPER FUNCTIONS ==============
def fetch_url(args):
url, site = args
try:
r = requests.get(url, headers=HEADERS, timeout=12)
soup = BeautifulSoup(r.text, 'html.parser')
for s in soup(["script", "style"]):
s.decompose()
text = soup.get_text(separator='\n', strip=True)[:8000]
with lock:
counter["done"] += 1
pct = (counter["done"] / counter["total"]) * 100
print(f" [{pct:.0f}%] {site}: OK")
return {'url': url, 'site': site, 'content': text, 'status': 'success'}
except Exception as e:
with lock:
counter["done"] += 1
return {'url': url, 'site': site, 'error': str(e), 'status': 'error'}
# ============== PARSERS ROBUSTES ==============
def parse_canalturf_quinte(content):
"""
Extrait depuis courses_quinte.php :
- Infos course (nom, hippodrome, heure, distance, allocation)
- Liste des partants (numéro, cheval, jockey, cote)
- Pronostic structuré (bases, chances régulières, outsiders)
"""
result = {
"course": {},
"partants": [],
"pronostic": {"bases": [], "chances": [], "outsiders": []}
}
lines = [l.strip() for l in content.split('\n') if l.strip()]
# Nom de la course
for line in lines:
if re.search(r'^PRIX\s+[A-Z]', line):
result["course"]["nom"] = line.strip()
break
# Hippodrome
m = re.search(r'hippodrome de\s+([A-Z\-]+)', content, re.IGNORECASE)
if m:
result["course"]["hippodrome"] = m.group(1).strip()
# Heure
m = re.search(r'(\d{1,2}:\d{2})', content)
if m:
result["course"]["heure"] = m.group(1)
# Distance
m = re.search(r'(\d{3,4})m', content)
if m:
result["course"]["distance"] = int(m.group(1))
# Type de course
for t in ['TROT ATTELE', 'TROT MONTE', 'PLAT', 'OBSTACLE', 'HAIES', 'STEEPLE']:
if t in content.upper():
result["course"]["type"] = t
break
# Partants : on cherche des blocs numéro / NOM / Jockey / cote
# On s'arrête dès qu'on a trouvé la section "Liste des partants" pour éviter
# de parser aussi le bloc pronostic qui contient les mêmes noms sans cote
liste_idx = content.find("Liste des partants")
prono_idx = content.find("Le pronostic du Quinté+")
partants_zone = content[liste_idx:prono_idx] if liste_idx != -1 and prono_idx != -1 else content
lines_partants = [l.strip() for l in partants_zone.split('\n') if l.strip()]
seen_nums = set()
i = 0
while i < len(lines_partants):
if re.match(r'^\d{1,2}$', lines_partants[i]):
num = int(lines_partants[i])
if 1 <= num <= 20 and num not in seen_nums and i + 2 < len(lines_partants):
nom_cheval = lines_partants[i + 1]
jockey = lines_partants[i + 2]
cote = None
if i + 3 < len(lines_partants) and re.match(r'[\d\.]+/\d', lines_partants[i + 3]):
try:
cote = float(lines_partants[i + 3].split('/')[0])
except:
pass
i += 4
else:
i += 3
# Valider que le nom est bien en majuscules
if re.search(r'[A-Z]{3,}', nom_cheval) and re.search(r'[A-Z]', jockey):
seen_nums.add(num)
result["partants"].append({
"numero": num,
"cheval": nom_cheval.strip(),
"jockey": jockey.strip(),
"cote": cote
})
continue
i += 1
# Pronostic : extraire uniquement les chevaux dans la section dédiée
# On délimite chaque section entre son mot-clé et le suivant
section_keywords = ["Base(s)", "Chance(s) régulière(s)", "Outsider(s)", "Le cheval du Quinté+"]
def extract_horses_between(start_kw, end_kws):
horses = []
idx_start = content.find(start_kw)
if idx_start == -1:
return horses
idx_end = len(content)
for kw in end_kws:
idx = content.find(kw, idx_start + len(start_kw))
if idx != -1 and idx < idx_end:
idx_end = idx
snippet = content[idx_start:idx_end]
for m in re.finditer(r'(\d{1,2})\s+([A-Z][A-Z\s\-\']+?)\s*\(', snippet):
try:
horses.append({"numero": int(m.group(1)), "cheval": m.group(2).strip()})
except:
pass
return horses
result["pronostic"]["bases"] = extract_horses_between("Base(s)", ["Chance(s) régulière(s)", "Outsider(s)", "Le cheval"])
result["pronostic"]["chances"] = extract_horses_between("Chance(s) régulière(s)", ["Outsider(s)", "Le cheval"])
result["pronostic"]["outsiders"] = extract_horses_between("Outsider(s)", ["Le cheval", "Partants détaillés"])
return result
def parse_canalturf_selections(content):
"""
Extrait depuis courses_chevaux_jour.php :
Sélections gagnantes/placées par course (hippodrome, heure, cheval, jockey, cote PMU)
"""
selections = []
today = datetime.now().strftime('%Y-%m-%d')
for m in re.finditer(
r'C(\d+)\s*[-]\s*(PRIX[^(]+)\((\d{1,2}:\d{2})\)\s*'
r'(\d{1,2})\s*[-]\s*([A-Z][A-Z\s\'\-]+?)\s*\(([^)]+)\)',
content
):
race_name = m.group(2).strip()
race_time = m.group(3)
horse_num = int(m.group(4))
horse_name = m.group(5).strip()
jockey = m.group(6).strip()
after = content[m.end():m.end() + 100]
cote_m = re.search(r'(\d+\.?\d*)\s*PMU', after)
cote = float(cote_m.group(1)) if cote_m else 0.0
selections.append({
"date": today,
"race_name": race_name,
"race_time": race_time,
"horse_number": horse_num,
"horse_name": horse_name,
"jockey": jockey,
"cote_pmu": cote,
})
return selections
def parse_boturfers_quinte(content):
"""
Extrait depuis boturfers.fr/quinte-du-jour :
Infos course (nb partants, distance, météo, probabilités)
"""
info = {}
m = re.search(r'(\d+)\s*partants', content)
if m:
info["nb_partants"] = int(m.group(1))
m = re.search(r'(\d+)°C', content)
if m:
info["temperature"] = int(m.group(1))
probs = re.findall(r'(\d+)%\s*\nen (\d+) cheval', content)
if probs:
info["probabilites"] = {f"top{p[1]}": int(p[0]) for p in probs}
return info
def save_parsed_data(quinte_data, selections, today):
"""Sauvegarde en BDD toutes les données parsées"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
now = datetime.now().isoformat()
saved = 0
course = quinte_data.get("course", {})
race_name = course.get("nom", "Quinté+")
hippodrome = course.get("hippodrome", "")
race_time = course.get("heure", "13:55")
# 1. Partants avec cotes
for p in quinte_data.get("partants", []):
try:
c.execute('''
INSERT OR IGNORE INTO predictions
(date, race_name, race_hippodrome, race_time, horse_number, horse_name,
odds, prediction_rank, source, jockey, odds_time)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)
''', (today, race_name, hippodrome, race_time,
p["numero"], p["cheval"], p.get("cote") or 0,
"canalturf_partants", p.get("jockey", ""), now))
saved += c.rowcount
except Exception as e:
print(f" ⚠️ Partant {p['cheval']}: {e}")
# 2. Pronostic (bases=1, chances=2, outsiders=3)
for category, rank in [("bases", 1), ("chances", 2), ("outsiders", 3)]:
for horse in quinte_data.get("pronostic", {}).get(category, []):
try:
c.execute('''
INSERT OR IGNORE INTO predictions
(date, race_name, race_hippodrome, race_time, horse_number, horse_name,
odds, prediction_rank, source, odds_time)
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)
''', (today, race_name, hippodrome, race_time,
horse["numero"], horse["cheval"], rank,
f"canalturf_prono_{category}", now))
saved += c.rowcount
except Exception as e:
print(f" ⚠️ Prono {horse['cheval']}: {e}")
# 3. Sélections autres courses
for sel in selections:
try:
c.execute('''
INSERT OR IGNORE INTO predictions
(date, race_name, race_hippodrome, race_time, horse_number, horse_name,
odds, prediction_rank, source, jockey, odds_time)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)
''', (sel["date"], sel["race_name"], hippodrome, sel["race_time"],
sel["horse_number"], sel["horse_name"], sel.get("cote_pmu") or 0,
"canalturf_selections", sel.get("jockey", ""), now))
saved += c.rowcount
except Exception as e:
print(f" ⚠️ Sélection {sel['horse_name']}: {e}")
c.execute('''
CREATE TABLE IF NOT EXISTS race_meta (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
race_name TEXT,
race_hippodrome TEXT,
race_time TEXT,
race_timestamp INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
c.execute('SELECT COUNT(*) FROM predictions WHERE date = ?', (today,))
total_today = c.fetchone()[0]
conn.close()
return saved, total_today
def save_race_meta(quinte_data, today):
"""Sauvegarde l'heure de la course (HH:MM + timestamp) dans race_meta."""
course = quinte_data.get("course", {})
race_name = course.get("nom", "Quinté+")
hippodrome = course.get("hippodrome", "")
race_time = course.get("heure", "13:55")
# Convertir HH:MM en timestamp du jour
try:
dt = datetime.strptime(f"{today} {race_time}", "%Y-%m-%d %H:%M")
ts = int(dt.timestamp())
except:
ts = None
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''
INSERT INTO race_meta (date, race_name, race_hippodrome, race_time, race_timestamp)
VALUES (?, ?, ?, ?, ?)
''', (today, race_name, hippodrome, race_time, ts))
conn.commit()
conn.close()
print(f"🕒 Heure course sauvegardée : {race_time} (ts={ts})")
def save_odds_history(quinte_data, today):
"""
Sauvegarde un snapshot des cotes dans odds_history à chaque run.
Permet de suivre l'évolution des cotes tout au long de la journée.
"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
now = datetime.now().isoformat()
saved = 0
course = quinte_data.get("course", {})
race_name = course.get("nom", "Quinté+")
hippodrome = course.get("hippodrome", "")
for p in quinte_data.get("partants", []):
cote = p.get("cote")
if not cote or cote <= 0:
continue
c.execute('''
INSERT INTO odds_history
(date, race_name, race_hippodrome, horse_number, horse_name, odds, scraped_at, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (today, race_name, hippodrome,
p["numero"], p["cheval"], cote, now, "canalturf"))
saved += c.rowcount
c.execute('''
CREATE TABLE IF NOT EXISTS race_meta (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
race_name TEXT,
race_hippodrome TEXT,
race_time TEXT,
race_timestamp INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
conn.close()
return saved
def print_odds_evolution(today):
"""
Affiche l'évolution des cotes depuis le début de la journée.
Compare le premier snapshot du matin avec le snapshot actuel.
"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# Récupérer tous les snapshots du jour
c.execute('''
SELECT horse_name, odds, scraped_at
FROM odds_history
WHERE date = ?
ORDER BY horse_name, scraped_at ASC
''', (today,))
rows = c.fetchall()
conn.close()
if not rows:
return
# Grouper par cheval
horses = {}
for horse, odds, ts in rows:
if horse not in horses:
horses[horse] = []
horses[horse].append((odds, ts))
# Afficher l'évolution
print(f"\n📈 ÉVOLUTION DES COTES — {today}")
print(f"{'-'*60}")
print(f" {'CHEVAL':<25} {'MATIN':<8} {'ACTUEL':<8} {'ÉVOL':<8} TENDANCE")
print(f"{'-'*60}")
evolutions = []
for horse, snapshots in horses.items():
if len(snapshots) < 1:
continue
cote_debut = snapshots[0][0]
cote_actuel = snapshots[-1][0]
nb_snapshots = len(snapshots)
if cote_debut > 0:
evol_pct = ((cote_actuel - cote_debut) / cote_debut) * 100
else:
evol_pct = 0
evolutions.append((horse, cote_debut, cote_actuel, evol_pct, nb_snapshots))
# Trier par cote actuelle
for horse, debut, actuel, evol, nb in sorted(evolutions, key=lambda x: x[2]):
if evol < -5:
tendance = "📉 BAISSE"
elif evol > 5:
tendance = "📈 HAUSSE"
else:
tendance = "➡️ STABLE"
evol_str = f"{evol:+.0f}%" if nb > 1 else "1er snap"
print(f" {horse:<25} {debut:<8} {actuel:<8} {evol_str:<8} {tendance}")
print(f"{'-'*60}")
print(f" ({len(evolutions)} chevaux, {rows[0][2][:16] if rows else '?'} → maintenant)")
# ============== URL LIST ==============
def get_urls():
"""ALL 7 WORKING SITES"""
sites = {
'equidia': ['https://www.equidia.fr/courses', 'https://www.equidia.fr/courses/2026-02-24'],
'zeturf': ['https://www.zeturf.fr/fr/courses-du-jour', 'https://www.zeturf.fr/en'],
'canalturf': ['https://www.canalturf.com/courses_chevaux_jour.php', 'https://www.canalturf.com/courses_quinte.php'],
'boturfers': ['https://www.boturfers.fr', 'https://www.boturfers.fr/quinte-du-jour', 'https://www.boturfers.fr/quinte-de-demain'],
'zone-turf': ['https://www.zone-turf.fr', 'https://www.zone-turf.fr/programmes/'],
'genybet': ['https://www.genybet.fr', 'https://www.genybet.fr/courses/'],
'ruedesjoueurs': ['https://www.ruedesjoueurs.com/turf.html', 'https://www.ruedesjoueurs.com/turf/pronostics.html']
}
urls = []
for site, pages in sites.items():
for url in pages:
urls.append((url, site))
return urls
# ============== MAIN ==============
def main():
start = time.time()
print(f"\n{'='*50}")
print(f"🐾 TURF SCRAPER v5 - REALTIME SAVING")
print(f"{'='*50}\n")
init_db()
urls = get_urls()
counter["total"] = len(urls)
print(f"📡 Fetching {len(urls)} pages...\n")
results = []
with ThreadPoolExecutor(max_workers=10) as executor:
futures = {executor.submit(fetch_url, u): u for u in urls}
for future in as_completed(futures):
results.append(future.result())
elapsed = time.time() - start
today = datetime.now().strftime('%Y-%m-%d')
print(f"\n📊 Parsing predictions...")
quinte_data = {"course": {}, "partants": [], "pronostic": {}}
selections = []
boturfers_info = {}
for r in results:
if r['status'] != 'success':
continue
site = r['site']
url = r['url']
content = r['content']
if site == 'canalturf':
if 'quinte' in url:
quinte_data = parse_canalturf_quinte(content)
nb_p = len(quinte_data['partants'])
nb_b = len(quinte_data['pronostic'].get('bases', []))
print(f" canalturf quinté : {nb_p} partants, {nb_b} base(s) trouvé(s)")
else:
selections = parse_canalturf_selections(content)
print(f" canalturf sélections : {len(selections)} course(s)")
elif site == 'boturfers' and 'quinte-du-jour' in url:
boturfers_info = parse_boturfers_quinte(content)
temp = boturfers_info.get('temperature', '?')
print(f" boturfers : {boturfers_info.get('nb_partants', '?')} partants, {temp}°C")
# Sauvegarde BDD
saved, total_today = save_parsed_data(quinte_data, selections, today)
print(f"\n💾 {saved} nouvelles entrées insérées en BDD")
# Snapshot cotes dans odds_history
odds_saved = save_odds_history(quinte_data, today)
print(f"📊 {odds_saved} cotes sauvegardées dans odds_history")
# Afficher l'évolution des cotes
print_odds_evolution(today)
# Affichage résumé Quinté+
if quinte_data["partants"]:
course = quinte_data["course"]
print(f"\n{'='*55}")
print(f"🏇 {course.get('nom', 'Quinté+')}{course.get('hippodrome', '')} {course.get('heure', '')} ({course.get('distance', '')}m)")
print(f"{''*55}")
print(f" {'':<4} {'CHEVAL':<25} {'JOCKEY':<20} COTE")
print(f"{''*55}")
for p in sorted(quinte_data["partants"], key=lambda x: x.get("cote") or 999):
cote_str = str(p['cote']) if p['cote'] else "?"
print(f" {p['numero']:<4} {p['cheval']:<25} {p['jockey']:<20} {cote_str}")
bases = [h['cheval'] for h in quinte_data['pronostic'].get('bases', [])]
if bases:
print(f"\n ⭐ Bases : {', '.join(bases)}")
chances = [h['cheval'] for h in quinte_data['pronostic'].get('chances', [])]
if chances:
print(f" 🎯 Chances : {', '.join(chances)}")
outsiders = [h['cheval'] for h in quinte_data['pronostic'].get('outsiders', [])]
if outsiders:
print(f" 🔍 Outsiders : {', '.join(outsiders)}")
print(f"{'='*55}")
# Stats par site
by_site = {}
for r in results:
s = r['site']
by_site[s] = by_site.get(s, 0) + (1 if r['status'] == 'success' else 0)
print(f"\n📊 STATS:")
for site, count in by_site.items():
print(f" {site}: {count} pages")
# Sauvegarde JSON
output = f"{os.environ.get('TURF_DIR', '/home/h3r7/turf_scraper')}/v5_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
with open(output, 'w', encoding='utf-8') as f:
json.dump({
'timestamp': datetime.now().isoformat(),
'runtime_sec': round(elapsed, 2),
'total_pages': len(urls),
'pages': results
}, f, indent=2, ensure_ascii=False)
print(f"\n{'='*50}")
print(f"✅ DONE! {len(results)} pages in {elapsed:.1f}s")
print(f"💾 {total_today} prédictions en BDD pour aujourd'hui")
print(f"📁 {output}")
print(f"{'='*50}\n")
if __name__ == "__main__":
main()

281
n8n-chat.html Normal file
View File

@@ -0,0 +1,281 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>N8N Workflow Chat - H3R7Tech</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Syne:wght@400;700;800&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0d0f1a;
--bg2: #13162a;
--bg3: #1a1f38;
--accent: #e94560;
--cyan: #00d9ff;
--green: #2ec4b6;
--yellow: #f4c542;
--muted: #4a5080;
--text: #e8eaf6;
--text2: #8892b0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Syne', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
.header {
background: linear-gradient(135deg, #0d0f1a 0%, #1a1f38 100%);
border-bottom: 1px solid var(--muted);
padding: 16px 24px;
display: flex;
align-items: center;
gap: 16px;
}
.header h1 { font-size: 20px; font-weight: 800; color: var(--cyan); }
.header .status { margin-left: auto; font-family: 'Space Mono', monospace; font-size: 12px; color: var(--text2); }
.status-dot {
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
background: var(--accent); margin-right: 6px;
}
.status-dot.connected { background: var(--green); }
.content { max-width: 800px; margin: 0 auto; padding: 20px; }
.card {
background: var(--bg2);
border: 1px solid var(--bg3);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
.chat-container {
height: 400px;
overflow-y: auto;
background: var(--bg);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.message {
margin-bottom: 12px;
padding: 12px 16px;
border-radius: 12px;
max-width: 85%;
}
.message.user {
background: rgba(0, 217, 255, 0.15);
border: 1px solid rgba(0, 217, 255, 0.3);
margin-left: auto;
}
.message.system {
background: rgba(46, 196, 182, 0.15);
border: 1px solid rgba(46, 196, 182, 0.3);
}
.message.error {
background: rgba(233, 69, 96, 0.15);
border: 1px solid rgba(233, 69, 96, 0.3);
color: var(--accent);
}
.message .role {
font-size: 10px; font-weight: 700; text-transform: uppercase;
color: var(--text2); margin-bottom: 4px;
}
.message .content { padding: 0; max-width: 100%; }
.input-row {
display: flex;
gap: 12px;
}
.input-row input {
flex: 1;
padding: 14px 18px;
border-radius: 8px;
border: 1px solid var(--muted);
background: var(--bg);
color: var(--text);
font-family: 'Syne', sans-serif;
font-size: 14px;
}
.input-row input:focus {
outline: none;
border-color: var(--cyan);
}
.input-row button {
padding: 14px 24px;
border-radius: 8px;
border: none;
background: var(--cyan);
color: #000;
font-weight: 700;
cursor: pointer;
font-family: 'Syne', sans-serif;
transition: all .2s;
}
.input-row button:hover { transform: translateY(-2px); }
.input-row button:disabled { background: var(--muted); cursor: not-allowed; }
.loader {
display: flex; align-items: center; gap: 8px; padding: 20px;
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--cyan); animation: bounce .8s infinite; }
.dot:nth-child(2) { animation-delay: .15s; }
.dot:nth-child(3) { animation-delay: .3s; }
@keyframes bounce {
0%,100% { transform: translateY(0); opacity: .4; }
50% { transform: translateY(-8px); opacity: 1; }
}
.quick-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.quick-actions button {
padding: 8px 14px;
border-radius: 20px;
border: 1px solid var(--muted);
background: var(--bg2);
color: var(--text2);
font-size: 12px;
cursor: pointer;
transition: all .2s;
}
.quick-actions button:hover {
border-color: var(--cyan);
color: var(--cyan);
}
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<div class="header">
<h1>🤖 N8N Workflow Chat</h1>
<div class="status">
<span class="status-dot" id="status-dot"></span>
<span id="status-text">Connecting...</span>
</div>
</div>
<div class="content">
<div class="card">
<div class="quick-actions">
<button onclick="sendQuick('Quelle est la date du jour?')">📅 Date</button>
<button onclick="sendQuick('Liste les 5 derniers workflows actifs')">📋 Workflows</button>
<button onclick="sendQuick('Affiche les statistiques du système')">📊 Stats</button>
<button onclick="sendQuick('Test de connexion')">✅ Test</button>
</div>
<div class="chat-container" id="chat-container">
<div class="message system">
<div class="role">Système</div>
<div class="content">👋 Bienvenue! Posez vos questions au workflow n8n.</div>
</div>
</div>
<div class="input-row">
<input type="text" id="message-input" placeholder="Votre message..."
onkeypress="if(event.key==='Enter')sendMessage()">
<button id="send-btn" onclick="sendMessage()">Envoyer</button>
</div>
</div>
</div>
<script>
const WEBHOOK_URL = '/turf/api/n8n-proxy';
const CHAT_CONTAINER = document.getElementById('chat-container');
const MESSAGE_INPUT = document.getElementById('message-input');
const SEND_BTN = document.getElementById('send-btn');
function addMessage(role, content, isError = false) {
const div = document.createElement('div');
div.className = `message ${role}${isError ? ' error' : ''}`;
div.innerHTML = `<div class="role">${role}</div><div class="content">${content}</div>`;
CHAT_CONTAINER.appendChild(div);
CHAT_CONTAINER.scrollTop = CHAT_CONTAINER.scrollHeight;
}
function showLoader() {
const div = document.createElement('div');
div.id = 'loader';
div.className = 'message system';
div.innerHTML = `<div class="loader"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>`;
CHAT_CONTAINER.appendChild(div);
CHAT_CONTAINER.scrollTop = CHAT_CONTAINER.scrollHeight;
}
function hideLoader() {
const loader = document.getElementById('loader');
if (loader) loader.remove();
}
async function sendQuick(msg) {
MESSAGE_INPUT.value = msg;
sendMessage();
}
async function sendMessage() {
const msg = MESSAGE_INPUT.value.trim();
if (!msg) return;
addMessage('user', msg);
MESSAGE_INPUT.value = '';
SEND_BTN.disabled = true;
showLoader();
try {
const response = await fetch(WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: msg,
timestamp: Date.now(),
source: 'n8n-chat-web'
})
});
hideLoader();
try {
const data = await response.json();
let msg = data.message || 'Message envoyé';
addMessage('system', msg);
} catch(e) {
const text = await response.text();
addMessage('system', text || 'Message envoyé');
}
} catch (e) {
hideLoader();
addMessage('system', `Erreur de connexion: ${e.message}`, true);
}
SEND_BTN.disabled = false;
MESSAGE_INPUT.focus();
}
// Test connection on load
async function testConnection() {
try {
const resp = await fetch(WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: 'ping', timestamp: Date.now() })
});
document.getElementById('status-dot').classList.add('connected');
document.getElementById('status-text').textContent = 'Connecté';
} catch (e) {
document.getElementById('status-text').textContent = 'Erreur: ' + e.message;
}
}
testConnection();
</script>
</body>
</html>

View File

@@ -0,0 +1,114 @@
# Import du workflow n8n OpenClaw Chat
## Fichier à importer
`/home/h3r7/turf_scraper/n8n-openclaw-workflow.json`
## Instructions d'import
### Méthode 1 : Via l'interface n8n
1. **Accède à n8n**
- URL : `https://kolifee.duckdns.org`
- Login avec tes identifiants
2. **Importe le workflow**
- Clique sur le bouton **"+"** (Nouveau workflow) en haut à droite
- Ou va dans **Workflows****Import from File**
- Sélectionne le fichier `n8n-openclaw-workflow.json`
3. **Active le workflow**
- Clique sur le toggle **"Active"** en haut à droite
- Le webhook `/webhook/openclaw` sera automatiquement disponible
### Méthode 2 : Copier-coller le JSON
1. Ouvre n8n : `https://kolifee.duckdns.org`
2. Crée un nouveau workflow
3. Clique sur les **3 points** (⋮) en haut à droite
4. Sélectionne **"Import from URL or File"**
5. Choisis **"Paste JSON"**
6. Copie tout le contenu de `n8n-openclaw-workflow.json`
7. Colle-le et clique **"Import"**
## Structure du workflow
```
┌─────────────────┐
│ Webhook │ ← Reçoit les messages POST /webhook/openclaw
│ (openclaw) │
└────────┬────────┘
┌─────────────────┐
│ Process Query │ ← Traite la question (date, stats, etc.)
│ (Code Node) │
└────────┬────────┘
┌─────────────────┐
│ Respond to │ ← Renvoie la réponse JSON
│ Webhook │
└─────────────────┘
```
## Fonctionnalités incluses
Le workflow répond automatiquement à :
- 📅 **"date"** → Affiche la date et l'heure
- 📋 **"workflow"** → Liste les workflows actifs
- 📊 **"stats"** / **"système"** → Statistiques système
-**"test"** / **"ping"** → Test de connexion
- 💬 **Autre message** → Confirmation de réception
## Test après import
1. Active le workflow
2. Va sur `https://portal-kolifee.duckdns.org/turf/n8n-chat`
3. Envoie un message de test
4. Tu devrais recevoir une réponse formatée
## Alternative : Créer manuellement
Si l'import ne fonctionne pas :
### 1. Webhook
- Type : Webhook
- Method : POST
- Path : `openclaw`
- Response Mode : **"Using 'Respond to Webhook' Node"**
### 2. Code Node
```javascript
const query = $input.item.json.query || "Aucune question";
let response = "";
if (query.toLowerCase().includes('date')) {
response = `📅 ${new Date().toLocaleDateString('fr-FR')}`;
} else if (query.toLowerCase().includes('test')) {
response = "✅ Connexion OK!";
} else {
response = `💬 Message reçu: "${query}"`;
}
return {
json: {
response: response,
timestamp: new Date().toISOString()
}
};
```
### 3. Respond to Webhook
- Respond With : **JSON**
- Response Body : `{{ JSON.stringify({ message: $json.response }) }}`
## Troubleshooting
**Erreur "Unused Respond to Webhook"**
→ Vérifie que le nœud "Respond to Webhook" est bien connecté après le Code node
**Webhook non trouvé**
→ Vérifie que le workflow est **activé** (toggle en haut à droite)
**Pas de réponse**
→ Regarde les logs d'exécution dans n8n (onglet "Executions")

318
organigramme_h3r7tech.html Executable file
View File

@@ -0,0 +1,318 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Organigramme H3R7Tech</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #1a1a2e; color: #eee; padding: 20px; }
header { background: linear-gradient(90deg, #00d9ff, #7b2cbf, #e94560); padding: 30px; text-align: center; margin: -20px -20px 30px; border-radius: 0 0 30px 30px; }
h1 { font-size: 36px; margin-bottom: 10px; }
h2 { color: #00d9ff; margin: 30px 0 15px; padding-bottom: 10px; border-bottom: 2px solid #7b2cbf; }
h3 { color: #000; margin: 20px 0 10px; }
.ceo-box { background: linear-gradient(135deg, #e94560, #7b2cbf); padding: 25px; border-radius: 15px; text-align: center; margin: 20px 0; }
.ceo-name { font-size: 28px; font-weight: bold; }
.org-level { margin: 30px 0; }
.level-title { font-size: 14px; text-transform: uppercase; color: #888; margin-bottom: 15px; letter-spacing: 2px; }
.agents-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; }
.agent-card { background: #16213e; border-radius: 15px; padding: 20px; border-left: 4px solid #00d9ff; }
.agent-card.chief { border-left-color: #e94560; }
.agent-card.scraper { border-left-color: #00ff88; }
.agent-card.sales { border-left-color: #ffd700; }
.agent-card.mailing { border-left-color: #7b2cbf; }
.agent-card.support { border-left-color: #00d9ff; }
.agent-card.analyst { border-left-color: #ff6b6b; }
.agent-icon { font-size: 40px; margin-bottom: 10px; }
.agent-name { font-size: 20px; font-weight: bold; margin-bottom: 5px; }
.agent-role { color: #00d9ff; font-size: 14px; margin-bottom: 15px; }
.missions { background: #0f3460; padding: 15px; border-radius: 10px; }
.missions h4 { color: #000; margin-bottom: 10px; font-size: 14px; }
.missions ul { padding-left: 20px; font-size: 13px; color: #aaa; }
.missions li { margin: 5px 0; }
.tools { margin-top: 15px; font-size: 12px; color: #666; }
.tools span { background: #ff9f1c; padding: 3px 8px; border-radius: 5px; margin-right: 5px; }
.workflow { background: #16213e; padding: 25px; border-radius: 15px; margin: 30px 0; }
.workflow-steps { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; margin-top: 20px; }
.step { background: #0f3460; padding: 15px; border-radius: 10px; text-align: center; flex: 1; min-width: 150px; }
.step-num { background: #00d9ff; color: #000; width: 30px; height: 30px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold; margin-bottom: 10px; }
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin: 20px 0; }
.stat-box { background: #16213e; padding: 20px; border-radius: 12px; text-align: center; }
.stat-num { font-size: 28px; font-weight: bold; color: #00d9ff; }
.stat-label { font-size: 12px; color: #888; margin-top: 5px; }
a { color: #00d9ff; }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<div style="text-align:center;padding:20px;">
<img src="H3R7Tech_logo.png" alt="H3R7Tech" style="width:150px;border-radius:15px;">
</div>
<header>
<h1>🏢 Organigramme H3R7Tech</h1>
<p>Organisation et missions des agents</p>
</header>
<!-- CEO -->
<div class="ceo-box">
<div class="agent-icon">👑</div>
<div class="ceo-name">H3R7</div>
<div>CEO / Fondateur</div>
</div>
<!-- Stats -->
<div class="stats-grid">
<div class="stat-box">
<div class="stat-num">6</div>
<div class="stat-label">Agents</div>
</div>
<div class="stat-box">
<div class="stat-num">5</div>
<div class="stat-label">Tâches/jour</div>
</div>
<div class="stat-box">
<div class="stat-num">24/7</div>
<div class="stat-label">Disponibilité</div>
</div>
<div class="stat-box">
<div class="stat-num">100%</div>
<div class="stat-label">Automatisé</div>
</div>
</div>
<!-- Niveau 1: Chefs -->
<div class="org-level">
<div class="level-title">Niveau 1 - Direction</div>
<div class="agents-grid">
<div class="agent-card chief">
<div class="agent-icon">🤖</div>
<div class="agent-name">AgentChief</div>
<div class="agent-role">Superviseur Global</div>
<div class="missions">
<h4>🎯 Missions</h4>
<ul>
<li>Orchestration des agents</li>
<li>Décisions stratégiques</li>
<li>Rapports quotidiens</li>
<li>Gestion des priorities</li>
</ul>
</div>
<div class="tools">
<span>OpenClaw</span><span>Dashboard</span>
</div>
</div>
</div>
</div>
<!-- Niveau 2: Agents -->
<div class="org-level">
<div class="level-title">Niveau 2 - Agents Opérationnels</div>
<div class="agents-grid">
<div class="agent-card scraper">
<div class="agent-icon">🔍</div>
<div class="agent-name">AgentScraper</div>
<div class="agent-role">Collecte de Données</div>
<div class="missions">
<h4>🎯 Missions</h4>
<ul>
<li>Scraping annuaires pro</li>
<li>Extraction données prospects</li>
<li>Veille concurrentielle</li>
<li>Nettoyage/dédoublonnage</li>
<li>Mise à jour CRM</li>
</ul>
</div>
<div class="tools">
<span>Python</span><span>Scrapy</span><span>CRM</span>
</div>
</div>
<div class="agent-card sales">
<div class="agent-icon">📞</div>
<div class="agent-name">AgentSales</div>
<div class="agent-role">Prospection Commerciale</div>
<div class="missions">
<h4>🎯 Missions</h4>
<ul>
<li>Appels sortants</li>
<li>Découverte besoins</li>
<li>Présentation offres</li>
<li>Prise de rendez-vous</li>
<li>Relances prospects</li>
</ul>
</div>
<div class="tools">
<span>CRM</span><span>Scripts</span><span>Phone</span>
</div>
</div>
<div class="agent-card mailing">
<div class="agent-icon">📧</div>
<div class="agent-name">AgentMailing</div>
<div class="agent-role">Email Marketing</div>
<div class="missions">
<h4>🎯 Missions</h4>
<ul>
<li>Campagnes email</li>
<li>Séquences prospection</li>
<li>Templates professionnels</li>
<li>Tracking ouvertures/clics</li>
<li>A/B testing</li>
</ul>
</div>
<div class="tools">
<span>Email</span><span>SendGrid</span><span>Analytics</span>
</div>
</div>
<div class="agent-card support">
<div class="agent-icon">💬</div>
<div class="agent-name">AgentSupport</div>
<div class="agent-role">Relation Client</div>
<div class="missions">
<h4>🎯 Missions</h4>
<ul>
<li>Réponses aux demandes</li>
<li>SAV et suivi</li>
<li>Foire aux questions</li>
<li>Gestion tickets</li>
<li>Satisfaction client</li>
</ul>
</div>
<div class="tools">
<span>Telegram</span><span>Email</span><span>Chat</span>
</div>
</div>
<div class="agent-card analyst">
<div class="agent-icon">📊</div>
<div class="agent-name">AgentAnalyst</div>
<div class="agent-role">Analyste Données</div>
<div class="missions">
<h4>🎯 Missions</h4>
<ul>
<li>KPI tracking</li>
<li>Rapports quotidiens</li>
<li>Analyse conversions</li>
<li>Prévisions business</li>
<li>Recommendations</li>
</ul>
</div>
<div class="tools">
<span>Analytics</span><span>Excel</span><span>IA</span>
</div>
</div>
<div class="agent-card">
<div class="agent-icon">🎨</div>
<div class="agent-name">AgentDesign</div>
<div class="agent-role">Créateur Contenu</div>
<div class="missions">
<h4>🎯 Missions</h4>
<ul>
<li>Logos et branding</li>
<li>Sites web</li>
<li>Visuels marketing</li>
<li>Présentations</li>
<li>Templates</li>
</ul>
</div>
<div class="tools">
<span>Canva</span><span>Figma</span><span>IA</span>
</div>
</div>
</div>
</div>
<!-- Workflow -->
<div class="workflow">
<h2>🔄 Flux de Travail Quotidien</h2>
<div class="workflow-steps">
<div class="step">
<div class="step-num">1</div>
<div>🔍 Scraper</div>
<div style="font-size:12px;color:#888;">09:00</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div>📧 Mailing</div>
<div style="font-size:12px;color:#888;">10:00</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div>📞 Appels</div>
<div style="font-size:12px;color:#888;">14:00</div>
</div>
<div class="step">
<div class="step-num">4</div>
<div>📊 Bilan</div>
<div style="font-size:12px;color:#888;">18:00</div>
</div>
</div>
</div>
<!-- Services -->
<h2>🛠️ Services H3R7Tech</h2>
<div class="agents-grid">
<div class="agent-card">
<div class="agent-icon">🌐</div>
<div class="agent-name">Sites Web Pro</div>
<div class="missions">
<ul>
<li>Vitrine</li>
<li>E-commerce</li>
<li>Responsive design</li>
</ul>
</div>
</div>
<div class="agent-card">
<div class="agent-icon">📈</div>
<div class="agent-name">Marketing Digital</div>
<div class="missions">
<ul>
<li>SEO/SEA</li>
<li>Réseaux sociaux</li>
<li>Content marketing</li>
</ul>
</div>
</div>
<div class="agent-card">
<div class="agent-icon">🤖</div>
<div class="agent-name">Automatisation</div>
<div class="missions">
<ul>
<li>CRM</li>
<li>Scraping</li>
<li>Workflows</li>
</ul>
</div>
</div>
<div class="agent-card">
<div class="agent-icon">🏇</div>
<div class="agent-name">Turf Analytics</div>
<div class="missions">
<ul>
<li>Prédictions</li>
<li>Statistiques</li>
<li>Gestion paris</li>
</ul>
</div>
</div>
</div>
<div style="text-align:center;margin:40px 0;">
<a href="/">← Retour au Portail</a>
</div>
</body>
</html>

68
parse_predictions.py Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Parse Canalturf predictions and save to DB"""
import json
import sqlite3
from datetime import datetime
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
# Load today's scraper output
with open('/home/h3r7/turf_scraper/v5_20260224_100313.json') as f:
data = json.load(f)
# Find Canalturf quinté content
quinte_data = None
for page in data['pages']:
if page['site'] == 'canalturf' and 'PRIX RAUBA CAPEU' in page['content']:
quinte_data = page['content']
break
if not quinte_data:
print("No Quinté data found!")
exit()
# Parse predictions from Canalturf
import re
# Extract horses from the content
horses = []
# Base (7 - I'M A BELIEVER)
base_match = re.search(r'Base\(s\)\s+(\d+)\s+([^\(]+)\s+\(([^)]+)\)', quinte_data)
if base_match:
num, name, jockey = base_match.groups()
horses.append((name.strip(), int(num), 90)) # 90% confidence for base
# Chance régulière
chance_matches = re.findall(r'Chance\(s\) régulière\(s\)\s+(\d+)\s+([^\(]+)\s+\(([^)]+)\)', quinte_data)
for num, name, jockey in chance_matches:
horses.append((name.strip(), int(num), 70))
# Outsider
outsider_matches = re.findall(r'Outsider\(s\)\s+(\d+)\s+([^\(]+)\s+\(([^)]+)\)', quinte_data)
for num, name, jockey in outsider_matches:
horses.append((name.strip(), int(num), 50))
print(f"Parsed {len(horses)} horses from Canalturf")
# Save to DB
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
today = "2026-02-24"
for name, num, conf in horses:
c.execute("""
INSERT INTO predictions (date, race_name, horse_number, horse_name, odds, prediction_rank, confidence, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (today, "Quinté Cagnes-sur-Mer", num, name, 0, len(horses)+1, conf, "canalturf"))
conn.commit()
print(f"Saved {len(horses)} predictions!")
# Show what we saved
c.execute("SELECT horse_name, horse_number, confidence, source FROM predictions WHERE date = ?", (today,))
for row in c.fetchall():
print(f" {row[1]} - {row[0]} ({row[2]}%) - {row[3]}")
conn.close()

135
performance_tracker.py Executable file
View File

@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""
Turf Performance Tracker
Calculates REX after results are in
"""
import sqlite3
from datetime import datetime, timedelta
import requests
from bs4 import BeautifulSoup
import re
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
}
def get_yesterday_results():
"""Scrape yesterday's results from Zone-Turf"""
url = "https://www.zone-turf.fr/rapports/"
try:
r = requests.get(url, headers=HEADERS, timeout=15)
soup = BeautifulSoup(r.text, 'html.parser')
# Extract results - simplified
return []
except:
return []
def calculate_performance():
"""Calculate performance from predictions vs results"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# Get predictions without performance
c.execute('''
SELECT p.id, p.date, p.race_name, p.horse_name, p.prediction_rank, r.position
FROM predictions p
LEFT JOIN results r ON p.date = r.date AND p.horse_name = r.horse_name
WHERE p.date >= date('now', '-7 days')
AND p.id NOT IN (SELECT id FROM performance)
''')
pending = c.fetchall()
added = 0
for row in pending:
pred_id, date, race, horse, pred_rank, actual_pos = row
if actual_pos:
hit = (pred_rank == actual_pos)
c.execute('''
INSERT INTO performance (prediction_date, race_date, horse_name, predicted_rank, actual_position, hit)
VALUES (?, ?, ?, ?, ?, ?)
''', (date, date, horse, pred_rank, actual_pos, hit))
added += 1
conn.commit()
# Get overall stats
c.execute('''
SELECT
COUNT(*) as total,
SUM(CASE WHEN hit = 1 THEN 1 ELSE 0 END) as hits,
ROUND(CAST(SUM(CASE WHEN hit = 1 THEN 1 ELSE 0 END) AS FLOAT) / COUNT(*) * 100, 1) as hit_rate
FROM performance
''')
stats = c.fetchone()
conn.close()
return added, stats
def main():
print("="*50)
print("TURF PERFORMANCE TRACKER")
print("="*50)
# First, try to get results from Zone-Turf
print("\n1. Scraping results...")
results = get_yesterday_results()
print(f" Found {len(results)} results")
# Calculate performance
print("\n2. Calculating performance...")
added, stats = calculate_performance()
print(f"\n📊 REX:")
print(f" Total predictions: {stats[0]}")
print(f" Hits: {stats[1]}")
print(f" Hit rate: {stats[2]}%")
if stats[0] > 0:
# Calculate ROI (simplified)
print(f"\n💰 ROI Analysis:")
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# Get predictions with results
c.execute('''
SELECT p.horse_name, p.odds, p.prediction_rank, r.position, r.odds
FROM predictions p
JOIN results r ON p.date = r.date AND p.horse_name = r.horse_name
WHERE p.prediction_rank <= 3
''')
total_stake = 0
total_return = 0
for row in c.fetchall():
horse, pred_odds, pred_rank, actual_pos, result_odds = row
stake = 2 # 2 euros per bet
total_stake += stake
# If placed (top 5), win money
if actual_pos and actual_pos <= 5:
# Simple placed bet calculation
if actual_pos == 1:
total_return += stake * (result_odds - 1)
else:
total_return += stake * 0.5 # Placed usually pays ~1.5x
else:
total_return -= stake
roi = ((total_return / total_stake) * 100) if total_stake > 0 else 0
print(f" Stake: {total_stake}")
print(f" Return: {total_return:.2f}")
print(f" ROI: {roi:.1f}%")
conn.close()
print("\n" + "="*50)
if __name__ == "__main__":
main()

968
pmu_results.py Executable file
View File

@@ -0,0 +1,968 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
pmu_results.py — Récupération complète des données PMU via API client/7
=========================================================================
Sources : https://online.turfinfo.api.pmu.fr/rest/client/7
Endpoints utilisés :
/programme/DDMMYYYY?meteo=true → programme + météo + ordreArrivee
/programme/.../R{n}/C{n}/participants → partants + cotes + stats + résultats
/programme/.../R{n}/C{n}/rapports-definitifs → gains
Schéma DB (nouvelles tables, compatibles avec turf.db existant) :
pmu_reunions — une ligne par réunion du jour
pmu_courses — une ligne par course
pmu_partants — un cheval par course (stats + cotes + résultat)
pmu_rapports — gains par type de pari
pmu_meteo — météo par réunion
Usage :
python3 pmu_results.py # aujourd'hui
python3 pmu_results.py --date 23032026 # date en DDMMYYYY
python3 pmu_results.py --show # afficher résultats BDD sans scraper
python3 pmu_results.py --bilan # bilan prédictions vs arrivées
python3 pmu_results.py --yesterday # raccourci hier
"""
import sqlite3
import requests
import json
import base64
import time
import argparse
import re
from datetime import datetime, timedelta
from pathlib import Path
# ─────────────────────────────────────────────────────────
# CONFIG
# ─────────────────────────────────────────────────────────
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
OUTPUT_DIR = Path("/home/h3r7/turf_scraper")
API_BASE = "https://online.turfinfo.api.pmu.fr/rest/client/7"
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "application/json",
"Accept-Language": "fr-FR,fr;q=0.9",
"Referer": "https://www.pmu.fr/",
}
SESSION = requests.Session()
SESSION.headers.update(HEADERS)
# ─────────────────────────────────────────────────────────
# SCHÉMA BASE DE DONNÉES
# ─────────────────────────────────────────────────────────
SCHEMA = """
CREATE TABLE IF NOT EXISTS pmu_reunions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date_programme TEXT NOT NULL,
num_reunion INTEGER NOT NULL,
num_externe INTEGER,
nature TEXT,
statut TEXT,
audience TEXT,
hippodrome_code TEXT,
hippodrome_court TEXT,
hippodrome_long TEXT,
pays_code TEXT,
pays_libelle TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(date_programme, num_reunion)
);
CREATE TABLE IF NOT EXISTS pmu_meteo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date_programme TEXT NOT NULL,
num_reunion INTEGER NOT NULL,
nebulositecode TEXT,
nebulosite_court TEXT,
nebulosite_long TEXT,
temperature INTEGER,
force_vent INTEGER,
direction_vent TEXT,
date_prevision INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(date_programme, num_reunion)
);
CREATE TABLE IF NOT EXISTS pmu_courses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date_programme TEXT NOT NULL,
num_reunion INTEGER NOT NULL,
num_course INTEGER NOT NULL,
num_externe INTEGER,
libelle TEXT,
libelle_court TEXT,
heure_depart INTEGER,
heure_depart_str TEXT,
distance INTEGER,
distance_unit TEXT,
parcours TEXT,
discipline TEXT,
specialite TEXT,
type_piste TEXT,
corde TEXT,
condition_age TEXT,
condition_sexe TEXT,
categorie_particularite TEXT,
nb_declares_partants INTEGER,
montant_prix INTEGER,
montant_1er INTEGER,
montant_2eme INTEGER,
montant_3eme INTEGER,
montant_4eme INTEGER,
montant_5eme INTEGER,
penetrometre_intitule TEXT,
penetrometre_valeur TEXT,
statut TEXT,
categorie_statut TEXT,
arrivee_definitive INTEGER DEFAULT 0,
rapports_disponibles INTEGER DEFAULT 0,
duree_course_ms INTEGER,
conditions TEXT,
ordre_arrivee_json TEXT,
incidents_json TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(date_programme, num_reunion, num_course)
);
CREATE TABLE IF NOT EXISTS pmu_partants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date_programme TEXT NOT NULL,
num_reunion INTEGER NOT NULL,
num_course INTEGER NOT NULL,
num_pmu INTEGER NOT NULL,
id_cheval TEXT,
nom TEXT,
age INTEGER,
sexe TEXT,
race TEXT,
robe TEXT,
pays TEXT,
place_corde INTEGER,
nom_pere TEXT,
nom_mere TEXT,
nom_pere_mere TEXT,
driver TEXT,
driver_change INTEGER DEFAULT 0,
entraineur TEXT,
proprietaire TEXT,
eleveur TEXT,
pays_entrainement TEXT,
oeilleres TEXT,
supplement INTEGER DEFAULT 0,
handicap_valeur REAL,
handicap_poids INTEGER,
musique TEXT,
nombre_courses INTEGER DEFAULT 0,
nombre_victoires INTEGER DEFAULT 0,
nombre_places INTEGER DEFAULT 0,
nombre_places_2eme INTEGER DEFAULT 0,
nombre_places_3eme INTEGER DEFAULT 0,
gains_carriere INTEGER DEFAULT 0,
gains_victoires INTEGER DEFAULT 0,
gains_place INTEGER DEFAULT 0,
gains_annee_en_cours INTEGER DEFAULT 0,
gains_annee_precedente INTEGER DEFAULT 0,
cote_direct REAL,
cote_reference REAL,
tendance_cote TEXT,
favoris INTEGER DEFAULT 0,
ordre_arrivee INTEGER DEFAULT 0,
statut_partant TEXT,
distance_cheval_prec TEXT,
distance_cheval_prec_code INTEGER,
commentaire_apres_course TEXT,
indicateur_inedit INTEGER DEFAULT 0,
tx_victoire REAL,
tx_place REAL,
forme_recente REAL,
tendance_forme REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(date_programme, num_reunion, num_course, num_pmu)
);
CREATE TABLE IF NOT EXISTS pmu_rapports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date_programme TEXT NOT NULL,
num_reunion INTEGER NOT NULL,
num_course INTEGER NOT NULL,
type_pari TEXT NOT NULL,
famille_pari TEXT,
mise_base INTEGER,
combinaison TEXT NOT NULL,
dividende INTEGER,
dividende_euro REAL,
nb_gagnants REAL,
libelle TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(date_programme, num_reunion, num_course, type_pari, combinaison)
);
"""
# Vues recréées séparément (DROP + CREATE)
VUES = [
("v_resultats_complets", """
CREATE VIEW v_resultats_complets AS
SELECT
p.date_programme,
p.num_reunion,
p.num_course,
c.libelle AS course,
c.heure_depart_str AS heure,
r.hippodrome_long AS hippodrome,
c.discipline,
c.distance,
c.type_piste,
c.penetrometre_intitule AS penetrometre,
m.temperature,
m.nebulosite_court AS meteo,
m.force_vent,
m.direction_vent,
p.num_pmu,
p.nom AS cheval,
p.age,
p.sexe,
p.race,
p.driver,
p.entraineur,
p.oeilleres,
p.handicap_valeur,
p.musique,
p.nombre_courses,
p.nombre_victoires,
p.nombre_places,
p.gains_carriere,
p.gains_annee_en_cours,
p.cote_direct,
p.cote_reference,
p.tendance_cote,
p.tx_victoire,
p.tx_place,
p.forme_recente,
p.tendance_forme,
p.ordre_arrivee AS position_finale,
p.distance_cheval_prec,
p.commentaire_apres_course,
p.nom_pere,
p.nom_mere,
p.pays_entrainement
FROM pmu_partants p
JOIN pmu_courses c ON c.date_programme=p.date_programme
AND c.num_reunion=p.num_reunion
AND c.num_course=p.num_course
JOIN pmu_reunions r ON r.date_programme=p.date_programme
AND r.num_reunion=p.num_reunion
LEFT JOIN pmu_meteo m ON m.date_programme=p.date_programme
AND m.num_reunion=p.num_reunion
"""),
("v_bilan_predictions", """
CREATE VIEW v_bilan_predictions AS
SELECT
pr.date,
pr.race_name,
pr.horse_name,
pr.horse_number,
pr.prediction_rank,
pr.odds AS cote_pred,
pr.source,
pa.ordre_arrivee AS position_reelle,
pa.cote_direct AS cote_finale,
pa.commentaire_apres_course,
CASE
WHEN pa.ordre_arrivee = 1 THEN 'GAGNANT'
WHEN pa.ordre_arrivee <= 3 THEN 'PLACE'
WHEN pa.ordre_arrivee <= 5 THEN 'TOP5'
WHEN pa.ordre_arrivee > 5 THEN 'HORS'
ELSE 'INCONNU'
END AS resultat
FROM predictions pr
LEFT JOIN pmu_partants pa
ON pa.date_programme = pr.date
AND pa.nom = pr.horse_name
AND pa.num_pmu = pr.horse_number
"""),
]
# ─────────────────────────────────────────────────────────
# INITIALISATION DB
# ─────────────────────────────────────────────────────────
def init_db():
conn = sqlite3.connect(DB_PATH)
conn.executescript(SCHEMA)
for vue_name, vue_sql in VUES:
conn.execute(f"DROP VIEW IF EXISTS {vue_name}")
conn.execute(vue_sql)
conn.commit()
conn.close()
print("✅ Schéma DB initialisé (5 tables + 2 vues)")
# ─────────────────────────────────────────────────────────
# APPELS API
# ─────────────────────────────────────────────────────────
def api_get(path, params=None, retries=3):
url = API_BASE + path
for attempt in range(retries):
try:
r = SESSION.get(url, params=params, timeout=15)
if r.status_code == 200:
return r.json()
elif r.status_code == 404:
return None
else:
print(f" ⚠️ HTTP {r.status_code}")
time.sleep(1)
except Exception as e:
if attempt == retries - 1:
print(f" ❌ Erreur réseau : {e}")
return None
def ts_to_hhmm(ts_ms):
if not ts_ms:
return ""
try:
return datetime.fromtimestamp(ts_ms / 1000).strftime("%H:%M")
except Exception:
return ""
# ─────────────────────────────────────────────────────────
# CALCUL STATS MUSIQUE
# ─────────────────────────────────────────────────────────
def parse_musique(musique):
if not musique:
return None, None, None, None
clean = re.sub(r'\(\d+\)', '', musique)
resultats = re.findall(r'(\d+|D|0)([amphsc]?)', clean)
positions = []
for pos, _ in resultats[:10]:
positions.append(99 if pos == 'D' else int(pos))
if not positions:
return None, None, None, None
nb = len(positions)
vict = positions.count(1)
plac = sum(1 for p in positions if 1 <= p <= 3)
rec = [p for p in positions[:3] if p != 99]
forme_recente = round(sum(rec) / len(rec), 1) if rec else None
if len(positions) >= 4:
tendance = round(sum(positions[-4:]) / 4 - sum(positions[:4]) / 4, 1)
else:
tendance = 0.0
return (
round(vict / nb * 100, 1) if nb else None,
round(plac / nb * 100, 1) if nb else None,
forme_recente,
tendance,
)
# ─────────────────────────────────────────────────────────
# SAUVEGARDE RÉUNION
# ─────────────────────────────────────────────────────────
def save_reunion(conn, date_str, reunion):
hipp = reunion.get("hippodrome", {})
pays = reunion.get("pays", {})
conn.execute("""
INSERT OR REPLACE INTO pmu_reunions
(date_programme, num_reunion, num_externe, nature, statut, audience,
hippodrome_code, hippodrome_court, hippodrome_long, pays_code, pays_libelle)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
""", (
date_str,
reunion.get("numOfficiel"),
reunion.get("numExterne"),
reunion.get("nature"),
reunion.get("statut"),
reunion.get("audience"),
hipp.get("code"),
hipp.get("libelleCourt"),
hipp.get("libelleLong"),
pays.get("code"),
pays.get("libelle"),
))
# ─────────────────────────────────────────────────────────
# SAUVEGARDE MÉTÉO
# ─────────────────────────────────────────────────────────
def save_meteo(conn, date_str, num_reunion, meteo):
if not meteo:
return
conn.execute("""
INSERT OR REPLACE INTO pmu_meteo
(date_programme, num_reunion, nebulositecode, nebulosite_court,
nebulosite_long, temperature, force_vent, direction_vent, date_prevision)
VALUES (?,?,?,?,?,?,?,?,?)
""", (
date_str, num_reunion,
meteo.get("nebulositeCode"),
meteo.get("nebulositeLibelleCourt"),
meteo.get("nebulositeLibelleLong"),
meteo.get("temperature"),
meteo.get("forceVent"),
meteo.get("directionVent"),
meteo.get("datePrevision"),
))
# ─────────────────────────────────────────────────────────
# SAUVEGARDE COURSE
# ─────────────────────────────────────────────────────────
def save_course(conn, date_str, num_reunion, course):
heure_ts = course.get("heureDepart")
penet = course.get("penetrometre") or {}
incidents = course.get("incidents", [])
ordre_arr = course.get("ordreArrivee", [])
conn.execute("""
INSERT OR REPLACE INTO pmu_courses
(date_programme, num_reunion, num_course, num_externe,
libelle, libelle_court, heure_depart, heure_depart_str,
distance, distance_unit, parcours, discipline, specialite,
type_piste, corde, condition_age, condition_sexe,
categorie_particularite, nb_declares_partants,
montant_prix, montant_1er, montant_2eme, montant_3eme, montant_4eme, montant_5eme,
penetrometre_intitule, penetrometre_valeur,
statut, categorie_statut, arrivee_definitive, rapports_disponibles,
duree_course_ms, conditions, ordre_arrivee_json, incidents_json)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
date_str, num_reunion,
course.get("numOrdre"),
course.get("numExterne"),
course.get("libelle"),
course.get("libelleCourt"),
heure_ts,
ts_to_hhmm(heure_ts),
course.get("distance"),
course.get("distanceUnit"),
course.get("parcours"),
course.get("discipline"),
course.get("specialite"),
course.get("typePiste"),
course.get("corde"),
course.get("conditionAge"),
course.get("conditionSexe"),
course.get("categorieParticularite"),
course.get("nombreDeclaresPartants"),
course.get("montantPrix"),
course.get("montantOffert1er"),
course.get("montantOffert2eme"),
course.get("montantOffert3eme"),
course.get("montantOffert4eme"),
course.get("montantOffert5eme"),
penet.get("intitule"),
penet.get("commentaire"),
course.get("statut"),
course.get("categorieStatut"),
int(bool(course.get("arriveeDefinitive"))),
int(bool(course.get("rapportsDefinitifsDisponibles"))),
course.get("dureeCourse"),
course.get("conditions"),
json.dumps(ordre_arr) if ordre_arr else None,
json.dumps(incidents) if incidents else None,
))
# ─────────────────────────────────────────────────────────
# SAUVEGARDE PARTANTS
# ─────────────────────────────────────────────────────────
def save_partants(conn, date_str, num_reunion, num_course, participants):
saved = 0
for p in participants:
robe = p.get("robe") or {}
gains = p.get("gainsParticipant") or {}
rdr = p.get("dernierRapportDirect") or {}
rref = p.get("dernierRapportReference") or {}
dist_prec = p.get("distanceChevalPrecedent") or {}
comm = p.get("commentaireApresCourse") or {}
musique = p.get("musique", "")
tx_vic, tx_plac, forme_rec, tendance_f = parse_musique(musique)
conn.execute("""
INSERT OR REPLACE INTO pmu_partants
(date_programme, num_reunion, num_course,
num_pmu, id_cheval, nom, age, sexe, race, robe, pays, place_corde,
nom_pere, nom_mere, nom_pere_mere,
driver, driver_change, entraineur, proprietaire, eleveur, pays_entrainement,
oeilleres, supplement, handicap_valeur, handicap_poids,
musique, nombre_courses, nombre_victoires, nombre_places,
nombre_places_2eme, nombre_places_3eme,
gains_carriere, gains_victoires, gains_place,
gains_annee_en_cours, gains_annee_precedente,
cote_direct, cote_reference, tendance_cote, favoris,
ordre_arrivee, statut_partant,
distance_cheval_prec, distance_cheval_prec_code,
commentaire_apres_course, indicateur_inedit,
tx_victoire, tx_place, forme_recente, tendance_forme)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,
?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
date_str, num_reunion, num_course,
p.get("numPmu"),
p.get("idCheval"),
p.get("nom"),
p.get("age"),
p.get("sexe"),
p.get("race"),
robe.get("libelleLong"),
p.get("pays"),
p.get("placeCorde"),
p.get("nomPere"),
p.get("nomMere"),
p.get("nomPereMere"),
p.get("driver"),
int(bool(p.get("driverChange"))),
p.get("entraineur"),
p.get("proprietaire"),
p.get("eleveur"),
p.get("paysEntrainement"),
p.get("oeilleres"),
p.get("supplement", 0),
p.get("handicapValeur"),
p.get("handicapPoids"),
musique,
p.get("nombreCourses", 0),
p.get("nombreVictoires", 0),
p.get("nombrePlaces", 0),
p.get("nombrePlacesSecond", 0),
p.get("nombrePlacesTroisieme", 0),
gains.get("gainsCarriere", 0),
gains.get("gainsVictoires", 0),
gains.get("gainsPlace", 0),
gains.get("gainsAnneeEnCours", 0),
gains.get("gainsAnneePrecedente", 0),
rdr.get("rapport"),
rref.get("rapport"),
rref.get("indicateurTendance"),
int(bool(rdr.get("favoris"))),
p.get("ordreArrivee", 0),
p.get("statut"),
dist_prec.get("libelleLong"),
dist_prec.get("code"),
comm.get("texte"),
int(bool(p.get("indicateurInedit"))),
tx_vic, tx_plac, forme_rec, tendance_f,
))
saved += 1
return saved
# ─────────────────────────────────────────────────────────
# SAUVEGARDE RAPPORTS
# ─────────────────────────────────────────────────────────
def save_rapports(conn, date_str, num_reunion, num_course, data):
saved = 0
if not data or not isinstance(data, list):
return 0
for bloc in data:
type_pari = bloc.get("typePari", "")
famille = bloc.get("famillePari", "")
mise_base = bloc.get("miseBase", 0)
for rap in bloc.get("rapports", []):
combinaison = str(rap.get("combinaison", ""))
dividende = rap.get("dividende", 0) or 0
try:
conn.execute("""
INSERT OR IGNORE INTO pmu_rapports
(date_programme, num_reunion, num_course,
type_pari, famille_pari, mise_base,
combinaison, dividende, dividende_euro, nb_gagnants, libelle)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
""", (
date_str, num_reunion, num_course,
type_pari, famille, mise_base,
combinaison,
dividende,
round(dividende / 100, 2),
rap.get("nombreGagnants"),
rap.get("libelle"),
))
saved += 1
except Exception:
pass
return saved
# ─────────────────────────────────────────────────────────
# BILAN PRÉDICTIONS VS ARRIVÉES
# ─────────────────────────────────────────────────────────
def afficher_bilan(date_str):
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT * FROM v_bilan_predictions WHERE date=? ORDER BY race_name, prediction_rank",
(date_str,)
).fetchall()
conn.close()
if not rows:
print(f"Aucune donnée de bilan pour {date_str} (prédictions ou partants manquants)")
return
from collections import defaultdict
par_course = defaultdict(list)
for r in rows:
par_course[r["race_name"]].append(dict(r))
total_g = total_p = total_t = total = 0
print(f"\n{'='*65}")
print(f" 📊 BILAN PRÉDICTIONS vs ARRIVÉES — {date_str}")
print(f"{'='*65}")
for course, chevaux in sorted(par_course.items()):
print(f"\n 🏇 {course}")
print(f" {'':<4} {'CHEVAL':<22} {'RANG PRÉDIT':<12} {'POSITION':<9} RÉSULTAT")
print(f" {'-'*60}")
for ch in chevaux:
pos = ch["position_reelle"] or "?"
res = ch["resultat"] or "-"
em = {"GAGNANT": "🥇", "PLACE": "🥈", "TOP5": "", "HORS": ""}.get(res, "·")
print(f" {ch['horse_number']:<4} {ch['horse_name']:<22} #{ch['prediction_rank']:<11} {str(pos):<9} {em} {res}")
total += 1
if res == "GAGNANT": total_g += 1
if res in ("GAGNANT", "PLACE"): total_p += 1
if res in ("GAGNANT", "PLACE", "TOP5"): total_t += 1
print(f"\n TOTAL : {total} prédictions analysées")
print(f" 🥇 Gagnants : {total_g} | 🥈 Placés top3 : {total_p} | ✅ Top5 : {total_t}")
print(f"{'='*65}\n")
# ─────────────────────────────────────────────────────────
# AFFICHAGE RÉSULTATS BDD
# ─────────────────────────────────────────────────────────
def afficher_resultats(date_str):
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
courses = conn.execute("""
SELECT c.num_reunion, c.num_course, c.libelle, c.heure_depart_str,
r.hippodrome_court, c.discipline, c.distance,
c.penetrometre_intitule, c.nb_declares_partants,
m.temperature, m.nebulosite_court, m.direction_vent, m.force_vent
FROM pmu_courses c
JOIN pmu_reunions r ON r.date_programme=c.date_programme AND r.num_reunion=c.num_reunion
LEFT JOIN pmu_meteo m ON m.date_programme=c.date_programme AND m.num_reunion=c.num_reunion
WHERE c.date_programme=? AND c.arrivee_definitive=1
ORDER BY c.num_reunion, c.num_course
""", (date_str,)).fetchall()
if not courses:
print(f"\nAucune course terminée en BDD pour {date_str}")
conn.close()
return
print(f"\n{'='*65}")
print(f" RÉSULTATS PMU — {date_str} ({len(courses)} course(s))")
print(f"{'='*65}")
for co in courses:
print(f"\n R{co['num_reunion']}C{co['num_course']} | {co['libelle']}")
meteo_str = ""
if co["temperature"]:
meteo_str = f" | {co['temperature']}°C {co['nebulosite_court']} vent {co['direction_vent']} {co['force_vent']}km/h"
print(f" {co['hippodrome_court']}{co['heure_depart_str']}{co['discipline']} {co['distance']}m — {co['nb_declares_partants']} partants{meteo_str}")
if co["penetrometre_intitule"]:
print(f" Terrain : {co['penetrometre_intitule']}")
top5 = conn.execute("""
SELECT num_pmu, nom, driver, ordre_arrivee,
cote_direct, distance_cheval_prec, commentaire_apres_course
FROM pmu_partants
WHERE date_programme=? AND num_reunion=? AND num_course=?
AND ordre_arrivee BETWEEN 1 AND 5
ORDER BY ordre_arrivee
""", (date_str, co["num_reunion"], co["num_course"])).fetchall()
if top5:
print(f" {'Pos':<4} {'':<4} {'CHEVAL':<22} {'Driver':<16} {'Cote':>6} Ecart")
print(f" {'-'*64}")
for ch in top5:
cote = f"{ch['cote_direct']:.1f}" if ch["cote_direct"] else "-"
ecart = ch["distance_cheval_prec"] or ""
print(f" {ch['ordre_arrivee']:<4} {ch['num_pmu']:<4} {ch['nom']:<22} {ch['driver'] or '':<16} {cote:>6} {ecart}")
if ch["commentaire_apres_course"]:
print(f"{ch['commentaire_apres_course'][:80]}")
# Rapports principaux
raps = conn.execute("""
SELECT type_pari, combinaison, dividende_euro, nb_gagnants
FROM pmu_rapports
WHERE date_programme=? AND num_reunion=? AND num_course=?
AND type_pari IN ('SIMPLE_GAGNANT','SIMPLE_PLACE','COUPLE_GAGNANT',
'TIERCE','QUARTE_PLUS','QUINTE_PLUS')
ORDER BY CASE type_pari
WHEN 'SIMPLE_GAGNANT' THEN 1
WHEN 'SIMPLE_PLACE' THEN 2
WHEN 'COUPLE_GAGNANT' THEN 3
WHEN 'TIERCE' THEN 4
WHEN 'QUARTE_PLUS' THEN 5
WHEN 'QUINTE_PLUS' THEN 6 END, combinaison
""", (date_str, co["num_reunion"], co["num_course"])).fetchall()
if raps:
print(f" Rapports :")
for rap in raps:
nb = f"({int(rap['nb_gagnants'])} gagnants)" if rap["nb_gagnants"] else ""
print(f" {rap['type_pari']:<22} {rap['combinaison']:<12} {rap['dividende_euro']:>8.2f}{nb}")
conn.close()
print()
# ─────────────────────────────────────────────────────────
# EXPORT JSON
# ─────────────────────────────────────────────────────────
def export_json(date_str, date_ddmmyyyy):
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
def rows(sql):
return [dict(r) for r in conn.execute(sql, (date_str,)).fetchall()]
out = {
"date": date_str,
"generated_at": datetime.now().isoformat(),
"reunions": rows("SELECT * FROM pmu_reunions WHERE date_programme=?"),
"courses": rows("SELECT * FROM pmu_courses WHERE date_programme=?"),
"partants": rows("SELECT * FROM pmu_partants WHERE date_programme=?"),
"rapports": rows("SELECT * FROM pmu_rapports WHERE date_programme=?"),
}
conn.close()
fname = OUTPUT_DIR / f"pmu_{date_ddmmyyyy}.json"
with open(fname, "w", encoding="utf-8") as f:
json.dump(out, f, indent=2, ensure_ascii=False, default=str)
print(f" 📁 Export JSON : {fname}\n")
# ─────────────────────────────────────────────────────────
# ORCHESTRATEUR PRINCIPAL
# ─────────────────────────────────────────────────────────
def run(date_ddmmyyyy: str):
d = datetime.strptime(date_ddmmyyyy, "%d%m%Y")
date_str = d.strftime("%Y-%m-%d")
print(f"\n{'='*65}")
print(f" 🏇 PMU SCRAPER — {date_str} (API client/7)")
print(f"{'='*65}\n")
init_db()
conn = sqlite3.connect(DB_PATH)
# ── 1. Programme ─────────────────────────────────────
print("📡 Récupération du programme...")
prog = api_get(f"/programme/{date_ddmmyyyy}", params={"meteo": "true"})
if not prog:
print("❌ Programme indisponible.")
conn.close()
return
reunions = prog.get("programme", {}).get("reunions", [])
print(f"{len(reunions)} réunion(s)\n")
total_courses = total_partants = total_rapports = 0
for reunion in reunions:
r_num = reunion.get("numOfficiel")
hipp = reunion.get("hippodrome", {}).get("libelleCourt", "?")
statut_r = reunion.get("statut", "")
courses = reunion.get("courses", [])
print(f" ── R{r_num} {hipp} ({statut_r}) — {len(courses)} course(s)")
save_reunion(conn, date_str, reunion)
save_meteo(conn, date_str, r_num, reunion.get("meteo"))
conn.commit()
for course in courses:
c_num = course.get("numOrdre")
libelle = course.get("libelleCourt", f"C{c_num}")
heure = ts_to_hhmm(course.get("heureDepart"))
terminee = bool(course.get("arriveeDefinitive"))
print(f" C{c_num} {libelle:<20} {heure} ", end="", flush=True)
save_course(conn, date_str, r_num, course)
total_courses += 1
# ── 2. Participants ───────────────────────────
parts = api_get(
f"/programme/{date_ddmmyyyy}/R{r_num}/C{c_num}/participants",
params={"withCotes": "true"}
)
time.sleep(0.2)
if parts and "participants" in parts:
n = save_partants(conn, date_str, r_num, c_num, parts["participants"])
total_partants += n
print(f"{n} partants ", end="", flush=True)
else:
print("(pas de partants) ", end="", flush=True)
# ── 3. Rapports (si terminée) ─────────────────
if terminee:
raps = api_get(
f"/programme/{date_ddmmyyyy}/R{r_num}/C{c_num}/rapports-definitifs"
)
time.sleep(0.2)
if raps and isinstance(raps, list):
n = save_rapports(conn, date_str, r_num, c_num, raps)
total_rapports += n
print(f"{n} rapports", flush=True)
else:
print("(pas de rapports)", flush=True)
else:
print("(en attente)", flush=True)
conn.commit()
calculate_and_save_scores(conn, date_str)
conn.close()
print(f"\n{'='*65}")
print(f" ✅ TERMINÉ — Courses: {total_courses} | Partants: {total_partants} | Rapports: {total_rapports}")
print(f"{'='*65}\n")
export_json(date_str, date_ddmmyyyy)
afficher_resultats(date_str)
afficher_bilan(date_str)
def calculate_and_save_scores(conn, date_str):
"""Calcule et sauvegarde les scores Top 5 pour chaque course terminée"""
conn.row_factory = sqlite3.Row
courses = conn.execute("""
SELECT date_programme, num_reunion, num_course, libelle, heure_depart_str,
ordre_arrivee_json
FROM pmu_courses
WHERE date_programme = ? AND arrivee_definitive = 1
""", (date_str,)).fetchall()
if not courses:
print(" Aucune course terminée pour calcul des scores")
return
for course in courses:
if not course['ordre_arrivee_json']:
continue
arrivee = json.loads(course['ordre_arrivee_json'])
if not arrivee:
continue
race_name = f"R{course['num_reunion']}C{course['num_course']} - {course['libelle']}"
partants = conn.execute("""
SELECT nom, num_pmu, ordre_arrivee, cote_direct
FROM pmu_partants
WHERE date_programme = ? AND num_reunion = ? AND num_course = ?
ORDER BY cote_direct ASC
""", (date_str, course['num_reunion'], course['num_course'])).fetchall()
if not partants:
continue
arrived_names = [p['nom'] for p in partants if p['ordre_arrivee'] and 1 <= p['ordre_arrivee'] <= 5]
top5_cotes = [p['nom'] for p in partants[:5] if p['nom']]
bases = [p['nom'] for p in conn.execute("""
SELECT DISTINCT pr.horse_name
FROM predictions pr
WHERE pr.date = ? AND pr.source = 'Bases'
""", (date_str,)).fetchall()]
chances = [p['nom'] for p in conn.execute("""
SELECT DISTINCT pr.horse_name
FROM predictions pr
WHERE pr.date = ? AND pr.source = 'Chances'
""", (date_str,)).fetchall()]
outsiders = [p['nom'] for p in conn.execute("""
SELECT DISTINCT pr.horse_name
FROM predictions pr
WHERE pr.date = ? AND pr.source = 'Outsiders'
""", (date_str,)).fetchall()]
top5_bc = (bases + chances)[:5]
top5_bo = (bases + outsiders)[:5]
hits_cotes = sum(1 for h in top5_cotes if h in arrived_names)
hits_bc = sum(1 for h in top5_bc if h in arrived_names)
hits_bo = sum(1 for h in top5_bo if h in arrived_names)
hippodrome = conn.execute("""
SELECT r.hippodrome_long
FROM pmu_reunions r
WHERE r.date_programme = ? AND r.num_reunion = ?
""", (date_str, course['num_reunion'])).fetchone()
hippodrome_name = hippodrome['hippodrome_long'] if hippodrome else ''
data = {
'date': date_str,
'race_name': race_name,
'race_time': course['heure_depart_str'],
'hippodrome': hippodrome_name,
'top5_cotes': top5_cotes,
'top5_cotes_hits': hits_cotes,
'top5_bc': top5_bc,
'top5_bc_hits': hits_bc,
'top5_bo': top5_bo,
'top5_bo_hits': hits_bo,
'results_top5': arrived_names
}
try:
auth = base64.b64encode(b'h3r7:h3r7').decode('ascii')
resp = requests.post(
'http://localhost:8765/turf/api/scores',
json=data,
headers={'Authorization': f'Basic {auth}'},
timeout=10
)
print(f" 💾 Scores sauvegardés: {race_name} - Hits: Cotes:{hits_cotes} BC:{hits_bc} BO:{hits_bo}")
except Exception as e:
print(f" ⚠️ Erreur sauvegarde scores: {e}")
# ─────────────────────────────────────────────────────────
# POINT D'ENTRÉE
# ─────────────────────────────────────────────────────────
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="PMU Results — API client/7")
parser.add_argument("--date", "-d",
default=datetime.now().strftime("%d%m%Y"),
help="Date DDMMYYYY (défaut: aujourd'hui)")
parser.add_argument("--yesterday", "-y", action="store_true")
parser.add_argument("--show", "-s", action="store_true",
help="Afficher résultats BDD sans scraper")
parser.add_argument("--bilan", "-b", action="store_true",
help="Afficher bilan prédictions vs arrivées")
args = parser.parse_args()
if args.yesterday:
date_fmt = (datetime.now() - timedelta(days=1)).strftime("%d%m%Y")
else:
date_fmt = args.date
date_iso = datetime.strptime(date_fmt, "%d%m%Y").strftime("%Y-%m-%d")
if args.show:
init_db()
afficher_resultats(date_iso)
elif args.bilan:
init_db()
afficher_bilan(date_iso)
else:
run(date_fmt)

294
populate_analytics.py Normal file
View File

@@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""
Populate Analytics Tables
Remplit les tables de statistiques depuis les données existantes
"""
import sqlite3
import os
from datetime import datetime, timedelta
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
def get_db():
return sqlite3.connect(DB_PATH)
def populate_bet_results():
"""Remplit bet_results depuis recommendations"""
conn = get_db()
c = conn.cursor()
# Vérifier si déjà rempli
c.execute("SELECT COUNT(*) FROM bet_results")
if c.fetchone()[0] > 0:
print("✅ bet_results déjà rempli")
conn.close()
return
# Récupérer depuis recommendations
c.execute("""
SELECT date, race_name, type_pari, cheval1, numero1, cote, mise, resultat
FROM recommendations
WHERE resultat IS NOT NULL AND resultat != ''
ORDER BY date
""")
rows = c.fetchall()
for row in rows:
date, race_name, type_pari, cheval1, numero1, cote, mise, resultat = row
gain = 0
if resultat == 'GAGNE':
gain = float(cote or 1) * float(mise or 1)
c2 = conn.cursor()
c2.execute("""
INSERT INTO bet_results (date, race_name, type_pari, horse_name, horse_number, cote, mise, resultat, gain, model_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (date, race_name, type_pari, cheval1, numero1, cote, mise, resultat, gain, source_reco or 'manual'))
conn.commit()
print(f"{len(rows)} paris ajoutés dans bet_results")
conn.close()
def populate_daily_stats():
"""Remplit daily_stats depuis bet_results"""
conn = get_db()
c = conn.cursor()
# Vérifier si déjà rempli
c.execute("SELECT COUNT(*) FROM daily_stats")
if c.fetchone()[0] > 0:
print("✅ daily_stats déjà rempli")
conn.close()
return
# Grouper par date
c.execute("""
SELECT
date,
COUNT(*) as total,
SUM(CASE WHEN resultat = 'GAGNE' THEN 1 ELSE 0 END) as gagne,
SUM(mise) as mise,
SUM(gain) as gain
FROM bet_results
GROUP BY date
ORDER BY date
""")
rows = c.fetchall()
for row in rows:
date, total, gagne, mise, gain = row
precision = (gagne / total * 100) if total > 0 else 0
roi = ((gain - mise) / mise * 100) if mise > 0 else 0
c2 = conn.cursor()
c2.execute("""
INSERT INTO daily_stats (date, total_bets, bets_gagne, bets_perdu, mise_totale, gain_total, precision_pct, roi_pct)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (date, total, gagne, total - gagne, mise, gain, precision, roi))
conn.commit()
print(f"{len(rows)} jours ajoutés dans daily_stats")
conn.close()
def populate_stats_by_type():
"""Remplit stats_by_type depuis bet_results"""
conn = get_db()
c = conn.cursor()
# Vérifier si déjà rempli
c.execute("SELECT COUNT(*) FROM stats_by_type")
if c.fetchone()[0] > 0:
print("✅ stats_by_type déjà rempli")
conn.close()
return
# Grouper par date et type
c.execute("""
SELECT
date,
type_pari,
COUNT(*) as total,
SUM(CASE WHEN resultat = 'GAGNE' THEN 1 ELSE 0 END) as gagne,
SUM(mise) as mise,
SUM(gain) as gain
FROM bet_results
GROUP BY date, type_pari
ORDER BY date, type_pari
""")
rows = c.fetchall()
for row in rows:
date, type_pari, total, gagne, mise, gain = row
precision = (gagne / total * 100) if total > 0 else 0
roi = ((gain - mise) / mise * 100) if mise > 0 else 0
c2 = conn.cursor()
c2.execute("""
INSERT INTO stats_by_type (date, type_pari, total_bets, gagne, perdu, mise_totale, gain_total, precision_pct, roi_pct)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (date, type_pari, total, gagne, total - gagne, mise, gain, precision, roi))
conn.commit()
print(f"{len(rows)} lignes ajoutées dans stats_by_type")
conn.close()
def update_daily_stats():
"""Met à jour les statistiques quotidiennes (à appeler après chaque nouveau résultat)"""
conn = get_db()
c = conn.cursor()
# Récupérer les dates sans stats ou avec nouveaux paris
c.execute("""
SELECT DISTINCT date FROM bet_results
WHERE date NOT IN (SELECT date FROM daily_stats)
ORDER BY date
""")
dates = [r[0] for r in c.fetchall()]
for date in dates:
c.execute("""
SELECT
COUNT(*) as total,
SUM(CASE WHEN resultat = 'GAGNE' THEN 1 ELSE 0 END) as gagne,
SUM(mise) as mise,
SUM(gain) as gain
FROM bet_results
WHERE date = ?
""", (date,))
row = c.fetchone()
if row:
total, gagne, mise, gain = row
precision = (gagne / total * 100) if total > 0 else 0
roi = ((gain - mise) / mise * 100) if mise > 0 else 0
c.execute("""
INSERT OR REPLACE INTO daily_stats (date, total_bets, bets_gagne, bets_perdu, mise_totale, gain_total, precision_pct, roi_pct)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (date, total, gagne, total - gagne, mise, gain, precision, roi))
conn.commit()
print(f"{len(dates)} dates mises à jour dans daily_stats")
conn.close()
def get_stats_from_db(start_date=None, end_date=None):
"""Récupère les stats depuis la base"""
conn = get_db()
c = conn.cursor()
if not start_date:
start_date = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
# Daily stats
c.execute("""
SELECT date, total_bets, bets_gagne, bets_perdu, mise_totale, gain_total, precision_pct, roi_pct
FROM daily_stats
WHERE date BETWEEN ? AND ?
ORDER BY date DESC
""", (start_date, end_date))
daily = []
for row in c.fetchall():
daily.append({
'date': row[0],
'total_bets': row[1],
'gagne': row[2],
'perdu': row[3],
'precision': row[6],
'mises': row[4],
'gains': row[5],
'roi': row[7]
})
# Summary
c.execute("""
SELECT
SUM(total_bets) as total,
SUM(bets_gagne) as gagne,
SUM(mise_totale) as mise,
SUM(gain_total) as gain
FROM daily_stats
WHERE date BETWEEN ? AND ?
""", (start_date, end_date))
row = c.fetchone()
total, gagne, mise, gain = row
summary = {
'total_bets': total or 0,
'gagne': gagne or 0,
'perdu': (total or 0) - (gagne or 0),
'precision': (gagne / total * 100) if total and total > 0 else 0,
'mise_totale': mise or 0,
'gain_total': gain or 0,
'roi': ((gain - mise) / mise * 100) if mise and mise > 0 else 0
}
# By type
c.execute("""
SELECT
type_pari,
SUM(total_bets) as total,
SUM(gagne) as gagne,
SUM(mise_totale) as mise,
SUM(gain_total) as gain
FROM stats_by_type
WHERE date BETWEEN ? AND ?
GROUP BY type_pari
""", (start_date, end_date))
by_type = {}
for row in c.fetchall():
type_pari, total, gagne, mise, gain = row
roi = ((gain - mise) / mise * 100) if mise > 0 else 0
precision = (gagne / total * 100) if total > 0 else 0
by_type[type_pari] = {
'count': total,
'gagne': gagne,
'mise': mise,
'gain': gain,
'roi': roi,
'precision': precision
}
conn.close()
return {
'period': {'start': start_date, 'end': end_date},
'summary': summary,
'daily': daily,
'by_type': by_type
}
if __name__ == "__main__":
print("="*50)
print("Population des tables analytics")
print("="*50)
populate_bet_results()
populate_daily_stats()
populate_stats_by_type()
print("\n✅ Tables analytics populées!")
# Test
stats = get_stats_from_db()
print(f"\nRésumé: {stats['summary']}")

305
portail.html Executable file
View File

@@ -0,0 +1,305 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🐾 H3R7Tech - Portail</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%); min-height: 100vh; color: #eee; }
/* Header */
header { background: linear-gradient(90deg, #1a1a2e, #0f3460); padding: 30px 20px; text-align: center; border-bottom: 2px solid #00d9ff; }
header h1 { font-size: 42px; background: linear-gradient(90deg, #00d9ff, #e94560); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 10px; }
header p { color: #888; font-size: 16px; }
/* Categories */
.categories { display: flex; justify-content: center; gap: 15px; padding: 25px 20px; flex-wrap: wrap; background: rgba(0,0,0,0.2); }
.cat-btn { padding: 12px 25px; background: #16213e; border: 1px solid #30363d; border-radius: 8px; color: #c9d1d9; cursor: pointer; transition: all 0.3s; text-decoration: none; }
.cat-btn:hover, .cat-btn.active { background: #00d9ff; color: #0f0f23; border-color: #00d9ff; }
/* Links */
.links { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; padding: 30px; max-width: 1400px; margin: 0 auto; }
.link-card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 20px; transition: all 0.3s; text-decoration: none; color: inherit; }
.link-card:hover { transform: translateY(-5px); border-color: #00d9ff; box-shadow: 0 10px 30px rgba(0,217,255,0.1); }
.link-card h3 { font-size: 18px; margin-bottom: 8px; color: #00d9ff; }
.link-card p { font-size: 13px; color: #8b949e; }
.link-card .badge { display: inline-block; padding: 3px 8px; border-radius: 4px; font-size: 11px; margin-top: 10px; }
.badge.ok { background: rgba(63,185,80,0.2); color: #3fb950; }
.badge.warn { background: rgba(210,153,34,0.2); color: #d29922; }
/* Footer */
footer { text-align: center; padding: 30px; color: #666; font-size: 14px; }
@media (max-width: 768px) {
.links { grid-template-columns: 1fr; padding: 15px; }
header h1 { font-size: 28px; }
.search-bar { flex-direction: column; }
}
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
/* Brave Search Zone */
.search-zone { padding: 20px 30px 10px; max-width: 860px; margin: 0 auto; width: 100%; }
.search-zone-title { font-size: 13px; color: #555; text-align: center; margin-bottom: 10px; letter-spacing: 0.5px; text-transform: uppercase; }
.search-bar { display: flex; gap: 8px; }
.search-bar input { flex: 1; padding: 12px 16px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; color: #eee; font-size: 14px; outline: none; transition: border-color 0.2s; }
.search-bar input:focus { border-color: #00d9ff; box-shadow: 0 0 0 2px rgba(0,217,255,0.1); }
.search-bar input::placeholder { color: #555; }
.search-bar select { padding: 12px 10px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; color: #888; font-size: 13px; outline: none; cursor: pointer; }
.search-bar select:focus { border-color: #00d9ff; }
.search-bar button { padding: 12px 22px; background: linear-gradient(135deg, #00d9ff, #7b2cbf); border: none; border-radius: 8px; color: #fff; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity 0.2s; white-space: nowrap; }
.search-bar button:hover { opacity: 0.85; }
.search-bar button:disabled { opacity: 0.5; cursor: not-allowed; }
.search-results { margin-top: 14px; display: none; }
.search-results.visible { display: block; }
.search-result-item { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 14px 16px; margin-bottom: 10px; transition: border-color 0.2s; }
.search-result-item:hover { border-color: #00d9ff; }
.search-result-item a { color: #00d9ff; text-decoration: none; font-size: 15px; font-weight: 600; }
.search-result-item a:hover { text-decoration: underline; }
.search-result-item .result-url { font-size: 11px; color: #555; margin: 4px 0 6px; word-break: break-all; }
.search-result-item .result-desc { font-size: 13px; color: #8b949e; line-height: 1.5; }
.search-result-item .result-meta { font-size: 11px; color: #444; margin-top: 6px; display: flex; gap: 12px; }
.search-status { text-align: center; padding: 12px; color: #888; font-size: 13px; }
.search-error { color: #e94560; font-size: 13px; text-align: center; padding: 8px; background: rgba(233,69,96,0.08); border-radius: 6px; margin-top: 8px; }
.search-count { font-size: 12px; color: #555; margin-bottom: 10px; }
</style>
</head>
<body>
<a href="https://portal-kolifee.duckdns.org/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<header>
<h1>🐾 H3R7Tech</h1>
<p>Portail des services & applications</p>
</header>
<!-- BRAVE SEARCH ZONE -->
<div class="search-zone">
<div class="search-zone-title">🔍 Brave Search — Recherche web intégrée</div>
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Rechercher sur le web..." autocomplete="off" />
<select id="searchType">
<option value="web">Web</option>
<option value="news">Actualités</option>
</select>
<button id="searchBtn" onclick="doSearch()">Rechercher</button>
</div>
<div id="searchResults" class="search-results"></div>
</div>
<div class="categories">
<a href="#all" class="cat-btn active">Tous</a>
<a href="#business" class="cat-btn">Business</a>
<a href="#tools" class="cat-btn">Outils</a>
<a href="#templates" class="cat-btn">Templates</a>
<a href="#poc" class="cat-btn">POC</a>
</div>
<div class="links">
<!-- BUSINESS -->
<a href="/crm/" class="link-card" data-category="business">
<h3>📊 CRM Prospects</h3>
<p>Gestion des prospects artisans, boulangeries, garages</p>
<span class="badge ok">✅ Opérationnel</span>
</a>
<a href="/depenses/" class="link-card" data-category="business">
<h3>💰 Dépenses Trello</h3>
<p>Suivi des dépenses avec export Trello</p>
<span class="badge ok">✅ Opérationnel</span>
</a>
<a href="/business" class="link-card" data-category="business">
<h3>💼 Discord Leads</h3>
<p>Dashboard leads Discord - Analyse automatique</p>
<span class="badge ok">✅ Opérationnel</span>
</a>
<a href="/pod/pod_manager.html" class="link-card" data-category="business">
<h3>📦 POD Manager</h3>
<p>Gestion Print on Demand - Designs, produits, ventes</p>
<span class="badge ok">✅ Opérationnel</span>
</a>
<a href="/pod/niches_business.html" class="link-card" data-category="business">
<h3>🎨 Guide Niches POD</h3>
<p>Analyse des 6 niches POD rentables</p>
<span class="badge ok">✅ Opérationnel</span>
</a>
<!-- PROMPTS -->
<a href="/prompts" class="link-card" data-category="tools">
<h3>📝 Prompts IA</h3>
<p>Catalogue de prompts par profession - Juriste, Graphiste, Commercial...</p>
<span class="badge ok">✅ 110+</span>
</a>
<!-- SKILLS -->
<a href="/skills" class="link-card" data-category="tools">
<h3>🤖 Agent AI Skills</h3>
<p>Base de connaissances de skills pour agents IA</p>
<span class="badge ok">✅ Nouveau</span>
</a>
<a href="/agent-ia" class="link-card" data-category="tools">
<h3>🧠 Agent IA</h3>
<p>Chat multi-workflows avec historique persistant</p>
<span class="badge ok">✅ Nouveau</span>
</a>
<!-- TURF -->
<a href="/turf/" class="link-card" data-category="tools">
<h3>🏇 Turf Dashboard</h3>
<p>Analyses et prédictions courses hippiques</p>
<span class="badge ok">✅ Opérationnel</span>
</a>
<!-- MAP -->
<a href="/map" class="link-card" data-category="tools">
<h3>🗺️ Cartographie Projets</h3>
<p>Visualisation des 12 projets H3R7Tech - Architecture et status</p>
<span class="badge ok">✅ Nouveau</span>
</a>
<!-- DOCS -->
<a href="/boite_a_idees.html" class="link-card" data-category="tools">
<h3>💡 Boîte à Idées</h3>
<p>Suivi de tous les Projets H3R7Tech</p>
<span class="badge ok">✅ Opérationnel</span>
</a>
<a href="/gitea/" class="link-card" data-category="tools">
<h3>🔧 Gitea (Git)</h3>
<p>Dépôt de code (accès privé requis)</p>
<span class="badge ok">✅ Opérationnel</span>
</a>
<a href="/datagouv_explorer.html" class="link-card" data-category="tools">
<h3>📂 Data.gouv.fr Explorer</h3>
<p>Explorer les datasets ouverts de l'administration française</p>
<span class="badge ok">✅ Nouveau</span>
</a>
<a href="/api_datagouv_reference.html" class="link-card" data-category="tools">
<h3>📡 API Data.gouv.fr</h3>
<p>Référence complète des endpoints de l'API</p>
<span class="badge ok">✅ Nouveau</span>
</a>
<!-- TEMPLATES -->
<a href="/template_restaurant_json.html" class="link-card" data-category="templates">
<h3>🍽️ Template Restaurant</h3>
<p>Site web pour restaurant</p>
<span class="badge ok"></span>
</a>
<a href="/template_boulangerie_final.html" class="link-card" data-category="templates">
<h3>🥖 Template Boulangerie</h3>
<p>Site web pour boulangerie</p>
<span class="badge ok"></span>
</a>
<a href="/template_artisan_final.html" class="link-card" data-category="templates">
<h3>🔨 Template Artisan</h3>
<p>Site web pour artisan</p>
<span class="badge ok"></span>
</a>
<!-- POC -->
<a href="/candidatures/crm_candidatures.html" class="link-card" data-category="poc">
<h3>📋 CRM Candidatures</h3>
<p>Kanban des candidatures</p>
<span class="badge ok">✅ Opérationnel</span>
</a>
</div>
<footer>
<p>H3R7Tech Portal | Dernière mise à jour: 25/04/2026 | Brave Search & Email Resend intégrés</p>
</footer>
<script>
// Simple filter
document.querySelectorAll('.cat-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
document.querySelectorAll('.cat-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const cat = btn.getAttribute('href').replace('#', '');
document.querySelectorAll('.link-card').forEach(card => {
if (cat === 'all' || card.dataset.category === cat) {
card.style.display = 'block';
} else {
card.style.display = 'none';
}
});
});
});
// Brave Search integration
const searchInput = document.getElementById('searchInput');
const searchBtn = document.getElementById('searchBtn');
const searchResults = document.getElementById('searchResults');
const searchType = document.getElementById('searchType');
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doSearch();
});
async function doSearch() {
const q = searchInput.value.trim();
if (!q) return;
const type = searchType.value;
searchBtn.disabled = true;
searchBtn.textContent = '...';
searchResults.className = 'search-results visible';
searchResults.innerHTML = '<div class="search-status">Recherche en cours...</div>';
try {
const params = new URLSearchParams({ q, count: 10, type });
const resp = await fetch('/api/brave-search?' + params);
const data = await resp.json();
if (!resp.ok || data.error) {
searchResults.innerHTML = `<div class="search-error">Erreur: ${data.error || 'Requête échouée'}</div>`;
return;
}
if (!data.results || data.results.length === 0) {
searchResults.innerHTML = '<div class="search-status">Aucun résultat trouvé.</div>';
return;
}
let html = `<div class="search-count">${data.count} résultat(s) pour "<strong>${escapeHtml(q)}</strong>" (${type === 'news' ? 'Actualités' : 'Web'})</div>`;
data.results.forEach(r => {
const source = r.source ? `<span>📰 ${escapeHtml(r.source)}</span>` : '';
const age = r.age ? `<span>🕒 ${escapeHtml(r.age)}</span>` : '';
html += `
<div class="search-result-item">
<a href="${escapeHtml(r.url)}" target="_blank" rel="noopener">${escapeHtml(r.title)}</a>
<div class="result-url">${escapeHtml(r.url)}</div>
${r.description ? `<div class="result-desc">${escapeHtml(r.description)}</div>` : ''}
<div class="result-meta">${source}${age}</div>
</div>`;
});
searchResults.innerHTML = html;
} catch (err) {
searchResults.innerHTML = `<div class="search-error">Erreur réseau: ${escapeHtml(err.message)}</div>`;
} finally {
searchBtn.disabled = false;
searchBtn.textContent = 'Rechercher';
}
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
</script>
</body>
</html>

831
portal_server.py Executable file
View File

@@ -0,0 +1,831 @@
#!/usr/bin/env python3
from flask import Flask, send_from_directory, jsonify, request, make_response
import os
import json
import requests
import subprocess
import db
app = Flask(__name__)
DASHBOARD_API_URL = "http://localhost:8791"
COMBINED_API_URL = "http://localhost:8790"
COMBINED_API_URL = "http://localhost:8790"
@app.route("/")
def portal():
return send_from_directory("/home/h3r7/turf_saas", "portail.html")
@app.route("/favicon.ico")
def favicon():
return send_from_directory("/home/h3r7/turf_saas", "favicon.ico")
@app.route("/prompts", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
@app.route("/prompts/", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
@app.route("/prompts/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
def proxy_prompts(subpath=""):
PROMPTS_API_URL = "http://localhost:8781"
full_url = PROMPTS_API_URL + ("/" + subpath if subpath else "/")
if request.query_string:
full_url += "?" + request.query_string.decode()
try:
headers = {
k: v
for k, v in request.headers
if k.lower()
not in ("host", "content-length", "transfer-encoding", "connection")
}
raw_body = request.get_data()
resp = requests.request(
request.method,
full_url,
headers=headers,
data=raw_body,
cookies=request.cookies,
allow_redirects=False,
timeout=10,
)
if resp.status_code in (301, 302, 303, 307, 308):
location = resp.headers.get("Location", "")
if location.startswith("/") and not location.startswith("/prompts"):
location = "/prompts" + location
r = make_response(b"", resp.status_code)
r.headers["Location"] = location
return r
response = make_response(resp.content, resp.status_code)
for k, v in resp.headers.items():
if k.lower() not in ("content-encoding", "transfer-encoding", "connection"):
response.headers[k] = v
return response
except Exception as e:
return f"Erreur proxy prompts: {e}", 502
@app.route("/business")
def business():
return send_from_directory("/home/h3r7/depenses_trello/templates", "business.html")
@app.route(
"/depenses", methods=["GET", "POST", "PUT", "DELETE", "PATCH"], strict_slashes=False
)
@app.route(
"/depenses/",
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
strict_slashes=False,
)
@app.route(
"/depenses/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]
)
def proxy_depenses(subpath=""):
backend_url = "http://localhost:8769"
if subpath:
full_url = f"{backend_url}/{subpath}"
else:
full_url = backend_url
if request.query_string:
full_url += "?" + request.query_string.decode()
try:
print(f"[DEPENSES PROXY] {request.method} {full_url}")
# Transmettre les headers bruts sans reparser
headers = {
k: v
for k, v in request.headers
if k.lower()
not in ("host", "content-length", "transfer-encoding", "connection")
}
# Body brut transmis tel quel — Content-Type préservé
raw_body = request.get_data()
resp = requests.request(
request.method,
full_url,
headers=headers,
data=raw_body,
cookies=request.cookies,
allow_redirects=False,
timeout=10,
)
print(f"[DEPENSES PROXY] resp={resp.status_code}")
if resp.status_code in (301, 302, 303, 307, 308):
location = resp.headers.get("Location", "")
if location.startswith("/") and not location.startswith("/depenses"):
location = "/depenses" + location
response = make_response(b"", resp.status_code)
response.headers["Location"] = location
response.headers["Content-Length"] = "0"
return response
if resp.status_code >= 400:
return resp.content, resp.status_code
response = make_response(resp.content, resp.status_code)
for k, v in resp.headers.items():
if k.lower() not in ("content-encoding", "transfer-encoding", "connection"):
response.headers[k] = v
return response
except Exception as e:
return f"Erreur proxy depenses: {e}", 502
@app.route("/skills", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
@app.route("/skills/", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
@app.route("/skills/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
def proxy_skills(subpath=""):
SKILLS_API_URL = "http://localhost:8772"
full_url = SKILLS_API_URL + ("/" + subpath if subpath else "/")
if request.query_string:
full_url += "?" + request.query_string.decode()
try:
headers = {
k: v
for k, v in request.headers
if k.lower()
not in ("host", "content-length", "transfer-encoding", "connection")
}
raw_body = request.get_data()
resp = requests.request(
request.method,
full_url,
headers=headers,
data=raw_body,
cookies=request.cookies,
allow_redirects=False,
timeout=10,
)
if resp.status_code in (301, 302, 303, 307, 308):
location = resp.headers.get("Location", "")
if location.startswith("/") and not location.startswith("/skills"):
location = "/skills" + location
r = make_response(b"", resp.status_code)
r.headers["Location"] = location
return r
response = make_response(resp.content, resp.status_code)
for k, v in resp.headers.items():
if k.lower() not in ("content-encoding", "transfer-encoding", "connection"):
response.headers[k] = v
return response
except Exception as e:
return f"Erreur proxy skills: {e}", 502
@app.route("/crm", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
@app.route("/crm/", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
@app.route("/crm/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
def proxy_crm(subpath=""):
CRM_API_URL = "http://localhost:8770"
full_url = CRM_API_URL + ("/" + subpath if subpath else "/")
if request.query_string:
full_url += "?" + request.query_string.decode()
try:
headers = {
k: v
for k, v in request.headers
if k.lower()
not in ("host", "content-length", "transfer-encoding", "connection")
}
raw_body = request.get_data()
resp = requests.request(
request.method,
full_url,
headers=headers,
data=raw_body,
cookies=request.cookies,
allow_redirects=False,
timeout=10,
)
if resp.status_code in (301, 302, 303, 307, 308):
location = resp.headers.get("Location", "")
if location.startswith("/") and not location.startswith("/crm"):
location = "/crm" + location
r = make_response(b"", resp.status_code)
r.headers["Location"] = location
return r
response = make_response(resp.content, resp.status_code)
for k, v in resp.headers.items():
if k.lower() not in ("content-encoding", "transfer-encoding", "connection"):
response.headers[k] = v
return response
except Exception as e:
return f"Erreur proxy crm: {e}", 502
@app.route("/gitea", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
@app.route("/gitea/", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
@app.route("/gitea/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
def proxy_gitea(subpath=""):
GITEA_API_URL = "http://localhost:3000"
full_url = GITEA_API_URL + ("/" + subpath if subpath else "/")
if request.query_string:
full_url += "?" + request.query_string.decode()
try:
headers = {
k: v
for k, v in request.headers
if k.lower()
not in ("host", "content-length", "transfer-encoding", "connection")
}
raw_body = request.get_data()
resp = requests.request(
request.method,
full_url,
headers=headers,
data=raw_body,
cookies=request.cookies,
allow_redirects=False,
timeout=10,
)
if resp.status_code in (301, 302, 303, 307, 308):
location = resp.headers.get("Location", "")
if location.startswith("/") and not location.startswith("/gitea"):
location = "/gitea" + location
r = make_response(b"", resp.status_code)
r.headers["Location"] = location
return r
response = make_response(resp.content, resp.status_code)
for k, v in resp.headers.items():
if k.lower() not in ("content-encoding", "transfer-encoding", "connection"):
response.headers[k] = v
return response
except Exception as e:
return f"Erreur proxy gitea: {e}", 502
@app.route("/boite_a_idees.html")
def boite_a_idees():
return send_from_directory("/home/h3r7/depenses_trello", "boite_a_idees.html")
@app.route("/niches_business.html")
def niches_business():
return send_from_directory("/home/h3r7/depenses_trello/templates", "business.html")
@app.route("/template_restaurant_json.html")
def template_restaurant():
return send_from_directory(
"/home/h3r7/turf_saas", "template_restaurant_json.html"
)
@app.route("/template_boulangerie_final.html")
def template_boulangerie():
return send_from_directory(
"/home/h3r7/turf_saas", "template_boulangerie_final.html"
)
@app.route("/template_artisan_final.html")
def template_artisan():
return send_from_directory("/home/h3r7/turf_saas", "template_artisan_final.html")
@app.route("/template_restaurant_final.html")
def template_restaurant_final():
return send_from_directory(
"/home/h3r7/turf_saas", "template_restaurant_final.html"
)
@app.route("/template_complet.html")
def template_complet():
return send_from_directory("/home/h3r7/turf_saas", "template_complet.html")
@app.route("/boite_a_idees_dashboard")
def boite_a_idees_dashboard():
return send_from_directory(
"/home/h3r7/turf_saas", "boite_a_idees_dashboard.html"
)
@app.route("/datagouv_explorer.html")
def datagouv_explorer():
return send_from_directory("/home/h3r7/turf_saas", "datagouv_explorer.html")
@app.route("/api_datagouv_reference.html")
def api_datagouv_reference():
return send_from_directory("/home/h3r7/turf_saas", "api_datagouv_reference.html")
# Agent IA - Page principale
@app.route("/agent-ia")
@app.route("/agent-ia/")
def agent_ia():
return send_from_directory("/home/h3r7/turf_saas", "agent_ia.html")
# Agent IA - Page de config
@app.route("/agent-ia/config")
@app.route("/agent-ia/config/")
def agent_ia_config():
return send_from_directory("/home/h3r7/turf_saas", "agent_ia_config.html")
# Ancienne page (compatibilité)
@app.route("/agent-ia/legacy")
def agent_ia_legacy():
return send_from_directory("/home/h3r7/turf_saas", "gemini_agent.html")
# --- API Chat ---
@app.route("/api/chat/workflows", methods=["GET"])
def api_chat_workflows():
try:
workflows = db.get_workflows()
return jsonify([dict(w) for w in workflows])
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/chat/nvidia-models", methods=["GET"])
def api_nvidia_models():
return jsonify([
{"id": k, "name": v.split("/")[-1].replace("-instruct", "").replace("-", " ").title(), "full_id": v}
for k, v in NVIDIA_MODELS.items()
])
@app.route("/api/chat/sessions", methods=["GET"])
def api_chat_sessions():
workflow_slug = request.args.get("workflow")
if not workflow_slug:
return jsonify({"error": "Paramètre workflow requis"}), 400
try:
sessions = db.get_sessions(workflow_slug)
return jsonify([dict(s) for s in sessions])
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/chat/history", methods=["GET"])
def api_chat_history():
session_id = request.args.get("session_id")
workflow_slug = request.args.get("workflow")
if not session_id or not workflow_slug:
return jsonify({"error": "session_id et workflow requis"}), 400
try:
messages = db.get_messages(session_id, workflow_slug)
return jsonify([dict(m) for m in messages])
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/chat/search", methods=["GET"])
def api_chat_search():
query = request.args.get("q")
workflow_slug = request.args.get("workflow")
if not query or not workflow_slug:
return jsonify({"error": "q et workflow requis"}), 400
try:
results = db.search_messages(query, workflow_slug)
return jsonify([dict(r) for r in results])
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/chat/session", methods=["POST"])
def api_chat_session_create():
data = request.json or {}
session_id = data.get("session_id")
workflow_slug = data.get("workflow")
title = data.get("title")
if not session_id or not workflow_slug:
return jsonify({"error": "session_id et workflow requis"}), 400
try:
workflows = db.get_workflows()
wf = next((w for w in workflows if w["slug"] == workflow_slug), None)
if not wf:
return jsonify({"error": "Workflow introuvable"}), 404
db.create_session(session_id, wf["id"], title)
return jsonify({"status": "ok", "session_id": session_id})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/chat/session", methods=["PUT"])
def api_chat_session_rename():
data = request.json or {}
session_id = request.args.get("session_id")
workflow_slug = request.args.get("workflow")
new_title = data.get("title")
if not session_id or not workflow_slug:
return jsonify({"error": "session_id et workflow requis"}), 400
try:
db.rename_session(session_id, workflow_slug, new_title)
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/chat/session", methods=["DELETE"])
def api_chat_session_delete():
session_id = request.args.get("session_id")
workflow_slug = request.args.get("workflow")
if not session_id or not workflow_slug:
return jsonify({"error": "session_id et workflow requis"}), 400
try:
deleted = db.delete_session(session_id, workflow_slug)
return jsonify({"status": "ok", "deleted": deleted})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/chat/cleanup", methods=["POST"])
def api_chat_cleanup():
data = request.json or {}
days = data.get("days")
workflow_slug = data.get("workflow")
if not days or not workflow_slug:
return jsonify({"error": "days et workflow requis"}), 400
try:
deleted = db.delete_messages_before(days, workflow_slug)
return jsonify({"status": "ok", "deleted": deleted})
except Exception as e:
return jsonify({"error": str(e)}), 500
# --- Webhook proxy avec persistance ---
OPENCLAW_TOKEN = "szDd75yG15ZRADWEhGus3dfNoQve7ZhdxpGq9gudeEzoVhUqB0TomVJd90XcwkUz"
OPENCLAW_CONTAINER = "openclaw-ow404080wwkkgkgc4oswwssc"
NVIDIA_API_KEY = "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"
NVIDIA_API_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
NVIDIA_MODEL = "meta/llama-3.1-8b-instruct" # Default model
NVIDIA_MODELS = {
"llama-3.1-8b": "meta/llama-3.1-8b-instruct",
"llama-3.1-70b": "meta/llama-3.1-70b-instruct",
"llama-3.3-70b": "meta/llama-3.3-70b-instruct",
"llama-4-scout": "meta/llama-4-scout-17b-16e-instruct",
"nemotron-mini": "nvidia/nemotron-mini-4b-instruct",
"nemotron-super": "nvidia/llama-3.3-nemotron-super-49b-v1",
"mistral-small": "mistralai/mistral-small-3.1-24b-instruct-2503",
"mistral-medium": "mistralai/mistral-medium-3-instruct",
"mistral-large": "mistralai/mistral-large-3-675b-instruct-2512",
"qwen-coder": "qwen/qwen3-coder-480b-a35b-instruct",
"gemma-3": "google/gemma-3-27b-it",
"deepseek": "deepseek-ai/deepseek-v3.2",
}
@app.route("/webhook/telegram", methods=["POST"])
def telegram_webhook():
try:
data = request.get_data()
resp = requests.post(
"http://localhost:5003/webhook",
data=data,
headers={"Content-Type": "application/json"},
timeout=30,
)
return resp.content, resp.status_code
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/webhook/<workflow_slug>", methods=["POST"])
def webhook_proxy(workflow_slug):
try:
workflows = db.get_workflows()
wf = next((w for w in workflows if w["slug"] == workflow_slug), None)
if not wf:
return jsonify({"error": f'Workflow "{workflow_slug}" introuvable'}), 404
session_id = request.headers.get("X-Session-ID", "default")
user_message = request.json.get("message", "")
db.create_session(session_id, wf["id"])
db.save_message(session_id, wf["id"], "user", user_message)
mode = wf.get("mode", "n8n")
if mode == "direct":
# OpenClaw gateway bind sur 127.0.0.1 uniquement → docker exec
import subprocess
escaped_msg = user_message.replace('"', '\\"').replace("\n", "\\n")
cmd = [
"docker",
"exec",
OPENCLAW_CONTAINER,
"curl",
"-s",
"http://127.0.0.1:18789/v1/chat/completions",
"-H",
"Content-Type: application/json",
"-H",
f"Authorization: Bearer {OPENCLAW_TOKEN}",
"-d",
f'{{"model":"openclaw","messages":[{{"role":"user","content":"{escaped_msg}"}}],"max_tokens":4096,"temperature":0.7}}',
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
raise Exception(f"docker exec failed: {result.stderr}")
data = json.loads(result.stdout)
ai_response = (
data.get("choices", [{}])[0]
.get("message", {})
.get("content", str(data))
)
elif mode == "nvidia":
# Appel direct a l API Nvidia NIM
# Recuperer le modele choisi (par defaut: llama-3.1-8b)
model_key = request.json.get("model", "llama-3.1-8b")
model_id = NVIDIA_MODELS.get(model_key, NVIDIA_MODEL)
resp = requests.post(
NVIDIA_API_URL,
headers={
"Authorization": f"Bearer {NVIDIA_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": model_id,
"messages": [{"role": "user", "content": user_message}],
"max_tokens": 1024,
"temperature": 0.7,
},
timeout=60,
)
data = resp.json()
ai_response = (
data.get("choices", [{}])[0]
.get("message", {})
.get("content", str(data))
)
else:
# Proxy vers webhook n8n
resp = requests.post(
wf["webhook_url"],
headers={
"Content-Type": "application/json",
"X-Session-ID": session_id,
},
json=request.json,
timeout=60,
)
ai_response = resp.text
db.save_message(session_id, wf["id"], "ai", ai_response)
return ai_response, 200, {"Content-Type": "text/plain; charset=utf-8"}
except Exception as e:
return str(e), 500
# --- Anciens webhooks (compatibilité) ---
@app.route("/webhook/gemini-agent", methods=["POST"])
def webhook_proxy_legacy_gemini():
return webhook_proxy("llm-models")
@app.route("/webhook/openclaw-web", methods=["POST"])
def webhook_proxy_legacy_openclaw():
return webhook_proxy("openclaw")
TELEGRAM_TOKEN = "8649773134:AAFqzZVtSHfPPFDadcte1B-1h23nZ8DmdYE"
@app.route("/webhook/telegram-opencode", methods=["POST"])
def webhook_proxy_telegram():
"""Handle Telegram messages directly"""
try:
data = request.get_json(force=True, silent=True)
print(f"[TELEGRAM] Raw: {request.data}")
print(f"[TELEGRAM] Remote: {request.remote_addr}")
print(f"[TELEGRAM] Headers: {request.headers.get('X-Forwarded-For', 'none')}")
if not data:
return jsonify({"error": "No data", "raw": request.data.decode()}), 400
# Accept both direct message and Telegram update format
message = data.get("message") or data.get("update", {}).get("message")
if not message:
return jsonify({"data": data, "keys": list(data.keys())}), 400
chat_id = message["chat"]["id"]
text = message.get("text", "")
user_id = message["from"]["id"]
if text.startswith("/start"):
reply = "🤖 *OpencdPilot Bot*\n\nPilotez OpenCode depuis Telegram!"
elif text.startswith("/help"):
reply = "*Commandes:*\n/start - Démarrer\n/help - Aide\n/status - Statut"
elif text.startswith("/status"):
reply = "✅ *Système actif*\n- OpenCode: OK\n- Bot: OK"
else:
# Call OpenCode API
opencode_resp = requests.post(
"http://localhost:8792/api/opencode", json={"prompt": text}, timeout=180
)
opencode_data = opencode_resp.json()
reply = opencode_data.get("output", opencode_data.get("error", "Erreur"))[
:4000
]
# Send reply
requests.post(
f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage",
json={"chat_id": chat_id, "text": reply, "parse_mode": "Markdown"},
)
return jsonify({"ok": True})
except Exception as e:
print(f"[TELEGRAM] Error: {e}")
return jsonify({"error": str(e)}), 500
# --- Turf Dashboard ---
@app.route("/dashboard")
@app.route("/dashboard.html")
def dashboard():
return send_from_directory("/home/h3r7/turf_saas", "dashboard_system.html")
@app.route("/turf/")
@app.route("/turf")
def turf_index():
return send_from_directory("/home/h3r7/turf_saas", "dashboard.html")
@app.route("/turf/<path:filename>")
def turf_static(filename):
return send_from_directory("/home/h3r7/turf_saas", filename)
# --- POD Routes ---
@app.route("/pod/")
@app.route("/pod/<path:filename>")
def pod_static(filename=""):
return send_from_directory(
"/home/h3r7/turf_saas/POD", filename if filename else "pod_manager.html"
)
@app.route("/turf/api")
@app.route("/turf/api/")
@app.route("/turf/api/<path:api_path>")
def api_proxy(api_path=""):
if api_path.startswith("vitesse"):
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
elif api_path.startswith("n8n-proxy"):
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
elif api_path.startswith("backtest"):
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
elif api_path.startswith("stats"):
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
elif api_path.startswith("predictions_analysis"):
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
elif api_path.startswith("parisroi"):
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
elif api_path.startswith("paris"):
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
elif api_path.startswith("scoring"):
url = f"{DASHBOARD_API_URL}/turf/api/{api_path}"
elif api_path:
url = f"{DASHBOARD_API_URL}/turf/api/{api_path}"
else:
url = f"{DASHBOARD_API_URL}/turf/api"
try:
fwd_method = request.method
fwd_json = request.get_json(silent=True) if fwd_method in ("POST", "PUT", "PATCH") else None
fwd_headers = {"Content-Type": "application/json"}
if request.headers.get("Authorization"):
fwd_headers["Authorization"] = request.headers.get("Authorization")
resp = requests.request(method=fwd_method, url=url, json=fwd_json, timeout=30,
headers=fwd_headers)
return resp.content, resp.status_code, {"Content-Type": "application/json"}
except Exception as e:
return jsonify({"error": str(e), "url": url}), 500
@app.route("/api/opencode", methods=["POST"])
def opencode_api():
"""Execute OpenCode commands via API"""
try:
data = request.get_json()
prompt = data.get("prompt", "")
if not prompt:
return jsonify({"error": "No prompt provided"}), 400
# Execute opencode with the wrapper script
result = subprocess.run(
["/home/h3r7/opencode_wrapper.sh", prompt],
cwd="/home/h3r7",
capture_output=True,
text=True,
timeout=180,
)
return jsonify(
{
"output": result.stdout,
"error": result.stderr,
"returncode": result.returncode,
}
)
except subprocess.TimeoutExpired:
return jsonify({"error": "Timeout"}), 504
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/candidatures/")
def candidatures_index():
return send_from_directory("/home/h3r7/turf_saas", "crm_candidatures.html")
@app.route("/candidatures/<path:filename>")
def candidatures_static(filename):
return send_from_directory("/home/h3r7/turf_saas", filename)
@app.route("/map")
def map_visual():
return send_from_directory("/home/h3r7/turf_saas", "map_visual.html")
@app.route("/architecture.json")
def architecture_json():
return send_from_directory("/home/h3r7/turf_saas", "architecture.json")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8792, debug=False)
# Telegram Bot Webhook Proxy
def telegram_webhook():
"""Proxy Telegram webhook to bot service"""
try:
data = request.get_data()
resp = requests.post(
"http://localhost:5003/webhook",
data=data,
headers={"Content-Type": "application/json"},
timeout=30,
)
return resp.content, resp.status_code
except Exception as e:
return jsonify({"error": str(e)}), 500
PROMPTS_API_URL = "http://localhost:8781"
@app.route("/testprompts", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
def proxy_prompts_test():
return "TEST_PROMPTS_OK"
full_url = PROMPTS_API_URL + ("/" + subpath if subpath else "/")
if request.query_string:
full_url += "?" + request.query_string.decode()
try:
headers = {
k: v
for k, v in request.headers
if k.lower()
not in (
"host",
"content-length",
"transfer-encoding",
"connection",
"authorization",
)
}
raw_body = request.get_data()
resp = requests.request(
request.method,
full_url,
headers=headers,
data=raw_body,
cookies=request.cookies,
allow_redirects=False,
timeout=10,
)
if resp.status_code in (301, 302, 303, 307, 308):
location = resp.headers.get("Location", "")
if location.startswith("/") and not location.startswith("/prompts"):
location = "/prompts" + location
r = make_response(b"", resp.status_code)
r.headers["Location"] = location
return r
response = make_response(resp.content, resp.status_code)
for k, v in resp.headers.items():
if k.lower() not in ("content-encoding", "transfer-encoding", "connection"):
response.headers[k] = v
return response
except Exception as e:
return f"Erreur proxy prompts: {e}", 502

290
prompts_llm.py Normal file
View File

@@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""
Prompts LLM Centralisés - Turf Scraper
"""
import os
TURF_DB_SCHEMA = """
Tables disponibles dans turf.db:
1. predictions
- id (INTEGER PRIMARY KEY)
- date (TEXT) - format YYYY-MM-DD
- race_name (TEXT) - ex: "Quinté+"
- race_hippodrome (TEXT) - ex: "Vincennes"
- race_time (TEXT) - ex: "13:55"
- horse_number (INTEGER)
- horse_name (TEXT)
- odds (REAL) - cote
- prediction_rank (INTEGER) - position prédite (1-5)
- source (TEXT) - origine du prono
2. pmu_courses (table des courses)
- id (INTEGER PRIMARY KEY)
- date_programme (TEXT) - YYYY-MM-DD
- num_reunion (INTEGER)
- num_course (INTEGER)
- libelle (TEXT) - nom de la course
- libelle_court (TEXT)
- discipline (TEXT) - PLAT, TROT, ATTELE, MONTE
- distance (INTEGER)
- heure_depart_str (TEXT) - ex: "13:55"
3. pmu_partants (table des partants/chevaux)
- id (INTEGER PRIMARY KEY)
- date_programme (TEXT) - YYYY-MM-DD
- num_reunion (INTEGER)
- num_course (INTEGER)
- num_pmu (INTEGER) - numéro du cheval
- nom (TEXT) - nom du cheval
- driver (TEXT) - jockey
- entraineur (TEXT)
- cote_direct (REAL) - cote
- ordre_arrivee (INTEGER) - position finale (0 = pas couru)
- favoris (INTEGER) - 1 si favori
- tx_victoire (REAL) - taux de victoire %
- tx_place (REAL) - taux podium %
- forme_recente (REAL)
4. odds_history
- id (INTEGER PRIMARY KEY)
- date (TEXT)
- id_race (TEXT)
- scraped_at (TEXT)
- odds_json (TEXT) - cotes au moment X
"""
SAMPLE_DATA = """
Exemples de données réelles (2026-03-26):
- Hippodrome: CHANTILLY
- Course: PRIX DE LA JOURNEE DES PLANTES
- Type: PLAT
- Partants: TORTISAMBERT (3) @ 4.5, WIT (8) @ 7.1, BIN ZARAK (7) @ 12.7
- Résultat: TORTISAMBERT 1er, WIT 2ème, BIN ZARAK 3ème
"""
SYSTEM_PROMPT = """Tu es un expert en paris hippiques et turf français.
Tu as accès à une base de données SQLite (turf.db) contenant:
- Prédictions de pronostics (table: predictions)
- Résultats de courses PMU (table: pmu_partants)
- Historique des cotes (table: odds_history)
- Statistiques de performance (table: performance_stats)
{schema}
Règles importantes:
1. Tu génères ONLY des requêtes SQL SELECT (jamais INSERT/UPDATE/DELETE)
2. Les dates sont au format YYYY-MM-DD (ex: 2026-03-26)
3. Le taux de réussite se calcule: (victoires / total) * 100
4. Le ROI se calcule: (gains misés - mises) / mises * 100
5. Un cheval "placé" est sur le podium (positions 1, 2 ou 3)
6. Les positions 0 = pas encore couru
Réponds en français. Sois précis et concis."""
SQL_GENERATION_PROMPT = """
Génère une requête SQL SQLite pour répondre à cette question.
Question: {question}
Contraintes:
- ONLY SELECT queries (pas de INSERT/UPDATE/DELETE)
- Utilise les noms de colonnes exactes du schéma fourni
- Retourne uniquement le SQL, pas d'explication
- Si impossible, retourne "IMPOSSIBLE: [raison]"
Exemples de requêtes valides:
Q: "taux de réussite des favoris"
SQL: SELECT
AVG(CASE WHEN position = 1 THEN 100.0 ELSE 0.0 END) as win_rate,
AVG(CASE WHEN position IN (1,2,3) THEN 100.0 ELSE 0.0 END) as podium_rate,
COUNT(*) as total_races
FROM pmu_partants
WHERE numero IN (1,2,3)
AND date_armee >= date('now', '-30 days')
AND position > 0
Q: "meilleurs jockeys par victoires"
SQL: SELECT driver, COUNT(*) as wins,
AVG(CASE WHEN position IN (1,2,3) THEN 100.0 ELSE 0.0 END) as podium_rate,
AVG(position) as avg_position
FROM pmu_partants
WHERE position = 1
AND date_armee >= date('now', '-30 days')
GROUP BY driver
ORDER BY wins DESC
LIMIT 10
Q: "performances du cheval TORTISAMBERT"
SQL: SELECT * FROM performance_stats WHERE horse_name LIKE '%TORTISAMBERT%'
Q: "{question}"
SQL:"""
CONVERSATION_PROMPT = """
Historique de la conversation:
{history}
Dernière question: {question}
Analyse cette question en tenant compte du contexte précédent.
Si la question fait référence à un élément de l'historique, utilise-le.
Si la question est vague, fournis une réponse utile basée sur les données disponibles.
Contexte DB:
- predictions: tes prédictions
- pmu_partants: résultats réels
- odds_history: évolution des cotes
Réponds de façon conversationnelle en français."""
REPORT_PROMPTS = {
"daily": """
Génère un rapport quotidien de performance pour la date {date}.
Inclure:
1. Nombre de courses aujourd'hui
2. Résultats (positions des partants)
3. Taux de réussite des prédictions
4. ROI du jour
5. Meilleure cote réalisée
Format: Markdown français concis.
""",
"weekly": """
Génère un rapport hebdomadaire pour la semaine du {start_date} au {end_date}.
Inclure:
1. Total courses, paris, gains
2. Taux de réussite global
3. Meilleurs jockeys/chiens cette semaine
4. Évolution vs semaine précédente
5. Recommandations
Format: Markdown français concis.
""",
"monthly": """
Génère un rapport mensuel pour {month} {year}.
Inclure:
1. Bilan全局 (courses, paris, gains, ROI)
2. Meilleures performances par hippodrome
3. Trends (amélioration/détérioration)
4. Statistiques détaillées
Format: Markdown français concis.
"""
}
SUGGESTIONS_PROMPT = """
Basé sur les données disponibles dans turf.db, génère 5 suggestions de questions
que l'utilisateur pourrait poser.
Les suggestions doivent couvrir différents aspects:
- Statistiques de performance
- Analyse de courses/chevaux spécifiques
- Historique et tendances
- ROI et gains
Retourne SEULEMENT une liste de questions, une par ligne, sans numérotation."""
KEYWORD_PATTERNS = {
"favoris": """
SELECT
AVG(CASE WHEN ordre_arrivee = 1 THEN 100.0 ELSE 0.0 END) as win_rate,
AVG(CASE WHEN ordre_arrivee IN (1,2,3) THEN 100.0 ELSE 0.0 END) as podium_rate,
COUNT(*) as total_races
FROM pmu_partants
WHERE favoris = 1 AND ordre_arrivee > 0
AND date_programme >= date('now', '-7 days')""",
"jockeys": """
SELECT driver, COUNT(*) as wins,
AVG(CASE WHEN ordre_arrivee IN (1,2,3) THEN 100.0 ELSE 0.0 END) as podium_rate,
AVG(ordre_arrivee) as avg_position
FROM pmu_partants
WHERE ordre_arrivee = 1 AND date_programme >= date('now', '-30 days')
GROUP BY driver ORDER BY wins DESC LIMIT 10""",
"entraineurs": """
SELECT entraineur, COUNT(*) as wins,
AVG(CASE WHEN ordre_arrivee IN (1,2,3) THEN 100.0 ELSE 0.0 END) as podium_rate
FROM pmu_partants
WHERE ordre_arrivee = 1 AND date_programme >= date('now', '-30 days')
GROUP BY entraineur ORDER BY wins DESC LIMIT 10""",
"programme": """
SELECT c.id, c.num_reunion, c.num_course, c.libelle, c.discipline, c.distance, c.heure_depart_str,
COUNT(p.id) as partants
FROM pmu_courses c
LEFT JOIN pmu_partants p ON c.date_programme = p.date_programme
AND c.num_reunion = p.num_reunion AND c.num_course = p.num_course
WHERE c.date_programme = date('now')
GROUP BY c.id
ORDER BY c.num_reunion, c.num_course""",
"resultats": """
SELECT p.nom as cheval, p.num_pmu as numero, p.ordre_arrivee as position,
p.cote_direct as cote, p.driver, c.libelle as course
FROM pmu_partants p
JOIN pmu_courses c ON p.date_programme = c.date_programme
AND p.num_reunion = c.num_reunion AND p.num_course = c.num_course
WHERE p.date_programme = date('now', '-1 day')
AND p.ordre_arrivee > 0
ORDER BY c.num_reunion, c.num_course, p.ordre_arrivee""",
"gagnants": """
SELECT p.nom as cheval, p.cote_direct as cote, c.libelle as course, p.date_programme
FROM pmu_partants p
JOIN pmu_courses c ON p.date_programme = c.date_programme
AND p.num_reunion = c.num_reunion AND p.num_course = c.num_course
WHERE p.ordre_arrivee = 1 AND p.cote_direct > 0
ORDER BY p.date_programme DESC
LIMIT 10""",
"cote": """
SELECT p.nom as cheval, p.cote_direct as cote_initiale,
p.ordre_arrivee as position, p.date_programme
FROM pmu_partants p
WHERE p.date_programme >= date('now', '-7 days')
AND p.ordre_arrivee > 0
ORDER BY p.date_programme DESC""",
}
def get_system_prompt() -> str:
"""Retourne le prompt système complet"""
return SYSTEM_PROMPT.format(schema=TURF_DB_SCHEMA)
def get_sql_prompt(question: str) -> str:
"""Retourne le prompt pour génération SQL"""
return SQL_GENERATION_PROMPT.format(question=question)
def get_conversation_prompt(question: str, history: str = "") -> str:
"""Retourne le prompt pour conversation"""
if history:
return CONVERSATION_PROMPT.format(question=question, history=history)
return f"Question: {question}\n\n{get_sql_prompt(question)}"
def get_report_prompt(report_type: str, **kwargs) -> str:
"""Retourne le prompt pour un rapport"""
template = REPORT_PROMPTS.get(report_type, "")
return template.format(**kwargs)
def get_suggestions_prompt() -> str:
"""Retourne le prompt pour suggestions"""
return SUGGESTIONS_PROMPT
def get_keyword_sql(question: str) -> str | None:
"""Retourne SQL basé sur mots-clés (fallback)"""
q = question.lower()
for keyword, sql in KEYWORD_PATTERNS.items():
if keyword in q:
return sql
return None

383
results_scraper.py Executable file
View File

@@ -0,0 +1,383 @@
#!/usr/bin/env python3
"""
Results Scraper - API PMU officielle
Scrape les résultats réels du Quinté+, les sauvegarde en BDD
et calcule le taux de réussite des prédictions.
À lancer à 21h via cron ou OpenClaw.
"""
import requests
import sqlite3
import json
from datetime import datetime
import os; DB_PATH = os.environ.get("DB_PATH", "/home/h3r7/turf_scraper/turf.db")
HEADERS = {'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json'}
# ============================================================
# API PMU
# ============================================================
def get_programme(date_str):
"""
Récupère le programme complet du jour via l'API PMU.
date_str : format DDMMYYYY
Retourne la liste des réunions avec leurs courses.
"""
url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_str}/reunions"
r = requests.get(url, headers=HEADERS, timeout=15)
r.raise_for_status()
data = r.json()
return data.get("programme", {}).get("reunions", [])
def get_participants(date_str, num_reunion, num_course):
"""
Récupère les participants + ordreArrivee pour une course donnée.
ordreArrivee = position finale officielle (0 = non classé/disqualifié)
"""
url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_str}/R{num_reunion}/C{num_course}/participants"
r = requests.get(url, headers=HEADERS, timeout=15)
r.raise_for_status()
return r.json().get("participants", [])
def find_quinte(reunions):
"""
Identifie la course Quinté+ du jour (pariMultiCourses=True ou libelle contient 'PARIS-TURF').
Retourne (num_reunion, num_course, libelle, hippodrome) ou None.
"""
for reunion in reunions:
for course in reunion.get("courses", []):
libelle = course.get("libelle", "")
paris_types = [p["typePari"] for p in course.get("paris", [])]
if any("QUINTE" in p for p in paris_types) or "PARIS-TURF" in libelle:
return (
reunion["numOfficiel"],
course["numOrdre"],
libelle,
reunion["hippodrome"]["libelleCourt"],
course.get("arriveeDefinitive", False)
)
return None
# ============================================================
# BASE DE DONNÉES
# ============================================================
def init_db_results():
"""Crée les tables si elles n'existent pas encore."""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# Table results : arrivée officielle
c.execute('''
CREATE TABLE IF NOT EXISTS results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
race_name TEXT,
race_hippodrome TEXT,
position INTEGER,
horse_name TEXT,
odds REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Table performance : comparaison prédictions vs résultats
c.execute('''
CREATE TABLE IF NOT EXISTS performance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
race_name TEXT,
horse_name TEXT,
predicted_rank INTEGER,
actual_position INTEGER,
hit_top5 BOOLEAN,
hit_winner BOOLEAN,
source TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
conn.close()
def save_results(date, race_name, hippodrome, participants):
"""Sauvegarde les positions officielles en BDD (évite les doublons)."""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
saved = 0
for p in participants:
position = p.get("ordreArrivee", 0)
if position == 0:
continue # Non classé / disqualifié
horse = p.get("nom", "")
# Cote finale (rapport direct simple gagnant)
rapport = p.get("dernierRapportDirect", {})
odds = rapport.get("rapport", 0.0) if rapport else 0.0
# Vérifier si déjà inséré
c.execute(
"SELECT id FROM results WHERE date=? AND race_name=? AND horse_name=? AND position=?",
(date, race_name, horse, position)
)
if c.fetchone():
continue
c.execute('''
INSERT INTO results (date, race_name, race_hippodrome, position, horse_name, odds)
VALUES (?, ?, ?, ?, ?, ?)
''', (date, race_name, hippodrome, position, horse, odds))
saved += c.rowcount
conn.commit()
conn.close()
return saved
def compare_predictions(date, race_name):
"""
Compare les prédictions du jour avec les résultats réels.
Retourne un dict avec les stats de performance.
"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# Récupérer toutes les prédictions du jour, puis dédoublonner en Python
# Priorité source : bases > chances > outsiders > partants
c.execute('''
SELECT horse_name, prediction_rank, source
FROM predictions
WHERE date=? AND source LIKE 'canalturf%'
ORDER BY prediction_rank ASC, odds ASC
''', (date,))
rows = c.fetchall()
# Dédoublonner : pour chaque cheval, garder la source la plus précise
SOURCE_PRIORITY = {
'canalturf_prono_bases': 1,
'canalturf_prono_chances': 2,
'canalturf_prono_outsiders': 3,
'canalturf_partants': 4,
'canalturf_selections': 5,
}
seen = {}
for horse, rank, source in rows:
prio = SOURCE_PRIORITY.get(source, 9)
if horse not in seen or prio < SOURCE_PRIORITY.get(seen[horse][2], 9):
seen[horse] = (horse, rank, source)
predictions = list(seen.values())
# Récupérer les résultats réels
c.execute('''
SELECT horse_name, position
FROM results
WHERE date=? AND race_name LIKE ?
ORDER BY position ASC
''', (date, f"%{race_name[:15]}%"))
results = {row[0]: row[1] for row in c.fetchall()}
if not results:
conn.close()
return None
# Top 5 réel
top5_real = {h for h, pos in results.items() if pos <= 5}
winner_real = next((h for h, pos in results.items() if pos == 1), None)
# Calcul des hits
hits_top5 = []
hits_winner = []
performance_rows = []
for horse, pred_rank, source in predictions:
actual_pos = results.get(horse, 99)
hit_top5 = horse in top5_real
hit_winner = horse == winner_real
if hit_top5:
hits_top5.append(horse)
if hit_winner:
hits_winner.append(horse)
# Sauvegarder en table performance (structure existante)
c.execute("SELECT id FROM performance WHERE prediction_date=? AND horse_name=?",
(date, horse))
if not c.fetchone():
c.execute('''
INSERT INTO performance
(prediction_date, race_date, horse_name, predicted_rank, actual_position, hit)
VALUES (?, ?, ?, ?, ?, ?)
''', (date, date, horse, pred_rank, actual_pos, hit_top5))
performance_rows.append({
"cheval": horse,
"pred_rank": pred_rank,
"actual_pos": actual_pos,
"hit_top5": hit_top5,
"hit_winner": hit_winner,
"source": source
})
conn.commit()
# Stats globales
bases = [p for p in performance_rows if p["source"] == "canalturf_prono_bases"]
chances = [p for p in performance_rows if p["source"] == "canalturf_prono_chances"]
outsiders = [p for p in performance_rows if p["source"] == "canalturf_prono_outsiders"]
partants = [p for p in performance_rows if p["source"] == "canalturf_partants"]
nb_pred = len(performance_rows)
nb_top5 = len(hits_top5)
stats = {
"date": date,
"race_name": race_name,
"total_predictions": nb_pred,
"hits_top5": nb_top5,
"hit_rate_top5": round(nb_top5 / nb_pred * 100, 1) if nb_pred else 0,
"winner": winner_real,
"winner_predicted": winner_real in [p["cheval"] for p in performance_rows],
"bases_hit": [p["cheval"] for p in bases if p["hit_top5"]],
"bases_miss": [p["cheval"] for p in bases if not p["hit_top5"]],
"top5_real": sorted([(h, pos) for h, pos in results.items() if pos <= 5], key=lambda x: x[1]),
"details": performance_rows
}
conn.close()
return stats
# ============================================================
# RAPPORT
# ============================================================
def print_report(stats):
"""Affiche un rapport détaillé en console."""
if not stats:
print("❌ Aucune donnée à comparer.")
return
print(f"\n{'='*60}")
print(f"📊 BILAN QUINTÉ+ — {stats['date']}")
print(f"🏇 {stats['race_name']}")
print(f"{'='*60}")
# Arrivée réelle
print(f"\n🏆 ARRIVÉE OFFICIELLE (Top 5) :")
for horse, pos in stats["top5_real"]:
print(f" {pos}. {horse}")
# Gagnant prédit ?
winner = stats["winner"]
if stats["winner_predicted"]:
print(f"\n✅ GAGNANT PRÉDIT : {winner}")
else:
print(f"\n❌ Gagnant non prédit : {winner}")
# Bases
print(f"\n⭐ BASES :")
for h in stats["bases_hit"]:
print(f"{h} (dans le top 5)")
for h in stats["bases_miss"]:
print(f"{h} (hors top 5)")
# Taux de réussite global
print(f"\n📈 TAUX DE RÉUSSITE : {stats['hit_rate_top5']}% ({stats['hits_top5']}/{stats['total_predictions']} chevaux dans le top 5)")
# Top 5 favori (cotes les plus basses)
partants_hits = [p for p in stats["details"] if p["source"] == "canalturf_partants" and p["hit_top5"]]
print(f"\n💰 FAVORIS PLACÉS : {', '.join([p['cheval'] for p in partants_hits]) or 'aucun'}")
print(f"{'='*60}\n")
def save_report_json(stats, date):
"""Sauvegarde le rapport en JSON pour archivage."""
path = f"{os.environ.get('TURF_DIR', '/home/h3r7/turf_scraper')}/perf_{date.replace('-','')}.json"
with open(path, 'w', encoding='utf-8') as f:
json.dump(stats, f, indent=2, ensure_ascii=False)
return path
# ============================================================
# MAIN
# ============================================================
def main():
today = datetime.now().strftime('%Y-%m-%d')
date_pmu = datetime.now().strftime('%d%m%Y')
print(f"\n{'='*60}")
print(f"🏇 RESULTS SCRAPER — {datetime.now().strftime('%d/%m/%Y %H:%M')}")
print(f"{'='*60}\n")
# Init BDD
init_db_results()
# Récupérer le programme
print("📡 Récupération du programme PMU...")
try:
reunions = get_programme(date_pmu)
print(f"{len(reunions)} réunion(s) trouvée(s)")
except Exception as e:
print(f" ❌ Erreur API PMU : {e}")
return
# Trouver le Quinté+
quinte = find_quinte(reunions)
if not quinte:
print(" ❌ Quinté+ non trouvé dans le programme")
return
num_r, num_c, libelle, hippodrome, arrivee_def = quinte
print(f" 🏇 Quinté+ : R{num_r}C{num_c}{libelle} ({hippodrome})")
print(f" Arrivée définitive : {'✅ OUI' if arrivee_def else '⏳ PAS ENCORE'}")
if not arrivee_def:
print("\n⚠️ La course n'est pas encore terminée. Relancez après la course.")
return
# Récupérer les participants avec résultats
print(f"\n📡 Récupération des résultats R{num_r}C{num_c}...")
try:
participants = get_participants(date_pmu, num_r, num_c)
print(f"{len(participants)} participants récupérés")
except Exception as e:
print(f" ❌ Erreur : {e}")
return
# Trier par position
classes = sorted(
[p for p in participants if p.get("ordreArrivee", 0) > 0],
key=lambda x: x["ordreArrivee"]
)
print(f"\n🏆 TOP 5 OFFICIEL :")
for p in classes[:5]:
cote = p.get("dernierRapportDirect", {}).get("rapport", "?") if p.get("dernierRapportDirect") else "?"
print(f" {p['ordreArrivee']}. {p['nom']:<25} cote={cote}")
# Sauvegarder les résultats
saved = save_results(today, libelle, hippodrome, participants)
print(f"\n💾 {saved} résultats sauvegardés en BDD")
# Comparer avec les prédictions
print(f"\n🔍 Comparaison avec les prédictions...")
stats = compare_predictions(today, libelle)
if stats:
print_report(stats)
path = save_report_json(stats, today)
print(f"📁 Rapport sauvegardé : {path}")
else:
print("⚠️ Aucune prédiction trouvée pour aujourd'hui en BDD.")
print(" Vérifiez que multi_scraper_v5.py a bien tourné ce matin.")
if __name__ == "__main__":
main()

5
run_api.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
# Toujours passer par systemd pour éviter les doublons
systemctl restart combined_api
sleep 2
systemctl is-active combined_api && echo "✅ API active" || echo "❌ API down"

4
run_api.sh.disabled Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
cd /home/h3r7/turf_scraper
source /home/h3r7/turf_scraper/venv/bin/activate
exec python3 combined_api.py

33
run_pmu_range.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# Début et fin
START="2026-01-01"
END="2026-03-21"
# Conversion en timestamps
start_ts=$(date -d "$START" +%s)
end_ts=$(date -d "$END" +%s)
echo "=== Lancement PMU du $START au $END ==="
current_ts=$start_ts
while [ $current_ts -le $end_ts ]; do
# Format JJMMAAAA
DATE=$(date -d "@$current_ts" +%d%m%Y)
echo "----------------------------------------"
echo "📅 Traitement date : $DATE"
echo "----------------------------------------"
# Exécution de la commande
python3 /home/h3r7/turf_scraper/pmu_results.py --date "$DATE" 2>&1 | head -60
# Pause légère pour éviter de spammer lAPI
sleep 1
# Date suivante
current_ts=$(( current_ts + 86400 ))
done
echo "=== Terminé ==="

4
run_results.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
export DB_PATH=/home/h3r7/turf_scraper/turf.db
export TURF_DIR=/home/h3r7/turf_scraper
python3 /home/h3r7/turf_scraper/pmu_results.py

4
run_scoring.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
export DB_PATH=/home/h3r7/turf_scraper/turf.db
export TURF_DIR=/home/h3r7/turf_scraper
python3 /home/h3r7/turf_scraper/scoring_v2.py

Some files were not shown because too many files have changed in this diff Show More