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,293 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Competitor Research Service.
|
|
3
|
+
|
|
4
|
+
Identifies potential competitors using:
|
|
5
|
+
1. OpenStreetMap (Overpass API / provider registry).
|
|
6
|
+
2. Category and industry semantic overlap scoring (distinguishing direct vs indirect competitors).
|
|
7
|
+
3. Digital presence analysis (reusing website_analyzer and seo_auditor).
|
|
8
|
+
4. Explicit confidence scoring and qualification explanations.
|
|
9
|
+
|
|
10
|
+
Compliance & Ethics:
|
|
11
|
+
- Reuses existing OverpassProvider, respects ODbL attribution.
|
|
12
|
+
- Enforces request throttling and SSRF protections.
|
|
13
|
+
- Never fabricates competitor facts or ratings.
|
|
14
|
+
- Transparently distinguishes verified data from estimated competitive relevance.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
from ..config import settings
|
|
23
|
+
from ..models.business import Business
|
|
24
|
+
from ..providers import build_registry
|
|
25
|
+
from .website_analyzer import analyze_target_website
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_registry = build_registry(settings)
|
|
30
|
+
|
|
31
|
+
# High-level category grouping for semantic relatedness
|
|
32
|
+
CATEGORY_GROUPS: dict[str, set[str]] = {
|
|
33
|
+
"food_dining": {
|
|
34
|
+
"restaurant", "cafe", "coffee", "bakery", "bar", "pub", "fast food",
|
|
35
|
+
"food & beverage", "bistro", "diner", "pizzeria"
|
|
36
|
+
},
|
|
37
|
+
"technology": {
|
|
38
|
+
"software development", "technology", "it", "cloud computing",
|
|
39
|
+
"web design", "information technology", "saas", "consulting"
|
|
40
|
+
},
|
|
41
|
+
"marketing": {
|
|
42
|
+
"digital marketing", "marketing", "advertising", "seo",
|
|
43
|
+
"social media", "public relations", "media agency"
|
|
44
|
+
},
|
|
45
|
+
"professional_services": {
|
|
46
|
+
"management consulting", "consulting", "accounting", "legal services",
|
|
47
|
+
"lawyer", "human resources", "real estate", "recruitment"
|
|
48
|
+
},
|
|
49
|
+
"healthcare": {
|
|
50
|
+
"hospital", "clinic", "pharmacy", "dentist", "healthcare",
|
|
51
|
+
"medical", "optician", "wellness"
|
|
52
|
+
},
|
|
53
|
+
"retail": {
|
|
54
|
+
"retail", "supermarket", "grocery", "clothing", "fashion",
|
|
55
|
+
"electronics", "furniture", "store"
|
|
56
|
+
},
|
|
57
|
+
"fitness": {
|
|
58
|
+
"gym", "fitness", "health & fitness", "yoga", "pilates", "sports"
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _tokenize(text: Optional[str]) -> set[str]:
|
|
64
|
+
"""Extract normalized lowercase alphanumeric tokens."""
|
|
65
|
+
if not text:
|
|
66
|
+
return set()
|
|
67
|
+
import re
|
|
68
|
+
tokens = re.findall(r"[a-z0-9]+", text.lower())
|
|
69
|
+
stopwords = {"and", "or", "the", "a", "an", "in", "of", "for", "llc", "inc", "ltd", "corp", "co", "services", "solutions", "group"}
|
|
70
|
+
return {t for t in tokens if t not in stopwords}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def calculate_competitor_relevance(
|
|
74
|
+
target_name: str,
|
|
75
|
+
target_category: Optional[str],
|
|
76
|
+
candidate_name: str,
|
|
77
|
+
candidate_category: Optional[str],
|
|
78
|
+
) -> tuple[float, str, str]:
|
|
79
|
+
"""
|
|
80
|
+
Score competitive relevance between target and candidate business.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
(confidence_score: float (0.0-1.0),
|
|
84
|
+
competitor_type: "direct" | "indirect" | "unrelated",
|
|
85
|
+
qualification_reason: str)
|
|
86
|
+
"""
|
|
87
|
+
# Exclude identical business
|
|
88
|
+
if target_name.strip().lower() == candidate_name.strip().lower():
|
|
89
|
+
return 0.0, "unrelated", "Identical business entity."
|
|
90
|
+
|
|
91
|
+
t_cat_tokens = _tokenize(target_category)
|
|
92
|
+
c_cat_tokens = _tokenize(candidate_category)
|
|
93
|
+
|
|
94
|
+
# 1. Exact or high category overlap
|
|
95
|
+
if t_cat_tokens and c_cat_tokens:
|
|
96
|
+
overlap = t_cat_tokens.intersection(c_cat_tokens)
|
|
97
|
+
if overlap:
|
|
98
|
+
confidence = min(0.95, 0.6 + (0.15 * len(overlap)))
|
|
99
|
+
reason = (
|
|
100
|
+
f"Direct category alignment: shared specialization in "
|
|
101
|
+
f"'{', '.join(sorted(overlap))}'."
|
|
102
|
+
)
|
|
103
|
+
return confidence, "direct", reason
|
|
104
|
+
|
|
105
|
+
# 2. Check cluster / group overlap
|
|
106
|
+
target_groups = set()
|
|
107
|
+
candidate_groups = set()
|
|
108
|
+
t_cat_str = (target_category or "").lower()
|
|
109
|
+
c_cat_str = (candidate_category or "").lower()
|
|
110
|
+
|
|
111
|
+
for group_name, members in CATEGORY_GROUPS.items():
|
|
112
|
+
if any(m in t_cat_str for m in members):
|
|
113
|
+
target_groups.add(group_name)
|
|
114
|
+
if any(m in c_cat_str for m in members):
|
|
115
|
+
candidate_groups.add(group_name)
|
|
116
|
+
|
|
117
|
+
common_groups = target_groups.intersection(candidate_groups)
|
|
118
|
+
if common_groups:
|
|
119
|
+
group_desc = sorted(common_groups)[0].replace("_", " ").title()
|
|
120
|
+
return (
|
|
121
|
+
0.65,
|
|
122
|
+
"indirect",
|
|
123
|
+
f"Indirect competitor in the broader {group_desc} industry, offering related services.",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# 3. Check name tokens overlap if category is absent or inconclusive
|
|
127
|
+
t_name_tokens = _tokenize(target_name)
|
|
128
|
+
c_name_tokens = _tokenize(candidate_name)
|
|
129
|
+
name_overlap = t_name_tokens.intersection(c_name_tokens)
|
|
130
|
+
if name_overlap and (not target_category or not candidate_category):
|
|
131
|
+
return (
|
|
132
|
+
0.50,
|
|
133
|
+
"indirect",
|
|
134
|
+
f"Possible competitor based on name keywords '{', '.join(sorted(name_overlap))}', though categories differ or are unspecified.",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# 4. Fallback: unrelated
|
|
138
|
+
return (
|
|
139
|
+
0.10,
|
|
140
|
+
"unrelated",
|
|
141
|
+
"Different industry focus with no significant service or category overlap.",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def find_industry_competitors(
|
|
146
|
+
business_name: str,
|
|
147
|
+
business_category: Optional[str] = None,
|
|
148
|
+
city: Optional[str] = None,
|
|
149
|
+
state: Optional[str] = None,
|
|
150
|
+
country: Optional[str] = None,
|
|
151
|
+
website: Optional[str] = None,
|
|
152
|
+
max_competitors: int = 5,
|
|
153
|
+
enrich_websites: bool = True,
|
|
154
|
+
) -> dict[str, Any]:
|
|
155
|
+
"""
|
|
156
|
+
Search OpenStreetMap and provider registry for relevant competitors,
|
|
157
|
+
classify them as direct or indirect, and optionally evaluate their digital health.
|
|
158
|
+
"""
|
|
159
|
+
if not business_name or not business_name.strip():
|
|
160
|
+
raise ValueError("business_name must be a non-empty string.")
|
|
161
|
+
|
|
162
|
+
max_competitors = max(1, min(max_competitors, 20))
|
|
163
|
+
|
|
164
|
+
# Construct geographic location search string
|
|
165
|
+
location_parts = [p for p in (city, state, country) if p and p.strip()]
|
|
166
|
+
location_str = ", ".join(location_parts) if location_parts else None
|
|
167
|
+
|
|
168
|
+
# Search query: prioritize category if given, otherwise business name keywords
|
|
169
|
+
search_term = business_category if (business_category and business_category.strip()) else business_name
|
|
170
|
+
|
|
171
|
+
candidates: list[Business] = []
|
|
172
|
+
source_provider = "demo"
|
|
173
|
+
|
|
174
|
+
# Query provider registry (Overpass / OSM first, fallback to demo)
|
|
175
|
+
try:
|
|
176
|
+
raw_results, source_provider = await _registry.search(
|
|
177
|
+
query=search_term,
|
|
178
|
+
location=location_str,
|
|
179
|
+
category=business_category,
|
|
180
|
+
limit=max_competitors * 4, # Overfetch to filter unrelated & self
|
|
181
|
+
)
|
|
182
|
+
candidates.extend(raw_results)
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
logger.warning("Provider registry search failed during competitor lookup: %s", exc)
|
|
185
|
+
|
|
186
|
+
# If category search was empty, retry with general category keyword or location
|
|
187
|
+
if not candidates and business_category and location_str:
|
|
188
|
+
try:
|
|
189
|
+
raw_results, source_provider = await _registry.search(
|
|
190
|
+
query="*",
|
|
191
|
+
location=location_str,
|
|
192
|
+
category=business_category,
|
|
193
|
+
limit=max_competitors * 4,
|
|
194
|
+
)
|
|
195
|
+
candidates.extend(raw_results)
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
# Score and classify candidates
|
|
200
|
+
scored_candidates: list[dict[str, Any]] = []
|
|
201
|
+
seen_identifiers: set[str] = {business_name.strip().lower()}
|
|
202
|
+
|
|
203
|
+
for cand in candidates:
|
|
204
|
+
cand_key = cand.name.strip().lower()
|
|
205
|
+
if cand_key in seen_identifiers:
|
|
206
|
+
continue
|
|
207
|
+
seen_identifiers.add(cand_key)
|
|
208
|
+
|
|
209
|
+
confidence, comp_type, reason = calculate_competitor_relevance(
|
|
210
|
+
target_name=business_name,
|
|
211
|
+
target_category=business_category,
|
|
212
|
+
candidate_name=cand.name,
|
|
213
|
+
candidate_category=cand.category,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Filter out unrelated businesses
|
|
217
|
+
if comp_type == "unrelated":
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
scored_candidates.append({
|
|
221
|
+
"business": cand,
|
|
222
|
+
"competitor_type": comp_type,
|
|
223
|
+
"confidence_score": round(confidence, 2),
|
|
224
|
+
"qualification_reason": reason,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
# Sort candidates by relevance: direct before indirect, then confidence desc
|
|
228
|
+
scored_candidates.sort(
|
|
229
|
+
key=lambda x: (1 if x["competitor_type"] == "direct" else 0, x["confidence_score"]),
|
|
230
|
+
reverse=True,
|
|
231
|
+
)
|
|
232
|
+
selected = scored_candidates[:max_competitors]
|
|
233
|
+
|
|
234
|
+
# Optionally enrich digital presence of top competitors
|
|
235
|
+
enriched_competitors: list[dict[str, Any]] = []
|
|
236
|
+
for entry in selected:
|
|
237
|
+
cand_biz: Business = entry["business"]
|
|
238
|
+
digital_profile: dict[str, Any] = {
|
|
239
|
+
"has_website": bool(cand_biz.website),
|
|
240
|
+
"website": cand_biz.website,
|
|
241
|
+
"has_phone": bool(cand_biz.phone),
|
|
242
|
+
"has_email": bool(cand_biz.email),
|
|
243
|
+
"rating": cand_biz.rating,
|
|
244
|
+
"review_count": cand_biz.review_count,
|
|
245
|
+
"social_links_count": sum(1 for val in (cand_biz.linkedin_url, cand_biz.twitter_url, cand_biz.facebook_url) if val),
|
|
246
|
+
"website_status": "not_analyzed",
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# Analyze website if available and requested
|
|
250
|
+
if enrich_websites and cand_biz.website:
|
|
251
|
+
try:
|
|
252
|
+
web_res = await analyze_target_website(cand_biz.website, check_broken_links=False)
|
|
253
|
+
digital_profile["website_status"] = web_res.get("status", "error")
|
|
254
|
+
digital_profile["website_available"] = web_res.get("available", False)
|
|
255
|
+
digital_profile["response_time_seconds"] = web_res.get("response_time_seconds")
|
|
256
|
+
digital_profile["has_mobile_viewport"] = web_res.get("metadata", {}).get("has_mobile_viewport", False)
|
|
257
|
+
digital_profile["social_profiles_found"] = sum(
|
|
258
|
+
len(p) for p in web_res.get("social_profiles", {}).values()
|
|
259
|
+
)
|
|
260
|
+
except Exception as e:
|
|
261
|
+
digital_profile["website_status"] = "error"
|
|
262
|
+
digital_profile["website_error"] = str(e)
|
|
263
|
+
|
|
264
|
+
enriched_competitors.append({
|
|
265
|
+
"id": cand_biz.id,
|
|
266
|
+
"name": cand_biz.name,
|
|
267
|
+
"category": cand_biz.category,
|
|
268
|
+
"address": cand_biz.address,
|
|
269
|
+
"city": cand_biz.city,
|
|
270
|
+
"state": cand_biz.state,
|
|
271
|
+
"country": cand_biz.country,
|
|
272
|
+
"competitor_type": entry["competitor_type"],
|
|
273
|
+
"confidence_score": entry["confidence_score"],
|
|
274
|
+
"qualification_reason": entry["qualification_reason"],
|
|
275
|
+
"digital_presence": digital_profile,
|
|
276
|
+
"source": cand_biz.source,
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
"target_business": {
|
|
281
|
+
"name": business_name,
|
|
282
|
+
"category": business_category,
|
|
283
|
+
"location": location_str,
|
|
284
|
+
"website": website,
|
|
285
|
+
},
|
|
286
|
+
"total_competitors_found": len(enriched_competitors),
|
|
287
|
+
"competitors": enriched_competitors,
|
|
288
|
+
"provider": source_provider,
|
|
289
|
+
"note": (
|
|
290
|
+
"Competitors identified via industry taxonomy and geographical relevance. "
|
|
291
|
+
"ODbL attribution applies for OpenStreetMap data."
|
|
292
|
+
),
|
|
293
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lead Scoring Service.
|
|
3
|
+
|
|
4
|
+
Deterministic, evidence-based lead qualification engine tailored for digital agencies.
|
|
5
|
+
Evaluates the digital maturity and addressable gaps of a business without relying on paid APIs or AI tokens.
|
|
6
|
+
|
|
7
|
+
Core Philosophy:
|
|
8
|
+
1. Max 100 points composite score.
|
|
9
|
+
2. Missing data is penalized via an Evidence Completeness & Confidence metric, NOT arbitrary negative scores.
|
|
10
|
+
3. Distinguishes verified defects (e.g. broken links, missing H1, HTTP without SSL) from absent data.
|
|
11
|
+
4. Generates opportunity scores for specific agency services:
|
|
12
|
+
- Website Development Opportunity
|
|
13
|
+
- SEO Improvement Opportunity
|
|
14
|
+
- Social Media Management Opportunity
|
|
15
|
+
- Digital Marketing Opportunity
|
|
16
|
+
5. Does not fabricate claims of commercial budget or guaranteed conversion.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from typing import Any, Optional
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Category weights totaling 100 points
|
|
27
|
+
CATEGORY_WEIGHTS = {
|
|
28
|
+
"website_quality": 20.0,
|
|
29
|
+
"technical_seo": 20.0,
|
|
30
|
+
"social_media_presence": 15.0,
|
|
31
|
+
"contact_accessibility": 10.0,
|
|
32
|
+
"mobile_experience": 10.0,
|
|
33
|
+
"digital_presence_gaps": 15.0,
|
|
34
|
+
"business_relevance": 10.0,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def score_business_lead(
|
|
39
|
+
business_name: str,
|
|
40
|
+
business_category: Optional[str] = None,
|
|
41
|
+
website_analysis: Optional[dict[str, Any]] = None,
|
|
42
|
+
seo_audit: Optional[dict[str, Any]] = None,
|
|
43
|
+
social_profiles: Optional[dict[str, Any]] = None,
|
|
44
|
+
competitor_comparison: Optional[dict[str, Any]] = None,
|
|
45
|
+
contact_info: Optional[dict[str, Any]] = None,
|
|
46
|
+
weights: Optional[dict[str, float]] = None,
|
|
47
|
+
) -> dict[str, Any]:
|
|
48
|
+
"""
|
|
49
|
+
Score a business lead deterministically across 7 categories.
|
|
50
|
+
|
|
51
|
+
Returns structured output containing overall score, priority tier, category breakdown,
|
|
52
|
+
evidence completeness, verified issues, and service package recommendations.
|
|
53
|
+
"""
|
|
54
|
+
if not business_name or not isinstance(business_name, str) or not business_name.strip():
|
|
55
|
+
raise ValueError("business_name must be a non-empty string.")
|
|
56
|
+
|
|
57
|
+
w = dict(CATEGORY_WEIGHTS)
|
|
58
|
+
if weights:
|
|
59
|
+
w.update(weights)
|
|
60
|
+
|
|
61
|
+
verified_issues: list[str] = []
|
|
62
|
+
missing_information: list[str] = []
|
|
63
|
+
evidence_points = 0.0
|
|
64
|
+
total_evidence_possible = 6.0 # Tracking presence of our 6 evidence vectors
|
|
65
|
+
|
|
66
|
+
# -------------------------------------------------------------
|
|
67
|
+
# 1. Track Evidence Completeness
|
|
68
|
+
# -------------------------------------------------------------
|
|
69
|
+
if business_category and business_category.strip():
|
|
70
|
+
evidence_points += 1.0
|
|
71
|
+
else:
|
|
72
|
+
missing_information.append("Business category not specified.")
|
|
73
|
+
|
|
74
|
+
if website_analysis is not None:
|
|
75
|
+
evidence_points += 1.0
|
|
76
|
+
else:
|
|
77
|
+
missing_information.append("Website technical analysis data absent.")
|
|
78
|
+
|
|
79
|
+
if seo_audit is not None:
|
|
80
|
+
evidence_points += 1.0
|
|
81
|
+
else:
|
|
82
|
+
missing_information.append("Technical SEO audit data absent.")
|
|
83
|
+
|
|
84
|
+
if social_profiles is not None:
|
|
85
|
+
evidence_points += 1.0
|
|
86
|
+
else:
|
|
87
|
+
missing_information.append("Social media profile scan absent.")
|
|
88
|
+
|
|
89
|
+
if contact_info is not None:
|
|
90
|
+
evidence_points += 1.0
|
|
91
|
+
else:
|
|
92
|
+
missing_information.append("Direct contact directory data absent.")
|
|
93
|
+
|
|
94
|
+
if competitor_comparison is not None:
|
|
95
|
+
evidence_points += 1.0
|
|
96
|
+
else:
|
|
97
|
+
missing_information.append("Competitor benchmark comparison absent.")
|
|
98
|
+
|
|
99
|
+
evidence_completeness_pct = round((evidence_points / total_evidence_possible) * 100, 1)
|
|
100
|
+
|
|
101
|
+
if evidence_completeness_pct >= 80:
|
|
102
|
+
confidence_level = "High"
|
|
103
|
+
elif evidence_completeness_pct >= 50:
|
|
104
|
+
confidence_level = "Medium"
|
|
105
|
+
else:
|
|
106
|
+
confidence_level = "Low"
|
|
107
|
+
|
|
108
|
+
# -------------------------------------------------------------
|
|
109
|
+
# 2. Category-wise Scoring (Max 100 points)
|
|
110
|
+
# -------------------------------------------------------------
|
|
111
|
+
cat_scores: dict[str, float] = {}
|
|
112
|
+
|
|
113
|
+
# Category 1: Website Quality (20 pts)
|
|
114
|
+
# Criteria: Available, HTTPS secure, reasonable response time (< 1.5s), content structure.
|
|
115
|
+
# Note: If no website, agency prospect opportunity for web dev is high, but raw website quality score is low.
|
|
116
|
+
wq_score = 0.0
|
|
117
|
+
if website_analysis:
|
|
118
|
+
if website_analysis.get("available"):
|
|
119
|
+
wq_score += 8.0 # Base availability
|
|
120
|
+
protocol = website_analysis.get("protocol", {})
|
|
121
|
+
if protocol.get("is_https") and protocol.get("ssl_valid"):
|
|
122
|
+
wq_score += 4.0
|
|
123
|
+
else:
|
|
124
|
+
verified_issues.append("Website lacks SSL certificate or is not secured over HTTPS.")
|
|
125
|
+
|
|
126
|
+
resp_time = website_analysis.get("response_time_seconds")
|
|
127
|
+
if resp_time is not None:
|
|
128
|
+
if resp_time < 1.0:
|
|
129
|
+
wq_score += 4.0
|
|
130
|
+
elif resp_time < 2.5:
|
|
131
|
+
wq_score += 2.0
|
|
132
|
+
else:
|
|
133
|
+
verified_issues.append(f"Slow initial page response time ({resp_time}s).")
|
|
134
|
+
|
|
135
|
+
content = website_analysis.get("content_structure", {})
|
|
136
|
+
if content.get("h1_headings"):
|
|
137
|
+
wq_score += 2.0
|
|
138
|
+
if content.get("images_missing_alt", 0) == 0 and content.get("images_total", 0) > 0:
|
|
139
|
+
wq_score += 2.0
|
|
140
|
+
else:
|
|
141
|
+
verified_issues.append(f"Website is currently inaccessible: {website_analysis.get('error', 'Unreachable')}")
|
|
142
|
+
else:
|
|
143
|
+
# Neutral baseline if information absent
|
|
144
|
+
wq_score = 10.0
|
|
145
|
+
cat_scores["website_quality"] = min(w["website_quality"], round(wq_score, 1))
|
|
146
|
+
|
|
147
|
+
# Category 2: Technical SEO (20 pts)
|
|
148
|
+
# Directly normalized from the 0-100 deterministic SEO audit
|
|
149
|
+
t_seo_score = 0.0
|
|
150
|
+
if seo_audit and "seo_score" in seo_audit:
|
|
151
|
+
raw_seo = float(seo_audit.get("seo_score", 0))
|
|
152
|
+
t_seo_score = (raw_seo / 100.0) * w["technical_seo"]
|
|
153
|
+
# Extract verified issues from audit
|
|
154
|
+
for iss in seo_audit.get("issues", []):
|
|
155
|
+
if iss.get("level") in ("error", "warning"):
|
|
156
|
+
verified_issues.append(f"SEO: {iss.get('message')}")
|
|
157
|
+
else:
|
|
158
|
+
t_seo_score = 10.0 # Neutral baseline
|
|
159
|
+
cat_scores["technical_seo"] = min(w["technical_seo"], round(t_seo_score, 1))
|
|
160
|
+
|
|
161
|
+
# Category 3: Social Media Presence (15 pts)
|
|
162
|
+
# Criteria: Active channels across LinkedIn, Facebook, Instagram, Twitter/X, YouTube.
|
|
163
|
+
smp_score = 0.0
|
|
164
|
+
total_social_found = 0
|
|
165
|
+
if social_profiles:
|
|
166
|
+
profiles_dict = social_profiles.get("profiles", social_profiles)
|
|
167
|
+
active_channels = []
|
|
168
|
+
for plat, links in profiles_dict.items():
|
|
169
|
+
if isinstance(links, list) and len(links) > 0:
|
|
170
|
+
active_channels.append(plat)
|
|
171
|
+
total_social_found = len(active_channels)
|
|
172
|
+
|
|
173
|
+
if total_social_found >= 4:
|
|
174
|
+
smp_score = 15.0
|
|
175
|
+
elif total_social_found == 3:
|
|
176
|
+
smp_score = 12.0
|
|
177
|
+
elif total_social_found == 2:
|
|
178
|
+
smp_score = 8.0
|
|
179
|
+
elif total_social_found == 1:
|
|
180
|
+
smp_score = 4.0
|
|
181
|
+
else:
|
|
182
|
+
verified_issues.append("No active social media profiles detected on the company domain.")
|
|
183
|
+
else:
|
|
184
|
+
smp_score = 7.5 # Neutral baseline
|
|
185
|
+
cat_scores["social_media_presence"] = min(w["social_media_presence"], round(smp_score, 1))
|
|
186
|
+
|
|
187
|
+
# Category 4: Contact Accessibility (10 pts)
|
|
188
|
+
# Criteria: Direct email, telephone, and interactive contact form.
|
|
189
|
+
ca_score = 0.0
|
|
190
|
+
has_form = False
|
|
191
|
+
has_email = False
|
|
192
|
+
has_phone = False
|
|
193
|
+
|
|
194
|
+
if website_analysis and "contact_detection" in website_analysis:
|
|
195
|
+
cd = website_analysis["contact_detection"]
|
|
196
|
+
has_form = cd.get("has_contact_form", False)
|
|
197
|
+
has_email = len(cd.get("emails_found", [])) > 0
|
|
198
|
+
has_phone = len(cd.get("phones_found", [])) > 0
|
|
199
|
+
|
|
200
|
+
if contact_info:
|
|
201
|
+
if contact_info.get("email"):
|
|
202
|
+
has_email = True
|
|
203
|
+
if contact_info.get("phone"):
|
|
204
|
+
has_phone = True
|
|
205
|
+
|
|
206
|
+
if has_form:
|
|
207
|
+
ca_score += 4.0
|
|
208
|
+
else:
|
|
209
|
+
verified_issues.append("No digital contact or inquiry form detected.")
|
|
210
|
+
|
|
211
|
+
if has_email:
|
|
212
|
+
ca_score += 3.0
|
|
213
|
+
else:
|
|
214
|
+
verified_issues.append("No public contact email address detected.")
|
|
215
|
+
|
|
216
|
+
if has_phone:
|
|
217
|
+
ca_score += 3.0
|
|
218
|
+
else:
|
|
219
|
+
verified_issues.append("No direct business phone number detected.")
|
|
220
|
+
|
|
221
|
+
cat_scores["contact_accessibility"] = min(w["contact_accessibility"], round(ca_score, 1))
|
|
222
|
+
|
|
223
|
+
# Category 5: Mobile Experience (10 pts)
|
|
224
|
+
# Criteria: Explicit viewport meta tag configuration for mobile responsiveness.
|
|
225
|
+
me_score = 0.0
|
|
226
|
+
if website_analysis and "metadata" in website_analysis:
|
|
227
|
+
if website_analysis["metadata"].get("has_mobile_viewport"):
|
|
228
|
+
me_score = 10.0
|
|
229
|
+
else:
|
|
230
|
+
verified_issues.append("Website lacks mobile viewport configuration (poor mobile responsiveness).")
|
|
231
|
+
else:
|
|
232
|
+
me_score = 5.0 # Neutral baseline
|
|
233
|
+
cat_scores["mobile_experience"] = min(w["mobile_experience"], round(me_score, 1))
|
|
234
|
+
|
|
235
|
+
# Category 6: Digital Presence Gaps (15 pts)
|
|
236
|
+
# Measures agency prospecting leverage: companies with identifiable gaps or lagging competitors
|
|
237
|
+
# are better suited for digital agency intervention.
|
|
238
|
+
dpg_score = 0.0
|
|
239
|
+
if competitor_comparison and "comparison_matrix" in competitor_comparison:
|
|
240
|
+
matrix = competitor_comparison.get("comparison_matrix", [])
|
|
241
|
+
if len(matrix) > 1:
|
|
242
|
+
target_row = next((r for r in matrix if r.get("role") == "Target"), None)
|
|
243
|
+
comp_rows = [r for r in matrix if r.get("role") == "Competitor"]
|
|
244
|
+
if target_row and comp_rows:
|
|
245
|
+
target_seo = target_row.get("seo_score", 0)
|
|
246
|
+
comp_seos = [r.get("seo_score", 0) for r in comp_rows if r.get("seo_score", 0) > 0]
|
|
247
|
+
avg_comp_seo = (sum(comp_seos) / len(comp_seos)) if comp_seos else 50
|
|
248
|
+
if target_seo < avg_comp_seo:
|
|
249
|
+
dpg_score += 8.0 # Identifiable gap vs peers
|
|
250
|
+
verified_issues.append(f"Digital performance lags behind local competitor average by {round(avg_comp_seo - target_seo, 1)} SEO points.")
|
|
251
|
+
else:
|
|
252
|
+
dpg_score += 4.0
|
|
253
|
+
else:
|
|
254
|
+
dpg_score += 7.5
|
|
255
|
+
else:
|
|
256
|
+
dpg_score += 7.5
|
|
257
|
+
|
|
258
|
+
# If website is missing or inaccessible, the gap is high
|
|
259
|
+
if website_analysis and not website_analysis.get("available"):
|
|
260
|
+
dpg_score = max(dpg_score, 12.0)
|
|
261
|
+
|
|
262
|
+
cat_scores["digital_presence_gaps"] = min(w["digital_presence_gaps"], round(dpg_score, 1))
|
|
263
|
+
|
|
264
|
+
# Category 7: Business Relevance (10 pts)
|
|
265
|
+
# Criteria: Clear industry classification and identifiable location/address.
|
|
266
|
+
br_score = 5.0
|
|
267
|
+
if business_category and business_category.strip():
|
|
268
|
+
br_score += 3.0
|
|
269
|
+
if contact_info and (contact_info.get("address") or contact_info.get("city")):
|
|
270
|
+
br_score += 2.0
|
|
271
|
+
cat_scores["business_relevance"] = min(w["business_relevance"], round(br_score, 1))
|
|
272
|
+
|
|
273
|
+
# Total Overall Score
|
|
274
|
+
raw_total = sum(cat_scores.values())
|
|
275
|
+
overall_score = round(max(0.0, min(100.0, raw_total)), 1)
|
|
276
|
+
|
|
277
|
+
# Priority Classification
|
|
278
|
+
if overall_score >= 80.0:
|
|
279
|
+
priority_classification = "High Priority"
|
|
280
|
+
elif overall_score >= 60.0:
|
|
281
|
+
priority_classification = "Good Prospect"
|
|
282
|
+
elif overall_score >= 40.0:
|
|
283
|
+
priority_classification = "Medium Priority"
|
|
284
|
+
else:
|
|
285
|
+
priority_classification = "Low Priority"
|
|
286
|
+
|
|
287
|
+
# -------------------------------------------------------------
|
|
288
|
+
# 3. Opportunity Scoring (0-100 each for agency services)
|
|
289
|
+
# -------------------------------------------------------------
|
|
290
|
+
# Website Development: High opportunity if website is missing, slow, not mobile-ready, or insecure
|
|
291
|
+
web_dev_opp = 0.0
|
|
292
|
+
if not website_analysis or not website_analysis.get("available"):
|
|
293
|
+
web_dev_opp = 95.0
|
|
294
|
+
else:
|
|
295
|
+
if not website_analysis.get("metadata", {}).get("has_mobile_viewport"):
|
|
296
|
+
web_dev_opp += 35.0
|
|
297
|
+
if not website_analysis.get("protocol", {}).get("is_https"):
|
|
298
|
+
web_dev_opp += 25.0
|
|
299
|
+
if not website_analysis.get("contact_detection", {}).get("has_contact_form"):
|
|
300
|
+
web_dev_opp += 20.0
|
|
301
|
+
if (website_analysis.get("response_time_seconds") or 0) > 2.0:
|
|
302
|
+
web_dev_opp += 20.0
|
|
303
|
+
web_dev_opp = min(100.0, web_dev_opp)
|
|
304
|
+
|
|
305
|
+
# SEO Improvement: Inversely related to on-page SEO score
|
|
306
|
+
seo_opp = 0.0
|
|
307
|
+
if seo_audit and "seo_score" in seo_audit:
|
|
308
|
+
seo_opp = max(0.0, 100.0 - float(seo_audit["seo_score"]))
|
|
309
|
+
else:
|
|
310
|
+
seo_opp = 50.0 # Moderate default opportunity if uninspected
|
|
311
|
+
|
|
312
|
+
# Social Media Management: High opportunity if social presence is sparse
|
|
313
|
+
if total_social_found == 0:
|
|
314
|
+
social_opp = 90.0
|
|
315
|
+
elif total_social_found == 1:
|
|
316
|
+
social_opp = 70.0
|
|
317
|
+
elif total_social_found == 2:
|
|
318
|
+
social_opp = 40.0
|
|
319
|
+
else:
|
|
320
|
+
social_opp = 15.0
|
|
321
|
+
|
|
322
|
+
# Digital Marketing: Opportunity combining contact funnels and competitor gaps
|
|
323
|
+
marketing_opp = 0.0
|
|
324
|
+
if not has_form:
|
|
325
|
+
marketing_opp += 30.0
|
|
326
|
+
if total_social_found < 2:
|
|
327
|
+
marketing_opp += 30.0
|
|
328
|
+
if seo_opp > 40.0:
|
|
329
|
+
marketing_opp += 40.0
|
|
330
|
+
marketing_opp = min(100.0, marketing_opp)
|
|
331
|
+
|
|
332
|
+
service_opportunities = {
|
|
333
|
+
"website_development": round(web_dev_opp, 1),
|
|
334
|
+
"seo_services": round(seo_opp, 1),
|
|
335
|
+
"social_media_management": round(social_opp, 1),
|
|
336
|
+
"digital_marketing": round(marketing_opp, 1),
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
# -------------------------------------------------------------
|
|
340
|
+
# 4. Recommended Service Package Selection
|
|
341
|
+
# -------------------------------------------------------------
|
|
342
|
+
# If website is missing or completely unavailable, website development is the foundational prerequisite
|
|
343
|
+
if web_dev_opp >= 90.0:
|
|
344
|
+
recommended_package = "Website Development"
|
|
345
|
+
else:
|
|
346
|
+
high_opps = [k for k, v in service_opportunities.items() if v >= 60.0]
|
|
347
|
+
if len(high_opps) >= 3:
|
|
348
|
+
recommended_package = "Combined Digital Growth Package"
|
|
349
|
+
elif web_dev_opp >= 70.0:
|
|
350
|
+
recommended_package = "Website Development"
|
|
351
|
+
elif seo_opp >= 70.0:
|
|
352
|
+
recommended_package = "SEO Services"
|
|
353
|
+
elif social_opp >= 70.0:
|
|
354
|
+
recommended_package = "Social Media Management"
|
|
355
|
+
elif marketing_opp >= 60.0:
|
|
356
|
+
recommended_package = "Digital Marketing"
|
|
357
|
+
else:
|
|
358
|
+
recommended_package = "Combined Digital Growth Package"
|
|
359
|
+
|
|
360
|
+
# Explanation text
|
|
361
|
+
explanation = (
|
|
362
|
+
f"{business_name} scored {overall_score}/100 with a {confidence_level} confidence rating "
|
|
363
|
+
f"({evidence_completeness_pct}% evidence completeness). "
|
|
364
|
+
f"Ranked as a {priority_classification} for digital service offerings. "
|
|
365
|
+
f"Identified {len(verified_issues)} verified areas for improvement, with primary growth potential in "
|
|
366
|
+
f"{recommended_package}."
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
"business_name": business_name,
|
|
371
|
+
"overall_lead_score": overall_score,
|
|
372
|
+
"priority_classification": priority_classification,
|
|
373
|
+
"evidence_completeness_percentage": evidence_completeness_pct,
|
|
374
|
+
"confidence_level": confidence_level,
|
|
375
|
+
"category_scores": cat_scores,
|
|
376
|
+
"verified_issues": verified_issues,
|
|
377
|
+
"missing_information": missing_information,
|
|
378
|
+
"service_opportunities": service_opportunities,
|
|
379
|
+
"recommended_service_package": recommended_package,
|
|
380
|
+
"explanation": explanation,
|
|
381
|
+
"disclaimer": (
|
|
382
|
+
"Lead scoring is deterministic and reflects observable digital gaps. "
|
|
383
|
+
"It does not guarantee commercial budget, purchasing authority, or intent to buy."
|
|
384
|
+
),
|
|
385
|
+
}
|