// @ts-check /** * jd-years.mjs — the years of experience a job description asks for, and * whether that is within reach of the candidate (#6153). * * Titles say little about this. "Lead" roles ask for four years and plain * "Analyst" roles ask for ten, so skip_tiers (classify-tier.mjs, which reads * the title) cannot answer it. The number is in the JD body, and this module * reads it there: rules only, no LLM, so the scanner stays zero-token. * * Nothing is dropped unless `action: drop` is set; by default an out-of-band * posting is kept and marked `above band`. With `drop` on, the one rule * everything below serves: a posting is dropped only when the JD * states a clear overall requirement and even its lowest reading is out of * reach. Everything else (no statement, a preferred-only or skill-only figure, * bars that disagree, a JD that could not be read) is kept and labelled. A * wrong drop costs the user a real job they never see; a wrong keep costs a * glance at one pipeline row. * * The parse, per sentence: * 1. Find each "N years" mention: digits or spelled out ("five (5)"), "N+", * "N or more", "at least N", ranges ("3-5", "between 3 and 5"), months. * 2. Keep only mentions about the candidate's experience: the words after * "years" must read as experience ("of", "in", "experience", "working", * ...), and the sentence must not be about age, tenure, contracts, * vesting, degree length ("4-year degree") or the company's own history. * 3. Tag each one: * - kind: required / preferred, from the nearest cue in the sentence * ("preferred", "nice to have", "a plus" / "required", "minimum", * "must"), else from the section heading above it. * - scope: overall / skill ("3 years of experience with SQL") / * degree-substitute ("a degree or 4 years of equivalent experience", * which binds only candidates without the degree) / sub ("7 years, * including 2 managing people"). * - degreeAlt: one of several degree-conditional bars in one sentence * ("7 with a BS or 5 with an MS"). * 4. The gating figure is the LOWEST floor among required overall mentions, * the most lenient honest reading. Several bars that differ ("5+ years * of software", "2+ years leading a team") are all required, so the real * bar is at least the highest; gating on the lowest means a drop happens * only when every one of them is out of reach. A bar with an * "equivalent combination" route, or read from a listing description * rather than the full JD, is shown but never drops. */ import { jdHtmlToText, fetchJdViaKnownApi, normalizeAshbyJob, normalizeGreenhouseJob, normalizeLeverJob, normalizeSmartRecruitersJob, normalizeWorkdayJob, } from './browser-extract.mjs'; import { resolveAtsApi, JD_TEXT_API_ATS, throttleProviderRequest } from './liveness-api.mjs'; import { fetchJsonWithRetry, makeHttpCtx } from './providers/_http.mjs'; /** * @typedef {Object} YearsMention * @property {number} floor Lowest figure ("3" in "3-5 years") * @property {number|null} ceiling Upper bound of a range, null for "N+" * @property {'required'|'preferred'|'unclear'} kind unclear: under a heading the parser does not know * @property {'overall'|'skill'|'degree-substitute'|'sub'} scope * @property {boolean} degreeAlt One of several degree-conditional bars * @property {boolean} [alternative] The JD offers another way to meet it * ("or an equivalent combination of education and experience") * @property {string} quote The sentence it came from (trimmed) */ /** * @typedef {Object} YearsRequirement * @property {'stated'|'preferred-only'|'skill-only'|'none'} status * stated: the JD names a required overall bar; the others carry no gate. * @property {number|null} min Gating floor (lowest required overall floor) * @property {number|null} max Upper bound when that bar is a range * @property {boolean} confident The lowest bar found is a hard bar: the text was the whole * JD and offers no "equivalent combination" route around it * @property {string} display Compact form for the pipeline row * @property {YearsMention[]} mentions */ const WORD_NUMBERS = /** @type {Record} */ ({ zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19, twenty: 20, }); const NUM = `(?:\\d{1,2}(?:\\.\\d)?|${Object.keys(WORD_NUMBERS).join('|')})`; // "five (5)" — a spelled number restated in digits. const RESTATED = `(?:\\s*\\(\\s*\\d{1,2}\\s*\\+?\\s*\\))?`; const PLUS = `(?:\\s*\\+|[\\s-]*plus\\b)?`; const OR_MORE = `(?:\\s+or\\s+(?:more|greater|longer))?`; const MENTION_RE = new RegExp( `\\b(between\\s+)?(${NUM})${RESTATED}${PLUS}${OR_MORE}` + `(?:\\s*(?:-|–|—|to|and)\\s*(${NUM})${RESTATED}${PLUS})?` + `[\\s-]*(years?|yrs?|months?)\\b`, 'gi', ); // "a year of experience", "one full year". Only with experience named right // after it: "$100K a year, depending on experience" is pay, not a bar. const SINGLE_YEAR_RE = /\b(?:a|an|one)\s+(?:full\s+)?year\b/gi; const SINGLE_YEAR_TRAIL = /^(?:'s|')?(?:\s+(?:of\s+)?(?:[\w/&-]+\s+){0,3}?(?:experience|exp)\b|\s+(?:in|as|working|managing|leading)\b)/i; // What may follow "years" for the mention to be about experience. // A dash counts only when spaced ("5+ years - required"): "a year-long // program" and "3-year plan" are not about the candidate. const EXPERIENCE_TRAIL = /^(?:\s+[-—–]|(?:'s|')?\s*(?:\+\s*)?(?:of\b|in\b|as\b|at\b|on\b|within\b|across\b|working|leading|managing|building|doing|designing|developing|running|owning|delivering|supporting|selling|practicing|practising|teaching|serving|consulting|operating|(?:[\w/&-]+\s+){1,3}(?:experience|exp)\b|experience|exp\b|professional|relevant|related|direct|hands|industry|work\b|progressive|combined|total|post|full[- ]time|practical|prior|previous|demonstrated|proven|minimum|min\b|or more|or greater|or longer|plus\b|\(|\)|,|;|:|\.|$|and\b|with\b|using\b|required|preferred))/i; // "years of X" where X makes it not about work experience. const NON_EXPERIENCE_OF = /^(?:'s|')?\s*of\s+(?:age|college|university|school|schooling|education|study|studies|service|history|data|operations?|growth|existence|tenure|employment with us)\b/i; // Words right before the number that make it a time window, not a requirement. const WINDOW_LEAD = /\b(?:within|after|every|per|ago|the (?:first|next|last|past)|(?:first|next|last|past)|in the|for the|over the|up to the|aged?|age of)\s*$/i; // A ceiling, not a floor: "up to 10 years", "less than 8 years", "6 years // or less", "5 years maximum". Never a minimum, so never a bar. const CEILING_LEAD = /\b(?:up to|less than|fewer than|no more than|not more than|under|at most|maximum of|max(?:imum)?\.?)\s*$/i; const CEILING_TRAIL = /^(?:'s|')?\s*(?:\+\s*)?(?:of\s+)?(?:[\w/&-]+\s+){0,4}?(?:experience\s+)?(?:or (?:less|fewer|under)|maximum|max\b|at most|at the most)/i; // Sentences about something other than the candidate's experience. const NOT_A_REQUIREMENT = /\b(?:year[- ]over[- ]year|years? old|years? of age|ago\b|founded|anniversary|in business|vest(?:s|ed|ing)?\b|contract (?:term|length|duration)|fixed[- ]term|warranty|retention bonus|sabbatical|parental leave|commitment of|\d+[- ]year (?:degree|program|programme|course|plan|contract|term|appointment)|(?:four|two|three)[- ]year (?:degree|college|university|program))/i; // Company voice ("our team has 50 years of combined experience") with no // candidate-facing word in the sentence. Case-sensitive "us", so "a US // multinational" is not read as the company speaking. // Someone else's experience: "work alongside engineers with 10+ years", // "learn from mentors who have 15 years". Skipping it can only keep a row. const THIRD_PARTY_LEAD = /\b(?:engineers|experts|colleagues|peers|leaders|mentors|teammates|team members|veterans|specialists|founders|partners|advisors|people)\s+(?:with|who have|who bring|having)\s+(?:over\s+|more than\s+|a combined\s+|decades and\s+)?$/i; const THIRD_PARTY_SENTENCE = /\b(?:alongside|learn from|mentored by|surrounded by|led by)\b/i; // A team's average ("a team that averages 12 years"), or the people the role // manages, serves or hires ("manage analysts with 5-8 years", "hiring // candidates with 10+ years"): their experience, not the candidate's. const THIRD_PARTY_AVERAGE = /\baverag(?:e|es|ing)\s+(?:of\s+)?(?:over\s+|more than\s+)?$/i; const THIRD_PARTY_OBJECT = /\b(?:manag\w*|lead|leading|supervis\w*|support\w*|serv(?:e|es|ing)|coach\w*|mentor(?:s|ing)?|hir(?:e|es|ing)|recruit\w*|sourc(?:e|es|ing)|interview\w*|team of|group of)\b[^.;]{0,50}\b[a-z]+s\s+with\s+(?:over\s+|more than\s+)?$/i; const COMPANY_VOICE = /\b(?:[Ww]e|[Ww]e've|[Ww]e're|[Oo]ur|us)\b/; const CANDIDATE_HAS = /\byou(?:'ve| have| bring| possess|'ll (?:need|have|bring)| will (?:need|have|bring)| should have| must have)\b/i; const CANDIDATE_VOICE = /\b(?:you|your|candidate|applicant|required|requirements?|minimum|must|should|ideal(?:ly)?|qualif\w*|looking for|seeking|need)\b/i; const PREFERRED_CUE = /\b(?:prefer(?:red|ably|ence)?|nice[- ]to[- ]have|bonus|a (?:big |huge |strong )?plus|is a plus|are a plus|ideally|desired|desirable|advantageous|would be (?:great|nice|helpful|ideal)|is helpful|not required|extra credit|stand out)\b/gi; const TRAILING_PREFERRED = /\b(?:preferred|desired|desirable|a (?:big |huge |strong )?plus|nice[- ]to[- ]have|helpful|advantageous)\W*$/i; const REQUIRED_CUE = /\b(?:require[ds]?|requirements?|minimum|must|at least|basic qualifications?|mandatory|essential|need(?:ed|s)?)\b/gi; const DEGREE_WORD = /\b(?:degree|bachelor'?s?|master'?s?|mba|ph\.?\s?d|doctorate|b\.?s\.?|b\.?a\.?|m\.?s\.?|m\.?a\.?|bsc|msc|undergraduate|graduate|advanced degree|diploma)\b/i; const DEGREE_SUBSTITUTE_AFTER = /^[^.;]{0,50}\b(?:in lieu of|instead of|in place of|as a substitute|equivalent to a degree|without a degree)/i; const DEGREE_SUBSTITUTE_OWN = /^(?:'s|')?\s*(?:of\s+)?(?:\w+\s+){0,2}?equivalent\b/i; const DEGREE_OR_BEFORE = /\b(?:degree|bachelor'?s?|diploma|b\.?s\.?|b\.?a\.?|bsc)\b[^.;]{0,40}\bor\s+(?:an?\s+)?(?:equivalent\s+)?(?:combination\s+of\s+)?(?:at least\s+|a minimum of\s+|minimum of\s+)?$/i; const SUB_LEAD = /\b(?:including|incl\.?|of which|of those|of this|within that|with at least|out of which)\s*(?:at least\s+|a minimum of\s+)?$/i; // "with a Bachelor's" names the degree the bar goes with, not a skill. const DEGREE_TRAIL = /^(?:'s|')?\s*(?:\+\s*)?(?:of\s+(?:\w+\s+){0,2}?)?(?:experience\s+)?(?:with|and|plus|\+)\s+(?:an?\s+|the\s+)?(?:relevant\s+|related\s+)?(?:degree|bachelor|master|mba|ph\.?\s?d|doctorate|b\.?s\b|b\.?a\b|m\.?s\b|m\.?a\b|bsc|msc|advanced|graduate|undergraduate)/i; const SKILL_TRAIL = /^(?:'s|')?\s*(?:\+\s*)?(?:of\s+)?(?:(?:hands[- ]on|direct|professional|practical|relevant|production|commercial)\s+)?(?:experience\s+)?(?:with|using|programming|coding|writing|scripting)\b/i; // "or an equivalent combination of education and experience": the bar can // be met another way, so it is shown but never drops on its own. const EQUIVALENT_COMBINATION = /\bequivalent combination\b|\bor (?:an? )?equivalent (?:amount|level|mix) of\b|\bcombination of (?:education|training) and (?:work )?experience\b/i; const NO_EXPERIENCE_RE = /\bno (?:prior |previous )?(?:work |professional |industry )?experience (?:is )?(?:required|necessary|needed)\b/i; // Heading shapes: short, no year figure, and a recognisable section name. const PREFERRED_HEADING = /\b(?:prefer|nice[- ]to[- ]have|good[- ]to[- ]have|great[- ]to[- ]have|bonus|desir|plus|ideal|extra credit|stand out|even better|would be (?:great|nice)|great if|added advantage|differentiat|love to see|also have)/i; const REQUIRED_HEADING = /\b(?:require|minimum|basic|must|qualif|what you(?:'ll)? (?:need|bring)|you (?:have|bring)|looking for|about you|who you are|experience|skills)/i; /** @param {string} raw */ function toNumber(raw) { const key = raw.toLowerCase(); return key in WORD_NUMBERS ? WORD_NUMBERS[key] : Number(raw); } const LIST_MARKER = /^\s*(?:[-*•·]|\d+[.)])(?:\s+|$)/; /** * Plain text for parsing. Provider descriptions arrive as HTML, ATS JD * normalizers already return text; both end up as lines. jdHtmlToText writes an *
  • as "- ", so `item` holds for either source. * @param {string} input * @returns {{ text: string, item: boolean }[]} */ function toLines(input) { const raw = String(input ?? ''); // [^<>]: ']*>/i.test(raw) ? jdHtmlToText(raw) : raw; // Runs of spaces collapse first: the sentence split is quadratic on them. // A marker may also end its line: `
  • \n

    text` and `

  • ` arrive as a // bare "-", and headingKind would take it for a heading and hide the real one // above it. In a 2026-10-30 sample, 654 of 3,573 postings had such a line. // The item's text then starts the next line with text, so that line is the // item: read without its marker, "Strong analytical skills" under Preferred // opened a Required section. A heading after an empty `
  • ` looks the // same, and the flag only ever keeps a line without a colon from opening one. const lines = []; let marker = false; for (const l of text.replace(/[’‘]/g, "'").replace(/[^\S\r\n]+/g, ' ').split(/\r?\n/)) { const item = LIST_MARKER.test(l); const rest = l.replace(LIST_MARKER, '').trim(); if (!rest) { marker ||= item; continue; } lines.push({ text: rest, item: item || marker }); marker = false; } return lines; } /** * Sentence split that leaves abbreviations ("B.S.", "e.g.", "U.S.") whole: * a break needs two word characters before the stop. * @param {string} line */ function sentences(line) { return line.split(/(?<=[a-z0-9)]{2}[.!?])\s+(?=[A-Z("])|;\s+|\s+[•·]\s+/).map(s => s.trim()).filter(Boolean); } /** * @param {string} line * @param {boolean} item the line was a list item */ function headingKind(line, item) { // "Preferred Qualifications (any 2 of the following)": the parenthetical // is an aside, not a figure. const bare = line.replace(/\s*\([^)]*\)\s*/g, ' ').trim(); // A lead-in line that introduces a preferred list, however long ("It would // be great if you also have the following things"). if (bare.length <= 120 && !/\d/.test(bare) && PREFERRED_HEADING.test(bare) && /(?::|\bfollowing\b[\w\s]*)$/i.test(bare)) return 'preferred'; if (bare.length > 60 || /\d/.test(bare)) return null; const words = bare.split(/\s+/).length; if (!/:$/.test(bare) && (words > 6 || /[.!?]$/.test(bare))) return null; // A digit only inside a parenthetical is allowed for a heading that names // its section; "Ability to travel (50%+)" is a bullet, not a heading. const known = PREFERRED_HEADING.test(bare) ? 'preferred' // "Additional Qualifications" may be required or preferred: unclear. : REQUIRED_HEADING.test(bare) && !/^additional\b/i.test(bare) ? 'required' : null; if (/\d/.test(line) && !known) return null; // "Excellent communication skills" and "Experience in fintech" are items that // happen to hold a heading word. Read as Required, one under Preferred // Qualifications turned the bars after it into a confident drop. A colon // makes a list item a heading, but "Experience in fintech:" introduces the // items after it, not a section. if (known === 'required' && ((item && !/:$/.test(bare)) || /^experience (?:in|with|of)\b/i.test(bare))) return null; return known ?? 'other'; } /** * Required or preferred, from the cue nearest the mention: the last cue * before it, else the first after it, else the section default. * @param {string} sentence * @param {number} at * @param {'required'|'preferred'|'unclear'} fallback * @returns {'required'|'preferred'|'unclear'} */ function mentionKind(sentence, at, fallback) { // A sentence that ends on the cue ("Minimum of 2 years ... preferred") // qualifies the whole of itself, whatever word opened it. if (TRAILING_PREFERRED.test(sentence)) return 'preferred'; /** @type {{pos: number, kind: 'required'|'preferred'}[]} */ const cues = []; for (const m of sentence.matchAll(PREFERRED_CUE)) cues.push({ pos: m.index ?? 0, kind: 'preferred' }); for (const m of sentence.matchAll(REQUIRED_CUE)) cues.push({ pos: m.index ?? 0, kind: 'required' }); if (cues.length === 0) { // Under a heading the parser does not know ("About the role", // "Responsibilities"), only a sentence addressed to the candidate is a bar. if (fallback === 'unclear') return CANDIDATE_HAS.test(sentence) ? 'required' : 'unclear'; return fallback; } const before = cues.filter(c => c.pos < at).sort((a, b) => b.pos - a.pos)[0]; if (before) return before.kind; return cues.sort((a, b) => a.pos - b.pos)[0].kind; } /** * Every experience mention in one sentence. * @param {string} sentence * @param {'required'|'preferred'|'unclear'} sectionKind * @returns {YearsMention[]} */ function mentionsIn(sentence, sectionKind) { if (NOT_A_REQUIREMENT.test(sentence)) return []; if (COMPANY_VOICE.test(sentence) && !CANDIDATE_VOICE.test(sentence)) return []; if (THIRD_PARTY_SENTENCE.test(sentence)) return []; /** @type {Array} */ const found = []; const consider = (/** @type {number} */ at, /** @type {number} */ end, /** @type {number} */ floor, /** @type {number|null} */ ceiling, /** @type {string} */ unit) => { const lead = sentence.slice(Math.max(0, at - 40), at); const trail = sentence.slice(end); if (WINDOW_LEAD.test(lead) || THIRD_PARTY_LEAD.test(lead) || THIRD_PARTY_AVERAGE.test(lead) || THIRD_PARTY_OBJECT.test(lead)) return; if (CEILING_LEAD.test(lead) || CEILING_TRAIL.test(trail)) return; if (NON_EXPERIENCE_OF.test(trail)) return; const months = /^mo/i.test(unit); if (months && !/^(?:'s|')?\s*(?:of\s+)?(?:\w+\s+){0,3}?experience\b/i.test(trail)) return; if (!EXPERIENCE_TRAIL.test(trail)) return; // Bare "N years." needs experience named nearby ("Experience: 5+ years"). if (/^(?:'s|')?\s*(?:[,;:.)]|$)/.test(trail) && !/\bexp(?:erience)?\b/i.test(sentence)) return; const scale = months ? 1 / 12 : 1; const lo = Math.round(floor * scale * 10) / 10; const hi = ceiling === null ? null : Math.round(ceiling * scale * 10) / 10; if (lo > 20) return; // company history, not a hiring bar let scope = /** @type {YearsMention['scope']} */ ('overall'); // "a degree or 4 years" is a substitute only when no earlier bar in the // sentence owns that degree; "10 years with a BS, or 8 with an MS" is a // list of alternatives, not a substitute. const substitute = (DEGREE_OR_BEFORE.test(lead) && found.length === 0) || DEGREE_SUBSTITUTE_AFTER.test(trail) || DEGREE_SUBSTITUTE_OWN.test(trail); if (substitute) { scope = 'degree-substitute'; } else if (SKILL_TRAIL.test(trail) && !DEGREE_TRAIL.test(trail)) { scope = 'skill'; } else if (SUB_LEAD.test(lead) && found.some(f => f.scope === 'overall')) { scope = 'sub'; } found.push({ floor: lo, ceiling: hi !== null && hi > lo ? hi : null, kind: mentionKind(sentence, at, sectionKind), scope, degreeAlt: false, quote: sentence.length > 220 ? `${sentence.slice(0, 217)}...` : sentence, at, end, }); }; for (const m of sentence.matchAll(MENTION_RE)) { const [whole, between, a, b, unit] = m; const at = m.index ?? 0; // "and" joins a range only after "between"; otherwise "3 and 5 years" is two bars. const joiner = b ? whole.slice(whole.search(/(?:-|–|—|to|and)\s*/i)).match(/^(-|–|—|to|and)/i)?.[1] : null; if (b && joiner?.toLowerCase() === 'and' && !between) { consider(at + whole.length - (unit.length), at + whole.length, toNumber(b), null, unit); continue; } consider(at, at + whole.length, toNumber(a), b ? toNumber(b) : null, unit); } for (const m of sentence.matchAll(SINGLE_YEAR_RE)) { const at = m.index ?? 0; if (found.some(f => at >= f.at && at < f.end)) continue; if (!SINGLE_YEAR_TRAIL.test(sentence.slice(at + m[0].length))) continue; consider(at, at + m[0].length, 1, null, 'year'); } // Degree-conditional alternatives: two or more overall bars in one sentence, // each with a degree named beside it. const overall = found.filter(f => f.scope === 'overall'); if (overall.length >= 2) { const withDegree = overall.filter(f => DEGREE_WORD.test(sentence.slice(Math.max(0, f.at - 45), f.end + 45))); if (withDegree.length >= 2 && /\bor\b|\//i.test(sentence)) for (const f of withDegree) f.degreeAlt = true; } return found.map(({ at: _at, end: _end, ...rest }) => rest); } /** @param {number} n */ const fmt = n => String(n); /** @param {{floor: number, ceiling: number|null}} m */ function rangeText(m) { return m.ceiling !== null ? `${fmt(m.floor)}-${fmt(m.ceiling)}` : `${fmt(m.floor)}+`; } /** * Read the stated years-of-experience requirement from JD text or HTML. * @param {string} jd * @returns {YearsRequirement} */ export function parseYearsRequirement(jd) { const lines = toLines(jd); /** @type {YearsMention[]} */ const mentions = []; /** @type {'required'|'preferred'} */ // Text before any heading reads as required; a heading the parser does not // know makes its section unclear, so its bars show but never gate. /** @type {'required'|'preferred'|'unclear'} */ let section = 'required'; let noExperience = false; /** @type {YearsMention[]} */ let previous = []; for (const { text: line, item } of lines) { if (NO_EXPERIENCE_RE.test(line)) noExperience = true; const heading = headingKind(line, item); if (heading) { section = heading === 'other' ? 'unclear' : heading; continue; } for (const sentence of sentences(line)) { const found = mentionsIn(sentence, section); if (EQUIVALENT_COMBINATION.test(sentence)) { // Its own bars, or the ones just before it ("5+ years.\n- Or an // equivalent combination ..."). for (const m of found.length ? found : previous) m.alternative = true; } mentions.push(...found); if (found.length) previous = found; } } // Unclear bars count toward the lowest reading, so they can show and keep a // row, but a reading that rests on one is never confident: it cannot drop. const gating = mentions.filter(m => (m.kind === 'required' || m.kind === 'unclear') && m.scope === 'overall'); if (gating.length === 0 && noExperience) { return { status: 'stated', min: 0, max: null, confident: true, display: '0+', mentions }; } if (gating.length === 0) { const preferred = mentions.filter(m => m.kind === 'preferred' && m.scope === 'overall'); const skill = mentions.filter(m => m.kind === 'required' && m.scope === 'skill'); if (preferred.length > 0) { const lowest = preferred.reduce((a, b) => (b.floor < a.floor ? b : a)); return { status: 'preferred-only', min: null, max: null, confident: false, display: `pref ${rangeText(lowest)}`, mentions }; } if (skill.length > 0) { const lowest = skill.reduce((a, b) => (b.floor < a.floor ? b : a)); return { status: 'skill-only', min: null, max: null, confident: false, display: `skill ${rangeText(lowest)}`, mentions }; } return { status: 'none', min: null, max: null, confident: false, display: 'none stated', mentions }; } const lowest = gating.reduce((a, b) => (b.floor < a.floor ? b : a)); const floors = [...new Set(gating.map(m => m.floor))].sort((a, b) => a - b); let display = rangeText(lowest); if (floors.length > 1) { display = gating.every(m => m.degreeAlt) ? `${floors.join('/')}+ by degree` : `${floors.join('/')}+`; } const alternative = gating.some(m => m.alternative); const unclear = gating.some(m => m.kind === 'unclear' && m.floor === lowest.floor); const confident = !alternative && !unclear; if (alternative) display = `${display} or equivalent`; return { status: 'stated', min: lowest.floor, max: lowest.ceiling, confident, display, mentions }; } /** * Band verdict. 'out' only for a stated requirement whose lowest reading is * beyond the candidate's years plus the stretch they allow, read from a whole * JD (not a truncated listing description). * * @param {YearsRequirement|null} req - null when the JD could not be read * @param {{years: number, stretchYears: number}} band * @returns {'in'|'out'|'unknown'} */ export function classifyYears(req, { years, stretchYears }) { if (!req || req.status !== 'stated' || req.min === null) return 'unknown'; if (req.min <= years + stretchYears) return 'in'; return req.confident ? 'out' : 'unknown'; } /** * The `yrs:` pipeline segment body. * @param {YearsRequirement|null} req - null when the JD could not be read * @param {'in'|'out'|'unknown'} verdict * @returns {string} */ export function formatYearsSegment(req, verdict) { if (!req) return 'yrs: unread'; return verdict === 'out' ? `yrs: ${req.display} above band` : `yrs: ${req.display}`; } /** * Resolve `experience_filter` (portals.yml) against the candidate's years * (config/profile.yml `candidate.years_of_experience`). * * Off unless the block is present and not `enabled: false`. An enabled block * with no usable years is reported, not guessed at: the filter stays off. * * @param {unknown} block - portals.yml `experience_filter` * @param {unknown} profileYears - profile.yml `candidate.years_of_experience` * @returns {{enabled: false, reason?: string} | {enabled: true, years: number, stretchYears: number, action: 'drop'|'label', concurrency: number}} */ export function resolveExperienceFilter(block, profileYears) { if (block === undefined || block === null) return { enabled: false }; if (typeof block !== 'object' || Array.isArray(block)) return { enabled: false, reason: 'experience_filter must be an object' }; const cfg = /** @type {Record} */ (block); if (cfg.enabled === false) return { enabled: false }; // Off rather than guessed: `enabled: "false"` must not switch it on. if (cfg.enabled !== undefined && cfg.enabled !== true) return { enabled: false, reason: 'experience_filter.enabled must be true or false' }; const years = cfg.years ?? profileYears; if (typeof years !== 'number' || !Number.isFinite(years) || years < 0 || years > 60) { return { enabled: false, reason: 'experience_filter is on but no years are set: add candidate.years_of_experience to config/profile.yml (or experience_filter.years)' }; } const stretch = cfg.stretch_years ?? 1; const stretchYears = typeof stretch === 'number' && Number.isFinite(stretch) && stretch >= 0 ? stretch : 1; // `label` unless `drop` is asked for: a wrong drop silently costs a real job, // a wrong label costs one glance at a row. const action = cfg.action === 'drop' ? 'drop' : 'label'; const conc = cfg.concurrency; const concurrency = Number.isInteger(conc) && /** @type {number} */ (conc) >= 1 && /** @type {number} */ (conc) <= 16 ? /** @type {number} */ (conc) : 4; return { enabled: true, years, stretchYears, action, concurrency }; } // ── JD fetch ──────────────────────────────────────────────────────── // ATS whose JD comes from one public GET returning JSON. These go through // providers/_http.mjs (SSRF-guarded connect, no redirects, body cap) with its // shared retry, which honours Retry-After on a 429. The remaining // JD_TEXT_API_ATS entries (an embed hop, an HTML page) use // fetchJdViaKnownApi as-is. Normalizers are browser-extract.mjs's own, so // fetch-jd.mjs, `browser-extract.mjs jd` and this read the same text. const JSON_JD = /** @type {Record {text: string}|null>} */ ({ greenhouse: (json, _r, url, cap) => normalizeGreenhouseJob(json, url, cap), lever: (json, _r, url, cap) => normalizeLeverJob(json, url, cap), ashby: (json, r, url, cap) => normalizeAshbyJob(json, r.parts.jobId, url, cap), workday: (json, _r, url, cap) => normalizeWorkdayJob(json, url, cap), smartrecruiters: (json, _r, url, cap) => normalizeSmartRecruitersJob(json, url, cap), }); // A whole JD, qualifications included: they often sit at the end. const JD_TEXT_CAP = 60_000; const JD_TIMEOUT_MS = 15_000; const JD_RETRY_POLICY = { retries: 2 }; /** * Full JD text for a posting, or null when its host has no known public JD * API. Throws on a failed request (the caller labels the row `unread`). * * @param {string} url * @param {{ boardCache?: Map>, ctx?: any }} [opts] * boardCache: Ashby's API is the whole board, so one fetch serves every * posting on it for the rest of the run. * @returns {Promise} */ export async function fetchJdText(url, { boardCache, ctx = makeHttpCtx() } = {}) { const resolved = resolveAtsApi(url); if (!resolved || !JD_TEXT_API_ATS.has(resolved.ats)) return null; const normalize = JSON_JD[resolved.ats]; if (!normalize) { const result = await fetchJdViaKnownApi(url, JD_TEXT_CAP, JD_TIMEOUT_MS); if (!result) throw new Error(`${resolved.ats} JD fetch returned nothing`); return result.text; } const apiUrl = resolved.ats === 'greenhouse' ? `${resolved.apiUrl}?content=true` : resolved.apiUrl; const get = async () => { await throttleProviderRequest(resolved.ats, resolved.throttleMs); return fetchJsonWithRetry(ctx, apiUrl, { timeoutMs: resolved.timeoutMs || JD_TIMEOUT_MS, headers: { accept: 'application/json' }, }, JD_RETRY_POLICY); }; let json; if (resolved.ats === 'ashby' && boardCache) { // A failure is cached too: get() already retried, so asking again for // every other posting on a down board would only repeat that backoff. if (!boardCache.has(apiUrl)) boardCache.set(apiUrl, get()); json = await boardCache.get(apiUrl); } else { json = await get(); } const result = normalize(json, resolved, url, JD_TEXT_CAP); if (!result?.text) throw new Error(`${resolved.ats} answered without a JD for this posting`); return result.text; } /** * @typedef {Object} YearsVerdict * @property {YearsRequirement|null} req null when the JD could not be read * @property {'in'|'out'|'unknown'} verdict * @property {'ats'|'listing'|null} source * @property {string|null} error * @property {string} label the `yrs:` segment body ("5+", "unread") */ /** * Read each offer's stated years and split the list by band. * * One JD request per offer at most, through its ATS's public API. The full JD * always wins over the listing's own description: a listing can carry a * teaser or a capped excerpt that states a bar and leaves out the * alternative or the "preferred" that follows it. So a reading taken from the * listing (no JD API for the host, or the request failed) is shown but never * drops. Every failure keeps the offer. Order is preserved. * * @template {{url: string, description?: string}} T * @param {T[]} offers * @param {{years: number, stretchYears: number, action: 'drop'|'label', concurrency: number}} filter * @param {{ fetchJd?: (url: string) => Promise, onProgress?: (done: number, total: number) => void }} [deps] * @returns {Promise<{ kept: Array, dropped: Array }>} */ export async function applyExperienceFilter(offers, filter, deps = {}) { const boardCache = new Map(); const fetchJd = deps.fetchJd || (url => fetchJdText(url, { boardCache })); /** @type {Array} */ const annotated = new Array(offers.length); let cursor = 0; let done = 0; async function worker() { while (cursor < offers.length) { const i = cursor++; const offer = offers[i]; /** @type {YearsRequirement|null} */ let req = null; /** @type {YearsVerdict['source']} */ let source = null; let error = null; let text = null; try { text = await fetchJd(offer.url); } catch (err) { error = err instanceof Error ? err.message : String(err); } // Capped like an ATS read: a provider's description has no size limit. const listing = typeof offer.description === 'string' ? offer.description.slice(0, JD_TEXT_CAP).trim() : ''; if (text) { req = parseYearsRequirement(text); source = 'ats'; } else if (listing) { req = { ...parseYearsRequirement(listing), confident: false }; source = 'listing'; } const verdict = classifyYears(req, filter); const label = formatYearsSegment(req, verdict).replace(/^yrs: /, ''); annotated[i] = { ...offer, years: { req, verdict, source, error, label } }; deps.onProgress?.(++done, offers.length); } } await Promise.all(Array.from({ length: Math.min(filter.concurrency, offers.length) }, worker)); const dropped = filter.action === 'drop' ? annotated.filter(o => o.years.verdict === 'out') : []; const kept = annotated.filter(o => !dropped.includes(o)); return { kept, dropped }; }