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
package/scripts/db.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Persistent SQLite store for scanned jobs, tailored CVs, and scan history.
|
|
3
|
+
Every job, score, and tailored CV lives on disk — survives restarts.
|
|
4
|
+
0 hardcoded paths. DB path from config.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import sqlite3
|
|
11
|
+
import threading
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from scripts.config import config
|
|
17
|
+
|
|
18
|
+
_local = threading.local()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def compute_job_id(title: str, company: str, url: str = "", source: str = "") -> str:
|
|
22
|
+
"""Deterministic job ID that distinguishes separate postings at one employer."""
|
|
23
|
+
raw = f"{title}|{company}|{url}|{source}".encode()
|
|
24
|
+
try:
|
|
25
|
+
return hashlib.md5(raw, usedforsecurity=False).hexdigest()[:12]
|
|
26
|
+
except TypeError:
|
|
27
|
+
return hashlib.md5(raw).hexdigest()[:12]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_conn() -> sqlite3.Connection:
|
|
31
|
+
"""Thread-local connection — safe for TUI background workers."""
|
|
32
|
+
if not hasattr(_local, "conn") or _local.conn is None:
|
|
33
|
+
db_path = config.resolve("db_path") or (
|
|
34
|
+
Path(__file__).resolve().parent.parent / ".chameleon" / "jobs.db"
|
|
35
|
+
)
|
|
36
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
_local.conn = sqlite3.connect(str(db_path))
|
|
38
|
+
_local.conn.row_factory = sqlite3.Row
|
|
39
|
+
_local.conn.execute("PRAGMA journal_mode=WAL")
|
|
40
|
+
_local.conn.execute("PRAGMA busy_timeout=5000")
|
|
41
|
+
return _local.conn
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def migrate():
|
|
45
|
+
"""Ensure all tables exist. Idempotent — safe to call on every startup."""
|
|
46
|
+
conn = _get_conn()
|
|
47
|
+
conn.executescript("""
|
|
48
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
49
|
+
id TEXT PRIMARY KEY,
|
|
50
|
+
title TEXT NOT NULL,
|
|
51
|
+
company TEXT NOT NULL,
|
|
52
|
+
url TEXT DEFAULT '',
|
|
53
|
+
location TEXT DEFAULT 'Remote',
|
|
54
|
+
description TEXT DEFAULT '',
|
|
55
|
+
salary TEXT DEFAULT '',
|
|
56
|
+
source TEXT DEFAULT '',
|
|
57
|
+
remote INTEGER DEFAULT 0,
|
|
58
|
+
tags TEXT DEFAULT '[]',
|
|
59
|
+
score INTEGER DEFAULT 0,
|
|
60
|
+
score_detail TEXT DEFAULT '{}',
|
|
61
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
62
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
CREATE TABLE IF NOT EXISTS tailored_cvs (
|
|
66
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
67
|
+
job_id TEXT REFERENCES jobs(id) ON DELETE CASCADE,
|
|
68
|
+
title TEXT NOT NULL,
|
|
69
|
+
company TEXT NOT NULL,
|
|
70
|
+
score INTEGER DEFAULT 0,
|
|
71
|
+
yaml_path TEXT DEFAULT '',
|
|
72
|
+
pdf_path TEXT DEFAULT '',
|
|
73
|
+
description TEXT DEFAULT '',
|
|
74
|
+
url TEXT DEFAULT '',
|
|
75
|
+
location TEXT DEFAULT 'Remote',
|
|
76
|
+
remote INTEGER DEFAULT 0,
|
|
77
|
+
salary TEXT DEFAULT '',
|
|
78
|
+
tags TEXT DEFAULT '[]',
|
|
79
|
+
source TEXT DEFAULT 'tailored',
|
|
80
|
+
aliases TEXT DEFAULT '',
|
|
81
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
CREATE TABLE IF NOT EXISTS scans (
|
|
85
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
86
|
+
query TEXT NOT NULL,
|
|
87
|
+
platform TEXT NOT NULL,
|
|
88
|
+
job_count INTEGER DEFAULT 0,
|
|
89
|
+
duration_ms INTEGER DEFAULT 0,
|
|
90
|
+
scanned_at TEXT DEFAULT (datetime('now'))
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_score ON jobs(score DESC);
|
|
94
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_source ON jobs(source);
|
|
95
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_company_title ON jobs(company, title);
|
|
96
|
+
CREATE INDEX IF NOT EXISTS idx_tailored_job_id ON tailored_cvs(job_id);
|
|
97
|
+
""")
|
|
98
|
+
conn.commit()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── Jobs ─────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def save_job(
|
|
105
|
+
title: str,
|
|
106
|
+
company: str,
|
|
107
|
+
url: str = "",
|
|
108
|
+
location: str = "Remote",
|
|
109
|
+
description: str = "",
|
|
110
|
+
salary: str = "",
|
|
111
|
+
source: str = "",
|
|
112
|
+
remote: bool = False,
|
|
113
|
+
tags: list[str] | None = None,
|
|
114
|
+
score: int = 0,
|
|
115
|
+
score_detail: dict | None = None,
|
|
116
|
+
job_id: str | None = None,
|
|
117
|
+
) -> str:
|
|
118
|
+
"""Insert or update a job. Returns the job ID."""
|
|
119
|
+
job_id = job_id or compute_job_id(title, company, url, source)
|
|
120
|
+
conn = _get_conn()
|
|
121
|
+
conn.execute(
|
|
122
|
+
"""
|
|
123
|
+
INSERT INTO jobs (id, title, company, url, location, description, salary, source, remote, tags, score, score_detail, updated_at)
|
|
124
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
125
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
126
|
+
url = excluded.url,
|
|
127
|
+
location = excluded.location,
|
|
128
|
+
description = excluded.description,
|
|
129
|
+
salary = excluded.salary,
|
|
130
|
+
source = excluded.source,
|
|
131
|
+
remote = excluded.remote,
|
|
132
|
+
tags = excluded.tags,
|
|
133
|
+
score = excluded.score,
|
|
134
|
+
score_detail = excluded.score_detail,
|
|
135
|
+
updated_at = datetime('now')
|
|
136
|
+
""",
|
|
137
|
+
(
|
|
138
|
+
job_id, title, company, url, location, description,
|
|
139
|
+
salary, source, int(remote),
|
|
140
|
+
json.dumps(tags or []), score, json.dumps(score_detail or {}),
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
conn.commit()
|
|
144
|
+
return job_id
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def save_jobs(jobs: list[dict]) -> int:
|
|
148
|
+
"""Save multiple jobs. Returns count saved."""
|
|
149
|
+
count = 0
|
|
150
|
+
for j in jobs:
|
|
151
|
+
save_job(
|
|
152
|
+
title=j.get("title", ""),
|
|
153
|
+
company=j.get("company", ""),
|
|
154
|
+
url=j.get("url", ""),
|
|
155
|
+
location=j.get("location", "Remote"),
|
|
156
|
+
description=j.get("description", ""),
|
|
157
|
+
salary=j.get("salary", ""),
|
|
158
|
+
source=j.get("source", ""),
|
|
159
|
+
remote=bool(j.get("remote", False)),
|
|
160
|
+
tags=j.get("tags") or [],
|
|
161
|
+
score=j.get("score", 0),
|
|
162
|
+
score_detail=j.get("score_detail") or {},
|
|
163
|
+
job_id=j.get("job_id") or j.get("id"),
|
|
164
|
+
)
|
|
165
|
+
count += 1
|
|
166
|
+
return count
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_all_jobs(limit: int = 500, offset: int = 0) -> list[dict]:
|
|
170
|
+
"""Return all jobs ordered by score DESC."""
|
|
171
|
+
conn = _get_conn()
|
|
172
|
+
rows = conn.execute(
|
|
173
|
+
"SELECT * FROM jobs ORDER BY score DESC LIMIT ? OFFSET ?",
|
|
174
|
+
(limit, offset),
|
|
175
|
+
).fetchall()
|
|
176
|
+
return [_row_to_job(r) for r in rows]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def get_job(job_id: str) -> dict | None:
|
|
180
|
+
conn = _get_conn()
|
|
181
|
+
r = conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
|
182
|
+
return _row_to_job(r) if r else None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def find_jobs(
|
|
186
|
+
keyword: str = "",
|
|
187
|
+
source: str = "",
|
|
188
|
+
min_score: int = 0,
|
|
189
|
+
limit: int = 100,
|
|
190
|
+
) -> list[dict]:
|
|
191
|
+
"""Search jobs by keyword in title/company/description."""
|
|
192
|
+
conn = _get_conn()
|
|
193
|
+
parts: list[str] = ["SELECT * FROM jobs WHERE 1=1"]
|
|
194
|
+
params: list[Any] = []
|
|
195
|
+
if keyword:
|
|
196
|
+
parts.append("AND (title LIKE ? OR company LIKE ? OR description LIKE ?)")
|
|
197
|
+
kw = f"%{keyword}%"
|
|
198
|
+
params.extend([kw, kw, kw])
|
|
199
|
+
if source:
|
|
200
|
+
parts.append("AND source = ?")
|
|
201
|
+
params.append(source)
|
|
202
|
+
if min_score:
|
|
203
|
+
parts.append("AND score >= ?")
|
|
204
|
+
params.append(min_score)
|
|
205
|
+
parts.append("ORDER BY score DESC LIMIT ?")
|
|
206
|
+
params.append(limit)
|
|
207
|
+
rows = conn.execute(" ".join(parts), params).fetchall()
|
|
208
|
+
return [_row_to_job(r) for r in rows]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def delete_job(job_id: str) -> bool:
|
|
212
|
+
conn = _get_conn()
|
|
213
|
+
c = conn.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
|
|
214
|
+
conn.commit()
|
|
215
|
+
return c.rowcount > 0
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def update_job_score(job_id: str, score: int, score_detail: dict) -> bool:
|
|
219
|
+
conn = _get_conn()
|
|
220
|
+
c = conn.execute(
|
|
221
|
+
"UPDATE jobs SET score = ?, score_detail = ?, updated_at = datetime('now') WHERE id = ?",
|
|
222
|
+
(score, json.dumps(score_detail), job_id),
|
|
223
|
+
)
|
|
224
|
+
conn.commit()
|
|
225
|
+
return c.rowcount > 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def job_count() -> int:
|
|
229
|
+
conn = _get_conn()
|
|
230
|
+
return conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ── Tailored CVs ──────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def save_tailored_cv(
|
|
237
|
+
job_id: str,
|
|
238
|
+
title: str,
|
|
239
|
+
company: str,
|
|
240
|
+
score: int = 0,
|
|
241
|
+
yaml_path: str = "",
|
|
242
|
+
pdf_path: str = "",
|
|
243
|
+
description: str = "",
|
|
244
|
+
url: str = "",
|
|
245
|
+
location: str = "Remote",
|
|
246
|
+
remote: bool = False,
|
|
247
|
+
salary: str = "",
|
|
248
|
+
tags: list[str] | None = None,
|
|
249
|
+
source: str = "tailored",
|
|
250
|
+
aliases: str = "",
|
|
251
|
+
) -> int:
|
|
252
|
+
conn = _get_conn()
|
|
253
|
+
cur = conn.execute(
|
|
254
|
+
"""
|
|
255
|
+
INSERT INTO tailored_cvs
|
|
256
|
+
(job_id, title, company, score, yaml_path, pdf_path,
|
|
257
|
+
description, url, location, remote, salary, tags, source, aliases)
|
|
258
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
259
|
+
""",
|
|
260
|
+
(
|
|
261
|
+
job_id, title, company, score, yaml_path, pdf_path,
|
|
262
|
+
description, url, location, int(remote), salary,
|
|
263
|
+
json.dumps(tags or []), source, aliases,
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
conn.commit()
|
|
267
|
+
return cur.lastrowid or 0
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def get_tailored_cvs(limit: int = 50) -> list[dict]:
|
|
271
|
+
rows = _get_conn().execute(
|
|
272
|
+
"SELECT * FROM tailored_cvs ORDER BY created_at DESC LIMIT ?",
|
|
273
|
+
(limit,),
|
|
274
|
+
).fetchall()
|
|
275
|
+
return [_row_to_tailored(r) for r in rows]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def delete_tailored_cv(tailored_id: int) -> bool:
|
|
279
|
+
conn = _get_conn()
|
|
280
|
+
c = conn.execute("DELETE FROM tailored_cvs WHERE id = ?", (tailored_id,))
|
|
281
|
+
conn.commit()
|
|
282
|
+
return c.rowcount > 0
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ── Scan history ──────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def record_scan(query: str, platform: str, job_count: int, duration_ms: int) -> int:
|
|
289
|
+
conn = _get_conn()
|
|
290
|
+
cur = conn.execute(
|
|
291
|
+
"INSERT INTO scans (query, platform, job_count, duration_ms) VALUES (?, ?, ?, ?)",
|
|
292
|
+
(query, platform, job_count, duration_ms),
|
|
293
|
+
)
|
|
294
|
+
conn.commit()
|
|
295
|
+
return cur.lastrowid or 0
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def get_recent_scans(limit: int = 20) -> list[dict]:
|
|
299
|
+
rows = _get_conn().execute(
|
|
300
|
+
"SELECT * FROM scans ORDER BY scanned_at DESC LIMIT ?",
|
|
301
|
+
(limit,),
|
|
302
|
+
).fetchall()
|
|
303
|
+
return [dict(r) for r in rows]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ── Helpers ───────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _row_to_job(r: sqlite3.Row) -> dict:
|
|
310
|
+
d = dict(r)
|
|
311
|
+
d["remote"] = bool(d["remote"])
|
|
312
|
+
d["tags"] = json.loads(d["tags"]) if isinstance(d["tags"], str) else (d["tags"] or [])
|
|
313
|
+
d["score_detail"] = json.loads(d["score_detail"]) if isinstance(d["score_detail"], str) else (d["score_detail"] or {})
|
|
314
|
+
return d
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _row_to_tailored(r: sqlite3.Row) -> dict:
|
|
318
|
+
d = dict(r)
|
|
319
|
+
d["remote"] = bool(d["remote"])
|
|
320
|
+
d["tags"] = json.loads(d["tags"]) if isinstance(d["tags"], str) else (d["tags"] or [])
|
|
321
|
+
return d
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def close():
|
|
325
|
+
if hasattr(_local, "conn") and _local.conn:
|
|
326
|
+
_local.conn.close()
|
|
327
|
+
_local.conn = None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# Auto-migrate on first import (idempotent)
|
|
331
|
+
migrate()
|
package/scripts/fonts.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Font manager for CV rendering — Google Fonts integration + local fallbacks.
|
|
2
|
+
|
|
3
|
+
0 hardcoded font paths. TUI can install, preview, and select fonts dynamically.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import urllib.request
|
|
13
|
+
import urllib.error
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from scripts.config import config
|
|
18
|
+
|
|
19
|
+
# Built-in Google Fonts that work with RenderCV/Typst
|
|
20
|
+
BUILTIN_FONTS: list[dict[str, Any]] = [
|
|
21
|
+
{"family": "Source Sans Pro", "category": "sans-serif", "builtin": True},
|
|
22
|
+
{"family": "Source Serif Pro", "category": "serif", "builtin": True},
|
|
23
|
+
{"family": "Source Code Pro", "category": "monospace", "builtin": True},
|
|
24
|
+
{"family": "IBM Plex Sans", "category": "sans-serif", "builtin": True},
|
|
25
|
+
{"family": "IBM Plex Serif", "category": "serif", "builtin": True},
|
|
26
|
+
{"family": "Inter", "category": "sans-serif", "builtin": True},
|
|
27
|
+
{"family": "Roboto", "category": "sans-serif", "builtin": True},
|
|
28
|
+
{"family": "Lato", "category": "sans-serif", "builtin": True},
|
|
29
|
+
{"family": "Open Sans", "category": "sans-serif", "builtin": True},
|
|
30
|
+
{"family": "Noto Sans", "category": "sans-serif", "builtin": True},
|
|
31
|
+
{"family": "Noto Serif", "category": "serif", "builtin": True},
|
|
32
|
+
{"family": "Merriweather", "category": "serif", "builtin": True},
|
|
33
|
+
{"family": "Playfair Display", "category": "serif", "builtin": True},
|
|
34
|
+
{"family": "Fira Sans", "category": "sans-serif", "builtin": True},
|
|
35
|
+
{"family": "Cabin", "category": "sans-serif", "builtin": True},
|
|
36
|
+
{"family": "Montserrat", "category": "sans-serif", "builtin": True},
|
|
37
|
+
{"family": "Nunito", "category": "sans-serif", "builtin": True},
|
|
38
|
+
{"family": "Poppins", "category": "sans-serif", "builtin": True},
|
|
39
|
+
{"family": "Raleway", "category": "sans-serif", "builtin": True},
|
|
40
|
+
{"family": "Ubuntu", "category": "sans-serif", "builtin": True},
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Google Fonts CSS API base for downloading
|
|
44
|
+
GFONTS_API = "https://fonts.googleapis.com/css2?family={family}:wght@300;400;500;600;700&display=swap"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def list_available_fonts() -> list[dict[str, Any]]:
|
|
48
|
+
"""List all available fonts (built-in + locally installed)."""
|
|
49
|
+
fonts = list(BUILTIN_FONTS)
|
|
50
|
+
|
|
51
|
+
# Check for locally downloaded fonts
|
|
52
|
+
fonts_dir = config.fonts_dir()
|
|
53
|
+
if fonts_dir.exists():
|
|
54
|
+
for f in fonts_dir.glob("*"):
|
|
55
|
+
if f.suffix.lower() in (".ttf", ".otf", ".woff", ".woff2"):
|
|
56
|
+
fonts.append({
|
|
57
|
+
"family": f.stem,
|
|
58
|
+
"category": "local",
|
|
59
|
+
"builtin": False,
|
|
60
|
+
"path": str(f),
|
|
61
|
+
"format": f.suffix[1:],
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
# Check system fonts (Linux fc-list)
|
|
65
|
+
try:
|
|
66
|
+
result = subprocess.run(
|
|
67
|
+
["fc-list", "--format=%{family}\n"],
|
|
68
|
+
capture_output=True, text=True, timeout=5,
|
|
69
|
+
)
|
|
70
|
+
for line in result.stdout.strip().split("\n"):
|
|
71
|
+
families = [f.strip() for f in line.split(",")]
|
|
72
|
+
for fam in families:
|
|
73
|
+
if fam and not any(
|
|
74
|
+
f["family"].lower() == fam.lower() for f in fonts
|
|
75
|
+
):
|
|
76
|
+
fonts.append({
|
|
77
|
+
"family": fam,
|
|
78
|
+
"category": "system",
|
|
79
|
+
"builtin": False,
|
|
80
|
+
})
|
|
81
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
# Deduplicate
|
|
85
|
+
seen: set[str] = set()
|
|
86
|
+
unique: list[dict[str, Any]] = []
|
|
87
|
+
for f in fonts:
|
|
88
|
+
key = f["family"].lower()
|
|
89
|
+
if key not in seen:
|
|
90
|
+
seen.add(key)
|
|
91
|
+
unique.append(f)
|
|
92
|
+
return unique
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def download_font(family: str) -> tuple[bool, str]:
|
|
96
|
+
"""Download a Google Font for local use."""
|
|
97
|
+
fonts_dir = config.fonts_dir()
|
|
98
|
+
fonts_dir.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
safe_name = family.replace(" ", "_")
|
|
100
|
+
out_path = fonts_dir / f"{safe_name}.ttf"
|
|
101
|
+
if out_path.exists():
|
|
102
|
+
return True, f"Already downloaded: {out_path}"
|
|
103
|
+
|
|
104
|
+
css_url = GFONTS_API.format(family=family.replace(" ", "+"))
|
|
105
|
+
try:
|
|
106
|
+
req = urllib.request.Request(
|
|
107
|
+
css_url,
|
|
108
|
+
headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"},
|
|
109
|
+
)
|
|
110
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
111
|
+
css = resp.read().decode()
|
|
112
|
+
except (urllib.error.URLError, OSError) as e:
|
|
113
|
+
return False, f"Failed to fetch font CSS: {e}"
|
|
114
|
+
|
|
115
|
+
# Extract first .ttf or .woff2 URL from the CSS
|
|
116
|
+
urls = re.findall(r"url\(([^)]+)\)", css)
|
|
117
|
+
if not urls:
|
|
118
|
+
return False, "No font URL found in Google Fonts CSS"
|
|
119
|
+
|
|
120
|
+
font_url = urls[0]
|
|
121
|
+
try:
|
|
122
|
+
req = urllib.request.Request(
|
|
123
|
+
font_url,
|
|
124
|
+
headers={"User-Agent": "Mozilla/5.0"},
|
|
125
|
+
)
|
|
126
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
127
|
+
data = resp.read()
|
|
128
|
+
out_path.write_bytes(data)
|
|
129
|
+
return True, f"Downloaded: {out_path} ({len(data) / 1024:.0f} KB)"
|
|
130
|
+
except (urllib.error.URLError, OSError) as e:
|
|
131
|
+
return False, f"Failed to download font file: {e}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_current_font() -> str:
|
|
135
|
+
"""Get the currently configured CV font family."""
|
|
136
|
+
return config.get("cv_font_family", "Source Sans Pro")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def set_font(family: str) -> None:
|
|
140
|
+
"""Set the CV font family in config."""
|
|
141
|
+
config.set("cv_font_family", family)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def apply_font_to_master_cv(yaml_path: Path, family: str | None = None) -> bool:
|
|
145
|
+
"""Apply font settings to a CV YAML file."""
|
|
146
|
+
if not yaml_path.exists():
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
family = family or get_current_font()
|
|
150
|
+
try:
|
|
151
|
+
text = yaml_path.read_text()
|
|
152
|
+
lines = text.split("\n")
|
|
153
|
+
in_design = False
|
|
154
|
+
design_end = 0
|
|
155
|
+
has_font = False
|
|
156
|
+
new_lines = []
|
|
157
|
+
|
|
158
|
+
for i, line in enumerate(lines):
|
|
159
|
+
if line.strip() == "design:":
|
|
160
|
+
in_design = True
|
|
161
|
+
if in_design and line.strip() == "":
|
|
162
|
+
in_design = False
|
|
163
|
+
design_end = i
|
|
164
|
+
if in_design and "font:" in line:
|
|
165
|
+
has_font = True
|
|
166
|
+
|
|
167
|
+
new_lines = list(lines)
|
|
168
|
+
if not has_font:
|
|
169
|
+
insert_at = design_end if design_end else len(lines) - 1
|
|
170
|
+
new_lines.insert(insert_at, f" typography:")
|
|
171
|
+
new_lines.insert(insert_at + 1, f" font: {family}")
|
|
172
|
+
else:
|
|
173
|
+
new_lines = [
|
|
174
|
+
re.sub(r"font:\s*.+", f"font: {family}", line)
|
|
175
|
+
for line in new_lines
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
yaml_path.write_text("\n".join(new_lines))
|
|
179
|
+
return True
|
|
180
|
+
except Exception:
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def preview_text(family: str, sample: str = "The quick brown fox jumps over the lazy dog.") -> str:
|
|
185
|
+
"""Generate a text preview for a font family."""
|
|
186
|
+
return f"[bold]{family}[/bold] — {sample}"
|
package/scripts/ghost.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
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
|
+
from scripts.ats_ghost import collect_ats_terms, inject_ghost_text, verify_ats_text
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
ap = argparse.ArgumentParser(description="Inject ATS ghost text into a PDF")
|
|
16
|
+
ap.add_argument("pdf_path", help="Path to the rendered PDF")
|
|
17
|
+
ap.add_argument("--jd", "-j", default="", help="Job description text file (for keyword extraction)")
|
|
18
|
+
ap.add_argument("--jd-text", default="", help="Job description text directly")
|
|
19
|
+
ap.add_argument("--extra", "-e", default="", help="Extra comma-separated ATS terms")
|
|
20
|
+
ap.add_argument("--verify", action="store_true", help="Verify ghost text is extractable")
|
|
21
|
+
ap.add_argument("--json", action="store_true", help="JSON output")
|
|
22
|
+
|
|
23
|
+
args = ap.parse_args()
|
|
24
|
+
|
|
25
|
+
pdf_path = Path(args.pdf_path)
|
|
26
|
+
if not pdf_path.exists():
|
|
27
|
+
result = {"success": False, "error": f"PDF not found: {pdf_path}"}
|
|
28
|
+
_output(result, args.json)
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
|
|
31
|
+
jd_text = ""
|
|
32
|
+
if args.jd:
|
|
33
|
+
jd_path = Path(args.jd)
|
|
34
|
+
if jd_path.exists():
|
|
35
|
+
jd_text = jd_path.read_text(encoding="utf-8")
|
|
36
|
+
if args.jd_text:
|
|
37
|
+
jd_text = args.jd_text
|
|
38
|
+
|
|
39
|
+
extra_terms = args.extra or config.get("ats_ghost_extra_terms", "")
|
|
40
|
+
|
|
41
|
+
# Collect terms from JD + extras
|
|
42
|
+
if jd_text:
|
|
43
|
+
signal_terms = []
|
|
44
|
+
gap_terms = []
|
|
45
|
+
terms = collect_ats_terms(
|
|
46
|
+
jd_text,
|
|
47
|
+
extra_terms=extra_terms,
|
|
48
|
+
existing_keywords=list(set(signal_terms + gap_terms)),
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
terms = [t.strip() for t in extra_terms.split(",") if t.strip()]
|
|
52
|
+
|
|
53
|
+
if not terms:
|
|
54
|
+
_output({"success": False, "error": "No ATS terms to inject"}, args.json)
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
|
|
57
|
+
ok = inject_ghost_text(pdf_path, terms)
|
|
58
|
+
|
|
59
|
+
result = {
|
|
60
|
+
"success": ok,
|
|
61
|
+
"pdf_path": str(pdf_path),
|
|
62
|
+
"terms_injected": len(terms),
|
|
63
|
+
"terms": terms[:20],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if args.verify and ok:
|
|
67
|
+
found, found_terms = verify_ats_text(pdf_path, terms[:5])
|
|
68
|
+
result["verified"] = found
|
|
69
|
+
result["found_terms"] = found_terms
|
|
70
|
+
|
|
71
|
+
_output(result, args.json)
|
|
72
|
+
sys.exit(0 if ok else 1)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _output(result: dict, as_json: bool):
|
|
76
|
+
if as_json:
|
|
77
|
+
print(json.dumps(result, indent=2))
|
|
78
|
+
else:
|
|
79
|
+
if result.get("success"):
|
|
80
|
+
print(f"ATS ghost injected: {result['terms_injected']} terms into {result['pdf_path']}")
|
|
81
|
+
if result.get("verified"):
|
|
82
|
+
print(f"Verified: {len(result.get('found_terms', []))} terms extractable")
|
|
83
|
+
else:
|
|
84
|
+
print(f"Error: {result.get('error', 'unknown')}", file=sys.stderr)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
main()
|