@pranavraut033/ats-checker 1.4.0 → 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,17 +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
26
- - **Skill-experience gaps** — parses "N+ years of X" requirements from the JD and flags resume skills that don't have enough overall experience behind them
27
- - **Contact detection** — flags resumes with no parseable email address (warning-only by default, configurable to a scoring penalty)
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)
28
32
  - **Configurable** — adjust weights, add skill aliases or a custom keyword registry, define custom penalty rules
29
- - **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
30
34
  - **PDF input** — optional `/pdf` subpath extracts resume text from a PDF buffer (requires `pdfjs-dist` peer dep)
31
35
  - **Built-in profiles** — software engineer, data scientist, product manager out of the box
32
36
 
@@ -68,11 +72,11 @@ const result = analyzeResume({
68
72
  config: { referenceDate: "2026-01-01" }, // freeze clock for reproducible scores
69
73
  });
70
74
 
71
- console.log(result.score); // 44.44
72
- console.log(result.matchedSkills); // ["javascript", "node", "react", "typescript"]
73
- console.log(result.missingSkills); // ["accessibility", "frontend", "graphql"]
74
- console.log(result.experienceGap); // 0 (requirement met)
75
- 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", ...]
76
80
  ```
77
81
 
78
82
  ---
@@ -81,34 +85,42 @@ console.log(result.suggestions); // ["Highlight these required skills: acce
81
85
 
82
86
  `analyzeResume()` returns an `ATSAnalysisResult`:
83
87
 
84
- | Field | Type | Description |
85
- |---|---|---|
86
- | `score` | `number` | Overall ATS score 0–100 after rule penalties |
87
- | `breakdown` | `ATSBreakdown` | Sub-scores: `skills`, `experience`, `keywords`, `education` |
88
- | `matchedSkills` | `string[]` | Required skills found in the resume |
89
- | `missingSkills` | `string[]` | Required skills absent from the resume |
90
- | `matchedKeywords` | `string[]` | JD keywords present in the resume (sorted) |
91
- | `missingKeywords` | `string[]` | JD keywords absent from the resume (sorted) |
92
- | `overusedKeywords` | `string[]` | Keywords exceeding density threshold (sorted) |
93
- | `keywordsByCategory` | `Record<KeywordCategory, {matched, missing}>` | Matched/missing keywords grouped by category |
94
- | `keywordWeights` | `KeywordWeight[]` | Per-keyword JD importance (`jdWeight`) and resume usage (`resumeWeight`) |
95
- | `achievementStrength` | `{ strong: number; weak: number }` | Count of resume bullets classified as strong vs weak achievement statements |
96
- | `matchedLanguages` | `ParsedLanguage[]` | JD-required languages the resume meets or exceeds in proficiency |
97
- | `missingLanguages` | `ParsedLanguage[]` | JD-required languages absent or below the required proficiency |
98
- | `skillExperienceGaps` | `{ skill, requiredYears, resumeYears }[]` | JD skills the resume has but whose overall experience falls short of a JD "N+ years of X" requirement — informational, doesn't affect `score` |
99
- | `suggestions` | `string[]` | Deterministic improvement recommendations |
100
- | `warnings` | `string[]` | Parse warnings and section alerts |
101
- | `experienceGap` | `number` | Years below JD minimum; `0` when met |
102
- | `detectedSections` | `string[]` | Resume sections the parser found |
103
- | `parsedExperienceYears` | `number` | Total years from resume date ranges (overlap-deduplicated) |
104
- | `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) |
105
113
 
106
114
  **Scoring formula:**
