| """ |
| Wellfound AI - Main FastAPI Application |
| |
| Automated Excel data completion tool with: |
| - AI-powered deep website understanding |
| - Smart address processing (US Census regions) |
| - Multi-source contact finding (email priority decision tree) |
| - Deep multi-page website scanning (careers, contact, about, team, locations) |
| - Breakpoint resume support |
| """ |
|
|
| import asyncio |
| import hashlib |
| import json |
| import os |
| import time |
| import uuid |
| from datetime import datetime |
| from pathlib import Path |
| from typing import Optional, Dict, Any, List |
|
|
| import pandas as pd |
| import openpyxl |
| from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Request |
| from fastapi.responses import FileResponse, HTMLResponse, JSONResponse |
| from fastapi.staticfiles import StaticFiles |
| from fastapi.middleware.cors import CORSMiddleware |
|
|
| from core.excel_handler import ExcelHandler |
| from core.scraper import WebScraper |
| from core.ai_extractor import AIExtractor, DecisionEngine |
| from core.address_processor import AddressProcessor |
| from core.contact_finder import ContactFinder |
| from core.degradation_manager import DegradationManager, DegradationLogger |
|
|
| |
| app = FastAPI( |
| title="Wellfound AI - Excel Data Completion", |
| description="AI-powered automated Excel data completion for Wellfound exports", |
| version="3.0.0", |
| ) |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_credentials=True, |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| |
| static_dir = Path(__file__).resolve().parent / "static" |
| static_dir.mkdir(parents=True, exist_ok=True) |
| app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") |
|
|
| |
| DATA_DIR = Path(__file__).resolve().parent / "data" |
| UPLOAD_DIR = DATA_DIR / "uploads" |
| RESULT_DIR = DATA_DIR / "results" |
| CHECKPOINT_DIR = DATA_DIR / "checkpoints" |
| for d in [UPLOAD_DIR, RESULT_DIR, CHECKPOINT_DIR]: |
| d.mkdir(parents=True, exist_ok=True) |
|
|
| |
| processing_tasks: dict = {} |
| excel_handler = ExcelHandler(str(CHECKPOINT_DIR)) |
| address_processor = AddressProcessor() |
| contact_finder = ContactFinder() |
| degradation_logger = DegradationLogger(str(DATA_DIR / "logs")) |
| degradation_manager = DegradationManager(degradation_logger) |
|
|
|
|
| |
|
|
| @app.get("/", response_class=HTMLResponse) |
| async def index(): |
| """Serve the main application page.""" |
| template_path = Path(__file__).parent / "templates" / "index.html" |
| if template_path.exists(): |
| return template_path.read_text(encoding="utf-8") |
| return HTMLResponse("<h1>Wellfound AI</h1><p>Frontend not found.</p>") |
|
|
|
|
| @app.get("/api/health") |
| async def health(): |
| """Health check endpoint.""" |
| return { |
| "status": "ok", |
| "timestamp": datetime.now().isoformat(), |
| "version": "3.0.0", |
| "modules": { |
| "excel": True, |
| "scraper": True, |
| "ai_extractor": True, |
| "address_processor": True, |
| "contact_finder": True, |
| "decision_engine": True, |
| }, |
| } |
|
|
|
|
| @app.post("/api/upload") |
| async def upload_file(file: UploadFile = File(...)): |
| """Upload Excel file and return metadata.""" |
| if not file.filename.endswith((".xlsx", ".xls")): |
| raise HTTPException(400, "Only Excel files (.xlsx, .xls) are supported") |
|
|
| file_id = uuid.uuid4().hex[:12] |
| file_path = UPLOAD_DIR / f"{file_id}_{file.filename}" |
| content = await file.read() |
| file_path.write_bytes(content) |
|
|
| try: |
| df = excel_handler.load(str(file_path)) |
| except Exception as e: |
| file_path.unlink(missing_ok=True) |
| raise HTTPException(400, f"Failed to read Excel file: {str(e)}") |
|
|
| return { |
| "file_id": file_id, |
| "filename": file.filename, |
| "rows": len(df), |
| "columns": list(df.columns), |
| "missing_data": { |
| col: int(df[col].isnull().sum()) |
| for col in df.columns |
| if df[col].isnull().sum() > 0 |
| }, |
| } |
|
|
|
|
| @app.post("/api/process") |
| async def process_file(request: Request): |
| """Start processing an uploaded Excel file.""" |
| data = await request.json() |
| file_id = data.get("file_id") |
| api_key = data.get("api_key", "") |
| provider = data.get("provider", "openai") |
| model = data.get("model", "auto") |
| start_row = data.get("start_row", 0) |
| max_rows = data.get("max_rows", 0) |
| use_ai = data.get("use_ai", True) |
| use_web_scraping = data.get("use_web_scraping", True) |
| concurrency = data.get("concurrency", 2) |
|
|
| if not file_id: |
| raise HTTPException(400, "file_id is required") |
|
|
| |
| files = list(UPLOAD_DIR.glob(f"{file_id}_*")) |
| if not files: |
| raise HTTPException(404, "Uploaded file not found") |
| file_path = str(files[0]) |
|
|
| |
| if use_ai and not api_key: |
| raise HTTPException(400, "API key is required when AI extraction is enabled") |
|
|
| task_id = uuid.uuid4().hex[:8] |
| processing_tasks[task_id] = { |
| "status": "starting", |
| "progress": 0, |
| "total": 0, |
| "current": "", |
| "errors": [], |
| "file_id": file_id, |
| "result_path": None, |
| } |
|
|
| |
| asyncio.create_task( |
| _process_file( |
| task_id=task_id, |
| file_path=file_path, |
| api_key=api_key, |
| provider=provider, |
| model=model, |
| start_row=start_row, |
| max_rows=max_rows, |
| use_ai=use_ai, |
| use_web_scraping=use_web_scraping, |
| concurrency=concurrency, |
| ) |
| ) |
|
|
| return {"task_id": task_id, "status": "started"} |
|
|
|
|
| @app.get("/api/status/{task_id}") |
| async def task_status(task_id: str): |
| """Get processing task status.""" |
| if task_id not in processing_tasks: |
| raise HTTPException(404, "Task not found") |
|
|
| task = processing_tasks[task_id] |
| return { |
| "task_id": task_id, |
| "status": task["status"], |
| "progress": task["progress"], |
| "total": task["total"], |
| "current": task["current"], |
| "errors": task["errors"][-10:], |
| "error_count": len(task["errors"]), |
| "has_result": task["result_path"] is not None, |
| } |
|
|
|
|
| @app.get("/api/download/{task_id}") |
| async def download_result(task_id: str): |
| """Download the processed Excel file.""" |
| if task_id not in processing_tasks: |
| raise HTTPException(404, "Task not found") |
|
|
| task = processing_tasks[task_id] |
| if not task["result_path"] or not os.path.exists(task["result_path"]): |
| raise HTTPException(404, "Result file not ready yet") |
|
|
| return FileResponse( |
| task["result_path"], |
| filename=f"wellfound_processed_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx", |
| media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", |
| ) |
|
|
|
|
| @app.get("/api/providers") |
| async def get_providers(): |
| """Get available AI providers and models.""" |
| return AIExtractor.PROVIDERS |
|
|
|
|
| @app.get("/api/degradation/summary") |
| async def degradation_summary(): |
| """Get degradation statistics for the current session.""" |
| return degradation_logger.get_session_summary() |
|
|
|
|
| @app.get("/api/degradation/recent") |
| async def degradation_recent(limit: int = 20): |
| """Get recent degradation events.""" |
| return degradation_logger.get_recent_entries(limit) |
|
|
|
|
| |
|
|
| @app.post("/api/decision/location") |
| async def decide_location(request: Request): |
| """Determine best US location for a company.""" |
| data = await request.json() |
| ai_data = data.get("ai_data", {}) |
| wellfound_location = data.get("wellfound_location", "") |
| website_addresses = data.get("website_addresses", []) |
|
|
| is_us = DecisionEngine.is_us_office(ai_data) |
| city, state = DecisionEngine.determine_best_us_location( |
| ai_data, wellfound_location, website_addresses |
| ) |
|
|
| return { |
| "has_us_office": is_us, |
| "location_apply": city, |
| "state_apply": state, |
| } |
|
|
|
|
| @app.post("/api/decision/contact") |
| async def decide_contact(request: Request): |
| """Rank contact methods by priority and return the best one.""" |
| data = await request.json() |
| email = data.get("email") |
| contact_form_url = data.get("contact_form_url") |
| careers_page_url = data.get("careers_page_url") |
|
|
| best = DecisionEngine.rank_contact_email( |
| email, contact_form_url, careers_page_url |
| ) |
|
|
| |
| if email: |
| email_lower = email.lower() |
| hr_patterns = [ |
| "careers@", "career@", "jobs@", "job@", |
| "recruiting@", "recruit@", "talent@", |
| "hr@", "hiring@", "people@", |
| ] |
| if any(p in email_lower for p in hr_patterns): |
| priority = "hiring_email" |
| else: |
| priority = "general_email" |
| elif contact_form_url: |
| priority = "contact_form" |
| elif careers_page_url: |
| priority = "careers_page" |
| else: |
| priority = "none" |
|
|
| return { |
| "best_contact": best, |
| "priority": priority, |
| } |
|
|
|
|
| |
|
|
| async def _process_file( |
| task_id: str, |
| file_path: str, |
| api_key: str, |
| provider: str, |
| model: str, |
| start_row: int, |
| max_rows: int, |
| use_ai: bool, |
| use_web_scraping: bool, |
| concurrency: int, |
| ): |
| """Main processing pipeline with deep multi-page website scanning.""" |
| task = processing_tasks[task_id] |
| scraper = None |
| ai_extractor = None |
|
|
| try: |
| |
| task["status"] = "loading" |
| df = excel_handler.load(file_path) |
|
|
| |
| result_path = str(RESULT_DIR / f"processed_{task_id}.xlsx") |
| df.to_excel(result_path, index=False, engine="openpyxl") |
|
|
| |
| wb = openpyxl.load_workbook(result_path) |
| ws = wb.active |
| header_row = [cell.value for cell in ws[1]] |
| if "ε€ζ³¨" not in header_row: |
| next_col = len(header_row) + 1 |
| ws.cell(row=1, column=next_col, value="ε€ζ³¨") |
| wb.save(result_path) |
| wb.close() |
|
|
| task["result_path"] = result_path |
|
|
| total_rows = len(df) |
| if max_rows > 0: |
| total_rows = min(max_rows, total_rows - start_row) |
| else: |
| total_rows = total_rows - start_row |
|
|
| task["total"] = total_rows |
| task["status"] = "processing" |
|
|
| |
| if use_web_scraping: |
| scraper = WebScraper(headless=True) |
| await scraper.start() |
|
|
| if use_ai and api_key: |
| ai_extractor = AIExtractor(provider=provider, api_key=api_key, model=model) |
|
|
| |
| semaphore = asyncio.Semaphore(concurrency) |
| processed = 0 |
|
|
| async def process_row(idx: int): |
| nonlocal processed |
| async with semaphore: |
| try: |
| row = df.iloc[idx].to_dict() |
| company = str(row.get("Company Name", "")) |
| internal_link = str(row.get("Internal Link", "")) |
| external_link = str(row.get("External Link", "")) |
| location = str(row.get("Location", "")) |
|
|
| task["current"] = f"Row {idx + 1}/{start_row + total_rows}: {company}" |
|
|
| updates = {} |
|
|
| |
| if use_web_scraping and internal_link and internal_link != "nan": |
| funding_data = await _extract_funding( |
| scraper, ai_extractor, company, internal_link |
| ) |
| if funding_data: |
| for col_key, excel_col in [ |
| ("valuation", "Valuation"), |
| ("rounds", "Rounds"), |
| ("series", "Series"), |
| ("total_raised", "Total Raised"), |
| ]: |
| if funding_data.get(col_key) and pd.isna(row.get(excel_col)): |
| updates[excel_col] = funding_data[col_key] |
|
|
| |
| |
| |
| if use_web_scraping and external_link and external_link != "nan": |
| ai_result = await _deep_scan_company( |
| scraper, ai_extractor, company, external_link, location, idx |
| ) |
| if ai_result: |
| remark = ai_result.get("remark") |
|
|
| |
| if pd.isna(row.get("contact")): |
| contact = ai_result.get("contact") |
| if contact: |
| updates["contact"] = contact |
|
|
| |
| if pd.isna(row.get("location.apply")): |
| loc = ai_result.get("location_apply") |
| if loc: |
| updates["location.apply"] = loc |
| if pd.isna(row.get("state.apply")): |
| state = ai_result.get("state_apply") |
| if state: |
| updates["state.apply"] = state |
|
|
| |
| if remark: |
| updates["ε€ζ³¨"] = remark |
|
|
| |
| if updates: |
| excel_handler.save_row(result_path, idx, updates) |
|
|
| processed += 1 |
| task["progress"] = processed |
|
|
| except Exception as e: |
| task["errors"].append(f"Row {idx}: {str(e)[:200]}") |
|
|
| |
| end_row = start_row + total_rows |
| tasks_list = [ |
| process_row(i) for i in range(start_row, min(end_row, len(df))) |
| ] |
| await asyncio.gather(*tasks_list, return_exceptions=True) |
|
|
| task["status"] = "completed" |
| task["progress"] = total_rows |
|
|
| |
| summary = degradation_logger.get_session_summary() |
| if summary["total_degradations"] > 0: |
| task["degradation_summary"] = summary |
| degradation_logger._file_logger.info( |
| "Processing complete | task=%s | total_degradations=%d | affected_rows=%s", |
| task_id, summary["total_degradations"], summary["affected_rows"][:20], |
| ) |
|
|
| except Exception as e: |
| task["status"] = "error" |
| task["errors"].append(f"Fatal: {str(e)}") |
|
|
| finally: |
| if scraper: |
| await scraper.stop() |
|
|
|
|
| async def _deep_scan_company( |
| scraper: WebScraper, |
| ai_extractor: Optional[AIExtractor], |
| company: str, |
| url: str, |
| wellfound_location: str = "", |
| row_idx: int = 0, |
| ) -> dict: |
| """Deep scan a company website and determine location.apply, state.apply, |
| and contact β with automatic degradation when AI fails. |
| |
| Pipeline: |
| 1. Fetch homepage β extract emails, links, phones, addresses, text |
| 2. Discover and follow key sub-pages (careers, contact, about, locations, team) |
| 3. Aggregate ALL content from all pages |
| 4. Send aggregated content to AI with the decision prompt β get final fields |
| 5. If AI fails β DegradationManager handles fallback: |
| - contact_finder for email scoring + URL detection |
| - address_processor for address parsing |
| - "ε€ζ³¨" column tagged with source: "non-ai" + failure reason |
| - Error logged with full traceability |
| |
| Returns: |
| Dict with: location_apply, state_apply, contact, remark (nullable). |
| """ |
| result = { |
| "location_apply": None, |
| "state_apply": None, |
| "contact": None, |
| "remark": None, |
| } |
|
|
| try: |
| |
| homepage = await scraper.fetch_company_website(url) |
| if homepage.get("error"): |
| |
| await degradation_manager.handle_degradation( |
| row_idx=row_idx, |
| company=company, |
| ai_extractor=ai_extractor, |
| scraped_data={"base_url": url}, |
| wellfound_location=wellfound_location, |
| ) |
| return result |
|
|
| homepage_emails = homepage.get("emails", []) |
| homepage_links = homepage.get("links", []) |
| homepage_phones = homepage.get("phones", []) |
| homepage_addresses = homepage.get("addresses", []) |
| homepage_text = homepage.get("text", "") |
|
|
| all_emails = list(homepage_emails) |
| all_addresses = list(homepage_addresses) |
| all_text_parts = [homepage_text] |
|
|
| |
| sub_pages_to_visit = [] |
| visited_urls = set() |
|
|
| for link in homepage_links: |
| link_url = link.get("url", "") |
| link_text = (link.get("text", "") or "").lower() |
| href_lower = link_url.lower() |
| resolved = contact_finder._resolve_url(url, link_url) |
|
|
| if resolved in visited_urls or resolved == url: |
| continue |
| if not resolved.startswith(("http://", "https://")): |
| continue |
| if any(domain in resolved for domain in [ |
| "facebook.com", "twitter.com", "x.com", "linkedin.com", |
| "instagram.com", "youtube.com", "github.com", |
| ]): |
| continue |
|
|
| visited_urls.add(resolved) |
|
|
| if any(kw in href_lower or kw in link_text for kw in |
| ["career", "job", "join", "work-with", "opening", "position", "talent"]): |
| sub_pages_to_visit.append(("careers", resolved)) |
| elif any(kw in href_lower or kw in link_text for kw in |
| ["contact", "reach", "get-in-touch", "connect"]): |
| sub_pages_to_visit.append(("contact", resolved)) |
| elif any(kw in href_lower or kw in link_text for kw in |
| ["about", "company", "our-story", "who-we-are"]): |
| sub_pages_to_visit.append(("about", resolved)) |
| elif any(kw in href_lower or kw in link_text for kw in |
| ["team", "people", "leadership"]): |
| sub_pages_to_visit.append(("team", resolved)) |
| elif any(kw in href_lower or kw in link_text for kw in |
| ["location", "office", "headquarter"]): |
| sub_pages_to_visit.append(("location", resolved)) |
|
|
| page_priority = {"careers": 0, "contact": 1, "about": 2, "location": 3, "team": 4} |
| sub_pages_to_visit.sort(key=lambda x: page_priority.get(x[0], 99)) |
|
|
| for page_type, page_url in sub_pages_to_visit[:4]: |
| try: |
| sub_page = await scraper.fetch_page(page_url) |
| if sub_page.get("text") and not sub_page.get("error"): |
| all_text_parts.append(sub_page["text"]) |
| all_emails.extend(sub_page.get("emails", [])) |
| sub_addrs = sub_page.get("addresses", []) |
| if sub_addrs: |
| all_addresses.extend(sub_addrs) |
| except Exception: |
| continue |
|
|
| |
| seen = set() |
| unique_emails = [] |
| for e in all_emails: |
| el = e.lower() |
| if el not in seen: |
| seen.add(el) |
| unique_emails.append(e) |
|
|
| |
| seen_addrs = set() |
| unique_addrs = [] |
| for a in all_addresses: |
| al = a.strip().lower() |
| if al not in seen_addrs: |
| seen_addrs.add(al) |
| unique_addrs.append(a) |
|
|
| |
| combined_text = "\n\n---\n\n".join(all_text_parts) |
|
|
| |
| scraped_data = { |
| "emails": unique_emails, |
| "links": homepage_links, |
| "addresses": unique_addrs, |
| "html": homepage.get("html", ""), |
| "base_url": url, |
| "combined_text": combined_text, |
| } |
|
|
| if ai_extractor and combined_text: |
| try: |
| ai_data = await ai_extractor.analyze_company_website( |
| company_name=company, |
| page_text=combined_text, |
| emails=unique_emails, |
| links=homepage_links, |
| phones=homepage_phones, |
| addresses=unique_addrs, |
| wellfound_location=wellfound_location, |
| ) |
|
|
| |
| failure_type = degradation_manager.detect_failure( |
| ai_extractor=ai_extractor, |
| ai_result=ai_data, |
| ) |
|
|
| if failure_type is None: |
| |
| result["location_apply"] = ai_data.get("location_apply") |
| result["state_apply"] = ai_data.get("state_apply") |
| result["contact"] = ai_data.get("contact") |
| return result |
|
|
| |
| degraded = await degradation_manager.handle_degradation( |
| row_idx=row_idx, |
| company=company, |
| ai_extractor=ai_extractor, |
| ai_result=ai_data, |
| scraped_data=scraped_data, |
| wellfound_location=wellfound_location, |
| ) |
| return degraded |
|
|
| except Exception as exc: |
| |
| degraded = await degradation_manager.handle_degradation( |
| row_idx=row_idx, |
| company=company, |
| ai_extractor=ai_extractor, |
| exc=exc, |
| scraped_data=scraped_data, |
| wellfound_location=wellfound_location, |
| ) |
| return degraded |
|
|
| |
| degraded = await degradation_manager.handle_degradation( |
| row_idx=row_idx, |
| company=company, |
| ai_extractor=ai_extractor, |
| scraped_data=scraped_data, |
| wellfound_location=wellfound_location, |
| ) |
| return degraded |
|
|
| except Exception as outer_exc: |
| |
| try: |
| await degradation_manager.handle_degradation( |
| row_idx=row_idx, |
| company=company, |
| ai_extractor=ai_extractor, |
| exc=outer_exc, |
| scraped_data={"base_url": url}, |
| wellfound_location=wellfound_location, |
| ) |
| except Exception: |
| pass |
|
|
| return result |
|
|
|
|
| async def _extract_funding( |
| scraper: Optional[WebScraper], |
| ai_extractor: Optional[AIExtractor], |
| company: str, |
| url: str, |
| ) -> dict: |
| """Extract funding information from wellfound page.""" |
| result = {} |
|
|
| if not scraper: |
| return result |
|
|
| try: |
| page_data = await scraper.fetch_wellfound_page(url) |
|
|
| if page_data.get("error"): |
| return result |
|
|
| |
| funding_data = page_data.get("funding_data", {}) |
|
|
| |
| if ai_extractor and page_data.get("text"): |
| try: |
| ai_data = await ai_extractor.analyze_funding_page( |
| company, |
| page_data["text"], |
| page_data.get("meta"), |
| ) |
|
|
| |
| for key in ["valuation", "total_raised", "rounds", "series"]: |
| ai_val = ai_data.get(key) |
| ai_conf = ai_data.get("confidence", {}).get(key, "low") |
| regex_val = funding_data.get(key) |
|
|
| if ai_val and ai_conf == "high": |
| result[key] = ai_val |
| elif regex_val: |
| result[key] = regex_val |
| elif ai_val: |
| result[key] = ai_val |
| except Exception: |
| |
| for key in ["valuation", "total_raised", "rounds", "series"]: |
| if funding_data.get(key): |
| result[key] = funding_data[key] |
| else: |
| |
| for key in ["valuation", "total_raised", "rounds", "series"]: |
| if funding_data.get(key): |
| result[key] = funding_data[key] |
|
|
| except Exception: |
| pass |
|
|
| return result |
|
|
|
|
| |
|
|
| if __name__ == "__main__": |
| import uvicorn |
|
|
| |
| hf_port = os.environ.get("PORT") or os.environ.get("HF_SPACE_PORT") |
| if hf_port: |
| port = int(hf_port) |
| print(f"\n HF Spaces detected: using PORT={port}") |
| else: |
| port = 7860 |
| |
| import socket |
| try: |
| s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
| s.bind(("0.0.0.0", port)) |
| s.close() |
| except OSError: |
| for alt in range(7861, 7870): |
| try: |
| s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
| s.bind(("0.0.0.0", alt)) |
| s.close() |
| port = alt |
| break |
| except OSError: |
| continue |
|
|
| print(f"\n{'='*50}") |
| print(f" Wellfound AI - Server Starting") |
| print(f" URL: http://0.0.0.0:{port}") |
| print(f" Data Dir: {DATA_DIR}") |
| print(f"{'='*50}\n") |
|
|
| uvicorn.run( |
| app, |
| host="0.0.0.0", |
| port=port, |
| log_level="info", |
| timeout_keep_alive=30, |
| ) |
|
|