@timurtekb/tekjobs 0.35.1 → 0.36.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,7 +8,7 @@
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-DB7xTVcd.js"></script>
11
+ <script type="module" crossorigin src="/assets/index-DR4ECosu.js"></script>
12
12
  <link rel="stylesheet" crossorigin href="/assets/index-CR3ppGJT.css">
13
13
  </head>
14
14
  <body>
@@ -235,7 +235,9 @@ export function classifyRun({ code, out = '', err = '', command = 'claude' }) {
235
235
  }
236
236
  if (code === 0 && out.trim()) return null;
237
237
  const signedOut = /not logged in|please run \/login|run `?\/login|failed to authenticate|authentication[_ ]error|invalid[_ ]api[_ ]key|token (has )?expired|(^|\W)unauthorized(\W|$)|\b(http|status|error)\s*:?\s*401\b/i;
238
- if (signedOut.test(err) || (!out.trim() && signedOut.test(out))) {
238
+ // Claude Code prints "Not logged in · Please run /login" on stdout with a non-zero exit, so a failed run's
239
+ // stdout counts too; a successful run's stdout never does, whatever words it holds.
240
+ if (signedOut.test(err) || (code !== 0 && signedOut.test(out))) {
239
241
  return Object.assign(new Error(`${command} is signed out. Open a terminal, run "${command}", sign in, then try again. (The desktop app's login is separate from the terminal's.) It said: ${tail || 'nothing'}`), { kind: 'auth' });
240
242
  }
241
243
  if (/requires a newer version|please upgrade/i.test(`${out}\n${err}`)) {
@@ -255,7 +257,7 @@ export function start(id, opts = {}) {
255
257
  if (cur?.running) return state(id);
256
258
  store.getJob(id); // throws early, and synchronously, if there is no such job
257
259
  drafts.set(id, { running: true, startedAt: new Date().toISOString(), finishedAt: null, error: '', errorKind: '' });
258
- materials(id, opts).then((m) => runLLM(m.prompt))
260
+ materials(id, opts).then((m) => runLLM(m.prompt)).then((text) => { if (String(text).trim().length < 80) throw Object.assign(new Error(`The writing CLI answered with "${String(text).trim().slice(0, 140)}" instead of a letter. If that is a sign-in or permission message, fix it in a terminal and try again.`), { kind: 'failed' }); return text; })
259
261
  .then((text) => { save(id, text, `app · ${runnerConfig().command}`); drafts.set(id, { ...drafts.get(id), running: false, finishedAt: new Date().toISOString() }); })
260
262
  .catch((e) => drafts.set(id, { ...drafts.get(id), running: false, finishedAt: new Date().toISOString(), error: e.message, errorKind: e.kind || 'failed' }));
261
263
  return state(id);
@@ -10,6 +10,7 @@ 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
12
  import * as linkedin from './linkedin-import.mjs';
13
+ import { TOOLS } from './mcp.mjs';
13
14
 
14
15
  const PORT = Number(process.env.PORT || 8787);
15
16
  const DIST = fileURLToPath(new URL('../dist', import.meta.url));
@@ -20,6 +21,8 @@ const readBody = (req) => new Promise((resolve, reject) => { let s = ''; req.on(
20
21
 
21
22
  const routes = [
22
23
  ['GET', /^\/api\/summary$/, () => store.summary()],
24
+ // The MCP tools the server offers, for the Agent access page; importing mcp.mjs does not start its stdio loop.
25
+ ['GET', /^\/api\/tools$/, () => TOOLS.map((t) => ({ name: t.name, description: t.description }))],
23
26
  ['GET', /^\/api\/today$/, (_, q) => store.today({ cap: Number(q.get('cap') || 7), waitingDays: Number(q.get('waitingDays') || 14) })],
24
27
  ['GET', /^\/api\/jobs$/, (_, q) => store.searchJobs({ ...store.filtersFromParams(q), sort: q.get('sort') || 'score', dir: q.get('dir') || 'desc', limit: Number(q.get('limit') || 500), offset: Number(q.get('offset') || 0) })],
25
28
  ['GET', /^\/api\/jobs\/facets$/, (_, q) => store.jobFacets(store.filtersFromParams(q))],
@@ -33,7 +33,7 @@ export const TOOLS = [
33
33
  { name: 'application_packet', description: "The application packet for one job as structured data: every field, what is in it, which required ones are still missing, and whether it is ready for a person to approve. 'ready' means the required fields are filled, not that the application is good, and it is never permission to send anything.", inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
34
34
  { name: 'application_materials', description: 'Everything needed to tailor an application for one job: the job note plus the candidate profile, positioning note, and current resume draft from the vault. Read this, then draft; then save with save_application_field.', inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
35
35
  { name: 'summary', description: 'Pipeline counts by status, pay band and kind; last scan; current floor, stretch and score bar.', inputSchema: { type: 'object', properties: {} } },
36
- { name: 'run_scan', description: 'Start a scan of every board now. It returns at once; the scan runs on its own (it survives this session ending) and takes two to three minutes. Poll scan_status. Pass dry=true to score without writing notes. Pass criteria=<preset name> to score this one run with a named criteria preset instead of the active Search Criteria (see list_criteria_presets).', inputSchema: { type: 'object', properties: { dry: { type: 'boolean' }, criteria: { type: 'string' } } } },
36
+ { name: 'run_scan', description: 'Start a scan of every board now. It returns at once; the scan runs on its own (it survives this session ending) and takes two to three minutes. Poll scan_status. Pass dry=true to score without writing notes. Pass criteria=<preset name> to score this one run with a named criteria preset instead of the active Search Criteria (see list_criteria_presets). Pass wait=<seconds> (up to 300) to answer only when the scan has finished, for a client that cannot poll between turns; the scan keeps running if the wait ends first.', inputSchema: { type: 'object', properties: { dry: { type: 'boolean' }, criteria: { type: 'string' }, wait: { type: 'number', description: 'seconds to wait for the scan to finish, up to 300; omit to return at once' } } } },
37
37
  { name: 'mail_check', description: 'Start a read-only pass over the person\'s mailbox for application updates (confirmations, rejections, interview invitations) through the local CLI\'s Gmail connector, with only the Gmail read tools allowed. Runs in the background for a few minutes; poll mail_items. days: how far back (default: since the last check, or 21 days the first time).', inputSchema: { type: 'object', properties: { days: { type: 'number' } } } },
38
38
  { name: 'mail_items', description: 'The application emails found so far, each matched to a job note (exact role, same company, or none) with what confirming it would do. Confirming is done by the person in the app; an agent can read these and tell them what is waiting.', inputSchema: { type: 'object', properties: {} } },
39
39
  { name: 'preview_criteria', description: 'What a proposed criteria JSON would do to the notes that exist, without saving or scanning: how many rise above or fall below the bar, who enters or leaves the top 20, the biggest movers. Covers title terms, recency and pay exactly; description, seniority and location rules need a scan.', inputSchema: { type: 'object', properties: { raw: { type: 'string' } }, required: ['raw'] } },
@@ -43,7 +43,7 @@ export const TOOLS = [
43
43
  { name: 'activate_criteria_preset', description: 'Copy a named preset into Targets/Search Criteria.md so the daily scan and everything else use it.', inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } },
44
44
  { name: 'scan_status', description: 'Whether a scan is running, when it started and finished, its exit code, and its last output lines. The scan runs on its own and its status lives in the profile folder, so this answers correctly from a new session or after this server restarts.', inputSchema: { type: 'object', properties: {} } },
45
45
  { name: 'rescore_notes', description: 'Score every existing note again under the saved criteria (the CLI\'s rescore --full): score, pay band, match reasons and a status-log line on each note that moved; notes already at the current weights are left alone. dry previews the count.', inputSchema: { type: 'object', properties: { dry: { type: 'boolean' } } } },
46
- { name: 'scan_preview', description: 'The top of the last scan\'s ranking, dry or real, with score, pay band and the first reasons: what the criteria find, before or without notes. Use after a dry run_scan.', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: '1 to 50, default 15' } } } },
46
+ { name: 'scan_preview', description: 'The top of the last scan\'s ranking, dry or real, with score, pay band and the first reasons: what the criteria find, before or without notes. Use after a dry run_scan. In each row, `salary` is the range as the posting states it (not a floor); the pay score reads the TOP of that range, and `payBand` says where that top sits against the criteria floor: floor (at or above), stretch, below, or unknown (no stated range).', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: '1 to 50, default 15' } } } },
47
47
  { name: 'get_criteria', description: 'The scoring criteria JSON from Targets/Search Criteria.md.', inputSchema: { type: 'object', properties: {} } },
48
48
  { name: 'set_criteria', description: 'Replace the criteria JSON block. Pass the full JSON as a string; it is validated before writing.', inputSchema: { type: 'object', properties: { raw: { type: 'string' } }, required: ['raw'] } },
49
49
  { name: 'list_companies', description: 'The company watchlist rows (name, ats, slug, tier, status) with each board\'s health: state (failed, zero, stale, never, ok), last success, last attempt, last error.', inputSchema: { type: 'object', properties: {} } },
@@ -79,7 +79,14 @@ export async function call(name, a = {}) {
79
79
  case 'save_cover_letter': return letter.save(a.id, a.text, 'mcp');
80
80
  case 'application_materials': return { job: store.getJob(a.id), packet: store.applicationPacket(a.id), ...store.profile() };
81
81
  case 'summary': return store.summary();
82
- case 'run_scan': return store.runScan(a.dry ? ['--dry'] : [], { criteria: a.criteria || '', via: 'mcp' });
82
+ case 'run_scan': {
83
+ const started = store.runScan(a.dry ? ['--dry'] : [], { criteria: a.criteria || '', via: 'mcp' });
84
+ const wait = Math.min(300, Math.max(0, Number(a.wait) || 0));
85
+ if (!wait) return started;
86
+ const until = Date.now() + wait * 1000;
87
+ while (Date.now() < until && store.scanStatus().running) await new Promise((r) => setTimeout(r, 2000));
88
+ return store.scanStatus();
89
+ }
83
90
  case 'mail_check': return mail.start({ sinceDays: a.days });
84
91
  case 'mail_items': return mail.items();
85
92
  case 'preview_criteria': return store.previewCriteria(a.raw);
@@ -1135,7 +1135,10 @@ export function scanPreview({ limit = 15 } = {}) {
1135
1135
  score: j.score, company: j.company, title: j.title, location: j.location, remote: !!j.remote, salary: j.salary || '', payBand: j.payBand, posted: j.posted, url: j.url, source: j.source, isNew: !!j.isNew,
1136
1136
  reasons: (j.reasons || []).slice(0, 3),
1137
1137
  }));
1138
- return { when: snap.when, minScore: snap.minScore, total: (snap.jobs || []).length, aboveBar: (snap.jobs || []).filter((j) => j.score >= (snap.minScore || 0)).length, failed: (snap.failed || []).length, rows };
1138
+ return {
1139
+ when: snap.when, minScore: snap.minScore, total: (snap.jobs || []).length, aboveBar: (snap.jobs || []).filter((j) => j.score >= (snap.minScore || 0)).length, failed: (snap.failed || []).length, rows,
1140
+ fields: { salary: 'the range as the posting states it, not a floor; the pay score reads its top', payBand: 'where that top sits against the criteria floor: floor (at or above), stretch, below, unknown (no stated range)' },
1141
+ };
1139
1142
  }
1140
1143
  export function runScan(args = [], { criteria = '', retryFailed = false, via = 'app' } = {}) {
1141
1144
  if (scanStatus().running) return scanStatus();
package/cli.mjs CHANGED
@@ -12,6 +12,8 @@ const opt = (f) => { const i = rest.indexOf(f); return i >= 0 ? rest[i + 1] : un
12
12
  const HELP = `tekjobs — a local job-search machine
