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
leadintel/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """LeadIntel — open-source MCP server for B2B lead generation."""
2
+
3
+ from .storage.db import init_db
4
+
5
+ __version__ = "0.3.0"
6
+ __all__ = ["init_db", "__version__"]
leadintel/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Module entrypoint: python -m leadintel"""
2
+
3
+ from .server import mcp
4
+
5
+ if __name__ == "__main__":
6
+ mcp.run()
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,6 @@
1
+ """Exporters package — CSV, Excel, and JSON lead export tools."""
2
+
3
+ from .csv_exporter import export_to_csv
4
+ from .excel_exporter import export_to_excel, sanitize_excel_cell
5
+
6
+ __all__ = ["export_to_csv", "export_to_excel", "sanitize_excel_cell"]
@@ -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