Files
DomGod/app/static/index.html
Malin d62e4e986e feat: web search for contacts, copyright year, contact page scan, CMS/age from Gemini
- replicate_ai: DDG pre-search runs before every Gemini call; top 4 snippets
  injected into prompt so Gemini can find phone/email not on homepage
- replicate_ai: explicit instructions to use search results for contact lookup
- replicate_ai: new output fields cms_detected + site_last_updated
- site_analyzer: copyright year extracted from footer (© / copyright pattern)
- site_analyzer: Last-Modified from HTTP header + OG meta tag
- site_analyzer: scans /contacto /contact /contactanos /sobre-nosotros for
  additional emails/phones (parallel with sitemap/robots fetch)
- index.html: modal shows CMS (AI-detected), Last Updated (red if pre-2021)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 08:22:14 +02:00

776 lines
43 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;align-items:center}
.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}
/* Social platform icon badges */
.sicon{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:4px;font-size:9px;font-weight:900;text-decoration:none;flex-shrink:0;line-height:1}
.sicon.fb{background:#1877f2;color:#fff}
.sicon.ig{background:linear-gradient(135deg,#f09433 0%,#e6683c 25%,#dc2743 50%,#cc2366 75%,#bc1888 100%);color:#fff}
.sicon.li{background:#0a66c2;color:#fff}
.sicon.tw{background:#000;color:#fff}
.sicon.tt{background:#010101;color:#fff}
.sicon.yt{background:#ff0000;color:#fff}
.sicon.gmb{background:#4285f4;color:#fff}
.sicon.other{background:var(--surface2);border:1px solid var(--border);color:var(--muted)}
/* 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">CMS</span><span x-text="modal.ai.cms_detected || modal.sa?.cms || '—'"></span></div>
<div class="mrow"><span class="mlabel">Last updated</span><span :style="(modal.ai.site_last_updated&&parseInt(modal.ai.site_last_updated)<2021)?'color:var(--danger)':''" x-text="modal.ai.site_last_updated || (modal.sa?.copyright_year ? 'Copyright '+modal.sa.copyright_year : '—')"></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>
<div class="mrow"><span class="mlabel">GMB</span><span :style="!modal.ai.has_gmb?'color:var(--warn)':'color:var(--accent2)'" x-text="modal.ai.has_gmb ? '✅ Found' : '❌ Not detected — opportunity'"></span></div>
<div class="mrow"><span class="mlabel">Social</span><span :style="!modal.ai.has_social_media?'color:var(--warn)':''" x-text="modal.ai.has_social_media ? '✅ Present' : '❌ No social media found — opportunity'"></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||[])">
<a :href="s" target="_blank" :class="'sicon '+socialIconClass(s)" :title="s" x-text="socialIconLabel(s)"></a>
</template>
<template x-if="modal.ai?.has_gmb">
<a :href="modal.sa?.gmb_url||'#'" target="_blank" class="sicon gmb" title="Google My Business">G</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">
<a :href="'mailto:'+em" class="chip email" :title="em"><span x-text="em"></span></a>
</template>
<template x-for="ph in (c.phones||[]).slice(0,1)" :key="ph">
<a :href="'tel:'+ph" class="chip phone" :title="ph">📞 <span x-text="ph"></span></a>
</template>
<template x-for="wa in (c.whatsapp||[]).slice(0,1)" :key="wa">
<a :href="wa" target="_blank" class="chip wa" title="WhatsApp">💬</a>
</template>
<template x-for="url in (c.social||[]).slice(0,4)" :key="url">
<a :href="url" target="_blank" :class="'sicon '+socialIconClass(url)" :title="url" x-text="socialIconLabel(url)"></a>
</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>
<button class="btn bw sm" @click="resetAiStuck()">⚡ Reset stuck jobs</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, _lastAiDone: 0,
async init() {
await Promise.all([this.loadStats(), this.pollIndex(), this.loadAiStatus()]);
this._lastAiDone = this.aiSt.done ?? 0;
this._poll = setInterval(async ()=>{
this.loadStats(); this.pollIndex();
await this.loadAiStatus();
// Refresh browse results when new AI assessments complete
if((this.aiSt.done ?? 0) > this._lastAiDone && this.tab==='browse' && this.domains.length>0) {
this._lastAiDone = this.aiSt.done;
await this._fetch();
} else {
this._lastAiDone = this.aiSt.done ?? 0;
}
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() { await fetch('/api/ai/worker/restart',{method:'POST'}); this.notify('AI worker restarted','info'); await this.loadAiStatus(); },
async resetAiStuck() { const r=await fetch('/api/ai/reset',{method:'POST'}); const d=await r.json(); this.notify(`Reset ${d.reset} stuck jobs → pending`,'success'); 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 {}; }
},
socialIconClass(url) {
if(!url) return 'other';
const u = url.toLowerCase();
if(u.includes('facebook.com') || u.includes('fb.com')) return 'fb';
if(u.includes('instagram.com')) return 'ig';
if(u.includes('linkedin.com')) return 'li';
if(u.includes('twitter.com') || u.includes('x.com')) return 'tw';
if(u.includes('tiktok.com')) return 'tt';
if(u.includes('youtube.com') || u.includes('youtu.be')) return 'yt';
return 'other';
},
socialIconLabel(url) {
if(!url) return '?';
const u = url.toLowerCase();
if(u.includes('facebook.com') || u.includes('fb.com')) return 'f';
if(u.includes('instagram.com')) return 'ig';
if(u.includes('linkedin.com')) return 'in';
if(u.includes('twitter.com') || u.includes('x.com')) return '𝕏';
if(u.includes('tiktok.com')) return 'tt';
if(u.includes('youtube.com') || u.includes('youtu.be')) return '▶';
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>