import asyncio import json import os import random import tempfile import urllib.parse import time import logging from datetime import datetime, timezone import concurrent.futures import re from typing import Optional from dotenv import load_dotenv load_dotenv() # loads .env from project root → SERPER_API_KEY etc. from openai import OpenAI import httpx from fastapi import FastAPI, HTTPException, Query, BackgroundTasks from pydantic import BaseModel from starlette.responses import StreamingResponse from fastapi.middleware.cors import CORSMiddleware from bs4 import BeautifulSoup from school_classifier import classify_job, SCHOOLS, PROGRAM_KEYWORDS from locations import INDIAN_METROS from database import save_scraped_jobs, get_jobs_by_timeframe, get_jobs_in_timeframe, cleanup_old_jobs, get_binned_jobs, update_company_ratings, get_rated_companies, _connect # Primary engine: Selenium with undetected-chromedriver import undetected_chromedriver as uc from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By # Fallback engine: Playwright with stealth from playwright.async_api import async_playwright from playwright_stealth import Stealth from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger scheduler = AsyncIOScheduler() # ── Logging setup ───────────────────────────────────────────── logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) logger = logging.getLogger("internscrapper") logger.setLevel(logging.INFO) app = FastAPI(title="LinkedIn Public Job Scraper") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) # ── Shared resources ────────────────────────────────────────── class ScrapeSession: def __init__(self): self.history: list[str] = [] self.queues: list[asyncio.Queue] = [] self.is_active = False self.task: Optional[asyncio.Task] = None def cancel(self): self.is_active = False if self.task and not self.task.done(): self.task.cancel() global_scrape = ScrapeSession() executor = concurrent.futures.ThreadPoolExecutor(max_workers=6) _scrape_lock = asyncio.Lock() # ── Auto-scrape state (updated live so the admin UI can poll progress) ──── _auto_scrape_state: dict = { "is_running": False, "current_school": None, "completed": [], "failed": [], "started_at": None, "finished_at": None, "cancel_requested": False, } _auto_scrape_task: Optional[asyncio.Task] = None # ── Auto-scrape history (in-memory log of all sweep runs) ───── _auto_scrape_history: list[dict] = [] # HuggingFace Spaces sets PORT=7860 in its environment. # Locally, uvicorn defaults to 8000 unless overridden. _SELF_PORT = int(os.environ.get("PORT", "8000")) _SELF_BASE_URL = f"http://localhost:{_SELF_PORT}" se_driver = None # Selenium undetected-chromedriver pw_browser = None # Playwright browser pw_stealth_ctx = None # Playwright stealth context manager # Cookie warm-up cache — browser-derived cookies with a TTL. _warm_cookies: dict = {} _warm_cookies_ts: float = 0.0 _COOKIE_TTL_SECONDS = 600 # 10-minute TTL before re-warming # ── Lifecycle ───────────────────────────────────────────────── def _get_chrome_major_version() -> Optional[int]: """Helper to detect the installed Chrome major version on Windows/Linux to prevent driver mismatch.""" try: import winreg # Check user-level install / BLBeacon first try: with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Google\Chrome\BLBeacon") as key: version, _ = winreg.QueryValueEx(key, "version") return int(version.split(".")[0]) except Exception: pass # Check system-level WOW64 install try: with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Google Chrome") as key: version, _ = winreg.QueryValueEx(key, "DisplayVersion") return int(version.split(".")[0]) except Exception: pass # Check system-level 64-bit install try: with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Google Chrome") as key: version, _ = winreg.QueryValueEx(key, "DisplayVersion") return int(version.split(".")[0]) except Exception: pass except ImportError: # Linux/macOS import subprocess import re for cmd in ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]: try: output = subprocess.check_output([cmd, "--version"], stderr=subprocess.DEVNULL) version_str = output.decode("utf-8").strip() match = re.search(r"(\d+)\.", version_str) if match: return int(match.group(1)) except Exception: continue return None # ── User-Agent helpers ──────────────────────────────────────── def _build_user_agent(chrome_version: Optional[int] = None) -> str: """Build a realistic Chrome User-Agent string using the installed version.""" v = chrome_version or _get_chrome_major_version() or 131 return ( f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) " f"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{v}.0.0.0 Safari/537.36" ) def _build_ua_pool() -> list[str]: """Build a pool of realistic User-Agent strings for rotation.""" v = _get_chrome_major_version() or 131 return [ # Windows Chrome (primary — matches our Selenium fingerprint) f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{v}.0.0.0 Safari/537.36", # Windows Chrome (slightly older minor) f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{v-1}.0.0.0 Safari/537.36", # macOS Chrome f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{v}.0.0.0 Safari/537.36", # Linux Chrome f"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{v}.0.0.0 Safari/537.36", # Windows Edge (Chromium-based, same engine) f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{v}.0.0.0 Safari/537.36 Edg/{v}.0.0.0", ] _UA_POOL: list[str] = [] # populated at startup # ── Response validation helpers ─────────────────────────────── def _is_empty_stub(html: str) -> bool: """Detect LinkedIn's known empty/blocked response patterns. Returns True if the response is the 26-byte empty stub, a login wall, a CAPTCHA page, or any other pattern that indicates zero real content. """ stripped = html.strip() # 26-byte empty stub: ' ' if len(stripped) < 60 and "