File size: 15,753 Bytes
9f4dbc4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
import os
import re
from datetime import datetime, timedelta, timezone
from typing import List, Dict

import psycopg2
from psycopg2.extras import Json

# ── Configuration ────────────────────────────────────────────────────────────

# Pull DATABASE_URL from the environment, falling back to a local .env file
# so the same Postgres credentials work for both this scraper and the
# Next.js / Drizzle side without having to re-export the variable.
try:
    from dotenv import load_dotenv

    _here = os.path.dirname(os.path.abspath(__file__))
    for _candidate in (
        os.path.join(_here, ".env"),
        os.path.join(_here, "components", ".env"),
    ):
        if os.path.exists(_candidate):
            load_dotenv(_candidate, override=False)
except ImportError:
    pass

DATABASE_URL = os.environ.get("DATABASE_URL")
if not DATABASE_URL:
    raise RuntimeError(
        "DATABASE_URL is not set. Define it in your environment or in a "
        ".env file at the project root (or in components/.env)."
    )


def _connect():
    """Open a fresh Postgres connection.



    We open/close per call instead of holding a global. With Neon's pooler

    endpoint (the one in DATABASE_URL) each `connect` is effectively a

    cheap checkout from their server-side pool, so this stays simple and

    plays nicely with FastAPI's threadpool β€” no shared cursors between

    request handlers.

    """
    return psycopg2.connect(DATABASE_URL)


# ── Schema bootstrap ─────────────────────────────────────────────────────────


def init_database():
    """Initialize the database with required tables.



    Idempotent: every statement uses IF NOT EXISTS, so it's safe to run on

    every import and it won't fight with Drizzle's migrations if they've

    already created the table.

    """
    conn = _connect()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """

                CREATE TABLE IF NOT EXISTS scraped_jobs (

                    id              SERIAL PRIMARY KEY,

                    title           TEXT,

                    company         TEXT,

                    location        TEXT,

                    link            TEXT UNIQUE,

                    posted          TEXT,

                    posted_datetime TIMESTAMP,

                    programs        JSONB DEFAULT '[]'::jsonb,

                    schools         JSONB DEFAULT '[]'::jsonb,

                    scraped_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

                    keywords        TEXT,

                    freshness       TEXT,

                    work_types      JSONB DEFAULT '[]'::jsonb,

                    job_types       JSONB DEFAULT '[]'::jsonb,

                    company_rating  REAL,

                    contact_details JSONB DEFAULT '[]'::jsonb

                )

                """
            )
            # Migrate existing tables that don't yet have company_rating or contact_details
            cur.execute(
                """

                ALTER TABLE scraped_jobs

                    ADD COLUMN IF NOT EXISTS company_rating REAL

                """
            )
            cur.execute(
                """

                ALTER TABLE scraped_jobs

                    ADD COLUMN IF NOT EXISTS contact_details JSONB DEFAULT '[]'::jsonb

                """
            )
            cur.execute(
                "CREATE INDEX IF NOT EXISTS idx_scraped_at "
                "ON scraped_jobs(scraped_at)"
            )
            cur.execute(
                "CREATE INDEX IF NOT EXISTS idx_link ON scraped_jobs(link)"
            )
        conn.commit()
    finally:
        conn.close()


# ── Internal helpers ─────────────────────────────────────────────────────────


def _coerce_posted_datetime(posted: str, raw_posted_dt, now_dt: datetime) -> datetime:
    """Normalize the scraper's posted_datetime into a real `datetime`.



    The scraper sometimes only gives us a relative label like "3 hours

    ago" or a bare date string. SQLite stored these as TEXT, but Postgres

    has a real TIMESTAMP column, so we resolve everything to a `datetime`

    here before insertion. Falls back to `now_dt` when neither source is

    usable. Behavior matches the original SQLite version.

    """
    needs_recompute = (
        not raw_posted_dt
        or (isinstance(raw_posted_dt, str) and len(raw_posted_dt) <= 10)
    )

    if posted and needs_recompute:
        posted_lower = posted.lower()
        match = re.search(r"(\d+)", posted_lower)
        if match:
            num = int(match.group(1))
            if "minute" in posted_lower:
                return now_dt - timedelta(minutes=num)
            if "hour" in posted_lower:
                return now_dt - timedelta(hours=num)
            if "day" in posted_lower:
                return now_dt - timedelta(days=num)
            if "week" in posted_lower:
                return now_dt - timedelta(weeks=num)
            if "month" in posted_lower:
                return now_dt - timedelta(days=num * 30)
            return now_dt

    if isinstance(raw_posted_dt, datetime):
        return raw_posted_dt
    if isinstance(raw_posted_dt, str):
        try:
            return datetime.fromisoformat(raw_posted_dt)
        except ValueError:
            pass

    return now_dt


