Files
MarketingTool/api/browser_investigator.py
T
2026-09-05 21:53:29 +02:00

174 lines
6.6 KiB
Python

"""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)