Sambhavvvvv commited on
Commit
899fd4d
·
verified ·
1 Parent(s): a8adf8c

Upload main.py

Browse files
Files changed (1) hide show
  1. main.py +2343 -0
main.py ADDED
@@ -0,0 +1,2343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import random
5
+ import tempfile
6
+ import urllib.parse
7
+ import time
8
+ import logging
9
+ from datetime import datetime, timezone
10
+ import concurrent.futures
11
+ import re
12
+ from typing import Optional
13
+
14
+ from dotenv import load_dotenv
15
+ load_dotenv() # loads .env from project root → SERPER_API_KEY etc.
16
+
17
+ from openai import OpenAI
18
+
19
+ import httpx
20
+ from fastapi import FastAPI, HTTPException, Query, BackgroundTasks
21
+ from pydantic import BaseModel
22
+ from starlette.responses import StreamingResponse
23
+ from fastapi.middleware.cors import CORSMiddleware
24
+ from bs4 import BeautifulSoup
25
+
26
+ from school_classifier import classify_job, SCHOOLS, PROGRAM_KEYWORDS
27
+ from locations import INDIAN_METROS
28
+ from database import save_scraped_jobs, get_jobs_by_timeframe, get_jobs_in_timeframe, cleanup_old_jobs, get_binned_jobs, update_company_ratings, _connect, get_existing_jobs_contacts, get_companies_with_ratings
29
+
30
+ # Primary engine: Selenium with undetected-chromedriver
31
+ import undetected_chromedriver as uc
32
+ from selenium.webdriver.support.ui import WebDriverWait
33
+ from selenium.webdriver.support import expected_conditions as EC
34
+ from selenium.webdriver.common.by import By
35
+
36
+ # Fallback engine: Playwright with stealth
37
+ from playwright.async_api import async_playwright
38
+ from playwright_stealth import Stealth
39
+
40
+
41
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
42
+ from apscheduler.triggers.interval import IntervalTrigger
43
+
44
+ scheduler = AsyncIOScheduler()
45
+
46
+ # ── Logging setup ─────────────────────────────────────────────
47
+ logging.basicConfig(
48
+ level=logging.INFO,
49
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
50
+ datefmt="%Y-%m-%d %H:%M:%S",
51
+ )
52
+ logger = logging.getLogger("internscrapper")
53
+ logger.setLevel(logging.INFO)
54
+
55
+ app = FastAPI(title="LinkedIn Public Job Scraper")
56
+
57
+ app.add_middleware(
58
+ CORSMiddleware,
59
+ allow_origins=["*"],
60
+ allow_credentials=False,
61
+ allow_methods=["*"],
62
+ allow_headers=["*"],
63
+ )
64
+
65
+ # ── Shared resources ──────────────────────────────────────────
66
+ class ScrapeSession:
67
+ def __init__(self):
68
+ self.history: list[str] = []
69
+ self.queues: list[asyncio.Queue] = []
70
+ self.is_active = False
71
+ self.task: Optional[asyncio.Task] = None
72
+
73
+ def cancel(self):
74
+ self.is_active = False
75
+ if self.task and not self.task.done():
76
+ self.task.cancel()
77
+
78
+ global_scrape = ScrapeSession()
79
+
80
+ executor = concurrent.futures.ThreadPoolExecutor(max_workers=6)
81
+ _scrape_lock = asyncio.Lock()
82
+
83
+ # ── Auto-scrape state (updated live so the admin UI can poll progress) ────
84
+ _auto_scrape_state: dict = {
85
+ "is_running": False,
86
+ "current_school": None,
87
+ "completed": [],
88
+ "failed": [],
89
+ "started_at": None,
90
+ "finished_at": None,
91
+ "cancel_requested": False,
92
+ }
93
+ _auto_scrape_task: Optional[asyncio.Task] = None
94
+
95
+ # ── Auto-scrape history (in-memory log of all sweep runs) ─────
96
+ _auto_scrape_history: list[dict] = []
97
+
98
+ # HuggingFace Spaces sets PORT=7860 in its environment.
99
+ # Locally, uvicorn defaults to 8000 unless overridden.
100
+ _SELF_PORT = int(os.environ.get("PORT", "8000"))
101
+ _SELF_BASE_URL = f"http://localhost:{_SELF_PORT}"
102
+
103
+ se_driver = None # Selenium undetected-chromedriver
104
+ pw_browser = None # Playwright browser
105
+ pw_stealth_ctx = None # Playwright stealth context manager
106
+
107
+ # Cookie warm-up cache — browser-derived cookies with a TTL.
108
+ _warm_cookies: dict = {}
109
+ _warm_cookies_ts: float = 0.0
110
+ _COOKIE_TTL_SECONDS = 600 # 10-minute TTL before re-warming
111
+
112
+
113
+ # ── Lifecycle ─────────────────────────────────────────────────
114
+
115
+ def _get_chrome_major_version() -> Optional[int]:
116
+ """Helper to detect the installed Chrome major version on Windows/Linux to prevent driver mismatch."""
117
+ try:
118
+ import winreg
119
+ # Check user-level install / BLBeacon first
120
+ try:
121
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Google\Chrome\BLBeacon") as key:
122
+ version, _ = winreg.QueryValueEx(key, "version")
123
+ return int(version.split(".")[0])
124
+ except Exception:
125
+ pass
126
+ # Check system-level WOW64 install
127
+ try:
128
+ with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Google Chrome") as key:
129
+ version, _ = winreg.QueryValueEx(key, "DisplayVersion")
130
+ return int(version.split(".")[0])
131
+ except Exception:
132
+ pass
133
+ # Check system-level 64-bit install
134
+ try:
135
+ with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Google Chrome") as key:
136
+ version, _ = winreg.QueryValueEx(key, "DisplayVersion")
137
+ return int(version.split(".")[0])
138
+ except Exception:
139
+ pass
140
+ except ImportError:
141
+ # Linux/macOS
142
+ import subprocess
143
+ import re
144
+ for cmd in ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]:
145
+ try:
146
+ output = subprocess.check_output([cmd, "--version"], stderr=subprocess.DEVNULL)
147
+ version_str = output.decode("utf-8").strip()
148
+ match = re.search(r"(\d+)\.", version_str)
149
+ if match:
150
+ return int(match.group(1))
151
+ except Exception:
152
+ continue
153
+ return None
154
+
155
+
156
+ # ── User-Agent helpers ────────────────────────────────────────
157
+
158
+ def _build_user_agent(chrome_version: Optional[int] = None) -> str:
159
+ """Build a realistic Chrome User-Agent string using the installed version."""
160
+ v = chrome_version or _get_chrome_major_version() or 131
161
+ return (
162
+ f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
163
+ f"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{v}.0.0.0 Safari/537.36"
164
+ )
165
+
166
+
167
+ def _build_ua_pool() -> list[str]:
168
+ """Build a pool of realistic User-Agent strings for rotation."""
169
+ v = _get_chrome_major_version() or 131
170
+ return [
171
+ # Windows Chrome (primary — matches our Selenium fingerprint)
172
+ f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{v}.0.0.0 Safari/537.36",
173
+ # Windows Chrome (slightly older minor)
174
+ 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",
175
+ # macOS Chrome
176
+ 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",
177
+ # Linux Chrome
178
+ f"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{v}.0.0.0 Safari/537.36",
179
+ # Windows Edge (Chromium-based, same engine)
180
+ 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",
181
+ ]
182
+
183
+
184
+ _UA_POOL: list[str] = [] # populated at startup
185
+
186
+
187
+ # ── Response validation helpers ───────────────────────────────
188
+
189
+ def _is_empty_stub(html: str) -> bool:
190
+ """Detect LinkedIn's known empty/blocked response patterns.
191
+
192
+ Returns True if the response is the 26-byte empty stub, a login wall,
193
+ a CAPTCHA page, or any other pattern that indicates zero real content.
194
+ """
195
+ stripped = html.strip()
196
+ # 26-byte empty stub: '<!DOCTYPE html> <!----> '
197
+ if len(stripped) < 60 and "<!--" in stripped:
198
+ return True
199
+ # Login/signup redirect pages
200
+ lower = stripped[:2000].lower()
201
+ if any(sig in lower for sig in [
202
+ "login", "sign in", "signup", "sign up",
203
+ "authwall", "auth_wall", "checkpoint",
204
+ ]):
205
+ return True
206
+ return False
207
+
208
+
209
+ def _create_selenium_driver():
210
+ """Create an undetected Chrome instance (runs in thread pool)."""
211
+ options = uc.ChromeOptions()
212
+ options.add_argument("--headless=new")
213
+ options.add_argument("--no-sandbox")
214
+ options.add_argument("--disable-dev-shm-usage")
215
+ options.add_argument("--disable-gpu")
216
+ options.add_argument("--disable-blink-features=AutomationControlled")
217
+ options.add_argument("--window-size=1920,1080")
218
+ options.add_argument(f"user-agent={_build_user_agent()}")
219
+ version_main = _get_chrome_major_version()
220
+ if version_main:
221
+ driver = uc.Chrome(options=options, version_main=version_main)
222
+ else:
223
+ driver = uc.Chrome(options=options)
224
+ driver.set_page_load_timeout(30)
225
+ return driver
226
+
227
+ _AUTO_SCRAPE_SCHOOLS = ["socse", "sob", "sodi", "soepp", "solaw", "sofmca", "solas", "soahp"]
228
+
229
+ async def _auto_scrape_all_schools():
230
+ global _auto_scrape_state, _auto_scrape_history
231
+ logger.info("⏰ Auto-scrape: starting school sweep")
232
+
233
+ _auto_scrape_state = {
234
+ "is_running": True,
235
+ "current_school": None,
236
+ "completed": [],
237
+ "failed": [],
238
+ "started_at": time.time(),
239
+ "finished_at": None,
240
+ "cancel_requested": False,
241
+ }
242
+
243
+ # ── Create a history entry for this sweep run ──────────────
244
+ sweep_entry = {
245
+ "sweep_id": len(_auto_scrape_history) + 1,
246
+ "is_running": True,
247
+ "current_school": None,
248
+ "completed": [],
249
+ "failed": [],
250
+ "started_at": time.time(),
251
+ "finished_at": None,
252
+ "cancelled": False,
253
+ }
254
+ _auto_scrape_history.append(sweep_entry)
255
+
256
+ all_scraped_companies = set()
257
+
258
+ for school in _AUTO_SCRAPE_SCHOOLS:
259
+ if _auto_scrape_state.get("cancel_requested"):
260
+ logger.info("⏰ Auto-scrape: cancelled by user")
261
+ sweep_entry["cancelled"] = True
262
+ break
263
+
264
+ _auto_scrape_state["current_school"] = school
265
+ sweep_entry["current_school"] = school
266
+ try:
267
+ logger.info(f"⏰ Auto-scrape: triggering [{school}]")
268
+
269
+ params = {
270
+ "keywords": school,
271
+ "freshness": "r86400",
272
+ "work_types": ["onsite", "remote", "hybrid"],
273
+ "job_types": ["internship"],
274
+ }
275
+
276
+ async with httpx.AsyncClient(
277
+ base_url=_SELF_BASE_URL,
278
+ timeout=None,
279
+ ) as client:
280
+ async with client.stream(
281
+ "POST",
282
+ "/scrape-internships",
283
+ params=params,
284
+ ) as resp:
285
+ total = 0
286
+ async for line in resp.aiter_lines():
287
+ if not line.strip():
288
+ continue
289
+ try:
290
+ event = json.loads(line)
291
+ etype = event.get("type")
292
+ if etype == "info":
293
+ logger.info(f"⏰ [{school}] {event.get('message')}")
294
+ elif etype == "jobs":
295
+ jobs_data = event.get("data", [])
296
+ total += len(jobs_data)
297
+ for j in jobs_data:
298
+ if j.get("company"):
299
+ all_scraped_companies.add(j["company"])
300
+ logger.info(f"⏰ [{school}] +{len(jobs_data)} jobs streamed")
301
+ elif etype == "done":
302
+ total = event.get("total", total)
303
+ logger.info(
304
+ f"⏰ [{school}] done — total={total} "
305
+ f"engine={event.get('engine')} "
306
+ f"filtered_out={event.get('filtered_out')}"
307
+ )
308
+ elif etype == "error":
309
+ logger.error(f"⏰ [{school}] error: {event.get('message')}")
310
+ except Exception:
311
+ pass
312
+
313
+ _auto_scrape_state["completed"].append({"school": school, "jobs": total})
314
+ sweep_entry["completed"].append({"school": school, "jobs": total})
315
+ await asyncio.sleep(random.uniform(10.0, 20.0))
316
+
317
+ except Exception as e:
318
+ logger.error(f"⏰ Auto-scrape [{school}]: failed — {e}")
319
+ _auto_scrape_state["failed"].append({"school": school, "error": str(e)})
320
+ sweep_entry["failed"].append({"school": school, "error": str(e)})
321
+ continue
322
+
323
+ _auto_scrape_state["is_running"] = False
324
+ _auto_scrape_state["current_school"] = None
325
+ _auto_scrape_state["finished_at"] = time.time()
326
+
327
+ # ── Finalize the history entry ─────────────────────────────
328
+ sweep_entry["is_running"] = False
329
+ sweep_entry["current_school"] = None
330
+ sweep_entry["finished_at"] = time.time()
331
+ logger.info("⏰ Auto-scrape: sweep complete")
332
+
333
+ if all_scraped_companies and not _auto_scrape_state.get("cancel_requested"):
334
+ try:
335
+ existing_companies = set(get_companies_with_ratings(list(all_scraped_companies)))
336
+ companies_to_fetch = [c for c in all_scraped_companies if c.lower() not in existing_companies]
337
+
338
+ if companies_to_fetch:
339
+ logger.info(f"⏰ Auto-scrape: rating {len(companies_to_fetch)} new companies...")
340
+ ratings = await process_company_ratings(companies_to_fetch)
341
+ loop = asyncio.get_event_loop()
342
+ await loop.run_in_executor(executor, _persist_ratings, ratings)
343
+ else:
344
+ logger.info("⏰ Auto-scrape: no new companies to rate.")
345
+ except Exception as e:
346
+ logger.error(f"⏰ Auto-scrape: failed to rate companies: {e}")
347
+
348
+
349
+ @app.on_event("startup")
350
+ async def startup_event():
351
+ global se_driver, pw_browser, pw_stealth_ctx, _UA_POOL
352
+ loop = asyncio.get_event_loop()
353
+
354
+ # 0. Build the UA rotation pool
355
+ _UA_POOL = _build_ua_pool()
356
+ logger.info(f"✓ UA pool built ({len(_UA_POOL)} variants, Chrome v{_get_chrome_major_version() or '?'})")
357
+
358
+ # 1. Primary: Selenium (best anti-detection for LinkedIn)
359
+ try:
360
+ se_driver = await loop.run_in_executor(executor, _create_selenium_driver)
361
+ logger.info("✓ Selenium undetected-chromedriver ready")
362
+ except Exception as e:
363
+ logger.warning(f"✗ Selenium init failed (Chrome installed?): {e}")
364
+
365
+ # 2. Fallback: Playwright + Stealth
366
+ try:
367
+ stealth = Stealth()
368
+ pw_stealth_ctx = stealth.use_async(async_playwright())
369
+ pw = await pw_stealth_ctx.__aenter__()
370
+ pw_browser = await pw.chromium.launch(
371
+ headless=True,
372
+ args=["--disable-blink-features=AutomationControlled"],
373
+ )
374
+ logger.info("✓ Playwright stealth browser ready")
375
+ except Exception as e:
376
+ logger.warning(f"✗ Playwright init failed: {e}")
377
+ if not scheduler.running:
378
+ scheduler.start()
379
+ logger.info("✓ Scheduler started (awaiting manual trigger)")
380
+ else:
381
+ logger.info("⚠ Scheduler already running, skipping start")
382
+
383
+
384
+
385
+ @app.on_event("shutdown")
386
+ async def shutdown_event():
387
+ global se_driver, pw_browser, pw_stealth_ctx
388
+ scheduler.shutdown(wait=False)
389
+ loop = asyncio.get_event_loop()
390
+
391
+ if se_driver:
392
+ await loop.run_in_executor(executor, se_driver.quit)
393
+ if pw_browser:
394
+ await pw_browser.close()
395
+ if pw_stealth_ctx:
396
+ await pw_stealth_ctx.__aexit__(None, None, None)
397
+
398
+
399
+ # ── Cookie warm-up ────────────────────────────────────────────
400
+
401
+ def _selenium_get_cookies(driver, url: str) -> dict:
402
+ """Navigate to a URL and extract cookies (runs in executor thread)."""
403
+ driver.get(url)
404
+ time.sleep(random.uniform(2.0, 4.0)) # let LinkedIn set session cookies
405
+ try:
406
+ return {c["name"]: c["value"] for c in driver.get_cookies()}
407
+ except Exception:
408
+ return {}
409
+
410
+
411
+ async def _warm_up_cookies(force: bool = False) -> dict:
412
+ """Obtain fresh LinkedIn session cookies via a browser visit.
413
+
414
+ Uses Selenium (preferred) or Playwright to visit a simple LinkedIn
415
+ guest page, allowing LinkedIn to set its session/tracking cookies
416
+ (JSESSIONID, bcookie, li_gc, etc.). These cookies are then forwarded
417
+ to the httpx HTTP client for API requests.
418
+
419
+ Results are cached for _COOKIE_TTL_SECONDS (10 min) to avoid
420
+ redundant browser visits on consecutive scrapes.
421
+ """
422
+ global _warm_cookies, _warm_cookies_ts, se_driver
423
+
424
+ # Return cached cookies if still fresh
425
+ if not force and _warm_cookies and (time.time() - _warm_cookies_ts) < _COOKIE_TTL_SECONDS:
426
+ logger.info("cookie warm-up: using cached cookies (still fresh)")
427
+ return _warm_cookies
428
+
429
+ warm_url = "https://www.linkedin.com/jobs/search/?keywords=intern&f_TPR=r86400"
430
+ cookies: dict = {}
431
+
432
+ # Try Selenium first
433
+ if se_driver:
434
+ try:
435
+ loop = asyncio.get_event_loop()
436
+ cookies = await loop.run_in_executor(
437
+ executor, _selenium_get_cookies, se_driver, warm_url
438
+ )
439
+ if cookies:
440
+ logger.info(f"cookie warm-up: got {len(cookies)} cookies via Selenium")
441
+ except Exception as e:
442
+ logger.warning(f"cookie warm-up: Selenium failed: {e}")
443
+ err_str = str(e).lower()
444
+ if "window" in err_str or "closed" in err_str or "view" in err_str:
445
+ logger.info("cookie warm-up: Recreating crashed Selenium driver...")
446
+ try:
447
+ loop = asyncio.get_event_loop()
448
+ try:
449
+ await loop.run_in_executor(executor, se_driver.quit)
450
+ except Exception:
451
+ pass
452
+ se_driver = await loop.run_in_executor(executor, _create_selenium_driver)
453
+ cookies = await loop.run_in_executor(
454
+ executor, _selenium_get_cookies, se_driver, warm_url
455
+ )
456
+ if cookies:
457
+ logger.info(f"cookie warm-up: got {len(cookies)} cookies via recreated Selenium")
458
+ except Exception as e2:
459
+ logger.warning(f"cookie warm-up: Selenium recreation failed: {e2}")
460
+
461
+ # Fall back to Playwright
462
+ if not cookies and pw_browser:
463
+ try:
464
+ ctx = await pw_browser.new_context(user_agent=_build_user_agent())
465
+ page = await ctx.new_page()
466
+ await page.goto(warm_url, wait_until="domcontentloaded", timeout=20000)
467
+ await asyncio.sleep(random.uniform(2.0, 4.0))
468
+ cookies = {c["name"]: c["value"] for c in await ctx.cookies()}
469
+ await page.close()
470
+ await ctx.close()
471
+ if cookies:
472
+ logger.info(f"cookie warm-up: got {len(cookies)} cookies via Playwright")
473
+ except Exception as e:
474
+ logger.warning(f"cookie warm-up: Playwright failed: {e}")
475
+
476
+ if cookies:
477
+ _warm_cookies = cookies
478
+ _warm_cookies_ts = time.time()
479
+ else:
480
+ logger.warning("cookie warm-up: no cookies obtained from any engine")
481
+
482
+ return cookies
483
+
484
+
485
+ # ── URL builder ───��───────────────────────────────────────────
486
+ #
487
+ # Reference: https://www.linkedin.com/jobs/search/ guest-search parameter spec
488
+ # sortBy : R = relevance (fixed)
489
+ # f_E : 1 = Internship experience level (fixed)
490
+ # geoId : LinkedIn region id — narrows location (fixed, Bengaluru region)
491
+ # f_PP : comma-joined Primary-Place ids — UI-only narrowing (fixed)
492
+ # f_TPR : r<seconds> freshness — variable (dropdown)
493
+ # f_WT : comma-joined work-type codes — variable (3-checkbox group)
494
+ # f_JT : comma-joined job-type codes — variable (2-checkbox group)
495
+ #
496
+ # Per project spec, location is fixed (no city picker) and we only expose
497
+ # three user-visible filters: work-type, job-type, freshness.
498
+ #
499
+ # IMPORTANT — geoId vs f_PP split:
500
+ # • The full UI page at /jobs/search/ respects BOTH geoId AND f_PP and uses
501
+ # f_PP to narrow within a broader geoId. We keep both on the UI URL so the
502
+ # "Open in LinkedIn" link matches the user's reference URL.
503
+ # • The guest /jobs-guest/jobs/api/seeMoreJobPostings/search endpoint
504
+ # SILENTLY returns an empty 26-byte stub when f_PP is present (it does
505
+ # not support the f_PP filter), so the HTTP-pagination path strips f_PP
506
+ # and relies on geoId only. See _PAGINATION_PARAM_KEYS below.
507
+
508
+ # Always-on params. geoId 90009633 = "Bengaluru" (covers Bengaluru metro on
509
+ # the guest API). f_PP narrows further on the UI URL for the "Open in
510
+ # LinkedIn" link; the API silently ignores it so we strip it for pagination.
511
+ _FIXED_PARAMS: dict[str, str] = {
512
+ "sortBy": "R",
513
+ "f_E": "1",
514
+ "geoId": "90009633",
515
+ }
516
+
517
+ # Work-type checkbox options (label → LinkedIn f_WT code).
518
+ WORK_TYPES: dict[str, str] = {
519
+ "onsite": "1",
520
+ "remote": "2",
521
+ "hybrid": "3",
522
+ }
523
+
524
+ # Job-type checkbox options (label → LinkedIn f_JT code) — spec restricts to
525
+ # these two only.
526
+ JOB_TYPES: dict[str, str] = {
527
+ "internship": "I",
528
+ "full_time": "F",
529
+ }
530
+
531
+ # Freshness dropdown presets (label → LinkedIn f_TPR code).
532
+ FRESHNESS_PRESETS: dict[str, str] = {
533
+ "hour": "r3600",
534
+ "day": "r86400",
535
+ "week": "r604800",
536
+ "month": "r2592000",
537
+ }
538
+
539
+ # ── School-based search term generation ───────────────────────
540
+ #
541
+ # Convenient shortcut keywords that resolve to a specific school.
542
+ # The user specifically requested that searches for a school (e.g. "SOB")
543
+ # should search EXACTLY these keywords on LinkedIn.
544
+ KEYWORD_TO_SCHOOL: dict[str, str] = {
545
+ # [[SOCSE]] — Computer Science & Engineering
546
+ "software": "SOCSE",
547
+ "cs": "SOCSE",
548
+ "computer science": "SOCSE",
549
+ "programming": "SOCSE",
550
+ "coding": "SOCSE",
551
+ "data": "SOCSE",
552
+ "data science": "SOCSE",
553
+ "machine learning": "SOCSE",
554
+ "ai": "SOCSE",
555
+ "cyber": "SOCSE",
556
+ "cybersecurity": "SOCSE",
557
+ "qa": "SOCSE",
558
+ "devops": "SOCSE",
559
+ "frontend": "SOCSE",
560
+ "backend": "SOCSE",
561
+ "fullstack": "SOCSE",
562
+ "cloud": "SOCSE",
563
+ "developer": "SOCSE",
564
+ "engineer": "SOCSE",
565
+ "sde": "SOCSE",
566
+ "swe": "SOCSE",
567
+ "it": "SOCSE",
568
+ "web development": "SOCSE",
569
+ "app development": "SOCSE",
570
+ "mobile development": "SOCSE",
571
+ "android": "SOCSE",
572
+ "ios": "SOCSE",
573
+ "react": "SOCSE",
574
+ "node": "SOCSE",
575
+ "python": "SOCSE",
576
+ "java": "SOCSE",
577
+ "c++": "SOCSE",
578
+ "golang": "SOCSE",
579
+ "flutter": "SOCSE",
580
+ "aws": "SOCSE",
581
+ "azure": "SOCSE",
582
+ "gcp": "SOCSE",
583
+ "data analyst": "SOCSE",
584
+ "data engineer": "SOCSE",
585
+ "nlp": "SOCSE",
586
+ "computer vision": "SOCSE",
587
+ "quality assurance": "SOCSE",
588
+ "testing": "SOCSE",
589
+ "blockchain": "SOCSE",
590
+ "web3": "SOCSE",
591
+ "systems engineer": "SOCSE",
592
+ "network engineer": "SOCSE",
593
+ "site reliability": "SOCSE",
594
+ "sre": "SOCSE",
595
+ "hardware": "SOCSE",
596
+ "embedded": "SOCSE",
597
+ # SODI — Design & Innovation
598
+ "designing": "SOCSE,SODI",
599
+ "ui ux": "SOCSE,SODI",
600
+ "graphic designer": "SOCSE,SODI",
601
+ "ui designer": "SOCSE,SODI",
602
+ "ux designer": "SOCSE,SODI",
603
+ "product designer": "SOCSE,SODI",
604
+ "product design": "SODI",
605
+
606
+ "industrial design":"SODI",
607
+ "interaction design":"SODI",
608
+ "visual design": "SODI",
609
+ # SOB — Business
610
+ "business": "SOB",
611
+ "marketing": "SOB",
612
+ "finance": "SOB",
613
+ "hr": "SOB",
614
+ "accounting": "SOB",
615
+ "commerce": "SOB",
616
+ "mba": "SOB",
617
+ "sales": "SOB",
618
+ "strategy": "SOB",
619
+ "operations": "SOB",
620
+ "management": "SOB",
621
+ "consulting": "SOB",
622
+ "analyst": "SOB",
623
+ "business analyst": "SOB",
624
+ "business development": "SOB",
625
+ "bda": "SOB",
626
+ "bde": "SOB",
627
+ "sales intern": "SOB",
628
+ "marketing intern": "SOB",
629
+ "hr intern": "SOB",
630
+ "finance intern": "SOB",
631
+ "investment banking": "SOB",
632
+ "venture capital": "SOB",
633
+ "private equity": "SOB",
634
+ "digital marketing": "SOB",
635
+ "seo": "SOB",
636
+ "content writer": "SOB",
637
+ "social media": "SOB",
638
+ "growth": "SOB",
639
+ "product manager": "SOB",
640
+ "project manager": "SOB",
641
+ # SoEPP — Economics & Public Policy
642
+ "economics": "SoEPP",
643
+ "policy": "SoEPP",
644
+ "governance": "SoEPP",
645
+ "public policy": "SoEPP",
646
+ "research": "SoEPP",
647
+ "development studies":"SoEPP",
648
+ "economist": "SoEPP",
649
+ "policy analyst": "SoEPP",
650
+ "public relations": "SoEPP",
651
+ "government": "SoEPP",
652
+ # SOLaw — Law
653
+ "law": "SOLaw",
654
+ "legal": "SOLaw",
655
+ "paralegal": "SOLaw",
656
+ "attorney": "SOLaw",
657
+ "counsel": "SOLaw",
658
+ "cyber law": "SOLaw",
659
+ "law intern": "SOLaw",
660
+ "legal intern": "SOLaw",
661
+ "litigation": "SOLaw",
662
+ "corporate law": "SOLaw",
663
+ "ipr": "SOLaw",
664
+ # SOFMCA — Film, Media & Creative Arts
665
+ "media": "SOFMCA",
666
+ "film": "SOFMCA",
667
+ "journalism": "SOFMCA",
668
+ "animation": "SOFMCA",
669
+ "vfx": "SOFMCA",
670
+ "gaming": "SOFMCA",
671
+ "content creation": "SOFMCA",
672
+ "acting": "SOFMCA",
673
+ "editor": "SOFMCA",
674
+ "video editor": "SOFMCA",
675
+ "cinematographer": "SOFMCA",
676
+ "copywriter": "SOFMCA",
677
+ "media intern": "SOFMCA",
678
+ "pr intern": "SOFMCA",
679
+ "producer": "SOFMCA",
680
+ # SOLAS — Liberal Arts & Sciences
681
+ "psychology": "SOLAS",
682
+ "environment": "SOLAS",
683
+ "liberal arts": "SOLAS",
684
+ "sociology": "SOLAS",
685
+ "history": "SOLAS",
686
+ "behavioral science":"SOLAS",
687
+ "psychology intern": "SOLAS",
688
+ "counseling": "SOLAS",
689
+ "sociologist": "SOLAS",
690
+ "research assistant":"SOLAS",
691
+ # SOAHP — Allied & Healthcare
692
+ "healthcare": "SOAHP",
693
+ "medical": "SOAHP",
694
+ "clinical": "SOAHP",
695
+ "laboratory": "SOAHP",
696
+ "nursing": "SOAHP",
697
+ "public health": "SOAHP",
698
+ "clinical research": "SOAHP",
699
+ "public health intern": "SOAHP",
700
+ "hospital administration": "SOAHP",
701
+ "pharma": "SOAHP",
702
+ }
703
+
704
+ # Build SCHOOL_SEARCH_TERMS directly from KEYWORD_TO_SCHOOL.
705
+ # The user explicitly wants us to search *exactly* these keywords.
706
+ SCHOOL_SEARCH_TERMS: dict[str, list[str]] = {}
707
+ for kw, school_str in KEYWORD_TO_SCHOOL.items():
708
+ for school in school_str.split(","):
709
+ school_lower = school.strip().lower()
710
+ if school_lower not in SCHOOL_SEARCH_TERMS:
711
+ SCHOOL_SEARCH_TERMS[school_lower] = []
712
+ SCHOOL_SEARCH_TERMS[school_lower].append(kw)
713
+
714
+
715
+ def build_search_params(
716
+ keywords: str,
717
+ freshness: str = "r86400",
718
+ work_types: Optional[list[str]] = None,
719
+ job_types: Optional[list[str]] = None,
720
+ ) -> dict[str, str]:
721
+ """Build the LinkedIn guest search query-param dict.
722
+
723
+ Fixed params (sortBy, f_E, f_PP) are always present.
724
+ Variable params reflect user-selected checkboxes / dropdown:
725
+ - f_TPR : freshness (raw "r<seconds>" code)
726
+ - f_WT : comma-joined work-type codes (only when any box ticked)
727
+ - f_JT : comma-joined job-type codes (only when any box ticked)
728
+ """
729
+ params: dict[str, str] = {
730
+ "keywords": keywords,
731
+ **_FIXED_PARAMS,
732
+ "f_TPR": freshness,
733
+ }
734
+
735
+ if work_types:
736
+ codes = [WORK_TYPES[k] for k in work_types if k in WORK_TYPES]
737
+ if codes:
738
+ params["f_WT"] = ",".join(codes)
739
+
740
+ if job_types:
741
+ codes = [JOB_TYPES[k] for k in job_types if k in JOB_TYPES]
742
+ if codes:
743
+ params["f_JT"] = ",".join(codes)
744
+
745
+ return params
746
+
747
+
748
+ def build_search_url(
749
+ keywords: str,
750
+ freshness: str = "r86400",
751
+ work_types: Optional[list[str]] = None,
752
+ job_types: Optional[list[str]] = None,
753
+ ) -> str:
754
+ """Builds a fully-parameterised LinkedIn guest search URL.
755
+
756
+ Shape matches LinkedIn's UI-canonical form, e.g.:
757
+ https://www.linkedin.com/jobs/search/?keywords=intern&sortBy=R&f_E=1
758
+ &f_PP=105214831,113968072,112565523,119634689&f_TPR=r86400&f_WT=1,2&f_JT=I
759
+ """
760
+ params = build_search_params(
761
+ keywords=keywords,
762
+ freshness=freshness,
763
+ work_types=work_types,
764
+ job_types=job_types,
765
+ )
766
+ # Trailing slash on /jobs/search/ matches LinkedIn's UI-canonical URL.
767
+ return f"https://www.linkedin.com/jobs/search/?{urllib.parse.urlencode(params)}"
768
+
769
+
770
+ # ── Shared HTML parser ────────────────────────────────────────
771
+
772
+ def _parse_jobs_from_html(html: str) -> list[dict]:
773
+ """Extract job listings from raw HTML using BeautifulSoup.
774
+ Cascading selectors handle multiple LinkedIn page variants.
775
+
776
+ NOTE: contact_details (hiring team links) are NOT extracted here.
777
+ They live on each job's *detail* page, not the search results list.
778
+ Use _fetch_hiring_links_for_jobs() after collection to enrich jobs.
779
+ """
780
+ soup = BeautifulSoup(html, "html.parser")
781
+ jobs = []
782
+
783
+ # Primary selector: public guest search results
784
+ cards = soup.select("ul.jobs-search__results-list > li")
785
+
786
+ # Fallback selectors for alternate page states + seeMoreJobPostings fragments
787
+ # (the paginated endpoint returns bare <li> elements with no wrapping <ul>).
788
+ if not cards:
789
+ cards = soup.select("li.job-search-card, li.result-card")
790
+ if not cards:
791
+ cards = soup.select("div.base-card.base-search-card")
792
+ if not cards:
793
+ cards = soup.select("[data-entity-urn*='jobPosting']")
794
+
795
+ for card in cards:
796
+ try:
797
+ # Title
798
+ title_el = (
799
+ card.select_one(".base-search-card__title")
800
+ or card.select_one("h3")
801
+ or card.select_one("[class*='title']")
802
+ )
803
+ # Company
804
+ company_el = (
805
+ card.select_one(".base-search-card__subtitle a")
806
+ or card.select_one(".base-search-card__subtitle")
807
+ or card.select_one("h4 a")
808
+ )
809
+ # Location
810
+ loc_el = (
811
+ card.select_one(".job-search-card__location")
812
+ or card.select_one("[class*='location']")
813
+ )
814
+ # Link
815
+ link_el = (
816
+ card.select_one("a.base-card__full-link")
817
+ or card.select_one("a[href*='/jobs/view/']")
818
+ or card.select_one("a[href*='linkedin.com/jobs']")
819
+ )
820
+ # Posted date
821
+ date_el = (
822
+ card.select_one("time.job-search-card__listdate")
823
+ or card.select_one("time.job-search-card__listdate--new")
824
+ or card.select_one("time")
825
+ )
826
+
827
+ title = title_el.get_text(strip=True) if title_el else None
828
+ company = company_el.get_text(strip=True) if company_el else None
829
+ location = loc_el.get_text(strip=True) if loc_el else None
830
+ raw_link = link_el.get("href", "") if link_el else ""
831
+ link = raw_link.split("?")[0] if raw_link else None
832
+ posted = date_el.get_text(strip=True) if date_el else None
833
+ posted_dt = date_el.get("datetime") if date_el else None
834
+
835
+ # At least title or company must exist for a valid card
836
+ if title or company:
837
+ classification = classify_job(title or "", company or "")
838
+ jobs.append({
839
+ "title": title,
840
+ "company": company,
841
+ "location": location,
842
+ "link": link,
843
+ "posted": posted,
844
+ "posted_datetime": posted_dt,
845
+ "programs": classification["programs"],
846
+ "schools": classification["schools"],
847
+ "contact_details": [], # enriched later by _fetch_hiring_links_for_jobs
848
+ })
849
+ except Exception:
850
+ continue
851
+
852
+ return jobs
853
+
854
+
855
+
856
+ # ── Serper-based hiring manager search ───────────────────────
857
+
858
+ _SERPER_API_KEY = os.environ.get("SERPER_API_KEY", "")
859
+ _SERPER_URL = "https://google.serper.dev/search"
860
+
861
+ # Semaphore: at most 3 concurrent Serper requests
862
+ _SERPER_SEM = asyncio.Semaphore(3)
863
+
864
+ _LINKEDIN_IN_RE = re.compile(r'https?://(?:[\w-]+\.)?linkedin\.com/in/([\w%-]+)', re.IGNORECASE)
865
+
866
+
867
+ def _extract_linkedin_profile_from_serper(data: dict) -> dict | None:
868
+ """Parse a Serper JSON response and return the first LinkedIn /in/ profile found.
869
+
870
+ Priority order:
871
+ 1. answerBox.link (Google AI / Featured Snippet)
872
+ 2. answerBox.snippet (text contains a linkedin.com/in/ URL)
873
+ 3. organic[0].link (first organic result)
874
+ 4. organic[].link (first organic result that is a /in/ URL)
875
+
876
+ Returns:
877
+ {"name": "<person_slug_or_extracted_name>", "url": "<linkedin_profile_url>"}
878
+ or None if nothing found.
879
+ """
880
+ # 1. answerBox direct link
881
+ ab = data.get("answerBox", {})
882
+ ab_link = ab.get("link", "")
883
+ if ab_link and "linkedin.com/in/" in ab_link:
884
+ slug = ab_link.rstrip("/").split("/in/")[-1].split("?")[0]
885
+ name = ab.get("title") or slug.replace("-", " ").title()
886
+ return {"name": name, "url": ab_link.split("?")[0]}
887
+
888
+ # 2. answerBox snippet contains a URL
889
+ ab_snippet = ab.get("snippet", "") or ab.get("answer", "")
890
+ m = _LINKEDIN_IN_RE.search(ab_snippet)
891
+ if m:
892
+ url = m.group(0).split("?")[0]
893
+ slug = m.group(1)
894
+ name = ab.get("title") or slug.replace("-", " ").title()
895
+ return {"name": name, "url": url}
896
+
897
+ # 3 & 4. Organic results — prefer linkedin.com/in/ links
898
+ for result in data.get("organic", []):
899
+ link = result.get("link", "")
900
+ if "linkedin.com/in/" in link:
901
+ slug = link.rstrip("/").split("/in/")[-1].split("?")[0]
902
+ name = result.get("title", slug).split(" - ")[0].split(" | ")[0].strip()
903
+ return {"name": name, "url": link.split("?")[0]}
904
+
905
+ return None
906
+
907
+
908
+ async def _fetch_hiring_contacts_via_serper(jobs: list[dict]) -> None:
909
+ """Enrich each job dict in-place with contact_details via Serper Google search.
910
+
911
+ For each job, searches:
912
+ site:linkedin.com/in "{company}" "hiring" OR "recruiter" OR "HR"
913
+
914
+ contact_details becomes a list with at most 1 entry:
915
+ [{"name": "Person Name", "url": "https://linkedin.com/in/slug"}]
916
+
917
+ Jobs where no profile is found keep contact_details as [].
918
+ Requires SERPER_API_KEY in environment.
919
+ """
920
+ if not _SERPER_API_KEY:
921
+ logger.warning("serper: SERPER_API_KEY not set — skipping hiring contact search")
922
+ return
923
+
924
+ async def _search_one(job: dict) -> None:
925
+ company = job.get("company") or ""
926
+ if not company:
927
+ return
928
+
929
+ # Build a targeted query: site operator + company + hiring signals
930
+ query = f'site:linkedin.com/in "{company}" hiring OR recruiter OR "HR"'
931
+
932
+ try:
933
+ async with _SERPER_SEM:
934
+ await asyncio.sleep(random.uniform(0.2, 0.6))
935
+ async with httpx.AsyncClient(timeout=10.0) as client:
936
+ resp = await client.post(
937
+ _SERPER_URL,
938
+ json={"q": query, "num": 5, "gl": "in", "hl": "en"},
939
+ headers={
940
+ "X-API-KEY": _SERPER_API_KEY,
941
+ "Content-Type": "application/json",
942
+ },
943
+ )
944
+
945
+ if resp.status_code != 200:
946
+ logger.debug(
947
+ f"serper: HTTP {resp.status_code} for company={company!r}"
948
+ )
949
+ return
950
+
951
+ data = resp.json()
952
+ profile = _extract_linkedin_profile_from_serper(data)
953
+ if profile:
954
+ job["contact_details"] = [profile]
955
+ logger.info(
956
+ f"serper: ✓ {job.get('title','?')} @ {company} → "
957
+ f"{profile['name']} ({profile['url']})"
958
+ )
959
+ else:
960
+ logger.debug(f"serper: no profile found for company={company!r}")
961
+
962
+ except Exception as e:
963
+ logger.debug(f"serper: error searching for {company!r}: {e}")
964
+
965
+ await asyncio.gather(*[_search_one(j) for j in jobs])
966
+
967
+
968
+
969
+
970
+ # ── Paginated HTTP fetcher (seeMoreJobPostings) ───────────────
971
+
972
+ _SEE_MORE_URL = (
973
+ "https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search"
974
+ )
975
+ # Keys forwarded to the seeMoreJobPostings endpoint.
976
+ # NOTE: f_PP is intentionally EXCLUDED here — the guest API returns an empty
977
+ # 26-byte stub whenever f_PP is present (verified 2026-05-20). geoId provides
978
+ # equivalent narrowing. f_PP stays on the UI URL only.
979
+ _PAGINATION_PARAM_KEYS = {
980
+ "keywords", "sortBy", "f_E", "geoId", "f_TPR", "f_WT", "f_JT",
981
+ }
982
+
983
+
984
+ async def _fetch_more_pages(
985
+ base_params: dict,
986
+ cookies: dict,
987
+ headers: dict,
988
+ max_pages: int = 20,
989
+ start_offset: int = 25,
990
+ ) -> list[dict]:
991
+ """Page through LinkedIn's guest seeMoreJobPostings endpoint.
992
+
993
+ The endpoint returns ~25 job cards as a raw HTML fragment per call.
994
+ Stops on: 4xx/5xx (rate limit, with retry on 429), empty body,
995
+ empty stub detection, or empty parsed result. Returns whatever was
996
+ collected before stopping — never raises.
997
+
998
+ Retries on transient network errors (DNS, connection timeouts) with
999
+ exponential backoff to handle temporary infrastructure issues.
1000
+ """
1001
+ params_clean = {
1002
+ k: v for k, v in base_params.items()
1003
+ if k in _PAGINATION_PARAM_KEYS and v not in (None, "")
1004
+ }
1005
+
1006
+ collected: list[dict] = []
1007
+ rate_limit_retries_left = 2 # total 429s tolerated across the whole loop
1008
+ first_response_dumped = False
1009
+ dump_path = os.path.join(tempfile.gettempdir(), "linkedin_pagination_first_response.html")
1010
+ try:
1011
+ async with httpx.AsyncClient(
1012
+ timeout=20.0,
1013
+ cookies=cookies,
1014
+ headers=headers,
1015
+ follow_redirects=True,
1016
+ http2=False,
1017
+ ) as client:
1018
+ # Brief settle before first request — reduced from 3s for speed.
1019
+ await asyncio.sleep(random.uniform(1.0, 2.0))
1020
+
1021
+ start = start_offset
1022
+ for page_idx in range(max_pages):
1023
+ url = f"{_SEE_MORE_URL}?{urllib.parse.urlencode({**params_clean, 'start': start})}"
1024
+ logger.info(f"pagination: GET {url}")
1025
+
1026
+ # Retry loop for transient network errors
1027
+ max_network_retries = 3
1028
+ for attempt in range(1, max_network_retries + 1):
1029
+ try:
1030
+ resp = await client.get(url)
1031
+ break # Success, exit retry loop
1032
+ except (httpx.TimeoutException, httpx.NetworkError, OSError) as e:
1033
+ # Transient network errors: DNS (getaddrinfo), timeout, connection reset
1034
+ if attempt < max_network_retries:
1035
+ backoff = min(2 ** attempt, 10.0) * random.uniform(1.0, 1.5)
1036
+ logger.warning(
1037
+ f"pagination: transient network error at start={start} "
1038
+ f"(attempt {attempt}/{max_network_retries}): {type(e).__name__}. "
1039
+ f"Retrying in {backoff:.1f}s..."
1040
+ )
1041
+ await asyncio.sleep(backoff)
1042
+ else:
1043
+ logger.warning(
1044
+ f"pagination: network error at start={start} after "
1045
+ f"{max_network_retries} attempts: {e}; stopping with {len(collected)} jobs"
1046
+ )
1047
+ return collected
1048
+ except httpx.HTTPError as e:
1049
+ logger.warning(
1050
+ f"pagination: HTTP error at start={start}: {e}; "
1051
+ f"stopping with {len(collected)} extra jobs"
1052
+ )
1053
+ return collected
1054
+
1055
+ # Retry on 429 with longer backoff — LinkedIn's rate limits
1056
+ # are usually short-lived and clear after ~10s.
1057
+ if resp.status_code == 429 and rate_limit_retries_left > 0:
1058
+ rate_limit_retries_left -= 1
1059
+ backoff = random.uniform(15.0, 25.0)
1060
+ logger.warning(
1061
+ f"pagination: 429 at start={start}; backing off {backoff:.1f}s "
1062
+ f"and retrying ({rate_limit_retries_left} retries left)"
1063
+ )
1064
+ await asyncio.sleep(backoff)
1065
+ try:
1066
+ resp = await client.get(url)
1067
+ except (httpx.TimeoutException, httpx.NetworkError, OSError, httpx.HTTPError) as e:
1068
+ logger.warning(f"pagination: retry HTTP error: {e}; stopping")
1069
+ return collected
1070
+
1071
+ html = resp.text or ""
1072
+ body_len = len(resp.content) if resp.content is not None else 0
1073
+ li_count = html.lower().count("<li")
1074
+
1075
+ logger.info(
1076
+ f"pagination: start={start} status={resp.status_code} "
1077
+ f"bytes={body_len} <li>={li_count}"
1078
+ )
1079
+
1080
+ if not first_response_dumped:
1081
+ try:
1082
+ with open(dump_path, "w", encoding="utf-8") as f:
1083
+ f.write(html)
1084
+ logger.info(f"pagination: dumped first response → {dump_path}")
1085
+ except Exception as e:
1086
+ logger.warning(f"pagination: could not dump response: {e}")
1087
+ first_response_dumped = True
1088
+
1089
+ if resp.status_code >= 400:
1090
+ logger.warning(
1091
+ f"pagination: status {resp.status_code} at start={start} "
1092
+ f"(likely rate-limit); stopping with {len(collected)} extra jobs"
1093
+ )
1094
+ break
1095
+
1096
+ if not html.strip():
1097
+ logger.info(
1098
+ f"pagination: empty body at start={start}; end of results "
1099
+ f"(+{len(collected)} extra jobs)"
1100
+ )
1101
+ break
1102
+
1103
+ # ── Early stub detection ──────────────────────────
1104
+ # Detect the 26-byte empty stub and login walls before
1105
+ # wasting time on HTML parsing. On the very first page
1106
+ # this signals "HTTP path is blocked" so we can bail fast.
1107
+ if _is_empty_stub(html):
1108
+ logger.warning(
1109
+ f"pagination: empty stub/block detected at start={start} "
1110
+ f"(bytes={body_len}); stopping with {len(collected)} extra jobs"
1111
+ )
1112
+ break
1113
+
1114
+ page_jobs = _parse_jobs_from_html(html)
1115
+ logger.info(
1116
+ f"pagination: start={start} parser returned {len(page_jobs)} jobs"
1117
+ )
1118
+
1119
+ if not page_jobs:
1120
+ snippet = html[:500].replace("\n", " ")
1121
+ logger.warning(
1122
+ f"pagination: parser returned 0 jobs from a non-empty body "
1123
+ f"(bytes={body_len}, <li>={li_count}). First 500 chars: {snippet!r}"
1124
+ )
1125
+ logger.info(
1126
+ f"pagination: no jobs parsed at start={start}; end of results "
1127
+ f"(+{len(collected)} extra jobs)"
1128
+ )
1129
+ break
1130
+
1131
+ collected.extend(page_jobs)
1132
+ logger.info(
1133
+ f"pagination: start={start} → +{len(page_jobs)} jobs "
1134
+ f"(running paginated total {len(collected)})"
1135
+ )
1136
+
1137
+ # Advance by the actual page size LinkedIn returned.
1138
+ start += len(page_jobs)
1139
+
1140
+ # Human-like inter-page jitter — varied enough to avoid
1141
+ # pattern detection.
1142
+ await asyncio.sleep(random.uniform(3.5, 6.0))
1143
+ except Exception as e:
1144
+ logger.warning(
1145
+ f"pagination: unexpected error: {e}; returning {len(collected)} extra jobs"
1146
+ )
1147
+
1148
+ return collected
1149
+
1150
+
1151
+ # ── Scraping engines ──────────────────────────────────────────
1152
+
1153
+ _JOB_CARD_CSS = (
1154
+ "ul.jobs-search__results-list > li, "
1155
+ "div.base-card.base-search-card, "
1156
+ "[data-entity-urn*='jobPosting']"
1157
+ )
1158
+
1159
+
1160
+ def _selenium_scrape(driver, url: str) -> tuple[list[dict], dict]:
1161
+ """Synchronous Selenium scrape — executed inside executor thread.
1162
+ Returns (jobs, cookies) so the async caller can paginate over HTTP."""
1163
+ driver.get(url)
1164
+
1165
+ # Wait up to 15 s for at least one job card — if nothing loads, bail so
1166
+ # Playwright fallback can try instead.
1167
+ try:
1168
+ WebDriverWait(driver, 15).until(
1169
+ EC.presence_of_element_located((By.CSS_SELECTOR, _JOB_CARD_CSS))
1170
+ )
1171
+ except Exception:
1172
+ logger.warning("Selenium: no job cards appeared within 15 s")
1173
+ return [], {}
1174
+
1175
+ # Scroll to bottom repeatedly; break after 3 consecutive unchanged heights.
1176
+ no_change = 0
1177
+ for _ in range(15):
1178
+ prev_height = driver.execute_script("return document.body.scrollHeight")
1179
+ driver.execute_script("window.scrollTo(0, document.body.scrollHeight)")
1180
+ time.sleep(1.5)
1181
+
1182
+ # Click "See more jobs" / "Show more results" if present
1183
+ try:
1184
+ btn = driver.find_element(
1185
+ By.XPATH,
1186
+ "//button[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'see more')"
1187
+ " or contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'show more')]"
1188
+ )
1189
+ btn.click()
1190
+ time.sleep(1.0)
1191
+ except Exception:
1192
+ pass
1193
+
1194
+ new_height = driver.execute_script("return document.body.scrollHeight")
1195
+ if new_height == prev_height:
1196
+ no_change += 1
1197
+ if no_change >= 3:
1198
+ break
1199
+ else:
1200
+ no_change = 0
1201
+
1202
+ jobs = _parse_jobs_from_html(driver.page_source)
1203
+ try:
1204
+ cookies = {c["name"]: c["value"] for c in driver.get_cookies()}
1205
+ except Exception as e:
1206
+ logger.warning(f"Selenium: could not extract cookies for pagination: {e}")
1207
+ cookies = {}
1208
+ return jobs, cookies
1209
+
1210
+
1211
+ async def _playwright_scrape(browser, url: str) -> tuple[list[dict], dict]:
1212
+ """Async Playwright scrape — fallback engine.
1213
+ Returns (jobs, cookies) so the caller can paginate over HTTP."""
1214
+ context = await browser.new_context(
1215
+ user_agent=_build_user_agent()
1216
+ )
1217
+ page = await context.new_page()
1218
+
1219
+ try:
1220
+ await page.goto(url, wait_until="domcontentloaded", timeout=30000)
1221
+
1222
+ # Wait up to 15 s for at least one job card
1223
+ try:
1224
+ await page.wait_for_selector(_JOB_CARD_CSS, timeout=15000)
1225
+ except Exception:
1226
+ logger.warning("Playwright: no job cards appeared within 15 s")
1227
+ return [], {}
1228
+
1229
+ # Scroll to bottom repeatedly; break after 3 consecutive unchanged heights.
1230
+ no_change = 0
1231
+ for _ in range(15):
1232
+ prev_height = await page.evaluate("document.body.scrollHeight")
1233
+ await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
1234
+ await asyncio.sleep(1.5)
1235
+
1236
+ # Click "See more jobs" button if present
1237
+ try:
1238
+ await page.locator("button:has-text('See more')").click(timeout=1000)
1239
+ await asyncio.sleep(1.0)
1240
+ except Exception:
1241
+ pass
1242
+
1243
+ new_height = await page.evaluate("document.body.scrollHeight")
1244
+ if new_height == prev_height:
1245
+ no_change += 1
1246
+ if no_change >= 3:
1247
+ break
1248
+ else:
1249
+ no_change = 0
1250
+
1251
+ html = await page.content()
1252
+ jobs = _parse_jobs_from_html(html)
1253
+ try:
1254
+ cookies = {c["name"]: c["value"] for c in await context.cookies()}
1255
+ except Exception as e:
1256
+ logger.warning(f"Playwright: could not extract cookies for pagination: {e}")
1257
+ cookies = {}
1258
+ return jobs, cookies
1259
+ finally:
1260
+ await page.close()
1261
+ await context.close()
1262
+
1263
+
1264
+ # ── Single-URL scrape helper (Selenium → Playwright) ─────────
1265
+
1266
+ def _build_pagination_headers(referer_url: str) -> dict:
1267
+ """Browser-realistic headers for the seeMoreJobPostings endpoint.
1268
+ Uses a random UA from the rotation pool and matches the installed
1269
+ Chrome version for Sec-Ch-Ua consistency."""
1270
+ v = _get_chrome_major_version() or 131
1271
+ ua = random.choice(_UA_POOL) if _UA_POOL else _build_user_agent()
1272
+ return {
1273
+ "User-Agent": ua,
1274
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
1275
+ "Accept-Language": "en-US,en;q=0.9",
1276
+ "Accept-Encoding": "gzip, deflate, br",
1277
+ "Referer": referer_url,
1278
+ "Sec-Fetch-Dest": "empty",
1279
+ "Sec-Fetch-Mode": "cors",
1280
+ "Sec-Fetch-Site": "same-origin",
1281
+ "Sec-Ch-Ua": f'"Chromium";v="{v}", "Not_A Brand";v="24"',
1282
+ "Sec-Ch-Ua-Mobile": "?0",
1283
+ "Sec-Ch-Ua-Platform": '"Windows"',
1284
+ "Connection": "keep-alive",
1285
+ }
1286
+
1287
+
1288
+ async def _scrape_one_url(
1289
+ url: str,
1290
+ params: Optional[dict] = None,
1291
+ paginate: bool = False,
1292
+ ) -> tuple[list[dict], str]:
1293
+ """Scrape one URL with cookie warm-up, exponential-backoff retry,
1294
+ and smarter browser fallback.
1295
+
1296
+ Pipeline:
1297
+ 1. Warm up cookies via browser visit (cached for 10 min).
1298
+ 2. Try HTTP pagination with warm cookies (up to 3 attempts
1299
+ with exponential backoff on failure).
1300
+ 3. If HTTP fails after all retries, fall back to Selenium
1301
+ then Playwright to scrape the UI page directly.
1302
+ 4. If a browser engine succeeds, try one more HTTP pagination
1303
+ pass using the browser's fresh cookies to extend the results.
1304
+
1305
+ Returns (jobs, engine_used) where engine_used is one of
1306
+ 'http' / 'selenium' / 'playwright' / 'none'.
1307
+ """
1308
+ global se_driver
1309
+ jobs: list[dict] = []
1310
+ engine_used = "none"
1311
+ browser_cookies: dict = {}
1312
+
1313
+ # ── Phase 1 — HTTP with warm cookies + retry loop ─────────────
1314
+ if paginate and params:
1315
+ max_http_attempts = 3
1316
+ for attempt in range(1, max_http_attempts + 1):
1317
+ # Warm up cookies (uses cache if fresh; force re-warm on retries)
1318
+ cookies = await _warm_up_cookies(force=(attempt > 1))
1319
+ logger.info(
1320
+ f"HTTP attempt {attempt}/{max_http_attempts} "
1321
+ f"(cookies={'warm' if cookies else 'cold'})"
1322
+ )
1323
+
1324
+ http_jobs = await _fetch_more_pages(
1325
+ base_params=params,
1326
+ cookies=cookies,
1327
+ headers=_build_pagination_headers(referer_url=url),
1328
+ max_pages=40,
1329
+ start_offset=0,
1330
+ )
1331
+
1332
+ if http_jobs:
1333
+ jobs = http_jobs
1334
+ engine_used = "http"
1335
+ logger.info(
1336
+ f"HTTP scraped {len(jobs)} jobs on attempt {attempt}"
1337
+ )
1338
+ break
1339
+
1340
+ # Exponential backoff before next attempt
1341
+ if attempt < max_http_attempts:
1342
+ backoff = (2 ** attempt) * random.uniform(2.0, 4.0)
1343
+ logger.warning(
1344
+ f"HTTP attempt {attempt} returned 0 jobs; "
1345
+ f"backing off {backoff:.1f}s before retry"
1346
+ )
1347
+ await asyncio.sleep(backoff)
1348
+
1349
+ if not jobs:
1350
+ logger.warning(
1351
+ f"HTTP pagination failed after {max_http_attempts} attempts "
1352
+ "— falling back to browser engines"
1353
+ )
1354
+
1355
+ # ── Phase 2 — Browser fallback (Selenium → Playwright) ────────
1356
+ if not jobs and se_driver:
1357
+ try:
1358
+ async with _scrape_lock:
1359
+ loop = asyncio.get_event_loop()
1360
+ jobs, browser_cookies = await loop.run_in_executor(
1361
+ executor, _selenium_scrape, se_driver, url
1362
+ )
1363
+ if jobs:
1364
+ engine_used = "selenium"
1365
+ logger.info(f"Selenium scraped {len(jobs)} jobs (fallback)")
1366
+ except Exception as e:
1367
+ logger.warning(f"Selenium fallback failed: {e}")
1368
+ err_str = str(e).lower()
1369
+ if "window" in err_str or "closed" in err_str or "view" in err_str:
1370
+ logger.info("Selenium fallback: Recreating crashed driver...")
1371
+ try:
1372
+ async with _scrape_lock:
1373
+ loop = asyncio.get_event_loop()
1374
+ try:
1375
+ await loop.run_in_executor(executor, se_driver.quit)
1376
+ except Exception:
1377
+ pass
1378
+ se_driver = await loop.run_in_executor(executor, _create_selenium_driver)
1379
+ jobs, browser_cookies = await loop.run_in_executor(
1380
+ executor, _selenium_scrape, se_driver, url
1381
+ )
1382
+ if jobs:
1383
+ engine_used = "selenium"
1384
+ logger.info(f"Selenium scraped {len(jobs)} jobs via recreated driver")
1385
+ except Exception as e2:
1386
+ logger.warning(f"Selenium fallback recreation failed: {e2}")
1387
+
1388
+ if not jobs and pw_browser:
1389
+ try:
1390
+ jobs, browser_cookies = await _playwright_scrape(pw_browser, url)
1391
+ if jobs:
1392
+ engine_used = "playwright"
1393
+ logger.info(f"Playwright scraped {len(jobs)} jobs (fallback)")
1394
+ except Exception as e:
1395
+ logger.warning(f"Playwright fallback failed: {e}")
1396
+
1397
+ # ── Phase 3 — Post-browser HTTP extension ─────────────────────
1398
+ # If a browser engine got results, try HTTP pagination with its
1399
+ # fresh cookies to collect additional pages beyond what the browser
1400
+ # initially loaded (the browser typically only sees page 1).
1401
+ if jobs and browser_cookies and paginate and params:
1402
+ logger.info(
1403
+ f"Extending {len(jobs)} browser results via HTTP with "
1404
+ f"{len(browser_cookies)} fresh browser cookies"
1405
+ )
1406
+ extra_jobs = await _fetch_more_pages(
1407
+ base_params=params,
1408
+ cookies=browser_cookies,
1409
+ headers=_build_pagination_headers(referer_url=url),
1410
+ max_pages=20,
1411
+ start_offset=len(jobs), # continue from where the browser left off
1412
+ )
1413
+ if extra_jobs:
1414
+ jobs.extend(extra_jobs)
1415
+ logger.info(
1416
+ f"HTTP extension added {len(extra_jobs)} jobs "
1417
+ f"(total now {len(jobs)})"
1418
+ )
1419
+
1420
+ return jobs, engine_used
1421
+
1422
+
1423
+ # ── API endpoints ─────────────────────────────────────────────
1424
+
1425
+ @app.get("/schools")
1426
+ async def get_schools():
1427
+ """Return the full school registry used for job classification."""
1428
+ return SCHOOLS
1429
+
1430
+
1431
+ @app.get("/locations")
1432
+ async def get_locations():
1433
+ """Return the city → geoId mapping used for location checkboxes."""
1434
+ return INDIAN_METROS
1435
+
1436
+
1437
+ # ── Parallel multi-keyword HTTP scraper ───────────────────────
1438
+
1439
+ # Concurrency limiter — at most 1 parallel HTTP scrape to avoid
1440
+ # triggering LinkedIn's rate limiter. Sequential is safer and still fast.
1441
+ _PARALLEL_SEMAPHORE = asyncio.Semaphore(1)
1442
+
1443
+
1444
+ async def _fetch_all_pages_for_keyword(
1445
+ keyword: str,
1446
+ base_params: dict,
1447
+ cookies: dict,
1448
+ max_pages: int = 10,
1449
+ ) -> list[dict]:
1450
+ """Fast HTTP-only scraper for a single keyword.
1451
+
1452
+ Designed for parallel fan-out: lightweight, no browser, shorter
1453
+ inter-page delays. Uses the shared semaphore to throttle concurrency.
1454
+
1455
+ Retries on transient network errors with exponential backoff to handle
1456
+ temporary infrastructure issues.
1457
+ """
1458
+ params = {
1459
+ k: v for k, v in base_params.items()
1460
+ if k in _PAGINATION_PARAM_KEYS and v not in (None, "")
1461
+ }
1462
+ params["keywords"] = keyword # override with this specific keyword
1463
+
1464
+ referer_url = f"https://www.linkedin.com/jobs/search/?{urllib.parse.urlencode(params)}"
1465
+ headers = _build_pagination_headers(referer_url=referer_url)
1466
+
1467
+ collected: list[dict] = []
1468
+
1469
+ async with _PARALLEL_SEMAPHORE:
1470
+ try:
1471
+ async with httpx.AsyncClient(
1472
+ timeout=20.0,
1473
+ cookies=cookies,
1474
+ headers=headers,
1475
+ follow_redirects=True,
1476
+ http2=False,
1477
+ ) as client:
1478
+ # Brief initial settle
1479
+ await asyncio.sleep(random.uniform(2.0, 4.0))
1480
+
1481
+ start = 0
1482
+ consecutive_empty = 0
1483
+ for page_idx in range(max_pages):
1484
+ url = f"{_SEE_MORE_URL}?{urllib.parse.urlencode({**params, 'start': start})}"
1485
+ logger.info(f"parallel[{keyword[:30]}]: GET start={start}")
1486
+
1487
+ # Retry loop for transient network errors
1488
+ max_network_retries = 3
1489
+ resp = None
1490
+ for attempt in range(1, max_network_retries + 1):
1491
+ try:
1492
+ resp = await client.get(url)
1493
+ break # Success, exit retry loop
1494
+ except (httpx.TimeoutException, httpx.NetworkError, OSError) as e:
1495
+ # Transient network errors
1496
+ if attempt < max_network_retries:
1497
+ backoff = min(2 ** attempt, 10.0) * random.uniform(1.0, 1.5)
1498
+ logger.warning(
1499
+ f"parallel[{keyword[:30]}]: transient network error "
1500
+ f"(attempt {attempt}/{max_network_retries}): {type(e).__name__}. "
1501
+ f"Retrying in {backoff:.1f}s..."
1502
+ )
1503
+ await asyncio.sleep(backoff)
1504
+ else:
1505
+ logger.warning(
1506
+ f"parallel[{keyword[:30]}]: network error at start={start} "
1507
+ f"after {max_network_retries} attempts: {e}; "
1508
+ f"stopping with {len(collected)} jobs"
1509
+ )
1510
+ break
1511
+ except httpx.HTTPError as e:
1512
+ logger.warning(
1513
+ f"parallel[{keyword[:30]}]: HTTP error at start={start}: {e}; stopping"
1514
+ )
1515
+ break
1516
+
1517
+ if resp is None:
1518
+ break # All retries failed, stop pagination
1519
+
1520
+ # Handle rate limits
1521
+ if resp.status_code == 429:
1522
+ backoff = random.uniform(15.0, 25.0)
1523
+ logger.warning(
1524
+ f"parallel[{keyword[:30]}]: 429 at start={start}; "
1525
+ f"backing off {backoff:.1f}s"
1526
+ )
1527
+ await asyncio.sleep(backoff)
1528
+ try:
1529
+ resp = await client.get(url)
1530
+ except (httpx.TimeoutException, httpx.NetworkError, OSError, httpx.HTTPError):
1531
+ break
1532
+
1533
+ if resp.status_code >= 400:
1534
+ logger.warning(
1535
+ f"parallel[{keyword[:30]}]: status {resp.status_code}; stopping"
1536
+ )
1537
+ break
1538
+
1539
+ html = resp.text or ""
1540
+ if not html.strip() or _is_empty_stub(html):
1541
+ consecutive_empty += 1
1542
+ if consecutive_empty >= 2:
1543
+ break
1544
+ # One empty might be transient — try next offset
1545
+ start += 25
1546
+ await asyncio.sleep(random.uniform(0.5, 1.0))
1547
+ continue
1548
+
1549
+ consecutive_empty = 0
1550
+ page_jobs = _parse_jobs_from_html(html)
1551
+ logger.info(
1552
+ f"parallel[{keyword[:30]}]: start={start} → {len(page_jobs)} jobs"
1553
+ )
1554
+
1555
+ if not page_jobs:
1556
+ break
1557
+
1558
+ collected.extend(page_jobs)
1559
+ start += len(page_jobs)
1560
+
1561
+ # Human-like delays between pages
1562
+ await asyncio.sleep(random.uniform(4.0, 7.0))
1563
+
1564
+ except Exception as e:
1565
+ logger.warning(
1566
+ f"parallel[{keyword[:30]}]: unexpected error: {e}; "
1567
+ f"returning {len(collected)} jobs"
1568
+ )
1569
+
1570
+ # Cool-down before releasing semaphore to next keyword
1571
+ await asyncio.sleep(random.uniform(4.0, 8.0))
1572
+
1573
+ logger.info(f"parallel[{keyword[:30]}]: done — {len(collected)} jobs total")
1574
+ return collected
1575
+
1576
+
1577
+ def _resolve_search(user_keyword: str) -> tuple[list[str], Optional[str]]:
1578
+ """Resolve user input into expanded search keywords + optional school filter.
1579
+
1580
+ Resolution order:
1581
+ 1. Exact school code (case-insensitive): "SOCSE" → all SOCSE terms
1582
+ 2. Shortcut keyword: "software" → SOCSE terms
1583
+ 3. Direct passthrough: "Data Analyst" → ["Data Analyst"], no filter
1584
+
1585
+ Returns:
1586
+ (search_keywords, school_code_to_filter_by_or_None)
1587
+ """
1588
+ key = user_keyword.strip().lower()
1589
+
1590
+ # 1. Exact school code match (SOCSE, solas, SOB, etc.)
1591
+ school_lower_map = {code.lower(): code for code in SCHOOLS}
1592
+ if key in school_lower_map:
1593
+ code = school_lower_map[key]
1594
+ terms = SCHOOL_SEARCH_TERMS.get(key, [])
1595
+ logger.info(
1596
+ f"resolve_search: school code '{code}' → {len(terms)} search terms"
1597
+ )
1598
+ return terms if terms else [user_keyword], code
1599
+
1600
+ # 2. Shortcut keyword → school (e.g. "software" → SOCSE)
1601
+ if key in KEYWORD_TO_SCHOOL:
1602
+ school_code = KEYWORD_TO_SCHOOL[key]
1603
+ terms = []
1604
+ for school in school_code.split(","):
1605
+ for t in SCHOOL_SEARCH_TERMS.get(school.strip().lower(), []).copy():
1606
+ if t not in terms:
1607
+ terms.append(t)
1608
+
1609
+ # Guarantee the user's explicit shortcut keyword is also searched!
1610
+ # Sometimes shortcuts are exact job titles (e.g. "marketing", "data").
1611
+ if key not in [t.lower() for t in terms]:
1612
+ # Insert at the beginning so it's prioritized
1613
+ terms.insert(0, user_keyword.strip())
1614
+
1615
+ logger.info(
1616
+ f"resolve_search: shortcut '{key}' → school {school_code} "
1617
+ f"({len(terms)} search terms including shortcut)"
1618
+ )
1619
+ return terms if terms else [user_keyword], school_code
1620
+
1621
+ # 3. Direct keyword — no expansion, no school filter
1622
+ logger.info(f"resolve_search: direct keyword '{user_keyword}' (no expansion)")
1623
+ return [user_keyword], None
1624
+
1625
+
1626
+ def _deduplicate_jobs(jobs: list[dict]) -> tuple[list[dict], int]:
1627
+ """Deduplicate jobs by canonical link. Returns (deduped_list, num_removed)."""
1628
+ seen: dict[str, dict] = {}
1629
+ untagged: list[dict] = []
1630
+ for job in jobs:
1631
+ key = job.get("link")
1632
+ if key:
1633
+ if key not in seen:
1634
+ seen[key] = job
1635
+ else:
1636
+ untagged.append(job)
1637
+ deduped = list(seen.values()) + untagged
1638
+ return deduped, len(jobs) - len(deduped)
1639
+
1640
+
1641
+
1642
+ @app.post("/scrape-internships")
1643
+ async def scrape_internships(
1644
+ keywords: str = Query("Software Engineer", description="Job search keywords"),
1645
+ freshness: str = Query("r86400", description="Posting age: r3600=1h r86400=24h r604800=week r2592000=month"),
1646
+ work_types: Optional[list[str]] = Query(
1647
+ default=None,
1648
+ description="Work-type checkboxes — repeat per selection: onsite | remote | hybrid",
1649
+ ),
1650
+ job_types: Optional[list[str]] = Query(
1651
+ default=None,
1652
+ description="Job-type checkboxes — repeat per selection: internship | full_time",
1653
+ ),
1654
+ ):
1655
+ # ── Resolve keywords + optional school filter ──────────────
1656
+ all_keywords, school_filter = _resolve_search(keywords)
1657
+
1658
+ if job_types and any(jt.lower() == "internship" for jt in job_types):
1659
+ all_keywords = [f"{kw} intern" for kw in all_keywords]
1660
+ keywords = f"{keywords} intern"
1661
+
1662
+ is_multi = len(all_keywords) > 1
1663
+
1664
+ # Build the "primary" URL (used for the "Open in LinkedIn" link)
1665
+ primary_url = build_search_url(
1666
+ keywords=keywords,
1667
+ freshness=freshness,
1668
+ work_types=work_types,
1669
+ job_types=job_types,
1670
+ )
1671
+ primary_params = build_search_params(
1672
+ keywords=keywords,
1673
+ freshness=freshness,
1674
+ work_types=work_types,
1675
+ job_types=job_types,
1676
+ )
1677
+
1678
+ async def _stream_generator():
1679
+ """NDJSON streaming generator.
1680
+
1681
+ Emits lines of JSON, one per event:
1682
+ {"type": "start", ...}
1683
+ {"type": "info", "message": ...}
1684
+ {"type": "jobs", "data": [...], ...}
1685
+ {"type": "done", ...}
1686
+ {"type": "error", "message": ...}
1687
+ """
1688
+ all_jobs: list[dict] = []
1689
+ engine_used = "http"
1690
+ total_dupes_removed = 0
1691
+ total_filtered_out = 0
1692
+
1693
+ try:
1694
+ # ── Emit start event ──────────────────────────────
1695
+ yield json.dumps({
1696
+ "type": "start",
1697
+ "engine": "http",
1698
+ "total_searches": len(all_keywords) if is_multi else 1,
1699
+ }) + "\n"
1700
+
1701
+ if is_multi:
1702
+ school_label = f" (school: {school_filter})" if school_filter else ""
1703
+ yield json.dumps({
1704
+ "type": "info",
1705
+ "message": f"Expanding '{keywords}' → {len(all_keywords)} parallel searches{school_label}",
1706
+ }) + "\n"
1707
+
1708
+ # ── Warm up cookies once ──────────────────────────
1709
+ cookies = await _warm_up_cookies(force=False)
1710
+ if not cookies:
1711
+ yield json.dumps({
1712
+ "type": "info",
1713
+ "message": "Cookie warm-up: re-warming...",
1714
+ }) + "\n"
1715
+ cookies = await _warm_up_cookies(force=True)
1716
+
1717
+ if is_multi:
1718
+ # ══ PARALLEL MULTI-KEYWORD PATH ══════════════
1719
+ # Fan out all keywords as concurrent tasks.
1720
+ # As each completes, stream its results immediately.
1721
+
1722
+ seen_links: set = set()
1723
+
1724
+ async def _scrape_and_collect(kw: str, index: int):
1725
+ """Scrape one keyword with a stagger delay to avoid 429s."""
1726
+ # Stagger: each task waits before starting so requests are spread out.
1727
+ stagger = index * random.uniform(3.0, 6.0)
1728
+ if stagger > 0:
1729
+ await asyncio.sleep(stagger)
1730
+ jobs = await _fetch_all_pages_for_keyword(
1731
+ keyword=kw,
1732
+ base_params=primary_params,
1733
+ cookies=cookies,
1734
+ max_pages=10,
1735
+ )
1736
+ return kw, jobs
1737
+
1738
+ # Create all tasks
1739
+ tasks = [
1740
+ asyncio.create_task(_scrape_and_collect(kw, i))
1741
+ for i, kw in enumerate(all_keywords)
1742
+ ]
1743
+
1744
+ completed = 0
1745
+ for coro in asyncio.as_completed(tasks):
1746
+ try:
1747
+ kw, kw_jobs = await coro
1748
+ completed += 1
1749
+
1750
+ # Deduplicate against already-seen links
1751
+ new_jobs = []
1752
+ for job in kw_jobs:
1753
+ link = job.get("link")
1754
+ if link:
1755
+ if link not in seen_links:
1756
+ seen_links.add(link)
1757
+ new_jobs.append(job)
1758
+ else:
1759
+ total_dupes_removed += 1
1760
+ else:
1761
+ new_jobs.append(job)
1762
+
1763
+ if school_filter and new_jobs:
1764
+ pre = len(new_jobs)
1765
+ filter_schools = [s.strip() for s in school_filter.split(",")]
1766
+ new_jobs = [
1767
+ j for j in new_jobs
1768
+ if any(sf in j.get("schools", []) for sf in filter_schools)
1769
+ ]
1770
+ total_filtered_out += pre - len(new_jobs)
1771
+
1772
+ # Strict internship title filter if only internship is selected
1773
+ if job_types and "internship" in job_types and "full_time" not in job_types:
1774
+ pre = len(new_jobs)
1775
+ intern_kw = ["intern", "trainee", "student", "co-op", "apprentice", "fellow"]
1776
+ new_jobs = [
1777
+ j for j in new_jobs
1778
+ if any(ik in j.get("title", "").lower() for ik in intern_kw)
1779
+ ]
1780
+ total_filtered_out += pre - len(new_jobs)
1781
+
1782
+
1783
+ all_jobs.extend(new_jobs)
1784
+
1785
+ logger.info(
1786
+ f"multi-search [{completed}/{len(all_keywords)}]: "
1787
+ f"'{kw}' → {len(kw_jobs)} raw, {len(new_jobs)} new "
1788
+ f"(total {len(all_jobs)})"
1789
+ )
1790
+
1791
+ # Stream the batch
1792
+ if new_jobs:
1793
+ yield json.dumps({
1794
+ "type": "jobs",
1795
+ "data": new_jobs,
1796
+ "keyword": kw,
1797
+ "engine": "http",
1798
+ "deduplicated": total_dupes_removed,
1799
+ }) + "\n"
1800
+
1801
+ yield json.dumps({
1802
+ "type": "info",
1803
+ "message": (
1804
+ f"[{completed}/{len(all_keywords)}] "
1805
+ f"'{kw}' → {len(new_jobs)} new jobs "
1806
+ f"(total: {len(all_jobs)})"
1807
+ ),
1808
+ }) + "\n"
1809
+
1810
+ except Exception as e:
1811
+ completed += 1
1812
+ logger.warning(f"multi-search: task error: {e}")
1813
+ yield json.dumps({
1814
+ "type": "info",
1815
+ "message": f"[{completed}/{len(all_keywords)}] Search failed: {e}",
1816
+ }) + "\n"
1817
+
1818
+ engine_used = "http"
1819
+
1820
+
1821
+ else:
1822
+ # ══ SINGLE-KEYWORD PATH ══════════════════════
1823
+ # Original flow: HTTP → Selenium → Playwright
1824
+ jobs, engine_used = await _scrape_one_url(
1825
+ primary_url, params=primary_params, paginate=True
1826
+ )
1827
+
1828
+ if jobs:
1829
+ jobs, dupes = _deduplicate_jobs(jobs)
1830
+ total_dupes_removed = dupes
1831
+
1832
+ if school_filter:
1833
+ pre = len(jobs)
1834
+ filter_schools = [s.strip() for s in school_filter.split(",")]
1835
+ jobs = [
1836
+ j for j in jobs
1837
+ if any(sf in j.get("schools", []) for sf in filter_schools)
1838
+ ]
1839
+ total_filtered_out += pre - len(jobs)
1840
+
1841
+ # Strict internship title filter if only internship is selected
1842
+ if job_types and "internship" in job_types and "full_time" not in job_types:
1843
+ pre = len(jobs)
1844
+ intern_kw = ["intern", "trainee", "student", "co-op", "apprentice", "fellow"]
1845
+ jobs = [
1846
+ j for j in jobs
1847
+ if any(ik in j.get("title", "").lower() for ik in intern_kw)
1848
+ ]
1849
+ total_filtered_out += pre - len(jobs)
1850
+
1851
+
1852
+ all_jobs = jobs
1853
+
1854
+ yield json.dumps({
1855
+ "type": "jobs",
1856
+ "data": all_jobs,
1857
+ "engine": engine_used,
1858
+ "deduplicated": total_dupes_removed,
1859
+ }) + "\n"
1860
+
1861
+ # ── Emit done event ───────────────────────────────
1862
+ if not all_jobs:
1863
+ yield json.dumps({
1864
+ "type": "error",
1865
+ "message": "All scraping engines failed — 0 results",
1866
+ }) + "\n"
1867
+ else:
1868
+ try:
1869
+ logger.info(
1870
+ f"serper: Searching hiring contacts for {len(all_jobs)} companies..."
1871
+ )
1872
+ yield json.dumps({
1873
+ "type": "info",
1874
+ "message": f"Searching for hiring contacts via Google for {len(all_jobs)} companies...",
1875
+ }) + "\n"
1876
+ await _fetch_hiring_contacts_via_serper(all_jobs)
1877
+ found_count = sum(
1878
+ 1 for j in all_jobs if j.get("contact_details")
1879
+ )
1880
+ logger.info(
1881
+ f"serper: Hiring contact search done — "
1882
+ f"{found_count}/{len(all_jobs)} jobs have a contact"
1883
+ )
1884
+ # ── VERIFICATION PRINT (visible in terminal logs) ──────
1885
+ print(f"\n{'='*60}")
1886
+ print(f"[SERPER HIRING CONTACTS] {found_count}/{len(all_jobs)} jobs enriched")
1887
+ for j in all_jobs:
1888
+ contacts = j.get("contact_details") or []
1889
+ if contacts:
1890
+ for c in contacts:
1891
+ if isinstance(c, dict):
1892
+ print(
1893
+ f" ✓ {j.get('title','?')} @ {j.get('company','?')}: "
1894
+ f"{c.get('name','?')} → {c.get('url','?')}"
1895
+ )
1896
+ else:
1897
+ print(f" ✓ {j.get('title','?')} @ {j.get('company','?')}: {c}")
1898
+ print(f"{'='*60}\n")
1899
+
1900
+ # ── Stream enriched jobs back to frontend ──────────────
1901
+ enriched_jobs = [j for j in all_jobs if j.get("contact_details")]
1902
+ if enriched_jobs:
1903
+ yield json.dumps({
1904
+ "type": "jobs",
1905
+ "data": enriched_jobs,
1906
+ "engine": engine_used,
1907
+ "deduplicated": 0,
1908
+ "is_contact_update": True,
1909
+ }) + "\n"
1910
+ logger.info(
1911
+ f"serper: Streamed {len(enriched_jobs)} enriched jobs back to frontend"
1912
+ )
1913
+ except Exception as e:
1914
+ logger.warning(f"serper: Hiring contact search failed: {e}")
1915
+
1916
+ # Save jobs to database for student dashboard
1917
+ search_params = {
1918
+ 'keywords': keywords,
1919
+ 'freshness': freshness,
1920
+ 'work_types': work_types or [],
1921
+ 'job_types': job_types or []
1922
+ }
1923
+ try:
1924
+ saved_count = save_scraped_jobs(all_jobs, search_params)
1925
+ logger.info(f"Saved {saved_count} jobs to database")
1926
+ except Exception as e:
1927
+ logger.warning(f"Failed to save jobs to database: {e}")
1928
+
1929
+ yield json.dumps({
1930
+ "type": "done",
1931
+ "total": len(all_jobs),
1932
+ "engine": engine_used,
1933
+ "url": primary_url,
1934
+ "deduplicated": total_dupes_removed,
1935
+ "filtered_out": total_filtered_out,
1936
+ "school_filter": school_filter,
1937
+ "searches_completed": len(all_keywords),
1938
+ }) + "\n"
1939
+
1940
+ except Exception as e:
1941
+ logger.exception(f"scrape-internships stream error: {e}")
1942
+ # If we have collected any jobs, emit them first with partial results notification
1943
+ if all_jobs:
1944
+ yield json.dumps({
1945
+ "type": "info",
1946
+ "message": f"Stream interrupted after collecting {len(all_jobs)} jobs",
1947
+ }) + "\n"
1948
+ yield json.dumps({
1949
+ "type": "done",
1950
+ "total": len(all_jobs),
1951
+ "engine": engine_used,
1952
+ "partial": True,
1953
+ "error": str(e),
1954
+ "url": primary_url,
1955
+ "deduplicated": total_dupes_removed,
1956
+ "filtered_out": total_filtered_out,
1957
+ "school_filter": school_filter,
1958
+ "searches_completed": len(all_keywords),
1959
+ }) + "\n"
1960
+ else:
1961
+ # Only emit error if we have no jobs
1962
+ yield json.dumps({
1963
+ "type": "error",
1964
+ "message": str(e),
1965
+ }) + "\n"
1966
+
1967
+ global global_scrape
1968
+ if global_scrape.is_active:
1969
+ global_scrape.cancel()
1970
+
1971
+ session = ScrapeSession()
1972
+ session.is_active = True
1973
+ global_scrape = session
1974
+
1975
+ async def _pump():
1976
+ search_params_for_db = {
1977
+ 'keywords': keywords,
1978
+ 'freshness': freshness,
1979
+ 'work_types': work_types or [],
1980
+ 'job_types': job_types or []
1981
+ }
1982
+ try:
1983
+ async for item in _stream_generator():
1984
+ # Intercept and incrementally save jobs to DB so they appear in student portal mid-scrape
1985
+ try:
1986
+ data = json.loads(item.strip())
1987
+ if data.get("type") == "jobs" and data.get("data"):
1988
+ save_scraped_jobs(data["data"], search_params_for_db)
1989
+ except Exception as e:
1990
+ logger.warning(f"Incremental save failed: {e}")
1991
+
1992
+ # Save to history and broadcast to active queues
1993
+ session.history.append(item)
1994
+ for q in session.queues:
1995
+ await q.put(item)
1996
+ except Exception as e:
1997
+ logger.error(f"Background scraper error: {e}")
1998
+ finally:
1999
+ session.is_active = False
2000
+ for q in session.queues:
2001
+ await q.put(None)
2002
+ session.queues.clear()
2003
+
2004
+ # Start the background task so it continues even if the HTTP connection drops
2005
+ session.task = asyncio.create_task(_pump())
2006
+
2007
+ async def _consumer():
2008
+ q = asyncio.Queue()
2009
+ session.queues.append(q)
2010
+ try:
2011
+ while True:
2012
+ item = await q.get()
2013
+ if item is None:
2014
+ break
2015
+ yield item
2016
+ except asyncio.CancelledError:
2017
+ logger.info("Client disconnected, background scraper will continue.")
2018
+ raise
2019
+ finally:
2020
+ if q in session.queues:
2021
+ session.queues.remove(q)
2022
+
2023
+ return StreamingResponse(
2024
+ _consumer(),
2025
+ media_type="application/x-ndjson",
2026
+ headers={
2027
+ "Cache-Control": "no-cache",
2028
+ "X-Accel-Buffering": "no",
2029
+ },
2030
+ )
2031
+
2032
+ @app.get("/scrape-internships/status")
2033
+ async def scrape_status():
2034
+ """Return whether a scrape session is currently active or has history."""
2035
+ return {
2036
+ "is_active": global_scrape.is_active,
2037
+ "has_history": len(global_scrape.history) > 0
2038
+ }
2039
+
2040
+ @app.get("/scrape-internships/stream")
2041
+ async def stream_scrape():
2042
+ """Reconnect to the active scrape session or replay the last session's history."""
2043
+ async def _reconnect_consumer():
2044
+ # Yield all past events instantly
2045
+ for item in global_scrape.history:
2046
+ yield item
2047
+
2048
+ # If the session is still active, listen for new events
2049
+ if global_scrape.is_active:
2050
+ q = asyncio.Queue()
2051
+ global_scrape.queues.append(q)
2052
+ try:
2053
+ while True:
2054
+ item = await q.get()
2055
+ if item is None:
2056
+ break
2057
+ yield item
2058
+ except asyncio.CancelledError:
2059
+ raise
2060
+ finally:
2061
+ if q in global_scrape.queues:
2062
+ global_scrape.queues.remove(q)
2063
+
2064
+ return StreamingResponse(
2065
+ _reconnect_consumer(),
2066
+ media_type="application/x-ndjson",
2067
+ headers={
2068
+ "Cache-Control": "no-cache",
2069
+ "X-Accel-Buffering": "no",
2070
+ },
2071
+ )
2072
+
2073
+
2074
+ # ── Student Dashboard Endpoints ───────────────────────────────
2075
+
2076
+ @app.get("/student/jobs/recent")
2077
+ def get_recent_jobs(hours: int = Query(1, description="Hours ago (1 or 24)")):
2078
+ """Get jobs scraped within the last N hours for student dashboard"""
2079
+ try:
2080
+ if hours == 1:
2081
+ # Jobs from the last 1 hour
2082
+ jobs = get_jobs_in_timeframe(1, 0)
2083
+ elif hours == 24:
2084
+ # Jobs from 1-24 hours ago (excluding the last hour to avoid overlap)
2085
+ jobs = get_jobs_in_timeframe(24, 1)
2086
+ else:
2087
+ raise HTTPException(status_code=400, detail="Only 1 or 24 hours supported")
2088
+
2089
+ return {
2090
+ "jobs": jobs,
2091
+ "count": len(jobs),
2092
+ "timeframe": f"{hours} hour{'s' if hours > 1 else ''} ago"
2093
+ }
2094
+ except Exception as e:
2095
+ raise HTTPException(status_code=500, detail=str(e))
2096
+
2097
+ @app.get("/student/jobs/all-timeframes")
2098
+ def get_all_timeframes(background_tasks: BackgroundTasks):
2099
+ """Get jobs for all timeframes for student dashboard and auto-cleanup old jobs"""
2100
+ try:
2101
+ # Auto-delete jobs older than 30 days in the background
2102
+ background_tasks.add_task(cleanup_old_jobs, 30)
2103
+
2104
+ # Fetch all jobs in one query and bin them to save DB roundtrips
2105
+ return get_binned_jobs()
2106
+ except Exception as e:
2107
+ raise HTTPException(status_code=500, detail=str(e))
2108
+
2109
+ @app.post("/admin/cleanup")
2110
+ def cleanup_old_data(days: int = Query(7, description="Remove jobs older than N days")):
2111
+ """Admin endpoint to cleanup old job data"""
2112
+ try:
2113
+ deleted_count = cleanup_old_jobs(days)
2114
+ return {
2115
+ "message": f"Cleaned up {deleted_count} jobs older than {days} days",
2116
+ "deleted_count": deleted_count
2117
+ }
2118
+ except Exception as e:
2119
+ raise HTTPException(status_code=500, detail=str(e))
2120
+
2121
+
2122
+ @app.post("/admin/trigger-scrape")
2123
+ async def trigger_auto_scrape():
2124
+ """
2125
+ Manually kick off the full school sweep. Returns immediately;
2126
+ progress can be polled at GET /admin/scrape-status.
2127
+ If a sweep is already running, returns 409.
2128
+ """
2129
+ global _auto_scrape_task
2130
+ if _auto_scrape_state.get("is_running"):
2131
+ return {
2132
+ "status": "already_running",
2133
+ "message": "Auto-scrape is already in progress.",
2134
+ "state": _auto_scrape_state,
2135
+ }
2136
+
2137
+ scheduler.add_job(
2138
+ _auto_scrape_all_schools,
2139
+ trigger=IntervalTrigger(hours=1),
2140
+ id="hourly_school_scrape",
2141
+ replace_existing=True,
2142
+ next_run_time=datetime.now(timezone.utc),
2143
+ misfire_grace_time=300,
2144
+ )
2145
+
2146
+ return {
2147
+ "status": "started",
2148
+ "message": f"Auto-scrape started for {len(_AUTO_SCRAPE_SCHOOLS)} schools (running hourly).",
2149
+ "schools": _AUTO_SCRAPE_SCHOOLS,
2150
+ }
2151
+
2152
+
2153
+ @app.post("/admin/stop-scrape")
2154
+ async def stop_auto_scrape():
2155
+ """
2156
+ Stop the currently running auto-scrape sweep and remove the hourly scheduled job.
2157
+ """
2158
+ if scheduler.get_job("hourly_school_scrape"):
2159
+ scheduler.remove_job("hourly_school_scrape")
2160
+
2161
+ _auto_scrape_state["cancel_requested"] = True
2162
+
2163
+ global global_scrape
2164
+ if global_scrape.is_active:
2165
+ global_scrape.cancel()
2166
+
2167
+ return {"status": "stopped", "message": "Auto-scrape has been stopped and scheduled jobs removed."}
2168
+
2169
+
2170
+ @app.get("/admin/scrape-status")
2171
+ async def get_scrape_status():
2172
+ """Poll the live progress of the currently-running (or last) auto-scrape sweep."""
2173
+ state = dict(_auto_scrape_state)
2174
+ if state.get("started_at"):
2175
+ elapsed = (state.get("finished_at") or time.time()) - state["started_at"]
2176
+ state["elapsed_seconds"] = round(elapsed)
2177
+ total_done = len(state.get("completed", [])) + len(state.get("failed", []))
2178
+ state["progress"] = f"{total_done}/{len(_AUTO_SCRAPE_SCHOOLS)}"
2179
+ return state
2180
+
2181
+
2182
+ @app.get("/admin/scrape-history")
2183
+ async def get_scrape_history():
2184
+ """Return the full history of all auto-scrape sweep runs (newest first).
2185
+
2186
+ Each entry has: sweep_id, is_running, current_school, completed, failed,
2187
+ started_at, finished_at, cancelled, elapsed_seconds, progress.
2188
+ """
2189
+ history = []
2190
+ for entry in reversed(_auto_scrape_history):
2191
+ item = dict(entry)
2192
+ # Compute elapsed seconds
2193
+ if item.get("started_at"):
2194
+ elapsed = (item.get("finished_at") or time.time()) - item["started_at"]
2195
+ item["elapsed_seconds"] = round(elapsed)
2196
+ # Compute progress string
2197
+ total_done = len(item.get("completed", [])) + len(item.get("failed", []))
2198
+ item["progress"] = f"{total_done}/{len(_AUTO_SCRAPE_SCHOOLS)}"
2199
+ item["total_schools"] = len(_AUTO_SCRAPE_SCHOOLS)
2200
+ item["schools"] = _AUTO_SCRAPE_SCHOOLS
2201
+ history.append(item)
2202
+ return {"history": history, "total_runs": len(history)}
2203
+
2204
+
2205
+ async def process_company_ratings(companies: list[str]) -> dict:
2206
+ if not companies:
2207
+ return {}
2208
+
2209
+ SERPER_API_KEY = "3dab84d0cc8479711c62e0ecceab95bd9702dba1"
2210
+ ratings = {}
2211
+
2212
+ async def _fetch_company_rating(client: httpx.AsyncClient, company: str, semaphore: asyncio.Semaphore) -> tuple[str, float]:
2213
+ async with semaphore:
2214
+ query = f'"{company}" reviews Bangalore site:glassdoor.co.in OR site:ambitionbox.com'
2215
+ try:
2216
+ resp = await client.post(
2217
+ "https://google.serper.dev/search",
2218
+ json={"q": query},
2219
+ headers={"X-API-KEY": SERPER_API_KEY, "Content-Type": "application/json"}
2220
+ )
2221
+ data = resp.json()
2222
+ for org in data.get("organic", []):
2223
+ # Try Serper's built-in rating first
2224
+ if "rating" in org and isinstance(org["rating"], (int, float)):
2225
+ return company, float(org["rating"])
2226
+ # Fallback to regex on snippet
2227
+ snippet = org.get("snippet", "")
2228
+ match = re.search(r"Rating:\s*([\d\.]+)", snippet, re.IGNORECASE)
2229
+ if match:
2230
+ return company, float(match.group(1))
2231
+ except Exception as e:
2232
+ logger.warning(f"rate-companies: Serper search failed for {company}: {repr(e)}")
2233
+ return company, 2.5
2234
+
2235
+ try:
2236
+ # We limit concurrency to 10 to avoid overwhelming the network or API limits
2237
+ semaphore = asyncio.Semaphore(10)
2238
+ async with httpx.AsyncClient(timeout=15.0) as client:
2239
+ tasks = [_fetch_company_rating(client, c, semaphore) for c in companies]
2240
+ results = await asyncio.gather(*tasks)
2241
+
2242
+ for company, rating in results:
2243
+ # Clamp to [1, 5] just in case
2244
+ ratings[company] = round(max(1.0, min(5.0, rating)), 1)
2245
+
2246
+ # Fill in any companies missed with a neutral score
2247
+ for company in companies:
2248
+ if company not in ratings:
2249
+ ratings[company] = 2.5
2250
+
2251
+ return ratings
2252
+ except Exception as e:
2253
+ logger.error(f"rate-companies: process_company_ratings failed: {repr(e)}")
2254
+ raise e
2255
+
2256
+
2257
+ class RateCompaniesRequest(BaseModel):
2258
+ companies: list[str]
2259
+
2260
+
2261
+ @app.post("/rate-companies")
2262
+ async def rate_companies(req: RateCompaniesRequest, background_tasks: BackgroundTasks):
2263
+ """
2264
+ Use Serper.dev (Google Search API) to estimate a reputation/internship
2265
+ quality rating (1.0–5.0) for each submitted company name (Bangalore context).
2266
+
2267
+ A concurrent search is made for each company, extracting the rating from
2268
+ Glassdoor/Ambitionbox search snippets.
2269
+ """
2270
+ try:
2271
+ ratings = await process_company_ratings(req.companies)
2272
+ # Persist ratings to DB in the background (non-blocking)
2273
+ background_tasks.add_task(_persist_ratings, ratings)
2274
+
2275
+ logger.info(f"rate-companies: successfully rated {len(ratings)} companies via Search API")
2276
+ return {"ratings": ratings}
2277
+
2278
+ except Exception as e:
2279
+ logger.error(f"rate-companies: Search API call failed: {repr(e)}")
2280
+ raise HTTPException(status_code=500, detail=f"Search rating failed: {str(e)}")
2281
+
2282
+
2283
+ def _persist_ratings(ratings: dict):
2284
+ """Write company ratings back to the DB (called as a background task)."""
2285
+ try:
2286
+ updated = update_company_ratings(ratings)
2287
+ logger.info(f"rate-companies: persisted ratings for {updated} job rows in DB")
2288
+ except Exception as e:
2289
+ logger.error(f"rate-companies: DB persist failed: {e}")
2290
+
2291
+
2292
+ # ── Manual Rating Management ─────────────────────────────────────
2293
+
2294
+ class SetRatingRequest(BaseModel):
2295
+ company: str
2296
+ rating: float
2297
+
2298
+ @app.post("/set-company-rating")
2299
+ async def set_company_rating(req: SetRatingRequest):
2300
+ """Manually set a rating for a specific company (for testing/admin purposes)."""
2301
+ try:
2302
+ # Validate rating range
2303
+ rating = max(1.0, min(5.0, req.rating))
2304
+
2305
+ # Update the database
2306
+ updated = update_company_ratings({req.company: rating})
2307
+
2308
+ return {
2309
+ "message": f"Updated rating for '{req.company}' to {rating}",
2310
+ "company": req.company,
2311
+ "rating": rating,
2312
+ "rows_updated": updated
2313
+ }
2314
+ except Exception as e:
2315
+ logger.error(f"set-company-rating: failed: {e}")
2316
+ raise HTTPException(status_code=500, detail=str(e))
2317
+
2318
+
2319
+ @app.get("/get-company-ratings")
2320
+ async def get_company_ratings():
2321
+ """Get all companies and their current ratings from the database."""
2322
+ try:
2323
+ conn = _connect()
2324
+ try:
2325
+ with conn.cursor() as cur:
2326
+ cur.execute(
2327
+ """
2328
+ SELECT DISTINCT company, company_rating
2329
+ FROM scraped_jobs
2330
+ WHERE company IS NOT NULL AND company_rating IS NOT NULL
2331
+ ORDER BY company
2332
+ """
2333
+ )
2334
+ rows = cur.fetchall()
2335
+ finally:
2336
+ conn.close()
2337
+
2338
+ ratings = {row[0]: row[1] for row in rows}
2339
+ return {"ratings": ratings}
2340
+ except Exception as e:
2341
+ logger.error(f"get-company-ratings: failed: {e}")
2342
+ raise HTTPException(status_code=500, detail=str(e))
2343
+