13
13
 
14
14
  tekjobs init [dir] [--resume <file>] create the profile folder (default ~/.tekjobs/profile), remember it, import a resume
15
+ tekjobs init --sample [dir] a complete, fictional search to look at first (default ~/.tekjobs/sample);
16
+ nothing in it is real; \`tekjobs init\` afterwards starts your own
15
17
  tekjobs resume <file> import or replace the resume (PDF, DOCX, Markdown, text)
16
18
  tekjobs resume sync [<url|file>] refresh Profile/Resume.md from where you keep your resume (a link-shared
17
19
  Google Doc, a public page, or a file); remembers the source; flags drafts
@@ -52,6 +54,22 @@ async function main() {
52
54
  if (!cmd || cmd === 'help' || cmd === '--help') return console.log(HELP);
53
55
  if (cmd === '--version' || cmd === '-v' || cmd === 'version') return console.log(JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version);
54
56
  if (cmd === 'schedule') return schedule();
57
+ if (cmd === 'init' && rest.includes('--sample')) {
58
+ // The fictional search, copied where the app and the MCP server will read it next. Its own folder by default,
59
+ // so a later plain `init` starts the person's real profile beside it and leaves this one in place.
60
+ const dirArg = rest.find((a) => !a.startsWith('--'));
61
+ const dir = path.resolve(dirArg || path.join(process.env.USERPROFILE || process.env.HOME || '.', '.tekjobs', 'sample'));
62
+ const { installSample } = await import('./scraper/profile.mjs');
63
+ const r = installSample(dir);
64
+ console.log(`Sample profile: ${r.dir} (${r.files} files)
65
+ Jordan Example, a design engineer three weeks into a fictional search: fourteen postings, one interview, a rejection,
66
+ people on the threads, two scan logs. Nothing in it is a real person, company or posting.
67
+
68
+ tekjobs up the app on http://127.0.0.1:8787 shows it (tekjobs down first if it is already running)
69
+ tekjobs mcp your AI client reads it too, until the next init
70
+ tekjobs init when you are ready for your own search; the sample folder stays where it is`);
71
+ return;
72
+ }
55
73
  if (cmd === 'init') {
56
74
  const dirArg = rest.find((a) => !a.startsWith('--') && a !== opt('--resume'));
57
75
  const dir = path.resolve(dirArg || process.env.TEKJOBS_PROFILE || path.join(process.env.USERPROFILE || process.env.HOME || '.', '.tekjobs', 'profile'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timurtekb/tekjobs",
3
- "version": "0.35.1",
3
+ "version": "0.36.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",
@@ -2,7 +2,7 @@
2
2
 
3
3
  A complete, fictional TekJobs profile folder: Jordan Example, a design engineer in Portland, three weeks into a search. Fourteen postings at companies that do not exist, one application in interview, one rejected, two passed, one listing closed, three people on the threads, two scan logs and the dashboard. Every note was written by the product's own code (`npm run sample` rebuilds it), so it is always in the shape the app expects.
4
4
 
5
- Look at it in the app without touching your own folder:
5
+ Look at it in the app without touching your own folder. Installed from npm: `tekjobs init --sample`, then `tekjobs up`. From a clone:
6
6
 
7
7
  ```
