deploy Maps-backed ProspectOS workspace

This commit is contained in:
Marco0300
2026-09-05 15:47:02 +02:00
parent 03d545532a
commit 6dd9531a66
8 changed files with 929 additions and 157 deletions
+12
View File
@@ -0,0 +1,12 @@
# ProspectOS host port
HOST_PORT=8000
# Maps worker admin/UI port. Keep it private if the server is internet-facing.
GMAPS_HOST_PORT=8081
# Create this in the Maps worker admin screen at http://SERVER:8081.
GMAPS_API_KEY=
# A single personal run may take several minutes.
GMAPS_REQUEST_TIMEOUT=300
GMAPS_POLL_SECONDS=2
+74 -27
View File
@@ -1,45 +1,92 @@
# Browser-only ProspectOS
# ProspectOS · Maps Scout
This is the stripped-down replacement for the larger ProspectOS application.
It searches public engines (Google, Bing, DuckDuckGo, Brave Search, and
Startpage) through a headless Playwright Chromium browser, moving to the next
engine when a server-side challenge, timeout, or empty/changed result page is
encountered. Each
discovery run builds a query from category, keywords, and location, filters
results already present in the local catalogue by root domain, and stores only
new prospects. The explorer supports search and human review states
(`pending`, `approved`, `rejected`, `contacted`).
A small, personal-use prospect workspace. It sends one search at a time to
the bundled [gosom/google-maps-scraper](https://github.com/gosom/google-maps-scraper)
worker, waits for its asynchronous job to finish, filters places already in
the local catalogue, and presents the remaining places for human review.
There is deliberately no source registry, email scraper, AI provider, CRM,
outreach sender, or automatic contact action.
The interface is intentionally focused: discovery, run progress, catalogue
search, and review status. It does not send outreach, create a CRM campaign, or
make an AI decision. The opportunity score is a transparent triage hint based
on fields returned by Maps (for example, a missing website and a listed phone
number); you make the final review decision.
## Run with Docker
## Start the stack
```bash
cp .env.example .env
docker compose up -d --build
```
Open `http://SERVER:8000`.
Open `http://SERVER:8000` for ProspectOS. The Maps worker is available on
`http://SERVER:8081` for its one-time setup.
If another service already owns port 8000, choose the host port without
changing the container:
### Connect the Maps worker
1. Open port `8081` in a browser on your private network.
2. Use the worker's admin screen to create an API key for this local app.
3. Put that key in `.env` as `GMAPS_API_KEY=...`.
4. Restart the app with `docker compose up -d --build` and reload ProspectOS.
The app calls `/api/v1/scrape` to queue a job and `/api/v1/jobs/{id}` to poll
it. Jobs are deliberately asynchronous because a Maps scrape can take several
minutes. A run that loses its worker connection is shown as failed with a
specific error rather than remaining stuck forever.
## Configuration
| Variable | Default | Purpose |
|---|---:|---|
| `HOST_PORT` | `8000` | Host port for ProspectOS |
| `GMAPS_HOST_PORT` | `8081` | Host port for the Maps worker admin UI |
| `GMAPS_API_KEY` | empty | API key created in the Maps worker admin UI |
| `GMAPS_REQUEST_TIMEOUT` | `300` | Per-job scraper timeout in seconds |
| `GMAPS_POLL_SECONDS` | `2` | Poll interval while a job is running |
| `DISCOVERY_BACKEND` | `gmaps` when `GMAPS_URL` is set | Set to `browser` only for the old public-search fallback |
The app stores its catalogue in the `prospect_data` Docker volume. The worker
stores its own job data in `gmaps_data`; keep both volumes if you want to retain
history. To inspect status:
```bash
HOST_PORT=8080 docker compose up -d --build
docker compose ps
docker compose logs --tail=100 app
docker compose logs --tail=100 gmaps
```
If an engine presents a CAPTCHA or bot challenge, the run moves on to the next
engine. The app does not bypass challenges or rotate identities. Keep runs
small and spaced out. The implementation uses a fresh headless browser
context for each engine and unwraps normal search redirect links before
deduplication. If every public engine blocks the server IP, the run reports a
clear `search_engines_unavailable` error instead of inventing prospects.
## Local fallback mode
## Run locally
For a quick local smoke test without the Maps worker:
```bash
python -m pip install -r api/requirements.txt
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium python api/server.py
DISCOVERY_BACKEND=browser GMAPS_URL= docker compose up -d --build
```
The browser executable path can be changed for the host operating system.
This uses Playwright against public search engines, keeps the same catalogue
and review UI, and does not bypass CAPTCHAs or other access controls.
## Data and review behavior
Each saved place includes its name, Maps link, website (when present), root
domain, address, phone, category, rating, review count, public emails returned
by the worker when explicitly enabled, source ID, and raw result JSON. A place
with no website is still retained using a stable `maps:<place_id>` catalogue
key, so it can be reviewed as a potential website opportunity. Existing
websites are deduplicated by root domain.
Email extraction is off by default in the UI. If you enable it for a run, the
worker may visit the public business website; expect a longer run and review
the resulting provenance before using any contact information.
## Tests
```bash
python3 -m py_compile api/server.py
node --check web/app.js
python3 -m unittest discover -s tests -q
```
This project is packaged for personal research. Public availability does not
override a website's terms, robots controls, or applicable privacy law; keep
searches small, use the worker only on data you are permitted to collect, and
do not turn the review screen into unattended outreach.
+463 -108
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""Small browser-only prospect discovery service.
"""Personal ProspectOS service backed by Google Maps Scraper.
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.
The app owns the small local catalogue and review workflow. Google Maps
Scraper runs as a separate Playwright service and is called through its
asynchronous REST API. A browser-search implementation remains available as
an explicit fallback (DISCOVERY_BACKEND=browser) for local testing.
"""
from __future__ import annotations
@@ -14,14 +15,20 @@ import re
import sqlite3
import threading
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from typing import Any, Callable
from urllib.parse import parse_qs, quote_plus, unquote, urlparse
DB_PATH = os.environ.get("PROSPECT_DB", "/data/prospects.db")
MAX_RESULTS = 25
MAX_RESULTS = 50
GMAPS_POLL_SECONDS = float(os.environ.get("GMAPS_POLL_SECONDS", "2"))
GMAPS_REQUEST_TIMEOUT = int(os.environ.get("GMAPS_REQUEST_TIMEOUT", "300"))
GMAPS_URL = os.environ.get("GMAPS_URL", "").strip().rstrip("/")
GMAPS_API_KEY = os.environ.get("GMAPS_API_KEY", "").strip()
ALLOWED_REVIEW = {"pending", "approved", "rejected", "contacted"}
_discover_lock = threading.Lock()
@@ -30,21 +37,43 @@ def now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def backend_mode() -> str:
configured = os.environ.get("DISCOVERY_BACKEND", "").strip().lower()
if configured in {"gmaps", "browser"}:
return configured
return "gmaps" if os.environ.get("GMAPS_URL", "").strip() else "browser"
def db() -> sqlite3.Connection:
os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout=30000")
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,
website TEXT NOT NULL DEFAULT '',
domain TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
query TEXT NOT NULL,
query TEXT NOT NULL DEFAULT '',
discovered_at TEXT NOT NULL,
review_status TEXT NOT NULL DEFAULT 'pending',
review_notes TEXT NOT NULL DEFAULT '',
UNIQUE(domain)
source TEXT NOT NULL DEFAULT 'google_maps',
source_id TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
emails TEXT NOT NULL DEFAULT '',
rating REAL,
review_count INTEGER NOT NULL DEFAULT 0,
maps_url TEXT NOT NULL DEFAULT '',
latitude REAL,
longitude REAL,
listing_status TEXT NOT NULL DEFAULT '',
website_status TEXT NOT NULL DEFAULT 'unknown',
opportunity_score INTEGER NOT NULL DEFAULT 0,
raw_json TEXT NOT NULL DEFAULT '{}'
)""")
conn.execute("""CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -55,8 +84,60 @@ def db() -> sqlite3.Connection:
added INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'google_maps',
external_job_id TEXT NOT NULL DEFAULT '',
stage TEXT NOT NULL DEFAULT 'queued',
progress INTEGER NOT NULL DEFAULT 0,
completed_at TEXT NOT NULL DEFAULT '',
request_json TEXT NOT NULL DEFAULT '{}'
)""")
# Migrate databases created by v1-v5 without destroying the user's
# catalogue. SQLite permits adding nullable/defaulted columns in place.
migrations = {
"source": "TEXT NOT NULL DEFAULT 'google_maps'",
"source_id": "TEXT NOT NULL DEFAULT ''",
"category": "TEXT NOT NULL DEFAULT ''",
"address": "TEXT NOT NULL DEFAULT ''",
"phone": "TEXT NOT NULL DEFAULT ''",
"emails": "TEXT NOT NULL DEFAULT ''",
"rating": "REAL",
"review_count": "INTEGER NOT NULL DEFAULT 0",
"maps_url": "TEXT NOT NULL DEFAULT ''",
"latitude": "REAL",
"longitude": "REAL",
"listing_status": "TEXT NOT NULL DEFAULT ''",
"website_status": "TEXT NOT NULL DEFAULT 'unknown'",
"opportunity_score": "INTEGER NOT NULL DEFAULT 0",
"raw_json": "TEXT NOT NULL DEFAULT '{}'",
}
existing = {row[1] for row in conn.execute("PRAGMA table_info(prospects)")}
for name, ddl in migrations.items():
if name not in existing:
conn.execute(f"ALTER TABLE prospects ADD COLUMN {name} {ddl}")
run_migrations = {
"source": "TEXT NOT NULL DEFAULT 'google_maps'",
"external_job_id": "TEXT NOT NULL DEFAULT ''",
"stage": "TEXT NOT NULL DEFAULT 'queued'",
"progress": "INTEGER NOT NULL DEFAULT 0",
"completed_at": "TEXT NOT NULL DEFAULT ''",
"request_json": "TEXT NOT NULL DEFAULT '{}'",
}
existing_runs = {row[1] for row in conn.execute("PRAGMA table_info(runs)")}
for name, ddl in run_migrations.items():
if name not in existing_runs:
conn.execute(f"ALTER TABLE runs ADD COLUMN {name} {ddl}")
conn.execute("CREATE INDEX IF NOT EXISTS idx_prospects_review ON prospects(review_status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_prospects_score ON prospects(opportunity_score DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(id DESC)")
# A Maps scrape can legitimately take several minutes. Only recover jobs
# that have been abandoned for half an hour, never after 35 seconds.
conn.execute("""UPDATE runs SET status='failed', stage='failed',
error=CASE WHEN error='' THEN 'stale_run_recovered' ELSE error END,
completed_at=CASE WHEN completed_at='' THEN ? ELSE completed_at END
WHERE status IN ('queued','running')
AND julianday(created_at) < julianday('now','-30 minutes')""", (now(),))
conn.commit()
return conn
@@ -73,6 +154,208 @@ def clean_domain(url: str) -> str:
return host
def normalize_url(value: Any) -> str:
text = str(value or "").strip()
if not text:
return ""
if text.startswith("//"):
text = "https:" + text
if not text.startswith(("http://", "https://")):
return ""
return text
def as_text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, (list, tuple, set)):
return ", ".join(as_text(item) for item in value if as_text(item))
if isinstance(value, dict):
return ", ".join(as_text(item) for item in value.values() if as_text(item))
return " ".join(str(value).split()).strip()
def as_float(value: Any) -> float | None:
try:
if value in (None, ""):
return None
return float(value)
except (TypeError, ValueError):
return None
def as_int(value: Any) -> int:
try:
if value in (None, ""):
return 0
return max(0, int(float(str(value).replace(",", ""))))
except (TypeError, ValueError):
return 0
def score_place(item: dict[str, Any], website: str, phone: str, rating: float | None, review_count: int) -> int:
"""Small transparent triage score; it is not an AI verdict."""
score = 20
if not website:
score += 35
if phone:
score += 10
if rating is not None and rating >= 4:
score += 10
if review_count >= 10:
score += 10
if as_text(item.get("category") or item.get("type")):
score += 5
return min(100, score)
def map_gmaps_result(item: dict[str, Any], query: str) -> dict[str, Any]:
"""Normalize gosom/google-maps-scraper's place object to our catalogue."""
name = as_text(item.get("title") or item.get("name") or item.get("business_name")) or "Unnamed place"
website = normalize_url(item.get("website") or item.get("website_url"))
maps_url = normalize_url(item.get("link") or item.get("maps_url") or item.get("google_maps_url"))
address = as_text(item.get("complete_address") or item.get("address"))
phone = as_text(item.get("phone") or item.get("phone_number"))
category = as_text(item.get("category") or item.get("type") or item.get("categories"))
description = as_text(item.get("descriptions") or item.get("description") or category)
emails = as_text(item.get("emails") or item.get("email"))
rating = as_float(item.get("review_rating") if item.get("review_rating") is not None else item.get("rating"))
review_count = as_int(item.get("review_count") if item.get("review_count") is not None else item.get("reviews"))
source_id = as_text(item.get("place_id") or item.get("data_id") or item.get("cid") or maps_url)
if not source_id:
source_id = hashlib.sha1((name + "|" + address).encode("utf-8")).hexdigest()[:20]
domain = clean_domain(website) if website else "maps:" + source_id
latitude = as_float(item.get("latitude"))
longitude = as_float(item.get("longitude"))
return {
"name": name[:200],
"website": website,
"domain": domain[:255],
"description": description[:1000],
"query": query,
"source": "google_maps",
"source_id": source_id[:255],
"category": category[:255],
"address": address[:1000],
"phone": phone[:120],
"emails": emails[:500],
"rating": rating,
"review_count": review_count,
"maps_url": maps_url,
"latitude": latitude,
"longitude": longitude,
"listing_status": as_text(item.get("status"))[:120],
"website_status": "present" if website else "missing",
"opportunity_score": score_place(item, website, phone, rating, review_count),
"raw_json": json.dumps(item, ensure_ascii=False)[:100_000],
}
def _looks_like_place(value: dict[str, Any]) -> bool:
keys = {str(key).lower() for key in value}
return bool(keys.intersection({"title", "name", "business_name"})) and bool(
keys.intersection({"address", "complete_address", "website", "phone", "place_id", "link"})
)
def extract_gmaps_results(raw: Any) -> list[dict[str, Any]]:
"""Handle both current API payloads and older nested result formats."""
output: list[dict[str, Any]] = []
seen: set[str] = set()
def visit(value: Any) -> None:
if isinstance(value, list):
for item in value:
visit(item)
elif isinstance(value, dict):
if _looks_like_place(value):
marker = as_text(value.get("place_id") or value.get("data_id") or value.get("cid") or value.get("link") or value.get("title") or value.get("name"))
if marker and marker not in seen:
seen.add(marker)
output.append(value)
return
for key in ("results", "data", "items", "places", "result"):
if key in value:
visit(value[key])
if not any(key in value for key in ("results", "data", "items", "places", "result")):
for child in value.values():
if isinstance(child, (list, dict)):
visit(child)
visit(raw)
return output
def http_json(method: str, url: str, payload: Any = None, headers: dict[str, str] | None = None, timeout: float = 20) -> Any:
data = None if payload is None else json.dumps(payload).encode("utf-8")
request_headers = {"Accept": "application/json", **(headers or {})}
if data is not None:
request_headers["Content-Type"] = "application/json"
request = urllib.request.Request(url, data=data, headers=request_headers, method=method.upper())
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
body = response.read()
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:300]
raise RuntimeError(f"gmaps_http_{exc.code}:{detail}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"gmaps_unreachable:{exc.reason}") from exc
try:
return json.loads(body or b"{}")
except json.JSONDecodeError as exc:
raise RuntimeError("gmaps_invalid_json") from exc
def gmaps_headers() -> dict[str, str]:
if not GMAPS_API_KEY:
raise RuntimeError("gmaps_api_key_missing")
return {"X-API-Key": GMAPS_API_KEY}
def submit_gmaps_job(query: str, include_email: bool = False, extra_reviews: bool = False) -> str:
if not GMAPS_URL:
raise RuntimeError("gmaps_not_configured")
payload = {
"keyword": query,
"lang": os.environ.get("GMAPS_LANG", "en"),
"max_depth": max(1, min(5, int(os.environ.get("GMAPS_MAX_DEPTH", "1")))),
"email": bool(include_email),
"extra_reviews": bool(extra_reviews),
"timeout": max(1, min(300, GMAPS_REQUEST_TIMEOUT)),
}
response = http_json("POST", GMAPS_URL + "/api/v1/scrape", payload, gmaps_headers(), timeout=30)
job_id = response.get("job_id") or response.get("id")
if not job_id:
raise RuntimeError("gmaps_job_id_missing")
return str(job_id)
def poll_gmaps_job(job_id: str, progress: Callable[[str, int, int], None] | None = None) -> list[dict[str, Any]]:
if not GMAPS_URL:
raise RuntimeError("gmaps_not_configured")
deadline = time.monotonic() + max(60, GMAPS_REQUEST_TIMEOUT + 60)
terminal_ok = {"completed", "succeeded", "success"}
terminal_bad = {"failed", "cancelled", "canceled", "discarded"}
while time.monotonic() < deadline:
response = http_json("GET", GMAPS_URL + "/api/v1/jobs/" + quote_plus(str(job_id)), headers=gmaps_headers(), timeout=30)
status = str(response.get("status") or "pending").lower()
result_count = as_int(response.get("result_count"))
if progress:
progress(status, result_count, 0)
if status in terminal_ok:
return extract_gmaps_results(response.get("results") if "results" in response else response)
if status in terminal_bad:
raise RuntimeError("gmaps_job_" + status + (":" + as_text(response.get("error")) if response.get("error") else ""))
time.sleep(max(0.5, GMAPS_POLL_SECONDS))
raise RuntimeError("gmaps_job_timeout")
# The browser fallback is bounded and does not bypass challenge pages. It is
# retained for local testing or when the user deliberately sets backend=browser.
SEARCH_DEADLINE_SECONDS = 25
ENGINE_TIMEOUT_MS = 7000
def blocked(text: str) -> bool:
lowered = text.lower()
return any(marker in lowered for marker in (
@@ -82,12 +365,6 @@ def blocked(text: str) -> bool:
def normalize_result_href(raw_href: str) -> str:
"""Turn a search-result href into the public destination URL.
Engines commonly use protocol-relative links or redirect wrappers (for
example DuckDuckGo's ``uddg`` parameter). We unwrap those links before
applying the catalog/domain filters.
"""
href = (raw_href or "").strip()
if href.startswith("//"):
href = "https:" + href
@@ -105,9 +382,8 @@ def normalize_result_href(raw_href: str) -> str:
def extract_search_results(page: Any, selector: str, title_selector: str | None,
excluded_hosts: set[str], requested: int) -> list[dict[str, str]]:
"""Extract external result links, with a generic-anchor safety net."""
output: list[dict[str, str]] = []
excluded_hosts: set[str], requested: int) -> list[dict[str, Any]]:
output: list[dict[str, Any]] = []
seen_domains: set[str] = set()
def add_anchor(anchor: Any) -> None:
@@ -127,10 +403,8 @@ def extract_search_results(page: Any, selector: str, title_selector: str | None,
if not domain or domain in excluded_hosts or domain in seen_domains:
return
seen_domains.add(domain)
output.append({"name": title, "website": href, "domain": domain})
output.append({"title": title, "website": href, "name": title})
# Prefer stable engine-specific result selectors to avoid collecting nav
# and footer links. If markup changed, fall back to generic anchors.
try:
anchors = page.locator(selector)
for i in range(min(anchors.count(), requested * 4)):
@@ -149,13 +423,7 @@ def extract_search_results(page: Any, selector: str, title_selector: str | None,
return output
def google_search(query: str, requested: int) -> list[dict[str, str]]:
"""Search public engines through Playwright, with safe engine fallback.
Google frequently challenges datacenter IPs. We do not bypass that
challenge; we close the context and try other public engines, still using
a real headless browser and the same bounded result extraction.
"""
def google_search(query: str, requested: int) -> list[dict[str, Any]]:
try:
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
except ImportError as exc:
@@ -165,64 +433,58 @@ def google_search(query: str, requested: int) -> list[dict[str, str]]:
if executable:
options["executable_path"] = executable
engines = [
("google", "https://www.google.com/search?q=" + quote_plus(query) + "&num=" + str(requested), "a:has(h3)", "h3", {"google.com", "google.co.za"}),
("bing", "https://www.bing.com/search?q=" + quote_plus(query) + "&count=" + str(requested), "li.b_algo h2 a", "h2", {"bing.com", "bingj.com"}),
("duckduckgo", "https://html.duckduckgo.com/html/?q=" + quote_plus(query), "a.result__a,a[data-testid='result-title-a']", None, {"duckduckgo.com"}),
("brave", "https://search.brave.com/search?q=" + quote_plus(query), "a.heading-serpresult,a.result-header", None, {"search.brave.com"}),
("startpage", "https://www.startpage.com/sp/search?query=" + quote_plus(query), "a.w-gl__result-title", None, {"startpage.com"}),
("duckduckgo", "https://html.duckduckgo.com/html/?q=" + quote_plus(query), "a.result__a,a[data-testid='result-title-a']", None, {"duckduckgo.com"}),
("bing", "https://www.bing.com/search?q=" + quote_plus(query) + "&count=" + str(requested), "li.b_algo h2 a", "h2", {"bing.com", "bingj.com"}),
("google", "https://www.google.com/search?q=" + quote_plus(query) + "&num=" + str(requested), "a:has(h3)", "h3", {"google.com", "google.co.za"}),
]
errors: list[str] = []
deadline = time.monotonic() + SEARCH_DEADLINE_SECONDS
with sync_playwright() as p:
browser = p.chromium.launch(**options)
try:
for engine, url, selector, title_selector, excluded_hosts in engines:
context = None
try:
context = browser.new_context(
locale="en-ZA",
java_script_enabled=True,
service_workers="block",
viewport={"width": 1280, "height": 900},
user_agent=(
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
),
)
page = context.new_page()
page.set_default_timeout(12000)
navigation_timed_out = False
# DOMContentLoaded gives us usable markup without waiting for
# analytics, ads, or other background requests. If an engine
# keeps navigation open, still inspect the partially loaded
# page rather than discarding it as a hard failure.
for engine, url, selector, title_selector, excluded_hosts in engines:
remaining = deadline - time.monotonic()
if remaining <= 0:
errors.append("search_deadline_exceeded")
break
context = None
try:
page.goto(url, wait_until="domcontentloaded", timeout=15000)
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(ENGINE_TIMEOUT_MS)
navigation_timed_out = False
try:
page.goto(url, wait_until="domcontentloaded", timeout=min(ENGINE_TIMEOUT_MS, max(1000, int(remaining * 1000))))
except PlaywrightTimeout:
navigation_timed_out = True
page.wait_for_timeout(min(500, max(0, int((deadline - time.monotonic()) * 1000))))
body = page.locator("body").inner_text()
html = page.content()
if blocked(body) or blocked(html):
errors.append(engine + "_blocked")
continue
output = extract_search_results(page, selector, title_selector, excluded_hosts, requested)
if output:
return output
errors.append(engine + ("_timeout" if navigation_timed_out else "_empty"))
except PlaywrightTimeout:
navigation_timed_out = True
page.wait_for_timeout(1000)
body = page.locator("body").inner_text()
html = page.content()
if blocked(body) or blocked(html):
errors.append(engine + "_blocked")
continue
output = extract_search_results(page, selector, title_selector, excluded_hosts, requested)
if output:
return output
errors.append(engine + ("_timeout" if navigation_timed_out else "_empty"))
except PlaywrightTimeout:
errors.append(engine + "_timeout")
except Exception as exc:
errors.append(engine + "_error")
finally:
if context is not None:
try: context.close()
except Exception: pass
errors.append(engine + "_timeout")
except Exception:
errors.append(engine + "_error")
finally:
if context is not None:
try:
context.close()
except Exception:
pass
finally:
browser.close()
raise RuntimeError("search_engines_unavailable:" + ",".join(errors))
def run_discovery(payload: dict[str, Any]) -> dict[str, Any]:
def validate_payload(payload: dict[str, Any]) -> tuple[str, str, str, int, bool, bool]:
category = str(payload.get("category", "")).strip()
keywords = str(payload.get("keywords", "")).strip()
location = str(payload.get("location", "South Africa")).strip() or "South Africa"
@@ -233,34 +495,102 @@ def run_discovery(payload: dict[str, Any]) -> dict[str, Any]:
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
include_email = bool(payload.get("include_email", False))
extra_reviews = bool(payload.get("extra_reviews", False))
return category, query, location, requested, include_email, extra_reviews
def update_run(run_id: int, **fields: Any) -> None:
if not fields:
return
allowed = {"found", "added", "status", "error", "source", "external_job_id", "stage", "progress", "completed_at"}
safe = {key: value for key, value in fields.items() if key in allowed}
if not safe:
return
assignments = ", ".join(f"{key}=?" for key in safe)
conn = db()
try:
conn.execute(f"UPDATE runs SET {assignments} WHERE id=?", (*safe.values(), run_id))
conn.commit()
finally:
conn.close()
def insert_catalogue(run_id: int, query: str, raw_results: list[dict[str, Any]], requested: int) -> tuple[list[dict[str, Any]], int]:
mapped = [map_gmaps_result(item, query) for item in raw_results[:requested]]
conn = db()
added = 0
try:
for item in mapped:
cur = conn.execute("""INSERT OR IGNORE INTO prospects(
name,website,domain,description,query,discovered_at,source,source_id,
category,address,phone,emails,rating,review_count,maps_url,latitude,
longitude,listing_status,website_status,opportunity_score,raw_json
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (
item["name"], item["website"], item["domain"], item["description"], item["query"], now(),
item["source"], item["source_id"], item["category"], item["address"], item["phone"], item["emails"],
item["rating"], item["review_count"], item["maps_url"], item["latitude"], item["longitude"],
item["listing_status"], item["website_status"], item["opportunity_score"], item["raw_json"],
))
if cur.rowcount:
added += 1
conn.commit()
finally:
conn.close()
return mapped, added
def run_discovery(run_id: int, payload: dict[str, Any]) -> None:
category, query, location, requested, include_email, extra_reviews = validate_payload(payload)
# Serialize local runs so two personal browser jobs do not fight over the
# same machine's Chromium/RAM. The Maps worker itself remains async.
with _discover_lock:
update_run(run_id, status="running", stage="connecting", progress=5, source=backend_mode())
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}
if backend_mode() == "gmaps":
external_job_id = submit_gmaps_job(query, include_email, extra_reviews)
update_run(run_id, external_job_id=external_job_id, stage="scraping", progress=10)
def on_progress(status: str, found: int, _unused: int) -> None:
progress = 15 if status == "pending" else 45 if status == "running" else 75
update_run(run_id, stage="scraping", progress=progress, found=found)
raw_results = poll_gmaps_job(external_job_id, on_progress)
else:
update_run(run_id, stage="browser_search", progress=20)
raw_results = google_search(query, requested)
results, added = insert_catalogue(run_id, query, raw_results, requested)
update_run(run_id, found=len(results), added=added, status="succeeded", stage="complete", progress=100, completed_at=now(), error="")
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()
message = str(exc).strip()[:500] or "discovery_failed"
update_run(run_id, status="failed", stage="failed", progress=100, error=message, completed_at=now())
def create_discovery(payload: dict[str, Any]) -> dict[str, Any]:
category, query, location, requested, include_email, extra_reviews = validate_payload(payload)
request_json = json.dumps({
"category": category,
"keywords": str(payload.get("keywords", "")).strip(),
"location": location,
"max_results": requested,
"include_email": include_email,
"extra_reviews": extra_reviews,
}, ensure_ascii=False)
conn = db()
try:
cur = conn.execute("""INSERT INTO runs(query,location,requested,status,created_at,source,stage,request_json)
VALUES(?,?,?,?,?,?,?,?)""", (query, location, requested, "queued", now(), backend_mode(), "queued", request_json))
run_id = int(cur.lastrowid)
conn.commit()
finally:
conn.close()
worker = threading.Thread(target=run_discovery, args=(run_id, payload), name=f"prospect-run-{run_id}", daemon=True)
worker.start()
return {"id": run_id, "status": "queued", "source": backend_mode(), "query": query, "requested": requested}
class Handler(BaseHTTPRequestHandler):
server_version = "BrowserProspectOS/1.0"
server_version = "PersonalProspectOS/2.0"
def send_json(self, status: int, value: Any) -> None:
body = json_bytes(value)
@@ -286,22 +616,46 @@ class Handler(BaseHTTPRequestHandler):
conn = db()
try:
if parsed.path == "/api/health":
return self.send_json(200, {"status": "ok", "source": "google_playwright"})
configured = bool(GMAPS_URL and GMAPS_API_KEY) if backend_mode() == "gmaps" else True
return self.send_json(200, {"status": "ok", "source": backend_mode(), "configured": configured, "maps_service": bool(GMAPS_URL), "maps_api_key": bool(GMAPS_API_KEY)})
if parsed.path == "/api/config":
return self.send_json(200, {
"backend": backend_mode(),
"gmaps_url": bool(GMAPS_URL),
"gmaps_api_key": bool(GMAPS_API_KEY),
"ready": backend_mode() == "browser" or bool(GMAPS_URL and GMAPS_API_KEY),
"email_extraction_default": False,
})
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)]
rows = conn.execute("SELECT * FROM prospects ORDER BY opportunity_score DESC, id DESC").fetchall()
items = []
for row in rows:
haystack = " ".join(str(row[key] or "") for key in ("name", "domain", "description", "address", "category", "phone", "emails")).lower()
if q and q not in haystack:
continue
if status and row["review_status"] != status:
continue
items.append(dict(row))
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})
match = re.fullmatch(r"/api/runs/(\d+)", parsed.path)
if match:
row = conn.execute("SELECT * FROM runs WHERE id=?", (int(match.group(1)),)).fetchone()
if not row:
return self.send_json(404, {"error": "not_found"})
return self.send_json(200, dict(row))
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})
contacted = conn.execute("SELECT COUNT(*) FROM prospects WHERE review_status='contacted'").fetchone()[0]
running = conn.execute("SELECT COUNT(*) FROM runs WHERE status IN ('queued','running')").fetchone()[0]
return self.send_json(200, {"total": total, "pending": pending, "approved": approved, "contacted": contacted, "running": running})
return self.send_json(404, {"error": "not_found"})
finally:
conn.close()
@@ -332,17 +686,18 @@ class Handler(BaseHTTPRequestHandler):
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)
result = create_discovery(self.read_json())
return self.send_json(202, 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)})
except (TypeError, json.JSONDecodeError) as exc:
return self.send_json(400, {"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"})
conn: sqlite3.Connection | None = None
try:
payload = self.read_json()
status = str(payload.get("status", "")).strip().lower()
@@ -358,15 +713,15 @@ class Handler(BaseHTTPRequestHandler):
except (ValueError, TypeError, json.JSONDecodeError) as exc:
return self.send_json(400, {"error": str(exc)})
finally:
try: conn.close()
except Exception: pass
if conn is not None:
conn.close()
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)
print(f"Personal ProspectOS listening on http://{host}:{port} (backend={backend_mode()})", flush=True)
ThreadingHTTPServer((host, port), Handler).serve_forever()
+18
View File
@@ -1,4 +1,13 @@
services:
gmaps:
image: gosom/google-maps-scraper:latest
command: ["-web", "-addr", ":8080", "-data-folder", "/gmapsdata"]
ports:
- "${GMAPS_HOST_PORT:-8081}:8080"
volumes:
- gmaps_data:/gmapsdata
restart: unless-stopped
app:
build:
context: .
@@ -9,8 +18,16 @@ services:
environment:
PROSPECT_DB: /data/prospects.db
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH: /usr/bin/chromium
DISCOVERY_BACKEND: ${DISCOVERY_BACKEND:-gmaps}
GMAPS_URL: ${GMAPS_URL:-http://gmaps:8080}
GMAPS_API_KEY: ${GMAPS_API_KEY:-}
GMAPS_REQUEST_TIMEOUT: ${GMAPS_REQUEST_TIMEOUT:-300}
GMAPS_POLL_SECONDS: ${GMAPS_POLL_SECONDS:-2}
volumes:
- prospect_data:/data
depends_on:
gmaps:
condition: service_started
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=2)"]
@@ -20,3 +37,4 @@ services:
volumes:
prospect_data:
gmaps_data:
+27 -3
View File
@@ -2,6 +2,7 @@ import json
import os
import tempfile
import threading
import time
import unittest
from http.client import HTTPConnection
from unittest.mock import patch
@@ -30,22 +31,45 @@ class BrowserOnlyAppTests(unittest.TestCase):
response = self.conn.getresponse()
return response.status, json.loads(response.read() or b"{}")
def wait_for_run(self, run_id):
for _ in range(50):
status, body = self.request("GET", f"/api/runs/{run_id}")
self.assertEqual(status, 200)
if body["status"] not in {"queued", "running"}:
return body
time.sleep(0.01)
self.fail("run did not finish")
def test_search_redirect_links_are_unwrapped(self):
wrapped = "https://duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.example.co.za%2Fservices"
self.assertEqual(server.normalize_result_href(wrapped), "https://www.example.co.za/services")
self.assertEqual(server.normalize_result_href("//example.co.za/about"), "https://example.co.za/about")
@patch.dict(os.environ, {"DISCOVERY_BACKEND": "browser"})
@patch("api.server.google_search", return_value=[{"name": "Acme Plumbing", "website": "https://www.acme.co.za", "domain": "acme.co.za"}, {"name": "Other", "website": "https://other.co.za", "domain": "other.co.za"}])
def test_discovery_filters_catalogued_root_domains(self, search):
status, first = self.request("POST", "/api/discover", {"category": "plumbers", "keywords": "plumbing", "location": "Cape Town", "max_results": 5})
self.assertEqual(status, 201); self.assertEqual(first["added"], 2)
self.assertEqual(status, 202)
first = self.wait_for_run(first["id"])
self.assertEqual(first["added"], 2)
status, second = self.request("POST", "/api/discover", {"category": "plumbers", "keywords": "plumbing", "location": "Cape Town", "max_results": 5})
self.assertEqual(status, 201); self.assertEqual(second["found"], 2); self.assertEqual(second["added"], 0)
self.assertEqual(status, 202)
second = self.wait_for_run(second["id"])
self.assertEqual(second["found"], 2); self.assertEqual(second["added"], 0)
self.assertEqual(self.request("GET", "/api/prospects")[1]["total"], 2)
@patch.dict(os.environ, {"DISCOVERY_BACKEND": "browser"})
def test_map_without_website_gets_stable_catalogue_key(self):
mapped = server.map_gmaps_result({"title": "A Local Plumber", "place_id": "abc123", "address": "Cape Town", "phone": "+27 10", "review_rating": 4.5, "review_count": 12}, "plumbers Cape Town")
self.assertEqual(mapped["domain"], "maps:abc123")
self.assertEqual(mapped["website_status"], "missing")
self.assertGreaterEqual(mapped["opportunity_score"], 70)
@patch.dict(os.environ, {"DISCOVERY_BACKEND": "browser"})
def test_review_updates_only_selected_prospect(self):
with patch("api.server.google_search", return_value=[{"name": "Acme", "website": "https://acme.co.za", "domain": "acme.co.za"}]):
self.request("POST", "/api/discover", {"category": "plumbers", "keywords": "plumbing", "location": "South Africa"})
_, run = self.request("POST", "/api/discover", {"category": "plumbers", "keywords": "plumbing", "location": "South Africa"})
self.wait_for_run(run["id"])
status, item = self.request("PATCH", "/api/prospects/1/review", {"status": "approved", "notes": "Looks relevant"})
self.assertEqual(status, 200); self.assertEqual(item["review_status"], "approved")
+152 -10
View File
@@ -1,10 +1,152 @@
const $=id=>document.getElementById(id);let prospects=[];
async function api(path,options={}){const r=await fetch(path,{...options,headers:{'Content-Type':'application/json',...(options.headers||{})}});const b=await r.json().catch(()=>({}));if(!r.ok)throw Error(b.error||'Request failed');return b}
function renderStats(){ $('total').textContent=prospects.length; $('pending').textContent=prospects.filter(x=>x.review_status==='pending').length; $('approved').textContent=prospects.filter(x=>x.review_status==='approved').length; }
function renderExplorer(){const q=$('search').value.trim().toLowerCase(),s=$('status').value;const rows=prospects.filter(x=>(!q||(x.name+' '+x.domain).toLowerCase().includes(q))&&(!s||x.review_status===s));$('explorer').innerHTML=rows.length?rows.map(x=>`<article class="prospect"><div><h3>${esc(x.name)}</h3><a href="${esc(x.website)}" target="_blank" rel="noopener">${esc(x.domain)}</a><div class="meta">Found ${esc(x.discovered_at)} · Query: ${esc(x.query)}</div></div><div class="review"><select data-review="${x.id}"><option value="pending" ${x.review_status==='pending'?'selected':''}>Pending</option><option value="approved" ${x.review_status==='approved'?'selected':''}>Approved</option><option value="rejected" ${x.review_status==='rejected'?'selected':''}>Rejected</option><option value="contacted" ${x.review_status==='contacted'?'selected':''}>Contacted</option></select></div></article>`).join(''):'<p class="muted">No prospects match this filter.</p>';renderStats();}
function esc(v){return String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
async function loadProspects(){prospects=(await api('/api/prospects')).items||[];renderExplorer()}
async function loadStats(){const s=await api('/api/stats');$('total').textContent=s.total;$('pending').textContent=s.pending;$('approved').textContent=s.approved}
async function loadRuns(){const rows=(await api('/api/runs')).items||[];$('runs').innerHTML=rows.length?rows.map(x=>`<div class="run"><span>#${x.id} · ${esc(x.query)}</span><span class="${x.status==='succeeded'?'ok':'bad'}">${x.status} · ${x.added} added${x.error?' · '+esc(x.error):''}</span></div>`).join(''):'<p class="muted">No discovery runs yet.</p>';if(rows[0])$('lastRun').textContent='#'+rows[0].id}
$('discoverForm').addEventListener('submit',async e=>{e.preventDefault();const btn=$('discoverBtn'),msg=$('discoverMessage');btn.disabled=true;msg.textContent='Opening headless browser…';try{const result=await api('/api/discover',{method:'POST',body:JSON.stringify(Object.fromEntries(new FormData(e.target)))});msg.textContent=`Found ${result.found}; added ${result.added} new prospect${result.added===1?'':'s'}.`;await Promise.all([loadProspects(),loadRuns(),loadStats()])}catch(err){msg.textContent='Discovery failed: '+err.message;msg.className='bad'}finally{btn.disabled=false}});
$('explorer').addEventListener('change',async e=>{const id=e.target.dataset.review;if(!id)return;try{await api('/api/prospects/'+id+'/review',{method:'PATCH',body:JSON.stringify({status:e.target.value})});await Promise.all([loadProspects(),loadStats()])}catch(err){alert(err.message)}});$('search').addEventListener('input',renderExplorer);$('status').addEventListener('change',renderExplorer);$('refresh').addEventListener('click',loadProspects);$('runsRefresh').addEventListener('click',loadRuns);Promise.all([loadProspects(),loadRuns(),loadStats()]).catch(err=>{$('explorer').innerHTML='<p class="bad">Unable to load the local catalogue: '+esc(err.message)+'</p>'});
const $ = (id) => document.getElementById(id);
let prospects = [];
let runs = [];
let activePoll = 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 website found</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>${item.website_status === "missing" ? '<span class="chip warning">Website missing</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}` : ""}</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 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" : "Add your API key to continue";
$("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_api_key) $("setupText").textContent = "Create an API key in the Maps worker admin screen, add it as GMAPS_API_KEY, then restart the stack.";
}
async function refreshAll() {
$("updated").textContent = "Refreshing…";
try { await Promise.all([loadConfig(), loadProspects(), loadRuns(), loadStats()]); $("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: $("include-email").checked, 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); }
});
$("search").addEventListener("input", renderExplorer);
$("status").addEventListener("change", renderExplorer);
$("refresh").addEventListener("click", loadProspects);
$("runsRefresh").addEventListener("click", loadRuns);
$("refreshAll").addEventListener("click", refreshAll);
$("showRuns").addEventListener("click", (event) => { event.preventDefault(); $("runs").scrollIntoView({ behavior: "smooth", block: "center" }); });
refreshAll().catch((error) => { $("explorerList").innerHTML = `<div class="empty-state"><strong>Unable to load workspace</strong><p>${esc(error.message)}</p></div>`; });
+81 -8
View File
@@ -1,9 +1,82 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ProspectOS · Browser Discovery</title><link rel="stylesheet" href="/style.css"></head>
<body><header class="top"><div><span class="eyebrow">PROSPECTOS</span><h1>Browser discovery</h1><p>Find businesses through public search engines, remove anything already catalogued, then review the remaining prospects.</p></div><div class="source-pill">● Browser search · Playwright</div></header>
<main>
<section class="grid stats"><article><span>Catalogued prospects</span><strong id="total">0</strong></article><article><span>Pending review</span><strong id="pending">0</strong></article><article><span>Approved</span><strong id="approved">0</strong></article><article><span>Last run</span><strong id="lastRun">—</strong></article></section>
<section class="panel"><div class="section-head"><div><span class="eyebrow">DISCOVERY</span><h2>Find new prospects</h2><p>Search criteria are used only for this run. The system stores the business name, website, query, and discovery time.</p></div></div><form id="discoverForm" class="form-grid"><label>Category<input name="category" placeholder="Plumbers" required></label><label>Keywords<input name="keywords" placeholder="plumbing services" required></label><label>Location<input name="location" value="South Africa" required></label><label>Maximum results<select name="max_results"><option value="5">5</option><option value="10" selected>10</option><option value="25">25</option></select></label><div class="form-actions"><button class="primary" type="submit" id="discoverBtn">Run browser discovery</button><span id="discoverMessage" role="status"></span></div></form></section>
<section class="panel"><div class="section-head row"><div><span class="eyebrow">CATALOGUE</span><h2>Prospect explorer</h2><p>Duplicates are filtered by root domain before they enter the catalogue.</p></div><div class="filters"><input id="search" placeholder="Search name or domain"><select id="status"><option value="">All review states</option><option value="pending">Pending</option><option value="approved">Approved</option><option value="rejected">Rejected</option><option value="contacted">Contacted</option></select><button class="secondary" id="refresh" type="button">Refresh</button></div></div><div id="explorer" class="explorer"><p class="muted">Loading prospects…</p></div></section>
<section class="panel"><div class="section-head row"><div><span class="eyebrow">RUN HISTORY</span><h2>Discovery runs</h2></div><button class="secondary" id="runsRefresh" type="button">Refresh</button></div><div id="runs" class="runs"><p class="muted">Loading runs…</p></div></section>
</main><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="#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">Add the scraper API key to your environment, then restart the stack. The rest of the workspace is ready.</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"><input type="checkbox" id="include-email" name="include_email"><span class="check-box"></span><span><strong>Look for public website emails</strong><small>Slower; only follows the business website found by Maps.</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="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 Maps worker runs locally. Create an API key in its admin screen, place it in <code>GMAPS_API_KEY</code>, and restart Docker.</p></div><ol><li>Open the Maps worker on port <code>8081</code>.</li><li>Sign in to its admin area and create one API key.</li><li>Copy the key into your <code>.env</code> file, then run <code>docker compose up -d --build</code>.</li></ol>
</section>
<footer class="footer"><span>ProspectOS · personal research workspace</span><span>Public data only · no automated outreach</span></footer>
</main>
</div>
<script src="/app.js"></script>
</body>
</html>
+102 -1
View File
@@ -1 +1,102 @@
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;color:#172033;background:#f5f7fb;line-height:1.45}*{box-sizing:border-box}body{margin:0}.top{background:#151b31;color:#fff;padding:36px max(24px,calc((100% - 1120px)/2));display:flex;justify-content:space-between;gap:24px;align-items:flex-start}.top h1{font-size:32px;margin:4px 0 6px}.top p{margin:0;color:#bfc8e0}.eyebrow{font-size:11px;letter-spacing:.16em;font-weight:800;color:#685cff}.top .eyebrow{color:#9c93ff}.source-pill{border:1px solid #495273;border-radius:999px;padding:9px 14px;color:#d9dded;font-size:13px;white-space:nowrap}main{max-width:1120px;margin:26px auto;padding:0 24px}.grid{display:grid;gap:14px}.stats{grid-template-columns:repeat(4,1fr);margin-bottom:18px}.stats article,.panel{background:#fff;border:1px solid #e1e6f0;border-radius:14px;box-shadow:0 8px 24px #1c29400b}.stats article{padding:18px}.stats span{display:block;color:#69758c;font-size:13px}.stats strong{font-size:28px;display:block;margin-top:7px}.panel{padding:24px;margin-bottom:18px}.section-head{margin-bottom:18px}.section-head.row{display:flex;justify-content:space-between;align-items:flex-end;gap:20px}.section-head h2{margin:4px 0;font-size:22px}.section-head p{margin:0;color:#69758c;font-size:14px}.form-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:16px}label{font-weight:700;font-size:13px;color:#33405a}input,select{display:block;width:100%;margin-top:6px;border:1px solid #cdd5e5;border-radius:9px;padding:12px;background:#fff;color:#172033;font:inherit}button{border:0;border-radius:9px;padding:11px 16px;font-weight:800;cursor:pointer}.primary{background:#6257f5;color:#fff}.secondary{background:#eef1f8;color:#33405a}.form-actions{grid-column:1/-1;display:flex;align-items:center;gap:14px}.form-actions span{font-size:13px;color:#52705e}.filters{display:flex;gap:8px;align-items:center}.filters input{width:190px;margin:0}.filters select{width:155px;margin:0}.explorer{display:grid;gap:10px}.prospect{border:1px solid #e3e7ef;border-radius:10px;padding:15px;display:grid;grid-template-columns:1fr auto;gap:10px}.prospect h3{font-size:16px;margin:0}.prospect a{color:#4b46c6;font-size:13px;word-break:break-all}.meta{color:#738098;font-size:12px;margin-top:4px}.review{display:flex;align-items:center;gap:7px}.review select{margin:0;width:auto;padding:7px 9px;font-size:12px}.runs{display:grid;gap:8px}.run{display:flex;justify-content:space-between;border-bottom:1px solid #edf0f5;padding:10px 0;font-size:13px}.run:last-child{border:0}.ok{color:#22754b}.bad{color:#b83346}.muted{color:#7a8598}@media(max-width:720px){.top{display:block}.source-pill{display:inline-block;margin-top:18px}.stats{grid-template-columns:repeat(2,1fr)}.form-grid{grid-template-columns:1fr}.section-head.row{display:block}.filters{margin-top:14px;flex-wrap:wrap}.filters input,.filters select{width:100%}.prospect{grid-template-columns:1fr}.form-actions{align-items:flex-start;flex-direction:column}}
:root {
--ink: #172033;
--muted: #718096;
--line: #e7eaf1;
--soft: #f6f7fb;
--purple: #6657f5;
--purple-dark: #5042d0;
--navy: #10182e;
--green: #18845b;
--amber: #b36c08;
--red: #ba4254;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--ink);
background: var(--soft);
line-height: 1.45;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; background: var(--soft); }
button, input, select { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .62; }
a { color: inherit; text-decoration: none; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 234px minmax(0, 1fr); }
.sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; padding: 27px 16px 20px; background: var(--navy); color: #e9edfb; }
.brand { display: flex; align-items: center; gap: 11px; padding: 0 10px; }
.brand-mark { width: 35px; height: 35px; display: grid; place-items: center; border-radius: 11px; background: linear-gradient(135deg, #8375ff, #5344da); color: #fff; font-size: 19px; font-weight: 900; box-shadow: 0 8px 22px #6657f566; }
.brand strong { display: block; font-size: 15px; letter-spacing: -.01em; }
.brand small { display: block; color: #9da8c9; font-size: 11px; margin-top: 1px; }
.side-nav { display: grid; gap: 5px; margin-top: 55px; }
.side-nav a { display: flex; align-items: center; gap: 12px; padding: 11px 12px; border-radius: 9px; color: #aeb8d4; font-size: 13px; font-weight: 700; transition: .15s ease; }
.side-nav a:hover, .side-nav a.active { color: #fff; background: #252e4c; }
.side-nav a.active { box-shadow: inset 3px 0 var(--purple); }
.nav-icon { width: 18px; color: #8995ba; font-size: 17px; text-align: center; }
.side-nav a.active .nav-icon { color: #a99fff; }
.sidebar-foot { margin-top: auto; padding: 0 6px; }
.source-card { display: flex; align-items: center; gap: 9px; padding: 12px 10px; border: 1px solid #2f395b; border-radius: 11px; background: #1a2340; }
.source-card strong { display: block; color: #edf0fb; font-size: 11px; }
.source-card small { display: block; color: #9ba6c6; font-size: 10px; margin-top: 2px; }
.sidebar-foot p { padding: 0 10px; color: #8e9abd; font-size: 11px; line-height: 1.65; }
.sidebar-foot p span { color: #687594; }
.status-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #4ed495; box-shadow: 0 0 0 4px #4ed49520; }
.status-dot.offline { background: #e4a63f; box-shadow: 0 0 0 4px #e4a63f20; }
.main-content { min-width: 0; padding: 0 42px 40px; }
.topbar { height: 70px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); }
.breadcrumbs { display: flex; gap: 10px; align-items: center; color: #8690a7; font-size: 12px; }
.breadcrumbs i { color: #c5cbd8; font-style: normal; }
.breadcrumbs strong { color: #3c465a; }
.top-actions { display: flex; align-items: center; gap: 14px; }
.updated { color: #9aa4b7; font-size: 11px; }
.icon-btn { width: 33px; height: 33px; border: 1px solid var(--line); border-radius: 9px; background: #fff; color: #58647b; font-size: 18px; }
.page-heading { display: flex; justify-content: space-between; gap: 25px; align-items: flex-start; max-width: 1160px; padding: 37px 0 26px; }
.page-heading h1 { max-width: 670px; margin: 5px 0 8px; font-size: clamp(26px, 3vw, 35px); letter-spacing: -.045em; line-height: 1.12; }
.page-heading p { max-width: 670px; margin: 0; color: var(--muted); font-size: 14px; }
.eyebrow { color: var(--purple); font-size: 10px; letter-spacing: .17em; font-weight: 900; }
.heading-badge { display: grid; grid-template-columns: auto auto; align-items: center; column-gap: 8px; padding: 11px 14px; border: 1px solid #dfe3ef; border-radius: 13px; background: #fff; color: #364158; font-size: 12px; font-weight: 800; white-space: nowrap; }
.heading-badge small { grid-column: 2; color: #96a0b4; font-size: 10px; font-weight: 500; }
.pulse { width: 8px; height: 8px; border-radius: 50%; background: #36b87b; box-shadow: 0 0 0 4px #36b87b1e; }
.setup-notice { display: flex; align-items: center; gap: 13px; margin-bottom: 20px; padding: 14px 17px; border: 1px solid #eadab5; border-radius: 13px; background: #fff9eb; color: #6c4a12; }
.notice-icon { width: 27px; height: 27px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 8px; background: #f1c15d; color: #6c4a12; font-weight: 900; }
.setup-notice strong { font-size: 13px; }
.setup-notice p { margin: 2px 0 0; color: #896c35; font-size: 12px; }
.setup-notice .text-link { margin-left: auto; color: #946411; font-size: 12px; font-weight: 800; }
.stats-grid { max-width: 1160px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 13px; margin-bottom: 17px; }
.stat-card { min-height: 120px; padding: 17px 18px; border: 1px solid var(--line); border-radius: 13px; background: #fff; box-shadow: 0 9px 28px #2b38500a; }
.stat-card.stat-primary { border-color: #d7d2ff; background: linear-gradient(135deg, #fbfaff, #f6f5ff); }
.stat-label { display: flex; align-items: center; gap: 7px; color: #727e94; font-size: 11px; font-weight: 800; }
.stat-icon { color: var(--purple); font-size: 14px; }
.stat-icon.amber { color: #d09324; }.stat-icon.green { color: #35a974; }.stat-icon.blue { color: #5482c4; }
.stat-card strong { display: block; margin-top: 10px; color: #202a40; font-size: 28px; letter-spacing: -.05em; line-height: 1; }
.stat-card small { display: block; margin-top: 6px; color: #9aa4b7; font-size: 10px; }
.workspace-grid { max-width: 1160px; display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(300px, .85fr); gap: 17px; align-items: start; }
.panel { border: 1px solid var(--line); border-radius: 15px; background: #fff; box-shadow: 0 10px 32px #2b38500b; }
.discovery-panel { padding: 24px; }.run-panel { padding: 22px; }
.panel-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; margin-bottom: 21px; }
.panel-heading.compact { align-items: center; margin-bottom: 17px; }
.panel-heading h2 { margin: 4px 0 5px; color: #202b40; font-size: 20px; letter-spacing: -.035em; }.panel-heading p { margin: 0; color: #8792a6; font-size: 12px; }
.source-tag { display: inline-flex; align-items: center; gap: 7px; padding: 7px 9px; border: 1px solid #e4e1ff; border-radius: 999px; color: #655ad3; font-size: 10px; font-weight: 800; white-space: nowrap; }.source-tag .status-dot { width: 5px; height: 5px; box-shadow: none; }
.discovery-form { display: grid; grid-template-columns: 1.15fr 1fr 1fr; gap: 16px 13px; }.field.wide, .options.wide, .form-footer.wide { grid-column: 1 / -1; }
.field label { display: block; color: #4e5a71; font-size: 11px; font-weight: 800; }.field label span { margin-left: 5px; color: #a0a9b9; font-weight: 500; }
input, select { width: 100%; height: 42px; margin-top: 6px; padding: 0 12px; border: 1px solid #dbe0e9; border-radius: 9px; outline: none; background: #fff; color: #2c374c; font-size: 12px; transition: .15s ease; }
input::placeholder { color: #aeb6c5; } input:focus, select:focus { border-color: #9b91f7; box-shadow: 0 0 0 3px #6657f514; }
.options { display: flex; flex-wrap: wrap; gap: 20px 25px; padding-top: 1px; }.check { display: flex; gap: 9px; align-items: flex-start; color: #38445a; cursor: pointer; }.check input { position: absolute; opacity: 0; width: 1px; height: 1px; }.check-box { width: 17px; height: 17px; flex: 0 0 auto; margin-top: 1px; border: 1px solid #cbd3e2; border-radius: 5px; background: #fff; }.check input:checked + .check-box { border-color: var(--purple); background: var(--purple); box-shadow: inset 0 0 0 3px #fff; }.check strong { display: block; font-size: 11px; }.check small { display: block; margin-top: 2px; color: #98a2b4; font-size: 10px; }
.form-footer { display: flex; align-items: center; gap: 13px; padding-top: 4px; }.primary-btn { display: inline-flex; align-items: center; gap: 16px; height: 42px; padding: 0 14px 0 17px; border: 0; border-radius: 9px; background: var(--purple); color: #fff; font-size: 12px; font-weight: 900; box-shadow: 0 9px 18px #6657f52f; }.primary-btn:hover { background: var(--purple-dark); }.btn-arrow { font-size: 18px; font-weight: 400; }.form-message { color: #8792a6; font-size: 11px; }.form-message.success { color: var(--green); }.form-message.error { color: var(--red); }
.quiet-btn { padding: 6px 9px; border: 0; border-radius: 7px; background: #f3f4f8; color: #667289; font-size: 10px; font-weight: 800; }.quiet-btn:hover { background: #e8eaf1; }
.active-run { min-height: 97px; display: flex; align-items: center; gap: 13px; padding: 14px; border: 1px solid #e3e0ff; border-radius: 11px; background: #faf9ff; }.active-run.empty-run { border-style: dashed; border-color: #dfe3eb; background: #fafbfc; }.run-orb { width: 35px; height: 35px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 11px; background: #ebe8ff; color: var(--purple); font-size: 19px; }.empty-run .run-orb { background: #eef1f5; color: #8c97aa; }.running-run .run-orb { color: var(--purple); }.spinning { animation: spin 1.4s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }
.active-run strong { display: block; color: #39445b; font-size: 11px; }.active-run p { margin: 4px 0 0; color: #97a1b2; font-size: 10px; }.run-copy { min-width: 0; flex: 1; }.run-copy-head { display: flex; justify-content: space-between; gap: 9px; }.run-copy-head strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.run-copy-head span { color: var(--purple); font-size: 10px; font-weight: 900; }.progress-track { height: 5px; overflow: hidden; margin-top: 9px; border-radius: 99px; background: #e5e1ff; }.progress-track i { display: block; height: 100%; border-radius: inherit; background: var(--purple); transition: width .5s ease; }
.mini-history { margin-top: 20px; }.mini-history-head { display: flex; justify-content: space-between; margin-bottom: 8px; color: #8b95a7; font-size: 10px; font-weight: 800; }.mini-history-head a { color: var(--purple); }.history-row { display: flex; justify-content: space-between; gap: 8px; padding: 10px 0; border-bottom: 1px solid #f0f1f5; }.history-row:last-child { border-bottom: 0; }.history-row > div:first-child { min-width: 0; display: flex; gap: 8px; align-items: baseline; }.history-row strong { color: #667289; font-size: 10px; }.history-row span { overflow: hidden; color: #5d687d; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }.history-result { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }.history-result b { font-size: 11px; }.history-result small { font-size: 9px; }.history-result.success { color: var(--green); }.history-result.failure { color: var(--red); }.history-result.pending { color: #c18a2a; }
.explorer-panel { max-width: 1160px; margin-top: 17px; padding: 24px; }.explorer-heading { margin-bottom: 15px; align-items: flex-end; }.explorer-actions { display: flex; align-items: center; gap: 8px; }.explorer-actions select { width: 125px; margin: 0; }.search-box { display: flex; align-items: center; gap: 7px; width: 190px; height: 37px; padding: 0 10px; border: 1px solid #dbe0e9; border-radius: 8px; background: #fff; }.search-box span { color: #929daf; font-size: 17px; }.search-box input { height: auto; margin: 0; padding: 0; border: 0; box-shadow: none; }.explorer-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; padding: 0 2px; color: #8b95a7; font-size: 10px; font-weight: 800; }.legend { display: inline-flex; align-items: center; gap: 6px; color: #a0a9b8; font-weight: 500; }.legend i { width: 7px; height: 7px; border-radius: 50%; background: #35ab78; }.prospect-list { display: grid; gap: 9px; }.prospect-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 20px; padding: 16px; border: 1px solid #e8eaf0; border-radius: 11px; transition: border-color .15s ease, box-shadow .15s ease; }.prospect-card:hover { border-color: #cfc9ff; box-shadow: 0 6px 20px #6657f512; }.prospect-title { display: flex; gap: 10px; align-items: flex-start; }.place-pin { width: 28px; height: 28px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 9px; background: #f0efff; color: var(--purple); font-size: 17px; }.prospect-title h3 { margin: 0; color: #283249; font-size: 14px; letter-spacing: -.01em; }.subline { overflow: hidden; max-width: 700px; margin-top: 3px; color: #8d98aa; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }.chips { display: flex; flex-wrap: wrap; gap: 7px; margin: 11px 0 8px 38px; }.score-chip, .chip { display: inline-flex; align-items: center; gap: 5px; padding: 4px 7px; border-radius: 999px; background: #f3f4f8; color: #6d788c; font-size: 9px; font-weight: 800; }.score-chip i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }.score-chip.high { background: #eaf8f1; color: #25835e; }.score-chip.medium { background: #fff6e5; color: #a26d16; }.score-chip.low { background: #f0f2f6; color: #7d8798; }.chip.warning { background: #fff4dd; color: #a36d13; }.chip em { color: #a0a8b8; font-style: normal; font-weight: 500; }.prospect-details { display: flex; flex-wrap: wrap; gap: 13px; margin-left: 38px; color: #6c778c; font-size: 11px; }.inline-link { color: #5b50d3; font-weight: 800; }.inline-link span { font-size: 12px; }.missing-site { color: #b47a1e; font-weight: 800; }.contact-line { display: flex; flex-wrap: wrap; gap: 10px; color: #7e899b; }.prospect-meta { display: flex; align-items: center; gap: 6px; margin: 12px 0 0 38px; color: #a6aebb; font-size: 9px; }.meta-spacer { flex: 1; }.action-link { color: #6b60d8; font-weight: 800; }.review-control { display: flex; flex-direction: column; align-items: flex-end; gap: 5px; }.review-control label { color: #9aa3b3; font-size: 9px; font-weight: 800; }.review-control select { width: 117px; height: 34px; margin: 0; padding: 0 8px; color: #59657b; font-size: 10px; }.loading-state, .empty-state { min-height: 170px; display: grid; place-items: center; align-content: center; gap: 5px; color: #8e99aa; font-size: 12px; text-align: center; }.empty-state strong { color: #536079; font-size: 13px; }.empty-state p { max-width: 300px; margin: 0; font-size: 11px; }.empty-icon { width: 38px; height: 38px; display: grid; place-items: center; margin-bottom: 5px; border-radius: 12px; background: #f0efff; color: var(--purple); font-size: 22px; }.spinner { width: 18px; height: 18px; border: 2px solid #ddd9ff; border-top-color: var(--purple); border-radius: 50%; animation: spin .8s linear infinite; }
.setup-section { max-width: 1160px; display: grid; grid-template-columns: 1.2fr 1fr; gap: 30px; margin-top: 17px; padding: 22px 24px; border: 1px solid #e3e6ee; border-radius: 13px; background: #fbfcfe; }.setup-section h2 { margin: 5px 0; color: #38445a; font-size: 15px; }.setup-section p, .setup-section ol { margin: 6px 0 0; color: #8792a5; font-size: 11px; }.setup-section ol { padding-left: 17px; line-height: 1.9; }.setup-section code { padding: 2px 4px; border-radius: 4px; background: #eff1f6; color: #5d687d; font-size: 10px; }.footer { max-width: 1160px; display: flex; justify-content: space-between; gap: 20px; padding: 23px 2px 0; color: #a3acbb; font-size: 10px; }
.muted { color: #9ca5b5; font-size: 11px; }
@media (max-width: 980px) { .app-shell { grid-template-columns: 72px minmax(0, 1fr); }.sidebar { padding: 22px 10px 16px; }.brand { justify-content: center; padding: 0; }.brand > div, .side-nav a:not(.active) { display: none; }.side-nav { margin-top: 44px; }.side-nav a { justify-content: center; padding: 12px 0; }.side-nav .nav-icon { width: auto; }.source-card { justify-content: center; padding: 11px 0; }.source-card > div, .sidebar-foot p { display: none; }.main-content { padding: 0 25px 34px; }.workspace-grid { grid-template-columns: 1fr; }.run-panel { order: -1; }.run-panel .mini-history { display: none; } }
@media (max-width: 680px) { .app-shell { display: block; }.sidebar { position: static; height: auto; flex-direction: row; align-items: center; justify-content: space-between; padding: 13px 16px; }.side-nav { display: flex; gap: 3px; margin: 0; }.side-nav a, .side-nav a:not(.active) { display: flex; padding: 8px 10px; }.sidebar-foot { display: none; }.main-content { padding: 0 14px 30px; }.topbar { height: 53px; }.updated { display: none; }.page-heading { display: block; padding: 27px 0 20px; }.heading-badge { display: inline-grid; margin-top: 17px; }.stats-grid { grid-template-columns: repeat(2, 1fr); gap: 9px; }.stat-card { min-height: 105px; padding: 14px; }.stat-card strong { font-size: 24px; }.discovery-panel, .run-panel, .explorer-panel { padding: 17px; }.discovery-form { grid-template-columns: 1fr; gap: 13px; }.field.wide, .options.wide, .form-footer.wide { grid-column: auto; }.options { display: grid; gap: 12px; }.form-footer { display: block; }.form-message { display: block; margin-top: 10px; }.panel-heading, .explorer-heading { display: block; }.source-tag { display: inline-flex; margin-top: 14px; }.explorer-actions { display: grid; grid-template-columns: 1fr 1fr; margin-top: 15px; }.search-box { width: auto; grid-column: 1 / -1; }.explorer-actions select, .explorer-actions .quiet-btn { width: 100%; }.prospect-card { grid-template-columns: 1fr; gap: 12px; }.review-control { align-items: flex-start; }.review-control select { width: 100%; }.prospect-meta { flex-wrap: wrap; }.meta-spacer { display: none; }.setup-section { display: block; padding: 17px; }.setup-section ol { margin-top: 14px; }.footer { display: block; line-height: 1.8; } }