@timurtekb/tekjobs 0.32.1 → 0.34.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-DsZ1UCTf.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-CbvdnTFY.css">
11
+ <script type="module" crossorigin src="/assets/index-Dt4Ynnmg.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-CR3ppGJT.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -69,6 +69,8 @@ const routes = [
69
69
  ['GET', /^\/api\/profile\/summary$/, () => store.profileSummary()],
70
70
  ['GET', /^\/api\/companies$/, () => store.companies()],
71
71
  ['GET', /^\/api\/feeds$/, () => store.feeds()],
72
+ ['GET', /^\/api\/companies\/find$/, (_, q) => store.findBoard({ slug: q.get('slug') || '', ats: q.get('ats') || '', name: q.get('name') || '' })],
73
+ ['POST', /^\/api\/companies\/move$/, async (_, __, req) => { const b = await readBody(req); return store.moveBoard({ name: b.name || '', slug: b.slug, from: b.from || '', to: b.to }); }],
72
74
  ['POST', /^\/api\/companies$/, async (_, __, req) => store.addCompany(await readBody(req))],
73
75
  ['POST', /^\/api\/jobs\/import$/, async (_, __, req) => { const b = await readBody(req); return store.importLinks(b.urls ?? b.url); }],
74
76
  ['POST', /^\/api\/jobs\/([^/]+)\/reveal$/, (m) => store.revealJob(decodeURIComponent(m[1]))],
