"""Safe, bounded website and domain intelligence for ProspectOS. This module deliberately stays dependency-free. It performs small public HTTP requests, refuses private/loopback destinations, follows only same-domain redirects, and never treats a failed check as proof that a site does not exist. """ from __future__ import annotations import html import ipaddress import os import re import socket import ssl import time from datetime import datetime, timezone from html.parser import HTMLParser from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import urljoin, urlparse from urllib.request import HTTPRedirectHandler, Request, build_opener EMAIL_RE = re.compile(r"[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,63}", re.I) MAX_BODY_BYTES = 700_000 MAX_REDIRECTS = 2 MAX_PAGES = 2 TIMEOUT_SECONDS = max(2, min(30, int(os.environ.get("WEBSITE_SCAN_TIMEOUT", "8")))) # getaddrinfo has no per-call timeout parameter on all supported platforms. # Set a bounded process default so a broken DNS server cannot hold a discovery # worker forever during website analysis. socket.setdefaulttimeout(TIMEOUT_SECONDS) class _PageParser(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) self.title_parts: list[str] = [] self.meta: dict[str, str] = {} self.links: list[tuple[str, str]] = [] self._in_title = False def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: values = {key.lower(): value or "" for key, value in attrs} if tag.lower() == "title": self._in_title = True if tag.lower() == "meta": key = (values.get("name") or values.get("property") or "").lower() if key: self.meta[key] = values.get("content", "")[:300] if tag.lower() == "a" and values.get("href"): self.links.append((values.get("href", "")[:500], "")) def handle_endtag(self, tag: str) -> None: if tag.lower() == "title": self._in_title = False def handle_data(self, data: str) -> None: if self._in_title: self.title_parts.append(data) class _NoRedirect(HTTPRedirectHandler): def redirect_request(self, req: Request, fp: Any, code: int, msg: str, headers: Any, newurl: str) -> None: # type: ignore[override] return None _opener = build_opener(_NoRedirect) def _host_is_public(host: str) -> tuple[bool, list[str]]: """Resolve a hostname and reject local, reserved, or unroutable targets.""" try: addresses = {item[4][0] for item in socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)} except (OSError, socket.gaierror): return False, [] if not addresses: return False, [] for raw in addresses: try: ip = ipaddress.ip_address(raw) except ValueError: return False, list(addresses) if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified: return False, list(addresses) return True, list(addresses) def _safe_url(value: str) -> bool: parsed = urlparse(value) if parsed.scheme not in {"http", "https"} or not parsed.hostname: return False if parsed.username or parsed.password: return False allowed, _ = _host_is_public(parsed.hostname) return allowed def _same_root(left: str, right: str) -> bool: a = (urlparse(left).hostname or "").lower().rstrip(".") b = (urlparse(right).hostname or "").lower().rstrip(".") if a.startswith("www."): a = a[4:] if b.startswith("www."): b = b[4:] return bool(a and b and (a == b or a.endswith("." + b) or b.endswith("." + a))) def _fetch(url: str) -> dict[str, Any]: current = url redirects = 0 started = time.monotonic() last_status = 0 last_headers: Any = {} body = b"" while redirects <= MAX_REDIRECTS: if not _safe_url(current): return {"error": "unsafe_or_unresolvable_host", "url": current, "latency_ms": int((time.monotonic() - started) * 1000)} request = Request(current, headers={"User-Agent": "ProspectOS/1.0 (+public-site-check)", "Accept": "text/html,application/xhtml+xml"}) try: with _opener.open(request, timeout=TIMEOUT_SECONDS) as response: last_status = int(getattr(response, "status", 200) or 200) last_headers = response.headers body = response.read(MAX_BODY_BYTES) final_url = response.geturl() or current return { "url": final_url, "initial_url": url, "status": last_status, "headers": last_headers, "body": body, "latency_ms": int((time.monotonic() - started) * 1000), "redirects": redirects, } except HTTPError as exc: last_status = int(exc.code or 0) last_headers = exc.headers if 300 <= last_status < 400 and exc.headers.get("Location"): target = urljoin(current, exc.headers.get("Location", "")) if not _same_root(current, target) or not _safe_url(target): return {"url": current, "initial_url": url, "status": last_status, "headers": last_headers, "body": b"", "latency_ms": int((time.monotonic() - started) * 1000), "redirects": redirects, "error": "cross_domain_redirect_blocked"} current = target redirects += 1 continue return {"url": current, "initial_url": url, "status": last_status, "headers": last_headers, "body": b"", "latency_ms": int((time.monotonic() - started) * 1000), "redirects": redirects, "error": f"http_{last_status}"} except (URLError, TimeoutError, OSError) as exc: return {"url": current, "initial_url": url, "status": last_status, "headers": last_headers, "body": b"", "latency_ms": int((time.monotonic() - started) * 1000), "redirects": redirects, "error": str(getattr(exc, "reason", exc))[:180]} return {"url": current, "initial_url": url, "status": last_status, "headers": last_headers, "body": body, "latency_ms": int((time.monotonic() - started) * 1000), "redirects": redirects, "error": "redirect_limit"} def _tls_expiry(url: str) -> str: parsed = urlparse(url) if parsed.scheme != "https" or not parsed.hostname: return "" try: context = ssl.create_default_context() with socket.create_connection((parsed.hostname, parsed.port or 443), timeout=TIMEOUT_SECONDS) as raw: with context.wrap_socket(raw, server_hostname=parsed.hostname) as secure: cert = secure.getpeercert() value = cert.get("notAfter") if cert else "" if not value: return "" stamp = ssl.cert_time_to_seconds(value) return datetime.fromtimestamp(stamp, timezone.utc).replace(microsecond=0).isoformat() except (OSError, ssl.SSLError, ValueError): return "" def _emails(text: str) -> list[str]: found: list[str] = [] for value in EMAIL_RE.findall(html.unescape(text or "")): normalized = value.strip(".,;:()[]{}<>").lower() if normalized not in found and not normalized.endswith((".png", ".jpg", ".jpeg", ".gif", ".svg")): found.append(normalized) return found[:30] def scan_website(url: str) -> dict[str, Any]: """Return bounded website health, metadata, and public email evidence.""" if not url: return {"website_status": "no_detected_website", "website_checked_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(), "website_emails": [], "email_evidence": []} first = _fetch(url) status = int(first.get("status") or 0) final_url = str(first.get("url") or url) body = bytes(first.get("body") or b"") decoded = body.decode("utf-8", "replace") parser = _PageParser() try: parser.feed(decoded) except Exception: pass title = " ".join(" ".join(parser.title_parts).split())[:300] emails = _emails(decoded) pages: list[dict[str, Any]] = [first] if 200 <= status < 400 and len(pages) < MAX_PAGES: for href, _ in parser.links: candidate = urljoin(final_url, href) label = (href or "").lower() if any(token in label for token in ("contact", "about", "get-in-touch")) and _same_root(final_url, candidate) and _safe_url(candidate): page = _fetch(candidate) pages.append(page) emails.extend(_emails(bytes(page.get("body") or b"").decode("utf-8", "replace"))) break emails = list(dict.fromkeys(emails))[:30] if first.get("error") and not status: website_status = "unreachable" elif status >= 500: website_status = "broken" elif status >= 400: website_status = "error" elif status >= 300 or int(first.get("redirects") or 0) > 0: website_status = "redirect" elif int(first.get("latency_ms") or 0) >= 3000: website_status = "slow" elif urlparse(final_url).scheme != "https": website_status = "http_only" else: website_status = "healthy" headers = first.get("headers") or {} server = str(headers.get("Server", ""))[:120] generator = parser.meta.get("generator", "")[:120] technologies = ", ".join(dict.fromkeys(value for value in (server, generator) if value))[:250] checked = datetime.now(timezone.utc).replace(microsecond=0).isoformat() evidence = [{"email": value, "source_url": final_url, "found_at": checked, "method": "website_scan", "confidence": "high"} for value in emails] return { "website_status": website_status, "website_http_status": status or None, "website_final_url": final_url[:1000], "website_latency_ms": int(first.get("latency_ms") or 0), "website_https": 1 if urlparse(final_url).scheme == "https" else 0, "website_title": title, "website_technology": technologies, "website_has_viewport": 1 if "viewport" in parser.meta else 0, "website_tls_expires_at": _tls_expiry(final_url) if urlparse(final_url).scheme == "https" else "", "website_checked_at": checked, "website_error": str(first.get("error") or "")[:300], "website_emails": emails, "email_evidence": evidence, "scanned_pages": len(pages), } def _slug(value: str) -> str: value = re.sub(r"[^a-z0-9]+", "-", (value or "").lower()).strip("-") return value[:45] def domain_candidates(name: str, address: str = "", category: str = "") -> list[dict[str, str]]: """Suggest likely .co.za names; DNS state is evidence, not registrar availability.""" base = _slug(name) city = _slug((address or "").split(",")[-1]) cat = _slug(category) parts = [base, f"{base}-{cat}" if cat else "", f"{base}-{city}" if city else ""] seen: set[str] = set() output: list[dict[str, str]] = [] for stem in parts: if not stem: continue domain = stem + ".co.za" if domain in seen: continue seen.add(domain) try: socket.getaddrinfo(domain, None, type=socket.SOCK_STREAM) dns_status = "resolves" except OSError: dns_status = "no_dns_response" output.append({"domain": domain, "dns_status": dns_status, "availability_note": "DNS is only a hint; confirm availability with a registrar."}) return output