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,123 @@
1
+ """
2
+ Excel export for business lead lists.
3
+
4
+ Generates a formatted 3-worksheet Excel workbook:
5
+ 1. All Leads
6
+ 2. High Website Opportunity (opportunity score >= 50)
7
+ 3. Data Quality Summary
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any, Optional
15
+
16
+ import openpyxl
17
+ from openpyxl.styles import Font
18
+
19
+ from ..models.business import Business
20
+
21
+
22
+ def _sanitize(value: Any) -> Any:
23
+ """Prevent spreadsheet formula injection."""
24
+ if value is None:
25
+ return ""
26
+ if isinstance(value, str) and value.startswith(("=", "+", "-", "@")):
27
+ return "'" + value
28
+ return value
29
+
30
+
31
+ # Backwards compatibility alias
32
+ sanitize_excel_cell = _sanitize
33
+
34
+
35
+ def _to_business(item: Any) -> Business:
36
+ """Ensure lead item is a Business instance."""
37
+ if isinstance(item, Business):
38
+ return item
39
+ if isinstance(item, dict):
40
+ d = dict(item)
41
+ if "id" not in d:
42
+ d["id"] = "lead_" + str(hash(str(d.get("name", ""))))
43
+ if "name" not in d:
44
+ d["name"] = "Unknown Business"
45
+ try:
46
+ return Business.model_validate(d)
47
+ except Exception:
48
+ valid_fields = {k: v for k, v in d.items() if hasattr(Business, k)}
49
+ return Business(id=d.get("id", "lead_temp"), name=d.get("name", "Unknown"), **valid_fields)
50
+ return Business(id="lead_unknown", name=str(item))
51
+
52
+
53
+ def export_to_excel(
54
+ businesses: Optional[list[Any]] = None,
55
+ output_path: str | Path = "leads.xlsx",
56
+ **kwargs: Any,
57
+ ) -> str:
58
+ """
59
+ Export business leads to a 3-sheet Excel workbook.
60
+
61
+ Worksheets:
62
+ 1. All Leads
63
+ 2. High Website Opportunity (Score >= 50)
64
+ 3. Data Quality Summary
65
+ """
66
+ businesses = [_to_business(b) for b in (businesses or [])]
67
+
68
+ wb = openpyxl.Workbook()
69
+
70
+ headers = [
71
+ "Business Name", "Category", "Address", "City", "Phone", "Email", "Website",
72
+ "Website Status", "Website Opportunity Score", "Score Explanation",
73
+ "Latitude", "Longitude", "Distance (km)", "Data Confidence", "Source", "Research Timestamp"
74
+ ]
75
+
76
+ # Worksheet 1: All Leads
77
+ ws_all = wb.active
78
+ ws_all.title = "All Leads"
79
+ ws_all.append(headers)
80
+ for cell in ws_all[1]:
81
+ cell.font = Font(bold=True)
82
+ ws_all.freeze_panes = "A2"
83
+
84
+ # Worksheet 2: High Website Opportunity
85
+ ws_high = wb.create_sheet("High Website Opportunity")
86
+ ws_high.append(headers)
87
+ for cell in ws_high[1]:
88
+ cell.font = Font(bold=True)
89
+ ws_high.freeze_panes = "A2"
90
+
91
+ timestamp = datetime.now(timezone.utc).isoformat()
92
+
93
+ for b in businesses:
94
+ row = [
95
+ b.name, b.category, b.address, b.city, b.phone, b.email, b.website,
96
+ b.website_status, b.website_opportunity_score, b.website_opportunity_explanation,
97
+ b.latitude, b.longitude, b.distance_km, b.data_confidence, b.source, timestamp
98
+ ]
99
+ sanitized_row = [_sanitize(c) for c in row]
100
+ ws_all.append(sanitized_row)
101
+
102
+ if b.website_opportunity_score is not None and b.website_opportunity_score >= 50:
103
+ ws_high.append(sanitized_row)
104
+
105
+ # Worksheet 3: Data Quality Summary
106
+ ws_summary = wb.create_sheet("Data Quality Summary")
107
+ ws_summary.append(["Metric", "Count"])
108
+ for cell in ws_summary[1]:
109
+ cell.font = Font(bold=True)
110
+
111
+ ws_summary.append(["Total Leads", len(businesses)])
112
+ ws_summary.append(["With Phone Numbers", sum(1 for b in businesses if b.phone)])
113
+ ws_summary.append(["With Email Addresses", sum(1 for b in businesses if b.email)])
114
+ ws_summary.append(["Verified Websites", sum(1 for b in businesses if b.website_status == "website_verified")])
115
+ ws_summary.append(["No Verified Official Website", sum(1 for b in businesses if b.website_status == "website_not_found_after_research")])
116
+ ws_summary.append(["Social Media Presence", sum(1 for b in businesses if b.facebook_url or getattr(b, "instagram_url", None) or b.linkedin_url)])
117
+ ws_summary.append(["Uncertain Information", sum(1 for b in businesses if b.website_status == "website_unverified")])
118
+ ws_summary.append(["Missing Contact Details", sum(1 for b in businesses if not b.phone and not b.email)])
119
+
120
+ out_path = Path(output_path)
121
+ out_path.parent.mkdir(parents=True, exist_ok=True)
122
+ wb.save(out_path)
123
+ return str(out_path.absolute())
@@ -0,0 +1,6 @@
1
+ """Pydantic models for LeadIntel data structures."""
2
+
3
+ from .business import Business, BusinessSearchResult, LeadScore
4
+ from .report import BusinessReport
5
+
6
+ __all__ = ["Business", "BusinessSearchResult", "LeadScore", "BusinessReport"]
@@ -0,0 +1,203 @@
1
+ """Business data models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Literal, Optional
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, field_serializer
9
+
10
+ WebsiteStatus = Literal[
11
+ "website_verified",
12
+ "website_not_found_after_research",
13
+ "website_unverified",
14
+ "website_broken",
15
+ "website_not_checked",
16
+ ]
17
+
18
+
19
+ class Business(BaseModel):
20
+ """Represents a single business entity."""
21
+
22
+ # Identity
23
+ id: str = Field(description="Stable unique identifier (e.g. 'osm_node_12345' or slug)")
24
+ name: str = Field(description="Business name")
25
+ category: Optional[str] = Field(default=None, description="Primary industry / category")
26
+
27
+ # Location
28
+ address: Optional[str] = Field(default=None, description="Full formatted address")
29
+ city: Optional[str] = Field(default=None)
30
+ state: Optional[str] = Field(default=None)
31
+ country: Optional[str] = Field(default=None)
32
+ postal_code: Optional[str] = Field(default=None)
33
+ latitude: Optional[float] = Field(default=None)
34
+ longitude: Optional[float] = Field(default=None)
35
+ distance_meters: Optional[float] = Field(
36
+ default=None,
37
+ ge=0,
38
+ description="Calculated distance in metres from the search center coordinates",
39
+ )
40
+ distance_km: Optional[float] = Field(
41
+ default=None,
42
+ ge=0,
43
+ description="Calculated distance in kilometres from search center",
44
+ )
45
+
46
+ # Contact & Digital Presence
47
+ phone: Optional[str] = Field(default=None)
48
+ email: Optional[str] = Field(default=None)
49
+ website: Optional[str] = Field(default=None)
50
+ website_status: WebsiteStatus = Field(
51
+ default="website_not_checked",
52
+ description=(
53
+ "Verification status of the business website. One of: "
54
+ "'website_verified', 'website_not_found_after_research', "
55
+ "'website_unverified', 'website_broken', 'website_not_checked'."
56
+ ),
57
+ )
58
+ website_opportunity_score: Optional[float] = Field(
59
+ default=None,
60
+ ge=0,
61
+ le=100,
62
+ description="Deterministic 0–100 score prioritizing web dev opportunity for agencies",
63
+ )
64
+ website_opportunity_explanation: Optional[str] = Field(
65
+ default=None,
66
+ description="Evidence-based explanation of the website opportunity score",
67
+ )
68
+ detected_cms: Optional[str] = Field(
69
+ default=None,
70
+ description="Detected CMS like WordPress, Shopify, Wix",
71
+ )
72
+ has_contact_route: Optional[bool] = Field(
73
+ default=None,
74
+ description="True if a contact page or mailto link was found",
75
+ )
76
+
77
+ # Social
78
+ linkedin_url: Optional[str] = Field(default=None)
79
+ twitter_url: Optional[str] = Field(default=None)
80
+ facebook_url: Optional[str] = Field(default=None)
81
+ instagram_url: Optional[str] = Field(default=None)
82
+
83
+ # Metrics
84
+ rating: Optional[float] = Field(default=None, ge=0, le=5)
85
+ review_count: Optional[int] = Field(default=None, ge=0)
86
+ employee_count_estimate: Optional[str] = Field(
87
+ default=None, description="Rough headcount range, e.g. '10–50'"
88
+ )
89
+
90
+ # Provenance — every record must carry this for compliance
91
+ source: str = Field(
92
+ default="demo",
93
+ description=(
94
+ "Data source identifier. "
95
+ "'openstreetmap' → ODbL licence applies; "
96
+ "'demo' → synthetic data."
97
+ ),
98
+ )
99
+ attribution: Optional[str] = Field(
100
+ default=None,
101
+ description=(
102
+ "Required attribution string when source is 'openstreetmap'. "
103
+ "Must be displayed wherever data is shown or exported."
104
+ ),
105
+ )
106
+ retrieved_at: datetime = Field(default_factory=datetime.utcnow)
107
+ is_ai_generated: bool = Field(
108
+ default=False,
109
+ description="True when any field value was produced by an AI model",
110
+ )
111
+ data_confidence: Optional[float] = Field(
112
+ default=None,
113
+ ge=0.0,
114
+ le=1.0,
115
+ description="Confidence indicator for data completeness and verification",
116
+ )
117
+ osm_tags: Optional[dict[str, str]] = Field(
118
+ default=None,
119
+ description="Preserved raw OpenStreetMap tags for transparency and auditability",
120
+ )
121
+ ranking_explanation: Optional[str] = Field(
122
+ default=None,
123
+ description="Human-readable explanation of why this business was ranked in this search position",
124
+ )
125
+
126
+ model_config = ConfigDict()
127
+
128
+ @field_serializer("retrieved_at")
129
+ def serialise_retrieved_at(self, v: datetime) -> str:
130
+ return v.isoformat()
131
+
132
+ @property
133
+ def osm_id(self) -> Optional[str]:
134
+ """Return the raw OSM element ID string if this record came from OSM."""
135
+ if self.id.startswith("osm_"):
136
+ return self.id
137
+ return None
138
+
139
+ @property
140
+ def requires_attribution(self) -> bool:
141
+ """True when the ODbL attribution string must be displayed."""
142
+ return self.source == "openstreetmap"
143
+
144
+
145
+ class BusinessSearchResult(BaseModel):
146
+ """Wrapper returned by search_businesses."""
147
+
148
+ query: str
149
+ location: Optional[str] = None
150
+ category: Optional[str] = None
151
+ search_center: Optional[dict[str, float]] = Field(
152
+ default=None,
153
+ description="Resolved search center coordinates {'latitude': lat, 'longitude': lon}",
154
+ )
155
+ radius_meters: Optional[int] = Field(
156
+ default=None,
157
+ description="Search radius in metres applied around search center",
158
+ )
159
+ sort_by: Optional[str] = Field(
160
+ default=None,
161
+ description="Applied ranking criterion (e.g. 'highest_website_opportunity', 'closest')",
162
+ )
163
+ total_found: int = 0
164
+ businesses: list[Business] = Field(default_factory=list)
165
+ source: str = "demo"
166
+ provider: str = "demo"
167
+ retrieved_at: datetime = Field(default_factory=datetime.utcnow)
168
+ note: Optional[str] = Field(
169
+ default=None,
170
+ description="Human-readable note about data quality, limits, or caveats",
171
+ )
172
+ attribution: Optional[str] = Field(
173
+ default=None,
174
+ description="Attribution string — required when source is 'openstreetmap'.",
175
+ )
176
+ error: Optional[str] = Field(
177
+ default=None,
178
+ description="Error details if the search provider encountered an issue",
179
+ )
180
+
181
+ @field_serializer("retrieved_at")
182
+ def serialise_retrieved_at(self, v: datetime) -> str:
183
+ return v.isoformat()
184
+
185
+
186
+ class LeadScore(BaseModel):
187
+ """Lead quality score for a business."""
188
+
189
+ business_id: str
190
+ business_name: str
191
+ score: float = Field(ge=0, le=100, description="0–100 lead quality score")
192
+ grade: str = Field(description="A / B / C / D / F")
193
+ factors: dict[str, float] = Field(
194
+ default_factory=dict,
195
+ description="Breakdown of individual scoring dimensions",
196
+ )
197
+ recommendation: str = Field(default="", description="Short actionable text")
198
+ scored_at: datetime = Field(default_factory=datetime.utcnow)
199
+ is_ai_generated: bool = False
200
+
201
+ @field_serializer("scored_at")
202
+ def serialise_scored_at(self, v: datetime) -> str:
203
+ return v.isoformat()
@@ -0,0 +1,31 @@
1
+ """Business report model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Optional
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from .business import Business, LeadScore
11
+
12
+
13
+ class BusinessReport(BaseModel):
14
+ """Structured intelligence report for a single business."""
15
+
16
+ business: Business
17
+ lead_score: Optional[LeadScore] = None
18
+ summary: str = Field(default="", description="Executive summary")
19
+ strengths: list[str] = Field(default_factory=list)
20
+ weaknesses: list[str] = Field(default_factory=list)
21
+ opportunities: list[str] = Field(default_factory=list)
22
+ threats: list[str] = Field(default_factory=list)
23
+ competitors: list[str] = Field(default_factory=list, description="Competitor names")
24
+ generated_at: datetime = Field(default_factory=datetime.utcnow)
25
+ is_ai_generated: bool = False
26
+ disclaimer: str = Field(
27
+ default=(
28
+ "This report was generated from publicly available data. "
29
+ "Verify all facts independently before making business decisions."
30
+ )
31
+ )
@@ -0,0 +1,51 @@
1
+ """
2
+ Providers package — exports the registry factory and all providers.
3
+
4
+ Usage:
5
+ from leadintel.providers import build_registry
6
+
7
+ registry = build_registry(settings)
8
+ businesses, source = await registry.search(...)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from ..config import Settings
14
+ from .base import BusinessProvider
15
+ from .demo_provider import DemoProvider
16
+ from .overpass_provider import OverpassProvider
17
+ from .registry import ProviderRegistry
18
+
19
+
20
+ def build_registry(cfg: Settings) -> ProviderRegistry:
21
+ """
22
+ Construct the provider registry from application settings.
23
+
24
+ Providers are listed in priority order (first = highest priority).
25
+ DemoProvider is always appended last as a guaranteed fallback.
26
+ """
27
+ providers: list[BusinessProvider] = [] # type: ignore[type-arg]
28
+
29
+ if not cfg.demo_mode:
30
+ # Live providers — ordered by reliability and data quality
31
+ providers.append(
32
+ OverpassProvider(
33
+ endpoint=cfg.overpass_endpoint,
34
+ radius_m=cfg.overpass_radius_m,
35
+ cache_ttl=cfg.cache_ttl,
36
+ )
37
+ )
38
+
39
+ # Demo is always last so the server works with zero configuration
40
+ providers.append(DemoProvider()) # type: ignore[arg-type]
41
+
42
+ return ProviderRegistry(providers)
43
+
44
+
45
+ __all__ = [
46
+ "BusinessProvider",
47
+ "DemoProvider",
48
+ "OverpassProvider",
49
+ "ProviderRegistry",
50
+ "build_registry",
51
+ ]
@@ -0,0 +1,54 @@
1
+ """
2
+ Provider base protocol.
3
+
4
+ Every data provider must implement BusinessProvider. Tools import the
5
+ registry and call `registry.search(...)` — they never import a specific
6
+ provider directly, so swapping or adding providers requires zero tool changes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Protocol, runtime_checkable
12
+
13
+ from ..models.business import Business
14
+
15
+
16
+ @runtime_checkable
17
+ class BusinessProvider(Protocol):
18
+ """Protocol that every LeadIntel data provider must satisfy."""
19
+
20
+ #: Human-readable name shown in tool responses and logs
21
+ name: str
22
+
23
+ #: True when the provider can be used without any API key or auth
24
+ requires_auth: bool
25
+
26
+ async def search(
27
+ self,
28
+ query: str,
29
+ location: str | None = None,
30
+ category: str | None = None,
31
+ limit: int = 10,
32
+ **kwargs: Any,
33
+ ) -> list[Business]:
34
+ """
35
+ Search for businesses.
36
+
37
+ Args:
38
+ query: Free-text keyword. Pass "*" for a broad sweep.
39
+ location: City / region name or "lat,lon" coordinate string.
40
+ category: Industry hint (e.g. "restaurant", "software").
41
+ limit: Max results to return.
42
+
43
+ Returns:
44
+ List of Business instances, possibly empty.
45
+ """
46
+ ...
47
+
48
+ async def get_details(self, business_id: str) -> Business | None:
49
+ """
50
+ Fetch enriched details for a known business ID.
51
+
52
+ Returns None if the ID is unknown to this provider.
53
+ """
54
+ ...
@@ -0,0 +1,65 @@
1
+ """
2
+ DemoProvider — always-available fallback using synthetic fixture data.
3
+
4
+ Returns the same 5 sample businesses as Phase 1. No network required.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from ..data.demo import get_demo_business, search_demo_businesses
12
+ from ..models.business import Business
13
+ from .base import BusinessProvider
14
+
15
+
16
+ class DemoProvider:
17
+ """Synthetic demo data provider. Zero dependencies, always works."""
18
+
19
+ name: str = "demo"
20
+ requires_auth: bool = False
21
+
22
+ def __init__(self) -> None:
23
+ self.last_search_center: dict[str, float] | None = None
24
+ self.last_radius_meters: int | None = None
25
+
26
+ async def search(
27
+ self,
28
+ query: str,
29
+ location: str | None = None,
30
+ category: str | None = None,
31
+ limit: int = 10,
32
+ latitude: float | None = None,
33
+ longitude: float | None = None,
34
+ radius_meters: int | None = None,
35
+ wider_metro: bool = False,
36
+ **kwargs: Any,
37
+ ) -> list[Business]:
38
+ if latitude is not None and longitude is not None:
39
+ self.last_search_center = {"latitude": float(latitude), "longitude": float(longitude)}
40
+ elif location and "pune" in location.lower():
41
+ self.last_search_center = {"latitude": 18.5204, "longitude": 73.8567}
42
+ self.last_radius_meters = (
43
+ (radius_meters if radius_meters is not None else 25_000)
44
+ if wider_metro
45
+ else (radius_meters if radius_meters is not None else 6_000)
46
+ )
47
+
48
+ return search_demo_businesses(
49
+ query=query,
50
+ location=location,
51
+ category=category,
52
+ limit=limit,
53
+ latitude=latitude,
54
+ longitude=longitude,
55
+ radius_meters=radius_meters,
56
+ wider_metro=wider_metro,
57
+ **kwargs,
58
+ )
59
+
60
+ async def get_details(self, business_id: str) -> Business | None:
61
+ return get_demo_business(business_id)
62
+
63
+
64
+ # Satisfy the protocol at import time (structural subtyping check)
65
+ _: BusinessProvider = DemoProvider() # type: ignore[assignment]