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.
Files changed (54) hide show
  1. leadintel/__init__.py +6 -0
  2. leadintel/__main__.py +6 -0
  3. leadintel/config.py +165 -0
  4. leadintel/data/__init__.py +1 -0
  5. leadintel/data/demo.py +315 -0
  6. leadintel/exporters/__init__.py +6 -0
  7. leadintel/exporters/csv_exporter.py +113 -0
  8. leadintel/exporters/excel_exporter.py +123 -0
  9. leadintel/models/__init__.py +6 -0
  10. leadintel/models/business.py +203 -0
  11. leadintel/models/report.py +31 -0
  12. leadintel/providers/__init__.py +51 -0
  13. leadintel/providers/base.py +54 -0
  14. leadintel/providers/demo_provider.py +65 -0
  15. leadintel/providers/overpass_provider.py +1040 -0
  16. leadintel/providers/registry.py +151 -0
  17. leadintel/server.py +61 -0
  18. leadintel/services/__init__.py +48 -0
  19. leadintel/services/ai_business_insights.py +310 -0
  20. leadintel/services/business_comparison.py +220 -0
  21. leadintel/services/business_report.py +294 -0
  22. leadintel/services/competitor_research.py +293 -0
  23. leadintel/services/lead_scoring.py +385 -0
  24. leadintel/services/local_ai.py +186 -0
  25. leadintel/services/robots_checker.py +81 -0
  26. leadintel/services/seo_auditor.py +281 -0
  27. leadintel/services/social_finder.py +119 -0
  28. leadintel/services/url_validator.py +295 -0
  29. leadintel/services/website_analyzer.py +213 -0
  30. leadintel/services/website_opportunity.py +523 -0
  31. leadintel/storage/__init__.py +12 -0
  32. leadintel/storage/db.py +189 -0
  33. leadintel/storage/path_validator.py +118 -0
  34. leadintel/tools/__init__.py +38 -0
  35. leadintel/tools/analyze_website.py +57 -0
  36. leadintel/tools/check_seo.py +64 -0
  37. leadintel/tools/compare_businesses.py +65 -0
  38. leadintel/tools/export_leads.py +112 -0
  39. leadintel/tools/find_business_website.py +66 -0
  40. leadintel/tools/find_competitors.py +75 -0
  41. leadintel/tools/find_social_profiles.py +85 -0
  42. leadintel/tools/generate_business_insights.py +93 -0
  43. leadintel/tools/generate_business_report.py +92 -0
  44. leadintel/tools/generate_outreach.py +153 -0
  45. leadintel/tools/get_business_details.py +96 -0
  46. leadintel/tools/get_saved_leads.py +58 -0
  47. leadintel/tools/score_lead.py +88 -0
  48. leadintel/tools/search_businesses.py +305 -0
  49. leadintel/tools/website_finder.py +147 -0
  50. leadintel-0.3.0.dist-info/METADATA +351 -0
  51. leadintel-0.3.0.dist-info/RECORD +54 -0
  52. leadintel-0.3.0.dist-info/WHEEL +4 -0
  53. leadintel-0.3.0.dist-info/entry_points.txt +3 -0
  54. 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()