Wellfound / core /scraper.py
Zoey7Web's picture
Upload 21 files
28291d7 verified
Raw
History Blame Contribute Delete
14.2 kB
"""
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
# Wait for key content to load
if wait_for_selector:
try:
await page.wait_for_selector(wait_for_selector, timeout=10000)
except Exception:
pass
# Let JS finish rendering
await asyncio.sleep(1.5)
# Scroll to load lazy content
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)
# Extract content
result["title"] = await page.title()
result["html"] = await page.content()
# Clean text extraction (keep footer — addresses/emails often there)
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)
# Extract emails
result["emails"] = self._extract_emails(result["text"])
# Extract important links (broader set for deep scanning)
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"]:
# Wellfound-specific extraction: look for funding/valuation patterns in text
funding_data = self._extract_wellfound_funding(result["text"])
result["funding_data"] = funding_data
# Additional: try to extract structured data from meta tags
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"]:
# Extract additional contact patterns
soup = BeautifulSoup(result["html"], "lxml")
# Find contact page links
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]
# Extract phone numbers
phones = re.findall(
r'(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}',
result["text"]
)
result["phones"] = list(set(phones))[:10]
# Extract physical addresses (US-focused)
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."""
# Remove excessive whitespace
text = re.sub(r'[ \t]+', ' ', text)
text = re.sub(r'\n\s*\n+', '\n\n', text)
# Remove very short lines (usually noise)
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)
# Filter out common false positives
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": [],
}
# Look for funding amounts
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()
# Valuation
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
# Total raised
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/Rounds
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()
# Round count
rounds_match = re.search(r'(\d+)\s*(?:funding\s*)?rounds?', line, re.IGNORECASE)
if rounds_match:
data["rounds"] = int(rounds_match.group(1))
# Investors
investor_keywords = ['investor', 'backed by', 'funded by']
if any(kw in line_lower for kw in investor_keywords):
# Extract what comes after
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'