Sambhavvvvv commited on
Commit
4aeff1e
·
verified ·
1 Parent(s): 67ce839

Upload main.py

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