/** * jd-parse.mjs: pure HTML-to-JD parsers shared by batch/jd-rescue.mjs and * lib/jd-page-fetch.mjs. They live here, a system file in its own right, so the * page fetcher never depends on batch/jd-rescue.mjs, which the updater preserves * when the user has edited it. No I/O: every function takes a string. */ /** Lowercase A-Z only. String#toLowerCase can change the length (e.g. U+0130), which would misalign indexOf offsets with the source. */ export const asciiLower = (s) => String(s).replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32)); const HTML_ENTITIES = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'", '#160': ' ', '#xa0': ' ', '#x27': "'", '#38': '&', mdash: '—', ndash: '–', rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', hellip: '…', }; /** Latin-1 named references (U+00A1-U+00FF), case-sensitive: `É` and `é` differ. nbsp stays a plain space. */ export const LATIN1_ENTITIES = Object.fromEntries(Object.entries({ AElig: 0xc6, Aacute: 0xc1, Acirc: 0xc2, Agrave: 0xc0, Aring: 0xc5, Atilde: 0xc3, Auml: 0xc4, Ccedil: 0xc7, ETH: 0xd0, Eacute: 0xc9, Ecirc: 0xca, Egrave: 0xc8, Euml: 0xcb, Iacute: 0xcd, Icirc: 0xce, Igrave: 0xcc, Iuml: 0xcf, Ntilde: 0xd1, Oacute: 0xd3, Ocirc: 0xd4, Ograve: 0xd2, Oslash: 0xd8, Otilde: 0xd5, Ouml: 0xd6, THORN: 0xde, Uacute: 0xda, Ucirc: 0xdb, Ugrave: 0xd9, Uuml: 0xdc, Yacute: 0xdd, aacute: 0xe1, acirc: 0xe2, acute: 0xb4, aelig: 0xe6, agrave: 0xe0, aring: 0xe5, atilde: 0xe3, auml: 0xe4, brvbar: 0xa6, ccedil: 0xe7, cedil: 0xb8, cent: 0xa2, copy: 0xa9, curren: 0xa4, deg: 0xb0, divide: 0xf7, eacute: 0xe9, ecirc: 0xea, egrave: 0xe8, eth: 0xf0, euml: 0xeb, frac12: 0xbd, frac14: 0xbc, frac34: 0xbe, iacute: 0xed, icirc: 0xee, iexcl: 0xa1, igrave: 0xec, iquest: 0xbf, iuml: 0xef, laquo: 0xab, macr: 0xaf, micro: 0xb5, middot: 0xb7, nbsp: 0xa0, not: 0xac, ntilde: 0xf1, oacute: 0xf3, ocirc: 0xf4, ograve: 0xf2, ordf: 0xaa, ordm: 0xba, oslash: 0xf8, otilde: 0xf5, ouml: 0xf6, para: 0xb6, plusmn: 0xb1, pound: 0xa3, raquo: 0xbb, reg: 0xae, sect: 0xa7, shy: 0xad, sup1: 0xb9, sup2: 0xb2, sup3: 0xb3, szlig: 0xdf, thorn: 0xfe, times: 0xd7, uacute: 0xfa, ucirc: 0xfb, ugrave: 0xf9, uml: 0xa8, uuml: 0xfc, yacute: 0xfd, yen: 0xa5, yuml: 0xff }).filter(([n]) => n !== 'nbsp').map(([n, cp]) => [n, String.fromCodePoint(cp)])); // An entity-escaped tag: `<p>`, `</p>`, `<br />`, `<a href="x">`. // Known tags only, and attributes must be name=value pairs (see htmlToText). const ESCAPED_TAG_NAMES = 'p|br|hr|div|span|b|i|u|em|strong|ul|ol|li|h[1-6]|a|table|thead|tbody|tr|td|th|section|article|blockquote|font|sup|sub|pre|code|small|big'; // `&` is the one entity that legitimately sits inside a quoted value (a URL's // query string), so it is allowed there while any other `&` keeps the prose guard. const ESCAPED_ATTR = String.raw`\s+[a-z_:][\w:.-]*=(?:"(?:[^"<>&]|&)*"|'(?:[^'<>&]|&)*'|"(?:[^<>&]|&)*?"|[^\s"'<>&=]+)`; const ESCAPED_TAG_RE = new RegExp(String.raw`<(/?(?:${ESCAPED_TAG_NAMES}))((?:${ESCAPED_ATTR})*)\s*/?>`, 'gi'); // `�` is out of range and String.fromCodePoint throws on it, which // aborted the whole extraction (the batch CLI then logged "error"). An entity // that is not a real code point is not decodable, so it reads as a space. function codePointOrSpace(cp) { return Number.isInteger(cp) && cp >= 0 && cp <= 0x10ffff && !(cp >= 0xd800 && cp <= 0xdfff) ? String.fromCodePoint(cp) : ' '; } /** Strip HTML to visible text, matching the runner's own normalization. */ function htmlToText(html) { // Some boards (Phenom, observed on careers.roberthalf.com) ship the JobPosting // description as entity-ESCAPED markup (`<div><p>…`). The tag strip // below cannot see those as tags, and the entity pass then turns them back into // literal `

` in the JD. Unescape the tags, and ONLY the tags: a global // `<`/`>` replace would also decode prose like `latency < 50 ms`, and // the strip would then eat the prose up to the next `>`. A tag qualifies when // it is a known HTML tag whose attributes are all name="value" pairs, so // `<b and c>` stays text. const markup = String(html ?? '').replace( ESCAPED_TAG_RE, (_m, name, attrs) => `<${name}${String(attrs).replace(/"/gi, '"')}>`, ); return stripMarkup(markup) .replace(/&([a-z]+|#x?[0-9a-f]+);/gi, (m, e) => { if (Object.hasOwn(LATIN1_ENTITIES, e)) return LATIN1_ENTITIES[e]; const k = e.toLowerCase(); if (Object.hasOwn(HTML_ENTITIES, k)) return HTML_ENTITIES[k]; const dec = /^#(\d+)$/.exec(k); if (dec) return codePointOrSpace(Number(dec[1])); const hex = /^#x([0-9a-f]+)$/.exec(k); if (hex) return codePointOrSpace(parseInt(hex[1], 16)); return ' '; }) .replace(/[ \t ]+/g, ' ') .replace(/\n\s*\n\s*\n+/g, '\n\n') .replace(/^[ \t]+|[ \t]+$/gm, '') .trim(); } export const wordCount = (t) => t.split(/\s+/).filter(Boolean).length; const BLOCK_END_TAG = /^\/(p|div|li|h[1-6]|tr|section|article)$/; /** * Remove `