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,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Tool: get_saved_leads
|
|
3
|
+
|
|
4
|
+
Retrieves previously researched and stored business leads from the local SQLite database.
|
|
5
|
+
Supports filtering by minimum website opportunity score and configurable record limits.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
from mcp.server.mcpserver import MCPServer
|
|
14
|
+
|
|
15
|
+
from ..storage.db import get_all_businesses
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register(mcp: MCPServer) -> None:
|
|
21
|
+
"""Register get_saved_leads tool on the MCP server."""
|
|
22
|
+
|
|
23
|
+
@mcp.tool()
|
|
24
|
+
async def get_saved_leads(
|
|
25
|
+
min_opportunity_score: Optional[float] = None,
|
|
26
|
+
limit: int = 50,
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
"""
|
|
29
|
+
Retrieve previously researched businesses saved in the local LeadIntel SQLite database.
|
|
30
|
+
|
|
31
|
+
Allows filtering leads by minimum website opportunity score, making it easy
|
|
32
|
+
to fetch high-priority web development or digital agency prospects.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
min_opportunity_score: Optional minimum website opportunity score (0–100) to filter by.
|
|
36
|
+
limit: Maximum number of businesses to return (default: 50, max: 200).
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Dict containing retrieved leads, count, and applied query filters.
|
|
40
|
+
"""
|
|
41
|
+
clamped_limit = max(1, min(limit, 200))
|
|
42
|
+
try:
|
|
43
|
+
businesses = get_all_businesses(min_score=min_opportunity_score, limit=clamped_limit)
|
|
44
|
+
return {
|
|
45
|
+
"total_retrieved": len(businesses),
|
|
46
|
+
"min_opportunity_score": min_opportunity_score,
|
|
47
|
+
"limit": clamped_limit,
|
|
48
|
+
"businesses": [b.model_dump(mode="json") for b in businesses],
|
|
49
|
+
}
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
logger.warning("Failed to retrieve saved leads from database: %s", exc)
|
|
52
|
+
return {
|
|
53
|
+
"error": "Failed to retrieve saved leads from local database.",
|
|
54
|
+
"message": str(exc),
|
|
55
|
+
"total_retrieved": 0,
|
|
56
|
+
"businesses": [],
|
|
57
|
+
}
|
|
58
|
+
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Tool: score_lead
|
|
3
|
+
|
|
4
|
+
Evaluates and qualifies a business lead for digital service providers
|
|
5
|
+
using deterministic, multi-dimensional evidence scoring.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
from mcp.server.mcpserver import MCPServer
|
|
13
|
+
|
|
14
|
+
from ..services.lead_scoring import score_business_lead
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register(mcp: MCPServer) -> None:
|
|
18
|
+
"""Register score_lead tool on the MCP server."""
|
|
19
|
+
|
|
20
|
+
@mcp.tool()
|
|
21
|
+
async def score_lead(
|
|
22
|
+
business_name: str,
|
|
23
|
+
business_category: Optional[str] = None,
|
|
24
|
+
website_analysis: Optional[dict[str, Any]] = None,
|
|
25
|
+
seo_audit: Optional[dict[str, Any]] = None,
|
|
26
|
+
social_profiles: Optional[dict[str, Any]] = None,
|
|
27
|
+
competitor_comparison: Optional[dict[str, Any]] = None,
|
|
28
|
+
contact_info: Optional[dict[str, Any]] = None,
|
|
29
|
+
weights: Optional[dict[str, float]] = None,
|
|
30
|
+
) -> dict[str, Any]:
|
|
31
|
+
"""
|
|
32
|
+
Evaluate and score a business lead for digital services suitability.
|
|
33
|
+
|
|
34
|
+
Calculates a deterministic 0-100 overall prospect score based on:
|
|
35
|
+
1. Website quality (max 20 pts)
|
|
36
|
+
2. Technical SEO (max 20 pts)
|
|
37
|
+
3. Social media presence (max 15 pts)
|
|
38
|
+
4. Contact accessibility (max 10 pts)
|
|
39
|
+
5. Mobile readiness (max 10 pts)
|
|
40
|
+
6. Digital presence gaps vs competitors (max 15 pts)
|
|
41
|
+
7. Business relevance & localization (max 10 pts)
|
|
42
|
+
|
|
43
|
+
Opportunity Analysis:
|
|
44
|
+
Provides specialized opportunity scores (0-100) for:
|
|
45
|
+
- Website Development
|
|
46
|
+
- SEO Services
|
|
47
|
+
- Social Media Management
|
|
48
|
+
- Digital Marketing
|
|
49
|
+
|
|
50
|
+
Compliance & Ethics:
|
|
51
|
+
- Missing inputs reduce confidence and evidence completeness, avoiding arbitrary score penalties.
|
|
52
|
+
- Verified defects are separated from missing information.
|
|
53
|
+
- Does not speculate on customer purchasing budget or guarantee conversion.
|
|
54
|
+
- Uses zero paid APIs or tokens.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
business_name: Name of the business (required).
|
|
58
|
+
business_category: Industry/category description.
|
|
59
|
+
website_analysis: Results from analyze_website tool (optional).
|
|
60
|
+
seo_audit: Results from check_seo tool (optional).
|
|
61
|
+
social_profiles: Results from find_social_profiles tool (optional).
|
|
62
|
+
competitor_comparison: Results from compare_businesses tool (optional).
|
|
63
|
+
contact_info: Available phone, email, or address dictionary (optional).
|
|
64
|
+
weights: Optional custom weight overrides for the 7 categories.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Structured lead evaluation report including overall score, priority tier,
|
|
68
|
+
service opportunities, and recommended package.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
return score_business_lead(
|
|
72
|
+
business_name=business_name,
|
|
73
|
+
business_category=business_category,
|
|
74
|
+
website_analysis=website_analysis,
|
|
75
|
+
seo_audit=seo_audit,
|
|
76
|
+
social_profiles=social_profiles,
|
|
77
|
+
competitor_comparison=competitor_comparison,
|
|
78
|
+
contact_info=contact_info,
|
|
79
|
+
weights=weights,
|
|
80
|
+
)
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
return {
|
|
83
|
+
"error": str(exc),
|
|
84
|
+
"business_name": business_name,
|
|
85
|
+
"overall_lead_score": 0.0,
|
|
86
|
+
"priority_classification": "Low Priority",
|
|
87
|
+
"explanation": f"Lead scoring failed: {exc}",
|
|
88
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""MCP tool: search_businesses — powered by the provider registry and website opportunity engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from mcp.server.mcpserver import MCPServer
|
|
9
|
+
|
|
10
|
+
from ..config import settings
|
|
11
|
+
from ..models.business import Business, BusinessSearchResult
|
|
12
|
+
from ..providers import build_registry
|
|
13
|
+
from ..services.website_opportunity import batch_evaluate_websites
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
OSM_ATTRIBUTION = "Data © OpenStreetMap contributors, ODbL 1.0 — https://www.openstreetmap.org/copyright"
|
|
18
|
+
|
|
19
|
+
_registry = build_registry(settings)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _compute_data_confidence(b: Business) -> float:
|
|
23
|
+
"""Calculate an evidence-based confidence indicator (0.0 to 1.0) for a business record."""
|
|
24
|
+
points = 0.0
|
|
25
|
+
# Location certainty
|
|
26
|
+
if b.latitude is not None and b.longitude is not None:
|
|
27
|
+
points += 0.25
|
|
28
|
+
elif b.address:
|
|
29
|
+
points += 0.15
|
|
30
|
+
|
|
31
|
+
# Category classification
|
|
32
|
+
if b.category:
|
|
33
|
+
points += 0.20
|
|
34
|
+
|
|
35
|
+
# Contact accessibility
|
|
36
|
+
if b.phone:
|
|
37
|
+
points += 0.15
|
|
38
|
+
if b.email:
|
|
39
|
+
points += 0.10
|
|
40
|
+
|
|
41
|
+
# Website verification completeness
|
|
42
|
+
if b.website_status in ("website_verified", "website_not_found_after_research", "website_broken"):
|
|
43
|
+
points += 0.20
|
|
44
|
+
elif b.website_status == "website_unverified":
|
|
45
|
+
points += 0.10
|
|
46
|
+
|
|
47
|
+
# OpenStreetMap provenance
|
|
48
|
+
if b.osm_tags:
|
|
49
|
+
points += 0.10
|
|
50
|
+
|
|
51
|
+
return round(min(1.0, max(0.2, points)), 2)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _apply_ranking(
|
|
55
|
+
businesses: list[Business],
|
|
56
|
+
sort_by: str,
|
|
57
|
+
) -> list[Business]:
|
|
58
|
+
"""Sort businesses by selected criterion and generate ranking_explanation for each."""
|
|
59
|
+
sort_strategy = (sort_by or "highest_website_opportunity").lower().strip()
|
|
60
|
+
|
|
61
|
+
if sort_strategy == "closest":
|
|
62
|
+
businesses.sort(
|
|
63
|
+
key=lambda b: (
|
|
64
|
+
b.distance_meters if b.distance_meters is not None else 999_999_999,
|
|
65
|
+
-(b.website_opportunity_score or 0.0),
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
elif sort_strategy == "highest_lead_score":
|
|
69
|
+
# Score based on commercial visibility and review count
|
|
70
|
+
businesses.sort(
|
|
71
|
+
key=lambda b: (
|
|
72
|
+
-(b.review_count or 0),
|
|
73
|
+
-(b.rating or 0.0),
|
|
74
|
+
b.distance_meters if b.distance_meters is not None else 999_999,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
elif sort_strategy == "best_seo_opportunity":
|
|
78
|
+
businesses.sort(
|
|
79
|
+
key=lambda b: (
|
|
80
|
+
-(b.website_opportunity_score or 0.0),
|
|
81
|
+
b.distance_meters if b.distance_meters is not None else 999_999,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
else:
|
|
85
|
+
# Default: highest_website_opportunity
|
|
86
|
+
# Prioritize:
|
|
87
|
+
# 1. No website found after research (90-100)
|
|
88
|
+
# 2. Broken websites (75-89)
|
|
89
|
+
# 3. Weak / outdated websites (50-74)
|
|
90
|
+
# 4. Significant SEO problems (30-49)
|
|
91
|
+
# 5. Good websites (5-29)
|
|
92
|
+
# Tie-breaker: distance to search center
|
|
93
|
+
businesses.sort(
|
|
94
|
+
key=lambda b: (
|
|
95
|
+
-(b.website_opportunity_score if b.website_opportunity_score is not None else -1.0),
|
|
96
|
+
b.distance_meters if b.distance_meters is not None else 999_999_999,
|
|
97
|
+
-(b.data_confidence or 0.0),
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Populate ranking_explanation
|
|
102
|
+
for idx, b in enumerate(businesses, 1):
|
|
103
|
+
dist_str = f"{b.distance_km} km away" if b.distance_km is not None else "distance uncalculated"
|
|
104
|
+
opp_score_str = (
|
|
105
|
+
f"Opportunity score: {b.website_opportunity_score:.1f}/100"
|
|
106
|
+
if b.website_opportunity_score is not None
|
|
107
|
+
else "Opportunity unassessed"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if b.website_status == "website_not_found_after_research":
|
|
111
|
+
explanation = (
|
|
112
|
+
f"Ranked #{idx}: High-priority web development opportunity. "
|
|
113
|
+
f"No official website found after thorough web research ({opp_score_str}, {dist_str})."
|
|
114
|
+
)
|
|
115
|
+
elif b.website_status == "website_broken":
|
|
116
|
+
explanation = (
|
|
117
|
+
f"Ranked #{idx}: Broken website detected ({opp_score_str}, {dist_str}). "
|
|
118
|
+
f"Immediate redesign or technical repair required."
|
|
119
|
+
)
|
|
120
|
+
elif b.website_status == "website_unverified":
|
|
121
|
+
explanation = (
|
|
122
|
+
f"Ranked #{idx}: Unverified website ({opp_score_str}, {dist_str}). "
|
|
123
|
+
f"Domain cannot be reliably matched to the business name."
|
|
124
|
+
)
|
|
125
|
+
elif b.website_status == "website_verified":
|
|
126
|
+
if (b.website_opportunity_score or 0) >= 50:
|
|
127
|
+
explanation = (
|
|
128
|
+
f"Ranked #{idx}: Outdated website presence ({opp_score_str}, {dist_str}). "
|
|
129
|
+
f"Active site lacks mobile responsiveness or HTTPS encryption."
|
|
130
|
+
)
|
|
131
|
+
elif (b.website_opportunity_score or 0) >= 30:
|
|
132
|
+
explanation = (
|
|
133
|
+
f"Ranked #{idx}: Active website with SEO/performance deficiencies ({opp_score_str}, {dist_str})."
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
explanation = (
|
|
137
|
+
f"Ranked #{idx}: Established digital presence ({opp_score_str}, {dist_str}). "
|
|
138
|
+
f"Fast, secure, and mobile-friendly."
|
|
139
|
+
)
|
|
140
|
+
else:
|
|
141
|
+
explanation = (
|
|
142
|
+
f"Ranked #{idx}: Website uninspected ({opp_score_str}, {dist_str})."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
b.ranking_explanation = explanation
|
|
146
|
+
|
|
147
|
+
return businesses
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def register(mcp: MCPServer) -> None:
|
|
151
|
+
"""Register search_businesses tool on the MCP server."""
|
|
152
|
+
|
|
153
|
+
@mcp.tool()
|
|
154
|
+
async def search_businesses(
|
|
155
|
+
query: str,
|
|
156
|
+
location: Optional[str] = None,
|
|
157
|
+
category: Optional[str] = None,
|
|
158
|
+
limit: int = 10,
|
|
159
|
+
latitude: Optional[float] = None,
|
|
160
|
+
longitude: Optional[float] = None,
|
|
161
|
+
radius_meters: Optional[int] = None,
|
|
162
|
+
sort_by: Optional[str] = "highest_website_opportunity",
|
|
163
|
+
research_websites: bool = True,
|
|
164
|
+
wider_metro: bool = False,
|
|
165
|
+
) -> dict:
|
|
166
|
+
"""
|
|
167
|
+
Search for local businesses and identify high-value website development opportunities.
|
|
168
|
+
|
|
169
|
+
Features:
|
|
170
|
+
- Geographic precision: strictly limits searches to the requested radius around city/coords.
|
|
171
|
+
- Distance calculation: provides exact distance from search centre for every business.
|
|
172
|
+
- Website opportunity intelligence: researches missing websites, verifies domain ownership,
|
|
173
|
+
detects broken sites, and scores web dev opportunity (0–100 scale).
|
|
174
|
+
- Multi-criteria ranking: prioritizes businesses without websites by default.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
query: Free-text search term, e.g. "coffee shop", "restaurants",
|
|
178
|
+
"law firm", "software". Pass "*" for a broad sweep.
|
|
179
|
+
location: City, neighbourhood, or "lat,lon" coordinates, e.g. "Pune",
|
|
180
|
+
"Shivajinagar, Pune", "London", "Austin, TX".
|
|
181
|
+
category: Optional industry hint, e.g. "Food & Beverage", "Technology",
|
|
182
|
+
"Healthcare". Narrows OSM tag filters.
|
|
183
|
+
limit: Maximum results to return (1–50, default 10).
|
|
184
|
+
latitude: Optional explicit latitude coordinate to center the search.
|
|
185
|
+
longitude: Optional explicit longitude coordinate to center the search.
|
|
186
|
+
radius_meters: Configurable search radius in metres (default: 6000m / 6km).
|
|
187
|
+
sort_by: Ranking criterion: 'highest_website_opportunity' (default),
|
|
188
|
+
'closest', 'highest_lead_score', 'best_seo_opportunity'.
|
|
189
|
+
research_websites: If True (default), performs web research on businesses missing
|
|
190
|
+
an OSM website to verify if they genuinely lack an official site.
|
|
191
|
+
wider_metro: If True, expands search radius to metropolitan boundaries (25km)
|
|
192
|
+
instead of strictly limiting to the urban core.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
dict containing:
|
|
196
|
+
query, location, category, search_center, radius_meters, sort_by
|
|
197
|
+
total_found (int)
|
|
198
|
+
businesses (list[dict]):
|
|
199
|
+
- name, category, address, phone, email, website
|
|
200
|
+
- distance_meters, distance_km
|
|
201
|
+
- website_status ('website_verified', 'website_not_found_after_research',
|
|
202
|
+
'website_unverified', 'website_broken', 'website_not_checked')
|
|
203
|
+
- website_opportunity_score (0–100)
|
|
204
|
+
- website_opportunity_explanation
|
|
205
|
+
- data_confidence (0.0–1.0)
|
|
206
|
+
- ranking_explanation
|
|
207
|
+
- osm_tags
|
|
208
|
+
provider, attribution, retrieved_at, note, error
|
|
209
|
+
"""
|
|
210
|
+
try:
|
|
211
|
+
limit = max(1, min(limit, 50))
|
|
212
|
+
|
|
213
|
+
raw_businesses, provider_name = await _registry.search(
|
|
214
|
+
query=query,
|
|
215
|
+
location=location,
|
|
216
|
+
category=category,
|
|
217
|
+
limit=limit,
|
|
218
|
+
latitude=latitude,
|
|
219
|
+
longitude=longitude,
|
|
220
|
+
radius_meters=radius_meters,
|
|
221
|
+
wider_metro=wider_metro,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
is_osm = provider_name == "openstreetmap"
|
|
225
|
+
is_demo = provider_name == "demo"
|
|
226
|
+
|
|
227
|
+
# 1. Evaluate website opportunity and verify presence
|
|
228
|
+
evaluated_businesses = await batch_evaluate_websites(
|
|
229
|
+
businesses=raw_businesses,
|
|
230
|
+
location=location,
|
|
231
|
+
research_missing=research_websites,
|
|
232
|
+
max_concurrent=5,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# 2. Compute data confidence for each record
|
|
236
|
+
for b in evaluated_businesses:
|
|
237
|
+
b.data_confidence = _compute_data_confidence(b)
|
|
238
|
+
|
|
239
|
+
# 3. Apply multi-criteria ranking and populate explanations
|
|
240
|
+
ranked_businesses = _apply_ranking(
|
|
241
|
+
businesses=evaluated_businesses,
|
|
242
|
+
sort_by=sort_by or "highest_website_opportunity",
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
note: Optional[str] = None
|
|
246
|
+
error: Optional[str] = None
|
|
247
|
+
|
|
248
|
+
if is_demo:
|
|
249
|
+
note = (
|
|
250
|
+
"ℹ️ Demo data returned. "
|
|
251
|
+
"Provide a 'location' parameter to enable live OpenStreetMap search "
|
|
252
|
+
"(no API key required)."
|
|
253
|
+
)
|
|
254
|
+
elif is_osm:
|
|
255
|
+
if getattr(_registry, "last_error", None):
|
|
256
|
+
error = _registry.last_error
|
|
257
|
+
note = f"⚠️ OpenStreetMap live search encountered an issue: {_registry.last_error}"
|
|
258
|
+
elif len(ranked_businesses) == 0:
|
|
259
|
+
note = f"No businesses found matching your criteria in OpenStreetMap near '{location}'."
|
|
260
|
+
else:
|
|
261
|
+
note = (
|
|
262
|
+
f"✅ Live data from OpenStreetMap with website opportunity intelligence. "
|
|
263
|
+
f"Attribution required: {OSM_ATTRIBUTION}"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Safely save leads to SQLite
|
|
267
|
+
try:
|
|
268
|
+
from ..storage.db import save_businesses
|
|
269
|
+
save_businesses(ranked_businesses)
|
|
270
|
+
except Exception as db_err:
|
|
271
|
+
logger.error(f"Failed to save leads to SQLite: {db_err}")
|
|
272
|
+
|
|
273
|
+
serialized_businesses = [b.model_dump(mode="json") for b in ranked_businesses]
|
|
274
|
+
|
|
275
|
+
result = BusinessSearchResult(
|
|
276
|
+
query=query,
|
|
277
|
+
location=location,
|
|
278
|
+
category=category,
|
|
279
|
+
search_center=_registry.last_search_center,
|
|
280
|
+
radius_meters=_registry.last_radius_meters,
|
|
281
|
+
sort_by=sort_by or "highest_website_opportunity",
|
|
282
|
+
total_found=len(ranked_businesses),
|
|
283
|
+
businesses=ranked_businesses,
|
|
284
|
+
source=provider_name,
|
|
285
|
+
provider=provider_name,
|
|
286
|
+
attribution=OSM_ATTRIBUTION if is_osm else None,
|
|
287
|
+
note=note,
|
|
288
|
+
error=error,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
result_dict = result.model_dump(mode="json")
|
|
292
|
+
result_dict["businesses"] = serialized_businesses
|
|
293
|
+
return result_dict
|
|
294
|
+
|
|
295
|
+
except Exception as e:
|
|
296
|
+
import traceback
|
|
297
|
+
logger.error(f"Fatal error in search_businesses: {e}\n{traceback.format_exc()}")
|
|
298
|
+
return {
|
|
299
|
+
"query": query,
|
|
300
|
+
"location": location,
|
|
301
|
+
"error": f"Internal execution error: {str(e)}",
|
|
302
|
+
"note": "A fatal error occurred during search execution.",
|
|
303
|
+
"total_found": 0,
|
|
304
|
+
"businesses": [],
|
|
305
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Website Finder & Search Utilities.
|
|
3
|
+
|
|
4
|
+
Provides free, open discovery of a business's official website using:
|
|
5
|
+
1. Provider registry lookup (e.g. OSM tags `website`, `contact:website`, `url`).
|
|
6
|
+
2. DuckDuckGo HTML / Instant Answers (completely free, no API key required).
|
|
7
|
+
3. Fallback demo data resolver.
|
|
8
|
+
|
|
9
|
+
Compliance:
|
|
10
|
+
- Respects robots policies and rate limiting.
|
|
11
|
+
- Avoids scraping protected personal data.
|
|
12
|
+
- Never sends telemetry or sensitive data to third-party providers.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import re
|
|
19
|
+
from typing import Optional
|
|
20
|
+
from urllib.parse import parse_qs, quote, unquote, urlparse
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from ..config import settings
|
|
25
|
+
from ..providers import build_registry
|
|
26
|
+
from ..services.website_opportunity import (
|
|
27
|
+
DIRECTORY_AND_AGGREGATOR_DOMAINS,
|
|
28
|
+
SOCIAL_DOMAINS,
|
|
29
|
+
is_directory_or_social_domain,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
USER_AGENT = "LeadIntel/0.2.0 (Business Intelligence Researcher; open-source; respectful crawler)"
|
|
35
|
+
_registry = build_registry(settings)
|
|
36
|
+
|
|
37
|
+
# Domains to ignore when seeking a company's direct website
|
|
38
|
+
IGNORED_DOMAINS = DIRECTORY_AND_AGGREGATOR_DOMAINS | SOCIAL_DOMAINS
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _clean_url(url: str) -> Optional[str]:
|
|
42
|
+
"""Sanitize and validate extracted URL."""
|
|
43
|
+
if not url:
|
|
44
|
+
return None
|
|
45
|
+
url = url.strip()
|
|
46
|
+
if not (url.startswith("http://") or url.startswith("https://")):
|
|
47
|
+
url = "https://" + url
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
parsed = urlparse(url)
|
|
51
|
+
domain = parsed.netloc.lower()
|
|
52
|
+
if not domain or "." not in domain:
|
|
53
|
+
return None
|
|
54
|
+
if is_directory_or_social_domain(domain) or domain in IGNORED_DOMAINS:
|
|
55
|
+
return None
|
|
56
|
+
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}".rstrip("/")
|
|
57
|
+
except Exception:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def find_website_for_business(
|
|
62
|
+
business_name: str,
|
|
63
|
+
location: Optional[str] = None,
|
|
64
|
+
business_id: Optional[str] = None,
|
|
65
|
+
) -> tuple[Optional[str], str]:
|
|
66
|
+
"""
|
|
67
|
+
Locate official website for a business name / location or business_id.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
tuple (website_url, source_method)
|
|
71
|
+
"""
|
|
72
|
+
# 1. Check if business_id is provided and known to provider registry
|
|
73
|
+
if business_id:
|
|
74
|
+
biz, provider_name = await _registry.get_details(business_id)
|
|
75
|
+
if biz and biz.website:
|
|
76
|
+
return biz.website, f"provider:{provider_name}"
|
|
77
|
+
|
|
78
|
+
# 2. Check demo fixtures if offline/demo
|
|
79
|
+
demo_biz = None
|
|
80
|
+
if business_id:
|
|
81
|
+
from ..data.demo import get_demo_business
|
|
82
|
+
demo_biz = get_demo_business(business_id)
|
|
83
|
+
if not demo_biz and business_name:
|
|
84
|
+
from ..data.demo import search_demo_businesses
|
|
85
|
+
matches = search_demo_businesses(query=business_name, location=location, limit=1)
|
|
86
|
+
if matches:
|
|
87
|
+
demo_biz = matches[0]
|
|
88
|
+
if demo_biz and demo_biz.website and (settings.demo_mode or not business_name):
|
|
89
|
+
return demo_biz.website, "demo_fixture"
|
|
90
|
+
|
|
91
|
+
# 3. DuckDuckGo HTML Lite free lookup
|
|
92
|
+
query = f"{business_name} {location or ''}".strip()
|
|
93
|
+
if not query:
|
|
94
|
+
return None, "empty_query"
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
url = await _search_duckduckgo_html(query)
|
|
98
|
+
if url:
|
|
99
|
+
return url, "duckduckgo_lite"
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
logger.debug("DuckDuckGo lookup failed for %s: %s", query, exc)
|
|
102
|
+
|
|
103
|
+
# 4. Fallback to demo fixture if available as last resort
|
|
104
|
+
if demo_biz and demo_biz.website:
|
|
105
|
+
return demo_biz.website, "demo_fallback"
|
|
106
|
+
|
|
107
|
+
return None, "not_found"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def _search_duckduckgo_html(query: str) -> Optional[str]:
|
|
111
|
+
"""
|
|
112
|
+
Query DuckDuckGo HTML Lite (no JS, no API key) for first organic non-directory link.
|
|
113
|
+
"""
|
|
114
|
+
search_url = f"https://html.duckduckgo.com/html/?q={quote(query)}"
|
|
115
|
+
headers = {
|
|
116
|
+
"User-Agent": USER_AGENT,
|
|
117
|
+
"Accept": "text/html,application/xhtml+xml",
|
|
118
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
|
122
|
+
resp = await client.get(search_url, headers=headers)
|
|
123
|
+
if resp.status_code != 200:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
# DuckDuckGo HTML links format: <a class="result__url" href="/l/?uddg=URL..."> or similar
|
|
127
|
+
content = resp.text
|
|
128
|
+
# Extract hrefs
|
|
129
|
+
matches = re.findall(r'href=["\'](/l/\?uddg=[^"\']+)["\']', content)
|
|
130
|
+
for m in matches:
|
|
131
|
+
parsed = parse_qs(urlparse(m).query)
|
|
132
|
+
uddg = parsed.get("uddg")
|
|
133
|
+
if uddg:
|
|
134
|
+
raw_url = unquote(uddg[0])
|
|
135
|
+
cleaned = _clean_url(raw_url)
|
|
136
|
+
if cleaned:
|
|
137
|
+
return cleaned
|
|
138
|
+
|
|
139
|
+
# Fallback regex for standard a tags
|
|
140
|
+
links = re.findall(r'class="result__snippet"[^>]*>.*?</a>|<a[^>]+class="result__url"[^>]+href=["\']([^"\']+)["\']', content, re.DOTALL)
|
|
141
|
+
for link in links:
|
|
142
|
+
if isinstance(link, str) and link.startswith("http"):
|
|
143
|
+
cleaned = _clean_url(link)
|
|
144
|
+
if cleaned:
|
|
145
|
+
return cleaned
|
|
146
|
+
|
|
147
|
+
return None
|