8
8
  TEKJOBS_PROFILE=$PWD/samples/vault npm run serve
@@ -7,6 +7,7 @@ import { P, VAULT, rememberProfileDir, loadCriteria } from './config.mjs';
7
7
  import { extractText } from './resume.mjs';
8
8
 
9
9
  const STARTER = fileURLToPath(new URL('./starter/', import.meta.url));
10
+ const SAMPLE = fileURLToPath(new URL('../samples/vault/', import.meta.url));
10
11
  const today = () => new Date().toISOString().slice(0, 10);
11
12
 
12
13
  /** Create the folder layout and starter notes in `dir` (default: the resolved profile dir). Never overwrites. */
@@ -28,6 +29,20 @@ export function initProfile(dir = VAULT) {
28
29
  return { dir, made };
29
30
  }
30
31
 
32
+ /**
33
+ * Copy the fictional sample search (samples/vault, shipped in the package) into `dir` and make it the profile the
34
+ * app and the MCP server read next. Refuses a folder that already has files in it: it never merges into a real one.
35
+ */
36
+ export function installSample(dir) {
37
+ if (!fs.existsSync(SAMPLE)) throw new Error(`The sample folder is missing from this install (${SAMPLE}).`);
38
+ if (fs.existsSync(dir) && fs.readdirSync(dir).length) throw new Error(`${dir} already has files in it. Pick an empty folder: tekjobs init --sample <dir>`);
39
+ fs.cpSync(SAMPLE, dir, { recursive: true });
40
+ fs.mkdirSync(path.join(dir, '.tekjobs'), { recursive: true });
41
+ rememberProfileDir(dir);
42
+ const count = (d) => fs.readdirSync(d, { withFileTypes: true }).reduce((n, e) => n + (e.isDirectory() ? count(path.join(d, e.name)) : 1), 0);
43
+ return { dir, files: count(dir) };
44
+ }
45
+
31
46
  /** Copy a resume into Profile/ and write its extracted text beside it as `Resume - Source.md`. */
