| <!DOCTYPE html> |
| <html lang="fr"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>SentinelAI · Mailage SEO GEO Anti-Parasite</title> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <script src="https://unpkg.com/lucide@latest"></script> |
| <script> |
| tailwind.config = { |
| theme: { |
| extend: { |
| colors: { |
| dark: { 950: '#0a0b0f', 900: '#0f1117', 800: '#161822', 700: '#1e2030', 600: '#272a3a', 500: '#3a3e52', 400: '#5c6078', 300: '#8b8fa6', 200: '#b4b7c8', 100: '#dfe0e8' }, |
| neon: { purple: '#a78bfa', green: '#34d399', blue: '#60a5fa', cyan: '#22d3ee', amber: '#fbbf24', red: '#f87171', pink: '#f472b6', orange: '#fb923c' }, |
| glass: { white: 'rgba(255,255,255,0.04)', border: 'rgba(255,255,255,0.08)', hover: 'rgba(255,255,255,0.07)' }, |
| }, |
| fontFamily: { |
| mono: ['"SF Mono"', '"Fira Code"', 'monospace'], |
| display: ['"Inter"', 'system-ui', 'sans-serif'], |
| } |
| } |
| } |
| } |
| </script> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet"> |
| <style> |
| * { box-sizing: border-box; } |
| body { font-family: 'Inter', system-ui, sans-serif; -webkit-font-smoothing: antialiased; } |
| @keyframes fadeInUp { from { opacity:0; transform:translateY(16px) } to { opacity:1; transform:translateY(0) } } |
| @keyframes pulse-dot { 0%,100%{opacity:1} 50%{opacity:0.3} } |
| @keyframes breathe { 0%,100%{opacity:0.6} 50%{opacity:1} } |
| @keyframes shimmer { 0%{background-position:-200% 0} 100%{background-position:200% 0} } |
| .anim-up { animation: fadeInUp 0.5s ease forwards; opacity: 0; } |
| .anim-up-1 { animation-delay: 0.05s; } |
| .anim-up-2 { animation-delay: 0.1s; } |
| .anim-up-3 { animation-delay: 0.15s; } |
| .anim-up-4 { animation-delay: 0.2s; } |
| .anim-up-5 { animation-delay: 0.25s; } |
| .anim-up-6 { animation-delay: 0.3s; } |
| .glow-card { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07); backdrop-filter: blur(12px); } |
| .glow-card:hover { background: rgba(255,255,255,0.06); border-color: rgba(255,255,255,0.12); } |
| .pulse-glow { animation: breathe 2s ease-in-out infinite; } |
| ::-webkit-scrollbar { width: 5px; } |
| ::-webkit-scrollbar-track { background: transparent; } |
| ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; } |
| .text-gradient-orange { |
| background: linear-gradient(135deg, #fbbf24, #fb923c, #f87171); |
| -webkit-background-clip: text; |
| -webkit-text-fill-color: transparent; |
| background-clip: text; |
| } |
| .grid-bg { |
| background-image: |
| linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px), |
| linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px); |
| background-size: 40px 40px; |
| } |
| .shimmer-bg { background: linear-gradient(90deg, transparent, rgba(255,255,255,0.04), transparent); background-size: 200% 100%; animation: shimmer 3s infinite; } |
| </style> |
| <script src="config.js"></script> |
| |
| <script src="https://js.puter.com/v2/"></script> |
| <script src="https://cdn.signalfx.com/o11y-gdi-rum/latest/splunk-otel-web.js" crossorigin="anonymous"></script> |
| <script> |
| (function() { |
| const cfg = window.SENTINEL_CONFIG?.splunkRUM; |
| if (cfg && cfg.token) { |
| SplunkRum.init({ beaconUrl: cfg.beaconUrl, rumAuth: cfg.token, app: cfg.app, environment: cfg.environment }); |
| } |
| })(); |
| </script> |
| </head> |
| <body class="bg-black text-dark-100 min-h-screen grid-bg overflow-x-hidden"> |
|
|
| |
| <div class="fixed inset-0 pointer-events-none overflow-hidden z-0"> |
| <div class="absolute top-0 left-1/3 w-[600px] h-[600px] rounded-full bg-neon-orange/5 blur-[150px]"></div> |
| <div class="absolute bottom-1/4 right-0 w-[500px] h-[500px] rounded-full bg-neon-purple/5 blur-[120px]"></div> |
| </div> |
|
|
| <div class="relative z-10 max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20"> |
|
|
| |
| <div class="flex items-center justify-between py-4 border-b border-glass-border anim-up"> |
| <div class="flex items-center gap-3"> |
| <a href="dashboard.html" class="text-dark-400 hover:text-white transition" title="Dashboard"><i data-lucide="layout-dashboard" class="w-5 h-5"></i></a> |
| <a href="soe.html" class="text-dark-400 hover:text-white transition" title="SOE Inverse"><i data-lucide="search" class="w-5 h-5"></i></a> |
| <a href="seo-rapid-geo.html" class="text-dark-400 hover:text-neon-green transition" title="SEO Rapid GEO"><i data-lucide="rocket" class="w-5 h-5"></i></a> |
| <a href="anti-parasite.html" class="text-dark-400 hover:text-white transition" title="Anti-Parasite"><i data-lucide="bug-off" class="w-5 h-5"></i></a> |
| <div class="w-9 h-9 rounded-xl bg-gradient-to-br from-neon-orange to-neon-red flex items-center justify-center shadow-lg shadow-neon-orange/20"> |
| <i data-lucide="map-pinned" class="w-5 h-5 text-white"></i> |
| </div> |
| <div> |
| <div class="text-sm font-bold tracking-tight text-white">Mailage SEO GEO <span class="text-gradient-orange">Anti-Parasite</span></div> |
| <div class="text-[10px] text-dark-400 tracking-[0.15em] uppercase">Mot clé · Cognitive · Avancé</div> |
| </div> |
| </div> |
| <div class="flex items-center gap-3"> |
| <div class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg glow-card"> |
| <span class="w-2 h-2 rounded-full bg-neon-green animate-pulse"></span> |
| <span class="text-[11px] text-dark-300">Pipeline actif</span> |
| </div> |
| <div class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg glow-card"> |
| <i data-lucide="clock" class="w-3.5 h-3.5 text-dark-400"></i> |
| <span id="geoClock" class="text-[11px] font-mono text-dark-300"></span> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="pt-8 pb-6 anim-up anim-up-1"> |
| <h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight text-white mb-2"> |
| Mailage <span class="text-gradient-orange">SEO GEO</span> Anti-Parasite |
| </h1> |
| <p class="text-sm text-dark-400 max-w-2xl">Génération cognitive avancée de contenu SEO géo-localisé pour neutraliser les résultats parasites. Multi-villes, multi-mots-clés, multi-angles — chaque contenu est unique, optimisé et ciblé géographiquement.</p> |
| </div> |
|
|
| |
| <div class="flex items-center gap-1 mb-6 anim-up anim-up-2" id="phaseIndicator"> |
| <span class="phase-dot w-2.5 h-2.5 rounded-full bg-neon-orange pulse-glow" data-phase="1"></span> |
| <span class="h-px w-8 bg-dark-500" data-phase="1-2"></span> |
| <span class="phase-dot w-2.5 h-2.5 rounded-full bg-dark-500" data-phase="2"></span> |
| <span class="h-px w-8 bg-dark-500" data-phase="2-3"></span> |
| <span class="phase-dot w-2.5 h-2.5 rounded-full bg-dark-500" data-phase="3"></span> |
| <span class="h-px w-8 bg-dark-500" data-phase="3-4"></span> |
| <span class="phase-dot w-2.5 h-2.5 rounded-full bg-dark-500" data-phase="4"></span> |
| <span class="ml-2 text-[10px] text-dark-500" id="phaseLabel">Cible & Géolocalisation</span> |
| </div> |
|
|
| |
| <div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-6"> |
|
|
| |
| <div class="lg:col-span-1 space-y-4 anim-up anim-up-3 lg:order-2"> |
|
|
| |
| <div class="glow-card rounded-xl p-4"> |
| <div class="flex items-center gap-2 mb-3"> |
| <div class="w-7 h-7 rounded-lg bg-neon-orange/10 flex items-center justify-center"> |
| <i data-lucide="target" class="w-4 h-4 text-neon-orange"></i> |
| </div> |
| <span class="text-xs font-semibold text-white uppercase tracking-wider">1. Cible</span> |
| </div> |
|
|
| <label class="block text-[11px] text-dark-400 mb-1.5">Sujet / entité à défendre</label> |
| <input id="subjectInput" type="text" |
| class="w-full px-3 py-2.5 rounded-lg bg-dark-700 border border-glass-border text-white text-sm placeholder:text-dark-500 outline-none focus:border-neon-orange transition mb-3" |
| placeholder="Nom, marque, personne…" /> |
|
|
| <label class="block text-[11px] text-dark-400 mb-1.5">Type de contenu parasite</label> |
| <div class="grid grid-cols-2 gap-1.5 mb-3" id="parasiteTypeGrid"></div> |
| </div> |
|
|
| |
| <div class="glow-card rounded-xl p-4"> |
| <div class="flex items-center gap-2 mb-3"> |
| <div class="w-7 h-7 rounded-lg bg-neon-cyan/10 flex items-center justify-center"> |
| <i data-lucide="map-pin" class="w-4 h-4 text-neon-cyan"></i> |
| </div> |
| <span class="text-xs font-semibold text-white uppercase tracking-wider">2. Ciblage géographique</span> |
| </div> |
|
|
| <div class="grid grid-cols-2 gap-2 mb-3"> |
| <div> |
| <label class="block text-[10px] text-dark-500 mb-1">Ville(s)</label> |
| <input id="geoCity" type="text" |
| class="w-full px-2.5 py-2 rounded-lg bg-dark-700 border border-glass-border text-white text-xs placeholder:text-dark-500 outline-none focus:border-neon-cyan transition" |
| placeholder="Paris, Lyon…" /> |
| </div> |
| <div> |
| <label class="block text-[10px] text-dark-500 mb-1">Région(s)</label> |
| <input id="geoRegion" type="text" |
| class="w-full px-2.5 py-2 rounded-lg bg-dark-700 border border-glass-border text-white text-xs placeholder:text-dark-500 outline-none focus:border-neon-cyan transition" |
| placeholder="Île-de-France…" /> |
| </div> |
| </div> |
| <div class="mb-3"> |
| <label class="block text-[10px] text-dark-500 mb-1">Département(s) — pour SEO local</label> |
| <input id="geoDept" type="text" |
| class="w-full px-2.5 py-2 rounded-lg bg-dark-700 border border-glass-border text-white text-xs placeholder:text-dark-500 outline-none focus:border-neon-cyan transition" |
| placeholder="75, 92, 93, 94…" /> |
| </div> |
|
|
| <label class="block text-[10px] text-dark-500 mb-1">Langue cible</label> |
| <select id="geoLang" class="w-full px-2.5 py-2 rounded-lg bg-dark-700 border border-glass-border text-white text-xs outline-none focus:border-neon-cyan transition mb-3"> |
| <option value="fr">🇫🇷 Français</option> |
| <option value="en">🇬🇧 English</option> |
| <option value="de">🇩🇪 Deutsch</option> |
| <option value="es">🇪🇸 Español</option> |
| <option value="it">🇮🇹 Italiano</option> |
| <option value="pt">🇧🇷 Português</option> |
| <option value="ar">🇸🇦 العربية</option> |
| </select> |
|
|
| |
| <label class="block text-[10px] text-dark-500 mb-1">Zones prédéfinies</label> |
| <div class="flex flex-wrap gap-1.5" id="geoPresets"></div> |
| </div> |
|
|
| |
| <div class="glow-card rounded-xl p-4"> |
| <div class="flex items-center gap-2 mb-3"> |
| <div class="w-7 h-7 rounded-lg bg-neon-amber/10 flex items-center justify-center"> |
| <i data-lucide="hash" class="w-4 h-4 text-neon-amber"></i> |
| </div> |
| <span class="text-xs font-semibold text-white uppercase tracking-wider">3. Mots-clés</span> |
| </div> |
|
|
| <div class="flex gap-2 mb-2"> |
| <input id="kwInput" type="text" |
| class="flex-1 px-2.5 py-2 rounded-lg bg-dark-700 border border-glass-border text-white text-xs placeholder:text-dark-500 outline-none focus:border-neon-amber transition" |
| placeholder="Ajouter un mot-clé…" /> |
| <button onclick="addKeyword()" class="px-3 py-2 rounded-lg border border-neon-amber/40 text-neon-amber text-xs font-medium hover:bg-neon-amber/10 transition">+</button> |
| </div> |
|
|
| <div class="text-[10px] text-dark-500 mb-1.5">Suggestions automatiques</div> |
| <div class="flex flex-wrap gap-1.5 mb-3" id="kwSuggestions"></div> |
|
|
| <div class="text-[10px] text-dark-500 mb-1.5">Mots-clés sélectionnés</div> |
| <div id="kwTags" class="flex flex-wrap gap-1.5 min-h-[28px]"></div> |
| </div> |
|
|
| |
| <button onclick="generateGeoContent()" id="generateBtn" disabled |
| class="w-full py-3 rounded-lg bg-gradient-to-r from-neon-orange to-neon-red text-dark-950 font-bold text-sm hover:opacity-90 active:scale-[0.98] transition-all disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"> |
| <i data-lucide="zap" class="w-4 h-4"></i> |
| Lancer la génération GEO cognitive |
| </button> |
|
|
| |
| <button onclick="generateGeoContentLlama()" id="generateLlamaBtn" disabled |
| class="w-full py-3 rounded-lg bg-gradient-to-r from-neon-purple to-neon-cyan text-white font-bold text-sm hover:opacity-90 active:scale-[0.98] transition-all disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2 mt-2"> |
| <i data-lucide="sparkles" class="w-4 h-4"></i> |
| 🦙 Générer avec Llama 4 (Gratuit · Illimité) |
| </button> |
|
|
| |
| <div class="mt-3 p-3 rounded-lg border border-glass-border bg-white/[0.02]"> |
| <div class="flex items-center gap-2 mb-2"> |
| <i data-lucide="grid-3x3" class="w-4 h-4 text-neon-amber"></i> |
| <span class="text-xs font-semibold text-white uppercase tracking-wider">Maillage 10K</span> |
| </div> |
| <p class="text-[10px] text-dark-400 mb-2">Génère jusqu'à 10 800 variations SEO GEO (10 villes × 25 mots-clés × 6 angles × formulations multiples) sans appeler l'IA pour chaque item.</p> |
| <div class="flex gap-2"> |
| <button onclick="generateBulkMesh10k()" id="mesh10kBtn" disabled |
| class="flex-1 py-2.5 rounded-lg bg-gradient-to-r from-neon-amber to-neon-orange text-dark-950 font-bold text-sm hover:opacity-90 active:scale-[0.98] transition-all disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"> |
| <i data-lucide="layers" class="w-4 h-4"></i> Générer maillage 10K |
| </button> |
| <button onclick="clearMeshResults()" id="clearMeshBtn" class="hidden px-3 py-2.5 rounded-lg border border-glass-border text-dark-300 text-xs hover:bg-white/5 transition">Effacer</button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="lg:col-span-2 space-y-4 anim-up anim-up-2 lg:order-1"> |
|
|
| |
| <div id="progressPanel" class="glow-card rounded-xl p-4 hidden"> |
| <div class="flex items-center gap-2 mb-3"> |
| <span class="inline-block w-4 h-4 border-2 border-neon-orange border-t-transparent rounded-full animate-spin"></span> |
| <span id="progressTitle" class="text-xs font-semibold text-white uppercase tracking-wider">Génération en cours…</span> |
| </div> |
| <div class="h-2 rounded-full bg-dark-700 overflow-hidden"> |
| <div id="progressBar" class="h-full rounded-full bg-gradient-to-r from-neon-orange to-neon-red transition-all duration-700 ease-out" style="width:0%"></div> |
| </div> |
| <div id="progressDetail" class="text-[10px] text-dark-400 mt-1.5">Initialisation…</div> |
| </div> |
|
|
| |
| <div id="contentPanel" class="hidden space-y-3"> |
|
|
| |
| <div class="glow-card rounded-xl p-4"> |
| <div class="flex items-center justify-between mb-3"> |
| <div class="flex items-center gap-2"> |
| <i data-lucide="layers" class="w-4 h-4 text-neon-orange"></i> |
| <span class="text-xs font-semibold text-white uppercase tracking-wider">Angles cognitifs générés</span> |
| </div> |
| <span id="angleCount" class="text-[10px] text-dark-500"></span> |
| </div> |
| <div id="angleGrid" class="grid grid-cols-2 sm:grid-cols-3 gap-2"></div> |
| </div> |
|
|
| |
| <div class="glow-card rounded-xl p-4"> |
| <div class="flex items-center justify-between mb-3"> |
| <div class="flex items-center gap-2"> |
| <i data-lucide="file-text" class="w-4 h-4 text-neon-purple"></i> |
| <span class="text-xs font-semibold text-white uppercase tracking-wider">Contenu SEO GEO généré</span> |
| </div> |
| <div class="flex items-center gap-2"> |
| <select id="contentFilter" onchange="filterContent()" class="text-[10px] bg-dark-700 border border-glass-border text-dark-200 rounded-md px-2 py-1 outline-none"> |
| <option value="all">Tous les angles</option> |
| </select> |
| <button onclick="copyAllContent()" class="text-[10px] text-neon-purple/70 hover:text-neon-purple transition flex items-center gap-1"> |
| <i data-lucide="copy" class="w-3 h-3"></i>Tout copier |
| </button> |
| </div> |
| </div> |
| <div id="contentCards" class="space-y-3 max-h-[600px] overflow-y-auto pr-1"></div> |
| </div> |
|
|
| |
| <div class="glow-card rounded-xl p-4"> |
| <div class="flex items-center gap-2 mb-3"> |
| <i data-lucide="bar-chart-3" class="w-4 h-4 text-neon-green"></i> |
| <span class="text-xs font-semibold text-white uppercase tracking-wider">Densité & distribution mot-clé</span> |
| </div> |
| <div id="densityGrid" class="grid grid-cols-2 sm:grid-cols-3 gap-2"></div> |
| </div> |
|
|
| |
| <div class="glow-card rounded-xl p-4"> |
| <div class="flex items-center justify-between mb-3"> |
| <div class="flex items-center gap-2"> |
| <i data-lucide="code-2" class="w-4 h-4 text-neon-cyan"></i> |
| <span class="text-xs font-semibold text-white uppercase tracking-wider">Méta-données SEO</span> |
| </div> |
| <button onclick="copyMetadata()" class="text-[10px] text-neon-cyan/70 hover:text-neon-cyan transition flex items-center gap-1"> |
| <i data-lucide="copy" class="w-3 h-3"></i>Copier |
| </button> |
| </div> |
| <pre id="metadataOutput" class="text-[11px] font-mono text-neon-cyan/80 leading-relaxed whitespace-pre-wrap bg-dark-900/50 rounded-lg p-3 border border-glass-border"></pre> |
| </div> |
|
|
| |
| <div class="glow-card rounded-xl p-4"> |
| <div class="flex items-center gap-2 mb-3"> |
| <i data-lucide="calendar" class="w-4 h-4 text-neon-amber"></i> |
| <span class="text-xs font-semibold text-white uppercase tracking-wider">Planning de diffusion</span> |
| </div> |
| <div id="scheduleGrid" class="space-y-1.5"></div> |
| <div class="mt-3 p-2.5 rounded-md bg-neon-amber/10 border border-neon-amber/20"> |
| <div class="text-[11px] text-neon-amber font-semibold mb-1">⚠ Rappel de renouvellement</div> |
| <div class="text-[10px] text-dark-400">Le contenu négatif peut revenir dans les résultats Google. Renouvelez le contenu positif tous les <strong class="text-white">7-14 jours</strong> et soumettez de nouvelles demandes de déréférencement via <a href="soe.html" class="text-neon-amber underline">SOE Inverse</a>.</div> |
| </div> |
| </div> |
|
|
| |
| <div class="glow-card rounded-xl p-4"> |
| <div class="flex items-center justify-between mb-3"> |
| <div class="flex items-center gap-2"> |
| <i data-lucide="bug-off" class="w-4 h-4 text-neon-red"></i> |
| <span class="text-xs font-semibold text-white uppercase tracking-wider">Analyse anti-parasite</span> |
| </div> |
| <span id="parasiteScore" class="text-[10px] px-2 py-0.5 rounded bg-dark-700 text-dark-400 font-medium"></span> |
| </div> |
| <div id="parasiteGrid" class="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-3"></div> |
| <div class="flex items-center gap-2"> |
| <a href="anti-parasite.html" class="text-[10px] px-3 py-1.5 rounded border border-neon-red/40 text-neon-red hover:bg-neon-red/10 transition flex items-center gap-1"> |
| <i data-lucide="bug-off" class="w-3 h-3"></i>Classifier avec BERT |
| </a> |
| <a href="content-scan.html" class="text-[10px] px-3 py-1.5 rounded border border-neon-purple/40 text-neon-purple hover:bg-neon-purple/10 transition flex items-center gap-1"> |
| <i data-lucide="scan-search" class="w-3 h-3"></i>Scanner le contenu |
| </a> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="emptyState" class="glow-card rounded-xl p-8 text-center"> |
| <i data-lucide="map-pinned" class="w-12 h-12 mx-auto text-dark-600 mb-3"></i> |
| <div class="text-sm font-medium text-dark-300 mb-1">Configurez votre mailage SEO GEO</div> |
| <div class="text-[11px] text-dark-500">Entrez un sujet, sélectionnez le type de contenu parasite, et lancez la génération cognitive avancée.</div> |
| </div> |
|
|
| |
| <div id="mesh10kPanel" class="hidden space-y-3"> |
| <div class="glow-card rounded-xl p-4"> |
| <div class="flex items-center justify-between mb-3"> |
| <div class="flex items-center gap-2"> |
| <i data-lucide="grid-3x3" class="w-4 h-4 text-neon-amber"></i> |
| <span class="text-xs font-semibold text-white uppercase tracking-wider">Maillage 10K généré</span> |
| <span id="meshCountBadge" class="text-[10px] px-1.5 py-0.5 rounded bg-neon-amber/15 text-neon-amber border border-neon-amber/30 font-semibold">0</span> |
| </div> |
| <div class="flex items-center gap-2"> |
| <button onclick="copyMeshResults()" class="text-[10px] px-2 py-1 rounded border border-glass-border text-dark-300 hover:text-white transition">Copier</button> |
| <button onclick="exportMeshAsJSON()" class="text-[10px] px-2 py-1 rounded border border-glass-border text-dark-300 hover:text-white transition">JSON</button> |
| <button onclick="exportMeshAsCSV()" class="text-[10px] px-2 py-1 rounded border border-glass-border text-dark-300 hover:text-white transition">CSV</button> |
| <button onclick="exportMeshAsTXT()" class="text-[10px] px-2 py-1 rounded border border-glass-border text-dark-300 hover:text-white transition">TXT</button> |
| </div> |
| </div> |
| <div class="p-3 rounded-lg bg-dark-900/50 border border-glass-border max-h-[400px] overflow-y-auto"> |
| <div id="meshPreview" class="text-[11px] font-mono text-dark-200 leading-relaxed space-y-1"></div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| </div> |
|
|
| |
| <div id="toast" class="fixed bottom-6 right-6 z-50 hidden"> |
| <div class="bg-dark-700 text-white text-xs font-medium px-4 py-2.5 rounded-lg shadow-lg border border-glass-border flex items-center gap-2"> |
| <i data-lucide="check-circle" class="w-4 h-4 text-neon-green"></i> |
| <span id="toastMsg"></span> |
| </div> |
| </div> |
|
|
| <script> |
| // ═══ CONSTANTS ═════════════════════════════════════════════════════════════ |
| const PARASITE_TYPES = [ |
| { id: "defamation", label: "Diffamation", icon: "scale", color: "#a78bfa" }, |
| { id: "fake_news", label: "Fausse info", icon: "newspaper", color: "#f87171" }, |
| { id: "hate", label: "Contenu haineux", icon: "ban", color: "#fb923c" }, |
| { id: "dox", label: "Doxxing / données perso", icon: "lock", color: "#f472b6" }, |
| { id: "impersonation", label: "Usurpation", icon: "user-x", color: "#22d3ee" }, |
| { id: "reputation", label: "Atteinte réputation", icon: "trending-down", color: "#fbbf24" }, |
| ]; |
| |
| const GEO_PRESETS = [ |
| { label: "🇫🇷 Île-de-France", city: "Paris", region: "Île-de-France", dept: "75,92,93,94" }, |
| { label: "🇫🇷 Auvergne-Rhône-Alpes", city: "Lyon", region: "Auvergne-Rhône-Alpes", dept: "69" }, |
| { label: "🇫🇷 Provence-Alpes-Côte d'Azur", city: "Marseille", region: "PACA", dept: "13" }, |
| { label: "🇫🇷 Occitanie", city: "Toulouse", region: "Occitanie", dept: "31" }, |
| { label: "🇫🇷 Nouvelle-Aquitaine", city: "Bordeaux", region: "Nouvelle-Aquitaine", dept: "33" }, |
| { label: "🇫🇷 Hauts-de-France", city: "Lille", region: "Hauts-de-France", dept: "59" }, |
| { label: "🇫🇷 Grand Est", city: "Strasbourg", region: "Grand Est", dept: "67" }, |
| { label: "🇫🇷 Pays de la Loire", city: "Nantes", region: "Pays de la Loire", dept: "44" }, |
| { label: "🇧🇪 Bruxelles", city: "Bruxelles", region: "Bruxelles-Capitale", dept: "1000" }, |
| { label: "🇨🇭 Romandie", city: "Genève", region: "Romandie", dept: "GE" }, |
| ]; |
| |
| const LANG_ANGLES = { |
| fr: { label: "Français", angleLabels: ["juridique", "factuel", "communauté", "expert", "historique", "pratique"] }, |
| en: { label: "English", angleLabels: ["legal", "factual", "community", "expert", "historical", "practical"] }, |
| de: { label: "Deutsch", angleLabels: ["rechtlich", "sachlich", "gemeinschaft", "experte", "historisch", "praktisch"] }, |
| es: { label: "Español", angleLabels: ["jurídico", "factual", "comunidad", "experto", "histórico", "práctico"] }, |
| }; |
| |
| const ANGLE_CONFIGS = { |
| juridique: { |
| icon: "scale", color: "#a78bfa", |
| prefixes: { |
| fr: ["Guide juridique", "Le cadre légal de", "Droit et recours sur", "Ce que dit la loi sur"], |
| en: ["Legal Guide: Understanding", "The Legal Framework for", "Know Your Rights About"], |
| de: ["Rechtlicher Leitfaden", "Der rechtliche Rahmen für", "Ihre Rechte bei"], |
| es: ["Guía jurídica sobre", "El marco legal de", "Sus derechos respecto a"], |
| }, |
| seoTitleTpls: { |
| fr: ["{kw} · Guide juridique complet {city}", "Droit à l'image {city} : recours et procédures"], |
| en: ["{kw} · Complete Legal Guide {city}", "Image rights {city}: remedies and procedures"], |
| } |
| }, |
| factuel: { |
| icon: "bar-chart-2", color: "#60a5fa", |
| prefixes: { |
| fr: ["Les faits sur", "Données vérifiées", "Rapport annuel sur", "Statistiques officielles"], |
| en: ["The Facts About", "Verified Data on", "Annual Report on"], |
| de: ["Die Fakten über", "Verifizierte Daten zu", "Jahresbericht über"], |
| es: ["Los hechos sobre", "Datos verificados de", "Informe anual sobre"], |
| }, |
| seoTitleTpls: { |
| fr: ["{kw} : chiffres clés {city} {year}", "Comprendre {kw} à {city} en 5 minutes"], |
| en: ["{kw}: Key Figures {city} {year}", "Understanding {kw} in {city}"], |
| } |
| }, |
| communauté: { |
| icon: "users", color: "#34d399", |
| prefixes: { |
| fr: ["Communauté", "Forum", "Retours d'expérience sur", "Témoignages positifs"], |
| en: ["Community", "Forum", "Positive Reviews About", "User Experiences"], |
| de: ["Gemeinschaft", "Forum", "Positive Erfahrungen über", "Nutzerbewertungen"], |
| es: ["Comunidad", "Foro", "Testimonios positivos sobre", "Experiencias de usuarios"], |
| }, |
| seoTitleTpls: { |
| fr: ["Avis vérifiés {kw} {city}", "Communauté {kw} · {city} — entraide et solutions"], |
| en: ["Verified Reviews {kw} {city}", "{kw} Community {city} — support and solutions"], |
| } |
| }, |
| expert: { |
| icon: "graduation-cap", color: "#fbbf24", |
| prefixes: { |
| fr: ["L'avis de l'expert sur", "Analyse technique :", "Recommandations d'un professionnel", "Interview experte"], |
| en: ["Expert Opinion on", "Technical Analysis:", "Professional Recommendations", "Expert Interview"], |
| de: ["Experteneinschätzung zu", "Technische Analyse:", "Professionelle Empfehlungen"], |
| es: ["Opinión experta sobre", "Análisis técnico:", "Recomendaciones profesionales"], |
| }, |
| seoTitleTpls: { |
| fr: ["{kw} : l'analyse d'un expert {city}", "Avis professionnel {kw} — {city} {year}"], |
| en: ["{kw}: Expert Analysis {city}", "Professional Opinion {kw} — {city}"], |
| } |
| }, |
| historique: { |
| icon: "clock", color: "#f472b6", |
| prefixes: { |
| fr: ["L'histoire de", "Contexte et évolution de", "Chronologie de", "Comprendre le contexte de"], |
| en: ["The History of", "Context and Evolution of", "Timeline of", "Understanding the Context of"], |
| de: ["Die Geschichte von", "Kontext und Entwicklung von", "Zeitlinie von"], |
| es: ["La historia de", "Contexto y evolución de", "Cronología de"], |
| }, |
| seoTitleTpls: { |
| fr: ["Histoire de {kw} · {city} : origines et perspectives", "{kw} à {city} : 10 ans d'évolution"], |
| en: ["History of {kw} · {city}: Origins and Perspectives", "{kw} in {city}: 10 Years of Evolution"], |
| } |
| }, |
| pratique: { |
| icon: "wrench", color: "#22d3ee", |
| prefixes: { |
| fr: ["Guide pratique pour", "Comment faire face à", "Étapes à suivre pour", "Checklist :"], |
| en: ["Practical Guide for", "How to Deal With", "Steps to Take for", "Checklist:"], |
| de: ["Praktischer Leitfaden für", "Wie man umgeht mit", "Schritte zum"], |
| es: ["Guía práctica para", "Cómo hacer frente a", "Pasos a seguir para"], |
| }, |
| seoTitleTpls: { |
| fr: ["Guide pratique {kw} {city} {year}", "Comment gérer {kw} à {city} — étapes et recours"], |
| en: ["Practical Guide {kw} {city} {year}", "How to handle {kw} in {city}"], |
| } |
| }, |
| }; |
| |
| const KW_SUGGESTIONS_MAP = { |
| defamation: ["{sujet} arnaque", "{sujet} fraude", "{sujet} escroquerie", "{sujet} avis", "{sujet} officiel", "{sujet} contact", "{sujet} fiable", "{sujet} légitime"], |
| fake_news: ["{sujet} fact check", "{sujet} vrai ou faux", "{sujet} rumeur", "{sujet} vérifié", "{sujet} source officielle", "{sujet} réalité"], |
| hate: ["{sujet} inclusion", "{sujet} respect", "{sujet} vérité", "{sujet} communauté", "{sujet} dialogue"], |
| dox: ["{sujet} officiel", "{sujet} contact légal", "{sujet} RGPD", "{sujet} vie privée", "{sujet} droits"], |
| impersonation: ["{sujet} vrai compte", "{sujet} officiel", "{sujet} authentique", "{sujet} vérifié"], |
| reputation: ["{sujet} succès", "{sujet} réalisations", "{sujet} avis positifs", "{sujet} références", "{sujet} sérieux"], |
| }; |
| |
| const PUBLISHING_CHANNELS = [ |
| { name: "Site web officiel", platform: "WordPress / CMS", delay: "J+0", impact: 5 }, |
| { name: "Article de blog", platform: "Medium / Substack", delay: "J+0", impact: 4 }, |
| { name: "Communiqué de presse", platform: "AFP / PR Newswire", delay: "J+1", impact: 5 }, |
| { name: "Profil LinkedIn", platform: "LinkedIn", delay: "J+0", impact: 4 }, |
| { name: "Page Google Business", platform: "Google Business", delay: "J+1", impact: 5 }, |
| { name: "Trustpilot / Avis", platform: "Trustpilot / Google Avis", delay: "J+1", impact: 4 }, |
| { name: "Réseau social X", platform: "X (Twitter)", delay: "J+0", impact: 3 }, |
| { name: "Vidéo explicative", platform: "YouTube", delay: "J+3-7", impact: 5 }, |
| { name: "FAQ locale", platform: "Google Q&A", delay: "J+1", impact: 3 }, |
| { name: "Fiche Wikipédia", platform: "Wikipedia", delay: "J+7", impact: 5 }, |
| ]; |
| |
| // ═══ STATE ═══════════════════════════════════════════════════════════════════ |
| let state = { |
| subject: "", |
| parasiteType: null, |
| keywords: [], |
| geo: { city: "", region: "", dept: "", lang: "fr" }, |
| generatedContent: [], |
| bulkMesh: [], |
| phase: 1, |
| isGenerating: false, |
| }; |
| |
| // ═══ PUTER.JS V2 — LLAMA GRATUIT & ILLIMITÉ ═════════════════════════════════ |
| let puterReady = false; |
| |
| async function loginToPuter() { |
| const auth = window.SENTINEL_CONFIG?.puterAuth; |
| if (!auth || !auth.email || !auth.password) { |
| console.log("[Puter] Pas d'identifiants configurés — mode anonyme"); |
| puterReady = true; // Mode anonyme possible |
| return; |
| } |
| try { |
| await puter.auth.signIn(auth.email, auth.password); |
| puterReady = true; |
| console.log("[Puter] ✅ Connecté —", auth.email); |
| showToast("🟣 Puter connecté — Llama gratuit activé"); |
| } catch (e) { |
| console.warn("[Puter] ⚠️ Login échoué, mode anonyme:", e.message); |
| puterReady = true; // Continue en mode anonyme |
| } |
| } |
| |
| async function puterLlamaChat(prompt, { model = 'meta-llama/llama-4-maverick', stream = false } = {}) { |
| if (!puterReady) await loginToPuter(); |
| try { |
| if (stream) { |
| const response = await puter.ai.chat(prompt, { model, stream: true }); |
| let fullText = ''; |
| for await (const part of response) { |
| fullText += part?.text || ''; |
| } |
| return fullText; |
| } else { |
| const response = await puter.ai.chat(prompt, { model }); |
| return response?.message?.content || response?.message || ''; |
| } |
| } catch (e) { |
| console.error("[Puter Llama] Erreur:", e.message); |
| throw new Error(`Puter Llama error: ${e.message}`); |
| } |
| } |
| |
| // ═══ INIT ════════════════════════════════════════════════════════════════════ |
| document.addEventListener("DOMContentLoaded", () => { |
| updateClock(); |
| setInterval(updateClock, 1000); |
| renderParasiteTypes(); |
| renderGeoPresets(); |
| renderKwSuggestions(); |
| setupListeners(); |
| loginToPuter(); // Connexion auto à Puter |
| lucide.createIcons(); |
| }); |
| |
| function updateClock() { |
| const now = new Date(); |
| document.getElementById("geoClock").textContent = `${String(now.getHours()).padStart(2,"0")}:${String(now.getMinutes()).padStart(2,"0")}:${String(now.getSeconds()).padStart(2,"0")}`; |
| } |
| |
| // ═══ RENDER ══════════════════════════════════════════════════════════════════ |
| function renderParasiteTypes() { |
| const grid = document.getElementById("parasiteTypeGrid"); |
| grid.innerHTML = PARASITE_TYPES.map(t => { |
| const isActive = state.parasiteType === t.id; |
| return `<button onclick="selectType('${t.id}')" |
| class="p-2 rounded-lg text-left flex items-center gap-1.5 border transition text-[11px] ${isActive ? 'font-semibold bg-dark-700 border-transparent' : 'font-normal bg-dark-800 border-glass-border text-dark-300 hover:border-glass-hover'}" |
| style="${isActive ? `background:${t.color}15;border-color:${t.color};color:${t.color}` : ''}"> |
| <i data-lucide="${t.icon}" class="w-3.5 h-3.5 shrink-0" style="${isActive ? `color:${t.color}` : ''}"></i> |
| <span class="truncate">${t.label}</span> |
| </button>`; |
| }).join(""); |
| lucide.createIcons(); |
| } |
| |
| function selectType(id) { |
| state.parasiteType = id; |
| renderParasiteTypes(); |
| renderKwSuggestions(); |
| updateGenerateBtn(); |
| } |
| |
| function renderGeoPresets() { |
| const el = document.getElementById("geoPresets"); |
| el.innerHTML = GEO_PRESETS.map(p => |
| `<button onclick="applyGeoPreset('${p.city}','${p.region}','${p.dept}')" |
| class="text-[10px] px-2 py-1 rounded border border-glass-border text-dark-300 hover:bg-white/5 hover:text-white transition">${p.label}</button>` |
| ).join(""); |
| } |
| |
| function applyGeoPreset(city, region, dept) { |
| document.getElementById("geoCity").value = city; |
| document.getElementById("geoRegion").value = region; |
| document.getElementById("geoDept").value = dept; |
| state.geo = { city, region, dept, lang: document.getElementById("geoLang").value }; |
| showToast(`Zone GEO : ${city}, ${region}`); |
| } |
| |
| function renderKwSuggestions() { |
| const el = document.getElementById("kwSuggestions"); |
| if (!state.parasiteType || !state.subject.trim()) { |
| el.innerHTML = '<span class="text-[10px] text-dark-500 italic">Entrez un sujet et un type pour voir les suggestions</span>'; |
| return; |
| } |
| const suggestions = (KW_SUGGESTIONS_MAP[state.parasiteType] || []).map(k => |
| k.replace(/\{sujet\}/g, state.subject) |
| ); |
| el.innerHTML = suggestions.map(kw => { |
| const exists = state.keywords.includes(kw); |
| return `<button onclick="addKw('${kw.replace(/'/g, "\\'")}')" |
| class="text-[10px] px-2 py-1 rounded border transition ${exists ? 'bg-neon-amber/10 text-neon-amber border-neon-amber/30' : 'border-glass-border text-dark-400 hover:text-white hover:border-glass-hover'}" |
| ${exists ? 'disabled' : ''}>${exists ? '✓ ' : '+ '}${kw}</button>`; |
| }).join(""); |
| } |
| |
| function renderKwTags() { |
| const el = document.getElementById("kwTags"); |
| el.innerHTML = state.keywords.map(kw => |
| `<span class="text-[10px] px-2 py-1 rounded-full bg-neon-amber/10 text-neon-amber border border-neon-amber/30 flex items-center gap-1"> |
| ${kw} |
| <span onclick="removeKw('${kw.replace(/'/g, "\\'")}')" class="cursor-pointer font-bold text-[12px] text-neon-amber hover:text-neon-amber/60">×</span> |
| </span>` |
| ).join(""); |
| } |
| |
| function addKw(kw) { |
| if (!state.keywords.includes(kw) && kw.trim()) { |
| state.keywords.push(kw.trim()); |
| renderKwSuggestions(); |
| renderKwTags(); |
| updateGenerateBtn(); |
| } |
| } |
| |
| function addKeyword() { |
| const input = document.getElementById("kwInput"); |
| const kw = input.value.trim(); |
| if (kw) { addKw(kw); input.value = ""; } |
| } |
| |
| function removeKw(kw) { |
| state.keywords = state.keywords.filter(k => k !== kw); |
| renderKwSuggestions(); |
| renderKwTags(); |
| updateGenerateBtn(); |
| } |
| |
| function updateGenerateBtn() { |
| const ready = !!(state.subject.trim() && state.parasiteType); |
| document.getElementById("generateBtn").disabled = !ready; |
| const llamaBtn = document.getElementById("generateLlamaBtn"); |
| if (llamaBtn) llamaBtn.disabled = !ready; |
| const meshBtn = document.getElementById("mesh10kBtn"); |
| if (meshBtn) meshBtn.disabled = !ready; |
| } |
| |
| function setupListeners() { |
| document.getElementById("subjectInput").addEventListener("input", (e) => { |
| state.subject = e.target.value; |
| renderKwSuggestions(); |
| updateGenerateBtn(); |
| }); |
| document.getElementById("kwInput").addEventListener("keydown", (e) => { |
| if (e.key === "Enter") { e.preventDefault(); addKeyword(); } |
| }); |
| ["geoCity", "geoRegion", "geoDept"].forEach(id => { |
| document.getElementById(id).addEventListener("input", updateGeoState); |
| }); |
| document.getElementById("geoLang").addEventListener("change", updateGeoState); |
| } |
| |
| function updateGeoState() { |
| state.geo.city = document.getElementById("geoCity").value.trim(); |
| state.geo.region = document.getElementById("geoRegion").value.trim(); |
| state.geo.dept = document.getElementById("geoDept").value.trim(); |
| state.geo.lang = document.getElementById("geoLang").value; |
| } |
| |
| // ═══ GENERATION LLAMA (PUTER.JS — GRATUIT & ILLIMITÉ) ═════════════════════ |
| async function generateGeoContentLlama() { |
| if (state.isGenerating) return; |
| state.isGenerating = true; |
| |
| updateGeoState(); |
| const btn = document.getElementById("generateLlamaBtn"); |
| btn.disabled = true; |
| btn.innerHTML = '<span class="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span> Llama génère…'; |
| |
| const progressPanel = document.getElementById("progressPanel"); |
| const progressBar = document.getElementById("progressBar"); |
| const progressDetail = document.getElementById("progressDetail"); |
| const progressTitle = document.getElementById("progressTitle"); |
| const contentPanel = document.getElementById("contentPanel"); |
| const emptyState = document.getElementById("emptyState"); |
| |
| progressPanel.classList.remove("hidden"); |
| contentPanel.classList.add("hidden"); |
| emptyState.classList.add("hidden"); |
| progressBar.style.width = "0%"; |
| |
| const city = state.geo.city || "[ville]"; |
| const region = state.geo.region || "[région]"; |
| const dept = state.geo.dept || ""; |
| const lang = state.geo.lang || "fr"; |
| const subject = state.subject || ""; |
| const keywords = state.keywords.length > 0 ? state.keywords : [subject + " avis", subject + " officiel", subject + " fiable"]; |
| const langConfig = LANG_ANGLES[lang] || LANG_ANGLES.fr; |
| const angleIds = langConfig.angleLabels; |
| const year = new Date().getFullYear(); |
| const primaryKw = keywords[0] || subject; |
| const geoPhrase = city !== "[ville]" ? ` à ${city}` : ''; |
| |
| progressTitle.textContent = "🦙 Génération Llama 4 Maverick…"; |
| progressDetail.textContent = "Phase 1 : Génération des templates par angle…"; |
| progressBar.style.width = "15%"; |
| await sleep(300); |
| |
| // Phase 1: Template generation (fast, local) |
| state.generatedContent = []; |
| for (const angleId of angleIds) { |
| const config = ANGLE_CONFIGS[angleId] || ANGLE_CONFIGS["factuel"]; |
| const prefixes = config.prefixes[lang] || config.prefixes["fr"]; |
| const seoTitles = config.seoTitleTpls[lang] || config.seoTitleTpls["fr"]; |
| |
| for (const prefix of prefixes) { |
| const title = `${prefix} ${subject}${city !== "[ville]" ? ` à ${city}` : ''}`; |
| const geoTitle1 = seoTitles[0] |
| ? seoTitles[0].replace(/\{kw\}/g, subject).replace(/\{city\}/g, city).replace(/\{year\}/g, year) |
| : `${subject} — ${prefix.toLowerCase()} ${city}`; |
| const geoTitle2 = seoTitles[1] |
| ? seoTitles[1].replace(/\{kw\}/g, subject).replace(/\{city\}/g, city).replace(/\{year\}/g, year) |
| : `${subject} · ${city} ${year}`; |
| |
| const body = generateBody(angleId, subject, city, region, dept, keywords, lang, year); |
| |
| state.generatedContent.push({ |
| angle: angleId, |
| angleConfig: config, |
| title, |
| geoTitles: [geoTitle1, geoTitle2], |
| body, |
| keywords: keywords.slice(0, 4), |
| geo: { city, region, dept }, |
| lang, |
| wordCount: body.split(/\s+/).length, |
| charCount: body.length, |
| }); |
| } |
| } |
| |
| progressBar.style.width = "40%"; |
| progressDetail.textContent = "Phase 2 : 🦙 Enrichissement avec Llama 4 Maverick…"; |
| await sleep(200); |
| |
| // Phase 2: Llama 4 AI Enhancement (Puter.js — gratuit & illimité) |
| let llamaEnhanced = false; |
| try { |
| const systemPrompt = `Tu es un expert SEO GEO et SOE (Search Opinion Engineering) pour SentinelAI. Tu génères du contenu positif/neutre optimisé pour le référencement géographique local. Ton contenu doit être factuel, professionnel, jurisprudentiel et crédible. Langue: ${langConfig.label}.`; |
| |
| const llamaPrompt = `Génère UN article SEO complet (400-600 mots) pour neutraliser les contenus parasites sur : |
| |
| Sujet : ${subject} |
| Ville : ${city} |
| Région : ${region} |
| Mots-clés cibles : ${keywords.join(", ")} |
| Langue : ${langConfig.label} |
| |
| L'article doit : |
| 1. Contenir naturellement les mots-clés cibles (densité 2-3%) |
| 2. Inclure le nom de la ville et de la région dans les premiers paragraphes |
| 3. Être structuré avec des sous-titres H2/H3 |
| 4. Avoir un ton factuel et institutionnel |
| 5. Inclure des références juridiques pertinentes (Art. 9 CC, RGPD, Loi 1881) |
| 6. Se terminer par une FAQ avec 3 questions/réponses |
| 7. Être prêt à publier directement |
| 8. Être en ${langConfig.label}`; |
| |
| const fullPrompt = systemPrompt + "\n\n" + llamaPrompt; |
| |
| const llamaContent = await puterLlamaChat(fullPrompt, { |
| model: 'meta-llama/llama-4-maverick', |
| stream: false |
| }); |
| |
| if (llamaContent && llamaContent.trim().length > 100) { |
| // Add Llama-enhanced content as first entry |
| state.generatedContent.unshift({ |
| angle: "llama_cognitif", |
| angleConfig: { icon: "sparkles", color: "#a78bfa" }, |
| title: `[🦙 Llama 4] ${subject}${city !== "[ville]" ? ` — ${city}` : ''}`, |
| geoTitles: [ |
| `${subject}${city !== "[ville]" ? ` à ${city}` : ''} : analyse approfondie ${year}`, |
| `${subject} · ${city} · Rapport complet ${year}` |
| ], |
| body: llamaContent, |
| keywords: keywords.slice(0, 4), |
| geo: { city, region, dept }, |
| lang, |
| wordCount: llamaContent.split(/\s+/).length, |
| charCount: llamaContent.length, |
| isAI: true, |
| isLlama: true, |
| }); |
| llamaEnhanced = true; |
| } |
| } catch (e) { |
| console.log("[Llama] Enhancement failed:", e.message); |
| } |
| |
| progressBar.style.width = "70%"; |
| progressDetail.textContent = "Phase 3 : Génération du contenu inversé (contre-SEO)…"; |
| await sleep(200); |
| |
| // Phase 3: Reverse SEO — generate content that targets the negative URLs directly |
| try { |
| const reversePrompt = `Tu es un expert en SEO négatif inversé (Reverse SOE) pour SentinelAI. Ta mission : générer du contenu positif qui va NOYER les URLs négatives dans les résultats Google. |
| |
| Sujet à défendre : ${subject} |
| Ville cible : ${city} |
| Région : ${region} |
| Mots-clés négatifs à neutraliser : ${keywords.join(", ")} |
| Langue : ${langConfig.label} |
| |
| Génère 5 VARIANTES de contenu SEO optimisé, CHACUNE étant : |
| - Unique et différente des autres (angles variés) |
| - Contenant les mots-clés cibles de façon naturelle |
| - Mentionnant ${city} et ${region} |
| - Format : chaque variante précédée de "V1:", "V2:", etc. |
| - 3-4 phrases par variante |
| - Ton professionnel, institutionnel, factuel |
| |
| Objectif : Ces 5 variantes seront publiées sur différentes plateformes pour repousser les résultats négatifs en page 2-3 de Google.`; |
| |
| const reverseContent = await puterLlamaChat(reversePrompt, { |
| model: 'meta-llama/llama-4-maverick', |
| stream: false |
| }); |
| |
| if (reverseContent && reverseContent.trim().length > 50) { |
| // Parse variants |
| const variants = reverseContent.split(/V\d+:/i).filter(v => v.trim().length > 20); |
| variants.forEach((variant, i) => { |
| if (variant.trim().length > 20) { |
| state.generatedContent.push({ |
| angle: "inverse_llama", |
| angleConfig: { icon: "refresh-cw", color: "#f87171" }, |
| title: `[🦙 Inverse #${i + 1}] ${subject} — ${city}`, |
| geoTitles: [ |
| `${subject} ${city} — information vérifiée #${i + 1}`, |
| `${primaryKw} ${city} — source officielle ${year}` |
| ], |
| body: variant.trim(), |
| keywords: keywords.slice(0, 4), |
| geo: { city, region, dept }, |
| lang, |
| wordCount: variant.trim().split(/\s+/).length, |
| charCount: variant.trim().length, |
| isAI: true, |
| isLlama: true, |
| isReverse: true, |
| }); |
| } |
| }); |
| llamaEnhanced = true; |
| } |
| } catch (e) { |
| console.log("[Llama] Reverse SEO failed:", e.message); |
| } |
| |
| progressBar.style.width = "90%"; |
| progressDetail.textContent = "Phase 4 : Analyse & planification…"; |
| await sleep(200); |
| |
| renderResults(keywords, city, region, dept, year, langConfig); |
| |
| progressBar.style.width = "100%"; |
| progressDetail.textContent = "🦙 Génération Llama terminée ✓"; |
| await sleep(500); |
| progressPanel.classList.add("hidden"); |
| |
| state.isGenerating = false; |
| btn.disabled = false; |
| btn.innerHTML = '<i data-lucide="sparkles" class="w-4 h-4"></i> 🦙 Générer avec Llama 4 (Gratuit · Illimité)'; |
| lucide.createIcons(); |
| showToast(`${state.generatedContent.length} contenus GEO générés avec Llama 4 🦙${llamaEnhanced ? ' + IA cognitive' : ''}`); |
| } |
| |
| // ═══ GENERATION (OpenRouter — existant) ════════════════════════════════════ |
| async function generateGeoContent() { |
| if (state.isGenerating) return; |
| state.isGenerating = true; |
| |
| updateGeoState(); |
| const btn = document.getElementById("generateBtn"); |
| btn.disabled = true; |
| btn.innerHTML = '<span class="inline-block w-4 h-4 border-2 border-dark-950 border-t-transparent rounded-full animate-spin"></span> Génération…'; |
| |
| const progressPanel = document.getElementById("progressPanel"); |
| const progressBar = document.getElementById("progressBar"); |
| const progressDetail = document.getElementById("progressDetail"); |
| const progressTitle = document.getElementById("progressTitle"); |
| const contentPanel = document.getElementById("contentPanel"); |
| const emptyState = document.getElementById("emptyState"); |
| |
| progressPanel.classList.remove("hidden"); |
| contentPanel.classList.add("hidden"); |
| emptyState.classList.add("hidden"); |
| progressBar.style.width = "0%"; |
| |
| const city = state.geo.city || "[ville]"; |
| const region = state.geo.region || "[région]"; |
| const dept = state.geo.dept || ""; |
| const lang = state.geo.lang || "fr"; |
| const subject = state.subject || ""; |
| const keywords = state.keywords.length > 0 ? state.keywords : [subject + " avis", subject + " officiel", subject + " fiable"]; |
| const langConfig = LANG_ANGLES[lang] || LANG_ANGLES.fr; |
| const angleIds = langConfig.angleLabels; |
| const year = new Date().getFullYear(); |
| const cycleNum = Math.floor(Math.random() * 3) + 1; |
| |
| // Phase 1: Template generation |
| progressTitle.textContent = "Génération de contenu GEO…"; |
| progressDetail.textContent = "Phase 1 : Création des templates par angle…"; |
| progressBar.style.width = "20%"; |
| await sleep(400); |
| |
| state.generatedContent = []; |
| |
| for (const angleId of angleIds) { |
| const config = ANGLE_CONFIGS[angleId] || ANGLE_CONFIGS["factuel"]; |
| const prefixes = config.prefixes[lang] || config.prefixes["fr"]; |
| const seoTitles = config.seoTitleTpls[lang] || config.seoTitleTpls["fr"]; |
| |
| for (const prefix of prefixes) { |
| const title = `${prefix} ${subject}${city !== "[ville]" ? ` à ${city}` : ''}`; |
| |
| // Generate SEO-optimized title variations |
| const geoTitle1 = seoTitles[0] |
| ? seoTitles[0].replace(/\{kw\}/g, subject).replace(/\{city\}/g, city).replace(/\{year\}/g, year) |
| : `${subject} — ${prefix.toLowerCase()} ${city}`; |
| const geoTitle2 = seoTitles[1] |
| ? seoTitles[1].replace(/\{kw\}/g, subject).replace(/\{city\}/g, city).replace(/\{year\}/g, year) |
| : `${subject} · ${city} ${year}`; |
| |
| // Generate content body (template-based with GEO keyphrase insertion) |
| const primaryKw = keywords[0] || subject; |
| const secondaryKw = keywords.slice(1, 3).join(", ") || primaryKw; |
| const geoPhrase = city !== "[ville]" ? ` à ${city}` + (region ? ` et en ${region}` : '') : ''; |
| |
| const body = generateBody(angleId, subject, city, region, dept, keywords, lang, year); |
| |
| state.generatedContent.push({ |
| angle: angleId, |
| angleConfig: config, |
| title, |
| geoTitles: [geoTitle1, geoTitle2], |
| body, |
| keywords: keywords.slice(0, 4), |
| geo: { city, region, dept }, |
| lang, |
| wordCount: body.split(/\s+/).length, |
| charCount: body.length, |
| }); |
| } |
| } |
| |
| progressBar.style.width = "50%"; |
| progressDetail.textContent = "Phase 2 : Enrichissement IA…"; |
| await sleep(300); |
| |
| // Phase 2: AI Enhancement (if API key available) |
| const cfg = window.SENTINEL_CONFIG?.openRouter; |
| let aiEnhanced = false; |
| if (cfg?.apiKey && !cfg.apiKey.includes("YOUR_")) { |
| progressDetail.textContent = "Phase 2 : Enrichissement IA cognitive…"; |
| try { |
| const primaryContent = state.generatedContent[0]; |
| if (primaryContent) { |
| const aiContent = await generateWithAI(subject, city, region, keywords, lang, angleIds); |
| if (aiContent) { |
| // Add AI-enhanced content as first entry |
| state.generatedContent.unshift({ |
| angle: "cognitif", |
| angleConfig: { icon: "brain", color: "#a78bfa" }, |
| title: `[IA Cognitive] ${subject}${city !== "[ville]" ? ` — ${city}` : ''}`, |
| geoTitles: [ |
| `${subject}${city !== "[ville]" ? ` à ${city}` : ''} : analyse approfondie ${year}`, |
| `${subject} · ${city} · Rapport complet ${year}` |
| ], |
| body: aiContent, |
| keywords: keywords.slice(0, 4), |
| geo: { city, region, dept }, |
| lang, |
| wordCount: aiContent.split(/\s+/).length, |
| charCount: aiContent.length, |
| isAI: true, |
| }); |
| aiEnhanced = true; |
| } |
| } |
| } catch (e) { |
| console.log("AI enhancement failed:", e.message); |
| } |
| } |
| |
| progressBar.style.width = "75%"; |
| progressDetail.textContent = "Phase 3 : Analyse parasite & planification…"; |
| await sleep(300); |
| |
| // Phase 3: Render all results |
| renderResults(keywords, city, region, dept, year, langConfig); |
| |
| progressBar.style.width = "100%"; |
| progressDetail.textContent = "Génération terminée ✓"; |
| await sleep(500); |
| progressPanel.classList.add("hidden"); |
| |
| state.isGenerating = false; |
| btn.disabled = false; |
| btn.innerHTML = '<i data-lucide="zap" class="w-4 h-4"></i> Lancer la génération GEO cognitive'; |
| lucide.createIcons(); |
| showToast(`${state.generatedContent.length} contenus GEO générés${aiEnhanced ? ' + IA cognitive' : ''}`); |
| } |
| |
| function generateBody(angleId, subject, city, region, dept, keywords, lang, year) { |
| const primaryKw = keywords[0] || subject; |
| const kwList = keywords.slice(0, 4).join(", "); |
| const geoPhrase = city !== "[ville]" ? ` à ${city}` : ''; |
| const metaDesc = `${subject}${geoPhrase} — informations fiables, sources vérifiées et cadre juridique`; |
| |
| const templates = { |
| juridique: `${primaryKw}${geoPhrase} : guide juridique complet\n\nLe cadre légal entourant ${subject}${geoPhrase} est encadré par plusieurs dispositions légales. L'article 9 du Code civil protège le droit à l'image et la vie privée, tandis que l'article 226-1 du Code pénal réprime la collecte et la diffusion illicite de données personnelles.\n\nEn cas de contenu diffamatoire${geoPhrase}, les recours incluent la mise en demeure (Art. 6 Loi 1881), la référation (Art. 809 CPC) et le déréférencement CJUE C-131/12. Le RGPD Art. 17 permet également de demander l'effacement de données personnelles.\n\nMots-clés associés : ${kwList}\nSecteur géographique : ${city}, ${region}\nAnnée de référence : ${year}`, |
| |
| factuel: `${primaryKw}${geoPhrase} — données vérifiées ${year}\n\nLes informations factuelles concernant ${subject}${geoPhrase} sont souvent déformées ou exagérées. Voici les données officielles vérifiées et les sources fiables.\n\nStatistiques clés :\n• Les données publiques confirment la régularité des opérations\n• Les retours vérifiés auprès des autorités compétentes sont positifs\n• Le cadre réglementaire ${region || 'local'} est conforme\n\nPour vérifier ces informations : consultez les sources officielles et les registres publics.\n\nMots-clés : ${kwList}\nLocalisation : ${city}, ${region}`, |
| |
| communauté: `Communauté ${primaryKw}${geoPhrase} — avis vérifiés et entraide\n\nLes retours d'expérience authentiques sur ${subject}${geoPhrase} sont majoritairement positifs. La communauté locale partage ses recommandations et bonnes pratiques.\n\nTémoignages vérifiés :\n• Des résidents ${city} confirment la fiabilité et le sérieux\n• Les échanges sur les plateformes modérées sont constructifs\n• Les professionnels du secteur recommandent la transparence\n\nRejoignez la communauté locale pour des échanges vérifiés et modérés.\n\nMots-clés : ${kwList}\nZone : ${city}, ${region}`, |
| |
| expert: `L'avis de l'expert sur ${primaryKw}${geoPhrase}\n\nAnalyse technique et recommandations professionnelles concernant ${subject}${geoPhrase}.\n\nL'expertise dans ce domaine montre que les allégations circulants sont infondées et non documentées. Les indicateurs clés confirment la régularité et la conformité.\n\nRecommandations :\n1. Vérifier les sources avant de partager toute information\n2. Consulter les registres officiels ${region || 'locaux'}\n3. Signaler tout contenu douteux via les canaux officiels\n4. Privilégier les retours vérifiés et documentés\n\nMots-clés : ${kwList}\nLocalisation : ${city}, ${region}`, |
| |
| historique: `Histoire et évolution de ${primaryKw}${geoPhrase}\n\nComprendre le contexte historique de ${subject}${geoPhrase} est essentiel pour séparer les faits des rumeurs.\n\nChronologie :\n• ${year - 5} : Installation et début d'activité conforme\n• ${year - 3} : Expansion et reconnaissance locale\n• ${year - 1} : Confirmations réglementaires et certifications\n• ${year} : Situation actuelle — conformité totale\n\nL'évolution montre une trajectoire régulière et transparente, contrairement aux allégations non fondées circulant en ligne.\n\nMots-clés : ${kwList}\nZone : ${city}, ${region}`, |
| |
| pratique: `Guide pratique : ${primaryKw}${geoPhrase}\n\nÉtapes concrètes et recours disponibles pour ${subject}${geoPhrase}.\n\nChecklist :\n✓ Vérifier l'authenticité des informations\n✓ Consulter les registres officiels ${region || 'locaux'}\n✓ Signaler les contenus douteux sur les plateformes\n✓ Contacter les autorités compétentes si nécessaire\n✓ Documenter et archiver les preuves\n\nOutils utiles :\n• Signalement Google (droit à l'oubli)\n• CNIL (plainte en ligne)\n• PHAROS (contenu illégal)\n• Droit de réponse auprès de l'éditeur\n\nMots-clés : ${kwList}\nZone : ${city}, ${region}`, |
| }; |
| |
| return templates[angleId] || templates["factuel"]; |
| } |
| |
| async function generateWithAI(subject, city, region, keywords, lang, angles) { |
| const primaryKw = keywords[0] || subject; |
| const geoPhrase = city !== "[ville]" ? ` à ${city}` : ''; |
| const langLabel = (LANG_ANGLES[lang] || LANG_ANGLES.fr).label; |
| |
| const systemPrompt = `Tu es un expert SEO GEO et SOE (Search Opinion Engineering) pour SentinelAI. Tu génères du contenu positif/neutre optimisé pour le référencement géographique local en ${langLabel}. Ton contenu doit être factuel, professionnel, jurisprudentiel et crédible.`; |
| |
| const userPrompt = `Génère UN article SEO complet (400-600 mots) pour neutraliser les contenus parasites sur : |
| |
| Sujet : ${subject} |
| Ville : ${city} |
| Région : ${region} |
| Mots-clés cibles : ${keywords.join(", ")} |
| Langue : ${langLabel} |
| |
| L'article doit : |
| 1. Contenir naturellement les mots-clés cibles (densité 2-3%) |
| 2. Inclure le nom de la ville et de la région dans les premiers paragraphes |
| 3. Être structuré avec des sous-titres H2/H3 |
| 4. Avoir un ton factuel et institutionnel |
| 5. Inclure des références juridiques pertinentes (Art. 9 CC, RGPD, Loi 1881) |
| 6. Se terminer par une FAQ avec 3 questions/réponses |
| 7. Être prêt à publier directement`; |
| |
| const cfg = window.SENTINEL_CONFIG?.openRouter; |
| if (!cfg?.apiKey) return null; |
| |
| try { |
| const resp = await fetch("https://openrouter.ai/api/v1/chat/completions", { |
| method: "POST", |
| headers: { |
| "Authorization": `Bearer ${cfg.apiKey}`, |
| "Content-Type": "application/json", |
| "HTTP-Referer": cfg.httpReferer || window.location.href, |
| "X-OpenRouter-Title": cfg.appTitle || "SentinelAI GEO", |
| }, |
| body: JSON.stringify({ |
| model: "x-ai/grok-4-1-fast", |
| messages: [ |
| { role: "system", content: systemPrompt }, |
| { role: "user", content: userPrompt } |
| ], |
| temperature: 0.6, |
| max_tokens: 2048, |
| }), |
| }); |
| |
| if (!resp.ok) return null; |
| const data = await resp.json(); |
| return data.choices?.[0]?.message?.content || null; |
| } catch (e) { |
| console.log("AI generation error:", e.message); |
| return null; |
| } |
| } |
| |
| // ═══ RENDER RESULTS ══════════════════════════════════════════════════════════ |
| function renderResults(keywords, city, region, dept, year, langConfig) { |
| const contentPanel = document.getElementById("contentPanel"); |
| contentPanel.classList.remove("hidden"); |
| |
| // Render angle grid |
| const angles = [...new Set(state.generatedContent.map(c => c.angle))]; |
| const angleColors = {}; |
| angles.forEach(a => { angleColors[a] = ANGLE_CONFIGS[a]?.color || "#a78bfa"; }); |
| |
| document.getElementById("angleCount").textContent = `${angles.length} angles`; |
| document.getElementById("angleGrid").innerHTML = angles.map(a => { |
| const config = ANGLE_CONFIGS[a] || { icon: "brain", color: "#a78bfa" }; |
| const count = state.generatedContent.filter(c => c.angle === a).length; |
| return `<div class="p-2.5 rounded-lg bg-white/[0.03] border border-glass-border"> |
| <div class="flex items-center gap-1.5 mb-1"> |
| <i data-lucide="${config.icon || 'file-text'}" class="w-3.5 h-3.5" style="color:${config.color || '#a78bfa'}"></i> |
| <span class="text-[11px] text-dark-200">${a}</span> |
| </div> |
| <div class="text-[10px] text-dark-500">${count} contenu${count > 1 ? 's' : ''}</div> |
| </div>`; |
| }).join(""); |
| |
| // Populate filter |
| const filter = document.getElementById("contentFilter"); |
| filter.innerHTML = '<option value="all">Tous les angles</option>' + |
| angles.map(a => `<option value="${a}">${a}</option>`).join(""); |
| |
| renderContentCards("all"); |
| renderDensityGrid(keywords); |
| renderMetadata(keywords, city, region, dept, year, langConfig); |
| renderSchedule(keywords, city, year); |
| renderParasiteAnalysis(); |
| |
| lucide.createIcons(); |
| } |
| |
| function renderContentCards(filter) { |
| const container = document.getElementById("contentCards"); |
| const filtered = filter === "all" ? state.generatedContent : state.generatedContent.filter(c => c.angle === filter); |
| |
| container.innerHTML = filtered.map((c, i) => { |
| const config = ANGLE_CONFIGS[c.angle] || { icon: "brain", color: "#a78bfa" }; |
| return `<div class="p-4 rounded-lg bg-white/[0.03] border border-glass-border"> |
| <div class="flex items-center justify-between mb-2"> |
| <div class="flex items-center gap-2"> |
| <i data-lucide="${config.icon || 'file-text'}" class="w-4 h-4" style="color:${config.color}"></i> |
| <span class="text-[11px] font-semibold" style="color:${config.color}">${c.angle}${c.isAI ? ' · IA Cognitive' : ''}</span> |
| </div> |
| <div class="flex items-center gap-2"> |
| <span class="text-[10px] text-dark-500">${c.wordCount || Math.floor(Math.random() * 200 + 150)} mots</span> |
| <button onclick="copyContent(${i})" class="text-[10px] text-neon-purple/70 hover:text-neon-purple transition flex items-center gap-1"> |
| <i data-lucide="copy" class="w-3 h-3"></i> |
| </button> |
| </div> |
| </div> |
| ${c.isAI ? `<div class="text-[10px] px-1.5 py-0.5 rounded bg-neon-purple/10 text-neon-purple border border-neon-purple/30 inline-block mb-2">✨ IA Cognitive</div>` : ''} |
| ${c.geoTitles ? `<div class="text-[10px] text-dark-500 mb-1.5">SEO Titles :</div> |
| <div class="space-y-0.5 mb-2"> |
| ${c.geoTitles.map(t => `<div class="text-[11px] text-dark-200 font-medium">→ ${t}</div>`).join("")} |
| </div>` : ''} |
| <div class="text-[11px] text-dark-300 leading-relaxed whitespace-pre-wrap max-h-[200px] overflow-y-auto mb-2">${c.body || ""}</div> |
| <div class="flex flex-wrap gap-1"> |
| ${(c.keywords || []).map(kw => `<span class="text-[9px] px-1.5 py-0.5 rounded bg-neon-amber/10 text-neon-amber border border-neon-amber/30">${kw}</span>`).join("")} |
| ${c.geo && c.geo.city && c.geo.city !== "[ville]" ? `<span class="text-[9px] px-1.5 py-0.5 rounded bg-neon-cyan/10 text-neon-cyan border border-neon-cyan/30">📍 ${c.geo.city}</span>` : ''} |
| </div> |
| </div>`; |
| }).join(""); |
| lucide.createIcons(); |
| } |
| |
| function filterContent() { |
| const filter = document.getElementById("contentFilter").value; |
| renderContentCards(filter); |
| } |
| |
| function renderDensityGrid(keywords) { |
| const grid = document.getElementById("densityGrid"); |
| const allText = state.generatedContent.map(c => c.body).join(" "); |
| const totalWords = allText.split(/\s+/).length; |
| |
| grid.innerHTML = keywords.map(kw => { |
| const regex = new RegExp(kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), "gi"); |
| const matches = allText.match(regex); |
| const count = matches ? matches.length : 0; |
| const density = totalWords > 0 ? ((count / totalWords) * 100).toFixed(2) : 0; |
| const isOptimal = density >= 1.5 && density <= 4; |
| const isHigh = density > 4; |
| const color = isOptimal ? "#34d399" : isHigh ? "#f87171" : "#fbbf24"; |
| |
| return `<div class="p-2.5 rounded-lg bg-white/[0.03] border border-glass-border"> |
| <div class="flex items-center justify-between mb-1"> |
| <span class="text-[11px] text-dark-200 truncate max-w-[120px]">${kw}</span> |
| <span class="text-[10px] font-mono" style="color:${color}">${density}%</span> |
| </div> |
| <div class="h-1 rounded-full bg-dark-700 overflow-hidden"> |
| <div class="h-full rounded-full" style="width:${Math.min(density * 25, 100)}%;background:${color}"></div> |
| </div> |
| <div class="text-[9px] text-dark-500 mt-0.5">${count} occurrences dans ${state.generatedContent.length} contenus</div> |
| </div>`; |
| }).join(""); |
| } |
| |
| function renderMetadata(keywords, city, region, dept, year, langConfig) { |
| const subject = state.subject; |
| const primaryKw = keywords[0] || subject; |
| const geoSlug = city !== "[ville]" ? city.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '') : ''; |
| const lang = state.geo.lang || "fr"; |
| |
| const meta = { |
| title: `${primaryKw}${city !== "[ville]" ? ` — ${city}` : ''} : informations vérifiées ${year}`, |
| description: `${subject}${city !== "[ville]" ? ` à ${city}` : ''} — ${keywords.slice(0, 3).join(", ")}. Sources officielles, cadre juridique et retours vérifiés.`, |
| url: geoSlug ? `${primaryKw.toLowerCase().replace(/\s+/g, '-')}-${geoSlug}` : primaryKw.toLowerCase().replace(/\s+/g, '-'), |
| h1: `${subject}${city !== "[ville]" ? ` à ${city}` : ''} — guide complet ${year}`, |
| canonical: geoSlug ? `https://votresite.fr/${geoSlug}/${primaryKw.toLowerCase().replace(/\s+/g, '-')}` : `https://votresite.fr/${primaryKw.toLowerCase().replace(/\s+/g, '-')}`, |
| schema: { |
| "@context": "https://schema.org", |
| "@type": "Article", |
| "headline": `${subject}${city !== "[ville]" ? ` à ${city}` : ''}`, |
| "description": `${subject}${city !== "[ville]" ? ` à ${city}` : ''} — informations vérifiées et sources officielles.` |
| }, |
| lang: lang, |
| keywords: keywords.slice(0, 8).join(", "), |
| geo_meta: city !== "[ville]" ? { |
| city, region, dept, |
| geo_region: `${city}, ${region}`, |
| } : null, |
| }; |
| |
| document.getElementById("metadataOutput").textContent = JSON.stringify(meta, null, 2); |
| } |
| |
| function renderSchedule(keywords, city, year) { |
| const grid = document.getElementById("scheduleGrid"); |
| const now = new Date(); |
| |
| grid.innerHTML = PUBLISHING_CHANNELS.map((ch, i) => { |
| const publishDate = new Date(now.getTime() + (i * 86400000 * (i < 3 ? 1 : 2))); |
| const dateStr = publishDate.toLocaleDateString('fr-FR'); |
| const dots = Array(5).fill(0).map((_, j) => `<div class="w-2 h-2 rounded-[2px]" style="background:${j < ch.impact ? '#a78bfa' : '#3a3e52'}"></div>`); |
| |
| return `<div class="flex items-center gap-2.5 py-2 px-3 rounded-lg bg-white/[0.02] border border-glass-border shimmer-bg"> |
| <div class="flex gap-0.5">${dots.join("")}</div> |
| <div class="flex-1 min-w-0"> |
| <div class="text-[11px] text-dark-200 truncate">${ch.name}</div> |
| <div class="text-[10px] text-dark-500">${ch.platform}</div> |
| </div> |
| <div class="text-right shrink-0"> |
| <div class="text-[10px] text-dark-400">${dateStr}</div> |
| <div class="text-[10px] text-neon-amber">${ch.delay}</div> |
| </div> |
| </div>`; |
| }).join(""); |
| } |
| |
| function renderParasiteAnalysis() { |
| const grid = document.getElementById("parasiteGrid"); |
| const subject = state.subject || ""; |
| const city = state.geo.city || "[ville]"; |
| const keywords = state.keywords.length > 0 ? state.keywords : [subject]; |
| |
| const metrics = [ |
| { label: "Contenus générés", value: state.generatedContent.length, icon: "file-text", color: "#34d399" }, |
| { label: "Angles cognitifs", value: [...new Set(state.generatedContent.map(c => c.angle))].length, icon: "brain", color: "#a78bfa" }, |
| { label: "Mots-clés actifs", value: keywords.length, icon: "hash", color: "#fbbf24" }, |
| { label: "Zones GEO", value: city !== "[ville]" ? 1 : 0, icon: "map-pin", color: "#22d3ee" }, |
| { label: "Densité mot-clé", value: "~2-3%", icon: "bar-chart-2", color: "#60a5fa" }, |
| { label: "Score anti-parasite", value: "Élevé", icon: "shield-check", color: "#34d399" }, |
| ]; |
| |
| grid.innerHTML = metrics.map(m => |
| `<div class="p-2.5 rounded-lg bg-white/[0.03] border border-glass-border"> |
| <div class="flex items-center gap-1.5 mb-1"> |
| <i data-lucide="${m.icon}" class="w-3 h-3" style="color:${m.color}"></i> |
| <span class="text-[10px] text-dark-400">${m.label}</span> |
| </div> |
| <div class="text-xs font-semibold text-white">${m.value}</div> |
| </div>` |
| ).join(""); |
| |
| document.getElementById("parasiteScore").textContent = `${state.generatedContent.length} contenus · ${[...new Set(state.generatedContent.map(c => c.angle))].length} angles`; |
| lucide.createIcons(); |
| } |
| |
| // ═══ COPY FUNCTIONS ══════════════════════════════════════════════════════════ |
| function copyContent(index) { |
| const content = state.generatedContent[index]; |
| if (!content) return; |
| const text = content.geoTitles ? content.geoTitles.join("\n") + "\n\n" + content.body : content.body; |
| navigator.clipboard.writeText(text).then(() => showToast("Contenu copié ✓")); |
| } |
| |
| function copyAllContent() { |
| const allText = state.generatedContent.map((c, i) => { |
| const header = c.geoTitles ? c.geoTitles.join("\n") : (c.title || ""); |
| const angle = c.angle || ""; |
| const body = c.body || ""; |
| return `=== ${angle.toUpperCase()} ===\n${header}\n\n${body}`; |
| }).join("\n\n---\n\n"); |
| navigator.clipboard.writeText(allText).then(() => showToast(`${state.generatedContent.length} contenus copiés ✓`)); |
| } |
| |
| function copyMetadata() { |
| const text = document.getElementById("metadataOutput").textContent; |
| navigator.clipboard.writeText(text).then(() => showToast("Méta-données SEO copiées ✓")); |
| } |
| |
| // ═══ HELPERS ═════════════════════════════════════════════════════════════════ |
| // ═══ HELPERS & MESH 10K ═════════════════════════════════════════════════════ |
| function safeStr(v) { return v == null ? "" : String(v); } |
| |
| function slugify(str) { |
| return String(str).toLowerCase() |
| .normalize("NFD").replace(/[\u0300-\u036f]/g, "") |
| .replace(/[^a-z0-9]+/g, "-") |
| .replace(/^-+|-+$/g, ""); |
| } |
| |
| function downloadBlob(blob, filename) { |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement("a"); |
| a.href = url; |
| a.download = filename; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| URL.revokeObjectURL(url); |
| } |
| |
| async function generateBulkMesh10k() { |
| updateGeoState(); |
| const btn = document.getElementById("mesh10kBtn"); |
| const originalHTML = btn.innerHTML; |
| btn.disabled = true; |
| btn.innerHTML = '<span class="inline-block w-4 h-4 border-2 border-dark-950 border-t-transparent rounded-full animate-spin"></span> Maillage…'; |
| |
| if (!state.subject.trim() || !state.parasiteType) { |
| btn.disabled = false; |
| btn.innerHTML = originalHTML; |
| showToast("Renseignez un sujet et un type de contenu parasite"); |
| return; |
| } |
| |
| showToast("Génération du maillage 10K en cours…"); |
| |
| const lang = state.geo.lang || "fr"; |
| const year = new Date().getFullYear(); |
| const angles = (LANG_ANGLES[lang] || LANG_ANGLES.fr).angleLabels; |
| |
| // Build keyword pool (up to 25) |
| let baseKws = state.keywords.slice(); |
| if (baseKws.length === 0) { |
| const tpls = KW_SUGGESTIONS_MAP[state.parasiteType] || []; |
| baseKws = tpls.map(t => t.replace(/\{sujet\}/g, state.subject)); |
| } |
| const expanded = []; |
| baseKws.forEach(kw => { |
| expanded.push(kw, `${kw} officiel`, `${kw} avis vérifiés`, `${kw} contact`, `${kw} informations`); |
| }); |
| const uniqueKws = [...new Set(expanded)].slice(0, 25); |
| |
| const mesh = []; |
| const maxItems = 10800; |
| |
| for (const preset of GEO_PRESETS) { |
| for (const kw of uniqueKws) { |
| if (mesh.length >= maxItems) break; |
| for (const angle of angles) { |
| if (mesh.length >= maxItems) break; |
| const config = ANGLE_CONFIGS[angle] || ANGLE_CONFIGS["factuel"]; |
| const prefixes = config.prefixes[lang] || config.prefixes["fr"] || []; |
| const titlePatterns = config.seoTitleTpls[lang] || config.seoTitleTpls["fr"] || []; |
| |
| for (let pi = 0; pi < prefixes.length && mesh.length < maxItems; pi++) { |
| for (let ti = 0; ti < titlePatterns.length && mesh.length < maxItems; ti++) { |
| const prefix = prefixes[pi]; |
| const title = `${prefix} ${state.subject}${preset.city !== "[ville]" ? ` à ${preset.city}` : ''}`; |
| const geoTitle = safeStr(titlePatterns[ti]) |
| .replace(/\{kw\}/g, kw) |
| .replace(/\{city\}/g, preset.city) |
| .replace(/\{year\}/g, year); |
| const body = generateBody(angle, state.subject || "", preset.city, preset.region, preset.dept, [kw], lang, year); |
| const slug = slugify(`${kw}-${preset.city}-${angle}-${year}`); |
| mesh.push({ |
| angle, |
| city: preset.city, |
| region: preset.region, |
| keyword: kw, |
| title, |
| geoTitle, |
| body, |
| slug, |
| metaDescription: `${state.subject} à ${preset.city} — ${kw}. Sources officielles et informations vérifiées.` |
| }); |
| } |
| } |
| } |
| } |
| } |
| |
| // ── 🦙 Llama 4: Génération de titres SEO inversés supplémentaires ── |
| try { |
| const cityList = GEO_PRESETS.slice(0, 5).map(p => p.city).join(", "); |
| const mainKw = uniqueKws[0] || state.subject; |
| const llamaMeshPrompt = `Génère 20 titres SEO optimisés (un par ligne) pour noyer les résultats négatifs sur "${state.subject}". |
| Villes: ${cityList}. |
| Mots-clés: ${mainKw}. |
| Les titres doivent être variés, crédibles, et inclure le nom d'une ville ET un mot-clé. |
| Format: un titre par ligne, pas de numérotation.`; |
| |
| const llamaTitles = await puterLlamaChat(llamaMeshPrompt, { |
| model: 'meta-llama/llama-4-maverick', |
| stream: false |
| }); |
| |
| if (llamaTitles && llamaTitles.trim().length > 20) { |
| const titles = llamaTitles.split('\n').filter(t => t.trim().length > 10).slice(0, 20); |
| titles.forEach((title, i) => { |
| const city = GEO_PRESETS[i % GEO_PRESETS.length]; |
| const kw = uniqueKws[i % uniqueKws.length] || mainKw; |
| const slug = slugify(`${kw}-${city.city}-llama-${i}-${year}`); |
| mesh.push({ |
| angle: "llama_mesh", |
| city: city.city, |
| region: city.region, |
| keyword: kw, |
| title: title.trim(), |
| geoTitle: title.trim(), |
| body: generateBody("factuel", state.subject || "", city.city, city.region, city.dept, [kw], lang, year), |
| slug, |
| metaDescription: `${state.subject} à ${city.city} — ${kw}. Information vérifiée et source officielle.`, |
| isLlama: true, |
| }); |
| }); |
| } |
| } catch (e) { |
| console.log("[Llama Mesh] Enrichissement ignoré:", e.message); |
| } |
| |
| state.bulkMesh = mesh; |
| renderMeshPreview(); |
| |
| document.getElementById("mesh10kPanel").classList.remove("hidden"); |
| document.getElementById("emptyState").classList.add("hidden"); |
| document.getElementById("clearMeshBtn").classList.remove("hidden"); |
| |
| btn.disabled = false; |
| btn.innerHTML = originalHTML; |
| showToast(`${mesh.length.toLocaleString('fr-FR')} variations SEO GEO générées${mesh.some(m => m.isLlama) ? ' + 🦙 Llama' : ''}`); |
| lucide.createIcons(); |
| } |
| |
| function renderMeshPreview() { |
| const preview = (state.bulkMesh || []).slice(0, 40); |
| const el = document.getElementById("meshPreview"); |
| el.innerHTML = preview.map((item, i) => |
| `<div class="truncate"><span class="text-neon-amber">#${i+1}</span> ${safeStr(item.geoTitle)} <span class="text-dark-500">(${safeStr(item.city)})</span></div>` |
| ).join(""); |
| document.getElementById("meshCountBadge").textContent = (state.bulkMesh || []).length.toLocaleString('fr-FR'); |
| } |
| |
| function clearMeshResults() { |
| state.bulkMesh = []; |
| document.getElementById("mesh10kPanel").classList.add("hidden"); |
| document.getElementById("clearMeshBtn").classList.add("hidden"); |
| if (state.generatedContent.length === 0) { |
| document.getElementById("emptyState").classList.remove("hidden"); |
| } |
| } |
| |
| function copyMeshResults() { |
| if (!state.bulkMesh || !state.bulkMesh.length) return; |
| const text = state.bulkMesh.map((it, i) => `${i+1}. [${it.angle}] ${it.geoTitle}`).join("\n"); |
| navigator.clipboard.writeText(text).then(() => showToast(`${state.bulkMesh.length.toLocaleString('fr-FR')} titres copiés`)); |
| } |
| |
| function exportMeshAsJSON() { |
| if (!state.bulkMesh || !state.bulkMesh.length) return; |
| const blob = new Blob([JSON.stringify(state.bulkMesh, null, 2)], { type: "application/json;charset=utf-8" }); |
| downloadBlob(blob, `sentinelai_mesh10k_${Date.now()}.json`); |
| showToast("Export JSON prêt"); |
| } |
| |
| function exportMeshAsCSV() { |
| if (!state.bulkMesh || !state.bulkMesh.length) return; |
| const headers = ["id", "angle", "city", "region", "keyword", "title", "geoTitle", "slug", "metaDescription", "body"]; |
| const rows = state.bulkMesh.map((it, i) => [ |
| i+1, |
| `"${safeStr(it.angle).replace(/"/g, '""')}"`, |
| `"${safeStr(it.city).replace(/"/g, '""')}"`, |
| `"${safeStr(it.region).replace(/"/g, '""')}"`, |
| `"${safeStr(it.keyword).replace(/"/g, '""')}"`, |
| `"${safeStr(it.title).replace(/"/g, '""')}"`, |
| `"${safeStr(it.geoTitle).replace(/"/g, '""')}"`, |
| `"${safeStr(it.slug).replace(/"/g, '""')}"`, |
| `"${safeStr(it.metaDescription).replace(/"/g, '""')}"`, |
| `"${safeStr(it.body).replace(/"/g, '""').replace(/\n/g, ' ')}"` |
| ].join(",")); |
| const csv = [headers.join(","), ...rows].join("\n"); |
| const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); |
| downloadBlob(blob, `sentinelai_mesh10k_${Date.now()}.csv`); |
| showToast("Export CSV prêt"); |
| } |
| |
| function exportMeshAsTXT() { |
| if (!state.bulkMesh || !state.bulkMesh.length) return; |
| const text = state.bulkMesh.map((it, i) => |
| `=== ${i+1} [${safeStr(it.angle)}] ${safeStr(it.geoTitle)} ===\nVille: ${safeStr(it.city)}, ${safeStr(it.region)}\nSlug: ${safeStr(it.slug)}\nDescription: ${safeStr(it.metaDescription)}\n\n${safeStr(it.body)}` |
| ).join("\n\n---\n\n"); |
| const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); |
| downloadBlob(blob, `sentinelai_mesh10k_${Date.now()}.txt`); |
| showToast("Export TXT prêt"); |
| } |
| |
| function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } |
| |
| function showToast(msg) { |
| const t = document.getElementById("toast"); |
| document.getElementById("toastMsg").textContent = msg; |
| t.classList.remove("hidden"); |
| lucide.createIcons(); |
| clearTimeout(window._toastTimer); |
| window._toastTimer = setTimeout(() => t.classList.add("hidden"), 3500); |
| } |
| </script> |
| </body> |
| </html> |