@pranavraut033/ats-checker 1.1.0 → 1.2.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/dist/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/utils/text.ts
2
2
  var STOP_WORDS = /* @__PURE__ */ new Set([
3
+ // articles / prepositions / conjunctions
3
4
  "the",
4
5
  "and",
5
6
  "or",
@@ -15,12 +16,16 @@ var STOP_WORDS = /* @__PURE__ */ new Set([
15
16
  "by",
16
17
  "from",
17
18
  "as",
19
+ "into",
20
+ "onto",
21
+ "upon",
22
+ "via",
23
+ "per",
24
+ "plus",
25
+ // verbs / modals
18
26
  "is",
19
27
  "are",
20
28
  "be",
21
- "this",
22
- "that",
23
- "it",
24
29
  "was",
25
30
  "were",
26
31
  "will",
@@ -29,7 +34,98 @@ var STOP_WORDS = /* @__PURE__ */ new Set([
29
34
  "must",
30
35
  "have",
31
36
  "has",
32
- "had"
37
+ "had",
38
+ "do",
39
+ "does",
40
+ "did",
41
+ "get",
42
+ "give",
43
+ "go",
44
+ "use",
45
+ "see",
46
+ "help",
47
+ "work",
48
+ "build",
49
+ "show",
50
+ "need",
51
+ "want",
52
+ "make",
53
+ "let",
54
+ // pronouns / determiners
55
+ "it",
56
+ "its",
57
+ "this",
58
+ "that",
59
+ "these",
60
+ "those",
61
+ "we",
62
+ "our",
63
+ "you",
64
+ "your",
65
+ "they",
66
+ "their",
67
+ "us",
68
+ "who",
69
+ "what",
70
+ "which",
71
+ "how",
72
+ // common English fillers that leak into JDs
73
+ "no",
74
+ "not",
75
+ "all",
76
+ "any",
77
+ "also",
78
+ "more",
79
+ "well",
80
+ "very",
81
+ "highly",
82
+ "across",
83
+ "over",
84
+ "under",
85
+ "within",
86
+ "about",
87
+ "out",
88
+ "up",
89
+ "down",
90
+ "new",
91
+ "if",
92
+ "so",
93
+ "such",
94
+ "both",
95
+ "each",
96
+ "one",
97
+ "many",
98
+ "only",
99
+ // JD/HR boilerplate — never skills
100
+ "years",
101
+ "year",
102
+ "experience",
103
+ "required",
104
+ "requirement",
105
+ "requirements",
106
+ "preferred",
107
+ "role",
108
+ "degree",
109
+ "practices",
110
+ "best",
111
+ "skills",
112
+ "team",
113
+ "field",
114
+ "related",
115
+ "relevant",
116
+ "desired",
117
+ "strong",
118
+ "solid",
119
+ "good",
120
+ "first",
121
+ "based",
122
+ "day",
123
+ "week",
124
+ "month",
125
+ "time",
126
+ "fast",
127
+ "open",
128
+ "dynamic"
33
129
  ]);
34
130
  function normalizeWhitespace(text) {
35
131
  return text.replace(/\r\n?/g, "\n").replace(/\s+/g, " ").trim();
@@ -43,7 +139,12 @@ function splitLines(text) {
43
139
  var TECH_TOKEN_RE = /[a-z0-9][a-z0-9.#+\-/]*[a-z0-9#+]/g;
44
140
  function tokenize(text) {
45
141
  const normalized = normalizeForComparison(text);
46
- return (normalized.match(TECH_TOKEN_RE) ?? []).filter((t) => !STOP_WORDS.has(t));
142
+ return (normalized.match(TECH_TOKEN_RE) ?? []).filter(
143
+ (t) => /[a-z]/.test(t) && !STOP_WORDS.has(t)
144
+ );
145
+ }
146
+ function escapeRegExp(input) {
147
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47
148
  }
48
149
  function unique(values) {
49
150
  const seen = /* @__PURE__ */ new Set();
@@ -99,37 +200,30 @@ function normalizeSkills(skills, aliases) {
99
200
  }
100
201
 
101
202
  // src/core/parser/jd.parser.ts
102
- var DEGREE_KEYWORDS = [
103
- "bachelor",
104
- "b.s",
105
- "bs",
106
- "bsc",
107
- "master",
108
- "m.s",
109
- "ms",
110
- "msc",
111
- "phd",
112
- "doctorate",
113
- "mba",
114
- "associate"
203
+ var DEGREE_VARIANTS = [
204
+ [/\b(?:bachelor(?:'s)?|b\.s\.?|bs\.?|bsc\.?)\b/i, "bachelor"],
205
+ [/\b(?:master(?:'s)?|m\.s\.?|ms\.?|msc\.?)\b/i, "master"],
206
+ [/\b(?:phd|ph\.d\.?|doctorate)\b/i, "phd"],
207
+ [/\bmba\b/i, "mba"],
208
+ [/\bassociate(?:'s)?\b/i, "associate"]
115
209
  ];
116
210
  function extractRequiredSkills(lines) {
117
211
  const required = [];
118
212
  for (const line of lines) {
119
213
  if (/must|require|required|need/i.test(line)) {
120
- required.push(...line.split(/[,.;•-]/));
214
+ required.push(...tokenize(line));
121
215
  }
122
216
  }
123
- return required.map((value) => value.trim()).filter(Boolean);
217
+ return required;
124
218
  }
125
219
  function extractPreferredSkills(lines) {
126
220
  const preferred = [];
127
221
  for (const line of lines) {
128
222
  if (/preferred|nice to have|plus/i.test(line)) {
129
- preferred.push(...line.split(/[,.;•-]/));
223
+ preferred.push(...tokenize(line));
130
224
  }
131
225
  }
132
- return preferred.map((value) => value.trim()).filter(Boolean);
226
+ return preferred;
133
227
  }
134
228
  function extractRoleKeywords(text) {
135
229
  const roleMatch = text.match(/(engineer|developer|manager|scientist|analyst|designer|architect)/i);
@@ -144,23 +238,41 @@ function extractMinExperience(text) {
144
238
  return void 0;
145
239
  }
146
240
  function extractEducationRequirements(text) {
147
- const normalized = normalizeForComparison(text);
148
- return DEGREE_KEYWORDS.filter((degree) => normalized.includes(degree));
241
+ const found = /* @__PURE__ */ new Set();
242
+ for (const [pattern, canonical] of DEGREE_VARIANTS) {
243
+ if (pattern.test(text)) found.add(canonical);
244
+ }
245
+ return [...found];
149
246
  }
150
247
  function parseJobDescription(jobDescription, config) {
151
248
  const normalizedText = normalizeWhitespace(jobDescription);
152
249
  const lines = splitLines(jobDescription);
153
- const requiredSkillsRaw = extractRequiredSkills(lines);
154
- const preferredSkillsRaw = extractPreferredSkills(lines);
250
+ const skillVocab = /* @__PURE__ */ new Set();
251
+ for (const [canonical, aliases] of Object.entries(config.skillAliases)) {
252
+ skillVocab.add(canonical.toLowerCase());
253
+ for (const alias of aliases) skillVocab.add(alias.toLowerCase());
254
+ }
255
+ for (const s of config.profile?.mandatorySkills ?? []) skillVocab.add(s.toLowerCase());
256
+ for (const s of config.profile?.optionalSkills ?? []) skillVocab.add(s.toLowerCase());
257
+ const isSkillLike = (t) => {
258
+ if (skillVocab.has(t)) return true;
259
+ if (/[.#+]/.test(t) && /[a-z]/.test(t)) return true;
260
+ if (t.includes("/")) return t.split("/").some((p) => p.length >= 2 && !STOP_WORDS.has(p));
261
+ return false;
262
+ };
263
+ const requiredSkillsRaw = extractRequiredSkills(lines).filter(isSkillLike);
264
+ const preferredSkillsRaw = extractPreferredSkills(lines).filter(isSkillLike);
155
265
  const requiredSkills = normalizeSkills(requiredSkillsRaw, config.skillAliases);
156
266
  const preferredSkills = normalizeSkills(preferredSkillsRaw, config.skillAliases);
157
- const keywords = unique([...requiredSkills, ...preferredSkills, ...tokenize(normalizedText)]);
267
+ const bodyTokens = tokenize(normalizedText).filter(isSkillLike);
268
+ const roleKeywords = extractRoleKeywords(jobDescription);
269
+ const keywords = unique([...requiredSkills, ...preferredSkills, ...roleKeywords, ...bodyTokens]);
158
270
  return {
159
271
  raw: jobDescription,
160
272
  normalizedText,
161
273
  requiredSkills,
162
274
  preferredSkills,
163
- roleKeywords: extractRoleKeywords(jobDescription),
275
+ roleKeywords,
164
276
  keywords,
165
277
  minExperienceYears: extractMinExperience(jobDescription),
166
278
  educationRequirements: extractEducationRequirements(jobDescription)
@@ -205,6 +317,14 @@ function parseDateToken(raw) {
205
317
  return { year, month };
206
318
  }
207
319
  }
320
+ const slashMatch = cleaned.match(/^(\d{1,2})\/(\d{4})$/);
321
+ if (slashMatch) {
322
+ const month = Number.parseInt(slashMatch[1], 10);
323
+ const year = Number.parseInt(slashMatch[2], 10);
324
+ if (month >= 1 && month <= 12 && !Number.isNaN(year)) {
325
+ return { year, month };
326
+ }
327
+ }
208
328
  const yearMatch = cleaned.match(/(20\d{2}|19\d{2})/);
209
329
  if (yearMatch) {
210
330
  const year = Number.parseInt(yearMatch[1], 10);
@@ -219,7 +339,9 @@ function monthsBetween(start, end) {
219
339
  }
220
340
  function parseDateRange(text, referenceDate) {
221
341
  const normalized = text.trim();
222
- const rangeMatch = normalized.match(/([A-Za-z]{3,9}\s+\d{4}|\d{4})\s*(?:-|to|–|—)\s*(Present|Current|Now|[A-Za-z]{3,9}\s+\d{4}|\d{4})/i);
342
+ const rangeMatch = normalized.match(
343
+ /(\d{1,2}\/\d{4}|[A-Za-z]{3,9}\s+\d{4}|\d{4})\s*(?:-|to|–|—)\s*(Present|Current|Now|\d{1,2}\/\d{4}|[A-Za-z]{3,9}\s+\d{4}|\d{4})/i
344
+ );
223
345
  if (!rangeMatch) {
224
346
  return null;
225
347
  }
@@ -240,11 +362,40 @@ function parseDateRange(text, referenceDate) {
240
362
  raw: normalized,
241
363
  start: rangeMatch[1],
242
364
  end: isPresent ? "present" : rangeMatch[2],
243
- durationInMonths: durationInMonths > 0 ? durationInMonths : void 0
365
+ durationInMonths: durationInMonths > 0 ? durationInMonths : void 0,
366
+ startYear: startToken.year,
367
+ startMonth: startToken.month,
368
+ endYear: endTokenResolved.year,
369
+ endMonth: endTokenResolved.month
244
370
  };
245
371
  }
246
372
  function sumExperienceYears(ranges) {
247
- const months = ranges.map((range) => range.durationInMonths ?? 0).reduce((total, value) => total + value, 0);
373
+ const withBounds = ranges.filter(
374
+ (r) => r.startYear !== void 0 && r.endYear !== void 0
375
+ );
376
+ if (withBounds.length === ranges.length && ranges.length > 0) {
377
+ const toIndex = (year, month) => year * 12 + month;
378
+ const intervals = withBounds.map((r) => ({
379
+ s: toIndex(r.startYear, r.startMonth ?? 1),
380
+ e: toIndex(r.endYear, r.endMonth ?? 12)
381
+ })).sort((a, b) => a.s - b.s);
382
+ let totalMonths = 0;
383
+ let curStart = intervals[0].s;
384
+ let curEnd = intervals[0].e;
385
+ for (let i = 1; i < intervals.length; i++) {
386
+ const { s, e } = intervals[i];
387
+ if (s <= curEnd) {
388
+ curEnd = Math.max(curEnd, e);
389
+ } else {
390
+ totalMonths += curEnd - curStart + 1;
391
+ curStart = s;
392
+ curEnd = e;
393
+ }
394
+ }
395
+ totalMonths += curEnd - curStart + 1;
396
+ return Number((totalMonths / 12).toFixed(2));
397
+ }
398
+ const months = ranges.reduce((total, r) => total + (r.durationInMonths ?? 0), 0);
248
399
  return Number((months / 12).toFixed(2));
249
400
  }
250
401
 
@@ -278,9 +429,6 @@ var ACTION_VERBS = [
278
429
  "reduced",
279
430
  "increased"
280
431
  ];
281
- function escapeRegExp(input) {
282
- return input.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&");
283
- }
284
432
  function detectSection(line) {
285
433
  const normalized = line.trim().toLowerCase();
286
434
  for (const [section, aliases] of Object.entries(SECTION_ALIASES)) {
@@ -379,11 +527,28 @@ function parseResume(resumeText, config) {
379
527
  const actionVerbs = parseActionVerbs(normalizedText);
380
528
  const experienceData = parseExperience(sections.experience, config.referenceDate);
381
529
  const educationEntries = parseEducation(sections.education);
382
- const totalExperienceYears = sumExperienceYears(
530
+ let totalExperienceYears = sumExperienceYears(
383
531
  experienceData.entries.map((entry) => entry.dates).filter((range) => Boolean(range))
384
532
  );
533
+ if (totalExperienceYears === 0) {
534
+ const textToScan = sections.summary ?? normalizedText;
535
+ const yearsMatch = textToScan.match(/(\d{1,2})\+?\s*years?/i);
536
+ if (yearsMatch) {
537
+ totalExperienceYears = Number.parseInt(yearsMatch[1], 10);
538
+ }
539
+ }
385
540
  const requiredSections = ["summary", "experience", "skills", "education"];
386
541
  const warnings = [];
542
+ const lineCount = splitLines(resumeText).length;
543
+ if (resumeText.trim().length < 100) {
544
+ warnings.push(
545
+ "Almost no text was extracted \u2014 the resume may be a scanned/image PDF. Upload a text-based PDF or paste the text directly."
546
+ );
547
+ } else if (lineCount <= 2) {
548
+ warnings.push(
549
+ "Resume text has no line breaks \u2014 the PDF layout likely didn't export cleanly (common with multi-column designs). Export as a single-column PDF or paste plain text for accurate parsing."
550
+ );
551
+ }
387
552
  for (const section of requiredSections) {
388
553
  if (!detected.includes(section)) {
389
554
  warnings.push(`${section} section not detected`);
@@ -584,10 +749,11 @@ function calculateScore(resume, job, config) {
584
749
  overusedKeywords: keywordResult.overusedKeywords,
585
750
  suggestions: [],
586
751
  warnings: [],
587
- // detectedSections / parsedExperienceYears / experienceGap: filled by index.ts
752
+ // detectedSections / parsedExperienceYears / experienceGap / experienceEntries: filled by index.ts
588
753
  experienceGap: experienceResult.missingYears,
589
754
  detectedSections: [],
590
755
  parsedExperienceYears: 0,
756
+ experienceEntries: [],
591
757
  missingExperienceYears: experienceResult.missingYears,
592
758
  educationScore
593
759
  };
@@ -611,7 +777,31 @@ var defaultSkillAliases = {
611
777
  docker: ["containers"],
612
778
  kubernetes: ["k8s"],
613
779
  html: ["html5"],
614
- css: ["css3"]
780
+ css: ["css3"],
781
+ // ML / data science
782
+ pytorch: ["torch"],
783
+ tensorflow: ["tf"],
784
+ "scikit-learn": ["sklearn"],
785
+ pandas: [],
786
+ numpy: [],
787
+ fastapi: [],
788
+ flask: [],
789
+ django: [],
790
+ // data / infra
791
+ kafka: [],
792
+ redis: [],
793
+ elasticsearch: ["elastic"],
794
+ spark: ["apache spark"],
795
+ // common pure-letter tech skills (no symbol chars)
796
+ accessibility: ["a11y"],
797
+ frontend: ["front-end"],
798
+ backend: ["back-end"],
799
+ security: ["cybersecurity"],
800
+ testing: ["unittest", "pytest"],
801
+ microservices: [],
802
+ agile: ["scrum"],
803
+ blockchain: [],
804
+ devops: []
615
805
  };
616
806
  var softwareEngineerProfile = {
617
807
  name: "software-engineer",
@@ -738,6 +928,11 @@ var SuggestionEngine = class {
738
928
  "Strengthen bullet points with impact verbs (led, built, improved, delivered)."
739
929
  );
740
930
  }
931
+ if (input.resume.detectedSections.length < 2 && input.resume.raw.trim().length > 300) {
932
+ suggestions.push(
933
+ "Your resume may use a multi-column layout. Export as a single-column PDF or paste plain text \u2014 most ATS systems and this parser work best with a linear layout."
934
+ );
935
+ }
741
936
  return { suggestions, warnings };
742
937
  }
743
938
  };
@@ -1398,6 +1593,7 @@ function analyzeResume(input) {
1398
1593
  experienceGap: scoring.experienceGap,
1399
1594
  detectedSections: parsedResume.detectedSections,
1400
1595
  parsedExperienceYears: parsedResume.totalExperienceYears,
1596
+ experienceEntries: parsedResume.experience,
1401
1597
  suggestions,
1402
1598
  warnings: [...suggestionResult.warnings, ...llmWarnings]
1403
1599
  };
@@ -1462,6 +1658,7 @@ async function analyzeResumeAsync(input) {
1462
1658
  experienceGap: scoring.experienceGap,
1463
1659
  detectedSections: parsedResume.detectedSections,
1464
1660
  parsedExperienceYears: parsedResume.totalExperienceYears,
1661
+ experienceEntries: parsedResume.experience,
1465
1662
  suggestions,
1466
1663
  warnings: [...suggestionResult.warnings, ...llmWarnings]
1467
1664
  };