Files
DomGod/app/static/index.html
Malin 60c9b495ae fix: AI worker crash-proof + GDPR/hosting/accessibility analysis
AI worker fixes (root cause of "nothing reaches Replicate"):
- Worker task died silently — no exception handler around while loop
- Added try/except around entire loop body with exc_info logging
- Added watchdog task that restarts dead workers every 10 seconds
- ensure_workers_alive() called on every /api/ai/assess/batch POST
- _assess_one() is now a top-level function (not closure) — avoids
  subtle scoping bugs with async inner functions in while loops
- /api/ai/debug endpoint: shows worker alive status, task exception,
  last 10 queue entries — browse to /api/ai/debug to diagnose
- /api/ai/worker/restart endpoint + UI button
- "Restart AI worker" button + "Debug AI queue" link in enrichment tab

site_analyzer.py — new signals:
- IP resolution + ip-api.com for ASN, org, ISP, host country
- EU hosting detection (27 EU + EEA + adequacy countries)
- GDPR: detects Cookiebot, OneTrust, CookiePro, Osano, Iubenda,
  Borlabs, CookieYes, Complianz, Usercentrics + text signals
- Privacy policy and GDPR text presence
- Accessibility: html lang missing, images without alt count,
  skip nav link, empty links, inputs without labels

Gemini prompt additions:
- Hosting section: IP, ASN, org/ISP, EU vs non-EU flag
- GDPR section: cookie tool, notice, privacy policy
- Accessibility section: all quick-scan results
- New output fields: hosting_notes, gdpr_compliance,
  accessibility_issues[]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:01:34 +02:00

