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.
Files changed (151) hide show
  1. package/AGENTS.md +186 -0
  2. package/LICENSE +21 -0
  3. package/Makefile +136 -0
  4. package/README.md +356 -0
  5. package/bin/chameleon.js +136 -0
  6. package/chameleon.bat +286 -0
  7. package/chameleon.sh +353 -0
  8. package/package.json +39 -0
  9. package/profiles/github_repos.json +1266 -0
  10. package/requirements.txt +5 -0
  11. package/scan.sh +12 -0
  12. package/score.sh +12 -0
  13. package/scripts/__init__.py +1 -0
  14. package/scripts/__main_tui__.py +11 -0
  15. package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
  16. package/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  17. package/scripts/__pycache__/__main_tui__.cpython-313.pyc +0 -0
  18. package/scripts/__pycache__/ats_ghost.cpython-313.pyc +0 -0
  19. package/scripts/__pycache__/cache.cpython-313.pyc +0 -0
  20. package/scripts/__pycache__/config.cpython-312.pyc +0 -0
  21. package/scripts/__pycache__/config.cpython-313.pyc +0 -0
  22. package/scripts/__pycache__/cover_letter.cpython-313.pyc +0 -0
  23. package/scripts/__pycache__/db.cpython-312.pyc +0 -0
  24. package/scripts/__pycache__/db.cpython-313.pyc +0 -0
  25. package/scripts/__pycache__/fonts.cpython-312.pyc +0 -0
  26. package/scripts/__pycache__/fonts.cpython-313.pyc +0 -0
  27. package/scripts/__pycache__/ghost.cpython-313.pyc +0 -0
  28. package/scripts/__pycache__/job_matcher.cpython-312.pyc +0 -0
  29. package/scripts/__pycache__/job_matcher.cpython-313.pyc +0 -0
  30. package/scripts/__pycache__/question.cpython-313.pyc +0 -0
  31. package/scripts/__pycache__/render.cpython-313.pyc +0 -0
  32. package/scripts/__pycache__/review.cpython-313.pyc +0 -0
  33. package/scripts/__pycache__/setup.cpython-312.pyc +0 -0
  34. package/scripts/__pycache__/tailor_brief.cpython-312.pyc +0 -0
  35. package/scripts/__pycache__/tailor_brief.cpython-313.pyc +0 -0
  36. package/scripts/__pycache__/tailor_cv.cpython-312.pyc +0 -0
  37. package/scripts/__pycache__/tailor_cv.cpython-313.pyc +0 -0
  38. package/scripts/__pycache__/tailor_prompts.cpython-312.pyc +0 -0
  39. package/scripts/__pycache__/tailor_prompts.cpython-313.pyc +0 -0
  40. package/scripts/__pycache__/tui_app.cpython-312.pyc +0 -0
  41. package/scripts/__pycache__/tui_app.cpython-313.pyc +0 -0
  42. package/scripts/ats_ghost.py +170 -0
  43. package/scripts/cache.py +63 -0
  44. package/scripts/cli.py +109 -0
  45. package/scripts/config.py +376 -0
  46. package/scripts/cover_letter.py +105 -0
  47. package/scripts/db.py +331 -0
  48. package/scripts/fonts.py +186 -0
  49. package/scripts/ghost.py +88 -0
  50. package/scripts/job_matcher.py +636 -0
  51. package/scripts/job_scanner/__init__.py +8 -0
  52. package/scripts/job_scanner/__main__.py +6 -0
  53. package/scripts/job_scanner/__pycache__/__init__.cpython-312.pyc +0 -0
  54. package/scripts/job_scanner/__pycache__/__init__.cpython-313.pyc +0 -0
  55. package/scripts/job_scanner/__pycache__/__main__.cpython-312.pyc +0 -0
  56. package/scripts/job_scanner/__pycache__/__main__.cpython-313.pyc +0 -0
  57. package/scripts/job_scanner/__pycache__/base.cpython-312.pyc +0 -0
  58. package/scripts/job_scanner/__pycache__/base.cpython-313.pyc +0 -0
  59. package/scripts/job_scanner/__pycache__/browser.cpython-312.pyc +0 -0
  60. package/scripts/job_scanner/__pycache__/browser.cpython-313.pyc +0 -0
  61. package/scripts/job_scanner/__pycache__/cli.cpython-313.pyc +0 -0
  62. package/scripts/job_scanner/__pycache__/registry.cpython-312.pyc +0 -0
  63. package/scripts/job_scanner/__pycache__/registry.cpython-313.pyc +0 -0
  64. package/scripts/job_scanner/__pycache__/scanner.cpython-312.pyc +0 -0
  65. package/scripts/job_scanner/__pycache__/scanner.cpython-313.pyc +0 -0
  66. package/scripts/job_scanner/base.py +197 -0
  67. package/scripts/job_scanner/browser.py +244 -0
  68. package/scripts/job_scanner/cli.py +6 -0
  69. package/scripts/job_scanner/parsers/__init__.py +24 -0
  70. package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-312.pyc +0 -0
  71. package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-313.pyc +0 -0
  72. package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-312.pyc +0 -0
  73. package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-313.pyc +0 -0
  74. package/scripts/job_scanner/parsers/__pycache__/arc.cpython-312.pyc +0 -0
  75. package/scripts/job_scanner/parsers/__pycache__/arc.cpython-313.pyc +0 -0
  76. package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-312.pyc +0 -0
  77. package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-313.pyc +0 -0
  78. package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-312.pyc +0 -0
  79. package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-313.pyc +0 -0
  80. package/scripts/job_scanner/parsers/__pycache__/flexjobs.cpython-313.pyc +0 -0
  81. package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-312.pyc +0 -0
  82. package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-313.pyc +0 -0
  83. package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-312.pyc +0 -0
  84. package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-313.pyc +0 -0
  85. package/scripts/job_scanner/parsers/__pycache__/indeed.cpython-313.pyc +0 -0
  86. package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-312.pyc +0 -0
  87. package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-313.pyc +0 -0
  88. package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-312.pyc +0 -0
  89. package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-313.pyc +0 -0
  90. package/scripts/job_scanner/parsers/__pycache__/linkedin.cpython-313.pyc +0 -0
  91. package/scripts/job_scanner/parsers/__pycache__/opentoworkremote.cpython-313.pyc +0 -0
  92. package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-312.pyc +0 -0
  93. package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-313.pyc +0 -0
  94. package/scripts/job_scanner/parsers/__pycache__/remote_rocketship.cpython-313.pyc +0 -0
  95. package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-312.pyc +0 -0
  96. package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-313.pyc +0 -0
  97. package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-312.pyc +0 -0
  98. package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-313.pyc +0 -0
  99. package/scripts/job_scanner/parsers/__pycache__/web3_career.cpython-313.pyc +0 -0
  100. package/scripts/job_scanner/parsers/__pycache__/wellfound.cpython-313.pyc +0 -0
  101. package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-312.pyc +0 -0
  102. package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-313.pyc +0 -0
  103. package/scripts/job_scanner/parsers/__pycache__/ycomb.cpython-313.pyc +0 -0
  104. package/scripts/job_scanner/parsers/adzuna.py +81 -0
  105. package/scripts/job_scanner/parsers/arc.py +65 -0
  106. package/scripts/job_scanner/parsers/crypto_careers.py +88 -0
  107. package/scripts/job_scanner/parsers/dailyremote.py +91 -0
  108. package/scripts/job_scanner/parsers/flexjobs.py +91 -0
  109. package/scripts/job_scanner/parsers/himalayas.py +47 -0
  110. package/scripts/job_scanner/parsers/hn_hiring.py +94 -0
  111. package/scripts/job_scanner/parsers/indeed.py +98 -0
  112. package/scripts/job_scanner/parsers/jobicy.py +60 -0
  113. package/scripts/job_scanner/parsers/jooble.py +68 -0
  114. package/scripts/job_scanner/parsers/linkedin.py +140 -0
  115. package/scripts/job_scanner/parsers/opentoworkremote.py +53 -0
  116. package/scripts/job_scanner/parsers/relocate.py +95 -0
  117. package/scripts/job_scanner/parsers/remote_rocketship.py +90 -0
  118. package/scripts/job_scanner/parsers/remoteok.py +63 -0
  119. package/scripts/job_scanner/parsers/remotive.py +57 -0
  120. package/scripts/job_scanner/parsers/web3_career.py +92 -0
  121. package/scripts/job_scanner/parsers/wellfound.py +94 -0
  122. package/scripts/job_scanner/parsers/weworkremotely.py +60 -0
  123. package/scripts/job_scanner/parsers/ycomb.py +83 -0
  124. package/scripts/job_scanner/registry.py +65 -0
  125. package/scripts/job_scanner/scanner.py +162 -0
  126. package/scripts/question.py +107 -0
  127. package/scripts/render.py +110 -0
  128. package/scripts/review.py +89 -0
  129. package/scripts/setup.py +139 -0
  130. package/scripts/tailor_brief.py +379 -0
  131. package/scripts/tailor_cv.py +746 -0
  132. package/scripts/tailor_prompts.py +101 -0
  133. package/scripts/tui/__init__.py +1 -0
  134. package/scripts/tui/__pycache__/__init__.cpython-312.pyc +0 -0
  135. package/scripts/tui/__pycache__/__init__.cpython-313.pyc +0 -0
  136. package/scripts/tui/__pycache__/app.cpython-312.pyc +0 -0
  137. package/scripts/tui/__pycache__/app.cpython-313.pyc +0 -0
  138. package/scripts/tui/__pycache__/helpers.cpython-312.pyc +0 -0
  139. package/scripts/tui/__pycache__/helpers.cpython-313.pyc +0 -0
  140. package/scripts/tui/__pycache__/screens.cpython-312.pyc +0 -0
  141. package/scripts/tui/__pycache__/screens.cpython-313.pyc +0 -0
  142. package/scripts/tui/__pycache__/widgets.cpython-312.pyc +0 -0
  143. package/scripts/tui/__pycache__/widgets.cpython-313.pyc +0 -0
  144. package/scripts/tui/app.py +1547 -0
  145. package/scripts/tui/helpers.py +65 -0
  146. package/scripts/tui/screens.py +642 -0
  147. package/scripts/tui/widgets.py +276 -0
  148. package/scripts/tui_app.py +25 -0
  149. package/scripts/wizard.py +109 -0
  150. package/tui.bat +50 -0
  151. 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()