build prospect intelligence platform MVP
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"""Pure, dependency-free prospect domain rules."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
SCORE_VERSION = "mvp-1"
|
||||
_SOCIAL = {"facebook.com", "instagram.com", "linkedin.com", "twitter.com", "x.com", "youtube.com", "tiktok.com"}
|
||||
|
||||
|
||||
def normalize_domain(value: str | None) -> str:
|
||||
value = (value or "").strip().lower()
|
||||
if not value:
|
||||
return ""
|
||||
parsed = urlparse(value if "://" in value else "//" + value)
|
||||
host = (parsed.hostname or "").strip(".")
|
||||
if host.startswith("www."):
|
||||
host = host[4:]
|
||||
return host
|
||||
|
||||
|
||||
def normalize_phone(value: str | None) -> str:
|
||||
return re.sub(r"[^0-9+]", "", (value or "").strip())
|
||||
|
||||
|
||||
def normalize_business(raw: dict) -> dict:
|
||||
name = " ".join(str(raw.get("name", "")).split())
|
||||
email = str(raw.get("email", "")).strip().lower()
|
||||
website = str(raw.get("website", "")).strip()
|
||||
domain = normalize_domain(raw.get("website_domain") or website)
|
||||
phone = normalize_phone(raw.get("phone"))
|
||||
result = dict(raw)
|
||||
result.update({"name": name, "email": email, "website": website, "website_domain": domain, "phone": phone})
|
||||
return result
|
||||
|
||||
|
||||
def classify_website(website_or_domain: str | None) -> str:
|
||||
domain = normalize_domain(website_or_domain)
|
||||
if not domain:
|
||||
return "missing"
|
||||
if any(domain == item or domain.endswith("." + item) for item in _SOCIAL):
|
||||
return "social_profile"
|
||||
return "business_site"
|
||||
|
||||
|
||||
def score_business(business: dict) -> dict:
|
||||
b = normalize_business(business)
|
||||
score = 0
|
||||
factors = []
|
||||
if b.get("name"):
|
||||
score += 20; factors.append("named_business")
|
||||
site_class = classify_website(b.get("website_domain") or b.get("website"))
|
||||
if site_class == "business_site":
|
||||
score += 30; factors.append("business_site")
|
||||
if b.get("email"):
|
||||
score += 25; factors.append("email")
|
||||
if b.get("phone"):
|
||||
score += 15; factors.append("phone")
|
||||
if b.get("description"):
|
||||
score += 10; factors.append("description")
|
||||
return {"score": min(score, 100), "score_version": SCORE_VERSION, "factors": factors, "website_class": site_class}
|
||||
|
||||
|
||||
def suppression_values(business: dict) -> set[str]:
|
||||
b = normalize_business(business)
|
||||
return {x for x in (b.get("email"), b.get("website_domain"), b.get("phone")) if x}
|
||||
|
||||
|
||||
def is_suppressed(business: dict, suppressions: list[dict]) -> bool:
|
||||
values = suppression_values(business)
|
||||
for item in suppressions:
|
||||
kind, value = item.get("kind", ""), str(item.get("value", "")).strip().lower()
|
||||
if kind == "domain": value = normalize_domain(value)
|
||||
elif kind == "phone": value = normalize_phone(value)
|
||||
if value and value in values:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def deduplication_key(business: dict) -> tuple[str, str]:
|
||||
b = normalize_business(business)
|
||||
if b["website_domain"]: return ("domain", b["website_domain"])
|
||||
if b["email"]: return ("email", b["email"])
|
||||
if b["phone"]: return ("phone", b["phone"])
|
||||
return ("name", re.sub(r"[^a-z0-9]", "", b["name"].lower()))
|
||||
|
||||
|
||||
def deduplicate_businesses(rows: list[dict]) -> list[dict]:
|
||||
chosen: dict[tuple[str, str], dict] = {}
|
||||
for raw in rows:
|
||||
item = normalize_business(raw)
|
||||
key = deduplication_key(item)
|
||||
if key not in chosen or sum(bool(v) for v in item.values()) > sum(bool(v) for v in chosen[key].values()):
|
||||
chosen[key] = item
|
||||
return list(chosen.values())
|
||||
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
if __package__ in (None, ""):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from app.domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business
|
||||
else:
|
||||
from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business
|
||||
|
||||
ORGANIZATION_ID = "demo-tenant"
|
||||
SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql"
|
||||
|
||||
|
||||
def connect(db_path: str) -> sqlite3.Connection:
|
||||
db = sqlite3.connect(db_path)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA foreign_keys = ON")
|
||||
db.executescript(SCHEMA.read_text())
|
||||
return db
|
||||
|
||||
|
||||
def row_json(row: sqlite3.Row) -> dict:
|
||||
result = dict(row)
|
||||
result["score_factors"] = json.loads(result.pop("score_factors", "[]"))
|
||||
return result
|
||||
|
||||
|
||||
def existing_businesses(db: sqlite3.Connection) -> list[dict]:
|
||||
return [row_json(r) for r in db.execute("SELECT * FROM businesses WHERE organization_id = ? ORDER BY id", (ORGANIZATION_ID,))]
|
||||
|
||||
|
||||
def suppression_rows(db: sqlite3.Connection) -> list[dict]:
|
||||
return [dict(r) for r in db.execute("SELECT kind, value FROM suppressions WHERE organization_id = ?", (ORGANIZATION_ID,))]
|
||||
|
||||
|
||||
class ApiHandler(BaseHTTPRequestHandler):
|
||||
server_version = "ProspectPlatform/0.1"
|
||||
|
||||
def send_json(self, status: int, payload: dict | list):
|
||||
body = json.dumps(payload, sort_keys=True).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Access-Control-Allow-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080"))
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def read_json(self) -> dict:
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
value = json.loads(self.rfile.read(length) or b"{}")
|
||||
return value if isinstance(value, dict) else {}
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
def db(self):
|
||||
return connect(self.server.db_path)
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self.send_header("Access-Control-Allow-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080"))
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path.rstrip("/")
|
||||
if path == "/api/v1/health/live":
|
||||
return self.send_json(200, {"status": "ok", "organization_id": ORGANIZATION_ID})
|
||||
db = self.db()
|
||||
try:
|
||||
if path == "/api/v1/dashboard/summary":
|
||||
row = db.execute("SELECT COUNT(*) AS businesses, COALESCE(AVG(score), 0) AS average_score FROM businesses WHERE organization_id = ?", (ORGANIZATION_ID,)).fetchone()
|
||||
return self.send_json(200, {"organization_id": ORGANIZATION_ID, "businesses": row["businesses"], "average_score": round(row["average_score"], 2), "suppressed": db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id = ?", (ORGANIZATION_ID,)).fetchone()[0]})
|
||||
if path == "/api/v1/businesses":
|
||||
query = parse_qs(parsed.query).get("q", [""])[0].strip()
|
||||
if query:
|
||||
like = f"%{query}%"
|
||||
rows = db.execute("SELECT * FROM businesses WHERE organization_id = ? AND (name LIKE ? OR website_domain LIKE ? OR email LIKE ?) ORDER BY score DESC, id", (ORGANIZATION_ID, like, like, like)).fetchall()
|
||||
else:
|
||||
rows = db.execute("SELECT * FROM businesses WHERE organization_id = ? ORDER BY score DESC, id", (ORGANIZATION_ID,)).fetchall()
|
||||
return self.send_json(200, {"organization_id": ORGANIZATION_ID, "items": [row_json(r) for r in rows]})
|
||||
if path.startswith("/api/v1/businesses/"):
|
||||
ident = path.rsplit("/", 1)[1]
|
||||
if not ident.isdigit(): return self.send_json(404, {"error": "not_found"})
|
||||
row = db.execute("SELECT * FROM businesses WHERE id = ? AND organization_id = ?", (int(ident), ORGANIZATION_ID)).fetchone()
|
||||
return self.send_json(200, row_json(row)) if row else self.send_json(404, {"error": "not_found"})
|
||||
return self.send_json(404, {"error": "not_found"})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def do_POST(self):
|
||||
path = urlparse(self.path).path.rstrip("/")
|
||||
payload = self.read_json()
|
||||
if path == "/api/v1/businesses": return self.create_business(payload)
|
||||
if path == "/api/v1/suppressions": return self.create_suppression(payload)
|
||||
if path == "/api/v1/imports/preview": return self.preview_import(payload)
|
||||
return self.send_json(404, {"error": "not_found"})
|
||||
|
||||
def create_business(self, payload):
|
||||
if not str(payload.get("name", "")).strip(): return self.send_json(400, {"error": "name_required"})
|
||||
business = normalize_business(payload)
|
||||
db = self.db()
|
||||
try:
|
||||
if is_suppressed(business, suppression_rows(db)): return self.send_json(409, {"error": "suppressed"})
|
||||
identity_fields = [("website_domain", business["website_domain"]), ("email", business["email"]), ("phone", business["phone"])]
|
||||
identity_fields = [(column, value) for column, value in identity_fields if value]
|
||||
if identity_fields:
|
||||
predicates = " OR ".join(f"{column} = ?" for column, _ in identity_fields)
|
||||
values = [value for _, value in identity_fields]
|
||||
if db.execute(f"SELECT id FROM businesses WHERE organization_id = ? AND ({predicates})", [ORGANIZATION_ID, *values]).fetchone():
|
||||
return self.send_json(409, {"error": "duplicate"})
|
||||
scored = score_business(business)
|
||||
cur = db.execute("INSERT INTO businesses (organization_id,name,website,website_domain,email,phone,description,score,score_version,score_factors,website_class) VALUES (?,?,?,?,?,?,?,?,?,?,?)", (ORGANIZATION_ID, business["name"], business["website"], business["website_domain"], business["email"], business["phone"], str(business.get("description", "")), scored["score"], scored["score_version"], json.dumps(scored["factors"]), scored["website_class"]))
|
||||
db.commit()
|
||||
return self.send_json(201, row_json(db.execute("SELECT * FROM businesses WHERE id = ?", (cur.lastrowid,)).fetchone()))
|
||||
finally: db.close()
|
||||
|
||||
def create_suppression(self, payload):
|
||||
kind, value = payload.get("kind"), str(payload.get("value", "")).strip().lower()
|
||||
if kind not in {"email", "domain", "phone"} or not value: return self.send_json(400, {"error": "invalid_suppression"})
|
||||
db = self.db()
|
||||
try:
|
||||
try:
|
||||
db.execute("INSERT INTO suppressions (organization_id,kind,value) VALUES (?,?,?)", (ORGANIZATION_ID, kind, value)); db.commit()
|
||||
except sqlite3.IntegrityError: pass
|
||||
row = db.execute("SELECT * FROM suppressions WHERE organization_id = ? AND kind = ? AND value = ?", (ORGANIZATION_ID, kind, value)).fetchone()
|
||||
return self.send_json(201, dict(row))
|
||||
finally: db.close()
|
||||
|
||||
def preview_import(self, payload):
|
||||
rows = payload.get("rows", [])
|
||||
if not isinstance(rows, list): return self.send_json(400, {"error": "rows_required"})
|
||||
normalized = deduplicate_businesses([r for r in rows if isinstance(r, dict) and str(r.get("name", "")).strip()])
|
||||
db = self.db()
|
||||
try:
|
||||
suppressions = suppression_rows(db); existing = existing_businesses(db); seen = set()
|
||||
accepted, duplicate, suppressed = [], 0, 0
|
||||
for b in normalized:
|
||||
key = deduplication_key(b)
|
||||
if is_suppressed(b, suppressions): suppressed += 1
|
||||
elif (key in {deduplication_key(x) for x in existing} or key in seen): duplicate += 1
|
||||
else: seen.add(key); accepted.append(b)
|
||||
return self.send_json(200, {"accepted": len(accepted), "duplicates": duplicate + len(rows) - len(normalized), "suppressed": suppressed, "rows": accepted})
|
||||
finally: db.close()
|
||||
|
||||
def log_message(self, *_): pass
|
||||
|
||||
|
||||
def create_server(host="127.0.0.1", port=8000, db_path="prospects.db"):
|
||||
server = ThreadingHTTPServer((host, port), ApiHandler)
|
||||
server.db_path = db_path
|
||||
connect(db_path).close()
|
||||
return server
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Prospect Platform API")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=int(os.environ.get("PROSPECT_API_PORT", "8000")))
|
||||
parser.add_argument("--db", default=os.environ.get("PROSPECT_API_DB", "prospects.db"))
|
||||
args = parser.parse_args()
|
||||
server = create_server(args.host, args.port, args.db)
|
||||
print(f"Prospect API listening on http://{args.host}:{args.port}", flush=True)
|
||||
try: server.serve_forever()
|
||||
except KeyboardInterrupt: pass
|
||||
finally: server.server_close()
|
||||
Reference in New Issue
Block a user