deploy Nous v19 update
This commit is contained in:
@@ -16,6 +16,15 @@ GMAPS_POLL_SECONDS=2
|
|||||||
# Keep the default unless your provider asks for a different application name.
|
# Keep the default unless your provider asks for a different application name.
|
||||||
OUTBOUND_USER_AGENT=ProspectOS/2.0 (+personal-use)
|
OUTBOUND_USER_AGENT=ProspectOS/2.0 (+personal-use)
|
||||||
|
|
||||||
|
# Optional Browser Use investigator for finding likely official domains that
|
||||||
|
# Maps did not list. It uses your configured OpenAI-compatible AI provider and
|
||||||
|
# a normal headless Chromium session; it does not rotate proxies, use stealth,
|
||||||
|
# solve CAPTCHAs, or bypass access challenges.
|
||||||
|
BROWSER_USE_ENABLED=true
|
||||||
|
BROWSER_USE_MAX_STEPS=10
|
||||||
|
BROWSER_USE_TIMEOUT_SECONDS=120
|
||||||
|
ANONYMIZED_TELEMETRY=false
|
||||||
|
|
||||||
# AI provider for analysis and tailored drafts: nous or custom.
|
# AI provider for analysis and tailored drafts: nous or custom.
|
||||||
AI_PROVIDER=custom
|
AI_PROVIDER=custom
|
||||||
# For Nous, use https://portal.nousresearch.com to create an API key;
|
# For Nous, use https://portal.nousresearch.com to create an API key;
|
||||||
|
|||||||
@@ -8,10 +8,21 @@ the local catalogue, and presents the remaining places for human review.
|
|||||||
The interface is split into Dashboard, Discover, Prospects, Runs, and Settings
|
The interface is split into Dashboard, Discover, Prospects, Runs, and Settings
|
||||||
views. It includes website health and email evidence, explainable scoring,
|
views. It includes website health and email evidence, explainable scoring,
|
||||||
domain ideas, suppression, CSV export, scheduled runs, rescans, and human review. It never sends outreach
|
domain ideas, suppression, CSV export, scheduled runs, rescans, and human review. It never sends outreach
|
||||||
automatically. Optional AI can analyse a prospect and draft a subject/email
|
automatically. The Prospects explorer has an **All high potential** tab and
|
||||||
from supplied evidence and your offer; you edit and approve the draft yourself.
|
category tabs populated from each discovery run, with every tab sorted from
|
||||||
|
highest to lowest opportunity score.
|
||||||
|
|
||||||
|
Optional AI can analyse a prospect and draft a subject/email from supplied
|
||||||
|
evidence and your offer; you edit and approve the draft yourself. Drafts now
|
||||||
|
also return the reasoning, stored evidence IDs used, facts that still need
|
||||||
|
confirmation, and a follow-up review note so a polished email is not mistaken
|
||||||
|
for verified information.
|
||||||
From a prospect's detail drawer, **Assess with AI** stores a contact probability,
|
From a prospect's detail drawer, **Assess with AI** stores a contact probability,
|
||||||
recommendation, and short evidence-grounded rationale for that specific company.
|
recommendation, and short evidence-grounded rationale for that specific company.
|
||||||
|
The same drawer can optionally run a bounded Browser Use investigation to look
|
||||||
|
for a likely official website when Maps did not list one. Candidate domains are
|
||||||
|
shown with evidence and must be accepted by you before they replace the
|
||||||
|
catalogue record.
|
||||||
|
|
||||||
## Start the stack
|
## Start the stack
|
||||||
|
|
||||||
@@ -50,6 +61,10 @@ error rather than remaining stuck forever.
|
|||||||
| `GMAPS_POLL_SECONDS` | `2` | Poll interval while a job is running |
|
| `GMAPS_POLL_SECONDS` | `2` | Poll interval while a job is running |
|
||||||
| `OUTBOUND_USER_AGENT` | `ProspectOS/2.0 (+personal-use)` | Identifies provider HTTP requests; avoids Python's default client fingerprint |
|
| `OUTBOUND_USER_AGENT` | `ProspectOS/2.0 (+personal-use)` | Identifies provider HTTP requests; avoids Python's default client fingerprint |
|
||||||
| `DISCOVERY_BACKEND` | `gmaps` when `GMAPS_URL` is set | Set to `browser` only for the bounded public-search fallback |
|
| `DISCOVERY_BACKEND` | `gmaps` when `GMAPS_URL` is set | Set to `browser` only for the bounded public-search fallback |
|
||||||
|
| `BROWSER_USE_ENABLED` | `true` | Enables the optional website-candidate investigator |
|
||||||
|
| `BROWSER_USE_MAX_STEPS` | `10` | Maximum Browser Use actions for one investigation (3–15) |
|
||||||
|
| `BROWSER_USE_TIMEOUT_SECONDS` | `120` | Hard timeout for one investigation (30–300 seconds) |
|
||||||
|
| `ANONYMIZED_TELEMETRY` | `false` | Keeps Browser Use telemetry disabled in this personal deployment |
|
||||||
| `AI_PROVIDER` | `custom` | Set to `nous` for the Nous Research Portal, or `custom` for another OpenAI-compatible endpoint |
|
| `AI_PROVIDER` | `custom` | Set to `nous` for the Nous Research Portal, or `custom` for another OpenAI-compatible endpoint |
|
||||||
| `AI_BASE_URL` | empty | Optional OpenAI-compatible `/chat/completions` endpoint |
|
| `AI_BASE_URL` | empty | Optional OpenAI-compatible `/chat/completions` endpoint |
|
||||||
| `AI_API_KEY` | empty | Optional provider key; keep server-side |
|
| `AI_API_KEY` | empty | Optional provider key; keep server-side |
|
||||||
@@ -82,6 +97,32 @@ Hermes refresh token and is not a portable credential for this standalone
|
|||||||
Docker app. ProspectOS therefore uses a server-side Nous API key here. The key
|
Docker app. ProspectOS therefore uses a server-side Nous API key here. The key
|
||||||
is never returned by the Settings API or sent to the browser after saving.
|
is never returned by the Settings API or sent to the browser after saving.
|
||||||
|
|
||||||
|
### Browser Use website investigator
|
||||||
|
|
||||||
|
Browser Use is an optional local browser agent. It uses the AI provider already
|
||||||
|
configured in Settings to navigate a small number of public search/result pages,
|
||||||
|
then returns candidate URLs as structured data. It is intentionally limited to
|
||||||
|
unauthenticated research: no proxy rotation, stealth fingerprints, CAPTCHA
|
||||||
|
solving, login, or challenge bypass is configured. If a site blocks the browser,
|
||||||
|
the action returns an explicit error and the rest of the prospect workflow is
|
||||||
|
left intact. Browser Use itself is open source, but each investigation still
|
||||||
|
uses your configured model/provider and may consume its quota.
|
||||||
|
|
||||||
|
To use it:
|
||||||
|
|
||||||
|
1. Configure and test an AI provider in **Settings** (a Nous key plus a selected
|
||||||
|
model also works).
|
||||||
|
2. Open a prospect that has no detected website and choose **Find possible
|
||||||
|
websites**.
|
||||||
|
3. Review the returned domains and evidence. Choose **Accept** only when the
|
||||||
|
domain is genuinely the business's official site; accepting runs the normal
|
||||||
|
website health and public-email scan.
|
||||||
|
|
||||||
|
The API equivalents are `POST /api/prospects/{id}/website-candidates`,
|
||||||
|
`GET /api/prospects/{id}/website-candidates`, and
|
||||||
|
`PATCH /api/prospects/{id}/website-candidates/{candidate_id}` with
|
||||||
|
`{"status":"accepted"}` or `{"status":"rejected"}`.
|
||||||
|
|
||||||
The app stores its catalogue in the `prospect_data` Docker volume. The worker
|
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
|
stores its own job data in `gmaps_data`; keep both volumes if you want to retain
|
||||||
history. To inspect status:
|
history. To inspect status:
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends chromium && rm
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY api/requirements.txt .
|
COPY api/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY api/server.py api/intelligence.py ./
|
COPY api/server.py api/intelligence.py api/browser_investigator.py ./
|
||||||
COPY web /app/web
|
COPY web /app/web
|
||||||
RUN useradd --system --create-home app && mkdir -p /data && chown -R app:app /app /data
|
RUN useradd --system --create-home app && mkdir -p /data && chown -R app:app /app /data
|
||||||
USER app
|
USER app
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Optional Browser Use investigator for public website candidates.
|
||||||
|
|
||||||
|
This module is deliberately isolated and imports browser-use only when called.
|
||||||
|
It performs bounded, headless public research. It does not log in, solve
|
||||||
|
CAPTCHAs, rotate proxies, or continue after an access challenge.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
_BLOCKED_HOSTS = {
|
||||||
|
"facebook.com", "instagram.com", "linkedin.com", "youtube.com", "x.com",
|
||||||
|
"twitter.com", "google.com", "google.co.za", "bing.com", "brave.com",
|
||||||
|
"duckduckgo.com", "startpage.com", "yelp.com", "yellowpages.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_url(value: Any) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
if text.startswith("//"):
|
||||||
|
text = "https:" + text
|
||||||
|
if not re.match(r"^https?://", text, re.I):
|
||||||
|
text = "https://" + text
|
||||||
|
parsed = urlparse(text)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||||
|
return ""
|
||||||
|
host = parsed.hostname.lower().rstrip(".")
|
||||||
|
if host in {"localhost", "127.0.0.1", "::1"} or host.endswith(".local"):
|
||||||
|
return ""
|
||||||
|
if any(host == blocked or host.endswith("." + blocked) for blocked in _BLOCKED_HOSTS):
|
||||||
|
return ""
|
||||||
|
return text[:1000]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_agent_result(raw: Any) -> list[dict[str, Any]]:
|
||||||
|
"""Parse and normalise the agent's final JSON candidate list."""
|
||||||
|
text = str(raw or "").strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.I | re.S).strip()
|
||||||
|
try:
|
||||||
|
payload = json.loads(text)
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
start, end = text.find("{"), text.rfind("}")
|
||||||
|
if start < 0 or end <= start:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
payload = json.loads(text[start:end + 1])
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
return []
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return []
|
||||||
|
raw_candidates = payload.get("candidates")
|
||||||
|
if not isinstance(raw_candidates, list):
|
||||||
|
return []
|
||||||
|
output: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in raw_candidates[:10]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
url = _normalise_url(item.get("url") or item.get("website") or item.get("domain"))
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
domain = (urlparse(url).hostname or "").lower().removeprefix("www.")
|
||||||
|
if not domain or domain in seen:
|
||||||
|
continue
|
||||||
|
seen.add(domain)
|
||||||
|
try:
|
||||||
|
confidence = max(0, min(100, int(round(float(item.get("confidence", 0))))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
confidence = 0
|
||||||
|
evidence = item.get("evidence")
|
||||||
|
if not isinstance(evidence, list):
|
||||||
|
evidence = []
|
||||||
|
output.append({
|
||||||
|
"url": url,
|
||||||
|
"domain": domain,
|
||||||
|
"confidence": confidence,
|
||||||
|
"evidence": [str(value).strip()[:240] for value in evidence if str(value).strip()][:8],
|
||||||
|
"status": str(item.get("status") or "candidate").strip()[:80],
|
||||||
|
})
|
||||||
|
return sorted(output, key=lambda item: item["confidence"], reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def investigate_websites(
|
||||||
|
prospect: dict[str, Any],
|
||||||
|
*,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str,
|
||||||
|
model: str,
|
||||||
|
user_agent: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Run one bounded Browser Use investigation and return candidates."""
|
||||||
|
if os.environ.get("BROWSER_USE_ENABLED", "1").strip().lower() in {"0", "false", "no", "off"}:
|
||||||
|
raise RuntimeError("browser_use_disabled")
|
||||||
|
if not base_url or not model:
|
||||||
|
raise RuntimeError("browser_use_requires_ai_provider_and_model")
|
||||||
|
if not api_key:
|
||||||
|
raise RuntimeError("browser_use_requires_ai_api_key")
|
||||||
|
try:
|
||||||
|
from browser_use import Agent, BrowserProfile, ChatOpenAI
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("browser_use_not_installed_rebuild_container") from exc
|
||||||
|
|
||||||
|
name = str(prospect.get("name") or "").strip()
|
||||||
|
address = str(prospect.get("address") or "").strip()
|
||||||
|
category = str(prospect.get("category") or "").strip()
|
||||||
|
phone = str(prospect.get("phone") or "").strip()
|
||||||
|
emails = str(prospect.get("emails") or "").strip()
|
||||||
|
task = f"""
|
||||||
|
You are a bounded public-data website investigator. Find possible official websites for this one business:
|
||||||
|
Business name: {name}
|
||||||
|
Category/services: {category}
|
||||||
|
Address/location: {address}
|
||||||
|
Phone: {phone}
|
||||||
|
Public email(s): {emails}
|
||||||
|
|
||||||
|
Use only public, unauthenticated pages. Search at most three result pages and inspect at most five candidate domains.
|
||||||
|
Try exact business-name and location/phone searches. Prefer the official business website over social networks,
|
||||||
|
directories, review sites, and map pages. Do not log in, submit forms, solve CAPTCHAs, bypass access challenges,
|
||||||
|
or use a proxy. If a site blocks the browser, record it and move on.
|
||||||
|
|
||||||
|
Return ONLY JSON in this shape:
|
||||||
|
{{"candidates":[{{"url":"https://example.co.za","confidence":0,"evidence":["name match","phone match"],"status":"candidate"}}]}}
|
||||||
|
Confidence must be an integer from 0 to 100. Include only plausible official website URLs and never invent a URL.
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
async def run() -> Any:
|
||||||
|
browser_profile = BrowserProfile(
|
||||||
|
headless=True,
|
||||||
|
executable_path=os.environ.get("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH", "/usr/bin/chromium"),
|
||||||
|
minimum_wait_page_load_time=0.5,
|
||||||
|
wait_between_actions=0.7,
|
||||||
|
enable_default_extensions=False,
|
||||||
|
captcha_solver=False,
|
||||||
|
)
|
||||||
|
llm = ChatOpenAI(
|
||||||
|
model=model,
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=base_url,
|
||||||
|
default_headers={"User-Agent": user_agent},
|
||||||
|
temperature=0.1,
|
||||||
|
frequency_penalty=None,
|
||||||
|
max_completion_tokens=900,
|
||||||
|
max_retries=1,
|
||||||
|
timeout=45,
|
||||||
|
)
|
||||||
|
agent = Agent(
|
||||||
|
task=task,
|
||||||
|
llm=llm,
|
||||||
|
browser_profile=browser_profile,
|
||||||
|
use_vision=False,
|
||||||
|
flash_mode=True,
|
||||||
|
use_judge=False,
|
||||||
|
enable_planning=False,
|
||||||
|
enable_signal_handler=False,
|
||||||
|
)
|
||||||
|
return await agent.run(max_steps=max(3, min(15, int(os.environ.get("BROWSER_USE_MAX_STEPS", "10")))))
|
||||||
|
|
||||||
|
timeout = max(30, min(300, int(os.environ.get("BROWSER_USE_TIMEOUT_SECONDS", "120"))))
|
||||||
|
try:
|
||||||
|
history = asyncio.run(asyncio.wait_for(run(), timeout=timeout))
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
raise RuntimeError("browser_use_timeout") from exc
|
||||||
|
final = history.final_result() if history is not None and hasattr(history, "final_result") else ""
|
||||||
|
return _parse_agent_result(final)
|
||||||
@@ -1 +1,2 @@
|
|||||||
playwright==1.52.0
|
playwright==1.52.0
|
||||||
|
browser-use==0.13.10
|
||||||
|
|||||||
+238
-28
@@ -27,8 +27,10 @@ from urllib.parse import parse_qs, quote_plus, unquote, urlparse
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from intelligence import domain_candidates, scan_website
|
from intelligence import domain_candidates, scan_website
|
||||||
|
from browser_investigator import investigate_websites
|
||||||
except ImportError: # tests import the module as api.server
|
except ImportError: # tests import the module as api.server
|
||||||
from api.intelligence import domain_candidates, scan_website
|
from api.intelligence import domain_candidates, scan_website
|
||||||
|
from api.browser_investigator import investigate_websites
|
||||||
|
|
||||||
DB_PATH = os.environ.get("PROSPECT_DB", "/data/prospects.db")
|
DB_PATH = os.environ.get("PROSPECT_DB", "/data/prospects.db")
|
||||||
MAX_RESULTS = 50
|
MAX_RESULTS = 50
|
||||||
@@ -52,6 +54,7 @@ AI_PROVIDER = os.environ.get("AI_PROVIDER", "custom").strip().lower()
|
|||||||
# reaches the provider. Identify this application consistently on provider
|
# reaches the provider. Identify this application consistently on provider
|
||||||
# requests instead of relying on that default.
|
# requests instead of relying on that default.
|
||||||
OUTBOUND_USER_AGENT = os.environ.get("OUTBOUND_USER_AGENT", "ProspectOS/2.0 (+personal-use)").strip() or "ProspectOS/2.0 (+personal-use)"
|
OUTBOUND_USER_AGENT = os.environ.get("OUTBOUND_USER_AGENT", "ProspectOS/2.0 (+personal-use)").strip() or "ProspectOS/2.0 (+personal-use)"
|
||||||
|
BROWSER_USE_ENABLED = os.environ.get("BROWSER_USE_ENABLED", "1").strip().lower() not in {"0", "false", "no", "off"}
|
||||||
NOUS_BASE_URL = "https://inference-api.nousresearch.com/v1"
|
NOUS_BASE_URL = "https://inference-api.nousresearch.com/v1"
|
||||||
NOUS_FREE_MODELS = [
|
NOUS_FREE_MODELS = [
|
||||||
{"id": "meituan/longcat-2.0:free", "label": "Meituan · LongCat 2.0", "free": True},
|
{"id": "meituan/longcat-2.0:free", "label": "Meituan · LongCat 2.0", "free": True},
|
||||||
@@ -70,6 +73,19 @@ _schema_lock = threading.Lock()
|
|||||||
_schema_path: str | None = None
|
_schema_path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def browser_use_limits() -> tuple[int, int]:
|
||||||
|
"""Return bounded Browser Use steps/timeout even if .env is malformed."""
|
||||||
|
try:
|
||||||
|
steps = int(os.environ.get("BROWSER_USE_MAX_STEPS", "10"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
steps = 10
|
||||||
|
try:
|
||||||
|
timeout = int(os.environ.get("BROWSER_USE_TIMEOUT_SECONDS", "120"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
timeout = 120
|
||||||
|
return max(3, min(15, steps)), max(30, min(300, timeout))
|
||||||
|
|
||||||
|
|
||||||
def now() -> str:
|
def now() -> str:
|
||||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||||
|
|
||||||
@@ -106,6 +122,7 @@ def db() -> sqlite3.Connection:
|
|||||||
source TEXT NOT NULL DEFAULT 'google_maps',
|
source TEXT NOT NULL DEFAULT 'google_maps',
|
||||||
source_id TEXT NOT NULL DEFAULT '',
|
source_id TEXT NOT NULL DEFAULT '',
|
||||||
category TEXT NOT NULL DEFAULT '',
|
category TEXT NOT NULL DEFAULT '',
|
||||||
|
search_category TEXT NOT NULL DEFAULT '',
|
||||||
address TEXT NOT NULL DEFAULT '',
|
address TEXT NOT NULL DEFAULT '',
|
||||||
phone TEXT NOT NULL DEFAULT '',
|
phone TEXT NOT NULL DEFAULT '',
|
||||||
emails TEXT NOT NULL DEFAULT '',
|
emails TEXT NOT NULL DEFAULT '',
|
||||||
@@ -135,6 +152,9 @@ def db() -> sqlite3.Connection:
|
|||||||
ai_subject TEXT NOT NULL DEFAULT '',
|
ai_subject TEXT NOT NULL DEFAULT '',
|
||||||
ai_message TEXT NOT NULL DEFAULT '',
|
ai_message TEXT NOT NULL DEFAULT '',
|
||||||
ai_offer TEXT NOT NULL DEFAULT '',
|
ai_offer TEXT NOT NULL DEFAULT '',
|
||||||
|
ai_unknowns TEXT NOT NULL DEFAULT '[]',
|
||||||
|
ai_evidence_ids TEXT NOT NULL DEFAULT '[]',
|
||||||
|
ai_follow_up TEXT NOT NULL DEFAULT '',
|
||||||
opportunity_score INTEGER NOT NULL DEFAULT 0,
|
opportunity_score INTEGER NOT NULL DEFAULT 0,
|
||||||
raw_json TEXT NOT NULL DEFAULT '{}'
|
raw_json TEXT NOT NULL DEFAULT '{}'
|
||||||
)""")
|
)""")
|
||||||
@@ -172,6 +192,19 @@ def db() -> sqlite3.Connection:
|
|||||||
conn.execute("""CREATE TABLE IF NOT EXISTS settings (
|
conn.execute("""CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL
|
key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL
|
||||||
)""")
|
)""")
|
||||||
|
conn.execute("""CREATE TABLE IF NOT EXISTS website_candidates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
prospect_id INTEGER NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
confidence INTEGER NOT NULL DEFAULT 0,
|
||||||
|
evidence TEXT NOT NULL DEFAULT '[]',
|
||||||
|
source TEXT NOT NULL DEFAULT 'browser_use',
|
||||||
|
status TEXT NOT NULL DEFAULT 'candidate',
|
||||||
|
discovered_at TEXT NOT NULL,
|
||||||
|
raw_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
UNIQUE(prospect_id, domain)
|
||||||
|
)""")
|
||||||
|
|
||||||
# Migrate databases created by v1-v5 without destroying the user's
|
# Migrate databases created by v1-v5 without destroying the user's
|
||||||
# catalogue. SQLite permits adding nullable/defaulted columns in place.
|
# catalogue. SQLite permits adding nullable/defaulted columns in place.
|
||||||
@@ -179,6 +212,7 @@ def db() -> sqlite3.Connection:
|
|||||||
"source": "TEXT NOT NULL DEFAULT 'google_maps'",
|
"source": "TEXT NOT NULL DEFAULT 'google_maps'",
|
||||||
"source_id": "TEXT NOT NULL DEFAULT ''",
|
"source_id": "TEXT NOT NULL DEFAULT ''",
|
||||||
"category": "TEXT NOT NULL DEFAULT ''",
|
"category": "TEXT NOT NULL DEFAULT ''",
|
||||||
|
"search_category": "TEXT NOT NULL DEFAULT ''",
|
||||||
"address": "TEXT NOT NULL DEFAULT ''",
|
"address": "TEXT NOT NULL DEFAULT ''",
|
||||||
"phone": "TEXT NOT NULL DEFAULT ''",
|
"phone": "TEXT NOT NULL DEFAULT ''",
|
||||||
"emails": "TEXT NOT NULL DEFAULT ''",
|
"emails": "TEXT NOT NULL DEFAULT ''",
|
||||||
@@ -208,6 +242,9 @@ def db() -> sqlite3.Connection:
|
|||||||
"ai_subject": "TEXT NOT NULL DEFAULT ''",
|
"ai_subject": "TEXT NOT NULL DEFAULT ''",
|
||||||
"ai_message": "TEXT NOT NULL DEFAULT ''",
|
"ai_message": "TEXT NOT NULL DEFAULT ''",
|
||||||
"ai_offer": "TEXT NOT NULL DEFAULT ''",
|
"ai_offer": "TEXT NOT NULL DEFAULT ''",
|
||||||
|
"ai_unknowns": "TEXT NOT NULL DEFAULT '[]'",
|
||||||
|
"ai_evidence_ids": "TEXT NOT NULL DEFAULT '[]'",
|
||||||
|
"ai_follow_up": "TEXT NOT NULL DEFAULT ''",
|
||||||
"opportunity_score": "INTEGER NOT NULL DEFAULT 0",
|
"opportunity_score": "INTEGER NOT NULL DEFAULT 0",
|
||||||
"raw_json": "TEXT NOT NULL DEFAULT '{}'",
|
"raw_json": "TEXT NOT NULL DEFAULT '{}'",
|
||||||
}
|
}
|
||||||
@@ -230,6 +267,7 @@ def db() -> sqlite3.Connection:
|
|||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_prospects_review ON prospects(review_status)")
|
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_prospects_score ON prospects(opportunity_score DESC)")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(id DESC)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(id DESC)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_website_candidates_prospect ON website_candidates(prospect_id, confidence DESC)")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
_schema_path = DB_PATH
|
_schema_path = DB_PATH
|
||||||
_schema_lock.release()
|
_schema_lock.release()
|
||||||
@@ -380,7 +418,7 @@ def score_place(item: dict[str, Any], website: str, phone: str, rating: float |
|
|||||||
return score_opportunity(item)[0]
|
return score_opportunity(item)[0]
|
||||||
|
|
||||||
|
|
||||||
def map_gmaps_result(item: dict[str, Any], query: str) -> dict[str, Any]:
|
def map_gmaps_result(item: dict[str, Any], query: str, search_category: str = "") -> dict[str, Any]:
|
||||||
"""Normalize gosom/google-maps-scraper's place object to our catalogue."""
|
"""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"
|
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"))
|
website = normalize_url(item.get("website") or item.get("website_url"))
|
||||||
@@ -408,6 +446,7 @@ def map_gmaps_result(item: dict[str, Any], query: str) -> dict[str, Any]:
|
|||||||
"source": "google_maps",
|
"source": "google_maps",
|
||||||
"source_id": source_id[:255],
|
"source_id": source_id[:255],
|
||||||
"category": category[:255],
|
"category": category[:255],
|
||||||
|
"search_category": as_text(search_category)[:255],
|
||||||
"address": address[:1000],
|
"address": address[:1000],
|
||||||
"phone": phone[:120],
|
"phone": phone[:120],
|
||||||
"emails": emails[:500],
|
"emails": emails[:500],
|
||||||
@@ -869,8 +908,9 @@ def enrich_place(item: dict[str, Any]) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def insert_catalogue(run_id: int, query: str, raw_results: list[dict[str, Any]], requested: int,
|
def insert_catalogue(run_id: int, query: str, raw_results: list[dict[str, Any]], requested: int,
|
||||||
progress: Callable[[int, int], None] | None = None) -> tuple[list[dict[str, Any]], int]:
|
progress: Callable[[int, int], None] | None = None,
|
||||||
mapped = [map_gmaps_result(item, query) for item in raw_results[:requested]]
|
search_category: str = "") -> tuple[list[dict[str, Any]], int]:
|
||||||
|
mapped = [map_gmaps_result(item, query, search_category) for item in raw_results[:requested]]
|
||||||
conn = db()
|
conn = db()
|
||||||
added = 0
|
added = 0
|
||||||
try:
|
try:
|
||||||
@@ -899,14 +939,14 @@ def insert_catalogue(run_id: int, query: str, raw_results: list[dict[str, Any]],
|
|||||||
try:
|
try:
|
||||||
cur = conn.execute("""INSERT OR IGNORE INTO prospects(
|
cur = conn.execute("""INSERT OR IGNORE INTO prospects(
|
||||||
name,website,domain,description,query,discovered_at,source,source_id,
|
name,website,domain,description,query,discovered_at,source,source_id,
|
||||||
category,address,phone,emails,rating,review_count,maps_url,latitude,
|
category,search_category,address,phone,emails,rating,review_count,maps_url,latitude,
|
||||||
longitude,listing_status,website_status,website_http_status,website_final_url,
|
longitude,listing_status,website_status,website_http_status,website_final_url,
|
||||||
website_latency_ms,website_https,website_title,website_technology,website_has_viewport,
|
website_latency_ms,website_https,website_title,website_technology,website_has_viewport,
|
||||||
website_tls_expires_at,website_checked_at,website_error,email_evidence,score_reasons,
|
website_tls_expires_at,website_checked_at,website_error,email_evidence,score_reasons,
|
||||||
ai_probability,ai_recommendation,ai_reasoning,ai_checked_at,ai_subject,ai_message,ai_offer,opportunity_score,raw_json
|
ai_probability,ai_recommendation,ai_reasoning,ai_checked_at,ai_subject,ai_message,ai_offer,opportunity_score,raw_json
|
||||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (
|
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (
|
||||||
item["name"], item["website"], item["domain"], item["description"], item["query"], now(),
|
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["source"], item["source_id"], item["category"], item.get("search_category", ""), item["address"], item["phone"], item["emails"],
|
||||||
item["rating"], item["review_count"], item["maps_url"], item["latitude"], item["longitude"],
|
item["rating"], item["review_count"], item["maps_url"], item["latitude"], item["longitude"],
|
||||||
item.get("listing_status", ""), item.get("website_status", "unknown"), item.get("website_http_status"),
|
item.get("listing_status", ""), item.get("website_status", "unknown"), item.get("website_http_status"),
|
||||||
item.get("website_final_url", ""), item.get("website_latency_ms", 0), item.get("website_https", 0),
|
item.get("website_final_url", ""), item.get("website_latency_ms", 0), item.get("website_https", 0),
|
||||||
@@ -952,7 +992,7 @@ def run_discovery(run_id: int, payload: dict[str, Any]) -> None:
|
|||||||
if total:
|
if total:
|
||||||
update_run(run_id, stage="website_analysis", progress=76 + int(index * 19 / total), found=index)
|
update_run(run_id, stage="website_analysis", progress=76 + int(index * 19 / total), found=index)
|
||||||
|
|
||||||
results, added = insert_catalogue(run_id, query, raw_results, requested, enrichment_progress)
|
results, added = insert_catalogue(run_id, query, raw_results, requested, enrichment_progress, search_category=category)
|
||||||
update_run(run_id, found=len(results), added=added, status="succeeded", stage="complete", progress=100, completed_at=now(), error="")
|
update_run(run_id, found=len(results), added=added, status="succeeded", stage="complete", progress=100, completed_at=now(), error="")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
message = str(exc).strip()[:500] or "discovery_failed"
|
message = str(exc).strip()[:500] or "discovery_failed"
|
||||||
@@ -998,7 +1038,7 @@ def scheduler_loop() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def ai_generate_message(prospect: dict[str, Any], offer: str) -> dict[str, Any]:
|
def ai_generate_message(prospect: dict[str, Any], offer: str) -> dict[str, Any]:
|
||||||
"""Generate an evidence-grounded draft through an OpenAI-compatible endpoint."""
|
"""Generate an evidence-grounded, human-editable outreach draft."""
|
||||||
runtime = ai_runtime_settings()
|
runtime = ai_runtime_settings()
|
||||||
ai_base_url = runtime["base_url"]
|
ai_base_url = runtime["base_url"]
|
||||||
ai_api_key = runtime["api_key"]
|
ai_api_key = runtime["api_key"]
|
||||||
@@ -1007,24 +1047,57 @@ def ai_generate_message(prospect: dict[str, Any], offer: str) -> dict[str, Any]:
|
|||||||
raise RuntimeError("nous_api_key_required_for_standalone_app")
|
raise RuntimeError("nous_api_key_required_for_standalone_app")
|
||||||
if not ai_base_url or not ai_model:
|
if not ai_base_url or not ai_model:
|
||||||
raise RuntimeError("ai_not_configured_set_AI_BASE_URL_and_AI_MODEL")
|
raise RuntimeError("ai_not_configured_set_AI_BASE_URL_and_AI_MODEL")
|
||||||
|
evidence_ids = ["prospect_record"]
|
||||||
|
if prospect.get("maps_url"):
|
||||||
|
evidence_ids.append("public_maps_listing")
|
||||||
|
if prospect.get("website"):
|
||||||
|
evidence_ids.append("website_scan")
|
||||||
|
if prospect.get("emails"):
|
||||||
|
evidence_ids.append("public_email_listing")
|
||||||
|
if prospect.get("phone"):
|
||||||
|
evidence_ids.append("public_phone_listing")
|
||||||
evidence = {
|
evidence = {
|
||||||
"business": prospect.get("name"), "category": prospect.get("category"),
|
"evidence_ids": evidence_ids,
|
||||||
"location": prospect.get("address"), "website": prospect.get("website"),
|
"business_name": prospect.get("name"),
|
||||||
"website_status": prospect.get("website_status"), "website_title": prospect.get("website_title"),
|
"search_category": prospect.get("search_category") or prospect.get("category"),
|
||||||
"technology": prospect.get("website_technology"), "emails": prospect.get("emails"),
|
"address_or_location": prospect.get("address"),
|
||||||
"phone": prospect.get("phone"), "score": prospect.get("opportunity_score"),
|
"public_listing": {
|
||||||
|
"maps_url": prospect.get("maps_url"), "phone": prospect.get("phone"),
|
||||||
|
"rating": prospect.get("rating"), "review_count": prospect.get("review_count"),
|
||||||
|
},
|
||||||
|
"website": {
|
||||||
|
"url": prospect.get("website"), "status": prospect.get("website_status"),
|
||||||
|
"http_status": prospect.get("website_http_status"), "title": prospect.get("website_title"),
|
||||||
|
"technology": prospect.get("website_technology"), "https": bool(prospect.get("website_https")),
|
||||||
|
"latency_ms": prospect.get("website_latency_ms"),
|
||||||
|
},
|
||||||
|
"public_emails": prospect.get("emails"),
|
||||||
|
"opportunity_score": prospect.get("opportunity_score"),
|
||||||
"score_reasons": _json_list(prospect.get("score_reasons")),
|
"score_reasons": _json_list(prospect.get("score_reasons")),
|
||||||
}
|
}
|
||||||
system = ("You write concise, respectful B2B prospecting drafts for a South African web hosting and design business. "
|
system = (
|
||||||
"Use only the supplied evidence; never invent facts, prices, names, services, or claims. "
|
"You are a senior B2B copywriter for a South African web-hosting and website-design business. "
|
||||||
"Do not mention scraping or hidden analysis. Return strict JSON with keys subject, message, rationale. "
|
"Create a short, respectful, genuinely personalised first-contact email from the supplied evidence. "
|
||||||
"The message must be plain text, personalised, transparent, and include a polite opt-out line.")
|
"Use only stored facts and the user's offer; never invent a person's name, company activity, price, "
|
||||||
|
"service, problem, result, testimonial, urgency, or link. Do not mention scraping, scoring, hidden "
|
||||||
|
"analysis, or imply certainty where the evidence is uncertain. Do not insult the prospect or call a "
|
||||||
|
"site bad; use neutral wording such as 'may be having trouble loading' when appropriate. "
|
||||||
|
"If there is no website, do not say you reviewed their website. If there is no mockup URL in the offer, "
|
||||||
|
"do not invent one. Keep the email around 90-150 words, with a natural greeting using the business name, "
|
||||||
|
"one specific evidence-based observation, the exact offer/instructions supplied by the user, one simple "
|
||||||
|
"call to action, and a polite opt-out sentence. Avoid spammy hype, excessive emojis, all-caps, and fake "
|
||||||
|
"personal familiarity. Return ONLY strict JSON with exactly these keys: subject, message, rationale, "
|
||||||
|
"recommended_offer, unknowns, evidence_used, follow_up. subject is 5-10 natural words. message is plain "
|
||||||
|
"text. rationale is concise. recommended_offer is a short summary of the user's offer. unknowns is an "
|
||||||
|
"array of facts that should be confirmed. evidence_used is an array containing only the supplied evidence "
|
||||||
|
"IDs. follow_up is a short human review note, not part of the email."
|
||||||
|
)
|
||||||
prompt = json.dumps({"evidence": evidence, "user_offer_or_instruction": offer[:2000]}, ensure_ascii=False)
|
prompt = json.dumps({"evidence": evidence, "user_offer_or_instruction": offer[:2000]}, ensure_ascii=False)
|
||||||
headers = {"Authorization": "Bearer " + ai_api_key} if ai_api_key else {}
|
headers = {"Authorization": "Bearer " + ai_api_key} if ai_api_key else {}
|
||||||
request_payload: dict[str, Any] = {
|
request_payload: dict[str, Any] = {
|
||||||
"model": ai_model,
|
"model": ai_model,
|
||||||
"temperature": 0.4,
|
"temperature": 0.4,
|
||||||
"max_tokens": 700,
|
"max_tokens": 900,
|
||||||
"messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}],
|
"messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}],
|
||||||
}
|
}
|
||||||
# Nous exposes an OpenAI-compatible chat endpoint, but not every free model
|
# Nous exposes an OpenAI-compatible chat endpoint, but not every free model
|
||||||
@@ -1034,13 +1107,23 @@ def ai_generate_message(prospect: dict[str, Any], offer: str) -> dict[str, Any]:
|
|||||||
request_payload["response_format"] = {"type": "json_object"}
|
request_payload["response_format"] = {"type": "json_object"}
|
||||||
result = http_json("POST", ai_base_url + "/chat/completions", request_payload, headers=headers, timeout=90, error_prefix="ai")
|
result = http_json("POST", ai_base_url + "/chat/completions", request_payload, headers=headers, timeout=90, error_prefix="ai")
|
||||||
content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else ""
|
content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else ""
|
||||||
try:
|
parsed = _parse_ai_object(content)
|
||||||
parsed = json.loads(content)
|
|
||||||
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
||||||
raise RuntimeError("ai_invalid_json_response") from exc
|
|
||||||
if not isinstance(parsed, dict) or not str(parsed.get("subject") or "").strip() or not str(parsed.get("message") or "").strip():
|
if not isinstance(parsed, dict) or not str(parsed.get("subject") or "").strip() or not str(parsed.get("message") or "").strip():
|
||||||
raise RuntimeError("ai_incomplete_message")
|
raise RuntimeError("ai_incomplete_message")
|
||||||
return {"subject": str(parsed["subject"])[:200], "message": str(parsed["message"])[:8000], "rationale": str(parsed.get("rationale") or "")[:2000]}
|
def text_list(value: Any, limit: int = 6, size: int = 400) -> list[str]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
return [str(item).strip()[:size] for item in value if str(item).strip()][:limit]
|
||||||
|
used = [item for item in text_list(parsed.get("evidence_used"), limit=10, size=80) if item in evidence_ids]
|
||||||
|
return {
|
||||||
|
"subject": str(parsed["subject"]).strip()[:200],
|
||||||
|
"message": str(parsed["message"]).strip()[:8000],
|
||||||
|
"rationale": str(parsed.get("rationale") or "").strip()[:2000],
|
||||||
|
"recommended_offer": str(parsed.get("recommended_offer") or offer).strip()[:2000],
|
||||||
|
"unknowns": text_list(parsed.get("unknowns")),
|
||||||
|
"evidence_used": used,
|
||||||
|
"follow_up": str(parsed.get("follow_up") or "Review the draft and confirm the offer before sending.").strip()[:1000],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _parse_ai_object(content: Any) -> dict[str, Any]:
|
def _parse_ai_object(content: Any) -> dict[str, Any]:
|
||||||
@@ -1163,6 +1246,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"ready": ready,
|
"ready": ready,
|
||||||
"email_extraction_default": True,
|
"email_extraction_default": True,
|
||||||
"email_extraction_always": True,
|
"email_extraction_always": True,
|
||||||
|
"browser_use_enabled": BROWSER_USE_ENABLED,
|
||||||
"ai_provider": ai_runtime["provider"],
|
"ai_provider": ai_runtime["provider"],
|
||||||
"ai_configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"])),
|
"ai_configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"])),
|
||||||
})
|
})
|
||||||
@@ -1174,7 +1258,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if parsed.path == "/api/settings":
|
if parsed.path == "/api/settings":
|
||||||
ai_runtime = ai_runtime_settings()
|
ai_runtime = ai_runtime_settings()
|
||||||
models = nous_models() if ai_runtime["provider"] == "nous" else []
|
models = nous_models() if ai_runtime["provider"] == "nous" else []
|
||||||
return self.send_json(200, {"maps": {"backend": backend_mode(), "worker_url": GMAPS_URL, "ready": backend_mode() == "browser" or bool(GMAPS_URL)}, "ai": {"provider": ai_runtime["provider"], "base_url": ai_runtime["base_url"], "model": ai_runtime["model"], "configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"])), "models": models}, "email_collection": {"always_on": True, "public_only": True}, "limits": {"max_results": MAX_RESULTS, "website_scan_timeout_seconds": 8}})
|
browser_steps, browser_timeout = browser_use_limits()
|
||||||
|
return self.send_json(200, {"maps": {"backend": backend_mode(), "worker_url": GMAPS_URL, "ready": backend_mode() == "browser" or bool(GMAPS_URL)}, "ai": {"provider": ai_runtime["provider"], "base_url": ai_runtime["base_url"], "model": ai_runtime["model"], "configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"])), "models": models}, "browser_use": {"enabled": BROWSER_USE_ENABLED, "headless": True, "public_only": True, "max_steps": browser_steps, "timeout_seconds": browser_timeout}, "email_collection": {"always_on": True, "public_only": True}, "limits": {"max_results": MAX_RESULTS, "website_scan_timeout_seconds": 8}})
|
||||||
if parsed.path == "/api/prospects":
|
if parsed.path == "/api/prospects":
|
||||||
params = parse_qs(parsed.query)
|
params = parse_qs(parsed.query)
|
||||||
q = (params.get("q", [""])[0]).strip().lower()
|
q = (params.get("q", [""])[0]).strip().lower()
|
||||||
@@ -1182,7 +1267,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
rows = conn.execute("SELECT * FROM prospects ORDER BY opportunity_score DESC, id DESC").fetchall()
|
rows = conn.execute("SELECT * FROM prospects ORDER BY opportunity_score DESC, id DESC").fetchall()
|
||||||
items = []
|
items = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
haystack = " ".join(str(row[key] or "") for key in ("name", "domain", "description", "address", "category", "phone", "emails")).lower()
|
haystack = " ".join(str(row[key] or "") for key in ("name", "domain", "description", "address", "category", "search_category", "phone", "emails")).lower()
|
||||||
if q and q not in haystack:
|
if q and q not in haystack:
|
||||||
continue
|
continue
|
||||||
if status and row["review_status"] != status:
|
if status and row["review_status"] != status:
|
||||||
@@ -1192,14 +1277,21 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if parsed.path == "/api/export.csv":
|
if parsed.path == "/api/export.csv":
|
||||||
params = parse_qs(parsed.query); q = params.get("q", [""])[0].strip().lower()
|
params = parse_qs(parsed.query); q = params.get("q", [""])[0].strip().lower()
|
||||||
rows = [dict(r) for r in conn.execute("SELECT * FROM prospects ORDER BY opportunity_score DESC, id DESC")]
|
rows = [dict(r) for r in conn.execute("SELECT * FROM prospects ORDER BY opportunity_score DESC, id DESC")]
|
||||||
if q: rows = [r for r in rows if q in " ".join(str(r.get(k) or "") for k in ("name","domain","address","category","emails")).lower()]
|
if q: rows = [r for r in rows if q in " ".join(str(r.get(k) or "") for k in ("name","domain","address","category","search_category","emails")).lower()]
|
||||||
out = io.StringIO(); fields = ["id","name","category","address","phone","emails","domain","website","website_status","opportunity_score","score_reasons","review_status","last_seen"]
|
out = io.StringIO(); fields = ["id","name","search_category","category","address","phone","emails","domain","website","website_status","opportunity_score","score_reasons","review_status","last_seen"]
|
||||||
writer = csv.DictWriter(out, fieldnames=fields); writer.writeheader(); writer.writerows({k:r.get(k) for k in fields} for r in rows)
|
writer = csv.DictWriter(out, fieldnames=fields); writer.writeheader(); writer.writerows({k:r.get(k) for k in fields} for r in rows)
|
||||||
body = out.getvalue().encode(); self.send_response(200); self.send_header("Content-Type","text/csv; charset=utf-8"); self.send_header("Content-Disposition","attachment; filename=prospects.csv"); self.send_header("Content-Length",str(len(body))); self.end_headers(); self.wfile.write(body); return
|
body = out.getvalue().encode(); self.send_response(200); self.send_header("Content-Type","text/csv; charset=utf-8"); self.send_header("Content-Disposition","attachment; filename=prospects.csv"); self.send_header("Content-Length",str(len(body))); self.end_headers(); self.wfile.write(body); return
|
||||||
if parsed.path == "/api/suppressions":
|
if parsed.path == "/api/suppressions":
|
||||||
return self.send_json(200, {"items": [dict(r) for r in conn.execute("SELECT * FROM suppressions ORDER BY id DESC")]})
|
return self.send_json(200, {"items": [dict(r) for r in conn.execute("SELECT * FROM suppressions ORDER BY id DESC")]})
|
||||||
if parsed.path == "/api/schedules":
|
if parsed.path == "/api/schedules":
|
||||||
return self.send_json(200, {"items": [dict(r) for r in conn.execute("SELECT * FROM schedules ORDER BY id DESC")]})
|
return self.send_json(200, {"items": [dict(r) for r in conn.execute("SELECT * FROM schedules ORDER BY id DESC")]})
|
||||||
|
match = re.fullmatch(r"/api/prospects/(\d+)/website-candidates", parsed.path)
|
||||||
|
if match:
|
||||||
|
prospect_id = int(match.group(1))
|
||||||
|
if not conn.execute("SELECT 1 FROM prospects WHERE id=?", (prospect_id,)).fetchone():
|
||||||
|
return self.send_json(404, {"error": "not_found"})
|
||||||
|
rows = conn.execute("SELECT * FROM website_candidates WHERE prospect_id=? ORDER BY confidence DESC, id DESC", (prospect_id,)).fetchall()
|
||||||
|
return self.send_json(200, {"items": [dict(row) for row in rows]})
|
||||||
match = re.fullmatch(r"/api/prospects/(\d+)/domains", parsed.path)
|
match = re.fullmatch(r"/api/prospects/(\d+)/domains", parsed.path)
|
||||||
if match:
|
if match:
|
||||||
row = conn.execute("SELECT * FROM prospects WHERE id=?", (int(match.group(1)),)).fetchone()
|
row = conn.execute("SELECT * FROM prospects WHERE id=?", (int(match.group(1)),)).fetchone()
|
||||||
@@ -1283,6 +1375,50 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self.send_json(502, {"error": str(exc)})
|
return self.send_json(502, {"error": str(exc)})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return self.send_json(502, {"error": "ai_test_failed:" + str(exc)[:200]})
|
return self.send_json(502, {"error": "ai_test_failed:" + str(exc)[:200]})
|
||||||
|
candidates_match = re.fullmatch(r"/api/prospects/(\d+)/website-candidates", urlparse(self.path).path)
|
||||||
|
if candidates_match:
|
||||||
|
prospect_id = int(candidates_match.group(1))
|
||||||
|
conn = db()
|
||||||
|
try:
|
||||||
|
row = conn.execute("SELECT * FROM prospects WHERE id=?", (prospect_id,)).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return self.send_json(404, {"error": "not_found"})
|
||||||
|
if not BROWSER_USE_ENABLED:
|
||||||
|
return self.send_json(503, {"error": "browser_use_disabled"})
|
||||||
|
try:
|
||||||
|
runtime = ai_runtime_settings()
|
||||||
|
candidates = investigate_websites(
|
||||||
|
dict(row), base_url=runtime["base_url"], api_key=runtime["api_key"],
|
||||||
|
model=runtime["model"], user_agent=OUTBOUND_USER_AGENT,
|
||||||
|
)
|
||||||
|
conn = db()
|
||||||
|
for candidate in candidates:
|
||||||
|
encoded_evidence = json.dumps(candidate.get("evidence") or [], ensure_ascii=False)
|
||||||
|
encoded_raw = json.dumps(candidate, ensure_ascii=False)
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO website_candidates(prospect_id,url,domain,confidence,evidence,source,status,discovered_at,raw_json)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(prospect_id,domain) DO UPDATE SET
|
||||||
|
url=excluded.url, confidence=excluded.confidence, evidence=excluded.evidence,
|
||||||
|
source=excluded.source, discovered_at=excluded.discovered_at, raw_json=excluded.raw_json,
|
||||||
|
status=CASE WHEN website_candidates.status='accepted' THEN 'accepted' ELSE 'candidate' END""",
|
||||||
|
(prospect_id, candidate["url"], candidate["domain"], candidate["confidence"], encoded_evidence,
|
||||||
|
"browser_use", "candidate", now(), encoded_raw),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
rows = conn.execute("SELECT * FROM website_candidates WHERE prospect_id=? ORDER BY confidence DESC, id DESC", (prospect_id,)).fetchall()
|
||||||
|
return self.send_json(200, {"items": [dict(item) for item in rows], "count": len(rows)})
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return self.send_json(503, {"error": str(exc)})
|
||||||
|
except Exception as exc:
|
||||||
|
return self.send_json(502, {"error": "browser_use_failed:" + str(exc)[:240]})
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
assess_match = re.fullmatch(r"/api/prospects/(\d+)/assess", urlparse(self.path).path)
|
assess_match = re.fullmatch(r"/api/prospects/(\d+)/assess", urlparse(self.path).path)
|
||||||
if assess_match:
|
if assess_match:
|
||||||
conn = None
|
conn = None
|
||||||
@@ -1315,7 +1451,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
payload = self.read_json(); offer = str(payload.get("offer") or "").strip()
|
payload = self.read_json(); offer = str(payload.get("offer") or "").strip()
|
||||||
if not offer: return self.send_json(400, {"error":"offer_required"})
|
if not offer: return self.send_json(400, {"error":"offer_required"})
|
||||||
draft = ai_generate_message(dict(row), offer)
|
draft = ai_generate_message(dict(row), offer)
|
||||||
stamp = now(); conn.execute("UPDATE prospects SET ai_subject=?,ai_message=?,ai_offer=?,ai_reasoning=?,ai_checked_at=? WHERE id=?", (draft["subject"],draft["message"],offer,draft["rationale"],stamp,int(message_match.group(1)))); conn.commit()
|
stamp = now(); conn.execute("UPDATE prospects SET ai_subject=?,ai_message=?,ai_offer=?,ai_reasoning=?,ai_unknowns=?,ai_evidence_ids=?,ai_follow_up=?,ai_checked_at=? WHERE id=?", (draft["subject"],draft["message"],offer,draft["rationale"],json.dumps(draft.get("unknowns") or [], ensure_ascii=False),json.dumps(draft.get("evidence_used") or [], ensure_ascii=False),draft.get("follow_up") or "",stamp,int(message_match.group(1)))); conn.commit()
|
||||||
return self.send_json(200, {**draft,"ai_checked_at":stamp,"prospect_id":int(message_match.group(1))})
|
return self.send_json(200, {**draft,"ai_checked_at":stamp,"prospect_id":int(message_match.group(1))})
|
||||||
except RuntimeError as exc: return self.send_json(503, {"error":str(exc)})
|
except RuntimeError as exc: return self.send_json(503, {"error":str(exc)})
|
||||||
except (ValueError, TypeError, json.JSONDecodeError) as exc: return self.send_json(400, {"error":str(exc)})
|
except (ValueError, TypeError, json.JSONDecodeError) as exc: return self.send_json(400, {"error":str(exc)})
|
||||||
@@ -1355,6 +1491,80 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self.send_json(400, {"error": str(exc)})
|
return self.send_json(400, {"error": str(exc)})
|
||||||
|
|
||||||
def do_PATCH(self) -> None:
|
def do_PATCH(self) -> None:
|
||||||
|
candidate_match = re.fullmatch(r"/api/prospects/(\d+)/website-candidates/(\d+)", urlparse(self.path).path)
|
||||||
|
if candidate_match:
|
||||||
|
prospect_id, candidate_id = (int(candidate_match.group(1)), int(candidate_match.group(2)))
|
||||||
|
conn: sqlite3.Connection | None = None
|
||||||
|
try:
|
||||||
|
payload = self.read_json()
|
||||||
|
status = str(payload.get("status", "")).strip().lower()
|
||||||
|
if status not in {"candidate", "accepted", "rejected"}:
|
||||||
|
return self.send_json(400, {"error": "invalid_website_candidate_status"})
|
||||||
|
conn = db()
|
||||||
|
candidate = conn.execute(
|
||||||
|
"SELECT * FROM website_candidates WHERE id=? AND prospect_id=?",
|
||||||
|
(candidate_id, prospect_id),
|
||||||
|
).fetchone()
|
||||||
|
prospect = conn.execute("SELECT * FROM prospects WHERE id=?", (prospect_id,)).fetchone()
|
||||||
|
if not candidate or not prospect:
|
||||||
|
return self.send_json(404, {"error": "not_found"})
|
||||||
|
if status != "accepted":
|
||||||
|
conn.execute("UPDATE website_candidates SET status=? WHERE id=?", (status, candidate_id))
|
||||||
|
conn.commit()
|
||||||
|
updated_candidate = conn.execute("SELECT * FROM website_candidates WHERE id=?", (candidate_id,)).fetchone()
|
||||||
|
return self.send_json(200, {"candidate": dict(updated_candidate)})
|
||||||
|
|
||||||
|
# The candidate is now a real catalogue domain. Check the
|
||||||
|
# catalogue before scanning so SQLite's unique domain index
|
||||||
|
# produces an actionable response instead of a generic 500.
|
||||||
|
duplicate = conn.execute(
|
||||||
|
"SELECT id FROM prospects WHERE domain=? AND id<>?",
|
||||||
|
(str(candidate["domain"]).lower(), prospect_id),
|
||||||
|
).fetchone()
|
||||||
|
if duplicate:
|
||||||
|
return self.send_json(409, {"error": "domain_already_catalogued", "prospect_id": int(duplicate["id"])})
|
||||||
|
candidate_url = normalize_url(candidate["url"])
|
||||||
|
if not candidate_url:
|
||||||
|
return self.send_json(400, {"error": "invalid_candidate_url"})
|
||||||
|
|
||||||
|
# Website probing is network-bound. Release the SQLite handle
|
||||||
|
# while it runs, then persist the selected URL and evidence in
|
||||||
|
# one short transaction.
|
||||||
|
base_item = {**dict(prospect), "website": candidate_url, "domain": str(candidate["domain"]).lower()}
|
||||||
|
conn.close()
|
||||||
|
conn = None
|
||||||
|
updated = enrich_place(base_item)
|
||||||
|
cols = [
|
||||||
|
"website", "domain", "website_status", "website_http_status", "website_final_url",
|
||||||
|
"website_latency_ms", "website_https", "website_title", "website_technology",
|
||||||
|
"website_has_viewport", "website_tls_expires_at", "website_checked_at", "website_error",
|
||||||
|
"emails", "email_evidence", "score_reasons", "opportunity_score",
|
||||||
|
]
|
||||||
|
conn = db()
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE website_candidates SET status='candidate' WHERE prospect_id=? AND id<>? AND status='accepted'",
|
||||||
|
(prospect_id, candidate_id),
|
||||||
|
)
|
||||||
|
conn.execute("UPDATE website_candidates SET status='accepted' WHERE id=? AND prospect_id=?", (candidate_id, prospect_id))
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE prospects SET " + ",".join(f"{column}=?" for column in cols) + " WHERE id=?",
|
||||||
|
tuple(updated.get(column) for column in cols) + (prospect_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
updated_candidate = conn.execute("SELECT * FROM website_candidates WHERE id=?", (candidate_id,)).fetchone()
|
||||||
|
updated_prospect = conn.execute("SELECT * FROM prospects WHERE id=?", (prospect_id,)).fetchone()
|
||||||
|
return self.send_json(200, {"candidate": dict(updated_candidate), "prospect": dict(updated_prospect)})
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
if conn is not None:
|
||||||
|
conn.rollback()
|
||||||
|
return self.send_json(409, {"error": "domain_already_catalogued"})
|
||||||
|
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||||
|
return self.send_json(400, {"error": str(exc)})
|
||||||
|
except Exception as exc:
|
||||||
|
return self.send_json(502, {"error": "website_candidate_accept_failed:" + str(exc)[:240]})
|
||||||
|
finally:
|
||||||
|
if conn is not None:
|
||||||
|
conn.close()
|
||||||
match = re.fullmatch(r"/api/prospects/(\d+)/review", urlparse(self.path).path)
|
match = re.fullmatch(r"/api/prospects/(\d+)/review", urlparse(self.path).path)
|
||||||
if not match:
|
if not match:
|
||||||
return self.send_json(404, {"error": "not_found"})
|
return self.send_json(404, {"error": "not_found"})
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ services:
|
|||||||
GMAPS_REQUEST_TIMEOUT: ${GMAPS_REQUEST_TIMEOUT:-900}
|
GMAPS_REQUEST_TIMEOUT: ${GMAPS_REQUEST_TIMEOUT:-900}
|
||||||
GMAPS_POLL_SECONDS: ${GMAPS_POLL_SECONDS:-2}
|
GMAPS_POLL_SECONDS: ${GMAPS_POLL_SECONDS:-2}
|
||||||
OUTBOUND_USER_AGENT: ${OUTBOUND_USER_AGENT:-ProspectOS/2.0 (+personal-use)}
|
OUTBOUND_USER_AGENT: ${OUTBOUND_USER_AGENT:-ProspectOS/2.0 (+personal-use)}
|
||||||
|
BROWSER_USE_ENABLED: ${BROWSER_USE_ENABLED:-true}
|
||||||
|
BROWSER_USE_MAX_STEPS: ${BROWSER_USE_MAX_STEPS:-10}
|
||||||
|
BROWSER_USE_TIMEOUT_SECONDS: ${BROWSER_USE_TIMEOUT_SECONDS:-120}
|
||||||
|
ANONYMIZED_TELEMETRY: ${ANONYMIZED_TELEMETRY:-false}
|
||||||
AI_PROVIDER: ${AI_PROVIDER:-custom}
|
AI_PROVIDER: ${AI_PROVIDER:-custom}
|
||||||
AI_BASE_URL: ${AI_BASE_URL:-}
|
AI_BASE_URL: ${AI_BASE_URL:-}
|
||||||
AI_API_KEY: ${AI_API_KEY:-}
|
AI_API_KEY: ${AI_API_KEY:-}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from http.client import HTTPConnection
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from api import server
|
from api import server
|
||||||
|
from api import browser_investigator
|
||||||
|
|
||||||
|
|
||||||
class BrowserOnlyAppTests(unittest.TestCase):
|
class BrowserOnlyAppTests(unittest.TestCase):
|
||||||
@@ -56,6 +57,8 @@ class BrowserOnlyAppTests(unittest.TestCase):
|
|||||||
self.assertEqual(status, 202)
|
self.assertEqual(status, 202)
|
||||||
first = self.wait_for_run(first["id"])
|
first = self.wait_for_run(first["id"])
|
||||||
self.assertEqual(first["added"], 2)
|
self.assertEqual(first["added"], 2)
|
||||||
|
saved = self.request("GET", "/api/prospects")[1]["items"]
|
||||||
|
self.assertTrue(all(item["search_category"] == "plumbers" for item in saved))
|
||||||
status, second = self.request("POST", "/api/discover", {"category": "plumbers", "keywords": "plumbing", "location": "Cape Town", "max_results": 5})
|
status, second = self.request("POST", "/api/discover", {"category": "plumbers", "keywords": "plumbing", "location": "Cape Town", "max_results": 5})
|
||||||
self.assertEqual(status, 202)
|
self.assertEqual(status, 202)
|
||||||
second = self.wait_for_run(second["id"])
|
second = self.wait_for_run(second["id"])
|
||||||
@@ -153,6 +156,48 @@ class BrowserOnlyAppTests(unittest.TestCase):
|
|||||||
saved = server.db().execute("SELECT ai_probability,ai_recommendation,ai_reasoning FROM prospects WHERE id=1").fetchone()
|
saved = server.db().execute("SELECT ai_probability,ai_recommendation,ai_reasoning FROM prospects WHERE id=1").fetchone()
|
||||||
self.assertEqual(tuple(saved), (84.0, "contact", "Active business with a clear website opportunity."))
|
self.assertEqual(tuple(saved), (84.0, "contact", "Active business with a clear website opportunity."))
|
||||||
|
|
||||||
|
def test_browser_investigator_normalises_and_filters_candidates(self):
|
||||||
|
raw = '{"candidates":[{"url":"https://www.acme.co.za/about","confidence":88,"evidence":["name match"]},{"url":"https://facebook.com/acme","confidence":99},{"domain":"other.co.za","confidence":"42","evidence":"not a list"}]}'
|
||||||
|
candidates = browser_investigator._parse_agent_result(raw)
|
||||||
|
self.assertEqual([item["domain"] for item in candidates], ["acme.co.za", "other.co.za"])
|
||||||
|
self.assertEqual(candidates[0]["confidence"], 88)
|
||||||
|
self.assertEqual(candidates[1]["evidence"], [])
|
||||||
|
|
||||||
|
def test_browser_website_candidates_can_be_found_and_accepted(self):
|
||||||
|
conn = server.db()
|
||||||
|
conn.execute("INSERT INTO prospects(name,domain,category,address,discovered_at) VALUES(?,?,?,?,?)", ("Acme Plumbing", "maps:abc", "plumber", "Cape Town", server.now()))
|
||||||
|
conn.commit(); conn.close()
|
||||||
|
found = [{"url": "https://acmeplumbing.co.za", "domain": "acmeplumbing.co.za", "confidence": 91, "evidence": ["name match", "location match"]}]
|
||||||
|
with patch("api.server.investigate_websites", return_value=found):
|
||||||
|
status, body = self.request("POST", "/api/prospects/1/website-candidates", {})
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(body["count"], 1)
|
||||||
|
candidate_id = body["items"][0]["id"]
|
||||||
|
status, body = self.request("GET", "/api/prospects/1/website-candidates")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(body["items"][0]["domain"], "acmeplumbing.co.za")
|
||||||
|
status, body = self.request("PATCH", f"/api/prospects/1/website-candidates/{candidate_id}", {"status": "accepted"})
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(body["candidate"]["status"], "accepted")
|
||||||
|
self.assertEqual(body["prospect"]["domain"], "acmeplumbing.co.za")
|
||||||
|
self.assertEqual(body["prospect"]["website"], "https://acmeplumbing.co.za")
|
||||||
|
|
||||||
|
def test_email_draft_returns_structured_review_notes(self):
|
||||||
|
conn = server.db()
|
||||||
|
conn.execute("INSERT INTO prospects(name,domain,search_category,category,address,maps_url,website_status,emails,opportunity_score,score_reasons,discovered_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", ("Acme Plumbing", "maps:abc", "plumbers", "Plumber", "Cape Town", "https://maps.example/acme", "no_detected_website", "acme@gmail.com", 88, '["No detected website","Public email found"]', server.now()))
|
||||||
|
for key, value in (("AI_PROVIDER", "custom"), ("AI_BASE_URL", "https://ai.example/v1"), ("AI_MODEL", "test-model"), ("AI_API_KEY", "test-key")):
|
||||||
|
conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?)", (key, value, server.now()))
|
||||||
|
conn.commit(); conn.close()
|
||||||
|
content = json.dumps({"subject": "A website idea for Acme Plumbing", "message": "Good day Acme Plumbing,\n\nI noticed your public listing does not show a dedicated website. We can help with a professional online presence. Would you like me to send a few details?\n\nKind regards", "rationale": "Uses the missing-website signal and the supplied offer.", "recommended_offer": "Gold Plus hosting and a free one-page website", "unknowns": ["Confirm the preferred contact person"], "evidence_used": ["prospect_record", "public_maps_listing", "public_email_listing"], "follow_up": "Confirm the offer and recipient before sending."})
|
||||||
|
with patch("api.server.http_json", return_value={"choices": [{"message": {"content": content}}]}):
|
||||||
|
status, body = self.request("POST", "/api/prospects/1/message", {"offer": "Gold Plus hosting at R119/month with a free one-page website."})
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(body["unknowns"], ["Confirm the preferred contact person"])
|
||||||
|
self.assertEqual(body["evidence_used"], ["prospect_record", "public_maps_listing", "public_email_listing"])
|
||||||
|
saved = server.db().execute("SELECT ai_unknowns,ai_evidence_ids,ai_follow_up FROM prospects WHERE id=1").fetchone()
|
||||||
|
self.assertEqual(json.loads(saved[0]), body["unknowns"])
|
||||||
|
self.assertEqual(json.loads(saved[1]), body["evidence_used"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+19
-6
@@ -1,28 +1,41 @@
|
|||||||
const $=id=>document.getElementById(id); const esc=v=>String(v??"").replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[c]));
|
const $=id=>document.getElementById(id); const esc=v=>String(v??"").replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[c]));
|
||||||
let prospects=[],runs=[],settings={},selected=null,pollTimer=null;
|
let prospects=[],runs=[],settings={},selected=null,pollTimer=null;
|
||||||
|
let activeProspectCategory='all';
|
||||||
|
const HIGH_POTENTIAL_SCORE=70;
|
||||||
async function api(path,opt={}){const r=await fetch(path,{...opt,headers:{'Content-Type':'application/json',...(opt.headers||{})}});const b=await r.json().catch(()=>({}));if(!r.ok)throw Error(b.error||`Request failed (${r.status})`);return b}
|
async function api(path,opt={}){const r=await fetch(path,{...opt,headers:{'Content-Type':'application/json',...(opt.headers||{})}});const b=await r.json().catch(()=>({}));if(!r.ok)throw Error(b.error||`Request failed (${r.status})`);return b}
|
||||||
function date(v){if(!v)return'—';const d=new Date(v);return isNaN(d)?String(v):d.toLocaleString(undefined,{day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})}
|
function date(v){if(!v)return'—';const d=new Date(v);return isNaN(d)?String(v):d.toLocaleString(undefined,{day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})}
|
||||||
function layout(title,sub,body,actions=''){ $('pageName').textContent=title; return `<section class="page-head"><div><span class="eyebrow">PROSPECTOS</span><h1>${title}</h1><p>${sub}</p></div><div class="head-actions">${actions}</div></section>${body}` }
|
function layout(title,sub,body,actions=''){ $('pageName').textContent=title; return `<section class="page-head"><div><span class="eyebrow">PROSPECTOS</span><h1>${title}</h1><p>${sub}</p></div><div class="head-actions">${actions}</div></section>${body}` }
|
||||||
function empty(text){return `<div class="empty"><span>⌕</span><b>${text}</b></div>`}
|
function empty(text){return `<div class="empty"><span>⌕</span><b>${text}</b></div>`}
|
||||||
function scoreClass(n){return n>=75?'hot':n>=55?'warm':'cool'}
|
function scoreClass(n){return n>=75?'hot':n>=55?'warm':'cool'}
|
||||||
function prospectRows(){const q=($('prospectSearch')?.value||'').toLowerCase();const st=$('prospectStatus')?.value||'';return prospects.filter(p=>{const hay=[p.name,p.domain,p.category,p.address,p.emails].join(' ').toLowerCase();return(!q||hay.includes(q))&&(!st||p.review_status===st)})}
|
function prospectCategory(p){const raw=String(p.search_category||p.category||'').trim();return raw?raw.replace(/\s+/g,' ').replace(/^./,c=>c.toUpperCase()):'Other'}
|
||||||
function prospectTable(){const rows=prospectRows();return `<div class="table-wrap"><table><thead><tr><th>Prospect</th><th>Website</th><th>Email</th><th>Score</th><th>Review</th><th></th></tr></thead><tbody>${rows.map(p=>`<tr data-open="${p.id}"><td><b>${esc(p.name)}</b><small>${esc(p.category||'Business')} · ${esc(p.address||'')}</small></td><td><span class="badge ${p.website_status==='healthy'?'good':'warn'}">${esc((p.website_status||'unknown').replaceAll('_',' '))}</span>${p.website?`<a href="${esc(p.website)}" target="_blank" rel="noopener">Open ↗</a>`:'<small>No detected website</small>'}</td><td>${p.emails?`<span class="email">✉ ${esc(p.emails)}</span>`:'<small>Not found</small>'}</td><td><span class="score ${scoreClass(Number(p.opportunity_score||0))}">${p.opportunity_score||0}</span></td><td><select data-review="${p.id}"><option value="pending" ${p.review_status==='pending'?'selected':''}>Needs review</option><option value="approved" ${p.review_status==='approved'?'selected':''}>Approved</option><option value="rejected" ${p.review_status==='rejected'?'selected':''}>Rejected</option><option value="contacted" ${p.review_status==='contacted'?'selected':''}>Contacted</option></select></td><td><button class="icon-action" data-message="${p.id}" title="Draft AI email">✦</button></td></tr>`).join('')}</tbody></table></div>${rows.length?'':empty('No prospects match these filters')}`}
|
function prospectTabs(){const categories=[...new Set(prospects.map(prospectCategory))].sort((a,b)=>a.localeCompare(b));if(activeProspectCategory!=='all'&&!categories.includes(activeProspectCategory))activeProspectCategory='all';const count=category=>category==='all'?prospects.filter(p=>Number(p.opportunity_score||0)>=HIGH_POTENTIAL_SCORE).length:prospects.filter(p=>prospectCategory(p)===category).length;return `<div class="prospect-tab-list"><button class="prospect-tab ${activeProspectCategory==='all'?'active':''}" data-category-tab="all">All high potential <span>${count('all')}</span></button>${categories.map(category=>`<button class="prospect-tab ${activeProspectCategory===category?'active':''}" data-category-tab="${esc(category)}">${esc(category)} <span>${count(category)}</span></button>`).join('')}</div><p class="tab-hint">All shows scores ${HIGH_POTENTIAL_SCORE}+; category tabs keep every result grouped by the search category.</p>`}
|
||||||
|
function prospectRows(){const q=($('prospectSearch')?.value||'').toLowerCase();const st=$('prospectStatus')?.value||'';return prospects.filter(p=>{const hay=[p.name,p.domain,p.search_category,p.category,p.address,p.emails].join(' ').toLowerCase();const tabMatch=activeProspectCategory==='all'?Number(p.opportunity_score||0)>=HIGH_POTENTIAL_SCORE:prospectCategory(p)===activeProspectCategory;return tabMatch&&(!q||hay.includes(q))&&(!st||p.review_status===st)}).sort((a,b)=>Number(b.opportunity_score||0)-Number(a.opportunity_score||0)||String(a.name||'').localeCompare(String(b.name||'')))}
|
||||||
|
function prospectTable(){const rows=prospectRows();return `<div class="table-wrap"><table><thead><tr><th>Prospect</th><th>Website</th><th>Email</th><th>Potential</th><th>Review</th><th></th></tr></thead><tbody>${rows.map(p=>`<tr data-open="${p.id}"><td><b>${esc(p.name)}</b><small>${esc(prospectCategory(p))} · ${esc(p.address||'')}</small></td><td><span class="badge ${p.website_status==='healthy'?'good':'warn'}">${esc((p.website_status||'unknown').replaceAll('_',' '))}</span>${p.website?`<a href="${esc(p.website)}" target="_blank" rel="noopener">Open ↗</a>`:'<small>No detected website</small>'}</td><td>${p.emails?`<span class="email">✉ ${esc(p.emails)}</span>`:'<small>Not found</small>'}</td><td><span class="score ${scoreClass(Number(p.opportunity_score||0))}">${p.opportunity_score||0}</span><small>${Number(p.opportunity_score||0)>=85?'Hot':Number(p.opportunity_score||0)>=70?'High':Number(p.opportunity_score||0)>=45?'Medium':'Low'}</small></td><td><select data-review="${p.id}"><option value="pending" ${p.review_status==='pending'?'selected':''}>Needs review</option><option value="approved" ${p.review_status==='approved'?'selected':''}>Approved</option><option value="rejected" ${p.review_status==='rejected'?'selected':''}>Rejected</option><option value="contacted" ${p.review_status==='contacted'?'selected':''}>Contacted</option></select></td><td><button class="icon-action" data-message="${p.id}" title="Draft AI email">✦</button></td></tr>`).join('')}</tbody></table></div>${rows.length?'':empty('No prospects match this tab and filter')}`}
|
||||||
function renderDashboard(){const total=prospects.length,pending=prospects.filter(p=>p.review_status==='pending').length,hot=prospects.filter(p=>p.opportunity_score>=75).length,withEmail=prospects.filter(p=>p.emails).length;const latest=runs[0];$('view').innerHTML=layout('Dashboard','A clear view of what was found, what needs review, and where the best opportunities are.',`<div class="metric-grid"><div class="metric"><small>CATALOGUE</small><b>${total}</b><span>saved prospects</span></div><div class="metric"><small>NEEDS REVIEW</small><b>${pending}</b><span>awaiting your decision</span></div><div class="metric"><small>HIGH OPPORTUNITY</small><b>${hot}</b><span>score 75 or higher</span></div><div class="metric"><small>PUBLIC EMAILS</small><b>${withEmail}</b><span>evidence-backed contacts</span></div></div><div class="dashboard-grid"><article class="panel"><div class="panel-title"><div><span class="eyebrow">NEXT ACTION</span><h2>Review your strongest leads</h2><p>Inspect the evidence before deciding whether to contact them.</p></div><a class="primary" href="#prospects">Open explorer →</a></div><div class="spotlight">${prospects.slice(0,5).map(p=>`<button class="spot" data-open="${p.id}"><span class="score ${scoreClass(p.opportunity_score)}">${p.opportunity_score}</span><span><b>${esc(p.name)}</b><small>${esc(p.website_status?.replaceAll('_',' ')||'unknown')} · ${esc(p.emails||'no public email')}</small></span><span>›</span></button>`).join('')||empty('Run a discovery to build your catalogue')}</div></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">RECENT ACTIVITY</span><h2>Runs</h2></div><a href="#runs">View all</a></div><div class="activity">${runs.slice(0,6).map(r=>`<div><span class="run-dot ${r.status}"></span><span><b>#${r.id} · ${esc(r.query)}</b><small>${esc(r.status)} · ${r.added||0} added</small></span><time>${date(r.created_at)}</time></div>`).join('')||empty('No runs yet')}</div></article></div>`)}
|
function renderDashboard(){const total=prospects.length,pending=prospects.filter(p=>p.review_status==='pending').length,hot=prospects.filter(p=>p.opportunity_score>=75).length,withEmail=prospects.filter(p=>p.emails).length;const latest=runs[0];$('view').innerHTML=layout('Dashboard','A clear view of what was found, what needs review, and where the best opportunities are.',`<div class="metric-grid"><div class="metric"><small>CATALOGUE</small><b>${total}</b><span>saved prospects</span></div><div class="metric"><small>NEEDS REVIEW</small><b>${pending}</b><span>awaiting your decision</span></div><div class="metric"><small>HIGH OPPORTUNITY</small><b>${hot}</b><span>score 75 or higher</span></div><div class="metric"><small>PUBLIC EMAILS</small><b>${withEmail}</b><span>evidence-backed contacts</span></div></div><div class="dashboard-grid"><article class="panel"><div class="panel-title"><div><span class="eyebrow">NEXT ACTION</span><h2>Review your strongest leads</h2><p>Inspect the evidence before deciding whether to contact them.</p></div><a class="primary" href="#prospects">Open explorer →</a></div><div class="spotlight">${prospects.slice(0,5).map(p=>`<button class="spot" data-open="${p.id}"><span class="score ${scoreClass(p.opportunity_score)}">${p.opportunity_score}</span><span><b>${esc(p.name)}</b><small>${esc(p.website_status?.replaceAll('_',' ')||'unknown')} · ${esc(p.emails||'no public email')}</small></span><span>›</span></button>`).join('')||empty('Run a discovery to build your catalogue')}</div></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">RECENT ACTIVITY</span><h2>Runs</h2></div><a href="#runs">View all</a></div><div class="activity">${runs.slice(0,6).map(r=>`<div><span class="run-dot ${r.status}"></span><span><b>#${r.id} · ${esc(r.query)}</b><small>${esc(r.status)} · ${r.added||0} added</small></span><time>${date(r.created_at)}</time></div>`).join('')||empty('No runs yet')}</div></article></div>`)}
|
||||||
function renderDiscover(){ $('view').innerHTML=layout('Discover','Set the criteria for this run. Source configuration and provider settings live in Settings.',`<div class="two-col"><article class="panel form-panel"><div class="panel-title"><div><span class="eyebrow">NEW RUN</span><h2>Find potential customers</h2><p>Search Google Maps, deduplicate the catalogue, then enrich each result.</p></div><span class="pill">Email collection always on</span></div><form id="discoverForm"><label>Keywords<input id="keywords" required placeholder="plumbing services"></label><label>Category<input id="category" required placeholder="plumbers"></label><label>Location<input id="location" value="South Africa" required></label><label>Maximum results<select id="max-results"><option>10</option><option selected>25</option><option>50</option></select></label><label class="checkline"><input type="checkbox" id="extra-reviews"> Collect extra reviews <small>May increase run time</small></label><div class="form-row"><button class="primary" type="submit" id="discoverBtn">Run discovery →</button><span id="discoverMessage" class="notice"></span></div></form></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">HOW IT WORKS</span><h2>Evidence-first pipeline</h2></div></div><ol class="steps"><li><b>Search</b><span>Maps worker finds public business listings.</span></li><li><b>Enrich</b><span>Website, HTTPS, DNS hints and public email checks run safely.</span></li><li><b>Review</b><span>You decide what is relevant before any contact.</span></li></ol></article></div>`)}
|
function renderDiscover(){ $('view').innerHTML=layout('Discover','Set the criteria for this run. Source configuration and provider settings live in Settings.',`<div class="two-col"><article class="panel form-panel"><div class="panel-title"><div><span class="eyebrow">NEW RUN</span><h2>Find potential customers</h2><p>Search Google Maps, deduplicate the catalogue, then enrich each result.</p></div><span class="pill">Email collection always on</span></div><form id="discoverForm"><label>Keywords<input id="keywords" required placeholder="plumbing services"></label><label>Category<input id="category" required placeholder="plumbers"></label><label>Location<input id="location" value="South Africa" required></label><label>Maximum results<select id="max-results"><option>10</option><option selected>25</option><option>50</option></select></label><label class="checkline"><input type="checkbox" id="extra-reviews"> Collect extra reviews <small>May increase run time</small></label><div class="form-row"><button class="primary" type="submit" id="discoverBtn">Run discovery →</button><span id="discoverMessage" class="notice"></span></div></form></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">HOW IT WORKS</span><h2>Evidence-first pipeline</h2></div></div><ol class="steps"><li><b>Search</b><span>Maps worker finds public business listings.</span></li><li><b>Enrich</b><span>Website, HTTPS, DNS hints and public email checks run safely.</span></li><li><b>Review</b><span>You decide what is relevant before any contact.</span></li></ol></article></div>`)}
|
||||||
function renderProspects(){ $('view').innerHTML=layout('Prospects','Search, filter and review your catalogue. Click a row to inspect the evidence.',`<article class="panel"><div class="toolbar"><div class="search"><span>⌕</span><input id="prospectSearch" placeholder="Search name, address, domain or email"></div><select id="prospectStatus"><option value="">All review statuses</option><option value="pending">Needs review</option><option value="approved">Approved</option><option value="rejected">Rejected</option><option value="contacted">Contacted</option></select><button class="secondary" id="exportCsv">Export CSV</button><button class="secondary" id="refreshProspects">Refresh</button></div><div id="prospectTable">${prospectTable()}</div></article><aside id="detail" class="detail-panel" hidden></aside>`)}
|
function renderProspects(){ $('view').innerHTML=layout('Prospects','Browse the strongest opportunities first, then switch to a search category when you want a focused list.',`<article class="panel"><div class="toolbar"><div class="search"><span>⌕</span><input id="prospectSearch" placeholder="Search name, address, domain or email"></div><select id="prospectStatus"><option value="">All review statuses</option><option value="pending">Needs review</option><option value="approved">Approved</option><option value="rejected">Rejected</option><option value="contacted">Contacted</option></select><button class="secondary" id="exportCsv">Export CSV</button><button class="secondary" id="refreshProspects">Refresh</button></div><div id="prospectTabs" class="prospect-tabs">${prospectTabs()}</div><div id="prospectTable">${prospectTable()}</div></article><aside id="detail" class="detail-panel" hidden></aside>`)}
|
||||||
function renderRuns(){ $('view').innerHTML=layout('Runs','Monitor discovery jobs and inspect errors without leaving the run history.',`<article class="panel"><div class="table-wrap"><table><thead><tr><th>Run</th><th>Status</th><th>Results</th><th>Started</th><th>Source</th></tr></thead><tbody>${runs.map(r=>`<tr><td><b>#${r.id}</b><small>${esc(r.query)}</small></td><td><span class="status ${r.status}">${esc(r.status)}</span><small>${esc(r.stage||'')}</small></td><td>${r.added||0} added / ${r.found||0} found</td><td>${date(r.created_at)}</td><td>${esc(r.source||'google_maps')}</td></tr>`).join('')}</tbody></table></div>${runs.length?'':empty('No discovery runs yet')}</article>`)}
|
function renderRuns(){ $('view').innerHTML=layout('Runs','Monitor discovery jobs and inspect errors without leaving the run history.',`<article class="panel"><div class="table-wrap"><table><thead><tr><th>Run</th><th>Status</th><th>Results</th><th>Started</th><th>Source</th></tr></thead><tbody>${runs.map(r=>`<tr><td><b>#${r.id}</b><small>${esc(r.query)}</small></td><td><span class="status ${r.status}">${esc(r.status)}</span><small>${esc(r.stage||'')}</small></td><td>${r.added||0} added / ${r.found||0} found</td><td>${date(r.created_at)}</td><td>${esc(r.source||'google_maps')}</td></tr>`).join('')}</tbody></table></div>${runs.length?'':empty('No discovery runs yet')}</article>`)}
|
||||||
function renderSettings(){const ai=settings.ai||{};const sch=settings.schedules||[];const sup=settings.suppressions||[];const provider=ai.provider||'custom';const modelList=Array.isArray(ai.models)?ai.models:[];const selectedModel=ai.model||'';const options=modelList.map(m=>`<option value="${esc(m.id)}">${esc(m.label||m.id)}${m.free?' · free':''}</option>`).join('')+(selectedModel&&!modelList.some(m=>m.id===selectedModel)?`<option value="${esc(selectedModel)}">${esc(selectedModel)}</option>`:'');$('view').innerHTML=layout('Settings','Configure providers, automation and safety controls here. These settings are separate from daily discovery.',`<div class="settings-grid"><article class="panel"><div class="panel-title"><div><span class="eyebrow">SOURCE</span><h2>Google Maps worker</h2><p>The bundled standard gosom worker runs locally and does not require an API key.</p></div><span class="status ${settings.maps?.ready?'succeeded':'failed'}">${settings.maps?.ready?'Ready':'Not connected'}</span></div><div class="setting-line"><span>Connection</span><b>${esc(settings.maps?.worker_url||'Not configured')}</b></div><div class="setting-line"><span>Email extraction</span><b class="good-text">Always on · public pages only</b></div><div class="setting-line"><span>Website scan</span><b>8 second bounded timeout</b></div></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">AI PROVIDER</span><h2>Draft assistant</h2><p>Used only when you click “Draft AI email”.</p></div><span class="status ${ai.configured?'succeeded':'failed'}">${ai.configured?'Configured':'Not configured'}</span></div><form id="settingsForm"><label>Provider<select id="aiProvider"><option value="nous" ${provider==='nous'?'selected':''}>Nous Portal · free models</option><option value="custom" ${provider!=='nous'?'selected':''}>Custom OpenAI-compatible</option></select></label><p class="field-help" id="aiProviderNote">${provider==='nous'?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':'Use any OpenAI-compatible /v1 endpoint.'}</p><label>Base URL<input id="aiBase" value="${esc(ai.base_url||'')}" ${provider==='nous'?'readonly':''} placeholder="https://provider.example/v1"></label><label>API key<input id="aiKey" type="password" placeholder="Leave blank to keep current key"></label><label>Model<input id="aiModel" list="nousModelList" value="${esc(selectedModel)}" placeholder="model-name"><datalist id="nousModelList">${options}</datalist></label><p class="field-help">Nous model availability can change. Refresh Settings to update the free catalog.</p><div class="form-row"><button class="primary" type="submit">Save AI settings</button><button class="secondary" id="testAi" type="button">Test connection</button><span id="settingsMessage" class="notice"></span></div></form></article><article class="panel wide-panel"><div class="panel-title"><div><span class="eyebrow">SCHEDULES</span><h2>Recurring discovery</h2><p>Run bounded searches automatically; all results still arrive as needs-review.</p></div></div><form id="scheduleForm" class="inline-form"><input id="sCat" placeholder="Category"><input id="sKey" placeholder="Keywords"><input id="sLoc" value="South Africa" placeholder="Location"><select id="sHours"><option value="24">Daily</option><option value="48">Every 2 days</option><option value="168">Weekly</option></select><button class="secondary">Add schedule</button></form><div class="list">${sch.map(s=>`<div><b>${esc(s.keywords)} · ${esc(s.category)}</b><span>${esc(s.location)} · every ${s.interval_hours}h · next ${date(s.next_run_at)}</span></div>`).join('')||'<p class="muted">No schedules configured.</p>'}</div></article><article class="panel wide-panel"><div class="panel-title"><div><span class="eyebrow">SAFETY</span><h2>Suppression centre</h2><p>Suppressed domains and emails are excluded before new prospects are saved.</p></div></div><form id="suppressionForm" class="inline-form"><select id="supKind"><option value="domain">Domain</option><option value="email">Email</option></select><input id="supValue" placeholder="example.co.za or info@example.co.za"><input id="supReason" placeholder="Reason"><button class="secondary">Suppress</button></form><div class="list">${sup.map(s=>`<div><b>${esc(s.value)}</b><span>${esc(s.kind)} · ${esc(s.reason||'manual')} · ${date(s.created_at)}</span></div>`).join('')||'<p class="muted">No suppressions configured.</p>'}</div></article></div>`)}
|
function renderSettings(){const ai=settings.ai||{};const sch=settings.schedules||[];const sup=settings.suppressions||[];const provider=ai.provider||'custom';const modelList=Array.isArray(ai.models)?ai.models:[];const selectedModel=ai.model||'';const options=modelList.map(m=>`<option value="${esc(m.id)}">${esc(m.label||m.id)}${m.free?' · free':''}</option>`).join('')+(selectedModel&&!modelList.some(m=>m.id===selectedModel)?`<option value="${esc(selectedModel)}">${esc(selectedModel)}</option>`:'');$('view').innerHTML=layout('Settings','Configure providers, automation and safety controls here. These settings are separate from daily discovery.',`<div class="settings-grid"><article class="panel"><div class="panel-title"><div><span class="eyebrow">SOURCE</span><h2>Google Maps worker</h2><p>The bundled standard gosom worker runs locally and does not require an API key.</p></div><span class="status ${settings.maps?.ready?'succeeded':'failed'}">${settings.maps?.ready?'Ready':'Not connected'}</span></div><div class="setting-line"><span>Connection</span><b>${esc(settings.maps?.worker_url||'Not configured')}</b></div><div class="setting-line"><span>Email extraction</span><b class="good-text">Always on · public pages only</b></div><div class="setting-line"><span>Website scan</span><b>8 second bounded timeout</b></div></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">AI PROVIDER</span><h2>Draft assistant</h2><p>Used only when you click “Draft AI email”.</p></div><span class="status ${ai.configured?'succeeded':'failed'}">${ai.configured?'Configured':'Not configured'}</span></div><form id="settingsForm"><label>Provider<select id="aiProvider"><option value="nous" ${provider==='nous'?'selected':''}>Nous Portal · free models</option><option value="custom" ${provider!=='nous'?'selected':''}>Custom OpenAI-compatible</option></select></label><p class="field-help" id="aiProviderNote">${provider==='nous'?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':'Use any OpenAI-compatible /v1 endpoint.'}</p><label>Base URL<input id="aiBase" value="${esc(ai.base_url||'')}" ${provider==='nous'?'readonly':''} placeholder="https://provider.example/v1"></label><label>API key<input id="aiKey" type="password" placeholder="Leave blank to keep current key"></label><label>Model<input id="aiModel" list="nousModelList" value="${esc(selectedModel)}" placeholder="model-name"><datalist id="nousModelList">${options}</datalist></label><p class="field-help">Nous model availability can change. Refresh Settings to update the free catalog.</p><div class="form-row"><button class="primary" type="submit">Save AI settings</button><button class="secondary" id="testAi" type="button">Test connection</button><span id="settingsMessage" class="notice"></span></div></form></article><article class="panel wide-panel"><div class="panel-title"><div><span class="eyebrow">SCHEDULES</span><h2>Recurring discovery</h2><p>Run bounded searches automatically; all results still arrive as needs-review.</p></div></div><form id="scheduleForm" class="inline-form"><input id="sCat" placeholder="Category"><input id="sKey" placeholder="Keywords"><input id="sLoc" value="South Africa" placeholder="Location"><select id="sHours"><option value="24">Daily</option><option value="48">Every 2 days</option><option value="168">Weekly</option></select><button class="secondary">Add schedule</button></form><div class="list">${sch.map(s=>`<div><b>${esc(s.keywords)} · ${esc(s.category)}</b><span>${esc(s.location)} · every ${s.interval_hours}h · next ${date(s.next_run_at)}</span></div>`).join('')||'<p class="muted">No schedules configured.</p>'}</div></article><article class="panel wide-panel"><div class="panel-title"><div><span class="eyebrow">SAFETY</span><h2>Suppression centre</h2><p>Suppressed domains and emails are excluded before new prospects are saved.</p></div></div><form id="suppressionForm" class="inline-form"><select id="supKind"><option value="domain">Domain</option><option value="email">Email</option></select><input id="supValue" placeholder="example.co.za or info@example.co.za"><input id="supReason" placeholder="Reason"><button class="secondary">Suppress</button></form><div class="list">${sup.map(s=>`<div><b>${esc(s.value)}</b><span>${esc(s.kind)} · ${esc(s.reason||'manual')} · ${date(s.created_at)}</span></div>`).join('')||'<p class="muted">No suppressions configured.</p>'}</div></article></div>`)}
|
||||||
async function load(){[prospects,runs,settings]=await Promise.all([api('/api/prospects').then(x=>x.items||[]),api('/api/runs').then(x=>x.items||[]),Promise.all([api('/api/settings'),api('/api/schedules'),api('/api/suppressions')]).then(([s,sch,sup])=>({...s,schedules:sch.items||[],suppressions:sup.items||[]}))]);}
|
async function load(){[prospects,runs,settings]=await Promise.all([api('/api/prospects').then(x=>x.items||[]),api('/api/runs').then(x=>x.items||[]),Promise.all([api('/api/settings'),api('/api/schedules'),api('/api/suppressions')]).then(([s,sch,sup])=>({...s,schedules:sch.items||[],suppressions:sup.items||[]}))]);}
|
||||||
async function route(){const r=location.hash.slice(1)||'dashboard';document.querySelectorAll('[data-route]').forEach(a=>a.classList.toggle('active',a.dataset.route===r));try{await load();({dashboard:renderDashboard,discover:renderDiscover,prospects:renderProspects,runs:renderRuns,settings:renderSettings}[r]||renderDashboard)();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});}catch(e){$('view').innerHTML=empty(e.message)}}
|
async function route(){const r=location.hash.slice(1)||'dashboard';document.querySelectorAll('[data-route]').forEach(a=>a.classList.toggle('active',a.dataset.route===r));try{await load();({dashboard:renderDashboard,discover:renderDiscover,prospects:renderProspects,runs:renderRuns,settings:renderSettings}[r]||renderDashboard)();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});}catch(e){$('view').innerHTML=empty(e.message)}}
|
||||||
async function openMessage(id){selected=prospects.find(p=>String(p.id)===String(id));if(!selected)return;$('messageTitle').textContent='Draft for '+selected.name;$('offerInput').value='';$('subjectOutput').value=selected.ai_subject||'';$('bodyOutput').value=selected.ai_message||'';$('messageStatus').textContent='';$('messageDialog').showModal()}
|
function suggestedOffer(p){if(p.ai_offer)return p.ai_offer;if(['broken','unreachable','error','slow'].includes(String(p.website_status||'')))return'Move their hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge.';if(!p.website)return'Offer a professional .co.za domain, reliable hosting from R119/month, professional email, and a free one-page website.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'}
|
||||||
|
function jsonArray(value){try{const parsed=JSON.parse(value||'[]');return Array.isArray(parsed)?parsed:[]}catch{return[]}}
|
||||||
|
function showDraftNotes(d){let notes=$('draftNotes');if(!notes){notes=document.createElement('div');notes.id='draftNotes';notes.className='draft-notes';const status=$('messageStatus');if(status)status.parentNode.insertBefore(notes,status)}if(!notes)return;const unknowns=Array.isArray(d?.unknowns)?d.unknowns:[];const used=Array.isArray(d?.evidence_used)?d.evidence_used:[];notes.innerHTML=`<b>AI review notes</b><p>${esc(d?.rationale||'The draft uses the prospect evidence and your instructions.')}</p>${unknowns.length?`<p><b>Confirm before sending:</b> ${esc(unknowns.join(' · '))}</p>`:''}${used.length?`<p class="muted">Evidence used: ${esc(used.join(', '))}</p>`:''}${d?.follow_up?`<p class="muted">${esc(d.follow_up)}</p>`:''}`}
|
||||||
|
async function openMessage(id){selected=prospects.find(p=>String(p.id)===String(id));if(!selected)return;$('messageTitle').textContent='Draft for '+selected.name;$('offerInput').value=suggestedOffer(selected);$('subjectOutput').value=selected.ai_subject||'';$('bodyOutput').value=selected.ai_message||'';$('messageStatus').textContent='';showDraftNotes({rationale:selected.ai_reasoning,unknowns:jsonArray(selected.ai_unknowns),evidence_used:jsonArray(selected.ai_evidence_ids),follow_up:selected.ai_follow_up});$('messageDialog').showModal()}
|
||||||
function openDrawer(id){const p=prospects.find(x=>String(x.id)===String(id));if(!p)return;selected=p;$('drawerName').textContent=p.name;let reasons=[];try{reasons=JSON.parse(p.score_reasons||'[]')}catch{};$('drawerBody').innerHTML=`<div class="drawer-score"><span class="score ${scoreClass(Number(p.opportunity_score||0))}">${p.opportunity_score||0}</span><div><b>Opportunity score</b><small>Explainable triage signal, not an AI verdict</small></div></div><div class="drawer-section"><h3>Contact</h3><p>${esc(p.emails||'No public email found')}</p><p>${esc(p.phone||'No phone listed')}</p></div><div class="drawer-section"><h3>Website health</h3><div class="detail-grid"><span>Status<b>${esc((p.website_status||'unknown').replaceAll('_',' '))}</b></span><span>HTTPS<b>${p.website_https?'Yes':'No / unknown'}</b></span><span>Latency<b>${p.website_latency_ms?p.website_latency_ms+' ms':'—'}</b></span><span>Checked<b>${date(p.website_checked_at)}</b></span></div></div><div class="drawer-section"><h3>Why this scored</h3><ul>${reasons.map(r=>`<li>${esc(r)}</li>`).join('')||'<li>No score reasons recorded</li>'}</ul></div><div class="drawer-actions"><button class="primary" data-message="${p.id}">Draft AI email</button>${p.website?`<a class="secondary" href="${esc(p.website)}" target="_blank" rel="noopener">Open website ↗</a>`:''}</div>`;$('detailDrawer').classList.add('open');$('detailDrawer').setAttribute('aria-hidden','false')}
|
function openDrawer(id){const p=prospects.find(x=>String(x.id)===String(id));if(!p)return;selected=p;$('drawerName').textContent=p.name;let reasons=[];try{reasons=JSON.parse(p.score_reasons||'[]')}catch{};$('drawerBody').innerHTML=`<div class="drawer-score"><span class="score ${scoreClass(Number(p.opportunity_score||0))}">${p.opportunity_score||0}</span><div><b>Opportunity score</b><small>Explainable triage signal, not an AI verdict</small></div></div><div class="drawer-section"><h3>Contact</h3><p>${esc(p.emails||'No public email found')}</p><p>${esc(p.phone||'No phone listed')}</p></div><div class="drawer-section"><h3>Website health</h3><div class="detail-grid"><span>Status<b>${esc((p.website_status||'unknown').replaceAll('_',' '))}</b></span><span>HTTPS<b>${p.website_https?'Yes':'No / unknown'}</b></span><span>Latency<b>${p.website_latency_ms?p.website_latency_ms+' ms':'—'}</b></span><span>Checked<b>${date(p.website_checked_at)}</b></span></div></div><div class="drawer-section"><h3>Why this scored</h3><ul>${reasons.map(r=>`<li>${esc(r)}</li>`).join('')||'<li>No score reasons recorded</li>'}</ul></div><div class="drawer-actions"><button class="primary" data-message="${p.id}">Draft AI email</button>${p.website?`<a class="secondary" href="${esc(p.website)}" target="_blank" rel="noopener">Open website ↗</a>`:''}</div>`;$('detailDrawer').classList.add('open');$('detailDrawer').setAttribute('aria-hidden','false')}
|
||||||
document.addEventListener('click',async e=>{const open=e.target.closest('[data-open]');if(open)openDrawer(open.dataset.open);const msg=e.target.closest('[data-message]');if(msg)openMessage(msg.dataset.message);if(e.target.id==='exportCsv')location.href='/api/export.csv';if(e.target.id==='refreshProspects')route();if(e.target.id==='closeDrawer'||!e.target.closest('#detailDrawer')&&$('detailDrawer').classList.contains('open')&&!e.target.closest('[data-open]')){$('detailDrawer').classList.remove('open');$('detailDrawer').setAttribute('aria-hidden','true')}});
|
document.addEventListener('click',async e=>{const open=e.target.closest('[data-open]');if(open)openDrawer(open.dataset.open);const msg=e.target.closest('[data-message]');if(msg)openMessage(msg.dataset.message);if(e.target.id==='exportCsv')location.href='/api/export.csv';if(e.target.id==='refreshProspects')route();if(e.target.id==='closeDrawer'||!e.target.closest('#detailDrawer')&&$('detailDrawer').classList.contains('open')&&!e.target.closest('[data-open]')){$('detailDrawer').classList.remove('open');$('detailDrawer').setAttribute('aria-hidden','true')}});
|
||||||
document.addEventListener('change',async e=>{if(e.target.dataset.review){try{const p=await api(`/api/prospects/${e.target.dataset.review}/review`,{method:'PATCH',body:JSON.stringify({status:e.target.value})});const i=prospects.findIndex(x=>x.id===p.id);if(i>=0)prospects[i]=p}catch(err){alert(err.message)}}if(e.target.id==='prospectSearch'||e.target.id==='prospectStatus')$('prospectTable').innerHTML=prospectTable();if(e.target.id==='aiProvider'){const nous=e.target.value==='nous';$('aiBase').readOnly=nous;$('aiBase').value=nous?'https://inference-api.nousresearch.com/v1':$('aiBase').value;const note=$('aiProviderNote');if(note)note.textContent=nous?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':'Use any OpenAI-compatible /v1 endpoint.';if(nous){try{const data=await api('/api/ai/models?provider=nous');const model=$('aiModel');const list=$('nousModelList');const models=data.models||[];if(list)list.innerHTML=models.map(m=>`<option value="${esc(m.id)}" label="${esc(m.label||m.id)}"></option>`).join('');if(model&&models.length&&!models.some(m=>m.id===model.value))model.value=models[0].id;}catch(err){}}}});
|
document.addEventListener('change',async e=>{if(e.target.dataset.review){try{const p=await api(`/api/prospects/${e.target.dataset.review}/review`,{method:'PATCH',body:JSON.stringify({status:e.target.value})});const i=prospects.findIndex(x=>x.id===p.id);if(i>=0)prospects[i]=p}catch(err){alert(err.message)}}if(e.target.id==='prospectSearch'||e.target.id==='prospectStatus')$('prospectTable').innerHTML=prospectTable();if(e.target.id==='aiProvider'){const nous=e.target.value==='nous';$('aiBase').readOnly=nous;$('aiBase').value=nous?'https://inference-api.nousresearch.com/v1':$('aiBase').value;const note=$('aiProviderNote');if(note)note.textContent=nous?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':'Use any OpenAI-compatible /v1 endpoint.';if(nous){try{const data=await api('/api/ai/models?provider=nous');const model=$('aiModel');const list=$('nousModelList');const models=data.models||[];if(list)list.innerHTML=models.map(m=>`<option value="${esc(m.id)}" label="${esc(m.label||m.id)}"></option>`).join('');if(model&&models.length&&!models.some(m=>m.id===model.value))model.value=models[0].id;}catch(err){}}}});
|
||||||
document.addEventListener('submit',async e=>{if(e.target.id==='discoverForm'){e.preventDefault();const m=$('discoverMessage');$('discoverBtn').disabled=true;m.textContent='Queueing…';try{const r=await api('/api/discover',{method:'POST',body:JSON.stringify({keywords:$('keywords').value,category:$('category').value,location:$('location').value,max_results:Number($('max-results').value),extra_reviews:$('extra-reviews').checked,include_email:true})});m.textContent=`Run #${r.id} started. View it in Runs.`;setTimeout(route,500)}catch(x){m.textContent=x.message}finally{$('discoverBtn').disabled=false}}if(e.target.id==='settingsForm'){e.preventDefault();const m=$('settingsMessage');try{const ai={AI_PROVIDER:$('aiProvider').value,AI_BASE_URL:$('aiBase').value,AI_MODEL:$('aiModel').value};if($('aiKey').value)ai.AI_API_KEY=$('aiKey').value;await api('/api/settings',{method:'POST',body:JSON.stringify({ai})});m.textContent='Settings saved.';m.className='notice ok';}catch(x){m.textContent=x.message}}if(e.target.id==='scheduleForm'){e.preventDefault();try{await api('/api/schedules',{method:'POST',body:JSON.stringify({category:$('sCat').value,keywords:$('sKey').value,location:$('sLoc').value,interval_hours:Number($('sHours').value)})});route()}catch(x){alert(x.message)}}if(e.target.id==='suppressionForm'){e.preventDefault();try{await api('/api/suppressions',{method:'POST',body:JSON.stringify({kind:$('supKind').value,value:$('supValue').value,reason:$('supReason').value})});route()}catch(x){alert(x.message)}}});
|
document.addEventListener('submit',async e=>{if(e.target.id==='discoverForm'){e.preventDefault();const m=$('discoverMessage');$('discoverBtn').disabled=true;m.textContent='Queueing…';try{const r=await api('/api/discover',{method:'POST',body:JSON.stringify({keywords:$('keywords').value,category:$('category').value,location:$('location').value,max_results:Number($('max-results').value),extra_reviews:$('extra-reviews').checked,include_email:true})});m.textContent=`Run #${r.id} started. View it in Runs.`;setTimeout(route,500)}catch(x){m.textContent=x.message}finally{$('discoverBtn').disabled=false}}if(e.target.id==='settingsForm'){e.preventDefault();const m=$('settingsMessage');try{const ai={AI_PROVIDER:$('aiProvider').value,AI_BASE_URL:$('aiBase').value,AI_MODEL:$('aiModel').value};if($('aiKey').value)ai.AI_API_KEY=$('aiKey').value;await api('/api/settings',{method:'POST',body:JSON.stringify({ai})});m.textContent='Settings saved.';m.className='notice ok';}catch(x){m.textContent=x.message}}if(e.target.id==='scheduleForm'){e.preventDefault();try{await api('/api/schedules',{method:'POST',body:JSON.stringify({category:$('sCat').value,keywords:$('sKey').value,location:$('sLoc').value,interval_hours:Number($('sHours').value)})});route()}catch(x){alert(x.message)}}if(e.target.id==='suppressionForm'){e.preventDefault();try{await api('/api/suppressions',{method:'POST',body:JSON.stringify({kind:$('supKind').value,value:$('supValue').value,reason:$('supReason').value})});route()}catch(x){alert(x.message)}}});
|
||||||
document.addEventListener('click',async e=>{if(e.target.id==='generateMessage'){if(!selected)return;const s=$('messageStatus');s.textContent='Generating…';try{const d=await api(`/api/prospects/${selected.id}/message`,{method:'POST',body:JSON.stringify({offer:$('offerInput').value})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;s.textContent='Draft ready. Review and edit before sending.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}if(e.target.id==='testAi'){const s=$('settingsMessage');s.textContent='Testing…';s.className='notice';try{const d=await api('/api/ai/test',{method:'POST',body:'{}'});s.textContent=`Connection works · ${d.provider} · ${d.model}`;s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}});
|
document.addEventListener('click',async e=>{if(e.target.id==='generateMessage'){if(!selected)return;const s=$('messageStatus');const button=e.target;button.disabled=true;button.textContent='Generating…';s.textContent='The AI is comparing the evidence with your offer…';try{const d=await api(`/api/prospects/${selected.id}/message`,{method:'POST',body:JSON.stringify({offer:$('offerInput').value})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;selected={...selected,ai_subject:d.subject,ai_message:d.message,ai_offer:$('offerInput').value,ai_reasoning:d.rationale||'',ai_unknowns:JSON.stringify(d.unknowns||[]),ai_evidence_ids:JSON.stringify(d.evidence_used||[]),ai_follow_up:d.follow_up||''};const i=prospects.findIndex(p=>String(p.id)===String(selected.id));if(i>=0)prospects[i]=selected;showDraftNotes(d);s.textContent='Draft ready. Review, edit, and confirm the facts before sending.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}finally{button.disabled=false;button.textContent='Regenerate draft'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}if(e.target.id==='testAi'){const s=$('settingsMessage');s.textContent='Testing…';s.className='notice';try{const d=await api('/api/ai/test',{method:'POST',body:'{}'});s.textContent=`Connection works · ${d.provider} · ${d.model}`;s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}});
|
||||||
function aiRecommendationLabel(value){return value==='contact'?'Contact recommended':value==='review'?'Review first':'Do not contact yet'}
|
function aiRecommendationLabel(value){return value==='contact'?'Contact recommended':value==='review'?'Review first':'Do not contact yet'}
|
||||||
function addAssessmentPanel(id){const p=prospects.find(x=>String(x.id)===String(id));const body=$('drawerBody');if(!p||!body)return;const has=Number.isFinite(Number(p.ai_probability))&&p.ai_checked_at;const section=document.createElement('section');section.className='drawer-section';section.id='aiAssessmentPanel';section.innerHTML=has?`<h3>AI assessment</h3><div class="detail-grid"><span>Contact probability<b>${Math.round(Number(p.ai_probability))}%</b></span><span>Recommendation<b>${esc(aiRecommendationLabel(p.ai_recommendation))}</b></span></div><p>${esc(p.ai_reasoning||'No rationale returned.')}</p><p class="muted">Assessed ${date(p.ai_checked_at)}</p><button class="secondary" data-assess="${p.id}">Reassess with AI</button>`:`<h3>AI assessment</h3><p>Use the collected evidence to estimate whether this company is worth contacting.</p><button class="secondary" data-assess="${p.id}">Assess with AI</button>`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section)}
|
function addAssessmentPanel(id){const p=prospects.find(x=>String(x.id)===String(id));const body=$('drawerBody');if(!p||!body)return;const has=Number.isFinite(Number(p.ai_probability))&&p.ai_checked_at;const section=document.createElement('section');section.className='drawer-section';section.id='aiAssessmentPanel';section.innerHTML=has?`<h3>AI assessment</h3><div class="detail-grid"><span>Contact probability<b>${Math.round(Number(p.ai_probability))}%</b></span><span>Recommendation<b>${esc(aiRecommendationLabel(p.ai_recommendation))}</b></span></div><p>${esc(p.ai_reasoning||'No rationale returned.')}</p><p class="muted">Assessed ${date(p.ai_checked_at)}</p><button class="secondary" data-assess="${p.id}">Reassess with AI</button>`:`<h3>AI assessment</h3><p>Use the collected evidence to estimate whether this company is worth contacting.</p><button class="secondary" data-assess="${p.id}">Assess with AI</button>`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section)}
|
||||||
const _openDrawerWithAssessment=openDrawer;openDrawer=function(id){_openDrawerWithAssessment(id);addAssessmentPanel(id)}
|
function candidateEvidence(candidate){try{const value=JSON.parse(candidate.evidence||'[]');return Array.isArray(value)?value.filter(Boolean).map(String):[]}catch{return[]}}
|
||||||
|
function renderWebsiteCandidates(items,id){const section=$('websiteCandidatesPanel');if(!section)return;const rows=Array.isArray(items)?items:[];section.innerHTML=`<h3>Possible official websites</h3><p class="muted">Review the evidence before accepting a domain. Accepting runs the normal website and public-email scan.</p><button class="secondary" data-find-websites="${id}">Search again</button>${rows.length?`<div class="candidate-list">${rows.map(c=>{const evidence=candidateEvidence(c);const status=c.status||'candidate';return `<div class="candidate-card"><div><a href="${esc(c.url)}" target="_blank" rel="noopener">${esc(c.domain)} ↗</a><span class="badge ${status==='accepted'?'good':status==='rejected'?'warn':'info'}">${esc(status)}</span></div><p>Confidence <b>${Number(c.confidence||0)}%</b></p>${evidence.length?`<ul>${evidence.map(item=>`<li>${esc(item)}</li>`).join('')}</ul>`:'<p class="muted">No evidence returned.</p>'}${status==='candidate'?`<div class="candidate-actions"><button class="primary" data-accept-website="${c.id}" data-prospect-id="${id}">Accept domain</button><button class="secondary" data-reject-website="${c.id}" data-prospect-id="${id}">Reject</button></div>`:''}</div>`}).join('')}</div>`:'<p class="muted">No plausible official website candidates found yet.</p>'}`}
|
||||||
|
function addWebsitePanel(id){const body=$('drawerBody');const p=prospects.find(x=>String(x.id)===String(id));if(!p||!body)return;const section=document.createElement('section');section.className='drawer-section';section.id='websiteCandidatesPanel';section.innerHTML=`<h3>Find a possible website</h3><p>Searches a few permitted public pages for a likely official domain when Maps did not list one. It never logs in or bypasses access challenges.</p><button class="secondary" data-find-websites="${p.id}">Find possible websites</button><p class="muted">Requires a tested AI provider in Settings.</p>`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section);api(`/api/prospects/${p.id}/website-candidates`).then(d=>{if((d.items||[]).length)renderWebsiteCandidates(d.items,p.id)}).catch(()=>{})}
|
||||||
|
const _openDrawerWithAssessment=openDrawer;openDrawer=function(id){_openDrawerWithAssessment(id);addAssessmentPanel(id);addWebsitePanel(id)}
|
||||||
async function assessProspect(id,button){if(button){button.disabled=true;button.textContent='Assessing…'}try{const d=await api(`/api/prospects/${id}/assess`,{method:'POST',body:'{}'});const index=prospects.findIndex(p=>String(p.id)===String(id));if(index>=0)prospects[index]={...prospects[index],...d};selected=prospects[index]||selected;openDrawer(id)}catch(error){const panel=$('aiAssessmentPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}if(button){button.disabled=false;button.textContent='Assess with AI'}}}
|
async function assessProspect(id,button){if(button){button.disabled=true;button.textContent='Assessing…'}try{const d=await api(`/api/prospects/${id}/assess`,{method:'POST',body:'{}'});const index=prospects.findIndex(p=>String(p.id)===String(id));if(index>=0)prospects[index]={...prospects[index],...d};selected=prospects[index]||selected;openDrawer(id)}catch(error){const panel=$('aiAssessmentPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}if(button){button.disabled=false;button.textContent='Assess with AI'}}}
|
||||||
document.addEventListener('click',async e=>{const button=e.target.closest('[data-assess]');if(button){e.preventDefault();e.stopPropagation();await assessProspect(button.dataset.assess,button)}});
|
document.addEventListener('click',async e=>{const button=e.target.closest('[data-assess]');if(button){e.preventDefault();e.stopPropagation();await assessProspect(button.dataset.assess,button)}});
|
||||||
|
async function findWebsiteCandidates(id,button){if(button){button.disabled=true;button.textContent='Searching…'}const section=$('websiteCandidatesPanel');try{const d=await api(`/api/prospects/${id}/website-candidates`,{method:'POST',body:'{}'});renderWebsiteCandidates(d.items||[],id)}catch(error){if(section){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;section.append(notice)}}finally{if(button){button.disabled=false;button.textContent='Search again'}}}
|
||||||
|
document.addEventListener('click',async e=>{const find=e.target.closest('[data-find-websites]');if(find){e.preventDefault();e.stopPropagation();await findWebsiteCandidates(find.dataset.findWebsites,find);return}const accept=e.target.closest('[data-accept-website]');if(accept){e.preventDefault();e.stopPropagation();accept.disabled=true;accept.textContent='Checking…';try{const d=await api(`/api/prospects/${accept.dataset.prospectId}/website-candidates/${accept.dataset.acceptWebsite}`,{method:'PATCH',body:JSON.stringify({status:'accepted'})});if(d.prospect){const i=prospects.findIndex(p=>String(p.id)===String(d.prospect.id));if(i>=0)prospects[i]={...prospects[i],...d.prospect};selected=d.prospect}openDrawer(accept.dataset.prospectId);if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){accept.disabled=false;accept.textContent='Accept domain';const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}return}const reject=e.target.closest('[data-reject-website]');if(reject){e.preventDefault();e.stopPropagation();reject.disabled=true;try{const d=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates/${reject.dataset.rejectWebsite}`,{method:'PATCH',body:JSON.stringify({status:'rejected'})});const current=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates`);renderWebsiteCandidates(current.items||[],reject.dataset.prospectId)}catch(error){reject.disabled=false;const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}}});
|
||||||
window.addEventListener('hashchange',route);$('refreshAll').addEventListener('click',route);route();
|
window.addEventListener('hashchange',route);$('refreshAll').addEventListener('click',route);route();
|
||||||
|
document.addEventListener('click',e=>{const tab=e.target.closest('[data-category-tab]');if(!tab)return;e.preventDefault();e.stopPropagation();activeProspectCategory=tab.dataset.categoryTab||'all';if($('prospectTabs'))$('prospectTabs').innerHTML=prospectTabs();if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()});
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=ui-v14"></head>
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=ui-v16"></head>
|
||||||
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Use the evidence and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate subject and email</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=ui-v15"></script></body></html>
|
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Use the evidence and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate subject and email</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=prospects-tabs-v1"></script></body></html>
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ input::placeholder { color: #aeb6c5; } input:focus, select:focus { border-color:
|
|||||||
|
|
||||||
.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; }
|
.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; }
|
.muted { color: #9ca5b5; font-size: 11px; }
|
||||||
|
.draft-notes{padding:12px;border:1px solid #e6eaf2;border-radius:9px;background:#fafbff;color:#536178;font-size:11px;line-height:1.5}.draft-notes b{color:#334158}.draft-notes p{margin:4px 0}.draft-notes .muted{font-size:10px}
|
||||||
|
.prospect-tabs{margin:4px 0 22px;padding:14px 0 0;border-top:1px solid #edf0f5}.prospect-tab-list{display:flex;gap:8px;overflow-x:auto;padding:2px 1px 3px}.prospect-tab{border:1px solid #dfe4ed;background:#fff;color:#536178;border-radius:999px;padding:9px 13px;white-space:nowrap;font:inherit;font-size:12px;font-weight:750;cursor:pointer;transition:.18s}.prospect-tab:hover{border-color:#b9b5ff;background:#f7f6ff;color:#4f47df}.prospect-tab.active{background:#635bff;border-color:#635bff;color:#fff;box-shadow:0 6px 14px #635bff2e}.prospect-tab span{display:inline-grid;place-items:center;min-width:20px;height:20px;margin-left:5px;padding:0 5px;border-radius:99px;background:#eef1f7;color:#647188;font-size:10px}.prospect-tab.active span{background:#fff3;color:#fff}.tab-hint{margin:10px 0 0;color:#8a96a8;font-size:11px}.candidate-list{display:grid;gap:10px;margin-top:15px}.candidate-card{padding:13px;border:1px solid #e6eaf2;border-radius:10px;background:#fbfcff}.candidate-card>div:first-child{display:flex;align-items:center;gap:8px}.candidate-card a{color:#4f47df;font-weight:750;text-decoration:none;font-size:13px}.candidate-card p{font-size:12px!important;color:#68758a!important}.candidate-card ul{padding-left:18px;margin:7px 0}.candidate-card li{font-size:11px!important;padding:2px 0!important}.candidate-actions{display:flex;gap:7px;margin-top:10px}.candidate-actions button{padding:8px 10px;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: 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; } }
|
@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