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,523 @@
1
+ """
2
+ Website Opportunity Identification & Digital Verification Service.
3
+
4
+ Identifies website opportunities for local businesses:
5
+ - Checks OSM website field.
6
+ - If missing or unverified, performs web research to look for an official website.
7
+ - Verifies whether discovered websites belong to the business (token/name matching).
8
+ - Distinguishes official websites from directories, aggregators, and social profiles.
9
+ - Validates website accessibility and basic performance via safe HTTP clients.
10
+ - Assigns deterministic website_status and website_opportunity_score (0–100 scale).
11
+
12
+ Status States:
13
+ - 'website_verified': Official active website confirmed.
14
+ - 'website_not_found_after_research': No official website found after web research.
15
+ - 'website_unverified': Discovered domain cannot be confirmed as belonging to business.
16
+ - 'website_broken': Website is configured but unreachable or returning error codes.
17
+ - 'website_not_checked': OSM website field is empty and research was not requested.
18
+
19
+ Scoring Scale:
20
+ - No website found after research: 90–100 (default: 95.0)
21
+ - Broken website: 75–89 (default: 80.0)
22
+ - Weak / outdated website: 50–74 (default: 65.0)
23
+ - Significant SEO problems: 30–49 (default: 40.0)
24
+ - Good website: 5–29 (default: 15.0)
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import logging
31
+ import re
32
+ import time
33
+ from typing import Optional
34
+ from urllib.parse import parse_qs, quote, unquote, urlparse
35
+
36
+ import httpx
37
+ from bs4 import BeautifulSoup
38
+
39
+ from ..models.business import Business
40
+ from .url_validator import create_safe_client, validate_and_normalize_url
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ USER_AGENT = "LeadIntel/0.2.0 (Business Intelligence Researcher; open-source; respectful crawler)"
45
+
46
+ # ── Directory & Aggregator Domains ───────────────────────────────────────────
47
+ DIRECTORY_AND_AGGREGATOR_DOMAINS = {
48
+ # Global directories & review portals
49
+ "yelp.com",
50
+ "tripadvisor.com",
51
+ "yellowpages.com",
52
+ "wikipedia.org",
53
+ "crunchbase.com",
54
+ "glassdoor.com",
55
+ "foursquare.com",
56
+ "trustpilot.com",
57
+ "bbb.org",
58
+ "mapquest.com",
59
+ "superpages.com",
60
+ "angis.com",
61
+ "thumbtack.com",
62
+ "manta.com",
63
+ "merchantcircle.com",
64
+ "citysearch.com",
65
+ "ezlocal.com",
66
+ "hotfrog.com",
67
+ # Search engines & map platforms
68
+ "google.com",
69
+ "maps.google.com",
70
+ "bing.com",
71
+ "yahoo.com",
72
+ "duckduckgo.com",
73
+ "baidu.com",
74
+ "yandex.com",
75
+ # Indian directories, food & commercial aggregators (critical for Pune & India)
76
+ "justdial.com",
77
+ "sulekha.com",
78
+ "indiamart.com",
79
+ "tradeindia.com",
80
+ "zomato.com",
81
+ "swiggy.com",
82
+ "magicpin.in",
83
+ "dineout.co.in",
84
+ "lbb.in",
85
+ "eattreat.in",
86
+ "burrp.com",
87
+ "mouthshut.com",
88
+ "urbancompany.com",
89
+ "housing.com",
90
+ "99acres.com",
91
+ "magicbricks.com",
92
+ "quikr.com",
93
+ "olx.in",
94
+ "practo.com",
95
+ "lybrate.com",
96
+ "nearbuy.com",
97
+ "dialme.in",
98
+ "asklaila.com",
99
+ "eatsure.com",
100
+ }
101
+
102
+ # ── Social Media Domains ─────────────────────────────────────────────────────
103
+ SOCIAL_DOMAINS = {
104
+ "facebook.com",
105
+ "fb.com",
106
+ "instagram.com",
107
+ "twitter.com",
108
+ "x.com",
109
+ "linkedin.com",
110
+ "youtube.com",
111
+ "pinterest.com",
112
+ "tiktok.com",
113
+ "threads.net",
114
+ "whatsapp.com",
115
+ "t.me",
116
+ "telegram.org",
117
+ }
118
+
119
+ # In-memory research cache: query -> (candidate_url, timestamp)
120
+ _RESEARCH_CACHE: dict[str, tuple[Optional[str], float]] = {}
121
+ _RESEARCH_CACHE_TTL = 3600.0 # 1 hour
122
+
123
+
124
+ def extract_domain(url: str) -> str:
125
+ """Extract normalized base domain from a URL."""
126
+ try:
127
+ parsed = urlparse(url if "://" in url else f"https://{url}")
128
+ host = (parsed.netloc or "").lower().split(":")[0].strip()
129
+ if host.startswith("www."):
130
+ host = host[4:]
131
+ return host
132
+ except Exception:
133
+ return ""
134
+
135
+
136
+ def is_directory_or_social_domain(url_or_domain: str) -> bool:
137
+ """Check if a URL or domain belongs to a directory, aggregator, or social network."""
138
+ domain = extract_domain(url_or_domain)
139
+ if not domain:
140
+ return False
141
+
142
+ # Check exact match or subdomain
143
+ for d in DIRECTORY_AND_AGGREGATOR_DOMAINS:
144
+ if domain == d or domain.endswith(f".{d}"):
145
+ return True
146
+
147
+ for s in SOCIAL_DOMAINS:
148
+ if domain == s or domain.endswith(f".{s}"):
149
+ return True
150
+
151
+ return False
152
+
153
+
154
+ def is_domain_belonging_to_business(
155
+ domain: str, business_name: str, location: Optional[str] = None
156
+ ) -> bool:
157
+ """
158
+ Check if a discovered domain plausibly belongs to the business.
159
+
160
+ Matches significant tokens from the business name against the domain name.
161
+ """
162
+ if location and domain:
163
+ loc_lower = location.lower()
164
+ # Reject UK domains for India/Pune searches
165
+ if domain.endswith(".co.uk") and not any(x in loc_lower for x in ["uk", "united kingdom", "london"]):
166
+ return False
167
+ # Reject AU domains for India/Pune searches
168
+ if domain.endswith(".com.au") and not any(x in loc_lower for x in ["au", "australia", "sydney"]):
169
+ return False
170
+
171
+ if not domain or not business_name:
172
+ return False
173
+
174
+ # Normalize business name tokens
175
+ name_clean = re.sub(r"[^\w\s]", " ", business_name.lower())
176
+ tokens = [t.strip() for t in name_clean.split() if len(t.strip()) >= 3]
177
+
178
+ # Stopwords to discard
179
+ stopwords = {
180
+ "pvt", "ltd", "limited", "inc", "corp", "corporation", "co", "the",
181
+ "and", "services", "solutions", "enterprise", "group", "company",
182
+ "hotel", "restaurant", "cafe", "bistro", "shop", "store",
183
+ }
184
+ significant_tokens = [t for t in tokens if t not in stopwords]
185
+ if not significant_tokens:
186
+ significant_tokens = tokens # fallback to all tokens if all were stopwords
187
+
188
+ # Clean domain base (e.g. 'vaishalihotel' from 'vaishalihotel.in')
189
+ domain_base = domain.split(".")[0].lower()
190
+
191
+ for token in significant_tokens:
192
+ if token in domain_base or domain_base in token:
193
+ return True
194
+
195
+ # Check whole compacted business name
196
+ compacted_name = "".join(significant_tokens)
197
+ if compacted_name and (compacted_name in domain_base or domain_base in compacted_name):
198
+ return True
199
+
200
+ return False
201
+
202
+
203
+ async def search_official_website(
204
+ business_name: str,
205
+ location: Optional[str] = None,
206
+ ) -> Optional[str]:
207
+ """
208
+ Search for a business's official website using free DuckDuckGo HTML search.
209
+ Caches queries to respect rate limits.
210
+ """
211
+ query = f"{business_name} {location or ''}".strip()
212
+ if not query:
213
+ return None
214
+
215
+ cache_key = query.lower()
216
+ cached = _RESEARCH_CACHE.get(cache_key)
217
+ if cached is not None:
218
+ url, ts = cached
219
+ if time.monotonic() - ts < _RESEARCH_CACHE_TTL:
220
+ return url
221
+
222
+ search_url = f"https://html.duckduckgo.com/html/?q={quote(query)}"
223
+ headers = {
224
+ "User-Agent": USER_AGENT,
225
+ "Accept": "text/html,application/xhtml+xml",
226
+ "Accept-Language": "en-US,en;q=0.9",
227
+ }
228
+
229
+ limits = httpx.Limits(max_keepalive_connections=5, max_connections=10)
230
+ candidate: Optional[str] = None
231
+ try:
232
+ async with httpx.AsyncClient(timeout=5.0, follow_redirects=True, limits=limits) as client:
233
+ resp = await client.get(search_url, headers=headers)
234
+ if resp.status_code == 200:
235
+ content = resp.text
236
+ matches = re.findall(r'href=["\'](/l/\?uddg=[^"\']+)["\']', content)
237
+ for m in matches:
238
+ parsed = parse_qs(urlparse(m).query)
239
+ uddg = parsed.get("uddg")
240
+ if uddg:
241
+ raw_url = unquote(uddg[0])
242
+ domain = extract_domain(raw_url)
243
+ if not domain or is_directory_or_social_domain(domain):
244
+ continue
245
+ if is_domain_belonging_to_business(domain, business_name, location):
246
+ candidate = raw_url
247
+ break
248
+ except Exception as exc:
249
+ logger.debug("Web research search failed for '%s': %s", query, exc)
250
+
251
+ _RESEARCH_CACHE[cache_key] = (candidate, time.monotonic())
252
+ return candidate
253
+
254
+
255
+ async def evaluate_business_website(
256
+ biz: Business,
257
+ location: Optional[str] = None,
258
+ research_missing: bool = True,
259
+ client: Optional[httpx.AsyncClient] = None,
260
+ ) -> Business:
261
+ """
262
+ Diagnose website presence and calculate website_opportunity_score.
263
+
264
+ Modifies and returns the Business instance.
265
+ """
266
+ website_url = biz.website
267
+
268
+ # 1. Check if existing website is actually a directory or social profile
269
+ if website_url:
270
+ if is_directory_or_social_domain(website_url):
271
+ logger.debug(
272
+ "Business '%s' has directory/social URL '%s' as website. Treating as missing official website.",
273
+ biz.name,
274
+ website_url,
275
+ )
276
+ # Store in social profile if applicable
277
+ domain = extract_domain(website_url)
278
+ if "instagram" in domain:
279
+ biz.twitter_url = biz.twitter_url # keep
280
+ elif "facebook" in domain:
281
+ biz.facebook_url = website_url
282
+ elif "linkedin" in domain:
283
+ biz.linkedin_url = website_url
284
+
285
+ biz.website = None
286
+ website_url = None
287
+
288
+ # 2. If website is missing, decide whether to research or mark website_not_checked
289
+ if not website_url:
290
+ if not research_missing:
291
+ biz.website_status = "website_not_checked"
292
+ biz.website_opportunity_score = None
293
+ biz.website_opportunity_explanation = (
294
+ "OSM website field is empty; web research was not requested."
295
+ )
296
+ return biz
297
+
298
+ # Perform research
299
+ found_url = await search_official_website(biz.name, location=location or biz.city)
300
+ if found_url:
301
+ website_url = found_url
302
+ biz.website = found_url
303
+ else:
304
+ biz.website_status = "website_not_found_after_research"
305
+ biz.website_opportunity_score = 95.0
306
+ biz.website_opportunity_explanation = (
307
+ "No official website found after thorough web research across search engines "
308
+ "and business directories. Prime candidate for website development services."
309
+ )
310
+ return biz
311
+
312
+ # 3. We have a candidate website URL — verify and inspect accessibility
313
+ domain = extract_domain(website_url)
314
+ if not is_domain_belonging_to_business(domain, biz.name, location):
315
+ biz.website_status = "website_unverified"
316
+ biz.website_opportunity_score = 55.0
317
+ biz.website_opportunity_explanation = (
318
+ f"Website domain '{domain}' could not be definitively verified as belonging to {biz.name}."
319
+ )
320
+ return biz
321
+
322
+ # Validate URL structure and normalize
323
+ try:
324
+ norm_url = validate_and_normalize_url(website_url)
325
+ except Exception as exc:
326
+ biz.website_status = "website_broken"
327
+ biz.website_opportunity_score = 80.0
328
+ biz.website_opportunity_explanation = f"Website URL is invalid or unsafe: {exc}"
329
+ return biz
330
+
331
+ # Safe HTTP fetch to test accessibility and basic SEO
332
+ should_close = False
333
+ if client is None:
334
+ client = create_safe_client(timeout=6.0)
335
+ should_close = True
336
+
337
+ MAX_RESPONSE_BYTES = 200_000
338
+
339
+ try:
340
+ start_t = time.monotonic()
341
+ async with client.stream(
342
+ "GET",
343
+ norm_url,
344
+ headers={"User-Agent": USER_AGENT, "Accept": "text/html,application/xhtml+xml"},
345
+ ) as resp:
346
+ resp_time = round(time.monotonic() - start_t, 2)
347
+
348
+ if resp.status_code >= 400:
349
+ biz.website_status = "website_broken"
350
+ biz.website_opportunity_score = 80.0
351
+ biz.website_opportunity_explanation = (
352
+ f"Website returned HTTP error {resp.status_code} ({norm_url})."
353
+ )
354
+ return biz
355
+
356
+ chunks: list[bytes] = []
357
+ total_bytes = 0
358
+ async for chunk in resp.aiter_bytes():
359
+ chunks.append(chunk)
360
+ total_bytes += len(chunk)
361
+ if total_bytes >= MAX_RESPONSE_BYTES:
362
+ break
363
+
364
+ raw_bytes = b"".join(chunks)[:MAX_RESPONSE_BYTES]
365
+ html = raw_bytes.decode("utf-8", errors="ignore")
366
+ is_https = str(resp.url).startswith("https://")
367
+
368
+ soup = BeautifulSoup(html, "html.parser")
369
+
370
+ # CMS Footprint Detection
371
+ html_lower = html.lower()
372
+ if "wp-content" in html or "wp-content" in html_lower:
373
+ biz.detected_cms = "WordPress"
374
+ elif "cdn.shopify.com" in html or "cdn.shopify.com" in html_lower:
375
+ biz.detected_cms = "Shopify"
376
+ elif "wix.com" in html or "wixsite" in html or "wix.com" in html_lower or "wixsite" in html_lower:
377
+ biz.detected_cms = "Wix"
378
+ elif "squarespace" in html or "squarespace" in html_lower:
379
+ biz.detected_cms = "Squarespace"
380
+
381
+ # Contact Route Detection
382
+ has_contact = False
383
+ for a in soup.find_all("a", href=True):
384
+ href_val = a.get("href")
385
+ if isinstance(href_val, str):
386
+ href_lower = href_val.lower()
387
+ if "contact" in href_lower or "about" in href_lower or "mailto:" in href_lower:
388
+ has_contact = True
389
+ break
390
+ biz.has_contact_route = has_contact
391
+
392
+ extra_notes: list[str] = []
393
+ if biz.detected_cms:
394
+ extra_notes.append(f"Built on {biz.detected_cms}.")
395
+ if not biz.has_contact_route:
396
+ extra_notes.append("Missing a clear contact page.")
397
+ enrichment = f" {' '.join(extra_notes)}" if extra_notes else ""
398
+
399
+ has_viewport = bool(soup.find("meta", attrs={"name": re.compile(r"^viewport$", re.I)}))
400
+ has_title = bool(soup.find("title") and soup.find("title").get_text(strip=True))
401
+ has_meta_desc = bool(soup.find("meta", attrs={"name": re.compile(r"^description$", re.I)}))
402
+
403
+ # Classify website quality
404
+ if not is_https or not has_viewport:
405
+ biz.website_status = "website_verified"
406
+ biz.website_opportunity_score = 65.0
407
+ issues = []
408
+ if not is_https:
409
+ issues.append("missing HTTPS/SSL security")
410
+ if not has_viewport:
411
+ issues.append("missing mobile viewport configuration")
412
+ biz.website_opportunity_explanation = (
413
+ f"Website is active but outdated ({', '.join(issues)}). "
414
+ f"Good opportunity for website redesign / modernization.{enrichment}"
415
+ )
416
+ elif not has_title or not has_meta_desc or resp_time > 2.5:
417
+ biz.website_status = "website_verified"
418
+ biz.website_opportunity_score = 40.0
419
+ issues = []
420
+ if not has_meta_desc:
421
+ issues.append("missing meta description")
422
+ if not has_title:
423
+ issues.append("missing title tag")
424
+ if resp_time > 2.5:
425
+ issues.append(f"slow response time ({resp_time}s)")
426
+ biz.website_opportunity_explanation = (
427
+ f"Website is modern and mobile-ready, but has technical SEO deficiencies ({', '.join(issues)}).{enrichment}"
428
+ )
429
+ else:
430
+ biz.website_status = "website_verified"
431
+ biz.website_opportunity_score = 15.0
432
+ biz.website_opportunity_explanation = (
433
+ f"Website is fast ({resp_time}s), secured with HTTPS, and mobile-responsive.{enrichment}"
434
+ )
435
+
436
+ except Exception as exc:
437
+ biz.website_status = "website_broken"
438
+ biz.website_opportunity_score = 80.0
439
+ biz.website_opportunity_explanation = f"Website is unreachable or connection failed: {exc}"
440
+ finally:
441
+ if should_close:
442
+ await client.aclose()
443
+
444
+ return biz
445
+
446
+
447
+ async def batch_evaluate_websites(
448
+ businesses: list[Business],
449
+ location: Optional[str] = None,
450
+ research_missing: bool = True,
451
+ max_concurrent: int = 5,
452
+ global_timeout: float = 20.0,
453
+ ) -> list[Business]:
454
+ """
455
+ Concurrently evaluate websites for a list of businesses with limited concurrency
456
+ and a global timeout to protect against MCP tool timeouts.
457
+ """
458
+ if not businesses:
459
+ return []
460
+
461
+ sem = asyncio.Semaphore(max_concurrent)
462
+ client = create_safe_client(timeout=6.0)
463
+
464
+ async def _safe_eval(biz: Business) -> Business:
465
+ async with sem:
466
+ try:
467
+ return await evaluate_business_website(
468
+ biz,
469
+ location=location,
470
+ research_missing=research_missing,
471
+ client=client,
472
+ )
473
+ except Exception as exc:
474
+ logger.warning("Error evaluating website for '%s': %s", biz.name, exc)
475
+ return biz
476
+
477
+ try:
478
+ task_to_biz: dict[asyncio.Task[Business], Business] = {}
479
+ for b in businesses:
480
+ task = asyncio.create_task(_safe_eval(b))
481
+ task_to_biz[task] = b
482
+
483
+ done, pending = await asyncio.wait(
484
+ task_to_biz.keys(),
485
+ timeout=global_timeout,
486
+ )
487
+
488
+ results_by_id: dict[str, Business] = {}
489
+
490
+ for t in done:
491
+ original_biz = task_to_biz[t]
492
+ try:
493
+ if not t.cancelled() and t.exception() is None:
494
+ res_biz = t.result()
495
+ results_by_id[res_biz.id] = res_biz
496
+ else:
497
+ results_by_id[original_biz.id] = original_biz
498
+ except Exception as exc:
499
+ logger.warning("Task execution error for '%s': %s", original_biz.name, exc)
500
+ results_by_id[original_biz.id] = original_biz
501
+
502
+ if pending:
503
+ logger.warning(
504
+ "Global timeout of %.1fs reached during batch website evaluation; cancelling %d pending task(s).",
505
+ global_timeout,
506
+ len(pending),
507
+ )
508
+ for t in pending:
509
+ t.cancel()
510
+ original_biz = task_to_biz[t]
511
+ original_biz.website_status = "website_not_checked"
512
+ original_biz.website_opportunity_score = None
513
+ original_biz.website_opportunity_explanation = (
514
+ "Website research aborted to prevent system timeout."
515
+ )
516
+ results_by_id[original_biz.id] = original_biz
517
+
518
+ await asyncio.gather(*pending, return_exceptions=True)
519
+
520
+ return [results_by_id.get(b.id, b) for b in businesses]
521
+ finally:
522
+ await client.aclose()
523
+
@@ -0,0 +1,12 @@
1
+ """Storage, database persistence, and path validation package."""
2
+
3
+ from .db import get_all_businesses, get_business, init_db, save_businesses
4
+ from .path_validator import validate_safe_export_path
5
+
6
+ __all__ = [
7
+ "get_all_businesses",
8
+ "get_business",
9
+ "init_db",
10
+ "save_businesses",
11
+ "validate_safe_export_path",
12
+ ]