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 ───────────────────────────────────────────────────────
|
# ── 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
|
offset = (page - 1) * limit
|
||||||
conditions = ["score >= ?"]
|
conditions = ["score >= ?"]
|
||||||
params: list = [min_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:
|
if kit_digital is not None:
|
||||||
conditions.append("kit_digital = ?")
|
conditions.append("kit_digital = ?")
|
||||||
params.append(1 if kit_digital else 0)
|
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)
|
where = "WHERE " + " AND ".join(conditions)
|
||||||
async with aiosqlite.connect(SQLITE_PATH) as db:
|
async with aiosqlite.connect(SQLITE_PATH) as db:
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
|
|||||||
51
app/main.py
51
app/main.py
@@ -156,12 +156,15 @@ async def enriched(
|
|||||||
cms: str = Query(None),
|
cms: str = Query(None),
|
||||||
country: str = Query(None),
|
country: str = Query(None),
|
||||||
kit_digital: Optional[bool] = Query(None),
|
kit_digital: Optional[bool] = Query(None),
|
||||||
|
ai_only: bool = Query(False),
|
||||||
|
lead_quality: str = Query(None),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
):
|
):
|
||||||
total, rows = await get_enriched(
|
total, rows = await get_enriched(
|
||||||
min_score=min_score, cms=cms, country=country,
|
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}
|
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")
|
@app.post("/api/score/run")
|
||||||
async def score_run():
|
async def score_run():
|
||||||
return await run_scoring()
|
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==='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==='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==='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 class="tab" :class="{active:tab==='chart'}" @click="tab='chart';renderChart()">TLD Chart</div>
|
||||||
</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.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.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.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>
|
||||||
|
|
||||||
<div style="display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;align-items:center">
|
<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>
|
||||||
</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="card" x-show="tab==='chart'">
|
||||||
<div class="ct">Top 20 TLDs</div>
|
<div class="ct">Top 20 TLDs</div>
|
||||||
<div class="cw"><canvas id="tldChart"></canvas></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},
|
aiSt: {pending:0,running:0,done:0,failed:0,total:0},
|
||||||
domains: [], selected: [], aiLang: 'ES',
|
domains: [], selected: [], aiLang: 'ES',
|
||||||
loading: false, page: 1, searchTotal: 0,
|
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: '',
|
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:[]}},
|
pipeline: {hot:{count:0,samples:[]},warm:{count:0,samples:[]},cold:{count:0,samples:[]}},
|
||||||
toast: {show:false,msg:'',type:'success'},
|
toast: {show:false,msg:'',type:'success'},
|
||||||
modal: {open:false, domain:'', ai:{}, sa:null},
|
modal: {open:false, domain:'', ai:{}, sa:null},
|
||||||
@@ -577,8 +667,9 @@ function app() {
|
|||||||
} else {
|
} else {
|
||||||
this._lastAiDone = this.aiSt.done ?? 0;
|
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==='pipeline') this.loadPipeline();
|
||||||
|
if(this.tab==='leads') this.loadLeads();
|
||||||
}, 3000);
|
}, 3000);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -612,9 +703,10 @@ function app() {
|
|||||||
const data = await fetch('/api/domains?'+p).then(r=>r.json());
|
const data = await fetch('/api/domains?'+p).then(r=>r.json());
|
||||||
this.searchTotal = data.total ?? 0;
|
this.searchTotal = data.total ?? 0;
|
||||||
let rows = data.results;
|
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.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.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.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;
|
this.domains = rows;
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
this.domains = [];
|
this.domains = [];
|
||||||
@@ -624,7 +716,7 @@ function app() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
selectAll() { this.selected = this.domains.map(d=>d.domain); },
|
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() {
|
async enqueueSelected() {
|
||||||
if(!this.selected.length) return;
|
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');
|
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() {
|
async enqueueCustom() {
|
||||||
const domains = this.customDomains.split('\n').map(d=>d.trim()).filter(Boolean);
|
const domains = this.customDomains.split('\n').map(d=>d.trim()).filter(Boolean);
|
||||||
if(!domains.length) return;
|
if(!domains.length) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user