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,124 @@
1
+ import logging
2
+ import re
3
+ import xml.etree.ElementTree as ET
4
+
5
+ import httpx
6
+
7
+ from jobhunter.core.http import fetch_url
8
+ from jobhunter.core.logger import console
9
+ from jobhunter.core.textutil import clean_html, parse_date
10
+ from jobhunter.models import Job
11
+ from jobhunter.sources.base import BaseSource
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class Source(BaseSource):
17
+ name = "personio"
18
+ domains = ["personio.de", "personio.com"]
19
+ url_patterns = [
20
+ re.compile(r"(?://|https?://)(?!www\.)(?P<slug>[a-zA-Z0-9-]+)\.(?:jobs\.)?personio\.(?:de|com|es|fr|it)"),
21
+ ]
22
+
23
+ def fetch(self, slug: str = "", skip_parsing: bool = False, **kwargs) -> list[Job]:
24
+ """Fetches job listings from a specific company's Personio XML feed."""
25
+ if not slug:
26
+ raise ValueError(f"A company slug must be provided to fetch {self.name} jobs.")
27
+
28
+ response = None
29
+ base_url = ""
30
+ headers = {"Accept": "application/xml,text/xml,*/*"}
31
+ last_error = None
32
+
33
+ for domain in ("de", "com"):
34
+ base_url = f"https://{slug}.jobs.personio.{domain}"
35
+ url = f"{base_url}/xml?language=en"
36
+
37
+ try:
38
+ response = fetch_url(url, headers=headers)
39
+ break
40
+ except httpx.HTTPStatusError as e:
41
+ last_error = e
42
+ if e.response.status_code == 404:
43
+ continue
44
+ raise
45
+ except httpx.RequestError as e:
46
+ last_error = e
47
+ continue
48
+
49
+ if not response:
50
+ if last_error:
51
+ raise last_error
52
+ return []
53
+
54
+ if skip_parsing:
55
+ return []
56
+
57
+ try:
58
+ root = ET.fromstring(response.text)
59
+ except ET.ParseError as exc:
60
+ logger.error(f"[ {self.name} ] Failed to parse XML for '{slug}': {exc}")
61
+ console.print(f" [red]✖ [ {self.name} ] Failed to parse XML for '{slug}'[/red]")
62
+ return []
63
+
64
+ jobs: list[Job] = []
65
+
66
+ for position in root.iter("position"):
67
+
68
+ def field(tag: str, pos=position) -> str:
69
+ return (pos.findtext(tag) or "").strip()
70
+
71
+ job_id = str(field("id") or "")
72
+ title = str(field("name") or "")
73
+ job_url = str(f"{base_url}/job/{job_id}" if job_id else "")
74
+
75
+ if not all([job_id, title, job_url]):
76
+ logger.warning(f"[ {self.name} ] Skipping item with missing required fields: {position.attrib}")
77
+ console.print(f" [yellow]⚠ [ {self.name} ] Skipping item with missing required fields.[/yellow]")
78
+ continue
79
+
80
+ try:
81
+ offices = [field("office")] + [(o.text or "").strip() for o in position.findall("additionalOffices/office")]
82
+ location = ", ".join(dict.fromkeys(o for o in offices if o))
83
+
84
+ sections = []
85
+ for block in position.findall("jobDescriptions/jobDescription"):
86
+ name = (block.findtext("name") or "").strip()
87
+ value = clean_html(block.findtext("value"))
88
+ if value:
89
+ sections.append(f"{name}\n{value}" if name else value)
90
+
91
+ facts = [
92
+ f"{label}: {field(tag)}"
93
+ for label, tag in (
94
+ ("Seniority", "seniority"),
95
+ ("Years of experience", "yearsOfExperience"),
96
+ ("Employment", "employmentType"),
97
+ ("Schedule", "schedule"),
98
+ )
99
+ if field(tag)
100
+ ]
101
+
102
+ description = ("\n".join(facts) + "\n\n" if facts else "") + "\n\n".join(sections)
103
+
104
+ jobs.append(
105
+ Job(
106
+ source=self.name,
107
+ board=slug,
108
+ company=field("subcompany") or slug.replace("-", " ").title(),
109
+ job_id=job_id,
110
+ title=title,
111
+ url=job_url,
112
+ location=location,
113
+ remote=None,
114
+ posted=parse_date(field("createdAt")),
115
+ description=description,
116
+ )
117
+ )
118
+
119
+ except Exception as e:
120
+ logger.exception(f"[ {self.name} ] Error parsing job '{job_id}' for '{slug}': {e}")
121
+ console.print(f" [red]✖ [ {self.name} ] Error parsing job '{job_id}'[/red]")
122
+ continue
123
+
124
+ return jobs
@@ -0,0 +1,80 @@
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 = "recruitee"
15
+ domains = ["recruitee.com"]
16
+ url_patterns = [
17
+ re.compile(r"(?://|https?://)?(?:www\.)?(?P<slug>[a-zA-Z0-9-]+)\.recruitee\.com"),
18
+ ]
19
+
20
+ def fetch(self, slug: str = "", skip_parsing: bool = False, **kwargs) -> list[Job]:
21
+ """Fetches job listings from a specific company's Recruitee 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://{slug}.recruitee.com/api/offers/"
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("offers", []):
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 item.get("guid") or item.get("slug") or "")
40
+ title = str(item.get("title") or "")
41
+ job_url = str(item.get("careers_url") or item.get("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 ", ".join(x for x in (item.get("city"), item.get("country")) if x)
50
+
51
+ description = "\n\n".join(
52
+ x
53
+ for x in (
54
+ clean_html(item.get("description")),
55
+ clean_html(item.get("requirements")),
56
+ )
57
+ if x
58
+ )
59
+
60
+ jobs.append(
61
+ Job(
62
+ source=self.name,
63
+ board=slug,
64
+ company=item.get("company_name") or slug.replace("-", " ").title(),
65
+ job_id=job_id,
66
+ title=title,
67
+ url=job_url,
68
+ location=location or "",
69
+ remote=True if item.get("remote") else None,
70
+ posted=parse_date(item.get("published_at") or item.get("created_at")),
71
+ description=description,
72
+ )
73
+ )
74
+
75
+ except Exception as e:
76
+ logger.exception(f"[ {self.name} ] Error parsing job '{job_id}' for '{slug}': {e}")
77
+ console.print(f" [red]✖ [ {self.name} ] Error parsing job '{job_id}'[/red]")
78
+ continue
79
+
80
+ return jobs
@@ -0,0 +1,69 @@
1
+ import logging
2
+
3
+ from jobhunter.core.http import fetch_url
4
+ from jobhunter.core.logger import console
5
+ from jobhunter.core.textutil import clean_html, parse_date
6
+ from jobhunter.models import Job
7
+ from jobhunter.sources.base import BaseSource
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class Source(BaseSource):
13
+ name = "remoteok"
14
+ domains = ["remoteok.com"]
15
+ is_aggregator = True
16
+
17
+ def fetch(self, slug: str = "", skip_parsing: bool = False, **kwargs) -> list[Job]:
18
+ """Fetches job listings from a specific company's RemoteOK API."""
19
+ url = "https://remoteok.com/api"
20
+ data = fetch_url(url, expected_type=list)
21
+ jobs: list[Job] = []
22
+
23
+ for item in data:
24
+ if not isinstance(item, dict):
25
+ logger.warning(f"[ {self.name} ] Skipping unexpected item format: {type(item).__name__}")
26
+ console.print(f" [yellow]⚠ [ {self.name} ] Skipping unexpected item format...[/yellow]")
27
+ continue
28
+
29
+ if "legal" in item:
30
+ continue
31
+
32
+ job_id = str(item.get("id") or item.get("slug") or "")
33
+ title = str(item.get("position") or "")
34
+ job_url = str(item.get("url") or item.get("apply_url") or "")
35
+
36
+ if not all([job_id, title, job_url]):
37
+ logger.warning(f"[ {self.name} ] Skipping item with missing required fields: {item}")
38
+ console.print(f" [yellow]⚠ [ {self.name} ] Skipping item with missing required fields.[/yellow]")
39
+ continue
40
+
41
+ try:
42
+ raw_tags = item.get("tags") or []
43
+ tags = ", ".join(str(t) for t in raw_tags if t)
44
+ description = item.get("description", "")
45
+
46
+ if tags:
47
+ description = f"Tags: {tags}\n\n{description}"
48
+
49
+ jobs.append(
50
+ Job(
51
+ source=self.name,
52
+ board=self.name,
53
+ company=item.get("company") or "Unknown",
54
+ job_id=job_id,
55
+ title=title,
56
+ url=job_url,
57
+ location=item.get("location") or "Remote",
58
+ remote=True,
59
+ posted=parse_date(item.get("date")),
60
+ description=clean_html(description),
61
+ )
62
+ )
63
+
64
+ except Exception as e:
65
+ logger.exception(f"[ {self.name} ] Error parsing job '{job_id}': {e}")
66
+ console.print(f" [red]✖ [ {self.name} ] Error parsing job '{job_id}'[/red]")
67
+ continue
68
+
69
+ return jobs
@@ -0,0 +1,61 @@
1
+ import logging
2
+
3
+ from jobhunter.core.http import fetch_url
4
+ from jobhunter.core.logger import console
5
+ from jobhunter.core.textutil import clean_html, parse_date
6
+ from jobhunter.models import Job
7
+ from jobhunter.sources.base import BaseSource
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class Source(BaseSource):
13
+ name = "remotive"
14
+ domains = ["remotive.com", "remotive.io"]
15
+ is_aggregator = True
16
+
17
+ def fetch(self, slug: str = "", skip_parsing: bool = False, **kwargs) -> list[Job]:
18
+ """Fetches job listings from Remotive API."""
19
+ category = self.board_config.get("category", "software-dev") if isinstance(self.board_config, dict) else "software-dev"
20
+
21
+ url = f"https://remotive.com/api/remote-jobs?category={category}"
22
+ data = fetch_url(url, expected_type=dict)
23
+ jobs: list[Job] = []
24
+
25
+ for item in data.get("jobs", []):
26
+ if not isinstance(item, dict):
27
+ logger.warning(f"[ {self.name} ] Skipping unexpected item format: {type(item).__name__}")
28
+ console.print(f" [yellow]⚠ [ {self.name} ] Skipping unexpected item format...[/yellow]")
29
+ continue
30
+
31
+ job_id = str(item.get("id") or "")
32
+ title = str(item.get("title") or "")
33
+ job_url = str(item.get("url") or "")
34
+
35
+ if not all([job_id, title, job_url]):
36
+ logger.warning(f"[ {self.name} ] Skipping item with missing required fields: {item}")
37
+ console.print(f" [yellow]⚠ [ {self.name} ] Skipping item with missing required fields.[/yellow]")
38
+ continue
39
+
40
+ try:
41
+ jobs.append(
42
+ Job(
43
+ source=self.name,
44
+ board=self.name,
45
+ company=item.get("company_name") or "Unknown",
46
+ job_id=job_id,
47
+ title=title,
48
+ url=job_url,
49
+ location=item.get("candidate_required_location") or "Remote",
50
+ remote=True,
51
+ posted=parse_date(item.get("publication_date")),
52
+ description=clean_html(item.get("description", "")),
53
+ )
54
+ )
55
+
56
+ except Exception as e:
57
+ logger.exception(f"[ {self.name} ] Error parsing job '{job_id}': {e}")
58
+ console.print(f" [red]✖ [ {self.name} ] Error parsing job '{job_id}'[/red]")
59
+ continue
60
+
61
+ return jobs
@@ -0,0 +1,94 @@
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 = "workable"
15
+ domains = ["apply.workable.com"]
16
+ url_patterns = [
17
+ re.compile(r"apply\.workable\.com/(?P<slug>[^/?#]+)"),
18
+ ]
19
+
20
+ @staticmethod
21
+ def _parse_location(item: dict, location_obj: dict) -> str:
22
+ """Extracts and formats location data from Workable's messy location fields."""
23
+ parts = [
24
+ item.get("city") or location_obj.get("city"),
25
+ item.get("state") or location_obj.get("region"),
26
+ item.get("country") or location_obj.get("country"),
27
+ ]
28
+ text = ", ".join(p for p in parts if p)
29
+
30
+ if text:
31
+ return text
32
+
33
+ names = []
34
+
35
+ for entry in item.get("locations") or []:
36
+ if isinstance(entry, dict):
37
+ names.append(", ".join(x for x in (entry.get("city"), entry.get("region"), entry.get("country")) if x))
38
+
39
+ return " / ".join(n for n in names if n)
40
+
41
+ def fetch(self, slug: str = "", skip_parsing: bool = False, **kwargs) -> list[Job]:
42
+ """Fetches job listings from a specific company's Workable API."""
43
+ if not slug:
44
+ raise ValueError(f"A company slug must be provided to fetch {self.name} jobs.")
45
+
46
+ url = f"https://apply.workable.com/api/v1/widget/accounts/{slug}?details=true"
47
+ data = fetch_url(url, expected_type=dict)
48
+
49
+ if skip_parsing:
50
+ return []
51
+
52
+ jobs: list[Job] = []
53
+
54
+ for item in data.get("jobs", []):
55
+ if not isinstance(item, dict):
56
+ logger.warning(f"[ {self.name} ] Skipping unexpected item format: {type(item).__name__}")
57
+ console.print(f" [yellow]⚠ [ {self.name} ] Skipping unexpected item format...[/yellow]")
58
+ continue
59
+
60
+ job_id = str(item.get("shortcode") or item.get("id") or item.get("code") or "")
61
+ title = str(item.get("title") or "")
62
+ job_url = str(item.get("url") or item.get("shortlink") or item.get("application_url") or "")
63
+
64
+ if not all([job_id, title, job_url]):
65
+ logger.warning(f"[ {self.name} ] Skipping item with missing required fields: {item}")
66
+ console.print(f" [yellow]⚠ [ {self.name} ] Skipping item with missing required fields.[/yellow]")
67
+ continue
68
+
69
+ try:
70
+ loc_val = item.get("location")
71
+ location_obj = loc_val if isinstance(loc_val, dict) else {}
72
+ is_remote = bool(item.get("telecommuting")) or bool(location_obj.get("telecommuting"))
73
+
74
+ jobs.append(
75
+ Job(
76
+ source=self.name,
77
+ board=slug,
78
+ company=data.get("name") or slug.replace("-", " ").title(),
79
+ job_id=job_id,
80
+ title=title,
81
+ url=job_url,
82
+ location=self._parse_location(item, location_obj),
83
+ remote=True if is_remote else None,
84
+ posted=parse_date(item.get("published_on") or item.get("created_at")),
85
+ description=clean_html(item.get("description") or item.get("full_description") or ""),
86
+ )
87
+ )
88
+
89
+ except Exception as e:
90
+ logger.exception(f"[ {self.name} ] Error parsing job '{job_id}' for '{slug}': {e}")
91
+ console.print(f" [red]✖ [ {self.name} ] Error parsing job '{job_id}'[/red]")
92
+ continue
93
+
94
+ return jobs
File without changes