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
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from jobhunter.core.logger import console
|
|
7
|
+
from jobhunter.core.paths import DATA_PATH, read, write
|
|
8
|
+
from jobhunter.core.settings import settings
|
|
9
|
+
from jobhunter.core.textutil import clean_html
|
|
10
|
+
from jobhunter.models import EvaluatedCandidate
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_send_tasks() -> list[EvaluatedCandidate]:
|
|
16
|
+
"""Loads all evaluated candidates from disk and filters for jobs that passed evaluation (no score floor)."""
|
|
17
|
+
logger.info("Building send tasks...")
|
|
18
|
+
|
|
19
|
+
raw_evals = read(DATA_PATH / "evaluated.json", list)
|
|
20
|
+
|
|
21
|
+
tasks = []
|
|
22
|
+
for item in raw_evals:
|
|
23
|
+
try:
|
|
24
|
+
evaluation = item.get("evaluation", {})
|
|
25
|
+
hard_fail = evaluation.get("hard_fail", True)
|
|
26
|
+
|
|
27
|
+
if not hard_fail:
|
|
28
|
+
eval_candidate = EvaluatedCandidate(**item)
|
|
29
|
+
tasks.append(eval_candidate)
|
|
30
|
+
|
|
31
|
+
except Exception as e:
|
|
32
|
+
logger.error(f"Failed to parse evaluated candidate {item.get('uid', 'unknown')}: {e}")
|
|
33
|
+
console.print(f" [red]ā Failed to parse evaluated candidate {item.get('uid', 'unknown')}[/red]")
|
|
34
|
+
|
|
35
|
+
logger.info(f"Generated {len(tasks)} send tasks from evaluated candidates.")
|
|
36
|
+
|
|
37
|
+
return tasks
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def send_single_telegram_message(candidate: EvaluatedCandidate) -> bool:
|
|
41
|
+
token = settings.telegram_bot_token
|
|
42
|
+
chat_id = settings.telegram_chat_id
|
|
43
|
+
eval_data = candidate.evaluation
|
|
44
|
+
|
|
45
|
+
priority_badge = "ā <b>[VIP COMPANY]</b> " if candidate.priority else ""
|
|
46
|
+
|
|
47
|
+
score_threshold = settings.evaluating.score_threshold
|
|
48
|
+
if eval_data.score >= 80:
|
|
49
|
+
score_emoji = "š¢"
|
|
50
|
+
elif eval_data.score >= score_threshold:
|
|
51
|
+
score_emoji = "š”"
|
|
52
|
+
else:
|
|
53
|
+
score_emoji = "š "
|
|
54
|
+
|
|
55
|
+
pitch_section = f"\nš” <b>Pitch:</b>\n{clean_html(eval_data.pitch)}\n" if eval_data.pitch else ""
|
|
56
|
+
|
|
57
|
+
message = f"""
|
|
58
|
+
šÆ <b>New Job Match!</b>
|
|
59
|
+
{priority_badge}({score_emoji} Score: <code>{eval_data.score}/100</code>)
|
|
60
|
+
|
|
61
|
+
š¢ <b>Company:</b> {clean_html(candidate.company)}
|
|
62
|
+
š¼ <b>Title:</b> {clean_html(candidate.title)}
|
|
63
|
+
š <b>Location:</b> {clean_html(candidate.location) or "Remote"}
|
|
64
|
+
|
|
65
|
+
š <b>Reasoning:</b>\n{clean_html(eval_data.reasoning)}
|
|
66
|
+
š <b>Score Breakdown:</b>\n{clean_html(eval_data.score_explanation)}
|
|
67
|
+
{pitch_section}
|
|
68
|
+
š <a href="{candidate.url}">View Job Posting</a>
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
|
72
|
+
payload = {
|
|
73
|
+
"chat_id": chat_id,
|
|
74
|
+
"text": message,
|
|
75
|
+
"parse_mode": "HTML",
|
|
76
|
+
"disable_web_page_preview": True,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
response = httpx.post(url, json=payload, timeout=10.0)
|
|
81
|
+
response.raise_for_status()
|
|
82
|
+
return True
|
|
83
|
+
except httpx.HTTPStatusError as e:
|
|
84
|
+
logger.exception(f"Telegram API Error ({e.response.status_code}) for {candidate.company}: {e.response.text}")
|
|
85
|
+
console.print(f" [red]ā Telegram API Error: {e.response.status_code}[/red]")
|
|
86
|
+
return False
|
|
87
|
+
except Exception as e:
|
|
88
|
+
logger.exception(f"Failed to send Telegram notification for {candidate.company}: {e}")
|
|
89
|
+
console.print(f" [red]ā Failed to send Telegram notification for {candidate.company}[/red]")
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def execute_telegram_sends(tasks: list[EvaluatedCandidate]) -> tuple[list[EvaluatedCandidate], int]:
|
|
94
|
+
"""Iterates through send tasks and dispatches notifications via Telegram."""
|
|
95
|
+
logger.info(f"Executing Telegram dispatch across {len(tasks)} candidates.")
|
|
96
|
+
console.print(f"[bold cyan]Sending {len(tasks)} job alerts via Telegram...[/bold cyan]")
|
|
97
|
+
|
|
98
|
+
token = settings.telegram_bot_token
|
|
99
|
+
chat_id = settings.telegram_chat_id
|
|
100
|
+
|
|
101
|
+
if not token or not chat_id:
|
|
102
|
+
logger.error("Telegram token or chat ID is missing in settings.")
|
|
103
|
+
console.print("[bold red]Error: Telegram token or chat ID not configured in settings.[/bold red]")
|
|
104
|
+
return [], 0
|
|
105
|
+
|
|
106
|
+
sent_candidates = []
|
|
107
|
+
failed_count = 0
|
|
108
|
+
|
|
109
|
+
for candidate in tasks:
|
|
110
|
+
success = send_single_telegram_message(candidate)
|
|
111
|
+
|
|
112
|
+
if success:
|
|
113
|
+
sent_candidates.append(candidate)
|
|
114
|
+
console.print(f" [green]ā Sent alert for {candidate.company} - {candidate.title}[/green]")
|
|
115
|
+
else:
|
|
116
|
+
failed_count += 1
|
|
117
|
+
console.print(f" [red]ā Failed to send alert for {candidate.company}[/red]")
|
|
118
|
+
|
|
119
|
+
time.sleep(1)
|
|
120
|
+
|
|
121
|
+
logger.info(f"Telegram dispatch complete. Successfully sent: {len(sent_candidates)}, Failed: {failed_count}")
|
|
122
|
+
|
|
123
|
+
return sent_candidates, failed_count
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def save_send_results(sent_candidates: list[EvaluatedCandidate]):
|
|
127
|
+
"""Logs evaluated jobs to seen.json, then purges them from state files."""
|
|
128
|
+
logger.info("Updating state files...")
|
|
129
|
+
console.print("[dim cyan]Updating local state files...[/dim cyan]")
|
|
130
|
+
|
|
131
|
+
all_evaluated = read(DATA_PATH / "evaluated.json", list)
|
|
132
|
+
if not all_evaluated:
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
all_evaluated_uids = {e.get("uid") for e in all_evaluated if e.get("uid")}
|
|
136
|
+
sent_uids = {c.uid for c in sent_candidates}
|
|
137
|
+
current_time = int(time.time())
|
|
138
|
+
|
|
139
|
+
seen_data = read(DATA_PATH / "seen.json", dict)
|
|
140
|
+
for uid in all_evaluated_uids:
|
|
141
|
+
seen_data[uid] = current_time
|
|
142
|
+
|
|
143
|
+
retention_cutoff = current_time - (settings.limits.seen_retention_days * 86400)
|
|
144
|
+
seen_data = {uid: ts for uid, ts in seen_data.items() if ts > retention_cutoff}
|
|
145
|
+
write(DATA_PATH / "seen.json", seen_data)
|
|
146
|
+
|
|
147
|
+
candidates = read(DATA_PATH / "candidates.json", list)
|
|
148
|
+
filtered_candidates = [c for c in candidates if c.get("uid") not in all_evaluated_uids]
|
|
149
|
+
write(DATA_PATH / "candidates.json", filtered_candidates)
|
|
150
|
+
|
|
151
|
+
failed_to_send = [e for e in all_evaluated if e.get("uid") not in sent_uids and not e.get("evaluation", {}).get("hard_fail", True)]
|
|
152
|
+
write(DATA_PATH / "evaluated.json", failed_to_send)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def main(dry_run: bool = False):
|
|
156
|
+
"""Orchestrates the Telegram notification pipeline."""
|
|
157
|
+
logger.info("Starting Telegram send pipeline run.")
|
|
158
|
+
console.print("[bold cyan]Dispatching job evaluations to Telegram...[/bold cyan]")
|
|
159
|
+
|
|
160
|
+
all_evaluated = read(DATA_PATH / "evaluated.json", list)
|
|
161
|
+
if not all_evaluated:
|
|
162
|
+
logger.info("No evaluated candidates exist.")
|
|
163
|
+
console.print("[yellow]No evaluated jobs pending processing.[/yellow]")
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
tasks = build_send_tasks()
|
|
167
|
+
|
|
168
|
+
sent_candidates = []
|
|
169
|
+
failed_count = 0
|
|
170
|
+
|
|
171
|
+
if tasks:
|
|
172
|
+
sent_candidates, failed_count = execute_telegram_sends(tasks)
|
|
173
|
+
else:
|
|
174
|
+
logger.info("No pending job alerts to send (all evaluated jobs hard-failed).")
|
|
175
|
+
console.print("[dim]No passing jobs to alert you about. Updating seen state...[/dim]")
|
|
176
|
+
|
|
177
|
+
if not dry_run:
|
|
178
|
+
save_send_results(sent_candidates)
|
|
179
|
+
else:
|
|
180
|
+
logger.info("Dry run enabled. Skipping state file updates.")
|
|
181
|
+
console.print("\n[yellow]Dry run mode: skipped saving to local state files.[/yellow]")
|
|
182
|
+
|
|
183
|
+
total_sent = len(sent_candidates)
|
|
184
|
+
logger.info(f"Pipeline finished. Sent: {total_sent} | Failed: {failed_count}")
|
|
185
|
+
|
|
186
|
+
console.print("\n[bold]Telegram Dispatch Summary[/bold]")
|
|
187
|
+
console.print("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā")
|
|
188
|
+
console.print(f"Alerts sent : [bold green]{total_sent}[/bold green]")
|
|
189
|
+
console.print(f"Failed deliveries : [red]{failed_count}[/red]")
|
|
190
|
+
console.print(f"Dry run mode : [yellow]{dry_run}[/yellow]")
|
|
191
|
+
console.print("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā\n")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import pkgutil
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
SOURCES = {}
|
|
6
|
+
|
|
7
|
+
current_dir = str(Path(__file__).parent)
|
|
8
|
+
|
|
9
|
+
for _, module_name, _ in pkgutil.iter_modules([current_dir]):
|
|
10
|
+
if module_name == "base":
|
|
11
|
+
continue
|
|
12
|
+
|
|
13
|
+
module = importlib.import_module(f".{module_name}", package=__name__)
|
|
14
|
+
|
|
15
|
+
if hasattr(module, "Source"):
|
|
16
|
+
source_instance = module.Source()
|
|
17
|
+
if getattr(source_instance, "enabled", True):
|
|
18
|
+
SOURCES[source_instance.name] = source_instance
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = ["SOURCES"]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
|
|
5
|
+
from jobhunter.core.http import fetch_url
|
|
6
|
+
from jobhunter.core.logger import console
|
|
7
|
+
from jobhunter.core.textutil import clean_html, parse_date
|
|
8
|
+
from jobhunter.models import Job
|
|
9
|
+
from jobhunter.sources.base import BaseSource
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Source(BaseSource):
|
|
15
|
+
name = "arbeitnow"
|
|
16
|
+
domains = ["arbeitnow.com"]
|
|
17
|
+
is_aggregator = True
|
|
18
|
+
|
|
19
|
+
def fetch(self, slug: str = "", skip_parsing: bool = False, **kwargs) -> list[Job]:
|
|
20
|
+
"""Fetches all available job listings from the Arbeitnow API via pagination link traversal."""
|
|
21
|
+
url = "https://www.arbeitnow.com/api/job-board-api"
|
|
22
|
+
jobs: list[Job] = []
|
|
23
|
+
|
|
24
|
+
while url:
|
|
25
|
+
data = fetch_url(url, expected_type=dict)
|
|
26
|
+
|
|
27
|
+
for item in data.get("data", []):
|
|
28
|
+
if not isinstance(item, dict):
|
|
29
|
+
logger.warning(f"[ {self.name} ] Skipping unexpected item format: {type(item).__name__}")
|
|
30
|
+
console.print(f" [yellow]ā [ {self.name} ] Skipping unexpected item format...[/yellow]")
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
job_id = str(item.get("slug") or "")
|
|
34
|
+
title = str(item.get("title") or "")
|
|
35
|
+
job_url = str(item.get("url") or "")
|
|
36
|
+
|
|
37
|
+
if not all([job_id, title, job_url]):
|
|
38
|
+
logger.warning(f"[ {self.name} ] Skipping item with missing required fields: {item}")
|
|
39
|
+
console.print(f" [yellow]ā [ {self.name} ] Skipping item with missing required fields.[/yellow]")
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
raw_tags = item.get("tags") or []
|
|
44
|
+
tags = ", ".join(str(t) for t in raw_tags if t)
|
|
45
|
+
description = item.get("description", "")
|
|
46
|
+
if tags:
|
|
47
|
+
description = f"Tags: {tags}\n\n{description}"
|
|
48
|
+
|
|
49
|
+
created = item.get("created_at")
|
|
50
|
+
posted = datetime.fromtimestamp(created, tz=UTC).strftime("%Y-%m-%d") if isinstance(created, (int, float)) else parse_date(str(created))
|
|
51
|
+
|
|
52
|
+
jobs.append(
|
|
53
|
+
Job(
|
|
54
|
+
source=self.name,
|
|
55
|
+
board=self.name,
|
|
56
|
+
company=item.get("company_name") or "Unknown",
|
|
57
|
+
job_id=job_id,
|
|
58
|
+
title=title,
|
|
59
|
+
url=job_url,
|
|
60
|
+
location=item.get("location") or "",
|
|
61
|
+
remote=True if item.get("remote") else None,
|
|
62
|
+
posted=posted,
|
|
63
|
+
description=clean_html(description),
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logger.exception(f"[ {self.name} ] Error parsing job '{job_id}': {e}")
|
|
69
|
+
console.print(f" [red]ā [ {self.name} ] Error parsing job '{job_id}'[/red]")
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
url = (data.get("links") or {}).get("next")
|
|
73
|
+
|
|
74
|
+
if url:
|
|
75
|
+
time.sleep(1.5)
|
|
76
|
+
|
|
77
|
+
return jobs
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from jobhunter.core.http import fetch_url
|
|
5
|
+
from jobhunter.core.logger import console
|
|
6
|
+
from jobhunter.core.textutil import clean_html, parse_date
|
|
7
|
+
from jobhunter.models import Job
|
|
8
|
+
from jobhunter.sources.base import BaseSource
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Source(BaseSource):
|
|
14
|
+
name = "ashby"
|
|
15
|
+
domains = ["jobs.ashbyhq.com"]
|
|
16
|
+
url_patterns = [re.compile(r"(?://|www\.)(?P<slug>[^/?#.]+)\.ashbyhq\.com"), re.compile(r"ashbyhq\.com/(?P<slug>[^/?#]+)")]
|
|
17
|
+
|
|
18
|
+
def fetch(self, slug: str = "", skip_parsing: bool = False, **kwargs) -> list[Job]:
|
|
19
|
+
"""Fetches job listings from a specific company's Ashby API."""
|
|
20
|
+
if not slug:
|
|
21
|
+
raise ValueError(f"A company slug must be provided to fetch {self.name} jobs.")
|
|
22
|
+
|
|
23
|
+
url = f"https://api.ashbyhq.com/posting-api/job-board/{slug}"
|
|
24
|
+
data = fetch_url(url, expected_type=dict)
|
|
25
|
+
|
|
26
|
+
if skip_parsing:
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
jobs: list[Job] = []
|
|
30
|
+
|
|
31
|
+
for item in data.get("jobs", []):
|
|
32
|
+
if not isinstance(item, dict):
|
|
33
|
+
logger.warning(f"[ {self.name} ] Skipping unexpected item format: {type(item).__name__}")
|
|
34
|
+
console.print(f" [yellow]ā [ {self.name} ] Skipping unexpected item format...[/yellow]")
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
if item.get("isListed") is False:
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
job_id = str(item.get("id") or "")
|
|
41
|
+
title = str(item.get("title") or "")
|
|
42
|
+
job_url = str(item.get("jobUrl") or item.get("applyUrl") or "")
|
|
43
|
+
|
|
44
|
+
if not all([job_id, title, job_url]):
|
|
45
|
+
logger.warning(f"[ {self.name} ] Skipping item with missing required fields: {item}")
|
|
46
|
+
console.print(f" [yellow]ā [ {self.name} ] Skipping item with missing required fields.[/yellow]")
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
extra = [s.get("location", "") if isinstance(s, dict) else str(s) for s in item.get("secondaryLocations") or []]
|
|
51
|
+
location = ", ".join(x for x in [item.get("location", ""), *extra] if x)
|
|
52
|
+
|
|
53
|
+
is_remote = bool(item.get("isRemote")) or str(item.get("workplaceType", "")).lower() == "remote"
|
|
54
|
+
|
|
55
|
+
jobs.append(
|
|
56
|
+
Job(
|
|
57
|
+
source=self.name,
|
|
58
|
+
board=slug,
|
|
59
|
+
company=slug.replace("-", " ").title(),
|
|
60
|
+
job_id=job_id,
|
|
61
|
+
title=title,
|
|
62
|
+
url=job_url,
|
|
63
|
+
location=location,
|
|
64
|
+
remote=True if is_remote else None,
|
|
65
|
+
posted=parse_date(item.get("publishedAt") or item.get("publishedDate")),
|
|
66
|
+
description=item.get("descriptionPlain") or clean_html(item.get("descriptionHtml", "")),
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
except Exception as e:
|
|
71
|
+
logger.exception(f"[ {self.name} ] Error parsing job '{job_id}' for '{slug}': {e}")
|
|
72
|
+
console.print(f" [red]ā [ {self.name} ] Error parsing job '{job_id}'[/red]")
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
return jobs
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import unquote
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from jobhunter.core.logger import console
|
|
11
|
+
from jobhunter.core.settings import settings
|
|
12
|
+
from jobhunter.models import Job
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
DEFINITIVE_INVALID_STATUS = {404, 410}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SlugStatus(Enum):
|
|
20
|
+
VALID = "valid"
|
|
21
|
+
INVALID = "invalid"
|
|
22
|
+
UNKNOWN = "unknown"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BaseSource(ABC):
|
|
26
|
+
name: str
|
|
27
|
+
domains: list[str]
|
|
28
|
+
url_patterns: list[re.Pattern] = []
|
|
29
|
+
is_aggregator: bool = False
|
|
30
|
+
board_config: dict[str, Any] | bool
|
|
31
|
+
enabled: bool
|
|
32
|
+
|
|
33
|
+
def __init__(self):
|
|
34
|
+
super().__init__()
|
|
35
|
+
self.board_config = settings.boards.get(self.name, {})
|
|
36
|
+
if isinstance(self.board_config, dict):
|
|
37
|
+
self.enabled = self.board_config.get("enabled", True)
|
|
38
|
+
else:
|
|
39
|
+
self.enabled = bool(self.board_config)
|
|
40
|
+
|
|
41
|
+
def extract_slug(self, url: str) -> str | None:
|
|
42
|
+
"""Extracts the company slug from a URL using source-specific regex patterns."""
|
|
43
|
+
if not url:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
for pattern in self.url_patterns:
|
|
47
|
+
match = pattern.search(url)
|
|
48
|
+
if match:
|
|
49
|
+
slug = match.group("slug").lower().strip()
|
|
50
|
+
slug = unquote(slug)
|
|
51
|
+
if 1 < len(slug) < 60:
|
|
52
|
+
return slug
|
|
53
|
+
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def validate_slug(self, slug: str) -> SlugStatus:
|
|
57
|
+
"""Validates a slug and returns its status."""
|
|
58
|
+
if not slug or not isinstance(slug, str):
|
|
59
|
+
return SlugStatus.INVALID
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
self.fetch(slug=slug, skip_parsing=True)
|
|
63
|
+
return SlugStatus.VALID
|
|
64
|
+
|
|
65
|
+
except httpx.HTTPStatusError as e:
|
|
66
|
+
if e.response.status_code in DEFINITIVE_INVALID_STATUS:
|
|
67
|
+
return SlugStatus.INVALID
|
|
68
|
+
return SlugStatus.UNKNOWN
|
|
69
|
+
|
|
70
|
+
except Exception:
|
|
71
|
+
return SlugStatus.UNKNOWN
|
|
72
|
+
|
|
73
|
+
def execute_scraper(self, slug: str = "") -> list[Job]:
|
|
74
|
+
"""Executes the scraper for a given source and slug."""
|
|
75
|
+
if not self.enabled:
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
if not self.is_aggregator and not slug:
|
|
79
|
+
logger.warning(f"[ {self.name} ] Skipping execution: Non-aggregator sources require a slug.")
|
|
80
|
+
console.print(f" [yellow]ā [ {self.name} ] Skipping execution: Non-aggregator sources require a slug.[/yellow]")
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
return self.fetch(slug=slug) or []
|
|
85
|
+
|
|
86
|
+
except httpx.HTTPStatusError as e:
|
|
87
|
+
logger.error(f"[ {self.name} ] HTTP {e.response.status_code} error for slug '{slug}': {e}")
|
|
88
|
+
console.print(f" [red]ā [ {self.name} ] HTTP {e.response.status_code} error for slug '{slug}'[/red]")
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
except httpx.RequestError as e:
|
|
92
|
+
logger.error(f"[ {self.name} ] Network error while connecting to '{slug}': {e}")
|
|
93
|
+
console.print(f" [red]ā [ {self.name} ] Network error while connecting to '{slug}'[/red]")
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
except ValueError as e:
|
|
97
|
+
logger.error(f"[ {self.name} ] Validation error for slug '{slug}': {e}")
|
|
98
|
+
console.print(f" [red]ā [ {self.name} ] Validation error for slug '{slug}': {e}[/red]")
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
except Exception as e:
|
|
102
|
+
logger.exception(f"[ {self.name} ] Unexpected error executing scraper for slug '{slug}': {e}")
|
|
103
|
+
console.print(f" [red]ā [ {self.name} ] Unexpected error executing scraper for slug '{slug}'[/red]")
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
@abstractmethod
|
|
107
|
+
def fetch(self, slug: str = "", skip_parsing: bool = False, **kwargs) -> list[Job]:
|
|
108
|
+
pass
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from jobhunter.core.http import fetch_url
|
|
5
|
+
from jobhunter.core.logger import console
|
|
6
|
+
from jobhunter.core.textutil import clean_html, parse_date
|
|
7
|
+
from jobhunter.models import Job
|
|
8
|
+
from jobhunter.sources.base import BaseSource
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Source(BaseSource):
|
|
14
|
+
name = "greenhouse"
|
|
15
|
+
domains = ["boards.greenhouse.io", "job-boards.greenhouse.io"]
|
|
16
|
+
url_patterns = [
|
|
17
|
+
re.compile(r"greenhouse\.io/(?P<slug>[^/?#]+)"),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
def fetch(self, slug: str = "", skip_parsing: bool = False, **kwargs) -> list[Job]:
|
|
21
|
+
"""Fetches job listings from a specific company's Greenhouse API."""
|
|
22
|
+
if not slug:
|
|
23
|
+
raise ValueError(f"A company slug must be provided to fetch {self.name} jobs.")
|
|
24
|
+
|
|
25
|
+
url = f"https://boards-api.greenhouse.io/v1/boards/{slug}/jobs?content=true"
|
|
26
|
+
data = fetch_url(url, expected_type=dict)
|
|
27
|
+
|
|
28
|
+
if skip_parsing:
|
|
29
|
+
return []
|
|
30
|
+
|
|
31
|
+
jobs: list[Job] = []
|
|
32
|
+
|
|
33
|
+
for item in data.get("jobs", []):
|
|
34
|
+
if not isinstance(item, dict):
|
|
35
|
+
logger.warning(f"[ {self.name} ] Skipping unexpected item format: {type(item).__name__}")
|
|
36
|
+
console.print(f" [yellow]ā [ {self.name} ] Skipping unexpected item format...[/yellow]")
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
job_id = str(item.get("id") or "")
|
|
40
|
+
title = str(item.get("title") or "")
|
|
41
|
+
job_url = str(item.get("absolute_url") or "")
|
|
42
|
+
|
|
43
|
+
if not all([job_id, title, job_url]):
|
|
44
|
+
logger.warning(f"[ {self.name} ] Skipping item with missing required fields: {item}")
|
|
45
|
+
console.print(f" [yellow]ā [ {self.name} ] Skipping item with missing required fields.[/yellow]")
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
location = (item.get("location") or {}).get("name") or ""
|
|
50
|
+
|
|
51
|
+
jobs.append(
|
|
52
|
+
Job(
|
|
53
|
+
source=self.name,
|
|
54
|
+
board=slug,
|
|
55
|
+
company=slug.replace("-", " ").title(),
|
|
56
|
+
job_id=job_id,
|
|
57
|
+
title=title,
|
|
58
|
+
url=job_url,
|
|
59
|
+
location=location,
|
|
60
|
+
posted=parse_date(item.get("first_published") or item.get("updated_at")),
|
|
61
|
+
description=clean_html(item.get("content", "")),
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.exception(f"[ {self.name} ] Error parsing job '{job_id}' for '{slug}': {e}")
|
|
67
|
+
console.print(f" [red]ā [ {self.name} ] Error parsing job '{job_id}'[/red]")
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
return jobs
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
|
|
5
|
+
from jobhunter.core.http import fetch_url
|
|
6
|
+
from jobhunter.core.logger import console
|
|
7
|
+
from jobhunter.core.textutil import clean_html, parse_date
|
|
8
|
+
from jobhunter.models import Job
|
|
9
|
+
from jobhunter.sources.base import BaseSource
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Source(BaseSource):
|
|
15
|
+
name = "lever"
|
|
16
|
+
domains = ["lever.co"]
|
|
17
|
+
url_patterns = [
|
|
18
|
+
re.compile(r"lever\.co/(?P<slug>[^/?#]+)"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
def fetch(self, slug: str = "", skip_parsing: bool = False, **kwargs) -> list[Job]:
|
|
22
|
+
"""Fetches job listings from a specific company's Lever API."""
|
|
23
|
+
if not slug:
|
|
24
|
+
raise ValueError(f"A company slug must be provided to fetch {self.name} jobs.")
|
|
25
|
+
|
|
26
|
+
url = f"https://api.lever.co/v0/postings/{slug}?mode=json"
|
|
27
|
+
data = fetch_url(url, expected_type=list)
|
|
28
|
+
|
|
29
|
+
if skip_parsing:
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
jobs: list[Job] = []
|
|
33
|
+
|
|
34
|
+
for item in data:
|
|
35
|
+
if not isinstance(item, dict):
|
|
36
|
+
logger.warning(f"[ {self.name} ] Skipping unexpected item format: {type(item).__name__}")
|
|
37
|
+
console.print(f" [yellow]ā [ {self.name} ] Skipping unexpected item format...[/yellow]")
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
job_id = str(item.get("id") or "")
|
|
41
|
+
title = str(item.get("text") or "")
|
|
42
|
+
job_url = str(item.get("hostedUrl") or "")
|
|
43
|
+
|
|
44
|
+
if not all([job_id, title, job_url]):
|
|
45
|
+
logger.warning(f"[ {self.name} ] Skipping item with missing required fields: {item}")
|
|
46
|
+
console.print(f" [yellow]ā [ {self.name} ] Skipping item with missing required fields.[/yellow]")
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
categories = item.get("categories") or {}
|
|
51
|
+
location = categories.get("location") or ", ".join(categories.get("allLocations") or [])
|
|
52
|
+
|
|
53
|
+
parts = [item.get("descriptionPlain") or ""]
|
|
54
|
+
for block in item.get("lists") or []:
|
|
55
|
+
block_text = block.get("text") or ""
|
|
56
|
+
block_content = clean_html(block.get("content") or "")
|
|
57
|
+
parts.append(f"{block_text}\n{block_content}")
|
|
58
|
+
parts.append(item.get("additionalPlain") or "")
|
|
59
|
+
|
|
60
|
+
is_remote = str(item.get("workplaceType", "")).lower() == "remote"
|
|
61
|
+
|
|
62
|
+
created_raw = item.get("createdAt")
|
|
63
|
+
posted = datetime.fromtimestamp(created_raw / 1000, tz=UTC).strftime("%Y-%m-%d") if isinstance(created_raw, (int, float)) else parse_date(str(created_raw) if created_raw else None)
|
|
64
|
+
|
|
65
|
+
jobs.append(
|
|
66
|
+
Job(
|
|
67
|
+
source=self.name,
|
|
68
|
+
board=slug,
|
|
69
|
+
company=slug.replace("-", " ").title(),
|
|
70
|
+
job_id=job_id,
|
|
71
|
+
title=title,
|
|
72
|
+
url=job_url,
|
|
73
|
+
location=location or "",
|
|
74
|
+
remote=True if is_remote else None,
|
|
75
|
+
posted=posted,
|
|
76
|
+
description="\n\n".join(p for p in parts if p).strip(),
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.exception(f"[ {self.name} ] Error parsing job '{job_id}' for '{slug}': {e}")
|
|
82
|
+
console.print(f" [red]ā [ {self.name} ] Error parsing job '{job_id}'[/red]")
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
return jobs
|