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,746 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Chameleon CV Tailor — three-pass pipeline:
|
|
3
|
+
1. Algorithmic TailoringBrief (deep JD analysis + experience mapping + repo matching)
|
|
4
|
+
2. One-shot AI tailor (opencode run → tailored YAML)
|
|
5
|
+
3. AI review pass (opencode run → alignment audit + corrections)
|
|
6
|
+
4. Render (rendercv → PDF)
|
|
7
|
+
|
|
8
|
+
0 hardcoded — all driven from profile, CV, and config.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import argparse
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
|
|
26
|
+
from scripts.config import config
|
|
27
|
+
from scripts.tailor_brief import (
|
|
28
|
+
generate_brief,
|
|
29
|
+
load_cv_yaml,
|
|
30
|
+
render_brief_text,
|
|
31
|
+
)
|
|
32
|
+
from scripts.tailor_prompts import TAILOR_PROMPT, REVIEW_PROMPT, FIX_PROMPT
|
|
33
|
+
# ats_ghost is imported lazily in tailor_and_render (optional, requires pypdf)
|
|
34
|
+
|
|
35
|
+
# ── Global subprocess registry for kill-on-shutdown ────────────────────────
|
|
36
|
+
_running_processes: list[subprocess.Popen] = []
|
|
37
|
+
_processes_lock = threading.Lock()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _track_process(proc: subprocess.Popen) -> None:
|
|
41
|
+
with _processes_lock:
|
|
42
|
+
_running_processes.append(proc)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _untrack_process(proc: subprocess.Popen) -> None:
|
|
46
|
+
with _processes_lock:
|
|
47
|
+
try:
|
|
48
|
+
_running_processes.remove(proc)
|
|
49
|
+
except ValueError:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def kill_all_processes() -> None:
|
|
54
|
+
"""Kill every tracked subprocess. Safe to call from any thread."""
|
|
55
|
+
with _processes_lock:
|
|
56
|
+
for proc in _running_processes:
|
|
57
|
+
try:
|
|
58
|
+
proc.kill()
|
|
59
|
+
proc.wait(timeout=5)
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
_running_processes.clear()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_running = False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _now_ms() -> int:
|
|
69
|
+
return int(time.time() * 1000)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _opencode_run(prompt: str, timeout_s: int = 120) -> tuple[bool, str]:
|
|
73
|
+
"""Run opencode with a one-shot prompt. Returns (success, response_text).
|
|
74
|
+
|
|
75
|
+
The subprocess is registered in the global kill registry so it can be
|
|
76
|
+
aborted on crash/shutdown from any thread.
|
|
77
|
+
"""
|
|
78
|
+
opencode_bin = config.opencode_bin()
|
|
79
|
+
if not opencode_bin.exists():
|
|
80
|
+
return False, f"opencode not found at {opencode_bin}"
|
|
81
|
+
|
|
82
|
+
env = {
|
|
83
|
+
**config.env(),
|
|
84
|
+
"OPENCODE_CLI_DISABLE_ANIMATION": "true",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Always run opencode from the chameleon project root so it finds config
|
|
88
|
+
chameleon_root = Path(__file__).resolve().parent.parent
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
proc = subprocess.Popen(
|
|
92
|
+
[str(opencode_bin), "run", prompt],
|
|
93
|
+
stdout=subprocess.PIPE,
|
|
94
|
+
stderr=subprocess.PIPE,
|
|
95
|
+
text=True,
|
|
96
|
+
cwd=str(chameleon_root),
|
|
97
|
+
env=env,
|
|
98
|
+
)
|
|
99
|
+
except FileNotFoundError:
|
|
100
|
+
return False, f"opencode binary not executable at {opencode_bin}"
|
|
101
|
+
|
|
102
|
+
_track_process(proc)
|
|
103
|
+
try:
|
|
104
|
+
out, err = proc.communicate(timeout=timeout_s)
|
|
105
|
+
except subprocess.TimeoutExpired:
|
|
106
|
+
proc.kill()
|
|
107
|
+
proc.wait(timeout=5)
|
|
108
|
+
return False, f"opencode timed out after {timeout_s}s"
|
|
109
|
+
except Exception as e:
|
|
110
|
+
proc.kill()
|
|
111
|
+
proc.wait(timeout=5)
|
|
112
|
+
return False, str(e)
|
|
113
|
+
finally:
|
|
114
|
+
_untrack_process(proc)
|
|
115
|
+
|
|
116
|
+
if proc.returncode != 0:
|
|
117
|
+
stderr = (err or "").strip()
|
|
118
|
+
return False, (stderr or f"opencode exited non-zero ({proc.returncode})")[:200]
|
|
119
|
+
out = (out or "").strip()
|
|
120
|
+
if not out:
|
|
121
|
+
return False, (err or "opencode produced no output").strip()[:200]
|
|
122
|
+
return True, out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _extract_yaml_from_response(text: str) -> str:
|
|
126
|
+
"""Extract YAML from a response that may contain markdown fences or file paths."""
|
|
127
|
+
# Case 1: the AI wrote a file and tells us the path
|
|
128
|
+
m = re.search(r"(?:written to|saved to|saved at|saved as|→|->)[\s:]*`?([^\s`]+\.yaml)`?", text, re.IGNORECASE)
|
|
129
|
+
if m:
|
|
130
|
+
path_candidate = m.group(1).strip().strip("'\"")
|
|
131
|
+
p = Path(path_candidate)
|
|
132
|
+
if p.exists():
|
|
133
|
+
return p.read_text()
|
|
134
|
+
p2 = Path.cwd() / path_candidate
|
|
135
|
+
if p2.exists():
|
|
136
|
+
return p2.read_text()
|
|
137
|
+
|
|
138
|
+
# Case 2: between ```yaml fences
|
|
139
|
+
m = re.search(r"```(?:yaml)?\s*\n(.+?)\n```", text, re.DOTALL)
|
|
140
|
+
if m:
|
|
141
|
+
return m.group(1).strip()
|
|
142
|
+
|
|
143
|
+
# Case 3: between plain ``` fences that happen to be YAML
|
|
144
|
+
m = re.search(r"```\s*\n(cv:.+?)```", text, re.DOTALL)
|
|
145
|
+
if m:
|
|
146
|
+
return m.group(1).strip()
|
|
147
|
+
|
|
148
|
+
# Case 4: content starting with cv: (multiline)
|
|
149
|
+
m = re.search(r"(?:^|\n)(cv:[\s\S]*)", text)
|
|
150
|
+
if m:
|
|
151
|
+
return m.group(1).strip()
|
|
152
|
+
|
|
153
|
+
return text.strip()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _extract_json_from_response(text: str) -> dict | None:
|
|
157
|
+
"""Extract JSON from a response that may have markdown fences."""
|
|
158
|
+
m = re.search(r"```(?:json)?\s*\n(.+?)\n```", text, re.DOTALL)
|
|
159
|
+
raw = m.group(1) if m else text.strip()
|
|
160
|
+
try:
|
|
161
|
+
return json.loads(raw)
|
|
162
|
+
except json.JSONDecodeError:
|
|
163
|
+
# Try to find {...} in the response
|
|
164
|
+
m2 = re.search(r"(\{.*\})", raw, re.DOTALL)
|
|
165
|
+
if m2:
|
|
166
|
+
try:
|
|
167
|
+
return json.loads(m2.group(1))
|
|
168
|
+
except json.JSONDecodeError:
|
|
169
|
+
pass
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# Pass 1: Algorithmic Tailoring Brief
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def build_tailoring_brief(
|
|
179
|
+
jd_text: str, profile: dict | None = None
|
|
180
|
+
) -> dict:
|
|
181
|
+
"""Build a comprehensive TailoringBrief from JD + profile."""
|
|
182
|
+
cv = load_cv_yaml(config.master_cv_path())
|
|
183
|
+
if profile is None:
|
|
184
|
+
from scripts.job_matcher import load_profile
|
|
185
|
+
profile = load_profile()
|
|
186
|
+
brief = generate_brief(jd_text, profile, cv)
|
|
187
|
+
return brief
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Pass 2: One-shot AI Tailor via opencode
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
_TAILOR_RETRIES = 2
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def ai_tailor(jd_text: str, brief_text: str, cv_yaml: str) -> tuple[bool, str]:
|
|
198
|
+
"""Run one-shot opencode tailor. Returns (success, tailored_yaml)."""
|
|
199
|
+
prompt = TAILOR_PROMPT.format(cv_yaml=cv_yaml, brief_text=brief_text)
|
|
200
|
+
for attempt in range(1 + _TAILOR_RETRIES):
|
|
201
|
+
ok, response = _opencode_run(prompt, timeout_s=180)
|
|
202
|
+
if not ok:
|
|
203
|
+
if attempt < _TAILOR_RETRIES:
|
|
204
|
+
continue
|
|
205
|
+
return False, f"opencode call failed: {response}"
|
|
206
|
+
|
|
207
|
+
yaml_text = _extract_yaml_from_response(response)
|
|
208
|
+
if not yaml_text.startswith("cv:"):
|
|
209
|
+
if attempt < _TAILOR_RETRIES:
|
|
210
|
+
continue
|
|
211
|
+
return False, f"Response does not start with 'cv:':\n{yaml_text[:200]}"
|
|
212
|
+
|
|
213
|
+
# Validate it parses as YAML
|
|
214
|
+
try:
|
|
215
|
+
parsed = yaml.safe_load(yaml_text)
|
|
216
|
+
if not parsed or "cv" not in parsed:
|
|
217
|
+
if attempt < _TAILOR_RETRIES:
|
|
218
|
+
continue
|
|
219
|
+
return False, "Parsed YAML missing 'cv' top-level key"
|
|
220
|
+
except Exception as e:
|
|
221
|
+
if attempt < _TAILOR_RETRIES:
|
|
222
|
+
continue
|
|
223
|
+
return False, f"YAML parse failed: {e}"
|
|
224
|
+
|
|
225
|
+
return True, yaml_text
|
|
226
|
+
|
|
227
|
+
return False, "Max retries exceeded"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
# Pass 3: AI Review Pass
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
_REVIEW_RETRIES = 2
|
|
235
|
+
_REVIEW_MIN_SCORE = 70
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def ai_review(jd_text: str, tailored_yaml: str) -> tuple[bool, dict | None, str | None]:
|
|
239
|
+
"""Run one-shot opencode review. Returns (approved, review_json, corrections_text)."""
|
|
240
|
+
prompt = REVIEW_PROMPT.format(jd_text=jd_text, tailored_yaml=tailored_yaml)
|
|
241
|
+
for attempt in range(1 + _REVIEW_RETRIES):
|
|
242
|
+
ok, response = _opencode_run(prompt, timeout_s=120)
|
|
243
|
+
if not ok:
|
|
244
|
+
if attempt < _REVIEW_RETRIES:
|
|
245
|
+
continue
|
|
246
|
+
return False, None, f"Review call failed: {response}"
|
|
247
|
+
|
|
248
|
+
review = _extract_json_from_response(response)
|
|
249
|
+
if not review:
|
|
250
|
+
if attempt < _REVIEW_RETRIES:
|
|
251
|
+
continue
|
|
252
|
+
return False, None, f"Could not parse review JSON:\n{response[:300]}"
|
|
253
|
+
|
|
254
|
+
score = review.get("overall_score", 0)
|
|
255
|
+
approved = score >= _REVIEW_MIN_SCORE and not review.get("fabrication_concerns")
|
|
256
|
+
review["approved"] = approved
|
|
257
|
+
|
|
258
|
+
corrections = review.get("corrections_needed", [])
|
|
259
|
+
corrections_text = json.dumps(corrections, indent=2) if corrections else None
|
|
260
|
+
|
|
261
|
+
return approved, review, corrections_text
|
|
262
|
+
|
|
263
|
+
return False, None, "Max review retries exceeded"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def double_review(jd_text: str, tailored_yaml: str) -> tuple[bool, dict, str | None]:
|
|
267
|
+
"""Run two independent reviews + optional tiebreaker.
|
|
268
|
+
|
|
269
|
+
Returns (approved, consolidated_review, corrections_text).
|
|
270
|
+
The consolidated review takes the lower score and the union of corrections.
|
|
271
|
+
"""
|
|
272
|
+
# Run two reviews
|
|
273
|
+
r1_ok, r1, c1 = ai_review(jd_text, tailored_yaml)
|
|
274
|
+
r2_ok, r2, c2 = ai_review(jd_text, tailored_yaml)
|
|
275
|
+
|
|
276
|
+
reviews = []
|
|
277
|
+
if r1_ok and r1:
|
|
278
|
+
reviews.append(r1)
|
|
279
|
+
if r2_ok and r2:
|
|
280
|
+
reviews.append(r2)
|
|
281
|
+
|
|
282
|
+
if not reviews:
|
|
283
|
+
return False, {"overall_score": 0, "approved": False}, None
|
|
284
|
+
|
|
285
|
+
# Consolidate — take the lower/more conservative score
|
|
286
|
+
scores = [r.get("overall_score", 0) for r in reviews]
|
|
287
|
+
final_score = min(scores)
|
|
288
|
+
tiebreaker_needed = max(scores) - min(scores) > 15
|
|
289
|
+
|
|
290
|
+
# If scores diverge by >15, run tiebreaker
|
|
291
|
+
if tiebreaker_needed and len(reviews) == 2:
|
|
292
|
+
_, r3, c3 = ai_review(jd_text, tailored_yaml)
|
|
293
|
+
if r3:
|
|
294
|
+
reviews.append(r3)
|
|
295
|
+
scores.append(r3.get("overall_score", 0))
|
|
296
|
+
final_score = sorted(scores)[len(scores) // 2] # median
|
|
297
|
+
|
|
298
|
+
# Merge corrections (union)
|
|
299
|
+
all_corrections: list[dict] = []
|
|
300
|
+
seen_corrections: set[str] = set()
|
|
301
|
+
for r in reviews:
|
|
302
|
+
for corr in r.get("corrections_needed", []):
|
|
303
|
+
key = json.dumps(corr, sort_keys=True)
|
|
304
|
+
if key not in seen_corrections:
|
|
305
|
+
seen_corrections.add(key)
|
|
306
|
+
all_corrections.append(corr)
|
|
307
|
+
|
|
308
|
+
# Merge missing terms (union)
|
|
309
|
+
all_missing: list[str] = []
|
|
310
|
+
seen_missing: set[str] = set()
|
|
311
|
+
for r in reviews:
|
|
312
|
+
for term in r.get("missing_terms", []):
|
|
313
|
+
if term.lower() not in seen_missing:
|
|
314
|
+
seen_missing.add(term.lower())
|
|
315
|
+
all_missing.append(term)
|
|
316
|
+
|
|
317
|
+
# Merge gaps (union)
|
|
318
|
+
all_gaps: list[dict] = []
|
|
319
|
+
seen_gaps: set[str] = set()
|
|
320
|
+
for r in reviews:
|
|
321
|
+
for gap in r.get("alignment_gaps", []):
|
|
322
|
+
key = json.dumps(gap, sort_keys=True)
|
|
323
|
+
if key not in seen_gaps:
|
|
324
|
+
seen_gaps.add(key)
|
|
325
|
+
all_gaps.append(gap)
|
|
326
|
+
|
|
327
|
+
# Take the most conservative fabrication concerns
|
|
328
|
+
all_concerns: list[str] = []
|
|
329
|
+
seen_concerns: set[str] = set()
|
|
330
|
+
for r in reviews:
|
|
331
|
+
for c in r.get("fabrication_concerns", []):
|
|
332
|
+
if c.lower() not in seen_concerns:
|
|
333
|
+
seen_concerns.add(c.lower())
|
|
334
|
+
all_concerns.append(c)
|
|
335
|
+
|
|
336
|
+
approved = final_score >= _REVIEW_MIN_SCORE and not all_concerns
|
|
337
|
+
|
|
338
|
+
# Per-section scores: take the lower score for each section
|
|
339
|
+
by_section = {}
|
|
340
|
+
section_keys = ["summary", "skills", "experience", "projects"]
|
|
341
|
+
for sk in section_keys:
|
|
342
|
+
sec_scores = [r.get("by_section", {}).get(sk, 0) for r in reviews if r.get("by_section")]
|
|
343
|
+
by_section[sk] = min(sec_scores) if sec_scores else 0
|
|
344
|
+
|
|
345
|
+
consolidated = {
|
|
346
|
+
"overall_score": final_score,
|
|
347
|
+
"by_section": by_section,
|
|
348
|
+
"missing_terms": all_missing,
|
|
349
|
+
"present_terms": reviews[0].get("present_terms", []) if reviews else [],
|
|
350
|
+
"alignment_gaps": all_gaps,
|
|
351
|
+
"fabrication_concerns": all_concerns,
|
|
352
|
+
"corrections_needed": all_corrections,
|
|
353
|
+
"approved": approved,
|
|
354
|
+
"_scores": scores,
|
|
355
|
+
"_tiebreaker": tiebreaker_needed,
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
corrections_text = json.dumps(all_corrections, indent=2) if all_corrections else None
|
|
359
|
+
return approved, consolidated, corrections_text
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def apply_corrections(tailored_yaml: str, corrections_text: str) -> str:
|
|
363
|
+
"""Apply corrections via opencode fix pass."""
|
|
364
|
+
prompt = FIX_PROMPT.format(
|
|
365
|
+
corrections_json=corrections_text,
|
|
366
|
+
tailored_yaml=tailored_yaml,
|
|
367
|
+
)
|
|
368
|
+
ok, response = _opencode_run(prompt, timeout_s=120)
|
|
369
|
+
if not ok:
|
|
370
|
+
return tailored_yaml
|
|
371
|
+
fixed = _extract_yaml_from_response(response)
|
|
372
|
+
if fixed.startswith("cv:"):
|
|
373
|
+
return fixed
|
|
374
|
+
return tailored_yaml
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# ---------------------------------------------------------------------------
|
|
378
|
+
# Pass 4: Render
|
|
379
|
+
# ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _find_rendercv() -> Path | None:
|
|
383
|
+
"""Locate the rendercv binary, checking venv, PATH, and common locations."""
|
|
384
|
+
import shutil as _shutil
|
|
385
|
+
chameleon_root = Path(__file__).resolve().parent.parent
|
|
386
|
+
# Check venv first (platform-aware)
|
|
387
|
+
venv_bin = chameleon_root / ".venv" / ("Scripts" if sys.platform == "win32" else "bin")
|
|
388
|
+
ext = ".exe" if sys.platform == "win32" else ""
|
|
389
|
+
rendercv_path = venv_bin / f"rendercv{ext}"
|
|
390
|
+
if rendercv_path.exists():
|
|
391
|
+
return rendercv_path
|
|
392
|
+
# Check if on PATH
|
|
393
|
+
on_path = _shutil.which("rendercv")
|
|
394
|
+
if on_path:
|
|
395
|
+
return Path(on_path)
|
|
396
|
+
return None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def render_yaml(yaml_path: Path) -> tuple[bool, str]:
|
|
400
|
+
"""Render YAML to PDF using rendercv. Returns (success, message).
|
|
401
|
+
|
|
402
|
+
The subprocess is registered in the global kill registry so it can be
|
|
403
|
+
aborted on crash/shutdown from any thread.
|
|
404
|
+
"""
|
|
405
|
+
chameleon_root = Path(__file__).resolve().parent.parent
|
|
406
|
+
rendercv_path = _find_rendercv()
|
|
407
|
+
output_dir = config.output_dir()
|
|
408
|
+
output_dir.mkdir(exist_ok=True)
|
|
409
|
+
|
|
410
|
+
if rendercv_path is None:
|
|
411
|
+
return False, "rendercv not found. Run: make install-tools"
|
|
412
|
+
|
|
413
|
+
stem = yaml_path.stem
|
|
414
|
+
tmp = Path(yaml_path.name)
|
|
415
|
+
shutil.copy2(yaml_path, tmp)
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
proc = subprocess.Popen(
|
|
419
|
+
[
|
|
420
|
+
str(rendercv_path), "render", str(tmp),
|
|
421
|
+
"--pdf-path", str(output_dir / f"{stem}.pdf"),
|
|
422
|
+
"--typst-path", str(output_dir / f"{stem}.typ"),
|
|
423
|
+
"--markdown-path", str(output_dir / f"{stem}.md"),
|
|
424
|
+
"--html-path", str(output_dir / f"{stem}.html"),
|
|
425
|
+
],
|
|
426
|
+
stdout=subprocess.PIPE,
|
|
427
|
+
stderr=subprocess.PIPE,
|
|
428
|
+
text=True,
|
|
429
|
+
cwd=str(chameleon_root),
|
|
430
|
+
)
|
|
431
|
+
except FileNotFoundError:
|
|
432
|
+
return False, f"rendercv binary not executable at {rendercv_path}"
|
|
433
|
+
|
|
434
|
+
_track_process(proc)
|
|
435
|
+
try:
|
|
436
|
+
out, err = proc.communicate(timeout=60)
|
|
437
|
+
except subprocess.TimeoutExpired:
|
|
438
|
+
proc.kill()
|
|
439
|
+
proc.wait(timeout=5)
|
|
440
|
+
return False, "Render timeout (60s)"
|
|
441
|
+
except Exception as e:
|
|
442
|
+
proc.kill()
|
|
443
|
+
proc.wait(timeout=5)
|
|
444
|
+
return False, str(e)
|
|
445
|
+
finally:
|
|
446
|
+
_untrack_process(proc)
|
|
447
|
+
tmp.unlink(missing_ok=True)
|
|
448
|
+
|
|
449
|
+
if proc.returncode == 0:
|
|
450
|
+
pdf_path = output_dir / f"{stem}.pdf"
|
|
451
|
+
return True, str(pdf_path) if pdf_path.exists() else "Rendered (no PDF)"
|
|
452
|
+
else:
|
|
453
|
+
err = (err or out or "unknown error").strip()[:300]
|
|
454
|
+
return False, err
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# ---------------------------------------------------------------------------
|
|
458
|
+
# Orchestration
|
|
459
|
+
# ---------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def tailor_and_render(
|
|
463
|
+
jd_text: str,
|
|
464
|
+
company: str = "",
|
|
465
|
+
title: str = "",
|
|
466
|
+
profile: dict | None = None,
|
|
467
|
+
skip_review: bool = False,
|
|
468
|
+
skip_render: bool = False,
|
|
469
|
+
) -> tuple[bool, str, str]:
|
|
470
|
+
"""
|
|
471
|
+
Full three-pass pipeline: Brief → AI Tailor → AI Review → Render.
|
|
472
|
+
Returns (success, message, output_path).
|
|
473
|
+
"""
|
|
474
|
+
global _running
|
|
475
|
+
_running = True
|
|
476
|
+
start_ms = _now_ms()
|
|
477
|
+
|
|
478
|
+
master_cv_path = config.master_cv_path()
|
|
479
|
+
if not master_cv_path.exists():
|
|
480
|
+
_running = False
|
|
481
|
+
return False, f"Master CV not found: {master_cv_path}", ""
|
|
482
|
+
|
|
483
|
+
# Derive company/title from JD text if not provided
|
|
484
|
+
if not company or not title:
|
|
485
|
+
lines = jd_text.strip().split("\n")
|
|
486
|
+
first_line = lines[0].strip() if lines else "Unknown Role"
|
|
487
|
+
if not title:
|
|
488
|
+
title = first_line[:80]
|
|
489
|
+
# Try to find company in first few lines
|
|
490
|
+
for line in lines[1:8]:
|
|
491
|
+
m = re.search(r"(?:at|@|Company[:\s]*)(.+?)(?:\s*[-–—]|\s*$)", line)
|
|
492
|
+
if m:
|
|
493
|
+
company = m.group(1).strip()
|
|
494
|
+
break
|
|
495
|
+
if not company:
|
|
496
|
+
company = "Unknown"
|
|
497
|
+
|
|
498
|
+
safe_co = re.sub(r"[^a-zA-Z0-9]", "_", company)[:20]
|
|
499
|
+
safe_role = re.sub(r"[^a-zA-Z0-9]", "_", title)[:20]
|
|
500
|
+
raw = f"{company}{title}".encode()
|
|
501
|
+
try:
|
|
502
|
+
analysis_id = hashlib.md5(raw, usedforsecurity=False).hexdigest()[:8]
|
|
503
|
+
except TypeError:
|
|
504
|
+
analysis_id = hashlib.md5(raw).hexdigest()[:8]
|
|
505
|
+
cv_basename = master_cv_path.stem.replace("_cv", "") or "cv"
|
|
506
|
+
yaml_name = f"{cv_basename}_{safe_co}_{safe_role}_cv.yaml"
|
|
507
|
+
yaml_path = config.templates_dir() / yaml_name
|
|
508
|
+
|
|
509
|
+
# ======================================================================
|
|
510
|
+
# PASS 1: Algorithmic Tailoring Brief
|
|
511
|
+
# ======================================================================
|
|
512
|
+
print(f"[1/4] Building TailoringBrief for '{title}' @ '{company}'...", flush=True)
|
|
513
|
+
brief = build_tailoring_brief(jd_text, profile)
|
|
514
|
+
brief_text = render_brief_text(brief)
|
|
515
|
+
print(f" Alignment: {brief['summary']['alignment_score']}/100 | "
|
|
516
|
+
f"Gaps: {len(brief['gaps'])} | "
|
|
517
|
+
f"Matching repos: {len(brief.get('repo_analysis', []))}", flush=True)
|
|
518
|
+
|
|
519
|
+
# Save analysis artifact
|
|
520
|
+
analysis_dir = config.analyses_dir()
|
|
521
|
+
analysis_dir.mkdir(parents=True, exist_ok=True)
|
|
522
|
+
analysis_name = f"{analysis_id}__{safe_co}__{safe_role}.json"
|
|
523
|
+
analysis_path = analysis_dir / analysis_name
|
|
524
|
+
analysis_artifact = {
|
|
525
|
+
"company_name": company,
|
|
526
|
+
"role_title": title,
|
|
527
|
+
"analysis_id": analysis_id,
|
|
528
|
+
"alignment_score": brief["summary"]["alignment_score"],
|
|
529
|
+
"seniority": brief["summary"]["seniority"],
|
|
530
|
+
"signal_count": {
|
|
531
|
+
k: len(v) for k, v in brief["summary"]["signal_count"].items()
|
|
532
|
+
},
|
|
533
|
+
"gaps": brief["gaps"],
|
|
534
|
+
"brief_summary": brief_text[:500],
|
|
535
|
+
}
|
|
536
|
+
try:
|
|
537
|
+
analysis_path.write_text(json.dumps(analysis_artifact, indent=2))
|
|
538
|
+
except Exception:
|
|
539
|
+
pass
|
|
540
|
+
|
|
541
|
+
# ======================================================================
|
|
542
|
+
# PASS 2: One-shot AI Tailor
|
|
543
|
+
# ======================================================================
|
|
544
|
+
print(f"[2/4] Running one-shot AI tailor (opencode)...", flush=True)
|
|
545
|
+
cv_yaml = master_cv_path.read_text()
|
|
546
|
+
ok, tailored_yaml = ai_tailor(jd_text, brief_text, cv_yaml)
|
|
547
|
+
if not ok:
|
|
548
|
+
_running = False
|
|
549
|
+
return False, f"AI tailoring failed: {tailored_yaml}", str(analysis_path)
|
|
550
|
+
|
|
551
|
+
print(f" YAML generated ({len(tailored_yaml)} chars)", flush=True)
|
|
552
|
+
|
|
553
|
+
# ======================================================================
|
|
554
|
+
# PASS 3: AI Review
|
|
555
|
+
# ======================================================================
|
|
556
|
+
review_result = None
|
|
557
|
+
if not skip_review:
|
|
558
|
+
print(f"[3/4] Running double AI review (opencode x2, tiebreaker if needed)...", flush=True)
|
|
559
|
+
approved, review_result, corrections_text = double_review(jd_text, tailored_yaml)
|
|
560
|
+
|
|
561
|
+
if review_result:
|
|
562
|
+
score = review_result.get("overall_score", 0)
|
|
563
|
+
concerns = review_result.get("fabrication_concerns", [])
|
|
564
|
+
gaps_found = review_result.get("alignment_gaps", [])
|
|
565
|
+
missing = review_result.get("missing_terms", [])
|
|
566
|
+
raw_scores = review_result.get("_scores", [score])
|
|
567
|
+
print(f" Scores: {' + '.join(str(s) for s in raw_scores)} -> final={score}/100", flush=True)
|
|
568
|
+
print(f" Missing: {len(missing)} | Gaps: {len(gaps_found)} | Concerns: {len(concerns)}", flush=True)
|
|
569
|
+
|
|
570
|
+
if corrections_text:
|
|
571
|
+
n_corrections = len(json.loads(corrections_text))
|
|
572
|
+
print(f" Applying {n_corrections} corrections (union of both reviews)...", flush=True)
|
|
573
|
+
tailored_yaml = apply_corrections(tailored_yaml, corrections_text)
|
|
574
|
+
print(f" Corrections applied", flush=True)
|
|
575
|
+
elif review_result and not approved and not corrections_text:
|
|
576
|
+
print(f" Review not approved (score {review_result.get('overall_score', 0)} < {_REVIEW_MIN_SCORE}) but no corrections provided — proceeding with generated YAML", flush=True)
|
|
577
|
+
else:
|
|
578
|
+
print(f"[3/4] Review skipped", flush=True)
|
|
579
|
+
|
|
580
|
+
# ======================================================================
|
|
581
|
+
# Write tailored YAML
|
|
582
|
+
# ======================================================================
|
|
583
|
+
yaml_path.write_text(tailored_yaml)
|
|
584
|
+
print(f" Saved: {yaml_path}", flush=True)
|
|
585
|
+
|
|
586
|
+
# ======================================================================
|
|
587
|
+
# PASS 4: Render
|
|
588
|
+
# ======================================================================
|
|
589
|
+
output_path = str(yaml_path)
|
|
590
|
+
ghost_injected = False
|
|
591
|
+
if not skip_render:
|
|
592
|
+
print(f"[4/4] Rendering PDF...", flush=True)
|
|
593
|
+
render_ok, render_msg = render_yaml(yaml_path)
|
|
594
|
+
if render_ok:
|
|
595
|
+
output_path = render_msg
|
|
596
|
+
print(f" PDF: {render_msg}", flush=True)
|
|
597
|
+
|
|
598
|
+
# PASS 5: ATS Ghost Text Injection (if enabled)
|
|
599
|
+
if config.get("ats_ghost_enabled", False):
|
|
600
|
+
pdf_path = Path(render_msg)
|
|
601
|
+
if pdf_path.exists():
|
|
602
|
+
from scripts.ats_ghost import collect_ats_terms as _collect, inject_ghost_text as _inject
|
|
603
|
+
extra = config.get("ats_ghost_extra_terms", "")
|
|
604
|
+
# Collect keywords from signals + gaps + extra terms
|
|
605
|
+
signal_terms = (
|
|
606
|
+
brief["summary"]["signal_count"]["languages"]
|
|
607
|
+
+ brief["summary"]["signal_count"]["frameworks"]
|
|
608
|
+
)
|
|
609
|
+
gap_terms = [g["term"] for g in brief.get("gaps", [])]
|
|
610
|
+
existing_kws = list(
|
|
611
|
+
set(
|
|
612
|
+
signal_terms + gap_terms
|
|
613
|
+
+ [kw for exp in brief.get("experience_analysis", [])
|
|
614
|
+
for kw in exp.get("keyword_matches", [])]
|
|
615
|
+
)
|
|
616
|
+
)
|
|
617
|
+
all_terms = _collect(
|
|
618
|
+
jd_text,
|
|
619
|
+
extra_terms=extra,
|
|
620
|
+
existing_keywords=existing_kws,
|
|
621
|
+
)
|
|
622
|
+
if all_terms:
|
|
623
|
+
ok_ghost = _inject(pdf_path, all_terms)
|
|
624
|
+
ghost_injected = ok_ghost
|
|
625
|
+
if ok_ghost:
|
|
626
|
+
print(f" ATS ghost text injected ({len(all_terms)} terms)", flush=True)
|
|
627
|
+
else:
|
|
628
|
+
print(f" ATS ghost text injection failed", flush=True)
|
|
629
|
+
else:
|
|
630
|
+
print(f" Render issue: {render_msg}", flush=True)
|
|
631
|
+
|
|
632
|
+
elapsed = _now_ms() - start_ms
|
|
633
|
+
_running = False
|
|
634
|
+
|
|
635
|
+
summary = (
|
|
636
|
+
f"Tailored in {elapsed/1000:.1f}s\n"
|
|
637
|
+
f" Analysis: {analysis_path}\n"
|
|
638
|
+
f" YAML: {yaml_path}\n"
|
|
639
|
+
f" PDF: {output_path if not skip_render else 'skipped'}\n"
|
|
640
|
+
f" Alignment: {brief['summary']['alignment_score']}/100"
|
|
641
|
+
)
|
|
642
|
+
if ghost_injected:
|
|
643
|
+
summary += " | ATS ghost: ✓"
|
|
644
|
+
if review_result:
|
|
645
|
+
raw_scores = review_result.get("_scores", [])
|
|
646
|
+
if raw_scores:
|
|
647
|
+
summary += f" | Reviews: {', '.join(str(s) for s in raw_scores)}"
|
|
648
|
+
summary += f" | Final: {review_result.get('overall_score', '?')}/100"
|
|
649
|
+
return True, summary, output_path
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
# ---------------------------------------------------------------------------
|
|
653
|
+
# CLI
|
|
654
|
+
# ---------------------------------------------------------------------------
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def main():
|
|
658
|
+
parser = argparse.ArgumentParser(
|
|
659
|
+
description="Tailor a CV from job-description text, a file, or a job-posting URL."
|
|
660
|
+
)
|
|
661
|
+
parser.add_argument("jd", nargs="?", help="Job description text, path, or URL")
|
|
662
|
+
parser.add_argument("--company", default="", help="Company name override")
|
|
663
|
+
parser.add_argument("--title", default="", help="Role title override")
|
|
664
|
+
parser.add_argument("--no-review", action="store_true", help="Skip AI review")
|
|
665
|
+
parser.add_argument("--no-render", action="store_true", help="Skip PDF rendering")
|
|
666
|
+
parser.add_argument("--json", action="store_true", help="Emit machine-readable output")
|
|
667
|
+
parsed = parser.parse_args()
|
|
668
|
+
|
|
669
|
+
jd_arg = parsed.jd
|
|
670
|
+
if not jd_arg and not sys.stdin.isatty():
|
|
671
|
+
jd_arg = sys.stdin.read().strip()
|
|
672
|
+
if not jd_arg:
|
|
673
|
+
parser.error("a job description text, file, or URL is required")
|
|
674
|
+
|
|
675
|
+
# Handle URL input
|
|
676
|
+
if jd_arg.startswith("http://") or jd_arg.startswith("https://"):
|
|
677
|
+
print("[*] Crawling URL...", file=sys.stderr)
|
|
678
|
+
try:
|
|
679
|
+
import httpx
|
|
680
|
+
resp = httpx.get(jd_arg, timeout=30, follow_redirects=True, headers={
|
|
681
|
+
"User-Agent": "Mozilla/5.0 (compatible; Chameleon/1.0)"
|
|
682
|
+
})
|
|
683
|
+
resp.raise_for_status()
|
|
684
|
+
from html.parser import HTMLParser
|
|
685
|
+
class _TE(HTMLParser):
|
|
686
|
+
def __init__(self):
|
|
687
|
+
super().__init__()
|
|
688
|
+
self.parts = []
|
|
689
|
+
self._skip = False
|
|
690
|
+
def handle_starttag(self, tag, attrs):
|
|
691
|
+
if tag in ("script", "style"): self._skip = True
|
|
692
|
+
def handle_endtag(self, tag):
|
|
693
|
+
if tag in ("script", "style"): self._skip = False
|
|
694
|
+
def handle_data(self, data):
|
|
695
|
+
if not self._skip: self.parts.append(data)
|
|
696
|
+
te = _TE()
|
|
697
|
+
te.feed(resp.text)
|
|
698
|
+
import re as _re
|
|
699
|
+
desc = _re.sub(r"\s+", " ", " ".join(te.parts)).strip()
|
|
700
|
+
except ImportError:
|
|
701
|
+
import urllib.request
|
|
702
|
+
import re as _re
|
|
703
|
+
import html as _hm
|
|
704
|
+
req = urllib.request.Request(jd_arg, headers={"User-Agent": "Mozilla/5.0"})
|
|
705
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
706
|
+
raw = resp.read().decode("utf-8", errors="replace")
|
|
707
|
+
raw = _re.sub(r"<script[^>]*>.*?</script>", "", raw, flags=_re.DOTALL | _re.IGNORECASE)
|
|
708
|
+
raw = _re.sub(r"<style[^>]*>.*?</style>", "", raw, flags=_re.DOTALL | _re.IGNORECASE)
|
|
709
|
+
raw = _re.sub(r"<[^>]+>", " ", raw)
|
|
710
|
+
raw = _hm.unescape(raw)
|
|
711
|
+
desc = _re.sub(r"\s+", " ", raw).strip()[:10000]
|
|
712
|
+
except Exception as e:
|
|
713
|
+
print(f"Error crawling URL: {e}", file=sys.stderr)
|
|
714
|
+
sys.exit(1)
|
|
715
|
+
elif Path(jd_arg).is_file():
|
|
716
|
+
desc = Path(jd_arg).read_text()
|
|
717
|
+
else:
|
|
718
|
+
desc = jd_arg
|
|
719
|
+
|
|
720
|
+
ok, msg, output_path = tailor_and_render(
|
|
721
|
+
desc,
|
|
722
|
+
company=parsed.company,
|
|
723
|
+
title=parsed.title,
|
|
724
|
+
skip_review=parsed.no_review,
|
|
725
|
+
skip_render=parsed.no_render,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
if not parsed.json:
|
|
729
|
+
# Keep CLI output portable on Windows consoles configured for cp1252.
|
|
730
|
+
print(f"\n{'PASS' if ok else 'FAIL'}: {msg}")
|
|
731
|
+
sys.exit(0 if ok else 1)
|
|
732
|
+
|
|
733
|
+
if parsed.json:
|
|
734
|
+
import json as _json
|
|
735
|
+
print(_json.dumps({
|
|
736
|
+
"success": ok,
|
|
737
|
+
"message": msg,
|
|
738
|
+
"output_path": output_path,
|
|
739
|
+
}, indent=2))
|
|
740
|
+
else:
|
|
741
|
+
print(f"\n{'✓' if ok else '✗'} {'PASS' if ok else 'FAIL'}: {msg}")
|
|
742
|
+
sys.exit(0 if ok else 1)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
if __name__ == "__main__":
|
|
746
|
+
main()
|