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
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()
@@ -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}"
@@ -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()