Ctrl K
ComunidadGuías…

jd-years.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// @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<string, number>} */ ({
  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
 * <li> as "- ", so `item` holds for either source.
 * @param {string} input
 * @returns {{ text: string, item: boolean }[]}
 */
function toLines(input) {
  const raw = String(input ?? '');
  // [^<>]: '<a<a<a…' fails each start at once instead of scanning to the end.
  const text = /<[a-z!/][^<>]*>/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: `<li>\n<p>text` and `<li></li>` 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 `<li></li>` 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<YearsMention & {at: number, end: number}>} */
  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<string, unknown>} */ (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<string, (json: any, resolved: any, url: string, cap: number) => {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<string, Promise<any>>, 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<string|null>}
 */
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<string|null>, onProgress?: (done: number, total: number) => void }} [deps]
 * @returns {Promise<{ kept: Array<T & {years: YearsVerdict}>, dropped: Array<T & {years: YearsVerdict}> }>}
 */
export async function applyExperienceFilter(offers, filter, deps = {}) {
  const boardCache = new Map();
  const fetchJd = deps.fetchJd || (url => fetchJdText(url, { boardCache }));
  /** @type {Array<T & {years: YearsVerdict}>} */
  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 };
}