@timurtekb/tekjobs 0.29.1 → 0.29.3
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 +8 -0
- package/README.md +1 -1
- package/app/dist/assets/{index-DsgnBEaA.css → index-CbvdnTFY.css} +1 -1
- package/app/dist/assets/{index-BBfALXjj.js → index-Cl1o8hT9.js} +2 -2
- package/app/dist/index.html +2 -2
- package/app/server/index.mjs +1 -1
- package/app/server/linkedin-import.mjs +7 -16
- package/app/server/mcp.mjs +2 -2
- package/cli.mjs +4 -5
- package/package.json +1 -1
- package/scraper/linkedin.mjs +5 -16
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-Cl1o8hT9.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CbvdnTFY.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
package/app/server/index.mjs
CHANGED
|
@@ -38,7 +38,7 @@ const routes = [
|
|
|
38
38
|
['GET', /^\/api\/jobs\/([^/]+)\/connections$/, (m) => linkedin.connectionsAt(store.getJob(decodeURIComponent(m[1])).company)],
|
|
39
39
|
['GET', /^\/api\/linkedin$/, () => linkedin.status()],
|
|
40
40
|
['POST', /^\/api\/linkedin\/preview$/, async (_, __, req) => { const b = await readBody(req); return linkedin.preview(b.source, { since: b.since || undefined, everyone: !!b.everyone }); }],
|
|
41
|
-
['POST', /^\/api\/linkedin\/import$/, async (_, __, req) => { const b = await readBody(req); return linkedin.runImport(b.source, { since: b.since || undefined, everyone: !!b.everyone, writePeople: b.writePeople !== false,
|
|
41
|
+
['POST', /^\/api\/linkedin\/import$/, async (_, __, req) => { const b = await readBody(req); return linkedin.runImport(b.source, { since: b.since || undefined, everyone: !!b.everyone, writePeople: b.writePeople !== false, dry: !!b.dry }); }],
|
|
42
42
|
['GET', /^\/api\/people$/, () => people.listPeople()],
|
|
43
43
|
['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); }],
|
|
44
44
|
['GET', /^\/api\/people\/([^/]+)$/, (m) => people.getPerson(decodeURIComponent(m[1]))],
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// The LinkedIn import: reads the export in place (scraper/linkedin.mjs) and writes only into the profile folder:
|
|
2
|
-
// the index the app answers "who do I know there" from, People notes for the recruiters and hiring managers
|
|
3
|
-
// wrote lately
|
|
4
|
-
//
|
|
2
|
+
// the index the app answers "who do I know there" from, and People notes for the recruiters and hiring managers
|
|
3
|
+
// who wrote lately. Re-running is safe: people are recognised, not duplicated; log lines already present are not
|
|
4
|
+
// appended again.
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { P, DATA_DIR } from '../../scraper/config.mjs';
|
|
8
|
-
import { readExport, buildIndex, peopleCandidates,
|
|
8
|
+
import { readExport, buildIndex, peopleCandidates, warmPaths, companyKey } from '../../scraper/linkedin.mjs';
|
|
9
9
|
import * as people from './people.mjs';
|
|
10
10
|
import * as store from './store.mjs';
|
|
11
11
|
|
|
@@ -49,8 +49,6 @@ export function preview(source, { since = daysAgo(90), roles = ['recruiter', 'hi
|
|
|
49
49
|
const index = buildIndex(ex, { since });
|
|
50
50
|
const candidates = peopleCandidates(index, { since });
|
|
51
51
|
const picked = chosen(candidates, { roles, everyone });
|
|
52
|
-
const existing = store.getSnippets().items;
|
|
53
|
-
const snippets = snippetSuggestions(index, existing);
|
|
54
52
|
const jobs = store.listJobs();
|
|
55
53
|
const jobKeys = new Map(jobs.map((j) => [companyKey(j.company), j]));
|
|
56
54
|
const withNotes = picked.filter((c) => c.company && jobKeys.has(companyKey(c.company)));
|
|
@@ -59,7 +57,6 @@ export function preview(source, { since = daysAgo(90), roles = ['recruiter', 'hi
|
|
|
59
57
|
return {
|
|
60
58
|
source: ex.source, kind: ex.kind, files: ex.files, since, self: index.self, counts: index.counts,
|
|
61
59
|
people: { candidates: candidates.length, chosen: picked.length, onJobNotes: withNotes.length, sample: picked.slice(0, 12).map((c) => ({ name: c.name, role: c.role, company: c.company, title: c.title, last: c.last, messages: c.count })) },
|
|
62
|
-
snippets: { new: snippets.length, sample: snippets.slice(0, 8).map((s) => s.label) },
|
|
63
60
|
warmPaths: { jobsWithConnections: knownAt.length, top: knownAt.slice(0, 10) },
|
|
64
61
|
applicationsSince: index.applications.filter((a) => a.date >= since).length,
|
|
65
62
|
savedJobsSince: index.savedJobs.filter((s) => s.date >= since).length,
|
|
@@ -69,12 +66,12 @@ export function preview(source, { since = daysAgo(90), roles = ['recruiter', 'hi
|
|
|
69
66
|
// ---------- writing ----------
|
|
70
67
|
/**
|
|
71
68
|
* Run the import. Writes the index, then (unless told not to) People notes with their LinkedIn log and a link to
|
|
72
|
-
* any open job note at their company
|
|
69
|
+
* any open job note at their company. `dry` does everything but write.
|
|
73
70
|
*/
|
|
74
|
-
export function runImport(source, { since = daysAgo(90), roles = ['recruiter', 'hiring-manager'], everyone = false, writePeople = true,
|
|
71
|
+
export function runImport(source, { since = daysAgo(90), roles = ['recruiter', 'hiring-manager'], everyone = false, writePeople = true, dry = false } = {}) {
|
|
75
72
|
const ex = readExport(source);
|
|
76
73
|
const index = buildIndex(ex, { since });
|
|
77
|
-
const summary = { source: ex.source, since, self: index.self, counts: index.counts, dry, indexPath: P.linkedin, people: { created: 0, recognised: 0, attached: 0, skipped: 0, logged: 0 }
|
|
74
|
+
const summary = { source: ex.source, since, self: index.self, counts: index.counts, dry, indexPath: P.linkedin, people: { created: 0, recognised: 0, attached: 0, skipped: 0, logged: 0 } };
|
|
78
75
|
|
|
79
76
|
if (!dry) { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(P.linkedin, JSON.stringify(index, null, 1)); cached = { mtime: 0, index: null }; }
|
|
80
77
|
|
|
@@ -108,11 +105,5 @@ export function runImport(source, { since = daysAgo(90), roles = ['recruiter', '
|
|
|
108
105
|
}
|
|
109
106
|
}
|
|
110
107
|
|
|
111
|
-
if (writeSnippets) {
|
|
112
|
-
const existing = store.getSnippets().items;
|
|
113
|
-
const add = snippetSuggestions(index, existing);
|
|
114
|
-
summary.snippets.added = add.length;
|
|
115
|
-
if (add.length && !dry) store.saveSnippets([...existing, ...add]);
|
|
116
|
-
}
|
|
117
108
|
return summary;
|
|
118
109
|
}
|
package/app/server/mcp.mjs
CHANGED
|
@@ -13,7 +13,7 @@ const TOOLS = [
|
|
|
13
13
|
{ 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: {} } },
|
|
14
14
|
{ 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: {} } },
|
|
15
15
|
{ 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'] } },
|
|
16
|
-
{ 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)
|
|
16
|
+
{ 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'] } },
|
|
17
17
|
{ 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'] } },
|
|
18
18
|
{ 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'] } },
|
|
19
19
|
{ 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'] } },
|
|
@@ -97,7 +97,7 @@ async function call(name, a = {}) {
|
|
|
97
97
|
case 'list_snippets': return store.getSnippets();
|
|
98
98
|
case 'list_people': return people.listPeople();
|
|
99
99
|
case 'get_person': return people.getPerson(a.id);
|
|
100
|
-
case 'import_linkedin': return linkedin.runImport(a.source, { since: a.since, everyone: !!a.everyone, writePeople: a.writePeople !== false,
|
|
100
|
+
case 'import_linkedin': return linkedin.runImport(a.source, { since: a.since, everyone: !!a.everyone, writePeople: a.writePeople !== false, dry: !!a.dry });
|
|
101
101
|
case 'connections_at': return linkedin.connectionsAt(a.company);
|
|
102
102
|
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); }
|
|
103
103
|
case 'attach_person': return people.attachPerson(a.jobId, a.personId, { role: a.role, context: a.context });
|
package/cli.mjs
CHANGED
|
@@ -35,8 +35,8 @@ const HELP = `tekjobs — a local job-search machine
|
|
|
35
35
|
(07:30 by default), or prints the crontab or launchd line for macOS and
|
|
36
36
|
Linux; --print shows the command without installing anything
|
|
37
37
|
tekjobs import linkedin <zip|folder> your LinkedIn data export into the search: who you know at each
|
|
38
|
-
company, the recruiters who wrote
|
|
39
|
-
[--since YYYY-MM-DD] [--everyone] [--no-people] [--
|
|
38
|
+
company, and the recruiters who wrote
|
|
39
|
+
[--since YYYY-MM-DD] [--everyone] [--no-people] [--preview] [--dry]
|
|
40
40
|
tekjobs serve the app + API on http://127.0.0.1:8787
|
|
41
41
|
tekjobs mcp the MCP server on stdio (Claude Code, Codex, Cursor, Claude Desktop)
|
|
42
42
|
|
|
@@ -156,15 +156,14 @@ async function main() {
|
|
|
156
156
|
}
|
|
157
157
|
if (cmd === 'import' && rest[0] === 'linkedin') {
|
|
158
158
|
const source = rest.slice(1).find((x) => !x.startsWith('--'));
|
|
159
|
-
if (!source) return console.error('Usage: tekjobs import linkedin <path to the export zip or unpacked folder> [--since YYYY-MM-DD] [--everyone] [--no-people] [--
|
|
159
|
+
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');
|
|
160
160
|
const li = await import('./app/server/linkedin-import.mjs');
|
|
161
|
-
const o = { since: opt('--since') || undefined, everyone: rest.includes('--everyone'), writePeople: !rest.includes('--no-people'),
|
|
161
|
+
const o = { since: opt('--since') || undefined, everyone: rest.includes('--everyone'), writePeople: !rest.includes('--no-people'), dry: rest.includes('--dry') };
|
|
162
162
|
if (rest.includes('--preview')) return console.log(JSON.stringify(li.preview(source, o), null, 2));
|
|
163
163
|
const s = li.runImport(source, o);
|
|
164
164
|
console.log(`LinkedIn export read from ${s.source}${s.dry ? ' (dry run, nothing written)' : ''}`);
|
|
165
165
|
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`);
|
|
166
166
|
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)`);
|
|
167
|
-
console.log(` Copy panel: ${s.snippets.added} answers added under "From LinkedIn"`);
|
|
168
167
|
if (!s.dry) console.log(` Index: ${s.indexPath}. Job notes now show who you know at each company.`);
|
|
169
168
|
return;
|
|
170
169
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timurtekb/tekjobs",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.3",
|
|
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/scraper/linkedin.mjs
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// Reading a LinkedIn data export (the "larger archive" from linkedin.com/mypreferences/d/download-my-data),
|
|
2
2
|
// as a zip or an unpacked folder, into the few things a job search can use: who you know at which company,
|
|
3
|
-
// who has written to you lately,
|
|
4
|
-
//
|
|
5
|
-
// is read in place and never copied. Files this module does not name (ads, reactions, searches, phone numbers,
|
|
3
|
+
// who has written to you lately, what you applied to and saved. Nothing here writes; app/server/linkedin-import.mjs
|
|
4
|
+
// does, into the profile folder only. The archive is read in place and never copied. Files this module does not name (ads, reactions, searches, phone numbers,
|
|
6
5
|
// birth date, addresses) are never opened.
|
|
7
6
|
import fs from 'node:fs';
|
|
8
7
|
import path from 'node:path';
|
|
@@ -68,7 +67,6 @@ const WANT = {
|
|
|
68
67
|
applications: [/^Jobs\/Job Applications(_\d+)?\.csv$/],
|
|
69
68
|
savedJobs: [/^Jobs\/Saved Jobs(_\d+)?\.csv$/],
|
|
70
69
|
preferences: ['Jobs/Job Seeker Preferences.csv'],
|
|
71
|
-
answers: ['Jobs/Job Applicant Saved Answers.csv', /^Job Applicant Saved Screening Question Responses(_\d+)?\.csv$/],
|
|
72
70
|
profile: ['Profile.csv'],
|
|
73
71
|
positions: ['Positions.csv'],
|
|
74
72
|
skills: ['Skills.csv'],
|
|
@@ -117,7 +115,7 @@ const clip = (s, n) => { const t = String(s || '').replace(/\s+/g, ' ').trim();
|
|
|
117
115
|
|
|
118
116
|
/**
|
|
119
117
|
* Everything the app needs later, in one JSON the size of a small spreadsheet: connections with a company key,
|
|
120
|
-
* conversations and invitations since `since`, applications, saved jobs
|
|
118
|
+
* conversations and invitations since `since`, applications, saved jobs. Message text is
|
|
121
119
|
* kept only for messages since `since`, clipped, so the file stays about your search and not your history.
|
|
122
120
|
*/
|
|
123
121
|
export function buildIndex(ex, { since = '', now = new Date() } = {}) {
|
|
@@ -150,15 +148,13 @@ export function buildIndex(ex, { since = '', now = new Date() } = {}) {
|
|
|
150
148
|
const invitations = (ex.invitations || []).map((r) => ({ from: r.From || '', to: r.To || '', sentAt: day(r['Sent At']), direction: r.Direction || '', message: clip(r.Message, 240), url: r.Direction === 'INCOMING' ? (r.inviterProfileUrl || '') : (r.inviteeProfileUrl || '') })).filter((i) => recent(i.sentAt));
|
|
151
149
|
const applications = (ex.applications || []).map((r) => ({ date: day(r['Application Date']), company: r['Company Name'] || '', companyKey: companyKey(r['Company Name']), title: r['Job Title'] || '', url: r['Job Url'] || '', resume: r['Resume Name'] || '' })).filter((a) => a.date).sort((a, b) => b.date.localeCompare(a.date));
|
|
152
150
|
const savedJobs = (ex.savedJobs || []).map((r) => ({ date: day(r['Saved Date']), company: r['Company Name'] || '', title: r['Job Title'] || '', url: r['Job Url'] || '' })).filter((s) => s.date).sort((a, b) => b.date.localeCompare(a.date));
|
|
153
|
-
const seenQ = new Set();
|
|
154
|
-
const answers = (ex.answers || []).map((r) => ({ question: String(r.Question || '').trim(), answer: String(r.Answer || '').trim() })).filter((a) => a.question && a.answer && !seenQ.has(a.question.toLowerCase()) && seenQ.add(a.question.toLowerCase()));
|
|
155
151
|
const pref = ex.preferences?.[0] || {};
|
|
156
152
|
const preferences = pref ? { locations: pref.Locations || '', industries: pref.Industries || '', companySize: pref['Company Employee Count'] || '', jobTypes: pref['Preferred Job Types'] || '', titles: pref['Job Titles'] || '', openToRecruiters: pref['Open To Recruiters'] || '', dreamCompanies: pref['Dream Companies'] || '', startTime: pref['Preferred Start Time Range'] || '' } : {};
|
|
157
153
|
|
|
158
154
|
return {
|
|
159
155
|
built: now.toISOString(), since, source: ex.source, self,
|
|
160
|
-
counts: { connections: connections.length, threads: threads.length, invitations: invitations.length, applications: applications.length, savedJobs: savedJobs.length
|
|
161
|
-
connections, threads, invitations, applications, savedJobs,
|
|
156
|
+
counts: { connections: connections.length, threads: threads.length, invitations: invitations.length, applications: applications.length, savedJobs: savedJobs.length },
|
|
157
|
+
connections, threads, invitations, applications, savedJobs, preferences,
|
|
162
158
|
};
|
|
163
159
|
}
|
|
164
160
|
|
|
@@ -198,10 +194,3 @@ export function peopleCandidates(index, { since = index?.since || '' } = {}) {
|
|
|
198
194
|
}
|
|
199
195
|
return [...out.values()].map((c) => ({ ...c, log: c.log.sort((a, b) => a.date.localeCompare(b.date)).slice(-8) })).sort((a, b) => b.last.localeCompare(a.last));
|
|
200
196
|
}
|
|
201
|
-
|
|
202
|
-
/** Saved form answers as copy-panel items, minus labels the panel already has. */
|
|
203
|
-
export function snippetSuggestions(index, existing = []) {
|
|
204
|
-
const have = new Set(existing.map((s) => String(s.label || '').toLowerCase()));
|
|
205
|
-
const label = (q) => clip(q.replace(/[?:]+$/, ''), 60);
|
|
206
|
-
return (index.answers || []).filter((a) => !/^(yes|no|y|n)$/i.test(a.answer)).map((a) => ({ group: 'From LinkedIn', label: label(a.question), value: a.answer })).filter((s) => !have.has(s.label.toLowerCase()));
|
|
207
|
-
}
|