@timurtekb/tekjobs 0.45.0 → 0.47.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-pTsHxrh7.css → index-8e9hXz21.css} +1 -1
- package/app/dist/assets/{index-BtNxvOxd.js → index-DBhdgfnv.js} +11 -10
- package/app/dist/index.html +2 -2
- package/app/server/index.mjs +5 -1
- package/app/server/linkedin-import.mjs +99 -2
- package/app/server/mcp.mjs +6 -2
- package/cli.mjs +22 -1
- package/package.json +1 -1
- package/run.mjs +2 -0
- package/scraper/journal.mjs +114 -0
package/app/dist/index.html
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<link rel="icon" href="/brand/tekjobs-avatar-192.png" type="image/png" sizes="192x192" />
|
|
9
9
|
<link rel="apple-touch-icon" href="/brand/tekjobs-avatar-192.png" />
|
|
10
10
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@500;600&family=Hanken+Grotesk:wght@400;500;600&family=Azeret+Mono:wght@400;500&display=swap" data-zengin="fonts" />
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-DBhdgfnv.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-8e9hXz21.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
package/app/server/index.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import * as mail from './mail-check.mjs';
|
|
|
11
11
|
import * as people from './people.mjs';
|
|
12
12
|
import * as linkedin from './linkedin-import.mjs';
|
|
13
13
|
import { TOOLS } from './mcp.mjs';
|
|
14
|
+
import { buildJournal, renderJournal, writeJournal } from '../../scraper/journal.mjs';
|
|
14
15
|
|
|
15
16
|
const PORT = Number(process.env.PORT || 8787);
|
|
16
17
|
const DIST = fileURLToPath(new URL('../dist', import.meta.url));
|
|
@@ -41,7 +42,7 @@ const routes = [
|
|
|
41
42
|
['GET', /^\/api\/jobs\/([^/]+)\/connections$/, (m) => linkedin.connectionsAt(store.getJob(decodeURIComponent(m[1])).company)],
|
|
42
43
|
['GET', /^\/api\/linkedin$/, () => linkedin.status()],
|
|
43
44
|
['POST', /^\/api\/linkedin\/preview$/, async (_, __, req) => { const b = await readBody(req); return linkedin.preview(b.source, { since: b.since || undefined, everyone: !!b.everyone }); }],
|
|
44
|
-
['POST', /^\/api\/linkedin\/import$/, async (_, __, req) => { const b = await readBody(req);
|
|
45
|
+
['POST', /^\/api\/linkedin\/import$/, async (_, __, req) => { const b = await readBody(req); const r = linkedin.runImport(b.source, { since: b.since || undefined, everyone: !!b.everyone, writePeople: b.writePeople !== false, dry: !!b.dry }); if (b.applications || b.saved) r.jobs = linkedin.importJobs(b.source, { since: b.since || undefined, applications: !!b.applications, saved: !!b.saved, dry: !!b.dry }); return r; }],
|
|
45
46
|
['GET', /^\/api\/people$/, () => people.listPeople()],
|
|
46
47
|
['POST', /^\/api\/people$/, async (_, __, req) => { const b = await readBody(req); const p = people.createPerson(b); if (b.jobId) people.attachPerson(b.jobId, p.id, { role: b.role, context: b.context }); return people.getPerson(p.id); }],
|
|
47
48
|
['GET', /^\/api\/people\/([^/]+)$/, (m) => people.getPerson(decodeURIComponent(m[1]))],
|
|
@@ -72,6 +73,9 @@ const routes = [
|
|
|
72
73
|
['GET', /^\/api\/profile\/summary$/, () => store.profileSummary()],
|
|
73
74
|
// The one-page confirmation after the interview: roles, where, pay, exclusions, proof points, what remains.
|
|
74
75
|
['GET', /^\/api\/learned$/, () => store.learned()],
|
|
76
|
+
// The day's journal: runs, found, decisions, mail, notes, read from the record. ?date=YYYY-MM-DD for another day.
|
|
77
|
+
['GET', /^\/api\/journal$/, (_, q) => { const date = q.get('date') || undefined; const j = buildJournal(date); return { ...j, markdown: renderJournal(j) }; }],
|
|
78
|
+
['POST', /^\/api\/journal$/, async (_, __, req) => { const b = await readBody(req); return writeJournal(b.date || undefined); }],
|
|
75
79
|
['GET', /^\/api\/companies$/, () => store.companies()],
|
|
76
80
|
['GET', /^\/api\/feeds$/, () => store.feeds()],
|
|
77
81
|
['GET', /^\/api\/companies\/find$/, (_, q) => store.findBoard({ slug: q.get('slug') || '', ats: q.get('ats') || '', name: q.get('name') || '' })],
|
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
// appended again.
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import { P, DATA_DIR } from '../../scraper/config.mjs';
|
|
8
|
-
import { readExport, buildIndex, peopleCandidates, warmPaths, companyKey } from '../../scraper/linkedin.mjs';
|
|
7
|
+
import { loadCriteria, P, DATA_DIR } from '../../scraper/config.mjs';
|
|
8
|
+
import { readExport, buildIndex, peopleCandidates, warmPaths, companyKey, sameCompany } from '../../scraper/linkedin.mjs';
|
|
9
9
|
import * as people from './people.mjs';
|
|
10
10
|
import * as store from './store.mjs';
|
|
11
|
+
import { writeJobNote } from '../../scraper/vault.mjs';
|
|
12
|
+
import { scoreJob, normalizeTitle } from '../../scraper/score.mjs';
|
|
13
|
+
import { htmlToText } from '../../scraper/sources.mjs';
|
|
11
14
|
|
|
12
15
|
const isoDay = (d = new Date()) => d.toISOString().slice(0, 10);
|
|
13
16
|
const daysAgo = (n) => isoDay(new Date(Date.now() - n * 864e5));
|
|
@@ -107,3 +110,97 @@ export function runImport(source, { since = daysAgo(90), roles = ['recruiter', '
|
|
|
107
110
|
|
|
108
111
|
return summary;
|
|
109
112
|
}
|
|
113
|
+
|
|
114
|
+
// ---------------- applications and saved jobs, as notes ----------------
|
|
115
|
+
// The export carries the person's own record of where they applied through LinkedIn and what they saved. Opt-in:
|
|
116
|
+
// each becomes a note (or a line on the note that already exists), so the pipeline shows the search they were
|
|
117
|
+
// already running. The posting itself is never fetched; the note carries the LinkedIn link for the person to open.
|
|
118
|
+
const titleKey = (t) => normalizeTitle(t).replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
119
|
+
const sameTitle = (a, b) => { const x = titleKey(a), y = titleKey(b); return !!x && !!y && (x === y || x.includes(y) || y.includes(x)); };
|
|
120
|
+
const hash = (s) => { let h = 5381; for (const ch of String(s)) h = ((h * 33) ^ ch.charCodeAt(0)) >>> 0; return h.toString(16); };
|
|
121
|
+
const noteText = (id) => { try { return fs.readFileSync(store.getJob(id).path, 'utf8'); } catch { return ''; } };
|
|
122
|
+
// Empty or absent: a note written by hand or by an older version may not carry the field at all.
|
|
123
|
+
const appliedOnEmpty = (id) => !/^- \*\*Applied on:\*\*\s*\S/m.test(noteText(id));
|
|
124
|
+
const noteOnce = (id, line) => { if (!noteText(id).includes(line)) store.addNote(id, line, 'linkedin-export'); };
|
|
125
|
+
|
|
126
|
+
function findNote(jobs, { company, title, url }) {
|
|
127
|
+
const clean = (u) => String(u || '').split('?')[0].replace(/\/$/, '');
|
|
128
|
+
const byUrl = url && jobs.find((j) => j.url && clean(j.url) === clean(url));
|
|
129
|
+
if (byUrl) return byUrl;
|
|
130
|
+
return jobs.filter((j) => sameCompany(j.company, company)).find((j) => sameTitle(j.title, title)) || null;
|
|
131
|
+
}
|
|
132
|
+
function stubFor(kind, r) {
|
|
133
|
+
const html = kind === 'application'
|
|
134
|
+
? `<p>Applied through LinkedIn on ${r.date}. From the LinkedIn data export: the posting itself was not read, so there is no description or pay here. Open the link for the posting.</p>`
|
|
135
|
+
: `<p>Saved on LinkedIn on ${r.date}. From the LinkedIn data export: the posting itself was not read. Open the link for the posting.</p>`;
|
|
136
|
+
const job = {
|
|
137
|
+
id: `linkedin:${kind}:${hash(`${r.url || ''}|${r.company}|${r.title}|${r.date}`)}`, source: 'linkedin-export', company: r.company, title: r.title || 'Role not named in the export',
|
|
138
|
+
url: r.url || '', location: '', remote: false, posted: r.date, descriptionHtml: html, foundVia: kind === 'application' ? 'from the LinkedIn export: an application' : 'from the LinkedIn export: a saved job', addedBy: 'linkedin-export',
|
|
139
|
+
};
|
|
140
|
+
job.descriptionText = htmlToText(job.descriptionHtml);
|
|
141
|
+
return job;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** What importing the applications and saved jobs would touch: per kind, the notes it would mark and the ones it would create. */
|
|
145
|
+
export function planJobs(index, { since = index?.since || '', applications = false, saved = false } = {}) {
|
|
146
|
+
const jobs = store.listJobs();
|
|
147
|
+
const recent = (d) => !since || (d && d >= since);
|
|
148
|
+
const one = (rows, on) => {
|
|
149
|
+
const out = { matched: [], create: [], skipped: 0 };
|
|
150
|
+
if (!on) return out;
|
|
151
|
+
for (const r of rows || []) {
|
|
152
|
+
if (!recent(r.date) || !r.company) { out.skipped++; continue; }
|
|
153
|
+
const note = findNote(jobs, r);
|
|
154
|
+
if (note) out.matched.push({ id: note.id, status: note.status, date: r.date, company: r.company, title: r.title });
|
|
155
|
+
else out.create.push(r);
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
};
|
|
159
|
+
return { since, applications: one(index?.applications, applications), saved: one(index?.savedJobs, saved) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Applications become notes at "applied" with the date; saved jobs become notes at "reviewing". A note that already
|
|
164
|
+
* exists gets the line and, for an application, the status and the date if it had none. Re-running changes nothing.
|
|
165
|
+
* The status is set via "linkedin-export": the person's own record is the evidence, so the human-only rule for agents
|
|
166
|
+
* does not apply, and the status log says where it came from.
|
|
167
|
+
*/
|
|
168
|
+
export function importJobs(source, { since = daysAgo(90), applications = false, saved = false, dry = false } = {}) {
|
|
169
|
+
const index = buildIndex(readExport(source), { since });
|
|
170
|
+
const plan = planJobs(index, { since: index.since, applications, saved });
|
|
171
|
+
const out = { since: index.since, dry, applications: { matched: 0, marked: 0, created: 0, skipped: plan.applications.skipped }, saved: { matched: 0, created: 0, skipped: plan.saved.skipped } };
|
|
172
|
+
// A profile with no criteria yet (the import can run before the interview) still gets its notes; they are scored later by a rescore.
|
|
173
|
+
let criteria; try { criteria = loadCriteria(); } catch { criteria = { titleTerms: {}, titleExclude: [], descTerms: {}, minScore: 0 }; }
|
|
174
|
+
const advanced = ['applied', 'interviewing', 'offer', 'rejected'];
|
|
175
|
+
for (const m of plan.applications.matched) {
|
|
176
|
+
out.applications.matched++;
|
|
177
|
+
if (dry) continue;
|
|
178
|
+
if (!advanced.includes(m.status)) { store.setStatus(m.id, 'applied', 'linkedin-export'); out.applications.marked++; }
|
|
179
|
+
if (appliedOnEmpty(m.id)) store.saveApplicationDraft(m.id, { field: 'Applied on', value: m.date });
|
|
180
|
+
noteOnce(m.id, `Applied through LinkedIn on ${m.date} (from the LinkedIn export).`);
|
|
181
|
+
}
|
|
182
|
+
for (const r of plan.applications.create) {
|
|
183
|
+
out.applications.created++;
|
|
184
|
+
if (dry) continue;
|
|
185
|
+
const job = stubFor('application', r);
|
|
186
|
+
const id = path.basename(writeJobNote(job, scoreJob(job, criteria), criteria), '.md');
|
|
187
|
+
store.cacheClear?.();
|
|
188
|
+
store.setStatus(id, 'applied', 'linkedin-export');
|
|
189
|
+
if (appliedOnEmpty(id)) store.saveApplicationDraft(id, { field: 'Applied on', value: r.date });
|
|
190
|
+
}
|
|
191
|
+
for (const m of plan.saved.matched) {
|
|
192
|
+
out.saved.matched++;
|
|
193
|
+
if (dry) continue;
|
|
194
|
+
noteOnce(m.id, `Saved on LinkedIn on ${m.date} (from the LinkedIn export).`);
|
|
195
|
+
}
|
|
196
|
+
for (const r of plan.saved.create) {
|
|
197
|
+
out.saved.created++;
|
|
198
|
+
if (dry) continue;
|
|
199
|
+
const job = stubFor('saved', r);
|
|
200
|
+
const id = path.basename(writeJobNote(job, scoreJob(job, criteria), criteria), '.md');
|
|
201
|
+
store.cacheClear?.();
|
|
202
|
+
store.setStatus(id, 'reviewing', 'linkedin-export');
|
|
203
|
+
}
|
|
204
|
+
store.cacheClear?.();
|
|
205
|
+
return out;
|
|
206
|
+
}
|
package/app/server/mcp.mjs
CHANGED
|
@@ -19,7 +19,7 @@ export const TOOLS = [
|
|
|
19
19
|
{ name: 'list_snippets', description: 'The copy panel: the person\'s standard answers for application forms (name, email, phone, links, availability, salary answer, anything they added), grouped, from Profile/Snippets.md. Use these verbatim when drafting form answers; never invent a value that is empty here.', inputSchema: { type: 'object', properties: {} } },
|
|
20
20
|
{ name: 'list_people', description: 'The people in the search: recruiters, hiring managers, interviewers and referrals, one note each under People/, with role, company, email, last contact and the job notes they are on. Newest contact first.', inputSchema: { type: 'object', properties: {} } },
|
|
21
21
|
{ name: 'get_person', description: 'One person in full: the row plus their About text and dated Log of contacts.', inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
|
|
22
|
-
{ name: 'import_linkedin', description: "Read the person's LinkedIn data export (the larger archive from linkedin.com/mypreferences/d/download-my-data, as a zip or an unpacked folder, given by local path) into the search: an index of who they know at which company (job notes then show connections), People notes for the recruiters and hiring managers who wrote since the date (with the thread as their log, linked to open job notes at that company). The archive is read in place and never copied. dry=true reports without writing; everyone=true also writes senders whose title is not a recruiting or hiring one. Default since: 90 days ago.", inputSchema: { type: 'object', properties: { source: { type: 'string', description: 'Local path to the zip or folder' }, since: { type: 'string', description: 'YYYY-MM-DD' }, everyone: { type: 'boolean' }, writePeople: { type: 'boolean' }, dry: { type: 'boolean' } }, required: ['source'] } },
|
|
22
|
+
{ name: 'import_linkedin', description: "Read the person's LinkedIn data export (the larger archive from linkedin.com/mypreferences/d/download-my-data, as a zip or an unpacked folder, given by local path) into the search: an index of who they know at which company (job notes then show connections), People notes for the recruiters and hiring managers who wrote since the date (with the thread as their log, linked to open job notes at that company). The archive is read in place and never copied. dry=true reports without writing; everyone=true also writes senders whose title is not a recruiting or hiring one. applications=true turns the person's LinkedIn applications since the date into notes at applied with the date (or marks the note that exists); saved=true turns their saved jobs into notes at reviewing. The person's own export is the evidence, so these statuses are allowed here. Default since: 90 days ago.", inputSchema: { type: 'object', properties: { source: { type: 'string', description: 'Local path to the zip or folder' }, since: { type: 'string', description: 'YYYY-MM-DD' }, everyone: { type: 'boolean' }, writePeople: { type: 'boolean' }, applications: { type: 'boolean' }, saved: { type: 'boolean' }, dry: { type: 'boolean' } }, required: ['source'] } },
|
|
23
23
|
{ name: 'connections_at', description: "Who the person knows at a company, from the LinkedIn import: name, title, a guessed role (recruiter, hiring-manager, other), profile link, when connected. Empty before the first import (say so and offer import_linkedin). Use it before drafting an application or when a job note is discussed: a warm introduction beats a cold apply.", inputSchema: { type: 'object', properties: { company: { type: 'string' } }, required: ['company'] } },
|
|
24
24
|
{ name: 'add_person', description: 'Add a person (or recognise one already there, by email or name and company) and optionally put them on a job note. role: recruiter | hiring-manager | interviewer | referral | other. Give jobId to attach; context is a few words on the thread.', inputSchema: { type: 'object', properties: { name: { type: 'string' }, role: { type: 'string', enum: people.ROLES }, company: { type: 'string' }, email: { type: 'string' }, links: { type: 'string' }, about: { type: 'string' }, jobId: { type: 'string' }, context: { type: 'string' } }, required: ['name'] } },
|
|
25
25
|
{ name: 'attach_person', description: 'Put an existing person on a job note (a line under its People section) and the job on their Threads. Idempotent.', inputSchema: { type: 'object', properties: { jobId: { type: 'string' }, personId: { type: 'string' }, role: { type: 'string', enum: people.ROLES }, context: { type: 'string' } }, required: ['jobId', 'personId'] } },
|
|
@@ -116,7 +116,11 @@ export async function call(name, a = {}) {
|
|
|
116
116
|
case 'list_snippets': return store.getSnippets();
|
|
117
117
|
case 'list_people': return people.listPeople();
|
|
118
118
|
case 'get_person': return people.getPerson(a.id);
|
|
119
|
-
case 'import_linkedin':
|
|
119
|
+
case 'import_linkedin': {
|
|
120
|
+
const r = linkedin.runImport(a.source, { since: a.since, everyone: !!a.everyone, writePeople: a.writePeople !== false, dry: !!a.dry });
|
|
121
|
+
if (a.applications || a.saved) r.jobs = linkedin.importJobs(a.source, { since: a.since, applications: !!a.applications, saved: !!a.saved, dry: !!a.dry });
|
|
122
|
+
return r;
|
|
123
|
+
}
|
|
120
124
|
case 'connections_at': return linkedin.connectionsAt(a.company);
|
|
121
125
|
case 'add_person': { const p = people.createPerson(a); if (a.jobId) people.attachPerson(a.jobId, p.id, { role: a.role, context: a.context }); return people.getPerson(p.id); }
|
|
122
126
|
case 'attach_person': return people.attachPerson(a.jobId, a.personId, { role: a.role, context: a.context });
|
package/cli.mjs
CHANGED
|
@@ -40,10 +40,14 @@ const HELP = `tekjobs — a local job-search machine
|
|
|
40
40
|
tekjobs import linkedin <zip|folder> your LinkedIn data export into the search: who you know at each
|
|
41
41
|
company, and the recruiters who wrote
|
|
42
42
|
[--since YYYY-MM-DD] [--everyone] [--no-people] [--preview] [--dry]
|
|
43
|
+
[--applications] your LinkedIn applications as notes at "applied", with the date
|
|
44
|
+
[--saved] your saved jobs as notes at "reviewing"
|
|
43
45
|
tekjobs up [--dev] [--port N] start the API (and with --dev, Vite) detached: they stay up until \`down\`
|
|
44
46
|
tekjobs down stop what \`up\` started
|
|
45
47
|
tekjobs ps what \`up\` has running
|
|
46
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
|
|
47
51
|
tekjobs serve the app + API on http://127.0.0.1:8787, in this terminal
|
|
48
52
|
tekjobs mcp the MCP server on stdio (Claude Code, Codex, Cursor, Claude Desktop)
|
|
49
53
|
|
|
@@ -182,14 +186,31 @@ async function main() {
|
|
|
182
186
|
if (!source) return console.error('Usage: tekjobs import linkedin <path to the export zip or unpacked folder> [--since YYYY-MM-DD] [--everyone] [--no-people] [--preview] [--dry]\nRequest the larger archive at https://www.linkedin.com/mypreferences/d/download-my-data');
|
|
183
187
|
const li = await import('./app/server/linkedin-import.mjs');
|
|
184
188
|
const o = { since: opt('--since') || undefined, everyone: rest.includes('--everyone'), writePeople: !rest.includes('--no-people'), dry: rest.includes('--dry') };
|
|
185
|
-
|
|
189
|
+
const jobsOpt = { since: o.since, applications: rest.includes('--applications'), saved: rest.includes('--saved'), dry: o.dry };
|
|
190
|
+
if (rest.includes('--preview')) {
|
|
191
|
+
const p = li.preview(source, o);
|
|
192
|
+
if (jobsOpt.applications || jobsOpt.saved) p.jobs = li.importJobs(source, { ...jobsOpt, dry: true });
|
|
193
|
+
return console.log(JSON.stringify(p, null, 2));
|
|
194
|
+
}
|
|
186
195
|
const s = li.runImport(source, o);
|
|
196
|
+
const jobs = jobsOpt.applications || jobsOpt.saved ? li.importJobs(source, jobsOpt) : null;
|
|
187
197
|
console.log(`LinkedIn export read from ${s.source}${s.dry ? ' (dry run, nothing written)' : ''}`);
|
|
188
198
|
console.log(` ${s.counts.connections} connections indexed, ${s.counts.threads} conversations and ${s.counts.invitations} invitations since ${s.since}, ${s.counts.applications} applications, ${s.counts.savedJobs} saved jobs`);
|
|
189
199
|
console.log(` People: ${s.people.created} created, ${s.people.recognised} already there, ${s.people.attached} put on job notes, ${s.people.logged} log lines; ${s.people.skipped} senders skipped (not recruiters or hiring managers; --everyone includes them)`);
|
|
200
|
+
if (jobs) {
|
|
201
|
+
if (jobsOpt.applications) console.log(` Applications: ${jobs.applications.matched} already had a note (${jobs.applications.marked} moved to applied), ${jobs.applications.created} notes created at applied, ${jobs.applications.skipped} before ${jobs.since} or without a company`);
|
|
202
|
+
if (jobsOpt.saved) console.log(` Saved jobs: ${jobs.saved.matched} already had a note, ${jobs.saved.created} notes created at reviewing, ${jobs.saved.skipped} before ${jobs.since} or without a company`);
|
|
203
|
+
}
|
|
190
204
|
if (!s.dry) console.log(` Index: ${s.indexPath}. Job notes now show who you know at each company.`);
|
|
191
205
|
return;
|
|
192
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
|
+
}
|
|
193
214
|
if (cmd === 'board') {
|
|
194
215
|
const slug = rest.find((x) => !x.startsWith('--') && x !== opt('--from'));
|
|
195
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.47.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
|
+
}
|