From 23876bfa9535e605170ed8fc057e88cdb8d1f00b Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:55:15 -0400 Subject: [PATCH] fix(linkedin-search): preserve description paragraph and list breaks (#567) * fix(linkedin-search): preserve description paragraph and list breaks Clean rendered description lines separately so whitespace normalization does not erase paragraph and list boundaries in JSON and plain output. Exercise the documented detail CLI against a loopback-served captured response, with field and closed-status controls. Credits: portapulito diagnosed this behavior and supplied the captured guest-response fixture in upstream PR #153, which was closed as an accidental personalized-fork submission. This independent implementation revives only that correctness fix and its fixture evidence. * docs(changelog): record fix for #567 --- .../skills/linkedin-search/cli/src/helpers.ts | 5 +- .../cli/tests/detail-description-cli.test.ts | 74 +++++++++++++++++++ .../cli/tests/fixtures/README.md | 20 +++++ .../tests/fixtures/detail-orbis-excerpt.html | 46 ++++++++++++ CHANGELOG.md | 2 + 5 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/linkedin-search/cli/tests/detail-description-cli.test.ts create mode 100644 .agents/skills/linkedin-search/cli/tests/fixtures/README.md create mode 100644 .agents/skills/linkedin-search/cli/tests/fixtures/detail-orbis-excerpt.html diff --git a/.agents/skills/linkedin-search/cli/src/helpers.ts b/.agents/skills/linkedin-search/cli/src/helpers.ts index 21915495..4bce89ad 100644 --- a/.agents/skills/linkedin-search/cli/src/helpers.ts +++ b/.agents/skills/linkedin-search/cli/src/helpers.ts @@ -214,9 +214,12 @@ export function parseJobDetail(html: string, id: string): JobDetail { extractDivContent(html, "description__text") if (descHtml) { const withBreaks = descHtml + .replace(/\s+/g, " ") .replace(/<\s*br\s*\/?>/gi, "\n") .replace(/<\/(p|li|ul|ol|div|h\d)>/gi, "\n") - description = decodeHtmlEntities(stripTags(withBreaks)).replace(/\n{3,}/g, "\n\n").trim() || null + // Clean each rendered line separately: stripTags collapses whitespace, + // so applying it to the whole description erased these boundaries. + description = withBreaks.split("\n").map(clean).join("\n").replace(/\n{3,}/g, "\n\n").trim() || null } // Job-criteria items: subheader label -> text value. diff --git a/.agents/skills/linkedin-search/cli/tests/detail-description-cli.test.ts b/.agents/skills/linkedin-search/cli/tests/detail-description-cli.test.ts new file mode 100644 index 00000000..4804bef4 --- /dev/null +++ b/.agents/skills/linkedin-search/cli/tests/detail-description-cli.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const endpoint = "https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/4345841651"; +const cliPath = join(import.meta.dir, "../src/cli.ts"); +const fixture = Bun.file(join(import.meta.dir, "fixtures/detail-orbis-excerpt.html")); + +async function detail(format: "json" | "plain") { + const requests: string[] = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + requests.push(new URL(request.url).pathname); + return new Response(fixture, { headers: { "Content-Type": "text/html" } }); + }, + }); + const dir = mkdtempSync(join(tmpdir(), "linkedin-description-")); + const preload = join(dir, "fetch.ts"); + // Exercise the documented CLI in a fresh process, preserving its request + // options. Any request other than this posting fails before network access. + writeFileSync(preload, ` + const originalFetch = globalThis.fetch; + globalThis.fetch = (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url !== ${JSON.stringify(endpoint)}) throw new Error("Unexpected fixture request"); + return originalFetch(${JSON.stringify(server.url.origin)} + new URL(url).pathname, init); + }; + `); + try { + const proc = Bun.spawn([ + process.execPath, "run", "--preload", preload, cliPath, + "detail", "4345841651", "--format", format, + ], { stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + expect(requests).toEqual(["/jobs-guest/jobs/api/jobPosting/4345841651"]); + return stdout; + } finally { + server.stop(true); + rmSync(dir, { recursive: true, force: true }); + } +} + +test("detail JSON preserves paragraph and list boundaries from a captured guest response", async () => { + const job = JSON.parse(await detail("json")); + expect(job.description).toContain("Europe\n"); + expect(job.description).toContain("Designing and building agentic & multi-agent systems\nDeveloping"); + expect(job.description).not.toContain("
"); +}); + +test("detail plain output preserves the same description boundaries", async () => { + const output = await detail("plain"); + expect(output).toContain("Designing and building agentic & multi-agent systems\nDeveloping"); + expect(output).toContain("Status: CLOSED / EXPIRED"); +}); + +test("description formatting retains the captured posting's other fields and text", async () => { + const job = JSON.parse(await detail("json")); + expect(job.id).toBe("4345841651"); + expect(job.title).toBe("Agentic AI Engineer - Full remote - Europe"); + expect(job.seniority).toBe("Mid-Senior level"); + expect(job.employmentType).toBe("Contract"); + expect(job.isActive).toBe(false); + expect(job.description).toContain("Designing and building agentic & multi-agent systems"); + expect(job.description).toContain("Developing high-performance APIs that power AI agents at scale"); +}); diff --git a/.agents/skills/linkedin-search/cli/tests/fixtures/README.md b/.agents/skills/linkedin-search/cli/tests/fixtures/README.md new file mode 100644 index 00000000..bb9d1adc --- /dev/null +++ b/.agents/skills/linkedin-search/cli/tests/fixtures/README.md @@ -0,0 +1,20 @@ +# LinkedIn detail fixture + +`detail-orbis-excerpt.html` contains verbatim excerpts from the guest response for +posting `4345841651`, captured by **portapulito** through this CLI's `htmlFetch` +on 2026-07-14 and published in [upstream PR #153](https://github.com/MadsLorentzen/ai-job-search/pull/153). +That PR was closed because it was accidentally submitted to upstream instead of +the author's personalized fork; it was not merged. + +Source: `.agents/skills/linkedin-search/cli/test/__fixtures__/detail-orbis.html` +at commit `5bc3afb94d19b670ecf09287c20d0c95e5d13ba0`. +Original SHA-256: `2125a346c22f471e3db08cd3890d0569b194703fe5f49b831cdde257e4aa4a59`. + +Only the title, closed banner, description markup and criteria list are retained. +Their bytes are unmodified; recruiter cards, images, tracking links and unrelated +page sections are omitted. The description includes real adjacent paragraphs, +empty paragraphs and list items, rather than hand-written parser examples. + +The CLI regression serves this saved response over loopback and redirects only +the exact expected guest endpoint to it in a child process. It makes no LinkedIn +requests and does not regenerate the fixture during tests. diff --git a/.agents/skills/linkedin-search/cli/tests/fixtures/detail-orbis-excerpt.html b/.agents/skills/linkedin-search/cli/tests/fixtures/detail-orbis-excerpt.html new file mode 100644 index 00000000..924ed9c9 --- /dev/null +++ b/.agents/skills/linkedin-search/cli/tests/fixtures/detail-orbis-excerpt.html @@ -0,0 +1,46 @@ +
Agentic AI Engineer - Full remote - Europe
Our SaaS client based in The Netherlands are in need of a number of AI Engineers who can design, build and deploy agentic systems to their customers.
This is a hands-on engineering role, focused on turning ideas into fast POCs and then scaling them into production-ready systems.
What you’ll be doing:
Tech environment:
What we’re looking for:
Location : Fully remote (Europe)
Duration : 3-month rolling contract
Headcount : 2
Rate : €375 - 400 a day
If this sounds like you, or you know someone who fits, drop me a message and I’ll share more details.
+