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
leadintel/__init__.py
ADDED
leadintel/__main__.py
ADDED
leadintel/config.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LeadIntel configuration loaded from environment variables.
|
|
3
|
+
|
|
4
|
+
Core principle: ZERO mandatory API keys.
|
|
5
|
+
All external services are free and optional.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from dotenv import load_dotenv
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Load .env with intelligent path resolution (handles cwd, repo root, Claude Desktop launch)
|
|
18
|
+
def _load_env_configuration() -> None:
|
|
19
|
+
# 1. Explicit path in env var
|
|
20
|
+
custom_path = os.environ.get("LEADINTEL_ENV_FILE")
|
|
21
|
+
if custom_path:
|
|
22
|
+
p = Path(custom_path).expanduser().resolve()
|
|
23
|
+
if p.is_file():
|
|
24
|
+
load_dotenv(p)
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
# 2. Current working directory
|
|
28
|
+
cwd_env = Path.cwd() / ".env"
|
|
29
|
+
if cwd_env.is_file():
|
|
30
|
+
load_dotenv(cwd_env)
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
# 3. Project root (relative to this file: src/leadintel/config.py -> repo root)
|
|
34
|
+
repo_root_env = Path(__file__).resolve().parent.parent.parent / ".env"
|
|
35
|
+
if repo_root_env.is_file():
|
|
36
|
+
load_dotenv(repo_root_env)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
# 4. Search upward from current working directory
|
|
40
|
+
for parent in Path.cwd().parents:
|
|
41
|
+
candidate = parent / ".env"
|
|
42
|
+
if candidate.is_file():
|
|
43
|
+
load_dotenv(candidate)
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_load_env_configuration()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Settings:
|
|
52
|
+
"""Immutable configuration object for LeadIntel."""
|
|
53
|
+
|
|
54
|
+
# ── Overpass / OpenStreetMap ──────────────────────────────────────────────
|
|
55
|
+
overpass_endpoint: str = field(
|
|
56
|
+
default_factory=lambda: os.environ.get(
|
|
57
|
+
"OVERPASS_ENDPOINT", "https://overpass-api.de/api/interpreter"
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
# Search radius in metres around a geocoded point
|
|
61
|
+
overpass_radius_m: int = field(
|
|
62
|
+
default_factory=lambda: int(os.environ.get("OVERPASS_RADIUS_M", "5000"))
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# ── Ollama (optional local AI) ────────────────────────────────────────────
|
|
66
|
+
ollama_enabled: bool = field(
|
|
67
|
+
default_factory=lambda: os.environ.get("OLLAMA_ENABLED", "false").lower()
|
|
68
|
+
in ("true", "1", "yes")
|
|
69
|
+
)
|
|
70
|
+
ollama_base_url: str = field(
|
|
71
|
+
default_factory=lambda: os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
|
72
|
+
)
|
|
73
|
+
ollama_model: str = field(
|
|
74
|
+
default_factory=lambda: os.environ.get("OLLAMA_MODEL", "llama3")
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# ── Storage ───────────────────────────────────────────────────────────────
|
|
78
|
+
database_path: str = field(
|
|
79
|
+
default_factory=lambda: os.environ.get("LEADINTEL_DB_PATH", "leadintel.db")
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# ── HTTP / cache ──────────────────────────────────────────────────────────
|
|
83
|
+
# In-process result cache TTL in seconds (max 604 800 = 7 days per OSM policy)
|
|
84
|
+
cache_ttl: int = field(
|
|
85
|
+
default_factory=lambda: int(os.environ.get("CACHE_TTL", "3600"))
|
|
86
|
+
)
|
|
87
|
+
# HTTP request timeout for external calls (seconds)
|
|
88
|
+
request_timeout: float = field(
|
|
89
|
+
default_factory=lambda: float(os.environ.get("REQUEST_TIMEOUT", "30.0"))
|
|
90
|
+
)
|
|
91
|
+
# Respect robots.txt during website analysis
|
|
92
|
+
respect_robots: bool = field(
|
|
93
|
+
default_factory=lambda: os.environ.get("RESPECT_ROBOTS", "true").lower()
|
|
94
|
+
not in ("false", "0", "no")
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# ── Logging ───────────────────────────────────────────────────────────────
|
|
98
|
+
log_level: str = field(
|
|
99
|
+
default_factory=lambda: os.environ.get("LOG_LEVEL", "INFO")
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# ── Demo mode ─────────────────────────────────────────────────────────────
|
|
103
|
+
# When True, the DemoProvider is the ONLY provider (no network calls).
|
|
104
|
+
# When False, live providers (Overpass, etc.) are tried first with demo fallback.
|
|
105
|
+
demo_mode: bool = field(
|
|
106
|
+
default_factory=lambda: os.environ.get("DEMO_MODE", "false").lower()
|
|
107
|
+
in ("true", "1", "yes")
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# ── Legacy / optional third-party keys (kept for future providers) ────────
|
|
111
|
+
# These are never required; presence enables richer optional features.
|
|
112
|
+
google_places_api_key: str = field(
|
|
113
|
+
default_factory=lambda: os.environ.get("GOOGLE_PLACES_API_KEY", ""),
|
|
114
|
+
repr=False,
|
|
115
|
+
)
|
|
116
|
+
pagespeed_api_key: str = field(
|
|
117
|
+
default_factory=lambda: os.environ.get("PAGESPEED_API_KEY", ""),
|
|
118
|
+
repr=False,
|
|
119
|
+
)
|
|
120
|
+
gemini_api_key: str = field(
|
|
121
|
+
default_factory=lambda: os.environ.get("GEMINI_API_KEY", ""),
|
|
122
|
+
repr=False,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def __repr__(self) -> str:
|
|
126
|
+
"""Safe string representation masking all sensitive tokens."""
|
|
127
|
+
return f"Settings({self.safe_dict()})"
|
|
128
|
+
|
|
129
|
+
# ── Derived properties ────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def has_ollama(self) -> bool:
|
|
133
|
+
return self.ollama_enabled
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def has_google_places(self) -> bool:
|
|
137
|
+
return bool(self.google_places_api_key)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def has_gemini(self) -> bool:
|
|
141
|
+
return bool(self.gemini_api_key)
|
|
142
|
+
|
|
143
|
+
def safe_dict(self) -> dict:
|
|
144
|
+
"""Return config as dict with secrets redacted (safe for logging)."""
|
|
145
|
+
return {
|
|
146
|
+
"overpass_endpoint": self.overpass_endpoint,
|
|
147
|
+
"overpass_radius_m": self.overpass_radius_m,
|
|
148
|
+
"ollama_enabled": self.ollama_enabled,
|
|
149
|
+
"ollama_base_url": self.ollama_base_url,
|
|
150
|
+
"ollama_model": self.ollama_model,
|
|
151
|
+
"database_path": self.database_path,
|
|
152
|
+
"cache_ttl": self.cache_ttl,
|
|
153
|
+
"request_timeout": self.request_timeout,
|
|
154
|
+
"respect_robots": self.respect_robots,
|
|
155
|
+
"log_level": self.log_level,
|
|
156
|
+
"demo_mode": self.demo_mode,
|
|
157
|
+
# Third-party keys — never log the actual value
|
|
158
|
+
"google_places_api_key": "***" if self.google_places_api_key else "(not set)",
|
|
159
|
+
"pagespeed_api_key": "***" if self.pagespeed_api_key else "(not set)",
|
|
160
|
+
"gemini_api_key": "***" if self.gemini_api_key else "(not set)",
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# Module-level singleton imported throughout the codebase
|
|
165
|
+
settings = Settings()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Data package init."""
|
leadintel/data/demo.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Demo data for development and testing — no real API calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..models.business import Business
|
|
8
|
+
|
|
9
|
+
DEMO_BUSINESSES: list[Business] = [
|
|
10
|
+
Business(
|
|
11
|
+
id="demo_001",
|
|
12
|
+
name="Acme Tech Solutions",
|
|
13
|
+
category="Software Development",
|
|
14
|
+
address="100 Innovation Drive, San Francisco, CA 94105, USA",
|
|
15
|
+
city="San Francisco",
|
|
16
|
+
state="CA",
|
|
17
|
+
country="US",
|
|
18
|
+
postal_code="94105",
|
|
19
|
+
latitude=37.7749,
|
|
20
|
+
longitude=-122.4194,
|
|
21
|
+
phone="+1-415-555-0101",
|
|
22
|
+
email="hello@acmetech.example",
|
|
23
|
+
website="https://acmetech.example",
|
|
24
|
+
linkedin_url="https://linkedin.com/company/acmetech-example",
|
|
25
|
+
rating=4.5,
|
|
26
|
+
review_count=128,
|
|
27
|
+
employee_count_estimate="50–200",
|
|
28
|
+
source="demo",
|
|
29
|
+
),
|
|
30
|
+
Business(
|
|
31
|
+
id="demo_002",
|
|
32
|
+
name="BrightSpark Digital",
|
|
33
|
+
category="Digital Marketing",
|
|
34
|
+
address="200 Market Street, New York, NY 10001, USA",
|
|
35
|
+
city="New York",
|
|
36
|
+
state="NY",
|
|
37
|
+
country="US",
|
|
38
|
+
postal_code="10001",
|
|
39
|
+
latitude=40.7128,
|
|
40
|
+
longitude=-74.0060,
|
|
41
|
+
phone="+1-212-555-0202",
|
|
42
|
+
email="info@brightsparkdigital.example",
|
|
43
|
+
website="https://brightsparkdigital.example",
|
|
44
|
+
twitter_url="https://twitter.com/brightsparkdigital",
|
|
45
|
+
rating=4.2,
|
|
46
|
+
review_count=89,
|
|
47
|
+
employee_count_estimate="10–50",
|
|
48
|
+
source="demo",
|
|
49
|
+
),
|
|
50
|
+
Business(
|
|
51
|
+
id="demo_003",
|
|
52
|
+
name="GreenLeaf Consulting",
|
|
53
|
+
category="Management Consulting",
|
|
54
|
+
address="300 Michigan Avenue, Chicago, IL 60601, USA",
|
|
55
|
+
city="Chicago",
|
|
56
|
+
state="IL",
|
|
57
|
+
country="US",
|
|
58
|
+
postal_code="60601",
|
|
59
|
+
latitude=41.8827,
|
|
60
|
+
longitude=-87.6233,
|
|
61
|
+
phone="+1-312-555-0303",
|
|
62
|
+
email="contact@greenleafconsulting.example",
|
|
63
|
+
website="https://greenleafconsulting.example",
|
|
64
|
+
linkedin_url="https://linkedin.com/company/greenleaf-consulting-example",
|
|
65
|
+
facebook_url="https://facebook.com/greenleafconsulting.example",
|
|
66
|
+
rating=4.8,
|
|
67
|
+
review_count=54,
|
|
68
|
+
employee_count_estimate="10–50",
|
|
69
|
+
source="demo",
|
|
70
|
+
),
|
|
71
|
+
Business(
|
|
72
|
+
id="demo_004",
|
|
73
|
+
name="Nexus Cloud Systems",
|
|
74
|
+
category="Cloud Computing",
|
|
75
|
+
address="400 Congress Avenue, Austin, TX 78701, USA",
|
|
76
|
+
city="Austin",
|
|
77
|
+
state="TX",
|
|
78
|
+
country="US",
|
|
79
|
+
postal_code="78701",
|
|
80
|
+
latitude=30.2672,
|
|
81
|
+
longitude=-97.7431,
|
|
82
|
+
phone="+1-512-555-0404",
|
|
83
|
+
website="https://nexuscloud.example",
|
|
84
|
+
rating=4.0,
|
|
85
|
+
review_count=210,
|
|
86
|
+
employee_count_estimate="200–500",
|
|
87
|
+
source="demo",
|
|
88
|
+
),
|
|
89
|
+
Business(
|
|
90
|
+
id="demo_005",
|
|
91
|
+
name="Pinnacle HR Partners",
|
|
92
|
+
category="Human Resources",
|
|
93
|
+
address="500 Peachtree Street NE, Atlanta, GA 30308, USA",
|
|
94
|
+
city="Atlanta",
|
|
95
|
+
state="GA",
|
|
96
|
+
country="US",
|
|
97
|
+
postal_code="30308",
|
|
98
|
+
latitude=33.7490,
|
|
99
|
+
longitude=-84.3880,
|
|
100
|
+
phone="+1-404-555-0505",
|
|
101
|
+
email="team@pinnacleHR.example",
|
|
102
|
+
website="https://pinnacleHR.example",
|
|
103
|
+
rating=3.9,
|
|
104
|
+
review_count=37,
|
|
105
|
+
employee_count_estimate="10–50",
|
|
106
|
+
source="demo",
|
|
107
|
+
),
|
|
108
|
+
# Pune Central Businesses (Core Urban Area: Shivajinagar, FC Road, Deccan, Koregaon Park, Kothrud)
|
|
109
|
+
Business(
|
|
110
|
+
id="demo_pune_001",
|
|
111
|
+
name="Vaishali Restaurant",
|
|
112
|
+
category="Food & Beverage",
|
|
113
|
+
address="FC Road, Shivajinagar, Pune, Maharashtra 411004, India",
|
|
114
|
+
city="Pune",
|
|
115
|
+
state="MH",
|
|
116
|
+
country="IN",
|
|
117
|
+
postal_code="411004",
|
|
118
|
+
latitude=18.5246,
|
|
119
|
+
longitude=73.8415,
|
|
120
|
+
phone="+91-20-25531244",
|
|
121
|
+
website=None,
|
|
122
|
+
rating=4.6,
|
|
123
|
+
review_count=12500,
|
|
124
|
+
employee_count_estimate="50–100",
|
|
125
|
+
source="demo",
|
|
126
|
+
),
|
|
127
|
+
Business(
|
|
128
|
+
id="demo_pune_002",
|
|
129
|
+
name="Cafe Goodluck",
|
|
130
|
+
category="Food & Beverage",
|
|
131
|
+
address="Deccan Gymkhana, Pune, Maharashtra 411004, India",
|
|
132
|
+
city="Pune",
|
|
133
|
+
state="MH",
|
|
134
|
+
country="IN",
|
|
135
|
+
postal_code="411004",
|
|
136
|
+
latitude=18.5168,
|
|
137
|
+
longitude=73.8415,
|
|
138
|
+
phone="+91-20-25676893",
|
|
139
|
+
website=None,
|
|
140
|
+
rating=4.4,
|
|
141
|
+
review_count=8900,
|
|
142
|
+
employee_count_estimate="20–50",
|
|
143
|
+
source="demo",
|
|
144
|
+
),
|
|
145
|
+
Business(
|
|
146
|
+
id="demo_pune_003",
|
|
147
|
+
name="German Bakery",
|
|
148
|
+
category="Food & Beverage",
|
|
149
|
+
address="North Main Road, Koregaon Park, Pune, Maharashtra 411001, India",
|
|
150
|
+
city="Pune",
|
|
151
|
+
state="MH",
|
|
152
|
+
country="IN",
|
|
153
|
+
postal_code="411001",
|
|
154
|
+
latitude=18.5362,
|
|
155
|
+
longitude=73.8940,
|
|
156
|
+
phone="+91-20-26156543",
|
|
157
|
+
website="https://germanbakerypune.example",
|
|
158
|
+
rating=4.3,
|
|
159
|
+
review_count=4200,
|
|
160
|
+
employee_count_estimate="20–50",
|
|
161
|
+
source="demo",
|
|
162
|
+
),
|
|
163
|
+
Business(
|
|
164
|
+
id="demo_pune_004",
|
|
165
|
+
name="Kothrud Software Labs",
|
|
166
|
+
category="Technology",
|
|
167
|
+
address="Paud Road, Kothrud, Pune, Maharashtra 411038, India",
|
|
168
|
+
city="Pune",
|
|
169
|
+
state="MH",
|
|
170
|
+
country="IN",
|
|
171
|
+
postal_code="411038",
|
|
172
|
+
latitude=18.5074,
|
|
173
|
+
longitude=73.8077,
|
|
174
|
+
website="https://kothrudsoftware.example",
|
|
175
|
+
rating=4.5,
|
|
176
|
+
review_count=180,
|
|
177
|
+
employee_count_estimate="10–50",
|
|
178
|
+
source="demo",
|
|
179
|
+
),
|
|
180
|
+
# Pune Outlying / Distant Municipalities (Outside Central Core > 6km)
|
|
181
|
+
Business(
|
|
182
|
+
id="demo_pune_dist_001",
|
|
183
|
+
name="Alandi Pilgrim Refreshments",
|
|
184
|
+
category="Food & Beverage",
|
|
185
|
+
address="Temple Road, Alandi, Pune District, Maharashtra 412105, India",
|
|
186
|
+
city="Alandi",
|
|
187
|
+
state="MH",
|
|
188
|
+
country="IN",
|
|
189
|
+
postal_code="412105",
|
|
190
|
+
latitude=18.6775,
|
|
191
|
+
longitude=73.8967,
|
|
192
|
+
website=None,
|
|
193
|
+
rating=4.0,
|
|
194
|
+
review_count=310,
|
|
195
|
+
employee_count_estimate="5–20",
|
|
196
|
+
source="demo",
|
|
197
|
+
),
|
|
198
|
+
Business(
|
|
199
|
+
id="demo_pune_dist_002",
|
|
200
|
+
name="Pimpri Auto Spare Parts",
|
|
201
|
+
category="Automotive",
|
|
202
|
+
address="Old Mumbai-Pune Highway, Pimpri, Maharashtra 411018, India",
|
|
203
|
+
city="Pimpri-Chinchwad",
|
|
204
|
+
state="MH",
|
|
205
|
+
country="IN",
|
|
206
|
+
postal_code="411018",
|
|
207
|
+
latitude=18.6279,
|
|
208
|
+
longitude=73.8009,
|
|
209
|
+
website="https://pimpriauto.example",
|
|
210
|
+
rating=4.1,
|
|
211
|
+
review_count=520,
|
|
212
|
+
employee_count_estimate="10–50",
|
|
213
|
+
source="demo",
|
|
214
|
+
),
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def search_demo_businesses(
|
|
219
|
+
query: str,
|
|
220
|
+
location: str | None = None,
|
|
221
|
+
category: str | None = None,
|
|
222
|
+
limit: int | None = None,
|
|
223
|
+
latitude: float | None = None,
|
|
224
|
+
longitude: float | None = None,
|
|
225
|
+
radius_meters: int | None = None,
|
|
226
|
+
wider_metro: bool = False,
|
|
227
|
+
**kwargs: Any,
|
|
228
|
+
) -> list[Business]:
|
|
229
|
+
"""Filter demo businesses by query / location / category and enforce geographic distance."""
|
|
230
|
+
from ..providers.overpass_provider import haversine_distance_meters
|
|
231
|
+
|
|
232
|
+
q = (query or "").lower().strip()
|
|
233
|
+
results = []
|
|
234
|
+
|
|
235
|
+
# Center coordinates resolution
|
|
236
|
+
center_lat: float | None = None
|
|
237
|
+
center_lon: float | None = None
|
|
238
|
+
if latitude is not None and longitude is not None:
|
|
239
|
+
center_lat, center_lon = float(latitude), float(longitude)
|
|
240
|
+
elif location:
|
|
241
|
+
loc_lower = location.lower().strip()
|
|
242
|
+
if "pune" in loc_lower:
|
|
243
|
+
center_lat, center_lon = 18.5204, 73.8567
|
|
244
|
+
elif "san francisco" in loc_lower or "sf" in loc_lower:
|
|
245
|
+
center_lat, center_lon = 37.7749, -122.4194
|
|
246
|
+
elif "new york" in loc_lower or "nyc" in loc_lower:
|
|
247
|
+
center_lat, center_lon = 40.7128, -74.0060
|
|
248
|
+
|
|
249
|
+
eff_radius = (
|
|
250
|
+
(radius_meters if radius_meters is not None else 25_000)
|
|
251
|
+
if wider_metro
|
|
252
|
+
else (radius_meters if radius_meters is not None else 6_000)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
for biz in DEMO_BUSINESSES:
|
|
256
|
+
name_match = not q or q == "*" or q in biz.name.lower()
|
|
257
|
+
cat_match = not q or (biz.category and q in biz.category.lower())
|
|
258
|
+
|
|
259
|
+
# Location filtering
|
|
260
|
+
loc_match = True
|
|
261
|
+
if location and biz.city:
|
|
262
|
+
loc_l = location.lower()
|
|
263
|
+
# If searching for "pune", exclude separate municipalities like Alandi or Pimpri unless wider_metro
|
|
264
|
+
if "pune" in loc_l and not wider_metro:
|
|
265
|
+
loc_match = biz.city.lower() == "pune"
|
|
266
|
+
else:
|
|
267
|
+
loc_match = loc_l in biz.city.lower() or loc_l in (biz.address or "").lower()
|
|
268
|
+
|
|
269
|
+
filter_cat = (
|
|
270
|
+
not category
|
|
271
|
+
or (biz.category and category.lower() in biz.category.lower())
|
|
272
|
+
)
|
|
273
|
+
filter_loc = not location or loc_match
|
|
274
|
+
|
|
275
|
+
if (name_match or cat_match) and filter_cat and filter_loc:
|
|
276
|
+
# Clone model for annotation
|
|
277
|
+
b_copy = biz.model_copy()
|
|
278
|
+
if center_lat is not None and center_lon is not None and b_copy.latitude and b_copy.longitude:
|
|
279
|
+
dist_m = haversine_distance_meters(center_lat, center_lon, b_copy.latitude, b_copy.longitude)
|
|
280
|
+
b_copy.distance_meters = round(dist_m, 1)
|
|
281
|
+
b_copy.distance_km = round(dist_m / 1000.0, 2)
|
|
282
|
+
if not wider_metro and dist_m > eff_radius:
|
|
283
|
+
continue # Strict radius exclusion
|
|
284
|
+
results.append(b_copy)
|
|
285
|
+
|
|
286
|
+
# If no results matched query text, fallback to location/category matches
|
|
287
|
+
if not results and (location or category):
|
|
288
|
+
for biz in DEMO_BUSINESSES:
|
|
289
|
+
loc_match = not location or (
|
|
290
|
+
biz.city and location.lower() in biz.city.lower()
|
|
291
|
+
)
|
|
292
|
+
cat_match = not category or (
|
|
293
|
+
biz.category and category.lower() in biz.category.lower()
|
|
294
|
+
)
|
|
295
|
+
if loc_match and cat_match:
|
|
296
|
+
b_copy = biz.model_copy()
|
|
297
|
+
if center_lat is not None and center_lon is not None and b_copy.latitude and b_copy.longitude:
|
|
298
|
+
dist_m = haversine_distance_meters(center_lat, center_lon, b_copy.latitude, b_copy.longitude)
|
|
299
|
+
b_copy.distance_meters = round(dist_m, 1)
|
|
300
|
+
b_copy.distance_km = round(dist_m / 1000.0, 2)
|
|
301
|
+
if not wider_metro and dist_m > eff_radius:
|
|
302
|
+
continue
|
|
303
|
+
results.append(b_copy)
|
|
304
|
+
|
|
305
|
+
if limit is not None:
|
|
306
|
+
return results[:limit]
|
|
307
|
+
return results
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def get_demo_business(business_id: str) -> Business | None:
|
|
311
|
+
"""Return a single demo business by id."""
|
|
312
|
+
for biz in DEMO_BUSINESSES:
|
|
313
|
+
if biz.id == business_id:
|
|
314
|
+
return biz
|
|
315
|
+
return None
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""CSV export for business lead lists."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import os
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from ..models.business import Business
|
|
12
|
+
from ..storage.path_validator import validate_safe_export_path
|
|
13
|
+
|
|
14
|
+
# Columns written to CSV (in order)
|
|
15
|
+
_COLUMNS = [
|
|
16
|
+
"id",
|
|
17
|
+
"name",
|
|
18
|
+
"category",
|
|
19
|
+
"address",
|
|
20
|
+
"city",
|
|
21
|
+
"state",
|
|
22
|
+
"country",
|
|
23
|
+
"postal_code",
|
|
24
|
+
"phone",
|
|
25
|
+
"email",
|
|
26
|
+
"website",
|
|
27
|
+
"website_status",
|
|
28
|
+
"website_opportunity_score",
|
|
29
|
+
"detected_cms",
|
|
30
|
+
"has_contact_route",
|
|
31
|
+
"distance_km",
|
|
32
|
+
"linkedin_url",
|
|
33
|
+
"twitter_url",
|
|
34
|
+
"facebook_url",
|
|
35
|
+
"rating",
|
|
36
|
+
"review_count",
|
|
37
|
+
"employee_count_estimate",
|
|
38
|
+
"source",
|
|
39
|
+
"retrieved_at",
|
|
40
|
+
"is_ai_generated",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
DANGEROUS_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def sanitize_csv_cell(val: Any) -> Any:
|
|
47
|
+
"""
|
|
48
|
+
Protect against CSV/spreadsheet formula injection.
|
|
49
|
+
Prefixes values starting with '=', '+', '-', '@', '\\t', '\\r' with a single quote.
|
|
50
|
+
"""
|
|
51
|
+
if val is None:
|
|
52
|
+
return ""
|
|
53
|
+
if isinstance(val, (int, float, bool)):
|
|
54
|
+
return val
|
|
55
|
+
raw = str(val)
|
|
56
|
+
if raw.startswith(("\t", "\r")):
|
|
57
|
+
return "'" + raw
|
|
58
|
+
s = raw.strip()
|
|
59
|
+
if s.startswith(("=", "+", "-", "@")):
|
|
60
|
+
return "'" + s
|
|
61
|
+
return s
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _business_to_row(biz: Business) -> dict:
|
|
65
|
+
"""Convert a Business model to a flat, sanitized CSV-safe dict."""
|
|
66
|
+
data = biz.model_dump()
|
|
67
|
+
# Flatten datetime
|
|
68
|
+
data["retrieved_at"] = (
|
|
69
|
+
data["retrieved_at"].isoformat()
|
|
70
|
+
if isinstance(data["retrieved_at"], datetime)
|
|
71
|
+
else data["retrieved_at"]
|
|
72
|
+
)
|
|
73
|
+
# Drop geo fields (not useful in a spreadsheet view)
|
|
74
|
+
data.pop("latitude", None)
|
|
75
|
+
data.pop("longitude", None)
|
|
76
|
+
return {col: sanitize_csv_cell(data.get(col, "")) for col in _COLUMNS}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def export_to_csv(
|
|
80
|
+
businesses: list[Business],
|
|
81
|
+
output_path: Optional[str] = None,
|
|
82
|
+
) -> str:
|
|
83
|
+
"""
|
|
84
|
+
Serialise a list of Business objects to CSV.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
businesses: List of Business instances to export.
|
|
88
|
+
output_path: If provided, safely validate and write to this file path and return the path.
|
|
89
|
+
If None, return the CSV content as a string.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Absolute file path (when output_path is given) or raw CSV string.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
SecurityValidationError: If output_path violates path safety rules.
|
|
96
|
+
OSError: If writing to output_path fails.
|
|
97
|
+
"""
|
|
98
|
+
buf = io.StringIO()
|
|
99
|
+
writer = csv.DictWriter(buf, fieldnames=_COLUMNS, extrasaction="ignore")
|
|
100
|
+
writer.writeheader()
|
|
101
|
+
for biz in businesses:
|
|
102
|
+
writer.writerow(_business_to_row(biz))
|
|
103
|
+
|
|
104
|
+
csv_content = buf.getvalue()
|
|
105
|
+
|
|
106
|
+
if output_path:
|
|
107
|
+
safe_path = validate_safe_export_path(output_path, allowed_extensions={"csv"})
|
|
108
|
+
os.makedirs(os.path.dirname(safe_path) or ".", exist_ok=True)
|
|
109
|
+
with open(safe_path, "w", newline="", encoding="utf-8") as f:
|
|
110
|
+
f.write(csv_content)
|
|
111
|
+
return safe_path
|
|
112
|
+
|
|
113
|
+
return csv_content
|