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,295 @@
|
|
|
1
|
+
"""
|
|
2
|
+
URL and Host Validation Service for SSRF protection and security checks.
|
|
3
|
+
|
|
4
|
+
Prevents SSRF and DNS Rebinding attacks by:
|
|
5
|
+
- Enforcing http/https schemes only.
|
|
6
|
+
- Rejecting userinfo/credentials in URLs.
|
|
7
|
+
- Rejecting dangerous service ports (e.g., SSH, SMTP, Redis, DB ports).
|
|
8
|
+
- Blocking localhost, 127.0.0.1, ::1, 0.0.0.0, broadcasthost, and internal hostnames.
|
|
9
|
+
- Blocking non-global IP addresses, link-local, loopbacks, and private subnets (RFC 1918, RFC 6598 CGNAT).
|
|
10
|
+
- Blocking cloud metadata endpoints (169.254.169.254, metadata.google.internal, fd00:ec2::254, 100.100.100.200).
|
|
11
|
+
- Blocking internal and local domain suffixes (.local, .localhost, .internal, .lan, .corp, .home).
|
|
12
|
+
- Mitigating DNS Rebinding & TOCTOU: Validating all mixed DNS answers and pinning the TCP socket connection directly to the verified public IP.
|
|
13
|
+
- Bypassing ambient proxies (trust_env=False) to prevent proxy-based SSRF bypass.
|
|
14
|
+
- Validating HTTP redirects to prevent open redirect SSRF bypasses.
|
|
15
|
+
- Providing safe pre-configured HTTPX clients and transports.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ipaddress
|
|
21
|
+
import logging
|
|
22
|
+
import socket
|
|
23
|
+
from typing import Any, Optional
|
|
24
|
+
from urllib.parse import urljoin, urlparse
|
|
25
|
+
|
|
26
|
+
import httpcore
|
|
27
|
+
import httpx
|
|
28
|
+
from httpcore._backends.anyio import AnyIOBackend, AnyIOStream
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# Cloud metadata IPs, loopbacks, and internal hostnames explicitly blocked
|
|
33
|
+
BLOCKED_IP_STRINGS = {
|
|
34
|
+
"169.254.169.254", # AWS/GCP/Azure/DigitalOcean metadata (IPv4)
|
|
35
|
+
"fd00:ec2::254", # AWS IMDSv2 (IPv6)
|
|
36
|
+
"100.100.100.200", # Alibaba Cloud metadata
|
|
37
|
+
"metadata.google.internal", # GCP metadata DNS
|
|
38
|
+
"metadata.google", # GCP metadata alias
|
|
39
|
+
"metadata", # Cloud generic metadata hostname
|
|
40
|
+
"instance-data", # EC2 instance data hostname
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
BLOCKED_HOSTNAMES = {
|
|
44
|
+
"localhost",
|
|
45
|
+
"localhost.localdomain",
|
|
46
|
+
"127.0.0.1",
|
|
47
|
+
"::1",
|
|
48
|
+
"0.0.0.0",
|
|
49
|
+
"broadcasthost",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
BLOCKED_HOST_SUFFIXES = (
|
|
53
|
+
".local",
|
|
54
|
+
".localhost",
|
|
55
|
+
".internal",
|
|
56
|
+
".lan",
|
|
57
|
+
".localdomain",
|
|
58
|
+
".corp",
|
|
59
|
+
".home",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Ports associated with sensitive backend services that should never be probed by a website crawler
|
|
63
|
+
DANGEROUS_PORTS = {
|
|
64
|
+
21, # FTP
|
|
65
|
+
22, # SSH
|
|
66
|
+
23, # Telnet
|
|
67
|
+
25, # SMTP
|
|
68
|
+
53, # DNS
|
|
69
|
+
110, # POP3
|
|
70
|
+
111, # RPC
|
|
71
|
+
135, # RPC
|
|
72
|
+
139, # NetBIOS
|
|
73
|
+
143, # IMAP
|
|
74
|
+
445, # SMB
|
|
75
|
+
1433, # MSSQL
|
|
76
|
+
1521, # Oracle
|
|
77
|
+
2049, # NFS
|
|
78
|
+
3306, # MySQL
|
|
79
|
+
3389, # RDP
|
|
80
|
+
5432, # PostgreSQL
|
|
81
|
+
5900, # VNC
|
|
82
|
+
6379, # Redis
|
|
83
|
+
11211, # Memcached
|
|
84
|
+
27017, # MongoDB
|
|
85
|
+
28017, # MongoDB Web
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SecurityValidationError(ValueError):
|
|
90
|
+
"""Raised when a URL violates safety or SSRF restrictions."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_ip_private_or_restricted(ip_str: str) -> bool:
|
|
94
|
+
"""
|
|
95
|
+
Check if an IP string belongs to private, loopback, link-local, multicast,
|
|
96
|
+
non-global, or reserved ranges.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
ip = ipaddress.ip_address(ip_str.strip("[]"))
|
|
100
|
+
# Check IPv4-mapped IPv6 address (e.g. ::ffff:127.0.0.1)
|
|
101
|
+
mapped = getattr(ip, "ipv4_mapped", None)
|
|
102
|
+
if mapped and (not mapped.is_global or mapped.is_private or mapped.is_loopback):
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
not ip.is_global
|
|
107
|
+
or ip.is_private
|
|
108
|
+
or ip.is_loopback
|
|
109
|
+
or ip.is_link_local
|
|
110
|
+
or ip.is_multicast
|
|
111
|
+
or ip.is_reserved
|
|
112
|
+
or ip.is_unspecified
|
|
113
|
+
)
|
|
114
|
+
except ValueError:
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def validate_and_normalize_url(url: str) -> str:
|
|
119
|
+
"""
|
|
120
|
+
Validate a URL string for safety against SSRF and syntax issues.
|
|
121
|
+
|
|
122
|
+
Returns the normalized URL string if valid.
|
|
123
|
+
Raises SecurityValidationError or ValueError if invalid or unsafe.
|
|
124
|
+
"""
|
|
125
|
+
if not url or not isinstance(url, str):
|
|
126
|
+
raise ValueError("URL must be a non-empty string.")
|
|
127
|
+
|
|
128
|
+
url = url.strip()
|
|
129
|
+
parsed_initial = urlparse(url)
|
|
130
|
+
if parsed_initial.scheme and parsed_initial.scheme not in ("http", "https"):
|
|
131
|
+
raise SecurityValidationError(f"Invalid scheme '{parsed_initial.scheme}'. Only http and https are allowed.")
|
|
132
|
+
|
|
133
|
+
if not (url.startswith("http://") or url.startswith("https://")):
|
|
134
|
+
url = "https://" + url
|
|
135
|
+
|
|
136
|
+
parsed = urlparse(url)
|
|
137
|
+
|
|
138
|
+
if parsed.scheme not in ("http", "https"):
|
|
139
|
+
raise SecurityValidationError(f"Invalid scheme '{parsed.scheme}'. Only http and https are allowed.")
|
|
140
|
+
|
|
141
|
+
# Disallow credentials in URLs to prevent credential confusion/leakage
|
|
142
|
+
if parsed.username or parsed.password:
|
|
143
|
+
raise SecurityValidationError("URLs containing embedded username or password credentials are not permitted.")
|
|
144
|
+
|
|
145
|
+
hostname = parsed.hostname
|
|
146
|
+
if not hostname:
|
|
147
|
+
raise SecurityValidationError("URL missing valid hostname.")
|
|
148
|
+
|
|
149
|
+
hostname_lower = hostname.lower().strip("[]")
|
|
150
|
+
|
|
151
|
+
if hostname_lower in BLOCKED_HOSTNAMES or hostname_lower in BLOCKED_IP_STRINGS:
|
|
152
|
+
raise SecurityValidationError(f"Access to restricted host '{hostname}' is blocked for security reasons (SSRF protection).")
|
|
153
|
+
|
|
154
|
+
if hostname_lower.endswith(BLOCKED_HOST_SUFFIXES):
|
|
155
|
+
raise SecurityValidationError(f"Access to internal domain '{hostname}' is blocked for security reasons (SSRF protection).")
|
|
156
|
+
|
|
157
|
+
# Check dangerous port restrictions
|
|
158
|
+
if parsed.port and parsed.port in DANGEROUS_PORTS:
|
|
159
|
+
raise SecurityValidationError(f"Access to dangerous port {parsed.port} is blocked for security reasons.")
|
|
160
|
+
|
|
161
|
+
# Check if hostname directly represents an IP literal
|
|
162
|
+
try:
|
|
163
|
+
if is_ip_private_or_restricted(hostname_lower):
|
|
164
|
+
raise SecurityValidationError(f"Access to IP '{hostname}' is restricted (private/loopback/cloud metadata).")
|
|
165
|
+
except ValueError:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
# Resolve hostname via DNS to verify target IP isn't private / non-global.
|
|
169
|
+
# Checks ALL returned addresses (handles mixed IPv4/IPv6 answers).
|
|
170
|
+
try:
|
|
171
|
+
addrinfo = socket.getaddrinfo(hostname, None)
|
|
172
|
+
for family, socktype, proto, canonname, sockaddr in addrinfo:
|
|
173
|
+
ip_str = sockaddr[0]
|
|
174
|
+
if is_ip_private_or_restricted(ip_str) or ip_str in BLOCKED_IP_STRINGS:
|
|
175
|
+
raise SecurityValidationError(
|
|
176
|
+
f"Host '{hostname}' resolved to restricted IP '{ip_str}' (SSRF protection)."
|
|
177
|
+
)
|
|
178
|
+
except socket.gaierror as e:
|
|
179
|
+
logger.debug("DNS resolution failed for '%s': %s", hostname, e)
|
|
180
|
+
# We allow DNS resolution failures to proceed to HTTP request where HTTPX will cleanly handle connection error,
|
|
181
|
+
# unless it was specifically blocked above.
|
|
182
|
+
|
|
183
|
+
return url
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def validate_redirect_hook(response: httpx.Response) -> None:
|
|
187
|
+
"""
|
|
188
|
+
HTTPX event hook that intercepts 3xx redirects and validates the destination URL against SSRF rules.
|
|
189
|
+
Prevents malicious open redirects from redirecting crawler requests to private IPs or cloud metadata.
|
|
190
|
+
"""
|
|
191
|
+
if response.is_redirect:
|
|
192
|
+
location = response.headers.get("Location")
|
|
193
|
+
if location:
|
|
194
|
+
target_url = urljoin(str(response.url), location)
|
|
195
|
+
# Validates scheme, port, IP address, hostname, and restricts private IPs/metadata
|
|
196
|
+
validate_and_normalize_url(target_url)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class RebindingSafeAnyIOBackend(AnyIOBackend):
|
|
200
|
+
"""
|
|
201
|
+
AnyIO Network backend that validates DNS at connection time and pins the TCP socket
|
|
202
|
+
directly to the verified public IP. Eliminates DNS Rebinding TOCTOU vulnerabilities
|
|
203
|
+
and detects mixed IPv4/IPv6 DNS answers containing restricted IPs.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
async def connect_tcp(
|
|
207
|
+
self,
|
|
208
|
+
host: str,
|
|
209
|
+
port: int,
|
|
210
|
+
timeout: Optional[float] = None,
|
|
211
|
+
local_address: Optional[str] = None,
|
|
212
|
+
socket_options: Optional[Any] = None,
|
|
213
|
+
) -> AnyIOStream:
|
|
214
|
+
host_clean = host.strip("[]")
|
|
215
|
+
# Check blocked hostnames and suffixes directly
|
|
216
|
+
if host_clean.lower() in BLOCKED_HOSTNAMES or host_clean.lower() in BLOCKED_IP_STRINGS:
|
|
217
|
+
raise SecurityValidationError(f"Access to restricted host '{host}' blocked at connection time.")
|
|
218
|
+
|
|
219
|
+
if host_clean.lower().endswith(BLOCKED_HOST_SUFFIXES):
|
|
220
|
+
raise SecurityValidationError(f"Access to internal domain '{host}' blocked at connection time.")
|
|
221
|
+
|
|
222
|
+
# Resolve host at the exact connection instant
|
|
223
|
+
addrinfo = socket.getaddrinfo(host_clean, port, proto=socket.IPPROTO_TCP)
|
|
224
|
+
safe_ip: Optional[str] = None
|
|
225
|
+
|
|
226
|
+
# Verify EVERY IP in the answer set (detect mixed DNS answers)
|
|
227
|
+
for family, socktype, proto, canonname, sockaddr in addrinfo:
|
|
228
|
+
ip_str = sockaddr[0]
|
|
229
|
+
if is_ip_private_or_restricted(ip_str) or ip_str in BLOCKED_IP_STRINGS:
|
|
230
|
+
raise SecurityValidationError(
|
|
231
|
+
f"DNS Rebinding / SSRF blocked: {host} resolved to restricted IP '{ip_str}' at connection time."
|
|
232
|
+
)
|
|
233
|
+
if safe_ip is None:
|
|
234
|
+
safe_ip = ip_str
|
|
235
|
+
|
|
236
|
+
target_connect_ip = safe_ip if safe_ip else host_clean
|
|
237
|
+
|
|
238
|
+
# Connect directly to the validated IP to pin the TCP socket
|
|
239
|
+
return await super().connect_tcp(
|
|
240
|
+
target_connect_ip,
|
|
241
|
+
port,
|
|
242
|
+
timeout=timeout,
|
|
243
|
+
local_address=local_address,
|
|
244
|
+
socket_options=socket_options,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class SafeAsyncHTTPTransport(httpx.AsyncHTTPTransport):
|
|
249
|
+
"""
|
|
250
|
+
HTTPX transport pre-configured with DNS rebinding defense, connection pinning,
|
|
251
|
+
and ambient proxy bypass (trust_env=False).
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
def __init__(self, *args, **kwargs):
|
|
255
|
+
kwargs["trust_env"] = False # Bypasses ambient HTTP_PROXY/ALL_PROXY variables
|
|
256
|
+
super().__init__(*args, **kwargs)
|
|
257
|
+
# Configure connection pool with rebinding-safe network backend
|
|
258
|
+
self._pool = httpcore.AsyncConnectionPool(
|
|
259
|
+
ssl_context=self._pool._ssl_context,
|
|
260
|
+
network_backend=RebindingSafeAnyIOBackend(),
|
|
261
|
+
http1=True,
|
|
262
|
+
http2=False,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def create_safe_client(
|
|
267
|
+
timeout: float = 15.0,
|
|
268
|
+
max_redirects: int = 5,
|
|
269
|
+
headers: Optional[dict[str, str]] = None,
|
|
270
|
+
) -> httpx.AsyncClient:
|
|
271
|
+
"""
|
|
272
|
+
Create an httpx.AsyncClient pre-configured with:
|
|
273
|
+
- DNS rebinding mitigation (TCP connection pinning to validated IP)
|
|
274
|
+
- SSRF redirect validation hook
|
|
275
|
+
- Ambient proxy bypass (trust_env=False)
|
|
276
|
+
- Strict timeout and redirect limits
|
|
277
|
+
- Respectful User-Agent
|
|
278
|
+
"""
|
|
279
|
+
client_headers = {
|
|
280
|
+
"User-Agent": "LeadIntel/0.1 (Business Intelligence Researcher; +https://github.com/example/leadintel; respectful crawler)"
|
|
281
|
+
}
|
|
282
|
+
if headers:
|
|
283
|
+
client_headers.update(headers)
|
|
284
|
+
|
|
285
|
+
transport = SafeAsyncHTTPTransport(verify=True)
|
|
286
|
+
|
|
287
|
+
return httpx.AsyncClient(
|
|
288
|
+
transport=transport,
|
|
289
|
+
timeout=timeout,
|
|
290
|
+
follow_redirects=True,
|
|
291
|
+
max_redirects=max_redirects,
|
|
292
|
+
trust_env=False,
|
|
293
|
+
event_hooks={"response": [validate_redirect_hook]},
|
|
294
|
+
headers=client_headers,
|
|
295
|
+
)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Website Analyzer Service.
|
|
3
|
+
|
|
4
|
+
Comprehensive website diagnostic service:
|
|
5
|
+
- Checks availability, response time, and HTTP status.
|
|
6
|
+
- Detects HTTP vs HTTPS and verifies SSL/TLS validity.
|
|
7
|
+
- Extracts page title, meta description, and viewport configuration.
|
|
8
|
+
- Detects H1, H2, and image alt presence.
|
|
9
|
+
- Detects contact details (emails, phone numbers, contact forms).
|
|
10
|
+
- Detects social media links.
|
|
11
|
+
- Samples and checks internal links for 404/broken status where practical.
|
|
12
|
+
- Protects against SSRF, respects robots.txt, and enforces size/rate limits.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import logging
|
|
19
|
+
import re
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
from urllib.parse import urljoin, urlparse
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
from bs4 import BeautifulSoup
|
|
25
|
+
|
|
26
|
+
from .robots_checker import RobotsChecker
|
|
27
|
+
from .social_finder import extract_social_profiles
|
|
28
|
+
from .url_validator import create_safe_client, validate_and_normalize_url
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
USER_AGENT = "LeadIntel/0.1 (Business Intelligence Researcher; +https://github.com/example/leadintel; respectful crawler)"
|
|
33
|
+
MAX_HTML_BYTES = 5_000_000 # 5MB max payload limit
|
|
34
|
+
MAX_BROKEN_LINKS_CHECK = 10 # Maximum number of internal links to spot-check for broken links
|
|
35
|
+
|
|
36
|
+
EMAIL_REGEX = re.compile(r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+")
|
|
37
|
+
PHONE_REGEX = re.compile(r"(\+?\d{1,3}[-.\s]?)?\(?\d{2,4}\)?[-.\s]?\d{3,4}[-.\s]?\d{3,9}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def analyze_target_website(
|
|
41
|
+
url: str,
|
|
42
|
+
check_broken_links: bool = True,
|
|
43
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
"""
|
|
46
|
+
Perform a complete website analysis on the given URL.
|
|
47
|
+
|
|
48
|
+
Returns structured diagnostics or error details.
|
|
49
|
+
"""
|
|
50
|
+
norm_url = validate_and_normalize_url(url)
|
|
51
|
+
|
|
52
|
+
# 1. Check robots.txt
|
|
53
|
+
robots = RobotsChecker()
|
|
54
|
+
can_fetch, reason = await robots.can_fetch(norm_url)
|
|
55
|
+
if not can_fetch:
|
|
56
|
+
return {
|
|
57
|
+
"status": "blocked_by_robots",
|
|
58
|
+
"url": norm_url,
|
|
59
|
+
"available": False,
|
|
60
|
+
"error": reason or "Blocked by website robots.txt policy.",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
should_close = False
|
|
64
|
+
if client is None:
|
|
65
|
+
client = create_safe_client(timeout=15.0)
|
|
66
|
+
should_close = True
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
# Fetch page with streaming or byte cap
|
|
70
|
+
start_time = asyncio.get_event_loop().time()
|
|
71
|
+
try:
|
|
72
|
+
resp = await client.get(
|
|
73
|
+
norm_url,
|
|
74
|
+
headers={"User-Agent": USER_AGENT, "Accept": "text/html,application/xhtml+xml"},
|
|
75
|
+
)
|
|
76
|
+
elapsed = round(asyncio.get_event_loop().time() - start_time, 3)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
logger.debug("Failed to fetch target website %s: %s", norm_url, exc)
|
|
79
|
+
return {
|
|
80
|
+
"status": "error",
|
|
81
|
+
"url": norm_url,
|
|
82
|
+
"available": False,
|
|
83
|
+
"error": f"Connection failed or timed out: {exc}",
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# Content length safeguard
|
|
87
|
+
raw_bytes = resp.content
|
|
88
|
+
if len(raw_bytes) > MAX_HTML_BYTES:
|
|
89
|
+
raw_bytes = raw_bytes[:MAX_HTML_BYTES]
|
|
90
|
+
|
|
91
|
+
html = raw_bytes.decode(resp.encoding or "utf-8", errors="replace")
|
|
92
|
+
|
|
93
|
+
# 2. SSL / Protocol Checks
|
|
94
|
+
final_url = str(resp.url)
|
|
95
|
+
is_https = final_url.startswith("https://")
|
|
96
|
+
ssl_valid = is_https # If httpx connects over https with standard ssl verify=True without exception, it is valid
|
|
97
|
+
|
|
98
|
+
# 3. HTML Extraction via BeautifulSoup
|
|
99
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
100
|
+
|
|
101
|
+
title_tag = soup.find("title")
|
|
102
|
+
title = title_tag.get_text().strip() if title_tag else None
|
|
103
|
+
|
|
104
|
+
desc_tag = soup.find("meta", attrs={"name": lambda x: x and x.lower() == "description"})
|
|
105
|
+
meta_description = desc_tag.get("content", "").strip() if desc_tag else None
|
|
106
|
+
|
|
107
|
+
viewport_tag = soup.find("meta", attrs={"name": lambda x: x and x.lower() == "viewport"})
|
|
108
|
+
has_mobile_viewport = bool(viewport_tag and "width=" in viewport_tag.get("content", "").lower())
|
|
109
|
+
|
|
110
|
+
# Headings & Media
|
|
111
|
+
h1_tags = [h.get_text().strip() for h in soup.find_all("h1") if h.get_text().strip()]
|
|
112
|
+
h2_tags = [h.get_text().strip() for h in soup.find_all("h2") if h.get_text().strip()]
|
|
113
|
+
images = soup.find_all("img")
|
|
114
|
+
images_missing_alt = [img for img in images if not img.get("alt") or not img.get("alt").strip()]
|
|
115
|
+
|
|
116
|
+
# 4. Contact Detection (Emails, Phone numbers, Forms)
|
|
117
|
+
contact_forms = soup.find_all("form")
|
|
118
|
+
has_contact_form = False
|
|
119
|
+
for f in contact_forms:
|
|
120
|
+
form_text = str(f).lower()
|
|
121
|
+
if any(term in form_text for term in ("contact", "email", "message", "inquiry", "send", "submit")):
|
|
122
|
+
has_contact_form = True
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
# Search for mailto / tel links first
|
|
126
|
+
emails: set[str] = set()
|
|
127
|
+
phones: set[str] = set()
|
|
128
|
+
|
|
129
|
+
for a in soup.find_all("a", href=True):
|
|
130
|
+
href = a["href"].strip()
|
|
131
|
+
if href.lower().startswith("mailto:"):
|
|
132
|
+
clean_email = href[7:].split("?")[0].strip()
|
|
133
|
+
if EMAIL_REGEX.match(clean_email):
|
|
134
|
+
emails.add(clean_email)
|
|
135
|
+
elif href.lower().startswith("tel:"):
|
|
136
|
+
clean_tel = href[4:].split("?")[0].strip()
|
|
137
|
+
if len(clean_tel) >= 7:
|
|
138
|
+
phones.add(clean_tel)
|
|
139
|
+
|
|
140
|
+
# Secondary regex search in body text (exclude scripts/styles)
|
|
141
|
+
for s in soup(["script", "style", "noscript"]):
|
|
142
|
+
s.decompose()
|
|
143
|
+
body_text = soup.get_text()
|
|
144
|
+
|
|
145
|
+
for em in EMAIL_REGEX.findall(body_text):
|
|
146
|
+
if not em.endswith((".png", ".jpg", ".jpeg", ".webp", ".svg", ".gif")):
|
|
147
|
+
emails.add(em)
|
|
148
|
+
if len(emails) >= 5:
|
|
149
|
+
break
|
|
150
|
+
|
|
151
|
+
# 5. Social Profiles
|
|
152
|
+
social_profiles = extract_social_profiles(html, base_url=final_url)
|
|
153
|
+
|
|
154
|
+
# 6. Internal / Broken Links Spot-Check
|
|
155
|
+
base_domain = urlparse(final_url).netloc.lower()
|
|
156
|
+
internal_links_to_check: list[str] = []
|
|
157
|
+
for a in soup.find_all("a", href=True):
|
|
158
|
+
href = a["href"].strip()
|
|
159
|
+
if not href or href.startswith(("#", "javascript:", "mailto:", "tel:")):
|
|
160
|
+
continue
|
|
161
|
+
full_link = urljoin(final_url, href)
|
|
162
|
+
p_link = urlparse(full_link)
|
|
163
|
+
if p_link.netloc.lower() == base_domain and full_link != final_url:
|
|
164
|
+
if full_link not in internal_links_to_check:
|
|
165
|
+
internal_links_to_check.append(full_link)
|
|
166
|
+
if len(internal_links_to_check) >= MAX_BROKEN_LINKS_CHECK:
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
broken_links: list[dict[str, Any]] = []
|
|
170
|
+
if check_broken_links and internal_links_to_check:
|
|
171
|
+
for link in internal_links_to_check[:MAX_BROKEN_LINKS_CHECK]:
|
|
172
|
+
try:
|
|
173
|
+
head_resp = await client.head(link, timeout=5.0)
|
|
174
|
+
if head_resp.status_code >= 400:
|
|
175
|
+
broken_links.append({"url": link, "status_code": head_resp.status_code})
|
|
176
|
+
except Exception as e:
|
|
177
|
+
broken_links.append({"url": link, "error": str(e)})
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
"status": "success",
|
|
181
|
+
"url": final_url,
|
|
182
|
+
"available": resp.status_code < 400,
|
|
183
|
+
"http_status": resp.status_code,
|
|
184
|
+
"response_time_seconds": elapsed,
|
|
185
|
+
"protocol": {
|
|
186
|
+
"is_https": is_https,
|
|
187
|
+
"ssl_valid": ssl_valid,
|
|
188
|
+
"redirected": str(resp.url) != norm_url,
|
|
189
|
+
},
|
|
190
|
+
"metadata": {
|
|
191
|
+
"title": title,
|
|
192
|
+
"meta_description": meta_description,
|
|
193
|
+
"has_mobile_viewport": has_mobile_viewport,
|
|
194
|
+
},
|
|
195
|
+
"content_structure": {
|
|
196
|
+
"h1_headings": h1_tags,
|
|
197
|
+
"h2_headings_sample": h2_tags[:5],
|
|
198
|
+
"h2_count": len(h2_tags),
|
|
199
|
+
"images_total": len(images),
|
|
200
|
+
"images_missing_alt": len(images_missing_alt),
|
|
201
|
+
},
|
|
202
|
+
"contact_detection": {
|
|
203
|
+
"has_contact_form": has_contact_form,
|
|
204
|
+
"emails_found": list(emails)[:5],
|
|
205
|
+
"phones_found": list(phones)[:5],
|
|
206
|
+
},
|
|
207
|
+
"social_profiles": social_profiles,
|
|
208
|
+
"broken_internal_links": broken_links,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
finally:
|
|
212
|
+
if should_close:
|
|
213
|
+
await client.aclose()
|