32
47
  export async function importResume(file, dir = VAULT) {
33
48
  if (!fs.existsSync(file)) throw Object.assign(new Error(`No such file: ${file}`), { status: 404 });
@@ -54,7 +69,7 @@ export function onboardingStatus(dir = VAULT) {
54
69
  const steps = [
55
70
  { id: 'folder', label: 'Profile folder exists', done: exists('Targets/Search Criteria.md') && exists('Targets/Companies.md'), how: 'tekjobs init [dir]' },
56
71
  { id: 'resume', label: 'Resume imported', done: exists('Profile/Resume - Source.md') || (exists('Profile') && fs.readdirSync(path.join(dir, 'Profile')).some((f) => /^Resume.*\.md$/i.test(f))), how: 'tekjobs resume <file.pdf|docx|md>' },
57
- { id: 'profile', label: 'Profile written by the interview', done: profileFilled, how: 'Run the interview: in Claude Code (or any MCP client connected to tekjobs), ask it to call onboarding_materials and interview you.' },
72
+ { id: 'profile', label: 'Profile written by the interview', done: profileFilled, how: 'Run the interview. Connect the server once (Claude Code: claude mcp add tekjobs -- tekjobs mcp; other clients: the command tekjobs mcp), then say: Use the tekjobs MCP server, call onboarding_status, then onboarding_materials, and follow its script.' },
58
73
  { id: 'criteria', label: 'Search criteria filled (title terms set)', done: criteriaFilled, how: 'The interview writes them; or edit Targets/Search Criteria.md.' },
59
74
  { id: 'scan', label: 'First scan has run', done: jobs > 0, how: 'tekjobs scan, or the Runs screen.' },
60
75
  ];
@@ -115,12 +130,13 @@ const INTERVIEW_SCRIPT = `You are onboarding a job seeker into TekJobs. Goal: wr
115
130
  3. Write Profile/Profile.md using the existing note's headings (Basics · What you are, in three sentences · Target roles table with tiers A/B · Constraints & preferences · Proof points · Documents). Proof points are one line each, with numbers. Call save_profile with the full markdown.
116
131
  4. Build the criteria from the current JSON (keep every key). Set:
117
132
  - titleTerms: exact lowercase substrings that appear in real job titles for the target roles, weighted 40 for exact-fit titles down to ~20 for adjacent ones. Include common variants ("front-end", "frontend", "front end").
118
- - titleExclude: add the user's hard exclusions as lowercase substrings.
133
+ - titleExclude: add the user's hard exclusions as lowercase substrings (titles and words in titles; abbreviations like "sr." and "dir." are spelled out before matching).
134
+ - companyExclude: industries, company types and names the user will not work for, as lowercase substrings of the company name ("casino", "gambling", "staffing", "defense"). Title words do not catch these.
119
135
  - descTerms: 15 to 30 stack/domain words with weights 2 to 6.
120
136
  - salary.minAnnual and salary.stretchAnnual as integers (or null if the user declines).
121
137
  - location.requireRemote true only if the user said remote only; add their metro to bayAreaTerms with bayAreaBoost 12 if hybrid there is acceptable.
122
138
  Call set_criteria with the full JSON string. It is validated before writing.
123
- 5. Call run_scan with dry=true and poll scan_status every few seconds until running is false (two to three minutes; the scan runs on its own, so if this session ends first, the next one can pick up with scan_status). A dry run writes no notes, so do not use search_jobs yet: call scan_preview limit=15 and show the user the top matches with score, pay band and one reason each. Ask whether the list looks right. Adjust the criteria once if it does not (set_criteria, then another dry run_scan and scan_preview). When it does, call run_scan with dry=false and poll scan_status until it finishes; that run writes the notes, and search_jobs and the app's Today page work from then on.
139
+ 5. Call run_scan with dry=true and poll scan_status every few seconds until running is false (two to three minutes; the scan runs on its own, so if this session ends first, the next one can pick up with scan_status). If you cannot poll between turns, pass wait=240 to run_scan and it answers when the scan is done. A dry run writes no notes, so do not use search_jobs yet: call scan_preview limit=15 and show the user the top matches with score, pay band and one reason each. Ask whether the list looks right. Adjust the criteria once if it does not (set_criteria, then another dry run_scan and scan_preview). When it does, call run_scan with dry=false and poll scan_status until it finishes; that run writes the notes, and search_jobs and the app's Today page work from then on.
124
140
  6. Finish by telling the user where things live: Profile/Profile.md, Targets/Search Criteria.md, Jobs/. Remind them the daily scan runs on its own from here.`;
125
141
 
126
142
  function criteriaNote(json) {
@@ -134,7 +150,8 @@ updated: ${today()}
134
150
 
135
151
  How scoring works:
136
152
  - **titleTerms** — best single match in the job title counts fully, each extra match adds 5. Empty until the interview runs.
137
- - **titleExclude** — any hit in the title drops the job entirely.
153
+ - **titleExclude** — any hit in the title drops the job entirely. Abbreviations are spelled out first, so "director" catches "Sr. Dir".
154
+ - **companyExclude** — any hit in the company name drops the job entirely: industries, staffing agencies, names.
138
155
  - **noTitleMatchPenalty** — a posting whose title matches nothing in \`titleTerms\` takes this hit, so location and recency alone can't carry it over the bar.
139
156
  - **descTerms** — each term found in the description adds its weight (capped at \`descCap\`).
140
157
  - **seniority** — senior/staff/lead/principal adds; junior/intern subtracts.
@@ -17,7 +17,7 @@ import fs from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import crypto from 'node:crypto';
19
19
  import { P, loadCriteria } from './config.mjs';
20
- import { scoreJob } from './score.mjs';
20
+ import { scoreJob, normalizeTitle } from './score.mjs';
21
21
 
22
22
  /**
23
23
  * A short fingerprint of every criteria key the scorer reads.
@@ -48,7 +48,7 @@ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
48
48
  /** Title points under one set of weights. `extraCap` null means the old, uncapped behaviour. */
49
49
  export function titlePoints(title = '', c = {}, extraCap) {
50
50
  const hits = Object.entries(c.titleTerms || {})
51
- .filter(([t]) => title.toLowerCase().includes(t.toLowerCase()))
51
+ .filter(([t]) => normalizeTitle(title).includes(t.toLowerCase()))
52
52
  .sort((a, b) => b[1] - a[1]);
53
53
  if (!hits.length) return c.noTitleMatchPenalty ?? -40;
54
54
  const per = c.titleExtraPer ?? 5;
package/scraper/score.mjs CHANGED
@@ -16,13 +16,28 @@ export function parseSalary(text = '') {
16
16
  }
17
17
  const wordHit = (hay, term) => new RegExp(`(^|[^a-z0-9])${esc(term)}([^a-z0-9]|$)`, 'i').test(hay);
18
18
 
19
+ /**
20
+ * The abbreviations postings use in titles, spelled out before anything matches against them, so "Sr. Dir, Design"
21
+ * meets the "director" exclusion and the "senior" boost the way "Senior Director, Design" does. Whole words only.
22
+ */
23
+ const ABBREVIATIONS = [
24
+ [/\bsr\b\.?/g, 'senior'], [/\bjr\b\.?/g, 'junior'], [/\bdir\b\.?/g, 'director'], [/\bmgr\b\.?/g, 'manager'],
25
+ [/\beng\b\.?/g, 'engineer'], [/\bassoc\b\.?/g, 'associate'], [/\bprin\b\.?/g, 'principal'], [/\bswe\b/g, 'software engineer'],
26
+ ];
27
+ export function normalizeTitle(title) {
28
+ let t = String(title || '').toLowerCase();
29
+ for (const [re, word] of ABBREVIATIONS) t = t.replace(re, word);
30
+ return t;
31
+ }
32
+
19
33
  /**
20
34
  * Returns { score, reasons[], excluded } for one normalized job against the criteria JSON.
21
35
  * `now` is the moment recency is judged from: the scan passes nothing (today); a rescore of an existing note
22
36
  * passes the day the note was found, so the posting keeps the freshness it had when it was scored.
23
37
  */
24
38
  export function scoreJob(job, c, { now = Date.now() } = {}) {
25
- const title = (job.title || '').toLowerCase();
39
+ const title = normalizeTitle(job.title);
40
+ const company = (job.company || '').toLowerCase();
26
41
  const desc = (job.descriptionText || '').toLowerCase();
27
42
  const loc = (job.location || '').toLowerCase();
28
43
  const reasons = [];
@@ -31,6 +46,10 @@ export function scoreJob(job, c, { now = Date.now() } = {}) {
31
46
  for (const ex of c.titleExclude || []) {
32
47
  if (title.includes(ex.toLowerCase())) return { score: -999, reasons: [`excluded by title: "${ex}"`], excluded: true };
33
48
  }
49
+ // Company exclusions: industries, staffing agencies, names. A hit anywhere in the company name drops the posting.
50
+ for (const ex of c.companyExclude || []) {
51
+ if (ex && company.includes(String(ex).toLowerCase())) return { score: -999, reasons: [`excluded by company: "${ex}"`], excluded: true };
52
+ }
34
53
 
35
54
  const titleHits = Object.entries(c.titleTerms || {})
36
55
  .filter(([t]) => title.includes(t.toLowerCase()))
@@ -2,6 +2,7 @@
2
2
  // { id, source, company, title, url, location, remote, posted, descriptionHtml, salary, department, employmentType }
3
3
 
4
4
  import { UA } from './config.mjs';
5
+ import { normalizeTitle } from './score.mjs';
5
6
 
6
7
  async function getJSON(url, { timeoutMs = 25000 } = {}) {
7
8
  const ctrl = new AbortController();
@@ -271,7 +272,7 @@ export async function fetchWorkday(company, slug, titleFilter = () => true, { ma
271
272
  export function makeTitleFilter(criteria) {
272
273
  const terms = Object.keys(criteria?.titleTerms || {}).map((t) => t.toLowerCase());
273
274
  const excl = (criteria?.titleExclude || []).map((t) => t.toLowerCase());
274
- return (title) => { const t = (title || '').toLowerCase(); return terms.some((k) => t.includes(k)) && !excl.some((k) => t.includes(k)); };
275
+ return (title) => { const t = normalizeTitle(title); return terms.some((k) => t.includes(k)) && !excl.some((k) => t.includes(k)); };
275
276
  }
276
277
 
277
278
  export async function fetchCompany(c, criteria) {
@@ -9,6 +9,7 @@
9
9
  "intern", "internship", "apprentice", "new grad", "recruit", "account executive", "customer success",
10
10
  "manufacturing", "process engineer", "chemical", "biomedical", "automotive", "aerospace"
11
11
  ],
12
+ "companyExclude": [],
12
13
  "descTerms": {},
13
14
  "seniority": {
14
15
  "boost": { "senior": 8, "staff": 10, "lead": 10, "principal": 10, "head of": 8, "founding": 8 },