@timurtekb/tekjobs 0.27.1 → 0.29.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.
@@ -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-Byee80qa.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-DzGgV6NF.css">
11
+ <script type="module" crossorigin src="/assets/index-DvWnsMvq.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-DsgnBEaA.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -9,6 +9,7 @@ import * as letter from './cover-letter.mjs';
9
9
  import * as tailored from './tailored-resume.mjs';
10
10
  import * as mail from './mail-check.mjs';
11
11
  import * as people from './people.mjs';
12
+ import * as linkedin from './linkedin-import.mjs';
12
13
 
13
14
  const PORT = Number(process.env.PORT || 8787);
14
15
  const DIST = fileURLToPath(new URL('../dist', import.meta.url));
@@ -34,6 +35,10 @@ const routes = [
34
35
  ['GET', /^\/api\/jobs\/([^/]+)\/people$/, (m) => people.peopleOf(decodeURIComponent(m[1]))],
35
36
  ['GET', /^\/api\/jobs\/([^/]+)$/, (m) => store.getJob(decodeURIComponent(m[1]))],
36
37
  // People: one note per person under People/, linked from the job notes they are on.
38
+ ['GET', /^\/api\/jobs\/([^/]+)\/connections$/, (m) => linkedin.connectionsAt(store.getJob(decodeURIComponent(m[1])).company)],
39
+ ['GET', /^\/api\/linkedin$/, () => linkedin.status()],
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, writeSnippets: b.writeSnippets !== false, dry: !!b.dry }); }],
37
42
  ['GET', /^\/api\/people$/, () => people.listPeople()],
38
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); }],
39
44
  ['GET', /^\/api\/people\/([^/]+)$/, (m) => people.getPerson(decodeURIComponent(m[1]))],
