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.
This commit is contained in:
Yiğit ERDOĞAN
2026-07-07 19:40:32 +02:00
committed by GitHub
parent 4488290274
commit b27a3b5e81
2 changed files with 67 additions and 1 deletions
@@ -67,6 +67,15 @@ export interface JobDetail extends JobCard {
applyUrl: string | null 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 { function decodeHtmlEntities(text: string): string {
return text return text
.replace(/&amp;/g, "&") .replace(/&amp;/g, "&")
@@ -75,7 +84,9 @@ function decodeHtmlEntities(text: string): string {
.replace(/&quot;/g, '"') .replace(/&quot;/g, '"')
.replace(/&#39;/g, "'") .replace(/&#39;/g, "'")
.replace(/&apos;/g, "'") .replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10))) // Numeric character references: decimal (&#233;) and hexadecimal (&#xE9;).
.replace(/&#(\d+);/g, (_, dec) => numericEntity(parseInt(dec, 10)))
.replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex) => numericEntity(parseInt(hex, 16)))
.replace(/&nbsp;/g, " ") .replace(/&nbsp;/g, " ")
} }
@@ -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 `<li>
<div data-entity-urn="urn:li:jobPosting:${id}">
<a class="base-card__full-link" href="https://www.linkedin.com/jobs/view/${id}"></a>
<h3 class="base-search-card__title">${title}</h3>
<h4 class="base-search-card__subtitle"><a href="https://www.linkedin.com/company/acme">${company}</a></h4>
</div>
</li>`;
}
describe("decodeHtmlEntities (via parseJobCards)", () => {
test("decodes hexadecimal numeric entities (&#xE9;)", () => {
const [card] = parseJobCards(searchCard("123", "Caf&#xE9; Manager"));
expect(card.title).toBe("Café Manager");
});
test("decodes uppercase-X hexadecimal entities (&#X...;)", () => {
const [card] = parseJobCards(searchCard("124", "Deb&#XFC;t Role")); // &#XFC; = ü
expect(card.title).toBe("Debüt Role");
});
test("still decodes decimal numeric entities (&#233;) — regression", () => {
const [card] = parseJobCards(searchCard("125", "Caf&#233; Lead"));
expect(card.title).toBe("Café Lead");
});
test("decodes supplementary-plane code points with fromCodePoint (&#128512;)", () => {
const [card] = parseJobCards(searchCard("126", "Growth &#128512;"));
expect(card.title).toBe("Growth 😀");
});
test("decodes hex supplementary-plane code points (&#x1F600;)", () => {
const [card] = parseJobCards(searchCard("127", "Growth &#x1F600;"));
expect(card.title).toBe("Growth 😀");
});
test("decodes hex entities in the company subtitle too", () => {
const [card] = parseJobCards(searchCard("128", "Engineer", "N&#xF8;rrebro ApS"));
expect(card.company).toBe("Nørrebro ApS");
});
});
describe("decodeHtmlEntities (via parseJobDetail)", () => {
test("decodes hex entities inside the job title", () => {
const html = `<h1 class="topcard__title">Se&#xF1;or Engineer</h1>`;
const job = parseJobDetail(html, "999");
expect(job.title).toBe("Señor Engineer");
});
});