replace ProspectOS with BrowserOnly build

This commit is contained in:
Marco0300
2026-09-05 14:05:33 +02:00
parent f5b6f6c739
commit d68355f626
118 changed files with 405 additions and 12569 deletions
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
RUN apt-get update && apt-get install -y --no-install-recommends chromium && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY api/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY api/server.py .
COPY web /app/web
RUN useradd --system --create-home app && mkdir -p /data && chown -R app:app /app /data
USER app
EXPOSE 8000
CMD ["python", "server.py"]
+1
View File
@@ -0,0 +1 @@
"""Browser-only ProspectOS service package."""
+1
View File
@@ -0,0 +1 @@
playwright==1.52.0
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env python3
"""Small browser-only prospect discovery service.
The service deliberately has one live source (Google in a headless browser),
keeps a local catalog for deduplication, and exposes only review actions.
There is no email scraping, outreach, CRM automation, or source registry.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import sqlite3
import threading
import time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, quote_plus, urlparse
DB_PATH = os.environ.get("PROSPECT_DB", "/data/prospects.db")
MAX_RESULTS = 25
ALLOWED_REVIEW = {"pending", "approved", "rejected", "contacted"}
_discover_lock = threading.Lock()
def now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def db() -> sqlite3.Connection:
os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("""CREATE TABLE IF NOT EXISTS prospects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
website TEXT NOT NULL,
domain TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
query TEXT NOT NULL,
discovered_at TEXT NOT NULL,
review_status TEXT NOT NULL DEFAULT 'pending',
review_notes TEXT NOT NULL DEFAULT '',
UNIQUE(domain)
)""")
conn.execute("""CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
query TEXT NOT NULL,
location TEXT NOT NULL,
requested INTEGER NOT NULL,
found INTEGER NOT NULL DEFAULT 0,
added INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)""")
conn.commit()
return conn
def json_bytes(value: Any) -> bytes:
return json.dumps(value, ensure_ascii=False).encode("utf-8")
def clean_domain(url: str) -> str:
parsed = urlparse(url if "://" in url else "https://" + url)
host = (parsed.hostname or "").lower().strip().rstrip(".")
if host.startswith("www."):
host = host[4:]
return host
def blocked(text: str) -> bool:
lowered = text.lower()
return any(marker in lowered for marker in (
"unusual traffic", "recaptcha", "captcha", "automated queries",
"access denied", "sorry...", "detected unusual traffic",
))
def google_search(query: str, requested: int) -> list[dict[str, str]]:
try:
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
except ImportError as exc:
raise RuntimeError("playwright_not_installed") from exc
url = "https://www.google.com/search?q=" + quote_plus(query) + "&num=" + str(requested)
executable = os.environ.get("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH", "").strip()
options: dict[str, Any] = {"headless": True}
if executable:
options["executable_path"] = executable
try:
with sync_playwright() as p:
browser = p.chromium.launch(**options)
try:
context = browser.new_context(
locale="en-ZA",
java_script_enabled=True,
service_workers="block",
viewport={"width": 1280, "height": 900},
)
page = context.new_page()
page.set_default_timeout(15000)
page.goto(url, wait_until="domcontentloaded", timeout=15000)
page.wait_for_timeout(250)
body = page.locator("body").inner_text()
html = page.content()
if blocked(body) or blocked(html):
raise RuntimeError("google_blocked")
output: list[dict[str, str]] = []
anchors = page.locator("a:has(h3)")
for i in range(min(anchors.count(), requested * 3)):
anchor = anchors.nth(i)
title = " ".join((anchor.locator("h3").inner_text() or "").split())[:200]
href = anchor.get_attribute("href") or ""
domain = clean_domain(href)
if not title or not href or domain in {"google.com", "google.co.za"} or not domain:
continue
if not href.startswith(("http://", "https://")):
continue
if domain not in {item["domain"] for item in output}:
output.append({"name": title, "website": href, "domain": domain})
if len(output) >= requested:
break
return output
finally:
try:
context.close()
except Exception:
pass
browser.close()
except RuntimeError:
raise
except PlaywrightTimeout as exc:
raise RuntimeError("browser_timeout") from exc
except Exception as exc:
raise RuntimeError("browser_error") from exc
def run_discovery(payload: dict[str, Any]) -> dict[str, Any]:
category = str(payload.get("category", "")).strip()
keywords = str(payload.get("keywords", "")).strip()
location = str(payload.get("location", "South Africa")).strip() or "South Africa"
if not category or not keywords:
raise ValueError("category_and_keywords_required")
try:
requested = max(1, min(MAX_RESULTS, int(payload.get("max_results", 10))))
except (TypeError, ValueError) as exc:
raise ValueError("invalid_max_results") from exc
query = f"{keywords} {category} {location}".strip()
with _discover_lock:
conn = db()
cur = conn.execute("INSERT INTO runs(query,location,requested,status,created_at) VALUES(?,?,?,?,?)", (query, location, requested, "running", now()))
run_id = cur.lastrowid
conn.commit()
try:
results = google_search(query, requested)
added = 0
for item in results:
try:
conn.execute("INSERT INTO prospects(name,website,domain,description,query,discovered_at) VALUES(?,?,?,?,?,?)", (item["name"], item["website"], item["domain"], "Google search result", query, now()))
added += 1
except sqlite3.IntegrityError:
pass
conn.execute("UPDATE runs SET found=?,added=?,status='succeeded' WHERE id=?", (len(results), added, run_id))
conn.commit()
return {"id": run_id, "status": "succeeded", "query": query, "found": len(results), "added": added, "results": results}
except Exception as exc:
error = str(exc)[:120]
conn.execute("UPDATE runs SET status='failed',error=? WHERE id=?", (error, run_id))
conn.commit()
raise RuntimeError(error)
finally:
conn.close()
class Handler(BaseHTTPRequestHandler):
server_version = "BrowserProspectOS/1.0"
def send_json(self, status: int, value: Any) -> None:
body = json_bytes(value)
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def read_json(self) -> dict[str, Any]:
length = min(int(self.headers.get("Content-Length", "0") or 0), 100_000)
value = json.loads(self.rfile.read(length) or b"{}")
if not isinstance(value, dict):
raise ValueError("json_object_required")
return value
def do_GET(self) -> None:
parsed = urlparse(self.path)
if not parsed.path.startswith("/api/"):
self.serve_static(parsed.path)
return
conn = db()
try:
if parsed.path == "/api/health":
return self.send_json(200, {"status": "ok", "source": "google_playwright"})
if parsed.path == "/api/prospects":
params = parse_qs(parsed.query)
q = (params.get("q", [""])[0]).strip().lower()
status = params.get("status", [""])[0].strip().lower()
rows = conn.execute("SELECT * FROM prospects ORDER BY id DESC").fetchall()
items = [dict(row) for row in rows if (not q or q in (row["name"] + " " + row["domain"] + " " + row["description"]).lower()) and (not status or row["review_status"] == status)]
return self.send_json(200, {"items": items, "total": len(items)})
if parsed.path == "/api/runs":
rows = [dict(row) for row in conn.execute("SELECT * FROM runs ORDER BY id DESC LIMIT 50")]
return self.send_json(200, {"items": rows})
if parsed.path == "/api/stats":
total = conn.execute("SELECT COUNT(*) FROM prospects").fetchone()[0]
pending = conn.execute("SELECT COUNT(*) FROM prospects WHERE review_status='pending'").fetchone()[0]
approved = conn.execute("SELECT COUNT(*) FROM prospects WHERE review_status='approved'").fetchone()[0]
return self.send_json(200, {"total": total, "pending": pending, "approved": approved})
return self.send_json(404, {"error": "not_found"})
finally:
conn.close()
def serve_static(self, path: str) -> None:
relative = "index.html" if path in {"", "/"} else path.lstrip("/")
root = (Path(__file__).parent / "web").resolve()
if not root.is_dir():
root = (Path(__file__).parent.parent / "web").resolve()
candidate = (root / relative).resolve()
if root not in candidate.parents and candidate != root:
return self.send_json(400, {"error": "invalid_path"})
if not candidate.is_file():
return self.send_json(404, {"error": "not_found"})
content = candidate.read_bytes()
content_type = "text/plain; charset=utf-8"
if candidate.suffix == ".html": content_type = "text/html; charset=utf-8"
elif candidate.suffix == ".js": content_type = "text/javascript; charset=utf-8"
elif candidate.suffix == ".css": content_type = "text/css; charset=utf-8"
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "no-cache")
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
def do_POST(self) -> None:
if self.path != "/api/discover":
return self.send_json(404, {"error": "not_found"})
try:
result = run_discovery(self.read_json())
return self.send_json(201, result)
except ValueError as exc:
return self.send_json(400, {"error": str(exc)})
except RuntimeError as exc:
return self.send_json(502, {"error": str(exc)})
def do_PATCH(self) -> None:
match = re.fullmatch(r"/api/prospects/(\d+)/review", urlparse(self.path).path)
if not match:
return self.send_json(404, {"error": "not_found"})
try:
payload = self.read_json()
status = str(payload.get("status", "")).strip().lower()
if status not in ALLOWED_REVIEW:
return self.send_json(400, {"error": "invalid_review_status"})
conn = db()
cur = conn.execute("UPDATE prospects SET review_status=?,review_notes=? WHERE id=?", (status, str(payload.get("notes", ""))[:1000], int(match.group(1))))
conn.commit()
if not cur.rowcount:
return self.send_json(404, {"error": "not_found"})
row = conn.execute("SELECT * FROM prospects WHERE id=?", (int(match.group(1)),)).fetchone()
return self.send_json(200, dict(row))
except (ValueError, TypeError, json.JSONDecodeError) as exc:
return self.send_json(400, {"error": str(exc)})
finally:
try: conn.close()
except Exception: pass
def serve() -> None:
host = os.environ.get("HOST", "0.0.0.0")
port = int(os.environ.get("PORT", "8000"))
db()
print(f"Browser ProspectOS listening on http://{host}:{port}", flush=True)
ThreadingHTTPServer((host, port), Handler).serve_forever()
if __name__ == "__main__":
serve()