@@ -0,0 +1,118 @@
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 who
3
+ // wrote lately, and your saved form answers into the copy panel. Re-running is safe: people are recognised, not
4
+ // duplicated; log lines already present are not appended again; snippets already there are left alone.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { P, DATA_DIR } from '../../scraper/config.mjs';
8
+ import { readExport, buildIndex, peopleCandidates, snippetSuggestions, warmPaths, companyKey } from '../../scraper/linkedin.mjs';
9
+ import * as people from './people.mjs';
10
+ import * as store from './store.mjs';
11
+
12
+ const isoDay = (d = new Date()) => d.toISOString().slice(0, 10);
13
+ const daysAgo = (n) => isoDay(new Date(Date.now() - n * 864e5));
14
+
15
+ // ---------- the index ----------
16
+ let cached = { mtime: 0, index: null };
17
+ /** The last import's index, or null before the first import. Re-read when the file changes. */
18
+ export function loadIndex() {
19
+ try {
20
+ const mtime = fs.statSync(P.linkedin).mtimeMs;
21
+ if (mtime !== cached.mtime) cached = { mtime, index: JSON.parse(fs.readFileSync(P.linkedin, 'utf8')) };
22
+ return cached.index;
23
+ } catch { return null; }
24
+ }
25
+
26
+ /** Who you know at a company, from the index; an empty answer before any import. */
27
+ export function connectionsAt(company) {
28
+ const index = loadIndex();
29
+ if (!index) return { company, count: 0, people: [], imported: null };
30
+ return { ...warmPaths(index, company), imported: index.built };
31
+ }
32
+
33
+ /** What the last import left behind, for the People page: when, from what, and the counts. */
34
+ export function status() {
35
+ const index = loadIndex();
36
+ if (!index) return { imported: null };
37
+ return { imported: index.built, since: index.since, source: index.source, counts: index.counts, self: index.self };
38
+ }
39
+
40
+ // ---------- choosing ----------
41
+ /** The candidates an import writes as People by default: the ones whose title says recruiter or hiring manager. */
42
+ function chosen(candidates, { roles, everyone }) {
43
+ return candidates.filter((c) => everyone ? c.count > 0 : roles.includes(c.role));
44
+ }
45
+
46
+ /** Everything an import would do, without doing it. */
47
+ export function preview(source, { since = daysAgo(90), roles = ['recruiter', 'hiring-manager'], everyone = false } = {}) {
48
+ const ex = readExport(source);
49
+ const index = buildIndex(ex, { since });
50
+ const candidates = peopleCandidates(index, { since });
51
+ const picked = chosen(candidates, { roles, everyone });
52
+ const existing = store.getSnippets().items;
53
+ const snippets = snippetSuggestions(index, existing);
54
+ const jobs = store.listJobs();
55
+ const jobKeys = new Map(jobs.map((j) => [companyKey(j.company), j]));
56
+ const withNotes = picked.filter((c) => c.company && jobKeys.has(companyKey(c.company)));
57
+ const seenCompany = new Set();
58
+ const knownAt = jobs.filter((j) => { const k = companyKey(j.company); return k && !seenCompany.has(k) && seenCompany.add(k); }).map((j) => ({ company: j.company, count: warmPaths(index, j.company).count })).filter((j) => j.count > 0).sort((a, b) => b.count - a.count);
59
+ return {
60
+ source: ex.source, kind: ex.kind, files: ex.files, since, self: index.self, counts: index.counts,
61
+ 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
+ warmPaths: { jobsWithConnections: knownAt.length, top: knownAt.slice(0, 10) },
64
+ applicationsSince: index.applications.filter((a) => a.date >= since).length,
65
+ savedJobsSince: index.savedJobs.filter((s) => s.date >= since).length,
66
+ };
67
+ }
68
+
69
+ // ---------- writing ----------
70
+ /**
71
+ * 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, then the copy-panel suggestions. `dry` does everything but write.
73
+ */
74
+ export function runImport(source, { since = daysAgo(90), roles = ['recruiter', 'hiring-manager'], everyone = false, writePeople = true, writeSnippets = true, dry = false } = {}) {
75
+ const ex = readExport(source);
76
+ 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 }, snippets: { added: 0 } };
78
+
79
+ if (!dry) { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(P.linkedin, JSON.stringify(index, null, 1)); cached = { mtime: 0, index: null }; }
80
+
81
+ if (writePeople) {
82
+ const candidates = peopleCandidates(index, { since });
83
+ const picked = chosen(candidates, { roles, everyone });
84
+ summary.people.skipped = candidates.length - picked.length;
85
+ const jobs = store.listJobs().filter((j) => j.status !== 'closed' && j.status !== 'rejected' && j.status !== 'passed');
86
+ for (const c of picked) {
87
+ if (dry) { summary.people.created++; continue; }
88
+ const before = people.findPerson({ name: c.name, company: c.company });
89
+ const person = people.createPerson({ name: c.name, role: c.role, company: c.company, links: c.url, about: `${c.title ? c.title + (c.company ? ` at ${c.company}` : '') + '. ' : ''}From the LinkedIn import: ${c.count} message${c.count === 1 ? '' : 's'} between ${c.first} and ${c.last}.` });
90
+ summary.people[before ? 'recognised' : 'created']++;
91
+ const text = fs.readFileSync(person.path, 'utf8');
92
+ for (const l of c.log) {
93
+ const line = `- ${l.date} (${l.via}): ${l.text}`;
94
+ if (text.includes(line.slice(0, 80))) continue;
95
+ people.logContact(person.id, { date: l.date, via: l.via, text: l.text });
96
+ summary.people.logged++;
97
+ }
98
+ if (c.company) {
99
+ const key = companyKey(c.company);
100
+ for (const j of jobs.filter((j) => companyKey(j.company) === key)) {
101
+ // List rows carry no file path; the full read does.
102
+ const note = fs.readFileSync(store.getJob(j.id).path, 'utf8');
103
+ if (note.includes(`[[People/${person.id}`)) continue;
104
+ people.attachPerson(j.id, person.id, { role: c.role, context: `LinkedIn, ${c.last}` });
105
+ summary.people.attached++;
106
+ }
107
+ }
108
+ }
109
+ }
110
+
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
+ return summary;
118
+ }
@@ -7,11 +7,14 @@ import * as letter from './cover-letter.mjs';
7
7
  import * as tailored from './tailored-resume.mjs';
8
8
  import * as mail from './mail-check.mjs';
9
9
  import * as people from './people.mjs';
10
+ import * as linkedin from './linkedin-import.mjs';
10
11
 
