From b27a3b5e810d8edbd44884223a9210d84c7901dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fit=20ERDO=C4=9EAN?= Date: Tue, 7 Jul 2026 20:40:32 +0300 Subject: [PATCH] fix(linkedin-search): decode hex HTML entities in CLI output (#55) decodeHtmlEntities only handled decimal numeric character references (é); the equally valid hexadecimal form (é) fell through undecoded and surfaced as raw text in titles, companies, locations and descriptions. It also used String.fromCharCode, which corrupts supplementary-plane code points (e.g. emoji, U+1F600). Add a hexadecimal numeric-entity rule and route both decimal and hex through a fromCodePoint-based helper with a valid-range guard. Add network-free unit tests covering hex, uppercase-X hex, decimal (regression) and astral code points via the exported parse functions. --- .../skills/linkedin-search/cli/src/helpers.ts | 13 ++++- .../linkedin-search/cli/tests/parsing.test.ts | 55 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/linkedin-search/cli/tests/parsing.test.ts diff --git a/.agents/skills/linkedin-search/cli/src/helpers.ts b/.agents/skills/linkedin-search/cli/src/helpers.ts index 56308b3..19e0200 100644 --- a/.agents/skills/linkedin-search/cli/src/helpers.ts +++ b/.agents/skills/linkedin-search/cli/src/helpers.ts @@ -67,6 +67,15 @@ export interface JobDetail extends JobCard { applyUrl: string | null } +/** + * Convert a Unicode code point to a string. Uses `fromCodePoint` (not + * `fromCharCode`) so supplementary-plane code points (e.g. emoji, U+1F600) + * decode correctly, and drops out-of-range values instead of throwing. + */ +function numericEntity(cp: number): string { + return cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : "" +} + function decodeHtmlEntities(text: string): string { return text .replace(/&/g, "&") @@ -75,7 +84,9 @@ function decodeHtmlEntities(text: string): string { .replace(/"/g, '"') .replace(/'/g, "'") .replace(/'/g, "'") - .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10))) + // Numeric character references: decimal (é) and hexadecimal (é). + .replace(/&#(\d+);/g, (_, dec) => numericEntity(parseInt(dec, 10))) + .replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex) => numericEntity(parseInt(hex, 16))) .replace(/ /g, " ") } diff --git a/.agents/skills/linkedin-search/cli/tests/parsing.test.ts b/.agents/skills/linkedin-search/cli/tests/parsing.test.ts new file mode 100644 index 0000000..49d3115 --- /dev/null +++ b/.agents/skills/linkedin-search/cli/tests/parsing.test.ts @@ -0,0 +1,55 @@ +import { describe, test, expect } from "bun:test"; +import { parseJobCards, parseJobDetail } from "../src/helpers"; + +// Minimal search-card markup: parseJobCards splits on the job-posting URN and +// needs an id, a base-search-card__title, and a full-link. Everything else is +// optional. We inject HTML entities into the title/company to exercise decoding. +function searchCard(id: string, title: string, company = "Acme"): string { + return `
  • +
    + +

    ${title}

    +

    ${company}

    +
    +
  • `; +} + +describe("decodeHtmlEntities (via parseJobCards)", () => { + test("decodes hexadecimal numeric entities (é)", () => { + const [card] = parseJobCards(searchCard("123", "Café Manager")); + expect(card.title).toBe("Café Manager"); + }); + + test("decodes uppercase-X hexadecimal entities (&#X...;)", () => { + const [card] = parseJobCards(searchCard("124", "Debüt Role")); // ü = ü + expect(card.title).toBe("Debüt Role"); + }); + + test("still decodes decimal numeric entities (é) — regression", () => { + const [card] = parseJobCards(searchCard("125", "Café Lead")); + expect(card.title).toBe("Café Lead"); + }); + + test("decodes supplementary-plane code points with fromCodePoint (😀)", () => { + const [card] = parseJobCards(searchCard("126", "Growth 😀")); + expect(card.title).toBe("Growth 😀"); + }); + + test("decodes hex supplementary-plane code points (😀)", () => { + const [card] = parseJobCards(searchCard("127", "Growth 😀")); + expect(card.title).toBe("Growth 😀"); + }); + + test("decodes hex entities in the company subtitle too", () => { + const [card] = parseJobCards(searchCard("128", "Engineer", "Nørrebro ApS")); + expect(card.company).toBe("Nørrebro ApS"); + }); +}); + +describe("decodeHtmlEntities (via parseJobDetail)", () => { + test("decodes hex entities inside the job title", () => { + const html = `

    Señor Engineer

    `; + const job = parseJobDetail(html, "999"); + expect(job.title).toBe("Señor Engineer"); + }); +});