feat: add Leads tab and Hide Assessed filter in Browse
- db.py: get_enriched() accepts ai_only + lead_quality params - main.py: /api/enriched exposes ai_only + lead_quality query params; new /api/export/leads endpoint produces CSV with contacts + pitch - index.html: - New "Leads 🤖" tab shows all AI-assessed domains with contacts (quality/country/limit filters, per-row 📋 copy email, 🔍 modal, CSV export, pagination, auto-refreshes every 3s) - Browse: "Hide assessed" checkbox filters out already-processed domains so you can focus on fresh targets - Poll cycle refreshes Leads tab when active Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -306,7 +306,8 @@ async def get_stats():
|
||||
|
||||
# ── Enrichment helpers ───────────────────────────────────────────────────────
|
||||
|
||||
async def get_enriched(min_score=0, cms=None, country=None, kit_digital=None, page=1, limit=100):
|
||||
async def get_enriched(min_score=0, cms=None, country=None, kit_digital=None,
|
||||
ai_only=False, lead_quality=None, page=1, limit=100):
|
||||
offset = (page - 1) * limit
|
||||
conditions = ["score >= ?"]
|
||||
params: list = [min_score]
|
||||
@@ -319,6 +320,11 @@ async def get_enriched(min_score=0, cms=None, country=None, kit_digital=None, pa
|
||||
if kit_digital is not None:
|
||||
conditions.append("kit_digital = ?")
|
||||
params.append(1 if kit_digital else 0)
|
||||
if ai_only:
|
||||
conditions.append("ai_lead_quality IS NOT NULL")
|
||||
if lead_quality:
|
||||
conditions.append("ai_lead_quality = ?")
|
||||
params.append(lead_quality.upper())
|
||||
where = "WHERE " + " AND ".join(conditions)
|
||||
async with aiosqlite.connect(SQLITE_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
|
||||
51
app/main.py
51
app/main.py
@@ -156,12 +156,15 @@ async def enriched(
|
||||
cms: str = Query(None),
|
||||
country: str = Query(None),
|
||||
kit_digital: Optional[bool] = Query(None),
|
||||
ai_only: bool = Query(False),
|
||||
lead_quality: str = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
):
|
||||
total, rows = await get_enriched(
|
||||
min_score=min_score, cms=cms, country=country,
|
||||
kit_digital=kit_digital, page=page, limit=limit,
|
||||
kit_digital=kit_digital, ai_only=ai_only, lead_quality=lead_quality,
|
||||
page=page, limit=limit,
|
||||
)
|
||||
return {"page": page, "limit": limit, "total": total, "results": rows}
|
||||
|
||||
@@ -286,6 +289,52 @@ async def export_csv(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/export/leads")
|
||||
async def export_leads_csv(lead_quality: str = Query(None), country: str = Query(None)):
|
||||
import json as _json
|
||||
|
||||
async def generate():
|
||||
yield "domain,lead_quality,score,best_contact_channel,best_contact_value,emails,phones,whatsapp,social,cms,ip_country,page_title,ai_pitch\n"
|
||||
p = 1
|
||||
while True:
|
||||
_, rows = await get_enriched(ai_only=True, lead_quality=lead_quality,
|
||||
country=country, page=p, limit=500)
|
||||
if not rows:
|
||||
break
|
||||
for r in rows:
|
||||
contacts = {}
|
||||
try:
|
||||
contacts = _json.loads(r.get("contact_info") or "{}")
|
||||
except Exception:
|
||||
pass
|
||||
def esc(v):
|
||||
return f'"{str(v or "").replace(chr(34), chr(39))}"'
|
||||
emails = esc(";".join(contacts.get("emails", [])[:3]))
|
||||
phones = esc(";".join(contacts.get("phones", [])[:3]))
|
||||
whatsapp = esc(";".join(contacts.get("whatsapp",[])[:2]))
|
||||
social = esc(";".join(contacts.get("social", [])[:4]))
|
||||
line = ",".join([
|
||||
esc(r.get("domain")),
|
||||
esc(r.get("ai_lead_quality")),
|
||||
esc(r.get("score")),
|
||||
esc(r.get("ai_contact_channel")),
|
||||
esc(r.get("ai_contact_value")),
|
||||
emails, phones, whatsapp, social,
|
||||
esc(r.get("cms")),
|
||||
esc(r.get("ip_country")),
|
||||
esc(r.get("page_title")),
|
||||
esc(r.get("ai_pitch")),
|
||||
])
|
||||
yield line + "\n"
|
||||
p += 1
|
||||
|
||||
qual = f"_{lead_quality.lower()}" if lead_quality else ""
|
||||
return StreamingResponse(
|
||||
generate(), media_type="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="domgod_leads{qual}.csv"'},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/score/run")
|
||||
async def score_run():
|
||||
return await run_scoring()
|
||||
|
||||
@@ -309,6 +309,7 @@ tr:hover td{background:rgba(255,255,255,.025)}
|
||||
<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==='leads'}" @click="tab='leads';loadLeads(true)">Leads 🤖</div>
|
||||
<div class="tab" :class="{active:tab==='chart'}" @click="tab='chart';renderChart()">TLD Chart</div>
|
||||
</div>
|
||||
|
||||
@@ -335,6 +336,7 @@ tr:hover td{background:rgba(255,255,255,.025)}
|
||||
<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>
|
||||
<label class="tog"><input type="checkbox" x-model="f.exclude_assessed"><strong>Hide assessed</strong></label>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;align-items:center">
|
||||
@@ -541,7 +543,93 @@ tr:hover td{background:rgba(255,255,255,.025)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ⑤ TLD Chart -->
|
||||
<!-- ⑤ Leads (AI Assessed) -->
|
||||
<div class="card" x-show="tab==='leads'">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px">
|
||||
<div class="ct" style="margin:0">Assessed Leads (<span x-text="leadsTotal.toLocaleString()"></span>)</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
|
||||
<select x-model="leadsQ.quality" @change="loadLeads(true)" style="padding:4px 8px;border-radius:6px;border:1px solid var(--border);background:var(--card);color:var(--text);font-size:13px">
|
||||
<option value="">All Qualities</option>
|
||||
<option value="HOT">🔥 HOT</option>
|
||||
<option value="WARM">♨️ WARM</option>
|
||||
<option value="COLD">🧊 COLD</option>
|
||||
</select>
|
||||
<input type="text" x-model="leadsQ.country" placeholder="Country…" @keydown.enter="loadLeads(true)" style="width:80px;padding:4px 8px;border-radius:6px;border:1px solid var(--border);background:var(--card);color:var(--text);font-size:13px">
|
||||
<select x-model="leadsQ.limit" @change="loadLeads(true)" style="padding:4px 8px;border-radius:6px;border:1px solid var(--border);background:var(--card);color:var(--text);font-size:13px">
|
||||
<option value="25">25</option><option value="50">50</option><option value="100">100</option>
|
||||
</select>
|
||||
<button class="btn bp sm" @click="loadLeads(true)">↻ Refresh</button>
|
||||
<a class="btn bg sm" :href="leadsExportUrl()" target="_blank" style="text-decoration:none">⬇ CSV</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tw">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Quality</th><th>Domain</th><th>Score</th>
|
||||
<th>Best Contact</th><th>All Contacts</th><th>Pitch</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-if="leadsLoading">
|
||||
<tr><td colspan="7" style="text-align:center;padding:24px;color:var(--muted)">Loading…</td></tr>
|
||||
</template>
|
||||
<template x-if="!leadsLoading && leadsData.length===0">
|
||||
<tr><td colspan="7" style="text-align:center;padding:24px;color:var(--muted)">No assessed leads yet — run 🤖 AI Assess on some domains in Browse</td></tr>
|
||||
</template>
|
||||
<template x-for="row in leadsData" :key="row.domain">
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pill" :class="aiPillClass(row.ai_lead_quality)"
|
||||
style="cursor:pointer" @click="openModal(row)"
|
||||
x-text="row.ai_lead_quality||'—'"></span>
|
||||
</td>
|
||||
<td><a :href="'http://'+row.domain" target="_blank" rel="noopener" x-text="row.domain"></a></td>
|
||||
<td><span class="score" :style="scoreBg(row.score)" x-text="row.score??'—'"></span></td>
|
||||
<td style="max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px">
|
||||
<span :title="row.ai_contact_value" x-text="row.ai_contact_value||'—'"></span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="contact-chips" x-data="{c: parseContacts(row.contact_info)}">
|
||||
<template x-for="em in (c.emails||[]).slice(0,2)" :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,3)" :key="url">
|
||||
<a :href="url" target="_blank" :class="'sicon '+socialIconClass(url)" :title="url" x-text="socialIconLabel(url)"></a>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td style="max-width:220px;font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||
<span :title="row.ai_pitch" x-text="row.ai_pitch||'—'"></span>
|
||||
</td>
|
||||
<td style="white-space:nowrap">
|
||||
<button class="btn bg sm" @click="openModal(row)" title="Full details">🔍</button>
|
||||
<button class="btn bai sm" @click="copyLeadEmail(row)" title="Copy outreach email">📋</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pager">
|
||||
<button class="btn bg sm" @click="if(leadsPage>1){leadsPage--;loadLeads();}" :disabled="leadsPage<=1||leadsLoading">← Prev</button>
|
||||
<span class="inf">Page <b x-text="leadsPage"></b>
|
||||
<span x-show="leadsTotal>0" x-text="' of '+Math.ceil(leadsTotal/Number(leadsQ.limit))"></span>
|
||||
</span>
|
||||
<button class="btn bg sm" @click="if(leadsData.length>=Number(leadsQ.limit)){leadsPage++;loadLeads();}" :disabled="leadsLoading||leadsData.length<Number(leadsQ.limit)">Next →</button>
|
||||
<span class="inf" x-show="leadsTotal>0" x-text="leadsTotal.toLocaleString()+' assessed'"></span>
|
||||
</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>
|
||||
@@ -557,8 +645,10 @@ function app() {
|
||||
aiSt: {pending:0,running:0,done:0,failed:0,total:0},
|
||||
domains: [], selected: [], aiLang: 'ES',
|
||||
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'},
|
||||
f: {tld:'',keyword:'',min_score:0,cms:'',live_only:false,alpha_only:false,no_sld:false,kit_digital_only:false,exclude_assessed:false,limit:'100'},
|
||||
qst: {}, customDomains: '',
|
||||
leadsQ: {quality:'', country:'', limit:'50'},
|
||||
leadsData: [], leadsTotal: 0, leadsPage: 1, leadsLoading: false,
|
||||
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},
|
||||
@@ -577,8 +667,9 @@ function app() {
|
||||
} else {
|
||||
this._lastAiDone = this.aiSt.done ?? 0;
|
||||
}
|
||||
if(this.tab==='enrich') this.loadQueue();
|
||||
if(this.tab==='enrich') this.loadQueue();
|
||||
if(this.tab==='pipeline') this.loadPipeline();
|
||||
if(this.tab==='leads') this.loadLeads();
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
@@ -612,9 +703,10 @@ function app() {
|
||||
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);
|
||||
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);
|
||||
if(this.f.exclude_assessed) rows = rows.filter(r=> !r.ai_lead_quality);
|
||||
this.domains = rows;
|
||||
} catch(e) {
|
||||
this.domains = [];
|
||||
@@ -624,7 +716,7 @@ function app() {
|
||||
},
|
||||
|
||||
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'}; },
|
||||
resetFilters() { this.f={tld:'',keyword:'',min_score:0,cms:'',live_only:false,alpha_only:false,no_sld:false,kit_digital_only:false,exclude_assessed:false,limit:'100'}; },
|
||||
|
||||
async enqueueSelected() {
|
||||
if(!this.selected.length) return;
|
||||
@@ -654,6 +746,38 @@ function app() {
|
||||
this.notify(`Queued ${d2.queued} Kit Digital domains for AI assessment [${this.aiLang}]`,'info');
|
||||
},
|
||||
|
||||
async loadLeads(reset=false) {
|
||||
if(reset) this.leadsPage = 1;
|
||||
this.leadsLoading = true;
|
||||
const p = new URLSearchParams({page:this.leadsPage, limit:this.leadsQ.limit, min_score:'0', ai_only:'true'});
|
||||
if(this.leadsQ.quality) p.set('lead_quality', this.leadsQ.quality);
|
||||
if(this.leadsQ.country) p.set('country', this.leadsQ.country.trim());
|
||||
try {
|
||||
const d = await fetch('/api/enriched?'+p).then(r=>r.json());
|
||||
this.leadsData = d.results;
|
||||
this.leadsTotal = d.total ?? 0;
|
||||
} catch(e) { this.notify('Failed to load leads: '+e.message,'error'); }
|
||||
this.leadsLoading = false;
|
||||
},
|
||||
|
||||
copyLeadEmail(row) {
|
||||
let ai = {};
|
||||
try { ai = JSON.parse(row.ai_assessment || '{}'); } catch(e) {}
|
||||
const subj = ai.email_subject ? `Subject: ${ai.email_subject}\n\n` : '';
|
||||
const body = ai.outreach_email || ai.pitch_angle || '';
|
||||
navigator.clipboard.writeText(subj + body).then(
|
||||
() => this.notify('Email copied to clipboard','success'),
|
||||
() => this.notify('Copy failed — select text manually','error')
|
||||
);
|
||||
},
|
||||
|
||||
leadsExportUrl() {
|
||||
const p = new URLSearchParams();
|
||||
if(this.leadsQ.quality) p.set('lead_quality', this.leadsQ.quality);
|
||||
if(this.leadsQ.country) p.set('country', this.leadsQ.country.trim());
|
||||
return '/api/export/leads' + (p.toString() ? '?'+p : '');
|
||||
},
|
||||
|
||||
async enqueueCustom() {
|
||||
const domains = this.customDomains.split('\n').map(d=>d.trim()).filter(Boolean);
|
||||
if(!domains.length) return;
|
||||
|
||||
Reference in New Issue
Block a user