725 lines
40 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DomGod — Domain Intelligence</title>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root{
--bg:#0f1117;--surface:#1a1d27;--surface2:#222638;--border:#2e3250;
--accent:#6c63ff;--accent2:#00d4aa;--danger:#ff4f6d;--warn:#ffb347;
--text:#e8eaf0;--muted:#8891b0;--r:8px;
--kd:#f59e0b;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;font-size:14px}
a{color:var(--accent2);text-decoration:none}
header{background:var(--surface);border-bottom:1px solid var(--border);padding:11px 20px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:200}
header h1{font-size:20px;font-weight:900;letter-spacing:-1px}
header h1 span{color:var(--accent)}
.hbadge{background:var(--surface2);border:1px solid var(--border);color:var(--muted);font-size:11px;padding:2px 8px;border-radius:99px}
.ipill{font-size:11px;padding:3px 9px;border-radius:99px;font-weight:700}
.ip-ok{background:#00d4aa18;color:var(--accent2);border:1px solid #00d4aa33}
.ip-bld{background:#ffb34718;color:var(--warn);border:1px solid #ffb34733}
main{padding:14px 20px;display:flex;flex-direction:column;gap:14px;max-width:1500px;margin:0 auto;width:100%}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:14px}
.ct{font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;margin-bottom:10px}
/* Stats */
.sg{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px}
.sb{background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);padding:11px 13px}
.sb .l{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.3px}
.sb .v{font-size:22px;font-weight:800;margin-top:2px}
.sb .s{font-size:11px;color:var(--muted);margin-top:1px}
.c1{color:var(--accent2)} .c2{color:var(--danger)} .c3{color:var(--warn)} .c4{color:var(--muted)}
.ckd{color:var(--kd)}
/* Tabs */
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--border)}
.tab{padding:8px 15px;cursor:pointer;font-size:13px;font-weight:500;color:var(--muted);border-radius:6px 6px 0 0;border:1px solid transparent;border-bottom:none;user-select:none}
.tab.active{background:var(--surface);color:var(--text);border-color:var(--border)}
.tab:hover:not(.active){color:var(--text)}
/* Filters */
.frow{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;margin-bottom:10px}
.field{display:flex;flex-direction:column;gap:3px}
.field label{font-size:11px;color:var(--muted);text-transform:uppercase;font-weight:600}
input[type=text],input[type=number],select{
background:var(--surface2);border:1px solid var(--border);color:var(--text);
padding:5px 9px;border-radius:6px;font-size:13px;outline:none
}
input[type=text]:focus,select:focus{border-color:var(--accent)}
input[type=range]{accent-color:var(--accent);width:100px;cursor:pointer}
.tog{display:flex;align-items:center;gap:5px;cursor:pointer;padding:5px 0;font-size:12px;color:var(--muted)}
.tog input{accent-color:var(--accent);width:14px;height:14px;cursor:pointer}
.tog strong{color:var(--text)}
/* Buttons */
.btn{padding:6px 13px;border-radius:6px;font-size:12px;font-weight:700;cursor:pointer;border:none;transition:opacity .15s;white-space:nowrap}
.btn:hover:not(:disabled){opacity:.82} .btn:disabled{opacity:.35;cursor:not-allowed}
.bp{background:var(--accent);color:#fff}
.bs{background:var(--accent2);color:#111}
.bd{background:var(--danger);color:#fff}
.bw{background:var(--warn);color:#111}
.bg{background:var(--surface2);color:var(--text);border:1px solid var(--border)}
.bkd{background:#f59e0b22;color:var(--kd);border:1px solid #f59e0b44}
.bai{background:#a855f722;color:#c084fc;border:1px solid #a855f744}
.sm{padding:4px 9px;font-size:11px}
/* Table */
.tw{overflow-x:auto;border-radius:var(--r);border:1px solid var(--border)}
table{width:100%;border-collapse:collapse;font-size:12px}
th{text-align:left;padding:7px 9px;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.4px;background:var(--surface2);border-bottom:1px solid var(--border);white-space:nowrap}
td{padding:6px 9px;border-bottom:1px solid var(--border);vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:rgba(255,255,255,.025)}
.pill{display:inline-block;padding:1px 6px;border-radius:99px;font-size:10px;font-weight:700}
.pg{background:#00d4aa18;color:var(--accent2)} .pr{background:#ff4f6d18;color:var(--danger)}
.pp{background:#ffffff11;color:var(--muted)} .pc{background:#6c63ff18;color:var(--accent)}
.pkd{background:#f59e0b18;color:var(--kd);border:1px solid #f59e0b33}
/* AI quality pills */
.ai-hot{background:#ff4f6d18;color:var(--danger);border:1px solid #ff4f6d33}
.ai-warm{background:#ffb34718;color:var(--warn);border:1px solid #ffb34733}
.ai-cold{background:#6c7aff18;color:#6c7aff;border:1px solid #6c7aff33}
.ai-none{background:#ffffff08;color:var(--muted)}
.score{display:inline-block;padding:1px 6px;border-radius:5px;font-weight:800;font-size:11px;min-width:28px;text-align:center}
/* Contact chips */
.contact-chips{display:flex;flex-wrap:wrap;gap:3px}
.chip{display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:4px;font-size:10px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.chip.email{border-color:#00d4aa33;color:var(--accent2)}
.chip.phone{border-color:#6c63ff33;color:var(--accent)}
.chip.wa{border-color:#22c55e33;color:#4ade80}
.chip.social{border-color:#f59e0b33;color:var(--kd)}
/* Tooltip */
[title]{cursor:help}
.pitch-cell{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;color:var(--muted);font-style:italic}
/* Pagination */
.pager{display:flex;align-items:center;gap:8px;margin-top:10px;flex-wrap:wrap}
.pager .inf{font-size:11px;color:var(--muted)}
/* Progress */
.pw{background:var(--surface2);border-radius:99px;height:8px;overflow:hidden;margin:6px 0}
.pb{height:100%;background:linear-gradient(90deg,var(--accent),var(--accent2));border-radius:99px;transition:width .5s}
/* Pipeline */
.pipeline{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px}
.pcol{background:var(--surface2);border-radius:var(--r);border:1px solid var(--border);padding:12px;display:flex;flex-direction:column;gap:7px}
.pcol h3{font-size:14px;font-weight:800}
.pcol .cnt{font-size:28px;font-weight:900;line-height:1}
.samples{display:flex;flex-direction:column;gap:3px}
.sample{font-size:11px;padding:4px 7px;background:var(--surface);border-radius:5px;display:flex;justify-content:space-between;align-items:center;gap:6px}
/* Enrich stats */
.esg{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:8px;margin-bottom:12px}
.esb{background:var(--surface2);border-radius:var(--r);padding:9px;text-align:center}
.esb .ev{font-size:20px;font-weight:800}
.esb .el{font-size:10px;color:var(--muted);margin-top:1px}
/* Toast */
.toast{position:fixed;bottom:20px;right:20px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);padding:10px 16px;font-size:13px;font-weight:600;z-index:9999;transition:opacity .3s;box-shadow:0 4px 20px #0009}
.toast.hidden{opacity:0;pointer-events:none}
.toast.success{border-color:#00d4aa55;color:var(--accent2)}
.toast.error{border-color:#ff4f6d55;color:var(--danger)}
.toast.info{border-color:#6c63ff55;color:var(--accent)}
/* Chart */
.cw{height:260px}
/* AI detail modal */
.modal-bg{position:fixed;inset:0;background:#000a;z-index:300;display:flex;align-items:center;justify-content:center}
.modal{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:18px;max-width:560px;width:95%;max-height:88vh;overflow-y:auto}
.modal h2{font-size:15px;font-weight:800}
.mrow{display:flex;gap:8px;margin-bottom:6px;font-size:12px;line-height:1.4}
.mlabel{color:var(--muted);min-width:90px;font-size:11px;padding-top:1px;flex-shrink:0}
@media(max-width:700px){.pipeline{grid-template-columns:1fr}.sg{grid-template-columns:1fr 1fr}}
</style>
</head>
<body x-data="app()" x-init="init()">
<!-- Toast -->
<div class="toast" :class="[toast.type, {hidden:!toast.show}]" x-text="toast.msg"></div>
<!-- AI Detail Modal -->
<div class="modal-bg" x-show="modal.open" @click.self="modal.open=false" x-cloak>
<div class="modal" @click.stop>
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px">
<h2>AI Report — <span style="color:var(--accent2)" x-text="modal.domain"></span></h2>
<button class="btn bg sm" @click="modal.open=false"></button>
</div>
<!-- Summary banner -->
<div x-show="modal.ai.summary" style="background:var(--surface2);border-radius:6px;padding:10px 12px;margin-bottom:12px;font-size:12px;line-height:1.5;color:var(--text)" x-text="modal.ai.summary"></div>
<!-- Lead + quality -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:12px">
<div style="background:var(--surface2);border-radius:6px;padding:8px;text-align:center">
<div style="font-size:10px;color:var(--muted);margin-bottom:3px">LEAD</div>
<span class="pill" :class="aiPillClass(modal.ai.lead_quality)" x-text="modal.ai.lead_quality||'—'"></span>
</div>
<div style="background:var(--surface2);border-radius:6px;padding:8px;text-align:center">
<div style="font-size:10px;color:var(--muted);margin-bottom:3px">SITE QUALITY</div>
<span class="score" :style="qualityBg(modal.ai.site_quality_score)" x-text="(modal.ai.site_quality_score??'—')+'/10'"></span>
</div>
<div style="background:var(--surface2);border-radius:6px;padding:8px;text-align:center">
<div style="font-size:10px;color:var(--muted);margin-bottom:3px">KIT DIGITAL</div>
<span x-text="modal.ai.kit_digital_confirmed ? '✅ Yes' : '❌ No'" style="font-size:13px;font-weight:700"></span>
</div>
</div>
<div class="mrow"><span class="mlabel">Reasoning</span><span x-text="modal.ai.lead_reasoning||'—'"></span></div>
<div class="mrow"><span class="mlabel">KD notes</span><span x-text="modal.ai.kit_digital_reasoning||'—'"></span></div>
<div class="mrow"><span class="mlabel">Performance</span><span x-text="modal.ai.performance_notes||'—'"></span></div>
<div class="mrow"><span class="mlabel">SEO</span><span x-text="modal.ai.seo_status||'—'"></span></div>
<div class="mrow"><span class="mlabel">Hosting</span><span x-text="(modal.sa?.org||'?') + ' / ' + (modal.sa?.ip_country||'?') + (modal.sa?.eu_hosted===false?' ❌ Non-EU':modal.sa?.eu_hosted?' ✅ EU':'')"></span></div>
<div class="mrow"><span class="mlabel">GDPR</span><span :style="(!modal.sa?.has_cookie_notice)?'color:var(--danger)':''" x-text="modal.ai.gdpr_compliance||'—'"></span></div>
<!-- Content issues -->
<div x-show="(modal.ai.content_issues||[]).length>0" style="margin:8px 0">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;margin-bottom:4px">Content Issues</div>
<template x-for="issue in (modal.ai.content_issues||[])">
<div style="font-size:12px;color:var(--danger);padding:2px 0"><span x-text="issue"></span></div>
</template>
</div>
<!-- Urgency signals -->
<div x-show="(modal.ai.urgency_signals||[]).length>0" style="margin:8px 0">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;margin-bottom:4px">Urgency Signals</div>
<template x-for="sig in (modal.ai.urgency_signals||[])">
<div style="font-size:12px;color:var(--warn);padding:2px 0">🔴 <span x-text="sig"></span></div>
</template>
</div>
<!-- Contact -->
<div style="background:var(--surface2);border-radius:6px;padding:10px;margin:8px 0">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;margin-bottom:6px">Best Contact</div>
<div style="font-size:13px;font-weight:700;color:var(--accent2)" x-text="(modal.ai.best_contact_channel||'unknown').toUpperCase()"></div>
<div style="font-size:12px;color:var(--text);margin-top:2px;word-break:break-all" x-text="modal.ai.best_contact_value||'—'"></div>
<!-- All contacts from site_analysis -->
<div x-show="modal.sa" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:4px">
<template x-for="em in (modal.sa?.emails||[])">
<a :href="'mailto:'+em" class="chip email" x-text="em"></a>
</template>
<template x-for="ph in (modal.sa?.phones||[])">
<a :href="'tel:'+ph" class="chip phone" x-text="ph"></a>
</template>
<template x-for="wa in (modal.sa?.whatsapp||[])">
<a :href="wa" target="_blank" class="chip wa">💬 WhatsApp</a>
</template>
<template x-for="s in (modal.sa?.social_links||[]).slice(0,3)">
<a :href="s" target="_blank" class="chip social" x-text="s.replace('https://','').split('/')[0]"></a>
</template>
</div>
</div>
<!-- Pitch -->
<div style="background:#6c63ff15;border:1px solid #6c63ff33;border-radius:6px;padding:10px;margin:8px 0">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;margin-bottom:4px">Cold Pitch (ES)</div>
<div style="font-size:13px;font-style:italic;color:var(--accent2)" x-text="modal.ai.pitch_angle||'—'"></div>
</div>
<!-- Accessibility issues -->
<div x-show="(modal.ai.accessibility_issues||[]).length>0" style="margin:8px 0">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;margin-bottom:4px">Accessibility Issues</div>
<template x-for="issue in (modal.ai.accessibility_issues||[])">
<div style="font-size:12px;color:var(--warn);padding:2px 0"><span x-text="issue"></span></div>
</template>
</div>
<div class="mrow"><span class="mlabel">Services</span><span x-text="(modal.ai.services_needed||[]).join(', ')||'—'"></span></div>
<div class="mrow"><span class="mlabel">Notes</span><span x-text="modal.ai.outreach_notes||'—'"></span></div>
<!-- Site analysis tech snapshot -->
<div x-show="modal.sa" style="margin-top:10px;padding-top:10px;border-top:1px solid var(--border)">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;margin-bottom:6px">Technical Snapshot</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;font-size:11px">
<div>Load time: <b x-text="(modal.sa?.load_time_ms||'—')+'ms'"></b></div>
<div>Page size: <b x-text="(modal.sa?.page_size_kb||'—')+'KB'"></b></div>
<div>CMS: <b x-text="modal.sa?.cms||'unknown'"></b></div>
<div>Server: <b x-text="modal.sa?.server||'—'"></b></div>
<div>Sitemap: <b x-text="modal.sa?.has_sitemap?'✅':'❌'"></b></div>
<div>Robots: <b x-text="modal.sa?.has_robots?'✅':'❌'"></b></div>
<div>Analytics: <b x-text="(modal.sa?.analytics_present||[]).join(', ')||'none'"></b></div>
<div>Mobile: <b x-text="modal.sa?.has_mobile_viewport?'✅':'❌'"></b></div>
<div>Lorem ipsum: <b :style="modal.sa?.has_lorem_ipsum?'color:var(--danger)':''" x-text="modal.sa?.has_lorem_ipsum?'⚠ YES':'No'"></b></div>
<div>Words: <b x-text="modal.sa?.word_count||'—'"></b></div>
</div>
</div>
<button class="btn bg" style="margin-top:14px;width:100%" @click="modal.open=false">Close</button>
</div>
</div>
<header>
<h1>Dom<span>God</span></h1>
<span class="hbadge" x-text="stats.total_domains ? stats.total_domains.toLocaleString()+' domains' : 'Loading…'"></span>
<span class="ipill" :class="indexSt.ready ? 'ip-ok' : 'ip-bld'" x-text="indexSt.ready ? '⚡ Index ready' : '⏳ Building index…'"></span>
<span style="flex:1"></span>
<span style="font-size:11px;color:var(--muted)" x-text="aiSt.total ? aiSt.total + ' in AI queue' : ''"></span>
</header>
<main>
<!-- Stats bar -->
<div class="card">
<div class="ct">Overview</div>
<div class="sg">
<div class="sb"><div class="l">Total Domains</div><div class="v c1" x-text="stats.total_domains?.toLocaleString()??'—'"></div></div>
<div class="sb"><div class="l">Enriched</div><div class="v c1" x-text="stats.enriched?.toLocaleString()??'0'"></div><div class="s" x-text="stats.total_domains?((stats.enriched/stats.total_domains*100).toFixed(3)+'%'):''"></div></div>
<div class="sb"><div class="l">Hot Leads</div><div class="v c2" x-text="stats.hot_leads?.toLocaleString()??'0'"></div><div class="s">score ≥ 60</div></div>
<div class="sb"><div class="l">Kit Digital</div><div class="v ckd" x-text="stats.kit_digital_count?.toLocaleString()??'0'"></div><div class="s">detected</div></div>
<div class="sb"><div class="l">Queue</div><div class="v c3" x-text="stats.queue?.pending?.toLocaleString()??'0'"></div><div class="s" x-text="(stats.queue?.running??0)+' running'"></div></div>
<div class="sb"><div class="l">AI Queue</div><div class="v" style="color:#c084fc" x-text="aiSt.pending??'0'"></div><div class="s" x-text="(aiSt.done??0)+' assessed'"></div></div>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<div class="tab" :class="{active:tab==='browse'}" @click="tab='browse'">Browse & Filter</div>
<div class="tab" :class="{active:tab==='enrich'}" @click="tab='enrich';loadQueue()">Enrichment</div>
<div class="tab" :class="{active:tab==='pipeline'}" @click="tab='pipeline';loadPipeline()">Lead Pipeline</div>
<div class="tab" :class="{active:tab==='chart'}" @click="tab='chart';renderChart()">TLD Chart</div>
</div>
<!-- ② Browse & Filter -->
<div class="card" x-show="tab==='browse'">
<div class="frow">
<div class="field"><label>TLD</label><input type="text" x-model="f.tld" placeholder="es, com…" style="width:80px" @keydown.enter="search()"></div>
<div class="field"><label>Keyword</label><input type="text" x-model="f.keyword" placeholder="hotel, dental…" style="width:130px" @keydown.enter="search()"></div>
<div class="field"><label>Min Score: <b x-text="f.min_score"></b></label><input type="range" x-model="f.min_score" min="0" max="100" step="5"></div>
<div class="field"><label>CMS</label>
<select x-model="f.cms" style="width:120px">
<option value="">Any CMS</option>
<option>wordpress</option><option>joomla</option><option>drupal</option>
<option>wix</option><option>squarespace</option><option>shopify</option>
<option>prestashop</option><option>magento</option><option>typo3</option><option>opencart</option>
</select>
</div>
<div class="field"><label>Per page</label>
<select x-model="f.limit" style="width:75px">
<option value="50">50</option><option value="100">100</option><option value="250">250</option>
</select>
</div>
<label class="tog"><input type="checkbox" x-model="f.live_only"><strong>Live only</strong></label>
<label class="tog"><input type="checkbox" x-model="f.alpha_only"><strong>Alpha only</strong> <span>(no hyphens/nums)</span></label>
<label class="tog"><input type="checkbox" x-model="f.no_sld"><strong>No SLD</strong> <span>(skip com.es)</span></label>
<label class="tog"><input type="checkbox" x-model="f.kit_digital_only"><strong style="color:var(--kd)">🏅 Kit Digital only</strong></label>
</div>
<div style="display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;align-items:center">
<button class="btn bp" @click="search()">Search</button>
<button class="btn bg" @click="resetFilters()">Reset</button>
<button class="btn bs" @click="enqueueSelected()" :disabled="selected.length===0">
+ Enrich (<span x-text="selected.length"></span>)
</button>
<button class="btn bai" @click="aiAssessSelected()" :disabled="selected.length===0">
🤖 AI Assess (<span x-text="selected.length"></span>)
</button>
<button class="btn bg sm" @click="selectAll()" x-show="domains.length>0">Select page</button>
<button class="btn bg sm" @click="selected=[]" x-show="selected.length>0">Clear</button>
<span class="inf" x-show="searchTotal>0" x-text="searchTotal.toLocaleString()+' matches'"></span>
</div>
<div class="tw">
<table>
<thead>
<tr>
<th></th><th>Domain</th><th>Score</th><th>KD</th><th>AI</th>
<th>Contact</th><th>CMS</th><th>SSL days</th>
<th>Country</th><th>Live</th>
</tr>
</thead>
<tbody>
<template x-if="loading">
<tr><td colspan="10" style="text-align:center;padding:24px;color:var(--muted)">Searching… (first query may take 30-60s before index is ready)</td></tr>
</template>
<template x-if="!loading && domains.length===0">
<tr><td colspan="10" style="text-align:center;padding:24px;color:var(--muted)">No results — enter a TLD or keyword and click Search</td></tr>
</template>
<template x-for="row in domains" :key="row.domain">
<tr>
<td><input type="checkbox" :value="row.domain" x-model="selected"></td>
<td><a :href="'http://'+row.domain" target="_blank" rel="noopener" x-text="row.domain"></a></td>
<td>
<span x-show="row.score!=null" class="score" :style="scoreBg(row.score)" x-text="row.score"></span>
<span x-show="row.score==null" class="pill pp"></span>
</td>
<!-- Kit Digital badge -->
<td>
<span x-show="row.kit_digital" class="pill pkd" :title="parseSignals(row.kit_digital_signals)">🏅 KD</span>
<span x-show="!row.kit_digital" style="color:var(--border)"></span>
</td>
<!-- AI quality -->
<td>
<span x-show="row.ai_lead_quality"
class="pill" :class="aiPillClass(row.ai_lead_quality)"
style="cursor:pointer"
@click="openModal(row)"
x-text="row.ai_lead_quality"></span>
<span x-show="!row.ai_lead_quality" class="pill ai-none" style="cursor:pointer" @click="openModal(row)" title="Click to assess"></span>
</td>
<!-- Contact info -->
<td>
<div class="contact-chips" x-data="{c: parseContacts(row.contact_info)}">
<template x-for="em in (c.emails||[]).slice(0,1)" :key="em">
<span class="chip email" :title="em"><span x-text="em"></span></span>
</template>
<template x-for="ph in (c.phones||[]).slice(0,1)" :key="ph">
<span class="chip phone" :title="ph">📞 <span x-text="ph"></span></span>
</template>
<template x-for="wa in (c.whatsapp||[]).slice(0,1)" :key="wa">
<span class="chip wa" title="WhatsApp">💬 WA</span>
</template>
<template x-if="(c.social||[]).length>0">
<span class="chip social" :title="(c.social||[]).join(', ')">📲 <span x-text="(c.social||[]).length"></span></span>
</template>
</div>
</td>
<td>
<span x-show="row.cms" class="pill pc" x-text="row.cms"></span>
<span x-show="!row.cms" style="color:var(--border)"></span>
</td>
<td x-text="row.ssl_expiry_days??'—'"></td>
<td x-text="row.ip_country??'—'"></td>
<td><span class="pill" :class="row.is_live?'pg':'pp'" x-text="row.is_live?'Yes':'—'"></span></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="pager">
<button class="btn bg sm" @click="goPage(page-1)" :disabled="page<=1||loading">← Prev</button>
<span class="inf">Page <b x-text="page"></b>
<span x-show="searchTotal>0" x-text="' of '+Math.ceil(searchTotal/Number(f.limit))"></span>
</span>
<button class="btn bg sm" @click="goPage(page+1)" :disabled="loading||domains.length<Number(f.limit)">Next →</button>
<span class="inf" x-show="searchTotal>0" x-text="searchTotal.toLocaleString()+' total'"></span>
</div>
</div>
<!-- ③ Enrichment Queue -->
<div class="card" x-show="tab==='enrich'">
<div class="ct">Enrichment</div>
<div class="esg">
<div class="esb"><div class="ev c3" x-text="qst.pending??'—'"></div><div class="el">Pending</div></div>
<div class="esb"><div class="ev c1" x-text="qst.running??'—'"></div><div class="el">Running</div></div>
<div class="esb"><div class="ev c1" x-text="qst.done??'—'"></div><div class="el">Done</div></div>
<div class="esb"><div class="ev c2" x-text="qst.failed??'—'"></div><div class="el">Failed</div></div>
<div class="esb"><div class="ev c4" x-text="qst.eta_seconds?Math.ceil(qst.eta_seconds/60)+'m':'—'"></div><div class="el">ETA</div></div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;flex-wrap:wrap;gap:6px">
<span style="font-size:11px;color:var(--muted)" x-text="qLabel()"></span>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="btn bs" x-show="!qst.worker_running" @click="startEnrich()">▶ Start</button>
<button class="btn bw" x-show="qst.worker_running" @click="pauseEnrich()">⏸ Pause</button>
<button class="btn bg" @click="retryFailed()">↺ Retry Failed</button>
<button class="btn bg" @click="runScoring()">★ Re-score All</button>
</div>
</div>
<div class="pw"><div class="pb" :style="'width:'+qPct()+'%'"></div></div>
<div style="font-size:10px;color:var(--muted);margin-top:3px" x-text="qPct().toFixed(1)+'% complete'"></div>
<!-- AI Queue section -->
<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--border)">
<div class="ct" style="margin-bottom:8px">AI Assessment Queue (Gemini via Replicate)</div>
<div class="esg">
<div class="esb"><div class="ev" style="color:#c084fc" x-text="aiSt.pending??'0'"></div><div class="el">Pending</div></div>
<div class="esb"><div class="ev" style="color:#c084fc" x-text="aiSt.running??'0'"></div><div class="el">Running</div></div>
<div class="esb"><div class="ev c1" x-text="aiSt.done??'0'"></div><div class="el">Done</div></div>
<div class="esb"><div class="ev c2" x-text="aiSt.failed??'0'"></div><div class="el">Failed</div></div>
</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:8px">
Auto-assesses enriched domains via Gemini. Detects Kit Digital confirmation, extracts best contact channel, writes pitch.
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="btn bai" @click="aiAssessAllKD()">🤖 AI Assess all Kit Digital domains</button>
<button class="btn bg sm" @click="restartAiWorker()">↺ Restart AI worker</button>
<a class="btn bg sm" href="/api/ai/debug" target="_blank" style="text-decoration:none">🔍 Debug AI queue</a>
</div>
</div>
<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--border)">
<div class="ct" style="margin-bottom:6px">Queue custom domains</div>
<div style="display:flex;gap:8px;align-items:flex-end">
<textarea x-model="customDomains" placeholder="example.com&#10;another.es"
style="flex:1;background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:7px;min-height:70px;font-size:12px;resize:vertical"></textarea>
<button class="btn bp" @click="enqueueCustom()">Queue</button>
</div>
</div>
</div>
<!-- ④ Lead Pipeline -->
<div class="card" x-show="tab==='pipeline'">
<div style="display:flex;justify-content:flex-end;margin-bottom:10px;gap:8px">
<button class="btn bg sm" @click="loadPipeline()">↻ Refresh</button>
<button class="btn bg sm" @click="exportTier('all')">⬇ Export All CSV</button>
</div>
<div class="pipeline">
<div class="pcol" style="border-top:3px solid var(--danger)">
<h3>🔥 Hot</h3><div style="font-size:11px;color:var(--muted)">Score 80100</div>
<div class="cnt c2" x-text="pipeline.hot.count.toLocaleString()"></div>
<div class="samples">
<template x-for="d in pipeline.hot.samples" :key="d.domain">
<div class="sample">
<div style="display:flex;flex-direction:column;gap:2px;min-width:0">
<a :href="'http://'+d.domain" target="_blank" x-text="d.domain" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></a>
<span x-show="d.kit_digital" class="pill pkd" style="font-size:9px;width:fit-content">🏅 Kit Digital</span>
</div>
<span class="score" :style="scoreBg(d.score)" x-text="d.score"></span>
</div>
</template>
</div>
<button class="btn bd sm" style="margin-top:auto" @click="exportTier('hot')">⬇ Export Hot</button>
</div>
<div class="pcol" style="border-top:3px solid var(--warn)">
<h3>♨️ Warm</h3><div style="font-size:11px;color:var(--muted)">Score 5079</div>
<div class="cnt c3" x-text="pipeline.warm.count.toLocaleString()"></div>
<div class="samples">
<template x-for="d in pipeline.warm.samples" :key="d.domain">
<div class="sample">
<div style="display:flex;flex-direction:column;gap:2px;min-width:0">
<a :href="'http://'+d.domain" target="_blank" x-text="d.domain" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></a>
<span x-show="d.kit_digital" class="pill pkd" style="font-size:9px;width:fit-content">🏅 Kit Digital</span>
</div>
<span class="score" :style="scoreBg(d.score)" x-text="d.score"></span>
</div>
</template>
</div>
<button class="btn bw sm" style="margin-top:auto" @click="exportTier('warm')">⬇ Export Warm</button>
</div>
<div class="pcol" style="border-top:3px solid #6c7aff">
<h3>🧊 Cold</h3><div style="font-size:11px;color:var(--muted)">Score &lt; 50</div>
<div class="cnt" style="color:#6c7aff" x-text="pipeline.cold.count.toLocaleString()"></div>
<div class="samples">
<template x-for="d in pipeline.cold.samples" :key="d.domain">
<div class="sample">
<a :href="'http://'+d.domain" target="_blank" x-text="d.domain" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1"></a>
<span class="score" :style="scoreBg(d.score)" x-text="d.score"></span>
</div>
</template>
</div>
<button class="btn bg sm" style="margin-top:auto" @click="exportTier('cold')">⬇ Export Cold</button>
</div>
</div>
</div>
<!-- ⑤ TLD Chart -->
<div class="card" x-show="tab==='chart'">
<div class="ct">Top 20 TLDs</div>
<div class="cw"><canvas id="tldChart"></canvas></div>
</div>
</main>
<script>
function app() {
return {
tab: 'browse',
stats: {}, indexSt: {ready:false,building:false,total:0},
aiSt: {pending:0,running:0,done:0,failed:0,total:0},
domains: [], selected: [],
loading: false, page: 1, searchTotal: 0,
f: {tld:'',keyword:'',min_score:0,cms:'',live_only:false,alpha_only:false,no_sld:false,kit_digital_only:false,limit:'100'},
qst: {}, customDomains: '',
pipeline: {hot:{count:0,samples:[]},warm:{count:0,samples:[]},cold:{count:0,samples:[]}},
toast: {show:false,msg:'',type:'success'},
modal: {open:false, domain:'', ai:{}, sa:null},
_chart: null, _poll: null, _toastTimer: null,
async init() {
await Promise.all([this.loadStats(), this.pollIndex(), this.loadAiStatus()]);
this._poll = setInterval(()=>{
this.loadStats(); this.pollIndex(); this.loadAiStatus();
if(this.tab==='enrich') this.loadQueue();
if(this.tab==='pipeline') this.loadPipeline();
}, 3000);
},
async loadStats() {
try {
const s = await fetch('/api/stats').then(r=>r.json());
this.stats = s;
} catch(e){}
},
async pollIndex() {
try { this.indexSt = await fetch('/api/index/status').then(r=>r.json()); } catch(e){}
},
async loadAiStatus() {
try { this.aiSt = await fetch('/api/ai/status').then(r=>r.json()); } catch(e){}
},
async search() { this.page=1; await this._fetch(); },
async goPage(p) { if(p<1) return; this.page=p; await this._fetch(); },
async _fetch() {
this.loading = true;
const p = new URLSearchParams({page:this.page, limit:this.f.limit});
if(this.f.tld) p.set('tld', this.f.tld.trim());
if(this.f.keyword) p.set('keyword', this.f.keyword.trim());
if(this.f.live_only) p.set('live_only','true');
if(this.f.alpha_only) p.set('alpha_only','true');
if(this.f.no_sld) p.set('no_sld','true');
try {
const data = await fetch('/api/domains?'+p).then(r=>r.json());
this.searchTotal = data.total ?? 0;
let rows = data.results;
if(this.f.min_score>0) rows = rows.filter(r=> r.score==null || r.score>=Number(this.f.min_score));
if(this.f.cms) rows = rows.filter(r=> r.cms===this.f.cms);
if(this.f.kit_digital_only) rows = rows.filter(r=> r.kit_digital);
this.domains = rows;
} catch(e) {
this.domains = [];
this.notify('Search failed: '+e.message,'error');
}
this.loading = false;
},
selectAll() { this.selected = this.domains.map(d=>d.domain); },
resetFilters() { this.f={tld:'',keyword:'',min_score:0,cms:'',live_only:false,alpha_only:false,no_sld:false,kit_digital_only:false,limit:'100'}; },
async enqueueSelected() {
if(!this.selected.length) return;
try {
const r = await fetch('/api/enrich/batch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({domains:this.selected})});
const d = await r.json();
r.ok ? this.notify(`Queued ${d.queued} for enrichment`,'success') : this.notify('Error: '+(d.error||r.statusText),'error');
this.selected = [];
} catch(e) { this.notify('Request failed: '+e.message,'error'); }
},
async aiAssessSelected() {
if(!this.selected.length) return;
const r = await fetch('/api/ai/assess/batch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({domains:this.selected})});
const d = await r.json();
r.ok ? this.notify(`Queued ${d.queued} for AI assessment`,'info') : this.notify('Error: '+d.error,'error');
this.selected = [];
},
async aiAssessAllKD() {
// Get all Kit Digital domains and queue them
const r = await fetch('/api/enriched?kit_digital=true&limit=500').then(r=>r.json());
const domains = r.results.map(d=>d.domain);
if(!domains.length) { this.notify('No Kit Digital domains enriched yet','info'); return; }
const r2 = await fetch('/api/ai/assess/batch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({domains})});
const d2 = await r2.json();
this.notify(`Queued ${d2.queued} Kit Digital domains for AI assessment`,'info');
},
async enqueueCustom() {
const domains = this.customDomains.split('\n').map(d=>d.trim()).filter(Boolean);
if(!domains.length) return;
const r = await fetch('/api/enrich/batch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({domains})});
const d = await r.json();
r.ok ? this.notify(`Queued ${d.queued} domains`,'success') : this.notify('Error: '+d.error,'error');
this.customDomains = ''; await this.loadQueue();
},
async loadQueue() {
try { this.qst = await fetch('/api/enrich/status').then(r=>r.json()); } catch(e){}
},
async restartAiWorker() { const r=await fetch('/api/ai/worker/restart',{method:'POST'}); this.notify('AI worker restarted','info'); await this.loadAiStatus(); },
async startEnrich() { await fetch('/api/enrich/resume',{method:'POST'}); this.notify('Worker started','success'); await this.loadQueue(); },
async pauseEnrich() { await fetch('/api/enrich/pause',{method:'POST'}); this.notify('Worker paused','success'); await this.loadQueue(); },
async retryFailed() { await fetch('/api/enrich/retry',{method:'POST'}); this.notify('Retrying failed','success'); await this.loadQueue(); },
async runScoring() { const r = await fetch('/api/score/run',{method:'POST'}); const d=await r.json(); this.notify(`Scored ${d.scored} domains`,'success'); },
qPct() { const q=this.qst; return (!q||!q.total)?0:(q.done/q.total*100); },
qLabel() { const q=this.qst; return `${q.done??0} done · ${q.pending??0} pending · ${q.running??0} running · ${q.failed??0} failed`; },
async loadPipeline() {
const [hot,warm,cold] = await Promise.all([
fetch('/api/enriched?min_score=80&limit=5').then(r=>r.json()),
fetch('/api/enriched?min_score=50&limit=5').then(r=>r.json()),
fetch('/api/enriched?min_score=0&limit=5').then(r=>r.json()),
]);
this.pipeline.hot = {count:hot.total??0, samples:hot.results.slice(0,5)};
this.pipeline.warm = {count:Math.max(0,(warm.total??0)-(hot.total??0)), samples:warm.results.filter(d=>d.score<80).slice(0,5)};
this.pipeline.cold = {count:Math.max(0,(cold.total??0)-(warm.total??0)), samples:cold.results.filter(d=>d.score<50).slice(0,5)};
},
exportTier(tier) { window.location = `/api/export?tier=${tier}`; },
openModal(row) {
this.modal.domain = row.domain;
try { this.modal.ai = row.ai_assessment ? JSON.parse(row.ai_assessment) : {}; }
catch(e) { this.modal.ai = {}; }
try { this.modal.sa = row.site_analysis ? JSON.parse(row.site_analysis) : null; }
catch(e) { this.modal.sa = null; }
this.modal.open = true;
},
qualityBg(s) {
if(s==null) return 'background:#333;color:#888';
if(s>=8) return 'background:#00d4aa22;color:var(--accent2)';
if(s>=5) return 'background:#ffb34722;color:var(--warn)';
return 'background:#ff4f6d22;color:var(--danger)';
},
scoreBg(s) {
if(s==null) return 'background:#333;color:#888';
if(s>=80) return 'background:#ff4f6d22;color:#ff4f6d';
if(s>=50) return 'background:#ffb34722;color:#ffb347';
return 'background:#6c7aff22;color:#6c7aff';
},
aiPillClass(q) {
if(!q) return 'ai-none';
if(q==='HOT') return 'ai-hot';
if(q==='WARM') return 'ai-warm';
return 'ai-cold';
},
parseContacts(raw) {
if(!raw) return {};
try { return JSON.parse(raw); } catch(e) { return {}; }
},
parseSignals(raw) {
if(!raw) return 'No signals';
try { return JSON.parse(raw).join('\n'); } catch(e) { return raw; }
},
notify(msg, type='success') {
clearTimeout(this._toastTimer);
this.toast = {show:true,msg,type};
this._toastTimer = setTimeout(()=>{ this.toast.show=false; }, 3500);
},
async renderChart() {
await this.$nextTick();
const canvas = document.getElementById('tldChart');
if(!canvas) return;
if(this._chart){this._chart.destroy();this._chart=null;}
const tlds = this.stats.tld_breakdown || [];
this._chart = new Chart(canvas,{
type:'bar',
data:{
labels:tlds.map(t=>'.'+t.tld),
datasets:[{label:'Domains',data:tlds.map(t=>t.count),backgroundColor:'rgba(108,99,255,.7)',borderColor:'rgba(108,99,255,1)',borderWidth:1,borderRadius:4}]
},
options:{
responsive:true,maintainAspectRatio:false,
plugins:{legend:{display:false}},
scales:{x:{ticks:{color:'#8891b0'},grid:{color:'#2e3250'}},y:{ticks:{color:'#8891b0'},grid:{color:'#2e3250'}}}
}
});
},
}
}
</script>
</body>
</html>