import re import requests from bs4 import BeautifulSoup from urllib.parse import urljoin, urlparse from typing import Optional HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" ) } EMAIL_REGEX = re.compile( r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Z|a-z]{2,7}\b" ) PHONE_REGEX = re.compile( r"(?:(?:\+?1\s*(?:[.-]\s*)?)?(?:\(\s*([2-9]1[02-9]|[2-9][02-8]1|[2-9][02-8][02-9])\s*\)" r"|([2-9]1[02-9]|[2-9][02-8]1|[2-9][02-8][02-9]))\s*(?:[.-]\s*)?)" r"?([2-9]1[02-9]|[2-9][02-9]1|[2-9][02-9]{2})\s*(?:[.-]\s*)?([0-9]{4})" r"|(?:\+\d{1,3}[\s.-]?)?\(?\d{2,4}\)?[\s.-]?\d{3,4}[\s.-]?\d{3,4}" ) SKIP_EMAIL_DOMAINS = { "example.com", "test.com", "domain.com", "email.com", "yourdomain.com", "sentry.io", "wixpress.com" } def fetch_page(url: str, timeout: int = 10) -> Optional[BeautifulSoup]: try: resp = requests.get(url, headers=HEADERS, timeout=timeout, allow_redirects=True) resp.raise_for_status() return BeautifulSoup(resp.text, "lxml") except Exception: try: resp = requests.get(url, headers=HEADERS, timeout=timeout, allow_redirects=True) return BeautifulSoup(resp.text, "html.parser") except Exception: return None def extract_emails(soup: BeautifulSoup, page_text: str) -> list[str]: emails = set() # From mailto links for tag in soup.find_all("a", href=True): href = tag["href"] if href.startswith("mailto:"): email = href[7:].split("?")[0].strip() if email: emails.add(email.lower()) # From page text for match in EMAIL_REGEX.finditer(page_text): email = match.group().lower() domain = email.split("@")[-1] if domain not in SKIP_EMAIL_DOMAINS and not email.endswith((".png", ".jpg", ".gif")): emails.add(email) return list(emails) def extract_phones(page_text: str) -> list[str]: phones = set() for match in PHONE_REGEX.finditer(page_text): phone = match.group().strip() if len(re.sub(r"\D", "", phone)) >= 7: phones.add(phone) return list(phones) def extract_company_name(soup: BeautifulSoup, url: str) -> Optional[str]: # Try OG site name og_site = soup.find("meta", property="og:site_name") if og_site and og_site.get("content"): return og_site["content"].strip() # Try title tag title = soup.find("title") if title and title.text: name = title.text.strip().split("|")[0].split("-")[0].strip() if name: return name # Fallback: domain name domain = urlparse(url).netloc.replace("www.", "") return domain.split(".")[0].title() if domain else None def extract_social_links(soup: BeautifulSoup) -> dict: socials = {} patterns = { "linkedin": r"linkedin\.com/(?:company|in)/[\w\-]+", "twitter": r"twitter\.com/[\w]+", "facebook": r"facebook\.com/[\w\-\.]+", } for tag in soup.find_all("a", href=True): href = tag["href"] for platform, pattern in patterns.items(): if platform not in socials and re.search(pattern, href, re.IGNORECASE): socials[platform] = href return socials def scrape_url(url: str, config: dict) -> list[dict]: """Scrape a single URL and return extracted lead data.""" leads = [] if not url.startswith(("http://", "https://")): url = "https://" + url soup = fetch_page(url) if not soup: return leads page_text = soup.get_text(separator=" ", strip=True) emails = extract_emails(soup, page_text) if config.get("extract_emails", True) else [] phones = extract_phones(page_text) if config.get("extract_phones", True) else [] company_name = extract_company_name(soup, url) if config.get("extract_company_name", True) else None socials = extract_social_links(soup) # Apply custom CSS selectors if provided custom = config.get("custom_selectors", {}) or {} custom_data = {} for field, selector in custom.items(): el = soup.select_one(selector) custom_data[field] = el.get_text(strip=True) if el else None if emails: for email in emails: leads.append({ "company_name": company_name, "email": email, "phone": phones[0] if phones else None, "website": url, "linkedin_url": socials.get("linkedin"), "source": "web_scrape", "status": "new", "custom_fields": custom_data if custom_data else None, }) elif company_name or phones: leads.append({ "company_name": company_name, "email": None, "phone": phones[0] if phones else None, "website": url, "linkedin_url": socials.get("linkedin"), "source": "web_scrape", "status": "new", "custom_fields": custom_data if custom_data else None, }) # Follow internal links if configured if config.get("follow_links") and config.get("max_pages", 1) > 1: base = f"{urlparse(url).scheme}://{urlparse(url).netloc}" visited = {url} queue = [] for a in soup.find_all("a", href=True): href = a["href"] full = urljoin(base, href) if full.startswith(base) and full not in visited: queue.append(full) for link in queue[: config["max_pages"] - 1]: visited.add(link) sub_leads = scrape_url(link, {**config, "follow_links": False}) leads.extend(sub_leads) return leads