@pranavraut033/ats-checker 1.3.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![Tests](https://github.com/Pranavraut033/ats-checker/actions/workflows/ci.yml/badge.svg)](https://github.com/Pranavraut033/ats-checker/actions/workflows/ci.yml)
7
7
  [![Build Status](https://github.com/Pranavraut033/ats-checker/actions/workflows/deploy.yml/badge.svg)](https://github.com/Pranavraut033/ats-checker/actions/workflows/deploy.yml)
8
8
 
9
- Zero-dependency TypeScript library that scores a resume against a job description and explains why — skills coverage, keyword overlap, experience match, and education — with no randomness, no LLM, and no external calls.
9
+ Zero-dependency TypeScript library that scores a resume against a job description and explains why — skills coverage, keyword overlap, experience match, parseability, and education — with no randomness, no LLM, and no external calls.
10
10
 
11
11
  **[Live Demo →](https://pranavraut033.github.io/ats-checker/)**
12
12
  **[Docs →](https://pranavraut033.github.io/ats-checker/docs/)**
@@ -16,15 +16,21 @@ Zero-dependency TypeScript library that scores a resume against a job descriptio
16
16
  ## Features
17
17
 
18
18
  - **Deterministic** — same input always produces the same score; pin it with `referenceDate` to freeze "Present" date math
19
- - **Explainable** — breakdown by category (skills / experience / keywords / education) plus matched and missing skill/keyword lists
20
- - **Categorized keywords** — every keyword/alias belongs to a category (technical, tool, concept, soft, marketing, domain); results are grouped by category
21
- - **Weighted keyword scoring** — JD keywords are weighted by where they appear (required > preferred > body) and how often, so a missing "required" keyword costs more than a missing body-only one
19
+ - **Explainable** — breakdown by category (skills / experience / keywords / parseability / education) plus matched and missing skill/keyword lists
20
+ - **Parseability scoring** — a real-ATS-realism dimension that deducts for table/columnar formatting, multi-column layout, special/control characters, non-standard bullets, likely-scanned text, and an unparseable contact email — with the specific deductions itemized in `result.parseabilityReport`
21
+ - **Fuzzy/stem matching by default** — typos and word-form variants ("ReactJS" vs "react", "developing" vs "develop") still match; opt out via `config.matching = { fuzzy: false }` for exact-match-only behavior
22
+ - **Whole-document skill extraction** — skills mentioned in experience bullets and the summary count, not just an explicit Skills section, and each experience entry is dated per-role
23
+ - **Seniority & employment-gap awareness** — infers resume/JD seniority (`junior`–`principal`) and surfaces a capped match signal (`result.seniorityMatch`), and flags employment gaps of 3+ months between roles (`result.employmentGaps`)
24
+ - **Categorized keywords** — every keyword/alias belongs to a category (technical, tool, concept, soft, marketing, domain); results are grouped by category; 407 canonical terms ship by default
25
+ - **Weighted keyword scoring** — JD keywords are weighted by where they appear (required > preferred > body, including header-scoped `Requirements:`/`Preferred:` blocks) and how often, so a missing "required" keyword costs more than a missing body-only one
22
26
  - **Alias-aware suggestions** — flags resume terms that should be reworded to match the JD's own wording (e.g. "js" → "JavaScript")
23
27
  - **Achievement strength** — classifies resume experience bullets as strong/weak (verb + quantified impact) and suggests rewrites
24
28
  - **Multi-language keyword packs** — `/en` and `/de` subpaths ship categorized keyword registries; install more by passing your own `keywordRegistry`
25
29
  - **Language proficiency matching** — detects spoken-language requirements in the JD (CEFR `A1`–`C2` or words like "fluent"/"native") and flags resume gaps below the required level
30
+ - **Skill-experience gaps** — parses "N+ years of X" requirements from the JD and flags resume skills whose per-role dated experience (`result.perSkillExperience`) falls short
31
+ - **Contact detection** — parses email/phone/LinkedIn/location and penalizes an unparseable contact email by default (near-knockout, like a real ATS)
26
32
  - **Configurable** — adjust weights, add skill aliases or a custom keyword registry, define custom penalty rules
27
- - **Zero dependencies** — core library has no runtime deps; ships ESM + CJS
33
+ - **Zero dependencies** — core library has no runtime deps (fuzzy/stem matching is hand-rolled); ships ESM + CJS
28
34
  - **PDF input** — optional `/pdf` subpath extracts resume text from a PDF buffer (requires `pdfjs-dist` peer dep)
29
35
  - **Built-in profiles** — software engineer, data scientist, product manager out of the box
30
36
 
@@ -66,11 +72,11 @@ const result = analyzeResume({
66
72
  config: { referenceDate: "2026-01-01" }, // freeze clock for reproducible scores
67
73
  });
68
74
 
69
- console.log(result.score); // 44.44
70
- console.log(result.matchedSkills); // ["javascript", "node", "react", "typescript"]
71
- console.log(result.missingSkills); // ["accessibility", "frontend", "graphql"]
72
- console.log(result.experienceGap); // 0 (requirement met)
73
- console.log(result.suggestions); // ["Highlight these required skills: accessibility, frontend, graphql", ...]
75
+ console.log(result.score); // 39.44
76
+ console.log(result.matchedSkills); // ["javascript", "node", "react", "typescript"]
77
+ console.log(result.missingSkills); // ["accessibility", "frontend", "graphql"]
78
+ console.log(result.experienceGap); // 0 (requirement met)
79
+ console.log(result.suggestions); // ["Highlight these required skills: accessibility, frontend, graphql", ...]
74
80
  ```
75
81
 
76
82
  ---
@@ -79,33 +85,42 @@ console.log(result.suggestions); // ["Highlight these required skills: acce
79
85
 
80
86
  `analyzeResume()` returns an `ATSAnalysisResult`:
81
87
 
82
- | Field | Type | Description |
83
- |---|---|---|
84
- | `score` | `number` | Overall ATS score 0–100 after rule penalties |
85
- | `breakdown` | `ATSBreakdown` | Sub-scores: `skills`, `experience`, `keywords`, `education` |
86
- | `matchedSkills` | `string[]` | Required skills found in the resume |
87
- | `missingSkills` | `string[]` | Required skills absent from the resume |
88
- | `matchedKeywords` | `string[]` | JD keywords present in the resume (sorted) |
89
- | `missingKeywords` | `string[]` | JD keywords absent from the resume (sorted) |
90
- | `overusedKeywords` | `string[]` | Keywords exceeding density threshold (sorted) |
91
- | `keywordsByCategory` | `Record<KeywordCategory, {matched, missing}>` | Matched/missing keywords grouped by category |
92
- | `keywordWeights` | `KeywordWeight[]` | Per-keyword JD importance (`jdWeight`) and resume usage (`resumeWeight`) |
93
- | `achievementStrength` | `{ strong: number; weak: number }` | Count of resume bullets classified as strong vs weak achievement statements |
94
- | `matchedLanguages` | `ParsedLanguage[]` | JD-required languages the resume meets or exceeds in proficiency |
95
- | `missingLanguages` | `ParsedLanguage[]` | JD-required languages absent or below the required proficiency |
96
- | `suggestions` | `string[]` | Deterministic improvement recommendations |
97
- | `warnings` | `string[]` | Parse warnings and section alerts |
98
- | `experienceGap` | `number` | Years below JD minimum; `0` when met |
99
- | `detectedSections` | `string[]` | Resume sections the parser found |
100
- | `parsedExperienceYears` | `number` | Total years from resume date ranges (overlap-deduplicated) |
101
- | `experienceEntries` | `ParsedExperienceEntry[]` | Parsed job entries: `title`, `company`, `dates` (with `start`/`end`/`durationInMonths`) |
88
+ | Field | Type | Description |
89
+ | ----------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
90
+ | `score` | `number` | Overall ATS score 0–100 after rule penalties |
91
+ | `breakdown` | `ATSBreakdown` | Sub-scores: `skills`, `experience`, `keywords`, `parseability`, `education` |
92
+ | `parseabilityReport` | `ParseabilityReport` | Itemized `{ reason, points }` deductions behind `breakdown.parseability`, plus the underlying formatting signals |
93
+ | `matchedSkills` | `string[]` | Required skills found in the resume |
94
+ | `missingSkills` | `string[]` | Required skills absent from the resume |
95
+ | `matchedKeywords` | `string[]` | JD keywords present in the resume (sorted) |
96
+ | `missingKeywords` | `string[]` | JD keywords absent from the resume (sorted) |
97
+ | `overusedKeywords` | `string[]` | Keywords exceeding density threshold (sorted) |
98
+ | `keywordsByCategory` | `Record<KeywordCategory, {matched, missing}>` | Matched/missing keywords grouped by category |
99
+ | `keywordWeights` | `KeywordWeight[]` | Per-keyword JD importance (`jdWeight`) and resume usage (`resumeWeight`) |
100
+ | `achievementStrength` | `{ strong: number; weak: number }` | Count of resume bullets classified as strong vs weak achievement statements |
101
+ | `matchedLanguages` | `ParsedLanguage[]` | JD-required languages the resume meets or exceeds in proficiency |
102
+ | `missingLanguages` | `ParsedLanguage[]` | JD-required languages absent or below the required proficiency |
103
+ | `seniorityMatch` | `{ resume?, required?, met }` | Resume vs JD inferred seniority (`junior`–`principal`); `met` is `true` whenever either side is unknown |
104
+ | `employmentGaps` | `{ afterRole, months }[]` | Gaps of 3+ months between consecutive dated roles — informational |
105
+ | `perSkillExperience` | `{ skill, years }[]` | Per-skill years of experience derived from per-role dating (which roles actually mention the skill) |
106
+ | `skillExperienceGaps` | `{ skill, requiredYears, resumeYears }[]` | JD skills the resume has but whose per-role dated experience falls short of a JD "N+ years of X" requirement — informational, doesn't affect `score` |
107
+ | `suggestions` | `string[]` | Deterministic improvement recommendations |
108
+ | `warnings` | `string[]` | Parse warnings and section alerts |
109
+ | `experienceGap` | `number` | Years below JD minimum; `0` when met |
110
+ | `detectedSections` | `string[]` | Resume sections the parser found |
111
+ | `parsedExperienceYears` | `number` | Total years from resume date ranges (overlap-deduplicated) |
112
+ | `experienceEntries` | `ParsedExperienceEntry[]` | Parsed job entries: `title`, `company`, `dates` (with `start`/`end`/`durationInMonths`), `skills` (per-role skill mentions) |
102
113
 
103
114
  **Scoring formula:**
104
- `score = skills×0.30 + experience×0.30 + keywords×0.25 + education×0.15` → clamped to 0–100 → rule penalties subtracted.
115
+ `score = skills×0.25 + experience×0.20 + keywords×0.25 + parseability×0.20 + education×0.10` → clamped to 0–100 → rule penalties subtracted.
116
+
117
+ The `parseability` sub-score starts at 100 and deducts for formatting/structure signals that trip up real ATS parsers — tables/columns, multi-column layout, special/control characters, non-standard bullets, likely-scanned text, an unparseable contact email, and too few detected sections. This is the single biggest real-world ATS rejection cause the v1 model didn't score at all.
118
+
119
+ Skill and keyword matching use fuzzy/stemmed comparison by default (`config.matching = { fuzzy: true }`), so typos and word-form variants ("ReactJS" vs "react", "developing" vs "develop") still match. Pass `{ fuzzy: false }` to require exact matches only.
105
120
 
106
121
  The `keywords` sub-score is a **weighted** coverage ratio, not a flat count: each JD keyword gets a weight from its location (required > preferred > body text) and frequency, so missing a required keyword drops the score more than missing one mentioned once in the body.
107
122
 
108
- > **Caveat — malformed/copy-pasted JD text:** required/preferred detection scans each line for literal trigger phrases (`required`, `must`, `nice to have`, `preferred`). Job postings copy-pasted from a wrapped/columned source sometimes split words across line breaks (e.g. `"Nice to\n\nhaveExperience..."`), which breaks these phrases across two lines and silently drops them into the unweighted body-keyword bucket instead of required/preferred. Skill keywords themselves (e.g. `react`, `python/fastapi`) are still picked up via the whole-text token scan and unaffected. If a JD looks oddly broken, paste it through a plain-text cleanup pass first, or expect required/preferred weighting to under-count.
123
+ > **Caveat — malformed/copy-pasted JD text:** required/preferred detection combines two heuristics: header-scoped blocks (a line that's just "Requirements:"/"Preferred:"/"Must have:"/"Nice to have:" scopes the bulleted lines that follow it) and line-local trigger phrases (`required`, `must`, `nice to have`, `preferred`) as a fallback. Job postings copy-pasted from a wrapped/columned source can still split a header or trigger phrase across line breaks (e.g. `"Nice to\n\nhave..."`), which drops that block into the unweighted body-keyword bucket instead of required/preferred. Skill keywords themselves (e.g. `react`, `python/fastapi`) are still picked up via the whole-text token scan and unaffected. If a JD looks oddly broken, paste it through a plain-text cleanup pass first, or expect required/preferred weighting to under-count.
109
124
 
110
125
  The `education` sub-score normalizes degree abbreviations on both sides to a canonical level (`bachelor`, `master`, `phd`, `mba`, `associate`) before comparing — so a resume listing "B.S. Computer Science" satisfies a JD requiring "Bachelor's degree".
111
126
 
@@ -121,13 +136,24 @@ const result = analyzeResume({
121
136
  jobDescription: "...",
122
137
  config: {
123
138
  // Override scoring weights (auto-normalized to sum to 1)
124
- weights: { skills: 0.4, experience: 0.3, keywords: 0.2, education: 0.1 },
139
+ weights: {
140
+ skills: 0.3,
141
+ experience: 0.2,
142
+ keywords: 0.2,
143
+ parseability: 0.2,
144
+ education: 0.1,
145
+ },
146
+
147
+ // Fuzzy/stem matching is on by default; disable for exact-match-only (v1 behavior)
148
+ matching: { fuzzy: false },
125
149
 
126
150
  // Additional skill synonyms merged over built-in defaults
127
151
  skillAliases: { javascript: ["js", "ecmascript"] },
128
152
 
129
153
  // Categorized keyword/alias entries; merges over the default registry by canonical term
130
- keywordRegistry: [{ canonical: "rust", aliases: ["rustlang"], category: "technical" }],
154
+ keywordRegistry: [
155
+ { canonical: "rust", aliases: ["rustlang"], category: "technical" },
156
+ ],
131
157
 
132
158
  // Industry profile: sets mandatory/optional skills and minExperience
133
159
  profile: {
@@ -163,17 +189,20 @@ const result = analyzeResume({
163
189
 
164
190
  ### Defaults
165
191
 
166
- | Setting | Default |
167
- |---|---|
168
- | `weights.skills` | `0.30` |
169
- | `weights.experience` | `0.30` |
170
- | `weights.keywords` | `0.25` |
171
- | `weights.education` | `0.15` |
172
- | `keywordDensity.min` | `0.0025` |
173
- | `keywordDensity.max` | `0.04` |
174
- | `keywordDensity.overusePenalty` | `5` |
175
- | `allowPartialMatches` | `true` |
176
- | `referenceDate` | Current date (use explicit ISO string for determinism) |
192
+ | Setting | Default |
193
+ | --------------------------------- | ------------------------------------------------------------------------------ |
194
+ | `weights.skills` | `0.25` |
195
+ | `weights.experience` | `0.20` |
196
+ | `weights.keywords` | `0.25` |
197
+ | `weights.parseability` | `0.20` |
198
+ | `weights.education` | `0.10` |
199
+ | `matching.fuzzy` | `true` (stemmed/fuzzy skill & keyword matching) |
200
+ | `keywordDensity.min` | `0.0025` |
201
+ | `keywordDensity.max` | `0.04` |
202
+ | `keywordDensity.overusePenalty` | `5` |
203
+ | `allowPartialMatches` | `true` |
204
+ | `referenceDate` | Current date (use explicit ISO string for determinism) |
205
+ | `sectionPenalties.missingContact` | `12` (no parseable email detected — a real ATS treats this as a near-knockout) |
177
206
 
178
207
  See [Configuration docs](https://pranavraut033.github.io/ats-checker/docs/configuration/) for all options.
179
208
 
@@ -181,10 +210,13 @@ See [Configuration docs](https://pranavraut033.github.io/ats-checker/docs/config
181
210
 
182
211
  ## Keyword Registry, Categories & Aliases
183
212
 
184
- Every built-in keyword/skill belongs to a `KeywordRegistry` entry — a canonical term, its aliases, and a category (`technical` | `tool` | `concept` | `soft` | `marketing` | `domain`). Common tech synonyms are pre-loaded so `js` matches `javascript`, `k8s` matches `kubernetes`, etc.
213
+ Every built-in keyword/skill belongs to a `KeywordRegistry` entry — a canonical term, its aliases, and a category (`technical` | `tool` | `concept` | `soft` | `marketing` | `domain`). The default registry ships **407 canonical terms** across cloud/infra, databases, frameworks, ML/AI, testing, security/compliance, soft skills, marketing, and PM/agile vocabulary, with common surface-form aliases pre-loaded so `js` matches `javascript`, `k8s` matches `kubernetes`, `reactjs`/`react.js` match `react`, etc. — and typos/word-form variants match too via fuzzy/stem matching (on by default, see [Configuration](#configuration)).
185
214
 
186
215
  ```typescript
187
- import { defaultKeywordRegistry, defaultSkillAliases } from "@pranavraut033/ats-checker";
216
+ import {
217
+ defaultKeywordRegistry,
218
+ defaultSkillAliases,
219
+ } from "@pranavraut033/ats-checker";
188
220
  // defaultKeywordRegistry: [{ canonical: "javascript", aliases: ["js"], category: "technical" }, ...]
189
221
  // defaultSkillAliases: { javascript: ["js"], node: ["node.js", "nodejs"], ... } (derived, back-compat)
190
222
  ```
@@ -198,13 +230,17 @@ const result = analyzeResume({
198
230
  config: {
199
231
  keywordRegistry: [
200
232
  { canonical: "rust", aliases: ["rustlang"], category: "technical" },
201
- { canonical: "javascript", aliases: ["js", "ecmascript"], category: "technical" }, // overrides default
233
+ {
234
+ canonical: "javascript",
235
+ aliases: ["js", "ecmascript"],
236
+ category: "technical",
237
+ }, // overrides default
202
238
  ],
203
239
  },
204
240
  });
205
241
 
206
242
  console.log(result.keywordsByCategory.technical); // { matched: [...], missing: [...] }
207
- console.log(result.keywordWeights); // [{ term, category, jdWeight, resumeWeight, importance }, ...]
243
+ console.log(result.keywordWeights); // [{ term, category, jdWeight, resumeWeight, importance }, ...]
208
244
  ```
209
245
 
210
246
  You can still pass `config.skillAliases` for a flat override — it merges on top of the registry-derived aliases.
@@ -304,7 +340,9 @@ const resumeText = await extractTextFromPDF(bytes, {
304
340
  ocrFallback: async (data) => {
305
341
  // bring your own OCR engine, e.g. tesseract.js or a cloud OCR API
306
342
  const { recognize } = await import("tesseract.js");
307
- const { data: { text } } = await recognize(data, "eng");
343
+ const {
344
+ data: { text },
345
+ } = await recognize(data, "eng");
308
346
  return text;
309
347
  },
310
348
  });