Sambhavvvvv commited on
Commit
f2ebb34
·
verified ·
1 Parent(s): 9f79f3c

Upload database.py

Browse files
Files changed (1) hide show
  1. database.py +397 -0
database.py ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_jobs_by_timeframe(hours_ago: int) -> List[Dict]:
246
+ """Get jobs scraped within the specified timeframe."""
247
+ conn = _connect()
248
+ try:
249
+ with conn.cursor() as cur:
250
+ time_threshold = datetime.now(timezone.utc) - timedelta(hours=hours_ago)
251
+ cur.execute(
252
+ """
253
+ SELECT title, company, location, link, posted, posted_datetime,
254
+ programs, schools, scraped_at, keywords, company_rating, contact_details
255
+ FROM scraped_jobs
256
+ WHERE posted_datetime >= %s
257
+ ORDER BY posted_datetime DESC
258
+ """,
259
+ (time_threshold,),
260
+ )
261
+ rows = cur.fetchall()
262
+ finally:
263
+ conn.close()
264
+ return [_row_to_job(row) for row in rows]
265
+
266
+
267
+ def get_jobs_in_timeframe(start_hours: int, end_hours: int) -> List[Dict]:
268
+ """Get jobs scraped between start_hours and end_hours ago."""
269
+ conn = _connect()
270
+ try:
271
+ with conn.cursor() as cur:
272
+ now = datetime.now(timezone.utc)
273
+ start_time = now - timedelta(hours=start_hours)
274
+ end_time = now - timedelta(hours=end_hours)
275
+ cur.execute(
276
+ """
277
+ SELECT title, company, location, link, posted, posted_datetime,
278
+ programs, schools, scraped_at, keywords, company_rating, contact_details
279
+ FROM scraped_jobs
280
+ WHERE posted_datetime >= %s AND posted_datetime <= %s
281
+ ORDER BY posted_datetime DESC
282
+ """,
283
+ (start_time, end_time),
284
+ )
285
+ rows = cur.fetchall()
286
+ finally:
287
+ conn.close()
288
+ return [_row_to_job(row) for row in rows]
289
+
290
+
291
+ def cleanup_old_jobs(days_old: int = 7):
292
+ """Remove jobs older than specified days."""
293
+ conn = _connect()
294
+ try:
295
+ with conn.cursor() as cur:
296
+ cutoff_time = datetime.now(timezone.utc) - timedelta(days=days_old)
297
+ cur.execute(
298
+ "DELETE FROM scraped_jobs WHERE posted_datetime < %s",
299
+ (cutoff_time,),
300
+ )
301
+ deleted_count = cur.rowcount
302
+ conn.commit()
303
+ except Exception:
304
+ conn.rollback()
305
+ raise
306
+ finally:
307
+ conn.close()
308
+ return deleted_count
309
+
310
+
311
+ def get_binned_jobs() -> Dict[str, Dict]:
312
+ """Fetch jobs from the last 30 days in one query and bin them by timeframe."""
313
+ conn = _connect()
314
+ try:
315
+ with conn.cursor() as cur:
316
+ now = datetime.now(timezone.utc)
317
+ start_time = now - timedelta(hours=720)
318
+ cur.execute(
319
+ """
320
+ SELECT title, company, location, link, posted, posted_datetime,
321
+ programs, schools, scraped_at, keywords, company_rating, contact_details
322
+ FROM scraped_jobs
323
+ WHERE posted_datetime >= %s
324
+ ORDER BY posted_datetime DESC
325
+ """,
326
+ (start_time,),
327
+ )
328
+ rows = cur.fetchall()
329
+
330
+ t_1, t_2, t_5, t_24, t_168, t_720 = [], [], [], [], [], []
331
+ for row in rows:
332
+ dt = row[5]
333
+ job = _row_to_job(row)
334
+ if not dt:
335
+ continue
336
+
337
+ dt_aware = dt.replace(tzinfo=timezone.utc)
338
+ hours = (now - dt_aware).total_seconds() / 3600
339
+ if hours <= 1:
340
+ t_1.append(job)
341
+ elif hours <= 2:
342
+ t_2.append(job)
343
+ elif hours <= 5:
344
+ t_5.append(job)
345
+ elif hours <= 24:
346
+ t_24.append(job)
347
+ elif hours <= 168:
348
+ t_168.append(job)
349
+ elif hours <= 720:
350
+ t_720.append(job)
351
+
352
+ finally:
353
+ conn.close()
354
+
355
+ return {
356
+ "1_hour": {"jobs": t_1, "count": len(t_1)},
357
+ "2_hours": {"jobs": t_2, "count": len(t_2)},
358
+ "5_hours": {"jobs": t_5, "count": len(t_5)},
359
+ "24_hours": {"jobs": t_24, "count": len(t_24)},
360
+ "1_week": {"jobs": t_168, "count": len(t_168)},
361
+ "1_month": {"jobs": t_720, "count": len(t_720)},
362
+ }
363
+
364
+
365
+ init_database()
366
+
367
+
368
+ def update_company_ratings(ratings: Dict[str, float]) -> int:
369
+ """Bulk-update company_rating on all scraped_jobs rows by company name.
370
+
371
+ Uses a single multi-row UPDATE so the number of DB round-trips equals one
372
+ per unique company (not one per job row). Matching is case-insensitive.
373
+ Returns the total number of rows updated.
374
+ """
375
+ if not ratings:
376
+ return 0
377
+ conn = _connect()
378
+ updated = 0
379
+ try:
380
+ with conn.cursor() as cur:
381
+ for company, rating in ratings.items():
382
+ cur.execute(
383
+ """
384
+ UPDATE scraped_jobs
385
+ SET company_rating = %s
386
+ WHERE LOWER(company) = LOWER(%s)
387
+ """,
388
+ (float(rating), company),
389
+ )
390
+ updated += cur.rowcount
391
+ conn.commit()
392
+ except Exception:
393
+ conn.rollback()
394
+ raise
395
+ finally:
396
+ conn.close()
397
+ return updated