From 63f961dc80b27412629b26855ef9424d23c9834f Mon Sep 17 00:00:00 2001 From: Malin Date: Tue, 14 Apr 2026 18:57:15 +0200 Subject: [PATCH] feat: add Leads tab and Hide Assessed filter in Browse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/db.py | 8 ++- app/main.py | 51 +++++++++++++++- app/static/index.html | 138 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 188 insertions(+), 9 deletions(-) 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 +
+
+ +
+ + + + + + + + + + + + +
QualityDomainScoreBest ContactAll ContactsPitch
+
+ +
+ + Page + + + + +
+
+ +
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;