File size: 14,203 Bytes
28291d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
"""
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'