def _row_to_job(row) -> Dict:
    """Translate a SELECT row tuple into the dict shape main.py / the UI expect.



    Notes:

      * `programs` / `schools` come back as native Python lists because

        psycopg2 auto-parses JSONB β€” no `json.loads` needed.

      * `posted_datetime` / `scraped_at` come back as `datetime` objects;

        we ISO-format them so the result survives `json.dumps` in FastAPI

        without extra encoders.

    """
    return {
        "title": row[0],
        "company": row[1],
        "location": row[2],
        "link": row[3],
        "posted": row[4],
        "posted_datetime": row[5].replace(tzinfo=timezone.utc).isoformat() if row[5] else None,
        "programs": row[6] if row[6] is not None else [],
        "schools": row[7] if row[7] is not None else [],
        "scraped_at": row[8].replace(tzinfo=timezone.utc).isoformat() if row[8] else None,
        "keywords": row[9],
        "company_rating": row[10],  # float or None
        "contact_details": row[11] if len(row) > 11 and row[11] is not None else [],
    }


# ── Public API (signatures preserved from the SQLite version) ────────────────


def save_scraped_jobs(jobs: List[Dict], search_params: Dict):
    """Save scraped jobs to database."""
    conn = _connect()
    saved_count = 0
    try:
        with conn.cursor() as cur:
            now_dt = datetime.now(timezone.utc)

            for job in jobs:
                posted = job.get("posted", "")
                posted_dt = _coerce_posted_datetime(
                    posted, job.get("posted_datetime"), now_dt
                )

                cur.execute(
                    """

                    INSERT INTO scraped_jobs (

                        title, company, location, link, posted,

                        posted_datetime, programs, schools, keywords,

                        freshness, work_types, job_types, scraped_at, contact_details

                    )

                    VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)

                    ON CONFLICT (link) DO UPDATE SET

                        title           = EXCLUDED.title,

                        company         = EXCLUDED.company,

                        location        = EXCLUDED.location,

                        posted          = EXCLUDED.posted,

                        posted_datetime = EXCLUDED.posted_datetime,

                        programs        = EXCLUDED.programs,

                        schools         = EXCLUDED.schools,

                        keywords        = EXCLUDED.keywords,

                        freshness       = EXCLUDED.freshness,

                        work_types      = EXCLUDED.work_types,

                        job_types       = EXCLUDED.job_types,

                        scraped_at      = EXCLUDED.scraped_at,

                        contact_details = EXCLUDED.contact_details

                    """,
                    (
                        job.get("title"),
                        job.get("company"),
                        job.get("location"),
                        job.get("link"),
                        posted,
                        posted_dt,
                        Json(job.get("programs", []) or []),
                        Json(job.get("schools", []) or []),
                        search_params.get("keywords"),
                        search_params.get("freshness"),
                        Json(search_params.get("work_types", []) or []),
                        Json(search_params.get("job_types", []) or []),
                        now_dt,
                        Json(job.get("contact_details", []) or []),
                    ),
                )
                saved_count += 1
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()
    return saved_count


def get_jobs_by_timeframe(hours_ago: int) -> List[Dict]:
    """Get jobs scraped within the specified timeframe."""
    conn = _connect()
    try:
        with conn.cursor() as cur:
            time_threshold = datetime.now(timezone.utc) - timedelta(hours=hours_ago)
            cur.execute(
                """

                SELECT title, company, location, link, posted, posted_datetime,

                       programs, schools, scraped_at, keywords, company_rating, contact_details

                FROM scraped_jobs

                WHERE posted_datetime >= %s

                ORDER BY posted_datetime DESC

                """,
                (time_threshold,),
            )
            rows = cur.fetchall()
    finally:
        conn.close()
    return [_row_to_job(row) for row in rows]


