| import os
|
| import re
|
| from datetime import datetime, timedelta, timezone
|
| from typing import List, Dict
|
|
|
| import psycopg2
|
| from psycopg2.extras import Json
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| )
|
| """
|
| )
|
|
|
| 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()
|
|
|
|
|
|
|
|
|
|
|
| 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],
|
| "contact_details": row[11] if len(row) > 11 and row[11] is not None else [],
|
| }
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|