This commit is contained in:
+69
-17
@@ -5,6 +5,7 @@ from http.cookies import SimpleCookie
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
if __package__ in (None, ""):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from app.domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, normalize_domain, normalize_phone, match_businesses
|
||||
@@ -109,13 +110,44 @@ def row_json(row):
|
||||
|
||||
def source_config_with_credentials(db, source, config):
|
||||
"""Inject a credential only into the in-process adapter call; never persist/return it."""
|
||||
if source["kind"] != "google_places": return config
|
||||
if (source["source_code"] or source["kind"]) != "google_places": return config
|
||||
row = db.execute("SELECT secret_ref FROM source_credentials WHERE source_id=? AND organization_id=? AND provider=? AND key_name=?", (source["id"], source["organization_id"], "google_places", "api_key")).fetchone()
|
||||
if not row or not row["secret_ref"]: return config
|
||||
try: config["_api_key"] = decrypt_provider_secret(row["secret_ref"])
|
||||
except Exception: pass
|
||||
return config
|
||||
|
||||
def resolve_selected_source_codes(db, org, selected):
|
||||
"""Accept dashboard source IDs or adapter codes and normalize to codes."""
|
||||
if not isinstance(selected, list): return []
|
||||
codes=[]
|
||||
for value in selected:
|
||||
row = db.execute("SELECT source_code,kind FROM sources WHERE id=? AND organization_id=?", (int(value), org)).fetchone() if str(value).isdigit() else None
|
||||
code = (row["source_code"] or row["kind"]) if row else str(value).strip().lower()
|
||||
if code and code not in codes: codes.append(code)
|
||||
return codes
|
||||
|
||||
def enrich_source_business(db, org, bid, website_url, actor):
|
||||
"""Bounded post-discovery enrichment for source records with a public website."""
|
||||
if not website_url: return
|
||||
try: scan = scan_website(website_url, max_pages=1)
|
||||
except Exception: return
|
||||
now=datetime.now(timezone.utc).replace(microsecond=0); cache_key=hashlib.sha256(str(website_url).encode()).hexdigest()
|
||||
website=db.execute("SELECT id FROM websites WHERE organization_id=? AND business_id=? AND url=?",(org,bid,website_url)).fetchone()
|
||||
existing_scan=db.execute("SELECT id FROM website_scans WHERE organization_id=? AND business_id=? AND cache_key=? ORDER BY id DESC LIMIT 1",(org,bid,cache_key)).fetchone()
|
||||
scan_id=existing_scan["id"] if existing_scan else db.execute("INSERT INTO website_scans(organization_id,business_id,website_id,input_url,classification,result_json,cache_key,scanned_at,cache_expires_at) VALUES(?,?,?,?,?,?,?,?,?)",(org,bid,website["id"] if website else None,website_url,scan.get("classification","unknown"),json.dumps(scan,sort_keys=True),cache_key,now.isoformat(),(now+timedelta(seconds=WEBSITE_SCAN_CACHE_SECONDS)).isoformat())).lastrowid
|
||||
html=scan.get("html","")
|
||||
suppressions=[dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=? AND active=1",(org,))]
|
||||
for item in extract_contacts(html,website_url,suppressions=suppressions,max_results=25):
|
||||
key=hashlib.sha256((str(bid)+item["source_url"]+item["kind"]+item["value"]).encode()).hexdigest()
|
||||
db.execute("INSERT OR IGNORE INTO contact_extractions(organization_id,business_id,website_scan_id,extraction_key,kind,value,label,classification,confidence,source_url,public_business,mx_status,suppressed,do_not_contact,provenance) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",(org,bid,scan_id,key,item["kind"],item["value"],item["label"],item["classification"],item["confidence"],item["source_url"],1,item["mx_status"],int(item["suppressed"]),int(item["do_not_contact"]),item["provenance"]))
|
||||
if item["kind"]=="email": db.execute("UPDATE businesses SET email=CASE WHEN email='' THEN ? ELSE email END WHERE id=? AND organization_id=?",(item["value"],bid,org))
|
||||
if item["kind"]=="phone": db.execute("UPDATE businesses SET phone=CASE WHEN phone='' THEN ? ELSE phone END WHERE id=? AND organization_id=?",(item["value"],bid,org))
|
||||
db.execute("UPDATE businesses SET website_class=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?",(scan.get("classification","unknown"),bid,org))
|
||||
current=db.execute("SELECT * FROM businesses WHERE id=? AND organization_id=?",(bid,org)).fetchone()
|
||||
if current:
|
||||
scored=score_business_opportunity(row_json(current),website=scan,sources=[actor]); db.execute("UPDATE businesses SET score=?,score_version=?,score_factors=? WHERE id=? AND organization_id=?",(scored["score"],scored["score_version"],json.dumps(scored["explanations"]),bid,org))
|
||||
|
||||
class ApiHandler(BaseHTTPRequestHandler):
|
||||
server_version = "ProspectPlatform/0.1"
|
||||
def send_json(self, status, payload, extra_headers=None):
|
||||
@@ -869,7 +901,7 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def create_scoped_discovery(self, payload, db, user):
|
||||
criteria = payload.get("criteria", {}); seeds = payload.get("seed_urls")
|
||||
selected_adapters = payload.get("selected_adapters", payload.get("sources", []))
|
||||
selected_adapters = resolve_selected_source_codes(db, user["organization_id"], payload.get("selected_adapters", payload.get("source_ids", payload.get("sources", []))))
|
||||
source_mode = bool(selected_adapters) and seeds is None
|
||||
criteria_only = seeds is None and not source_mode
|
||||
if not isinstance(criteria, dict): return self.send_json(400, {"error": "invalid_criteria"})
|
||||
@@ -1367,16 +1399,18 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
out.append(x)
|
||||
return self.send_json(200,{"organization_id":org,"items":out,"limit":limit,"offset":offset,"has_more":len(rows)>limit})
|
||||
def create_source(self,payload,db,user):
|
||||
name=str(payload.get('name','')).strip(); kind=str(payload.get('source_code',payload.get('kind',''))).strip().lower(); config=payload.get('config',{})
|
||||
optional = kind not in ('csv','manual')
|
||||
if not name or kind not in ('csv','manual','google_places','bing_local','approved_directory','public_website','permitted_social','ct_logs','dns','rdap') or not isinstance(config,dict):return self.send_json(400,{"error":"invalid_source"})
|
||||
name=str(payload.get('name','')).strip(); requested=str(payload.get('source_code') or payload.get('kind','')).strip().lower(); config=payload.get('config',{})
|
||||
provider_kinds={'openstreetmap','wikidata','common_crawl'}
|
||||
kind='approved_directory' if requested in provider_kinds else requested
|
||||
source_code=requested
|
||||
if not name or requested not in ('csv','manual','google_places','bing_local','approved_directory','openstreetmap','wikidata','common_crawl','public_website','permitted_social','ct_logs','dns','rdap') or not isinstance(config,dict):return self.send_json(400,{"error":"invalid_source"})
|
||||
if contains_secret(config):return self.send_json(400,{"error":"secret_not_permitted"})
|
||||
try:
|
||||
validation=adapter_for(kind).validate_config(config)
|
||||
validation=adapter_for(source_code).validate_config(config)
|
||||
# Registration may precede the actual local payload or optional provider
|
||||
# approval. Discovery/ingest still validates the effective configuration.
|
||||
if config and not validation.valid:return self.send_json(400,{"error":"invalid_source_config","details":validation.errors})
|
||||
cur=db.execute("INSERT INTO sources(organization_id,name,kind,source_code,display_name,enabled,approved,config_json,policy_json,quota_json) VALUES(?,?,?,?,?,?,?,?,?,?)",(user['organization_id'],name,kind,kind,str(payload.get('display_name') or adapter_for(kind).display_name),int(bool(payload.get('enabled',False))),int(bool(payload.get('approved',config.get('approved',False)))),json.dumps(config,sort_keys=True),json.dumps(payload.get('policy',{}),sort_keys=True),json.dumps(payload.get('quota',{}),sort_keys=True)))
|
||||
cur=db.execute("INSERT INTO sources(organization_id,name,kind,source_code,display_name,enabled,approved,config_json,policy_json,quota_json) VALUES(?,?,?,?,?,?,?,?,?,?)",(user['organization_id'],name,kind,source_code,str(payload.get('display_name') or adapter_for(source_code).display_name),int(bool(payload.get('enabled',False))),int(bool(payload.get('approved',config.get('approved',False)))),json.dumps(config,sort_keys=True),json.dumps(payload.get('policy',{}),sort_keys=True),json.dumps(payload.get('quota',{}),sort_keys=True)))
|
||||
except sqlite3.IntegrityError:return self.send_json(409,{"error":"duplicate_source"})
|
||||
self.audit(db,user,'source.created',str(cur.lastrowid));db.commit();return self.send_json(201,row_json(db.execute("SELECT id,organization_id,name,kind,source_code,display_name,enabled,approved,policy_json,quota_json,health_status,consecutive_failures,circuit_open,last_success_at,last_failure_at,last_error,created_at,updated_at FROM sources WHERE id=?",(cur.lastrowid,)).fetchone()))
|
||||
def update_source(self,sid,payload,db,user):
|
||||
@@ -1385,7 +1419,7 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
if 'config' in payload:
|
||||
config=payload.get('config')
|
||||
if not isinstance(config,dict) or contains_secret(config): return self.send_json(400,{"error":"invalid_source_config"})
|
||||
adapter=adapter_for(source['kind']); validation=adapter.validate_config(config)
|
||||
adapter=adapter_for(source['source_code'] or source['kind']); validation=adapter.validate_config(config)
|
||||
if not validation.valid: return self.send_json(400,{"error":"invalid_source_config","details":validation.errors})
|
||||
db.execute("UPDATE sources SET config_json=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?",(json.dumps(config,sort_keys=True),sid,user['organization_id']))
|
||||
self.audit(db,user,'source.configured',str(sid)); db.commit()
|
||||
@@ -1393,7 +1427,7 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
row=db.execute("SELECT * FROM sources WHERE id=?",(sid,)).fetchone(); return self.send_json(200,row_json(row))
|
||||
value=int(bool(payload['enabled']))
|
||||
if value:
|
||||
adapter=adapter_for(source['kind'])
|
||||
adapter=adapter_for(source['source_code'] or source['kind'])
|
||||
try: config=json.loads(source['config_json'] or '{}')
|
||||
except (TypeError,ValueError): config={}
|
||||
metadata=next((item for item in available_adapters() if item['source_code']==source['kind']), {})
|
||||
@@ -1404,12 +1438,12 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
# before they are enabled.
|
||||
if not validation.valid and not (source['kind'] == 'manual' and not config):
|
||||
return self.send_json(409,{"error":"source_not_configured","details":validation.errors})
|
||||
if source['kind'] == 'google_places' and not db.execute("SELECT 1 FROM source_credentials WHERE source_id=? AND organization_id=? AND provider=? AND key_name=?", (sid,user['organization_id'],'google_places','api_key')).fetchone():
|
||||
if source['source_code'] == 'google_places' and not db.execute("SELECT 1 FROM source_credentials WHERE source_id=? AND organization_id=? AND provider=? AND key_name=?", (sid,user['organization_id'],'google_places','api_key')).fetchone():
|
||||
return self.send_json(409,{"error":"source_not_configured","details":["Google Places API key is required"]})
|
||||
db.execute("UPDATE sources SET enabled=?,updated_at=CURRENT_TIMESTAMP WHERE id=?",(value,sid));self.audit(db,user,'source.enabled' if value else 'source.disabled',str(sid));db.commit();return self.send_json(200,row_json(db.execute("SELECT * FROM sources WHERE id=?",(sid,)).fetchone()))
|
||||
def create_query(self,payload,db,user):
|
||||
sid=payload.get('source_id');name=str(payload.get('name','')).strip();query=payload.get('query',{})
|
||||
selected=payload.get('selected_adapters',payload.get('sources',[])); location=str(payload.get('location','')).strip(); category=str(payload.get('category','')).strip(); schedule=str(payload.get('schedule','')).strip()
|
||||
selected=resolve_selected_source_codes(db, user['organization_id'], payload.get('selected_adapters',payload.get('sources',[]))); location=str(payload.get('location','')).strip(); category=str(payload.get('category','')).strip(); schedule=str(payload.get('schedule','')).strip()
|
||||
try: max_records=int(payload.get('max_records',100)); daily_limit=int(payload.get('daily_limit',1000))
|
||||
except (TypeError,ValueError): return self.send_json(400,{"error":"invalid_limits"})
|
||||
if not isinstance(selected,list) or any(str(x) not in {a["source_code"] for a in available_adapters()} for x in selected): return self.send_json(400,{"error":"invalid_adapters"})
|
||||
@@ -1431,7 +1465,7 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
def test_source(self,sid,db,user):
|
||||
source=db.execute("SELECT * FROM sources WHERE id=? AND organization_id=?",(sid,user['organization_id'])).fetchone()
|
||||
if not source:return self.send_json(404,{"error":"not_found"})
|
||||
try: result=adapter_for(source['kind']).validate(json.loads(source['config_json'])); ok=result.valid; error='; '.join(result.errors) if not ok else None
|
||||
try: result=adapter_for(source['source_code'] or source['kind']).validate(json.loads(source['config_json'])); ok=result.valid; error='; '.join(result.errors) if not ok else None
|
||||
except Exception as exc:ok=False;error=str(exc)[:300]
|
||||
if ok:db.execute("UPDATE sources SET health_status='healthy',consecutive_failures=0,circuit_open=0,last_success_at=CURRENT_TIMESTAMP,last_error=NULL WHERE id=?",(sid,));action='source.test.succeeded'
|
||||
else:db.execute("UPDATE sources SET health_status='unhealthy',consecutive_failures=consecutive_failures+1,circuit_open=CASE WHEN consecutive_failures+1>=3 THEN 1 ELSE circuit_open END,last_failure_at=CURRENT_TIMESTAMP,last_error=? WHERE id=?",(error,sid));action='source.test.failed'
|
||||
@@ -1445,7 +1479,7 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
if len(json.dumps(config).encode())>5*1024*1024:return self.send_json(400,{"error":"ingest_limits"})
|
||||
if isinstance(config.get('rows'),list) and (len(config['rows'])>1000 or any(not isinstance(r,dict) or len(r)>50 or any(len(str(v))>10000 for v in r.values()) for r in config['rows'])):return self.send_json(400,{"error":"ingest_limits"})
|
||||
if isinstance(config.get('csv'),str) and config['csv'].count('\n')>1001:return self.send_json(400,{"error":"ingest_limits"})
|
||||
try:page=adapter_for(source['kind']).discover(source_config_with_credentials(db, source, config))
|
||||
try:page=adapter_for(source['source_code'] or source['kind']).discover(source_config_with_credentials(db, source, config))
|
||||
except (ValueError,KeyError) as exc:return self.send_json(400,{"error":"invalid_ingest","detail":str(exc)})
|
||||
inserted=0
|
||||
for record in page.records[:1000]:
|
||||
@@ -1460,7 +1494,7 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
if not source:return self.send_json(404,{"error":"not_found"})
|
||||
try: config=json.loads(source["config_json"] or "{}")
|
||||
except (TypeError,ValueError): config={}
|
||||
health=adapter_for(source["kind"]).health_check(config)
|
||||
health=adapter_for(source["source_code"] or source["kind"]).health_check(config)
|
||||
return self.send_json(200,{"id":sid,"source_code":source["source_code"] or source["kind"],"display_name":source["display_name"] or source["name"],"status":source["health_status"],"configured":health.status=="healthy","circuit_open":bool(source["circuit_open"]),"consecutive_failures":source["consecutive_failures"],"last_error":source["last_error"]})
|
||||
def discovery_run_action(self,rid,action,db,user):
|
||||
run=db.execute("SELECT * FROM discovery_runs WHERE id=? AND organization_id=?",(rid,user["organization_id"])).fetchone()
|
||||
@@ -1583,7 +1617,7 @@ def _run_source_discovery(db, job, handler):
|
||||
used_today=db.execute("SELECT COUNT(*) FROM source_records WHERE organization_id=? AND source_id=? AND date(created_at)=date('now')",(org,source["id"])).fetchone()[0]
|
||||
if used_today >= daily_limit or per_run_limit <= 0:
|
||||
handler.add_job_event(db,job["id"],org,"source.quota_exceeded",f"Quota reached for {source['kind']}",10,"SOURCE_QUOTA_EXCEEDED"); continue
|
||||
page=adapter_for(source["kind"]).discover(source_config_with_credentials(db, source, config))
|
||||
page=adapter_for(source["source_code"] or source["kind"]).discover(source_config_with_credentials(db, source, config))
|
||||
except Exception as exc:
|
||||
failures=int(source["consecutive_failures"])+1
|
||||
db.execute("UPDATE sources SET health_status='unhealthy',consecutive_failures=?,circuit_open=?,last_failure_at=CURRENT_TIMESTAMP,last_error=?,updated_at=CURRENT_TIMESTAMP WHERE id=?",(failures,int(circuit_is_open(failures)),str(exc)[:300],source["id"]))
|
||||
@@ -1613,6 +1647,7 @@ def _run_source_discovery(db, job, handler):
|
||||
if normalized["website_domain"] and not db.execute("SELECT 1 FROM domains WHERE organization_id=? AND business_id=? AND domain=?",(org,bid,normalized["website_domain"])).fetchone(): db.execute("INSERT INTO domains(business_id,organization_id,domain,kind) VALUES(?,?,?,?)",(bid,org,normalized["website_domain"],"website"))
|
||||
if normalized["website"] and not db.execute("SELECT 1 FROM websites WHERE organization_id=? AND business_id=? AND url=?",(org,bid,normalized["website"])).fetchone(): db.execute("INSERT INTO websites(business_id,organization_id,url,website_class) VALUES(?,?,?,?)",(bid,org,normalized["website"],"business_site"))
|
||||
if normalized["website"] or normalized["website_domain"]: db.execute("INSERT OR IGNORE INTO evidence(business_id,organization_id,kind,url,claim) VALUES(?,?,?,?,?)",(bid,org,"source_record",normalized["website"],"Discovered by "+source["kind"]))
|
||||
if normalized["website"]: enrich_source_business(db,org,bid,normalized["website"],source["source_code"] or source["kind"])
|
||||
if normalized["email"] or normalized["phone"]:
|
||||
if not db.execute("SELECT 1 FROM contacts WHERE organization_id=? AND business_id=? AND email=? AND phone=?",(org,bid,normalized["email"],normalized["phone"])).fetchone(): db.execute("INSERT INTO contacts(business_id,organization_id,email,phone) VALUES(?,?,?,?)",(bid,org,normalized["email"],normalized["phone"]))
|
||||
if record_id: db.execute("UPDATE source_records SET processing_status='processed',normalized_json=?,normalized_key=? WHERE id=? AND organization_id=?",(norm,nkey,record_id,org)); db.execute("INSERT OR IGNORE INTO enrichment_queue(organization_id,source_record_id,status) VALUES(?,?,?)",(org,record_id,"completed")); db.execute("UPDATE enrichment_queue SET status='completed',updated_at=CURRENT_TIMESTAMP WHERE source_record_id=? AND organization_id=?",(record_id,org))
|
||||
@@ -1669,13 +1704,30 @@ def _job_worker(server):
|
||||
db.commit()
|
||||
finally: db.close()
|
||||
|
||||
def _schedule_worker(server):
|
||||
"""Lightweight durable scheduler for daily/weekday/weekly discovery queries."""
|
||||
while not server.job_stop.is_set():
|
||||
db=connect(server.db_path)
|
||||
try:
|
||||
now=datetime.now(timezone.utc); today=now.date().isoformat()
|
||||
for query in db.execute("SELECT * FROM discovery_queries WHERE enabled=1 AND schedule IN ('daily','weekdays','weekly')"):
|
||||
if now.hour < 9 or (query["schedule"]=='weekdays' and now.weekday()>4) or (query["schedule"]=='weekly' and now.weekday()!=0): continue
|
||||
if db.execute("SELECT 1 FROM discovery_runs WHERE organization_id=? AND criteria_json=? AND date(created_at)=date('now') LIMIT 1",(query["organization_id"],query["query_json"])).fetchone(): continue
|
||||
key=f"scheduled-query-{query['id']}-{today}"
|
||||
if db.execute("SELECT 1 FROM jobs WHERE organization_id=? AND idempotency_key=?",(query["organization_id"],key)).fetchone(): continue
|
||||
cur=db.execute("INSERT INTO jobs(organization_id,idempotency_key,type,payload) VALUES(?,?,?,?)",(query["organization_id"],key,"source_discovery",json.dumps({"discovery_query_id":query["id"],"selected_adapters":json.loads(query["selected_adapters_json"] or '[]'),"max_records":query["max_records"],"daily_limit":query["daily_limit"]})))
|
||||
db.execute("INSERT INTO discovery_runs(organization_id,job_id,selected_adapters_json,location,category,max_records,daily_limit,schedule,dry_run,lifecycle,criteria_json,seed_urls_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",(query["organization_id"],cur.lastrowid,query["selected_adapters_json"],query["location"],query["category"],query["max_records"],query["daily_limit"],query["schedule"],query["dry_run"],"queued",query["query_json"],"[]"))
|
||||
db.commit()
|
||||
finally: db.close()
|
||||
server.job_wakeup.set(); server.job_stop.wait(30)
|
||||
|
||||
def create_server(host="127.0.0.1",port=8000,db_path="prospects.db"):
|
||||
load_config()
|
||||
configure_ai_research_db(db_path)
|
||||
server=ThreadingHTTPServer((host,port),ApiHandler);server.db_path=db_path;connect(db_path).close();server.job_stop=threading.Event();server.job_wakeup=threading.Event();server.job_thread=threading.Thread(target=_job_worker,args=(server,),daemon=True);server.job_thread.start()
|
||||
server=ThreadingHTTPServer((host,port),ApiHandler);server.db_path=db_path;connect(db_path).close();server.job_stop=threading.Event();server.job_wakeup=threading.Event();server.job_thread=threading.Thread(target=_job_worker,args=(server,),daemon=True);server.job_thread.start();server.schedule_thread=threading.Thread(target=_schedule_worker,args=(server,),daemon=True);server.schedule_thread.start()
|
||||
original_close=server.server_close
|
||||
def close():
|
||||
server.job_stop.set();server.job_wakeup.set();server.job_thread.join(timeout=2);original_close()
|
||||
server.job_stop.set();server.job_wakeup.set();server.job_thread.join(timeout=2);server.schedule_thread.join(timeout=2);original_close()
|
||||
server.server_close=close
|
||||
return server
|
||||
if __name__=="__main__":
|
||||
|
||||
+26
-1
@@ -315,6 +315,7 @@ class ApprovedDirectorySource(GatedSource):
|
||||
display_name = "Free public directories"
|
||||
available = True
|
||||
requires_credentials = False
|
||||
optional = False
|
||||
|
||||
def validate_config(self, config):
|
||||
result=super().validate_config(config)
|
||||
@@ -375,12 +376,36 @@ class GooglePlacesSource(GatedSource):
|
||||
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):
|
||||
page = super().discover({**dict(config), "provider": "openstreetmap"}, cursor)
|
||||
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):
|
||||
page = super().discover({**dict(config), "provider": "wikidata"}, cursor)
|
||||
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):
|
||||
page = super().discover({**dict(config), "provider": "common_crawl"}, cursor)
|
||||
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, BingLocalSource, ApprovedDirectorySource, PublicWebsiteSource, PermittedSocialSource, CtLogsSource, DnsSource, RdapSource)}
|
||||
ADAPTERS = {x.source_code: x for x in (ManualSource, CsvSource, GooglePlacesSource, BingLocalSource, ApprovedDirectorySource, OpenStreetMapSource, WikidataSource, CommonCrawlSource, PublicWebsiteSource, PermittedSocialSource, CtLogsSource, DnsSource, RdapSource)}
|
||||
# common aliases used by clients
|
||||
ADAPTER_REGISTRY = ADAPTERS
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ class SourceAdapterTests(unittest.TestCase):
|
||||
self.assertFalse(catalog[code]['available'], code)
|
||||
self.assertTrue(catalog[code]['optional'], code)
|
||||
self.assertTrue(catalog['approved_directory']['available'])
|
||||
self.assertTrue(catalog['approved_directory']['optional'])
|
||||
self.assertFalse(catalog['approved_directory']['optional'])
|
||||
self.assertTrue(catalog['google_places']['available'])
|
||||
self.assertTrue(catalog['google_places']['optional'])
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"version": "phase-24",
|
||||
"version": "phase-26",
|
||||
"entrypoints": [
|
||||
"config.js",
|
||||
"app.js",
|
||||
@@ -13,10 +13,10 @@
|
||||
"healthz"
|
||||
],
|
||||
"integrity": {
|
||||
"config.js": "sha256-29953b8de308d7831c606d2116b30ae59744fc0e45f998d970fe71fcbd80fc2a",
|
||||
"config.js": "sha256-036e9b1a2d16cf89ffc1cb23f22aab71608b1330e8323d2d42a6c6b86051698c",
|
||||
"app.js": "sha256-44809719afe72cd0ddc30819b3c73f36019319bfbda4e59d62055fd040b5c6c0",
|
||||
"styles.css": "sha256-a9fd194b001c0de98775bcf5fbba5da676ccfdd607a889eae84bd30a54c15e08",
|
||||
"index.html": "sha256-f72befe537a05b7172f94346b367c75f364b0895b9dac233cc6bd78220a222d9",
|
||||
"index.html": "sha256-f48882419575ac49c389f07ba25791115981330a1dd623ce3c21b026a0dedd3c",
|
||||
"health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81",
|
||||
"error.html": "sha256-f3cc28d2dfc9e8af112d0257c6ee47b9e9146e5da8590a754dd589bf43702aaf",
|
||||
"healthz": "sha256-dc51b8c96c2d745df3bd5590d990230a482fd247123599548e0632fdbf97fc22"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
/* Public, non-secret runtime configuration. Replace this file at deploy time if needed. */
|
||||
window.__PROSPECT_CONFIG__ = Object.freeze({
|
||||
apiBase: '',
|
||||
assetVersion: 'phase-24'
|
||||
assetVersion: 'phase-26'
|
||||
});
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ProspectOS · Pipeline intelligence</title>
|
||||
<meta name="description" content="Prospect discovery and review dashboard">
|
||||
<link rel="stylesheet" href="styles.css?v=phase-24">
|
||||
<link rel="stylesheet" href="styles.css?v=phase-26">
|
||||
</head>
|
||||
<body>
|
||||
<section class="login-screen" id="loginScreen" aria-labelledby="loginTitle">
|
||||
@@ -130,7 +130,7 @@
|
||||
<div class="merge-dialog" id="mergeDialog" hidden role="dialog" aria-modal="true" aria-labelledby="mergeDialogTitle">
|
||||
<div class="merge-dialog-card"><div class="panel-heading"><div><p class="eyebrow">REVIEW REQUIRED</p><h2 id="mergeDialogTitle">Confirm merge</h2></div><button class="icon-button" id="cancelMergeBtn" type="button" aria-label="Close merge confirmation">×</button></div><p id="mergeDialogCopy"></p><div class="merge-warning"><strong>This action is reversible.</strong> The merge will be recorded in history and can be reversed later.</div><p id="mergeDialogMessage" class="form-message" role="alert" aria-live="polite"></p><div class="merge-dialog-actions"><button class="button ghost" id="cancelMergeBtnSecondary" type="button">Cancel</button><button class="button primary" id="confirmMergeBtn" type="button">Confirm merge</button></div></div>
|
||||
</div>
|
||||
<script src="config.js?v=phase-24"></script>
|
||||
<script src="app.js?v=phase-24"></script>
|
||||
<script src="config.js?v=phase-26"></script>
|
||||
<script src="app.js?v=phase-26"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user