Sambhavvvvv commited on
Commit
fe07a7d
·
verified ·
1 Parent(s): 4810800

Upload database.py

Browse files
Files changed (1) hide show
  1. database.py +432 -0
database.py ADDED
@@ -0,0 +1,432 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ from datetime import datetime, timedelta, timezone
4
+ from typing import List, Dict
5
+
6
+ import psycopg2
7
+ from psycopg2.extras import Json
8
+
9
+ # ── Configuration ────────────────────────────────────────────────────────────
10
+
11
+ # Pull DATABASE_URL from the environment, falling back to a local .env file
12
+ # so the same Postgres credentials work for both this scraper and the
13
+ # Next.js / Drizzle side without having to re-export the variable.
14
+ try:
15
+ from dotenv import load_dotenv
16
+
17
+ _here = os.path.dirname(os.path.abspath(__file__))
18
+ for _candidate in (
19
+ os.path.join(_here, ".env"),
20
+ os.path.join(_here, "components", ".env"),
21
+ ):
22
+ if os.path.exists(_candidate):
23
+ load_dotenv(_candidate, override=False)
24
+ except ImportError:
25
+ pass
26
+
27
+ DATABASE_URL = os.environ.get("DATABASE_URL")
28
+ if not DATABASE_URL:
29
+ raise RuntimeError(
30
+ "DATABASE_URL is not set. Define it in your environment or in a "
31
+ ".env file at the project root (or in components/.env)."
32
+ )
33
+
34
+
35
+ def _connect():
36
+ """Open a fresh Postgres connection.
37
+
38
+ We open/close per call instead of holding a global. With Neon's pooler
39
+ endpoint (the one in DATABASE_URL) each `connect` is effectively a
40
+ cheap checkout from their server-side pool, so this stays simple and
41
+ plays nicely with FastAPI's threadpool — no shared cursors between
42
+ request handlers.
43
+ """
44
+ return psycopg2.connect(DATABASE_URL)
45
+
46
+
47
+ # ── Schema bootstrap ─────────────────────────────────────────────────────────
48
+
49
+
50
+ def init_database():
51
+ """Initialize the database with required tables.
52
+
53
+ Idempotent: every statement uses IF NOT EXISTS, so it's safe to run on
54
+ every import and it won't fight with Drizzle's migrations if they've
55
+ already created the table.
56
+ """
57
+ conn = _connect()
58
+ try:
59
+ with conn.cursor() as cur:
60
+ cur.execute(
61
+ """
62
+ CREATE TABLE IF NOT EXISTS scraped_jobs (
63
+ id SERIAL PRIMARY KEY,
64
+ title TEXT,
65
+ company TEXT,
66
+ location TEXT,
67
+ link TEXT UNIQUE,
68
+ posted TEXT,
69
+ posted_datetime TIMESTAMP,
70
+ programs JSONB DEFAULT '[]'::jsonb,
71
+ schools JSONB DEFAULT '[]'::jsonb,
72
+ scraped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
73
+ keywords TEXT,
74
+ freshness TEXT,
75
+ work_types JSONB DEFAULT '[]'::jsonb,
76
+ job_types JSONB DEFAULT '[]'::jsonb,
77
+ company_rating REAL,
78
+ contact_details JSONB DEFAULT '[]'::jsonb
79
+ )
80
+ """
81
+ )
82
+ # Migrate existing tables that don't yet have company_rating or contact_details
83
+ cur.execute(
84
+ """
85
+ ALTER TABLE scraped_jobs
86
+ ADD COLUMN IF NOT EXISTS company_rating REAL
87
+ """
88
+ )
89
+ cur.execute(
90
+ """
91
+ ALTER TABLE scraped_jobs
92
+ ADD COLUMN IF NOT EXISTS contact_details JSONB DEFAULT '[]'::jsonb
93
+ """
94
+ )
95
+ cur.execute(
96
+ "CREATE INDEX IF NOT EXISTS idx_scraped_at "
97
+ "ON scraped_jobs(scraped_at)"
98
+ )
99
+ cur.execute(
100
+ "CREATE INDEX IF NOT EXISTS idx_link ON scraped_jobs(link)"
101
+ )
102
+ conn.commit()
103
+ finally:
104
+ conn.close()
105
+
106
+
107
+ # ── Internal helpers ─────────────────────────────────────────────────────────
108
+
109
+
110
+ def _coerce_posted_datetime(posted: str, raw_posted_dt, now_dt: datetime) -> datetime:
111
+ """Normalize the scraper's posted_datetime into a real `datetime`.
112
+
113
+ The scraper sometimes only gives us a relative label like "3 hours
114
+ ago" or a bare date string. SQLite stored these as TEXT, but Postgres
115
+ has a real TIMESTAMP column, so we resolve everything to a `datetime`
116
+ here before insertion. Falls back to `now_dt` when neither source is
117
+ usable. Behavior matches the original SQLite version.
118
+ """
119
+ needs_recompute = (
120
+ not raw_posted_dt
121
+ or (isinstance(raw_posted_dt, str) and len(raw_posted_dt) <= 10)
122
+ )
123
+
124
+ if posted and needs_recompute:
125
+ posted_lower = posted.lower()
126
+ match = re.search(r"(\d+)", posted_lower)
127
+ if match:
128
+ num = int(match.group(1))
129
+ if "minute" in posted_lower:
130
+ return now_dt - timedelta(minutes=num)
131
+ if "hour" in posted_lower:
132
+ return now_dt - timedelta(hours=num)
133
+ if "day" in posted_lower:
134
+ return now_dt - timedelta(days=num)
135
+ if "week" in posted_lower:
136
+ return now_dt - timedelta(weeks=num)
137
+ if "month" in posted_lower:
138
+ return now_dt - timedelta(days=num * 30)
139
+ return now_dt
140
+
141
+ if isinstance(raw_posted_dt, datetime):
142
+ return raw_posted_dt
143
+ if isinstance(raw_posted_dt, str):
144
+ try:
145
+ return datetime.fromisoformat(raw_posted_dt)
146
+ except ValueError:
147
+ pass
148
+
149
+ return now_dt
150
+
151
+
152
+ def _row_to_job(row) -> Dict:
153
+ """Translate a SELECT row tuple into the dict shape main.py / the UI expect.
154
+
155
+ Notes:
156
+ * `programs` / `schools` come back as native Python lists because
157
+ psycopg2 auto-parses JSONB — no `json.loads` needed.
158
+ * `posted_datetime` / `scraped_at` come back as `datetime` objects;
159
+ we ISO-format them so the result survives `json.dumps` in FastAPI
160
+ without extra encoders.
161
+ """
162
+ return {
163
+ "title": row[0],
164
+ "company": row[1],
165
+ "location": row[2],
166
+ "link": row[3],
167
+ "posted": row[4],
168
+ "posted_datetime": row[5].replace(tzinfo=timezone.utc).isoformat() if row[5] else None,
169
+ "programs": row[6] if row[6] is not None else [],
170
+ "schools": row[7] if row[7] is not None else [],
171
+ "scraped_at": row[8].replace(tzinfo=timezone.utc).isoformat() if row[8] else None,
172
+ "keywords": row[9],
173
+ "company_rating": row[10], # float or None
174
+ "contact_details": row[11] if len(row) > 11 and row[11] is not None else [],
175
+ }
176
+
177
+
178
+ # ── Public API (signatures preserved from the SQLite version) ────────────────
179
+
180
+
181
+ def save_scraped_jobs(jobs: List[Dict], search_params: Dict):
182
+ """Save scraped jobs to database."""
183
+ conn = _connect()
184
+ saved_count = 0
185
+ try:
186
+ with conn.cursor() as cur:
187
+ now_dt = datetime.now(timezone.utc)
188
+
189
+ for job in jobs:
190
+ posted = job.get("posted", "")
191
+ posted_dt = _coerce_posted_datetime(
192
+ posted, job.get("posted_datetime"), now_dt
193
+ )
194
+
195
+ cur.execute(
196
+ """
197
+ INSERT INTO scraped_jobs (
198
+ title, company, location, link, posted,
199
+ posted_datetime, programs, schools, keywords,
200
+ freshness, work_types, job_types, scraped_at, contact_details
201
+ )
202
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
203
+ ON CONFLICT (link) DO UPDATE SET
204
+ title = EXCLUDED.title,
205
+ company = EXCLUDED.company,
206
+ location = EXCLUDED.location,
207
+ posted = EXCLUDED.posted,
208
+ posted_datetime = EXCLUDED.posted_datetime,
209
+ programs = EXCLUDED.programs,
210
+ schools = EXCLUDED.schools,
211
+ keywords = EXCLUDED.keywords,
212
+ freshness = EXCLUDED.freshness,
213
+ work_types = EXCLUDED.work_types,
214
+ job_types = EXCLUDED.job_types,
215
+ scraped_at = EXCLUDED.scraped_at,
216
+ contact_details = EXCLUDED.contact_details
217
+ """,
218
+ (
219
+ job.get("title"),
220
+ job.get("company"),
221
+ job.get("location"),
222
+ job.get("link"),
223
+ posted,
224
+ posted_dt,
225
+ Json(job.get("programs", []) or []),
226
+ Json(job.get("schools", []) or []),
227
+ search_params.get("keywords"),
228
+ search_params.get("freshness"),
229
+ Json(search_params.get("work_types", []) or []),
230
+ Json(search_params.get("job_types", []) or []),
231
+ now_dt,
232
+ Json(job.get("contact_details", []) or []),
233
+ ),
234
+ )
235
+ saved_count += 1
236
+ conn.commit()
237
+ except Exception:
238
+ conn.rollback()
239
+ raise
240
+ finally:
241
+ conn.close()
242
+ return saved_count
243
+
244
+
245
+ def get_existing_jobs_contacts(links: List[str]) -> Dict[str, list]:
246
+ """Get existing contact_details for jobs already in the database."""
247
+ if not links:
248
+ return {}
249
+ conn = _connect()
250
+ try:
251
+ with conn.cursor() as cur:
252
+ cur.execute(
253
+ "SELECT link, contact_details FROM scraped_jobs WHERE link = ANY(%s)",
254
+ (links,)
255
+ )
256
+ rows = cur.fetchall()
257
+ return {row[0]: row[1] if row[1] is not None else [] for row in rows}
258
+ finally:
259
+ conn.close()
260
+
261
+
262
+ def get_companies_with_ratings(companies: List[str]) -> List[str]:
263
+ """Get companies that already have a company_rating in the database."""
264
+ if not companies:
265
+ return []
266
+ conn = _connect()
267
+ try:
268
+ with conn.cursor() as cur:
269
+ lower_companies = [c.lower() for c in companies]
270
+ cur.execute(
271
+ "SELECT DISTINCT company FROM scraped_jobs WHERE LOWER(company) = ANY(%s) AND company_rating IS NOT NULL",
272
+ (lower_companies,)
273
+ )
274
+ rows = cur.fetchall()
275
+ return [row[0].lower() for row in rows]
276
+ finally:
277
+ conn.close()
278
+
279
+
280
+ def get_jobs_by_timeframe(hours_ago: int) -> List[Dict]:
281
+ """Get jobs scraped within the specified timeframe."""
282
+ conn = _connect()
283
+ try:
284
+ with conn.cursor() as cur:
285
+ time_threshold = datetime.now(timezone.utc) - timedelta(hours=hours_ago)
286
+ cur.execute(
287
+ """
288
+ SELECT title, company, location, link, posted, posted_datetime,
289
+ programs, schools, scraped_at, keywords, company_rating, contact_details
290
+ FROM scraped_jobs
291
+ WHERE posted_datetime >= %s
292
+ ORDER BY posted_datetime DESC
293
+ """,
294
+ (time_threshold,),
295
+ )
296
+ rows = cur.fetchall()
297
+ finally:
298
+ conn.close()
299
+ return [_row_to_job(row) for row in rows]
300
+
301
+
302
+ def get_jobs_in_timeframe(start_hours: int, end_hours: int) -> List[Dict]:
303
+ """Get jobs scraped between start_hours and end_hours ago."""
304
+ conn = _connect()
305
+ try:
306
+ with conn.cursor() as cur:
307
+ now = datetime.now(timezone.utc)
308
+ start_time = now - timedelta(hours=start_hours)
309
+ end_time = now - timedelta(hours=end_hours)
310
+ cur.execute(
311
+ """
312
+ SELECT title, company, location, link, posted, posted_datetime,
313
+ programs, schools, scraped_at, keywords, company_rating, contact_details
314
+ FROM scraped_jobs
315
+ WHERE posted_datetime >= %s AND posted_datetime <= %s
316
+ ORDER BY posted_datetime DESC
317
+ """,
318
+ (start_time, end_time),
319
+ )
320
+ rows = cur.fetchall()
321
+ finally:
322
+ conn.close()
323
+ return [_row_to_job(row) for row in rows]
324
+
325
+
326
+ def cleanup_old_jobs(days_old: int = 7):
327
+ """Remove jobs older than specified days."""
328
+ conn = _connect()
329
+ try:
330
+ with conn.cursor() as cur:
331
+ cutoff_time = datetime.now(timezone.utc) - timedelta(days=days_old)
332
+ cur.execute(
333
+ "DELETE FROM scraped_jobs WHERE posted_datetime < %s",
334
+ (cutoff_time,),
335
+ )
336
+ deleted_count = cur.rowcount
337
+ conn.commit()
338
+ except Exception:
339
+ conn.rollback()
340
+ raise
341
+ finally:
342
+ conn.close()
343
+ return deleted_count
344
+
345
+
346
+ def get_binned_jobs() -> Dict[str, Dict]:
347
+ """Fetch jobs from the last 30 days in one query and bin them by timeframe."""
348
+ conn = _connect()
349
+ try:
350
+ with conn.cursor() as cur:
351
+ now = datetime.now(timezone.utc)
352
+ start_time = now - timedelta(hours=720)
353
+ cur.execute(
354
+ """
355
+ SELECT title, company, location, link, posted, posted_datetime,
356
+ programs, schools, scraped_at, keywords, company_rating, contact_details
357
+ FROM scraped_jobs
358
+ WHERE posted_datetime >= %s
359
+ ORDER BY posted_datetime DESC
360
+ """,
361
+ (start_time,),
362
+ )
363
+ rows = cur.fetchall()
364
+
365
+ t_1, t_2, t_5, t_24, t_168, t_720 = [], [], [], [], [], []
366
+ for row in rows:
367
+ dt = row[5]
368
+ job = _row_to_job(row)
369
+ if not dt:
370
+ continue
371
+
372
+ dt_aware = dt.replace(tzinfo=timezone.utc)
373
+ hours = (now - dt_aware).total_seconds() / 3600
374
+ if hours <= 1:
375
+ t_1.append(job)
376
+ elif hours <= 2:
377
+ t_2.append(job)
378
+ elif hours <= 5:
379
+ t_5.append(job)
380
+ elif hours <= 24:
381
+ t_24.append(job)
382
+ elif hours <= 168:
383
+ t_168.append(job)
384
+ elif hours <= 720:
385
+ t_720.append(job)
386
+
387
+ finally:
388
+ conn.close()
389
+
390
+ return {
391
+ "1_hour": {"jobs": t_1, "count": len(t_1)},
392
+ "2_hours": {"jobs": t_2, "count": len(t_2)},
393
+ "5_hours": {"jobs": t_5, "count": len(t_5)},
394
+ "24_hours": {"jobs": t_24, "count": len(t_24)},
395
+ "1_week": {"jobs": t_168, "count": len(t_168)},
396
+ "1_month": {"jobs": t_720, "count": len(t_720)},
397
+ }
398
+
399
+
400
+ init_database()
401
+
402
+
403
+ def update_company_ratings(ratings: Dict[str, float]) -> int:
404
+ """Bulk-update company_rating on all scraped_jobs rows by company name.
405
+
406
+ Uses a single multi-row UPDATE so the number of DB round-trips equals one
407
+ per unique company (not one per job row). Matching is case-insensitive.
408
+ Returns the total number of rows updated.
409
+ """
410
+ if not ratings:
411
+ return 0
412
+ conn = _connect()
413
+ updated = 0
414
+ try:
415
+ with conn.cursor() as cur:
416
+ for company, rating in ratings.items():
417
+ cur.execute(
418
+ """
419
+ UPDATE scraped_jobs
420
+ SET company_rating = %s
421
+ WHERE LOWER(company) = LOWER(%s)
422
+ """,
423
+ (float(rating), company),
424
+ )
425
+ updated += cur.rowcount
426
+ conn.commit()
427
+ except Exception:
428
+ conn.rollback()
429
+ raise
430
+ finally:
431
+ conn.close()
432
+ return updated