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.
@@ -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