Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/nerajob/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,61 @@ def cv_cmd(
fmt: str = typer.Option("md", "--format", "-f", help="Output format: md or pdf"),
) -> None:
"""Build Markdown + text CV from your profile."""

# Root-level match command for offline matching
@app.command("match")
def match_root(
top: int = typer.Option(10, "--top", "-k", min=1, max=50),
job_id: str | None = typer.Option(None, "--job-id", "-j"),
resume_file: Path | None = typer.Option(
None,
"--resume-file",
"-r",
exists=True,
readable=True,
help="Offline: profile JSON file (instead of stored profile)",
),
jobs_file: Path | None = typer.Option(
None,
"--jobs-file",
"-f",
exists=True,
readable=True,
help="Offline: jobs JSON file (instead of stored jobs)",
),
skill_weight: float = typer.Option(
DEFAULT_MATCH_WEIGHTS.skills,
"--skill-weight",
min=0.0,
help="Maximum score contribution from profile skill matches",
),
title_weight: float = typer.Option(
DEFAULT_MATCH_WEIGHTS.title,
"--title-weight",
min=0.0,
help="Maximum score contribution from headline/title overlap",
),
location_weight: float = typer.Option(
DEFAULT_MATCH_WEIGHTS.location,
"--location-weight",
min=0.0,
help="Maximum score contribution from location or remote fit",
),
) -> None:
"""Root-level command delegating to jobs_match for backward compatibility.
Mirrors the `nerajob jobs match` subcommand, allowing users to run
`nerajob match` directly.
"""
# Re-use the existing implementation to keep behavior identical.
return jobs_match(
top=top,
job_id=job_id,
resume_file=resume_file,
jobs_file=jobs_file,
skill_weight=skill_weight,
title_weight=title_weight,
location_weight=location_weight,
)
profile = load_profile()
if not profile:
console.print("[red]No profile. Run: nerajob profile init[/red]")
Expand Down
5 changes: 5 additions & 0 deletions src/nerajob/scrapers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from nerajob.scrapers.sample import SampleScraper
from nerajob.scrapers.smartrecruiters import SmartRecruitersScraper
from nerajob.scrapers.themuse import TheMuseScraper
from nerajob.scrapers.usajobs import USAJobsScraper
from nerajob.scrapers.weworkremotely import WeWorkRemotelyScraper


Expand All @@ -38,6 +39,9 @@ def available_scrapers() -> dict[str, BaseScraper]:
Adzuna: live public API; set ADZUNA_APP_ID + ADZUNA_APP_KEY env vars.
Without credentials, returns deterministic offline fixtures.
Set NERAJOB_ADZUNA_OFFLINE=1 to force offline even with credentials.
USAJOBS: live search API; set USAJOBS_API_KEY + USAJOBS_EMAIL env vars.
Without credentials, returns deterministic offline fixtures.
Set NERAJOB_USAJOBS_OFFLINE=1 to force offline even with credentials.
"""
scrapers: list[BaseScraper] = [
SampleScraper(),
Expand All @@ -53,6 +57,7 @@ def available_scrapers() -> dict[str, BaseScraper]:
SmartRecruitersScraper(),
FindworkScraper(),
AdzunaScraper(),
USAJobsScraper(),
]
return {s.name: s for s in scrapers}

Expand Down
264 changes: 264 additions & 0 deletions src/nerajob/scrapers/usajobs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
"""USAJOBS Search API adapter with offline fallback."""

from __future__ import annotations

import hashlib
import os
from typing import Any

import httpx

from nerajob.config import http_timeout, user_agent
from nerajob.models import JobPosting
from nerajob.scrapers.base import BaseScraper

_OFFLINE: list[dict[str, Any]] = [
{
"PositionID": "usajobs-demo-1",
"PositionTitle": "IT Specialist (INFOSEC/NETWORK)",
"OrganizationName": "Department of the Army",
"PositionLocation": [
{
"LocationName": "Washington, District of Columbia",
}
],
"PositionURI": "https://www.usajobs.gov/GetJob/ViewDetails/usajobs-demo-1",
"UserArea": {
"Details": {
"JobSummary": "Serves as an IT Specialist (INFOSEC/NETWORK) for cybersecurity operations.",
"RemoteIndicator": False,
}
},
"PositionRemuneration": [
{
"MinimumRange": "95000",
"MaximumRange": "120000",
"RateIntervalCode": "Per Year",
}
]
},
{
"PositionID": "usajobs-demo-2",
"PositionTitle": "Computer Scientist",
"OrganizationName": "National Science Foundation",
"PositionLocation": [
{
"LocationName": "Remote, US",
}
],
"PositionURI": "https://www.usajobs.gov/GetJob/ViewDetails/usajobs-demo-2",
"UserArea": {
"Details": {
"JobSummary": "Responsible for conducting computer science research and program direction.",
"RemoteIndicator": True,
}
},
"PositionRemuneration": [
{
"MinimumRange": "115000",
"MaximumRange": "150000",
"RateIntervalCode": "Per Year",
}
]
},
{
"PositionID": "usajobs-demo-3",
"PositionTitle": "Data Analyst",
"OrganizationName": "Department of Transportation",
"PositionLocation": [
{
"LocationName": "Chicago, Illinois",
}
],
"PositionURI": "https://www.usajobs.gov/GetJob/ViewDetails/usajobs-demo-3",
"UserArea": {
"Details": {
"JobSummary": "Performs data analysis and statistics for transit systems.",
"RemoteIndicator": False,
}
},
"PositionRemuneration": [
{
"MinimumRange": "80000",
"MaximumRange": "98000",
"RateIntervalCode": "Per Year",
}
]
}
]


class USAJobsScraper(BaseScraper):
"""Scraper for USAJOBS.gov Search API with offline fallback."""

name = "usajobs"
BASE_URL = "https://data.usajobs.gov/api/search"

def search(
self,
query: str = "",
location: str = "",
limit: int = 20,
) -> list[JobPosting]:
"""Search USAJOBS for jobs matching *query* and *location*.

