leadintel 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- leadintel/__init__.py +6 -0
- leadintel/__main__.py +6 -0
- leadintel/config.py +165 -0
- leadintel/data/__init__.py +1 -0
- leadintel/data/demo.py +315 -0
- leadintel/exporters/__init__.py +6 -0
- leadintel/exporters/csv_exporter.py +113 -0
- leadintel/exporters/excel_exporter.py +123 -0
- leadintel/models/__init__.py +6 -0
- leadintel/models/business.py +203 -0
- leadintel/models/report.py +31 -0
- leadintel/providers/__init__.py +51 -0
- leadintel/providers/base.py +54 -0
- leadintel/providers/demo_provider.py +65 -0
- leadintel/providers/overpass_provider.py +1040 -0
- leadintel/providers/registry.py +151 -0
- leadintel/server.py +61 -0
- leadintel/services/__init__.py +48 -0
- leadintel/services/ai_business_insights.py +310 -0
- leadintel/services/business_comparison.py +220 -0
- leadintel/services/business_report.py +294 -0
- leadintel/services/competitor_research.py +293 -0
- leadintel/services/lead_scoring.py +385 -0
- leadintel/services/local_ai.py +186 -0
- leadintel/services/robots_checker.py +81 -0
- leadintel/services/seo_auditor.py +281 -0
- leadintel/services/social_finder.py +119 -0
- leadintel/services/url_validator.py +295 -0
- leadintel/services/website_analyzer.py +213 -0
- leadintel/services/website_opportunity.py +523 -0
- leadintel/storage/__init__.py +12 -0
- leadintel/storage/db.py +189 -0
- leadintel/storage/path_validator.py +118 -0
- leadintel/tools/__init__.py +38 -0
- leadintel/tools/analyze_website.py +57 -0
- leadintel/tools/check_seo.py +64 -0
- leadintel/tools/compare_businesses.py +65 -0
- leadintel/tools/export_leads.py +112 -0
- leadintel/tools/find_business_website.py +66 -0
- leadintel/tools/find_competitors.py +75 -0
- leadintel/tools/find_social_profiles.py +85 -0
- leadintel/tools/generate_business_insights.py +93 -0
- leadintel/tools/generate_business_report.py +92 -0
- leadintel/tools/generate_outreach.py +153 -0
- leadintel/tools/get_business_details.py +96 -0
- leadintel/tools/get_saved_leads.py +58 -0
- leadintel/tools/score_lead.py +88 -0
- leadintel/tools/search_businesses.py +305 -0
- leadintel/tools/website_finder.py +147 -0
- leadintel-0.3.0.dist-info/METADATA +351 -0
- leadintel-0.3.0.dist-info/RECORD +54 -0
- leadintel-0.3.0.dist-info/WHEEL +4 -0
- leadintel-0.3.0.dist-info/entry_points.txt +3 -0
- leadintel-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Excel export for business lead lists.
|
|
3
|
+
|
|
4
|
+
Generates a formatted 3-worksheet Excel workbook:
|
|
5
|
+
1. All Leads
|
|
6
|
+
2. High Website Opportunity (opportunity score >= 50)
|
|
7
|
+
3. Data Quality Summary
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
import openpyxl
|
|
17
|
+
from openpyxl.styles import Font
|
|
18
|
+
|
|
19
|
+
from ..models.business import Business
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _sanitize(value: Any) -> Any:
|
|
23
|
+
"""Prevent spreadsheet formula injection."""
|
|
24
|
+
if value is None:
|
|
25
|
+
return ""
|
|
26
|
+
if isinstance(value, str) and value.startswith(("=", "+", "-", "@")):
|
|
27
|
+
return "'" + value
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Backwards compatibility alias
|
|
32
|
+
sanitize_excel_cell = _sanitize
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _to_business(item: Any) -> Business:
|
|
36
|
+
"""Ensure lead item is a Business instance."""
|
|
37
|
+
if isinstance(item, Business):
|
|
38
|
+
return item
|
|
39
|
+
if isinstance(item, dict):
|
|
40
|
+
d = dict(item)
|
|
41
|
+
if "id" not in d:
|
|
42
|
+
d["id"] = "lead_" + str(hash(str(d.get("name", ""))))
|
|
43
|
+
if "name" not in d:
|
|
44
|
+
d["name"] = "Unknown Business"
|
|
45
|
+
try:
|
|
46
|
+
return Business.model_validate(d)
|
|
47
|
+
except Exception:
|
|
48
|
+
valid_fields = {k: v for k, v in d.items() if hasattr(Business, k)}
|
|
49
|
+
return Business(id=d.get("id", "lead_temp"), name=d.get("name", "Unknown"), **valid_fields)
|
|
50
|
+
return Business(id="lead_unknown", name=str(item))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def export_to_excel(
|
|
54
|
+
businesses: Optional[list[Any]] = None,
|
|
55
|
+
output_path: str | Path = "leads.xlsx",
|
|
56
|
+
**kwargs: Any,
|
|
57
|
+
) -> str:
|
|
58
|
+
"""
|
|
59
|
+
Export business leads to a 3-sheet Excel workbook.
|
|
60
|
+
|
|
61
|
+
Worksheets:
|
|
62
|
+
1. All Leads
|
|
63
|
+
2. High Website Opportunity (Score >= 50)
|
|
64
|
+
3. Data Quality Summary
|
|
65
|
+
"""
|
|
66
|
+
businesses = [_to_business(b) for b in (businesses or [])]
|
|
67
|
+
|
|
68
|
+
wb = openpyxl.Workbook()
|
|
69
|
+
|
|
70
|
+
headers = [
|
|
71
|
+
"Business Name", "Category", "Address", "City", "Phone", "Email", "Website",
|
|
72
|
+
"Website Status", "Website Opportunity Score", "Score Explanation",
|
|
73
|
+
"Latitude", "Longitude", "Distance (km)", "Data Confidence", "Source", "Research Timestamp"
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
# Worksheet 1: All Leads
|
|
77
|
+
ws_all = wb.active
|
|
78
|
+
ws_all.title = "All Leads"
|
|
79
|
+
ws_all.append(headers)
|
|
80
|
+
for cell in ws_all[1]:
|
|
81
|
+
cell.font = Font(bold=True)
|
|
82
|
+
ws_all.freeze_panes = "A2"
|
|
83
|
+
|
|
84
|
+
# Worksheet 2: High Website Opportunity
|
|
85
|
+
ws_high = wb.create_sheet("High Website Opportunity")
|
|
86
|
+
ws_high.append(headers)
|
|
87
|
+
for cell in ws_high[1]:
|
|
88
|
+
cell.font = Font(bold=True)
|
|
89
|
+
ws_high.freeze_panes = "A2"
|
|
90
|
+
|
|
91
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
92
|
+
|
|
93
|
+
for b in businesses:
|
|
94
|
+
row = [
|
|
95
|
+
b.name, b.category, b.address, b.city, b.phone, b.email, b.website,
|
|
96
|
+
b.website_status, b.website_opportunity_score, b.website_opportunity_explanation,
|
|
97
|
+
b.latitude, b.longitude, b.distance_km, b.data_confidence, b.source, timestamp
|
|
98
|
+
]
|
|
99
|
+
sanitized_row = [_sanitize(c) for c in row]
|
|
100
|
+
ws_all.append(sanitized_row)
|
|
101
|
+
|
|
102
|
+
if b.website_opportunity_score is not None and b.website_opportunity_score >= 50:
|
|
103
|
+
ws_high.append(sanitized_row)
|
|
104
|
+
|
|
105
|
+
# Worksheet 3: Data Quality Summary
|
|
106
|
+
ws_summary = wb.create_sheet("Data Quality Summary")
|
|
107
|
+
ws_summary.append(["Metric", "Count"])
|
|
108
|
+
for cell in ws_summary[1]:
|
|
109
|
+
cell.font = Font(bold=True)
|
|
110
|
+
|
|
111
|
+
ws_summary.append(["Total Leads", len(businesses)])
|
|
112
|
+
ws_summary.append(["With Phone Numbers", sum(1 for b in businesses if b.phone)])
|
|
113
|
+
ws_summary.append(["With Email Addresses", sum(1 for b in businesses if b.email)])
|
|
114
|
+
ws_summary.append(["Verified Websites", sum(1 for b in businesses if b.website_status == "website_verified")])
|
|
115
|
+
ws_summary.append(["No Verified Official Website", sum(1 for b in businesses if b.website_status == "website_not_found_after_research")])
|
|
116
|
+
ws_summary.append(["Social Media Presence", sum(1 for b in businesses if b.facebook_url or getattr(b, "instagram_url", None) or b.linkedin_url)])
|
|
117
|
+
ws_summary.append(["Uncertain Information", sum(1 for b in businesses if b.website_status == "website_unverified")])
|
|
118
|
+
ws_summary.append(["Missing Contact Details", sum(1 for b in businesses if not b.phone and not b.email)])
|
|
119
|
+
|
|
120
|
+
out_path = Path(output_path)
|
|
121
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
wb.save(out_path)
|
|
123
|
+
return str(out_path.absolute())
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Business data models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Literal, Optional
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, field_serializer
|
|
9
|
+
|
|
10
|
+
WebsiteStatus = Literal[
|
|
11
|
+
"website_verified",
|
|
12
|
+
"website_not_found_after_research",
|
|
13
|
+
"website_unverified",
|
|
14
|
+
"website_broken",
|
|
15
|
+
"website_not_checked",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Business(BaseModel):
|
|
20
|
+
"""Represents a single business entity."""
|
|
21
|
+
|
|
22
|
+
# Identity
|
|
23
|
+
id: str = Field(description="Stable unique identifier (e.g. 'osm_node_12345' or slug)")
|
|
24
|
+
name: str = Field(description="Business name")
|
|
25
|
+
category: Optional[str] = Field(default=None, description="Primary industry / category")
|
|
26
|
+
|
|
27
|
+
# Location
|
|
28
|
+
address: Optional[str] = Field(default=None, description="Full formatted address")
|
|
29
|
+
city: Optional[str] = Field(default=None)
|
|
30
|
+
state: Optional[str] = Field(default=None)
|
|
31
|
+
country: Optional[str] = Field(default=None)
|
|
32
|
+
postal_code: Optional[str] = Field(default=None)
|
|
33
|
+
latitude: Optional[float] = Field(default=None)
|
|
34
|
+
longitude: Optional[float] = Field(default=None)
|
|
35
|
+
distance_meters: Optional[float] = Field(
|
|
36
|
+
default=None,
|
|
37
|
+
ge=0,
|
|
38
|
+
description="Calculated distance in metres from the search center coordinates",
|
|
39
|
+
)
|
|
40
|
+
distance_km: Optional[float] = Field(
|
|
41
|
+
default=None,
|
|
42
|
+
ge=0,
|
|
43
|
+
description="Calculated distance in kilometres from search center",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Contact & Digital Presence
|
|
47
|
+
phone: Optional[str] = Field(default=None)
|
|
48
|
+
email: Optional[str] = Field(default=None)
|
|
49
|
+
website: Optional[str] = Field(default=None)
|
|
50
|
+
website_status: WebsiteStatus = Field(
|
|
51
|
+
default="website_not_checked",
|
|
52
|
+
description=(
|
|
53
|
+
"Verification status of the business website. One of: "
|
|
54
|
+
"'website_verified', 'website_not_found_after_research', "
|
|
55
|
+
"'website_unverified', 'website_broken', 'website_not_checked'."
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
website_opportunity_score: Optional[float] = Field(
|
|
59
|
+
default=None,
|
|
60
|
+
ge=0,
|
|
61
|
+
le=100,
|
|
62
|
+
description="Deterministic 0–100 score prioritizing web dev opportunity for agencies",
|
|
63
|
+
)
|
|
64
|
+
website_opportunity_explanation: Optional[str] = Field(
|
|
65
|
+
default=None,
|
|
66
|
+
description="Evidence-based explanation of the website opportunity score",
|
|
67
|
+
)
|
|
68
|
+
detected_cms: Optional[str] = Field(
|
|
69
|
+
default=None,
|
|
70
|
+
description="Detected CMS like WordPress, Shopify, Wix",
|
|
71
|
+
)
|
|
72
|
+
has_contact_route: Optional[bool] = Field(
|
|
73
|
+
default=None,
|
|
74
|
+
description="True if a contact page or mailto link was found",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Social
|
|
78
|
+
linkedin_url: Optional[str] = Field(default=None)
|
|
79
|
+
twitter_url: Optional[str] = Field(default=None)
|
|
80
|
+
facebook_url: Optional[str] = Field(default=None)
|
|
81
|
+
instagram_url: Optional[str] = Field(default=None)
|
|
82
|
+
|
|
83
|
+
# Metrics
|
|
84
|
+
rating: Optional[float] = Field(default=None, ge=0, le=5)
|
|
85
|
+
review_count: Optional[int] = Field(default=None, ge=0)
|
|
86
|
+
employee_count_estimate: Optional[str] = Field(
|
|
87
|
+
default=None, description="Rough headcount range, e.g. '10–50'"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Provenance — every record must carry this for compliance
|
|
91
|
+
source: str = Field(
|
|
92
|
+
default="demo",
|
|
93
|
+
description=(
|
|
94
|
+
"Data source identifier. "
|
|
95
|
+
"'openstreetmap' → ODbL licence applies; "
|
|
96
|
+
"'demo' → synthetic data."
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
attribution: Optional[str] = Field(
|
|
100
|
+
default=None,
|
|
101
|
+
description=(
|
|
102
|
+
"Required attribution string when source is 'openstreetmap'. "
|
|
103
|
+
"Must be displayed wherever data is shown or exported."
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
retrieved_at: datetime = Field(default_factory=datetime.utcnow)
|
|
107
|
+
is_ai_generated: bool = Field(
|
|
108
|
+
default=False,
|
|
109
|
+
description="True when any field value was produced by an AI model",
|
|
110
|
+
)
|
|
111
|
+
data_confidence: Optional[float] = Field(
|
|
112
|
+
default=None,
|
|
113
|
+
ge=0.0,
|
|
114
|
+
le=1.0,
|
|
115
|
+
description="Confidence indicator for data completeness and verification",
|
|
116
|
+
)
|
|
117
|
+
osm_tags: Optional[dict[str, str]] = Field(
|
|
118
|
+
default=None,
|
|
119
|
+
description="Preserved raw OpenStreetMap tags for transparency and auditability",
|
|
120
|
+
)
|
|
121
|
+
ranking_explanation: Optional[str] = Field(
|
|
122
|
+
default=None,
|
|
123
|
+
description="Human-readable explanation of why this business was ranked in this search position",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
model_config = ConfigDict()
|
|
127
|
+
|
|
128
|
+
@field_serializer("retrieved_at")
|
|
129
|
+
def serialise_retrieved_at(self, v: datetime) -> str:
|
|
130
|
+
return v.isoformat()
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def osm_id(self) -> Optional[str]:
|
|
134
|
+
"""Return the raw OSM element ID string if this record came from OSM."""
|
|
135
|
+
if self.id.startswith("osm_"):
|
|
136
|
+
return self.id
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def requires_attribution(self) -> bool:
|
|
141
|
+
"""True when the ODbL attribution string must be displayed."""
|
|
142
|
+
return self.source == "openstreetmap"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class BusinessSearchResult(BaseModel):
|
|
146
|
+
"""Wrapper returned by search_businesses."""
|
|
147
|
+
|
|
148
|
+
query: str
|
|
149
|
+
location: Optional[str] = None
|
|
150
|
+
category: Optional[str] = None
|
|
151
|
+
search_center: Optional[dict[str, float]] = Field(
|
|
152
|
+
default=None,
|
|
153
|
+
description="Resolved search center coordinates {'latitude': lat, 'longitude': lon}",
|
|
154
|
+
)
|
|
155
|
+
radius_meters: Optional[int] = Field(
|
|
156
|
+
default=None,
|
|
157
|
+
description="Search radius in metres applied around search center",
|
|
158
|
+
)
|
|
159
|
+
sort_by: Optional[str] = Field(
|
|
160
|
+
default=None,
|
|
161
|
+
description="Applied ranking criterion (e.g. 'highest_website_opportunity', 'closest')",
|
|
162
|
+
)
|
|
163
|
+
total_found: int = 0
|
|
164
|
+
businesses: list[Business] = Field(default_factory=list)
|
|
165
|
+
source: str = "demo"
|
|
166
|
+
provider: str = "demo"
|
|
167
|
+
retrieved_at: datetime = Field(default_factory=datetime.utcnow)
|
|
168
|
+
note: Optional[str] = Field(
|
|
169
|
+
default=None,
|
|
170
|
+
description="Human-readable note about data quality, limits, or caveats",
|
|
171
|
+
)
|
|
172
|
+
attribution: Optional[str] = Field(
|
|
173
|
+
default=None,
|
|
174
|
+
description="Attribution string — required when source is 'openstreetmap'.",
|
|
175
|
+
)
|
|
176
|
+
error: Optional[str] = Field(
|
|
177
|
+
default=None,
|
|
178
|
+
description="Error details if the search provider encountered an issue",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
@field_serializer("retrieved_at")
|
|
182
|
+
def serialise_retrieved_at(self, v: datetime) -> str:
|
|
183
|
+
return v.isoformat()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class LeadScore(BaseModel):
|
|
187
|
+
"""Lead quality score for a business."""
|
|
188
|
+
|
|
189
|
+
business_id: str
|
|
190
|
+
business_name: str
|
|
191
|
+
score: float = Field(ge=0, le=100, description="0–100 lead quality score")
|
|
192
|
+
grade: str = Field(description="A / B / C / D / F")
|
|
193
|
+
factors: dict[str, float] = Field(
|
|
194
|
+
default_factory=dict,
|
|
195
|
+
description="Breakdown of individual scoring dimensions",
|
|
196
|
+
)
|
|
197
|
+
recommendation: str = Field(default="", description="Short actionable text")
|
|
198
|
+
scored_at: datetime = Field(default_factory=datetime.utcnow)
|
|
199
|
+
is_ai_generated: bool = False
|
|
200
|
+
|
|
201
|
+
@field_serializer("scored_at")
|
|
202
|
+
def serialise_scored_at(self, v: datetime) -> str:
|
|
203
|
+
return v.isoformat()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Business report model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from .business import Business, LeadScore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BusinessReport(BaseModel):
|
|
14
|
+
"""Structured intelligence report for a single business."""
|
|
15
|
+
|
|
16
|
+
business: Business
|
|
17
|
+
lead_score: Optional[LeadScore] = None
|
|
18
|
+
summary: str = Field(default="", description="Executive summary")
|
|
19
|
+
strengths: list[str] = Field(default_factory=list)
|
|
20
|
+
weaknesses: list[str] = Field(default_factory=list)
|
|
21
|
+
opportunities: list[str] = Field(default_factory=list)
|
|
22
|
+
threats: list[str] = Field(default_factory=list)
|
|
23
|
+
competitors: list[str] = Field(default_factory=list, description="Competitor names")
|
|
24
|
+
generated_at: datetime = Field(default_factory=datetime.utcnow)
|
|
25
|
+
is_ai_generated: bool = False
|
|
26
|
+
disclaimer: str = Field(
|
|
27
|
+
default=(
|
|
28
|
+
"This report was generated from publicly available data. "
|
|
29
|
+
"Verify all facts independently before making business decisions."
|
|
30
|
+
)
|
|
31
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Providers package — exports the registry factory and all providers.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from leadintel.providers import build_registry
|
|
6
|
+
|
|
7
|
+
registry = build_registry(settings)
|
|
8
|
+
businesses, source = await registry.search(...)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from ..config import Settings
|
|
14
|
+
from .base import BusinessProvider
|
|
15
|
+
from .demo_provider import DemoProvider
|
|
16
|
+
from .overpass_provider import OverpassProvider
|
|
17
|
+
from .registry import ProviderRegistry
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_registry(cfg: Settings) -> ProviderRegistry:
|
|
21
|
+
"""
|
|
22
|
+
Construct the provider registry from application settings.
|
|
23
|
+
|
|
24
|
+
Providers are listed in priority order (first = highest priority).
|
|
25
|
+
DemoProvider is always appended last as a guaranteed fallback.
|
|
26
|
+
"""
|
|
27
|
+
providers: list[BusinessProvider] = [] # type: ignore[type-arg]
|
|
28
|
+
|
|
29
|
+
if not cfg.demo_mode:
|
|
30
|
+
# Live providers — ordered by reliability and data quality
|
|
31
|
+
providers.append(
|
|
32
|
+
OverpassProvider(
|
|
33
|
+
endpoint=cfg.overpass_endpoint,
|
|
34
|
+
radius_m=cfg.overpass_radius_m,
|
|
35
|
+
cache_ttl=cfg.cache_ttl,
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Demo is always last so the server works with zero configuration
|
|
40
|
+
providers.append(DemoProvider()) # type: ignore[arg-type]
|
|
41
|
+
|
|
42
|
+
return ProviderRegistry(providers)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"BusinessProvider",
|
|
47
|
+
"DemoProvider",
|
|
48
|
+
"OverpassProvider",
|
|
49
|
+
"ProviderRegistry",
|
|
50
|
+
"build_registry",
|
|
51
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provider base protocol.
|
|
3
|
+
|
|
4
|
+
Every data provider must implement BusinessProvider. Tools import the
|
|
5
|
+
registry and call `registry.search(...)` — they never import a specific
|
|
6
|
+
provider directly, so swapping or adding providers requires zero tool changes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from ..models.business import Business
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class BusinessProvider(Protocol):
|
|
18
|
+
"""Protocol that every LeadIntel data provider must satisfy."""
|
|
19
|
+
|
|
20
|
+
#: Human-readable name shown in tool responses and logs
|
|
21
|
+
name: str
|
|
22
|
+
|
|
23
|
+
#: True when the provider can be used without any API key or auth
|
|
24
|
+
requires_auth: bool
|
|
25
|
+
|
|
26
|
+
async def search(
|
|
27
|
+
self,
|
|
28
|
+
query: str,
|
|
29
|
+
location: str | None = None,
|
|
30
|
+
category: str | None = None,
|
|
31
|
+
limit: int = 10,
|
|
32
|
+
**kwargs: Any,
|
|
33
|
+
) -> list[Business]:
|
|
34
|
+
"""
|
|
35
|
+
Search for businesses.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
query: Free-text keyword. Pass "*" for a broad sweep.
|
|
39
|
+
location: City / region name or "lat,lon" coordinate string.
|
|
40
|
+
category: Industry hint (e.g. "restaurant", "software").
|
|
41
|
+
limit: Max results to return.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
List of Business instances, possibly empty.
|
|
45
|
+
"""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
async def get_details(self, business_id: str) -> Business | None:
|
|
49
|
+
"""
|
|
50
|
+
Fetch enriched details for a known business ID.
|
|
51
|
+
|
|
52
|
+
Returns None if the ID is unknown to this provider.
|
|
53
|
+
"""
|
|
54
|
+
...
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DemoProvider — always-available fallback using synthetic fixture data.
|
|
3
|
+
|
|
4
|
+
Returns the same 5 sample businesses as Phase 1. No network required.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..data.demo import get_demo_business, search_demo_businesses
|
|
12
|
+
from ..models.business import Business
|
|
13
|
+
from .base import BusinessProvider
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DemoProvider:
|
|
17
|
+
"""Synthetic demo data provider. Zero dependencies, always works."""
|
|
18
|
+
|
|
19
|
+
name: str = "demo"
|
|
20
|
+
requires_auth: bool = False
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self.last_search_center: dict[str, float] | None = None
|
|
24
|
+
self.last_radius_meters: int | None = None
|
|
25
|
+
|
|
26
|
+
async def search(
|
|
27
|
+
self,
|
|
28
|
+
query: str,
|
|
29
|
+
location: str | None = None,
|
|
30
|
+
category: str | None = None,
|
|
31
|
+
limit: int = 10,
|
|
32
|
+
latitude: float | None = None,
|
|
33
|
+
longitude: float | None = None,
|
|
34
|
+
radius_meters: int | None = None,
|
|
35
|
+
wider_metro: bool = False,
|
|
36
|
+
**kwargs: Any,
|
|
37
|
+
) -> list[Business]:
|
|
38
|
+
if latitude is not None and longitude is not None:
|
|
39
|
+
self.last_search_center = {"latitude": float(latitude), "longitude": float(longitude)}
|
|
40
|
+
elif location and "pune" in location.lower():
|
|
41
|
+
self.last_search_center = {"latitude": 18.5204, "longitude": 73.8567}
|
|
42
|
+
self.last_radius_meters = (
|
|
43
|
+
(radius_meters if radius_meters is not None else 25_000)
|
|
44
|
+
if wider_metro
|
|
45
|
+
else (radius_meters if radius_meters is not None else 6_000)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return search_demo_businesses(
|
|
49
|
+
query=query,
|
|
50
|
+
location=location,
|
|
51
|
+
category=category,
|
|
52
|
+
limit=limit,
|
|
53
|
+
latitude=latitude,
|
|
54
|
+
longitude=longitude,
|
|
55
|
+
radius_meters=radius_meters,
|
|
56
|
+
wider_metro=wider_metro,
|
|
57
|
+
**kwargs,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async def get_details(self, business_id: str) -> Business | None:
|
|
61
|
+
return get_demo_business(business_id)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Satisfy the protocol at import time (structural subtyping check)
|
|
65
|
+
_: BusinessProvider = DemoProvider() # type: ignore[assignment]
|