11
12
  const TOOLS = [
12
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: {} } },
13
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: {} } },
14
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), and their saved application answers into the copy panel. 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' }, writeSnippets: { type: 'boolean' }, dry: { type: 'boolean' } }, required: ['source'] } },
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'] } },
15
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'] } },
16
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'] } },
17
20
  { name: 'log_contact', description: "Append a dated line to a person's Log (a call, a reply, a note to self) and move their last contact forward. Nothing is sent.", inputSchema: { type: 'object', properties: { personId: { type: 'string' }, text: { type: 'string' }, date: { type: 'string', description: 'YYYY-MM-DD, default today.' } }, required: ['personId', 'text'] } },
@@ -94,6 +97,8 @@ async function call(name, a = {}) {
94
97
  case 'list_snippets': return store.getSnippets();
95
98
  case 'list_people': return people.listPeople();
96
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, writeSnippets: a.writeSnippets !== false, dry: !!a.dry });
101
+ case 'connections_at': return linkedin.connectionsAt(a.company);
97
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); }
98
103
  case 'attach_person': return people.attachPerson(a.jobId, a.personId, { role: a.role, context: a.context });
99
104
  case 'log_contact': return people.logContact(a.personId, { date: a.date, via: 'mcp', text: a.text });
package/cli.mjs CHANGED
@@ -34,6 +34,9 @@ const HELP = `tekjobs — a local job-search machine
34
34
  run the scan and the mail read every morning: creates the Windows task
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
+ tekjobs import linkedin <zip|folder> your LinkedIn data export into the search: who you know at each
38
+ company, the recruiters who wrote, your saved form answers
39
+ [--since YYYY-MM-DD] [--everyone] [--no-people] [--no-snippets] [--preview] [--dry]
37
40
  tekjobs serve the app + API on http://127.0.0.1:8787
38
41
  tekjobs mcp the MCP server on stdio (Claude Code, Codex, Cursor, Claude Desktop)
39
42
 
@@ -151,6 +154,20 @@ async function main() {
151
154
  if (dry) console.log('\nNothing written. Run without --dry to apply.');
152
155
  return;
153
156
  }
157
+ if (cmd === 'import' && rest[0] === 'linkedin') {
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] [--no-snippets] [--preview] [--dry]\nRequest the larger archive at https://www.linkedin.com/mypreferences/d/download-my-data');
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'), writeSnippets: !rest.includes('--no-snippets'), dry: rest.includes('--dry') };
162
+ if (rest.includes('--preview')) return console.log(JSON.stringify(li.preview(source, o), null, 2));
163
+ const s = li.runImport(source, o);
164
+ console.log(`LinkedIn export read from ${s.source}${s.dry ? ' (dry run, nothing written)' : ''}`);
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
+ 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
+ if (!s.dry) console.log(` Index: ${s.indexPath}. Job notes now show who you know at each company.`);
169
+ return;
170
+ }
154
171
  if (cmd === 'serve') return run(process.execPath, [path.join(ROOT, 'app', 'server', 'index.mjs')]);
155
172
  if (cmd === 'mcp') return run(process.execPath, [path.join(ROOT, 'app', 'server', 'mcp.mjs')]);
156
173
  console.error(`unknown command "${cmd}"\n`); console.log(HELP); process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timurtekb/tekjobs",
3
- "version": "0.27.1",
3
+ "version": "0.29.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",
@@ -58,6 +58,8 @@ export const P = {
58
58
  runsLog: path.join(DATA_DIR, 'runs.log'),
59
59
  // What each board and feed did the last time the scan tried it (scraper/health.mjs).
60
60
  health: path.join(DATA_DIR, 'board-health.json'),
61
+ // The index the LinkedIn import builds: connections by company, threads by person (scraper/linkedin.mjs).
62
+ linkedin: path.join(DATA_DIR, 'linkedin.json'),
61
63
  };
62
64
 
63
65
  export const ALL_ATS = ['greenhouse', 'lever', 'ashby', 'workday', 'rippling', 'smartrecruiters', 'workable', 'bamboohr', 'breezy', 'personio', 'teamtailor', 'eightfold', 'atlassian', 'github', 'spotify', 'amazon', 'google', 'apple'];
@@ -0,0 +1,207 @@
1
+ // Reading a LinkedIn data export (the "larger archive" from linkedin.com/mypreferences/d/download-my-data),
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, the answers you have typed into application forms, what you applied to and
4
+ // saved. Nothing here writes; app/server/linkedin-import.mjs does, into the profile folder only. The archive
5
+ // is read in place and never copied. Files this module does not name (ads, reactions, searches, phone numbers,
6
+ // birth date, addresses) are never opened.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { openZip } from './zip.mjs';
10
+
11
+ // ---------------- csv ----------------
12
+ /** RFC-4180-ish: quoted cells with commas, quotes and newlines inside; CRLF or LF. */
13
+ export function parseCsv(text) {
14
+ const rows = []; let row = [], cell = '', q = false;
15
+ const s = String(text).replace(/^/, '');
16
+ for (let i = 0; i < s.length; i++) {
17
+ const c = s[i];
18
+ if (q) { if (c === '"') { if (s[i + 1] === '"') { cell += '"'; i++; } else q = false; } else cell += c; }
19
+ else if (c === '"') q = true;
20
+ else if (c === ',') { row.push(cell); cell = ''; }
21
+ else if (c === '\n') { row.push(cell.replace(/\r$/, '')); rows.push(row); row = []; cell = ''; }
22
+ else cell += c;
23
+ }
24
+ if (cell !== '' || row.length) { row.push(cell.replace(/\r$/, '')); rows.push(row); }
25
+ return rows.filter((r) => r.some((c) => String(c).trim() !== ''));
26
+ }
27
+
28
+ /** Rows as objects keyed by the header. Connections.csv opens with a three-line note before its header. */
29
+ export function table(text) {
30
+ let s = String(text).replace(/^/, '');
31
+ if (/^Notes?:/i.test(s)) s = s.replace(/^[\s\S]*?\n\n/, '');
32
+ const rows = parseCsv(s);
33
+ if (!rows.length) return [];
34
+ const [head, ...data] = rows;
35
+ const keys = head.map((h) => h.trim());
36
+ return data.map((r) => Object.fromEntries(keys.map((k, i) => [k, (r[i] ?? '').trim()])));
37
+ }
38
+
39
+ // ---------------- names ----------------
40
+ const SUFFIX = /\b(inc|incorporated|llc|ltd|limited|corp|corporation|co|company|plc|gmbh|ag|sa|bv|technologies|technology|labs|group|holdings|the)\b/g;
41
+ /** "Northwind Traders, Inc." and "northwind traders" meet in the middle. */
42
+ export function companyKey(name = '') {
43
+ const base = String(name).toLowerCase().replace(/\.(com|io|ai|co|jobs|org|net|dev|app)\b/g, ' ').replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim();
44
+ const stripped = base.replace(SUFFIX, ' ').replace(/\s+/g, ' ').trim();
45
+ // "Group O" must not become "o": when the suffix rule eats the name, keep the name.
46
+ return stripped.length >= 3 ? stripped : base;
47
+ }
48
+ /** Two keys name the same company when equal, or when one is the other's first word(s) ("amazon", "amazon web services"). */
49
+ export function sameCompany(a, b) {
50
+ if (!a || !b) return false;
51
+ if (a === b) return true;
52
+ const [s, l] = a.length <= b.length ? [a, b] : [b, a];
53
+ return s.length >= 4 && l.startsWith(s + ' ');
54
+ }
55
+ const RECRUITER = /recruit|talent|sourc|people (partner|ops|operations)|staffing|head ?hunter|acquisition/i;
56
+ const HIRING = /hiring manager|engineering manager|head of|director|vp\b|vice president|cto|chief|founder|lead\b|manager/i;
57
+ export function roleGuess(title = '') {
58
+ if (RECRUITER.test(title)) return 'recruiter';
59
+ if (HIRING.test(title)) return 'hiring-manager';
60
+ return 'other';
61
+ }
62
+
63
+ // ---------------- reading ----------------
64
+ const WANT = {
65
+ connections: ['Connections.csv'],
66
+ messages: ['messages.csv'],
67
+ invitations: ['Invitations.csv'],
68
+ applications: [/^Jobs\/Job Applications(_\d+)?\.csv$/],
69
+ savedJobs: [/^Jobs\/Saved Jobs(_\d+)?\.csv$/],
70
+ preferences: ['Jobs/Job Seeker Preferences.csv'],
71
+ answers: ['Jobs/Job Applicant Saved Answers.csv', /^Job Applicant Saved Screening Question Responses(_\d+)?\.csv$/],
72
+ profile: ['Profile.csv'],
73
+ positions: ['Positions.csv'],
74
+ skills: ['Skills.csv'],
75
+ };
76
+
77
+ /** Open a zip or a folder; returns a reader with the export's file names and a text reader. */
78
+ export function openExport(source) {
79
+ const p = path.resolve(source);
80
+ if (!fs.existsSync(p)) throw Object.assign(new Error(`No such file or folder: ${p}`), { status: 400 });
81
+ if (fs.statSync(p).isDirectory()) {
82
+ const names = [];
83
+ const walk = (dir, rel = '') => { for (const f of fs.readdirSync(dir)) { const full = path.join(dir, f); const r = rel ? `${rel}/${f}` : f; if (fs.statSync(full).isDirectory()) walk(full, r); else names.push(r); } };
84
+ walk(p);
85
+ // A folder that holds the export inside one top-level directory (as some unzippers make) still works.
86
+ const prefix = names.includes('Connections.csv') ? '' : (names.find((n) => n.endsWith('/Connections.csv')) || '').replace(/Connections\.csv$/, '');
87
+ return { source: p, kind: 'folder', names: names.filter((n) => n.startsWith(prefix)).map((n) => n.slice(prefix.length)), readText: (n) => fs.readFileSync(path.join(p, prefix + n), 'utf8') };
88
+ }
89
+ const zip = openZip(p);
90
+ const prefix = zip.has('Connections.csv') ? '' : (zip.names.find((n) => n.endsWith('/Connections.csv')) || '').replace(/Connections\.csv$/, '');
91
+ return { source: p, kind: 'zip', names: zip.names.filter((n) => n.startsWith(prefix)).map((n) => n.slice(prefix.length)), readText: (n) => zip.readText(prefix + n) };
92
+ }
93
+
94
+ const matches = (name, pats) => pats.some((q) => (q instanceof RegExp ? q.test(name) : q === name));
95
+
96
+ /** The export's useful tables, each as an array of row objects; missing files give empty tables. */
97
+ export function readExport(source) {
98
+ const ex = openExport(source);
99
+ if (!ex.names.some((n) => matches(n, WANT.connections)) && !ex.names.some((n) => matches(n, WANT.messages))) {
100
+ throw Object.assign(new Error(`${ex.source} does not look like a LinkedIn data export: no Connections.csv or messages.csv. Request the larger archive at linkedin.com/mypreferences/d/download-my-data.`), { status: 400 });
101
+ }
102
+ const out = { source: ex.source, kind: ex.kind, files: [] };
103
+ for (const [key, pats] of Object.entries(WANT)) {
104
+ const names = ex.names.filter((n) => matches(n, pats)).sort();
105
+ out.files.push(...names);
106
+ out[key] = names.flatMap((n) => table(ex.readText(n)));
107
+ }
108
+ return out;
109
+ }
110
+
111
+ // ---------------- the index ----------------
112
+ // LinkedIn writes dates in the member's local time ("9/15/26, 8:03 PM", "22 Sep 2026") and messages in UTC; the
113
+ // calendar day is taken in this machine's zone, so an evening application does not slip to the next day.
114
+ const day = (s) => { const t = new Date(String(s || '').replace(/ (AM|PM)$/i, ' $1')); if (isNaN(t.getTime())) return ''; const p = (n) => String(n).padStart(2, '0'); return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}`; };
115
+ const fullName = (r) => `${r['First Name'] || ''} ${r['Last Name'] || ''}`.replace(/\s+/g, ' ').trim();
116
+ const clip = (s, n) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
117
+
118
+ /**
119
+ * 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, the saved form answers. Message text is
121
+ * kept only for messages since `since`, clipped, so the file stays about your search and not your history.
122
+ */
123
+ export function buildIndex(ex, { since = '', now = new Date() } = {}) {
124
+ const self = ex.profile?.[0] ? fullName(ex.profile[0]) : '';
125
+ const selfKey = self.toLowerCase();
126
+ const connections = (ex.connections || []).filter((r) => fullName(r)).map((r) => ({
127
+ name: fullName(r), url: r.URL || '', email: r['Email Address'] || '', company: r.Company || '', companyKey: companyKey(r.Company), title: r.Position || '', connectedOn: day(r['Connected On']),
128
+ }));
129
+ const byUrl = new Map(connections.filter((c) => c.url).map((c) => [c.url.replace(/\/$/, ''), c]));
130
+ const byName = new Map(connections.map((c) => [c.name.toLowerCase(), c]));
131
+ const look = (name, url) => byUrl.get(String(url || '').replace(/\/$/, '')) || byName.get(String(name || '').toLowerCase()) || null;
132
+
133
+ const recent = (d) => !since || (d && d >= since);
134
+ const convs = new Map();
135
+ for (const m of ex.messages || []) {
136
+ const d = day(m.DATE);
137
+ if (!recent(d)) continue;
138
+ const id = m['CONVERSATION ID'] || `${m.FROM}|${m.TO}`;
139
+ const c = convs.get(id) || { id, title: m['CONVERSATION TITLE'] || '', with: new Map(), first: d, last: d, count: 0, messages: [] };
140
+ c.count++; if (d < c.first) c.first = d; if (d > c.last) c.last = d;
141
+ const from = String(m.FROM || '').trim(), fromUrl = String(m['SENDER PROFILE URL'] || '').trim();
142
+ const outgoing = selfKey && from.toLowerCase() === selfKey;
143
+ if (!outgoing && from) c.with.set(fromUrl || from, { name: from, url: fromUrl });
144
+ for (const [n, u] of String(m.TO || '').split(',').map((t, i) => [t.trim(), (String(m['RECIPIENT PROFILE URLS'] || '').split(',')[i] || '').trim()])) { if (n && (!selfKey || n.toLowerCase() !== selfKey)) c.with.set(u || n, { name: n, url: u }); }
145
+ c.messages.push({ date: d, from, outgoing, subject: clip(m.SUBJECT, 120), text: clip(m.CONTENT, 240) });
146
+ convs.set(id, c);
147
+ }
148
+ const threads = [...convs.values()].map((c) => ({ ...c, with: [...c.with.values()].map((w) => ({ ...w, ...(look(w.name, w.url) ? { company: look(w.name, w.url).company, title: look(w.name, w.url).title } : {}) })), messages: c.messages.sort((a, b) => a.date.localeCompare(b.date)) })).sort((a, b) => b.last.localeCompare(a.last));
149
+
150
+ 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
+ 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
+ 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
+ const pref = ex.preferences?.[0] || {};
156
+ 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
+
158
+ return {
159
+ 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, answers: answers.length },
161
+ connections, threads, invitations, applications, savedJobs, answers, preferences,
162
+ };
163
+ }
164
+
165
+ // ---------------- questions the app asks the index ----------------
166
+ /** The connections at one company, by key equality or containment either way ("Vanta" and "Vanta Inc"). */
167
+ export function warmPaths(index, company) {
168
+ const k = companyKey(company);
169
+ if (!k || !index?.connections) return { company, count: 0, people: [] };
170
+ const people = index.connections.filter((c) => sameCompany(c.companyKey, k)).sort((a, b) => (roleGuess(a.title) === 'recruiter' ? -1 : 0) - (roleGuess(b.title) === 'recruiter' ? -1 : 0) || b.connectedOn.localeCompare(a.connectedOn));
171
+ return { company, count: people.length, people: people.map((c) => ({ name: c.name, title: c.title, url: c.url, connectedOn: c.connectedOn, role: roleGuess(c.title) })) };
172
+ }
173
+
174
+ /**
175
+ * Who has written to you lately, one entry per person, with what their connection record says they do.
176
+ * These become People notes when the import is asked to write them.
177
+ */
178
+ export function peopleCandidates(index, { since = index?.since || '' } = {}) {
179
+ const out = new Map();
180
+ const add = (w, date, via, gist) => {
181
+ if (!w.name || (index.self && w.name.toLowerCase() === index.self.toLowerCase())) return;
182
+ const key = (w.url || w.name).toLowerCase();
183
+ const c = out.get(key) || { name: w.name, url: w.url || '', company: w.company || '', title: w.title || '', role: roleGuess(w.title), first: date, last: date, count: 0, log: [] };
184
+ c.count++; if (date && (!c.first || date < c.first)) c.first = date; if (date && date > c.last) c.last = date;
185
+ if (gist) c.log.push({ date, via, text: gist });
186
+ if (!c.company && w.company) { c.company = w.company; c.title = w.title || c.title; c.role = roleGuess(c.title); }
187
+ out.set(key, c);
188
+ };
189
+ for (const t of index.threads || []) {
190
+ if (since && t.last < since) continue;
191
+ for (const w of t.with) for (const m of t.messages) if (!m.outgoing && m.from === w.name) add(w, m.date, 'linkedin message', m.subject ? `${m.subject}: ${m.text}` : m.text);
192
+ }
193
+ for (const i of index.invitations || []) {
194
+ if (since && i.sentAt < since) continue;
195
+ const who = i.direction === 'INCOMING' ? { name: i.from, url: i.url } : { name: i.to, url: i.url };
196
+ const rec = (index.connections || []).find((c) => (who.url && c.url === who.url) || c.name.toLowerCase() === who.name.toLowerCase());
197
+ add({ ...who, company: rec?.company, title: rec?.title }, i.sentAt, i.direction === 'INCOMING' ? 'linkedin invitation received' : 'linkedin invitation sent', i.message);
198
+ }
199
+ 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
+ }
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
+ }
@@ -0,0 +1,41 @@
1
+ // A small reader for ordinary zip files (stored or deflated entries, no encryption, under 4 GB), enough to read
2
+ // a LinkedIn data export in place without unpacking it or adding a dependency.
3
+ import fs from 'node:fs';
4
+ import zlib from 'node:zlib';
5
+
6
+ const EOCD = 0x06054b50, CENTRAL = 0x02014b50, LOCAL = 0x04034b50;
7
+
8
+ /** The entries of a zip file: name, sizes, and a reader for each. */
9
+ export function openZip(file) {
10
+ const buf = fs.readFileSync(file);
11
+ // The end-of-central-directory record is in the last 64 KB (its comment can push it back from the end).
12
+ let eocd = -1;
13
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) { if (buf.readUInt32LE(i) === EOCD) { eocd = i; break; } }
14
+ if (eocd < 0) throw new Error(`${file} is not a zip file`);
15
+ const count = buf.readUInt16LE(eocd + 10);
16
+ let p = buf.readUInt32LE(eocd + 16);
17
+ const entries = new Map();
18
+ for (let i = 0; i < count; i++) {
19
+ if (buf.readUInt32LE(p) !== CENTRAL) throw new Error(`${file}: bad central directory`);
20
+ const method = buf.readUInt16LE(p + 10);
21
+ const compressed = buf.readUInt32LE(p + 20);
22
+ const size = buf.readUInt32LE(p + 24);
23
+ const nameLen = buf.readUInt16LE(p + 28), extraLen = buf.readUInt16LE(p + 30), commentLen = buf.readUInt16LE(p + 32);
24
+ const offset = buf.readUInt32LE(p + 42);
25
+ // Some Windows archivers write backslashes in entry names; the zip standard, and LinkedIn, use slashes.
26
+ const name = buf.toString('utf8', p + 46, p + 46 + nameLen).replace(/\\/g, '/');
27
+ entries.set(name, { name, method, compressed, size, offset });
28
+ p += 46 + nameLen + extraLen + commentLen;
29
+ }
30
+ const read = (name) => {
31
+ const e = entries.get(name);
32
+ if (!e) throw new Error(`${name} is not in ${file}`);
33
+ if (buf.readUInt32LE(e.offset) !== LOCAL) throw new Error(`${file}: bad local header for ${name}`);
34
+ const start = e.offset + 30 + buf.readUInt16LE(e.offset + 26) + buf.readUInt16LE(e.offset + 28);
35
+ const raw = buf.subarray(start, start + e.compressed);
36
+ if (e.method === 0) return Buffer.from(raw);
37
+ if (e.method === 8) return zlib.inflateRawSync(raw);
38
+ throw new Error(`${name}: compression method ${e.method} is not supported`);
39
+ };
40
+ return { names: [...entries.keys()], has: (n) => entries.has(n), read, readText: (n) => read(n).toString('utf8') };
41
+ }