@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/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.46.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
+ }