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
2 changes: 1 addition & 1 deletion scripts/capture_gui_shots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 11 additions & 6 deletions src/nerajob/cli.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,31 @@
from __future__ import annotations

from pathlib import Path

import typer
from rich.console import Console
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,
)

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/nerajob/cv/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions src/nerajob/gui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion src/nerajob/gui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/nerajob/match.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
4 changes: 2 additions & 2 deletions src/nerajob/models.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
4 changes: 2 additions & 2 deletions src/nerajob/scrapers/ashby.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 1 addition & 3 deletions src/nerajob/scrapers/greenhouse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
153 changes: 153 additions & 0 deletions src/nerajob/scrapers/himalayas.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions src/nerajob/scrapers/lever.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions src/nerajob/scrapers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -53,6 +54,7 @@ def available_scrapers() -> dict[str, BaseScraper]:
SmartRecruitersScraper(),
FindworkScraper(),
AdzunaScraper(),
HimalayasScraper(),
]
return {s.name: s for s in scrapers}

Expand Down
12 changes: 10 additions & 2 deletions src/nerajob/storage.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
4 changes: 2 additions & 2 deletions tests/test_application_tracker.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime, timezone
from datetime import UTC, datetime

import pytest
from pydantic import ValidationError
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_fixture_packs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading