Files
MarketingTool/apps/api/app/sources.py
T

591 lines
31 KiB
Python
Raw Normal View History

"""Safe, tenant-neutral source adapter contracts.
Network adapters are intentionally capability gated: configuration must explicitly
approve public access, terms, rate limits and credentials (where required).
Adapters never emit or persist credential values.
"""
2026-09-02 18:58:10 +02:00
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Mapping, Protocol, Sequence
2026-09-04 20:52:37 +02:00
from html.parser import HTMLParser
import csv, io, os, random, threading, time, json, re
2026-09-04 10:03:34 +02:00
from urllib.parse import urlencode, urlparse
from urllib.request import Request, urlopen
try:
from .website_scanner import validate_url
except ImportError:
from website_scanner import validate_url
2026-09-02 18:58:10 +02:00
SECRET_KEYS = {"password", "passwd", "secret", "token", "api_key", "apikey", "authorization", "private_key", "credential"}
NETWORK_KINDS = {"google_places", "bing_local", "approved_directory", "public_website", "permitted_social", "ct_logs", "dns", "rdap"}
DISCOVERY_CRITERIA_FIELDS = {"query", "category", "city", "location", "keywords", "province", "country", "language", "search", "phrase", "industry", "keyword"}
2026-09-02 18:58:10 +02:00
def contains_secret(value: Any, path: str = "") -> str | None:
if isinstance(value, Mapping):
for key, child in value.items():
key_text = str(key).lower()
if key_text in SECRET_KEYS or any(x in key_text for x in ("password", "token", "secret", "api_key", "private_key")):
return path + str(key)
found = contains_secret(child, path + str(key) + ".")
if found: return found
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
for index, child in enumerate(value):
found = contains_secret(child, path + str(index) + ".")
if found: return found
return None
@dataclass(frozen=True)
class ValidationResult:
valid: bool
errors: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class DiscoveryPage:
records: list[dict[str, str]]
next_cursor: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class SourceHealth:
status: str = "unknown"
consecutive_failures: int = 0
circuit_open: bool = False
last_error: str | None = None
@dataclass(frozen=True)
class NormalizedRecord:
name: str = ""
website: str = ""
email: str = ""
phone: str = ""
description: str = ""
location: str = ""
source_url: str = ""
provenance: str = ""
raw: Mapping[str, Any] = field(default_factory=dict)
2026-09-02 18:58:10 +02:00
class DiscoverySource(Protocol):
source_code: str
display_name: str
def validate_config(self, config: Mapping[str, Any]) -> ValidationResult: ...
2026-09-02 18:58:10 +02:00
def discover(self, config: Mapping[str, Any], cursor: str | None = None) -> DiscoveryPage: ...
def health_check(self, config: Mapping[str, Any]) -> SourceHealth: ...
2026-09-02 18:58:10 +02:00
_FIELDS = ("name", "website", "email", "phone", "description", "location")
2026-09-02 18:58:10 +02:00
def normalize_record(row: Mapping[str, Any]) -> dict[str, str]:
result = {field: str(row.get(field, "") or "").strip() for field in _FIELDS}
aliases = {"company":"name", "business":"name", "url":"website", "domain":"website", "address":"location", "address_line":"location"}
2026-09-02 18:58:10 +02:00
for key, target in aliases.items():
if not result[target] and row.get(key) is not None: result[target] = str(row[key]).strip()
return result
def normalized_record(row: Mapping[str, Any], *, source_url="", provenance="") -> NormalizedRecord:
x = normalize_record(row)
return NormalizedRecord(**x, source_url=source_url, provenance=provenance, raw=dict(row))
def exponential_backoff(attempt: int, base: float = 0.5, maximum: float = 30.0, jitter: float = 0.25) -> float:
"""Bounded exponential delay with symmetric jitter; no sleeping occurs here."""
delay = min(maximum, base * (2 ** max(0, int(attempt))))
return max(0.0, delay + random.uniform(-jitter * delay, jitter * delay))
def backoff_delay(attempt: int, base: float = 0.5, maximum: float = 30.0, jitter: float = 0.25) -> float:
return exponential_backoff(attempt, base, maximum, jitter)
def circuit_is_open(consecutive_failures: int, threshold: int = 3) -> bool:
return int(consecutive_failures) >= max(1, int(threshold))
def quota_remaining(used: int, limit: int | None) -> int | None:
"""Return remaining quota, clamped so malformed values fail closed."""
if limit is None: return None
return max(0, int(limit) - max(0, int(used)))
def quota_allowed(used: int, limit: int | None) -> bool:
remaining = quota_remaining(used, limit)
return remaining is None or remaining > 0
def rate_limit_delay(last_request: float | None, min_interval: float) -> float:
if last_request is None: return 0.0
return max(0.0, float(min_interval) - (time.monotonic() - last_request))
class _Base:
kind = ""
source_code = ""
display_name = ""
2026-09-04 10:03:34 +02:00
available = True
optional = False
requires_credentials = False
def validate_config(self, config):
2026-09-02 18:58:10 +02:00
if not isinstance(config, Mapping): return ValidationResult(False, ["config must be an object"])
found = contains_secret(config)
if found: return ValidationResult(False, [f"secret field is not permitted: {found}"])
return ValidationResult(True)
validate = validate_config
def health_check(self, config):
result = self.validate_config(config)
return SourceHealth("healthy" if result.valid else "unhealthy", last_error=None if result.valid else "; ".join(result.errors))
def discover(self, config, cursor=None, criteria=None, limits=None):
result = self.validate_config(config)
if not result.valid: raise ValueError(result.errors[0])
raise RuntimeError("source_not_configured")
class ManualSource(_Base):
kind = source_code = "manual"; display_name = "Manual records"
def validate_config(self, config):
result = super().validate_config(config)
if not result.valid: return result
if not isinstance(config.get("rows"), list): return ValidationResult(False, ["rows must be a list"])
return ValidationResult(True)
def discover(self, config, cursor=None, criteria=None, limits=None):
result = self.validate_config(config)
if not result.valid: raise ValueError(result.errors[0])
return DiscoveryPage([normalize_record(row) for row in config["rows"] if isinstance(row, Mapping)], metadata={"adapter":self.source_code})
class CsvSource(_Base):
kind = source_code = "csv"; display_name = "CSV import"
def validate_config(self, config):
result = super().validate_config(config)
if not result.valid: return result
2026-09-02 18:58:10 +02:00
if not isinstance(config.get("csv"), str): return ValidationResult(False, ["csv must be text"])
try:
reader = csv.DictReader(io.StringIO(config["csv"]))
2026-09-02 18:58:10 +02:00
if not reader.fieldnames: return ValidationResult(False, ["CSV header is required"])
except csv.Error as exc: return ValidationResult(False, [f"invalid CSV: {exc}"])
return ValidationResult(True)
def discover(self, config, cursor=None, criteria=None, limits=None):
result = self.validate_config(config)
if not result.valid: raise ValueError(result.errors[0])
2026-09-02 18:58:10 +02:00
reader = csv.DictReader(io.StringIO(config["csv"].replace("\r\n", "\n")))
2026-09-04 10:03:34 +02:00
records = []
for row in reader:
normalized = {str(k).strip().lower(): v for k, v in row.items()}
if any(str(v or '').strip() for v in normalized.values()):
records.append(normalize_record(normalized))
return DiscoveryPage(records, metadata={"adapter":self.source_code,"columns":reader.fieldnames or [],"record_count":len(records)})
class _HttpJsonSource(_Base):
"""Small, bounded JSON client used only for public standards-based sources."""
max_bytes = 256 * 1024
timeout = 8
def _get_json(self, url):
safe = validate_url(url)
request = Request(safe, headers={"User-Agent": "ProspectOS/0.1 (+public-source-research)"})
with urlopen(request, timeout=self.timeout) as response:
body = response.read(self.max_bytes + 1)
if len(body) > self.max_bytes:
raise ValueError("source_response_too_large")
return json.loads(body.decode("utf-8", "replace")), safe
class PublicWebsiteSource(_Base):
kind = source_code = "public_website"
display_name = "Public website"
def validate_config(self, config):
result = super().validate_config(config)
if not result.valid: return result
urls = config.get("urls", config.get("url", []))
if isinstance(urls, str): urls = [urls] if urls.strip() else []
if not isinstance(urls, list) or not urls or len(urls) > 50:
return ValidationResult(False, ["urls must contain 1 to 50 public HTTP(S) URLs"])
for value in urls:
try: validate_url(str(value))
except ValueError: return ValidationResult(False, ["unsafe public website URL"])
return ValidationResult(True)
def discover(self, config, cursor=None, criteria=None, limits=None):
2026-09-04 10:03:34 +02:00
result = self.validate_config(config)
if not result.valid: raise ValueError(result.errors[0])
urls = config.get("urls", config.get("url"))
if isinstance(urls, str): urls = [urls]
records = []
for raw_url in urls[:50]:
safe = validate_url(str(raw_url))
request = Request(safe, headers={"User-Agent": "ProspectOS/0.1 (+public-source-research)"})
with urlopen(request, timeout=8) as response:
body = response.read(128 * 1024).decode("utf-8", "replace")
final_url = response.geturl()
from html.parser import HTMLParser
parser = HTMLParser()
title = urlparse(final_url).hostname or safe
records.append(normalize_record({"name": title, "website": final_url, "description": body[:1000]}))
return DiscoveryPage(records, metadata={"adapter": self.source_code, "record_count": len(records), "bounded": True})
class CtLogsSource(_HttpJsonSource):
kind = source_code = "ct_logs"
display_name = "Certificate transparency logs"
def validate_config(self, config):
result = super().validate_config(config)
if not result.valid: return result
query = str(config.get("domain", config.get("query", ""))).strip()
if not query or len(query) > 253 or any(ch in query for ch in "\r\n"):
return ValidationResult(False, ["domain or query is required"])
return ValidationResult(True)
def discover(self, config, cursor=None, criteria=None, limits=None):
2026-09-04 10:03:34 +02:00
result = self.validate_config(config)
if not result.valid: raise ValueError(result.errors[0])
query = str(config.get("domain", config.get("query", ""))).strip()
2026-09-04 10:03:34 +02:00
endpoint = "https://crt.sh/?" + urlencode({"q": "%25." + query.lstrip("%.") if not query.startswith("%") else query, "output": "json"})
payload, source_url = self._get_json(endpoint)
if not isinstance(payload, list): raise ValueError("invalid_ct_response")
records, seen = [], set()
for item in payload[:500]:
names = str(item.get("name_value", "")) if isinstance(item, dict) else ""
for name in names.splitlines():
name = name.strip().lower().lstrip("*.")
if not name or name in seen or "." not in name: continue
seen.add(name); records.append(normalize_record({"name": name, "website": "https://" + name}))
return DiscoveryPage(records[:100], metadata={"adapter": self.source_code, "source_url": source_url, "record_count": len(records), "signal_only": True})
class DnsSource(_Base):
kind = source_code = "dns"
display_name = "DNS"
def validate_config(self, config):
result = super().validate_config(config)
if not result.valid: return result
domains = config.get("domains", config.get("domain", []))
if isinstance(domains, str): domains = [domains] if domains.strip() else []
if not isinstance(domains, list) or not domains or len(domains) > 100: return ValidationResult(False, ["domains must contain 1 to 100 names"])
return ValidationResult(True)
def discover(self, config, cursor=None, criteria=None, limits=None):
2026-09-04 10:03:34 +02:00
result = self.validate_config(config)
if not result.valid: raise ValueError(result.errors[0])
import socket
domains = config.get("domains", config.get("domain")); domains = [domains] if isinstance(domains, str) else domains
records = []
for domain in domains[:100]:
domain = str(domain).strip().lower().rstrip(".")
if not domain or "." not in domain: continue
try: addresses = sorted({item[4][0] for item in socket.getaddrinfo(domain, 443, type=socket.SOCK_STREAM)})
except socket.gaierror: addresses = []
records.append(normalize_record({"name": domain, "website": "https://" + domain, "description": json.dumps({"a_aaaa": addresses})}))
return DiscoveryPage(records, metadata={"adapter": self.source_code, "record_count": len(records), "dns_status_only": True})
class RdapSource(_HttpJsonSource):
kind = source_code = "rdap"
display_name = "RDAP"
def validate_config(self, config):
result = super().validate_config(config)
if not result.valid: return result
domain = str(config.get("domain", "")).strip()
if not domain or "." not in domain: return ValidationResult(False, ["domain is required"])
return ValidationResult(True)
def discover(self, config, cursor=None, criteria=None, limits=None):
2026-09-04 10:03:34 +02:00
result = self.validate_config(config)
if not result.valid: raise ValueError(result.errors[0])
domain = str(config["domain"]).strip().lower().rstrip(".")
payload, source_url = self._get_json("https://rdap.org/domain/" + domain)
return DiscoveryPage([normalize_record({"name": domain, "website": "https://" + domain, "description": json.dumps({"rdap": payload}, default=str)[:1000]})], metadata={"adapter": self.source_code, "source_url": source_url, "record_count": 1, "registration_signal_only": True})
2026-09-02 18:58:10 +02:00
class GatedSource(_Base):
2026-09-04 10:03:34 +02:00
available = False
optional = True
requires_credentials = True
required = "approved"
def validate_config(self, config):
result = super().validate_config(config)
if not result.valid: return result
if config.get("approved") is not True: return ValidationResult(False, ["source approval is required"])
if config.get("public_access") is not True: return ValidationResult(False, ["public_access approval is required"])
if config.get("terms_accepted") is not True: return ValidationResult(False, ["terms_accepted is required"])
if self.source_code in {"google_places", "bing_local"} and not config.get("credential_ref"):
return ValidationResult(False, ["approved credential_ref is required"])
if not isinstance(config.get("rate_limit", 1), (int, float)) or config.get("rate_limit", 1) <= 0:
return ValidationResult(False, ["positive rate_limit is required"])
return ValidationResult(True)
def discover(self, config, cursor=None, criteria=None, limits=None):
result = self.validate_config(config)
if not result.valid: raise ValueError(result.errors[0])
# Network execution is delegated to an explicitly approved provider; never guess.
raise RuntimeError("network_adapter_not_configured")
2026-09-04 11:38:11 +02:00
class ApprovedDirectorySource(GatedSource):
kind = source_code = "approved_directory"
display_name = "Free public directories"
available = True
requires_credentials = False
2026-09-04 13:36:40 +02:00
optional = False
2026-09-04 11:38:11 +02:00
def validate_config(self, config):
result=super().validate_config(config)
if not result.valid:return result
provider=str(config.get("provider", "")).strip().lower()
if provider not in {"openstreetmap", "wikidata", "common_crawl"}: return ValidationResult(False,["provider must be openstreetmap, wikidata, or common_crawl"])
return ValidationResult(True)
def discover(self, config, cursor=None, criteria=None, limits=None):
2026-09-04 11:38:11 +02:00
result=self.validate_config(config)
if not result.valid: raise ValueError(result.errors[0])
2026-09-04 20:52:37 +02:00
criteria = criteria if isinstance(criteria, Mapping) else {}
keyword_values = criteria.get("keywords", criteria.get("keyword", criteria.get("query", "")))
if isinstance(keyword_values, str): keyword_values = [keyword_values]
terms = [str(value).strip() for value in keyword_values[:10] if str(value).strip()] if isinstance(keyword_values, Sequence) and not isinstance(keyword_values, (bytes, bytearray, str)) else []
for key in ("category", "industry"):
value = str(criteria.get(key, "")).strip()
if value: terms.append(value)
query = " ".join(dict.fromkeys(terms))[:500]
if not query: raise ValueError("discovery_criteria_required")
location_parts = [str(criteria.get(key, "")).strip() for key in ("city", "location", "province", "country")]
area = next((value for value in location_parts if value), "South Africa")
limit_source = limits if isinstance(limits, Mapping) else {}
try: limit=max(1,min(100,int(limit_source.get("per_run_limit", limit_source.get("max_records",50)))))
except (TypeError, ValueError): raise ValueError("invalid_limits")
provider=str(config["provider"]).lower()
2026-09-04 11:38:11 +02:00
if provider == "openstreetmap":
2026-09-04 18:21:05 +02:00
terms=[token.lower() for token in re.findall(r"[A-Za-z0-9]{2,32}",query)[:5]]
variants=sorted({variant for term in terms for variant in (term,term[:-1] if term.endswith('s') and len(term)>3 else term)})
pattern="|".join(re.escape(term) for term in variants)
overpass='[out:json][timeout:25];area["name"="%s"]->.a;(nwr["name"~"%s",i](area.a);nwr["craft"~"%s",i](area.a);nwr["amenity"~"%s",i](area.a);nwr["shop"~"%s",i](area.a););out center tags;' % ((area.replace('"',''),)+ (pattern,)*4)
2026-09-04 11:38:11 +02:00
req=Request("https://overpass-api.de/api/interpreter",data=overpass.encode(),method="POST",headers={"Content-Type":"application/x-www-form-urlencoded","User-Agent":"ProspectOS/0.1"})
with urlopen(req,timeout=30) as response: payload=json.loads(response.read(2*1024*1024).decode("utf-8","replace"))
records=[]
for element in payload.get("elements",[]):
tags=element.get("tags",{}); name=tags.get("name","")
2026-09-04 18:21:05 +02:00
if not name or not any(term in name.lower() or term in str(tags).lower() for term in variants): continue
2026-09-04 11:38:11 +02:00
records.append(normalize_record({"name":name,"website":tags.get("website") or tags.get("contact:website", ""),"phone":tags.get("phone") or tags.get("contact:phone", ""),"email":tags.get("email") or tags.get("contact:email", ""),"location":", ".join(x for x in (tags.get("addr:street"),tags.get("addr:city"),tags.get("addr:postcode")) if x),"description":"OpenStreetMap public listing"}))
return DiscoveryPage(records[:limit],metadata={"adapter":self.source_code,"provider":provider,"record_count":len(records)})
if provider == "wikidata":
sparql=query if query.lower().startswith("select") else 'SELECT ?item ?itemLabel ?website WHERE {?item rdfs:label ?itemLabel. FILTER(CONTAINS(LCASE(?itemLabel), LCASE("%s"))). OPTIONAL {?item wdt:P856 ?website} FILTER(LANG(?itemLabel)="en")} LIMIT %d'%(query.replace('"',''),limit)
url="https://query.wikidata.org/sparql?format=json&"+urlencode({"query":sparql})
payload, _ = _HttpJsonSource()._get_json(url); records=[normalize_record({"name":x.get("itemLabel",{}).get("value",""),"website":x.get("website",{}).get("value","")}) for x in payload.get("results",{}).get("bindings",[])]
return DiscoveryPage(records[:limit],metadata={"adapter":self.source_code,"provider":provider,"record_count":len(records)})
index="https://index.commoncrawl.org/CC-MAIN-2026-30-index?url="+query+"&output=json&filter=status:200&collapse=urlkey"
payload, _ = _HttpJsonSource()._get_json(index); records=[normalize_record({"name":str(x.get("url","")).split('/')[2] if '://' in str(x.get("url","")) else x.get("url", ""),"website":x.get("url","")}) for x in (payload if isinstance(payload,list) else [])]
return DiscoveryPage(records[:limit],metadata={"adapter":self.source_code,"provider":provider,"record_count":len(records)})
2026-09-04 20:52:37 +02:00
class GoogleBrowserSearchBlocked(RuntimeError):
"""Fail-closed result for a disabled, rate-limited, or Google-blocked fetch."""
code = "GOOGLE_BROWSER_BLOCKED"
def __init__(self, reason: str):
self.reason = reason
super().__init__(f"{self.code}:{reason}")
def as_dict(self) -> dict[str, str]:
return {"code": self.code, "reason": self.reason}
class _GoogleVisibleResults(HTMLParser):
"""Extract only human-visible heading links from public result HTML."""
def __init__(self):
super().__init__(convert_charrefs=True)
self._href = ""
self._depth = 0
self._parts: list[str] = []
self.results: list[tuple[str, str]] = []
def handle_starttag(self, tag, attrs):
attributes = dict(attrs)
if tag == "a" and not self._href:
self._href = str(attributes.get("href") or "")
if tag == "h3" and self._href:
self._depth = 1
self._parts = []
elif self._depth:
self._depth += 1
def handle_data(self, data):
if self._depth:
self._parts.append(data)
def handle_endtag(self, tag):
if not self._depth:
if tag == "a":
self._href = ""
return
self._depth -= 1
if tag != "h3" or self._depth:
return
title = " ".join("".join(self._parts).split())[:300]
href = self._href
self._href = ""
self._parts = []
if title and href:
self.results.append((title, href))
class GoogleBrowserSearchSource(GatedSource):
"""Experimental, feature-flagged public Google result-page connector.
This connector only requests the public result HTML. It does not use a
browser profile, JavaScript execution, login, proxy, CAPTCHA solver, or
alternative endpoint when Google blocks access.
"""
kind = source_code = "google_browser_search"
display_name = "Google Browser Search (experimental)"
available = True
optional = True
requires_credentials = False
_last_request_at: float | None = None
_rate_lock = threading.Lock()
max_response_bytes = 512 * 1024
timeout = 10
max_results = 10
def validate_config(self, config):
result = super().validate_config(config)
if not result.valid:
return result
rate = config.get("rate_limit")
if not isinstance(rate, int) or isinstance(rate, bool) or not 1 <= rate <= 12:
return ValidationResult(False, ["rate_limit must be an integer from 1 to 12 requests per minute"])
return ValidationResult(True)
@staticmethod
def _query(criteria: Mapping[str, Any]) -> str:
if not isinstance(criteria, Mapping):
raise ValueError("criteria must be an object")
values: list[str] = []
keywords = criteria.get("keywords", criteria.get("keyword", criteria.get("query", "")))
if isinstance(keywords, str):
keywords = [keywords]
if isinstance(keywords, Sequence) and not isinstance(keywords, (bytes, bytearray, str)):
values.extend(str(value).strip() for value in keywords[:10] if str(value).strip())
for key in ("category", "industry", "city", "location", "province", "country"):
value = str(criteria.get(key, "")).strip()
if value:
values.append(value)
query = " ".join(values)
if not query or len(query) > 500 or any(char in query for char in "\r\n"):
raise ValueError("bounded discovery criteria are required")
return query
@staticmethod
def _blocked(html: str) -> bool:
lowered = html.lower()
markers = ("our systems have detected unusual traffic", "recaptcha", "captcha", "automated queries", "access denied", "sorry...")
return any(marker in lowered for marker in markers)
def discover(self, config, cursor=None, criteria=None, limits=None):
result = self.validate_config(config)
if not result.valid:
raise ValueError(result.errors[0])
if os.environ.get("GOOGLE_BROWSER_SEARCH_ENABLED", "").strip().lower() != "true":
raise GoogleBrowserSearchBlocked("feature_disabled")
query = self._query(criteria or {})
limits = limits if isinstance(limits, Mapping) else {}
try:
requested = int(limits.get("per_run_limit", limits.get("max_records", self.max_results)))
except (TypeError, ValueError):
raise ValueError("invalid_limits")
count = max(1, min(self.max_results, requested))
interval = 60.0 / int(config["rate_limit"])
with self._rate_lock:
now = time.monotonic()
if self._last_request_at is not None and now - self._last_request_at < interval:
raise GoogleBrowserSearchBlocked("rate_limited")
self.__class__._last_request_at = now
url = "https://www.google.com/search?" + urlencode({"q": query, "num": count, "hl": str(criteria.get("language", "en"))[:12] or "en"})
request = Request(url, headers={"User-Agent": "ProspectPlatform/0.1 public-search (no-login; experimental)", "Accept": "text/html,application/xhtml+xml"})
try:
with urlopen(request, timeout=self.timeout) as response:
html = response.read(self.max_response_bytes + 1)
except Exception as exc:
raise GoogleBrowserSearchBlocked("access_denied") from exc
if len(html) > self.max_response_bytes:
raise GoogleBrowserSearchBlocked("response_too_large")
text = html.decode("utf-8", "replace")
if self._blocked(text):
raise GoogleBrowserSearchBlocked("google_challenge_or_denial")
parser = _GoogleVisibleResults()
parser.feed(text)
records, seen = [], set()
for title, href in parser.results:
parsed = urlparse(href)
host = (parsed.hostname or "").lower()
if parsed.scheme not in {"http", "https"} or not host or host.endswith("google.com") or href in seen:
continue
seen.add(href)
records.append(normalize_record({"name": title, "website": href, "description": "Public Google search result"}))
if len(records) >= count:
break
return DiscoveryPage(records, metadata={"adapter": self.source_code, "experimental": True, "public_html_only": True, "record_count": len(records), "query": query})
2026-09-04 11:18:18 +02:00
class GooglePlacesSource(GatedSource):
kind = source_code = "google_places"
display_name = "Google Places"
available = True
def validate_config(self, config):
result = super().validate_config(config)
if not result.valid: return result
if not str(config.get("query", "")).strip(): return ValidationResult(False, ["query is required"])
return ValidationResult(True)
def discover(self, config, cursor=None, criteria=None, limits=None):
2026-09-04 11:18:18 +02:00
result = self.validate_config(config)
if not result.valid: raise ValueError(result.errors[0])
api_key = str(config.get("_api_key", "")).strip()
if not api_key: raise ValueError("Google Places API key is not configured")
body = {"textQuery": str(config["query"]).strip(), "pageSize": max(1, min(20, int(config.get("max_records", 20))))}
if config.get("region_code"): body["regionCode"] = str(config["region_code"]).upper()[:2]
request = Request("https://places.googleapis.com/v1/places:searchText", data=json.dumps(body).encode(), method="POST", headers={"Content-Type":"application/json", "X-Goog-Api-Key":api_key, "X-Goog-FieldMask":"places.displayName,places.websiteUri,places.nationalPhoneNumber,places.internationalPhoneNumber,places.formattedAddress,places.googleMapsUri"})
with urlopen(request, timeout=12) as response:
payload=json.loads(response.read(2*1024*1024).decode("utf-8", "replace"))
records=[]
for place in payload.get("places", [])[:20]:
name=(place.get("displayName") or {}).get("text", "")
records.append(normalize_record({"name":name, "website":place.get("websiteUri", ""), "phone":place.get("nationalPhoneNumber") or place.get("internationalPhoneNumber", ""), "location":place.get("formattedAddress", ""), "source_url":place.get("googleMapsUri", "") , "description":"Google Places result"}))
return DiscoveryPage(records, metadata={"adapter":self.source_code,"record_count":len(records),"provider":"google_places"})
2026-09-04 13:36:40 +02:00
class OpenStreetMapSource(ApprovedDirectorySource):
kind = source_code = "openstreetmap"
display_name = "OpenStreetMap / Overpass"
optional = False
def discover(self, config, cursor=None, criteria=None, limits=None):
page = super().discover({**dict(config), "provider": "openstreetmap"}, cursor, criteria=criteria, limits=limits)
2026-09-04 13:36:40 +02:00
return DiscoveryPage(page.records, page.next_cursor, {**page.metadata, "adapter": self.source_code})
class WikidataSource(ApprovedDirectorySource):
kind = source_code = "wikidata"
display_name = "Wikidata"
optional = False
def discover(self, config, cursor=None, criteria=None, limits=None):
page = super().discover({**dict(config), "provider": "wikidata"}, cursor, criteria=criteria, limits=limits)
2026-09-04 13:36:40 +02:00
return DiscoveryPage(page.records, page.next_cursor, {**page.metadata, "adapter": self.source_code})
class CommonCrawlSource(ApprovedDirectorySource):
kind = source_code = "common_crawl"
display_name = "Common Crawl index"
optional = False
def discover(self, config, cursor=None, criteria=None, limits=None):
page = super().discover({**dict(config), "provider": "common_crawl"}, cursor, criteria=criteria, limits=limits)
2026-09-04 13:36:40 +02:00
return DiscoveryPage(page.records, page.next_cursor, {**page.metadata, "adapter": self.source_code})
def _gated(code, name):
return type(name.replace(" ", ""), (GatedSource,), {"kind":code, "source_code":code, "display_name":name})
BingLocalSource = _gated("bing_local", "Bing / approved local API")
PermittedSocialSource = _gated("permitted_social", "Permitted social")
2026-09-04 20:52:37 +02:00
ADAPTERS = {x.source_code: x for x in (ManualSource, CsvSource, GooglePlacesSource, GoogleBrowserSearchSource, BingLocalSource, ApprovedDirectorySource, OpenStreetMapSource, WikidataSource, CommonCrawlSource, PublicWebsiteSource, PermittedSocialSource, CtLogsSource, DnsSource, RdapSource)}
# common aliases used by clients
ADAPTER_REGISTRY = ADAPTERS
2026-09-02 18:58:10 +02:00
def adapter_for(kind: str) -> DiscoverySource:
try: return ADAPTERS[str(kind).strip().lower()]()
2026-09-02 18:58:10 +02:00
except KeyError: raise ValueError("unsupported source kind")
2026-09-04 10:03:34 +02:00
def available_adapters() -> list[dict[str, object]]:
return [{"source_code": cls.source_code, "display_name": cls.display_name,
"available": bool(getattr(cls, "available", False)),
"optional": bool(getattr(cls, "optional", False)),
"requires_credentials": bool(getattr(cls, "requires_credentials", False))}
2026-09-04 13:58:31 +02:00
for cls in ADAPTERS.values() if cls.source_code != "approved_directory"]