595 lines
35 KiB
HTML
Executable File
595 lines
35 KiB
HTML
Executable File
<?php
|
||
$db = new PDO("pgsql:host=localhost;dbname=adx_system", "admin", "admin123");
|
||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||
$total = (int)$db->query("SELECT COUNT(*) FROM ethica.medecins")->fetchColumn();
|
||
$valid_stats = $db->query("SELECT email_valid, COUNT(*) c FROM ethica.medecins GROUP BY email_valid ORDER BY c DESC")->fetchAll(PDO::FETCH_KEY_PAIR);
|
||
$consent_stats = $db->query("SELECT consent_status, COUNT(*) c FROM ethica.medecins GROUP BY consent_status ORDER BY c DESC")->fetchAll(PDO::FETCH_KEY_PAIR);
|
||
$running_val = (int)trim(shell_exec("pgrep -f 'ethica-validator' 2>/dev/null | wc -l") ?? '0');
|
||
$running_scr = (int)trim(shell_exec("pgrep -f 'ethica-mega\|ethica-scraper' 2>/dev/null | wc -l") ?? '0');
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||
<title>Ethica HCP — Validator · Consent · Scraping</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||
<style>
|
||
:root{--bg:#0f172a;--bg2:#1e293b;--bg3:#334155;--border:#475569;--text:#f1f5f9;--text2:#94a3b8;--text3:#64748b;
|
||
--teal:#14b8a6;--blue:#3b82f6;--purple:#a855f7;--orange:#f59e0b;--red:#ef4444;--green:#22c55e;--cyan:#06b6d4;--pink:#ec4899;}
|
||
*{margin:0;padding:0;box-sizing:border-box;}
|
||
body{background:var(--bg);color:var(--text);font-family:'Inter',sans-serif;min-height:100vh;}
|
||
.hdr{background:linear-gradient(135deg,#0d9488,#065f46);padding:14px 24px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid rgba(255,255,255,.1);}
|
||
.hdr-left{display:flex;align-items:center;gap:10px;}
|
||
.hdr h1{font-size:17px;font-weight:800;}
|
||
.hdr-sub{font-size:10px;opacity:.7;}
|
||
.hdr-right{display:flex;gap:8px;align-items:center;}
|
||
.chip{background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.15);border-radius:14px;padding:3px 10px;font-size:10px;font-weight:600;display:flex;align-items:center;gap:4px;}
|
||
.chip .dot{width:6px;height:6px;border-radius:50%;animation:pulse 2s infinite;}
|
||
.dot-green{background:var(--green);}.dot-orange{background:var(--orange);animation-duration:.6s!important;}
|
||
@keyframes pulse{50%{opacity:.3;}}
|
||
|
||
.tabs{display:flex;background:var(--bg2);border-bottom:1px solid rgba(255,255,255,.05);overflow-x:auto;}
|
||
.tab{padding:11px 18px;font-size:12px;font-weight:700;color:var(--text3);cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap;transition:all .15s;}
|
||
.tab:hover{color:var(--text);background:rgba(255,255,255,.02);}
|
||
.tab.active{color:var(--teal);border-bottom-color:var(--teal);}
|
||
|
||
.content{max-width:1500px;margin:0 auto;padding:20px 24px;}
|
||
.panel{display:none;}.panel.active{display:block;}
|
||
|
||
.stats-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin-bottom:18px;}
|
||
.stat{background:var(--bg2);border-radius:10px;padding:12px 14px;border-left:3px solid var(--teal);}
|
||
.stat.blue{border-left-color:var(--blue);}.stat.green{border-left-color:var(--green);}.stat.red{border-left-color:var(--red);}
|
||
.stat.orange{border-left-color:var(--orange);}.stat.purple{border-left-color:var(--purple);}.stat.cyan{border-left-color:var(--cyan);}.stat.pink{border-left-color:var(--pink);}
|
||
.stat-lbl{font-size:9px;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);font-weight:700;}
|
||
.stat-val{font-size:22px;font-weight:900;margin-top:2px;}
|
||
|
||
.card{background:var(--bg2);border-radius:10px;border:1px solid rgba(255,255,255,.05);overflow:hidden;margin-bottom:14px;}
|
||
.card-head{padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.05);display:flex;align-items:center;justify-content:space-between;}
|
||
.card-head h3{font-size:13px;font-weight:700;display:flex;align-items:center;gap:6px;}
|
||
.card-body{padding:14px;}
|
||
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:14px;}
|
||
.grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;}
|
||
|
||
.tbl{width:100%;border-collapse:collapse;font-size:11px;}
|
||
.tbl th{text-align:left;padding:7px 8px;font-weight:700;color:var(--text3);font-size:9px;text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--bg3);cursor:pointer;}
|
||
.tbl td{padding:7px 8px;border-bottom:1px solid rgba(255,255,255,.03);}
|
||
.tbl tr:hover td{background:rgba(255,255,255,.02);}
|
||
.badge{display:inline-block;padding:2px 7px;border-radius:6px;font-size:9px;font-weight:700;}
|
||
.b-valid{background:rgba(34,197,94,.15);color:#86efac;}.b-invalid{background:rgba(239,68,68,.15);color:#fca5a5;}
|
||
.b-risky{background:rgba(245,158,11,.15);color:#fcd34d;}.b-unknown{background:rgba(100,116,139,.15);color:#94a3b8;}
|
||
.b-catch{background:rgba(6,182,212,.15);color:#67e8f9;}
|
||
.b-pending{background:rgba(168,85,247,.15);color:#c4b5fd;}.b-optin{background:rgba(34,197,94,.15);color:#86efac;}
|
||
.b-optout{background:rgba(239,68,68,.15);color:#fca5a5;}.b-implicit{background:rgba(59,130,246,.15);color:#93c5fd;}
|
||
|
||
.bar-h{display:flex;align-items:center;gap:8px;margin-bottom:6px;}
|
||
.bar-lbl{font-size:11px;font-weight:600;min-width:80px;}
|
||
.bar-track{flex:1;height:7px;background:var(--bg);border-radius:4px;overflow:hidden;}
|
||
.bar-fill{height:100%;border-radius:4px;transition:width .4s;}
|
||
.bar-cnt{font-size:11px;font-weight:700;min-width:60px;text-align:right;}
|
||
|
||
.btn{padding:6px 14px;border:none;border-radius:6px;font-size:11px;font-weight:600;cursor:pointer;transition:all .15s;}
|
||
.btn-teal{background:var(--teal);color:#fff;}.btn-teal:hover{background:#0d9488;}
|
||
.btn-sm{padding:4px 10px;font-size:10px;}
|
||
.btn-ghost{background:transparent;border:1px solid var(--bg3);color:var(--text2);}.btn-ghost:hover{border-color:var(--teal);color:var(--teal);}
|
||
.btn-red{background:var(--red);color:#fff;}
|
||
.btn-row{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px;}
|
||
.toolbar{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-bottom:12px;}
|
||
.toolbar select,.toolbar input{background:var(--bg);border:1px solid var(--bg3);color:var(--text);padding:6px 10px;border-radius:6px;font-size:11px;font-family:inherit;}
|
||
|
||
.pager{display:flex;gap:3px;align-items:center;margin-top:10px;flex-wrap:wrap;}
|
||
.pager .btn.active{background:var(--teal);color:#fff;}
|
||
.pager-info{font-size:10px;color:var(--text3);margin-left:auto;}
|
||
|
||
.toast{position:fixed;top:16px;right:16px;background:var(--teal);color:#fff;padding:10px 18px;border-radius:8px;font-size:12px;font-weight:600;z-index:9999;display:none;box-shadow:0 4px 20px rgba(0,0,0,.3);}
|
||
.toast.show{display:block;animation:slideIn .3s;}
|
||
@keyframes slideIn{from{transform:translateX(80px);opacity:0;}}
|
||
|
||
/* Source checkboxes */
|
||
.src-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:6px;}
|
||
.src-item{display:flex;align-items:center;gap:8px;padding:8px 10px;background:var(--bg);border:1px solid var(--bg3);border-radius:8px;cursor:pointer;transition:all .15s;font-size:11px;}
|
||
.src-item:hover{border-color:var(--teal);background:rgba(20,184,166,.05);}
|
||
.src-item input{accent-color:var(--teal);width:14px;height:14px;}
|
||
.src-item .src-name{font-weight:600;flex:1;}
|
||
.src-item .src-type{font-size:9px;color:var(--text3);background:var(--bg2);padding:1px 6px;border-radius:4px;}
|
||
.src-item .src-pays{font-size:9px;}
|
||
|
||
.consent-box{background:var(--bg);border:1px solid var(--bg3);border-radius:10px;padding:16px;margin-bottom:12px;}
|
||
.consent-box h4{font-size:13px;font-weight:700;margin-bottom:8px;}
|
||
.consent-box p{font-size:12px;color:var(--text2);line-height:1.5;}
|
||
|
||
@media(max-width:900px){.grid-2,.grid-3{grid-template-columns:1fr;}.stats-row{grid-template-columns:repeat(2,1fr);}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="hdr">
|
||
<div class="hdr-left"><span style="font-size:22px;">💊</span><div><h1>Ethica HCP — Validator · Consent · Scraping</h1><div class="hdr-sub"><?=number_format($total)?> contacts — 10 spécialités prioritaires + 2 à venir — 3 pays</div></div></div>
|
||
<div class="hdr-right">
|
||
<div class="chip"><span class="dot <?=$running_val?'dot-orange':'dot-green'?>"></span><?=$running_val?'Validation...':'Validator idle'?></div>
|
||
<div class="chip"><span class="dot <?=$running_scr?'dot-orange':'dot-green'?>"></span><?=$running_scr?'Scraping...':'Scraper idle'?></div>
|
||
<a href="/ethica-dashboard.html" class="btn btn-sm btn-ghost"><i class="fas fa-arrow-left"></i> Dashboard</a>
|
||
<a href="/ethica-drill.html" class="btn btn-sm btn-ghost"><i class="fas fa-search"></i> Drill</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tabs">
|
||
<div class="tab active" onclick="showTab('validator')"><i class="fas fa-check-circle"></i> Email Validator</div>
|
||
<div class="tab" onclick="showTab('consent')"><i class="fas fa-shield-alt"></i> Consentement</div>
|
||
<div class="tab" onclick="showTab('scraping')"><i class="fas fa-spider"></i> Scraping Multi-Sources</div>
|
||
</div>
|
||
|
||
<div class="content">
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<!-- ============ EMAIL VALIDATOR ============ -->
|
||
<div class="panel active" id="validator">
|
||
<div class="stats-row" id="val-stats">
|
||
<div class="stat green"><div class="stat-lbl">✅ Valid</div><div class="stat-val" id="sv-valid"><?=$valid_stats['valid']??0?></div></div>
|
||
<div class="stat red"><div class="stat-lbl">❌ Invalid</div><div class="stat-val" id="sv-invalid"><?=$valid_stats['invalid']??0?></div></div>
|
||
<div class="stat orange"><div class="stat-lbl">⚠️ Risky</div><div class="stat-val" id="sv-risky"><?=$valid_stats['risky']??0?></div></div>
|
||
<div class="stat cyan"><div class="stat-lbl">🔄 Catch-All</div><div class="stat-val" id="sv-catch"><?=$valid_stats['catch_all']??0?></div></div>
|
||
<div class="stat"><div class="stat-lbl">❓ Unknown</div><div class="stat-val" id="sv-unknown"><?=$valid_stats['unknown']??0?></div></div>
|
||
<div class="stat purple"><div class="stat-lbl">📊 Checked</div><div class="stat-val" id="sv-checked"><?=number_format(($valid_stats['valid']??0)+($valid_stats['invalid']??0)+($valid_stats['risky']??0)+($valid_stats['catch_all']??0))?></div></div>
|
||
</div>
|
||
|
||
<div class="grid-2">
|
||
<div class="card">
|
||
<div class="card-head"><h3><i class="fas fa-play-circle"></i> Lancer Validation</h3></div>
|
||
<div class="card-body">
|
||
<div class="toolbar">
|
||
<select id="vf-pays"><option value="">Tous pays</option><option value="MA">🇲🇦 Maroc</option><option value="TN">🇹🇳 Tunisie</option><option value="DZ">🇩🇿 Algérie</option></select>
|
||
<select id="vf-batch">
|
||
<option value="100">100 emails</option><option value="500" selected>500 emails</option>
|
||
<option value="1000">1,000 emails</option><option value="2000">2,000 emails</option>
|
||
<option value="5000">5,000 emails</option><option value="10000">10,000 (loop)</option>
|
||
</select>
|
||
<button class="btn btn-teal" id="btn-validate" onclick="launchValidation()"><i class="fas fa-play"></i> Lancer</button>
|
||
<button class="btn btn-ghost" onclick="launchValidation(true)"><i class="fas fa-infinity"></i> Continu</button>
|
||
</div>
|
||
<div id="val-progress" style="font-size:12px;color:var(--text2);margin-top:8px;"></div>
|
||
|
||
<div style="margin-top:14px;">
|
||
<h4 style="font-size:12px;font-weight:700;margin-bottom:8px;">Répartition</h4>
|
||
<div id="val-bars"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head"><h3><i class="fas fa-history"></i> Historique Batches</h3></div>
|
||
<div class="card-body" style="max-height:400px;overflow-y:auto;" id="val-history">Chargement...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<h3><i class="fas fa-list"></i> Résultats Détaillés</h3>
|
||
<div class="toolbar" style="margin:0;">
|
||
<select id="vl-filter" onchange="loadValList()">
|
||
<option value="">Tous statuts</option>
|
||
<option value="valid">✅ Valid</option>
|
||
<option value="invalid">❌ Invalid</option>
|
||
<option value="risky">⚠️ Risky</option>
|
||
<option value="catch_all">🔄 Catch-All</option>
|
||
<option value="unknown">❓ Unknown</option>
|
||
</select>
|
||
<input type="text" id="vl-search" placeholder="🔍 Email..." oninput="clearTimeout(window._vst);window._vst=setTimeout(loadValList,400);" style="width:200px;">
|
||
</div>
|
||
</div>
|
||
<div class="card-body" style="overflow-x:auto;" id="val-list">Chargement...</div>
|
||
<div class="pager" id="val-pager"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ============ CONSENTEMENT ============ -->
|
||
<div class="panel" id="consent">
|
||
<div class="stats-row" id="con-stats">
|
||
<div class="stat purple"><div class="stat-lbl">⏳ Pending</div><div class="stat-val" id="sc-pending"><?=$consent_stats['pending']??$total?></div></div>
|
||
<div class="stat green"><div class="stat-lbl">✅ Opt-in</div><div class="stat-val" id="sc-optin"><?=$consent_stats['opt-in']??0?></div></div>
|
||
<div class="stat blue"><div class="stat-lbl">🔗 Implicite</div><div class="stat-val" id="sc-implicit"><?=$consent_stats['implicit']??0?></div></div>
|
||
<div class="stat red"><div class="stat-lbl">🚫 Opt-out</div><div class="stat-val" id="sc-optout"><?=$consent_stats['opt-out']??0?></div></div>
|
||
<div class="stat orange"><div class="stat-lbl">📧 Unsubscribed</div><div class="stat-val" id="sc-unsub"><?=$consent_stats['unsubscribed']??0?></div></div>
|
||
</div>
|
||
|
||
<div class="grid-2">
|
||
<div class="card">
|
||
<div class="card-head"><h3><i class="fas fa-shield-alt"></i> Système de Consentement</h3></div>
|
||
<div class="card-body">
|
||
<div class="consent-box">
|
||
<h4>📋 Texte de Consentement Officiel</h4>
|
||
<div style="background:rgba(13,148,136,.08);border:1px solid rgba(13,148,136,.25);border-radius:8px;padding:14px;margin:8px 0;font-style:italic;font-size:12px;line-height:1.7;color:var(--text);">
|
||
« En soumettant ce formulaire, je consens librement à recevoir des communications électroniques de WEVAL Consulting relatives à des informations médicales, scientifiques et professionnelles en lien avec ma pratique. Conformément à la réglementation en vigueur, mes données seront traitées de manière confidentielle. Je peux exercer mes droits d'accès, de rectification et de suppression, et retirer mon consentement à tout moment via le lien de désinscription. »
|
||
</div>
|
||
<h4 style="margin-top:14px;">⚖️ Cadre Réglementaire Applicable</h4>
|
||
<ul style="font-size:12px;color:var(--text2);margin:8px 0 0 16px;line-height:2;">
|
||
<li><strong>Maroc</strong> — Loi 09-08 – <span style="color:var(--teal);font-weight:700;">CNDP</span></li>
|
||
<li><strong>Tunisie</strong> — Loi 63-2004 – <span style="color:var(--teal);font-weight:700;">INPDP</span></li>
|
||
<li><strong>Algérie</strong> — Loi 18-07 – <span style="color:var(--teal);font-weight:700;">ANPDP</span></li>
|
||
</ul>
|
||
<div style="margin-top:10px;font-size:11px;color:var(--text3);">
|
||
<i class="fas fa-globe"></i> Module consentement : <a href="https://consent.wevup.app" target="_blank" style="color:var(--teal);">https://consent.wevup.app</a>
|
||
</div>
|
||
<h4 style="margin-top:14px;">🎯 Spécialités Ciblées</h4>
|
||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;">
|
||
<span class="badge b-optin">Cardiologie</span>
|
||
<span class="badge b-optin">Endocrinologie</span>
|
||
<span class="badge b-optin">Gastro-entérologie</span>
|
||
<span class="badge b-optin">Neurologie</span>
|
||
<span class="badge b-optin">Oncologie</span>
|
||
<span class="badge b-optin">Pneumologie</span>
|
||
<span class="badge b-optin">Rhumatologie</span>
|
||
<span class="badge b-optin">Dermatologie</span>
|
||
<span class="badge b-optin">Ophtalmologie</span>
|
||
<span class="badge b-optin">Médecine Interne</span>
|
||
<span class="badge b-pending" style="opacity:.7;">Gynécologie (à venir)</span>
|
||
<span class="badge b-pending" style="opacity:.7;">Cardiologie interventionnelle (à venir)</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="consent-box">
|
||
<h4>🔄 Workflow Consentement en 3 niveaux</h4>
|
||
<p><span class="badge b-pending">PENDING</span> → Premier email envoyé avec lien opt-in + opt-out<br>
|
||
<span class="badge b-implicit" style="margin-top:4px;">IMPLICIT</span> → Le médecin a ouvert l'email (soft opt-in, intérêt légitime)<br>
|
||
<span class="badge b-optin" style="margin-top:4px;">OPT-IN</span> → Le médecin a cliqué "Je souhaite recevoir" (explicit consent)<br>
|
||
<span class="badge b-optout" style="margin-top:4px;">OPT-OUT</span> → Le médecin a cliqué "Se désinscrire" (respect immédiat)</p>
|
||
</div>
|
||
|
||
<div class="btn-row">
|
||
<button class="btn btn-teal" onclick="applyImplicitConsent()"><i class="fas fa-check-double"></i> Appliquer Implicit aux Opens</button>
|
||
<button class="btn btn-ghost" onclick="exportConsent('opt-in')"><i class="fas fa-download"></i> Export Opt-in</button>
|
||
<button class="btn btn-ghost" onclick="exportConsent('opt-out')"><i class="fas fa-download"></i> Export Opt-out</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head"><h3><i class="fas fa-cog"></i> Outils Consentement</h3></div>
|
||
<div class="card-body">
|
||
<div style="margin-bottom:12px;">
|
||
<label style="font-size:11px;font-weight:600;color:var(--text2);">Changer statut par lot</label>
|
||
<div class="toolbar" style="margin-top:6px;">
|
||
<select id="con-from"><option value="pending">De: Pending</option><option value="implicit">De: Implicit</option></select>
|
||
<span style="color:var(--text3);">→</span>
|
||
<select id="con-to"><option value="implicit">Vers: Implicit</option><option value="opt-in">Vers: Opt-in</option><option value="opt-out">Vers: Opt-out</option></select>
|
||
<select id="con-pays"><option value="">Tous pays</option><option value="MA">MA</option><option value="TN">TN</option><option value="DZ">DZ</option></select>
|
||
<button class="btn btn-sm btn-teal" onclick="batchConsent()"><i class="fas fa-exchange-alt"></i> Appliquer</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-top:16px;">
|
||
<h4 style="font-size:12px;font-weight:700;margin-bottom:6px;">URL Pages Consentement</h4>
|
||
<div style="font-size:11px;color:var(--text2);background:var(--bg);padding:10px;border-radius:6px;">
|
||
<p>📥 <strong>Opt-in:</strong> <code>https://consent.wevup.app/ethica-consent.php?action=optin&token={TOKEN}</code></p>
|
||
<p style="margin-top:4px;">📤 <strong>Opt-out:</strong> <code>https://consent.wevup.app/ethica-consent.php?action=optout&token={TOKEN}</code></p>
|
||
<p style="margin-top:4px;">📋 <strong>Preferences:</strong> <code>https://consent.wevup.app/ethica-consent.php?action=prefs&token={TOKEN}</code></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head"><h3><i class="fas fa-list"></i> Contacts par Statut Consentement</h3>
|
||
<div class="toolbar" style="margin:0;">
|
||
<select id="cl-filter" onchange="loadConsentList()">
|
||
<option value="">Tous</option><option value="pending">Pending</option><option value="implicit">Implicit</option><option value="opt-in">Opt-in</option><option value="opt-out">Opt-out</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="card-body" style="overflow-x:auto;" id="consent-list">Chargement...</div>
|
||
<div class="pager" id="consent-pager"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ============ SCRAPING MULTI-SOURCES ============ -->
|
||
<div class="panel" id="scraping">
|
||
<div class="stats-row">
|
||
<div class="stat"><div class="stat-lbl">Total HCP</div><div class="stat-val" id="ss-total"><?=number_format($total)?></div></div>
|
||
<div class="stat blue"><div class="stat-lbl">Sources</div><div class="stat-val" id="ss-sources">30</div></div>
|
||
<div class="stat green"><div class="stat-lbl">Aujourd'hui</div><div class="stat-val" id="ss-today">0</div></div>
|
||
<div class="stat orange"><div class="stat-lbl">Scraper</div><div class="stat-val" id="ss-running"><?=$running_scr?'🟠 Actif':'🟢 Idle'?></div></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<h3><i class="fas fa-spider"></i> Sources de Scraping</h3>
|
||
<div>
|
||
<button class="btn btn-sm btn-ghost" onclick="toggleAllSources(true)"><i class="fas fa-check-double"></i> Tout cocher</button>
|
||
<button class="btn btn-sm btn-ghost" onclick="toggleAllSources(false)"><i class="fas fa-times"></i> Tout décocher</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="src-grid" id="sources-grid">Chargement...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid-2">
|
||
<div class="card">
|
||
<div class="card-head"><h3><i class="fas fa-play-circle"></i> Lancer Scraping</h3></div>
|
||
<div class="card-body">
|
||
<div class="toolbar">
|
||
<select id="scr-pays"><option value="all">Tous pays</option><option value="MA">🇲🇦 Maroc</option><option value="TN">🇹🇳 Tunisie</option><option value="DZ">🇩🇿 Algérie</option></select>
|
||
<select id="scr-spec"><option value="all">Toutes spécialités</option></select>
|
||
<select id="scr-mode">
|
||
<option value="all">Tous modes</option>
|
||
<option value="google">Google Search</option>
|
||
<option value="directories">Annuaires</option>
|
||
<option value="generate">Génération</option>
|
||
</select>
|
||
</div>
|
||
<button class="btn btn-teal" onclick="launchScraping()"><i class="fas fa-rocket"></i> Lancer Scraping</button>
|
||
<button class="btn btn-ghost" onclick="launchScrapingContinuous()" style="margin-left:6px;"><i class="fas fa-infinity"></i> Continu (24h)</button>
|
||
<div id="scr-status" style="margin-top:10px;font-size:12px;color:var(--text2);"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head"><h3><i class="fas fa-terminal"></i> Logs Live</h3></div>
|
||
<div class="card-body" style="max-height:300px;overflow-y:auto;">
|
||
<pre id="scr-logs" style="font-size:10px;color:var(--teal);background:var(--bg);padding:10px;border-radius:6px;white-space:pre-wrap;max-height:260px;overflow-y:auto;">En attente...</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head"><h3><i class="fas fa-history"></i> Historique Scraping</h3></div>
|
||
<div class="card-body" style="overflow-x:auto;" id="scr-history">Chargement...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const API = '/api/ethica-api.php';
|
||
const DRILL = '/api/ethica-drill.php';
|
||
const FLAGS = {MA:'🇲🇦',TN:'🇹🇳',DZ:'🇩🇿',ALL:'🌍'};
|
||
const VAL_BADGES = {valid:'b-valid',invalid:'b-invalid',risky:'b-risky',unknown:'b-unknown',catch_all:'b-catch'};
|
||
const CON_BADGES = {pending:'b-pending','opt-in':'b-optin','opt-out':'b-optout',implicit:'b-implicit',unsubscribed:'b-optout'};
|
||
|
||
function showTab(id) {
|
||
document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
|
||
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
|
||
document.getElementById(id).classList.add('active');
|
||
event.target.closest('.tab').classList.add('active');
|
||
if(id==='validator') loadValidator();
|
||
if(id==='consent') loadConsent();
|
||
if(id==='scraping') loadScraping();
|
||
}
|
||
function toast(m){const t=document.getElementById('toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),3000);}
|
||
|
||
// ========== VALIDATOR ==========
|
||
let valPage = 1;
|
||
async function loadValidator() {
|
||
const d = await (await fetch(DRILL+'?action=summary')).json();
|
||
// Fetch validation stats
|
||
const r = await (await fetch(API+'?action=validation_stats')).json();
|
||
if(r.stats) {
|
||
Object.entries(r.stats).forEach(([k,v]) => { const el=document.getElementById('sv-'+k); if(el) el.textContent=v.toLocaleString(); });
|
||
document.getElementById('sv-checked').textContent = ((r.stats.valid||0)+(r.stats.invalid||0)+(r.stats.risky||0)+(r.stats.catch_all||0)).toLocaleString();
|
||
// Bars
|
||
const total = Object.values(r.stats).reduce((a,b)=>a+b,0);
|
||
const colors = {valid:'#22c55e',invalid:'#ef4444',risky:'#f59e0b',catch_all:'#06b6d4',unknown:'#64748b'};
|
||
let barsHtml = '';
|
||
Object.entries(r.stats).forEach(([k,v]) => {
|
||
const pct = total>0?(v/total*100):0;
|
||
barsHtml += `<div class="bar-h"><span class="bar-lbl">${k}</span><div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:${colors[k]||'#64748b'}"></div></div><span class="bar-cnt">${v.toLocaleString()} (${pct.toFixed(1)}%)</span></div>`;
|
||
});
|
||
document.getElementById('val-bars').innerHTML = barsHtml;
|
||
}
|
||
// History
|
||
if(r.history) {
|
||
let h = '<table class="tbl"><thead><tr><th>Batch</th><th>Total</th><th>Valid</th><th>Invalid</th><th>Unknown</th><th>Durée</th><th>Date</th></tr></thead><tbody>';
|
||
r.history.forEach(b => {
|
||
h += `<tr><td style="font-size:10px;">${b.batch_id}</td><td>${b.total_checked}</td><td style="color:var(--green)">${b.valid}</td><td style="color:var(--red)">${b.invalid}</td><td>${b.unknown}</td><td>${b.duration_sec}s</td><td style="font-size:10px;">${new Date(b.started_at).toLocaleString('fr-FR')}</td></tr>`;
|
||
});
|
||
h += '</tbody></table>';
|
||
document.getElementById('val-history').innerHTML = h;
|
||
}
|
||
loadValList();
|
||
}
|
||
|
||
async function loadValList(page) {
|
||
if(page) valPage=page;
|
||
const filter = document.getElementById('vl-filter').value;
|
||
const search = document.getElementById('vl-search').value;
|
||
let url = API+`?action=validation_list&page=${valPage}&limit=50`;
|
||
if(filter) url += `&filter=${filter}`;
|
||
if(search) url += `&search=${encodeURIComponent(search)}`;
|
||
const r = await (await fetch(url)).json();
|
||
let h = '<table class="tbl"><thead><tr><th>Email</th><th>Status</th><th>MX</th><th>SMTP</th><th>Spécialité</th><th>Pays</th><th>Téléphone</th><th>Adresse</th><th>Vérifié</th></tr></thead><tbody>';
|
||
(r.contacts||[]).forEach(c => {
|
||
h += `<tr><td style="font-weight:500;font-size:10px;">${c.email}</td>
|
||
<td><span class="badge ${VAL_BADGES[c.email_valid]||'b-unknown'}">${c.email_valid}</span></td>
|
||
<td style="font-size:10px;color:var(--text3)">${c.mx_host||''}</td>
|
||
<td>${c.smtp_code||''}</td>
|
||
<td style="font-size:9px;color:var(--text3);max-width:200px;overflow:hidden;text-overflow:ellipsis;">${c.smtp_msg||''}</td>
|
||
<td><span class="badge b-pending">${c.specialite}</span></td>
|
||
<td>${FLAGS[c.pays]||''} ${c.pays}</td>
|
||
<td style="font-size:11px;">${c.telephone||''}</td>
|
||
<td style="font-size:10px;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${c.adresse||''}</td>
|
||
<td style="font-size:9px;">${c.email_checked_at?new Date(c.email_checked_at).toLocaleString('fr-FR'):''}</td></tr>`;
|
||
});
|
||
h += '</tbody></table>';
|
||
document.getElementById('val-list').innerHTML = h;
|
||
// Pager
|
||
let pg = '';
|
||
const pages = r.pages||1;
|
||
if(valPage>1) pg += `<button class="btn btn-sm btn-ghost" onclick="loadValList(${valPage-1})">‹</button>`;
|
||
for(let i=Math.max(1,valPage-3);i<=Math.min(pages,valPage+3);i++) pg+=`<button class="btn btn-sm ${i===valPage?'btn-teal active':'btn-ghost'}" onclick="loadValList(${i})">${i}</button>`;
|
||
if(valPage<pages) pg+=`<button class="btn btn-sm btn-ghost" onclick="loadValList(${valPage+1})">›</button>`;
|
||
pg += `<span class="pager-info">${(r.total||0).toLocaleString()} résultats</span>`;
|
||
document.getElementById('val-pager').innerHTML = pg;
|
||
}
|
||
|
||
async function launchValidation(continuous) {
|
||
const pays = document.getElementById('vf-pays').value;
|
||
const batch = document.getElementById('vf-batch').value;
|
||
const fd = new FormData();
|
||
fd.append('action','launch_validation');
|
||
fd.append('batch', batch);
|
||
fd.append('pays', pays);
|
||
fd.append('continuous', continuous?'1':'0');
|
||
document.getElementById('btn-validate').disabled = true;
|
||
document.getElementById('val-progress').innerHTML = '<span style="color:var(--orange);">⏳ Validation en cours...</span>';
|
||
const r = await (await fetch(API, {method:'POST', body:fd})).json();
|
||
toast(r.message||'Validation lancée');
|
||
document.getElementById('btn-validate').disabled = false;
|
||
// Poll progress
|
||
pollValidation();
|
||
}
|
||
|
||
async function pollValidation() {
|
||
for(let i=0;i<30;i++) {
|
||
await new Promise(r=>setTimeout(r,3000));
|
||
const r = await (await fetch(API+'?action=validation_stats')).json();
|
||
if(r.stats) {
|
||
const checked = (r.stats.valid||0)+(r.stats.invalid||0)+(r.stats.risky||0)+(r.stats.catch_all||0);
|
||
document.getElementById('val-progress').innerHTML = `<span style="color:var(--teal);">✅ ${checked.toLocaleString()} vérifiés | V:${r.stats.valid||0} I:${r.stats.invalid||0} R:${r.stats.risky||0}</span>`;
|
||
Object.entries(r.stats).forEach(([k,v])=>{const el=document.getElementById('sv-'+k);if(el)el.textContent=v.toLocaleString();});
|
||
}
|
||
if(!r.running) break;
|
||
}
|
||
loadValidator();
|
||
}
|
||
|
||
// ========== CONSENT ==========
|
||
let conPage = 1;
|
||
async function loadConsent() {
|
||
const r = await (await fetch(API+'?action=consent_stats')).json();
|
||
if(r.stats) Object.entries(r.stats).forEach(([k,v])=>{const el=document.getElementById('sc-'+k.replace('-',''));if(el)el.textContent=v.toLocaleString();});
|
||
loadConsentList();
|
||
}
|
||
|
||
async function loadConsentList(page) {
|
||
if(page) conPage=page;
|
||
const filter = document.getElementById('cl-filter').value;
|
||
let url = API+`?action=consent_list&page=${conPage}&limit=50`;
|
||
if(filter) url += `&filter=${filter}`;
|
||
const r = await (await fetch(url)).json();
|
||
let h = '<table class="tbl"><thead><tr><th>Email</th><th>Nom</th><th>Consent</th><th>Email Valid</th><th>Téléphone</th><th>Adresse</th><th>Téléphone</th><th>Adresse</th><th>Méthode</th><th>Spécialité</th><th>Pays</th></tr></thead><tbody>';
|
||
(r.contacts||[]).forEach(c => {
|
||
h += `<tr><td style="font-weight:500;font-size:10px;">${c.email}</td>
|
||
<td>${c.nom||''} ${c.prenom||''}</td>
|
||
<td><span class="badge ${CON_BADGES[c.consent_status]||'b-pending'}">${c.consent_status}</span></td>
|
||
<td><span class="badge ${VAL_BADGES[c.email_valid]||'b-unknown'}">${c.email_valid||'unknown'}</span></td>
|
||
<td style="font-size:11px;">${c.telephone||''}</td><td style="font-size:10px;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${c.adresse||''}</td><td style="font-size:10px;">${c.consent_method||''}</td>
|
||
<td style="font-size:9px;">${c.consent_date?new Date(c.consent_date).toLocaleString('fr-FR'):''}</td>
|
||
<td><span class="badge b-pending">${c.specialite}</span></td>
|
||
<td>${FLAGS[c.pays]||''}</td></tr>`;
|
||
});
|
||
h += '</tbody></table>';
|
||
document.getElementById('consent-list').innerHTML = h;
|
||
let pg='';const pages=r.pages||1;
|
||
for(let i=Math.max(1,conPage-3);i<=Math.min(pages,conPage+3);i++) pg+=`<button class="btn btn-sm ${i===conPage?'btn-teal active':'btn-ghost'}" onclick="loadConsentList(${i})">${i}</button>`;
|
||
pg+=`<span class="pager-info">${(r.total||0).toLocaleString()}</span>`;
|
||
document.getElementById('consent-pager').innerHTML=pg;
|
||
}
|
||
|
||
async function applyImplicitConsent() {
|
||
const fd = new FormData(); fd.append('action','apply_implicit_consent');
|
||
const r = await (await fetch(API,{method:'POST',body:fd})).json();
|
||
toast(`✅ ${r.updated||0} contacts → implicit consent`);
|
||
loadConsent();
|
||
}
|
||
|
||
async function batchConsent() {
|
||
const fd = new FormData();
|
||
fd.append('action','batch_consent');
|
||
fd.append('from', document.getElementById('con-from').value);
|
||
fd.append('to', document.getElementById('con-to').value);
|
||
fd.append('pays', document.getElementById('con-pays').value);
|
||
const r = await (await fetch(API,{method:'POST',body:fd})).json();
|
||
toast(`✅ ${r.updated||0} contacts mis à jour`);
|
||
loadConsent();
|
||
}
|
||
|
||
function exportConsent(status) { window.open(DRILL+`?action=export_csv&consent=${status}`, '_blank'); }
|
||
|
||
// ========== SCRAPING ==========
|
||
async function loadScraping() {
|
||
const r = await (await fetch(API+'?action=scraping_sources')).json();
|
||
let grid = '';
|
||
const types = {};
|
||
(r.sources||[]).forEach(s => {
|
||
if(!types[s.type]) types[s.type] = [];
|
||
types[s.type].push(s);
|
||
});
|
||
Object.entries(types).forEach(([type, sources]) => {
|
||
grid += `<div style="grid-column:1/-1;font-size:11px;font-weight:700;color:var(--teal);margin-top:8px;text-transform:uppercase;">${type} (${sources.length})</div>`;
|
||
sources.forEach(s => {
|
||
grid += `<label class="src-item">
|
||
<input type="checkbox" checked data-src-id="${s.id}" data-src-name="${s.name}">
|
||
<span class="src-name">${s.name}</span>
|
||
<span class="src-pays">${FLAGS[s.pays]||s.pays}</span>
|
||
<span class="src-type">${s.type}</span>
|
||
</label>`;
|
||
});
|
||
});
|
||
document.getElementById('sources-grid').innerHTML = grid;
|
||
|
||
// Load specs dropdown
|
||
const specs = await (await fetch(API+'?action=specialites')).json();
|
||
let opts = '<option value="all">Toutes spécialités</option>';
|
||
(specs.specialites||[]).forEach(s => opts += `<option value="${s.code}">${s.label}</option>`);
|
||
document.getElementById('scr-spec').innerHTML = opts;
|
||
|
||
// Scraping history
|
||
const h = await (await fetch(API+'?action=scraping_status')).json();
|
||
let hist = '<table class="tbl"><thead><tr><th>Date</th><th>Spécialité</th><th>Pays</th><th>Trouvés</th><th>Nouveaux</th><th>Durée</th></tr></thead><tbody>';
|
||
(h.recent_logs||[]).forEach(l => {
|
||
hist += `<tr><td style="font-size:10px;">${new Date(l.created_at).toLocaleString('fr-FR')}</td><td>${l.specialite||'all'}</td><td>${l.pays||'all'}</td><td>${l.emails_found}</td><td style="color:var(--green);font-weight:700;">${l.emails_new}</td><td>${l.duration_sec}s</td></tr>`;
|
||
});
|
||
hist += '</tbody></table>';
|
||
document.getElementById('scr-history').innerHTML = hist;
|
||
|
||
// Logs
|
||
refreshLogs();
|
||
}
|
||
|
||
function toggleAllSources(checked) {
|
||
document.querySelectorAll('#sources-grid input[type=checkbox]').forEach(cb => cb.checked = checked);
|
||
}
|
||
|
||
async function launchScraping() {
|
||
const sources = [...document.querySelectorAll('#sources-grid input:checked')].map(cb=>cb.dataset.srcName);
|
||
const pays = document.getElementById('scr-pays').value;
|
||
const spec = document.getElementById('scr-spec').value;
|
||
const mode = document.getElementById('scr-mode').value;
|
||
const fd = new FormData();
|
||
fd.append('action','launch_scraping');
|
||
fd.append('sources', JSON.stringify(sources));
|
||
fd.append('pays', pays);
|
||
fd.append('spec', spec);
|
||
fd.append('mode', mode);
|
||
const r = await (await fetch(API,{method:'POST',body:fd})).json();
|
||
toast(r.message||'Scraping lancé');
|
||
document.getElementById('scr-status').innerHTML = '<span style="color:var(--orange);">⏳ Scraping en cours...</span>';
|
||
pollLogs();
|
||
}
|
||
|
||
async function launchScrapingContinuous() {
|
||
const fd = new FormData();
|
||
fd.append('action','launch_scraping_continuous');
|
||
const r = await (await fetch(API,{method:'POST',body:fd})).json();
|
||
toast(r.message||'Scraping continu lancé (24h)');
|
||
}
|
||
|
||
async function refreshLogs() {
|
||
const r = await (await fetch(API+'?action=scraping_logs')).json();
|
||
document.getElementById('scr-logs').textContent = r.logs || 'Pas de logs récents';
|
||
}
|
||
|
||
async function pollLogs() {
|
||
for(let i=0;i<20;i++) { await new Promise(r=>setTimeout(r,5000)); refreshLogs(); }
|
||
}
|
||
|
||
// Init
|
||
loadValidator();
|
||
// Populate specs for scraping when tab opens
|
||
setInterval(async()=>{
|
||
const r = await (await fetch(API+'?action=validation_stats')).json();
|
||
if(r.stats) Object.entries(r.stats).forEach(([k,v])=>{const el=document.getElementById('sv-'+k);if(el)el.textContent=v.toLocaleString();});
|
||
}, 15000);
|
||
</script>
|
||
</body>
|
||
</html>
|