jobhunter-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of jobhunter-cli might be problematic. Click here for more details.
- jobhunter/__init__.py +0 -0
- jobhunter/__main__.py +4 -0
- jobhunter/core/__init__.py +0 -0
- jobhunter/core/filters.py +110 -0
- jobhunter/core/http.py +57 -0
- jobhunter/core/logger.py +54 -0
- jobhunter/core/paths.py +124 -0
- jobhunter/core/settings.py +123 -0
- jobhunter/core/textutil.py +33 -0
- jobhunter/main.py +94 -0
- jobhunter/models.py +81 -0
- jobhunter/pipeline/__init__.py +0 -0
- jobhunter/pipeline/evaluate.py +284 -0
- jobhunter/pipeline/fetch.py +233 -0
- jobhunter/pipeline/send.py +191 -0
- jobhunter/sources/__init__.py +21 -0
- jobhunter/sources/arbeitnow.py +77 -0
- jobhunter/sources/ashby.py +75 -0
- jobhunter/sources/base.py +108 -0
- jobhunter/sources/greenhouse.py +70 -0
- jobhunter/sources/lever.py +85 -0
- jobhunter/sources/personio.py +124 -0
- jobhunter/sources/recruitee.py +80 -0
- jobhunter/sources/remoteok.py +69 -0
- jobhunter/sources/remotive.py +61 -0
- jobhunter/sources/workable.py +94 -0
- jobhunter/tools/__init__.py +0 -0
- jobhunter/tools/discover.py +275 -0
- jobhunter/tools/prompt.py +106 -0
- jobhunter/tools/prune.py +124 -0
- jobhunter_cli-0.1.0.dist-info/METADATA +126 -0
- jobhunter_cli-0.1.0.dist-info/RECORD +35 -0
- jobhunter_cli-0.1.0.dist-info/WHEEL +4 -0
- jobhunter_cli-0.1.0.dist-info/entry_points.txt +2 -0
- jobhunter_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
jobhunter/__init__.py
ADDED
|
File without changes
|
jobhunter/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import re
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
|
|
5
|
+
from jobhunter.core.settings import settings
|
|
6
|
+
from jobhunter.models import Job
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@lru_cache(maxsize=512)
|
|
10
|
+
def _get_word_pattern(keyword: str) -> re.Pattern:
|
|
11
|
+
"""Compiles and caches word boundary regex patterns for tech keywords."""
|
|
12
|
+
return re.compile(rf"(?<![a-z0-9]){re.escape(keyword.lower())}(?![a-z0-9])")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def has_word(keyword: str, text: str) -> bool:
|
|
16
|
+
"""Checks if a keyword exists in the text as a whole word, ignoring case."""
|
|
17
|
+
if not text or not keyword:
|
|
18
|
+
return False
|
|
19
|
+
return bool(_get_word_pattern(keyword).search(text.lower()))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_valid_title(job: Job) -> bool:
|
|
23
|
+
"""Checks if the job title passes the include/exclude filters."""
|
|
24
|
+
title = (job.title or "").lower()
|
|
25
|
+
filters = settings.filters
|
|
26
|
+
|
|
27
|
+
is_excluded = bool(filters.title_exclude and any(has_word(ex, title) for ex in filters.title_exclude))
|
|
28
|
+
|
|
29
|
+
is_included = not filters.title_include or any(has_word(inc, title) for inc in filters.title_include)
|
|
30
|
+
|
|
31
|
+
return is_included and not is_excluded
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_valid_company(job: Job) -> bool:
|
|
35
|
+
"""Checks if the job company passes the exclude filters."""
|
|
36
|
+
company = (job.company or "").lower()
|
|
37
|
+
filters = settings.filters
|
|
38
|
+
|
|
39
|
+
is_excluded = bool(filters.company_exclude and any(has_word(ex, company) for ex in filters.company_exclude))
|
|
40
|
+
|
|
41
|
+
return not is_excluded
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_valid_location(job: Job) -> bool:
|
|
45
|
+
"""Checks location filters, giving absolute priority to location_allow_terms."""
|
|
46
|
+
location = (job.location or "").lower()
|
|
47
|
+
filters = settings.filters
|
|
48
|
+
|
|
49
|
+
is_allowed = bool(filters.location_allow_terms and any(has_word(allow, location) for allow in filters.location_allow_terms))
|
|
50
|
+
|
|
51
|
+
is_excluded = bool(filters.location_exclude_terms and any(has_word(ex, location) for ex in filters.location_exclude_terms))
|
|
52
|
+
|
|
53
|
+
is_missing_required = bool(filters.location_require_terms and not any(has_word(req, location) for req in filters.location_require_terms))
|
|
54
|
+
|
|
55
|
+
return is_allowed or (not is_excluded and not is_missing_required)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_job_remote(job: Job) -> bool:
|
|
59
|
+
"""Checks if the job is remote based on attribute or description keywords."""
|
|
60
|
+
is_flagged_remote = job.remote is True
|
|
61
|
+
|
|
62
|
+
search_text = f"{job.title or ''} {job.location or ''} {job.description or ''}"
|
|
63
|
+
|
|
64
|
+
has_remote_keyword = bool(settings.filters.remote_keywords and any(has_word(kw, search_text) for kw in settings.filters.remote_keywords))
|
|
65
|
+
|
|
66
|
+
return is_flagged_remote or has_remote_keyword
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_recent_enough(job: Job) -> bool:
|
|
70
|
+
"""Checks if the job posting is within the max age days filter."""
|
|
71
|
+
has_no_date = not job.posted
|
|
72
|
+
if has_no_date:
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
posted_date = datetime.datetime.strptime(str(job.posted)[:10], "%Y-%m-%d").date()
|
|
77
|
+
age_days = (datetime.datetime.now(datetime.UTC).date() - posted_date).days
|
|
78
|
+
is_within_age_limit = age_days <= settings.filters.max_age_days
|
|
79
|
+
|
|
80
|
+
return is_within_age_limit
|
|
81
|
+
except ValueError, TypeError:
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def count_stack_hits(job: Job) -> int:
|
|
86
|
+
"""Counts how many stack keywords are present in the job title and description."""
|
|
87
|
+
filters = settings.filters
|
|
88
|
+
if not filters.stack_keywords:
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
search_text = f"{job.title or ''} {job.description or ''}"
|
|
92
|
+
return sum(1 for kw in filters.stack_keywords if has_word(kw, search_text))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def has_required_stack(job: Job) -> bool:
|
|
96
|
+
"""Checks if the job description contains the required stack keywords."""
|
|
97
|
+
filters = settings.filters
|
|
98
|
+
|
|
99
|
+
no_stack_required = filters.min_stack_hits <= 0 or not filters.stack_keywords
|
|
100
|
+
|
|
101
|
+
has_enough_hits = count_stack_hits(job) >= filters.min_stack_hits
|
|
102
|
+
|
|
103
|
+
return no_stack_required or has_enough_hits
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def passes_all_filters(job: Job) -> bool:
|
|
107
|
+
"""Master function to run a job through all active filters."""
|
|
108
|
+
is_remote_valid = not settings.filters.require_remote or is_job_remote(job)
|
|
109
|
+
|
|
110
|
+
return is_remote_valid and is_valid_title(job) and is_valid_company(job) and is_valid_location(job) and is_recent_enough(job) and has_required_stack(job)
|
jobhunter/core/http.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from jobhunter.core.logger import console
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
RETRYABLE_STATUS = {408, 429}
|
|
13
|
+
CLIENT = httpx.Client(headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"})
|
|
14
|
+
|
|
15
|
+
atexit.register(CLIENT.close)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _should_retry(e: httpx.HTTPError) -> bool:
|
|
19
|
+
if isinstance(e, httpx.HTTPStatusError):
|
|
20
|
+
code = e.response.status_code
|
|
21
|
+
return code in RETRYABLE_STATUS or code >= 500
|
|
22
|
+
return isinstance(e, httpx.RequestError)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def fetch_url(url: str, headers: dict | None = None, timeout: float = 15.0, retries: int = 3, backoff: float = 1.5, parse_json: bool = False, expected_type: type | None = None, **kwargs) -> Any:
|
|
26
|
+
"""Fetches data from a URL. Returns parsed JSON if `parse_json=True` or `expected_type` is provided, else returns `httpx.Response`."""
|
|
27
|
+
if retries <= 0:
|
|
28
|
+
raise ValueError("retries must be at least 1")
|
|
29
|
+
|
|
30
|
+
if expected_type is not None:
|
|
31
|
+
parse_json = True
|
|
32
|
+
|
|
33
|
+
for attempt in range(retries):
|
|
34
|
+
try:
|
|
35
|
+
response = CLIENT.get(url, headers=headers, timeout=timeout, **kwargs)
|
|
36
|
+
response.raise_for_status()
|
|
37
|
+
|
|
38
|
+
if parse_json:
|
|
39
|
+
data = response.json()
|
|
40
|
+
if expected_type and not isinstance(data, expected_type):
|
|
41
|
+
raise ValueError(f"Expected {expected_type.__name__} from {url}, got {type(data).__name__}")
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
return response
|
|
45
|
+
|
|
46
|
+
except httpx.HTTPError as e:
|
|
47
|
+
if _should_retry(e) and attempt < retries - 1:
|
|
48
|
+
time.sleep(backoff * (2**attempt))
|
|
49
|
+
else:
|
|
50
|
+
is_client_not_found = isinstance(e, httpx.HTTPStatusError) and e.response.status_code in {404, 410}
|
|
51
|
+
if not is_client_not_found:
|
|
52
|
+
logger.error(f"HTTP request failed permanently for {url}: {e}")
|
|
53
|
+
console.print(f" [red]✖ Network error fetching {url}: {e}[/red]")
|
|
54
|
+
raise
|
|
55
|
+
|
|
56
|
+
except ValueError as e:
|
|
57
|
+
raise RuntimeError(f"JSON decoding or type error for {url}: {e}") from e
|
jobhunter/core/logger.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.logging import RichHandler
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def setup_logging(level: str = "INFO", log_file: str = "logs/jobhunter.log") -> None:
|
|
11
|
+
"""Configures global application logging to output to both the console (Rich) and a log file."""
|
|
12
|
+
numeric_level = getattr(logging, level.upper(), logging.INFO)
|
|
13
|
+
|
|
14
|
+
log_path = Path(log_file)
|
|
15
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
|
|
17
|
+
# 1. Rich Console Handler (Terminal UI & Tracebacks)
|
|
18
|
+
console_handler = RichHandler(
|
|
19
|
+
console=console,
|
|
20
|
+
rich_tracebacks=True,
|
|
21
|
+
tracebacks_show_locals=False,
|
|
22
|
+
markup=True,
|
|
23
|
+
)
|
|
24
|
+
# hide normal logger.info() from the terminal and only show errors!
|
|
25
|
+
console_handler.setLevel(logging.CRITICAL)
|
|
26
|
+
|
|
27
|
+
# 2. Standard File Handler (For debugging history)
|
|
28
|
+
file_handler = logging.FileHandler(log_path, encoding="utf-8")
|
|
29
|
+
file_handler.setLevel(numeric_level)
|
|
30
|
+
file_formatter = logging.Formatter(
|
|
31
|
+
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
32
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
33
|
+
)
|
|
34
|
+
file_handler.setFormatter(file_formatter)
|
|
35
|
+
|
|
36
|
+
logging.basicConfig(
|
|
37
|
+
level=numeric_level,
|
|
38
|
+
format="%(message)s",
|
|
39
|
+
datefmt="[%X]",
|
|
40
|
+
handlers=[console_handler, file_handler],
|
|
41
|
+
force=True,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# 3. Suppress Noisy Third-Party Loggers
|
|
45
|
+
noisy_prefixes = ("openai", "httpx", "httpcore", "urllib3", "ddgs", "primp")
|
|
46
|
+
|
|
47
|
+
# Direct suppression
|
|
48
|
+
for noisy_logger in noisy_prefixes:
|
|
49
|
+
logging.getLogger(noisy_logger).setLevel(logging.WARNING)
|
|
50
|
+
|
|
51
|
+
# Aggressive sub-logger suppression loop
|
|
52
|
+
for name, logger in logging.root.manager.loggerDict.items():
|
|
53
|
+
if any(name.startswith(prefix) for prefix in noisy_prefixes) and isinstance(logger, logging.Logger):
|
|
54
|
+
logger.setLevel(logging.WARNING)
|
jobhunter/core/paths.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from filelock import FileLock
|
|
11
|
+
|
|
12
|
+
from jobhunter.core.logger import console
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_project_root() -> Path:
|
|
18
|
+
"""Returns the root directory of the project by searching for 'pyproject.toml'."""
|
|
19
|
+
current_dir = Path(__file__).resolve().parent
|
|
20
|
+
|
|
21
|
+
for directory in [current_dir, *current_dir.parents]:
|
|
22
|
+
if (directory / "pyproject.toml").exists():
|
|
23
|
+
return directory
|
|
24
|
+
|
|
25
|
+
return Path.cwd()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
ROOT_PATH = get_project_root()
|
|
29
|
+
LOCKS_PATH = ROOT_PATH / ".locks"
|
|
30
|
+
DATA_PATH = ROOT_PATH / "data"
|
|
31
|
+
CONFIG_PATH = ROOT_PATH / "config"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _resolve_path(path_in: str | Path) -> Path:
|
|
35
|
+
"""
|
|
36
|
+
Safely resolves a path against the project root.
|
|
37
|
+
If path_in is absolute, it returns it as-is.
|
|
38
|
+
If path_in is relative, it prepends ROOT_PATH.
|
|
39
|
+
"""
|
|
40
|
+
return ROOT_PATH / path_in
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _json_default(obj: Any) -> Any:
|
|
44
|
+
"""Fallback handler for non-standard JSON types like Pydantic models."""
|
|
45
|
+
if hasattr(obj, "model_dump"):
|
|
46
|
+
return obj.model_dump(mode="json")
|
|
47
|
+
return str(obj)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def write(path_from_root: str | Path, data: Any, timeout: int = 10):
|
|
51
|
+
"""Atomically writes data to disk using a persistent lockfile for process safety."""
|
|
52
|
+
path = _resolve_path(path_from_root)
|
|
53
|
+
|
|
54
|
+
directory = path.parent
|
|
55
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
LOCKS_PATH.mkdir(exist_ok=True)
|
|
57
|
+
|
|
58
|
+
if isinstance(data, bytes):
|
|
59
|
+
raw_bytes = data
|
|
60
|
+
elif isinstance(data, str):
|
|
61
|
+
raw_bytes = data.encode("utf-8")
|
|
62
|
+
elif isinstance(data, (dict, list)):
|
|
63
|
+
raw_bytes = json.dumps(data, indent=2, default=_json_default).encode("utf-8")
|
|
64
|
+
elif hasattr(data, "model_dump"):
|
|
65
|
+
raw_bytes = json.dumps(data.model_dump(mode="json"), indent=2).encode("utf-8")
|
|
66
|
+
else:
|
|
67
|
+
raw_bytes = str(data).encode("utf-8")
|
|
68
|
+
|
|
69
|
+
path_hash = hashlib.md5(str(path.resolve()).encode()).hexdigest()
|
|
70
|
+
lock_path = LOCKS_PATH / f"{path.name}_{path_hash}.lock"
|
|
71
|
+
|
|
72
|
+
with FileLock(str(lock_path), timeout=timeout):
|
|
73
|
+
fd, temp_path = tempfile.mkstemp(dir=directory, prefix=".tmp_")
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
with os.fdopen(fd, "wb") as f:
|
|
77
|
+
f.write(raw_bytes)
|
|
78
|
+
f.flush()
|
|
79
|
+
if hasattr(os, "fdatasync"):
|
|
80
|
+
os.fdatasync(f.fileno())
|
|
81
|
+
else:
|
|
82
|
+
os.fsync(f.fileno())
|
|
83
|
+
|
|
84
|
+
os.replace(temp_path, path)
|
|
85
|
+
|
|
86
|
+
except Exception:
|
|
87
|
+
with contextlib.suppress(OSError):
|
|
88
|
+
if os.path.exists(temp_path):
|
|
89
|
+
os.remove(temp_path)
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def read[T: (dict, list, str)](
|
|
94
|
+
path_from_root: str | Path,
|
|
95
|
+
expected_type: type[T] = str,
|
|
96
|
+
) -> T:
|
|
97
|
+
"""Reads and parses a file. Returns a default empty instance ({}, [], "") if missing."""
|
|
98
|
+
path = _resolve_path(path_from_root)
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
text = path.read_text(encoding="utf-8")
|
|
102
|
+
except FileNotFoundError:
|
|
103
|
+
return expected_type()
|
|
104
|
+
|
|
105
|
+
if expected_type in (dict, list):
|
|
106
|
+
try:
|
|
107
|
+
data = json.loads(text)
|
|
108
|
+
|
|
109
|
+
if not isinstance(data, expected_type):
|
|
110
|
+
raise TypeError(f"Type mismatch in {path}: Expected {expected_type.__name__}, got {type(data).__name__}")
|
|
111
|
+
|
|
112
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
113
|
+
logger.warning(f"Error reading or parsing {path}: {e}")
|
|
114
|
+
console.print(f" [yellow]⚠ Warning: Could not parse {path.name} (corrupted). Using default empty state.[/yellow]")
|
|
115
|
+
return expected_type()
|
|
116
|
+
|
|
117
|
+
return data
|
|
118
|
+
|
|
119
|
+
return text # type: ignore
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def delete(path_from_root: str | Path):
|
|
123
|
+
"""Safely unlinks a file if it exists."""
|
|
124
|
+
_resolve_path(path_from_root).unlink(missing_ok=True)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
from pydantic.aliases import AliasChoices
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
from .paths import CONFIG_PATH, read
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProfileConfig(BaseModel):
|
|
13
|
+
target_roles: str = ""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FiltersConfig(BaseModel):
|
|
17
|
+
title_include: list[str] = []
|
|
18
|
+
title_exclude: list[str] = []
|
|
19
|
+
stack_keywords: list[str] = []
|
|
20
|
+
min_stack_hits: int = 0
|
|
21
|
+
company_exclude: list[str] = []
|
|
22
|
+
company_priority: list[str] = []
|
|
23
|
+
require_remote: bool = True
|
|
24
|
+
remote_keywords: list[str] = ["remote", "wfh", "work from home", "anywhere", "telecommute", "fully remote", "100% remote"]
|
|
25
|
+
location_exclude_terms: list[str] = ["us only", "us-only", "united states only", "canada only", "uk only", "must reside in"]
|
|
26
|
+
location_allow_terms: list[str] = ["worldwide", "emea", "anywhere", "global"]
|
|
27
|
+
location_require_terms: list[str] = []
|
|
28
|
+
max_age_days: int = 14
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LimitsConfig(BaseModel):
|
|
32
|
+
max_candidates_per_run: int = 30
|
|
33
|
+
description_max_chars: int = 7000
|
|
34
|
+
seen_retention_days: int = 90
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ScoringWeight(BaseModel):
|
|
38
|
+
name: str
|
|
39
|
+
weight: int
|
|
40
|
+
what: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class EvaluatingConfig(BaseModel):
|
|
44
|
+
score_threshold: int = 65
|
|
45
|
+
scoring_weights: list[ScoringWeight] = Field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LLMConfig(BaseModel):
|
|
49
|
+
provider: str = "openai"
|
|
50
|
+
base_url: str | None = None
|
|
51
|
+
model: str | None = None
|
|
52
|
+
request_delay: int = 5
|
|
53
|
+
temperature: float = 0.0
|
|
54
|
+
timeout: int = 600
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TelegramConfig(BaseModel):
|
|
58
|
+
link_preview: bool = False
|
|
59
|
+
notify_on_empty: bool = True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class WorkersConfig(BaseModel):
|
|
63
|
+
evaluation_workers: int = 1
|
|
64
|
+
fetch_workers: int = 8
|
|
65
|
+
discovery_workers: int = 8
|
|
66
|
+
pruning_workers: int = 8
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Settings(BaseSettings):
|
|
70
|
+
telegram_bot_token: str = Field(default="", validation_alias=AliasChoices("TELEGRAM_BOT_TOKEN", "telegram_bot_token"))
|
|
71
|
+
telegram_chat_id: str = Field(default="", validation_alias=AliasChoices("TELEGRAM_CHAT_ID", "telegram_chat_id"))
|
|
72
|
+
llm_api_key: str = Field(default="", validation_alias=AliasChoices("LLM_API_KEY", "llm_api_key"))
|
|
73
|
+
|
|
74
|
+
profile: ProfileConfig = Field(default_factory=ProfileConfig)
|
|
75
|
+
boards: dict[str, Any] = Field(default_factory=dict)
|
|
76
|
+
companies: dict[str, list[str]] = Field(default_factory=dict)
|
|
77
|
+
filters: FiltersConfig = Field(default_factory=FiltersConfig)
|
|
78
|
+
limits: LimitsConfig = Field(default_factory=LimitsConfig)
|
|
79
|
+
evaluating: EvaluatingConfig = Field(default_factory=EvaluatingConfig)
|
|
80
|
+
llm: LLMConfig = Field(default_factory=LLMConfig)
|
|
81
|
+
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
|
|
82
|
+
workers: WorkersConfig = Field(default_factory=WorkersConfig)
|
|
83
|
+
|
|
84
|
+
cv_text: str = ""
|
|
85
|
+
preferences_text: str = ""
|
|
86
|
+
|
|
87
|
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_settings() -> Settings:
|
|
91
|
+
"""Loads settings from files or environment variable fallbacks, returning a Settings object."""
|
|
92
|
+
config_path = CONFIG_PATH / "config.json"
|
|
93
|
+
|
|
94
|
+
if config_path.exists():
|
|
95
|
+
config_data = read(config_path, dict)
|
|
96
|
+
else:
|
|
97
|
+
config_str = os.getenv("CONFIG_JSON")
|
|
98
|
+
if config_str:
|
|
99
|
+
try:
|
|
100
|
+
config_data = json.loads(config_str)
|
|
101
|
+
except Exception:
|
|
102
|
+
config_data = {}
|
|
103
|
+
else:
|
|
104
|
+
config_data = {}
|
|
105
|
+
|
|
106
|
+
# Remove "_help"
|
|
107
|
+
config_data.pop("_help", None)
|
|
108
|
+
|
|
109
|
+
# Load Markdown with env fallbacks
|
|
110
|
+
cv_path = CONFIG_PATH / "cv.md"
|
|
111
|
+
cv_text = read(cv_path) if cv_path.exists() else os.getenv("CV") or ""
|
|
112
|
+
|
|
113
|
+
pref_path = CONFIG_PATH / "preferences.md"
|
|
114
|
+
preferences_text = read(pref_path) if pref_path.exists() else os.getenv("PREFERENCES") or ""
|
|
115
|
+
|
|
116
|
+
return Settings(
|
|
117
|
+
cv_text=cv_text,
|
|
118
|
+
preferences_text=preferences_text,
|
|
119
|
+
**config_data,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
settings = load_settings()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import html
|
|
2
|
+
import re
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def clean_html(text: str | None) -> str:
|
|
7
|
+
"""Cleans HTML content by removing tags, unescaping entities, and normalizing whitespace."""
|
|
8
|
+
if not text:
|
|
9
|
+
return ""
|
|
10
|
+
|
|
11
|
+
# 1. Remove script and style blocks with their contents
|
|
12
|
+
text = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", text, flags=re.DOTALL | re.IGNORECASE)
|
|
13
|
+
|
|
14
|
+
# 2. Strip HTML tags
|
|
15
|
+
text = re.sub(r"<[^>]+>", " ", text)
|
|
16
|
+
|
|
17
|
+
# 3. Unescape entities after tags are gone
|
|
18
|
+
text = html.unescape(text)
|
|
19
|
+
|
|
20
|
+
# 4. Normalize whitespace
|
|
21
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_date(date_str: str | None) -> str:
|
|
25
|
+
"""Parses an ISO date string into YYYY-MM-DD format."""
|
|
26
|
+
if not date_str:
|
|
27
|
+
return ""
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
|
31
|
+
return dt.strftime("%Y-%m-%d")
|
|
32
|
+
except ValueError:
|
|
33
|
+
return date_str.strip()
|
jobhunter/main.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from jobhunter.core.logger import console, setup_logging
|
|
8
|
+
from jobhunter.pipeline import evaluate, fetch, send
|
|
9
|
+
from jobhunter.tools import discover, prompt, prune
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
help="JobHunter - Automated job scraping and filtering pipeline.",
|
|
13
|
+
no_args_is_help=True,
|
|
14
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
SourceOpt = Annotated[str | None, typer.Option("--source", "-s", help="Specific source to fetch from")]
|
|
18
|
+
NoAggOpt = Annotated[bool, typer.Option("--no-aggregators", help="Skip aggregator boards")]
|
|
19
|
+
IgnoreSeenOpt = Annotated[bool, typer.Option("--ignore-seen", help="Ignore seen jobs and fetch all")]
|
|
20
|
+
LimitOpt = Annotated[int | None, typer.Option("--limit", "-l", help="Overrides max_candidates limit in config.json")]
|
|
21
|
+
WriteOpt = Annotated[bool, typer.Option("--write", "-w", help="Write changes to config.json")]
|
|
22
|
+
JobUidOpt = Annotated[str | None, typer.Option("--job", "-j", help="Evaluate a specific candidate by their UID")]
|
|
23
|
+
DryRunOpt = Annotated[bool, typer.Option("--dry-run", help="Send Telegram alerts without updating local state files")]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command("run", help="Run the full pipeline (fetch -> evaluate -> send)")
|
|
27
|
+
def run_all(
|
|
28
|
+
source: SourceOpt = None,
|
|
29
|
+
no_aggregators: NoAggOpt = False,
|
|
30
|
+
ignore_seen: IgnoreSeenOpt = False,
|
|
31
|
+
limit: LimitOpt = None,
|
|
32
|
+
dry_run: DryRunOpt = False,
|
|
33
|
+
):
|
|
34
|
+
fetch.main(source_name=source, no_aggregators=no_aggregators, ignore_seen=ignore_seen, limit=limit)
|
|
35
|
+
evaluate.main(limit=limit)
|
|
36
|
+
send.main(dry_run=dry_run)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command("fetch", help="Fetch jobs from configured boards")
|
|
40
|
+
def fetch_jobs(
|
|
41
|
+
source: SourceOpt = None,
|
|
42
|
+
no_aggregators: NoAggOpt = False,
|
|
43
|
+
ignore_seen: IgnoreSeenOpt = False,
|
|
44
|
+
limit: LimitOpt = None,
|
|
45
|
+
):
|
|
46
|
+
fetch.main(source_name=source, no_aggregators=no_aggregators, ignore_seen=ignore_seen, limit=limit)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command("evaluate", help="Score and evaluate candidates")
|
|
50
|
+
def evaluate_jobs(
|
|
51
|
+
job: JobUidOpt = None,
|
|
52
|
+
limit: LimitOpt = None,
|
|
53
|
+
):
|
|
54
|
+
evaluate.main(job_uid=job, limit=limit)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command("send", help="Send passing jobs to Telegram")
|
|
58
|
+
def send_jobs(dry_run: DryRunOpt = False):
|
|
59
|
+
send.main(dry_run=dry_run)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command("discover", help="Discover and validate new company boards")
|
|
63
|
+
def discover_boards(
|
|
64
|
+
write: WriteOpt = False,
|
|
65
|
+
source: SourceOpt = None,
|
|
66
|
+
titles: Annotated[str | None, typer.Option("--titles", "-t", help="Comma-separated list of titles to search for")] = None,
|
|
67
|
+
):
|
|
68
|
+
discover.main(write_flag=write, source_name=source, titles_override=titles)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command("prune", help="Prune broken company entries from config.json")
|
|
72
|
+
def prune_companies(write: WriteOpt = False, source: SourceOpt = None):
|
|
73
|
+
prune.main(write_flag=write, source_name=source)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command("prompt", help="Prints the AI evaluation prompt for review")
|
|
77
|
+
def show_prompt():
|
|
78
|
+
prompt.main()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main():
|
|
82
|
+
"""Entry point wrapper to catch global exceptions."""
|
|
83
|
+
setup_logging(level="INFO")
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
app()
|
|
87
|
+
except KeyboardInterrupt:
|
|
88
|
+
console.print("\n[yellow]Aborted by user.[/yellow]")
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
logger = logging.getLogger(__name__)
|
|
92
|
+
logger.exception(f"Fatal error: {e}")
|
|
93
|
+
console.print(f"[bold red]Fatal error: {e}[/bold red]")
|
|
94
|
+
sys.exit(1)
|
jobhunter/models.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field, computed_field
|
|
2
|
+
|
|
3
|
+
from jobhunter.core.settings import settings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Job(BaseModel):
|
|
7
|
+
source: str
|
|
8
|
+
board: str
|
|
9
|
+
company: str
|
|
10
|
+
job_id: str
|
|
11
|
+
title: str
|
|
12
|
+
url: str
|
|
13
|
+
location: str
|
|
14
|
+
remote: bool | None = None
|
|
15
|
+
posted: str = ""
|
|
16
|
+
description: str
|
|
17
|
+
|
|
18
|
+
@computed_field
|
|
19
|
+
@property
|
|
20
|
+
def uid(self) -> str:
|
|
21
|
+
"""Dynamically generates a unique identifier for the job."""
|
|
22
|
+
return f"{self.source}:{self.board}:{self.job_id or self.url}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Candidate(BaseModel):
|
|
26
|
+
uid: str = Field(..., description="Unique compound identifier: source:board:job_id_or_url")
|
|
27
|
+
priority: bool = False
|
|
28
|
+
stack_hits: int = 0
|
|
29
|
+
status: str = Field(default="pending", description="Pipeline status: pending, saved, applied, rejected")
|
|
30
|
+
source: str = ""
|
|
31
|
+
board: str = ""
|
|
32
|
+
company: str
|
|
33
|
+
job_id: str = ""
|
|
34
|
+
title: str
|
|
35
|
+
url: str
|
|
36
|
+
location: str
|
|
37
|
+
remote: bool | None = None
|
|
38
|
+
posted: str = ""
|
|
39
|
+
description: str = ""
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_job(cls, job: Job, priority: bool, stack_hits: int) -> Candidate:
|
|
43
|
+
"""Factory method to convert a raw scraped Job into a stored Candidate."""
|
|
44
|
+
max_chars = settings.limits.description_max_chars
|
|
45
|
+
truncated_description = (job.description or "No description provided.")[:max_chars]
|
|
46
|
+
|
|
47
|
+
return cls(
|
|
48
|
+
uid=job.uid,
|
|
49
|
+
priority=priority,
|
|
50
|
+
stack_hits=stack_hits,
|
|
51
|
+
status="pending",
|
|
52
|
+
source=job.source,
|
|
53
|
+
board=job.board,
|
|
54
|
+
company=job.company,
|
|
55
|
+
job_id=job.job_id,
|
|
56
|
+
title=job.title,
|
|
57
|
+
url=job.url,
|
|
58
|
+
location=job.location,
|
|
59
|
+
remote=job.remote,
|
|
60
|
+
posted=job.posted,
|
|
61
|
+
description=truncated_description,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class EvaluationResult(BaseModel):
|
|
66
|
+
"""The exact JSON schema the LLM must return."""
|
|
67
|
+
|
|
68
|
+
reasoning: str = Field(description="A brief 2-3 sentence thought process analyzing the match before scoring.")
|
|
69
|
+
hard_fail: bool = Field(description="True if the job hits any hard gate or dealbreaker.")
|
|
70
|
+
hard_fail_reason: str = Field(default="", description="If hard_fail is true, a max 12-word explanation. Otherwise empty.")
|
|
71
|
+
strengths: list[str] = Field(description="2-3 short items showing requirements explicitly met by the CV.")
|
|
72
|
+
gaps: list[str] = Field(default_factory=list, description="Missing requirements or weak points compared to the CV.")
|
|
73
|
+
score: int = Field(description="Final match score (0-100) based on the rubric.")
|
|
74
|
+
score_explanation: str = Field(description="A 1-2 sentence explanation of exactly why the job passed or failed the score threshold.")
|
|
75
|
+
pitch: str = Field(default="", description="A short, one-sentence first-person pitch. Empty if score is below threshold.")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class EvaluatedCandidate(Candidate):
|
|
79
|
+
"""A candidate that has been evaluated by the AI."""
|
|
80
|
+
|
|
81
|
+
evaluation: EvaluationResult
|
|
File without changes
|