deploy Maps-backed ProspectOS workspace
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
@@ -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 & 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
@@ -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; } }
|
||||
|
||||
Reference in New Issue
Block a user