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
|
@@ -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
|
+
]
|