@timurtekb/tekjobs 0.46.0 → 0.48.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/CHANGELOG.md +12 -0
- package/app/dist/assets/{index-V6WTCNu_.js → index-BAca4QLI.js} +5 -4
- package/app/dist/assets/{index-Dhv_7hBU.css → index-DXU1rm72.css} +1 -1
- package/app/dist/index.html +2 -2
- package/app/server/index.mjs +7 -0
- package/app/server/mail-check.mjs +9 -1
- package/app/server/store.mjs +47 -0
- package/cli.mjs +9 -0
- package/package.json +1 -1
- package/run.mjs +2 -0
- package/scraper/journal.mjs +114 -0
package/cli.mjs
CHANGED
|
@@ -46,6 +46,8 @@ const HELP = `tekjobs — a local job-search machine
|
|
|
46
46
|
tekjobs down stop what \`up\` started
|
|
47
47
|
tekjobs ps what \`up\` has running
|
|
48
48
|
tekjobs board <slug> [--from ats] [--move] where a failed board answers now; --move rewrites the watchlist row
|
|
49
|
+
tekjobs journal [YYYY-MM-DD] [--write] the day's journal from the record (runs, found, decisions, mail); --write
|
|
50
|
+
puts it at the end of Logs/<date>.md, as every real scan does
|
|
49
51
|
tekjobs serve the app + API on http://127.0.0.1:8787, in this terminal
|
|
50
52
|
tekjobs mcp the MCP server on stdio (Claude Code, Codex, Cursor, Claude Desktop)
|
|
51
53
|
|
|
@@ -202,6 +204,13 @@ async function main() {
|
|
|
202
204
|
if (!s.dry) console.log(` Index: ${s.indexPath}. Job notes now show who you know at each company.`);
|
|
203
205
|
return;
|
|
204
206
|
}
|
|
207
|
+
if (cmd === 'journal') {
|
|
208
|
+
const { buildJournal, renderJournal, writeJournal } = await import('./scraper/journal.mjs');
|
|
209
|
+
const date = rest.find((x) => /^\d{4}-\d{2}-\d{2}$/.test(x));
|
|
210
|
+
if (rest.includes('--write')) { const r = writeJournal(date); console.log(r.markdown); console.log(`Written to ${r.file}`); return; }
|
|
211
|
+
console.log(renderJournal(buildJournal(date)));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
205
214
|
if (cmd === 'board') {
|
|
206
215
|
const slug = rest.find((x) => !x.startsWith('--') && x !== opt('--from'));
|
|
207
216
|
if (!slug) return console.error('Usage: tekjobs board <slug> [--from <ats that failed>] [--move]');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timurtekb/tekjobs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/run.mjs
CHANGED
|
@@ -203,6 +203,8 @@ const logLines = [
|
|
|
203
203
|
if (!DRY) {
|
|
204
204
|
appendLog(logLines);
|
|
205
205
|
writeDashboard(summary);
|
|
206
|
+
// The day's journal is rebuilt from the record after every real run, so the log note ends with the day so far.
|
|
207
|
+
try { const { writeJournal } = await import('./scraper/journal.mjs'); writeJournal(); } catch (e) { console.log(` journal not written: ${e.message}`); }
|
|
206
208
|
}
|
|
207
209
|
console.log(logLines.join('\n'));
|
|
208
210
|
// The top of the list, with where each one stands. Ranking ignores status on purpose (the score is about the
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// The daily journal: what happened in the search on one day, read from the record itself and written as the last
|
|
2
|
+
// section of that day's log note. Runs (from the log's own run sections), what was found, every decision made
|
|
3
|
+
// (status moves, with who made them), mail confirmed or dismissed, and notes added. Nothing is stored twice: the
|
|
4
|
+
// section is rebuilt from the notes each time and replaces the previous one, so a day's journal is always the
|
|
5
|
+
// day's whole story. The site promises that the notes are the record; this is the record read back as a day.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { P, DATA_DIR } from './config.mjs';
|
|
9
|
+
|
|
10
|
+
export const JOURNAL_HEADING = '## Journal';
|
|
11
|
+
const isoDay = (d = new Date()) => d.toISOString().slice(0, 10);
|
|
12
|
+
|
|
13
|
+
const readFm = (text) => {
|
|
14
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
15
|
+
const out = {};
|
|
16
|
+
if (m) for (const line of m[1].split(/\r?\n/)) { const i = line.indexOf(':'); if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^"(.*)"$/, '$1'); }
|
|
17
|
+
return out;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** The day's events, as data. `date` is YYYY-MM-DD; today by default. */
|
|
21
|
+
export function buildJournal(date = isoDay()) {
|
|
22
|
+
const found = [], moves = [], notes = [];
|
|
23
|
+
if (fs.existsSync(P.jobs)) {
|
|
24
|
+
for (const f of fs.readdirSync(P.jobs).filter((x) => x.endsWith('.md'))) {
|
|
25
|
+
let text; try { text = fs.readFileSync(path.join(P.jobs, f), 'utf8'); } catch { continue; }
|
|
26
|
+
const fm = readFm(text);
|
|
27
|
+
const id = f.replace(/\.md$/, '');
|
|
28
|
+
const row = { id, company: fm.company || '', title: fm.title || '', score: Number(fm.score) || 0, status: fm.status || 'new' };
|
|
29
|
+
if (fm.found === date) found.push(row);
|
|
30
|
+
for (const m of text.matchAll(/^- (\d{4}-\d{2}-\d{2}) — (.*?) → \*\*([a-z]+)\*\*(?: \(([^)]*)\))? \(via ([^)]+)\)/gm)) {
|
|
31
|
+
if (m[1] === date) moves.push({ ...row, from: m[2], to: m[3], reason: m[4] || '', via: m[5] });
|
|
32
|
+
}
|
|
33
|
+
for (const m of text.matchAll(/^- (\d{4}-\d{2}-\d{2}) \(([^)]+)\): (.+)$/gm)) {
|
|
34
|
+
if (m[1] === date) notes.push({ ...row, via: m[2], text: m[3].slice(0, 160) });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Mail: items confirmed or dismissed on the day, from the check's own state.
|
|
39
|
+
let mail = { confirmed: 0, dismissed: 0, items: [] };
|
|
40
|
+
try {
|
|
41
|
+
const d = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'mail-check.json'), 'utf8'));
|
|
42
|
+
for (const i of d.items || []) {
|
|
43
|
+
const at = i.resolved?.at ? String(i.resolved.at).slice(0, 10) : '';
|
|
44
|
+
if (at !== date) continue;
|
|
45
|
+
if (i.state === 'confirmed') mail.confirmed++; else if (i.state === 'dismissed') mail.dismissed++;
|
|
46
|
+
mail.items.push({ company: i.company || '', role: i.role || '', kind: i.kind || '', state: i.state, status: i.resolved?.status || '' });
|
|
47
|
+
}
|
|
48
|
+
} catch { /* no mail state */ }
|
|
49
|
+
// Runs: the log's own "## Run" sections for the day.
|
|
50
|
+
const runs = [];
|
|
51
|
+
const logFile = path.join(P.logs, `${date}.md`);
|
|
52
|
+
if (fs.existsSync(logFile)) {
|
|
53
|
+
const text = fs.readFileSync(logFile, 'utf8');
|
|
54
|
+
for (const m of text.matchAll(/^## Run (.+?)\n([\s\S]*?)(?=^## |(?![\s\S]))/gm)) {
|
|
55
|
+
const b = m[2];
|
|
56
|
+
const g = (re) => (b.match(re) || [])[1];
|
|
57
|
+
runs.push({ when: m[1].trim(), boardsOk: Number(g(/Boards: (\d+)\//)) || 0, boardsTotal: Number(g(/Boards: \d+\/(\d+)/)) || 0, scanned: Number((g(/postings scanned: ([\d,]+)/) || '0').replace(/,/g, '')), newNotes: Number(g(/\*\*new: (\d+)\*\*/)) || 0, closed: Number(g(/closed: (\d+)/)) || 0, dry: /\(dry\)/.test(m[1]) });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const byTo = {};
|
|
61
|
+
for (const m of moves) (byTo[m.to] ||= []).push(m);
|
|
62
|
+
return { date, runs, found, moves, byTo, notes, mail, empty: runs.length === 0 && found.length === 0 && moves.length === 0 && notes.length === 0 && mail.items.length === 0 };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const line = (r) => `${r.company} — ${r.title}`;
|
|
66
|
+
const plural = (n, s, p = `${s}s`) => `${n} ${n === 1 ? s : p}`;
|
|
67
|
+
|
|
68
|
+
/** The journal as the markdown section the log note carries. */
|
|
69
|
+
export function renderJournal(j) {
|
|
70
|
+
const out = [JOURNAL_HEADING, ''];
|
|
71
|
+
if (j.empty) { out.push('Nothing happened in the search today: no run, no decisions, no mail.', ''); return out.join('\n'); }
|
|
72
|
+
const real = j.runs.filter((r) => !r.dry);
|
|
73
|
+
if (real.length) {
|
|
74
|
+
const last = real[real.length - 1];
|
|
75
|
+
out.push(`- **Scan**: ${plural(real.length, 'run')}; the last read ${last.boardsOk}/${last.boardsTotal} boards and ${last.scanned.toLocaleString('en-US')} postings, wrote ${plural(last.newNotes, 'new note')}, closed ${last.closed}.`);
|
|
76
|
+
} else if (j.runs.length) out.push(`- **Scan**: ${plural(j.runs.length, 'dry run')} only; nothing written.`);
|
|
77
|
+
if (j.found.length) {
|
|
78
|
+
const top = [...j.found].sort((a, b) => b.score - a.score).slice(0, 5);
|
|
79
|
+
out.push(`- **Found**: ${plural(j.found.length, 'new match', 'new matches')}. Best: ${top.map((r) => `${line(r)} (${r.score})`).join('; ')}.`);
|
|
80
|
+
}
|
|
81
|
+
const order = ['reviewing', 'applying', 'ready', 'applied', 'interviewing', 'offer', 'rejected', 'passed', 'new'];
|
|
82
|
+
const words = { reviewing: 'shortlisted', applying: 'started applying to', ready: 'made ready', applied: 'applied to', interviewing: 'interviewing at', offer: 'offer from', rejected: 'rejected by', passed: 'passed on', new: 'put back to new' };
|
|
83
|
+
for (const to of order) {
|
|
84
|
+
const ms = j.byTo[to]; if (!ms?.length) continue;
|
|
85
|
+
const who = [...new Set(ms.map((m) => m.via))].join(', ');
|
|
86
|
+
const names = ms.slice(0, 6).map((m) => `${line(m)}${m.reason ? ` (${m.reason})` : ''}`).join('; ');
|
|
87
|
+
out.push(`- **${words[to] ? words[to][0].toUpperCase() + words[to].slice(1) : to}**: ${ms.length}${ms.length > 6 ? `, including` : ''} ${names}${ms.length > 6 ? ` and ${ms.length - 6} more` : ''} (via ${who}).`);
|
|
88
|
+
}
|
|
89
|
+
if (j.mail.items.length) {
|
|
90
|
+
const kinds = {};
|
|
91
|
+
for (const i of j.mail.items.filter((i) => i.state === 'confirmed')) kinds[i.kind] = (kinds[i.kind] || 0) + 1;
|
|
92
|
+
const kindText = Object.entries(kinds).map(([k, n]) => `${n} ${k}`).join(', ');
|
|
93
|
+
out.push(`- **Mail**: ${plural(j.mail.confirmed, 'item')} confirmed${kindText ? ` (${kindText})` : ''}, ${j.mail.dismissed} dismissed.`);
|
|
94
|
+
}
|
|
95
|
+
const human = j.notes.filter((n) => n.via !== 'app' || true).length;
|
|
96
|
+
if (human) out.push(`- **Notes**: ${plural(j.notes.length, 'line')} added across ${new Set(j.notes.map((n) => n.id)).size} job notes.`);
|
|
97
|
+
out.push('');
|
|
98
|
+
return out.join('\n');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Write (or rewrite) the day's Journal section at the end of Logs/<date>.md. Returns the file and the text. */
|
|
102
|
+
export function writeJournal(date = isoDay()) {
|
|
103
|
+
const j = buildJournal(date);
|
|
104
|
+
const section = renderJournal(j);
|
|
105
|
+
fs.mkdirSync(P.logs, { recursive: true });
|
|
106
|
+
const file = path.join(P.logs, `${date}.md`);
|
|
107
|
+
let text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : `---\ntype: log\ndate: ${date}\n---\n# ${date} — scraper log\n\n`;
|
|
108
|
+
// Replace an earlier journal for the day; it is always the last section.
|
|
109
|
+
const i = text.indexOf(`\n${JOURNAL_HEADING}\n`);
|
|
110
|
+
if (i >= 0) text = text.slice(0, i + 1);
|
|
111
|
+
if (!text.endsWith('\n\n')) text = text.replace(/\n*$/, '\n\n');
|
|
112
|
+
fs.writeFileSync(file, text + section + '\n');
|
|
113
|
+
return { file, journal: j, markdown: section };
|
|
114
|
+
}
|