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,189 @@
1
+ """
2
+ SQLite database storage for LeadIntel.
3
+
4
+ Persists researched businesses across searches and server sessions.
5
+ Uses standard library sqlite3 without external dependencies.
6
+ Stores the database safely in the user's home directory.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import sqlite3
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from ..models.business import Business
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ DB_DIR = Path.home() / ".local" / "share" / "leadintel"
22
+ DB_PATH = DB_DIR / "leadintel.db"
23
+ DEFAULT_DB_PATH = str(DB_PATH)
24
+
25
+
26
+ def _resolve_path(db_path: Optional[str | Path] = None) -> str:
27
+ """Resolve database path, honoring test overrides or parameters."""
28
+ if db_path:
29
+ return str(db_path)
30
+ if DEFAULT_DB_PATH != str(DB_PATH):
31
+ return str(DEFAULT_DB_PATH)
32
+ return str(DB_PATH)
33
+
34
+
35
+ def _get_connection(db_path: Optional[str | Path] = None) -> sqlite3.Connection:
36
+ """Open a SQLite connection and configure row factory."""
37
+ target_path = _resolve_path(db_path)
38
+ if target_path != ":memory:":
39
+ parent = Path(target_path).parent
40
+ parent.mkdir(parents=True, exist_ok=True)
41
+
42
+ conn = sqlite3.connect(target_path)
43
+ conn.row_factory = sqlite3.Row
44
+ return conn
45
+
46
+
47
+ def init_db(db_path: Optional[str | Path] = None) -> None:
48
+ """Initialize the SQLite database schema if not already created."""
49
+ try:
50
+ DB_DIR.mkdir(parents=True, exist_ok=True)
51
+ target = _resolve_path(db_path)
52
+ conn = _get_connection(target)
53
+ try:
54
+ with conn:
55
+ conn.execute(
56
+ """
57
+ CREATE TABLE IF NOT EXISTS businesses (
58
+ id TEXT PRIMARY KEY,
59
+ model_json TEXT,
60
+ updated_at DATETIME
61
+ )
62
+ """
63
+ )
64
+ logger.debug("Database initialized at %s", target)
65
+ finally:
66
+ conn.close()
67
+ except Exception as e:
68
+ logger.error(f"Failed to initialize SQLite database: {e}")
69
+
70
+
71
+ def save_businesses(
72
+ businesses: list[Business], db_path: Optional[str | Path] = None
73
+ ) -> int:
74
+ """
75
+ Upsert businesses into the database.
76
+
77
+ Stores the serialized business JSON and current UTC timestamp.
78
+ Returns the count of businesses saved.
79
+ """
80
+ if not businesses:
81
+ return 0
82
+
83
+ try:
84
+ init_db(db_path)
85
+ target = _resolve_path(db_path)
86
+ conn = _get_connection(target)
87
+ count = 0
88
+ now_iso = datetime.now(timezone.utc).isoformat()
89
+ try:
90
+ with conn:
91
+ for b in businesses:
92
+ if not b.id:
93
+ continue
94
+ model_json = b.model_dump_json()
95
+ conn.execute(
96
+ """
97
+ INSERT INTO businesses (id, model_json, updated_at)
98
+ VALUES (?, ?, ?)
99
+ ON CONFLICT(id) DO UPDATE SET
100
+ model_json = excluded.model_json,
101
+ updated_at = excluded.updated_at
102
+ """,
103
+ (b.id, model_json, now_iso),
104
+ )
105
+ count += 1
106
+ logger.debug("Saved %d businesses to database", count)
107
+ return count
108
+ finally:
109
+ conn.close()
110
+ except Exception as e:
111
+ logger.error(f"Failed to save businesses to database: {e}")
112
+ return 0
113
+
114
+
115
+ def get_business(business_id: str, db_path: Optional[str | Path] = None) -> Optional[Business]:
116
+ """
117
+ Retrieve and deserialize a business from SQLite by ID.
118
+
119
+ Returns the Business model if found, None otherwise.
120
+ """
121
+ if not business_id:
122
+ return None
123
+
124
+ try:
125
+ init_db(db_path)
126
+ target = _resolve_path(db_path)
127
+ conn = _get_connection(target)
128
+ try:
129
+ cursor = conn.cursor()
130
+ cursor.execute(
131
+ "SELECT model_json FROM businesses WHERE id = ?",
132
+ (business_id,),
133
+ )
134
+ row = cursor.fetchone()
135
+ if not row or not row["model_json"]:
136
+ return None
137
+ return Business.model_validate_json(row["model_json"])
138
+ finally:
139
+ conn.close()
140
+ except Exception as e:
141
+ logger.error(f"Error fetching business {business_id}: {e}")
142
+ return None
143
+
144
+
145
+ def get_all_businesses(
146
+ min_score: Optional[float] = None,
147
+ limit: int = 50,
148
+ db_path: Optional[str | Path] = None,
149
+ ) -> list[Business]:
150
+ """
151
+ Retrieve saved businesses from SQLite, optionally filtered by minimum opportunity score.
152
+
153
+ Args:
154
+ min_score: Optional minimum website_opportunity_score threshold.
155
+ limit: Maximum number of businesses to return.
156
+ db_path: Optional database path override (e.g. for testing).
157
+
158
+ Returns:
159
+ List of matching Business objects ordered by updated_at descending.
160
+ """
161
+ try:
162
+ init_db(db_path)
163
+ target = _resolve_path(db_path)
164
+ conn = _get_connection(target)
165
+ results: list[Business] = []
166
+ try:
167
+ cursor = conn.cursor()
168
+ cursor.execute("SELECT model_json FROM businesses ORDER BY updated_at DESC")
169
+ rows = cursor.fetchall()
170
+ for row in rows:
171
+ if not row or not row["model_json"]:
172
+ continue
173
+ try:
174
+ biz = Business.model_validate_json(row["model_json"])
175
+ if min_score is not None:
176
+ opp_score = biz.website_opportunity_score
177
+ if opp_score is None or opp_score < min_score:
178
+ continue
179
+ results.append(biz)
180
+ if len(results) >= limit:
181
+ break
182
+ except Exception as exc:
183
+ logger.debug("Skipping unparseable business record: %s", exc)
184
+ return results
185
+ finally:
186
+ conn.close()
187
+ except Exception as e:
188
+ logger.error(f"Failed to retrieve businesses from database: {e}")
189
+ return []
@@ -0,0 +1,118 @@
1
+ """
2
+ Path Traversal and File Security Validator.
3
+
4
+ Validates destination file paths for exporters (Excel, CSV, JSON):
5
+ - Prevents directory traversal attacks ('..', null bytes).
6
+ - Blocks writes to system root and critical directories (/etc, /bin, /sbin, /usr, /root, /sys, /proc, /System, /Library, Windows System32, etc.).
7
+ - Blocks writes to sensitive user directories (.ssh, .gnupg, .aws, .config, shell rc files).
8
+ - Enforces allowed file extensions (.xlsx, .csv, .json).
9
+ - Canonicalizes and verifies destination paths cross-platform.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from typing import Optional
16
+
17
+ from ..services.url_validator import SecurityValidationError
18
+
19
+ # System and user directories strictly protected from export writes
20
+ BLOCKED_PATH_PREFIXES = (
21
+ "/etc",
22
+ "/bin",
23
+ "/sbin",
24
+ "/usr",
25
+ "/root",
26
+ "/dev",
27
+ "/sys",
28
+ "/proc",
29
+ "/System",
30
+ "/Library",
31
+ "/private/etc",
32
+ )
33
+
34
+ BLOCKED_WINDOWS_PREFIXES = (
35
+ "c:/windows",
36
+ "c:/program files",
37
+ "c:/program files (x86)",
38
+ "c:/programdata",
39
+ "d:/windows",
40
+ )
41
+
42
+ BLOCKED_USER_TARGETS = (
43
+ ".ssh",
44
+ ".gnupg",
45
+ ".aws",
46
+ ".config",
47
+ ".bashrc",
48
+ ".bash_profile",
49
+ ".zshrc",
50
+ ".zprofile",
51
+ ".profile",
52
+ "id_rsa",
53
+ "id_ed25519",
54
+ )
55
+
56
+
57
+ def validate_safe_export_path(
58
+ path: str,
59
+ allowed_extensions: Optional[set[str]] = None,
60
+ ) -> str:
61
+ """
62
+ Validate and return an absolute path for safe file exports.
63
+
64
+ Raises SecurityValidationError or ValueError if path is invalid or attempts traversal.
65
+ """
66
+ if not path or not isinstance(path, str):
67
+ raise ValueError("Export path must be a non-empty string.")
68
+
69
+ if "\0" in path:
70
+ raise SecurityValidationError("Null byte detected in file path.")
71
+
72
+ # Check raw path for cross-platform Windows system paths (regardless of host OS)
73
+ p_norm = path.replace("\\", "/").lower().strip()
74
+ for win_prefix in BLOCKED_WINDOWS_PREFIXES:
75
+ if p_norm == win_prefix or p_norm.startswith(win_prefix + "/"):
76
+ raise SecurityValidationError(
77
+ f"Export path '{path}' resolves to protected Windows system location '{win_prefix}'."
78
+ )
79
+
80
+ # Prevent UNC network share paths (e.g. \\server\share or //server/share)
81
+ if p_norm.startswith("//"):
82
+ raise SecurityValidationError("UNC network paths are not permitted for exports.")
83
+
84
+ # Enforce allowed extension if specified
85
+ if allowed_extensions:
86
+ _, ext = os.path.splitext(path)
87
+ ext_clean = ext.lower().lstrip(".")
88
+ allowed_clean = {e.lower().lstrip(".") for e in allowed_extensions}
89
+ if ext_clean not in allowed_clean:
90
+ raise SecurityValidationError(
91
+ f"Invalid file extension '{ext}'. Allowed extensions: {sorted(allowed_clean)}"
92
+ )
93
+
94
+ abs_path = os.path.abspath(path)
95
+ real_path = os.path.realpath(abs_path)
96
+
97
+ # Check blocked system path prefixes across both real_path and abs_path
98
+ for prefix in BLOCKED_PATH_PREFIXES:
99
+ if real_path == prefix or real_path.startswith(prefix + os.sep) or abs_path == prefix or abs_path.startswith(prefix + os.sep):
100
+ raise SecurityValidationError(
101
+ f"Export path '{path}' resolves to protected system directory '{prefix}'."
102
+ )
103
+
104
+ # Check sensitive user targets across both real_path and abs_path
105
+ home = os.path.expanduser("~")
106
+ for sensitive in BLOCKED_USER_TARGETS:
107
+ sensitive_full = os.path.join(home, sensitive)
108
+ if (
109
+ real_path == sensitive_full
110
+ or real_path.startswith(sensitive_full + os.sep)
111
+ or abs_path == sensitive_full
112
+ or abs_path.startswith(sensitive_full + os.sep)
113
+ ):
114
+ raise SecurityValidationError(
115
+ f"Export path '{path}' resolves to protected user location '{sensitive}'."
116
+ )
117
+
118
+ return abs_path
@@ -0,0 +1,38 @@
1
+ """MCP tools package — registers all tools on the shared MCPServer instance."""
2
+
3
+ from mcp.server.mcpserver import MCPServer
4
+
5
+ from . import (
6
+ analyze_website,
7
+ check_seo,
8
+ compare_businesses,
9
+ export_leads,
10
+ find_business_website,
11
+ find_competitors,
12
+ find_social_profiles,
13
+ generate_business_insights,
14
+ generate_business_report,
15
+ generate_outreach,
16
+ get_business_details,
17
+ get_saved_leads,
18
+ score_lead,
19
+ search_businesses,
20
+ )
21
+
22
+
23
+ def register_all(mcp: MCPServer) -> None:
24
+ """Register every tool on the provided MCP server."""
25
+ search_businesses.register(mcp)
26
+ get_business_details.register(mcp)
27
+ get_saved_leads.register(mcp)
28
+ find_business_website.register(mcp)
29
+ analyze_website.register(mcp)
30
+ check_seo.register(mcp)
31
+ find_social_profiles.register(mcp)
32
+ find_competitors.register(mcp)
33
+ compare_businesses.register(mcp)
34
+ score_lead.register(mcp)
35
+ generate_business_insights.register(mcp)
36
+ generate_outreach.register(mcp)
37
+ generate_business_report.register(mcp)
38
+ export_leads.register(mcp)
@@ -0,0 +1,57 @@
1
+ """
2
+ MCP Tool: analyze_website
3
+
4
+ Comprehensive diagnostic tool for evaluating website availability,
5
+ mobile readiness, HTML structure, contact channels, and broken internal links.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from mcp.server.mcpserver import MCPServer
13
+
14
+ from ..services.website_analyzer import analyze_target_website
15
+
16
+
17
+ def register(mcp: MCPServer) -> None:
18
+ """Register analyze_website tool on the MCP server."""
19
+
20
+ @mcp.tool()
21
+ async def analyze_website(
22
+ url: str,
23
+ check_broken_links: bool = True,
24
+ ) -> dict[str, Any]:
25
+ """
26
+ Analyze a business website for technical readiness, structure, and contacts.
27
+
28
+ Performs non-invasive examination of the target website:
29
+ - HTTP/HTTPS status and SSL verification.
30
+ - Title, meta description, and mobile viewport configuration.
31
+ - H1/H2 heading hierarchy and image alt tag presence.
32
+ - Public email addresses, telephone numbers, and contact forms.
33
+ - Linked social media profiles.
34
+ - Sampled check for broken internal links.
35
+
36
+ Security & Compliance:
37
+ - Respects robots.txt directives before scraping.
38
+ - Protects against SSRF (blocks localhost, private networks, cloud metadata).
39
+ - Enforces request timeouts and max page size limits.
40
+ - Does not scrape private or sensitive personal info.
41
+
42
+ Args:
43
+ url: The target website URL (e.g. 'https://example.com').
44
+ check_broken_links: If True, spot-checks up to 10 internal links for broken status (default: True).
45
+
46
+ Returns:
47
+ Structured diagnostic dictionary.
48
+ """
49
+ try:
50
+ return await analyze_target_website(url=url, check_broken_links=check_broken_links)
51
+ except Exception as exc:
52
+ return {
53
+ "status": "error",
54
+ "url": url,
55
+ "available": False,
56
+ "error": str(exc),
57
+ }
@@ -0,0 +1,64 @@
1
+ """
2
+ MCP Tool: check_seo
3
+
4
+ Custom, free Python SEO audit tool evaluating on-page metadata,
5
+ headings, robots.txt, sitemaps, open graph, and link architecture.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from mcp.server.mcpserver import MCPServer
13
+
14
+ from ..services.seo_auditor import run_full_seo_audit
15
+
16
+
17
+ def register(mcp: MCPServer) -> None:
18
+ """Register check_seo tool on the MCP server."""
19
+
20
+ @mcp.tool()
21
+ async def check_seo(
22
+ url: str,
23
+ ) -> dict[str, Any]:
24
+ """
25
+ Run a technical on-page SEO audit on a business website.
26
+
27
+ Evaluates fundamental search engine optimization signals:
28
+ - Missing or duplicate title tags and length bounds.
29
+ - Missing or suboptimal meta descriptions.
30
+ - Missing H1 or multiple H1 heading tags.
31
+ - Missing image alt attributes and overall image alt coverage percentage.
32
+ - Canonical URL link specification.
33
+ - Meta robots directives (noindex/nofollow).
34
+ - Availability of robots.txt and sitemap.xml.
35
+ - Open Graph tags for social preview readiness.
36
+ - Internal link count and page weight.
37
+
38
+ Output:
39
+ - SEO score from 0 to 100.
40
+ - Detailed checklist breakdown.
41
+ - Specific prioritized issues (errors, warnings, info).
42
+ - Actionable recommendations.
43
+
44
+ Notice:
45
+ This is an on-page technical SEO diagnostic tool and not an official
46
+ Google ranking assessment or proprietary PageRank score.
47
+
48
+ Args:
49
+ url: The target website URL (e.g. 'https://example.com').
50
+
51
+ Returns:
52
+ Structured SEO audit report.
53
+ """
54
+ try:
55
+ return await run_full_seo_audit(url=url)
56
+ except Exception as exc:
57
+ return {
58
+ "seo_score": 0,
59
+ "url": url,
60
+ "error": str(exc),
61
+ "disclaimer": "This technical audit requires a reachable, valid website URL.",
62
+ "issues": [{"level": "error", "message": f"Could not perform audit: {exc}"}],
63
+ "recommendations": ["Verify website is publicly accessible and URL is correct."],
64
+ }
@@ -0,0 +1,65 @@
1
+ """
2
+ MCP Tool: compare_businesses
3
+
4
+ Benchmark a target business against one or more competitors, generating
5
+ a side-by-side comparison matrix, strengths, weaknesses, and digital opportunities.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from mcp.server.mcpserver import MCPServer
13
+
14
+ from ..services.business_comparison import compare_target_against_competitors
15
+
16
+
17
+ def register(mcp: MCPServer) -> None:
18
+ """Register compare_businesses tool on the MCP server."""
19
+
20
+ @mcp.tool()
21
+ async def compare_businesses(
22
+ target_business: dict[str, Any],
23
+ competitor_businesses: list[dict[str, Any]],
24
+ ) -> dict[str, Any]:
25
+ """
26
+ Compare a target business against a list of competitor businesses.
27
+
28
+ Benchmarks organizations across critical digital and operational dimensions:
29
+ - Website availability & response time
30
+ - Technical SEO score (0-100)
31
+ - HTTPS/SSL security configuration
32
+ - Mobile viewport readiness
33
+ - Social media footprint (channels active)
34
+ - Contact accessibility (forms, direct email, direct phone)
35
+ - Public reviews and ratings where available
36
+
37
+ Outputs:
38
+ - Structured comparison table matrix
39
+ - High-confidence strengths
40
+ - Critical digital weaknesses
41
+ - High-leverage growth opportunities
42
+ - Executive competitive positioning summary
43
+
44
+ Args:
45
+ target_business: Dict representing target company. Must include 'name', optional 'website', 'category', 'city'.
46
+ competitor_businesses: List of dicts representing competitor companies to benchmark against.
47
+
48
+ Returns:
49
+ Comprehensive competitive intelligence comparison report.
50
+ """
51
+ try:
52
+ return await compare_target_against_competitors(
53
+ target_business=target_business,
54
+ competitors=competitor_businesses,
55
+ )
56
+ except Exception as exc:
57
+ return {
58
+ "error": str(exc),
59
+ "target_business": target_business.get("name") if isinstance(target_business, dict) else str(target_business),
60
+ "comparison_matrix": [],
61
+ "strengths": [],
62
+ "weaknesses": [],
63
+ "opportunities": [],
64
+ "competitive_positioning_summary": f"Comparison failed: {exc}",
65
+ }
@@ -0,0 +1,112 @@
1
+ """MCP tool: export_leads — search + export to CSV, JSON, or Excel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from datetime import datetime
9
+ from typing import Literal, Optional
10
+
11
+ from mcp.server.mcpserver import MCPServer
12
+
13
+ from ..config import settings
14
+ from ..exporters.csv_exporter import export_to_csv
15
+ from ..exporters.excel_exporter import export_to_excel
16
+ from ..providers import build_registry
17
+ from ..storage.path_validator import validate_safe_export_path
18
+
19
+ OSM_ATTRIBUTION = "Data © OpenStreetMap contributors, ODbL 1.0 — https://www.openstreetmap.org/copyright"
20
+
21
+ _registry = build_registry(settings)
22
+
23
+
24
+ def register(mcp: MCPServer) -> None:
25
+ """Register export_leads tool on the MCP server."""
26
+
27
+ @mcp.tool()
28
+ async def export_leads(
29
+ query: str,
30
+ location: Optional[str] = None,
31
+ category: Optional[str] = None,
32
+ limit: int = 50,
33
+ format: Literal["csv", "json", "excel", "xlsx"] = "csv",
34
+ output_path: Optional[str] = None,
35
+ ) -> dict:
36
+ """
37
+ Search for businesses and export results to an Excel (.xlsx), CSV, or JSON file.
38
+
39
+ Features:
40
+ - Excel: Creates a multi-sheet workbook with frozen headers, auto-filters, and clean styling.
41
+ - CSV: Standard tabular spreadsheet export.
42
+ - JSON: Complete structured lead metadata with timestamps and attribution.
43
+
44
+ Args:
45
+ query: Free-text search term (e.g. 'cafe', 'software').
46
+ location: City, region, or coordinates.
47
+ category: Industry or category filter.
48
+ limit: Max number of leads to export (1-200, default 50).
49
+ format: 'excel' (or 'xlsx'), 'csv' (default), or 'json'.
50
+ output_path: Destination file path (optional).
51
+
52
+ Returns:
53
+ Dict containing output_path, row_count, format, and data attribution.
54
+ """
55
+ limit = max(1, min(limit, 200))
56
+
57
+ businesses, provider_name = await _registry.search(
58
+ query=query,
59
+ location=location,
60
+ category=category,
61
+ limit=limit,
62
+ )
63
+
64
+ is_osm = provider_name == "openstreetmap"
65
+ fmt = format.lower()
66
+ if fmt in ("excel", "xlsx"):
67
+ ext = "xlsx"
68
+ elif fmt == "json":
69
+ ext = "json"
70
+ else:
71
+ ext = "csv"
72
+
73
+ if not output_path:
74
+ ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
75
+ fname = f"leadintel_export_{ts}.{ext}"
76
+ output_path = os.path.join(tempfile.gettempdir(), fname)
77
+
78
+ # Execute export
79
+ if ext == "xlsx":
80
+ abs_path = export_to_excel(businesses=businesses, output_path=output_path)
81
+ elif ext == "json":
82
+ abs_path = _write_json(businesses, output_path, provider_name)
83
+ else:
84
+ abs_path = export_to_csv(businesses, output_path=output_path)
85
+
86
+ return {
87
+ "output_path": abs_path,
88
+ "row_count": len(businesses),
89
+ "format": ext,
90
+ "provider": provider_name,
91
+ "attribution": OSM_ATTRIBUTION if is_osm else None,
92
+ "note": (
93
+ f"Exported {len(businesses)} leads to {ext.upper()}. "
94
+ + (f"Attribution: {OSM_ATTRIBUTION}" if is_osm else "")
95
+ ).strip(),
96
+ }
97
+
98
+
99
+ def _write_json(businesses, output_path: str, provider_name: str) -> str:
100
+ safe_path = validate_safe_export_path(output_path, allowed_extensions={"json"})
101
+ os.makedirs(os.path.dirname(safe_path) or ".", exist_ok=True)
102
+ payload = {
103
+ "leadintel_export": True,
104
+ "provider": provider_name,
105
+ "attribution": OSM_ATTRIBUTION if provider_name == "openstreetmap" else None,
106
+ "exported_at": datetime.utcnow().isoformat(),
107
+ "count": len(businesses),
108
+ "businesses": [b.model_dump(mode="json") for b in businesses],
109
+ }
110
+ with open(safe_path, "w", encoding="utf-8") as f:
111
+ json.dump(payload, f, indent=2, ensure_ascii=False)
112
+ return safe_path