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,65 @@
|
|
|
1
|
+
"""Arc.dev — parser for remote dev jobs via __NEXT_DATA__."""
|
|
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 ArcParser(BaseParser):
|
|
10
|
+
platform_name = "arc"
|
|
11
|
+
BASE_URL = "https://arc.dev/remote-jobs"
|
|
12
|
+
|
|
13
|
+
def fetch_jobs(
|
|
14
|
+
self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
|
|
15
|
+
) -> list[JobListing]:
|
|
16
|
+
url = self.BASE_URL
|
|
17
|
+
if query:
|
|
18
|
+
slug = query.lower().replace(" ", "-")
|
|
19
|
+
url = f"{self.BASE_URL}/{slug}"
|
|
20
|
+
|
|
21
|
+
resp = self._http_get(url)
|
|
22
|
+
if resp is None:
|
|
23
|
+
return []
|
|
24
|
+
|
|
25
|
+
data = self._extract_next_data(resp.text)
|
|
26
|
+
if data is None:
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
page_props = data.get("props", {}).get("pageProps", {})
|
|
30
|
+
jobs = []
|
|
31
|
+
|
|
32
|
+
for source_key in ("arcJobs", "externalJobs"):
|
|
33
|
+
items = page_props.get(source_key, [])
|
|
34
|
+
for item in items[:limit]:
|
|
35
|
+
company = item.get("company", {})
|
|
36
|
+
if isinstance(company, dict):
|
|
37
|
+
company_name = company.get("name", "")
|
|
38
|
+
else:
|
|
39
|
+
company_name = str(company) if company else ""
|
|
40
|
+
|
|
41
|
+
job_title = item.get("title", "") or ""
|
|
42
|
+
url_str = item.get("urlString", "")
|
|
43
|
+
job_url = f"https://arc.dev/remote-jobs/{url_str}" if url_str else ""
|
|
44
|
+
|
|
45
|
+
categories = item.get("categories", [])
|
|
46
|
+
tags_list = [c.get("name", str(c)) if isinstance(c, dict) else str(c) for c in categories]
|
|
47
|
+
|
|
48
|
+
jobs.append(
|
|
49
|
+
JobListing(
|
|
50
|
+
title=job_title,
|
|
51
|
+
company=company_name,
|
|
52
|
+
url=job_url,
|
|
53
|
+
location="Remote",
|
|
54
|
+
remote=True,
|
|
55
|
+
tags=tags_list,
|
|
56
|
+
posted_at=item.get("postedAt", ""),
|
|
57
|
+
source=self.platform_name,
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
if len(jobs) >= limit:
|
|
61
|
+
break
|
|
62
|
+
if len(jobs) >= limit:
|
|
63
|
+
break
|
|
64
|
+
|
|
65
|
+
return jobs
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Crypto Careers — parser with HTTP + Playwright browser fallback for Cloudflare."""
|
|
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 CryptoCareersParser(BaseParser):
|
|
12
|
+
platform_name = "crypto_careers"
|
|
13
|
+
BASE_URL = "https://www.crypto-careers.com"
|
|
14
|
+
|
|
15
|
+
def fetch_jobs(
|
|
16
|
+
self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
|
|
17
|
+
) -> list[JobListing]:
|
|
18
|
+
jobs = []
|
|
19
|
+
|
|
20
|
+
# Attempt 1: HTTP (usually Cloudflare-blocked)
|
|
21
|
+
resp = self._http_get(self.BASE_URL)
|
|
22
|
+
if resp and "cloudflare" not in resp.text[:500].lower() and len(resp.text) > 2000:
|
|
23
|
+
self._parse_html(resp.text, jobs, query, limit)
|
|
24
|
+
|
|
25
|
+
# Attempt 2: Browser fallback (bypasses Cloudflare)
|
|
26
|
+
if not jobs and self._browser_available():
|
|
27
|
+
browser_resp = self._browser_get(
|
|
28
|
+
self.BASE_URL, timeout=25,
|
|
29
|
+
wait_selector="[class*='job'], article, .card, tr",
|
|
30
|
+
scroll=True,
|
|
31
|
+
)
|
|
32
|
+
if browser_resp and not self._is_login_wall(browser_resp.text):
|
|
33
|
+
self._parse_html(browser_resp.text, jobs, query, limit)
|
|
34
|
+
|
|
35
|
+
return jobs
|
|
36
|
+
|
|
37
|
+
def _parse_html(self, html: str, jobs: list, query: str, limit: int) -> None:
|
|
38
|
+
# Try JSON-LD
|
|
39
|
+
ld = self._extract_json_ld(html)
|
|
40
|
+
seen = set()
|
|
41
|
+
for entry in ld:
|
|
42
|
+
if entry.get("@type") != "JobPosting":
|
|
43
|
+
continue
|
|
44
|
+
title = (entry.get("title", "") or "").strip()
|
|
45
|
+
org = entry.get("hiringOrganization", {})
|
|
46
|
+
company_name = (org.get("name", "") if isinstance(org, dict) else str(org)).strip()
|
|
47
|
+
url = entry.get("url", "")
|
|
48
|
+
if not title or not company_name:
|
|
49
|
+
continue
|
|
50
|
+
key = f"{title}|{company_name}"
|
|
51
|
+
if key in seen:
|
|
52
|
+
continue
|
|
53
|
+
seen.add(key)
|
|
54
|
+
if query and query.lower() not in f"{title} {company_name}".lower():
|
|
55
|
+
continue
|
|
56
|
+
jobs.append(
|
|
57
|
+
JobListing(
|
|
58
|
+
title=title, company=company_name, url=url,
|
|
59
|
+
location="Remote", remote=True,
|
|
60
|
+
tags=["crypto", "blockchain"],
|
|
61
|
+
description=self._truncate(self._clean_html(entry.get("description", "")), 500),
|
|
62
|
+
source=self.platform_name,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
if len(jobs) >= limit:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
# HTML card pattern
|
|
69
|
+
card = re.compile(
|
|
70
|
+
r'<a[^>]*href="(/job/[^"]+)"[^>]*>.*?'
|
|
71
|
+
r'<(?:h[234]|span|div)[^>]*>([^<]+)</.*?'
|
|
72
|
+
r'<(?:span|div|p)[^>]*class="[^"]*(?:company|employer)[^"]*"[^>]*>([^<]+)<',
|
|
73
|
+
re.DOTALL,
|
|
74
|
+
)
|
|
75
|
+
for match in card.finditer(html):
|
|
76
|
+
path, title, company = match.groups()
|
|
77
|
+
if query and query.lower() not in f"{title} {company}".lower():
|
|
78
|
+
continue
|
|
79
|
+
jobs.append(
|
|
80
|
+
JobListing(
|
|
81
|
+
title=title.strip(), company=company.strip(),
|
|
82
|
+
url=f"{self.BASE_URL}{path}",
|
|
83
|
+
location="Remote", remote=True,
|
|
84
|
+
tags=["crypto", "blockchain"], source=self.platform_name,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
if len(jobs) >= limit:
|
|
88
|
+
return
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""DailyRemote — 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 DailyRemoteParser(BaseParser):
|
|
12
|
+
platform_name = "dailyremote"
|
|
13
|
+
SEARCH_URL = "https://dailyremote.com/remote-jobs"
|
|
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
|
+
params["page"] = kwargs.get("page", 1)
|
|
22
|
+
|
|
23
|
+
jobs = []
|
|
24
|
+
|
|
25
|
+
# Attempt 1: HTTP
|
|
26
|
+
resp = self._http_get(self.SEARCH_URL, params=params)
|
|
27
|
+
if resp and len(resp.text) > 1000:
|
|
28
|
+
self._parse_html(resp.text, jobs, query, limit)
|
|
29
|
+
|
|
30
|
+
# Attempt 2: Browser fallback
|
|
31
|
+
if not jobs and self._browser_available():
|
|
32
|
+
qs = "&".join(f"{k}={v}" for k, v in params.items())
|
|
33
|
+
browser_resp = self._browser_get(
|
|
34
|
+
f"{self.SEARCH_URL}?{qs}", timeout=20,
|
|
35
|
+
wait_selector="[class*='card'], [class*='job'], article, tr",
|
|
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
|
+
description=self._truncate(self._clean_html(entry.get("description", "")), 500),
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
if len(jobs) >= limit:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
# HTML card pattern
|
|
73
|
+
card = re.compile(
|
|
74
|
+
r'<a[^>]*href="(/remote-jobs/[^"]+)"[^>]*>.*?'
|
|
75
|
+
r'<(?:h[234]|span)[^>]*>([^<]+)</.*?'
|
|
76
|
+
r'<(?:span|div)[^>]*class="[^"]*(?:company|employer)[^"]*"[^>]*>([^<]+)<',
|
|
77
|
+
re.DOTALL,
|
|
78
|
+
)
|
|
79
|
+
for match in card.finditer(html):
|
|
80
|
+
path, title, company = match.groups()
|
|
81
|
+
if query and query.lower() not in f"{title} {company}".lower():
|
|
82
|
+
continue
|
|
83
|
+
jobs.append(
|
|
84
|
+
JobListing(
|
|
85
|
+
title=title.strip(), company=company.strip(),
|
|
86
|
+
url=f"https://dailyremote.com{path}",
|
|
87
|
+
location="Remote", remote=True, source=self.platform_name,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
if len(jobs) >= limit:
|
|
91
|
+
return
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""FlexJobs — 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 FlexJobsParser(BaseParser):
|
|
12
|
+
platform_name = "flexjobs"
|
|
13
|
+
SEARCH_URL = "https://www.flexjobs.com/search"
|
|
14
|
+
|
|
15
|
+
def fetch_jobs(
|
|
16
|
+
self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
|
|
17
|
+
) -> list[JobListing]:
|
|
18
|
+
params = {"search": query}
|
|
19
|
+
location = kwargs.get("location", "")
|
|
20
|
+
if location:
|
|
21
|
+
params["location"] = location
|
|
22
|
+
|
|
23
|
+
jobs = []
|
|
24
|
+
|
|
25
|
+
# Attempt 1: HTTP
|
|
26
|
+
resp = self._http_get(self.SEARCH_URL, params=params)
|
|
27
|
+
if resp and len(resp.text) > 1000:
|
|
28
|
+
self._parse_html(resp.text, jobs, query, limit, location)
|
|
29
|
+
|
|
30
|
+
# Attempt 2: Browser fallback
|
|
31
|
+
if not jobs and self._browser_available():
|
|
32
|
+
qs = "&".join(f"{k}={v}" for k, v in params.items())
|
|
33
|
+
browser_resp = self._browser_get(
|
|
34
|
+
f"{self.SEARCH_URL}?{qs}", timeout=20,
|
|
35
|
+
wait_selector="[class*='job'], [class*='card'], .mosaic",
|
|
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, location)
|
|
40
|
+
|
|
41
|
+
return jobs
|
|
42
|
+
|
|
43
|
+
def _parse_html(self, html: str, jobs: list, query: str, limit: int, location: str) -> None:
|
|
44
|
+
ld = self._extract_json_ld(html)
|
|
45
|
+
seen = set()
|
|
46
|
+
for entry in ld:
|
|
47
|
+
if entry.get("@type") != "JobPosting":
|
|
48
|
+
continue
|
|
49
|
+
title = (entry.get("title", "") or "").strip()
|
|
50
|
+
org = entry.get("hiringOrganization", {})
|
|
51
|
+
company_name = (org.get("name", "") if isinstance(org, dict) else str(org)).strip()
|
|
52
|
+
url = entry.get("url", "")
|
|
53
|
+
if not title or not company_name:
|
|
54
|
+
continue
|
|
55
|
+
key = f"{title}|{company_name}"
|
|
56
|
+
if key in seen:
|
|
57
|
+
continue
|
|
58
|
+
seen.add(key)
|
|
59
|
+
if query and query.lower() not in f"{title} {company_name}".lower():
|
|
60
|
+
continue
|
|
61
|
+
jobs.append(
|
|
62
|
+
JobListing(
|
|
63
|
+
title=title, company=company_name, url=url,
|
|
64
|
+
location=location or "",
|
|
65
|
+
remote=True, tags=["flexible"],
|
|
66
|
+
source=self.platform_name,
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
if len(jobs) >= limit:
|
|
70
|
+
return
|
|
71
|
+
|
|
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"https://www.flexjobs.com{path}",
|
|
86
|
+
location=location or "", remote=True,
|
|
87
|
+
tags=["flexible"], source=self.platform_name,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
if len(jobs) >= limit:
|
|
91
|
+
return
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Himalayas — 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 HimalayasParser(BaseParser):
|
|
11
|
+
platform_name = "himalayas"
|
|
12
|
+
API_URL = "https://himalayas.app/jobs/api"
|
|
13
|
+
SEARCH_URL = "https://himalayas.app/jobs/api/search"
|
|
14
|
+
|
|
15
|
+
def fetch_jobs(
|
|
16
|
+
self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
|
|
17
|
+
) -> list[JobListing]:
|
|
18
|
+
params: dict = {"limit": min(limit, 50)}
|
|
19
|
+
url = self.SEARCH_URL if query else self.API_URL
|
|
20
|
+
if query:
|
|
21
|
+
params["q"] = query
|
|
22
|
+
|
|
23
|
+
resp = self._http_get(url, params=params)
|
|
24
|
+
if resp is None:
|
|
25
|
+
return []
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
data = resp.json()
|
|
29
|
+
except Exception:
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
jobs = []
|
|
33
|
+
for item in data.get("jobs", [])[:limit]:
|
|
34
|
+
jobs.append(
|
|
35
|
+
JobListing(
|
|
36
|
+
title=item.get("title", ""),
|
|
37
|
+
company=item.get("companyName", ""),
|
|
38
|
+
url=item.get("applicationLink") or item.get("guid", ""),
|
|
39
|
+
location=item.get("location", ""),
|
|
40
|
+
remote=item.get("remote", True),
|
|
41
|
+
tags=[t.get("name", "") for t in item.get("tags", [])],
|
|
42
|
+
posted_at=item.get("datePosted", ""),
|
|
43
|
+
description=self._truncate(self._clean_html(item.get("description", ""))),
|
|
44
|
+
source=self.platform_name,
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
return jobs
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""HN Hiring (Who Is Hiring) — Algolia API, no auth required."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from ..base import BaseParser, JobListing
|
|
8
|
+
from ..registry import register_parser
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@register_parser
|
|
12
|
+
class HNHiringParser(BaseParser):
|
|
13
|
+
platform_name = "hn_hiring"
|
|
14
|
+
ALGOLIA_URL = "https://hn.algolia.com/api/v1/search_by_date"
|
|
15
|
+
ITEMS_URL = "https://hn.algolia.com/api/v1/items"
|
|
16
|
+
|
|
17
|
+
def fetch_jobs(
|
|
18
|
+
self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
|
|
19
|
+
) -> list[JobListing]:
|
|
20
|
+
# Find the latest "Ask HN: Who is hiring?" thread
|
|
21
|
+
resp = self._http_get(
|
|
22
|
+
self.ALGOLIA_URL,
|
|
23
|
+
params={"tags": "story", "query": "Ask HN Who is hiring"},
|
|
24
|
+
)
|
|
25
|
+
if resp is None:
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
hits = resp.json().get("hits", [])
|
|
30
|
+
except Exception:
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
# Find the actual "Ask HN: Who is hiring?" thread
|
|
34
|
+
thread_id = None
|
|
35
|
+
for hit in hits:
|
|
36
|
+
title = hit.get("title", "")
|
|
37
|
+
lower = title.lower()
|
|
38
|
+
if "ask hn" in lower and "who is hiring?" in lower and "(" in title:
|
|
39
|
+
thread_id = hit.get("objectID")
|
|
40
|
+
break
|
|
41
|
+
|
|
42
|
+
if not thread_id:
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
# Fetch thread comments
|
|
46
|
+
resp = self._http_get(f"{self.ITEMS_URL}/{thread_id}")
|
|
47
|
+
if resp is None:
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
thread = resp.json()
|
|
52
|
+
except Exception:
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
comments = thread.get("children", [])
|
|
56
|
+
|
|
57
|
+
jobs = []
|
|
58
|
+
for comment in comments:
|
|
59
|
+
text = comment.get("text", "")
|
|
60
|
+
if not text or comment.get("dead") or comment.get("deleted"):
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
clean = self._clean_html(text)
|
|
64
|
+
lines = clean.split("\n")
|
|
65
|
+
header = lines[0] if lines else ""
|
|
66
|
+
|
|
67
|
+
parts = [p.strip() for p in header.split("|")]
|
|
68
|
+
company = parts[0] if parts else ""
|
|
69
|
+
title = parts[1] if len(parts) > 1 else ""
|
|
70
|
+
location = parts[2] if len(parts) > 2 else ""
|
|
71
|
+
|
|
72
|
+
if query and query.lower() not in clean.lower():
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
url = f"https://news.ycombinator.com/item?id={comment.get('id', '')}"
|
|
76
|
+
|
|
77
|
+
jobs.append(
|
|
78
|
+
JobListing(
|
|
79
|
+
title=title or "See posting",
|
|
80
|
+
company=company,
|
|
81
|
+
url=url,
|
|
82
|
+
location=location,
|
|
83
|
+
remote="remote" in clean.lower(),
|
|
84
|
+
tags=[],
|
|
85
|
+
posted_at=thread.get("time", ""),
|
|
86
|
+
description=self._truncate(clean),
|
|
87
|
+
source=self.platform_name,
|
|
88
|
+
job_id=str(comment.get("id", "")),
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
if len(jobs) >= limit:
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
return jobs
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Indeed — 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 IndeedParser(BaseParser):
|
|
12
|
+
platform_name = "indeed"
|
|
13
|
+
SEARCH_URL = "https://www.indeed.com/jobs"
|
|
14
|
+
|
|
15
|
+
def fetch_jobs(
|
|
16
|
+
self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
|
|
17
|
+
) -> list[JobListing]:
|
|
18
|
+
params = {"q": query}
|
|
19
|
+
location = kwargs.get("location", "")
|
|
20
|
+
if location:
|
|
21
|
+
params["l"] = location
|
|
22
|
+
remote = kwargs.get("remote", False)
|
|
23
|
+
if remote:
|
|
24
|
+
params["remotejob"] = "032b3046-06a3-4876-8dfd-474eb5e7ed11"
|
|
25
|
+
|
|
26
|
+
jobs = []
|
|
27
|
+
|
|
28
|
+
# Attempt 1: HTTP
|
|
29
|
+
resp = self._http_get(self.SEARCH_URL, params=params, retries=1)
|
|
30
|
+
if resp and len(resp.text) > 1000:
|
|
31
|
+
self._parse_html(resp.text, jobs, query, limit, location, remote)
|
|
32
|
+
|
|
33
|
+
# Attempt 2: Browser fallback
|
|
34
|
+
if not jobs and self._browser_available():
|
|
35
|
+
qs = "&".join(f"{k}={v}" for k, v in params.items())
|
|
36
|
+
browser_resp = self._browser_get(
|
|
37
|
+
f"{self.SEARCH_URL}?{qs}",
|
|
38
|
+
timeout=20,
|
|
39
|
+
wait_selector=".jobsearch-ResultsList",
|
|
40
|
+
scroll=True,
|
|
41
|
+
)
|
|
42
|
+
if browser_resp and not self._is_login_wall(browser_resp.text):
|
|
43
|
+
self._parse_html(browser_resp.text, jobs, query, limit, location, remote)
|
|
44
|
+
|
|
45
|
+
return jobs
|
|
46
|
+
|
|
47
|
+
def _parse_html(self, html: str, jobs: list, query: str, limit: int,
|
|
48
|
+
location: str, remote: bool) -> None:
|
|
49
|
+
# Try JSON-LD first
|
|
50
|
+
ld = self._extract_json_ld(html)
|
|
51
|
+
seen = set()
|
|
52
|
+
for entry in ld:
|
|
53
|
+
if entry.get("@type") != "JobPosting":
|
|
54
|
+
continue
|
|
55
|
+
title = (entry.get("title", "") or "").strip()
|
|
56
|
+
org = entry.get("hiringOrganization", {})
|
|
57
|
+
company_name = (org.get("name", "") if isinstance(org, dict) else str(org)).strip()
|
|
58
|
+
url = entry.get("url", "")
|
|
59
|
+
if not title or not company_name:
|
|
60
|
+
continue
|
|
61
|
+
key = f"{title}|{company_name}"
|
|
62
|
+
if key in seen:
|
|
63
|
+
continue
|
|
64
|
+
seen.add(key)
|
|
65
|
+
if query and query.lower() not in f"{title} {company_name}".lower():
|
|
66
|
+
continue
|
|
67
|
+
jobs.append(
|
|
68
|
+
JobListing(
|
|
69
|
+
title=title, company=company_name, url=url,
|
|
70
|
+
location=location or "", remote=remote,
|
|
71
|
+
description=self._truncate(self._clean_html(entry.get("description", "")), 500),
|
|
72
|
+
source=self.platform_name,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
if len(jobs) >= limit:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
# Fallback: HTML card pattern
|
|
79
|
+
card = re.compile(
|
|
80
|
+
r'<a[^>]*href="(/rc/clk[^"]*|/viewjob[^"]*)"[^>]*>.*?'
|
|
81
|
+
r'<(?:h2|span)[^>]*class="[^"]*jobTitle[^"]*"[^>]*>([^<]+)</.*?'
|
|
82
|
+
r'<(?:span|div)[^>]*data-testid="[^"]*company[^"]*"[^>]*>([^<]+)<',
|
|
83
|
+
re.DOTALL,
|
|
84
|
+
)
|
|
85
|
+
for match in card.finditer(html):
|
|
86
|
+
path, title, company = match.groups()
|
|
87
|
+
url = f"https://www.indeed.com{path.split('?')[0]}"
|
|
88
|
+
if query and query.lower() not in f"{title} {company}".lower():
|
|
89
|
+
continue
|
|
90
|
+
jobs.append(
|
|
91
|
+
JobListing(
|
|
92
|
+
title=title.strip(), company=company.strip(),
|
|
93
|
+
url=url, location=location or "",
|
|
94
|
+
remote=remote, source=self.platform_name,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
if len(jobs) >= limit:
|
|
98
|
+
return
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Jobicy — remote job board with a clean JSON API. Tier 1."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
import urllib.error
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..base import BaseParser, JobListing
|
|
12
|
+
from ..registry import register_parser
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@register_parser
|
|
16
|
+
class JobicyParser(BaseParser):
|
|
17
|
+
platform_name = "jobicy"
|
|
18
|
+
|
|
19
|
+
def fetch_jobs(self, query: str = "", **kwargs: Any) -> list[JobListing]:
|
|
20
|
+
api_url = "https://jobicy.com/api/v2/remote-jobs"
|
|
21
|
+
params = []
|
|
22
|
+
if query:
|
|
23
|
+
params.append(f"tag={urllib.parse.quote(query)}")
|
|
24
|
+
url = api_url + ("?" + "&".join(params) if params else "")
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
req = urllib.request.Request(url, headers={"User-Agent": "Chameleon/1.0"})
|
|
28
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
29
|
+
data = json.loads(resp.read().decode())
|
|
30
|
+
except (urllib.error.URLError, json.JSONDecodeError, OSError):
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
jobs: list[JobListing] = []
|
|
34
|
+
raw_jobs = data.get("jobs", []) if isinstance(data, dict) else data
|
|
35
|
+
for j in raw_jobs[:kwargs.get("limit", 25)]:
|
|
36
|
+
title = (j.get("jobTitle") or j.get("title") or "").strip()
|
|
37
|
+
company = (j.get("companyName") or j.get("company") or "").strip()
|
|
38
|
+
url = j.get("url") or j.get("jobURL") or ""
|
|
39
|
+
desc = (j.get("jobDescription") or j.get("description") or "")[:2000]
|
|
40
|
+
location = j.get("jobGeo") or j.get("location") or "Remote"
|
|
41
|
+
salary = j.get("salary") or j.get("salaryRange") or ""
|
|
42
|
+
remote = "remote" in str(location).lower() or "anywhere" in str(location).lower()
|
|
43
|
+
tags = j.get("jobIndustry") or j.get("tags") or []
|
|
44
|
+
if isinstance(tags, str):
|
|
45
|
+
tags = [t.strip() for t in tags.split(",") if t.strip()]
|
|
46
|
+
|
|
47
|
+
job_id = j.get("id") or j.get("jobId") or f"jobicy-{hash(title + company)}"
|
|
48
|
+
jobs.append(JobListing(
|
|
49
|
+
job_id=str(job_id),
|
|
50
|
+
title=title,
|
|
51
|
+
company=company,
|
|
52
|
+
url=url,
|
|
53
|
+
location=location,
|
|
54
|
+
description=desc,
|
|
55
|
+
salary=str(salary),
|
|
56
|
+
source=self.platform_name,
|
|
57
|
+
remote=remote,
|
|
58
|
+
tags=tags if isinstance(tags, list) else [str(tags)],
|
|
59
|
+
))
|
|
60
|
+
return jobs
|