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,636 @@
1
+ """
2
+ Dynamic Job Matcher — scores job postings against GitHub profile + CV.
3
+ 0 hardcoded skills. Everything comes from config, profile, and CV YAML.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from scripts.config import config
14
+
15
+ # ── Profile cache ────────────────────────────────────────────────────────────
16
+ _profile_cache: dict[str, dict] = {}
17
+ _profile_cache_key: str = ""
18
+
19
+
20
+ def _get_profile_cache_key(profile: dict, cv_path: Path | None = None) -> str:
21
+ """Build a cache key from profile mtime and CV mtime."""
22
+ parts = []
23
+ p = config.profile_path()
24
+ if p.exists():
25
+ parts.append(f"p:{p.stat().st_mtime_ns}")
26
+ cp = cv_path or config.master_cv_path()
27
+ if cp.exists():
28
+ parts.append(f"cv:{cp.stat().st_mtime_ns}")
29
+ return "|".join(parts)
30
+
31
+
32
+ def _cached_full_tech_profile(profile: dict) -> dict[str, set[str]]:
33
+ """Memoized version of _build_full_tech_profile — caches per profile+mtime."""
34
+ global _profile_cache_key, _profile_cache
35
+ key = _get_profile_cache_key(profile)
36
+ if key and key == _profile_cache_key:
37
+ return _profile_cache
38
+ result = _build_full_tech_profile(profile)
39
+ if key:
40
+ _profile_cache_key = key
41
+ _profile_cache = result
42
+ return result
43
+
44
+
45
+ # ── Terms known to be tech languages/frameworks (used for CV extraction) ────
46
+ _KNOWN_LANG_TERMS: set[str] = {
47
+ "python", "rust", "java", "javascript", "typescript", "go", "golang",
48
+ "ruby", "php", "c++", "c#", "swift", "kotlin", "scala", "r",
49
+ "dart", "elixir", "clojure", "haskell", "lua", "perl", "solidity",
50
+ "vue", "react", "angular", "svelte", "node", "deno", "bun",
51
+ "fastapi", "django", "flask", "express", "spring", "rails",
52
+ "tauri", "electron", "next.js", "nuxt", "sveltekit",
53
+ "tailwind", "bootstrap", "supabase", "firebase",
54
+ "docker", "kubernetes", "k8s", "aws", "azure", "gcp",
55
+ "langchain", "crewai", "langgraph", "pytorch", "tensorflow",
56
+ "postgresql", "postgres", "mysql", "mongodb", "redis",
57
+ "graphql", "rest", "grpc", "websocket",
58
+ }
59
+
60
+
61
+ # ── Regex patterns built dynamically from all known tech terms ──────────────
62
+
63
+
64
+ def _pattern(words: list[str]) -> re.Pattern:
65
+ """Build a case-insensitive regex pattern matching any of the words."""
66
+ escaped = sorted({re.escape(w) for w in words}, key=len, reverse=True)
67
+ return re.compile(r"\b(?:" + "|".join(escaped) + r")\b", re.IGNORECASE)
68
+
69
+
70
+ def _build_dynamic_patterns(
71
+ profile: dict,
72
+ ) -> dict[str, dict[str, re.Pattern]]:
73
+ """Build all matching patterns from the profile alone — no hardcoded lists."""
74
+ # Collect all unique tech terms from profile
75
+ tech_stack = [t.lower() for t in profile.get("skills_summary", {}).get("tech_stack", [])]
76
+ languages = [l.lower() for l in profile.get("skills_summary", {}).get("languages", {}).keys()]
77
+ domains = [d.lower() for d in profile.get("skills_summary", {}).get("domains", [])]
78
+ infra = [inf.lower() for inf in profile.get("skills_summary", {}).get("infrastructure", [])]
79
+
80
+ # Also collect from repo tech stacks to catch everything
81
+ for repo in profile.get("repos", []):
82
+ for t in repo.get("tech_stack_inferred", []):
83
+ if t.lower() not in tech_stack:
84
+ tech_stack.append(t.lower())
85
+ for d in repo.get("domains", []):
86
+ if d.lower() not in domains:
87
+ domains.append(d.lower())
88
+ repo_lang = repo.get("language", "")
89
+ if repo_lang and repo_lang.lower() not in languages:
90
+ languages.append(repo_lang.lower())
91
+
92
+ # Also extract skills from CV YAML — catches skills not on GitHub
93
+ cv_path = config.master_cv_path()
94
+ if cv_path.exists():
95
+ cv_kw = _build_cv_keywords(cv_path)
96
+ known_langs = _KNOWN_LANG_TERMS
97
+ for kw in cv_kw:
98
+ if kw in known_langs and kw.lower() not in languages:
99
+ languages.append(kw.lower())
100
+ if kw in known_langs and kw.lower() not in tech_stack:
101
+ tech_stack.append(kw.lower())
102
+
103
+ # Build patterns — one per term so we can identify which matched
104
+ lang_patterns: dict[str, re.Pattern] = {}
105
+ for lang in languages:
106
+ escaped = re.escape(lang)
107
+ lang_patterns[lang] = re.compile(r"\b" + escaped + r"\b", re.IGNORECASE)
108
+ # Handle special cases
109
+ if lang == "c++":
110
+ lang_patterns[lang] = re.compile(r"\bc\+\+\b", re.IGNORECASE)
111
+ if lang == "c#":
112
+ lang_patterns[lang] = re.compile(r"\bc#\b", re.IGNORECASE)
113
+ if lang == ".net":
114
+ lang_patterns[lang] = re.compile(r"\b\.net\b", re.IGNORECASE)
115
+ if "javascript" in lang:
116
+ lang_patterns[lang] = re.compile(r"\bjavascript\b|\bjs\b", re.IGNORECASE)
117
+ if "typescript" in lang:
118
+ lang_patterns[lang] = re.compile(r"\btypescript\b", re.IGNORECASE)
119
+
120
+ fw_patterns: dict[str, re.Pattern] = {}
121
+ for fw in tech_stack:
122
+ escaped = re.escape(fw)
123
+ fw_patterns[fw] = re.compile(r"\b" + escaped.replace(r"\.", r"\.?") + r"\b", re.IGNORECASE)
124
+
125
+ domain_patterns: dict[str, re.Pattern] = {}
126
+ for dom in domains:
127
+ escaped = re.escape(dom)
128
+ domain_patterns[dom] = re.compile(r"\b" + escaped + r"\b", re.IGNORECASE)
129
+
130
+ infra_patterns: dict[str, re.Pattern] = {}
131
+ for inf in infra:
132
+ escaped = re.escape(inf)
133
+ infra_patterns[inf] = re.compile(r"\b" + escaped + r"\b", re.IGNORECASE)
134
+
135
+ return {
136
+ "languages": lang_patterns,
137
+ "frameworks": fw_patterns,
138
+ "domains": domain_patterns,
139
+ "infra": infra_patterns,
140
+ }
141
+
142
+
143
+ def _build_cv_keywords(cv_path: Path) -> list[str]:
144
+ """Extract all skill-related keywords from CV YAML — no hardcoding."""
145
+ if not cv_path.exists():
146
+ return []
147
+ try:
148
+ import yaml
149
+ with open(cv_path) as f:
150
+ cv = yaml.safe_load(f)
151
+ except Exception:
152
+ return _fallback_cv_text(cv_path)
153
+
154
+ keywords: list[str] = []
155
+ sections = cv.get("cv", {}).get("sections", {})
156
+
157
+ # Extract from summary
158
+ summary = sections.get("summary", [])
159
+ if isinstance(summary, list):
160
+ for s in summary:
161
+ if isinstance(s, str):
162
+ keywords.extend(s.lower().split())
163
+ elif isinstance(summary, str):
164
+ keywords.extend(summary.lower().split())
165
+
166
+ # Extract from skills section
167
+ for section_name, section_data in sections.items():
168
+ if not isinstance(section_data, list):
169
+ continue
170
+ for entry in section_data:
171
+ if isinstance(entry, dict):
172
+ for val in entry.values():
173
+ if isinstance(val, str):
174
+ keywords.extend(val.lower().split())
175
+ elif isinstance(val, list):
176
+ for item in val:
177
+ if isinstance(item, str):
178
+ keywords.extend(item.lower().split())
179
+
180
+ # Extract from experience highlights
181
+ for section_name, section_data in sections.items():
182
+ if not isinstance(section_data, list):
183
+ continue
184
+ for entry in section_data:
185
+ if isinstance(entry, dict):
186
+ for highlight in entry.get("highlights", []):
187
+ if isinstance(highlight, str):
188
+ keywords.extend(re.sub(r"[^a-z0-9\s]", "", highlight.lower()).split())
189
+
190
+ # Deduplicate and filter common words
191
+ stopwords = {
192
+ "the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for",
193
+ "of", "with", "by", "from", "as", "is", "was", "are", "were", "be",
194
+ "been", "being", "have", "has", "had", "do", "does", "did", "will",
195
+ "would", "could", "should", "may", "might", "shall", "can", "need",
196
+ "using", "including", "such", "like", "also", "very", "just", "about",
197
+ }
198
+ seen: set[str] = set()
199
+ result: list[str] = []
200
+ for kw in keywords:
201
+ clean = kw.strip(".,;:!?()[]{}\"'")
202
+ if clean not in stopwords and len(clean) > 2 and clean not in seen:
203
+ seen.add(clean)
204
+ result.append(clean)
205
+ return result
206
+
207
+
208
+ def _fallback_cv_text(cv_path: Path) -> list[str]:
209
+ """Fallback: extract keywords from raw YAML text."""
210
+ if not cv_path.exists():
211
+ return []
212
+ text = cv_path.read_text()
213
+ words = re.findall(r"[a-zA-Z][a-zA-Z0-9_+#.-]{2,}", text.lower())
214
+ stopwords = {
215
+ "the", "and", "for", "that", "this", "with", "from", "your", "our",
216
+ "their", "its", "are", "was", "have", "has", "had", "been", "being",
217
+ "will", "would", "could", "should", "may", "might", "shall", "can",
218
+ "using", "including", "such", "like", "also", "very", "just",
219
+ }
220
+ seen: set[str] = set()
221
+ result: list[str] = []
222
+ for w in words:
223
+ if w not in stopwords and w not in seen:
224
+ seen.add(w)
225
+ result.append(w)
226
+ return result
227
+
228
+
229
+ # ── Main API ────────────────────────────────────────────────────────────────
230
+
231
+
232
+ def load_profile(path: Path | None = None) -> dict:
233
+ """Load GitHub profile from JSON. Returns empty profile on error."""
234
+ p = path or config.profile_path()
235
+ try:
236
+ with open(p) as f:
237
+ return json.load(f)
238
+ except (FileNotFoundError, json.JSONDecodeError, OSError) as e:
239
+ return {
240
+ "repos": [],
241
+ "skills_summary": {"languages": {}, "tech_stack": [], "domains": [], "infrastructure": []},
242
+ }
243
+
244
+
245
+ def extract_job_signals(job_text: str, profile: dict) -> dict:
246
+ """Extract keywords from job description using profile-built patterns."""
247
+ text_lower = job_text.lower()
248
+ patterns = _build_dynamic_patterns(profile)
249
+
250
+ signals: dict[str, Any] = {
251
+ "languages": [],
252
+ "frameworks": [],
253
+ "domains": [],
254
+ "infra": [],
255
+ "keywords": [],
256
+ "seniority": "mid",
257
+ "remote": False,
258
+ }
259
+
260
+ for cat in ("languages", "frameworks", "domains", "infra"):
261
+ for term, pat in patterns.get(cat, {}).items():
262
+ if pat.search(text_lower):
263
+ signals[cat].append(term)
264
+
265
+ # Seniority
266
+ if re.search(r"\bsenior\b|\bsr\.?\b|\bstaff\b|\blead\b|\bprincipal\b", text_lower, re.IGNORECASE):
267
+ signals["seniority"] = "senior"
268
+ elif re.search(r"\bjunior\b|\bentry.?level\b|\bintern\b", text_lower, re.IGNORECASE):
269
+ signals["seniority"] = "junior"
270
+
271
+ # Remote
272
+ if re.search(r"\bremote\b|\bworldwide\b|\banywhere\b|\bglobal\b|\bremoto\b", text_lower, re.IGNORECASE):
273
+ signals["remote"] = True
274
+
275
+ return signals
276
+
277
+
278
+ def score_languages(
279
+ job_signals: dict,
280
+ profile: dict,
281
+ ) -> tuple[int, list[str]]:
282
+ """Score language match — fully dynamic, no hardcoded lists."""
283
+ job_langs: set[str] = set(job_signals.get("languages", []))
284
+ profile_all = _cached_full_tech_profile(profile)
285
+ profile_langs = profile_all["languages"]
286
+
287
+ overlap: set[str] = job_langs & profile_langs
288
+ related: set[str] = set()
289
+ adj = config.lang_adjacency()
290
+ for jl in job_langs:
291
+ for rel in adj.get(jl, []):
292
+ if rel in profile_langs:
293
+ related.add(jl)
294
+
295
+ n_overlap = len(overlap)
296
+ w3 = config.get("lang_overlap_3_score", 25)
297
+ w2 = config.get("lang_overlap_2_score", 20)
298
+ w1 = config.get("lang_overlap_1_score", 15)
299
+ wr = config.get("lang_related_score", 10)
300
+
301
+ if n_overlap >= 3:
302
+ score = w3
303
+ elif n_overlap == 2:
304
+ score = w2
305
+ elif n_overlap == 1:
306
+ score = w1
307
+ elif related:
308
+ score = wr
309
+ else:
310
+ score = 0
311
+
312
+ return score, sorted(overlap | related)
313
+
314
+
315
+ def _build_full_tech_profile(profile: dict) -> dict[str, set[str]]:
316
+ """Build complete tech landscape from profile + repos + CV."""
317
+ result = {
318
+ "languages": set(l.lower() for l in profile.get("skills_summary", {}).get("languages", {}).keys()),
319
+ "frameworks": set(t.lower() for t in profile.get("skills_summary", {}).get("tech_stack", [])),
320
+ "domains": set(d.lower() for d in profile.get("skills_summary", {}).get("domains", [])),
321
+ }
322
+ for repo in profile.get("repos", []):
323
+ rl = repo.get("language", "")
324
+ if rl:
325
+ result["languages"].add(rl.lower())
326
+ for t in repo.get("tech_stack_inferred", []):
327
+ result["frameworks"].add(t.lower())
328
+ for d in repo.get("domains", []):
329
+ result["domains"].add(d.lower())
330
+ cv_path = config.master_cv_path()
331
+ if cv_path.exists():
332
+ cv_kw = _build_cv_keywords(cv_path)
333
+ for kw in cv_kw:
334
+ if kw in _KNOWN_LANG_TERMS:
335
+ result["languages"].add(kw.lower())
336
+ result["frameworks"].add(kw.lower())
337
+ return result
338
+
339
+
340
+ def score_frameworks(job_signals: dict, profile: dict) -> tuple[int, list[str]]:
341
+ """Score framework/tech match — fully dynamic."""
342
+ job_fws: set[str] = set(job_signals.get("frameworks", []))
343
+ profile_all = _cached_full_tech_profile(profile)
344
+ profile_techs = profile_all["frameworks"]
345
+
346
+ overlap: set[str] = job_fws & profile_techs
347
+
348
+ # Adjacent matches (react → vue.js, next.js → nuxt.js, etc.)
349
+ adjacent: set[str] = set()
350
+ adj_map: dict[str, str] = {
351
+ "react": "vue", "react.js": "vue", "vue": "react",
352
+ "next.js": "nuxt", "nuxt": "next.js",
353
+ "angular": "vue", "svelte": "vue",
354
+ "express": "fastapi", "django": "fastapi",
355
+ }
356
+ for jf in job_fws:
357
+ mapped = adj_map.get(jf)
358
+ if mapped and mapped in profile_techs:
359
+ adjacent.add(f"{jf}->{mapped}")
360
+
361
+ n_overlap = len(overlap)
362
+ w3 = config.get("fw_overlap_3_score", 25)
363
+ w2 = config.get("fw_overlap_2_score", 20)
364
+ w1 = config.get("fw_overlap_1_score", 15)
365
+ wa = config.get("fw_adjacent_score", 8)
366
+
367
+ if n_overlap >= 3:
368
+ score = w3
369
+ elif n_overlap == 2:
370
+ score = w2
371
+ elif n_overlap == 1:
372
+ score = w1
373
+ elif adjacent:
374
+ score = wa
375
+ else:
376
+ score = 0
377
+
378
+ return score, sorted(overlap | adjacent)
379
+
380
+
381
+ def score_domains(job_signals: dict, profile: dict) -> tuple[int, list[str]]:
382
+ """Score domain match — fully dynamic with adjacency."""
383
+ job_domains: set[str] = set(job_signals.get("domains", []))
384
+ profile_all = _cached_full_tech_profile(profile)
385
+ profile_domains = profile_all["domains"]
386
+
387
+ overlap: set[str] = job_domains & profile_domains
388
+
389
+ adj_map = config.domain_adjacency()
390
+ adjacent_count = 0
391
+ for jd in job_domains:
392
+ for adj_domain in adj_map.get(jd, []):
393
+ if adj_domain in profile_domains:
394
+ adjacent_count += 1
395
+
396
+ n_overlap = len(overlap)
397
+ w2 = config.get("dom_overlap_2_score", 25)
398
+ w1 = config.get("dom_overlap_1_score", 20)
399
+ wa2 = config.get("dom_adjacent_2_score", 15)
400
+ wa1 = config.get("dom_adjacent_1_score", 10)
401
+
402
+ if n_overlap >= 2:
403
+ score = w2
404
+ elif n_overlap == 1:
405
+ score = w1
406
+ elif adjacent_count >= config.get("domain_adjacent_min_strong", 2):
407
+ score = wa2
408
+ elif adjacent_count >= config.get("domain_adjacent_min_good", 1):
409
+ score = wa1
410
+ else:
411
+ score = 0
412
+
413
+ return score, sorted(overlap)
414
+
415
+
416
+ def score_cv_alignment(
417
+ job_signals: dict,
418
+ profile: dict,
419
+ raw_text: str = "",
420
+ cv_path: Path | None = None,
421
+ ) -> tuple[int, list[str]]:
422
+ """Score CV alignment — dynamically extracted from CV YAML."""
423
+ cv_keywords = _build_cv_keywords(cv_path or config.master_cv_path())
424
+ text_lower = raw_text.lower() if raw_text else " ".join(
425
+ item for cat in ("languages", "frameworks", "domains", "infra")
426
+ for item in job_signals.get(cat, [])
427
+ ).lower()
428
+
429
+ matched: list[str] = []
430
+ for kw in cv_keywords:
431
+ if len(kw) < 3:
432
+ continue
433
+ if kw in text_lower:
434
+ matched.append(kw)
435
+
436
+ # Score thresholds from config
437
+ n_matched = len(matched)
438
+ m5 = config.get("cv_match_5_score", 25)
439
+ m3 = config.get("cv_match_3_score", 20)
440
+ m1 = config.get("cv_match_1_score", 15)
441
+
442
+ if n_matched >= config.get("cv_alignment_min_match_strong", 5):
443
+ score = m5
444
+ elif n_matched >= config.get("cv_alignment_min_match_good", 3):
445
+ score = m3
446
+ elif n_matched >= config.get("cv_alignment_min_match_moderate", 1):
447
+ score = m1
448
+ else:
449
+ score = 0
450
+
451
+ return score, matched
452
+
453
+
454
+ def find_matching_repos(job_signals: dict, profile: dict) -> list[str]:
455
+ """Find repos matching job requirements — fully dynamic."""
456
+ _cached_full_tech_profile(profile) # warm the cache for upstream callers
457
+ job_langs: set[str] = set(job_signals.get("languages", []))
458
+ job_fws: set[str] = set(job_signals.get("frameworks", []))
459
+ job_domains: set[str] = set(job_signals.get("domains", []))
460
+
461
+ matches: list[tuple[str, int, str]] = []
462
+
463
+ for repo in profile.get("repos", []):
464
+ repo_techs: set[str] = set(t.lower() for t in repo.get("tech_stack_inferred", []))
465
+ repo_domains: set[str] = set(d.lower() for d in repo.get("domains", []))
466
+ repo_lang = (repo.get("language") or "").lower()
467
+
468
+ lang_match = bool(job_langs & repo_techs) or bool(repo_lang and repo_lang in job_langs)
469
+ fw_match = bool(job_fws & repo_techs)
470
+ domain_match = bool(job_domains & repo_domains)
471
+
472
+ match_count = int(lang_match) + int(fw_match) + int(domain_match)
473
+ if match_count:
474
+ desc = repo.get("description") or repo.get("summary", "") or ""
475
+ matches.append((repo["name"], match_count, desc))
476
+
477
+ matches.sort(key=lambda x: (-x[1], x[0]))
478
+ max_repos = config.get("max_matching_repos", 5)
479
+ return [m[0] for m in matches[:max_repos]]
480
+
481
+
482
+ def score_job(
483
+ job_text: str,
484
+ profile: dict,
485
+ cv_path: Path | None = None,
486
+ ) -> dict:
487
+ """Score a single job posting against profile + CV."""
488
+ signals = extract_job_signals(job_text, profile)
489
+
490
+ lang_score, lang_matched = score_languages(signals, profile)
491
+ fw_score, fw_matched = score_frameworks(signals, profile)
492
+ dom_score, dom_matched = score_domains(signals, profile)
493
+ cv_score, cv_matched = score_cv_alignment(signals, profile, raw_text=job_text, cv_path=cv_path)
494
+
495
+ total = lang_score + fw_score + dom_score + cv_score
496
+ matching_repos = find_matching_repos(signals, profile)
497
+
498
+ # Recommendation
499
+ strong_th = config.get("score_threshold_strong", 80)
500
+ good_th = config.get("score_threshold_good", 60)
501
+ moderate_th = config.get("score_threshold_moderate", 40)
502
+
503
+ if total >= strong_th:
504
+ highlights = (lang_matched + fw_matched)[:4]
505
+ rec = f"Strong match — tailor CV emphasizing {', '.join(highlights) if highlights else 'your strengths'}"
506
+ elif total >= good_th:
507
+ highlights = (lang_matched + fw_matched)[:3]
508
+ rec = f"Good match — highlight {', '.join(highlights) if highlights else 'relevant experience'}"
509
+ elif total >= moderate_th:
510
+ highlights = (lang_matched + fw_matched)[:2]
511
+ rec = f"Moderate match — emphasize {', '.join(highlights) if highlights else 'transferable skills'}"
512
+ else:
513
+ rec = "Weak match — consider other opportunities"
514
+
515
+ return {
516
+ "total_score": total,
517
+ "breakdown": {
518
+ "languages": lang_score,
519
+ "frameworks": fw_score,
520
+ "domains": dom_score,
521
+ "cv_alignment": cv_score,
522
+ },
523
+ "matched_languages": lang_matched,
524
+ "matched_frameworks": fw_matched,
525
+ "matched_domains": dom_matched,
526
+ "matched_cv_keywords": cv_matched,
527
+ "matching_repos": matching_repos,
528
+ "seniority": signals["seniority"],
529
+ "remote": signals["remote"],
530
+ "recommendation": rec,
531
+ }
532
+
533
+
534
+ def main():
535
+ profile = load_profile()
536
+
537
+ if len(sys.argv) < 2:
538
+ # Check for piped stdin
539
+ if not sys.stdin.isatty():
540
+ job_text = sys.stdin.read().strip()
541
+ if not job_text:
542
+ print("Error: no input provided", file=sys.stderr)
543
+ sys.exit(1)
544
+ result = score_job(job_text, profile)
545
+ print(json.dumps(result, indent=2))
546
+ sys.exit(0)
547
+ print("Usage: job_matcher.py <job_text_or_file_or_url> [--json] [--cv <path>]")
548
+ sys.exit(1)
549
+
550
+ args = sys.argv[1:]
551
+ as_json = "--json" in args
552
+ cv_path = None
553
+ jd_arg = ""
554
+
555
+ i = 0
556
+ while i < len(args):
557
+ if args[i] == "--json":
558
+ i += 1
559
+ elif args[i] == "--cv" and i + 1 < len(args):
560
+ cv_path = Path(args[i + 1])
561
+ i += 2
562
+ elif not args[i].startswith("--"):
563
+ if not jd_arg:
564
+ jd_arg = args[i]
565
+ i += 1
566
+ else:
567
+ i += 1
568
+
569
+ if not jd_arg:
570
+ print("Error: no job description text, file, or URL provided", file=sys.stderr)
571
+ sys.exit(1)
572
+
573
+ # Handle URL input — crawl it
574
+ if jd_arg.startswith("http://") or jd_arg.startswith("https://"):
575
+ try:
576
+ import httpx
577
+ resp = httpx.get(jd_arg, timeout=30, follow_redirects=True, headers={
578
+ "User-Agent": "Mozilla/5.0 (compatible; ChameleonBot/1.0)"
579
+ })
580
+ resp.raise_for_status()
581
+ from html.parser import HTMLParser
582
+ class _TextExtractor(HTMLParser):
583
+ def __init__(self):
584
+ super().__init__()
585
+ self.parts = []
586
+ self._skip = False
587
+ def handle_starttag(self, tag, attrs):
588
+ if tag in ("script", "style"):
589
+ self._skip = True
590
+ def handle_endtag(self, tag):
591
+ if tag in ("script", "style"):
592
+ self._skip = False
593
+ def handle_data(self, data):
594
+ if not self._skip:
595
+ self.parts.append(data)
596
+ te = _TextExtractor()
597
+ te.feed(resp.text)
598
+ import re as _re
599
+ job_text = _re.sub(r"\s+", " ", " ".join(te.parts)).strip()
600
+ except ImportError:
601
+ import urllib.request
602
+ import re as _re
603
+ import html as _html_mod
604
+ req = urllib.request.Request(jd_arg, headers={"User-Agent": "Mozilla/5.0"})
605
+ with urllib.request.urlopen(req, timeout=30) as resp:
606
+ raw = resp.read().decode("utf-8", errors="replace")
607
+ raw = _re.sub(r"<script[^>]*>.*?</script>", "", raw, flags=_re.DOTALL | _re.IGNORECASE)
608
+ raw = _re.sub(r"<style[^>]*>.*?</style>", "", raw, flags=_re.DOTALL | _re.IGNORECASE)
609
+ raw = _re.sub(r"<[^>]+>", " ", raw)
610
+ raw = _html_mod.unescape(raw)
611
+ job_text = _re.sub(r"\s+", " ", raw).strip()[:10000]
612
+ except Exception as e:
613
+ print(f"Error crawling URL: {e}", file=sys.stderr)
614
+ sys.exit(1)
615
+ elif Path(jd_arg).is_file():
616
+ job_text = Path(jd_arg).read_text()
617
+ else:
618
+ job_text = jd_arg
619
+
620
+ result = score_job(job_text, profile, cv_path)
621
+
622
+ if as_json:
623
+ print(json.dumps(result, indent=2))
624
+ else:
625
+ print(f"\n=== Job Match Score: {result['total_score']}/100 ===")
626
+ print(f"Languages: {result['breakdown']['languages']}/25 -> {result['matched_languages']}")
627
+ print(f"Frameworks: {result['breakdown']['frameworks']}/25 -> {result['matched_frameworks']}")
628
+ print(f"Domains: {result['breakdown']['domains']}/25 -> {result['matched_domains']}")
629
+ print(f"CV Align: {result['breakdown']['cv_alignment']}/25 -> {result['matched_cv_keywords'][:5]}")
630
+ print(f"\nMatching repos: {', '.join(result['matching_repos'])}")
631
+ print(f"Seniority: {result['seniority']} | Remote: {result['remote']}")
632
+ print(f"\n-> {result['recommendation']}")
633
+
634
+
635
+ if __name__ == "__main__":
636
+ main()
@@ -0,0 +1,8 @@
1
+ """Chameleon Job Scanner — search jobs across 20 platforms."""
2
+
3
+ from .base import BaseParser, JobListing
4
+
5
+ __all__ = [
6
+ "BaseParser",
7
+ "JobListing",
8
+ ]
@@ -0,0 +1,6 @@
1
+ """Chameleon Job Scanner — run as: python3 -m scripts.job_scanner.scanner"""
2
+
3
+ from .scanner import main
4
+
5
+ if __name__ == "__main__":
6
+ main()