diff --git a/scripts/capture_gui_shots.py b/scripts/capture_gui_shots.py index face883..fdd6666 100644 --- a/scripts/capture_gui_shots.py +++ b/scripts/capture_gui_shots.py @@ -17,7 +17,7 @@ def main() -> None: - from PySide6.QtCore import QTimer, Qt + from PySide6.QtCore import QTimer from PySide6.QtGui import QFont, QGuiApplication from PySide6.QtWidgets import QApplication diff --git a/src/nerajob/cli.py b/src/nerajob/cli.py index 6692212..227643c 100644 --- a/src/nerajob/cli.py +++ b/src/nerajob/cli.py @@ -1,4 +1,5 @@ from __future__ import annotations + from pathlib import Path import typer @@ -6,21 +7,25 @@ from rich.table import Table from nerajob import __version__ -from nerajob.match import SKILL_ALIASES, extract_skills_from_text from nerajob.apply.assistant import prepare_application from nerajob.cv.builder import write_cv_files -from nerajob.match import DEFAULT_MATCH_WEIGHTS, MatchWeights -from nerajob.models import JobPosting +from nerajob.match import ( + DEFAULT_MATCH_WEIGHTS, + SKILL_ALIASES, + MatchWeights, + extract_skills_from_text, +) +from nerajob.models import ApplicationPackage, JobPosting from nerajob.scrapers.registry import available_scrapers, get_scraper -from nerajob.models import ApplicationPackage from nerajob.storage import ( default_profile, get_job, load_applications, load_jobs, load_profile, - load_scan_preset, save_scan_preset, + load_scan_preset, save_profile, + save_scan_preset, upsert_jobs, ) @@ -411,7 +416,7 @@ def jobs_match( ) -> None: """Rank saved jobs against your profile (keyword skill match).""" from nerajob.match import match_score, rank_jobs - from nerajob.models import Profile, JobPosting + from nerajob.models import JobPosting, Profile from nerajob.storage import load_jobs, load_profile profile = None diff --git a/src/nerajob/cv/builder.py b/src/nerajob/cv/builder.py index 4679ccc..adbbc51 100644 --- a/src/nerajob/cv/builder.py +++ b/src/nerajob/cv/builder.py @@ -132,7 +132,7 @@ def write_cv_pdf(profile: Profile, target_role: str = "") -> Path | None: if line.startswith("# "): pdf.set_font("Helvetica", "B", 16) pdf.cell(0, 10, line[2:], new_x="LMARGIN", new_y="NEXT") - elif line.startswith("## ") or line.startswith("### "): + elif line.startswith(("## ", "### ")): pdf.set_font("Helvetica", "B", 14) label = line[line.index(" ") + 1 :] pdf.cell(0, 10, label, new_x="LMARGIN", new_y="NEXT") diff --git a/src/nerajob/gui/app.py b/src/nerajob/gui/app.py index 1bb8da8..e8ac873 100644 --- a/src/nerajob/gui/app.py +++ b/src/nerajob/gui/app.py @@ -11,10 +11,10 @@ def main(argv: list[str] | None = None) -> int: except ImportError: print("Install GUI extras: pip install -e \".[gui]\" (needs PySide6)", file=sys.stderr) return 1 - from nerajob.gui.main_window import MainWindow - from PySide6.QtGui import QFont + from nerajob.gui.main_window import MainWindow + app = QApplication(sys.argv if argv is None else argv) app.setApplicationName("NeraJob") app.setOrganizationName("MergeOS") diff --git a/src/nerajob/gui/main_window.py b/src/nerajob/gui/main_window.py index 343d710..adb567a 100644 --- a/src/nerajob/gui/main_window.py +++ b/src/nerajob/gui/main_window.py @@ -2,7 +2,7 @@ from __future__ import annotations -from PySide6.QtCore import Qt, QSize +from PySide6.QtCore import QSize, Qt from PySide6.QtGui import QFont from PySide6.QtWidgets import ( QAbstractItemView, diff --git a/src/nerajob/match.py b/src/nerajob/match.py index 7cdd1ba..c90c8e8 100644 --- a/src/nerajob/match.py +++ b/src/nerajob/match.py @@ -34,7 +34,7 @@ def as_dict(self) -> dict[str, float]: SKILL_ALIASES: dict[str, set[str]] = { "python": {"python", "django", "fastapi", "flask"}, "javascript": {"javascript", "js", "typescript", "node", "react"}, - "devops": {"devops", "docker", "kubernetes", "k8s", "ci/cd", "terraform", "helm", "ansible", "puppet", "chef", "prometheus", "grafana", "sre", "docker swarm", "nomad", "consul", "vault", "istio", "service mesh", "infrastructure as code", "iac", "site reliability", "argocd", "jenkins", "github actions", "gitlab ci", "vault", "consul", "nomad"}, + "devops": {"devops", "docker", "kubernetes", "k8s", "ci/cd", "terraform", "helm", "ansible", "puppet", "chef", "prometheus", "grafana", "sre", "docker swarm", "nomad", "consul", "vault", "istio", "service mesh", "infrastructure as code", "iac", "site reliability", "argocd", "jenkins", "github actions", "gitlab ci"}, "security_ops": {"secops", "soc analyst", "incident response", "threat hunting", "siem", "edr", "blue team", "detection"}, "platform_eng": {"platform engineer", "platform engineering", "internal developer platform", "idp", "developer experience", "devex", "paved path", "golden path"}, "sales_eng": {"solutions engineer", "sales engineer", "pre-sales", "demo", "poc", "technical account", "se ", "rfp"}, diff --git a/src/nerajob/models.py b/src/nerajob/models.py index 53961eb..21cb005 100644 --- a/src/nerajob/models.py +++ b/src/nerajob/models.py @@ -1,13 +1,13 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any, ClassVar from pydantic import BaseModel, Field, field_validator def utc_now_iso() -> str: - return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + return datetime.now(UTC).replace(microsecond=0).isoformat() class Experience(BaseModel): diff --git a/src/nerajob/scrapers/ashby.py b/src/nerajob/scrapers/ashby.py index ea9cc36..e04befe 100644 --- a/src/nerajob/scrapers/ashby.py +++ b/src/nerajob/scrapers/ashby.py @@ -4,12 +4,12 @@ import hashlib import json -from urllib.request import Request, urlopen from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen +from nerajob.config import http_timeout, user_agent from nerajob.models import JobPosting from nerajob.scrapers.base import BaseScraper -from nerajob.config import http_timeout, user_agent class AshbyScraper(BaseScraper): diff --git a/src/nerajob/scrapers/greenhouse.py b/src/nerajob/scrapers/greenhouse.py index 92923f9..a4741d0 100644 --- a/src/nerajob/scrapers/greenhouse.py +++ b/src/nerajob/scrapers/greenhouse.py @@ -127,9 +127,7 @@ def _matches_query(self, job: JobResult, query: str, location: str) -> bool: q = query.strip().strip("*") if q and q.lower() not in job.title.lower() and q.lower() not in job.description.lower(): return False - if location and location.lower() not in job.location.lower(): - return False - return True + return not (location and location.lower() not in job.location.lower()) # ------------------------------------------------------------------ # Offline fallback diff --git a/src/nerajob/scrapers/himalayas.py b/src/nerajob/scrapers/himalayas.py new file mode 100644 index 0000000..c311ef0 --- /dev/null +++ b/src/nerajob/scrapers/himalayas.py @@ -0,0 +1,153 @@ +"""Himalayas remote jobs public API adapter with offline fallback.""" + +from __future__ import annotations + +import hashlib +import os +import urllib.parse + +import httpx + +from nerajob.config import http_timeout, user_agent +from nerajob.models import JobPosting +from nerajob.scrapers.base import BaseScraper + +_OFFLINE = [ + ( + "Social Media and Creative Manager", + "Tala", + "Mexico", + ["brand", "marketing", "social-media"], + "https://himalayas.app/companies/tala/jobs/social-media-and-creative-manager", + ), + ( + "Systems Migration & Support Specialist", + "Asset Living", + "United States", + ["it", "support", "systems"], + "https://himalayas.app/companies/asset-living/jobs/systems-migration-support-specialist", + ), + ( + "Mobile Automation Engineer", + "CXT Software", + "Argentina", + ["qa", "automation", "playwright"], + "https://himalayas.app/companies/cxt-software/jobs/mobile-automation-engineer", + ), +] + + +class HimalayasScraper(BaseScraper): + """ + Himalayas remote jobs API. + + Docs: https://himalayas.app/jobs/api + Endpoints: + - Unfiltered: https://himalayas.app/jobs/api?offset=0&limit=20 + - Search: https://himalayas.app/jobs/api/search?q=query + """ + + name = "himalayas" + API_URL = "https://himalayas.app/jobs/api" + SEARCH_URL = "https://himalayas.app/jobs/api/search" + + def search(self, query: str, location: str = "", limit: int = 20) -> list[JobPosting]: + if os.getenv("NERAJOB_HIMALAYAS_OFFLINE", "").strip().lower() in {"1", "true", "yes"}: + return self._offline(query, limit) + + headers = { + "User-Agent": user_agent(), + "Accept": "application/json", + } + + q = query.strip() + loc = location.strip().lower() + + # Build URL + if q: + url = f"{self.SEARCH_URL}?q={urllib.parse.quote(q)}" + else: + url = f"{self.API_URL}?limit={limit}" + + try: + with httpx.Client(timeout=http_timeout(), headers=headers, follow_redirects=True) as client: + response = client.get(url) + response.raise_for_status() + payload = response.json() + except Exception: + return self._offline(query, limit) + + jobs_raw = payload.get("jobs") if isinstance(payload, dict) else None + if not isinstance(jobs_raw, list): + return self._offline(query, limit) + + jobs: list[JobPosting] = [] + for item in jobs_raw: + if not isinstance(item, dict): + continue + title = str(item.get("title") or "").strip() + company = str(item.get("companyName") or "").strip() + if not title: + continue + + tags = [str(t).lower() for t in (item.get("categories") or []) if t] + + loc_restrictions = item.get("locationRestrictions") or [] + place = ", ".join(loc_restrictions) if isinstance(loc_restrictions, list) else str(loc_restrictions) + if not place: + place = "Remote" + + hay = f"{title} {company} {place} {' '.join(tags)} {item.get('description', '')} {item.get('excerpt', '')}".lower() + if q.lower() and q.lower() not in hay: + continue + if loc and loc not in place.lower() and "remote" not in place.lower(): + continue + + url = str(item.get("applicationLink") or item.get("guid") or "") + raw_id = str(item.get("guid") or title) + digest = hashlib.sha1(f"{self.name}:{raw_id}".encode()).hexdigest()[:12] + + jobs.append( + JobPosting( + id=f"himalayas-{digest}", + source=self.name, + title=title, + company=company or "Unknown", + location=place, + url=url, + description=str(item.get("description") or item.get("excerpt") or "")[:4000], + tags=tags[:20], + remote=True, + raw={"guid": raw_id}, + ) + ) + if len(jobs) >= limit: + break + + return jobs if jobs else self._offline(query, limit) + + def _offline(self, query: str, limit: int) -> list[JobPosting]: + q = query.strip().lower() + out: list[JobPosting] = [] + for title, company, place, tags, url in _OFFLINE: + hay = f"{title} {company} {' '.join(tags)}".lower() + if q and q not in hay: + continue + digest = hashlib.sha1(f"{self.name}:{title}:{company}".encode()).hexdigest()[:12] + out.append( + JobPosting( + id=f"himalayas-{digest}", + source=self.name, + title=title, + company=company, + location=place, + url=url, + description=f"{title} at {company} (offline Himalayas sample).", + tags=tags, + remote=True, + raw={"offline": True}, + ) + ) + if len(out) >= limit: + break + return out diff --git a/src/nerajob/scrapers/lever.py b/src/nerajob/scrapers/lever.py index 2753206..a006aab 100644 --- a/src/nerajob/scrapers/lever.py +++ b/src/nerajob/scrapers/lever.py @@ -4,12 +4,12 @@ import hashlib import json -from urllib.request import Request, urlopen from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen +from nerajob.config import http_timeout, user_agent from nerajob.models import JobPosting from nerajob.scrapers.base import BaseScraper -from nerajob.config import http_timeout, user_agent class LeverScraper(BaseScraper): diff --git a/src/nerajob/scrapers/registry.py b/src/nerajob/scrapers/registry.py index c0c2bf2..5558707 100644 --- a/src/nerajob/scrapers/registry.py +++ b/src/nerajob/scrapers/registry.py @@ -7,6 +7,7 @@ from nerajob.scrapers.ashby import AshbyScraper from nerajob.scrapers.base import BaseScraper from nerajob.scrapers.findwork import FindworkScraper +from nerajob.scrapers.himalayas import HimalayasScraper from nerajob.scrapers.jobicy import JobicyScraper from nerajob.scrapers.jooble import JoobleScraper from nerajob.scrapers.lever import LeverScraper @@ -53,6 +54,7 @@ def available_scrapers() -> dict[str, BaseScraper]: SmartRecruitersScraper(), FindworkScraper(), AdzunaScraper(), + HimalayasScraper(), ] return {s.name: s for s in scrapers} diff --git a/src/nerajob/storage.py b/src/nerajob/storage.py index 313dc20..64f64f8 100644 --- a/src/nerajob/storage.py +++ b/src/nerajob/storage.py @@ -1,11 +1,19 @@ from __future__ import annotations import json +from collections.abc import Iterable from pathlib import Path -from typing import Iterable from nerajob.config import APPLICATIONS_DIR, JOBS_PATH, PROFILE_PATH, SCAN_PRESET_PATH -from nerajob.models import ApplicationPackage, Education, Experience, JobPosting, Profile, ScanPreset, utc_now_iso +from nerajob.models import ( + ApplicationPackage, + Education, + Experience, + JobPosting, + Profile, + ScanPreset, + utc_now_iso, +) def _read_json(path: Path, default): diff --git a/tests/test_application_tracker.py b/tests/test_application_tracker.py index 059aa68..1abc97a 100644 --- a/tests/test_application_tracker.py +++ b/tests/test_application_tracker.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest from pydantic import ValidationError @@ -7,7 +7,7 @@ def utc_now_iso() -> str: - return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + return datetime.now(UTC).replace(microsecond=0).isoformat() class TestModelValidation: diff --git a/tests/test_fixture_packs.py b/tests/test_fixture_packs.py index 5d2c5ce..b874d50 100644 --- a/tests/test_fixture_packs.py +++ b/tests/test_fixture_packs.py @@ -20,7 +20,7 @@ def test_frontend_fixture_pack(): assert "Vue.js Developer" in titles for j in jobs: assert j.remote - assert j.source == "sample" or True # no source validation + assert True # no source validation def test_devops_fixture_pack(): diff --git a/tests/test_himalayas.py b/tests/test_himalayas.py new file mode 100644 index 0000000..7d93ad6 --- /dev/null +++ b/tests/test_himalayas.py @@ -0,0 +1,91 @@ +from nerajob.scrapers.registry import available_scrapers, get_scraper + + +def test_himalayas_registered() -> None: + assert "himalayas" in available_scrapers() + + +def test_himalayas_offline(monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_HIMALAYAS_OFFLINE", "1") + jobs = get_scraper("himalayas").search("automation", limit=5) + assert len(jobs) >= 1 + assert all(j.source == "himalayas" for j in jobs) + + +def test_himalayas_online_mocked(monkeypatch) -> None: + """Test the online path with a mocked HTTP response.""" + mock_payload = { + "updatedAt": 1786188991, + "offset": 0, + "limit": 2, + "totalCount": 98863, + "jobs": [ + { + "title": "Mobile Automation Engineer", + "companyName": "CXT Software", + "companySlug": "cxt-software", + "categories": ["QA-Engineer", "Automation-Testing"], + "locationRestrictions": ["Argentina"], + "description": "We are looking for a Mobile Automation Engineer...", + "pubDate": 1786189418, + "applicationLink": "https://himalayas.app/companies/cxt-software/jobs/mobile-automation-engineer", + "guid": "https://himalayas.app/companies/cxt-software/jobs/mobile-automation-engineer" + }, + { + "title": "Social Media Manager", + "companyName": "Tala", + "companySlug": "tala", + "categories": ["Marketing"], + "locationRestrictions": ["Mexico"], + "description": "About Tala...", + "pubDate": 1786188991, + "applicationLink": "https://himalayas.app/companies/tala/jobs/social-media-and-creative-manager", + "guid": "https://himalayas.app/companies/tala/jobs/social-media-and-creative-manager" + } + ] + } + + class MockResponse: + def __init__(self, json_data, status_code=200): + self._json = json_data + self.status_code = status_code + + def json(self): + return self._json + + def raise_for_status(self): + if self.status_code >= 400: + raise Exception(f"HTTP {self.status_code}") + + class MockClient: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def get(self, url, *args, **kwargs): + return MockResponse(mock_payload) + + # Patch httpx.Client to return our mock + monkeypatch.setattr("httpx.Client", MockClient) + # Ensure offline mode is not set + monkeypatch.delenv("NERAJOB_HIMALAYAS_OFFLINE", raising=False) + + # With query="automation", only the Automation job should match + jobs_automation = get_scraper("himalayas").search("automation", limit=5) + assert len(jobs_automation) == 1 + assert jobs_automation[0].title == "Mobile Automation Engineer" + assert jobs_automation[0].company == "CXT Software" + assert jobs_automation[0].location == "Argentina" + assert "qa-engineer" in jobs_automation[0].tags + + # Without query, both jobs should be returned + jobs_all = get_scraper("himalayas").search("", limit=5) + assert len(jobs_all) == 2 + assert all(j.source == "himalayas" for j in jobs_all) + assert jobs_all[1].title == "Social Media Manager" + assert jobs_all[1].company == "Tala" diff --git a/tests/test_jobicy.py b/tests/test_jobicy.py index df27383..4d93465 100644 --- a/tests/test_jobicy.py +++ b/tests/test_jobicy.py @@ -1,3 +1,5 @@ +from typing import Self + from nerajob.scrapers import jobicy from nerajob.scrapers.registry import available_scrapers, get_scraper @@ -45,7 +47,7 @@ class FakeClient: def __init__(self, *args: object, **kwargs: object) -> None: return None - def __enter__(self) -> "FakeClient": + def __enter__(self) -> Self: return self def __exit__(self, *args: object) -> None: diff --git a/tests/test_jooble.py b/tests/test_jooble.py index 75aca6a..2629066 100644 --- a/tests/test_jooble.py +++ b/tests/test_jooble.py @@ -2,8 +2,8 @@ import httpx -from nerajob.scrapers.registry import available_scrapers, get_scraper from nerajob.scrapers.jooble import JoobleScraper +from nerajob.scrapers.registry import available_scrapers, get_scraper def test_jooble_registered() -> None: diff --git a/tests/test_lever_scraper.py b/tests/test_lever_scraper.py index 5705371..656e1fb 100644 --- a/tests/test_lever_scraper.py +++ b/tests/test_lever_scraper.py @@ -1,6 +1,7 @@ """Tests for the Lever scraper.""" from nerajob.scrapers.lever import LeverScraper + def test_lever_scraper_filters_python_roles(): jobs = LeverScraper().search(query="python", limit=10) assert jobs diff --git a/tests/test_match_precision.py b/tests/test_match_precision.py index b1fd0d8..a8f82d8 100644 --- a/tests/test_match_precision.py +++ b/tests/test_match_precision.py @@ -5,7 +5,6 @@ from nerajob.match import rank_jobs from nerajob.models import JobPosting, Profile - ROOT = Path(__file__).parent.parent