2026-09-03 22:49:05 +02:00
"""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" }
2026-09-03 22:49:05 +02:00
NETWORK_KINDS = { "google_places" , "bing_local" , "approved_directory" , "public_website" , "permitted_social" , "ct_logs" , "dns" , "rdap" }
2026-09-04 18:53:30 +02:00
DISCOVERY_CRITERIA_FIELDS = { "query" , "category" , "city" , "location" , "keywords" , "province" , "country" , "language" , "search" , "phrase" , "industry" , "keyword" }
2026-09-03 22:49:05 +02:00
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
2026-09-03 22:49:05 +02:00
@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 ):
2026-09-03 22:49:05 +02:00
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 : ...
2026-09-03 22:49:05 +02:00
def health_check ( self , config : Mapping [ str , Any ]) -> SourceHealth : ...
2026-09-02 18:58:10 +02:00
2026-09-03 22:49:05 +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 ]:
2026-09-03 22:49:05 +02:00
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
2026-09-03 22:49:05 +02:00
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 :
2026-09-03 23:01:25 +02:00
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 )))
2026-09-03 22:49:05 +02:00
def quota_allowed ( used : int , limit : int | None ) -> bool :
2026-09-03 23:01:25 +02:00
remaining = quota_remaining ( used , limit )
return remaining is None or remaining > 0
2026-09-03 22:49:05 +02:00
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
2026-09-03 22:49:05 +02:00
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 )
2026-09-03 22:49:05 +02:00
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 ))
2026-09-04 18:53:30 +02:00
def discover ( self , config , cursor = None , criteria = None , limits = None ):
2026-09-03 22:49:05 +02:00
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 )
2026-09-04 18:53:30 +02:00
def discover ( self , config , cursor = None , criteria = None , limits = None ):
2026-09-03 22:49:05 +02:00
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 :
2026-09-03 22:49:05 +02:00
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 )
2026-09-04 18:53:30 +02:00
def discover ( self , config , cursor = None , criteria = None , limits = None ):
2026-09-03 22:49:05 +02:00
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 )
2026-09-04 18:53:30 +02:00
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 )
2026-09-04 18:53:30 +02:00
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 ])
2026-09-04 18:53:30 +02:00
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 )
2026-09-04 18:53:30 +02:00
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 )
2026-09-04 18:53:30 +02:00
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
2026-09-03 22:49:05 +02:00
class GatedSource ( _Base ):
2026-09-04 10:03:34 +02:00
available = False
optional = True
requires_credentials = True
2026-09-03 22:49:05 +02:00
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 )
2026-09-04 18:53:30 +02:00
def discover ( self , config , cursor = None , criteria = None , limits = None ):
2026-09-03 22:49:05 +02:00
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 )
2026-09-04 18:53:30 +02:00
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 )
2026-09-04 18:53:30 +02:00
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
2026-09-04 18:53:30 +02:00
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
2026-09-04 18:53:30 +02:00
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
2026-09-04 18:53:30 +02:00
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 })
2026-09-03 22:49:05 +02:00
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 )}
2026-09-03 22:49:05 +02:00
# common aliases used by clients
ADAPTER_REGISTRY = ADAPTERS
2026-09-02 18:58:10 +02:00
def adapter_for ( kind : str ) -> DiscoverySource :
2026-09-03 22:49:05 +02:00
try : return ADAPTERS [ str ( kind ) . strip () . lower ()]()
2026-09-02 18:58:10 +02:00
except KeyError : raise ValueError ( "unsupported source kind" )
2026-09-03 22:49:05 +02:00
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" ]