deploy Maps v7 update

This commit is contained in:
Marco0300
2026-09-05 17:17:47 +02:00
parent 4bb71bf842
commit 24b22a3353
5 changed files with 63 additions and 265 deletions
+3 -3
View File
@@ -5,9 +5,9 @@ the bundled [gosom/google-maps-scraper](https://github.com/gosom/google-maps-scr
worker, waits for its asynchronous job to finish, filters places already in
the local catalogue, and presents the remaining places for human review.
The interface includes discovery, run progress, catalogue search, website
health and email evidence, explainable scoring, domain ideas, suppression,
CSV export, scheduled runs, rescans, and human review. It never sends outreach
The interface is split into Dashboard, Discover, Prospects, Runs, and Settings
views. It includes website health and email evidence, explainable scoring,
domain ideas, suppression, CSV export, scheduled runs, rescans, and human review. It never sends outreach
automatically. Optional AI can analyse a prospect and draft a subject/email
from supplied evidence and your offer; you edit and approve the draft yourself.
+31 -5
View File
@@ -16,7 +16,6 @@ import os
import re
import sqlite3
import threading
from threading import Thread
import time
import urllib.error
import urllib.request
@@ -149,6 +148,9 @@ def db() -> sqlite3.Connection:
interval_hours INTEGER NOT NULL DEFAULT 24, enabled INTEGER NOT NULL DEFAULT 1,
next_run_at TEXT NOT NULL, last_run_id INTEGER, created_at TEXT NOT NULL
)""")
conn.execute("""CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL
)""")
# Migrate databases created by v1-v5 without destroying the user's
# catalogue. SQLite permits adding nullable/defaulted columns in place.
@@ -891,7 +893,15 @@ def scheduler_loop() -> None:
def ai_generate_message(prospect: dict[str, Any], offer: str) -> dict[str, Any]:
"""Generate an evidence-grounded draft through an OpenAI-compatible endpoint."""
if not AI_BASE_URL or not AI_MODEL:
runtime = {}
try:
c = db(); runtime = {str(r["key"]): str(r["value"]) for r in c.execute("SELECT key,value FROM settings")}; c.close()
except Exception:
pass
ai_base_url = runtime.get("AI_BASE_URL", AI_BASE_URL).strip().rstrip("/")
ai_api_key = runtime.get("AI_API_KEY", AI_API_KEY).strip()
ai_model = runtime.get("AI_MODEL", AI_MODEL).strip()
if not ai_base_url or not ai_model:
raise RuntimeError("ai_not_configured_set_AI_BASE_URL_and_AI_MODEL")
evidence = {
"business": prospect.get("name"), "category": prospect.get("category"),
@@ -906,8 +916,8 @@ def ai_generate_message(prospect: dict[str, Any], offer: str) -> dict[str, Any]:
"Do not mention scraping or hidden analysis. Return strict JSON with keys subject, message, rationale. "
"The message must be plain text, personalised, transparent, and include a polite opt-out line.")
prompt = json.dumps({"evidence": evidence, "user_offer_or_instruction": offer[:2000]}, ensure_ascii=False)
headers = {"Authorization": "Bearer " + AI_API_KEY} if AI_API_KEY else {}
result = http_json("POST", AI_BASE_URL + "/chat/completions", {"model": AI_MODEL, "temperature": 0.4, "response_format": {"type": "json_object"}, "messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}]}, headers=headers, timeout=60)
headers = {"Authorization": "Bearer " + ai_api_key} if ai_api_key else {}
result = http_json("POST", ai_base_url + "/chat/completions", {"model": ai_model, "temperature": 0.4, "response_format": {"type": "json_object"}, "messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}]}, headers=headers, timeout=60)
content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else ""
try:
parsed = json.loads(content)
@@ -952,6 +962,7 @@ class Handler(BaseHTTPRequestHandler):
if parsed.path == "/api/config":
auth_required = GMAPS_API_MODE == "saas"
ready = backend_mode() == "browser" or (bool(GMAPS_URL) and (not auth_required or bool(GMAPS_API_KEY)))
saved = {str(r["key"]): str(r["value"]) for r in conn.execute("SELECT key,value FROM settings")}
return self.send_json(200, {
"backend": backend_mode(),
"gmaps_url": bool(GMAPS_URL),
@@ -961,8 +972,11 @@ class Handler(BaseHTTPRequestHandler):
"ready": ready,
"email_extraction_default": True,
"email_extraction_always": True,
"ai_configured": bool(AI_BASE_URL and AI_MODEL),
"ai_configured": bool(saved.get("AI_BASE_URL", AI_BASE_URL) and saved.get("AI_MODEL", AI_MODEL)),
})
if parsed.path == "/api/settings":
saved = {str(r["key"]): str(r["value"]) for r in conn.execute("SELECT key,value FROM settings")}
return self.send_json(200, {"maps": {"backend": backend_mode(), "worker_url": GMAPS_URL, "ready": backend_mode() == "browser" or bool(GMAPS_URL)}, "ai": {"base_url": saved.get("AI_BASE_URL", AI_BASE_URL), "model": saved.get("AI_MODEL", AI_MODEL), "configured": bool(saved.get("AI_BASE_URL", AI_BASE_URL) and saved.get("AI_MODEL", AI_MODEL))}, "email_collection": {"always_on": True, "public_only": True}, "limits": {"max_results": MAX_RESULTS, "website_scan_timeout_seconds": 8}})
if parsed.path == "/api/prospects":
params = parse_qs(parsed.query)
q = (params.get("q", [""])[0]).strip().lower()
@@ -1041,6 +1055,18 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(content)
def do_POST(self) -> None:
if self.path == "/api/settings":
try:
payload = self.read_json(); ai = payload.get("ai") if isinstance(payload.get("ai"), dict) else {}
conn = db()
for key in ("AI_BASE_URL", "AI_API_KEY", "AI_MODEL"):
if key in ai:
value = str(ai.get(key) or "").strip()[:500]
conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", (key, value, now()))
conn.commit(); saved={str(r["key"]):str(r["value"]) for r in conn.execute("SELECT key,value FROM settings")}; conn.close()
return self.send_json(200, {"saved": True, "ai": {"base_url": saved.get("AI_BASE_URL", AI_BASE_URL), "model": saved.get("AI_MODEL", AI_MODEL), "configured": bool(saved.get("AI_BASE_URL", AI_BASE_URL) and saved.get("AI_MODEL", AI_MODEL))}})
except Exception as exc:
return self.send_json(400, {"error": str(exc)})
message_match = re.fullmatch(r"/api/prospects/(\d+)/message", urlparse(self.path).path)
if message_match:
conn = None
+22 -174
View File
@@ -1,174 +1,22 @@
const $ = (id) => document.getElementById(id);
let prospects = [];
let runs = [];
let activePoll = null;
let selectedProspect = null;
async function api(path, options = {}) {
const response = await fetch(path, {
...options,
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error || body.message || `Request failed (${response.status})`);
return body;
}
function esc(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
}
function formatDate(value) {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value).replace("T", " ").replace("+00:00", "");
return new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }).format(date);
}
function plural(count, word) { return `${count} ${word}${count === 1 ? "" : "s"}`; }
function renderStats(stats = {}) {
$("total").textContent = stats.total ?? prospects.length;
$("pending").textContent = stats.pending ?? prospects.filter((item) => item.review_status === "pending").length;
$("approved").textContent = stats.approved ?? prospects.filter((item) => item.review_status === "approved").length;
$("running").textContent = stats.running ?? runs.filter((item) => ["queued", "running"].includes(item.status)).length;
const latest = runs[0];
$("lastRun").textContent = latest ? `last #${latest.id} · ${formatDate(latest.created_at)}` : "no recent run";
}
function scoreClass(score) {
if (score >= 75) return "high";
if (score >= 55) return "medium";
return "low";
}
function renderExplorer() {
const query = $("search").value.trim().toLowerCase();
const status = $("status").value;
const rows = prospects.filter((item) => {
const haystack = [item.name, item.domain, item.category, item.address, item.phone, item.emails].join(" ").toLowerCase();
return (!query || haystack.includes(query)) && (!status || item.review_status === status);
});
$("resultCount").textContent = `${plural(rows.length, "place")} ${query || status ? "matching" : "in catalogue"}`;
$("explorerList").innerHTML = rows.length ? rows.map((item) => {
const score = Number(item.opportunity_score || 0);
const website = item.website ? `<a class="inline-link" href="${esc(item.website)}" target="_blank" rel="noopener">${esc(item.domain || item.website)} <span>↗</span></a>` : `<span class="missing-site">No detected website</span>`;
const maps = item.maps_url ? `<a class="action-link" href="${esc(item.maps_url)}" target="_blank" rel="noopener">Open Maps ↗</a>` : "";
const contact = [item.phone && `<span>☎ ${esc(item.phone)}</span>`, item.emails && `<span>✉ ${esc(item.emails)}</span>`].filter(Boolean).join("");
return `<article class="prospect-card">
<div class="prospect-main"><div class="prospect-title"><span class="place-pin">⌖</span><div><h3>${esc(item.name)}</h3><div class="subline">${esc(item.category || "Business")} ${item.address ? ` · ${esc(item.address)}` : ""}</div></div></div>
<div class="chips"><span class="score-chip ${scoreClass(score)}"><i></i>${score}/100 opportunity</span><span class="chip ${item.website_status === "healthy" ? "" : "warning"}">${esc((item.website_status || "unknown").replaceAll("_", " "))}</span>${item.rating != null ? `<span class="chip">★ ${esc(Number(item.rating).toFixed(1))} <em>(${esc(item.review_count || 0)})</em></span>` : ""}</div>
<div class="prospect-details">${website}${contact ? `<span class="contact-line">${contact}</span>` : ""}</div>
<div class="prospect-meta"><span>Found ${esc(formatDate(item.discovered_at))}</span><span>·</span><span>${esc(item.source || "google_maps")}</span>${maps ? `<span class="meta-spacer"></span>${maps}` : ""}<button class="action-link message-action" data-message="${item.id}" type="button">Draft AI email</button><button class="action-link" data-domains="${item.id}" type="button">Domain ideas</button><button class="action-link" data-rescan="${item.id}" type="button">Rescan</button></div>
</div>
<div class="review-control"><label for="review-${item.id}">Review</label><select id="review-${item.id}" data-review="${item.id}"><option value="pending" ${item.review_status === "pending" ? "selected" : ""}>Needs review</option><option value="approved" ${item.review_status === "approved" ? "selected" : ""}>Approved</option><option value="rejected" ${item.review_status === "rejected" ? "selected" : ""}>Rejected</option><option value="contacted" ${item.review_status === "contacted" ? "selected" : ""}>Contacted</option></select></div>
</article>`;
}).join("") : `<div class="empty-state"><div class="empty-icon">⌕</div><strong>${query || status ? "Nothing matches these filters" : "Your catalogue is empty"}</strong><p>${query || status ? "Try a different search or clear the status filter." : "Run your first discovery above to bring places into the explorer."}</p></div>`;
}
function runStatusLabel(run) {
if (run.status === "succeeded") return "Complete";
if (run.status === "failed") return "Failed";
if (run.status === "queued") return "Queued";
return run.stage === "scraping" ? "Searching Maps" : "Preparing";
}
function renderActiveRun(run) {
const target = $("activeRun");
if (!run || !["queued", "running"].includes(run.status)) {
target.className = "active-run empty-run";
target.innerHTML = `<div class="run-orb">◷</div><div><strong>${run && run.status === "failed" ? "Last run failed" : "No active run"}</strong><p>${run && run.error ? esc(run.error) : "Start a search to see progress here."}</p></div>`;
return;
}
const progress = Math.max(5, Math.min(95, Number(run.progress || 5)));
target.className = "active-run running-run";
target.innerHTML = `<div class="run-orb spinning">◌</div><div class="run-copy"><div class="run-copy-head"><strong>#${run.id} · ${esc(run.query)}</strong><span>${progress}%</span></div><div class="progress-track"><i style="width:${progress}%"></i></div><p>${esc(runStatusLabel(run))} · ${esc(run.found || 0)} places seen</p></div>`;
}
function renderRuns() {
const current = runs.find((run) => ["queued", "running"].includes(run.status));
renderActiveRun(current || runs[0]);
const list = runs.slice(0, 5);
$("runsList").innerHTML = list.length ? list.map((run) => `<div class="history-row"><div><strong>#${run.id}</strong><span>${esc(run.query)}</span></div><div class="history-result ${run.status === "succeeded" ? "success" : run.status === "failed" ? "failure" : "pending"}"><b>${run.status === "succeeded" ? `+${run.added}` : run.status === "failed" ? "!" : "…"}</b><small>${esc(runStatusLabel(run))}</small></div></div>`).join("") : '<p class="muted">No runs yet.</p>';
renderStats();
}
async function loadProspects() { prospects = (await api("/api/prospects")).items || []; renderExplorer(); }
async function loadRuns() { runs = (await api("/api/runs")).items || []; renderRuns(); }
async function loadStats() { renderStats(await api("/api/stats")); }
async function loadSchedules() { const data=await api("/api/schedules"); $("scheduleList").innerHTML=(data.items||[]).map(s=>`<div class="history-row"><span>${esc(s.keywords)} · ${esc(s.category)} · ${esc(s.location)}</span><small>every ${s.interval_hours}h · next ${esc(formatDate(s.next_run_at))}</small></div>`).join("") || "No schedules configured."; }
async function loadConfig() {
const config = await api("/api/config");
$("backendLabel").textContent = config.backend === "gmaps" ? "Maps worker" : "Browser fallback";
$("sourceLabel").textContent = config.ready ? "Maps worker ready" : "Setup required";
$("sourceSub").textContent = config.ready ? "Google Maps scraper connected" : (config.gmaps_auth_required ? "SaaS API key required" : "Start the bundled Maps worker");
$("sourceDot").classList.toggle("offline", !config.ready);
$("setupNotice").hidden = Boolean(config.ready);
if (!config.gmaps_url) $("setupText").textContent = "Start the bundled Maps worker with Docker, then reload this page.";
else if (config.gmaps_auth_required && !config.gmaps_api_key) $("setupText").textContent = "SaaS mode is selected but no API key is configured. Use standard mode or add GMAPS_API_KEY.";
else $("setupText").textContent = "The standard worker needs no API key. If a run fails, check the gmaps container logs.";
}
async function refreshAll() {
$("updated").textContent = "Refreshing…";
try { await Promise.all([loadConfig(), loadProspects(), loadRuns(), loadStats(), loadSchedules()]); $("updated").textContent = `Updated ${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`; }
catch (error) { $("updated").textContent = "Refresh failed"; $("discoverMessage").textContent = error.message; $("discoverMessage").className = "form-message error"; }
}
async function pollRun(runId) {
if (activePoll) clearTimeout(activePoll);
try {
const run = await api(`/api/runs/${runId}`);
const index = runs.findIndex((item) => item.id === run.id);
if (index >= 0) runs[index] = run; else runs.unshift(run);
renderRuns();
if (["queued", "running"].includes(run.status)) activePoll = setTimeout(() => pollRun(runId), 2000);
else { await Promise.all([loadProspects(), loadStats()]); $("discoverBtn").disabled = false; $("discoverMessage").textContent = run.status === "succeeded" ? `Complete · ${run.added} new ${run.added === 1 ? "place" : "places"} added.` : `Run failed · ${run.error || "Unknown error"}`; $("discoverMessage").className = `form-message ${run.status === "succeeded" ? "success" : "error"}`; }
} catch (error) { $("discoverMessage").textContent = error.message; $("discoverMessage").className = "form-message error"; $("discoverBtn").disabled = false; }
}
$("discoverForm").addEventListener("submit", async (event) => {
event.preventDefault();
const button = $("discoverBtn");
const message = $("discoverMessage");
button.disabled = true; message.className = "form-message"; message.textContent = "Queueing Maps search…";
const payload = { category: $("category").value.trim(), keywords: $("keywords").value.trim(), location: $("location").value.trim(), max_results: Number($("max-results").value), include_email: true, extra_reviews: $("extra-reviews").checked };
try { const run = await api("/api/discover", { method: "POST", body: JSON.stringify(payload) }); runs.unshift(run); renderRuns(); message.textContent = "Search queued · this can take a few minutes."; await pollRun(run.id); }
catch (error) { button.disabled = false; message.textContent = `Could not start run · ${error.message}`; message.className = "form-message error"; }
});
$("explorerList").addEventListener("change", async (event) => {
const id = event.target.dataset.review; if (!id) return;
event.target.disabled = true;
try { const updated = await api(`/api/prospects/${id}/review`, { method: "PATCH", body: JSON.stringify({ status: event.target.value }) }); const index = prospects.findIndex((item) => item.id === updated.id); if (index >= 0) prospects[index] = updated; renderExplorer(); await loadStats(); }
catch (error) { event.target.disabled = false; alert(error.message); }
});
$("explorerList").addEventListener("click", (event) => {
const id = event.target.dataset.message; if (!id) return;
selectedProspect = prospects.find((item) => String(item.id) === String(id));
$("messageTitle").textContent = `Draft for ${selectedProspect?.name || "prospect"}`;
$("offerInput").value = ""; $("subjectOutput").value = selectedProspect?.ai_subject || ""; $("bodyOutput").value = selectedProspect?.ai_message || ""; $("messageStatus").textContent = ""; $("messageDialog").showModal();
});
$("explorerList").addEventListener("click", async (event) => {
const d=event.target.dataset.domains, r=event.target.dataset.rescan;
if (d) { try { const x=await api(`/api/prospects/${d}/domains`); alert((x.items||[]).map(i=>`${i.domain} · ${i.status}`).join("\n") || "No domain ideas found."); } catch(e){ alert(e.message); } }
if (r) { event.target.disabled=true; try { const updated=await api(`/api/prospects/${r}/scan`,{method:"POST",body:"{}"}); const i=prospects.findIndex(p=>p.id===updated.id); if(i>=0) prospects[i]=updated; renderExplorer(); } catch(e){ alert(e.message); } }
});
$("generateMessage").addEventListener("click", async () => {
if (!selectedProspect) return; const status = $("messageStatus"); status.textContent = "Generating evidence-grounded draft…";
try { const draft = await api(`/api/prospects/${selectedProspect.id}/message`, {method:"POST", body:JSON.stringify({offer:$("offerInput").value})}); $("subjectOutput").value=draft.subject; $("bodyOutput").value=draft.message; status.textContent="Draft generated. Edit it before sending."; status.className="form-message success"; selectedProspect.ai_subject=draft.subject; selectedProspect.ai_message=draft.message; }
catch (error) { status.textContent = error.message; status.className="form-message error"; }
});
$("copyMessage").addEventListener("click", async () => { const text = `Subject: ${$("subjectOutput").value}\n\n${$("bodyOutput").value}`; await navigator.clipboard.writeText(text); $("messageStatus").textContent="Copied to clipboard."; });
$("search").addEventListener("input", renderExplorer);
$("status").addEventListener("change", renderExplorer);
$("refresh").addEventListener("click", loadProspects);
document.getElementById("exportCsv")?.addEventListener("click", () => { window.location.href = "/api/export.csv"; });
$("runsRefresh").addEventListener("click", loadRuns);
$("refreshAll").addEventListener("click", refreshAll);
$("showRuns").addEventListener("click", (event) => { event.preventDefault(); $("runs").scrollIntoView({ behavior: "smooth", block: "center" }); });
$("scheduleForm").addEventListener("submit", async (event) => { event.preventDefault(); const m=$("scheduleMessage"); try { await api("/api/schedules", {method:"POST",body:JSON.stringify({category:$("schedule-category").value,keywords:$("schedule-keywords").value,location:$("schedule-location").value,interval_hours:Number($("schedule-interval").value)})}); m.textContent="Schedule saved."; m.className="form-message success"; await loadSchedules(); } catch(e){m.textContent=e.message;m.className="form-message error";} });
refreshAll().catch((error) => { $("explorerList").innerHTML = `<div class="empty-state"><strong>Unable to load workspace</strong><p>${esc(error.message)}</p></div>`; });
const $=id=>document.getElementById(id); const esc=v=>String(v??"").replace(/[&<>\"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',"'":'&#39;'}[c]));
let prospects=[],runs=[],settings={},selected=null,pollTimer=null;
async function api(path,opt={}){const r=await fetch(path,{...opt,headers:{'Content-Type':'application/json',...(opt.headers||{})}});const b=await r.json().catch(()=>({}));if(!r.ok)throw Error(b.error||`Request failed (${r.status})`);return b}
function date(v){if(!v)return'—';const d=new Date(v);return isNaN(d)?String(v):d.toLocaleString(undefined,{day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})}
function layout(title,sub,body,actions=''){ $('pageName').textContent=title; return `<section class="page-head"><div><span class="eyebrow">PROSPECTOS</span><h1>${title}</h1><p>${sub}</p></div><div class="head-actions">${actions}</div></section>${body}` }
function empty(text){return `<div class="empty"><span>⌕</span><b>${text}</b></div>`}
function scoreClass(n){return n>=75?'hot':n>=55?'warm':'cool'}
function prospectRows(){const q=($('prospectSearch')?.value||'').toLowerCase();const st=$('prospectStatus')?.value||'';return prospects.filter(p=>{const hay=[p.name,p.domain,p.category,p.address,p.emails].join(' ').toLowerCase();return(!q||hay.includes(q))&&(!st||p.review_status===st)})}
function prospectTable(){const rows=prospectRows();return `<div class="table-wrap"><table><thead><tr><th>Prospect</th><th>Website</th><th>Email</th><th>Score</th><th>Review</th><th></th></tr></thead><tbody>${rows.map(p=>`<tr data-open="${p.id}"><td><b>${esc(p.name)}</b><small>${esc(p.category||'Business')} · ${esc(p.address||'')}</small></td><td><span class="badge ${p.website_status==='healthy'?'good':'warn'}">${esc((p.website_status||'unknown').replaceAll('_',' '))}</span>${p.website?`<a href="${esc(p.website)}" target="_blank" rel="noopener">Open ↗</a>`:'<small>No detected website</small>'}</td><td>${p.emails?`<span class="email">✉ ${esc(p.emails)}</span>`:'<small>Not found</small>'}</td><td><span class="score ${scoreClass(Number(p.opportunity_score||0))}">${p.opportunity_score||0}</span></td><td><select data-review="${p.id}"><option value="pending" ${p.review_status==='pending'?'selected':''}>Needs review</option><option value="approved" ${p.review_status==='approved'?'selected':''}>Approved</option><option value="rejected" ${p.review_status==='rejected'?'selected':''}>Rejected</option><option value="contacted" ${p.review_status==='contacted'?'selected':''}>Contacted</option></select></td><td><button class="icon-action" data-message="${p.id}" title="Draft AI email">✦</button></td></tr>`).join('')}</tbody></table></div>${rows.length?'':empty('No prospects match these filters')}`}
function renderDashboard(){const total=prospects.length,pending=prospects.filter(p=>p.review_status==='pending').length,hot=prospects.filter(p=>p.opportunity_score>=75).length,withEmail=prospects.filter(p=>p.emails).length;const latest=runs[0];$('view').innerHTML=layout('Dashboard','A clear view of what was found, what needs review, and where the best opportunities are.',`<div class="metric-grid"><div class="metric"><small>CATALOGUE</small><b>${total}</b><span>saved prospects</span></div><div class="metric"><small>NEEDS REVIEW</small><b>${pending}</b><span>awaiting your decision</span></div><div class="metric"><small>HIGH OPPORTUNITY</small><b>${hot}</b><span>score 75 or higher</span></div><div class="metric"><small>PUBLIC EMAILS</small><b>${withEmail}</b><span>evidence-backed contacts</span></div></div><div class="dashboard-grid"><article class="panel"><div class="panel-title"><div><span class="eyebrow">NEXT ACTION</span><h2>Review your strongest leads</h2><p>Inspect the evidence before deciding whether to contact them.</p></div><a class="primary" href="#prospects">Open explorer →</a></div><div class="spotlight">${prospects.slice(0,5).map(p=>`<button class="spot" data-open="${p.id}"><span class="score ${scoreClass(p.opportunity_score)}">${p.opportunity_score}</span><span><b>${esc(p.name)}</b><small>${esc(p.website_status?.replaceAll('_',' ')||'unknown')} · ${esc(p.emails||'no public email')}</small></span><span>›</span></button>`).join('')||empty('Run a discovery to build your catalogue')}</div></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">RECENT ACTIVITY</span><h2>Runs</h2></div><a href="#runs">View all</a></div><div class="activity">${runs.slice(0,6).map(r=>`<div><span class="run-dot ${r.status}"></span><span><b>#${r.id} · ${esc(r.query)}</b><small>${esc(r.status)} · ${r.added||0} added</small></span><time>${date(r.created_at)}</time></div>`).join('')||empty('No runs yet')}</div></article></div>`)}
function renderDiscover(){ $('view').innerHTML=layout('Discover','Set the criteria for this run. Source configuration and provider settings live in Settings.',`<div class="two-col"><article class="panel form-panel"><div class="panel-title"><div><span class="eyebrow">NEW RUN</span><h2>Find potential customers</h2><p>Search Google Maps, deduplicate the catalogue, then enrich each result.</p></div><span class="pill">Email collection always on</span></div><form id="discoverForm"><label>Keywords<input id="keywords" required placeholder="plumbing services"></label><label>Category<input id="category" required placeholder="plumbers"></label><label>Location<input id="location" value="South Africa" required></label><label>Maximum results<select id="max-results"><option>10</option><option selected>25</option><option>50</option></select></label><label class="checkline"><input type="checkbox" id="extra-reviews"> Collect extra reviews <small>May increase run time</small></label><div class="form-row"><button class="primary" type="submit" id="discoverBtn">Run discovery →</button><span id="discoverMessage" class="notice"></span></div></form></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">HOW IT WORKS</span><h2>Evidence-first pipeline</h2></div></div><ol class="steps"><li><b>Search</b><span>Maps worker finds public business listings.</span></li><li><b>Enrich</b><span>Website, HTTPS, DNS hints and public email checks run safely.</span></li><li><b>Review</b><span>You decide what is relevant before any contact.</span></li></ol></article></div>`)}
function renderProspects(){ $('view').innerHTML=layout('Prospects','Search, filter and review your catalogue. Click a row to inspect the evidence.',`<article class="panel"><div class="toolbar"><div class="search"><span>⌕</span><input id="prospectSearch" placeholder="Search name, address, domain or email"></div><select id="prospectStatus"><option value="">All review statuses</option><option value="pending">Needs review</option><option value="approved">Approved</option><option value="rejected">Rejected</option><option value="contacted">Contacted</option></select><button class="secondary" id="exportCsv">Export CSV</button><button class="secondary" id="refreshProspects">Refresh</button></div><div id="prospectTable">${prospectTable()}</div></article><aside id="detail" class="detail-panel" hidden></aside>`)}
function renderRuns(){ $('view').innerHTML=layout('Runs','Monitor discovery jobs and inspect errors without leaving the run history.',`<article class="panel"><div class="table-wrap"><table><thead><tr><th>Run</th><th>Status</th><th>Results</th><th>Started</th><th>Source</th></tr></thead><tbody>${runs.map(r=>`<tr><td><b>#${r.id}</b><small>${esc(r.query)}</small></td><td><span class="status ${r.status}">${esc(r.status)}</span><small>${esc(r.stage||'')}</small></td><td>${r.added||0} added / ${r.found||0} found</td><td>${date(r.created_at)}</td><td>${esc(r.source||'google_maps')}</td></tr>`).join('')}</tbody></table></div>${runs.length?'':empty('No discovery runs yet')}</article>`)}
function renderSettings(){const ai=settings.ai||{};const sch=settings.schedules||[];const sup=settings.suppressions||[];$('view').innerHTML=layout('Settings','Configure providers, automation and safety controls here. These settings are separate from daily discovery.',`<div class="settings-grid"><article class="panel"><div class="panel-title"><div><span class="eyebrow">SOURCE</span><h2>Google Maps worker</h2><p>The bundled standard gosom worker runs locally and does not require an API key.</p></div><span class="status ${settings.maps?.ready?'succeeded':'failed'}">${settings.maps?.ready?'Ready':'Not connected'}</span></div><div class="setting-line"><span>Connection</span><b>${esc(settings.maps?.worker_url||'Not configured')}</b></div><div class="setting-line"><span>Email extraction</span><b class="good-text">Always on · public pages only</b></div><div class="setting-line"><span>Website scan</span><b>8 second bounded timeout</b></div></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">AI PROVIDER</span><h2>Draft assistant</h2><p>Used only when you click “Draft AI email”.</p></div></div><form id="settingsForm"><label>OpenAI-compatible base URL<input id="aiBase" value="${esc(ai.base_url||'')}" placeholder="https://provider.example/v1"></label><label>API key<input id="aiKey" type="password" placeholder="Leave blank to keep current key"></label><label>Model<input id="aiModel" value="${esc(ai.model||'')}" placeholder="model-name"></label><div class="form-row"><button class="primary" type="submit">Save AI settings</button><span id="settingsMessage" class="notice"></span></div></form></article><article class="panel wide-panel"><div class="panel-title"><div><span class="eyebrow">SCHEDULES</span><h2>Recurring discovery</h2><p>Run bounded searches automatically; all results still arrive as needs-review.</p></div></div><form id="scheduleForm" class="inline-form"><input id="sCat" placeholder="Category"><input id="sKey" placeholder="Keywords"><input id="sLoc" value="South Africa" placeholder="Location"><select id="sHours"><option value="24">Daily</option><option value="48">Every 2 days</option><option value="168">Weekly</option></select><button class="secondary">Add schedule</button></form><div class="list">${sch.map(s=>`<div><b>${esc(s.keywords)} · ${esc(s.category)}</b><span>${esc(s.location)} · every ${s.interval_hours}h · next ${date(s.next_run_at)}</span></div>`).join('')||'<p class="muted">No schedules configured.</p>'}</div></article><article class="panel wide-panel"><div class="panel-title"><div><span class="eyebrow">SAFETY</span><h2>Suppression centre</h2><p>Suppressed domains and emails are excluded before new prospects are saved.</p></div></div><form id="suppressionForm" class="inline-form"><select id="supKind"><option value="domain">Domain</option><option value="email">Email</option></select><input id="supValue" placeholder="example.co.za or info@example.co.za"><input id="supReason" placeholder="Reason"><button class="secondary">Suppress</button></form><div class="list">${sup.map(s=>`<div><b>${esc(s.value)}</b><span>${esc(s.kind)} · ${esc(s.reason||'manual')} · ${date(s.created_at)}</span></div>`).join('')||'<p class="muted">No suppressions configured.</p>'}</div></article></div>`)}
async function load(){[prospects,runs,settings]=await Promise.all([api('/api/prospects').then(x=>x.items||[]),api('/api/runs').then(x=>x.items||[]),Promise.all([api('/api/settings'),api('/api/schedules'),api('/api/suppressions')]).then(([s,sch,sup])=>({...s,schedules:sch.items||[],suppressions:sup.items||[]}))]);}
async function route(){const r=location.hash.slice(1)||'dashboard';document.querySelectorAll('[data-route]').forEach(a=>a.classList.toggle('active',a.dataset.route===r));try{await load();({dashboard:renderDashboard,discover:renderDiscover,prospects:renderProspects,runs:renderRuns,settings:renderSettings}[r]||renderDashboard)();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});}catch(e){$('view').innerHTML=empty(e.message)}}
async function openMessage(id){selected=prospects.find(p=>String(p.id)===String(id));if(!selected)return;$('messageTitle').textContent='Draft for '+selected.name;$('offerInput').value='';$('subjectOutput').value=selected.ai_subject||'';$('bodyOutput').value=selected.ai_message||'';$('messageStatus').textContent='';$('messageDialog').showModal()}
document.addEventListener('click',async e=>{const open=e.target.closest('[data-open]');if(open){const p=prospects.find(x=>String(x.id)===open.dataset.open);if(p){selected=p;alert(`${p.name}\n\nScore: ${p.opportunity_score}/100\nWebsite: ${p.website_status}\nReasons: ${JSON.parse(p.score_reasons||'[]').join(', ')||'—'}\n\nEmails: ${p.emails||'None found'}\nEvidence: ${p.email_evidence||'[]'}`)}}const msg=e.target.closest('[data-message]');if(msg)openMessage(msg.dataset.message);if(e.target.id==='exportCsv')location.href='/api/export.csv';if(e.target.id==='refreshProspects')route();});
document.addEventListener('change',async e=>{if(e.target.dataset.review){try{const p=await api(`/api/prospects/${e.target.dataset.review}/review`,{method:'PATCH',body:JSON.stringify({status:e.target.value})});const i=prospects.findIndex(x=>x.id===p.id);if(i>=0)prospects[i]=p}catch(err){alert(err.message)}}if(e.target.id==='prospectSearch'||e.target.id==='prospectStatus')$('prospectTable').innerHTML=prospectTable()});
document.addEventListener('submit',async e=>{if(e.target.id==='discoverForm'){e.preventDefault();const m=$('discoverMessage');$('discoverBtn').disabled=true;m.textContent='Queueing…';try{const r=await api('/api/discover',{method:'POST',body:JSON.stringify({keywords:$('keywords').value,category:$('category').value,location:$('location').value,max_results:Number($('max-results').value),extra_reviews:$('extra-reviews').checked,include_email:true})});m.textContent=`Run #${r.id} started. View it in Runs.`;setTimeout(route,500)}catch(x){m.textContent=x.message}finally{$('discoverBtn').disabled=false}}if(e.target.id==='settingsForm'){e.preventDefault();const m=$('settingsMessage');try{const ai={AI_BASE_URL:$('aiBase').value,AI_MODEL:$('aiModel').value};if($('aiKey').value)ai.AI_API_KEY=$('aiKey').value;await api('/api/settings',{method:'POST',body:JSON.stringify({ai})});m.textContent='Settings saved.';m.className='notice ok';}catch(x){m.textContent=x.message}}if(e.target.id==='scheduleForm'){e.preventDefault();try{await api('/api/schedules',{method:'POST',body:JSON.stringify({category:$('sCat').value,keywords:$('sKey').value,location:$('sLoc').value,interval_hours:Number($('sHours').value)})});route()}catch(x){alert(x.message)}}if(e.target.id==='suppressionForm'){e.preventDefault();try{await api('/api/suppressions',{method:'POST',body:JSON.stringify({kind:$('supKind').value,value:$('supValue').value,reason:$('supReason').value})});route()}catch(x){alert(x.message)}}});
document.addEventListener('click',async e=>{if(e.target.id==='generateMessage'){if(!selected)return;const s=$('messageStatus');s.textContent='Generating…';try{const d=await api(`/api/prospects/${selected.id}/message`,{method:'POST',body:JSON.stringify({offer:$('offerInput').value})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;s.textContent='Draft ready. Review and edit before sending.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}});
window.addEventListener('hashchange',route);$('refreshAll').addEventListener('click',route);route();
+2 -83
View File
@@ -1,84 +1,3 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#111827">
<title>ProspectOS · Maps Scout</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<div class="brand"><span class="brand-mark">P</span><div><strong>ProspectOS</strong><small>Maps Scout</small></div></div>
<nav class="side-nav" aria-label="Main navigation">
<a class="active" href="#discover"><span class="nav-icon">⌁</span>Discover</a>
<a href="#explorer"><span class="nav-icon">▦</span>Explorer</a>
<a href="#runs"><span class="nav-icon">◷</span>Run history</a>
</nav>
<div class="sidebar-foot">
<div class="source-card"><span class="status-dot" id="sourceDot"></span><div><strong id="sourceLabel">Checking source</strong><small id="sourceSub">Connecting to Maps worker</small></div></div>
<p>Personal workspace<br><span>No outreach is sent</span></p>
</div>
</aside>
<main class="main-content">
<header class="topbar">
<div class="breadcrumbs"><span>Workspace</span><i>/</i><strong>Maps Scout</strong></div>
<div class="top-actions"><span class="updated" id="updated">—</span><button class="icon-btn" id="refreshAll" type="button" title="Refresh workspace" aria-label="Refresh workspace">↻</button></div>
</header>
<section class="page-heading">
<div><span class="eyebrow">PERSONAL DISCOVERY WORKSPACE</span><h1>Find the businesses worth a closer look.</h1><p>Search Google Maps, remove places already in your catalogue, then review the strongest opportunities in one calm workspace.</p></div>
<div class="heading-badge"><span class="pulse"></span><span id="backendLabel">Maps worker</span><small>private &amp; local</small></div>
</section>
<section class="setup-notice" id="setupNotice" hidden>
<div class="notice-icon">!</div><div><strong>Finish the Maps worker setup</strong><p id="setupText">Start the bundled worker with Docker, then reload this page. The standard worker needs no API key.</p></div><a href="#setup" class="text-link">Setup guide ↓</a>
</section>
<section class="stats-grid" aria-label="Catalogue summary">
<article class="stat-card stat-primary"><div class="stat-label"><span class="stat-icon">◈</span><span>Catalogue</span></div><strong id="total">0</strong><small>places saved</small></article>
<article class="stat-card"><div class="stat-label"><span class="stat-icon amber">●</span><span>Needs review</span></div><strong id="pending">0</strong><small>pending decisions</small></article>
<article class="stat-card"><div class="stat-label"><span class="stat-icon green">✓</span><span>Approved</span></div><strong id="approved">0</strong><small>ready for your next step</small></article>
<article class="stat-card"><div class="stat-label"><span class="stat-icon blue">◷</span><span>Runs active</span></div><strong id="running">0</strong><small id="lastRun">no recent run</small></article>
</section>
<section class="workspace-grid" id="discover">
<article class="panel discovery-panel">
<div class="panel-heading"><div><span class="eyebrow">01 · NEW SEARCH</span><h2>Start a discovery run</h2><p>Describe the businesses you want. These criteria apply only to this run.</p></div><span class="source-tag"><span class="status-dot"></span> Google Maps</span></div>
<form id="discoverForm" class="discovery-form">
<div class="field wide"><label for="keywords">What should we find?<span>Keywords</span></label><input id="keywords" name="keywords" placeholder="e.g. plumbing services" required></div>
<div class="field"><label for="category">Business category</label><input id="category" name="category" placeholder="e.g. plumbers" required></div>
<div class="field"><label for="location">Location</label><input id="location" name="location" value="South Africa" required></div>
<div class="field"><label for="max-results">Places to import</label><select id="max-results" name="max_results"><option value="10">10 places</option><option value="20">20 places</option><option value="25" selected>25 places</option><option value="50">50 places</option></select></div>
<div class="options wide"><label class="check locked"><input type="checkbox" id="include-email" name="include_email" checked disabled><span class="check-box"></span><span><strong>Collect public website emails</strong><small>Always on. The worker follows each public business website when available.</small></span></label><label class="check"><input type="checkbox" id="extra-reviews" name="extra_reviews"><span class="check-box"></span><span><strong>Collect extra reviews</strong><small>Useful for research, but increases run time.</small></span></label></div>
<div class="form-footer wide"><button class="primary-btn" id="discoverBtn" type="submit"><span>Run discovery</span><span class="btn-arrow">→</span></button><span id="discoverMessage" class="form-message" role="status">Results are deduplicated before saving.</span></div>
</form>
</article>
<aside class="panel run-panel" id="runs">
<div class="panel-heading compact"><div><span class="eyebrow">02 · ACTIVITY</span><h2>Latest run</h2></div><button class="quiet-btn" id="runsRefresh" type="button">Refresh</button></div>
<div id="activeRun" class="active-run empty-run"><div class="run-orb">◷</div><div><strong>No active run</strong><p>Start a search to see progress here.</p></div></div>
<div class="mini-history"><div class="mini-history-head"><span>Recent runs</span><a href="#runs" id="showRuns">View all</a></div><div id="runsList"><p class="muted">No runs yet.</p></div></div>
</aside>
</section>
<section class="panel explorer-panel" id="explorer">
<div class="panel-heading explorer-heading"><div><span class="eyebrow">03 · CATALOGUE</span><h2>Prospect explorer</h2><p>Sort by opportunity score, inspect the evidence, and mark your next action.</p></div><div class="explorer-actions"><div class="search-box"><span>⌕</span><input id="search" placeholder="Search catalogue" aria-label="Search catalogue"></div><select id="status" aria-label="Filter by review status"><option value="">All statuses</option><option value="pending">Needs review</option><option value="approved">Approved</option><option value="rejected">Rejected</option><option value="contacted">Contacted</option></select><button class="quiet-btn" id="exportCsv" type="button">Export CSV</button><button class="quiet-btn" id="refresh" type="button">Refresh</button></div></div>
<div class="explorer-toolbar"><span id="resultCount">0 places</span><span class="legend"><i class="score-high"></i> Higher opportunity first</span></div>
<div id="explorerList" class="prospect-list"><div class="loading-state"><span class="spinner"></span>Loading catalogue…</div></div>
</section>
<section class="setup-section" id="setup">
<div><span class="eyebrow">SETUP</span><h2>One-time worker connection</h2><p>The bundled standard Maps worker runs locally and does not need a Google or scraper API key.</p></div><ol><li>Run <code>docker compose up -d --build</code>.</li><li>Keep <code>GMAPS_API_MODE=standard</code> in <code>.env</code>.</li><li>Start a discovery run. Open port <code>8081</code> only if you want to inspect the worker's native UI.</li></ol>
</section>
<section class="panel schedule-panel" id="schedules"><div class="panel-heading"><div><span class="eyebrow">AUTOMATION</span><h2>Scheduled discovery</h2><p>Repeat a search on a bounded interval and review the new results.</p></div></div><form id="scheduleForm" class="discovery-form"><div class="field"><label for="schedule-category">Category</label><input id="schedule-category" placeholder="e.g. plumbers"></div><div class="field"><label for="schedule-keywords">Keywords</label><input id="schedule-keywords" placeholder="e.g. plumbing services"></div><div class="field"><label for="schedule-location">Location</label><input id="schedule-location" value="South Africa"></div><div class="field"><label for="schedule-interval">Repeat every</label><select id="schedule-interval"><option value="24">24 hours</option><option value="48">48 hours</option><option value="168">7 days</option></select></div><div class="form-footer wide"><button class="quiet-btn" type="submit">Save schedule</button><span id="scheduleMessage" class="form-message"></span></div></form><div id="scheduleList" class="muted"></div></section>
<footer class="footer"><span>ProspectOS · personal research workspace</span><span>Public data only · no automated outreach</span></footer>
</main>
</div>
<dialog id="messageDialog" class="message-dialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><span class="eyebrow">AI ASSISTED DRAFT</span><h2 id="messageTitle">Tailor a message</h2><p>Review the evidence, add your offer, then edit before sending.</p></div><button class="icon-btn" value="cancel" aria-label="Close">×</button></div><label for="offerInput">What should we offer?</label><textarea id="offerInput" rows="4" placeholder="e.g. Move their hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="form-message"></div><button class="primary-btn" id="generateMessage" type="button">Generate subject and email →</button><label for="subjectOutput">Subject</label><input id="subjectOutput" readonly><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="quiet-btn" id="copyMessage" type="button">Copy draft</button><button class="quiet-btn" value="cancel">Close</button></div></form></dialog>
<script src="/app.js"></script>
</body>
</html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css"></head>
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Use the evidence and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate subject and email</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js"></script></body></html>
+5
View File
File diff suppressed because one or more lines are too long