107
- `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.
108
120
 
109
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.
110
122
 
111
- > **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.
112
124
 
113
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".
114
126
 
@@ -124,13 +136,24 @@ const result = analyzeResume({
124
136
  jobDescription: "...",
125
137
  config: {
126
138
  // Override scoring weights (auto-normalized to sum to 1)
127
- 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 },
128
149
 
129
150
  // Additional skill synonyms merged over built-in defaults
130
151
  skillAliases: { javascript: ["js", "ecmascript"] },
131
152
 
132
153
  // Categorized keyword/alias entries; merges over the default registry by canonical term
133
- keywordRegistry: [{ canonical: "rust", aliases: ["rustlang"], category: "technical" }],
154
+ keywordRegistry: [
155
+ { canonical: "rust", aliases: ["rustlang"], category: "technical" },
156
+ ],
134
157
 
135
158
  // Industry profile: sets mandatory/optional skills and minExperience
136
159
  profile: {
@@ -166,18 +189,20 @@ const result = analyzeResume({
166
189
 
167
190
  ### Defaults
168
191
 
169
- | Setting | Default |
170
- |---|---|
171
- | `weights.skills` | `0.30` |
172
- | `weights.experience` | `0.30` |
173
- | `weights.keywords` | `0.25` |
174
- | `weights.education` | `0.15` |
175
- | `keywordDensity.min` | `0.0025` |
176
- | `keywordDensity.max` | `0.04` |
177
- | `keywordDensity.overusePenalty` | `5` |
178
- | `allowPartialMatches` | `true` |
179
- | `referenceDate` | Current date (use explicit ISO string for determinism) |
180
- | `sectionPenalties.missingContact` | `0` (warning-only; no parseable email detected) |
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) |
181
206
 
182
207
  See [Configuration docs](https://pranavraut033.github.io/ats-checker/docs/configuration/) for all options.
183
208
 
@@ -185,10 +210,13 @@ See [Configuration docs](https://pranavraut033.github.io/ats-checker/docs/config
185
210
 
186
211
  ## Keyword Registry, Categories & Aliases
187
212
 
188
- 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)).
189
214
 
190
215
  ```typescript
191
- import { defaultKeywordRegistry, defaultSkillAliases } from "@pranavraut033/ats-checker";
216
+ import {
217
+ defaultKeywordRegistry,
218
+ defaultSkillAliases,
219
+ } from "@pranavraut033/ats-checker";
192
220
  // defaultKeywordRegistry: [{ canonical: "javascript", aliases: ["js"], category: "technical" }, ...]
193
221
  // defaultSkillAliases: { javascript: ["js"], node: ["node.js", "nodejs"], ... } (derived, back-compat)
194
222
  ```
@@ -202,13 +230,17 @@ const result = analyzeResume({
202
230
  config: {
203
231
  keywordRegistry: [
204
232
  { canonical: "rust", aliases: ["rustlang"], category: "technical" },
205
- { canonical: "javascript", aliases: ["js", "ecmascript"], category: "technical" }, // overrides default
233
+ {
234
+ canonical: "javascript",
235
+ aliases: ["js", "ecmascript"],
236
+ category: "technical",
237
+ }, // overrides default
206
238
  ],
207
239
  },
208
240
  });
209
241
 
210
242
  console.log(result.keywordsByCategory.technical); // { matched: [...], missing: [...] }
211
- console.log(result.keywordWeights); // [{ term, category, jdWeight, resumeWeight, importance }, ...]
243
+ console.log(result.keywordWeights); // [{ term, category, jdWeight, resumeWeight, importance }, ...]
212
244
  ```
213
245
 
214
246
  You can still pass `config.skillAliases` for a flat override — it merges on top of the registry-derived aliases.
@@ -308,7 +340,9 @@ const resumeText = await extractTextFromPDF(bytes, {
308
340
  ocrFallback: async (data) => {
309
341
  // bring your own OCR engine, e.g. tesseract.js or a cloud OCR API
310
342
  const { recognize } = await import("tesseract.js");
311
- const { data: { text } } = await recognize(data, "eng");
343
+ const {
344
+ data: { text },
345
+ } = await recognize(data, "eng");
312
346
  return text;
313
347
  },
314
348
  });