| """ |
| Web Scraper - AI-friendly web content extraction using Playwright. |
| |
| Handles: |
| - Rendering JavaScript-heavy pages (Wellfound, React apps) |
| - Extracting page text content cleanly |
| - Following redirects |
| - Screenshot capture for AI analysis (fallback) |
| - Rate limiting and retry logic |
| """ |
|
|
| import asyncio |
| import re |
| from typing import Optional, Dict, Any, Tuple |
| from urllib.parse import urlparse |
|
|
| from bs4 import BeautifulSoup |
| from tenacity import retry, stop_after_attempt, wait_exponential |
|
|
|
|
| class WebScraper: |
| """Intelligent web scraper with JS rendering support.""" |
|
|
| def __init__(self, headless: bool = True, timeout: int = 30000): |
| self.headless = headless |
| self.timeout = timeout |
| self._browser = None |
| self._context = None |
| self._playwright = None |
|
|
| async def start(self): |
| """Initialize Playwright browser.""" |
| try: |
| from playwright.async_api import async_playwright |
| except ImportError: |
| raise ImportError( |
| "Playwright not installed. Run: pip install playwright && playwright install chromium" |
| ) |
|
|
| self._playwright = await async_playwright().start() |
| self._browser = await self._playwright.chromium.launch( |
| headless=self.headless, |
| args=[ |
| "--disable-blink-features=AutomationControlled", |
| "--no-sandbox", |
| "--disable-dev-shm-usage", |
| "--disable-web-security", |
| "--disable-features=IsolateOrigins,site-per-process", |
| ], |
| ) |
|
|
| async def stop(self): |
| """Clean up Playwright resources.""" |
| if self._browser: |
| await self._browser.close() |
| if self._playwright: |
| await self._playwright.stop() |
|
|
| async def _get_page(self): |
| """Get or create a browser page.""" |
| if not self._context: |
| self._context = await self._browser.new_context( |
| user_agent=( |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " |
| "AppleWebKit/537.36 (KHTML, like Gecko) " |
| "Chrome/125.0.0.0 Safari/537.36" |
| ), |
| viewport={"width": 1920, "height": 1080}, |
| java_script_enabled=True, |
| ) |
| return await self._context.new_page() |
|
|
| @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=15)) |
| async def fetch_page(self, url: str, wait_for_selector: Optional[str] = None) -> Dict[str, Any]: |
| """Fetch and render a web page, return content and metadata.""" |
| page = await self._get_page() |
| result = { |
| "url": url, |
| "final_url": url, |
| "title": "", |
| "text": "", |
| "html": "", |
| "status": 200, |
| "error": None, |
| "emails": [], |
| "links": [], |
| } |
|
|
| try: |
| response = await page.goto(url, wait_until="domcontentloaded", timeout=self.timeout) |
|
|
| if response: |
| result["status"] = response.status |
| result["final_url"] = response.url |
|
|
| if result["status"] >= 400: |
| result["error"] = f"HTTP {result['status']}" |
| return result |
|
|
| |
| if wait_for_selector: |
| try: |
| await page.wait_for_selector(wait_for_selector, timeout=10000) |
| except Exception: |
| pass |
|
|
| |
| await asyncio.sleep(1.5) |
|
|
| |
| await page.evaluate("window.scrollTo(0, document.body.scrollHeight)") |
| await asyncio.sleep(0.8) |
| await page.evaluate("window.scrollTo(0, 0)") |
| await asyncio.sleep(0.5) |
|
|
| |
| result["title"] = await page.title() |
| result["html"] = await page.content() |
|
|
| |
| text = await page.evaluate(""" |
| () => { |
| // Remove script, style, nav, iframe, noscript elements |
| // Keep footer — it often contains contact info and addresses |
| const clone = document.body.cloneNode(true); |
| const removes = clone.querySelectorAll('script, style, iframe, noscript, [aria-hidden="true"]'); |
| removes.forEach(el => el.remove()); |
| return clone.innerText || clone.textContent || ''; |
| } |
| """) |
| result["text"] = self._clean_text(text) |
|
|
| |
| result["emails"] = self._extract_emails(result["text"]) |
|
|
| |
| result["links"] = await page.evaluate(""" |
| () => { |
| const links = []; |
| const anchors = document.querySelectorAll('a[href]'); |
| const keywords = ['contact', 'about', 'career', 'job', 'team', 'press', |
| 'investor', 'blog', 'support', 'help', 'location', |
| 'office', 'join', 'work', 'talent', 'opening', |
| 'apply', 'who-we-are', 'our-story', 'people', |
| 'headquarter', 'map', 'address', 'reach']; |
| anchors.forEach(a => { |
| const href = a.href || ''; |
| const text = (a.innerText || '').trim().toLowerCase(); |
| const hrefLower = href.toLowerCase(); |
| if (keywords.some(k => hrefLower.includes(k) || text.includes(k))) { |
| links.push({ url: href, text: a.innerText?.trim() || '' }); |
| } |
| }); |
| // Also capture footer links (often contain contact info) |
| const footerLinks = document.querySelectorAll('footer a[href]'); |
| footerLinks.forEach(a => { |
| const href = a.href || ''; |
| const text = (a.innerText || '').trim().toLowerCase(); |
| const hrefLower = href.toLowerCase(); |
| if (href && !href.startsWith('javascript:')) { |
| links.push({ url: href, text: a.innerText?.trim() || '' }); |
| } |
| }); |
| // Deduplicate by URL |
| const seen = new Set(); |
| return links.filter(l => { |
| if (seen.has(l.url)) return false; |
| seen.add(l.url); |
| return true; |
| }).slice(0, 80); |
| } |
| """) |
|
|
| except Exception as e: |
| result["error"] = str(e)[:500] |
| finally: |
| await page.close() |
|
|
| return result |
|
|
| async def fetch_wellfound_page(self, url: str) -> Dict[str, Any]: |
| """Specialized fetch for Wellfound company pages to extract funding data.""" |
| result = await self.fetch_page(url, wait_for_selector="[class*='company'], [class*='profile'], h1") |
|
|
| if not result["error"]: |
| |
| funding_data = self._extract_wellfound_funding(result["text"]) |
| result["funding_data"] = funding_data |
|
|
| |
| soup = BeautifulSoup(result["html"], "lxml") |
| meta_tags = {} |
| for meta in soup.find_all("meta"): |
| name = meta.get("name") or meta.get("property", "") |
| content = meta.get("content", "") |
| if name and content: |
| meta_tags[name] = content |
| result["meta"] = meta_tags |
|
|
| return result |
|
|
| async def fetch_company_website(self, url: str) -> Dict[str, Any]: |
| """Fetch company website to extract contact info and address.""" |
| result = await self.fetch_page(url) |
|
|
| if not result["error"]: |
| |
| soup = BeautifulSoup(result["html"], "lxml") |
|
|
| |
| contact_links = [] |
| for a in soup.find_all("a", href=True): |
| href = a.get("href", "").lower() |
| text = (a.get_text() or "").strip().lower() |
| if any(kw in href or kw in text for kw in |
| ["contact", "support", "help", "about", "career", "job", "team"]): |
| contact_links.append({ |
| "url": a["href"], |
| "text": a.get_text(strip=True), |
| "type": self._classify_link(href, text), |
| }) |
|
|
| result["contact_links"] = contact_links[:30] |
|
|
| |
| phones = re.findall( |
| r'(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}', |
| result["text"] |
| ) |
| result["phones"] = list(set(phones))[:10] |
|
|
| |
| address_patterns = re.findall( |
| r'\d{1,5}\s+[\w\s.,]+(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Drive|Dr|Lane|Ln|Way|Court|Ct|Plaza|Plz|Suite|Ste)[\w\s.,]*(?:[A-Z]{2}\s+\d{5})?', |
| result["text"] |
| ) |
| result["addresses"] = list(set(address_patterns))[:5] |
|
|
| return result |
|
|
| def _clean_text(self, text: str) -> str: |
| """Clean extracted text.""" |
| |
| text = re.sub(r'[ \t]+', ' ', text) |
| text = re.sub(r'\n\s*\n+', '\n\n', text) |
| |
| lines = [l.strip() for l in text.split('\n') if len(l.strip()) > 1] |
| return '\n'.join(lines) |
|
|
| def _extract_emails(self, text: str) -> list: |
| """Extract email addresses from text.""" |
| email_pattern = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}' |
| emails = re.findall(email_pattern, text) |
|
|
| |
| excluded = {'example.com', 'test.com', 'domain.com', 'email.com', 'yourcompany.com'} |
| filtered = [] |
| for email in emails: |
| domain = email.split('@')[1].lower() if '@' in email else '' |
| if domain not in excluded and not domain.startswith('example'): |
| filtered.append(email.lower()) |
|
|
| return list(set(filtered)) |
|
|
| def _extract_wellfound_funding(self, text: str) -> Dict[str, Any]: |
| """Extract funding-related information from Wellfound page text.""" |
| data = { |
| "valuation": None, |
| "total_raised": None, |
| "rounds": None, |
| "series": None, |
| "investors": [], |
| } |
|
|
| |
| amount_patterns = [ |
| (r'\$(\d+(?:\.\d+)?)\s*(?:million|M|Billion|B)', lambda m: f"${m.group(1)}{'M' if 'million' in m.group(0).lower() or m.group(0).strip().endswith('M') else 'B'}"), |
| (r'\$(\d{1,3}(?:,\d{3})*(?:\.\d+)?)\s*(?:million|M)', lambda m: f"${m.group(1)}M"), |
| (r'\$(\d{1,3}(?:,\d{3})*(?:\.\d+)?)\s*(?:billion|B)', lambda m: f"${m.group(1)}B"), |
| (r'valuation[:\s]*\$(\d+(?:\.\d+)?)\s*(million|M|billion|B)', lambda m: f"${m.group(1)}{m.group(2)[0].upper()}"), |
| (r'total funding[:\s]*\$(\d+(?:\.\d+)?)\s*(million|M|billion|B)', lambda m: f"${m.group(1)}{m.group(2)[0].upper()}"), |
| ] |
|
|
| for line in text.split('\n'): |
| line_lower = line.lower().strip() |
|
|
| |
| if any(kw in line_lower for kw in ['valuation', 'valued at']): |
| for pattern, fmt in amount_patterns: |
| match = re.search(pattern, line, re.IGNORECASE) |
| if match: |
| data["valuation"] = fmt(match) |
| break |
|
|
| |
| if any(kw in line_lower for kw in ['total funding', 'total raised', 'raised']): |
| for pattern, fmt in amount_patterns: |
| match = re.search(pattern, line, re.IGNORECASE) |
| if match: |
| data["total_raised"] = fmt(match) |
| break |
|
|
| |
| series_match = re.search( |
| r'(?:Series|Seed|Pre[-\s]?seed)\s*([A-F])?\b', |
| line, re.IGNORECASE |
| ) |
| if series_match: |
| data["series"] = series_match.group(0).strip() |
|
|
| |
| rounds_match = re.search(r'(\d+)\s*(?:funding\s*)?rounds?', line, re.IGNORECASE) |
| if rounds_match: |
| data["rounds"] = int(rounds_match.group(1)) |
|
|
| |
| investor_keywords = ['investor', 'backed by', 'funded by'] |
| if any(kw in line_lower for kw in investor_keywords): |
| |
| for kw in investor_keywords: |
| if kw in line_lower: |
| idx = line_lower.index(kw) + len(kw) |
| investors_text = line[idx:].strip(' :,;') |
| data["investors"].append(investors_text) |
| break |
|
|
| return data |
|
|
| def _classify_link(self, href: str, text: str) -> str: |
| """Classify a link by its type.""" |
| combined = f"{href} {text}".lower() |
| if any(kw in combined for kw in ['contact', 'reach', 'get in touch']): |
| return 'contact' |
| elif any(kw in combined for kw in ['career', 'job', 'work with', 'join']): |
| return 'career' |
| elif any(kw in combined for kw in ['about', 'company', 'our story']): |
| return 'about' |
| elif any(kw in combined for kw in ['team', 'people', 'leadership']): |
| return 'team' |
| elif any(kw in combined for kw in ['support', 'help', 'faq']): |
| return 'support' |
| else: |
| return 'other' |
|
|