jobhunter-cli 0.0.post1.dev85__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.
- jobhunter/__init__.py +0 -0
- jobhunter/__main__.py +4 -0
- jobhunter/_version.py +24 -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 +101 -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/init.py +74 -0
- jobhunter/tools/prompt.py +106 -0
- jobhunter/tools/prune.py +124 -0
- jobhunter_cli-0.0.post1.dev85.dist-info/METADATA +136 -0
- jobhunter_cli-0.0.post1.dev85.dist-info/RECORD +37 -0
- jobhunter_cli-0.0.post1.dev85.dist-info/WHEEL +4 -0
- jobhunter_cli-0.0.post1.dev85.dist-info/entry_points.txt +2 -0
- jobhunter_cli-0.0.post1.dev85.dist-info/licenses/LICENSE +21 -0
jobhunter/__init__.py
ADDED
|
File without changes
|
jobhunter/__main__.py
ADDED
jobhunter/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.0.post1.dev85'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 0, 'post1', 'dev85')
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
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,101 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from jobhunter.core.logger import console, setup_logging
|
|
9
|
+
from jobhunter.pipeline import evaluate, fetch, send
|
|
10
|
+
from jobhunter.tools import discover, init, prompt, prune
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
help="JobHunter - Automated job scraping and filtering pipeline.",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Reusable CLI Options with enhanced documentation
|
|
19
|
+
SourceOpt = Annotated[str | None, typer.Option("--source", "-s", help="Specific ATS source or aggregator to fetch from (e.g., 'greenhouse', 'remotive').")]
|
|
20
|
+
NoAggOpt = Annotated[bool, typer.Option("--no-aggregators", help="Skip aggregator boards and only scrape direct company ATS boards.")]
|
|
21
|
+
IgnoreSeenOpt = Annotated[bool, typer.Option("--ignore-seen", help="Ignore the 'seen' cache and fetch all jobs regardless of previous processing.")]
|
|
22
|
+
LimitOpt = Annotated[int | None, typer.Option("--limit", "-l", help="Override the maximum number of candidates to process (bypasses config.json limit).")]
|
|
23
|
+
WriteOpt = Annotated[bool, typer.Option("--write", "-w", help="Persist discovered/pruned companies by writing directly to config.json.")]
|
|
24
|
+
JobUidOpt = Annotated[str | None, typer.Option("--job", "-j", help="Evaluate only a specific candidate by their exact Job UID.")]
|
|
25
|
+
DryRunOpt = Annotated[bool, typer.Option("--dry-run", help="Dispatch alerts without modifying the local 'seen' cache or state files.")]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command("init", help="Scaffolds a new JobHunter workspace by downloading default templates and docs.")
|
|
29
|
+
def init_workspace(dir: Annotated[Path, typer.Argument(help="Target directory to initialize the JobHunter workspace.")] = Path(".")):
|
|
30
|
+
init.main(dir=dir)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command("run", help="Run the full pipeline (fetch -> evaluate -> send).")
|
|
34
|
+
def run_all(
|
|
35
|
+
source: SourceOpt = None,
|
|
36
|
+
no_aggregators: NoAggOpt = False,
|
|
37
|
+
ignore_seen: IgnoreSeenOpt = False,
|
|
38
|
+
limit: LimitOpt = None,
|
|
39
|
+
dry_run: DryRunOpt = False,
|
|
40
|
+
):
|
|
41
|
+
fetch.main(source_name=source, no_aggregators=no_aggregators, ignore_seen=ignore_seen, limit=limit)
|
|
42
|
+
evaluate.main(limit=limit)
|
|
43
|
+
send.main(dry_run=dry_run)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.command("fetch", help="Scrape and filter jobs from configured boards.")
|
|
47
|
+
def fetch_jobs(
|
|
48
|
+
source: SourceOpt = None,
|
|
49
|
+
no_aggregators: NoAggOpt = False,
|
|
50
|
+
ignore_seen: IgnoreSeenOpt = False,
|
|
51
|
+
limit: LimitOpt = None,
|
|
52
|
+
):
|
|
53
|
+
fetch.main(source_name=source, no_aggregators=no_aggregators, ignore_seen=ignore_seen, limit=limit)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command("evaluate", help="Score and evaluate pending candidates using AI.")
|
|
57
|
+
def evaluate_jobs(
|
|
58
|
+
job: JobUidOpt = None,
|
|
59
|
+
limit: LimitOpt = None,
|
|
60
|
+
):
|
|
61
|
+
evaluate.main(job_uid=job, limit=limit)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command("send", help="Dispatch passing job evaluations to Telegram.")
|
|
65
|
+
def send_jobs(dry_run: DryRunOpt = False):
|
|
66
|
+
send.main(dry_run=dry_run)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command("discover", help="Discover and validate new company boards via search engines.")
|
|
70
|
+
def discover_boards(
|
|
71
|
+
write: WriteOpt = False,
|
|
72
|
+
source: SourceOpt = None,
|
|
73
|
+
titles: Annotated[str | None, typer.Option("--titles", "-t", help="Comma-separated list of job titles to search for (e.g., 'engineer,developer').")] = None,
|
|
74
|
+
):
|
|
75
|
+
discover.main(write_flag=write, source_name=source, titles_override=titles)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command("prune", help="Validate and prune broken company endpoints from config.json.")
|
|
79
|
+
def prune_companies(write: WriteOpt = False, source: SourceOpt = None):
|
|
80
|
+
prune.main(write_flag=write, source_name=source)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.command("prompt", help="Prints the compiled AI evaluation system prompt for review.")
|
|
84
|
+
def show_prompt():
|
|
85
|
+
prompt.main()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main():
|
|
89
|
+
"""Entry point wrapper to catch global exceptions."""
|
|
90
|
+
setup_logging(level="INFO")
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
app()
|
|
94
|
+
except KeyboardInterrupt:
|
|
95
|
+
console.print("\n[yellow]Aborted by user.[/yellow]")
|
|
96
|
+
sys.exit(1)
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logger = logging.getLogger(__name__)
|
|
99
|
+
logger.exception(f"Fatal error: {e}")
|
|
100
|
+
console.print(f"[bold red]Fatal error: {e}[/bold red]")
|
|
101
|
+
sys.exit(1)
|