@juicedresume/mcp 0.3.2 → 0.4.1
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 +237 -72
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -220,6 +220,7 @@ var MULTI_COLUMN_TEMPLATES = [
|
|
|
220
220
|
"coral",
|
|
221
221
|
"prism",
|
|
222
222
|
"deedy",
|
|
223
|
+
"meridian",
|
|
223
224
|
"nova",
|
|
224
225
|
"axis",
|
|
225
226
|
"kintsugi",
|
|
@@ -238,6 +239,14 @@ var Styling = z.object({
|
|
|
238
239
|
accent: z.string().default("#111111"),
|
|
239
240
|
fontHeading: z.string().default("Playfair Display"),
|
|
240
241
|
fontBody: z.string().default("Inter"),
|
|
242
|
+
// Per-resume font OVERRIDES. Empty string = "use the template's designed
|
|
243
|
+
// font" (the default look). When set, the chosen family wins across every
|
|
244
|
+
// template — including Classic/Sidebar templates that otherwise hard-code
|
|
245
|
+
// their own faces — and is applied identically in the editor preview and the
|
|
246
|
+
// exported PDF. Kept separate from fontHeading/fontBody so existing resumes
|
|
247
|
+
// (which never set these) render exactly as before.
|
|
248
|
+
fontHeadingOverride: z.string().default(""),
|
|
249
|
+
fontBodyOverride: z.string().default(""),
|
|
241
250
|
fontSize: z.number().default(10.5),
|
|
242
251
|
// body pt
|
|
243
252
|
lineHeight: z.number().default(1.5),
|
|
@@ -268,7 +277,7 @@ var Locale = z.object({
|
|
|
268
277
|
dateFormat: z.enum(["MM/YYYY", "MMM YYYY", "YYYY", "DD/MM/YYYY", "MM/DD/YYYY"]).default("MMM YYYY"),
|
|
269
278
|
pageFormat: z.enum(["A4", "Letter"]).default("A4")
|
|
270
279
|
});
|
|
271
|
-
var
|
|
280
|
+
var ResumeObject = z.object({
|
|
272
281
|
id: z.string(),
|
|
273
282
|
name: z.string().default("Untitled"),
|
|
274
283
|
schemaVersion: z.literal(2).default(2),
|
|
@@ -279,6 +288,29 @@ var Resume = z.object({
|
|
|
279
288
|
styling: Styling,
|
|
280
289
|
locale: Locale
|
|
281
290
|
});
|
|
291
|
+
var RICH_TEXT_KEYS = /* @__PURE__ */ new Set(["body", "description"]);
|
|
292
|
+
var URL_KEYS = /* @__PURE__ */ new Set(["photoUrl", "url", "link", "employerLink", "schoolLink", "website", "linkedin", "github", "twitter"]);
|
|
293
|
+
var LONG_KEYS = /* @__PURE__ */ new Set(["skills"]);
|
|
294
|
+
function capFor(key) {
|
|
295
|
+
if (RICH_TEXT_KEYS.has(key)) return 6e4;
|
|
296
|
+
if (LONG_KEYS.has(key)) return 1e4;
|
|
297
|
+
if (URL_KEYS.has(key)) return 2e3;
|
|
298
|
+
return 1e3;
|
|
299
|
+
}
|
|
300
|
+
function capStrings(value, key = "") {
|
|
301
|
+
if (typeof value === "string") {
|
|
302
|
+
const max = capFor(key);
|
|
303
|
+
return value.length > max ? value.slice(0, max) : value;
|
|
304
|
+
}
|
|
305
|
+
if (Array.isArray(value)) return value.map((v) => capStrings(v, key));
|
|
306
|
+
if (value && typeof value === "object") {
|
|
307
|
+
const out = {};
|
|
308
|
+
for (const [k, v] of Object.entries(value)) out[k] = capStrings(v, k);
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
return value;
|
|
312
|
+
}
|
|
313
|
+
var Resume = z.preprocess((value) => capStrings(value), ResumeObject);
|
|
282
314
|
var CoverLetter = z.object({
|
|
283
315
|
id: z.string(),
|
|
284
316
|
name: z.string().default("Untitled"),
|
|
@@ -1415,15 +1447,29 @@ function bigrams(toks) {
|
|
|
1415
1447
|
return out;
|
|
1416
1448
|
}
|
|
1417
1449
|
function resumeText(resume) {
|
|
1418
|
-
const parts = [];
|
|
1419
|
-
parts.push(resume.personal.title);
|
|
1450
|
+
const parts = [resume.personal.title];
|
|
1420
1451
|
for (const sec of resume.sections) {
|
|
1452
|
+
if (sec.visible === false) continue;
|
|
1421
1453
|
for (const it of sec.items) {
|
|
1422
|
-
|
|
1423
|
-
parts.push(
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1454
|
+
if (it.visible === false) continue;
|
|
1455
|
+
parts.push(
|
|
1456
|
+
it.body || "",
|
|
1457
|
+
it.description || "",
|
|
1458
|
+
it.skills || "",
|
|
1459
|
+
it.category || "",
|
|
1460
|
+
it.jobTitle || "",
|
|
1461
|
+
it.degree || "",
|
|
1462
|
+
it.field || "",
|
|
1463
|
+
it.name || "",
|
|
1464
|
+
it.title || "",
|
|
1465
|
+
it.role || "",
|
|
1466
|
+
it.employer || "",
|
|
1467
|
+
it.school || "",
|
|
1468
|
+
it.publisher || "",
|
|
1469
|
+
it.institution || "",
|
|
1470
|
+
it.issuer || "",
|
|
1471
|
+
it.location || ""
|
|
1472
|
+
);
|
|
1427
1473
|
}
|
|
1428
1474
|
}
|
|
1429
1475
|
return parts.join(" ").replace(/<[^>]+>/g, " ");
|
|
@@ -1433,16 +1479,16 @@ function tailorToJob(resume, jdText) {
|
|
|
1433
1479
|
const resumeFullText = resumeText(resume).toLowerCase();
|
|
1434
1480
|
const resumeToks = new Set(tokens(resumeFullText));
|
|
1435
1481
|
const resumeNorm = new Set([...resumeToks].map((t) => normalizeSkill(t).toLowerCase()));
|
|
1436
|
-
function
|
|
1482
|
+
function matchKind(keyword) {
|
|
1437
1483
|
const k = keyword.toLowerCase();
|
|
1438
1484
|
if (k.includes(" ")) {
|
|
1439
|
-
return new RegExp(`\\b${escapeRe(k)}\\b`).test(resumeFullText);
|
|
1485
|
+
return new RegExp(`\\b${escapeRe(k)}\\b`).test(resumeFullText) ? "exact" : "none";
|
|
1440
1486
|
}
|
|
1441
|
-
if (resumeToks.has(k)) return
|
|
1487
|
+
if (resumeToks.has(k)) return "exact";
|
|
1442
1488
|
const canon = normalizeSkill(keyword).toLowerCase();
|
|
1443
|
-
if (resumeNorm.has(canon)) return
|
|
1444
|
-
if (canon.includes(" ") && new RegExp(`\\b${escapeRe(canon)}\\b`).test(resumeFullText)) return
|
|
1445
|
-
return
|
|
1489
|
+
if (resumeNorm.has(canon)) return "variant";
|
|
1490
|
+
if (canon.includes(" ") && new RegExp(`\\b${escapeRe(canon)}\\b`).test(resumeFullText)) return "variant";
|
|
1491
|
+
return "none";
|
|
1446
1492
|
}
|
|
1447
1493
|
const jdGrams = /* @__PURE__ */ new Set([...jdToks, ...bigrams(jdToks)]);
|
|
1448
1494
|
const skillNames = new Map(SKILLS.map((s) => [s.name.toLowerCase(), s]));
|
|
@@ -1460,10 +1506,20 @@ function tailorToJob(resume, jdText) {
|
|
|
1460
1506
|
const matched = [];
|
|
1461
1507
|
const missing = [];
|
|
1462
1508
|
const kindMult = (kind) => kind === "hard" ? 2 : kind === "soft" ? 1.3 : 1;
|
|
1509
|
+
const VARIANT_CREDIT = 0.6;
|
|
1463
1510
|
for (const [k, meta] of jdSkills) {
|
|
1464
1511
|
const w = meta.importance * kindMult(meta.kind);
|
|
1465
|
-
|
|
1512
|
+
const kind = matchKind(k);
|
|
1513
|
+
if (kind === "exact") {
|
|
1466
1514
|
matched.push({ keyword: k, kind: meta.kind, count: w });
|
|
1515
|
+
} else if (kind === "variant") {
|
|
1516
|
+
matched.push({
|
|
1517
|
+
keyword: k,
|
|
1518
|
+
kind: meta.kind,
|
|
1519
|
+
count: w * VARIANT_CREDIT,
|
|
1520
|
+
partial: true,
|
|
1521
|
+
note: `You use a variant of "${k}". ATS keyword search is usually exact-match, so add the exact term "${k}" too.`
|
|
1522
|
+
});
|
|
1467
1523
|
} else {
|
|
1468
1524
|
missing.push({ keyword: k, kind: meta.kind, importance: w });
|
|
1469
1525
|
}
|
|
@@ -1483,9 +1539,71 @@ function tailorToJob(resume, jdText) {
|
|
|
1483
1539
|
missing,
|
|
1484
1540
|
jdSkills: jdSkills.size,
|
|
1485
1541
|
resumeSkills: resumeToks.size,
|
|
1486
|
-
titleMatch
|
|
1542
|
+
titleMatch,
|
|
1543
|
+
flags: jdKnockoutFlags(resume, jdText)
|
|
1487
1544
|
};
|
|
1488
1545
|
}
|
|
1546
|
+
function tailorMMYYYY(d) {
|
|
1547
|
+
if (!d) return null;
|
|
1548
|
+
const m = d.match(/^(\d{1,2})\/(\d{4})$/);
|
|
1549
|
+
if (m) return parseInt(m[2], 10) * 12 + parseInt(m[1], 10);
|
|
1550
|
+
const mn = d.match(/^([A-Za-z]+)\s+(\d{4})$/);
|
|
1551
|
+
if (mn) {
|
|
1552
|
+
const months = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
1553
|
+
const i = months.findIndex((x) => mn[1].toLowerCase().startsWith(x));
|
|
1554
|
+
if (i >= 0) return parseInt(mn[2], 10) * 12 + (i + 1);
|
|
1555
|
+
}
|
|
1556
|
+
const y = d.match(/^(\d{4})$/);
|
|
1557
|
+
if (y) return parseInt(y[1], 10) * 12;
|
|
1558
|
+
return null;
|
|
1559
|
+
}
|
|
1560
|
+
function resumeYears(resume) {
|
|
1561
|
+
const exp = resume.sections.find((s) => s.type === "experience");
|
|
1562
|
+
if (!exp) return 0;
|
|
1563
|
+
const now = (/* @__PURE__ */ new Date()).getFullYear() * 12 + ((/* @__PURE__ */ new Date()).getMonth() + 1);
|
|
1564
|
+
let months = 0;
|
|
1565
|
+
for (const it of exp.items || []) {
|
|
1566
|
+
if (it.visible === false) continue;
|
|
1567
|
+
const s = tailorMMYYYY(it.startDate || "");
|
|
1568
|
+
const e = it.current ? now : tailorMMYYYY(it.endDate || "");
|
|
1569
|
+
if (s && e && e >= s) months += e - s;
|
|
1570
|
+
}
|
|
1571
|
+
return Math.round(months / 12 * 10) / 10;
|
|
1572
|
+
}
|
|
1573
|
+
function jdKnockoutFlags(resume, jdText) {
|
|
1574
|
+
const flags = [];
|
|
1575
|
+
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);
|
|
1576
|
+
if (degreeRequired) {
|
|
1577
|
+
const eduSec = resume.sections.find((s) => s.type === "education");
|
|
1578
|
+
const hasDegree = (eduSec?.items || []).some((it) => it.visible !== false && String(it.degree || "").trim());
|
|
1579
|
+
if (!hasDegree) {
|
|
1580
|
+
flags.push({
|
|
1581
|
+
id: "K4.missing-required-degree",
|
|
1582
|
+
severity: "risk",
|
|
1583
|
+
title: "This job states a degree requirement",
|
|
1584
|
+
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.",
|
|
1585
|
+
suggestion: "Add your degree in the Education section, or if you have equivalent experience, be ready to address the gap in a cover note.",
|
|
1586
|
+
requirement: "degree"
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
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);
|
|
1591
|
+
if (yoe.length) {
|
|
1592
|
+
const required = Math.min(...yoe);
|
|
1593
|
+
const have = resumeYears(resume);
|
|
1594
|
+
if (have + 0.5 < required) {
|
|
1595
|
+
flags.push({
|
|
1596
|
+
id: "K5.yoe-below-requirement",
|
|
1597
|
+
severity: "risk",
|
|
1598
|
+
title: `Asks for ${required}+ years; your resume shows about ${have}`,
|
|
1599
|
+
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.",
|
|
1600
|
+
suggestion: "Make sure every relevant role has dates so your full tenure is counted, and include earlier or concurrent experience if it applies.",
|
|
1601
|
+
requirement: `${required}+ years`
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
return flags;
|
|
1606
|
+
}
|
|
1489
1607
|
|
|
1490
1608
|
// ../../packages/scoring/src/v2-additions.ts
|
|
1491
1609
|
function stripHtml(s) {
|
|
@@ -1531,41 +1649,6 @@ function firstBulletStrongest(resume) {
|
|
|
1531
1649
|
)
|
|
1532
1650
|
};
|
|
1533
1651
|
}
|
|
1534
|
-
function dateFormatConsistency(resume) {
|
|
1535
|
-
const exp = resume.sections.find((s) => s.type === "experience");
|
|
1536
|
-
const edu = resume.sections.find((s) => s.type === "education");
|
|
1537
|
-
const dates = [];
|
|
1538
|
-
[...exp?.items || [], ...edu?.items || []].forEach((it) => {
|
|
1539
|
-
if (it.startDate) dates.push(it.startDate);
|
|
1540
|
-
if (it.endDate && !it.current) dates.push(it.endDate);
|
|
1541
|
-
});
|
|
1542
|
-
if (dates.length < 2) return null;
|
|
1543
|
-
const MM_YYYY = /^\d{1,2}\/\d{4}$/;
|
|
1544
|
-
const MMM_YYYY = /^[A-Za-z]+\s+\d{4}$/;
|
|
1545
|
-
const YYYY = /^\d{4}$/;
|
|
1546
|
-
const ABBREV = /['']\d{2}|\d{1,2}\/\d{2}|\b[A-Z][a-z]{2}'\d{2}\b/;
|
|
1547
|
-
const formats = /* @__PURE__ */ new Set();
|
|
1548
|
-
for (const d of dates) {
|
|
1549
|
-
if (ABBREV.test(d)) formats.add("abbrev");
|
|
1550
|
-
else if (MM_YYYY.test(d)) formats.add("mm/yyyy");
|
|
1551
|
-
else if (MMM_YYYY.test(d)) formats.add("month-yyyy");
|
|
1552
|
-
else if (YYYY.test(d)) formats.add("yyyy");
|
|
1553
|
-
else formats.add("other");
|
|
1554
|
-
}
|
|
1555
|
-
const hasAbbrev = formats.has("abbrev");
|
|
1556
|
-
const inconsistent = formats.size > 1;
|
|
1557
|
-
const score = hasAbbrev ? 0.2 : inconsistent ? 0.55 : 1;
|
|
1558
|
-
return {
|
|
1559
|
-
dim: "style",
|
|
1560
|
-
check: passSoft(
|
|
1561
|
-
"C9.date-format",
|
|
1562
|
-
"Date format is consistent",
|
|
1563
|
-
score,
|
|
1564
|
-
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.",
|
|
1565
|
-
"Use MM/YYYY (06/2024) or Month YYYY (June 2024) \u2014 consistently across every entry."
|
|
1566
|
-
)
|
|
1567
|
-
};
|
|
1568
|
-
}
|
|
1569
1652
|
function targetTitle(resume) {
|
|
1570
1653
|
const title = resume.personal?.title?.trim() || "";
|
|
1571
1654
|
const score = title.length >= 3 ? 1 : 0;
|
|
@@ -1626,7 +1709,6 @@ function threeLocationsRule(resume) {
|
|
|
1626
1709
|
function additionalChecks(resume) {
|
|
1627
1710
|
return [
|
|
1628
1711
|
firstBulletStrongest(resume),
|
|
1629
|
-
dateFormatConsistency(resume),
|
|
1630
1712
|
targetTitle(resume),
|
|
1631
1713
|
dobAgePenalty(resume),
|
|
1632
1714
|
threeLocationsRule(resume)
|
|
@@ -2642,7 +2724,8 @@ function atsChecks(resume, b) {
|
|
|
2642
2724
|
const allText = b.bullets.map((x) => x.text).join(" ");
|
|
2643
2725
|
const isSingleCol = !isMultiColumnTemplate(s.template);
|
|
2644
2726
|
const isSafeFont = SAFE_FONTS.has(s.fontHeading) && SAFE_FONTS.has(s.fontBody);
|
|
2645
|
-
const
|
|
2727
|
+
const fontSizeIdeal = s.fontSize >= 10 && s.fontSize <= 12;
|
|
2728
|
+
const fontSizeAcceptable = s.fontSize >= 9.5 && s.fontSize <= 12.5;
|
|
2646
2729
|
const safeBullet = SAFE_BULLET_GLYPHS.has(s.bulletGlyph === "disc" ? "\u2022" : s.bulletGlyph === "dash" ? "-" : "");
|
|
2647
2730
|
const emailOK = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(p.email);
|
|
2648
2731
|
const phoneOK = !p.phone || /(\+?\d[\d\s().-]{6,})/.test(p.phone);
|
|
@@ -2684,10 +2767,14 @@ function atsChecks(resume, b) {
|
|
|
2684
2767
|
// single-column), but ~1 in 10 still scrambles reading order and columns
|
|
2685
2768
|
// cost human skimmability. A single, evidence-scaled ding — the overall
|
|
2686
2769
|
// score also takes a small proportional multi-column haircut (see below).
|
|
2687
|
-
|
|
2688
|
-
|
|
2770
|
+
// ~0.85 reflects the real ~90% correct column-extraction rate reported by
|
|
2771
|
+
// Textkernel, so the within-dimension ding stays light; the small overall
|
|
2772
|
+
// multi-column haircut in scoreResume carries the actual score effect
|
|
2773
|
+
// (we deliberately don't double-penalize with a 0.55 here).
|
|
2774
|
+
isSingleCol ? 1 : 0.85,
|
|
2775
|
+
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.",
|
|
2689
2776
|
[],
|
|
2690
|
-
!isSingleCol ? "
|
|
2777
|
+
!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,
|
|
2691
2778
|
"soft"
|
|
2692
2779
|
),
|
|
2693
2780
|
mk(
|
|
@@ -2776,10 +2863,10 @@ function atsChecks(resume, b) {
|
|
|
2776
2863
|
mk(
|
|
2777
2864
|
"E11.font-size",
|
|
2778
2865
|
"Body font size 10\u201312pt",
|
|
2779
|
-
|
|
2780
|
-
|
|
2866
|
+
fontSizeIdeal ? 1 : fontSizeAcceptable ? 0.75 : 0.5,
|
|
2867
|
+
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.`,
|
|
2781
2868
|
[],
|
|
2782
|
-
!
|
|
2869
|
+
!fontSizeIdeal ? "Set body size to 10\u201312pt for the best balance of density and legibility." : void 0,
|
|
2783
2870
|
void 0,
|
|
2784
2871
|
0.5
|
|
2785
2872
|
),
|
|
@@ -2841,7 +2928,7 @@ function skillsChecks(resume, b) {
|
|
|
2841
2928
|
stuffed.length === 0 ? 1 : 1 - Math.min(1, stuffed.length / 3),
|
|
2842
2929
|
stuffed.length === 0 ? "No token over-repeated." : `Over-repeated: ${stuffed.slice(0, 4).map(([k, v]) => `${k}\xD7${v}`).join(", ")}.`,
|
|
2843
2930
|
[],
|
|
2844
|
-
stuffed.length ? "Diversify language
|
|
2931
|
+
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
|
|
2845
2932
|
),
|
|
2846
2933
|
mk(
|
|
2847
2934
|
"F5.acronym-expansion",
|
|
@@ -2928,7 +3015,9 @@ function polishChecks(resume, b) {
|
|
|
2928
3015
|
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
3016
|
[],
|
|
2930
3017
|
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
|
-
|
|
3018
|
+
// Hard severity: hidden text / prompt-injection is the one trick that
|
|
3019
|
+
// genuinely backfires, so a positive detection caps the overall score.
|
|
3020
|
+
"hard",
|
|
2932
3021
|
2
|
|
2933
3022
|
)
|
|
2934
3023
|
];
|
|
@@ -2972,6 +3061,52 @@ function experienceYears(resume) {
|
|
|
2972
3061
|
}
|
|
2973
3062
|
return Math.floor(months / 12);
|
|
2974
3063
|
}
|
|
3064
|
+
function computeFlags(resume) {
|
|
3065
|
+
const flags = [];
|
|
3066
|
+
const p = resume.personal;
|
|
3067
|
+
const emailOK = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(p.email);
|
|
3068
|
+
const hasPhone = !!(p.phone && /(\+?\d[\d\s().-]{6,})/.test(p.phone));
|
|
3069
|
+
const nameOK = !!(p.fullName && p.fullName.trim().length >= 2);
|
|
3070
|
+
if (!(nameOK && (emailOK || hasPhone))) {
|
|
3071
|
+
flags.push({
|
|
3072
|
+
id: "K1.contact-unparseable",
|
|
3073
|
+
severity: "block",
|
|
3074
|
+
title: "Contact details can't be parsed",
|
|
3075
|
+
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.",
|
|
3076
|
+
suggestion: "Add your full name and at least one of a valid email or phone."
|
|
3077
|
+
});
|
|
3078
|
+
}
|
|
3079
|
+
const expSec = resume.sections.find((s) => s.type === "experience");
|
|
3080
|
+
const expItems = (expSec?.items || []).filter((it) => it.visible !== false);
|
|
3081
|
+
const datelessRoles = expItems.filter((it) => !parseMMYYYY(it.startDate || ""));
|
|
3082
|
+
if (expItems.length > 0 && datelessRoles.length > 0) {
|
|
3083
|
+
flags.push({
|
|
3084
|
+
id: "K2.role-without-dates",
|
|
3085
|
+
severity: "risk",
|
|
3086
|
+
title: `${datelessRoles.length} role${datelessRoles.length === 1 ? "" : "s"} missing dates`,
|
|
3087
|
+
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.",
|
|
3088
|
+
suggestion: "Add a start and end date (MM/YYYY or Month YYYY) to every role."
|
|
3089
|
+
});
|
|
3090
|
+
}
|
|
3091
|
+
const nowMonths = (/* @__PURE__ */ new Date()).getFullYear() * 12 + ((/* @__PURE__ */ new Date()).getMonth() + 1);
|
|
3092
|
+
const ranges = expItems.map((it) => ({ start: parseMMYYYY(it.startDate || ""), end: it.current ? nowMonths : parseMMYYYY(it.endDate || it.startDate || "") })).filter((r) => r.start && r.end);
|
|
3093
|
+
ranges.sort((a, b) => b.start - a.start);
|
|
3094
|
+
let maxGap = 0;
|
|
3095
|
+
for (let i = 0; i < ranges.length - 1; i++) {
|
|
3096
|
+
const gap = ranges[i].start - ranges[i + 1].end;
|
|
3097
|
+
if (gap > maxGap) maxGap = gap;
|
|
3098
|
+
}
|
|
3099
|
+
if (maxGap > 6) {
|
|
3100
|
+
flags.push({
|
|
3101
|
+
id: "K3.employment-gap",
|
|
3102
|
+
severity: "risk",
|
|
3103
|
+
title: `About a ${maxGap}-month employment gap`,
|
|
3104
|
+
detail: "Some employers screen for gaps over 6 months. A gap is legitimate, but an unexplained one lowers callback odds.",
|
|
3105
|
+
suggestion: "Add a one-line note (contract, study, caregiving) or a short bridging entry so the gap does not read as unexplained."
|
|
3106
|
+
});
|
|
3107
|
+
}
|
|
3108
|
+
return flags;
|
|
3109
|
+
}
|
|
2975
3110
|
function aggregate(checks, weight) {
|
|
2976
3111
|
if (!checks.length) return { score: 0, weighted: 0 };
|
|
2977
3112
|
let wsum = 0, wtot = 0;
|
|
@@ -3001,7 +3136,7 @@ var LABELS = {
|
|
|
3001
3136
|
skills: "Skills & Keywords",
|
|
3002
3137
|
polish: "Completeness & Polish"
|
|
3003
3138
|
};
|
|
3004
|
-
function scoreResume(resume) {
|
|
3139
|
+
function scoreResume(resume, opts) {
|
|
3005
3140
|
const b = gather(resume);
|
|
3006
3141
|
const dims = [
|
|
3007
3142
|
{ dim: "impact", checks: impactChecks(resume, b) },
|
|
@@ -3036,6 +3171,21 @@ function scoreResume(resume) {
|
|
|
3036
3171
|
}
|
|
3037
3172
|
const hardFail = dimensions.some((d) => d.checks.some((c) => c.severity === "hard" && c.status === "fail"));
|
|
3038
3173
|
if (hardFail) overall = Math.min(overall, 60);
|
|
3174
|
+
const parseFindings = opts?.parseFindings ?? [];
|
|
3175
|
+
const parseHard = parseFindings.some((f) => f.severity === "hard");
|
|
3176
|
+
const parseSoftCount = parseFindings.filter((f) => f.severity === "soft").length;
|
|
3177
|
+
if (parseHard) overall = Math.min(overall, 55);
|
|
3178
|
+
if (parseSoftCount) overall = Math.max(0, overall - Math.min(12, parseSoftCount * 4));
|
|
3179
|
+
const flags = [
|
|
3180
|
+
...computeFlags(resume),
|
|
3181
|
+
...parseFindings.map((f) => ({
|
|
3182
|
+
id: f.id,
|
|
3183
|
+
severity: f.severity === "hard" ? "block" : "risk",
|
|
3184
|
+
title: f.title,
|
|
3185
|
+
detail: f.detail,
|
|
3186
|
+
suggestion: f.suggestion ?? null
|
|
3187
|
+
}))
|
|
3188
|
+
];
|
|
3039
3189
|
const sortedDims = [...dimensions].sort((a, b2) => a.score - b2.score);
|
|
3040
3190
|
const worst = sortedDims[0];
|
|
3041
3191
|
const best = sortedDims[sortedDims.length - 1];
|
|
@@ -3048,6 +3198,7 @@ function scoreResume(resume) {
|
|
|
3048
3198
|
summary: `Strongest: ${best.label} (${best.score}). Focus area: ${worst.label} (${worst.score}).`,
|
|
3049
3199
|
priorities,
|
|
3050
3200
|
hardFails: dimensions.flatMap((d) => d.checks.filter((c) => c.severity === "hard" && c.status === "fail").map((c) => ({ ...c, dim: d.dimension }))),
|
|
3201
|
+
flags,
|
|
3051
3202
|
stats: {
|
|
3052
3203
|
totalBullets: b.bullets.length,
|
|
3053
3204
|
experienceBullets: b.expBullets.length,
|
|
@@ -3069,6 +3220,10 @@ var CLOUD = CLOUD_TOKEN.length > 0;
|
|
|
3069
3220
|
async function ensure(dir) {
|
|
3070
3221
|
await fs.mkdir(dir, { recursive: true });
|
|
3071
3222
|
}
|
|
3223
|
+
function assertSafeId(id) {
|
|
3224
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(id)) throw new Error(`JuicedResume: invalid resume id "${id}".`);
|
|
3225
|
+
return id;
|
|
3226
|
+
}
|
|
3072
3227
|
async function cloudReq(method, p, body) {
|
|
3073
3228
|
let res;
|
|
3074
3229
|
try {
|
|
@@ -3109,6 +3264,7 @@ async function listResumes() {
|
|
|
3109
3264
|
return out.sort((a, b) => a.updatedAt < b.updatedAt ? 1 : -1);
|
|
3110
3265
|
}
|
|
3111
3266
|
async function getResume(id) {
|
|
3267
|
+
assertSafeId(id);
|
|
3112
3268
|
if (CLOUD) {
|
|
3113
3269
|
const data = await cloudReq("GET", `/resumes/${id}`);
|
|
3114
3270
|
return data ? Resume.parse(data) : null;
|
|
@@ -3121,6 +3277,7 @@ async function getResume(id) {
|
|
|
3121
3277
|
}
|
|
3122
3278
|
}
|
|
3123
3279
|
async function saveResume(r) {
|
|
3280
|
+
assertSafeId(r.id);
|
|
3124
3281
|
const next = { ...r, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
3125
3282
|
if (CLOUD) {
|
|
3126
3283
|
await cloudReq("PUT", `/resumes/${r.id}`, next);
|
|
@@ -3150,6 +3307,7 @@ async function createBlankResume(name = "Untitled") {
|
|
|
3150
3307
|
return await saveResume(blank);
|
|
3151
3308
|
}
|
|
3152
3309
|
async function deleteResume(id) {
|
|
3310
|
+
assertSafeId(id);
|
|
3153
3311
|
if (CLOUD) {
|
|
3154
3312
|
await cloudReq("DELETE", `/resumes/${id}`);
|
|
3155
3313
|
return;
|
|
@@ -3187,7 +3345,7 @@ function resumeToLatex(r) {
|
|
|
3187
3345
|
{\\LARGE \\textbf{${tex(p.fullName || "Your Name")}}}\\\\[2pt]
|
|
3188
3346
|
${p.title ? `{\\itshape ${tex(p.title)}}\\\\[2pt]` : ""}
|
|
3189
3347
|
${[
|
|
3190
|
-
p.email && `\\href{mailto:${p.email}}{${tex(p.email)}}`,
|
|
3348
|
+
p.email && `\\href{mailto:${texUrl(p.email)}}{${tex(p.email)}}`,
|
|
3191
3349
|
p.phone && tex(p.phone),
|
|
3192
3350
|
p.location && tex(p.location),
|
|
3193
3351
|
p.linkedin && hrefHandle(p.linkedin),
|
|
@@ -3232,7 +3390,7 @@ ${i.description ? htmlToTex(i.description) : ""}
|
|
|
3232
3390
|
return `${header}
|
|
3233
3391
|
${items.map((i) => `
|
|
3234
3392
|
\\noindent\\textbf{${tex(i.name)}}${i.role ? ` --- ${tex(i.role)}` : ""}\\hfill ${tex(dateRange(i.startDate, i.endDate, false, r))}\\\\
|
|
3235
|
-
${i.link ? `\\href{${ensureProto(i.link)}}{${tex(i.link)}}\\\\` : ""}
|
|
3393
|
+
${i.link ? `\\href{${texUrl(ensureProto(i.link))}}{${tex(i.link)}}\\\\` : ""}
|
|
3236
3394
|
${htmlToTex(i.description)}
|
|
3237
3395
|
`).join("\n")}
|
|
3238
3396
|
`;
|
|
@@ -3252,7 +3410,7 @@ ${items.map((i) => `
|
|
|
3252
3410
|
return `${header}
|
|
3253
3411
|
\\begin{itemize}
|
|
3254
3412
|
${items.map(
|
|
3255
|
-
(i) => ` \\item \\textbf{${tex(i.name)}}${i.issuer ? ` --- ${tex(i.issuer)}` : ""}${i.date ? ` (${tex(i.date)})` : ""}${i.link ? ` \\href{${ensureProto(i.link)}}{link}` : ""}`
|
|
3413
|
+
(i) => ` \\item \\textbf{${tex(i.name)}}${i.issuer ? ` --- ${tex(i.issuer)}` : ""}${i.date ? ` (${tex(i.date)})` : ""}${i.link ? ` \\href{${texUrl(ensureProto(i.link))}}{link}` : ""}`
|
|
3256
3414
|
).join("\n")}
|
|
3257
3415
|
\\end{itemize}
|
|
3258
3416
|
`;
|
|
@@ -3286,7 +3444,7 @@ ${i.description ? htmlToTex(i.description) : ""}
|
|
|
3286
3444
|
return `${header}
|
|
3287
3445
|
${items.map((i) => `
|
|
3288
3446
|
\\noindent\\textbf{${tex(i.title)}}${i.publisher ? ` --- ${tex(i.publisher)}` : ""}${i.date ? `\\hfill ${tex(i.date)}` : ""}\\\\
|
|
3289
|
-
${i.link ? `\\href{${ensureProto(i.link)}}{${tex(i.link)}}\\\\` : ""}
|
|
3447
|
+
${i.link ? `\\href{${texUrl(ensureProto(i.link))}}{${tex(i.link)}}\\\\` : ""}
|
|
3290
3448
|
${i.description ? htmlToTex(i.description) : ""}
|
|
3291
3449
|
`).join("\n")}
|
|
3292
3450
|
`;
|
|
@@ -3357,7 +3515,7 @@ function inlineHtmlToTex(html) {
|
|
|
3357
3515
|
const href = m[1];
|
|
3358
3516
|
const end = findCloseTag(src, close + 1, "a");
|
|
3359
3517
|
const inner = src.slice(close + 1, end.start);
|
|
3360
|
-
out += `\\href{${href}}{${inlineHtmlToTex(inner)}}`;
|
|
3518
|
+
out += `\\href{${texUrl(decodeEntities(href))}}{${inlineHtmlToTex(inner)}}`;
|
|
3361
3519
|
i = end.after;
|
|
3362
3520
|
continue;
|
|
3363
3521
|
}
|
|
@@ -3393,10 +3551,13 @@ ${after}`;
|
|
|
3393
3551
|
return html.split(/<\/?p[^>]*>/gi).map((c) => inlineHtmlToTex(c).trim()).filter(Boolean).join("\\\\\n");
|
|
3394
3552
|
}
|
|
3395
3553
|
function sanitizeColor(hex) {
|
|
3396
|
-
return hex.replace(
|
|
3554
|
+
return hex.replace(/[^0-9a-fA-F]/g, "").slice(0, 6).padEnd(6, "0");
|
|
3555
|
+
}
|
|
3556
|
+
function texUrl(s) {
|
|
3557
|
+
return s.replace(/[{}\\\s"]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0"));
|
|
3397
3558
|
}
|
|
3398
3559
|
function hrefHandle(s) {
|
|
3399
|
-
return `\\href{${ensureProto(s)}}{${tex(s)}}`;
|
|
3560
|
+
return `\\href{${texUrl(ensureProto(s))}}{${tex(s)}}`;
|
|
3400
3561
|
}
|
|
3401
3562
|
function ensureProto(s) {
|
|
3402
3563
|
if (/^https?:\/\//i.test(s)) return s;
|
|
@@ -3497,8 +3658,12 @@ var ok = (data) => ({ content: [{ type: "text", text: typeof data === "string" ?
|
|
|
3497
3658
|
async function mutate(id, fn) {
|
|
3498
3659
|
const r = await getResume(id);
|
|
3499
3660
|
if (!r) throw new Error(`Resume not found: ${id}`);
|
|
3500
|
-
const
|
|
3501
|
-
|
|
3661
|
+
const parsed = Resume.safeParse(fn(r));
|
|
3662
|
+
if (!parsed.success) {
|
|
3663
|
+
const first = parsed.error.issues[0];
|
|
3664
|
+
throw new Error(`Invalid patch \u2014 resume would no longer validate (${first?.path.join(".")}: ${first?.message}).`);
|
|
3665
|
+
}
|
|
3666
|
+
return await saveResume(parsed.data);
|
|
3502
3667
|
}
|
|
3503
3668
|
var TOOLS = [
|
|
3504
3669
|
// discovery
|
|
@@ -3577,7 +3742,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
3577
3742
|
case "add_section":
|
|
3578
3743
|
return ok(await mutate(args.id, (r) => {
|
|
3579
3744
|
const type = args.type;
|
|
3580
|
-
if (!DEFAULT_SECTION_TITLES
|
|
3745
|
+
if (!Object.hasOwn(DEFAULT_SECTION_TITLES, type)) throw new Error(`unknown section type: ${type}`);
|
|
3581
3746
|
const section = { id: newId(), type, title: args.title || DEFAULT_SECTION_TITLES[type], visible: true, columns: 1, items: [] };
|
|
3582
3747
|
return { ...r, sections: [...r.sections, section] };
|
|
3583
3748
|
}));
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juicedresume/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"mcpName": "com.juicedresume/mcp",
|
|
4
5
|
"description": "Model Context Protocol server for JuicedResume — score, tailor, and edit your resume from any MCP client (Claude Code, Claude Desktop, Cursor).",
|
|
5
6
|
"keywords": [
|
|
6
7
|
"mcp",
|