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
leadintel/storage/db.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLite database storage for LeadIntel.
|
|
3
|
+
|
|
4
|
+
Persists researched businesses across searches and server sessions.
|
|
5
|
+
Uses standard library sqlite3 without external dependencies.
|
|
6
|
+
Stores the database safely in the user's home directory.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import sqlite3
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from ..models.business import Business
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
DB_DIR = Path.home() / ".local" / "share" / "leadintel"
|
|
22
|
+
DB_PATH = DB_DIR / "leadintel.db"
|
|
23
|
+
DEFAULT_DB_PATH = str(DB_PATH)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _resolve_path(db_path: Optional[str | Path] = None) -> str:
|
|
27
|
+
"""Resolve database path, honoring test overrides or parameters."""
|
|
28
|
+
if db_path:
|
|
29
|
+
return str(db_path)
|
|
30
|
+
if DEFAULT_DB_PATH != str(DB_PATH):
|
|
31
|
+
return str(DEFAULT_DB_PATH)
|
|
32
|
+
return str(DB_PATH)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_connection(db_path: Optional[str | Path] = None) -> sqlite3.Connection:
|
|
36
|
+
"""Open a SQLite connection and configure row factory."""
|
|
37
|
+
target_path = _resolve_path(db_path)
|
|
38
|
+
if target_path != ":memory:":
|
|
39
|
+
parent = Path(target_path).parent
|
|
40
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
|
|
42
|
+
conn = sqlite3.connect(target_path)
|
|
43
|
+
conn.row_factory = sqlite3.Row
|
|
44
|
+
return conn
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def init_db(db_path: Optional[str | Path] = None) -> None:
|
|
48
|
+
"""Initialize the SQLite database schema if not already created."""
|
|
49
|
+
try:
|
|
50
|
+
DB_DIR.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
target = _resolve_path(db_path)
|
|
52
|
+
conn = _get_connection(target)
|
|
53
|
+
try:
|
|
54
|
+
with conn:
|
|
55
|
+
conn.execute(
|
|
56
|
+
"""
|
|
57
|
+
CREATE TABLE IF NOT EXISTS businesses (
|
|
58
|
+
id TEXT PRIMARY KEY,
|
|
59
|
+
model_json TEXT,
|
|
60
|
+
updated_at DATETIME
|
|
61
|
+
)
|
|
62
|
+
"""
|
|
63
|
+
)
|
|
64
|
+
logger.debug("Database initialized at %s", target)
|
|
65
|
+
finally:
|
|
66
|
+
conn.close()
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logger.error(f"Failed to initialize SQLite database: {e}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def save_businesses(
|
|
72
|
+
businesses: list[Business], db_path: Optional[str | Path] = None
|
|
73
|
+
) -> int:
|
|
74
|
+
"""
|
|
75
|
+
Upsert businesses into the database.
|
|
76
|
+
|
|
77
|
+
Stores the serialized business JSON and current UTC timestamp.
|
|
78
|
+
Returns the count of businesses saved.
|
|
79
|
+
"""
|
|
80
|
+
if not businesses:
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
init_db(db_path)
|
|
85
|
+
target = _resolve_path(db_path)
|
|
86
|
+
conn = _get_connection(target)
|
|
87
|
+
count = 0
|
|
88
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
89
|
+
try:
|
|
90
|
+
with conn:
|
|
91
|
+
for b in businesses:
|
|
92
|
+
if not b.id:
|
|
93
|
+
continue
|
|
94
|
+
model_json = b.model_dump_json()
|
|
95
|
+
conn.execute(
|
|
96
|
+
"""
|
|
97
|
+
INSERT INTO businesses (id, model_json, updated_at)
|
|
98
|
+
VALUES (?, ?, ?)
|
|
99
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
100
|
+
model_json = excluded.model_json,
|
|
101
|
+
updated_at = excluded.updated_at
|
|
102
|
+
""",
|
|
103
|
+
(b.id, model_json, now_iso),
|
|
104
|
+
)
|
|
105
|
+
count += 1
|
|
106
|
+
logger.debug("Saved %d businesses to database", count)
|
|
107
|
+
return count
|
|
108
|
+
finally:
|
|
109
|
+
conn.close()
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.error(f"Failed to save businesses to database: {e}")
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_business(business_id: str, db_path: Optional[str | Path] = None) -> Optional[Business]:
|
|
116
|
+
"""
|
|
117
|
+
Retrieve and deserialize a business from SQLite by ID.
|
|
118
|
+
|
|
119
|
+
Returns the Business model if found, None otherwise.
|
|
120
|
+
"""
|
|
121
|
+
if not business_id:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
init_db(db_path)
|
|
126
|
+
target = _resolve_path(db_path)
|
|
127
|
+
conn = _get_connection(target)
|
|
128
|
+
try:
|
|
129
|
+
cursor = conn.cursor()
|
|
130
|
+
cursor.execute(
|
|
131
|
+
"SELECT model_json FROM businesses WHERE id = ?",
|
|
132
|
+
(business_id,),
|
|
133
|
+
)
|
|
134
|
+
row = cursor.fetchone()
|
|
135
|
+
if not row or not row["model_json"]:
|
|
136
|
+
return None
|
|
137
|
+
return Business.model_validate_json(row["model_json"])
|
|
138
|
+
finally:
|
|
139
|
+
conn.close()
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.error(f"Error fetching business {business_id}: {e}")
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def get_all_businesses(
|
|
146
|
+
min_score: Optional[float] = None,
|
|
147
|
+
limit: int = 50,
|
|
148
|
+
db_path: Optional[str | Path] = None,
|
|
149
|
+
) -> list[Business]:
|
|
150
|
+
"""
|
|
151
|
+
Retrieve saved businesses from SQLite, optionally filtered by minimum opportunity score.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
min_score: Optional minimum website_opportunity_score threshold.
|
|
155
|
+
limit: Maximum number of businesses to return.
|
|
156
|
+
db_path: Optional database path override (e.g. for testing).
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
List of matching Business objects ordered by updated_at descending.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
init_db(db_path)
|
|
163
|
+
target = _resolve_path(db_path)
|
|
164
|
+
conn = _get_connection(target)
|
|
165
|
+
results: list[Business] = []
|
|
166
|
+
try:
|
|
167
|
+
cursor = conn.cursor()
|
|
168
|
+
cursor.execute("SELECT model_json FROM businesses ORDER BY updated_at DESC")
|
|
169
|
+
rows = cursor.fetchall()
|
|
170
|
+
for row in rows:
|
|
171
|
+
if not row or not row["model_json"]:
|
|
172
|
+
continue
|
|
173
|
+
try:
|
|
174
|
+
biz = Business.model_validate_json(row["model_json"])
|
|
175
|
+
if min_score is not None:
|
|
176
|
+
opp_score = biz.website_opportunity_score
|
|
177
|
+
if opp_score is None or opp_score < min_score:
|
|
178
|
+
continue
|
|
179
|
+
results.append(biz)
|
|
180
|
+
if len(results) >= limit:
|
|
181
|
+
break
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
logger.debug("Skipping unparseable business record: %s", exc)
|
|
184
|
+
return results
|
|
185
|
+
finally:
|
|
186
|
+
conn.close()
|
|
187
|
+
except Exception as e:
|
|
188
|
+
logger.error(f"Failed to retrieve businesses from database: {e}")
|
|
189
|
+
return []
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Path Traversal and File Security Validator.
|
|
3
|
+
|
|
4
|
+
Validates destination file paths for exporters (Excel, CSV, JSON):
|
|
5
|
+
- Prevents directory traversal attacks ('..', null bytes).
|
|
6
|
+
- Blocks writes to system root and critical directories (/etc, /bin, /sbin, /usr, /root, /sys, /proc, /System, /Library, Windows System32, etc.).
|
|
7
|
+
- Blocks writes to sensitive user directories (.ssh, .gnupg, .aws, .config, shell rc files).
|
|
8
|
+
- Enforces allowed file extensions (.xlsx, .csv, .json).
|
|
9
|
+
- Canonicalizes and verifies destination paths cross-platform.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from ..services.url_validator import SecurityValidationError
|
|
18
|
+
|
|
19
|
+
# System and user directories strictly protected from export writes
|
|
20
|
+
BLOCKED_PATH_PREFIXES = (
|
|
21
|
+
"/etc",
|
|
22
|
+
"/bin",
|
|
23
|
+
"/sbin",
|
|
24
|
+
"/usr",
|
|
25
|
+
"/root",
|
|
26
|
+
"/dev",
|
|
27
|
+
"/sys",
|
|
28
|
+
"/proc",
|
|
29
|
+
"/System",
|
|
30
|
+
"/Library",
|
|
31
|
+
"/private/etc",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
BLOCKED_WINDOWS_PREFIXES = (
|
|
35
|
+
"c:/windows",
|
|
36
|
+
"c:/program files",
|
|
37
|
+
"c:/program files (x86)",
|
|
38
|
+
"c:/programdata",
|
|
39
|
+
"d:/windows",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
BLOCKED_USER_TARGETS = (
|
|
43
|
+
".ssh",
|
|
44
|
+
".gnupg",
|
|
45
|
+
".aws",
|
|
46
|
+
".config",
|
|
47
|
+
".bashrc",
|
|
48
|
+
".bash_profile",
|
|
49
|
+
".zshrc",
|
|
50
|
+
".zprofile",
|
|
51
|
+
".profile",
|
|
52
|
+
"id_rsa",
|
|
53
|
+
"id_ed25519",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_safe_export_path(
|
|
58
|
+
path: str,
|
|
59
|
+
allowed_extensions: Optional[set[str]] = None,
|
|
60
|
+
) -> str:
|
|
61
|
+
"""
|
|
62
|
+
Validate and return an absolute path for safe file exports.
|
|
63
|
+
|
|
64
|
+
Raises SecurityValidationError or ValueError if path is invalid or attempts traversal.
|
|
65
|
+
"""
|
|
66
|
+
if not path or not isinstance(path, str):
|
|
67
|
+
raise ValueError("Export path must be a non-empty string.")
|
|
68
|
+
|
|
69
|
+
if "\0" in path:
|
|
70
|
+
raise SecurityValidationError("Null byte detected in file path.")
|
|
71
|
+
|
|
72
|
+
# Check raw path for cross-platform Windows system paths (regardless of host OS)
|
|
73
|
+
p_norm = path.replace("\\", "/").lower().strip()
|
|
74
|
+
for win_prefix in BLOCKED_WINDOWS_PREFIXES:
|
|
75
|
+
if p_norm == win_prefix or p_norm.startswith(win_prefix + "/"):
|
|
76
|
+
raise SecurityValidationError(
|
|
77
|
+
f"Export path '{path}' resolves to protected Windows system location '{win_prefix}'."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Prevent UNC network share paths (e.g. \\server\share or //server/share)
|
|
81
|
+
if p_norm.startswith("//"):
|
|
82
|
+
raise SecurityValidationError("UNC network paths are not permitted for exports.")
|
|
83
|
+
|
|
84
|
+
# Enforce allowed extension if specified
|
|
85
|
+
if allowed_extensions:
|
|
86
|
+
_, ext = os.path.splitext(path)
|
|
87
|
+
ext_clean = ext.lower().lstrip(".")
|
|
88
|
+
allowed_clean = {e.lower().lstrip(".") for e in allowed_extensions}
|
|
89
|
+
if ext_clean not in allowed_clean:
|
|
90
|
+
raise SecurityValidationError(
|
|
91
|
+
f"Invalid file extension '{ext}'. Allowed extensions: {sorted(allowed_clean)}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
abs_path = os.path.abspath(path)
|
|
95
|
+
real_path = os.path.realpath(abs_path)
|
|
96
|
+
|
|
97
|
+
# Check blocked system path prefixes across both real_path and abs_path
|
|
98
|
+
for prefix in BLOCKED_PATH_PREFIXES:
|
|
99
|
+
if real_path == prefix or real_path.startswith(prefix + os.sep) or abs_path == prefix or abs_path.startswith(prefix + os.sep):
|
|
100
|
+
raise SecurityValidationError(
|
|
101
|
+
f"Export path '{path}' resolves to protected system directory '{prefix}'."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Check sensitive user targets across both real_path and abs_path
|
|
105
|
+
home = os.path.expanduser("~")
|
|
106
|
+
for sensitive in BLOCKED_USER_TARGETS:
|
|
107
|
+
sensitive_full = os.path.join(home, sensitive)
|
|
108
|
+
if (
|
|
109
|
+
real_path == sensitive_full
|
|
110
|
+
or real_path.startswith(sensitive_full + os.sep)
|
|
111
|
+
or abs_path == sensitive_full
|
|
112
|
+
or abs_path.startswith(sensitive_full + os.sep)
|
|
113
|
+
):
|
|
114
|
+
raise SecurityValidationError(
|
|
115
|
+
f"Export path '{path}' resolves to protected user location '{sensitive}'."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return abs_path
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""MCP tools package — registers all tools on the shared MCPServer instance."""
|
|
2
|
+
|
|
3
|
+
from mcp.server.mcpserver import MCPServer
|
|
4
|
+
|
|
5
|
+
from . import (
|
|
6
|
+
analyze_website,
|
|
7
|
+
check_seo,
|
|
8
|
+
compare_businesses,
|
|
9
|
+
export_leads,
|
|
10
|
+
find_business_website,
|
|
11
|
+
find_competitors,
|
|
12
|
+
find_social_profiles,
|
|
13
|
+
generate_business_insights,
|
|
14
|
+
generate_business_report,
|
|
15
|
+
generate_outreach,
|
|
16
|
+
get_business_details,
|
|
17
|
+
get_saved_leads,
|
|
18
|
+
score_lead,
|
|
19
|
+
search_businesses,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def register_all(mcp: MCPServer) -> None:
|
|
24
|
+
"""Register every tool on the provided MCP server."""
|
|
25
|
+
search_businesses.register(mcp)
|
|
26
|
+
get_business_details.register(mcp)
|
|
27
|
+
get_saved_leads.register(mcp)
|
|
28
|
+
find_business_website.register(mcp)
|
|
29
|
+
analyze_website.register(mcp)
|
|
30
|
+
check_seo.register(mcp)
|
|
31
|
+
find_social_profiles.register(mcp)
|
|
32
|
+
find_competitors.register(mcp)
|
|
33
|
+
compare_businesses.register(mcp)
|
|
34
|
+
score_lead.register(mcp)
|
|
35
|
+
generate_business_insights.register(mcp)
|
|
36
|
+
generate_outreach.register(mcp)
|
|
37
|
+
generate_business_report.register(mcp)
|
|
38
|
+
export_leads.register(mcp)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Tool: analyze_website
|
|
3
|
+
|
|
4
|
+
Comprehensive diagnostic tool for evaluating website availability,
|
|
5
|
+
mobile readiness, HTML structure, contact channels, and broken internal links.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from mcp.server.mcpserver import MCPServer
|
|
13
|
+
|
|
14
|
+
from ..services.website_analyzer import analyze_target_website
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register(mcp: MCPServer) -> None:
|
|
18
|
+
"""Register analyze_website tool on the MCP server."""
|
|
19
|
+
|
|
20
|
+
@mcp.tool()
|
|
21
|
+
async def analyze_website(
|
|
22
|
+
url: str,
|
|
23
|
+
check_broken_links: bool = True,
|
|
24
|
+
) -> dict[str, Any]:
|
|
25
|
+
"""
|
|
26
|
+
Analyze a business website for technical readiness, structure, and contacts.
|
|
27
|
+
|
|
28
|
+
Performs non-invasive examination of the target website:
|
|
29
|
+
- HTTP/HTTPS status and SSL verification.
|
|
30
|
+
- Title, meta description, and mobile viewport configuration.
|
|
31
|
+
- H1/H2 heading hierarchy and image alt tag presence.
|
|
32
|
+
- Public email addresses, telephone numbers, and contact forms.
|
|
33
|
+
- Linked social media profiles.
|
|
34
|
+
- Sampled check for broken internal links.
|
|
35
|
+
|
|
36
|
+
Security & Compliance:
|
|
37
|
+
- Respects robots.txt directives before scraping.
|
|
38
|
+
- Protects against SSRF (blocks localhost, private networks, cloud metadata).
|
|
39
|
+
- Enforces request timeouts and max page size limits.
|
|
40
|
+
- Does not scrape private or sensitive personal info.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
url: The target website URL (e.g. 'https://example.com').
|
|
44
|
+
check_broken_links: If True, spot-checks up to 10 internal links for broken status (default: True).
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Structured diagnostic dictionary.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
return await analyze_target_website(url=url, check_broken_links=check_broken_links)
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
return {
|
|
53
|
+
"status": "error",
|
|
54
|
+
"url": url,
|
|
55
|
+
"available": False,
|
|
56
|
+
"error": str(exc),
|
|
57
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Tool: check_seo
|
|
3
|
+
|
|
4
|
+
Custom, free Python SEO audit tool evaluating on-page metadata,
|
|
5
|
+
headings, robots.txt, sitemaps, open graph, and link architecture.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from mcp.server.mcpserver import MCPServer
|
|
13
|
+
|
|
14
|
+
from ..services.seo_auditor import run_full_seo_audit
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register(mcp: MCPServer) -> None:
|
|
18
|
+
"""Register check_seo tool on the MCP server."""
|
|
19
|
+
|
|
20
|
+
@mcp.tool()
|
|
21
|
+
async def check_seo(
|
|
22
|
+
url: str,
|
|
23
|
+
) -> dict[str, Any]:
|
|
24
|
+
"""
|
|
25
|
+
Run a technical on-page SEO audit on a business website.
|
|
26
|
+
|
|
27
|
+
Evaluates fundamental search engine optimization signals:
|
|
28
|
+
- Missing or duplicate title tags and length bounds.
|
|
29
|
+
- Missing or suboptimal meta descriptions.
|
|
30
|
+
- Missing H1 or multiple H1 heading tags.
|
|
31
|
+
- Missing image alt attributes and overall image alt coverage percentage.
|
|
32
|
+
- Canonical URL link specification.
|
|
33
|
+
- Meta robots directives (noindex/nofollow).
|
|
34
|
+
- Availability of robots.txt and sitemap.xml.
|
|
35
|
+
- Open Graph tags for social preview readiness.
|
|
36
|
+
- Internal link count and page weight.
|
|
37
|
+
|
|
38
|
+
Output:
|
|
39
|
+
- SEO score from 0 to 100.
|
|
40
|
+
- Detailed checklist breakdown.
|
|
41
|
+
- Specific prioritized issues (errors, warnings, info).
|
|
42
|
+
- Actionable recommendations.
|
|
43
|
+
|
|
44
|
+
Notice:
|
|
45
|
+
This is an on-page technical SEO diagnostic tool and not an official
|
|
46
|
+
Google ranking assessment or proprietary PageRank score.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
url: The target website URL (e.g. 'https://example.com').
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Structured SEO audit report.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
return await run_full_seo_audit(url=url)
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
return {
|
|
58
|
+
"seo_score": 0,
|
|
59
|
+
"url": url,
|
|
60
|
+
"error": str(exc),
|
|
61
|
+
"disclaimer": "This technical audit requires a reachable, valid website URL.",
|
|
62
|
+
"issues": [{"level": "error", "message": f"Could not perform audit: {exc}"}],
|
|
63
|
+
"recommendations": ["Verify website is publicly accessible and URL is correct."],
|
|
64
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Tool: compare_businesses
|
|
3
|
+
|
|
4
|
+
Benchmark a target business against one or more competitors, generating
|
|
5
|
+
a side-by-side comparison matrix, strengths, weaknesses, and digital opportunities.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from mcp.server.mcpserver import MCPServer
|
|
13
|
+
|
|
14
|
+
from ..services.business_comparison import compare_target_against_competitors
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register(mcp: MCPServer) -> None:
|
|
18
|
+
"""Register compare_businesses tool on the MCP server."""
|
|
19
|
+
|
|
20
|
+
@mcp.tool()
|
|
21
|
+
async def compare_businesses(
|
|
22
|
+
target_business: dict[str, Any],
|
|
23
|
+
competitor_businesses: list[dict[str, Any]],
|
|
24
|
+
) -> dict[str, Any]:
|
|
25
|
+
"""
|
|
26
|
+
Compare a target business against a list of competitor businesses.
|
|
27
|
+
|
|
28
|
+
Benchmarks organizations across critical digital and operational dimensions:
|
|
29
|
+
- Website availability & response time
|
|
30
|
+
- Technical SEO score (0-100)
|
|
31
|
+
- HTTPS/SSL security configuration
|
|
32
|
+
- Mobile viewport readiness
|
|
33
|
+
- Social media footprint (channels active)
|
|
34
|
+
- Contact accessibility (forms, direct email, direct phone)
|
|
35
|
+
- Public reviews and ratings where available
|
|
36
|
+
|
|
37
|
+
Outputs:
|
|
38
|
+
- Structured comparison table matrix
|
|
39
|
+
- High-confidence strengths
|
|
40
|
+
- Critical digital weaknesses
|
|
41
|
+
- High-leverage growth opportunities
|
|
42
|
+
- Executive competitive positioning summary
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
target_business: Dict representing target company. Must include 'name', optional 'website', 'category', 'city'.
|
|
46
|
+
competitor_businesses: List of dicts representing competitor companies to benchmark against.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Comprehensive competitive intelligence comparison report.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
return await compare_target_against_competitors(
|
|
53
|
+
target_business=target_business,
|
|
54
|
+
competitors=competitor_businesses,
|
|
55
|
+
)
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
return {
|
|
58
|
+
"error": str(exc),
|
|
59
|
+
"target_business": target_business.get("name") if isinstance(target_business, dict) else str(target_business),
|
|
60
|
+
"comparison_matrix": [],
|
|
61
|
+
"strengths": [],
|
|
62
|
+
"weaknesses": [],
|
|
63
|
+
"opportunities": [],
|
|
64
|
+
"competitive_positioning_summary": f"Comparison failed: {exc}",
|
|
65
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""MCP tool: export_leads — search + export to CSV, JSON, or Excel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Literal, Optional
|
|
10
|
+
|
|
11
|
+
from mcp.server.mcpserver import MCPServer
|
|
12
|
+
|
|
13
|
+
from ..config import settings
|
|
14
|
+
from ..exporters.csv_exporter import export_to_csv
|
|
15
|
+
from ..exporters.excel_exporter import export_to_excel
|
|
16
|
+
from ..providers import build_registry
|
|
17
|
+
from ..storage.path_validator import validate_safe_export_path
|
|
18
|
+
|
|
19
|
+
OSM_ATTRIBUTION = "Data © OpenStreetMap contributors, ODbL 1.0 — https://www.openstreetmap.org/copyright"
|
|
20
|
+
|
|
21
|
+
_registry = build_registry(settings)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register(mcp: MCPServer) -> None:
|
|
25
|
+
"""Register export_leads tool on the MCP server."""
|
|
26
|
+
|
|
27
|
+
@mcp.tool()
|
|
28
|
+
async def export_leads(
|
|
29
|
+
query: str,
|
|
30
|
+
location: Optional[str] = None,
|
|
31
|
+
category: Optional[str] = None,
|
|
32
|
+
limit: int = 50,
|
|
33
|
+
format: Literal["csv", "json", "excel", "xlsx"] = "csv",
|
|
34
|
+
output_path: Optional[str] = None,
|
|
35
|
+
) -> dict:
|
|
36
|
+
"""
|
|
37
|
+
Search for businesses and export results to an Excel (.xlsx), CSV, or JSON file.
|
|
38
|
+
|
|
39
|
+
Features:
|
|
40
|
+
- Excel: Creates a multi-sheet workbook with frozen headers, auto-filters, and clean styling.
|
|
41
|
+
- CSV: Standard tabular spreadsheet export.
|
|
42
|
+
- JSON: Complete structured lead metadata with timestamps and attribution.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
query: Free-text search term (e.g. 'cafe', 'software').
|
|
46
|
+
location: City, region, or coordinates.
|
|
47
|
+
category: Industry or category filter.
|
|
48
|
+
limit: Max number of leads to export (1-200, default 50).
|
|
49
|
+
format: 'excel' (or 'xlsx'), 'csv' (default), or 'json'.
|
|
50
|
+
output_path: Destination file path (optional).
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Dict containing output_path, row_count, format, and data attribution.
|
|
54
|
+
"""
|
|
55
|
+
limit = max(1, min(limit, 200))
|
|
56
|
+
|
|
57
|
+
businesses, provider_name = await _registry.search(
|
|
58
|
+
query=query,
|
|
59
|
+
location=location,
|
|
60
|
+
category=category,
|
|
61
|
+
limit=limit,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
is_osm = provider_name == "openstreetmap"
|
|
65
|
+
fmt = format.lower()
|
|
66
|
+
if fmt in ("excel", "xlsx"):
|
|
67
|
+
ext = "xlsx"
|
|
68
|
+
elif fmt == "json":
|
|
69
|
+
ext = "json"
|
|
70
|
+
else:
|
|
71
|
+
ext = "csv"
|
|
72
|
+
|
|
73
|
+
if not output_path:
|
|
74
|
+
ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
|
75
|
+
fname = f"leadintel_export_{ts}.{ext}"
|
|
76
|
+
output_path = os.path.join(tempfile.gettempdir(), fname)
|
|
77
|
+
|
|
78
|
+
# Execute export
|
|
79
|
+
if ext == "xlsx":
|
|
80
|
+
abs_path = export_to_excel(businesses=businesses, output_path=output_path)
|
|
81
|
+
elif ext == "json":
|
|
82
|
+
abs_path = _write_json(businesses, output_path, provider_name)
|
|
83
|
+
else:
|
|
84
|
+
abs_path = export_to_csv(businesses, output_path=output_path)
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
"output_path": abs_path,
|
|
88
|
+
"row_count": len(businesses),
|
|
89
|
+
"format": ext,
|
|
90
|
+
"provider": provider_name,
|
|
91
|
+
"attribution": OSM_ATTRIBUTION if is_osm else None,
|
|
92
|
+
"note": (
|
|
93
|
+
f"Exported {len(businesses)} leads to {ext.upper()}. "
|
|
94
|
+
+ (f"Attribution: {OSM_ATTRIBUTION}" if is_osm else "")
|
|
95
|
+
).strip(),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _write_json(businesses, output_path: str, provider_name: str) -> str:
|
|
100
|
+
safe_path = validate_safe_export_path(output_path, allowed_extensions={"json"})
|
|
101
|
+
os.makedirs(os.path.dirname(safe_path) or ".", exist_ok=True)
|
|
102
|
+
payload = {
|
|
103
|
+
"leadintel_export": True,
|
|
104
|
+
"provider": provider_name,
|
|
105
|
+
"attribution": OSM_ATTRIBUTION if provider_name == "openstreetmap" else None,
|
|
106
|
+
"exported_at": datetime.utcnow().isoformat(),
|
|
107
|
+
"count": len(businesses),
|
|
108
|
+
"businesses": [b.model_dump(mode="json") for b in businesses],
|
|
109
|
+
}
|
|
110
|
+
with open(safe_path, "w", encoding="utf-8") as f:
|
|
111
|
+
json.dump(payload, f, indent=2, ensure_ascii=False)
|
|
112
|
+
return safe_path
|