def get_jobs_in_timeframe(start_hours: int, end_hours: int) -> List[Dict]:
    """Get jobs scraped between start_hours and end_hours ago."""
    conn = _connect()
    try:
        with conn.cursor() as cur:
            now = datetime.now(timezone.utc)
            start_time = now - timedelta(hours=start_hours)
            end_time = now - timedelta(hours=end_hours)
            cur.execute(
                """

                SELECT title, company, location, link, posted, posted_datetime,

                       programs, schools, scraped_at, keywords, company_rating, contact_details

                FROM scraped_jobs

                WHERE posted_datetime >= %s AND posted_datetime <= %s

                ORDER BY posted_datetime DESC

                """,
                (start_time, end_time),
            )
            rows = cur.fetchall()
    finally:
        conn.close()
    return [_row_to_job(row) for row in rows]


def cleanup_old_jobs(days_old: int = 7):
    """Remove jobs older than specified days."""
    conn = _connect()
    try:
        with conn.cursor() as cur:
            cutoff_time = datetime.now(timezone.utc) - timedelta(days=days_old)
            cur.execute(
                "DELETE FROM scraped_jobs WHERE posted_datetime < %s",
                (cutoff_time,),
            )
            deleted_count = cur.rowcount
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()
    return deleted_count


def get_binned_jobs() -> Dict[str, Dict]:
    """Fetch jobs from the last 30 days in one query and bin them by timeframe."""
    conn = _connect()
    try:
        with conn.cursor() as cur:
            now = datetime.now(timezone.utc)
            start_time = now - timedelta(hours=720)
            cur.execute(
                """

                SELECT title, company, location, link, posted, posted_datetime,

                       programs, schools, scraped_at, keywords, company_rating, contact_details

                FROM scraped_jobs

                WHERE posted_datetime >= %s

                ORDER BY posted_datetime DESC

                """,
                (start_time,),
            )
            rows = cur.fetchall()

            t_1, t_2, t_5, t_24, t_168, t_720 = [], [], [], [], [], []
            for row in rows:
                dt = row[5]
                job = _row_to_job(row)
                if not dt:
                    continue

                dt_aware = dt.replace(tzinfo=timezone.utc)
                hours = (now - dt_aware).total_seconds() / 3600
                if hours <= 1:
                    t_1.append(job)
                elif hours <= 2:
                    t_2.append(job)
                elif hours <= 5:
                    t_5.append(job)
                elif hours <= 24:
                    t_24.append(job)
                elif hours <= 168:
                    t_168.append(job)
                elif hours <= 720:
                    t_720.append(job)

    finally:
        conn.close()

    return {
        "1_hour": {"jobs": t_1, "count": len(t_1)},
        "2_hours": {"jobs": t_2, "count": len(t_2)},
        "5_hours": {"jobs": t_5, "count": len(t_5)},
        "24_hours": {"jobs": t_24, "count": len(t_24)},
        "1_week": {"jobs": t_168, "count": len(t_168)},
        "1_month": {"jobs": t_720, "count": len(t_720)},
    }


init_database()


def get_rated_companies() -> set:
    """Return a set of lowercased company names that already have a non-NULL rating.



    Used to skip re-fetching Serper ratings for companies we have already scored.

    """
    conn = _connect()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """

                SELECT DISTINCT LOWER(company)

                FROM scraped_jobs

                WHERE company_rating IS NOT NULL

                  AND company IS NOT NULL

                """
            )
            return {row[0] for row in cur.fetchall()}
    finally:
        conn.close()


def update_company_ratings(ratings: Dict[str, float]) -> int:
    """Bulk-update company_rating on all scraped_jobs rows by company name.



    Uses a single multi-row UPDATE so the number of DB round-trips equals one

    per unique company (not one per job row). Matching is case-insensitive.

    Returns the total number of rows updated.

    """
    if not ratings:
        return 0
    conn = _connect()
    updated = 0
    try:
        with conn.cursor() as cur:
            for company, rating in ratings.items():
                cur.execute(
                    """

                    UPDATE scraped_jobs

                       SET company_rating = %s

                     WHERE LOWER(company) = LOWER(%s)

                    """,
                    (float(rating), company),
                )
                updated += cur.rowcount
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()
    return updated