chameleon-cv 0.1.0
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.
- package/AGENTS.md +186 -0
- package/LICENSE +21 -0
- package/Makefile +136 -0
- package/README.md +356 -0
- package/bin/chameleon.js +136 -0
- package/chameleon.bat +286 -0
- package/chameleon.sh +353 -0
- package/package.json +39 -0
- package/profiles/github_repos.json +1266 -0
- package/requirements.txt +5 -0
- package/scan.sh +12 -0
- package/score.sh +12 -0
- package/scripts/__init__.py +1 -0
- package/scripts/__main_tui__.py +11 -0
- package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/__pycache__/__main_tui__.cpython-313.pyc +0 -0
- package/scripts/__pycache__/ats_ghost.cpython-313.pyc +0 -0
- package/scripts/__pycache__/cache.cpython-313.pyc +0 -0
- package/scripts/__pycache__/config.cpython-312.pyc +0 -0
- package/scripts/__pycache__/config.cpython-313.pyc +0 -0
- package/scripts/__pycache__/cover_letter.cpython-313.pyc +0 -0
- package/scripts/__pycache__/db.cpython-312.pyc +0 -0
- package/scripts/__pycache__/db.cpython-313.pyc +0 -0
- package/scripts/__pycache__/fonts.cpython-312.pyc +0 -0
- package/scripts/__pycache__/fonts.cpython-313.pyc +0 -0
- package/scripts/__pycache__/ghost.cpython-313.pyc +0 -0
- package/scripts/__pycache__/job_matcher.cpython-312.pyc +0 -0
- package/scripts/__pycache__/job_matcher.cpython-313.pyc +0 -0
- package/scripts/__pycache__/question.cpython-313.pyc +0 -0
- package/scripts/__pycache__/render.cpython-313.pyc +0 -0
- package/scripts/__pycache__/review.cpython-313.pyc +0 -0
- package/scripts/__pycache__/setup.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_brief.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_brief.cpython-313.pyc +0 -0
- package/scripts/__pycache__/tailor_cv.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_cv.cpython-313.pyc +0 -0
- package/scripts/__pycache__/tailor_prompts.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_prompts.cpython-313.pyc +0 -0
- package/scripts/__pycache__/tui_app.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tui_app.cpython-313.pyc +0 -0
- package/scripts/ats_ghost.py +170 -0
- package/scripts/cache.py +63 -0
- package/scripts/cli.py +109 -0
- package/scripts/config.py +376 -0
- package/scripts/cover_letter.py +105 -0
- package/scripts/db.py +331 -0
- package/scripts/fonts.py +186 -0
- package/scripts/ghost.py +88 -0
- package/scripts/job_matcher.py +636 -0
- package/scripts/job_scanner/__init__.py +8 -0
- package/scripts/job_scanner/__main__.py +6 -0
- package/scripts/job_scanner/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/__main__.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/__main__.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/base.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/base.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/browser.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/browser.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/cli.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/registry.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/registry.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/scanner.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/scanner.cpython-313.pyc +0 -0
- package/scripts/job_scanner/base.py +197 -0
- package/scripts/job_scanner/browser.py +244 -0
- package/scripts/job_scanner/cli.py +6 -0
- package/scripts/job_scanner/parsers/__init__.py +24 -0
- package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/arc.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/arc.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/flexjobs.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/indeed.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/linkedin.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/opentoworkremote.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remote_rocketship.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/web3_career.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/wellfound.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/ycomb.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/adzuna.py +81 -0
- package/scripts/job_scanner/parsers/arc.py +65 -0
- package/scripts/job_scanner/parsers/crypto_careers.py +88 -0
- package/scripts/job_scanner/parsers/dailyremote.py +91 -0
- package/scripts/job_scanner/parsers/flexjobs.py +91 -0
- package/scripts/job_scanner/parsers/himalayas.py +47 -0
- package/scripts/job_scanner/parsers/hn_hiring.py +94 -0
- package/scripts/job_scanner/parsers/indeed.py +98 -0
- package/scripts/job_scanner/parsers/jobicy.py +60 -0
- package/scripts/job_scanner/parsers/jooble.py +68 -0
- package/scripts/job_scanner/parsers/linkedin.py +140 -0
- package/scripts/job_scanner/parsers/opentoworkremote.py +53 -0
- package/scripts/job_scanner/parsers/relocate.py +95 -0
- package/scripts/job_scanner/parsers/remote_rocketship.py +90 -0
- package/scripts/job_scanner/parsers/remoteok.py +63 -0
- package/scripts/job_scanner/parsers/remotive.py +57 -0
- package/scripts/job_scanner/parsers/web3_career.py +92 -0
- package/scripts/job_scanner/parsers/wellfound.py +94 -0
- package/scripts/job_scanner/parsers/weworkremotely.py +60 -0
- package/scripts/job_scanner/parsers/ycomb.py +83 -0
- package/scripts/job_scanner/registry.py +65 -0
- package/scripts/job_scanner/scanner.py +162 -0
- package/scripts/question.py +107 -0
- package/scripts/render.py +110 -0
- package/scripts/review.py +89 -0
- package/scripts/setup.py +139 -0
- package/scripts/tailor_brief.py +379 -0
- package/scripts/tailor_cv.py +746 -0
- package/scripts/tailor_prompts.py +101 -0
- package/scripts/tui/__init__.py +1 -0
- package/scripts/tui/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/app.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/app.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/helpers.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/helpers.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/screens.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/screens.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/widgets.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/widgets.cpython-313.pyc +0 -0
- package/scripts/tui/app.py +1547 -0
- package/scripts/tui/helpers.py +65 -0
- package/scripts/tui/screens.py +642 -0
- package/scripts/tui/widgets.py +276 -0
- package/scripts/tui_app.py +25 -0
- package/scripts/wizard.py +109 -0
- package/tui.bat +50 -0
- package/tui.sh +59 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Jooble — job aggregator with structured API. Tier 1 (free tier key).
|
|
2
|
+
|
|
3
|
+
Requires JOOBLE_API_KEY env var. Falls back gracefully.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import urllib.request
|
|
10
|
+
import urllib.error
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ..base import BaseParser, JobListing
|
|
14
|
+
from ..registry import register_parser
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@register_parser
|
|
18
|
+
class JoobleParser(BaseParser):
|
|
19
|
+
platform_name = "jooble"
|
|
20
|
+
|
|
21
|
+
def fetch_jobs(self, query: str = "", **kwargs: Any) -> list[JobListing]:
|
|
22
|
+
api_key = os.environ.get("JOOBLE_API_KEY")
|
|
23
|
+
if not api_key:
|
|
24
|
+
return []
|
|
25
|
+
|
|
26
|
+
url = f"https://jooble.org/api/{api_key}"
|
|
27
|
+
payload = {"keywords": query or "software engineer", "page": 1}
|
|
28
|
+
if kwargs.get("location"):
|
|
29
|
+
payload["location"] = kwargs["location"]
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
req = urllib.request.Request(
|
|
33
|
+
url,
|
|
34
|
+
data=json.dumps(payload).encode(),
|
|
35
|
+
headers={
|
|
36
|
+
"Content-Type": "application/json",
|
|
37
|
+
"User-Agent": "Chameleon/1.0",
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
41
|
+
data = json.loads(resp.read().decode())
|
|
42
|
+
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, OSError):
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
jobs: list[JobListing] = []
|
|
46
|
+
for j in data.get("jobs", [])[:kwargs.get("limit", 25)]:
|
|
47
|
+
title = (j.get("title") or "").strip()
|
|
48
|
+
company = (j.get("company") or "Unknown").strip()
|
|
49
|
+
url = j.get("link") or j.get("url") or ""
|
|
50
|
+
desc = (j.get("snippet") or j.get("description") or "")[:2000]
|
|
51
|
+
location = j.get("location") or ""
|
|
52
|
+
salary = j.get("salary") or ""
|
|
53
|
+
remote = "remote" in str(location).lower() or "anywhere" in str(desc).lower()
|
|
54
|
+
|
|
55
|
+
job_id = j.get("id") or f"jooble-{hash(title + company)}"
|
|
56
|
+
jobs.append(JobListing(
|
|
57
|
+
job_id=f"job-{job_id}",
|
|
58
|
+
title=title,
|
|
59
|
+
company=company,
|
|
60
|
+
url=url,
|
|
61
|
+
location=location,
|
|
62
|
+
description=desc,
|
|
63
|
+
salary=str(salary),
|
|
64
|
+
source=self.platform_name,
|
|
65
|
+
remote=remote,
|
|
66
|
+
tags=[],
|
|
67
|
+
))
|
|
68
|
+
return jobs
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""LinkedIn Jobs — guest API + browser fallback for anti-bot."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from ..base import BaseParser, JobListing
|
|
7
|
+
from ..registry import register_parser
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@register_parser
|
|
11
|
+
class LinkedInParser(BaseParser):
|
|
12
|
+
platform_name = "linkedin"
|
|
13
|
+
GUEST_API = "https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search"
|
|
14
|
+
BROWSE_URL = "https://www.linkedin.com/jobs/search"
|
|
15
|
+
|
|
16
|
+
def fetch_jobs(
|
|
17
|
+
self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
|
|
18
|
+
) -> list[JobListing]:
|
|
19
|
+
params = {
|
|
20
|
+
"keywords": query,
|
|
21
|
+
"start": kwargs.get("start", 0),
|
|
22
|
+
"f_TPR": "r604800",
|
|
23
|
+
}
|
|
24
|
+
location = kwargs.get("location", "")
|
|
25
|
+
if location:
|
|
26
|
+
params["location"] = location
|
|
27
|
+
|
|
28
|
+
jobs = []
|
|
29
|
+
|
|
30
|
+
# Attempt 1: Guest API (HTTP)
|
|
31
|
+
resp = self._http_get(
|
|
32
|
+
self.GUEST_API,
|
|
33
|
+
params=params,
|
|
34
|
+
headers={"Accept": "text/html,application/xhtml+xml"},
|
|
35
|
+
)
|
|
36
|
+
if resp and len(resp.text) > 500:
|
|
37
|
+
self._parse_guest_api(resp.text, jobs, query, limit)
|
|
38
|
+
|
|
39
|
+
# Attempt 2: Browser automation fallback
|
|
40
|
+
if not jobs and self._browser_available():
|
|
41
|
+
browse_params = {
|
|
42
|
+
"keywords": query or "software engineer",
|
|
43
|
+
"f_TPR": "r604800",
|
|
44
|
+
}
|
|
45
|
+
qs = "&".join(f"{k}={v}" for k, v in browse_params.items())
|
|
46
|
+
browser_resp = self._browser_get(
|
|
47
|
+
f"{self.BROWSE_URL}?{qs}",
|
|
48
|
+
timeout=20,
|
|
49
|
+
wait_selector=".job-card-container",
|
|
50
|
+
scroll=True,
|
|
51
|
+
)
|
|
52
|
+
if browser_resp and not self._is_login_wall(browser_resp.text):
|
|
53
|
+
self._parse_browser_html(browser_resp.text, jobs, query, limit)
|
|
54
|
+
|
|
55
|
+
return jobs
|
|
56
|
+
|
|
57
|
+
def _parse_guest_api(self, html: str, jobs: list, query: str, limit: int) -> None:
|
|
58
|
+
card_pattern = re.compile(
|
|
59
|
+
r'<a[^>]*href="(https://www\.linkedin\.com/jobs/view/[^"]+)"[^>]*>.*?'
|
|
60
|
+
r'<h3[^>]*>([^<]+)</h3>.*?'
|
|
61
|
+
r'<h4[^>]*>([^<]+)</h4>',
|
|
62
|
+
re.DOTALL,
|
|
63
|
+
)
|
|
64
|
+
for match in card_pattern.finditer(html):
|
|
65
|
+
url, title, company = match.groups()
|
|
66
|
+
if query and query.lower() not in f"{title} {company}".lower():
|
|
67
|
+
continue
|
|
68
|
+
jobs.append(
|
|
69
|
+
JobListing(
|
|
70
|
+
title=self._clean_html(title).strip(),
|
|
71
|
+
company=self._clean_html(company).strip(),
|
|
72
|
+
url=url.split("?")[0],
|
|
73
|
+
location="",
|
|
74
|
+
remote=True,
|
|
75
|
+
source=self.platform_name,
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
if len(jobs) >= limit:
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
def _parse_browser_html(self, html: str, jobs: list, query: str, limit: int) -> None:
|
|
82
|
+
# Try JSON-LD first
|
|
83
|
+
ld = self._extract_json_ld(html)
|
|
84
|
+
seen = set()
|
|
85
|
+
for entry in ld:
|
|
86
|
+
if entry.get("@type") != "JobPosting":
|
|
87
|
+
continue
|
|
88
|
+
title = (entry.get("title", "") or "").strip()
|
|
89
|
+
org = entry.get("hiringOrganization", {})
|
|
90
|
+
company_name = (org.get("name", "") if isinstance(org, dict) else str(org)).strip()
|
|
91
|
+
url = entry.get("url", "") or entry.get("directApply", "")
|
|
92
|
+
if isinstance(url, dict):
|
|
93
|
+
url = url.get("url", "")
|
|
94
|
+
if not title or not company_name:
|
|
95
|
+
continue
|
|
96
|
+
key = f"{title}|{company_name}"
|
|
97
|
+
if key in seen:
|
|
98
|
+
continue
|
|
99
|
+
seen.add(key)
|
|
100
|
+
if query and query.lower() not in f"{title} {company_name}".lower():
|
|
101
|
+
continue
|
|
102
|
+
jobs.append(
|
|
103
|
+
JobListing(
|
|
104
|
+
title=title,
|
|
105
|
+
company=company_name,
|
|
106
|
+
url=url,
|
|
107
|
+
location="Remote",
|
|
108
|
+
remote=True,
|
|
109
|
+
description=self._truncate(self._clean_html(entry.get("description", "")), 500),
|
|
110
|
+
source=self.platform_name,
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
if len(jobs) >= limit:
|
|
114
|
+
break
|
|
115
|
+
|
|
116
|
+
# Fallback: HTML card pattern
|
|
117
|
+
if not jobs:
|
|
118
|
+
card = re.compile(
|
|
119
|
+
r'class="[^"]*job-card-container[^"]*"[^>]*>.*?'
|
|
120
|
+
r'<a[^>]*href="(/jobs/view/[^"]+)"[^>]*>.*?'
|
|
121
|
+
r'<span[^>]*>([^<]+)</span>.*?'
|
|
122
|
+
r'<span[^>]*>([^<]+)</span>',
|
|
123
|
+
re.DOTALL,
|
|
124
|
+
)
|
|
125
|
+
for match in card.finditer(html):
|
|
126
|
+
path, title, company = match.groups()
|
|
127
|
+
if query and query.lower() not in f"{title} {company}".lower():
|
|
128
|
+
continue
|
|
129
|
+
jobs.append(
|
|
130
|
+
JobListing(
|
|
131
|
+
title=self._clean_html(title).strip(),
|
|
132
|
+
company=self._clean_html(company).strip(),
|
|
133
|
+
url=f"https://www.linkedin.com{path}",
|
|
134
|
+
location="",
|
|
135
|
+
remote=True,
|
|
136
|
+
source=self.platform_name,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
if len(jobs) >= limit:
|
|
140
|
+
break
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""OpenToWorkRemote — RapidAPI aggregator (requires API key)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ..base import BaseParser, JobListing
|
|
8
|
+
from ..registry import register_parser
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@register_parser
|
|
12
|
+
class OpenToWorkRemoteParser(BaseParser):
|
|
13
|
+
platform_name = "opentoworkremote"
|
|
14
|
+
API_URL = "https://remote-jobs1.p.rapidapi.com/search"
|
|
15
|
+
|
|
16
|
+
def fetch_jobs(
|
|
17
|
+
self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
|
|
18
|
+
) -> list[JobListing]:
|
|
19
|
+
import os
|
|
20
|
+
api_key = kwargs.get("rapidapi_key") or os.environ.get("RAPIDAPI_KEY", "")
|
|
21
|
+
if not api_key:
|
|
22
|
+
return []
|
|
23
|
+
|
|
24
|
+
params = {"title": query, "limit": limit}
|
|
25
|
+
try:
|
|
26
|
+
resp = httpx.get(
|
|
27
|
+
self.API_URL,
|
|
28
|
+
params=params,
|
|
29
|
+
timeout=15,
|
|
30
|
+
headers={
|
|
31
|
+
"X-RapidAPI-Key": api_key,
|
|
32
|
+
"X-RapidAPI-Host": "remote-jobs1.p.rapidapi.com",
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
resp.raise_for_status()
|
|
36
|
+
data = resp.json()
|
|
37
|
+
except Exception:
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
jobs = []
|
|
41
|
+
for item in data.get("jobs", data if isinstance(data, list) else [])[:limit]:
|
|
42
|
+
jobs.append(
|
|
43
|
+
JobListing(
|
|
44
|
+
title=item.get("title", ""),
|
|
45
|
+
company=item.get("company_name", item.get("company", "")),
|
|
46
|
+
url=item.get("url", item.get("job_url", "")),
|
|
47
|
+
location=item.get("location", "Remote"),
|
|
48
|
+
remote=True,
|
|
49
|
+
tags=item.get("tags", []),
|
|
50
|
+
source=self.platform_name,
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
return jobs
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Relocate.me — scraping-based parser for relocation-friendly jobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from ..base import BaseParser, JobListing
|
|
9
|
+
from ..registry import register_parser
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@register_parser
|
|
13
|
+
class RelocateParser(BaseParser):
|
|
14
|
+
platform_name = "relocate"
|
|
15
|
+
SEARCH_URL = "https://relocate.me/search"
|
|
16
|
+
|
|
17
|
+
def fetch_jobs(
|
|
18
|
+
self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
|
|
19
|
+
) -> list[JobListing]:
|
|
20
|
+
params = {}
|
|
21
|
+
if query:
|
|
22
|
+
params["query"] = query
|
|
23
|
+
|
|
24
|
+
resp = self._http_get(self.SEARCH_URL, params=params, timeout=15)
|
|
25
|
+
if resp is None:
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
html = resp.text
|
|
29
|
+
jobs = []
|
|
30
|
+
|
|
31
|
+
# Try JSON-LD first
|
|
32
|
+
ld = self._extract_json_ld(html)
|
|
33
|
+
seen = set()
|
|
34
|
+
for entry in ld:
|
|
35
|
+
if entry.get("@type") != "JobPosting":
|
|
36
|
+
continue
|
|
37
|
+
title = (entry.get("title", "") or "").strip()
|
|
38
|
+
org = entry.get("hiringOrganization", {})
|
|
39
|
+
company_name = (org.get("name", "") if isinstance(org, dict) else str(org)).strip()
|
|
40
|
+
url = entry.get("url", "")
|
|
41
|
+
if not title or not company_name:
|
|
42
|
+
continue
|
|
43
|
+
key = f"{title}|{company_name}"
|
|
44
|
+
if key in seen:
|
|
45
|
+
continue
|
|
46
|
+
seen.add(key)
|
|
47
|
+
if query and query.lower() not in f"{title} {company_name}".lower():
|
|
48
|
+
continue
|
|
49
|
+
location = entry.get("jobLocation", "")
|
|
50
|
+
if isinstance(location, dict):
|
|
51
|
+
location = location.get("address", {}).get("addressLocality", location.get("name", "Remote"))
|
|
52
|
+
if isinstance(location, dict):
|
|
53
|
+
location = "Relocation required"
|
|
54
|
+
jobs.append(
|
|
55
|
+
JobListing(
|
|
56
|
+
title=title,
|
|
57
|
+
company=company_name,
|
|
58
|
+
url=url or f"https://relocate.me/job/{title.lower().replace(' ', '-')}",
|
|
59
|
+
location=str(location),
|
|
60
|
+
remote=False,
|
|
61
|
+
tags=["relocation"],
|
|
62
|
+
description=self._truncate(self._clean_html(entry.get("description", "")), 500),
|
|
63
|
+
source=self.platform_name,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
if len(jobs) >= limit:
|
|
67
|
+
break
|
|
68
|
+
|
|
69
|
+
# Fallback: HTML card parsing
|
|
70
|
+
if not jobs:
|
|
71
|
+
card_pattern = re.compile(
|
|
72
|
+
r'<a[^>]*href="(/job[^"]+)"[^>]*>.*?'
|
|
73
|
+
r'<(?:h[234]|span)[^>]*>([^<]+)</.*?'
|
|
74
|
+
r'(?:<(?:span|div)[^>]*class="[^"]*company[^"]*"[^>]*>([^<]+)<|([^<]{3,50})</)',
|
|
75
|
+
re.DOTALL,
|
|
76
|
+
)
|
|
77
|
+
for match in card_pattern.finditer(html):
|
|
78
|
+
path, title, company = match.group(1), match.group(2), match.group(3) or match.group(4) or ""
|
|
79
|
+
if query and query.lower() not in f"{title} {company}".lower():
|
|
80
|
+
continue
|
|
81
|
+
jobs.append(
|
|
82
|
+
JobListing(
|
|
83
|
+
title=title.strip(),
|
|
84
|
+
company=company.strip() if company else "Unknown",
|
|
85
|
+
url=f"https://relocate.me{path}",
|
|
86
|
+
location="Relocation required",
|
|
87
|
+
remote=False,
|
|
88
|
+
tags=["relocation"],
|
|
89
|
+
source=self.platform_name,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
if len(jobs) >= limit:
|
|
93
|
+
break
|
|
94
|
+
|
|
95
|
+
return jobs
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Remote Rocketship — parser with HTTP + Playwright browser fallback."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from ..base import BaseParser, JobListing
|
|
7
|
+
from ..registry import register_parser
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@register_parser
|
|
11
|
+
class RemoteRocketshipParser(BaseParser):
|
|
12
|
+
platform_name = "remote_rocketship"
|
|
13
|
+
BASE_URL = "https://remoterocketship.com"
|
|
14
|
+
|
|
15
|
+
def fetch_jobs(
|
|
16
|
+
self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
|
|
17
|
+
) -> list[JobListing]:
|
|
18
|
+
params = {}
|
|
19
|
+
if query:
|
|
20
|
+
params["q"] = query
|
|
21
|
+
|
|
22
|
+
jobs = []
|
|
23
|
+
|
|
24
|
+
# Attempt 1: HTTP (usually Cloudflare-blocked)
|
|
25
|
+
resp = self._http_get(self.BASE_URL, params=params)
|
|
26
|
+
if resp and "cloudflare" not in resp.text[:500].lower() and len(resp.text) > 2000:
|
|
27
|
+
self._parse_html(resp.text, jobs, query, limit)
|
|
28
|
+
|
|
29
|
+
# Attempt 2: Browser fallback
|
|
30
|
+
if not jobs and self._browser_available():
|
|
31
|
+
qs = "&".join(f"{k}={v}" for k, v in params.items()) if params else ""
|
|
32
|
+
url = f"{self.BASE_URL}?{qs}" if qs else self.BASE_URL
|
|
33
|
+
browser_resp = self._browser_get(
|
|
34
|
+
url, timeout=25,
|
|
35
|
+
wait_selector="[class*='job'], [class*='card'], article, .position",
|
|
36
|
+
scroll=True,
|
|
37
|
+
)
|
|
38
|
+
if browser_resp and not self._is_login_wall(browser_resp.text):
|
|
39
|
+
self._parse_html(browser_resp.text, jobs, query, limit)
|
|
40
|
+
|
|
41
|
+
return jobs
|
|
42
|
+
|
|
43
|
+
def _parse_html(self, html: str, jobs: list, query: str, limit: int) -> None:
|
|
44
|
+
# Try JSON-LD
|
|
45
|
+
ld = self._extract_json_ld(html)
|
|
46
|
+
seen = set()
|
|
47
|
+
for entry in ld:
|
|
48
|
+
if entry.get("@type") != "JobPosting":
|
|
49
|
+
continue
|
|
50
|
+
title = (entry.get("title", "") or "").strip()
|
|
51
|
+
org = entry.get("hiringOrganization", {})
|
|
52
|
+
company_name = (org.get("name", "") if isinstance(org, dict) else str(org)).strip()
|
|
53
|
+
url = entry.get("url", "")
|
|
54
|
+
if not title or not company_name:
|
|
55
|
+
continue
|
|
56
|
+
key = f"{title}|{company_name}"
|
|
57
|
+
if key in seen:
|
|
58
|
+
continue
|
|
59
|
+
seen.add(key)
|
|
60
|
+
if query and query.lower() not in f"{title} {company_name}".lower():
|
|
61
|
+
continue
|
|
62
|
+
jobs.append(
|
|
63
|
+
JobListing(
|
|
64
|
+
title=title, company=company_name, url=url,
|
|
65
|
+
location="Remote", remote=True, source=self.platform_name,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
if len(jobs) >= limit:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
# HTML card pattern
|
|
72
|
+
card = re.compile(
|
|
73
|
+
r'<a[^>]*href="(/job/[^"]+)"[^>]*>.*?'
|
|
74
|
+
r'<(?:h[234]|span)[^>]*>([^<]+)</.*?'
|
|
75
|
+
r'<(?:span|div)[^>]*class="[^"]*(?:company|employer)[^"]*"[^>]*>([^<]+)<',
|
|
76
|
+
re.DOTALL,
|
|
77
|
+
)
|
|
78
|
+
for match in card.finditer(html):
|
|
79
|
+
path, title, company = match.groups()
|
|
80
|
+
if query and query.lower() not in f"{title} {company}".lower():
|
|
81
|
+
continue
|
|
82
|
+
jobs.append(
|
|
83
|
+
JobListing(
|
|
84
|
+
title=title.strip(), company=company.strip(),
|
|
85
|
+
url=f"{self.BASE_URL}{path}",
|
|
86
|
+
location="Remote", remote=True, source=self.platform_name,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
if len(jobs) >= limit:
|
|
90
|
+
return
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Remote OK — free JSON API + RSS, no auth required."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..base import BaseParser, JobListing
|
|
6
|
+
from ..registry import register_parser
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@register_parser
|
|
10
|
+
class RemoteOKParser(BaseParser):
|
|
11
|
+
platform_name = "remoteok"
|
|
12
|
+
API_URL = "https://remoteok.com/api"
|
|
13
|
+
|
|
14
|
+
def fetch_jobs(
|
|
15
|
+
self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
|
|
16
|
+
) -> list[JobListing]:
|
|
17
|
+
url = self.API_URL
|
|
18
|
+
if tags:
|
|
19
|
+
url += "?tag=" + tags[0]
|
|
20
|
+
|
|
21
|
+
resp = self._http_get(url)
|
|
22
|
+
if resp is None:
|
|
23
|
+
return []
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
items = resp.json()
|
|
27
|
+
except Exception:
|
|
28
|
+
return []
|
|
29
|
+
|
|
30
|
+
# First item is metadata
|
|
31
|
+
if items and isinstance(items[0], dict) and "legal" in str(items[0]):
|
|
32
|
+
items = items[1:]
|
|
33
|
+
|
|
34
|
+
q = query.lower() if query else ""
|
|
35
|
+
jobs = []
|
|
36
|
+
for item in items:
|
|
37
|
+
if not isinstance(item, dict):
|
|
38
|
+
continue
|
|
39
|
+
title = item.get("position", "")
|
|
40
|
+
company = item.get("company", "")
|
|
41
|
+
job_tags = item.get("tags", [])
|
|
42
|
+
desc = item.get("description", "")
|
|
43
|
+
searchable = f"{title} {company} {' '.join(job_tags)} {desc}".lower()
|
|
44
|
+
if q and q not in searchable:
|
|
45
|
+
continue
|
|
46
|
+
jobs.append(
|
|
47
|
+
JobListing(
|
|
48
|
+
title=title,
|
|
49
|
+
company=company,
|
|
50
|
+
url=item.get("url", f"https://remoteok.com/remote-jobs/{item.get('id', '')}"),
|
|
51
|
+
location=item.get("location", "Remote"),
|
|
52
|
+
remote=True,
|
|
53
|
+
tags=item.get("tags", []),
|
|
54
|
+
salary=item.get("salary", ""),
|
|
55
|
+
posted_at=item.get("date", ""),
|
|
56
|
+
description=self._truncate(item.get("description", "")),
|
|
57
|
+
source=self.platform_name,
|
|
58
|
+
job_id=str(item.get("id", "")),
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
if len(jobs) >= limit:
|
|
62
|
+
break
|
|
63
|
+
return jobs
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Remotive — free JSON API + RSS, no auth required."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..base import BaseParser, JobListing
|
|
6
|
+
from ..registry import register_parser
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@register_parser
|
|
10
|
+
class RemotiveParser(BaseParser):
|
|
11
|
+
platform_name = "remotive"
|
|
12
|
+
API_URL = "https://remotive.com/api/remote-jobs"
|
|
13
|
+
|
|
14
|
+
def fetch_jobs(
|
|
15
|
+
self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
|
|
16
|
+
) -> list[JobListing]:
|
|
17
|
+
params: dict = {"limit": min(limit, 100)}
|
|
18
|
+
if query:
|
|
19
|
+
params["search"] = query
|
|
20
|
+
category = kwargs.get("category", "")
|
|
21
|
+
if category:
|
|
22
|
+
params["category"] = category
|
|
23
|
+
|
|
24
|
+
resp = self._http_get(self.API_URL, params=params)
|
|
25
|
+
if resp is None:
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
data = resp.json()
|
|
30
|
+
except Exception:
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
jobs = []
|
|
34
|
+
q = query.lower() if query else ""
|
|
35
|
+
for item in data.get("jobs", []):
|
|
36
|
+
if q:
|
|
37
|
+
searchable = f"{item.get('title', '')} {item.get('company_name', '')} {' '.join(item.get('tags', []))}".lower()
|
|
38
|
+
if q not in searchable:
|
|
39
|
+
continue
|
|
40
|
+
jobs.append(
|
|
41
|
+
JobListing(
|
|
42
|
+
title=item.get("title", ""),
|
|
43
|
+
company=item.get("company_name", ""),
|
|
44
|
+
url=item.get("url", ""),
|
|
45
|
+
location=item.get("candidate_required_location", ""),
|
|
46
|
+
remote=item.get("remote", True),
|
|
47
|
+
tags=item.get("tags", []),
|
|
48
|
+
salary=item.get("salary", ""),
|
|
49
|
+
posted_at=item.get("publication_date", ""),
|
|
50
|
+
description=self._truncate(self._clean_html(item.get("description", ""))),
|
|
51
|
+
source=self.platform_name,
|
|
52
|
+
job_id=str(item.get("id", "")),
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
if len(jobs) >= limit:
|
|
56
|
+
break
|
|
57
|
+
return jobs
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Web3 Career — parser for web3/blockchain jobs via JSON-LD."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ..base import BaseParser, JobListing
|
|
5
|
+
from ..registry import register_parser
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@register_parser
|
|
9
|
+
class Web3CareerParser(BaseParser):
|
|
10
|
+
platform_name = "web3_career"
|
|
11
|
+
BASE_URL = "https://web3.career"
|
|
12
|
+
|
|
13
|
+
def fetch_jobs(
|
|
14
|
+
self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
|
|
15
|
+
) -> list[JobListing]:
|
|
16
|
+
url = f"{self.BASE_URL}/web3-jobs"
|
|
17
|
+
if query:
|
|
18
|
+
slug = query.lower().replace(" ", "+")
|
|
19
|
+
url = f"{self.BASE_URL}/{slug}+crypto-jobs"
|
|
20
|
+
|
|
21
|
+
resp = self._http_get(url)
|
|
22
|
+
if resp is None:
|
|
23
|
+
return []
|
|
24
|
+
|
|
25
|
+
ld = self._extract_json_ld(resp.text)
|
|
26
|
+
jobs = []
|
|
27
|
+
seen = set()
|
|
28
|
+
|
|
29
|
+
for entry in ld:
|
|
30
|
+
if entry.get("@type") != "JobPosting":
|
|
31
|
+
continue
|
|
32
|
+
title = (entry.get("title", "") or "").strip()
|
|
33
|
+
company = entry.get("hiringOrganization", {})
|
|
34
|
+
if isinstance(company, dict):
|
|
35
|
+
company_name = (company.get("name", "") or "").strip()
|
|
36
|
+
else:
|
|
37
|
+
company_name = str(company).strip() if company else ""
|
|
38
|
+
# Skip artist/portfolio entries mislabeled as JobPosting
|
|
39
|
+
if company_name.lower() in ("nftphotographers.xyz",) or not company_name:
|
|
40
|
+
continue
|
|
41
|
+
if not title or len(title) < 5:
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
direct_apply = entry.get("directApply", entry.get("url", ""))
|
|
45
|
+
if isinstance(direct_apply, dict):
|
|
46
|
+
direct_apply = direct_apply.get("url", "")
|
|
47
|
+
if not direct_apply:
|
|
48
|
+
direct_apply = entry.get("url", "")
|
|
49
|
+
|
|
50
|
+
desc = self._clean_html(entry.get("description", ""))
|
|
51
|
+
location = entry.get("jobLocation", "")
|
|
52
|
+
if isinstance(location, dict):
|
|
53
|
+
location = location.get("address", {}).get("addressLocality", location.get("name", "Remote"))
|
|
54
|
+
if isinstance(location, dict):
|
|
55
|
+
location = "Remote"
|
|
56
|
+
|
|
57
|
+
salary = entry.get("baseSalary", {})
|
|
58
|
+
salary_str = ""
|
|
59
|
+
if isinstance(salary, dict):
|
|
60
|
+
min_v = salary.get("value", {}).get("minValue", salary.get("value", ""))
|
|
61
|
+
max_v = salary.get("value", {}).get("maxValue", "")
|
|
62
|
+
if min_v and max_v:
|
|
63
|
+
salary_str = f"${min_v} - ${max_v}"
|
|
64
|
+
elif min_v:
|
|
65
|
+
salary_str = f"${min_v}+"
|
|
66
|
+
|
|
67
|
+
dedup_key = f"{title}|{company_name}"
|
|
68
|
+
if dedup_key in seen:
|
|
69
|
+
continue
|
|
70
|
+
seen.add(dedup_key)
|
|
71
|
+
|
|
72
|
+
if query and query.lower() not in f"{title} {company_name}".lower():
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
job_url = direct_apply or f"{self.BASE_URL}/job/{title.lower().replace(' ', '-')}"
|
|
76
|
+
jobs.append(
|
|
77
|
+
JobListing(
|
|
78
|
+
title=title.strip(),
|
|
79
|
+
company=company_name.strip(),
|
|
80
|
+
url=job_url,
|
|
81
|
+
location=location if isinstance(location, str) else "Remote",
|
|
82
|
+
remote="remote" in str(location).lower() or "anywhere" in str(location).lower(),
|
|
83
|
+
tags=["web3", "crypto"],
|
|
84
|
+
description=self._truncate(desc, 500),
|
|
85
|
+
salary=salary_str,
|
|
86
|
+
source=self.platform_name,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
if len(jobs) >= limit:
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
return jobs
|