@juicedresume/mcp 0.3.1 → 0.3.2
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 +247 -99
- 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
|
}
|
|
@@ -1368,7 +1430,20 @@ function resumeText(resume) {
|
|
|
1368
1430
|
}
|
|
1369
1431
|
function tailorToJob(resume, jdText) {
|
|
1370
1432
|
const jdToks = tokens(jdText);
|
|
1371
|
-
const
|
|
1433
|
+
const resumeFullText = resumeText(resume).toLowerCase();
|
|
1434
|
+
const resumeToks = new Set(tokens(resumeFullText));
|
|
1435
|
+
const resumeNorm = new Set([...resumeToks].map((t) => normalizeSkill(t).toLowerCase()));
|
|
1436
|
+
function resumeHas(keyword) {
|
|
1437
|
+
const k = keyword.toLowerCase();
|
|
1438
|
+
if (k.includes(" ")) {
|
|
1439
|
+
return new RegExp(`\\b${escapeRe(k)}\\b`).test(resumeFullText);
|
|
1440
|
+
}
|
|
1441
|
+
if (resumeToks.has(k)) return true;
|
|
1442
|
+
const canon = normalizeSkill(keyword).toLowerCase();
|
|
1443
|
+
if (resumeNorm.has(canon)) return true;
|
|
1444
|
+
if (canon.includes(" ") && new RegExp(`\\b${escapeRe(canon)}\\b`).test(resumeFullText)) return true;
|
|
1445
|
+
return false;
|
|
1446
|
+
}
|
|
1372
1447
|
const jdGrams = /* @__PURE__ */ new Set([...jdToks, ...bigrams(jdToks)]);
|
|
1373
1448
|
const skillNames = new Map(SKILLS.map((s) => [s.name.toLowerCase(), s]));
|
|
1374
1449
|
const freq = /* @__PURE__ */ new Map();
|
|
@@ -1384,17 +1459,22 @@ function tailorToJob(resume, jdText) {
|
|
|
1384
1459
|
for (const [k, c] of sortedNonSkill) jdSkills.set(k, { importance: c });
|
|
1385
1460
|
const matched = [];
|
|
1386
1461
|
const missing = [];
|
|
1462
|
+
const kindMult = (kind) => kind === "hard" ? 2 : kind === "soft" ? 1.3 : 1;
|
|
1387
1463
|
for (const [k, meta] of jdSkills) {
|
|
1388
|
-
const
|
|
1389
|
-
if (
|
|
1390
|
-
matched.push({ keyword: k, kind: meta.kind, count:
|
|
1464
|
+
const w = meta.importance * kindMult(meta.kind);
|
|
1465
|
+
if (resumeHas(k)) {
|
|
1466
|
+
matched.push({ keyword: k, kind: meta.kind, count: w });
|
|
1391
1467
|
} else {
|
|
1392
|
-
missing.push({ keyword: k, kind: meta.kind, importance:
|
|
1468
|
+
missing.push({ keyword: k, kind: meta.kind, importance: w });
|
|
1393
1469
|
}
|
|
1394
1470
|
}
|
|
1395
1471
|
const matchedWeight = matched.reduce((a, m) => a + m.count, 0);
|
|
1396
1472
|
const totalWeight = matchedWeight + missing.reduce((a, m) => a + m.importance, 0);
|
|
1397
|
-
const score = totalWeight === 0 ? 0 : Math.round(matchedWeight / totalWeight * 100);
|
|
1473
|
+
const score = totalWeight === 0 ? 0 : Math.min(95, Math.round(matchedWeight / totalWeight * 100));
|
|
1474
|
+
const titleToks = tokens(resume.personal.title || "");
|
|
1475
|
+
const jdTokSet = new Set(jdToks);
|
|
1476
|
+
const titleHits = titleToks.filter((t) => jdTokSet.has(t)).length;
|
|
1477
|
+
const titleMatch = titleToks.length === 0 ? 0 : Math.round(titleHits / titleToks.length * 100);
|
|
1398
1478
|
missing.sort((a, b) => b.importance - a.importance);
|
|
1399
1479
|
matched.sort((a, b) => b.count - a.count);
|
|
1400
1480
|
return {
|
|
@@ -1402,7 +1482,8 @@ function tailorToJob(resume, jdText) {
|
|
|
1402
1482
|
matched,
|
|
1403
1483
|
missing,
|
|
1404
1484
|
jdSkills: jdSkills.size,
|
|
1405
|
-
resumeSkills: resumeToks.size
|
|
1485
|
+
resumeSkills: resumeToks.size,
|
|
1486
|
+
titleMatch
|
|
1406
1487
|
};
|
|
1407
1488
|
}
|
|
1408
1489
|
|
|
@@ -1410,7 +1491,7 @@ function tailorToJob(resume, jdText) {
|
|
|
1410
1491
|
function stripHtml(s) {
|
|
1411
1492
|
return (s || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
1412
1493
|
}
|
|
1413
|
-
function passSoft(id, label, score, message, suggestion) {
|
|
1494
|
+
function passSoft(id, label, score, message, suggestion, weight) {
|
|
1414
1495
|
const s = Math.max(0, Math.min(1, score));
|
|
1415
1496
|
return {
|
|
1416
1497
|
id,
|
|
@@ -1419,12 +1500,10 @@ function passSoft(id, label, score, message, suggestion) {
|
|
|
1419
1500
|
status: s >= 0.85 ? "pass" : s >= 0.55 ? "warn" : "fail",
|
|
1420
1501
|
message,
|
|
1421
1502
|
suggestion: suggestion ?? null,
|
|
1422
|
-
severity: "soft"
|
|
1503
|
+
severity: "soft",
|
|
1504
|
+
weight: weight ?? 1
|
|
1423
1505
|
};
|
|
1424
1506
|
}
|
|
1425
|
-
function hardCheck(id, label, score, message, suggestion) {
|
|
1426
|
-
return { ...passSoft(id, label, score, message, suggestion), severity: "hard" };
|
|
1427
|
-
}
|
|
1428
1507
|
function firstBulletStrongest(resume) {
|
|
1429
1508
|
const exp = resume.sections.find((s) => s.type === "experience");
|
|
1430
1509
|
if (!exp?.items?.length) return null;
|
|
@@ -1447,7 +1526,8 @@ function firstBulletStrongest(resume) {
|
|
|
1447
1526
|
"Lead bullet of recent role is strongest",
|
|
1448
1527
|
score,
|
|
1449
1528
|
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."
|
|
1529
|
+
"Reorder bullets in your most-recent experience so the most quantified / highest-impact bullet is first.",
|
|
1530
|
+
0.5
|
|
1451
1531
|
)
|
|
1452
1532
|
};
|
|
1453
1533
|
}
|
|
@@ -1515,19 +1595,6 @@ function dobAgePenalty(resume) {
|
|
|
1515
1595
|
)
|
|
1516
1596
|
};
|
|
1517
1597
|
}
|
|
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
1598
|
function threeLocationsRule(resume) {
|
|
1532
1599
|
const summary = resume.sections.find((s) => s.type === "summary");
|
|
1533
1600
|
const skills = resume.sections.find((s) => s.type === "skills");
|
|
@@ -1556,42 +1623,13 @@ function threeLocationsRule(resume) {
|
|
|
1556
1623
|
)
|
|
1557
1624
|
};
|
|
1558
1625
|
}
|
|
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
1626
|
function additionalChecks(resume) {
|
|
1587
1627
|
return [
|
|
1588
1628
|
firstBulletStrongest(resume),
|
|
1589
1629
|
dateFormatConsistency(resume),
|
|
1590
1630
|
targetTitle(resume),
|
|
1591
1631
|
dobAgePenalty(resume),
|
|
1592
|
-
|
|
1593
|
-
threeLocationsRule(resume),
|
|
1594
|
-
bulletLengthSweetSpot(resume)
|
|
1632
|
+
threeLocationsRule(resume)
|
|
1595
1633
|
].filter((x) => x !== null);
|
|
1596
1634
|
}
|
|
1597
1635
|
|
|
@@ -2119,6 +2157,11 @@ function impactChecks(resume, b) {
|
|
|
2119
2157
|
const scaleSignals = E.filter((x) => SCALE_RE.test(x.text));
|
|
2120
2158
|
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
2159
|
const timeBound = E.filter((x) => timeBoundRe.test(x.text));
|
|
2160
|
+
const quantifiedEarly = quantified.filter((x) => {
|
|
2161
|
+
const head = x.text.split(/\s+/).slice(0, 8).join(" ");
|
|
2162
|
+
return QUANT_RE.test(head);
|
|
2163
|
+
});
|
|
2164
|
+
const earlyRatio = quantified.length ? quantifiedEarly.length / quantified.length : 0;
|
|
2122
2165
|
return [
|
|
2123
2166
|
mk(
|
|
2124
2167
|
"A1.quantification",
|
|
@@ -2126,15 +2169,20 @@ function impactChecks(resume, b) {
|
|
|
2126
2169
|
qRatio >= 0.6 ? 1 : qRatio >= 0.4 ? 0.7 : qRatio / 0.4,
|
|
2127
2170
|
`${quantified.length} of ${total} experience bullets include numbers, %, $, or counts (${Math.round(qRatio * 100)}%).`,
|
|
2128
2171
|
nonQ.slice(0, 6).map((x) => x.id),
|
|
2129
|
-
qRatio < 0.6 ? "Add concrete metrics: % change, $ amount, users, time saved." : void 0
|
|
2172
|
+
qRatio < 0.6 ? "Add concrete metrics: % change, $ amount, users, time saved." : void 0,
|
|
2173
|
+
void 0,
|
|
2174
|
+
3
|
|
2130
2175
|
),
|
|
2131
2176
|
mk(
|
|
2132
2177
|
"A2.metric-diversity",
|
|
2133
2178
|
"Variety of metric types",
|
|
2134
|
-
|
|
2179
|
+
// Bonus signal: absence is neutral (0.8), presence lifts toward 1.
|
|
2180
|
+
metricTypes.size === 0 ? 0.8 : Math.min(1, 0.8 + metricTypes.size * 0.1),
|
|
2135
2181
|
metricTypes.size === 0 ? "No metric types detected (%, $, time, counts, ratios)." : `Uses ${metricTypes.size} metric type(s): ${[...metricTypes].join(", ")}.`,
|
|
2136
2182
|
[],
|
|
2137
|
-
metricTypes.size < 3 ? "Mix metric flavors: a % gain, a $ saving, and a scale number tells a richer story than three %s." : void 0
|
|
2183
|
+
metricTypes.size < 3 ? "Mix metric flavors: a % gain, a $ saving, and a scale number tells a richer story than three %s." : void 0,
|
|
2184
|
+
void 0,
|
|
2185
|
+
0.5
|
|
2138
2186
|
),
|
|
2139
2187
|
mk(
|
|
2140
2188
|
"A3.xyz-bullets",
|
|
@@ -2142,7 +2190,9 @@ function impactChecks(resume, b) {
|
|
|
2142
2190
|
Math.min(1, xyz.length / Math.max(1, total * 0.4)),
|
|
2143
2191
|
`${xyz.length} of ${total} bullets read as "Accomplished X (measured by Y) by doing Z."`,
|
|
2144
2192
|
[],
|
|
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
|
|
2193
|
+
xyz.length / total < 0.4 ? "Reframe bullets as Action + Measurable Outcome + Method ('Cut LLM cost 35% by redesigning the prompt+cache layer')." : void 0,
|
|
2194
|
+
void 0,
|
|
2195
|
+
2
|
|
2146
2196
|
),
|
|
2147
2197
|
mk(
|
|
2148
2198
|
"A4.responsibility",
|
|
@@ -2158,7 +2208,9 @@ function impactChecks(resume, b) {
|
|
|
2158
2208
|
strongStart.length / total,
|
|
2159
2209
|
`${strongStart.length} of ${total} bullets open with a strong verb (Led, Built, Drove, \u2026).`,
|
|
2160
2210
|
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
|
|
2211
|
+
strongStart.length / total < 0.85 ? "Replace soft openers with strong past-tense verbs." : void 0,
|
|
2212
|
+
void 0,
|
|
2213
|
+
2
|
|
2162
2214
|
),
|
|
2163
2215
|
mk(
|
|
2164
2216
|
"A6.weak-verbs",
|
|
@@ -2179,18 +2231,34 @@ function impactChecks(resume, b) {
|
|
|
2179
2231
|
mk(
|
|
2180
2232
|
"A8.scale-signals",
|
|
2181
2233
|
"Mentions team size, budget, or org scale",
|
|
2182
|
-
|
|
2183
|
-
scaleSignals.length === 0 ?
|
|
2234
|
+
// Bonus signal: absence is neutral, presence lifts the score.
|
|
2235
|
+
scaleSignals.length === 0 ? 0.8 : Math.min(1, 0.8 + scaleSignals.length * 0.1),
|
|
2236
|
+
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
2237
|
[],
|
|
2185
|
-
scaleSignals.length === 0 ? "Add scale: 'Led a team of 6', 'Owned a $2M budget', '60+ engineers depend on this service.'" : void 0
|
|
2238
|
+
scaleSignals.length === 0 ? "Optional boost. Add scale: 'Led a team of 6', 'Owned a $2M budget', '60+ engineers depend on this service.'" : void 0,
|
|
2239
|
+
void 0,
|
|
2240
|
+
0.5
|
|
2186
2241
|
),
|
|
2187
2242
|
mk(
|
|
2188
2243
|
"A9.time-bound",
|
|
2189
2244
|
"Time-bound impact statements",
|
|
2190
|
-
|
|
2191
|
-
timeBound.length === 0 ?
|
|
2245
|
+
// Bonus signal: absence is neutral, presence lifts the score.
|
|
2246
|
+
timeBound.length === 0 ? 0.8 : Math.min(1, 0.8 + timeBound.length * 0.1),
|
|
2247
|
+
timeBound.length === 0 ? "No time-bound results yet. A nice-to-have, not a deduction." : `${timeBound.length} bullet(s) include a timeframe.`,
|
|
2192
2248
|
[],
|
|
2193
|
-
timeBound.length === 0 ? "Anchor results in time: 'within 6 months', 'in 12 weeks', 'over Q3 2025'." : void 0
|
|
2249
|
+
timeBound.length === 0 ? "Optional boost. Anchor results in time: 'within 6 months', 'in 12 weeks', 'over Q3 2025'." : void 0,
|
|
2250
|
+
void 0,
|
|
2251
|
+
0.5
|
|
2252
|
+
),
|
|
2253
|
+
mk(
|
|
2254
|
+
"A10.metric-position",
|
|
2255
|
+
"Metrics appear early in the bullet",
|
|
2256
|
+
quantified.length === 0 ? 0.8 : earlyRatio,
|
|
2257
|
+
quantified.length === 0 ? "No quantified bullets yet to position." : `${quantifiedEarly.length} of ${quantified.length} quantified bullets lead with the number.`,
|
|
2258
|
+
[],
|
|
2259
|
+
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,
|
|
2260
|
+
void 0,
|
|
2261
|
+
1
|
|
2194
2262
|
)
|
|
2195
2263
|
];
|
|
2196
2264
|
}
|
|
@@ -2222,7 +2290,7 @@ function brevityChecks(resume, b) {
|
|
|
2222
2290
|
return r.bullets > 5;
|
|
2223
2291
|
});
|
|
2224
2292
|
const pages = Math.max(1, Math.ceil(totalWords / 450));
|
|
2225
|
-
const targetPages = experienceYears(resume)
|
|
2293
|
+
const targetPages = experienceYears(resume) < 5 ? 1 : 2;
|
|
2226
2294
|
const lengthOK = pages <= targetPages;
|
|
2227
2295
|
return [
|
|
2228
2296
|
mk(
|
|
@@ -2255,7 +2323,7 @@ function brevityChecks(resume, b) {
|
|
|
2255
2323
|
mk(
|
|
2256
2324
|
"B4.page-length",
|
|
2257
2325
|
`Resume length fits ${targetPages} page${targetPages > 1 ? "s" : ""}`,
|
|
2258
|
-
lengthOK ? 1 : Math.max(0, 1 - (pages - targetPages) * 0.
|
|
2326
|
+
lengthOK ? 1 : Math.max(0, 1 - (pages - targetPages) * 0.35),
|
|
2259
2327
|
`Estimated ${pages} page${pages > 1 ? "s" : ""} (${totalWords} words total).`,
|
|
2260
2328
|
[],
|
|
2261
2329
|
!lengthOK ? `Cut ~${(pages - targetPages) * 450} words to fit ${targetPages} page${targetPages > 1 ? "s" : ""}.` : void 0
|
|
@@ -2353,7 +2421,9 @@ function styleChecks(resume, b) {
|
|
|
2353
2421
|
passive.length / total <= 0.15 ? 1 : 1 - (passive.length / total - 0.15) * 2,
|
|
2354
2422
|
`${passive.length} of ${total} bullets read as passive voice (${Math.round(passive.length / total * 100)}%).`,
|
|
2355
2423
|
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
|
|
2424
|
+
passive.length / total > 0.15 ? "Flip subject/object: 'The project was led by me' \u2192 'Led the project.'" : void 0,
|
|
2425
|
+
void 0,
|
|
2426
|
+
1.5
|
|
2357
2427
|
),
|
|
2358
2428
|
mk(
|
|
2359
2429
|
"C3.tense-consistency",
|
|
@@ -2393,7 +2463,9 @@ function styleChecks(resume, b) {
|
|
|
2393
2463
|
periodConsistent ? 1 : 0.5,
|
|
2394
2464
|
periodConsistent ? "All bullets agree on whether they end with a period." : `Mixed: ${endsWithPeriod} end with a period, ${endsWithout} don't.`,
|
|
2395
2465
|
[],
|
|
2396
|
-
!periodConsistent ? "Pick one rule for the whole resume." : void 0
|
|
2466
|
+
!periodConsistent ? "Pick one rule for the whole resume." : void 0,
|
|
2467
|
+
void 0,
|
|
2468
|
+
0.4
|
|
2397
2469
|
),
|
|
2398
2470
|
mk(
|
|
2399
2471
|
"C8.punctuation-chars",
|
|
@@ -2401,7 +2473,9 @@ function styleChecks(resume, b) {
|
|
|
2401
2473
|
punctConsistent ? 1 : 0.5,
|
|
2402
2474
|
punctConsistent ? "Punctuation glyphs are consistent." : "Mixes smart quotes/em-dashes with ASCII equivalents.",
|
|
2403
2475
|
[],
|
|
2404
|
-
!punctConsistent ? 'Pick either smart quotes (\u201C\u201D) or straight quotes (") consistently.' : void 0
|
|
2476
|
+
!punctConsistent ? 'Pick either smart quotes (\u201C\u201D) or straight quotes (") consistently.' : void 0,
|
|
2477
|
+
void 0,
|
|
2478
|
+
0.4
|
|
2405
2479
|
),
|
|
2406
2480
|
mk(
|
|
2407
2481
|
"C9.capitalization",
|
|
@@ -2409,7 +2483,9 @@ function styleChecks(resume, b) {
|
|
|
2409
2483
|
capConsistent ? 1 : 0.5,
|
|
2410
2484
|
capConsistent ? "Every bullet starts with the same case." : `${startsCap} of ${total} bullets start with a capital letter.`,
|
|
2411
2485
|
[],
|
|
2412
|
-
!capConsistent ? "Capitalize the first letter of every bullet." : void 0
|
|
2486
|
+
!capConsistent ? "Capitalize the first letter of every bullet." : void 0,
|
|
2487
|
+
void 0,
|
|
2488
|
+
0.4
|
|
2413
2489
|
),
|
|
2414
2490
|
mk(
|
|
2415
2491
|
"C10.acronym-density",
|
|
@@ -2464,6 +2540,20 @@ function structureChecks(resume, b) {
|
|
|
2464
2540
|
}
|
|
2465
2541
|
const expRev = expSec ? rev(expSec.items || []) : true;
|
|
2466
2542
|
const eduRev = eduSec ? rev(eduSec.items || []) : true;
|
|
2543
|
+
const nowMonths = (/* @__PURE__ */ new Date()).getFullYear() * 12 + ((/* @__PURE__ */ new Date()).getMonth() + 1);
|
|
2544
|
+
let maxGapMonths = 0;
|
|
2545
|
+
if (expSec) {
|
|
2546
|
+
const ranges = (expSec.items || []).map((it) => ({
|
|
2547
|
+
start: parseMMYYYY(it.startDate || ""),
|
|
2548
|
+
end: it.current ? nowMonths : parseMMYYYY(it.endDate || it.startDate || "")
|
|
2549
|
+
})).filter((r) => r.start && r.end);
|
|
2550
|
+
ranges.sort((a, b2) => b2.start - a.start);
|
|
2551
|
+
for (let i = 0; i < ranges.length - 1; i++) {
|
|
2552
|
+
const gap = ranges[i].start - ranges[i + 1].end;
|
|
2553
|
+
if (gap > maxGapMonths) maxGapMonths = gap;
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
const gapOK = maxGapMonths <= 6;
|
|
2467
2557
|
function isCanonical(type, title) {
|
|
2468
2558
|
const t = title.toLowerCase().trim();
|
|
2469
2559
|
const list = ATS_SECTION_LABELS[type] || [];
|
|
@@ -2533,6 +2623,16 @@ function structureChecks(resume, b) {
|
|
|
2533
2623
|
expRev && eduRev ? "Both sections are reverse-chronological." : `${!expRev ? "Experience" : ""}${!expRev && !eduRev ? " & " : ""}${!eduRev ? "Education" : ""} out of order.`,
|
|
2534
2624
|
[],
|
|
2535
2625
|
!expRev || !eduRev ? "List most recent first." : void 0
|
|
2626
|
+
),
|
|
2627
|
+
mk(
|
|
2628
|
+
"D9.employment-gaps",
|
|
2629
|
+
"No large unexplained employment gaps",
|
|
2630
|
+
gapOK ? 1 : 0.7,
|
|
2631
|
+
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.`,
|
|
2632
|
+
[],
|
|
2633
|
+
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.",
|
|
2634
|
+
void 0,
|
|
2635
|
+
0.5
|
|
2536
2636
|
)
|
|
2537
2637
|
];
|
|
2538
2638
|
}
|
|
@@ -2548,6 +2648,9 @@ function atsChecks(resume, b) {
|
|
|
2548
2648
|
const phoneOK = !p.phone || /(\+?\d[\d\s().-]{6,})/.test(p.phone);
|
|
2549
2649
|
const hasLinkedIn = !!(p.linkedin && /linkedin\.com\/in\//i.test(p.linkedin));
|
|
2550
2650
|
const locationOK = !!p.location && /,/.test(p.location);
|
|
2651
|
+
const nameOK = !!(p.fullName && p.fullName.trim().length >= 2);
|
|
2652
|
+
const hasPhone = !!(p.phone && /(\+?\d[\d\s().-]{6,})/.test(p.phone));
|
|
2653
|
+
const contactParseable = nameOK && (emailOK || hasPhone);
|
|
2551
2654
|
const allDates = [];
|
|
2552
2655
|
for (const sec of resume.sections) for (const it of sec.items || []) {
|
|
2553
2656
|
if (it.startDate) allDates.push(it.startDate);
|
|
@@ -2575,19 +2678,29 @@ function atsChecks(resume, b) {
|
|
|
2575
2678
|
return [
|
|
2576
2679
|
mk(
|
|
2577
2680
|
"E1.single-column",
|
|
2578
|
-
"Single-column layout (ATS-
|
|
2579
|
-
|
|
2580
|
-
|
|
2681
|
+
"Single-column layout (ATS-friendly)",
|
|
2682
|
+
// Proportional soft penalty, not an auto-fail. Modern parsers handle
|
|
2683
|
+
// columns (Textkernel reports ~90% correct column extraction vs ~97%
|
|
2684
|
+
// single-column), but ~1 in 10 still scrambles reading order and columns
|
|
2685
|
+
// cost human skimmability. A single, evidence-scaled ding — the overall
|
|
2686
|
+
// score also takes a small proportional multi-column haircut (see below).
|
|
2687
|
+
isSingleCol ? 1 : 0.55,
|
|
2688
|
+
isSingleCol ? "Single-column layout \u2014 every ATS parses this cleanly." : "Multi-column template. Modern parsers usually handle columns, but a minority still scramble reading order, and columns are harder for a recruiter to skim. Single-column is the zero-risk choice.",
|
|
2581
2689
|
[],
|
|
2582
|
-
!isSingleCol ? "
|
|
2583
|
-
|
|
2690
|
+
!isSingleCol ? "For maximum ATS safety, switch to a single-column template (Jake, Harvard, Classic) in the Templates tab. Not required, but it removes all parse risk." : void 0,
|
|
2691
|
+
"soft"
|
|
2584
2692
|
),
|
|
2585
2693
|
mk(
|
|
2586
|
-
"E2.contact-
|
|
2587
|
-
"
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2694
|
+
"E2.contact-parseable",
|
|
2695
|
+
"Name + contact details are parseable",
|
|
2696
|
+
// Hard dependency: parsers build the candidate record from a name plus at
|
|
2697
|
+
// least one of email/phone (Daxtra: without one of these the parse fails).
|
|
2698
|
+
contactParseable ? 1 : 0,
|
|
2699
|
+
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.",
|
|
2700
|
+
[],
|
|
2701
|
+
!contactParseable ? "Add your full name and at least one of email or phone. Without these, many ATS drop the whole resume." : void 0,
|
|
2702
|
+
contactParseable ? "soft" : "hard",
|
|
2703
|
+
2
|
|
2591
2704
|
),
|
|
2592
2705
|
mk(
|
|
2593
2706
|
"E3.email-valid",
|
|
@@ -2620,7 +2733,9 @@ function atsChecks(resume, b) {
|
|
|
2620
2733
|
hasLinkedIn ? 1 : 0,
|
|
2621
2734
|
hasLinkedIn ? "LinkedIn URL detected." : "LinkedIn URL missing or non-canonical.",
|
|
2622
2735
|
[],
|
|
2623
|
-
!hasLinkedIn ? "Add a linkedin.com/in/your-handle URL." : void 0
|
|
2736
|
+
!hasLinkedIn ? "Add a linkedin.com/in/your-handle URL. Resumes with one see materially higher recruiter response." : void 0,
|
|
2737
|
+
void 0,
|
|
2738
|
+
1.5
|
|
2624
2739
|
),
|
|
2625
2740
|
mk(
|
|
2626
2741
|
"E7.date-format",
|
|
@@ -2644,7 +2759,9 @@ function atsChecks(resume, b) {
|
|
|
2644
2759
|
safeBullet ? 1 : 0.6,
|
|
2645
2760
|
safeBullet ? "Bullet glyph is ATS-safe." : `Bullet glyph '${s.bulletGlyph}' may not survive every ATS parser.`,
|
|
2646
2761
|
[],
|
|
2647
|
-
!safeBullet ? "Switch bullet glyph to disc, dash, or square (Customize \u2192 Layout \u2192 bullet glyph)." : void 0
|
|
2762
|
+
!safeBullet ? "Switch bullet glyph to disc, dash, or square (Customize \u2192 Layout \u2192 bullet glyph)." : void 0,
|
|
2763
|
+
void 0,
|
|
2764
|
+
0.5
|
|
2648
2765
|
),
|
|
2649
2766
|
mk(
|
|
2650
2767
|
"E10.font-safe",
|
|
@@ -2652,7 +2769,9 @@ function atsChecks(resume, b) {
|
|
|
2652
2769
|
isSafeFont ? 1 : 0.5,
|
|
2653
2770
|
isSafeFont ? "Fonts are ATS-safe." : `Heading '${s.fontHeading}' or body '${s.fontBody}' may not be embedded by some PDF renderers.`,
|
|
2654
2771
|
[],
|
|
2655
|
-
!isSafeFont ? "Use Arial, Calibri, Helvetica, Garamond, or one of the safe defaults." : void 0
|
|
2772
|
+
!isSafeFont ? "Use Arial, Calibri, Helvetica, Garamond, or one of the safe defaults." : void 0,
|
|
2773
|
+
void 0,
|
|
2774
|
+
0.5
|
|
2656
2775
|
),
|
|
2657
2776
|
mk(
|
|
2658
2777
|
"E11.font-size",
|
|
@@ -2660,7 +2779,9 @@ function atsChecks(resume, b) {
|
|
|
2660
2779
|
fontSizeOK ? 1 : 0.5,
|
|
2661
2780
|
fontSizeOK ? `Font size ${s.fontSize}pt is in range.` : `Font size ${s.fontSize}pt is outside the 10\u201312pt range.`,
|
|
2662
2781
|
[],
|
|
2663
|
-
!fontSizeOK ? "Set body size to 10\u201312pt." : void 0
|
|
2782
|
+
!fontSizeOK ? "Set body size to 10\u201312pt." : void 0,
|
|
2783
|
+
void 0,
|
|
2784
|
+
0.5
|
|
2664
2785
|
),
|
|
2665
2786
|
mk(
|
|
2666
2787
|
"E12.no-killer-chars",
|
|
@@ -2685,7 +2806,7 @@ function skillsChecks(resume, b) {
|
|
|
2685
2806
|
if (t.length < 3) continue;
|
|
2686
2807
|
counts.set(t, (counts.get(t) || 0) + 1);
|
|
2687
2808
|
}
|
|
2688
|
-
const stuffed = [...counts.entries()].filter(([k, v]) => v >
|
|
2809
|
+
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
2810
|
const allText = b.bullets.map((x) => x.text).join(" ");
|
|
2690
2811
|
const acronyms = [...new Set(Array.from(allText.matchAll(/\b([A-Z]{2,5})\b/g)).map((m) => m[1]))].slice(0, 5);
|
|
2691
2812
|
const expanded = acronyms.filter((a) => new RegExp(`\\b${a}\\s*\\(`).test(allText));
|
|
@@ -2728,7 +2849,9 @@ function skillsChecks(resume, b) {
|
|
|
2728
2849
|
acronyms.length === 0 ? 1 : expanded.length / acronyms.length,
|
|
2729
2850
|
acronyms.length === 0 ? "No acronyms detected." : `${expanded.length} of ${acronyms.length} top acronyms have an inline expansion.`,
|
|
2730
2851
|
[],
|
|
2731
|
-
acronyms.length > 0 && expanded.length < acronyms.length ? `Expand at least once: e.g. '${acronyms[0]} (\u2026)'.` : void 0
|
|
2852
|
+
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,
|
|
2853
|
+
void 0,
|
|
2854
|
+
0.5
|
|
2732
2855
|
)
|
|
2733
2856
|
];
|
|
2734
2857
|
}
|
|
@@ -2743,6 +2866,10 @@ function polishChecks(resume, b) {
|
|
|
2743
2866
|
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
2867
|
const hasCustomWebsite = !!p.website && !/(linkedin\.com|github\.com|facebook\.com|twitter\.com|x\.com)/i.test(p.website);
|
|
2745
2868
|
const hasHeadline = !!p.title;
|
|
2869
|
+
const rawHtml = resume.sections.flatMap((sec) => (sec.items || []).flatMap((it) => [it.description, it.body].filter(Boolean))).join(" ");
|
|
2870
|
+
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;
|
|
2871
|
+
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;
|
|
2872
|
+
const hasHiddenText = HIDDEN_STYLE_RE.test(rawHtml) || PROMPT_INJECT_RE.test(stripHtml2(rawHtml).toLowerCase());
|
|
2746
2873
|
return [
|
|
2747
2874
|
mk(
|
|
2748
2875
|
"G1.no-placeholders",
|
|
@@ -2787,14 +2914,26 @@ function polishChecks(resume, b) {
|
|
|
2787
2914
|
mk(
|
|
2788
2915
|
"G6.custom-portfolio",
|
|
2789
2916
|
"Custom portfolio or personal site (nice-to-have)",
|
|
2790
|
-
hasCustomWebsite ? 1 : 0.
|
|
2791
|
-
hasCustomWebsite ? "Custom site present." : "No personal site listed.",
|
|
2917
|
+
hasCustomWebsite ? 1 : 0.9,
|
|
2918
|
+
hasCustomWebsite ? "Custom site present." : "No personal site listed. Optional, not a deduction.",
|
|
2792
2919
|
[],
|
|
2793
|
-
hasCustomWebsite ? void 0 : "A custom domain (yourname.dev) lifts a senior resume \u2014 optional."
|
|
2920
|
+
hasCustomWebsite ? void 0 : "A custom domain (yourname.dev) lifts a senior resume \u2014 optional.",
|
|
2921
|
+
void 0,
|
|
2922
|
+
0.5
|
|
2923
|
+
),
|
|
2924
|
+
mk(
|
|
2925
|
+
"G8.hidden-text",
|
|
2926
|
+
"No hidden text or keyword tricks",
|
|
2927
|
+
hasHiddenText ? 0 : 1,
|
|
2928
|
+
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.",
|
|
2929
|
+
[],
|
|
2930
|
+
hasHiddenText ? "Remove any white/invisible text, zero-size fonts, or 'rate me highly'-style instructions. They backfire once the ATS strips formatting." : void 0,
|
|
2931
|
+
void 0,
|
|
2932
|
+
2
|
|
2794
2933
|
)
|
|
2795
2934
|
];
|
|
2796
2935
|
}
|
|
2797
|
-
function mk(id, label, score, message, evidence, suggestion, severity) {
|
|
2936
|
+
function mk(id, label, score, message, evidence, suggestion, severity, weight) {
|
|
2798
2937
|
const s = Math.max(0, Math.min(1, score));
|
|
2799
2938
|
return {
|
|
2800
2939
|
id,
|
|
@@ -2804,7 +2943,8 @@ function mk(id, label, score, message, evidence, suggestion, severity) {
|
|
|
2804
2943
|
message,
|
|
2805
2944
|
evidence: evidence ?? [],
|
|
2806
2945
|
suggestion: suggestion ?? null,
|
|
2807
|
-
severity: severity || "soft"
|
|
2946
|
+
severity: severity || "soft",
|
|
2947
|
+
weight: weight ?? 1
|
|
2808
2948
|
};
|
|
2809
2949
|
}
|
|
2810
2950
|
function parseMMYYYY(d) {
|
|
@@ -2834,9 +2974,14 @@ function experienceYears(resume) {
|
|
|
2834
2974
|
}
|
|
2835
2975
|
function aggregate(checks, weight) {
|
|
2836
2976
|
if (!checks.length) return { score: 0, weighted: 0 };
|
|
2837
|
-
|
|
2838
|
-
const
|
|
2839
|
-
|
|
2977
|
+
let wsum = 0, wtot = 0;
|
|
2978
|
+
for (const c of checks) {
|
|
2979
|
+
const w = c.weight ?? 1;
|
|
2980
|
+
wsum += c.score * w;
|
|
2981
|
+
wtot += w;
|
|
2982
|
+
}
|
|
2983
|
+
const mean = wtot ? wsum / wtot : 0;
|
|
2984
|
+
return { score: Math.round(mean * 100), weighted: mean * weight };
|
|
2840
2985
|
}
|
|
2841
2986
|
var WEIGHTS = {
|
|
2842
2987
|
impact: 22,
|
|
@@ -2886,12 +3031,15 @@ function scoreResume(resume) {
|
|
|
2886
3031
|
};
|
|
2887
3032
|
});
|
|
2888
3033
|
let overall = Math.round(weightedSum / totalWeight * 100);
|
|
3034
|
+
if (isMultiColumnTemplate(resume.styling.template)) {
|
|
3035
|
+
overall = Math.round(overall * 0.93);
|
|
3036
|
+
}
|
|
2889
3037
|
const hardFail = dimensions.some((d) => d.checks.some((c) => c.severity === "hard" && c.status === "fail"));
|
|
2890
3038
|
if (hardFail) overall = Math.min(overall, 60);
|
|
2891
3039
|
const sortedDims = [...dimensions].sort((a, b2) => a.score - b2.score);
|
|
2892
3040
|
const worst = sortedDims[0];
|
|
2893
3041
|
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) =>
|
|
3042
|
+
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
3043
|
const priorities = allFails.slice(0, 5);
|
|
2896
3044
|
return {
|
|
2897
3045
|
overall,
|
package/package.json
CHANGED