Parameters
----------
query : str
Free-text search (job title, skill, keyword).
location : str
Where string (e.g. ``"Washington, DC"``).
limit : int
Max results to return.

Returns
-------
list[JobPosting]
Matched job postings, or offline fixtures on fallback/failure.
"""
if os.getenv("NERAJOB_USAJOBS_OFFLINE", "").strip().lower() in {"1", "true", "yes"}:
return self._offline(query, location, limit)

api_key = os.getenv("USAJOBS_API_KEY", "").strip()
email = os.getenv("USAJOBS_EMAIL", "").strip()

if not api_key or not email:
return self._offline(query, location, limit)

params: dict[str, str | int] = {
"ResultsPerPage": max(1, min(limit, 500)),
}
if query.strip():
params["Keyword"] = query.strip()
if location.strip():
params["LocationName"] = location.strip()

headers = {
"Host": "data.usajobs.gov",
"User-Agent": email,
"Authorization-Key": api_key,
"Accept": "application/json",
}

try:
with httpx.Client(
timeout=http_timeout(),
headers=headers,
follow_redirects=True,
) as client:
response = client.get(self.BASE_URL, params=params)
response.raise_for_status()
payload = response.json()
except Exception:
return self._offline(query, location, limit)

search_result = payload.get("SearchResult", {}) or {}
items = search_result.get("SearchResultItems", []) or []

jobs: list[JobPosting] = []
for item in items:
if not isinstance(item, dict):
continue
desc = item.get("MatchedObjectDescriptor", {}) or {}
if not desc:
continue
posting = self._normalize(desc)
if posting is None:
continue

q = query.strip().lower()
loc = location.strip().lower()
hay = (
f"{posting.title} {posting.company} {posting.location} "
f"{' '.join(posting.tags)} {posting.description}"
).lower()

if q and q not in hay:
continue
if loc and loc not in posting.location.lower() and "remote" not in posting.location.lower():
continue

jobs.append(posting)
if len(jobs) >= limit:
break

return jobs if jobs else self._offline(query, location, limit)

def _normalize(self, desc: dict) -> JobPosting | None:
title = desc.get("PositionTitle", "") or ""
company = desc.get("OrganizationName", "") or ""
url = desc.get("PositionURI", "") or ""

# Extract locations
loc_objs = desc.get("PositionLocation", []) or []
loc_names = [l.get("LocationName", "") for l in loc_objs if l.get("LocationName")]
location = ", ".join(loc_names) if loc_names else "Remote"

# Extract description
user_area = desc.get("UserArea", {}) or {}
details = user_area.get("Details", {}) or {}
description = details.get("JobSummary", "") or ""

# Remote check
is_remote = False
if details.get("RemoteIndicator") is True:
is_remote = True
elif "remote" in location.lower():
is_remote = True

# Extract salary
remuns = desc.get("PositionRemuneration", []) or []
salary_str = ""
if remuns:
remun = remuns[0]
min_sal = remun.get("MinimumRange", "")
max_sal = remun.get("MaximumRange", "")
interval = remun.get("RateIntervalCode", "")
if min_sal and max_sal:
salary_str = f"USD {min_sal}-{max_sal} {interval}"
elif min_sal:
salary_str = f"USD {min_sal} {interval}"

# Generate unique ID
raw_id = desc.get("PositionID") or title
digest = hashlib.sha1(f"{self.name}:{raw_id}".encode()).hexdigest()[:12]

return JobPosting(
id=f"usajobs-{digest}",
source=self.name,
title=title,
company=company or "Unknown Agency",
location=location,
url=url,
description=description,
tags=["Government", "Federal"],
salary=salary_str,
remote=is_remote,
raw={"usajobs_id": raw_id},
)

def _offline(self, query: str = "", location: str = "", limit: int = 20) -> list[JobPosting]:
results: list[JobPosting] = []
q = query.strip().lower()
loc = location.strip().lower()

for item in _OFFLINE:
posting = self._normalize(item)
if posting is None:
continue

hay = (
f"{posting.title} {posting.company} {posting.location} "
f"{' '.join(posting.tags)} {posting.description}"
).lower()

if q and q not in hay:
continue
if loc and loc not in posting.location.lower() and "remote" not in posting.location.lower():
continue

results.append(posting)
if len(results) >= limit:
break

return results
Loading