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,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local AI Service via Ollama.
|
|
3
|
+
|
|
4
|
+
Communicates with locally running Ollama instances (default http://localhost:11434).
|
|
5
|
+
Strictly local-first:
|
|
6
|
+
- Enforces local loopback interface (localhost / 127.0.0.1 / ::1). Rejects external/cloud hosts.
|
|
7
|
+
- Bypasses ambient environment proxies (trust_env=False) to prevent data exfiltration.
|
|
8
|
+
- Rejects HTTP redirects (follow_redirects=False).
|
|
9
|
+
- Never sends business data to external/cloud services.
|
|
10
|
+
- Never falls back to cloud AI.
|
|
11
|
+
- Handles Ollama offline, uninstalled, or missing model gracefully.
|
|
12
|
+
- Does not download models automatically.
|
|
13
|
+
- Enforces request timeouts and JSON structured responses.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Any, Optional
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from ..config import settings
|
|
25
|
+
from .url_validator import SecurityValidationError
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
DEFAULT_TIMEOUT = 30.0
|
|
30
|
+
|
|
31
|
+
LOCAL_LOOPBACK_HOSTS = {"localhost", "localhost.localdomain", "127.0.0.1", "::1", "0.0.0.0"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class OllamaError(Exception):
|
|
35
|
+
"""Base exception for Ollama interactions."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OllamaUnavailableError(OllamaError):
|
|
39
|
+
"""Raised when the Ollama service is unreachable or not running."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OllamaModelNotFoundError(OllamaError):
|
|
43
|
+
"""Raised when the specified model is not loaded in Ollama."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LocalAIClient:
|
|
47
|
+
"""HTTP client interacting strictly with locally running Ollama API."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
base_url: Optional[str] = None,
|
|
52
|
+
model: Optional[str] = None,
|
|
53
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
54
|
+
) -> None:
|
|
55
|
+
raw_url = (base_url or settings.ollama_base_url).rstrip("/")
|
|
56
|
+
parsed = urlparse(raw_url)
|
|
57
|
+
host = (parsed.hostname or "").lower().strip("[]")
|
|
58
|
+
|
|
59
|
+
# Guarantee local-first privacy: reject external cloud or remote endpoints
|
|
60
|
+
if host and host not in LOCAL_LOOPBACK_HOSTS:
|
|
61
|
+
raise SecurityValidationError(
|
|
62
|
+
f"Ollama endpoint '{raw_url}' is not a local loopback address. "
|
|
63
|
+
"LeadIntel strictly enforces that Ollama must run on the local machine "
|
|
64
|
+
"(localhost / 127.0.0.1 / ::1) to ensure local-first privacy and prevent data exfiltration."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
self.base_url = raw_url
|
|
68
|
+
self.model = model or settings.ollama_model
|
|
69
|
+
self.timeout = timeout
|
|
70
|
+
|
|
71
|
+
async def is_available(self, client: Optional[httpx.AsyncClient] = None) -> bool:
|
|
72
|
+
"""Check if local Ollama server is running and reachable."""
|
|
73
|
+
endpoint = f"{self.base_url}/api/tags"
|
|
74
|
+
should_close = False
|
|
75
|
+
if client is None:
|
|
76
|
+
client = httpx.AsyncClient(timeout=3.0, trust_env=False, follow_redirects=False)
|
|
77
|
+
should_close = True
|
|
78
|
+
try:
|
|
79
|
+
resp = await client.get(endpoint)
|
|
80
|
+
return resp.status_code == 200
|
|
81
|
+
except Exception:
|
|
82
|
+
return False
|
|
83
|
+
finally:
|
|
84
|
+
if should_close:
|
|
85
|
+
await client.aclose()
|
|
86
|
+
|
|
87
|
+
async def list_models(self, client: Optional[httpx.AsyncClient] = None) -> list[str]:
|
|
88
|
+
"""List locally available models in Ollama."""
|
|
89
|
+
endpoint = f"{self.base_url}/api/tags"
|
|
90
|
+
should_close = False
|
|
91
|
+
if client is None:
|
|
92
|
+
client = httpx.AsyncClient(timeout=5.0, trust_env=False, follow_redirects=False)
|
|
93
|
+
should_close = True
|
|
94
|
+
try:
|
|
95
|
+
resp = await client.get(endpoint)
|
|
96
|
+
if resp.status_code != 200:
|
|
97
|
+
raise OllamaUnavailableError(f"Ollama returned HTTP {resp.status_code}")
|
|
98
|
+
data = resp.json()
|
|
99
|
+
models = [m.get("name", "") for m in data.get("models", [])]
|
|
100
|
+
return [m for m in models if m]
|
|
101
|
+
except (httpx.ConnectError, httpx.ConnectTimeout) as e:
|
|
102
|
+
raise OllamaUnavailableError(
|
|
103
|
+
f"Local Ollama server is not running at {self.base_url}. Start it with 'ollama serve'."
|
|
104
|
+
) from e
|
|
105
|
+
except Exception as e:
|
|
106
|
+
if isinstance(e, OllamaError):
|
|
107
|
+
raise
|
|
108
|
+
raise OllamaError(f"Failed to query Ollama models: {e}") from e
|
|
109
|
+
finally:
|
|
110
|
+
if should_close:
|
|
111
|
+
await client.aclose()
|
|
112
|
+
|
|
113
|
+
async def check_model_installed(self, model_name: Optional[str] = None, client: Optional[httpx.AsyncClient] = None) -> bool:
|
|
114
|
+
"""Check if a specific model or the default model is installed locally."""
|
|
115
|
+
target = model_name or self.model
|
|
116
|
+
available = await self.list_models(client=client)
|
|
117
|
+
# Match exact name or without tag (e.g. 'llama3:latest' matches 'llama3')
|
|
118
|
+
return any(m == target or m.split(":")[0] == target for m in available)
|
|
119
|
+
|
|
120
|
+
async def generate(
|
|
121
|
+
self,
|
|
122
|
+
prompt: str,
|
|
123
|
+
system_prompt: Optional[str] = None,
|
|
124
|
+
model: Optional[str] = None,
|
|
125
|
+
format_json: bool = False,
|
|
126
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
127
|
+
) -> str:
|
|
128
|
+
"""
|
|
129
|
+
Generate completion from local Ollama model.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
prompt: User prompt.
|
|
133
|
+
system_prompt: Optional system instructions.
|
|
134
|
+
model: Specific model override.
|
|
135
|
+
format_json: If True, request JSON formatting from Ollama.
|
|
136
|
+
client: Optional HTTPX async client.
|
|
137
|
+
"""
|
|
138
|
+
target_model = model or self.model
|
|
139
|
+
endpoint = f"{self.base_url}/api/generate"
|
|
140
|
+
|
|
141
|
+
payload: dict[str, Any] = {
|
|
142
|
+
"model": target_model,
|
|
143
|
+
"prompt": prompt,
|
|
144
|
+
"stream": False,
|
|
145
|
+
}
|
|
146
|
+
if system_prompt:
|
|
147
|
+
payload["system"] = system_prompt
|
|
148
|
+
if format_json:
|
|
149
|
+
payload["format"] = "json"
|
|
150
|
+
|
|
151
|
+
should_close = False
|
|
152
|
+
if client is None:
|
|
153
|
+
# Enforce trust_env=False to prevent proxy interception, and follow_redirects=False
|
|
154
|
+
client = httpx.AsyncClient(timeout=self.timeout, trust_env=False, follow_redirects=False)
|
|
155
|
+
should_close = True
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
resp = await client.post(endpoint, json=payload)
|
|
159
|
+
if resp.status_code == 404:
|
|
160
|
+
raise OllamaModelNotFoundError(
|
|
161
|
+
f"Model '{target_model}' not found in local Ollama. Install with 'ollama pull {target_model}'."
|
|
162
|
+
)
|
|
163
|
+
if resp.status_code != 200:
|
|
164
|
+
raise OllamaError(f"Ollama returned HTTP {resp.status_code}: {resp.text}")
|
|
165
|
+
|
|
166
|
+
data = resp.json()
|
|
167
|
+
response_text = data.get("response", "")
|
|
168
|
+
return response_text.strip()
|
|
169
|
+
|
|
170
|
+
except (httpx.ConnectError, httpx.ConnectTimeout) as e:
|
|
171
|
+
raise OllamaUnavailableError(
|
|
172
|
+
f"Local Ollama server is not running at {self.base_url}. Start it with 'ollama serve'."
|
|
173
|
+
) from e
|
|
174
|
+
except httpx.ReadTimeout as e:
|
|
175
|
+
raise OllamaError(f"Ollama request timed out after {self.timeout}s.") from e
|
|
176
|
+
except Exception as e:
|
|
177
|
+
if isinstance(e, OllamaError):
|
|
178
|
+
raise
|
|
179
|
+
raise OllamaError(f"Ollama inference error: {e}") from e
|
|
180
|
+
finally:
|
|
181
|
+
if should_close:
|
|
182
|
+
await client.aclose()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# Default singleton instance
|
|
186
|
+
local_ai_client = LocalAIClient()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Robots.txt Parser & Checker Service.
|
|
3
|
+
|
|
4
|
+
Checks robots.txt policies using standard python urllib.robotparser,
|
|
5
|
+
with respectful timeouts, caching, and User-Agent matching.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import urllib.robotparser
|
|
12
|
+
from typing import Optional
|
|
13
|
+
from urllib.parse import urljoin, urlparse
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .url_validator import SecurityValidationError, create_safe_client, validate_and_normalize_url
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
DEFAULT_USER_AGENT = "LeadIntel/0.1 (Business Intelligence Researcher; +https://github.com/example/leadintel; respectful crawler)"
|
|
22
|
+
_ROBOTS_CACHE: dict[str, tuple[float, urllib.robotparser.RobotFileParser]] = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RobotsChecker:
|
|
26
|
+
"""Manages fetching, parsing, and caching robots.txt for websites."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, user_agent: str = DEFAULT_USER_AGENT, timeout: float = 10.0):
|
|
29
|
+
self.user_agent = user_agent
|
|
30
|
+
self.timeout = timeout
|
|
31
|
+
|
|
32
|
+
async def can_fetch(self, target_url: str, client: Optional[httpx.AsyncClient] = None) -> tuple[bool, Optional[str]]:
|
|
33
|
+
"""
|
|
34
|
+
Check if target_url can be crawled according to the site's robots.txt.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
(allowed: bool, reason_or_robots_url: Optional[str])
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
norm_url = validate_and_normalize_url(target_url)
|
|
41
|
+
except SecurityValidationError as e:
|
|
42
|
+
return False, f"Blocked by URL security validation: {e}"
|
|
43
|
+
except Exception as e:
|
|
44
|
+
return False, f"Invalid URL: {e}"
|
|
45
|
+
|
|
46
|
+
parsed = urlparse(norm_url)
|
|
47
|
+
base_origin = f"{parsed.scheme}://{parsed.netloc}"
|
|
48
|
+
robots_url = urljoin(base_origin, "/robots.txt")
|
|
49
|
+
|
|
50
|
+
rp = urllib.robotparser.RobotFileParser()
|
|
51
|
+
rp.set_url(robots_url)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
should_close = False
|
|
55
|
+
if client is None:
|
|
56
|
+
client = create_safe_client(timeout=self.timeout)
|
|
57
|
+
should_close = True
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
resp = await client.get(robots_url, headers={"User-Agent": self.user_agent})
|
|
61
|
+
if resp.status_code == 200:
|
|
62
|
+
rp.parse(resp.text.splitlines())
|
|
63
|
+
elif resp.status_code in (401, 403):
|
|
64
|
+
# Access denied to robots.txt usually implies entire site is disallowed or restricted
|
|
65
|
+
return False, f"robots.txt returned HTTP {resp.status_code}"
|
|
66
|
+
else:
|
|
67
|
+
# 404 or other errors typically mean crawling is allowed per RFC standards
|
|
68
|
+
return True, None
|
|
69
|
+
finally:
|
|
70
|
+
if should_close:
|
|
71
|
+
await client.aclose()
|
|
72
|
+
|
|
73
|
+
allowed = rp.can_fetch(self.user_agent, norm_url)
|
|
74
|
+
if not allowed:
|
|
75
|
+
return False, f"Disallowed by rules in {robots_url}"
|
|
76
|
+
return True, None
|
|
77
|
+
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.debug("Failed to check robots.txt for %s: %s", robots_url, e)
|
|
80
|
+
# Default to allowed on fetch errors (unless server actively blocked with 401/403)
|
|
81
|
+
return True, None
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom Python SEO Audit Service.
|
|
3
|
+
|
|
4
|
+
Performs deterministic, rule-based SEO evaluation without external paid APIs:
|
|
5
|
+
- Title tag presence, length, and uniqueness.
|
|
6
|
+
- Meta description presence and length.
|
|
7
|
+
- H1 tags presence and uniqueness (detects zero or multiple H1s).
|
|
8
|
+
- Image alt attributes (percentage of images with descriptive alt text).
|
|
9
|
+
- Canonical link tag presence and format.
|
|
10
|
+
- Meta robots tag checking (index/noindex, follow/nofollow).
|
|
11
|
+
- Open Graph tags (og:title, og:description, og:image, og:url).
|
|
12
|
+
- robots.txt and sitemap.xml presence.
|
|
13
|
+
- Internal link counts and basic linking health.
|
|
14
|
+
- HTML page byte size.
|
|
15
|
+
|
|
16
|
+
Computes a transparent 0-100 composite score with actionable issues and recommendations.
|
|
17
|
+
Note: Transparently communicates that this is an on-page technical audit, not a guarantee of Google ranking.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
from typing import Any, Optional
|
|
24
|
+
from urllib.parse import urljoin, urlparse
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
from bs4 import BeautifulSoup
|
|
28
|
+
|
|
29
|
+
from .url_validator import create_safe_client, validate_and_normalize_url
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def audit_html_seo(html_content: str, page_url: str) -> dict[str, Any]:
|
|
35
|
+
"""
|
|
36
|
+
Perform on-page SEO analysis on the HTML string of page_url.
|
|
37
|
+
|
|
38
|
+
Returns structured audit data including checks, score (0-100), issues, and recommendations.
|
|
39
|
+
"""
|
|
40
|
+
score = 100
|
|
41
|
+
deductions = 0
|
|
42
|
+
issues: list[dict[str, str]] = []
|
|
43
|
+
recommendations: list[str] = []
|
|
44
|
+
checks: dict[str, Any] = {}
|
|
45
|
+
|
|
46
|
+
page_bytes = len(html_content.encode("utf-8"))
|
|
47
|
+
checks["html_size_bytes"] = page_bytes
|
|
48
|
+
if page_bytes > 2_000_000: # 2MB
|
|
49
|
+
issues.append({"level": "warning", "message": f"HTML document size is large ({page_bytes / 1024:.1f} KB)."})
|
|
50
|
+
recommendations.append("Reduce page weight and inline assets to improve load time.")
|
|
51
|
+
deductions += 5
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
soup = BeautifulSoup(html_content, "html.parser")
|
|
55
|
+
except Exception as e:
|
|
56
|
+
return {
|
|
57
|
+
"seo_score": 0,
|
|
58
|
+
"disclaimer": "This is a technical on-page SEO assessment and not a complete Google ranking guarantee.",
|
|
59
|
+
"checks": {"error": f"Failed to parse HTML: {e}"},
|
|
60
|
+
"issues": [{"level": "error", "message": "HTML could not be parsed."}],
|
|
61
|
+
"recommendations": ["Fix HTML syntax and structure."],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# 1. Title tag
|
|
65
|
+
title_tag = soup.find("title")
|
|
66
|
+
title_text = title_tag.get_text().strip() if title_tag else ""
|
|
67
|
+
checks["title"] = {
|
|
68
|
+
"present": bool(title_text),
|
|
69
|
+
"text": title_text,
|
|
70
|
+
"length": len(title_text),
|
|
71
|
+
}
|
|
72
|
+
if not title_text:
|
|
73
|
+
issues.append({"level": "error", "message": "Page is missing a <title> tag."})
|
|
74
|
+
recommendations.append("Add a concise, descriptive <title> tag (recommended 30-60 characters).")
|
|
75
|
+
deductions += 15
|
|
76
|
+
elif len(title_text) < 15:
|
|
77
|
+
issues.append({"level": "warning", "message": f"Title tag is very short ({len(title_text)} chars)."})
|
|
78
|
+
recommendations.append("Expand the title tag to provide clearer context for users and search engines.")
|
|
79
|
+
deductions += 5
|
|
80
|
+
elif len(title_text) > 70:
|
|
81
|
+
issues.append({"level": "warning", "message": f"Title tag may be truncated in search results ({len(title_text)} chars)."})
|
|
82
|
+
recommendations.append("Keep title tag under 60-70 characters.")
|
|
83
|
+
deductions += 3
|
|
84
|
+
|
|
85
|
+
# 2. Meta description
|
|
86
|
+
meta_desc = soup.find("meta", attrs={"name": lambda x: x and x.lower() == "description"})
|
|
87
|
+
desc_text = meta_desc.get("content", "").strip() if meta_desc else ""
|
|
88
|
+
checks["meta_description"] = {
|
|
89
|
+
"present": bool(desc_text),
|
|
90
|
+
"text": desc_text,
|
|
91
|
+
"length": len(desc_text),
|
|
92
|
+
}
|
|
93
|
+
if not desc_text:
|
|
94
|
+
issues.append({"level": "error", "message": "Page is missing a meta description."})
|
|
95
|
+
recommendations.append("Add a compelling meta description tag (recommended 120-160 characters).")
|
|
96
|
+
deductions += 15
|
|
97
|
+
elif len(desc_text) < 50:
|
|
98
|
+
issues.append({"level": "warning", "message": f"Meta description is short ({len(desc_text)} chars)."})
|
|
99
|
+
recommendations.append("Elaborate your meta description to better summarize page content.")
|
|
100
|
+
deductions += 5
|
|
101
|
+
elif len(desc_text) > 165:
|
|
102
|
+
issues.append({"level": "warning", "message": f"Meta description is long ({len(desc_text)} chars) and may be truncated."})
|
|
103
|
+
deductions += 2
|
|
104
|
+
|
|
105
|
+
# 3. Headings (H1 & H2)
|
|
106
|
+
h1_tags = soup.find_all("h1")
|
|
107
|
+
h1_texts = [h.get_text().strip() for h in h1_tags if h.get_text().strip()]
|
|
108
|
+
checks["h1_headings"] = {
|
|
109
|
+
"count": len(h1_tags),
|
|
110
|
+
"texts": h1_texts,
|
|
111
|
+
}
|
|
112
|
+
if len(h1_tags) == 0:
|
|
113
|
+
issues.append({"level": "error", "message": "No <h1> heading found on the page."})
|
|
114
|
+
recommendations.append("Include exactly one primary <h1> heading summarizing the main topic.")
|
|
115
|
+
deductions += 15
|
|
116
|
+
elif len(h1_tags) > 1:
|
|
117
|
+
issues.append({"level": "warning", "message": f"Multiple <h1> headings found ({len(h1_tags)})."})
|
|
118
|
+
recommendations.append("Consider using a single primary <h1> heading and structure secondary topics with <h2>.")
|
|
119
|
+
deductions += 5
|
|
120
|
+
|
|
121
|
+
h2_tags = soup.find_all("h2")
|
|
122
|
+
checks["h2_count"] = len(h2_tags)
|
|
123
|
+
if len(h2_tags) == 0 and len(html_content) > 1500:
|
|
124
|
+
issues.append({"level": "info", "message": "No <h2> subheadings found."})
|
|
125
|
+
recommendations.append("Use <h2> subheadings to divide text into readable sections.")
|
|
126
|
+
deductions += 3
|
|
127
|
+
|
|
128
|
+
# 4. Images & Alt attributes
|
|
129
|
+
images = soup.find_all("img")
|
|
130
|
+
total_imgs = len(images)
|
|
131
|
+
missing_alt = [img for img in images if not img.get("alt") or not img.get("alt").strip()]
|
|
132
|
+
checks["images"] = {
|
|
133
|
+
"total": total_imgs,
|
|
134
|
+
"missing_alt": len(missing_alt),
|
|
135
|
+
"alt_coverage_pct": round(((total_imgs - len(missing_alt)) / total_imgs * 100), 1) if total_imgs else 100.0,
|
|
136
|
+
}
|
|
137
|
+
if missing_alt:
|
|
138
|
+
issues.append({
|
|
139
|
+
"level": "warning",
|
|
140
|
+
"message": f"{len(missing_alt)} of {total_imgs} images are missing alt attributes."
|
|
141
|
+
})
|
|
142
|
+
recommendations.append("Add descriptive alt text to all informative images for accessibility and image search.")
|
|
143
|
+
deductions += min(15, len(missing_alt) * 3)
|
|
144
|
+
|
|
145
|
+
# 5. Canonical URL
|
|
146
|
+
canonical_tag = soup.find("link", rel=lambda x: x and "canonical" in x.lower())
|
|
147
|
+
canonical_href = canonical_tag.get("href", "").strip() if canonical_tag else ""
|
|
148
|
+
checks["canonical_url"] = {
|
|
149
|
+
"present": bool(canonical_href),
|
|
150
|
+
"url": canonical_href,
|
|
151
|
+
}
|
|
152
|
+
if not canonical_href:
|
|
153
|
+
issues.append({"level": "info", "message": "No canonical link element specified."})
|
|
154
|
+
recommendations.append("Specify a rel='canonical' link to prevent duplicate content issues.")
|
|
155
|
+
deductions += 5
|
|
156
|
+
|
|
157
|
+
# 6. Meta Robots
|
|
158
|
+
meta_robots = soup.find("meta", attrs={"name": lambda x: x and x.lower() == "robots"})
|
|
159
|
+
robots_content = meta_robots.get("content", "").strip() if meta_robots else ""
|
|
160
|
+
checks["meta_robots"] = {
|
|
161
|
+
"present": bool(robots_content),
|
|
162
|
+
"content": robots_content,
|
|
163
|
+
"is_noindex": "noindex" in robots_content.lower(),
|
|
164
|
+
"is_nofollow": "nofollow" in robots_content.lower(),
|
|
165
|
+
}
|
|
166
|
+
if "noindex" in robots_content.lower():
|
|
167
|
+
issues.append({"level": "warning", "message": "Page has 'noindex' directive in meta robots tag."})
|
|
168
|
+
deductions += 10
|
|
169
|
+
|
|
170
|
+
# 7. Open Graph metadata
|
|
171
|
+
og_title = soup.find("meta", property="og:title")
|
|
172
|
+
og_desc = soup.find("meta", property="og:description")
|
|
173
|
+
og_image = soup.find("meta", property="og:image")
|
|
174
|
+
checks["open_graph"] = {
|
|
175
|
+
"has_title": bool(og_title and og_title.get("content")),
|
|
176
|
+
"has_description": bool(og_desc and og_desc.get("content")),
|
|
177
|
+
"has_image": bool(og_image and og_image.get("content")),
|
|
178
|
+
}
|
|
179
|
+
if not (og_title and og_desc and og_image):
|
|
180
|
+
missing_og = []
|
|
181
|
+
if not og_title:
|
|
182
|
+
missing_og.append("og:title")
|
|
183
|
+
if not og_desc:
|
|
184
|
+
missing_og.append("og:description")
|
|
185
|
+
if not og_image:
|
|
186
|
+
missing_og.append("og:image")
|
|
187
|
+
issues.append({"level": "info", "message": f"Incomplete Open Graph tags: missing {', '.join(missing_og)}."})
|
|
188
|
+
recommendations.append("Add complete Open Graph meta tags (title, description, image) for social sharing previews.")
|
|
189
|
+
deductions += 5
|
|
190
|
+
|
|
191
|
+
# 8. Internal links
|
|
192
|
+
parsed_base = urlparse(page_url)
|
|
193
|
+
base_domain = parsed_base.netloc.lower()
|
|
194
|
+
all_links = soup.find_all("a", href=True)
|
|
195
|
+
internal_links: list[str] = []
|
|
196
|
+
external_links: list[str] = []
|
|
197
|
+
|
|
198
|
+
for a in all_links:
|
|
199
|
+
href = a["href"].strip()
|
|
200
|
+
if not href or href.startswith(("#", "javascript:", "mailto:", "tel:")):
|
|
201
|
+
continue
|
|
202
|
+
full_href = urljoin(page_url, href)
|
|
203
|
+
parsed_target = urlparse(full_href)
|
|
204
|
+
if parsed_target.netloc.lower() == base_domain:
|
|
205
|
+
internal_links.append(full_href)
|
|
206
|
+
elif parsed_target.netloc:
|
|
207
|
+
external_links.append(full_href)
|
|
208
|
+
|
|
209
|
+
checks["links"] = {
|
|
210
|
+
"total": len(all_links),
|
|
211
|
+
"internal_count": len(internal_links),
|
|
212
|
+
"external_count": len(external_links),
|
|
213
|
+
}
|
|
214
|
+
if len(internal_links) == 0:
|
|
215
|
+
issues.append({"level": "warning", "message": "No internal navigation links found on page."})
|
|
216
|
+
recommendations.append("Ensure key navigation links exist to facilitate crawl depth and user discovery.")
|
|
217
|
+
deductions += 5
|
|
218
|
+
|
|
219
|
+
final_score = max(0, min(100, score - deductions))
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
"seo_score": final_score,
|
|
223
|
+
"disclaimer": (
|
|
224
|
+
"This technical SEO evaluation is based on on-page source standards "
|
|
225
|
+
"and does not represent a proprietary or full Google ranking algorithm result."
|
|
226
|
+
),
|
|
227
|
+
"checks": checks,
|
|
228
|
+
"issues": issues,
|
|
229
|
+
"recommendations": recommendations,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def run_full_seo_audit(url: str, client: Optional[httpx.AsyncClient] = None) -> dict[str, Any]:
|
|
234
|
+
"""
|
|
235
|
+
Fetch target page, check robots.txt and sitemap.xml, and perform complete SEO audit.
|
|
236
|
+
"""
|
|
237
|
+
norm_url = validate_and_normalize_url(url)
|
|
238
|
+
should_close = False
|
|
239
|
+
if client is None:
|
|
240
|
+
client = create_safe_client(timeout=15.0)
|
|
241
|
+
should_close = True
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
# Check robots.txt availability
|
|
245
|
+
parsed = urlparse(norm_url)
|
|
246
|
+
base_origin = f"{parsed.scheme}://{parsed.netloc}"
|
|
247
|
+
robots_url = urljoin(base_origin, "/robots.txt")
|
|
248
|
+
sitemap_url = urljoin(base_origin, "/sitemap.xml")
|
|
249
|
+
|
|
250
|
+
robots_ok = False
|
|
251
|
+
sitemap_ok = False
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
r_resp = await client.get(robots_url, headers={"User-Agent": "LeadIntel/0.1"})
|
|
255
|
+
robots_ok = (r_resp.status_code == 200)
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
s_resp = await client.get(sitemap_url, headers={"User-Agent": "LeadIntel/0.1"})
|
|
261
|
+
sitemap_ok = (s_resp.status_code == 200)
|
|
262
|
+
except Exception:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
# Fetch main page
|
|
266
|
+
page_resp = await client.get(norm_url, headers={"User-Agent": "LeadIntel/0.1"})
|
|
267
|
+
page_resp.raise_for_status()
|
|
268
|
+
|
|
269
|
+
audit = audit_html_seo(page_resp.text, str(page_resp.url))
|
|
270
|
+
audit["checks"]["robots_txt"] = {"available": robots_ok, "url": robots_url}
|
|
271
|
+
audit["checks"]["sitemap"] = {"available": sitemap_ok, "url": sitemap_url}
|
|
272
|
+
|
|
273
|
+
if not robots_ok:
|
|
274
|
+
audit["issues"].append({"level": "info", "message": "robots.txt not detected at standard location."})
|
|
275
|
+
if not sitemap_ok:
|
|
276
|
+
audit["issues"].append({"level": "info", "message": "sitemap.xml not detected at standard root location."})
|
|
277
|
+
|
|
278
|
+
return audit
|
|
279
|
+
finally:
|
|
280
|
+
if should_close:
|
|
281
|
+
await client.aclose()
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Social Media Profile Finder Service.
|
|
3
|
+
|
|
4
|
+
Extracts official social media links from HTML documents and handles
|
|
5
|
+
filtering of share buttons, tracking parameters, and unrelated external links.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from typing import Optional
|
|
13
|
+
from urllib.parse import urlparse, urlunparse
|
|
14
|
+
|
|
15
|
+
from bs4 import BeautifulSoup
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# Canonical platform regexes
|
|
20
|
+
_PLATFORMS: dict[str, list[re.Pattern]] = {
|
|
21
|
+
"linkedin": [
|
|
22
|
+
re.compile(r"^https?://(www\.)?linkedin\.com/(company|in|school)/[a-zA-Z0-9_\-\.]+/?", re.IGNORECASE)
|
|
23
|
+
],
|
|
24
|
+
"facebook": [
|
|
25
|
+
re.compile(r"^https?://(www\.)?facebook\.com/[a-zA-Z0-9_\-\.]+/?", re.IGNORECASE)
|
|
26
|
+
],
|
|
27
|
+
"instagram": [
|
|
28
|
+
re.compile(r"^https?://(www\.)?instagram\.com/[a-zA-Z0-9_\-\.]+/?", re.IGNORECASE)
|
|
29
|
+
],
|
|
30
|
+
"twitter": [
|
|
31
|
+
re.compile(r"^https?://(www\.)?(twitter|x)\.com/[a-zA-Z0-9_]{1,30}/?", re.IGNORECASE)
|
|
32
|
+
],
|
|
33
|
+
"youtube": [
|
|
34
|
+
re.compile(r"^https?://(www\.)?youtube\.com/(@[a-zA-Z0-9_\-\.]+|channel/[a-zA-Z0-9_\-]+|c/[a-zA-Z0-9_\-]+)/?", re.IGNORECASE)
|
|
35
|
+
],
|
|
36
|
+
"pinterest": [
|
|
37
|
+
re.compile(r"^https?://(www\.)?pinterest\.(com|[a-z]{2})/[a-zA-Z0-9_\-\.]+/?", re.IGNORECASE)
|
|
38
|
+
],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Paths/query parameters indicating sharing intent rather than profile links
|
|
42
|
+
_IGNORE_PATTERNS = [
|
|
43
|
+
re.compile(r"sharer|share\.php|intent/tweet|sharing|dialog/share|pin/create", re.IGNORECASE),
|
|
44
|
+
re.compile(r"linkedin\.com/shareArticle", re.IGNORECASE),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _clean_social_url(url: str) -> Optional[str]:
|
|
49
|
+
"""Clean query parameters and check if URL is a profile vs sharing link."""
|
|
50
|
+
for pattern in _IGNORE_PATTERNS:
|
|
51
|
+
if pattern.search(url):
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
parsed = urlparse(url)
|
|
56
|
+
# Strip tracking query params
|
|
57
|
+
path = parsed.path.rstrip("/")
|
|
58
|
+
if not path or path in ("", "/"):
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
# Exclude common non-profile paths
|
|
62
|
+
ignored_paths = {
|
|
63
|
+
"/login", "/signup", "/home", "/privacy", "/terms", "/help",
|
|
64
|
+
"/about", "/policies", "/share", "/tr", "/plugins"
|
|
65
|
+
}
|
|
66
|
+
if path.lower() in ignored_paths:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
clean_url = urlunparse((parsed.scheme.lower(), parsed.netloc.lower(), path, "", "", ""))
|
|
70
|
+
return clean_url
|
|
71
|
+
except Exception:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def extract_social_profiles(html_content: str, base_url: str = "") -> dict[str, list[dict[str, str]]]:
|
|
76
|
+
"""
|
|
77
|
+
Extract verified social media profiles from HTML content.
|
|
78
|
+
|
|
79
|
+
Returns dict mapping platform name to list of dicts:
|
|
80
|
+
{
|
|
81
|
+
"linkedin": [{"url": "https://linkedin.com/company/acme", "source_page": base_url}],
|
|
82
|
+
...
|
|
83
|
+
}
|
|
84
|
+
"""
|
|
85
|
+
results: dict[str, list[dict[str, str]]] = {p: [] for p in _PLATFORMS}
|
|
86
|
+
seen_urls: set[str] = set()
|
|
87
|
+
|
|
88
|
+
if not html_content:
|
|
89
|
+
return results
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
soup = BeautifulSoup(html_content, "html.parser")
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logger.debug("HTML parsing failed in extract_social_profiles: %s", e)
|
|
95
|
+
return results
|
|
96
|
+
|
|
97
|
+
links = soup.find_all("a", href=True)
|
|
98
|
+
for a in links:
|
|
99
|
+
href = a["href"].strip()
|
|
100
|
+
if not href.startswith("http"):
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
for platform, patterns in _PLATFORMS.items():
|
|
104
|
+
matched = False
|
|
105
|
+
for pat in patterns:
|
|
106
|
+
if pat.search(href):
|
|
107
|
+
cleaned = _clean_social_url(href)
|
|
108
|
+
if cleaned and cleaned not in seen_urls:
|
|
109
|
+
seen_urls.add(cleaned)
|
|
110
|
+
results[platform].append({
|
|
111
|
+
"url": cleaned,
|
|
112
|
+
"source_page": base_url or "",
|
|
113
|
+
})
|
|
114
|
+
matched = True
|
|
115
|
+
break
|
|
116
|
+
if matched:
|
|
117
|
+
break
|
|
118
|
+
|
|
119
|
+
return results
|