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
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,7 @@ asyncio_default_fixture_loop_scope = "function"
[tool.ruff]
line-length = 100
target-version = "py311"

[tool.ruff.lint]
select = ["E", "F", "W"]
ignore = ["BLE001", "PIE810", "E501", "W293"]
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
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
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
113 changes: 113 additions & 0 deletions src/nerajob/scrapers/euremote.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Scraper for euremote - public API with offline fallback."""

from __future__ import annotations

import hashlib
import os

import httpx

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

_OFFLINE_KEY = "NERAJOB_EUREMOTE_OFFLINE"
_OFFLINE_JOBS = [
('Java Developer', 'EuroTech', 'Remote EU', ['java', 'spring', 'aws'], 'https://euremote.com/jobs/java'),
('UX Designer', 'DesignHub', 'Berlin, Germany', ['figma', 'ux'], 'https://euremote.com/jobs/ux'),
]


class EuremoteScraper(BaseScraper):
"""Public euremote job board adapter."""

name = "euremote"
API_URL = "https://euremote.com/api/jobs"

def search(self, query: str, location: str = "", limit: int = 20) -> list[JobPosting]:
if os.getenv(_OFFLINE_KEY, "").strip() in ("1", "true", "yes"):
return self._offline_search(query, location, limit)

headers = {"User-Agent": user_agent(), "Accept": "application/json"}
try:
with httpx.Client(timeout=http_timeout(), headers=headers, follow_redirects=True) as client:
response = client.get(self.API_URL)
response.raise_for_status()
payload = response.json()
except Exception:
return self._offline_search(query, location, limit)

return self._parse(payload, query, location, limit)

def _parse(self, payload, query, location, limit):
if not isinstance(payload, (list, dict)):
return self._offline_search(query, location, limit)

items = payload if isinstance(payload, list) else payload.get("jobs", payload.get("data", []))
if not isinstance(items, list):
return self._offline_search(query, location, limit)

q = query.strip().lower()
loc = location.strip().lower()
jobs = []
for item in items:
if not isinstance(item, dict):
continue
title = str(item.get("title") or item.get("position") or "").strip()
company = str(item.get("company") or item.get("company_name") or "").strip()
if not title:
continue
tags = [str(t).lower() for t in (item.get("tags") or item.get("skills") or []) if t]
hay = title.lower() + " " + company.lower() + " " + " ".join(tags)
if q and q not in hay:
continue
if loc and loc not in str(item.get("location", "")).lower():
continue
job_loc = str(item.get("location") or "Remote")
url = str(item.get("url") or item.get("apply_url") or item.get("link") or "")
raw_id = str(item.get("id") or item.get("slug") or hash(title + company))
digest = hashlib.sha1((self.name + ":" + raw_id).encode()).hexdigest()[:12]

jobs.append(JobPosting(
id=f"euremote-{digest}",
source=self.name,
title=title,
company=company or "Unknown",
location=job_loc or "Remote",
url=url,
description=str(item.get("description") or "")[:4000],
tags=tags[:20],
salary=str(item.get("salary") or ""),
remote=True,
raw={euremote_id: raw_id},
))
if len(jobs) >= limit:
break
return jobs if jobs else self._offline_search(query, location, limit)

def _offline_search(self, query, location, limit):
q = query.strip().lower()
loc = location.strip().lower()
jobs = []
for title, company, job_loc, tags, url in _OFFLINE_JOBS:
if q and q not in (title + " " + " ".join(tags)).lower():
continue
if loc and loc not in job_loc.lower():
continue
digest = hashlib.sha1(("offline-" + title + "-" + company).encode()).hexdigest()[:12]
jobs.append(JobPosting(
id=f"euremote-offline-{digest}",
source=self.name,
title=title,
company=company,
location=job_loc,
url=url,
description=f"Offline fixture for {title} at {company}",
tags=list(tags),
salary="",
remote=True,
raw={"offline": True},
))
if len(jobs) >= limit:
break
return jobs
113 changes: 113 additions & 0 deletions src/nerajob/scrapers/jobspresso.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Scraper for jobspresso - public API with offline fallback."""

from __future__ import annotations

import hashlib
import os

import httpx

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

_OFFLINE_KEY = "NERAJOB_JOBSPRESSO_OFFLINE"
_OFFLINE_JOBS = [
('iOS Developer', 'MobileFirst', 'Remote', ['swift', 'ios', 'xcode'], 'https://jobspresso.co/jobs/ios'),
('Data Scientist', 'DataDriven', 'Remote', ['python', 'sql', 'ml'], 'https://jobspresso.co/jobs/ds'),
]


class JobspressoScraper(BaseScraper):
"""Public jobspresso job board adapter."""

name = "jobspresso"
API_URL = "https://jobspresso.co/feed/"

def search(self, query: str, location: str = "", limit: int = 20) -> list[JobPosting]:
if os.getenv(_OFFLINE_KEY, "").strip() in ("1", "true", "yes"):
return self._offline_search(query, location, limit)

headers = {"User-Agent": user_agent(), "Accept": "application/json"}
try:
with httpx.Client(timeout=http_timeout(), headers=headers, follow_redirects=True) as client:
response = client.get(self.API_URL)
response.raise_for_status()
payload = response.json()
except Exception:
return self._offline_search(query, location, limit)

return self._parse(payload, query, location, limit)

def _parse(self, payload, query, location, limit):
if not isinstance(payload, (list, dict)):
return self._offline_search(query, location, limit)

items = payload if isinstance(payload, list) else payload.get("jobs", payload.get("data", []))
if not isinstance(items, list):
return self._offline_search(query, location, limit)

q = query.strip().lower()
loc = location.strip().lower()
jobs = []
for item in items:
if not isinstance(item, dict):
continue
title = str(item.get("title") or item.get("position") or "").strip()
company = str(item.get("company") or item.get("company_name") or "").strip()
if not title:
continue
tags = [str(t).lower() for t in (item.get("tags") or item.get("skills") or []) if t]
hay = title.lower() + " " + company.lower() + " " + " ".join(tags)
if q and q not in hay:
continue
if loc and loc not in str(item.get("location", "")).lower():
continue
job_loc = str(item.get("location") or "Remote")
url = str(item.get("url") or item.get("apply_url") or item.get("link") or "")
raw_id = str(item.get("id") or item.get("slug") or hash(title + company))
digest = hashlib.sha1((self.name + ":" + raw_id).encode()).hexdigest()[:12]

jobs.append(JobPosting(
id=f"jobspresso-{digest}",
source=self.name,
title=title,
company=company or "Unknown",
location=job_loc or "Remote",
url=url,
description=str(item.get("description") or "")[:4000],
tags=tags[:20],
salary=str(item.get("salary") or ""),
remote=True,
raw={jobspresso_id: raw_id},
))
if len(jobs) >= limit:
break
return jobs if jobs else self._offline_search(query, location, limit)

def _offline_search(self, query, location, limit):
q = query.strip().lower()
loc = location.strip().lower()
jobs = []
for title, company, job_loc, tags, url in _OFFLINE_JOBS:
if q and q not in (title + " " + " ".join(tags)).lower():
continue
if loc and loc not in job_loc.lower():
continue
digest = hashlib.sha1(("offline-" + title + "-" + company).encode()).hexdigest()[:12]
jobs.append(JobPosting(
id=f"jobspresso-offline-{digest}",
source=self.name,
title=title,
company=company,
location=job_loc,
url=url,
description=f"Offline fixture for {title} at {company}",
tags=list(tags),
salary="",
remote=True,
raw={"offline": True},
))
if len(jobs) >= limit:
break
return jobs
Loading