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,94 @@
|
|
|
1
|
+
"""Wellfound (AngelList) — 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 WellfoundParser(BaseParser):
|
|
12
|
+
platform_name = "wellfound"
|
|
13
|
+
SEARCH_URL = "https://wellfound.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 = {}
|
|
19
|
+
if query:
|
|
20
|
+
params["role"] = query.lower().replace(" ", "-")
|
|
21
|
+
|
|
22
|
+
jobs = []
|
|
23
|
+
|
|
24
|
+
# Attempt 1: HTTP
|
|
25
|
+
resp = self._http_get(self.SEARCH_URL, params=params)
|
|
26
|
+
if resp and len(resp.text) > 1000:
|
|
27
|
+
self._parse_html(resp.text, jobs, query, limit)
|
|
28
|
+
|
|
29
|
+
# Attempt 2: Browser fallback (Wellfound is heavy JS)
|
|
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.SEARCH_URL}?{qs}" if qs else self.SEARCH_URL
|
|
33
|
+
browser_resp = self._browser_get(
|
|
34
|
+
url, timeout=20,
|
|
35
|
+
wait_selector="[data-testid*='job'], .styles_jobCard",
|
|
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,
|
|
66
|
+
tags=["startup"],
|
|
67
|
+
description=self._truncate(self._clean_html(entry.get("description", "")), 500),
|
|
68
|
+
source=self.platform_name,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
if len(jobs) >= limit:
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
# HTML card pattern
|
|
75
|
+
card = re.compile(
|
|
76
|
+
r'<a[^>]*href="(/startup-jobs/[^"]+)"[^>]*>.*?'
|
|
77
|
+
r'<(?:h[23]|span)[^>]*>([^<]+)</.*?'
|
|
78
|
+
r'<(?:span|a)[^>]*>([^<]+)</.*?',
|
|
79
|
+
re.DOTALL,
|
|
80
|
+
)
|
|
81
|
+
for match in card.finditer(html):
|
|
82
|
+
path, title, company = match.groups()
|
|
83
|
+
if query and query.lower() not in f"{title} {company}".lower():
|
|
84
|
+
continue
|
|
85
|
+
jobs.append(
|
|
86
|
+
JobListing(
|
|
87
|
+
title=title.strip(), company=company.strip(),
|
|
88
|
+
url=f"https://wellfound.com{path}",
|
|
89
|
+
location="Remote", remote=True,
|
|
90
|
+
tags=["startup"], source=self.platform_name,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
if len(jobs) >= limit:
|
|
94
|
+
return
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""We Work Remotely — RSS feed, no auth required."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import xml.etree.ElementTree as ET
|
|
7
|
+
|
|
8
|
+
from ..base import BaseParser, JobListing
|
|
9
|
+
from ..registry import register_parser
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@register_parser
|
|
13
|
+
class WeWorkRemotelyParser(BaseParser):
|
|
14
|
+
platform_name = "weworkremotely"
|
|
15
|
+
RSS_URL = "https://weworkremotely.com/remote-jobs.rss"
|
|
16
|
+
|
|
17
|
+
def fetch_jobs(
|
|
18
|
+
self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
|
|
19
|
+
) -> list[JobListing]:
|
|
20
|
+
resp = self._http_get(self.RSS_URL)
|
|
21
|
+
if resp is None:
|
|
22
|
+
return []
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
root = ET.fromstring(resp.text)
|
|
26
|
+
except Exception:
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
jobs = []
|
|
30
|
+
for item in root.findall(".//item")[:limit * 2]:
|
|
31
|
+
title = item.findtext("title", "")
|
|
32
|
+
link = item.findtext("link", "")
|
|
33
|
+
desc = item.findtext("description", "")
|
|
34
|
+
pub_date = item.findtext("pubDate", "")
|
|
35
|
+
|
|
36
|
+
if query and query.lower() not in f"{title} {desc}".lower():
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
company = ""
|
|
40
|
+
if ":" in title:
|
|
41
|
+
parts = title.split(":", 1)
|
|
42
|
+
company = parts[0].strip()
|
|
43
|
+
title = parts[1].strip()
|
|
44
|
+
|
|
45
|
+
jobs.append(
|
|
46
|
+
JobListing(
|
|
47
|
+
title=title,
|
|
48
|
+
company=company,
|
|
49
|
+
url=link,
|
|
50
|
+
location="Remote",
|
|
51
|
+
remote=True,
|
|
52
|
+
tags=[],
|
|
53
|
+
posted_at=pub_date,
|
|
54
|
+
description=self._truncate(self._clean_html(desc)),
|
|
55
|
+
source=self.platform_name,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
if len(jobs) >= limit:
|
|
59
|
+
break
|
|
60
|
+
return jobs
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""YC Startup Jobs — scraping-based parser for Y Combinator startups."""
|
|
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 YCombParser(BaseParser):
|
|
14
|
+
platform_name = "ycomb"
|
|
15
|
+
BASE_URL = "https://www.ycombinator.com/jobs"
|
|
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
|
+
try:
|
|
25
|
+
resp = httpx.get(self.BASE_URL, params=params, timeout=15, follow_redirects=True, headers={
|
|
26
|
+
"User-Agent": "Mozilla/5.0 (compatible; ChameleonBot/1.0)"
|
|
27
|
+
})
|
|
28
|
+
resp.raise_for_status()
|
|
29
|
+
html = resp.text
|
|
30
|
+
except Exception:
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
jobs = []
|
|
34
|
+
# YC Jobs uses React — look for __NEXT_DATA__ or embedded JSON
|
|
35
|
+
next_data = re.search(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.DOTALL)
|
|
36
|
+
if next_data:
|
|
37
|
+
import json
|
|
38
|
+
try:
|
|
39
|
+
data = json.loads(next_data.group(1))
|
|
40
|
+
props = data.get("props", {}).get("pageProps", {})
|
|
41
|
+
job_list = props.get("jobs", props.get("results", []))
|
|
42
|
+
for item in job_list[:limit]:
|
|
43
|
+
jobs.append(
|
|
44
|
+
JobListing(
|
|
45
|
+
title=item.get("title", item.get("role", "")),
|
|
46
|
+
company=item.get("companyName", item.get("company", {}).get("name", "")),
|
|
47
|
+
url=f"https://www.ycombinator.com/companies/{item.get('company', {}).get('slug', '')}/jobs/{item.get('slug', item.get('id', ''))}",
|
|
48
|
+
location=item.get("location", ""),
|
|
49
|
+
remote=item.get("remote", True),
|
|
50
|
+
tags=item.get("tags", []),
|
|
51
|
+
source=self.platform_name,
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
# Fallback: HTML scraping
|
|
58
|
+
if not jobs:
|
|
59
|
+
card_pattern = re.compile(
|
|
60
|
+
r'<a[^>]*href="(/companies/[^/]+/jobs/[^"]+)"[^>]*>.*?'
|
|
61
|
+
r'<(?:span|div)[^>]*>([^<]+)</.*?'
|
|
62
|
+
r'<(?:span|a)[^>]*href="/companies/([^"]+)"[^>]*>([^<]+)<',
|
|
63
|
+
re.DOTALL,
|
|
64
|
+
)
|
|
65
|
+
for match in card_pattern.finditer(html):
|
|
66
|
+
job_path, title, company_path, company = match.groups()
|
|
67
|
+
if query and query.lower() not in f"{title} {company}".lower():
|
|
68
|
+
continue
|
|
69
|
+
jobs.append(
|
|
70
|
+
JobListing(
|
|
71
|
+
title=title.strip(),
|
|
72
|
+
company=company.strip(),
|
|
73
|
+
url=f"https://www.ycombinator.com{job_path}",
|
|
74
|
+
location="Remote",
|
|
75
|
+
remote=True,
|
|
76
|
+
tags=["startup", "yc"],
|
|
77
|
+
source=self.platform_name,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
if len(jobs) >= limit:
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
return jobs
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Parser registry — import all parsers here to auto-register them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .base import BaseParser
|
|
9
|
+
|
|
10
|
+
_registry: dict[str, type[BaseParser]] = {}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register_parser(cls: type[BaseParser]) -> type[BaseParser]:
|
|
14
|
+
"""Decorator to register a parser class."""
|
|
15
|
+
_registry[cls.platform_name] = cls
|
|
16
|
+
return cls
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_parser(platform: str) -> type[BaseParser] | None:
|
|
20
|
+
"""Get a parser class by platform name."""
|
|
21
|
+
_ensure_loaded()
|
|
22
|
+
return _registry.get(platform)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_all_parsers() -> dict[str, type[BaseParser]]:
|
|
26
|
+
"""Get all registered parsers."""
|
|
27
|
+
_ensure_loaded()
|
|
28
|
+
return dict(_registry)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def list_platforms() -> list[str]:
|
|
32
|
+
"""List all available platform names."""
|
|
33
|
+
_ensure_loaded()
|
|
34
|
+
return sorted(_registry.keys())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_loaded = False
|
|
38
|
+
|
|
39
|
+
def _ensure_loaded():
|
|
40
|
+
global _loaded
|
|
41
|
+
if _loaded:
|
|
42
|
+
return
|
|
43
|
+
_loaded = True
|
|
44
|
+
from .parsers import ( # noqa: F401
|
|
45
|
+
himalayas,
|
|
46
|
+
remoteok,
|
|
47
|
+
remotive,
|
|
48
|
+
weworkremotely,
|
|
49
|
+
hn_hiring,
|
|
50
|
+
jobicy,
|
|
51
|
+
adzuna,
|
|
52
|
+
jooble,
|
|
53
|
+
arc,
|
|
54
|
+
crypto_careers,
|
|
55
|
+
dailyremote,
|
|
56
|
+
relocate,
|
|
57
|
+
web3_career,
|
|
58
|
+
ycomb,
|
|
59
|
+
linkedin,
|
|
60
|
+
indeed,
|
|
61
|
+
wellfound,
|
|
62
|
+
flexjobs,
|
|
63
|
+
opentoworkremote,
|
|
64
|
+
remote_rocketship,
|
|
65
|
+
)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Chameleon Job Scanner — search jobs across 20 platforms.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python -m scripts.job_scanner --query "python engineer" --platforms himalayas,remoteok,remotive
|
|
7
|
+
python -m scripts.job_scanner --query "rust" --all --limit 10
|
|
8
|
+
python -m scripts.job_scanner --list-platforms
|
|
9
|
+
echo "python" | python -m scripts.job_scanner --stdin
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
# Ensure the project root is in path
|
|
21
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
22
|
+
sys.path.insert(0, str(ROOT))
|
|
23
|
+
|
|
24
|
+
from scripts.job_scanner.registry import get_all_parsers, list_platforms, get_parser
|
|
25
|
+
from scripts.job_scanner.base import JobListing
|
|
26
|
+
|
|
27
|
+
# All supported platforms
|
|
28
|
+
ALL_PLATFORMS = [
|
|
29
|
+
# Tier 1 — Direct API/RSS (most reliable)
|
|
30
|
+
"himalayas", "remoteok", "remotive", "weworkremotely", "hn_hiring",
|
|
31
|
+
"jobicy", "adzuna", "jooble",
|
|
32
|
+
# Tier 2 — Scraping (moderate)
|
|
33
|
+
"arc", "crypto_careers", "dailyremote", "relocate", "web3_career",
|
|
34
|
+
"ycomb",
|
|
35
|
+
# Tier 3 — Hard (anti-bot, limited)
|
|
36
|
+
"linkedin", "indeed", "wellfound", "flexjobs", "remote_rocketship",
|
|
37
|
+
# Tier 4 — API key required
|
|
38
|
+
"opentoworkremote",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
TIER_1 = ["himalayas", "remoteok", "remotive", "weworkremotely", "hn_hiring", "jobicy", "adzuna", "jooble"]
|
|
42
|
+
TIER_2 = ["arc", "crypto_careers", "dailyremote", "relocate", "web3_career", "ycomb"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def scan_jobs(
|
|
46
|
+
query: str = "",
|
|
47
|
+
platforms: list[str] | None = None,
|
|
48
|
+
tags: list[str] | None = None,
|
|
49
|
+
limit: int = 25,
|
|
50
|
+
use_tier1_only: bool = False,
|
|
51
|
+
**kwargs,
|
|
52
|
+
) -> list[JobListing]:
|
|
53
|
+
"""Scan jobs across specified platforms and return unified listings."""
|
|
54
|
+
if platforms is None:
|
|
55
|
+
platforms = TIER_1 if use_tier1_only else ALL_PLATFORMS
|
|
56
|
+
|
|
57
|
+
all_jobs: list[JobListing] = []
|
|
58
|
+
seen_ids: set[str] = set()
|
|
59
|
+
errors: list[str] = []
|
|
60
|
+
|
|
61
|
+
for platform_name in platforms:
|
|
62
|
+
parser_cls = get_parser(platform_name)
|
|
63
|
+
if parser_cls is None:
|
|
64
|
+
print(f"[warn] Unknown platform: {platform_name}", file=sys.stderr)
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
parser = parser_cls()
|
|
68
|
+
try:
|
|
69
|
+
jobs = parser.fetch_jobs(query=query, tags=tags, limit=limit, **kwargs)
|
|
70
|
+
for job in jobs:
|
|
71
|
+
if job.job_id not in seen_ids:
|
|
72
|
+
seen_ids.add(job.job_id)
|
|
73
|
+
all_jobs.append(job)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
errors.append(f"{platform_name}: {e}")
|
|
76
|
+
print(f"[error] {platform_name}: {e}", file=sys.stderr)
|
|
77
|
+
|
|
78
|
+
if errors:
|
|
79
|
+
print(f"\n[info] {len(errors)} platform(s) had errors", file=sys.stderr)
|
|
80
|
+
|
|
81
|
+
return all_jobs
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main():
|
|
85
|
+
ap = argparse.ArgumentParser(description="Chameleon Job Scanner")
|
|
86
|
+
ap.add_argument("--query", "-q", default="", help="Search query (keywords, role)")
|
|
87
|
+
ap.add_argument("--platforms", "-p", default="", help="Comma-separated platform names")
|
|
88
|
+
ap.add_argument("--all", "-a", action="store_true", help="Scan all platforms")
|
|
89
|
+
ap.add_argument("--tier1", action="store_true", help="Scan only Tier 1 (API/RSS) platforms")
|
|
90
|
+
ap.add_argument("--limit", "-n", type=int, default=25, help="Max jobs per platform")
|
|
91
|
+
ap.add_argument("--tags", "-t", default="", help="Comma-separated tags to filter")
|
|
92
|
+
ap.add_argument("--output", "-o", default="", help="Output JSON file path")
|
|
93
|
+
ap.add_argument("--list-platforms", "-l", action="store_true", help="List all available platforms")
|
|
94
|
+
ap.add_argument("--json", "-j", action="store_true", help="Output as JSON")
|
|
95
|
+
|
|
96
|
+
args = ap.parse_args()
|
|
97
|
+
|
|
98
|
+
if args.list_platforms:
|
|
99
|
+
for p in list_platforms():
|
|
100
|
+
tier = "T1" if p in TIER_1 else "T2" if p in TIER_2 else "T3/T4"
|
|
101
|
+
print(f" [{tier:4s}] {p}")
|
|
102
|
+
print(f"\n Total: {len(list_platforms())} platforms")
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
platforms = None
|
|
106
|
+
if args.platforms:
|
|
107
|
+
platforms = [p.strip() for p in args.platforms.split(",")]
|
|
108
|
+
elif args.all:
|
|
109
|
+
platforms = ALL_PLATFORMS
|
|
110
|
+
elif args.tier1:
|
|
111
|
+
platforms = TIER_1
|
|
112
|
+
|
|
113
|
+
tags = [t.strip() for t in args.tags.split(",") if t.strip()] if args.tags else None
|
|
114
|
+
|
|
115
|
+
start = time.time()
|
|
116
|
+
jobs = scan_jobs(
|
|
117
|
+
query=args.query,
|
|
118
|
+
platforms=platforms,
|
|
119
|
+
tags=tags,
|
|
120
|
+
limit=args.limit,
|
|
121
|
+
)
|
|
122
|
+
elapsed = time.time() - start
|
|
123
|
+
|
|
124
|
+
if args.json or args.output:
|
|
125
|
+
data = [j.to_dict() for j in jobs]
|
|
126
|
+
if args.output:
|
|
127
|
+
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
Path(args.output).write_text(json.dumps(data, indent=2))
|
|
129
|
+
print(f"Saved {len(data)} jobs to {args.output}", file=sys.stderr)
|
|
130
|
+
else:
|
|
131
|
+
print(json.dumps(data, indent=2))
|
|
132
|
+
else:
|
|
133
|
+
# Pretty table output
|
|
134
|
+
if not jobs:
|
|
135
|
+
print("No jobs found.")
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
print(f"\n{'='*80}")
|
|
139
|
+
print(f" CHAMELEON JOB SCANNER — {len(jobs)} results ({elapsed:.1f}s)")
|
|
140
|
+
print(f"{'='*80}\n")
|
|
141
|
+
|
|
142
|
+
for i, job in enumerate(jobs, 1):
|
|
143
|
+
tags_str = ", ".join(job.tags[:5]) if job.tags else ""
|
|
144
|
+
remote_tag = " [R]" if job.remote else ""
|
|
145
|
+
salary_tag = f" ${job.salary}" if job.salary else ""
|
|
146
|
+
print(f" {i:2}. [{job.source}]{remote_tag}{salary_tag} {job.title}")
|
|
147
|
+
print(f" Company: {job.company}")
|
|
148
|
+
if job.location:
|
|
149
|
+
print(f" Location: {job.location}")
|
|
150
|
+
if tags_str:
|
|
151
|
+
print(f" Tags: {tags_str}")
|
|
152
|
+
print(f" URL: {job.url}")
|
|
153
|
+
print()
|
|
154
|
+
|
|
155
|
+
print(f"{'='*80}")
|
|
156
|
+
src_count = len(set(j.source for j in jobs))
|
|
157
|
+
print(f" Total: {len(jobs)} jobs from {src_count} platforms ({elapsed:.1f}s)")
|
|
158
|
+
print(f"{'='*80}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
main()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
9
|
+
|
|
10
|
+
from scripts.config import config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _opencode_run(prompt: str, timeout_s: int = 120) -> tuple[bool, str]:
|
|
14
|
+
opencode_bin = config.opencode_bin()
|
|
15
|
+
if not opencode_bin.exists():
|
|
16
|
+
return False, f"opencode not found at {opencode_bin}"
|
|
17
|
+
|
|
18
|
+
env = {**config.env(), "OPENCODE_CLI_DISABLE_ANIMATION": "true"}
|
|
19
|
+
chameleon_root = Path(__file__).resolve().parent.parent
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
proc = subprocess.Popen(
|
|
23
|
+
[str(opencode_bin), "run", prompt],
|
|
24
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
25
|
+
cwd=str(chameleon_root), env=env,
|
|
26
|
+
)
|
|
27
|
+
except FileNotFoundError:
|
|
28
|
+
return False, f"opencode binary not executable at {opencode_bin}"
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
out, err = proc.communicate(timeout=timeout_s)
|
|
32
|
+
except subprocess.TimeoutExpired:
|
|
33
|
+
proc.kill()
|
|
34
|
+
proc.wait(timeout=5)
|
|
35
|
+
return False, f"opencode timed out after {timeout_s}s"
|
|
36
|
+
except Exception as e:
|
|
37
|
+
proc.kill()
|
|
38
|
+
proc.wait(timeout=5)
|
|
39
|
+
return False, str(e)
|
|
40
|
+
|
|
41
|
+
if proc.returncode != 0:
|
|
42
|
+
return False, (err or f"opencode exited non-zero ({proc.returncode})").strip()[:200]
|
|
43
|
+
out = (out or "").strip()
|
|
44
|
+
if not out:
|
|
45
|
+
return False, (err or "opencode produced no output").strip()[:200]
|
|
46
|
+
return True, out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
QUESTION_PROMPT = """Answer the following job application question in exactly 1 paragraph.
|
|
50
|
+
Write in simple, direct first-person language grounded in the resume and job context provided.
|
|
51
|
+
Answer the question directly in the first sentence.
|
|
52
|
+
Use assertive framing when supported, prefer one strong impact detail over a stack list.
|
|
53
|
+
Do not turn the answer into a cover letter, resume summary, or ATS keyword dump.
|
|
54
|
+
|
|
55
|
+
## QUESTION
|
|
56
|
+
{question_text}
|
|
57
|
+
|
|
58
|
+
## JOB DESCRIPTION
|
|
59
|
+
{jd_text}
|
|
60
|
+
|
|
61
|
+
## RESUME YAML
|
|
62
|
+
{cv_yaml}
|
|
63
|
+
|
|
64
|
+
Return ONLY the answer paragraph — no greeting, no sign-off, no markdown.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def answer_question(question_text: str, jd_text: str, cv_path: str | Path) -> dict:
|
|
69
|
+
cv_yaml = Path(cv_path).read_text(encoding="utf-8")
|
|
70
|
+
prompt = QUESTION_PROMPT.format(
|
|
71
|
+
question_text=question_text.strip(),
|
|
72
|
+
jd_text=jd_text.strip() if jd_text else "(not provided)",
|
|
73
|
+
cv_yaml=cv_yaml,
|
|
74
|
+
)
|
|
75
|
+
success, result = _opencode_run(prompt)
|
|
76
|
+
if success:
|
|
77
|
+
return {"success": True, "answer": result}
|
|
78
|
+
return {"success": False, "error": result}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main():
|
|
82
|
+
ap = argparse.ArgumentParser(description="Answer a screening question from JD + CV")
|
|
83
|
+
ap.add_argument("question_file", help="Path to file containing the question text")
|
|
84
|
+
ap.add_argument("--jd", "-j", default=None, help="Path to job description text file (optional)")
|
|
85
|
+
ap.add_argument("--cv", "-c", default=None, help="Path to CV YAML (default: master CV from config)")
|
|
86
|
+
ap.add_argument("--json", action="store_true", help="JSON output")
|
|
87
|
+
args = ap.parse_args()
|
|
88
|
+
|
|
89
|
+
question_text = Path(args.question_file).read_text(encoding="utf-8").strip()
|
|
90
|
+
jd_text = Path(args.jd).read_text(encoding="utf-8") if args.jd else ""
|
|
91
|
+
cv_path = args.cv or config.master_cv_path()
|
|
92
|
+
|
|
93
|
+
result = answer_question(question_text, jd_text, cv_path)
|
|
94
|
+
|
|
95
|
+
if args.json:
|
|
96
|
+
import json
|
|
97
|
+
print(json.dumps(result, indent=2))
|
|
98
|
+
else:
|
|
99
|
+
if result.get("success"):
|
|
100
|
+
print(result["answer"])
|
|
101
|
+
else:
|
|
102
|
+
print(f"Error: {result.get('error', 'unknown')}", file=sys.stderr)
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
main()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render a RenderCV YAML file to output/, using the venv rendercv binary."""
|
|
3
|
+
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
10
|
+
VENV_BIN = ROOT / ".venv" / ("Scripts" if sys.platform == "win32" else "bin")
|
|
11
|
+
RENDERCV = VENV_BIN / ("rendercv.exe" if sys.platform == "win32" else "rendercv")
|
|
12
|
+
|
|
13
|
+
_ENV = {**{k: str(v) for k, v in (__import__('os').environ).items()},
|
|
14
|
+
"PYTHONIOENCODING": "utf-8", "TERM": "xterm-256color"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _find_rendercv() -> Path | None:
|
|
18
|
+
"""Locate the rendercv binary, checking venv, PATH, and user site-packages."""
|
|
19
|
+
if RENDERCV.exists():
|
|
20
|
+
return RENDERCV
|
|
21
|
+
# Check if rendercv is on PATH
|
|
22
|
+
import shutil as _shutil
|
|
23
|
+
on_path = _shutil.which("rendercv")
|
|
24
|
+
if on_path:
|
|
25
|
+
return Path(on_path)
|
|
26
|
+
# Check pip user install directory
|
|
27
|
+
try:
|
|
28
|
+
import subprocess as _sp
|
|
29
|
+
r = _sp.run([sys.executable, "-m", "pip", "show", "rendercv"], capture_output=True, text=True)
|
|
30
|
+
if r.returncode == 0:
|
|
31
|
+
for line in r.stdout.splitlines():
|
|
32
|
+
if line.lower().startswith("location:"):
|
|
33
|
+
loc = line.split(":", 1)[1].strip()
|
|
34
|
+
bin_dir = Path(loc).parent / "Scripts"
|
|
35
|
+
exe = bin_dir / "rendercv.exe"
|
|
36
|
+
if exe.exists():
|
|
37
|
+
return exe
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def render_yaml(yaml_path: Path) -> tuple[bool, str]:
|
|
44
|
+
"""Render a YAML file to PDF. Returns (success, message_or_pdf_path)."""
|
|
45
|
+
rendercv = _find_rendercv()
|
|
46
|
+
if rendercv is None:
|
|
47
|
+
return False, "rendercv not found. Run: make install-tools"
|
|
48
|
+
|
|
49
|
+
output_dir = ROOT / "output"
|
|
50
|
+
output_dir.mkdir(exist_ok=True)
|
|
51
|
+
|
|
52
|
+
stem = yaml_path.stem
|
|
53
|
+
tmp = ROOT / yaml_path.name
|
|
54
|
+
shutil.copy2(yaml_path, tmp)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
result = subprocess.run(
|
|
58
|
+
[
|
|
59
|
+
str(rendercv), "render", str(tmp),
|
|
60
|
+
"--pdf-path", str(output_dir / f"{stem}.pdf"),
|
|
61
|
+
"--typst-path", str(output_dir / f"{stem}.typ"),
|
|
62
|
+
"--markdown-path", str(output_dir / f"{stem}.md"),
|
|
63
|
+
"--html-path", str(output_dir / f"{stem}.html"),
|
|
64
|
+
],
|
|
65
|
+
check=False,
|
|
66
|
+
capture_output=True,
|
|
67
|
+
text=False, # capture raw bytes to avoid encoding errors
|
|
68
|
+
)
|
|
69
|
+
stdout_text = result.stdout.decode("utf-8", errors="replace") if result.stdout else ""
|
|
70
|
+
stderr_text = result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
|
|
71
|
+
finally:
|
|
72
|
+
tmp.unlink(missing_ok=True)
|
|
73
|
+
|
|
74
|
+
if result.returncode == 0:
|
|
75
|
+
return True, _find_output_pdf(output_dir, stem)
|
|
76
|
+
else:
|
|
77
|
+
# rendercv sometimes exits non-zero even on success (rich UnicodeError on Windows)
|
|
78
|
+
pdf = _find_output_pdf(output_dir, stem)
|
|
79
|
+
if pdf:
|
|
80
|
+
return True, pdf
|
|
81
|
+
err = (stderr_text or stdout_text or "unknown error").strip()[:300]
|
|
82
|
+
return False, err
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _find_output_pdf(output_dir: Path, stem: str) -> str | None:
|
|
86
|
+
"""Return only the deterministic artifact for this render invocation."""
|
|
87
|
+
pdf_path = output_dir / f"{stem}.pdf"
|
|
88
|
+
return str(pdf_path) if pdf_path.exists() else None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main() -> None:
|
|
92
|
+
if len(sys.argv) != 2:
|
|
93
|
+
print("Usage: python scripts/render.py <path-to-yaml>")
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
|
|
96
|
+
source = Path(sys.argv[1].strip())
|
|
97
|
+
if not source.exists():
|
|
98
|
+
print(f"Error: {source} not found")
|
|
99
|
+
sys.exit(1)
|
|
100
|
+
|
|
101
|
+
ok, msg = render_yaml(source)
|
|
102
|
+
if ok:
|
|
103
|
+
print(f"Rendered: {msg}")
|
|
104
|
+
else:
|
|
105
|
+
print(f"Error: {msg}")
|
|
106
|
+
sys.exit(0 if ok else 1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
main()
|