@juicedresume/mcp 0.3.1 → 0.4.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.js +414 -147
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -974,8 +974,67 @@ var SKILLS = [
|
|
|
974
974
|
];
|
|
975
975
|
var SKILL_INDEX = /* @__PURE__ */ new Map();
|
|
976
976
|
for (const s of SKILLS) SKILL_INDEX.set(s.name.toLowerCase(), s);
|
|
977
|
+
var SKILL_ALIASES = {
|
|
978
|
+
js: "JavaScript",
|
|
979
|
+
ecmascript: "JavaScript",
|
|
980
|
+
ts: "TypeScript",
|
|
981
|
+
"react.js": "React",
|
|
982
|
+
reactjs: "React",
|
|
983
|
+
"vue.js": "Vue",
|
|
984
|
+
vuejs: "Vue",
|
|
985
|
+
"next.js": "Next.js",
|
|
986
|
+
next: "Next.js",
|
|
987
|
+
nextjs: "Next.js",
|
|
988
|
+
"node.js": "Node.js",
|
|
989
|
+
node: "Node.js",
|
|
990
|
+
nodejs: "Node.js",
|
|
991
|
+
k8s: "Kubernetes",
|
|
992
|
+
postgres: "PostgreSQL",
|
|
993
|
+
psql: "PostgreSQL",
|
|
994
|
+
golang: "Go",
|
|
995
|
+
py: "Python",
|
|
996
|
+
"c sharp": "C#",
|
|
997
|
+
csharp: "C#",
|
|
998
|
+
"c plus plus": "C++",
|
|
999
|
+
cpp: "C++",
|
|
1000
|
+
tf: "Terraform",
|
|
1001
|
+
gha: "GitHub Actions",
|
|
1002
|
+
"rest api": "REST APIs",
|
|
1003
|
+
rest: "REST APIs",
|
|
1004
|
+
restful: "REST APIs",
|
|
1005
|
+
"objective c": "Objective-C",
|
|
1006
|
+
"spring-boot": "Spring Boot",
|
|
1007
|
+
"tailwind": "Tailwind CSS",
|
|
1008
|
+
tailwindcss: "Tailwind CSS",
|
|
1009
|
+
gcp: "GCP",
|
|
1010
|
+
"google cloud": "GCP",
|
|
1011
|
+
"google cloud platform": "GCP",
|
|
1012
|
+
aws: "AWS",
|
|
1013
|
+
"amazon web services": "AWS",
|
|
1014
|
+
sklearn: "scikit-learn",
|
|
1015
|
+
"scikit learn": "scikit-learn",
|
|
1016
|
+
hf: "Hugging Face",
|
|
1017
|
+
huggingface: "Hugging Face",
|
|
1018
|
+
pytorch: "PyTorch",
|
|
1019
|
+
tensorflow: "TensorFlow",
|
|
1020
|
+
"power-bi": "Power BI",
|
|
1021
|
+
powerbi: "Power BI"
|
|
1022
|
+
};
|
|
1023
|
+
function normalizeSkill(s) {
|
|
1024
|
+
const key = s.toLowerCase().trim();
|
|
1025
|
+
if (SKILL_INDEX.has(key)) return SKILL_INDEX.get(key).name;
|
|
1026
|
+
const stripped = key.replace(/\.js$/, "");
|
|
1027
|
+
if (SKILL_ALIASES[key]) return SKILL_ALIASES[key];
|
|
1028
|
+
if (SKILL_ALIASES[stripped]) return SKILL_ALIASES[stripped];
|
|
1029
|
+
if (SKILL_INDEX.has(stripped)) return SKILL_INDEX.get(stripped).name;
|
|
1030
|
+
return s.trim();
|
|
1031
|
+
}
|
|
977
1032
|
function lookupSkill(s) {
|
|
978
|
-
|
|
1033
|
+
const key = s.toLowerCase().trim();
|
|
1034
|
+
const direct = SKILL_INDEX.get(key);
|
|
1035
|
+
if (direct) return direct;
|
|
1036
|
+
const canonical = normalizeSkill(s).toLowerCase();
|
|
1037
|
+
return SKILL_INDEX.get(canonical);
|
|
979
1038
|
}
|
|
980
1039
|
function searchSkills(query, limit = 12) {
|
|
981
1040
|
const q = query.toLowerCase().trim();
|
|
@@ -1344,6 +1403,9 @@ var STOPWORDS = /* @__PURE__ */ new Set([
|
|
|
1344
1403
|
"day",
|
|
1345
1404
|
"days"
|
|
1346
1405
|
]);
|
|
1406
|
+
function escapeRe(s) {
|
|
1407
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1408
|
+
}
|
|
1347
1409
|
function tokens(s) {
|
|
1348
1410
|
return s.toLowerCase().replace(/[^a-z0-9+./# -]/g, " ").split(/\s+/).filter((w) => w && w.length > 1 && !STOPWORDS.has(w));
|
|
1349
1411
|
}
|
|
@@ -1353,22 +1415,49 @@ function bigrams(toks) {
|
|
|
1353
1415
|
return out;
|
|
1354
1416
|
}
|
|
1355
1417
|
function resumeText(resume) {
|
|
1356
|
-
const parts = [];
|
|
1357
|
-
parts.push(resume.personal.title);
|
|
1418
|
+
const parts = [resume.personal.title];
|
|
1358
1419
|
for (const sec of resume.sections) {
|
|
1420
|
+
if (sec.visible === false) continue;
|
|
1359
1421
|
for (const it of sec.items) {
|
|
1360
|
-
|
|
1361
|
-
parts.push(
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1422
|
+
if (it.visible === false) continue;
|
|
1423
|
+
parts.push(
|
|
1424
|
+
it.body || "",
|
|
1425
|
+
it.description || "",
|
|
1426
|
+
it.skills || "",
|
|
1427
|
+
it.category || "",
|
|
1428
|
+
it.jobTitle || "",
|
|
1429
|
+
it.degree || "",
|
|
1430
|
+
it.field || "",
|
|
1431
|
+
it.name || "",
|
|
1432
|
+
it.title || "",
|
|
1433
|
+
it.role || "",
|
|
1434
|
+
it.employer || "",
|
|
1435
|
+
it.school || "",
|
|
1436
|
+
it.publisher || "",
|
|
1437
|
+
it.institution || "",
|
|
1438
|
+
it.issuer || "",
|
|
1439
|
+
it.location || ""
|
|
1440
|
+
);
|
|
1365
1441
|
}
|
|
1366
1442
|
}
|
|
1367
1443
|
return parts.join(" ").replace(/<[^>]+>/g, " ");
|
|
1368
1444
|
}
|
|
1369
1445
|
function tailorToJob(resume, jdText) {
|
|
1370
1446
|
const jdToks = tokens(jdText);
|
|
1371
|
-
const
|
|
1447
|
+
const resumeFullText = resumeText(resume).toLowerCase();
|
|
1448
|
+
const resumeToks = new Set(tokens(resumeFullText));
|
|
1449
|
+
const resumeNorm = new Set([...resumeToks].map((t) => normalizeSkill(t).toLowerCase()));
|
|
1450
|
+
function matchKind(keyword) {
|
|
1451
|
+
const k = keyword.toLowerCase();
|
|
1452
|
+
if (k.includes(" ")) {
|
|
1453
|
+
return new RegExp(`\\b${escapeRe(k)}\\b`).test(resumeFullText) ? "exact" : "none";
|
|
1454
|
+
}
|
|
1455
|
+
if (resumeToks.has(k)) return "exact";
|
|
1456
|
+
const canon = normalizeSkill(keyword).toLowerCase();
|
|
1457
|
+
if (resumeNorm.has(canon)) return "variant";
|
|
1458
|
+
if (canon.includes(" ") && new RegExp(`\\b${escapeRe(canon)}\\b`).test(resumeFullText)) return "variant";
|
|
1459
|
+
return "none";
|
|
1460
|
+
}
|
|
1372
1461
|
const jdGrams = /* @__PURE__ */ new Set([...jdToks, ...bigrams(jdToks)]);
|
|
1373
1462
|
const skillNames = new Map(SKILLS.map((s) => [s.name.toLowerCase(), s]));
|
|
1374
1463
|
const freq = /* @__PURE__ */ new Map();
|
|
@@ -1384,17 +1473,32 @@ function tailorToJob(resume, jdText) {
|
|
|
1384
1473
|
for (const [k, c] of sortedNonSkill) jdSkills.set(k, { importance: c });
|
|
1385
1474
|
const matched = [];
|
|
1386
1475
|
const missing = [];
|
|
1476
|
+
const kindMult = (kind) => kind === "hard" ? 2 : kind === "soft" ? 1.3 : 1;
|
|
1477
|
+
const VARIANT_CREDIT = 0.6;
|
|
1387
1478
|
for (const [k, meta] of jdSkills) {
|
|
1388
|
-
const
|
|
1389
|
-
|
|
1390
|
-
|
|
1479
|
+
const w = meta.importance * kindMult(meta.kind);
|
|
1480
|
+
const kind = matchKind(k);
|
|
1481
|
+
if (kind === "exact") {
|
|
1482
|
+
matched.push({ keyword: k, kind: meta.kind, count: w });
|
|
1483
|
+
} else if (kind === "variant") {
|
|
1484
|
+
matched.push({
|
|
1485
|
+
keyword: k,
|
|
1486
|
+
kind: meta.kind,
|
|
1487
|
+
count: w * VARIANT_CREDIT,
|
|
1488
|
+
partial: true,
|
|
1489
|
+
note: `You use a variant of "${k}". ATS keyword search is usually exact-match, so add the exact term "${k}" too.`
|
|
1490
|
+
});
|
|
1391
1491
|
} else {
|
|
1392
|
-
missing.push({ keyword: k, kind: meta.kind, importance:
|
|
1492
|
+
missing.push({ keyword: k, kind: meta.kind, importance: w });
|
|
1393
1493
|
}
|
|
1394
1494
|
}
|
|
1395
1495
|
const matchedWeight = matched.reduce((a, m) => a + m.count, 0);
|
|
1396
1496
|
const totalWeight = matchedWeight + missing.reduce((a, m) => a + m.importance, 0);
|
|
1397
|
-
const score = totalWeight === 0 ? 0 : Math.round(matchedWeight / totalWeight * 100);
|
|
1497
|
+
const score = totalWeight === 0 ? 0 : Math.min(95, Math.round(matchedWeight / totalWeight * 100));
|
|
1498
|
+
const titleToks = tokens(resume.personal.title || "");
|
|
1499
|
+
const jdTokSet = new Set(jdToks);
|
|
1500
|
+
const titleHits = titleToks.filter((t) => jdTokSet.has(t)).length;
|
|
1501
|
+
const titleMatch = titleToks.length === 0 ? 0 : Math.round(titleHits / titleToks.length * 100);
|
|
1398
1502
|
missing.sort((a, b) => b.importance - a.importance);
|
|
1399
1503
|
matched.sort((a, b) => b.count - a.count);
|
|
1400
1504
|
return {
|
|
@@ -1402,15 +1506,78 @@ function tailorToJob(resume, jdText) {
|
|
|
1402
1506
|
matched,
|
|
1403
1507
|
missing,
|
|
1404
1508
|
jdSkills: jdSkills.size,
|
|
1405
|
-
resumeSkills: resumeToks.size
|
|
1509
|
+
resumeSkills: resumeToks.size,
|
|
1510
|
+
titleMatch,
|
|
1511
|
+
flags: jdKnockoutFlags(resume, jdText)
|
|
1406
1512
|
};
|
|
1407
1513
|
}
|
|
1514
|
+
function tailorMMYYYY(d) {
|
|
1515
|
+
if (!d) return null;
|
|
1516
|
+
const m = d.match(/^(\d{1,2})\/(\d{4})$/);
|
|
1517
|
+
if (m) return parseInt(m[2], 10) * 12 + parseInt(m[1], 10);
|
|
1518
|
+
const mn = d.match(/^([A-Za-z]+)\s+(\d{4})$/);
|
|
1519
|
+
if (mn) {
|
|
1520
|
+
const months = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
1521
|
+
const i = months.findIndex((x) => mn[1].toLowerCase().startsWith(x));
|
|
1522
|
+
if (i >= 0) return parseInt(mn[2], 10) * 12 + (i + 1);
|
|
1523
|
+
}
|
|
1524
|
+
const y = d.match(/^(\d{4})$/);
|
|
1525
|
+
if (y) return parseInt(y[1], 10) * 12;
|
|
1526
|
+
return null;
|
|
1527
|
+
}
|
|
1528
|
+
function resumeYears(resume) {
|
|
1529
|
+
const exp = resume.sections.find((s) => s.type === "experience");
|
|
1530
|
+
if (!exp) return 0;
|
|
1531
|
+
const now = (/* @__PURE__ */ new Date()).getFullYear() * 12 + ((/* @__PURE__ */ new Date()).getMonth() + 1);
|
|
1532
|
+
let months = 0;
|
|
1533
|
+
for (const it of exp.items || []) {
|
|
1534
|
+
if (it.visible === false) continue;
|
|
1535
|
+
const s = tailorMMYYYY(it.startDate || "");
|
|
1536
|
+
const e = it.current ? now : tailorMMYYYY(it.endDate || "");
|
|
1537
|
+
if (s && e && e >= s) months += e - s;
|
|
1538
|
+
}
|
|
1539
|
+
return Math.round(months / 12 * 10) / 10;
|
|
1540
|
+
}
|
|
1541
|
+
function jdKnockoutFlags(resume, jdText) {
|
|
1542
|
+
const flags = [];
|
|
1543
|
+
const degreeRequired = /\b(bachelor|master|b\.?s\.?|m\.?s\.?|b\.?a\.?|ph\.?d|mba|degree)\b[^.\n]{0,40}\b(required|require|must|minimum|mandatory)\b/i.test(jdText) || /\b(required|must have|minimum|mandatory)\b[^.\n]{0,40}\b(bachelor|master|degree|b\.?s\.?|m\.?s\.?|ph\.?d|diploma)\b/i.test(jdText);
|
|
1544
|
+
if (degreeRequired) {
|
|
1545
|
+
const eduSec = resume.sections.find((s) => s.type === "education");
|
|
1546
|
+
const hasDegree = (eduSec?.items || []).some((it) => it.visible !== false && String(it.degree || "").trim());
|
|
1547
|
+
if (!hasDegree) {
|
|
1548
|
+
flags.push({
|
|
1549
|
+
id: "K4.missing-required-degree",
|
|
1550
|
+
severity: "risk",
|
|
1551
|
+
title: "This job states a degree requirement",
|
|
1552
|
+
detail: "The description asks for a specific degree, and no completed degree is listed on your resume. A stated degree requirement is often a hard knockout filter.",
|
|
1553
|
+
suggestion: "Add your degree in the Education section, or if you have equivalent experience, be ready to address the gap in a cover note.",
|
|
1554
|
+
requirement: "degree"
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
const yoe = [...jdText.matchAll(/(\d{1,2})\s*\+?\s*(?:to|-|–|—)?\s*\d{0,2}\s*years?(?:['’]?s?)?\s*(?:of\s+)?(?:experience|exp\b)/gi)].map((m) => parseInt(m[1], 10)).filter((n) => n >= 1 && n <= 20);
|
|
1559
|
+
if (yoe.length) {
|
|
1560
|
+
const required = Math.min(...yoe);
|
|
1561
|
+
const have = resumeYears(resume);
|
|
1562
|
+
if (have + 0.5 < required) {
|
|
1563
|
+
flags.push({
|
|
1564
|
+
id: "K5.yoe-below-requirement",
|
|
1565
|
+
severity: "risk",
|
|
1566
|
+
title: `Asks for ${required}+ years; your resume shows about ${have}`,
|
|
1567
|
+
detail: "Years of experience is one of the most common automated and recruiter filters. A year or more below the stated minimum is a frequent screen-out.",
|
|
1568
|
+
suggestion: "Make sure every relevant role has dates so your full tenure is counted, and include earlier or concurrent experience if it applies.",
|
|
1569
|
+
requirement: `${required}+ years`
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
return flags;
|
|
1574
|
+
}
|
|
1408
1575
|
|
|
1409
1576
|
// ../../packages/scoring/src/v2-additions.ts
|
|
1410
1577
|
function stripHtml(s) {
|
|
1411
1578
|
return (s || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
1412
1579
|
}
|
|
1413
|
-
function passSoft(id, label, score, message, suggestion) {
|
|
1580
|
+
function passSoft(id, label, score, message, suggestion, weight) {
|
|
1414
1581
|
const s = Math.max(0, Math.min(1, score));
|
|
1415
1582
|
return {
|
|
1416
1583
|
id,
|
|
@@ -1419,12 +1586,10 @@ function passSoft(id, label, score, message, suggestion) {
|
|
|
1419
1586
|
status: s >= 0.85 ? "pass" : s >= 0.55 ? "warn" : "fail",
|
|
1420
1587
|
message,
|
|
1421
1588
|
suggestion: suggestion ?? null,
|
|
1422
|
-
severity: "soft"
|
|
1589
|
+
severity: "soft",
|
|
1590
|
+
weight: weight ?? 1
|
|
1423
1591
|
};
|
|
1424
1592
|
}
|
|
1425
|
-
function hardCheck(id, label, score, message, suggestion) {
|
|
1426
|
-
return { ...passSoft(id, label, score, message, suggestion), severity: "hard" };
|
|
1427
|
-
}
|
|
1428
1593
|
function firstBulletStrongest(resume) {
|
|
1429
1594
|
const exp = resume.sections.find((s) => s.type === "experience");
|
|
1430
1595
|
if (!exp?.items?.length) return null;
|
|
@@ -1447,42 +1612,8 @@ function firstBulletStrongest(resume) {
|
|
|
1447
1612
|
"Lead bullet of recent role is strongest",
|
|
1448
1613
|
score,
|
|
1449
1614
|
topQuantified && topLength >= avgOtherLength ? "Top bullet of your most-recent role is quantified and substantive \u2014 recruiter eye lands here first." : "Your strongest bullet should be FIRST in your most-recent role (recruiters skim top-down).",
|
|
1450
|
-
"Reorder bullets in your most-recent experience so the most quantified / highest-impact bullet is first."
|
|
1451
|
-
|
|
1452
|
-
};
|
|
1453
|
-
}
|
|
1454
|
-
function dateFormatConsistency(resume) {
|
|
1455
|
-
const exp = resume.sections.find((s) => s.type === "experience");
|
|
1456
|
-
const edu = resume.sections.find((s) => s.type === "education");
|
|
1457
|
-
const dates = [];
|
|
1458
|
-
[...exp?.items || [], ...edu?.items || []].forEach((it) => {
|
|
1459
|
-
if (it.startDate) dates.push(it.startDate);
|
|
1460
|
-
if (it.endDate && !it.current) dates.push(it.endDate);
|
|
1461
|
-
});
|
|
1462
|
-
if (dates.length < 2) return null;
|
|
1463
|
-
const MM_YYYY = /^\d{1,2}\/\d{4}$/;
|
|
1464
|
-
const MMM_YYYY = /^[A-Za-z]+\s+\d{4}$/;
|
|
1465
|
-
const YYYY = /^\d{4}$/;
|
|
1466
|
-
const ABBREV = /['']\d{2}|\d{1,2}\/\d{2}|\b[A-Z][a-z]{2}'\d{2}\b/;
|
|
1467
|
-
const formats = /* @__PURE__ */ new Set();
|
|
1468
|
-
for (const d of dates) {
|
|
1469
|
-
if (ABBREV.test(d)) formats.add("abbrev");
|
|
1470
|
-
else if (MM_YYYY.test(d)) formats.add("mm/yyyy");
|
|
1471
|
-
else if (MMM_YYYY.test(d)) formats.add("month-yyyy");
|
|
1472
|
-
else if (YYYY.test(d)) formats.add("yyyy");
|
|
1473
|
-
else formats.add("other");
|
|
1474
|
-
}
|
|
1475
|
-
const hasAbbrev = formats.has("abbrev");
|
|
1476
|
-
const inconsistent = formats.size > 1;
|
|
1477
|
-
const score = hasAbbrev ? 0.2 : inconsistent ? 0.55 : 1;
|
|
1478
|
-
return {
|
|
1479
|
-
dim: "style",
|
|
1480
|
-
check: passSoft(
|
|
1481
|
-
"C9.date-format",
|
|
1482
|
-
"Date format is consistent",
|
|
1483
|
-
score,
|
|
1484
|
-
hasAbbrev ? "Date abbreviations like '21 or Jan'24 break ATS years-of-experience calculation." : inconsistent ? `You're mixing date formats (${[...formats].join(", ")}). Pick one and apply consistently.` : "Dates are formatted consistently \u2014 ATS parses them cleanly.",
|
|
1485
|
-
"Use MM/YYYY (06/2024) or Month YYYY (June 2024) \u2014 consistently across every entry."
|
|
1615
|
+
"Reorder bullets in your most-recent experience so the most quantified / highest-impact bullet is first.",
|
|
1616
|
+
0.5
|
|
1486
1617
|
)
|
|
1487
1618
|
};
|
|
1488
1619
|
}
|
|
@@ -1515,19 +1646,6 @@ function dobAgePenalty(resume) {
|
|
|
1515
1646
|
)
|
|
1516
1647
|
};
|
|
1517
1648
|
}
|
|
1518
|
-
function singleColumnHard(resume) {
|
|
1519
|
-
const twoCol = isMultiColumnTemplate(resume.styling?.template);
|
|
1520
|
-
return {
|
|
1521
|
-
dim: "ats",
|
|
1522
|
-
check: hardCheck(
|
|
1523
|
-
"AT12.single-column-hard",
|
|
1524
|
-
"Single-column layout (ATS-critical)",
|
|
1525
|
-
twoCol ? 0 : 1,
|
|
1526
|
-
twoCol ? "Two-column / sidebar template \u2014 a top cause of ATS parse failure. Workday can concatenate columns into gibberish. (The ATS-PDF export is always single-column, but the visual PDF is not.)" : "Single-column layout \u2014 every ATS parses this cleanly.",
|
|
1527
|
-
"Switch to a single-column template (Jake, Harvard, Classic) in the Templates tab. Multi-column is a top score-destroyer in ATS systems."
|
|
1528
|
-
)
|
|
1529
|
-
};
|
|
1530
|
-
}
|
|
1531
1649
|
function threeLocationsRule(resume) {
|
|
1532
1650
|
const summary = resume.sections.find((s) => s.type === "summary");
|
|
1533
1651
|
const skills = resume.sections.find((s) => s.type === "skills");
|
|
@@ -1556,42 +1674,12 @@ function threeLocationsRule(resume) {
|
|
|
1556
1674
|
)
|
|
1557
1675
|
};
|
|
1558
1676
|
}
|
|
1559
|
-
function bulletLengthSweetSpot(resume) {
|
|
1560
|
-
const exp = resume.sections.find((s) => s.type === "experience");
|
|
1561
|
-
if (!exp?.items?.length) return null;
|
|
1562
|
-
const bullets = [];
|
|
1563
|
-
for (const it of exp.items) {
|
|
1564
|
-
const html = it.description || "";
|
|
1565
|
-
const lis = html.match(/<li[^>]*>([\s\S]*?)<\/li>/gi) || [];
|
|
1566
|
-
for (const li of lis) bullets.push(stripHtml(li));
|
|
1567
|
-
}
|
|
1568
|
-
if (bullets.length === 0) return null;
|
|
1569
|
-
let inRange = 0;
|
|
1570
|
-
for (const b of bullets) {
|
|
1571
|
-
const w = b.split(/\s+/).filter(Boolean).length;
|
|
1572
|
-
if (w >= 12 && w <= 25) inRange++;
|
|
1573
|
-
}
|
|
1574
|
-
const ratio = inRange / bullets.length;
|
|
1575
|
-
return {
|
|
1576
|
-
dim: "brevity",
|
|
1577
|
-
check: passSoft(
|
|
1578
|
-
"B5.sweet-spot",
|
|
1579
|
-
"Bullets in 12\u201325 word range",
|
|
1580
|
-
ratio,
|
|
1581
|
-
`${Math.round(ratio * 100)}% of your bullets fall in the 12\u201325 word sweet spot.`,
|
|
1582
|
-
"Aim for bullets between 12 and 25 words \u2014 short enough for the 6-second recruiter scan, long enough to convey impact + metric."
|
|
1583
|
-
)
|
|
1584
|
-
};
|
|
1585
|
-
}
|
|
1586
1677
|
function additionalChecks(resume) {
|
|
1587
1678
|
return [
|
|
1588
1679
|
firstBulletStrongest(resume),
|
|
1589
|
-
dateFormatConsistency(resume),
|
|
1590
1680
|
targetTitle(resume),
|
|
1591
1681
|
dobAgePenalty(resume),
|
|
1592
|
-
|
|
1593
|
-
threeLocationsRule(resume),
|
|
1594
|
-
bulletLengthSweetSpot(resume)
|
|
1682
|
+
threeLocationsRule(resume)
|
|
1595
1683
|
].filter((x) => x !== null);
|
|
1596
1684
|
}
|
|
1597
1685
|
|
|
@@ -2119,6 +2207,11 @@ function impactChecks(resume, b) {
|
|
|
2119
2207
|
const scaleSignals = E.filter((x) => SCALE_RE.test(x.text));
|
|
2120
2208
|
const timeBoundRe = /\b(within|in|over)\s+\d+\s*(weeks?|months?|quarters?|years?|days?)\b|\bq[1-4]\s?'?\d{2,4}\b/i;
|
|
2121
2209
|
const timeBound = E.filter((x) => timeBoundRe.test(x.text));
|
|
2210
|
+
const quantifiedEarly = quantified.filter((x) => {
|
|
2211
|
+
const head = x.text.split(/\s+/).slice(0, 8).join(" ");
|
|
2212
|
+
return QUANT_RE.test(head);
|
|
2213
|
+
});
|
|
2214
|
+
const earlyRatio = quantified.length ? quantifiedEarly.length / quantified.length : 0;
|
|
2122
2215
|
return [
|
|
2123
2216
|
mk(
|
|
2124
2217
|
"A1.quantification",
|
|
@@ -2126,15 +2219,20 @@ function impactChecks(resume, b) {
|
|
|
2126
2219
|
qRatio >= 0.6 ? 1 : qRatio >= 0.4 ? 0.7 : qRatio / 0.4,
|
|
2127
2220
|
`${quantified.length} of ${total} experience bullets include numbers, %, $, or counts (${Math.round(qRatio * 100)}%).`,
|
|
2128
2221
|
nonQ.slice(0, 6).map((x) => x.id),
|
|
2129
|
-
qRatio < 0.6 ? "Add concrete metrics: % change, $ amount, users, time saved." : void 0
|
|
2222
|
+
qRatio < 0.6 ? "Add concrete metrics: % change, $ amount, users, time saved." : void 0,
|
|
2223
|
+
void 0,
|
|
2224
|
+
3
|
|
2130
2225
|
),
|
|
2131
2226
|
mk(
|
|
2132
2227
|
"A2.metric-diversity",
|
|
2133
2228
|
"Variety of metric types",
|
|
2134
|
-
|
|
2229
|
+
// Bonus signal: absence is neutral (0.8), presence lifts toward 1.
|
|
2230
|
+
metricTypes.size === 0 ? 0.8 : Math.min(1, 0.8 + metricTypes.size * 0.1),
|
|
2135
2231
|
metricTypes.size === 0 ? "No metric types detected (%, $, time, counts, ratios)." : `Uses ${metricTypes.size} metric type(s): ${[...metricTypes].join(", ")}.`,
|
|
2136
2232
|
[],
|
|
2137
|
-
metricTypes.size < 3 ? "Mix metric flavors: a % gain, a $ saving, and a scale number tells a richer story than three %s." : void 0
|
|
2233
|
+
metricTypes.size < 3 ? "Mix metric flavors: a % gain, a $ saving, and a scale number tells a richer story than three %s." : void 0,
|
|
2234
|
+
void 0,
|
|
2235
|
+
0.5
|
|
2138
2236
|
),
|
|
2139
2237
|
mk(
|
|
2140
2238
|
"A3.xyz-bullets",
|
|
@@ -2142,7 +2240,9 @@ function impactChecks(resume, b) {
|
|
|
2142
2240
|
Math.min(1, xyz.length / Math.max(1, total * 0.4)),
|
|
2143
2241
|
`${xyz.length} of ${total} bullets read as "Accomplished X (measured by Y) by doing Z."`,
|
|
2144
2242
|
[],
|
|
2145
|
-
xyz.length / total < 0.4 ? "Reframe bullets as Action + Measurable Outcome + Method ('Cut LLM cost 35% by redesigning the prompt+cache layer')." : void 0
|
|
2243
|
+
xyz.length / total < 0.4 ? "Reframe bullets as Action + Measurable Outcome + Method ('Cut LLM cost 35% by redesigning the prompt+cache layer')." : void 0,
|
|
2244
|
+
void 0,
|
|
2245
|
+
2
|
|
2146
2246
|
),
|
|
2147
2247
|
mk(
|
|
2148
2248
|
"A4.responsibility",
|
|
@@ -2158,7 +2258,9 @@ function impactChecks(resume, b) {
|
|
|
2158
2258
|
strongStart.length / total,
|
|
2159
2259
|
`${strongStart.length} of ${total} bullets open with a strong verb (Led, Built, Drove, \u2026).`,
|
|
2160
2260
|
E.filter((x) => !STRONG_VERBS.has(norm(firstWord(x.text)))).slice(0, 5).map((x) => x.id),
|
|
2161
|
-
strongStart.length / total < 0.85 ? "Replace soft openers with strong past-tense verbs." : void 0
|
|
2261
|
+
strongStart.length / total < 0.85 ? "Replace soft openers with strong past-tense verbs." : void 0,
|
|
2262
|
+
void 0,
|
|
2263
|
+
2
|
|
2162
2264
|
),
|
|
2163
2265
|
mk(
|
|
2164
2266
|
"A6.weak-verbs",
|
|
@@ -2179,18 +2281,34 @@ function impactChecks(resume, b) {
|
|
|
2179
2281
|
mk(
|
|
2180
2282
|
"A8.scale-signals",
|
|
2181
2283
|
"Mentions team size, budget, or org scale",
|
|
2182
|
-
|
|
2183
|
-
scaleSignals.length === 0 ?
|
|
2284
|
+
// Bonus signal: absence is neutral, presence lifts the score.
|
|
2285
|
+
scaleSignals.length === 0 ? 0.8 : Math.min(1, 0.8 + scaleSignals.length * 0.1),
|
|
2286
|
+
scaleSignals.length === 0 ? "No team/budget/scale signals detected. A nice-to-have, not a deduction." : `${scaleSignals.length} bullet(s) reference team size, budget, or organisation scale.`,
|
|
2184
2287
|
[],
|
|
2185
|
-
scaleSignals.length === 0 ? "Add scale: 'Led a team of 6', 'Owned a $2M budget', '60+ engineers depend on this service.'" : void 0
|
|
2288
|
+
scaleSignals.length === 0 ? "Optional boost. Add scale: 'Led a team of 6', 'Owned a $2M budget', '60+ engineers depend on this service.'" : void 0,
|
|
2289
|
+
void 0,
|
|
2290
|
+
0.5
|
|
2186
2291
|
),
|
|
2187
2292
|
mk(
|
|
2188
2293
|
"A9.time-bound",
|
|
2189
2294
|
"Time-bound impact statements",
|
|
2190
|
-
|
|
2191
|
-
timeBound.length === 0 ?
|
|
2295
|
+
// Bonus signal: absence is neutral, presence lifts the score.
|
|
2296
|
+
timeBound.length === 0 ? 0.8 : Math.min(1, 0.8 + timeBound.length * 0.1),
|
|
2297
|
+
timeBound.length === 0 ? "No time-bound results yet. A nice-to-have, not a deduction." : `${timeBound.length} bullet(s) include a timeframe.`,
|
|
2298
|
+
[],
|
|
2299
|
+
timeBound.length === 0 ? "Optional boost. Anchor results in time: 'within 6 months', 'in 12 weeks', 'over Q3 2025'." : void 0,
|
|
2300
|
+
void 0,
|
|
2301
|
+
0.5
|
|
2302
|
+
),
|
|
2303
|
+
mk(
|
|
2304
|
+
"A10.metric-position",
|
|
2305
|
+
"Metrics appear early in the bullet",
|
|
2306
|
+
quantified.length === 0 ? 0.8 : earlyRatio,
|
|
2307
|
+
quantified.length === 0 ? "No quantified bullets yet to position." : `${quantifiedEarly.length} of ${quantified.length} quantified bullets lead with the number.`,
|
|
2192
2308
|
[],
|
|
2193
|
-
|
|
2309
|
+
quantified.length && earlyRatio < 0.6 ? "Move the number toward the front: 'Cut costs 35% by\u2026' scans faster than a metric buried at the end." : void 0,
|
|
2310
|
+
void 0,
|
|
2311
|
+
1
|
|
2194
2312
|
)
|
|
2195
2313
|
];
|
|
2196
2314
|
}
|
|
@@ -2222,7 +2340,7 @@ function brevityChecks(resume, b) {
|
|
|
2222
2340
|
return r.bullets > 5;
|
|
2223
2341
|
});
|
|
2224
2342
|
const pages = Math.max(1, Math.ceil(totalWords / 450));
|
|
2225
|
-
const targetPages = experienceYears(resume)
|
|
2343
|
+
const targetPages = experienceYears(resume) < 5 ? 1 : 2;
|
|
2226
2344
|
const lengthOK = pages <= targetPages;
|
|
2227
2345
|
return [
|
|
2228
2346
|
mk(
|
|
@@ -2255,7 +2373,7 @@ function brevityChecks(resume, b) {
|
|
|
2255
2373
|
mk(
|
|
2256
2374
|
"B4.page-length",
|
|
2257
2375
|
`Resume length fits ${targetPages} page${targetPages > 1 ? "s" : ""}`,
|
|
2258
|
-
lengthOK ? 1 : Math.max(0, 1 - (pages - targetPages) * 0.
|
|
2376
|
+
lengthOK ? 1 : Math.max(0, 1 - (pages - targetPages) * 0.35),
|
|
2259
2377
|
`Estimated ${pages} page${pages > 1 ? "s" : ""} (${totalWords} words total).`,
|
|
2260
2378
|
[],
|
|
2261
2379
|
!lengthOK ? `Cut ~${(pages - targetPages) * 450} words to fit ${targetPages} page${targetPages > 1 ? "s" : ""}.` : void 0
|
|
@@ -2353,7 +2471,9 @@ function styleChecks(resume, b) {
|
|
|
2353
2471
|
passive.length / total <= 0.15 ? 1 : 1 - (passive.length / total - 0.15) * 2,
|
|
2354
2472
|
`${passive.length} of ${total} bullets read as passive voice (${Math.round(passive.length / total * 100)}%).`,
|
|
2355
2473
|
passive.slice(0, 5).map((x) => x.id),
|
|
2356
|
-
passive.length / total > 0.15 ? "Flip subject/object: 'The project was led by me' \u2192 'Led the project.'" : void 0
|
|
2474
|
+
passive.length / total > 0.15 ? "Flip subject/object: 'The project was led by me' \u2192 'Led the project.'" : void 0,
|
|
2475
|
+
void 0,
|
|
2476
|
+
1.5
|
|
2357
2477
|
),
|
|
2358
2478
|
mk(
|
|
2359
2479
|
"C3.tense-consistency",
|
|
@@ -2393,7 +2513,9 @@ function styleChecks(resume, b) {
|
|
|
2393
2513
|
periodConsistent ? 1 : 0.5,
|
|
2394
2514
|
periodConsistent ? "All bullets agree on whether they end with a period." : `Mixed: ${endsWithPeriod} end with a period, ${endsWithout} don't.`,
|
|
2395
2515
|
[],
|
|
2396
|
-
!periodConsistent ? "Pick one rule for the whole resume." : void 0
|
|
2516
|
+
!periodConsistent ? "Pick one rule for the whole resume." : void 0,
|
|
2517
|
+
void 0,
|
|
2518
|
+
0.4
|
|
2397
2519
|
),
|
|
2398
2520
|
mk(
|
|
2399
2521
|
"C8.punctuation-chars",
|
|
@@ -2401,7 +2523,9 @@ function styleChecks(resume, b) {
|
|
|
2401
2523
|
punctConsistent ? 1 : 0.5,
|
|
2402
2524
|
punctConsistent ? "Punctuation glyphs are consistent." : "Mixes smart quotes/em-dashes with ASCII equivalents.",
|
|
2403
2525
|
[],
|
|
2404
|
-
!punctConsistent ? 'Pick either smart quotes (\u201C\u201D) or straight quotes (") consistently.' : void 0
|
|
2526
|
+
!punctConsistent ? 'Pick either smart quotes (\u201C\u201D) or straight quotes (") consistently.' : void 0,
|
|
2527
|
+
void 0,
|
|
2528
|
+
0.4
|
|
2405
2529
|
),
|
|
2406
2530
|
mk(
|
|
2407
2531
|
"C9.capitalization",
|
|
@@ -2409,7 +2533,9 @@ function styleChecks(resume, b) {
|
|
|
2409
2533
|
capConsistent ? 1 : 0.5,
|
|
2410
2534
|
capConsistent ? "Every bullet starts with the same case." : `${startsCap} of ${total} bullets start with a capital letter.`,
|
|
2411
2535
|
[],
|
|
2412
|
-
!capConsistent ? "Capitalize the first letter of every bullet." : void 0
|
|
2536
|
+
!capConsistent ? "Capitalize the first letter of every bullet." : void 0,
|
|
2537
|
+
void 0,
|
|
2538
|
+
0.4
|
|
2413
2539
|
),
|
|
2414
2540
|
mk(
|
|
2415
2541
|
"C10.acronym-density",
|
|
@@ -2464,6 +2590,20 @@ function structureChecks(resume, b) {
|
|
|
2464
2590
|
}
|
|
2465
2591
|
const expRev = expSec ? rev(expSec.items || []) : true;
|
|
2466
2592
|
const eduRev = eduSec ? rev(eduSec.items || []) : true;
|
|
2593
|
+
const nowMonths = (/* @__PURE__ */ new Date()).getFullYear() * 12 + ((/* @__PURE__ */ new Date()).getMonth() + 1);
|
|
2594
|
+
let maxGapMonths = 0;
|
|
2595
|
+
if (expSec) {
|
|
2596
|
+
const ranges = (expSec.items || []).map((it) => ({
|
|
2597
|
+
start: parseMMYYYY(it.startDate || ""),
|
|
2598
|
+
end: it.current ? nowMonths : parseMMYYYY(it.endDate || it.startDate || "")
|
|
2599
|
+
})).filter((r) => r.start && r.end);
|
|
2600
|
+
ranges.sort((a, b2) => b2.start - a.start);
|
|
2601
|
+
for (let i = 0; i < ranges.length - 1; i++) {
|
|
2602
|
+
const gap = ranges[i].start - ranges[i + 1].end;
|
|
2603
|
+
if (gap > maxGapMonths) maxGapMonths = gap;
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
const gapOK = maxGapMonths <= 6;
|
|
2467
2607
|
function isCanonical(type, title) {
|
|
2468
2608
|
const t = title.toLowerCase().trim();
|
|
2469
2609
|
const list = ATS_SECTION_LABELS[type] || [];
|
|
@@ -2533,6 +2673,16 @@ function structureChecks(resume, b) {
|
|
|
2533
2673
|
expRev && eduRev ? "Both sections are reverse-chronological." : `${!expRev ? "Experience" : ""}${!expRev && !eduRev ? " & " : ""}${!eduRev ? "Education" : ""} out of order.`,
|
|
2534
2674
|
[],
|
|
2535
2675
|
!expRev || !eduRev ? "List most recent first." : void 0
|
|
2676
|
+
),
|
|
2677
|
+
mk(
|
|
2678
|
+
"D9.employment-gaps",
|
|
2679
|
+
"No large unexplained employment gaps",
|
|
2680
|
+
gapOK ? 1 : 0.7,
|
|
2681
|
+
gapOK ? "No employment gap over 6 months between roles." : `A gap of about ${maxGapMonths} months sits between two roles. Some employers screen for gaps over 6 months.`,
|
|
2682
|
+
[],
|
|
2683
|
+
gapOK ? void 0 : "A gap over 6 months is fine, but consider a one-line note (contract work, study, caregiving) or a brief bridging entry so it does not read as unexplained.",
|
|
2684
|
+
void 0,
|
|
2685
|
+
0.5
|
|
2536
2686
|
)
|
|
2537
2687
|
];
|
|
2538
2688
|
}
|
|
@@ -2542,12 +2692,16 @@ function atsChecks(resume, b) {
|
|
|
2542
2692
|
const allText = b.bullets.map((x) => x.text).join(" ");
|
|
2543
2693
|
const isSingleCol = !isMultiColumnTemplate(s.template);
|
|
2544
2694
|
const isSafeFont = SAFE_FONTS.has(s.fontHeading) && SAFE_FONTS.has(s.fontBody);
|
|
2545
|
-
const
|
|
2695
|
+
const fontSizeIdeal = s.fontSize >= 10 && s.fontSize <= 12;
|
|
2696
|
+
const fontSizeAcceptable = s.fontSize >= 9.5 && s.fontSize <= 12.5;
|
|
2546
2697
|
const safeBullet = SAFE_BULLET_GLYPHS.has(s.bulletGlyph === "disc" ? "\u2022" : s.bulletGlyph === "dash" ? "-" : "");
|
|
2547
2698
|
const emailOK = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(p.email);
|
|
2548
2699
|
const phoneOK = !p.phone || /(\+?\d[\d\s().-]{6,})/.test(p.phone);
|
|
2549
2700
|
const hasLinkedIn = !!(p.linkedin && /linkedin\.com\/in\//i.test(p.linkedin));
|
|
2550
2701
|
const locationOK = !!p.location && /,/.test(p.location);
|
|
2702
|
+
const nameOK = !!(p.fullName && p.fullName.trim().length >= 2);
|
|
2703
|
+
const hasPhone = !!(p.phone && /(\+?\d[\d\s().-]{6,})/.test(p.phone));
|
|
2704
|
+
const contactParseable = nameOK && (emailOK || hasPhone);
|
|
2551
2705
|
const allDates = [];
|
|
2552
2706
|
for (const sec of resume.sections) for (const it of sec.items || []) {
|
|
2553
2707
|
if (it.startDate) allDates.push(it.startDate);
|
|
@@ -2575,19 +2729,33 @@ function atsChecks(resume, b) {
|
|
|
2575
2729
|
return [
|
|
2576
2730
|
mk(
|
|
2577
2731
|
"E1.single-column",
|
|
2578
|
-
"Single-column layout (ATS-
|
|
2579
|
-
|
|
2580
|
-
|
|
2732
|
+
"Single-column layout (ATS-friendly)",
|
|
2733
|
+
// Proportional soft penalty, not an auto-fail. Modern parsers handle
|
|
2734
|
+
// columns (Textkernel reports ~90% correct column extraction vs ~97%
|
|
2735
|
+
// single-column), but ~1 in 10 still scrambles reading order and columns
|
|
2736
|
+
// cost human skimmability. A single, evidence-scaled ding — the overall
|
|
2737
|
+
// score also takes a small proportional multi-column haircut (see below).
|
|
2738
|
+
// ~0.85 reflects the real ~90% correct column-extraction rate reported by
|
|
2739
|
+
// Textkernel, so the within-dimension ding stays light; the small overall
|
|
2740
|
+
// multi-column haircut in scoreResume carries the actual score effect
|
|
2741
|
+
// (we deliberately don't double-penalize with a 0.55 here).
|
|
2742
|
+
isSingleCol ? 1 : 0.85,
|
|
2743
|
+
isSingleCol ? "Single-column layout. Every ATS parses this cleanly." : "Multi-column template. Modern parsers handle columns about 90% of the time, but a minority scramble reading order, and columns are harder for a recruiter to skim.",
|
|
2581
2744
|
[],
|
|
2582
|
-
!isSingleCol ? "
|
|
2583
|
-
|
|
2745
|
+
!isSingleCol ? "Exporting via the ATS PDF button always outputs a single-column, parse-clean file, so you can keep this template on screen and still submit a safe PDF. Or switch to a single-column template (Jake, Harvard, Classic) for a single source." : void 0,
|
|
2746
|
+
"soft"
|
|
2584
2747
|
),
|
|
2585
2748
|
mk(
|
|
2586
|
-
"E2.contact-
|
|
2587
|
-
"
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2749
|
+
"E2.contact-parseable",
|
|
2750
|
+
"Name + contact details are parseable",
|
|
2751
|
+
// Hard dependency: parsers build the candidate record from a name plus at
|
|
2752
|
+
// least one of email/phone (Daxtra: without one of these the parse fails).
|
|
2753
|
+
contactParseable ? 1 : 0,
|
|
2754
|
+
contactParseable ? "Full name and at least one contact channel (email or phone) are present." : !nameOK ? "No full name detected. Parsers need a name plus an email or phone to create a candidate record." : "No email or phone detected. Most parsers reject a record that has neither.",
|
|
2755
|
+
[],
|
|
2756
|
+
!contactParseable ? "Add your full name and at least one of email or phone. Without these, many ATS drop the whole resume." : void 0,
|
|
2757
|
+
contactParseable ? "soft" : "hard",
|
|
2758
|
+
2
|
|
2591
2759
|
),
|
|
2592
2760
|
mk(
|
|
2593
2761
|
"E3.email-valid",
|
|
@@ -2620,7 +2788,9 @@ function atsChecks(resume, b) {
|
|
|
2620
2788
|
hasLinkedIn ? 1 : 0,
|
|
2621
2789
|
hasLinkedIn ? "LinkedIn URL detected." : "LinkedIn URL missing or non-canonical.",
|
|
2622
2790
|
[],
|
|
2623
|
-
!hasLinkedIn ? "Add a linkedin.com/in/your-handle URL." : void 0
|
|
2791
|
+
!hasLinkedIn ? "Add a linkedin.com/in/your-handle URL. Resumes with one see materially higher recruiter response." : void 0,
|
|
2792
|
+
void 0,
|
|
2793
|
+
1.5
|
|
2624
2794
|
),
|
|
2625
2795
|
mk(
|
|
2626
2796
|
"E7.date-format",
|
|
@@ -2644,7 +2814,9 @@ function atsChecks(resume, b) {
|
|
|
2644
2814
|
safeBullet ? 1 : 0.6,
|
|
2645
2815
|
safeBullet ? "Bullet glyph is ATS-safe." : `Bullet glyph '${s.bulletGlyph}' may not survive every ATS parser.`,
|
|
2646
2816
|
[],
|
|
2647
|
-
!safeBullet ? "Switch bullet glyph to disc, dash, or square (Customize \u2192 Layout \u2192 bullet glyph)." : void 0
|
|
2817
|
+
!safeBullet ? "Switch bullet glyph to disc, dash, or square (Customize \u2192 Layout \u2192 bullet glyph)." : void 0,
|
|
2818
|
+
void 0,
|
|
2819
|
+
0.5
|
|
2648
2820
|
),
|
|
2649
2821
|
mk(
|
|
2650
2822
|
"E10.font-safe",
|
|
@@ -2652,15 +2824,19 @@ function atsChecks(resume, b) {
|
|
|
2652
2824
|
isSafeFont ? 1 : 0.5,
|
|
2653
2825
|
isSafeFont ? "Fonts are ATS-safe." : `Heading '${s.fontHeading}' or body '${s.fontBody}' may not be embedded by some PDF renderers.`,
|
|
2654
2826
|
[],
|
|
2655
|
-
!isSafeFont ? "Use Arial, Calibri, Helvetica, Garamond, or one of the safe defaults." : void 0
|
|
2827
|
+
!isSafeFont ? "Use Arial, Calibri, Helvetica, Garamond, or one of the safe defaults." : void 0,
|
|
2828
|
+
void 0,
|
|
2829
|
+
0.5
|
|
2656
2830
|
),
|
|
2657
2831
|
mk(
|
|
2658
2832
|
"E11.font-size",
|
|
2659
2833
|
"Body font size 10\u201312pt",
|
|
2660
|
-
|
|
2661
|
-
|
|
2834
|
+
fontSizeIdeal ? 1 : fontSizeAcceptable ? 0.75 : 0.5,
|
|
2835
|
+
fontSizeIdeal ? `Font size ${s.fontSize}pt is in the ideal 10\u201312pt band.` : fontSizeAcceptable ? `Font size ${s.fontSize}pt is readable but outside the ideal 10\u201312pt band.` : `Font size ${s.fontSize}pt is outside the readable 9.5\u201312.5pt range.`,
|
|
2662
2836
|
[],
|
|
2663
|
-
!
|
|
2837
|
+
!fontSizeIdeal ? "Set body size to 10\u201312pt for the best balance of density and legibility." : void 0,
|
|
2838
|
+
void 0,
|
|
2839
|
+
0.5
|
|
2664
2840
|
),
|
|
2665
2841
|
mk(
|
|
2666
2842
|
"E12.no-killer-chars",
|
|
@@ -2685,7 +2861,7 @@ function skillsChecks(resume, b) {
|
|
|
2685
2861
|
if (t.length < 3) continue;
|
|
2686
2862
|
counts.set(t, (counts.get(t) || 0) + 1);
|
|
2687
2863
|
}
|
|
2688
|
-
const stuffed = [...counts.entries()].filter(([k, v]) => v >
|
|
2864
|
+
const stuffed = [...counts.entries()].filter(([k, v]) => v > 8 && /^[a-z]+$/.test(k) && !["with", "from", "into", "over", "under", "across", "by", "to", "for", "the", "and", "of", "on", "in", "a", "an", "that", "this", "was", "were", "are", "have", "has"].includes(k));
|
|
2689
2865
|
const allText = b.bullets.map((x) => x.text).join(" ");
|
|
2690
2866
|
const acronyms = [...new Set(Array.from(allText.matchAll(/\b([A-Z]{2,5})\b/g)).map((m) => m[1]))].slice(0, 5);
|
|
2691
2867
|
const expanded = acronyms.filter((a) => new RegExp(`\\b${a}\\s*\\(`).test(allText));
|
|
@@ -2720,7 +2896,7 @@ function skillsChecks(resume, b) {
|
|
|
2720
2896
|
stuffed.length === 0 ? 1 : 1 - Math.min(1, stuffed.length / 3),
|
|
2721
2897
|
stuffed.length === 0 ? "No token over-repeated." : `Over-repeated: ${stuffed.slice(0, 4).map(([k, v]) => `${k}\xD7${v}`).join(", ")}.`,
|
|
2722
2898
|
[],
|
|
2723
|
-
stuffed.length ? "Diversify language
|
|
2899
|
+
stuffed.length ? "Diversify your language. Repeating the same keyword more than 8 times reads as stuffing, and frequency past the first mention does not help ATS ranking anyway." : void 0
|
|
2724
2900
|
),
|
|
2725
2901
|
mk(
|
|
2726
2902
|
"F5.acronym-expansion",
|
|
@@ -2728,7 +2904,9 @@ function skillsChecks(resume, b) {
|
|
|
2728
2904
|
acronyms.length === 0 ? 1 : expanded.length / acronyms.length,
|
|
2729
2905
|
acronyms.length === 0 ? "No acronyms detected." : `${expanded.length} of ${acronyms.length} top acronyms have an inline expansion.`,
|
|
2730
2906
|
[],
|
|
2731
|
-
acronyms.length > 0 && expanded.length < acronyms.length ? `Expand at least once: e.g. '${acronyms[0]} (\u2026)'.` : void 0
|
|
2907
|
+
acronyms.length > 0 && expanded.length < acronyms.length ? `Expand at least once: e.g. '${acronyms[0]} (\u2026)'. Recruiter search is exact-match, so 'ML (Machine Learning)' catches both queries.` : void 0,
|
|
2908
|
+
void 0,
|
|
2909
|
+
0.5
|
|
2732
2910
|
)
|
|
2733
2911
|
];
|
|
2734
2912
|
}
|
|
@@ -2743,6 +2921,10 @@ function polishChecks(resume, b) {
|
|
|
2743
2921
|
const linkedinGeneric = p.linkedin && /\/in\/[a-z0-9]{8,}-[a-z0-9]{4,}/i.test(p.linkedin) && !/[a-zA-Z]/.test(p.linkedin.split("/in/")[1]?.split(/[-?#]/)[0] || "");
|
|
2744
2922
|
const hasCustomWebsite = !!p.website && !/(linkedin\.com|github\.com|facebook\.com|twitter\.com|x\.com)/i.test(p.website);
|
|
2745
2923
|
const hasHeadline = !!p.title;
|
|
2924
|
+
const rawHtml = resume.sections.flatMap((sec) => (sec.items || []).flatMap((it) => [it.description, it.body].filter(Boolean))).join(" ");
|
|
2925
|
+
const HIDDEN_STYLE_RE = /color\s*:\s*(#fff(fff)?|#ffffff|white|rgba?\(\s*255\s*,\s*255\s*,\s*255)|font-size\s*:\s*0|opacity\s*:\s*0|visibility\s*:\s*hidden|display\s*:\s*none/i;
|
|
2926
|
+
const PROMPT_INJECT_RE = /\b(ignore (all )?(the )?previous|as an ai|you are an ai|rate (this|the|me|my) (resume|candidate|application) (highly|as|a perfect|10)|you must (rate|score|recommend)|system prompt|disregard (the )?instructions|hiring manager must)\b/i;
|
|
2927
|
+
const hasHiddenText = HIDDEN_STYLE_RE.test(rawHtml) || PROMPT_INJECT_RE.test(stripHtml2(rawHtml).toLowerCase());
|
|
2746
2928
|
return [
|
|
2747
2929
|
mk(
|
|
2748
2930
|
"G1.no-placeholders",
|
|
@@ -2787,14 +2969,28 @@ function polishChecks(resume, b) {
|
|
|
2787
2969
|
mk(
|
|
2788
2970
|
"G6.custom-portfolio",
|
|
2789
2971
|
"Custom portfolio or personal site (nice-to-have)",
|
|
2790
|
-
hasCustomWebsite ? 1 : 0.
|
|
2791
|
-
hasCustomWebsite ? "Custom site present." : "No personal site listed.",
|
|
2972
|
+
hasCustomWebsite ? 1 : 0.9,
|
|
2973
|
+
hasCustomWebsite ? "Custom site present." : "No personal site listed. Optional, not a deduction.",
|
|
2974
|
+
[],
|
|
2975
|
+
hasCustomWebsite ? void 0 : "A custom domain (yourname.dev) lifts a senior resume \u2014 optional.",
|
|
2976
|
+
void 0,
|
|
2977
|
+
0.5
|
|
2978
|
+
),
|
|
2979
|
+
mk(
|
|
2980
|
+
"G8.hidden-text",
|
|
2981
|
+
"No hidden text or keyword tricks",
|
|
2982
|
+
hasHiddenText ? 0 : 1,
|
|
2983
|
+
hasHiddenText ? "Hidden or invisible text (white-on-white, zero-size) or an AI-instruction string was detected. ATS convert your file to plain text, so these become visible and read as gaming the system." : "No hidden text or prompt-injection tricks detected.",
|
|
2792
2984
|
[],
|
|
2793
|
-
|
|
2985
|
+
hasHiddenText ? "Remove any white/invisible text, zero-size fonts, or 'rate me highly'-style instructions. They backfire once the ATS strips formatting." : void 0,
|
|
2986
|
+
// Hard severity: hidden text / prompt-injection is the one trick that
|
|
2987
|
+
// genuinely backfires, so a positive detection caps the overall score.
|
|
2988
|
+
"hard",
|
|
2989
|
+
2
|
|
2794
2990
|
)
|
|
2795
2991
|
];
|
|
2796
2992
|
}
|
|
2797
|
-
function mk(id, label, score, message, evidence, suggestion, severity) {
|
|
2993
|
+
function mk(id, label, score, message, evidence, suggestion, severity, weight) {
|
|
2798
2994
|
const s = Math.max(0, Math.min(1, score));
|
|
2799
2995
|
return {
|
|
2800
2996
|
id,
|
|
@@ -2804,7 +3000,8 @@ function mk(id, label, score, message, evidence, suggestion, severity) {
|
|
|
2804
3000
|
message,
|
|
2805
3001
|
evidence: evidence ?? [],
|
|
2806
3002
|
suggestion: suggestion ?? null,
|
|
2807
|
-
severity: severity || "soft"
|
|
3003
|
+
severity: severity || "soft",
|
|
3004
|
+
weight: weight ?? 1
|
|
2808
3005
|
};
|
|
2809
3006
|
}
|
|
2810
3007
|
function parseMMYYYY(d) {
|
|
@@ -2832,11 +3029,62 @@ function experienceYears(resume) {
|
|
|
2832
3029
|
}
|
|
2833
3030
|
return Math.floor(months / 12);
|
|
2834
3031
|
}
|
|
3032
|
+
function computeFlags(resume) {
|
|
3033
|
+
const flags = [];
|
|
3034
|
+
const p = resume.personal;
|
|
3035
|
+
const emailOK = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(p.email);
|
|
3036
|
+
const hasPhone = !!(p.phone && /(\+?\d[\d\s().-]{6,})/.test(p.phone));
|
|
3037
|
+
const nameOK = !!(p.fullName && p.fullName.trim().length >= 2);
|
|
3038
|
+
if (!(nameOK && (emailOK || hasPhone))) {
|
|
3039
|
+
flags.push({
|
|
3040
|
+
id: "K1.contact-unparseable",
|
|
3041
|
+
severity: "block",
|
|
3042
|
+
title: "Contact details can't be parsed",
|
|
3043
|
+
detail: !nameOK ? "No full name is set. ATS build the candidate record from a name plus an email or phone, so without a name the whole resume can be dropped." : "No valid email or phone is set. Most parsers reject a record that has neither.",
|
|
3044
|
+
suggestion: "Add your full name and at least one of a valid email or phone."
|
|
3045
|
+
});
|
|
3046
|
+
}
|
|
3047
|
+
const expSec = resume.sections.find((s) => s.type === "experience");
|
|
3048
|
+
const expItems = (expSec?.items || []).filter((it) => it.visible !== false);
|
|
3049
|
+
const datelessRoles = expItems.filter((it) => !parseMMYYYY(it.startDate || ""));
|
|
3050
|
+
if (expItems.length > 0 && datelessRoles.length > 0) {
|
|
3051
|
+
flags.push({
|
|
3052
|
+
id: "K2.role-without-dates",
|
|
3053
|
+
severity: "risk",
|
|
3054
|
+
title: `${datelessRoles.length} role${datelessRoles.length === 1 ? "" : "s"} missing dates`,
|
|
3055
|
+
detail: "A role with no start date can't be ordered or counted toward years of experience by a parser, and years-of-experience filters are a top recruiter screen.",
|
|
3056
|
+
suggestion: "Add a start and end date (MM/YYYY or Month YYYY) to every role."
|
|
3057
|
+
});
|
|
3058
|
+
}
|
|
3059
|
+
const nowMonths = (/* @__PURE__ */ new Date()).getFullYear() * 12 + ((/* @__PURE__ */ new Date()).getMonth() + 1);
|
|
3060
|
+
const ranges = expItems.map((it) => ({ start: parseMMYYYY(it.startDate || ""), end: it.current ? nowMonths : parseMMYYYY(it.endDate || it.startDate || "") })).filter((r) => r.start && r.end);
|
|
3061
|
+
ranges.sort((a, b) => b.start - a.start);
|
|
3062
|
+
let maxGap = 0;
|
|
3063
|
+
for (let i = 0; i < ranges.length - 1; i++) {
|
|
3064
|
+
const gap = ranges[i].start - ranges[i + 1].end;
|
|
3065
|
+
if (gap > maxGap) maxGap = gap;
|
|
3066
|
+
}
|
|
3067
|
+
if (maxGap > 6) {
|
|
3068
|
+
flags.push({
|
|
3069
|
+
id: "K3.employment-gap",
|
|
3070
|
+
severity: "risk",
|
|
3071
|
+
title: `About a ${maxGap}-month employment gap`,
|
|
3072
|
+
detail: "Some employers screen for gaps over 6 months. A gap is legitimate, but an unexplained one lowers callback odds.",
|
|
3073
|
+
suggestion: "Add a one-line note (contract, study, caregiving) or a short bridging entry so the gap does not read as unexplained."
|
|
3074
|
+
});
|
|
3075
|
+
}
|
|
3076
|
+
return flags;
|
|
3077
|
+
}
|
|
2835
3078
|
function aggregate(checks, weight) {
|
|
2836
3079
|
if (!checks.length) return { score: 0, weighted: 0 };
|
|
2837
|
-
|
|
2838
|
-
const
|
|
2839
|
-
|
|
3080
|
+
let wsum = 0, wtot = 0;
|
|
3081
|
+
for (const c of checks) {
|
|
3082
|
+
const w = c.weight ?? 1;
|
|
3083
|
+
wsum += c.score * w;
|
|
3084
|
+
wtot += w;
|
|
3085
|
+
}
|
|
3086
|
+
const mean = wtot ? wsum / wtot : 0;
|
|
3087
|
+
return { score: Math.round(mean * 100), weighted: mean * weight };
|
|
2840
3088
|
}
|
|
2841
3089
|
var WEIGHTS = {
|
|
2842
3090
|
impact: 22,
|
|
@@ -2856,7 +3104,7 @@ var LABELS = {
|
|
|
2856
3104
|
skills: "Skills & Keywords",
|
|
2857
3105
|
polish: "Completeness & Polish"
|
|
2858
3106
|
};
|
|
2859
|
-
function scoreResume(resume) {
|
|
3107
|
+
function scoreResume(resume, opts) {
|
|
2860
3108
|
const b = gather(resume);
|
|
2861
3109
|
const dims = [
|
|
2862
3110
|
{ dim: "impact", checks: impactChecks(resume, b) },
|
|
@@ -2886,12 +3134,30 @@ function scoreResume(resume) {
|
|
|
2886
3134
|
};
|
|
2887
3135
|
});
|
|
2888
3136
|
let overall = Math.round(weightedSum / totalWeight * 100);
|
|
3137
|
+
if (isMultiColumnTemplate(resume.styling.template)) {
|
|
3138
|
+
overall = Math.round(overall * 0.93);
|
|
3139
|
+
}
|
|
2889
3140
|
const hardFail = dimensions.some((d) => d.checks.some((c) => c.severity === "hard" && c.status === "fail"));
|
|
2890
3141
|
if (hardFail) overall = Math.min(overall, 60);
|
|
3142
|
+
const parseFindings = opts?.parseFindings ?? [];
|
|
3143
|
+
const parseHard = parseFindings.some((f) => f.severity === "hard");
|
|
3144
|
+
const parseSoftCount = parseFindings.filter((f) => f.severity === "soft").length;
|
|
3145
|
+
if (parseHard) overall = Math.min(overall, 55);
|
|
3146
|
+
if (parseSoftCount) overall = Math.max(0, overall - Math.min(12, parseSoftCount * 4));
|
|
3147
|
+
const flags = [
|
|
3148
|
+
...computeFlags(resume),
|
|
3149
|
+
...parseFindings.map((f) => ({
|
|
3150
|
+
id: f.id,
|
|
3151
|
+
severity: f.severity === "hard" ? "block" : "risk",
|
|
3152
|
+
title: f.title,
|
|
3153
|
+
detail: f.detail,
|
|
3154
|
+
suggestion: f.suggestion ?? null
|
|
3155
|
+
}))
|
|
3156
|
+
];
|
|
2891
3157
|
const sortedDims = [...dimensions].sort((a, b2) => a.score - b2.score);
|
|
2892
3158
|
const worst = sortedDims[0];
|
|
2893
3159
|
const best = sortedDims[sortedDims.length - 1];
|
|
2894
|
-
const allFails = dimensions.flatMap((d) => d.checks.map((c) => ({ ...c, dim: d.dimension, dimLabel: d.label, weight: d.weight }))).filter((c) => c.suggestion && c.status !== "pass").sort((a, b2) =>
|
|
3160
|
+
const allFails = dimensions.flatMap((d) => d.checks.map((c) => ({ ...c, dim: d.dimension, dimLabel: d.label, weight: d.weight }))).filter((c) => c.suggestion && c.status !== "pass").sort((a, b2) => (1 - b2.score) * b2.weight - (1 - a.score) * a.weight);
|
|
2895
3161
|
const priorities = allFails.slice(0, 5);
|
|
2896
3162
|
return {
|
|
2897
3163
|
overall,
|
|
@@ -2900,6 +3166,7 @@ function scoreResume(resume) {
|
|
|
2900
3166
|
summary: `Strongest: ${best.label} (${best.score}). Focus area: ${worst.label} (${worst.score}).`,
|
|
2901
3167
|
priorities,
|
|
2902
3168
|
hardFails: dimensions.flatMap((d) => d.checks.filter((c) => c.severity === "hard" && c.status === "fail").map((c) => ({ ...c, dim: d.dimension }))),
|
|
3169
|
+
flags,
|
|
2903
3170
|
stats: {
|
|
2904
3171
|
totalBullets: b.bullets.length,
|
|
2905
3172
|
experienceBullets: b.expBullets.length,
|
package/package.json
CHANGED