diff --git a/app/db.py b/app/db.py
index 9534028..210054b 100644
--- a/app/db.py
+++ b/app/db.py
@@ -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
diff --git a/app/main.py b/app/main.py
index f2dc67f..0c82d1d 100644
--- a/app/main.py
+++ b/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()
diff --git a/app/static/index.html b/app/static/index.html
index 8d6ec09..494eab2 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -309,6 +309,7 @@ tr:hover td{background:rgba(255,255,255,.025)}
Browse & Filter
Enrichment
Lead Pipeline
+ Leads 🤖
TLD Chart
@@ -335,6 +336,7 @@ tr:hover td{background:rgba(255,255,255,.025)}
+
@@ -541,7 +543,93 @@ tr:hover td{background:rgba(255,255,255,.025)}
-
+
+
+
+
Assessed Leads ()
+
+
+
+
+
+
⬇ CSV
+
+
+
+
+
+
+
+ | Quality | Domain | Score |
+ Best Contact | All Contacts | Pitch | |
+
+
+
+
+ | Loading… |
+
+
+ | No assessed leads yet — run 🤖 AI Assess on some domains in Browse |
+
+
+
+ |
+
+ |
+ |
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
Top 20 TLDs
@@ -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;