@@ -105,7 +105,7 @@ export function reconcile(mails, notes) {
105
105
  const best = exact || (otherRole ? null : byRank[0] || null);
106
106
  const match = exact ? 'exact' : otherRole ? 'company-other-role' : best ? 'company' : 'none';
107
107
  return {
108
- id: m.messageId, ...m, match,
108
+ id: m.messageId, ...m, match, link: gmailLink(m.messageId),
109
109
  noteId: best ? best.id : '', noteTitle: best ? best.title : '', noteStatus: best ? best.status : '',
110
110
  candidates: byRank.slice(0, 6).map((n) => ({ id: n.id, title: n.title, status: n.status })),
111
111
  suggestion: suggestionFor(m.kind, best ? best.status : '', m.date),
@@ -136,7 +136,7 @@ export function groupItems(items) {
136
136
  company: rep.company, role: rep.role || sorted.find((m) => m.role)?.role || '',
137
137
  kind: rep.kind, kinds: [...new Set(sorted.map((m) => m.kind))], date: sorted.map((m) => m.date).sort().pop() || '',
138
138
  first: sorted.map((m) => m.date).filter(Boolean).sort()[0] || '',
139
- gist: rep.gist, subject: rep.subject, from: rep.from,
139
+ gist: rep.gist, subject: rep.subject, from: rep.from, link: rep.link,
140
140
  person: rep.person || sorted.find((m) => m.person)?.person || null,
141
141
  match: rep.match, noteId: rep.noteId, noteTitle: rep.noteTitle, noteStatus: rep.noteStatus, candidates: rep.candidates, suggestion: rep.suggestion,
142
142
  };
@@ -147,7 +147,8 @@ export function groupItems(items) {
147
147
  const run = { running: false, startedAt: null, finishedAt: null, error: '', errorKind: '', sinceDays: null, lastOutput: '' };
148
148
  export function items() {
149
149
  const d = load();
150
- const all = d.items || [];
150
+ // Items written before the link existed get it here; the id is the Gmail message id.
151
+ const all = (d.items || []).map((i) => (i.link ? i : { ...i, link: gmailLink(i.id) }));
151
152
  return { ...run, runner: runnerConfig().command, lastRun: d.lastRun, lastSinceDays: d.sinceDays, items: all, groups: groupItems(all) };
152
153
  }
153
154
  export function start({ sinceDays } = {}) {
@@ -55,6 +55,7 @@ export const TOOLS = [
55
55
  { name: 'import_resume', description: 'Import a resume file from a local path (PDF, DOCX, Markdown, text): copies it into Profile/ and extracts its text for the interview.', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } },
56
56
  { name: 'add_job', description: 'Add postings the person found themselves, from links (LinkedIn job pages, a company careers page, a Greenhouse/Lever/Ashby posting). Each link is read once, scored with the current criteria, and written as a job note unless the scan already has it or a note with the same company and title exists. A LinkedIn link is opened as a signed-out visitor; if it names the company\'s own apply page on a known board, that page is imported and the LinkedIn link is recorded as where it was seen. Returns one result per link. Up to 25 links.', inputSchema: { type: 'object', properties: { urls: { type: 'array', items: { type: 'string' } } }, required: ['urls'] } },
57
57
  { name: 'attach_posting', description: 'Attach the real posting to a note that was created without one (from an application email or a pasted link with thin data). Reads the posting through its board when the link is Greenhouse/Lever/Ashby (including company pages with ?gh_jid= or ?ashby_jid=), replaces the note\'s posting facts, score, reasons and description, and keeps the status, notes, packet and drafts. The note name does not change.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, url: { type: 'string' } }, required: ['id', 'url'] } },
58
+ { name: 'find_board', description: 'A board on the watchlist answers 404: ask every platform the scan can read for the same slug and report which answer with jobs, best first. With apply=true, move the watchlist row to the best one (its status clears and the next scan reads it there). Companies usually keep their slug when they change tracker; when nothing answers, the slug changed too and the careers page says the new one.', inputSchema: { type: 'object', properties: { slug: { type: 'string' }, ats: { type: 'string', description: 'the platform that failed, left out of the probe' }, name: { type: 'string' }, apply: { type: 'boolean' } }, required: ['slug'] } },
58
59
  { name: 'add_company', description: 'Append a board to the watchlist. ats is the platform (greenhouse, lever, ashby, workday, rippling, smartrecruiters, workable, bamboohr, breezy, personio, teamtailor, eightfold, atlassian, github, spotify, amazon); slug is the board token.', inputSchema: { type: 'object', properties: { name: { type: 'string' }, ats: { type: 'string' }, slug: { type: 'string' }, tier: { type: 'string' }, notes: { type: 'string' } }, required: ['name', 'ats', 'slug'] } },
59
60
  ];
60
61
 
@@ -93,6 +94,7 @@ export async function call(name, a = {}) {
93
94
  case 'set_criteria': return store.setCriteria(a.raw);
94
95
  case 'list_companies': return store.companies();
95
96
  case 'list_feeds': return store.feeds();
97
+ case 'find_board': { const r = await store.findBoard(a); if (a.apply && r.found[0]) return { ...r, moved: await store.moveBoard({ name: a.name || '', slug: a.slug, from: a.ats || '', to: r.found[0].ats }) }; return r; }
96
98
  case 'add_company': return store.addCompany(a);
97
99
  case 'add_job': return store.importLinks(a.urls);
98
100
  case 'attach_posting': return store.attachPosting(a.id, a.url);
@@ -969,6 +969,18 @@ export async function importLinks(urls) {
969
969
  return out;
970
970
  }
971
971
 
972
+ /** Where a board went: every platform that answers for the slug, the failed one left out. */
973
+ export async function findBoard({ slug, ats = '', name = '' }) {
974
+ if (!slug) throw Object.assign(new Error('slug is required'), { status: 400 });
975
+ const { probeBoard } = await import('../../scraper/find-board.mjs');
976
+ return probeBoard(slug, { name: name || slug, except: ats });
977
+ }
978
+ /** Move a watchlist row to the platform that answers; the next scan reads it there. */
979
+ export async function moveBoard({ name = '', slug, from = '', to }) {
980
+ const { moveCompany } = await import('../../scraper/find-board.mjs');
981
+ const r = moveCompany({ name, slug, from, to });
982
+ return { ...r, companies: companies() };
983
+ }
972
984
  export function addCompany({ name, ats, slug, tier = 'B', notes = '' }) {
973
985
  if (!name || !ats || !slug) throw Object.assign(new Error('name, ats and slug are required'), { status: 400 });
974
986
  if (loadCompanies().some((c) => c.ats === ats.toLowerCase() && c.slug === slug)) throw Object.assign(new Error('that board is already in the table'), { status: 409 });
package/cli.mjs CHANGED
@@ -41,6 +41,7 @@ const HELP = `tekjobs — a local job-search machine
41
41
  tekjobs up [--dev] [--port N] start the API (and with --dev, Vite) detached: they stay up until \`down\`
42
42
  tekjobs down stop what \`up\` started
43
43
  tekjobs ps what \`up\` has running
44
+ tekjobs board <slug> [--from ats] [--move] where a failed board answers now; --move rewrites the watchlist row
44
45
  tekjobs serve the app + API on http://127.0.0.1:8787, in this terminal
45
46
  tekjobs mcp the MCP server on stdio (Claude Code, Codex, Cursor, Claude Desktop)
46
47
 
@@ -171,6 +172,15 @@ async function main() {
171
172
  if (!s.dry) console.log(` Index: ${s.indexPath}. Job notes now show who you know at each company.`);
172
173
  return;
173
174
  }
175
+ if (cmd === 'board') {
176
+ const slug = rest.find((x) => !x.startsWith('--') && x !== opt('--from'));
177
+ if (!slug) return console.error('Usage: tekjobs board <slug> [--from <ats that failed>] [--move]');
178
+ const { probeBoard, moveCompany } = await import('./scraper/find-board.mjs');
179
+ const r = await probeBoard(slug, { except: opt('--from') || '' });
180
+ console.log(r.note);
181
+ if (rest.includes('--move') && r.found[0]) { const m = moveCompany({ slug, from: opt('--from') || '', to: r.found[0].ats }); console.log(m.moved ? `Moved ${m.row.name} to ${m.row.ats}; the next scan reads it there.` : `${m.row.name} already reads from ${m.row.ats}.`); }
182
+ return;
183
+ }
174
184
  if (cmd === 'up') return up();
175
185
  if (cmd === 'down') return down();
176
186
  if (cmd === 'ps') return ps();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timurtekb/tekjobs",
3
- "version": "0.32.1",
3
+ "version": "0.34.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",
@@ -0,0 +1,51 @@
1
+ // Where did a board go? A company that answers 404 on the platform the watchlist names has usually moved its
2
+ // careers page to another tracker, often keeping the same slug (Amplitude, Hightouch and Outschool all went
3
+ // from Greenhouse to Ashby). This asks every platform the scan can read, with the same slug, and reports the
4
+ // ones that answer with jobs; `moveCompany` rewrites the row when the person says so.
5
+ import fs from 'node:fs';
6
+ import { P, loadCompanies } from './config.mjs';
7
+ import { fetchCompany } from './sources.mjs';
8
+
9
+ /** Platforms a slug alone identifies. Workday needs host/tenant/site; the career APIs are not per-company boards. */
10
+ export const PROBE_ATS = ['greenhouse', 'ashby', 'lever', 'workable', 'smartrecruiters', 'rippling', 'bamboohr', 'breezy', 'personio', 'teamtailor', 'eightfold'];
11
+ const ANY_TITLE = { titleTerms: {}, titleExclude: [] };
12
+
13
+ /**
14
+ * Every platform that answers for this slug, best first (most jobs). `except` is the platform that already
15
+ * failed, left out. `fetch` is the board fetcher, injectable so a test needs no network.
16
+ */
17
+ export async function probeBoard(slug, { name = slug, except = '', fetch = fetchCompany, platforms = PROBE_ATS } = {}) {
18
+ const tried = platforms.filter((p) => p !== except);
19
+ const results = await Promise.all(tried.map(async (ats) => {
20
+ try {
21
+ const r = await fetch({ name, ats, slug }, ANY_TITLE);
22
+ return { ats, ok: !!r.ok, jobs: r.ok ? (r.jobs || []).length : 0, error: r.ok ? '' : String(r.error || '') };
23
+ } catch (e) { return { ats, ok: false, jobs: 0, error: e.message }; }
24
+ }));
25
+ const found = results.filter((r) => r.ok && r.jobs > 0).sort((a, b) => b.jobs - a.jobs);
26
+ return { slug, except, tried, found, note: found.length ? `Answers on ${found.map((f) => `${f.ats} (${f.jobs} job${f.jobs === 1 ? '' : 's'})`).join(', ')}.` : `No platform answers for "${slug}". The company may have renamed its board; look at its careers page for the new slug.` };
27
+ }
28
+
29
+ /** Rewrite one watchlist row's platform in place; the status cell is cleared for the scan, the move is noted. */
30
+ export function moveCompany({ name, slug, from, to }, { today = new Date().toISOString().slice(0, 10) } = {}) {
31
+ if (!slug || !to) throw Object.assign(new Error('slug and to are required'), { status: 400 });
32
+ const row = loadCompanies().find((c) => c.slug === slug && (!from || c.ats === from) && (!name || c.name === name));
33
+ if (!row) throw Object.assign(new Error(`No row for ${from ? from + ':' : ''}${slug} on the watchlist`), { status: 404 });
34
+ if (row.ats === to) return { moved: false, row };
35
+ const md = fs.readFileSync(P.companies, 'utf8');
36
+ let moved = false;
37
+ const out = md.split(/\r?\n/).map((line) => {
38
+ if (moved || !line.trim().startsWith('|')) return line;
39
+ const cells = line.split('|').slice(1, -1).map((c) => c.trim());
40
+ if (cells.length < 3 || cells[0] !== row.name || cells[1].toLowerCase() !== row.ats || cells[2] !== row.slug) return line;
41
+ while (cells.length < 6) cells.push('');
42
+ cells[1] = to;
43
+ cells[4] = '';
44
+ cells[5] = [cells[5], `moved from ${row.ats} ${today}`].filter(Boolean).join('; ');
45
+ moved = true;
46
+ return `| ${cells.join(' | ')} |`;
47
+ });
48
+ if (!moved) throw Object.assign(new Error(`Could not find the row for ${row.name} in ${P.companies}`), { status: 500 });
49
+ fs.writeFileSync(P.companies, out.join('\n'));
50
+ return { moved: true, row: { ...row, ats: to, status: '', notes: [row.notes, `moved from ${row.ats} ${today}`].filter(Boolean).join('; ') } };
51
+ }
@@ -105,7 +105,7 @@
105
105
  | DoorDash | greenhouse | doordashusa | C | | |
106
106
  | Lyft | greenhouse | lyft | C | | |
107
107
  | Roblox | greenhouse | roblox | C | | |
108
- | Amplitude | greenhouse | amplitude | C | | |
108
+ | Amplitude | ashby | amplitude | C | | moved to ashby 2026-10-07 |
109
109
  | Mixpanel | greenhouse | mixpanel | C | | |
110
110
  | Lattice | greenhouse | lattice | C | | |
111
111
  | Carta | greenhouse | carta | C | | |
@@ -197,7 +197,7 @@
197
197
  | GitLab | greenhouse | gitlab | A | | |
198
198
  | Headway | ashby | headway | B | | |
199
199
  | Hex | ashby | hex | A | | |
200
- | Hightouch | greenhouse | hightouch | B | | |
200
+ | Hightouch | ashby | hightouch | B | | moved to ashby 2026-10-07 |
201
201
  | Honeycomb | greenhouse | honeycomb | B | | |
202
202
  | Included Health | lever | includedhealth | B | | |
203
203
  | Inngest | ashby | inngest | B | | |
@@ -224,7 +224,7 @@
224
224
  | Orum | ashby | orum | B | | |
225
225
  | Oscar | greenhouse | oscar | B | | |
226
226
  | Otter | greenhouse | otter | B | | |
227
- | Outschool | greenhouse | outschool | B | | |
227
+ | Outschool | ashby | outschool | B | | moved to ashby 2026-10-07 |
228
228
  | Parloa | greenhouse | parloa | B | | |
229
229
  | Payoneer | greenhouse | payoneer | B | | |
230
230
  | Peloton | greenhouse | peloton | B | | |