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.
Files changed (54) hide show
  1. leadintel/__init__.py +6 -0
  2. leadintel/__main__.py +6 -0
  3. leadintel/config.py +165 -0
  4. leadintel/data/__init__.py +1 -0
  5. leadintel/data/demo.py +315 -0
  6. leadintel/exporters/__init__.py +6 -0
  7. leadintel/exporters/csv_exporter.py +113 -0
  8. leadintel/exporters/excel_exporter.py +123 -0
  9. leadintel/models/__init__.py +6 -0
  10. leadintel/models/business.py +203 -0
  11. leadintel/models/report.py +31 -0
  12. leadintel/providers/__init__.py +51 -0
  13. leadintel/providers/base.py +54 -0
  14. leadintel/providers/demo_provider.py +65 -0
  15. leadintel/providers/overpass_provider.py +1040 -0
  16. leadintel/providers/registry.py +151 -0
  17. leadintel/server.py +61 -0
  18. leadintel/services/__init__.py +48 -0
  19. leadintel/services/ai_business_insights.py +310 -0
  20. leadintel/services/business_comparison.py +220 -0
  21. leadintel/services/business_report.py +294 -0
  22. leadintel/services/competitor_research.py +293 -0
  23. leadintel/services/lead_scoring.py +385 -0
  24. leadintel/services/local_ai.py +186 -0
  25. leadintel/services/robots_checker.py +81 -0
  26. leadintel/services/seo_auditor.py +281 -0
  27. leadintel/services/social_finder.py +119 -0
  28. leadintel/services/url_validator.py +295 -0
  29. leadintel/services/website_analyzer.py +213 -0
  30. leadintel/services/website_opportunity.py +523 -0
  31. leadintel/storage/__init__.py +12 -0
  32. leadintel/storage/db.py +189 -0
  33. leadintel/storage/path_validator.py +118 -0
  34. leadintel/tools/__init__.py +38 -0
  35. leadintel/tools/analyze_website.py +57 -0
  36. leadintel/tools/check_seo.py +64 -0
  37. leadintel/tools/compare_businesses.py +65 -0
  38. leadintel/tools/export_leads.py +112 -0
  39. leadintel/tools/find_business_website.py +66 -0
  40. leadintel/tools/find_competitors.py +75 -0
  41. leadintel/tools/find_social_profiles.py +85 -0
  42. leadintel/tools/generate_business_insights.py +93 -0
  43. leadintel/tools/generate_business_report.py +92 -0
  44. leadintel/tools/generate_outreach.py +153 -0
  45. leadintel/tools/get_business_details.py +96 -0
  46. leadintel/tools/get_saved_leads.py +58 -0
  47. leadintel/tools/score_lead.py +88 -0
  48. leadintel/tools/search_businesses.py +305 -0
  49. leadintel/tools/website_finder.py +147 -0
  50. leadintel-0.3.0.dist-info/METADATA +351 -0
  51. leadintel-0.3.0.dist-info/RECORD +54 -0
  52. leadintel-0.3.0.dist-info/WHEEL +4 -0
  53. leadintel-0.3.0.dist-info/entry_points.txt +3 -0
  54. 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