Files
MarketingTool/apps/api/app/sources.py
T
Marco0300 6aefbdc1f3
CI / compose (push) Failing after 5m46s
rebuild source discovery workflows
2026-09-04 20:52:37 +02:00

591 lines
31 KiB
Python

"""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.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Mapping, Protocol, Sequence
from html.parser import HTMLParser
import csv, io, os, random, threading, time, json, re
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
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"}
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)
class DiscoverySource(Protocol):
source_code: str
display_name: str
def validate_config(self, config: Mapping[str, Any]) -> ValidationResult: ...
def discover(self, config: Mapping[str, Any], cursor: str | None = None) -> DiscoveryPage: ...
def health_check(self, config: Mapping[str, Any]) -> SourceHealth: ...
_FIELDS = ("name", "website", "email", "phone", "description", "location")
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"}
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 = ""
available = True
optional = False
requires_credentials = False
def validate_config(self, config):
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
if not isinstance(config.get("csv"), str): return ValidationResult(False, ["csv must be text"])
try:
reader = csv.DictReader(io.StringIO(config["csv"]))
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])
reader = csv.DictReader(io.StringIO(config["csv"].replace("\r\n", "\n")))
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):
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):
result = self.validate_config(config)
if not result.valid: raise ValueError(result.errors[0])
query = str(config.get("domain", config.get("query", ""))).strip()
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):
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):
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})
class GatedSource(_Base):
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")
class ApprovedDirectorySource(GatedSource):
kind = source_code = "approved_directory"
display_name = "Free public directories"
available = True
requires_credentials = False
optional = False
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):
result=self.validate_config(config)
if not result.valid: raise ValueError(result.errors[0])
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()
if provider == "openstreetmap":
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)
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","")
if not name or not any(term in name.lower() or term in str(tags).lower() for term in variants): continue
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)})
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})
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):
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"})
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)
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)
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)
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")
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
def adapter_for(kind: str) -> DiscoverySource:
try: return ADAPTERS[str(kind).strip().lower()]()
except KeyError: raise ValueError("unsupported source kind")
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))}
for cls in ADAPTERS.values() if cls.source_code != "approved_directory"]