@timurtekb/tekjobs 0.24.1
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/CHANGELOG.md +131 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/app/README.md +22 -0
- package/app/dist/assets/index-DzGgV6NF.css +1 -0
- package/app/dist/assets/index-QteuouKv.js +77 -0
- package/app/dist/favicon.svg +1 -0
- package/app/dist/index.html +16 -0
- package/app/server/cover-letter.mjs +243 -0
- package/app/server/index.mjs +113 -0
- package/app/server/mail-check.mjs +234 -0
- package/app/server/mcp.mjs +129 -0
- package/app/server/people.mjs +164 -0
- package/app/server/store.mjs +1131 -0
- package/app/server/tailored-resume.mjs +213 -0
- package/cli.mjs +205 -0
- package/package.json +74 -0
- package/run.cmd +10 -0
- package/run.mjs +201 -0
- package/run.sh +12 -0
- package/samples/vault/Jobs/Basalt Systems - Senior Design Engineer, Growth (1010).md +56 -0
- package/samples/vault/Jobs/Brightline Studio - Design Engineer (1005).md +51 -0
- package/samples/vault/Jobs/Copperleaf - Principal Design Engineer (1006).md +53 -0
- package/samples/vault/Jobs/Example Studio - Senior Design Engineer (1013).md +52 -0
- package/samples/vault/Jobs/Fjord Analytics - Staff Frontend Engineer, Platform (1003).md +58 -0
- package/samples/vault/Jobs/Halcyon Robotics - Senior UX Engineer (1004).md +52 -0
- package/samples/vault/Jobs/Lumen Health - Design Systems Engineer (1002).md +51 -0
- package/samples/vault/Jobs/Meridian Pay - Staff Design Engineer (1009).md +67 -0
- package/samples/vault/Jobs/Northwind Labs - Staff Design Engineer, Design Systems (1000).md +57 -0
- package/samples/vault/Jobs/Orbital Software - Senior Design Engineer (1001).md +52 -0
- package/samples/vault/Jobs/Quill & Co - Design Engineer, Editor (1008).md +55 -0
- package/samples/vault/Jobs/Signalfire Design - Design Engineer (1012).md +53 -0
- package/samples/vault/Jobs/Tessellate - Senior Frontend Engineer, Design Systems (1007).md +52 -0
- package/samples/vault/Jobs/Verdant - UX Engineer (1011).md +51 -0
- package/samples/vault/Logs/2026-09-16.md +13 -0
- package/samples/vault/Logs/2026-09-23.md +13 -0
- package/samples/vault/People/Lee Example (Fjord Analytics).md +22 -0
- package/samples/vault/People/Priya Example (Meridian Pay).md +23 -0
- package/samples/vault/People/Sam Example (Northwind Labs).md +20 -0
- package/samples/vault/Profile/Positioning.md +16 -0
- package/samples/vault/Profile/Profile.md +41 -0
- package/samples/vault/Profile/Resume.md +32 -0
- package/samples/vault/Profile/Voice.md +15 -0
- package/samples/vault/README.md +11 -0
- package/samples/vault/Targets/Companies.md +318 -0
- package/samples/vault/Targets/Search Criteria.md +234 -0
- package/samples/vault/_Home.md +54 -0
- package/scraper/config.mjs +122 -0
- package/scraper/health.mjs +50 -0
- package/scraper/import-link.mjs +281 -0
- package/scraper/profile.mjs +186 -0
- package/scraper/rescore.mjs +236 -0
- package/scraper/resume-sync.mjs +224 -0
- package/scraper/resume.mjs +65 -0
- package/scraper/score.mjs +114 -0
- package/scraper/sources-email.mjs +242 -0
- package/scraper/sources-extra.mjs +558 -0
- package/scraper/sources-sites.mjs +280 -0
- package/scraper/sources.mjs +264 -0
- package/scraper/starter/companies-table.md +306 -0
- package/scraper/starter/criteria.json +56 -0
- package/scraper/starter/profile.md +38 -0
- package/scraper/vault.mjs +212 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// A resume tailored to one posting, and the proof that it is still the same resume.
|
|
2
|
+
//
|
|
3
|
+
// The letter is prose and may say new sentences. The resume is a record and may not. Tailoring here means:
|
|
4
|
+
// rewrite the summary for this posting, reorder and prune bullets, promote the products and skills the posting
|
|
5
|
+
// cares about, tighten wording. It never adds a bullet, a title, an employer, a date or a number. The checker
|
|
6
|
+
// enforces that mechanically: every bullet must trace to a line of the real resume, every figure must already
|
|
7
|
+
// be there, every employer and date line must survive unchanged.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import * as store from './store.mjs';
|
|
10
|
+
import { runLLM, runnerConfig, readVoice, letterClaims } from './cover-letter.mjs';
|
|
11
|
+
import { _internals as resumeCheck } from '../../scraper/resume-sync.mjs';
|
|
12
|
+
|
|
13
|
+
const EMPHASES = {
|
|
14
|
+
auto: 'Choose whichever emphasis the posting itself asks for, and commit to it.',
|
|
15
|
+
'design-systems': 'Lead with design systems: component architecture, tokens, accessibility gates, adoption across teams.',
|
|
16
|
+
'ai-product': 'Lead with AI product surfaces: retrieval, evaluation, agent tooling, making model behaviour legible.',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const RULES = `Hard rules, all binding. This is a factual document that the candidate will upload; a false line here is a lie on an application.
|
|
20
|
+
- Keep every employer, job title, location and date range exactly as the RESUME states them. Do not add, remove, merge or reorder jobs. Do not change a single number.
|
|
21
|
+
- Every bullet you write must be a rewording, tightening or straight copy of a bullet that exists in the RESUME for that same job. You may drop bullets, reorder them within a job, merge two into one, and cut words. You may not introduce a new claim, tool, outcome or responsibility. When in doubt, copy the original bullet.
|
|
22
|
+
- The summary is the one part you write fresh, for this posting: two or three sentences describing the candidate in the terms this posting cares about. It describes the candidate, never the company or the team ("The Magic Team needs..." is wrong; "Design engineer who builds..." is right). No "I". No "more than a decade" opener. Facts only from the RESUME.
|
|
23
|
+
- Reorder the Capabilities lines and the Selected independent products to put what this posting cares about first. Do not add capabilities the RESUME does not list.
|
|
24
|
+
- No adjectives about the candidate ("strong", "passionate", "proven"). No em dashes or en dashes.
|
|
25
|
+
- Output Markdown in exactly this structure and nothing else:
|
|
26
|
+
|
|
27
|
+
# {Name}
|
|
28
|
+
**{Title line}**
|
|
29
|
+
{Location} | {phone} | {email} | {links, exactly as the RESUME's header gives them}
|
|
30
|
+
|
|
31
|
+
## Summary
|
|
32
|
+
{paragraph}
|
|
33
|
+
|
|
34
|
+
## Experience
|
|
35
|
+
### {Company} | {Title}
|
|
36
|
+
{Dates} | {Location}
|
|
37
|
+
- {bullet}
|
|
38
|
+
|
|
39
|
+
(one ### block per job, all jobs, in the RESUME's order)
|
|
40
|
+
|
|
41
|
+
## Capabilities
|
|
42
|
+
- **{Group}:** {items}
|
|
43
|
+
|
|
44
|
+
## Selected independent products
|
|
45
|
+
### {Product} | {one-line descriptor}
|
|
46
|
+
- {bullet}
|
|
47
|
+
|
|
48
|
+
## Earlier experience
|
|
49
|
+
{the RESUME's earlier-experience lines, unchanged}
|
|
50
|
+
|
|
51
|
+
## Education
|
|
52
|
+
{unchanged}`;
|
|
53
|
+
|
|
54
|
+
export async function materials(id, { emphasis = 'auto', extra = '' } = {}) {
|
|
55
|
+
const job = store.getJob(id);
|
|
56
|
+
const packet = store.applicationPacket(id);
|
|
57
|
+
const { resume, profile } = store.profile();
|
|
58
|
+
if (!resume.trim()) throw Object.assign(new Error('No resume in the profile yet. Import one first (tekjobs resume <file>).'), { status: 409 });
|
|
59
|
+
const posting = job.sections.description.replace(/^>.*$/gm, '').trim();
|
|
60
|
+
const decided = ['Narrative', 'Tailored summary', 'Tailored bullets', 'Portfolio', 'Risks'].filter((f) => packet.values[f]).map((f) => `${f}: ${packet.values[f]}`).join('\n');
|
|
61
|
+
const voice = readVoice();
|
|
62
|
+
const prompt = [
|
|
63
|
+
`Tailor the candidate's resume to this posting. Same facts, same jobs, same dates, same numbers; different emphasis, order and summary.`,
|
|
64
|
+
`Emphasis: ${EMPHASES[emphasis] || EMPHASES.auto}`,
|
|
65
|
+
extra.trim() ? `The candidate also asked: ${extra.trim()}` : '',
|
|
66
|
+
RULES,
|
|
67
|
+
voice ? `=== HOW THE CANDIDATE WRITES (for the summary's register) ===\n${voice.split('## What a good letter')[0].trim()}` : '',
|
|
68
|
+
`=== POSTING ===\nCompany: ${job.company}\nTitle: ${job.title}\nLocation: ${job.location}\n\n${posting.slice(0, 8000)}`,
|
|
69
|
+
decided ? `=== WHAT HAS ALREADY BEEN DECIDED FOR THIS APPLICATION ===\n${decided}` : '',
|
|
70
|
+
`=== RESUME (the only source of facts; page markers like "Page 1" and "-- 1 of 2 --" are artifacts, drop them) ===\n${strip(resume)}`,
|
|
71
|
+
`=== PROFILE (context only; facts still come from the RESUME) ===\n${strip(profile).slice(0, 2500)}`,
|
|
72
|
+
].filter(Boolean).join('\n\n');
|
|
73
|
+
return { id, company: job.company, title: job.title, emphasis, prompt, rules: RULES };
|
|
74
|
+
}
|
|
75
|
+
const strip = (md) => md.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').replace(/^>.*$/gm, '').trim();
|
|
76
|
+
|
|
77
|
+
// ---------------- the check ----------------
|
|
78
|
+
|
|
79
|
+
const STOP = new Set(['the', 'a', 'an', 'and', 'or', 'of', 'for', 'to', 'in', 'on', 'with', 'at', 'by', 'from', 'as', 'that', 'this', 'it', 'its', 'is', 'are', 'was', 'were', 'be', 'i', 'my', 'we', 'our', 'their', 'into', 'across', 'than', 'plus', 'also', 'then', 'so', 'own', 'built', 'build', 'led', 'lead', 'work', 'worked', 'team', 'teams', 'product', 'products']);
|
|
80
|
+
const words = (s) => new Set(s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').split(' ').filter((w) => w.length > 2 && !STOP.has(w)));
|
|
81
|
+
/** How much of the bullet's substance appears in one source line: the share of its content words found there. */
|
|
82
|
+
function bestTrace(bullet, sourceLines) {
|
|
83
|
+
const b = words(bullet);
|
|
84
|
+
if (b.size === 0) return { score: 1, line: '' };
|
|
85
|
+
let best = { score: 0, line: '' };
|
|
86
|
+
for (const line of sourceLines) {
|
|
87
|
+
const l = words(line);
|
|
88
|
+
let hit = 0; for (const w of b) if (l.has(w)) hit++;
|
|
89
|
+
const score = hit / b.size;
|
|
90
|
+
if (score > best.score) best = { score, line };
|
|
91
|
+
}
|
|
92
|
+
return best;
|
|
93
|
+
}
|
|
94
|
+
const TRACE_MIN = 0.6;
|
|
95
|
+
|
|
96
|
+
/** Every bullet traces to the resume, every figure is already there, every job header is intact. */
|
|
97
|
+
export function check(id, markdown) {
|
|
98
|
+
return checkAgainst(markdown, store.profile().resume);
|
|
99
|
+
}
|
|
100
|
+
export function checkAgainst(markdown, resumeText) {
|
|
101
|
+
const src = strip(resumeText);
|
|
102
|
+
const srcLines = resumeCheck.lines(src);
|
|
103
|
+
const warnings = [];
|
|
104
|
+
const out = String(markdown || '');
|
|
105
|
+
// Bullets: traceability.
|
|
106
|
+
const bullets = out.split(/\r?\n/).filter((l) => /^\s*-\s+/.test(l) && !/^\s*-\s+\*\*/.test(l)).map((l) => l.replace(/^\s*-\s+/, '').trim());
|
|
107
|
+
for (const b of bullets) {
|
|
108
|
+
const t = bestTrace(b, srcLines);
|
|
109
|
+
if (t.score < TRACE_MIN) warnings.push({ kind: 'trace', text: `Not on your resume (${Math.round(t.score * 100)}% overlap with the closest line): "${b.slice(0, 90)}"` });
|
|
110
|
+
}
|
|
111
|
+
// Figures: none new.
|
|
112
|
+
for (const c of letterClaims(out).filter((c) => !resumeCheck.stillClaimed(c, src))) warnings.push({ kind: 'claim', text: `"${c}" is not a figure your resume gives.` });
|
|
113
|
+
// Jobs: every "Company | Title" header from the source must appear, and no header may be new.
|
|
114
|
+
const srcHeads = srcLines.filter((l) => /\|/.test(l) && !/@|\d{3}\.\d{3}/.test(l) && !/^(design|engineering|ai and automation|platform)\b/i.test(l)).map((l) => l.split('|')[0].trim().toLowerCase()).filter((h) => h.length > 2);
|
|
115
|
+
const outHeads = [...out.matchAll(/^###\s+(.+?)\s*\|/gm)].map((m) => m[1].trim().toLowerCase());
|
|
116
|
+
for (const h of outHeads) if (!srcHeads.some((s) => s.startsWith(h) || h.startsWith(s))) warnings.push({ kind: 'header', text: `"${h}" is not a job or product on your resume.` });
|
|
117
|
+
// Dates: every date range in the output must exist in the source.
|
|
118
|
+
const dateRe = /\b(January|February|March|April|May|June|July|August|September|October|November|December|[A-Z][a-z]{2})\s+\d{4}\s*(?:-|–|to)\s*(?:Present|[A-Z][a-z]+\s+\d{4})/g;
|
|
119
|
+
const srcDates = new Set((src.match(dateRe) || []).map((d) => d.replace(/\s+/g, ' ')));
|
|
120
|
+
for (const d of new Set((out.match(dateRe) || []).map((d) => d.replace(/\s+/g, ' ')))) if (!srcDates.has(d)) warnings.push({ kind: 'date', text: `Date range "${d}" is not on your resume.` });
|
|
121
|
+
if (/[—–]/.test(out)) warnings.push({ kind: 'style', text: 'Contains an em or en dash.' });
|
|
122
|
+
const w = out.trim().split(/\s+/).length;
|
|
123
|
+
if (w > 900) warnings.push({ kind: 'length', text: `${w} words; the source resume is about ${src.split(/\s+/).length}. This will not fit two pages.` });
|
|
124
|
+
return { words: w, bullets: bullets.length, warnings };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------- storage ----------------
|
|
128
|
+
|
|
129
|
+
// A note section ends at the next "## " line, and a resume is full of them. So the resume is stored with every
|
|
130
|
+
// heading demoted two levels (its "## Experience" becomes "#### Experience"), which keeps it nested under the
|
|
131
|
+
// note's own "## Tailored resume" heading, reads correctly in Obsidian, and comes back intact.
|
|
132
|
+
export const demote = (md) => md.replace(/^(#{1,4}) /gm, '$1## ');
|
|
133
|
+
export const promote = (md) => md.replace(/^#{2}(#{1,4}) /gm, '$1 ');
|
|
134
|
+
|
|
135
|
+
export function saved(id) {
|
|
136
|
+
const stored = store.getSection(id, 'Tailored resume');
|
|
137
|
+
if (!stored) return null;
|
|
138
|
+
const text = promote(stored);
|
|
139
|
+
return { text, ...check(id, text) };
|
|
140
|
+
}
|
|
141
|
+
export function save(id, markdown, via = 'app') {
|
|
142
|
+
const clean = String(markdown || '').replace(/\r/g, '').replace(/^```(?:markdown|md)?\n|\n```\s*$/g, '').trim();
|
|
143
|
+
if (!/^#\s+\S/m.test(clean) || !/^## Experience/m.test(clean)) throw Object.assign(new Error('That does not look like a resume in the expected structure (needs a "# Name" heading and "## Experience").'), { status: 400 });
|
|
144
|
+
store.saveSection(id, 'Tailored resume', demote(clean), `Tailored ${store.isoDay()} (via ${via}). Same facts as the resume of record, reordered and re-summarised for this posting. Headings are nested under this one; the app and the print view restore them. Check the warnings, then print it.`);
|
|
145
|
+
store.saveApplicationDraft(id, { field: 'Resume variant', value: `Tailored for this posting ${store.isoDay()}; full text under "Tailored resume" in this note; print view at /api/jobs/${encodeURIComponent(id)}/resume.html` });
|
|
146
|
+
return saved(id);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------- the run ----------------
|
|
150
|
+
|
|
151
|
+
const runs = new Map();
|
|
152
|
+
export function state(id) {
|
|
153
|
+
const d = runs.get(id) || { running: false, startedAt: null, finishedAt: null, error: '', errorKind: '' };
|
|
154
|
+
return { ...d, saved: saved(id), runner: runnerConfig().command };
|
|
155
|
+
}
|
|
156
|
+
export function start(id, opts = {}) {
|
|
157
|
+
if (runs.get(id)?.running) return state(id);
|
|
158
|
+
store.getJob(id);
|
|
159
|
+
runs.set(id, { running: true, startedAt: new Date().toISOString(), finishedAt: null, error: '', errorKind: '' });
|
|
160
|
+
materials(id, opts).then((m) => runLLM(m.prompt, { timeoutMs: 300000 }))
|
|
161
|
+
.then((text) => { save(id, text, `app · ${runnerConfig().command}`); runs.set(id, { ...runs.get(id), running: false, finishedAt: new Date().toISOString() }); })
|
|
162
|
+
.catch((e) => runs.set(id, { ...runs.get(id), running: false, finishedAt: new Date().toISOString(), error: e.message, errorKind: e.kind || 'failed' }));
|
|
163
|
+
return state(id);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------- print view ----------------
|
|
167
|
+
|
|
168
|
+
/** A self-contained page for "Print… → Save as PDF". Deliberately plain: one column, system-safe type, real margins. */
|
|
169
|
+
export function html(id) {
|
|
170
|
+
const stored = store.getSection(id, 'Tailored resume');
|
|
171
|
+
if (!stored) throw Object.assign(new Error('No tailored resume for this job yet.'), { status: 404 });
|
|
172
|
+
const md = promote(stored);
|
|
173
|
+
const job = store.getJob(id);
|
|
174
|
+
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
175
|
+
const inline = (s) => esc(s).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
176
|
+
const lines = md.split(/\r?\n/);
|
|
177
|
+
let out = ''; let inList = false; let i = 0;
|
|
178
|
+
const closeList = () => { if (inList) { out += '</ul>'; inList = false; } };
|
|
179
|
+
while (i < lines.length) {
|
|
180
|
+
const l = lines[i];
|
|
181
|
+
if (/^\s*-\s+/.test(l)) { if (!inList) { out += '<ul>'; inList = true; } out += `<li>${inline(l.replace(/^\s*-\s+/, ''))}</li>`; i++; continue; }
|
|
182
|
+
closeList();
|
|
183
|
+
if (/^# /.test(l)) out += `<h1>${inline(l.slice(2))}</h1>`;
|
|
184
|
+
else if (/^## /.test(l)) out += `<h2>${inline(l.slice(3))}</h2>`;
|
|
185
|
+
else if (/^### /.test(l)) { const [role, ...rest] = l.slice(4).split('|'); out += `<h3><span>${inline(role.trim())}</span>${rest.length ? `<span class="t">${inline(rest.join('|').trim())}</span>` : ''}</h3>`; }
|
|
186
|
+
else if (l.trim()) out += `<p class="${i === 1 || /^\*\*.*\*\*$/.test(l.trim()) ? 'title' : ''}">${inline(l)}</p>`;
|
|
187
|
+
i++;
|
|
188
|
+
}
|
|
189
|
+
closeList();
|
|
190
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${esc(job.company)} · resume</title>
|
|
191
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
192
|
+
<style>
|
|
193
|
+
@page { size: Letter; margin: 0.6in 0.7in; }
|
|
194
|
+
html { font-size: 10.5pt; }
|
|
195
|
+
body { max-width: 7.6in; margin: 0 auto; padding: 24px; font-family: Inter, "Helvetica Neue", Arial, system-ui, sans-serif; color: #1a1a1a; line-height: 1.38; }
|
|
196
|
+
h1 { font-size: 20pt; margin: 0 0 2px; letter-spacing: -0.01em; }
|
|
197
|
+
.title { margin: 0 0 2px; font-weight: 600; }
|
|
198
|
+
h1 + .title + p, h1 + p { color: #444; margin: 0 0 14px; font-size: 9.5pt; }
|
|
199
|
+
h2 { font-size: 9pt; letter-spacing: 0.12em; text-transform: uppercase; color: #555; margin: 16px 0 6px; padding-bottom: 3px; border-bottom: 1px solid #ccc; }
|
|
200
|
+
h3 { display: flex; justify-content: space-between; gap: 12px; font-size: 10.5pt; margin: 10px 0 0; }
|
|
201
|
+
h3 .t { font-weight: 500; color: #333; }
|
|
202
|
+
h3 + p { margin: 0 0 4px; color: #555; font-size: 9.5pt; }
|
|
203
|
+
p { margin: 0 0 6px; }
|
|
204
|
+
ul { margin: 2px 0 6px; padding-left: 16px; }
|
|
205
|
+
li { margin: 0 0 2px; }
|
|
206
|
+
.bar { position: sticky; top: 0; display: flex; gap: 12px; align-items: center; justify-content: space-between; margin: -24px -24px 20px; padding: 10px 24px; background: #f3f8f6; border-bottom: 1px solid #dadfdd; font-size: 12px; }
|
|
207
|
+
.bar button { font: inherit; padding: 6px 12px; border: 1px solid #0f6e56; border-radius: 6px; background: #0f6e56; color: #fff; cursor: pointer; }
|
|
208
|
+
@media print { .bar { display: none; } body { padding: 0; } }
|
|
209
|
+
</style></head><body>
|
|
210
|
+
<div class="bar"><span>Tailored for <strong>${esc(job.company)}</strong>, ${esc(job.title)}. Print and choose "Save as PDF"; margins and page size are set.</span><button onclick="window.print()">Print / Save as PDF</button></div>
|
|
211
|
+
${out}
|
|
212
|
+
</body></html>`;
|
|
213
|
+
}
|
package/cli.mjs
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tekjobs CLI: init a profile folder, import a resume, check onboarding, scan, serve, mcp.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const ROOT = fileURLToPath(new URL('.', import.meta.url));
|
|
9
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
10
|
+
const opt = (f) => { const i = rest.indexOf(f); return i >= 0 ? rest[i + 1] : undefined; };
|
|
11
|
+
const HELP = `tekjobs — a local job-search machine
|
|
12
|
+
|
|
13
|
+
tekjobs init [dir] [--resume <file>] create the profile folder (default ~/.tekjobs/profile), remember it, import a resume
|
|
14
|
+
tekjobs resume <file> import or replace the resume (PDF, DOCX, Markdown, text)
|
|
15
|
+
tekjobs resume sync [<url|file>] refresh Profile/Resume.md from where you keep your resume (a link-shared
|
|
16
|
+
Google Doc, a public page, or a file); remembers the source; flags drafts
|
|
17
|
+
that quote claims you removed (--dry to preview)
|
|
18
|
+
tekjobs status what the onboarding still needs
|
|
19
|
+
tekjobs --version the installed version
|
|
20
|
+
tekjobs scan [--dry] [--criteria <name|file>]
|
|
21
|
+
fetch every board, score, write matches; --criteria scores this one run
|
|
22
|
+
with a named preset from Targets/Criteria/ instead of Search Criteria.md
|
|
23
|
+
tekjobs mail [--days N] read the mailbox for application updates (confirmations, rejections,
|
|
24
|
+
interviews) through the local CLI's Gmail connector, read tools only;
|
|
25
|
+
matches them to notes; you confirm each one on the Today page
|
|
26
|
+
tekjobs add <url> [<url>...] add postings you found yourself (LinkedIn, a newsletter, a friend);
|
|
27
|
+
each link is read once, scored, and written as a note unless one exists
|
|
28
|
+
tekjobs rescore --from <old.json> apply changed scoring weights to notes that already exist (--dry to preview)
|
|
29
|
+
tekjobs rescore --stamp-only record the current weights on every note without changing a score
|
|
30
|
+
tekjobs rescore --full [--dry] score every note again from the note itself under the current criteria
|
|
31
|
+
(for location, description or seniority rule changes); notes already
|
|
32
|
+
at these weights are left alone
|
|
33
|
+
tekjobs schedule [--time HH:MM] [--print]
|
|
34
|
+
run the scan and the mail read every morning: creates the Windows task
|
|
35
|
+
(07:30 by default), or prints the crontab or launchd line for macOS and
|
|
36
|
+
Linux; --print shows the command without installing anything
|
|
37
|
+
tekjobs serve the app + API on http://127.0.0.1:8787
|
|
38
|
+
tekjobs mcp the MCP server on stdio (for Claude Code, Claude Desktop, ChatGPT, Cursor)
|
|
39
|
+
|
|
40
|
+
The profile folder is resolved from TEKJOBS_PROFILE, then ~/.tekjobs/config.json, then ~/.tekjobs/profile.
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
async function main() {
|
|
44
|
+
if (!cmd || cmd === 'help' || cmd === '--help') return console.log(HELP);
|
|
45
|
+
if (cmd === '--version' || cmd === '-v' || cmd === 'version') return console.log(JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version);
|
|
46
|
+
if (cmd === 'schedule') return schedule();
|
|
47
|
+
if (cmd === 'init') {
|
|
48
|
+
const dirArg = rest.find((a) => !a.startsWith('--') && a !== opt('--resume'));
|
|
49
|
+
const dir = path.resolve(dirArg || process.env.TEKJOBS_PROFILE || path.join(process.env.USERPROFILE || process.env.HOME || '.', '.tekjobs', 'profile'));
|
|
50
|
+
process.env.TEKJOBS_PROFILE = dir;
|
|
51
|
+
const { initProfile, importResume, onboardingStatus } = await import('./scraper/profile.mjs');
|
|
52
|
+
const r = initProfile(dir);
|
|
53
|
+
console.log(`Profile folder: ${r.dir}${r.made.length ? `\n created: ${r.made.join(', ')}` : '\n (already existed; nothing overwritten)'}`);
|
|
54
|
+
if (opt('--resume')) { const i = await importResume(path.resolve(opt('--resume')), dir); console.log(`Resume imported: ${i.chars} characters → ${i.source}`); }
|
|
55
|
+
return printStatus(onboardingStatus(dir));
|
|
56
|
+
}
|
|
57
|
+
const { importResume, onboardingStatus } = await import('./scraper/profile.mjs');
|
|
58
|
+
const { VAULT } = await import('./scraper/config.mjs');
|
|
59
|
+
if (cmd === 'resume' && rest[0] === 'sync') {
|
|
60
|
+
const { syncResume } = await import('./scraper/resume-sync.mjs');
|
|
61
|
+
const source = rest.slice(1).find((a) => !a.startsWith('--')) || '';
|
|
62
|
+
const r = await syncResume({ source, dry: rest.includes('--dry') });
|
|
63
|
+
const show = (label, list, mark) => {
|
|
64
|
+
if (!list.length) return;
|
|
65
|
+
console.log(`\n${label} (${list.length}):`);
|
|
66
|
+
for (const l of list.slice(0, 25)) console.log(` ${mark} ${l.length > 110 ? l.slice(0, 107) + '...' : l}`);
|
|
67
|
+
if (list.length > 25) console.log(` ... and ${list.length - 25} more`);
|
|
68
|
+
};
|
|
69
|
+
console.log(`${r.wrote ? 'Synced' : 'Dry run, nothing written:'} ${r.kind} ${r.source} (${r.chars} characters) → ${r.note}`);
|
|
70
|
+
console.log(r.previous ? `Compared with ${r.previous}.` : 'No earlier resume note to compare with.');
|
|
71
|
+
if (r.previous && !r.removed.length && !r.added.length) console.log('No line changed.');
|
|
72
|
+
show('Removed from the resume', r.removed, '-');
|
|
73
|
+
show('Added to the resume', r.added, '+');
|
|
74
|
+
if (r.stale.length) {
|
|
75
|
+
console.log(`\nApplication packets still quoting claims the resume no longer makes (${r.stale.length}):`);
|
|
76
|
+
for (const s of r.stale) console.log(` ${s.note}\n ${s.quotes.map((q) => `"${q}"`).join(', ')}`);
|
|
77
|
+
console.log('Review those drafts before sending anything.');
|
|
78
|
+
} else if (r.removed.length) console.log('\nNo application packet quotes a removed claim.');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (cmd === 'resume') {
|
|
82
|
+
const file = rest[0]; if (!file) return console.error('usage: tekjobs resume <file> | tekjobs resume sync [<url|file>] [--dry]');
|
|
83
|
+
const i = await importResume(path.resolve(file)); console.log(`Resume imported: ${i.chars} characters → ${i.source}`);
|
|
84
|
+
return printStatus(onboardingStatus(VAULT));
|
|
85
|
+
}
|
|
86
|
+
if (cmd === 'status') return printStatus(onboardingStatus(VAULT));
|
|
87
|
+
if (cmd === 'scan') return run(process.execPath, [path.join(ROOT, 'run.mjs'), ...rest]);
|
|
88
|
+
if (cmd === 'mail') {
|
|
89
|
+
const mail = await import('./app/server/mail-check.mjs');
|
|
90
|
+
const days = opt('--days') ? Number(opt('--days')) : undefined;
|
|
91
|
+
mail.start({ sinceDays: days });
|
|
92
|
+
process.stdout.write('Reading the mailbox through the local CLI (Gmail read tools only)');
|
|
93
|
+
while (mail.items().running) { await new Promise((r) => setTimeout(r, 3000)); process.stdout.write('.'); }
|
|
94
|
+
const s = mail.items(); console.log('');
|
|
95
|
+
if (s.error) { console.error(s.error); process.exitCode = 1; return; }
|
|
96
|
+
const pending = s.items.filter((i) => i.state === 'pending');
|
|
97
|
+
console.log(`${s.items.length} application emails on file, ${pending.length} waiting for a decision. Confirm them on the Today page.`);
|
|
98
|
+
for (const i of pending.slice(0, 40)) console.log(` ${i.date} ${i.kind.padEnd(12)} ${i.company} — ${i.role || '(role not stated)'} → ${i.match === 'none' ? 'no note: would create one' : `${i.match} match, ${i.noteStatus}: ${i.suggestion.action}${i.suggestion.status ? ' ' + i.suggestion.status : ''}`}`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (cmd === 'add') {
|
|
102
|
+
const urls = rest.filter((a) => !a.startsWith('--'));
|
|
103
|
+
if (!urls.length) return console.error('usage: tekjobs add <url> [<url>...] [--dry]');
|
|
104
|
+
const { importLink } = await import('./scraper/import-link.mjs');
|
|
105
|
+
for (const u of urls) {
|
|
106
|
+
const r = await importLink(u, { dry: rest.includes('--dry') });
|
|
107
|
+
if (!r.ok) { console.log(` x ${u}\n ${r.error}`); continue; }
|
|
108
|
+
const head = `${String(r.score).padStart(4)} ${r.job.company} — ${r.job.title} [${r.job.location || 'location n/a'}]`;
|
|
109
|
+
if (r.added) console.log(` + ${head}\n ${r.note}${r.belowMin ? '\n (below the scan\'s minimum score; written anyway because you asked for it)' : ''}`);
|
|
110
|
+
else if (r.dry) console.log(` · ${head}\n would write ${r.note}`);
|
|
111
|
+
else console.log(` = ${head}\n already here: ${r.reason}\n ${r.existing}`);
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (cmd === 'rescore') {
|
|
116
|
+
const { rescore, rescoreFull } = await import('./scraper/rescore.mjs');
|
|
117
|
+
const dry = rest.includes('--dry');
|
|
118
|
+
if (rest.includes('--full')) {
|
|
119
|
+
const r = rescoreFull({ dry });
|
|
120
|
+
const bar = (await import('./scraper/config.mjs')).loadCriteria().minScore || 0;
|
|
121
|
+
const crossed = r.changes.filter((c) => (c.stored >= bar) !== (c.updated >= bar));
|
|
122
|
+
console.log(`${r.changed} of ${r.considered} notes ${dry ? 'would change' : 'updated'} under weights ${r.fingerprint}; ${r.alreadyCurrent} already carried them.`);
|
|
123
|
+
console.log(`${crossed.filter((c) => c.updated >= bar).length} rise above the bar (${bar}), ${crossed.filter((c) => c.updated < bar).length} fall below it.`);
|
|
124
|
+
for (const c of r.changes.slice(0, 15)) console.log(` ${String(c.stored).padStart(4)} -> ${String(c.updated).padStart(4)} ${c.company} — ${c.title.trim().slice(0, 50)}${c.status && c.status !== 'new' ? ` [${c.status}]` : ''}`);
|
|
125
|
+
if (dry) console.log('\nNothing written. Run without --dry to apply.');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (rest.includes('--stamp-only')) {
|
|
129
|
+
const r = rescore({ dry, stampOnly: true });
|
|
130
|
+
console.log(`${r.changed} of ${r.total} notes ${dry ? 'would be' : ''} stamped with weights ${r.fingerprint}.`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const fromIdx = rest.indexOf('--from');
|
|
134
|
+
const fromPath = fromIdx >= 0 ? rest[fromIdx + 1] : null;
|
|
135
|
+
if (!fromPath) {
|
|
136
|
+
console.error([
|
|
137
|
+
'rescore needs the criteria your notes were scored under:',
|
|
138
|
+
' tekjobs rescore --from <old-criteria.json> [--dry]',
|
|
139
|
+
'',
|
|
140
|
+
'A note records its score but not the weights behind it, so without the old ones this would double-apply.',
|
|
141
|
+
].join('\n'));
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const before = JSON.parse(fs.readFileSync(fromPath, 'utf8'));
|
|
146
|
+
const r = rescore({ dry, before });
|
|
147
|
+
console.log(`${r.changed} of ${r.total} notes ${dry ? 'would change' : 'updated'}.`);
|
|
148
|
+
for (const c of r.changes.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)).slice(0, 10)) {
|
|
149
|
+
console.log(` ${String(c.stored).padStart(4)} -> ${String(c.updated).padStart(4)} (${c.delta > 0 ? '+' : ''}${c.delta}) ${c.company} — ${c.title.trim().slice(0, 44)}`);
|
|
150
|
+
}
|
|
151
|
+
if (dry) console.log('\nNothing written. Run without --dry to apply.');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (cmd === 'serve') return run(process.execPath, [path.join(ROOT, 'app', 'server', 'index.mjs')]);
|
|
155
|
+
if (cmd === 'mcp') return run(process.execPath, [path.join(ROOT, 'app', 'server', 'mcp.mjs')]);
|
|
156
|
+
console.error(`unknown command "${cmd}"\n`); console.log(HELP); process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
function printStatus(s) {
|
|
159
|
+
console.log(`\nOnboarding for ${s.dir}:`);
|
|
160
|
+
for (const st of s.steps) console.log(` ${st.done ? '✓' : '·'} ${st.label}${st.done ? '' : ` → ${st.how}`}`);
|
|
161
|
+
console.log(s.complete ? '\nAll set. The daily scan takes it from here.' : '\nNext: the first unchecked line above.');
|
|
162
|
+
}
|
|
163
|
+
function run(bin, args) { const p = spawn(bin, args, { stdio: 'inherit', env: process.env }); p.on('close', (c) => process.exit(c ?? 0)); }
|
|
164
|
+
main().catch((e) => { console.error(e.message); process.exit(1); });
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The morning task. On Windows, a Task Scheduler entry that runs run.cmd (the scan, then the mail read) daily;
|
|
168
|
+
* elsewhere, the crontab line or launchd plist that runs run.sh, printed for the person to install, because
|
|
169
|
+
* editing a crontab unasked is not this tool's place. --print shows the Windows command without running it.
|
|
170
|
+
*/
|
|
171
|
+
function schedule() {
|
|
172
|
+
// A task that runs from npx's cache breaks the first time the cache is cleaned. Install first.
|
|
173
|
+
if (/[\\/]_npx[\\/]/.test(ROOT)) return console.error(`This copy of tekjobs runs from npx's cache, which does not last. Install it, then schedule from there:\n npm install -g @timurtekb/tekjobs\n tekjobs schedule`);
|
|
174
|
+
const time = opt('--time') || '07:30';
|
|
175
|
+
if (!/^\d{2}:\d{2}$/.test(time)) return console.error('--time wants HH:MM, for example 07:30');
|
|
176
|
+
const [hh, mm] = time.split(':').map(Number);
|
|
177
|
+
const printOnly = rest.includes('--print');
|
|
178
|
+
if (process.platform === 'win32') {
|
|
179
|
+
const cmd = `schtasks /Create /F /SC DAILY /ST ${time} /TN "TekJobs Daily Scan" /TR "\\"${path.join(ROOT, 'run.cmd')}\\""`;
|
|
180
|
+
console.log(`Windows Task Scheduler entry "TekJobs Daily Scan", daily at ${time}, running run.cmd (the scan, then the mail read):\n ${cmd}`);
|
|
181
|
+
if (printOnly) return;
|
|
182
|
+
const r = spawnSync(cmd, { shell: true, encoding: 'utf8' });
|
|
183
|
+
if (r.status === 0) console.log('Installed. See it in Task Scheduler, or remove it with: schtasks /Delete /TN "TekJobs Daily Scan" /F');
|
|
184
|
+
else console.error(`schtasks answered ${r.status}: ${(r.stderr || r.stdout || '').trim()}\nRun the command above in an elevated terminal if Windows asked for permission.`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const sh = path.join(ROOT, 'run.sh');
|
|
188
|
+
console.log(`Add one line to your crontab (crontab -e), daily at ${time}:\n ${mm} ${hh} * * * ${sh}\n`);
|
|
189
|
+
if (process.platform === 'darwin') {
|
|
190
|
+
const plist = path.join(process.env.HOME || '~', 'Library', 'LaunchAgents', 'com.tekjobs.scan.plist');
|
|
191
|
+
console.log(`Or, on macOS, a launchd agent (it survives sleep better than cron):
|
|
192
|
+
cat > ${plist} <<'EOF'
|
|
193
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
194
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
195
|
+
<plist version="1.0"><dict>
|
|
196
|
+
<key>Label</key><string>com.tekjobs.scan</string>
|
|
197
|
+
<key>ProgramArguments</key><array><string>/bin/sh</string><string>${sh}</string></array>
|
|
198
|
+
<key>StartCalendarInterval</key><dict><key>Hour</key><integer>${hh}</integer><key>Minute</key><integer>${mm}</integer></dict>
|
|
199
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string></dict>
|
|
200
|
+
</dict></plist>
|
|
201
|
+
EOF
|
|
202
|
+
launchctl load ${plist}`);
|
|
203
|
+
}
|
|
204
|
+
console.log('\nBoth run run.sh: the scan, then the read-only mail pass, appending to data/runs.log.');
|
|
205
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@timurtekb/tekjobs",
|
|
3
|
+
"version": "0.24.1",
|
|
4
|
+
"description": "A local-first job-search machine: watches hundreds of company job boards, scores every posting against your profile, and files matches into a folder of markdown notes you own. Bring your own LLM through MCP.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": false,
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=20"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"tekjobs": "./cli.mjs"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"prepare": "husky || true",
|
|
16
|
+
"release": "semantic-release",
|
|
17
|
+
"test": "node --test \"test/**/*.test.mjs\"",
|
|
18
|
+
"init": "node cli.mjs init",
|
|
19
|
+
"status": "node cli.mjs status",
|
|
20
|
+
"scan": "node run.mjs",
|
|
21
|
+
"dry": "node run.mjs --dry",
|
|
22
|
+
"check": "node run.mjs --dry --check-slugs",
|
|
23
|
+
"serve": "node app/server/index.mjs",
|
|
24
|
+
"mcp": "node app/server/mcp.mjs",
|
|
25
|
+
"sample": "node tools/make-sample-vault.mjs"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"pdf-parse": "^2.4.5"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@commitlint/cli": "^19.8.1",
|
|
32
|
+
"@commitlint/config-conventional": "^19.8.1",
|
|
33
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
34
|
+
"@semantic-release/git": "^10.0.1",
|
|
35
|
+
"@semantic-release/npm": "^13.2.0",
|
|
36
|
+
"husky": "^9.1.7",
|
|
37
|
+
"semantic-release": "^24.2.7"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/Timurtek/tekjobs.git"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://tekjobs.timurtek.com",
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/Timurtek/tekjobs/issues"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"job-search",
|
|
49
|
+
"local-first",
|
|
50
|
+
"markdown",
|
|
51
|
+
"obsidian",
|
|
52
|
+
"mcp",
|
|
53
|
+
"scraper",
|
|
54
|
+
"greenhouse",
|
|
55
|
+
"lever",
|
|
56
|
+
"ashby"
|
|
57
|
+
],
|
|
58
|
+
"files": [
|
|
59
|
+
"cli.mjs",
|
|
60
|
+
"run.mjs",
|
|
61
|
+
"run.cmd",
|
|
62
|
+
"run.sh",
|
|
63
|
+
"scraper/",
|
|
64
|
+
"app/server/",
|
|
65
|
+
"app/dist/",
|
|
66
|
+
"samples/vault/",
|
|
67
|
+
"README.md",
|
|
68
|
+
"LICENSE",
|
|
69
|
+
"CHANGELOG.md"
|
|
70
|
+
],
|
|
71
|
+
"publishConfig": {
|
|
72
|
+
"access": "public"
|
|
73
|
+
}
|
|
74
|
+
}
|
package/run.cmd
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
rem Daily TekJobs scan. Invoked by Windows Task Scheduler (task name: TekJobs Daily Scan).
|
|
3
|
+
cd /d %~dp0
|
|
4
|
+
set TEKJOBS_RUN_VIA=schedule
|
|
5
|
+
if not exist data mkdir data
|
|
6
|
+
echo ===== %date% %time% ===== >> data\runs.log
|
|
7
|
+
node run.mjs >> data\runs.log 2>&1
|
|
8
|
+
rem Then the mailbox: a read-only pass through the local CLI's Gmail connector, matched to the notes for you to confirm in the app.
|
|
9
|
+
echo ----- mail %date% %time% ----- >> data\runs.log
|
|
10
|
+
node cli.mjs mail >> data\runs.log 2>&1
|