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,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