From dab215073e74e1d6fc3bd86f14983ac16c4231e7 Mon Sep 17 00:00:00 2001 From: Mads Lorentzen Date: Wed, 19 Aug 2026 20:53:24 +0200 Subject: [PATCH] fix(jobdanmark-search): normalize the detail command's HTML fallback branch The JSON-LD and rendered-HTML branches returned structurally different records: the fallback emitted raw DD-MM-YYYY page text as datePosted, free text (including the literal "Loebende") as validThrough, and a hardcoded null addressLocality. Overview dates now convert to ISO, Loebende maps to null, and the locality derives from the workplace address via the same exported extractCity search uses. Review finding F25 (2026-08-19). Co-Authored-By: Claude Opus 5 (1M context) --- .../cli/src/commands/detail.ts | 22 ++++++++++++++++--- .../cli/src/commands/search.ts | 4 ++-- .../cli/tests/detail-parsing.test.ts | 19 ++++++++++++++-- CHANGELOG.md | 8 +++++++ 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/.agents/skills/jobdanmark-search/cli/src/commands/detail.ts b/.agents/skills/jobdanmark-search/cli/src/commands/detail.ts index f4e0a29..4bca9db 100644 --- a/.agents/skills/jobdanmark-search/cli/src/commands/detail.ts +++ b/.agents/skills/jobdanmark-search/cli/src/commands/detail.ts @@ -2,6 +2,7 @@ import { defineCommand, option } from "@bunli/core" import { z } from "zod" import { parse } from "node-html-parser" import { BASE_URL, writeError } from "../helpers.js" +import { extractCity, toContractDate } from "./search.js" interface JsonLdJobPosting { "@context"?: string @@ -119,6 +120,21 @@ function fromJsonLd(jobPosting: JsonLdJobPosting, slug: string, url: string): De } } +/** + * Normalize a date read from the rendered page's overview list. The page + * writes DD-MM-YYYY (sometimes with a trailing time, "02-08-2026 23.59"), + * and the deadline can be the free-text "Løbende" (rolling) - jobbank maps + * its equivalent to null, and every consumer does date arithmetic on the + * value. The JSON-LD branch gets schema.org ISO dates and needs none of this. + */ +function normalizeOverviewDate(value: string | null): string | null { + if (!value) return null + const trimmed = value.trim() + if (/^løbende$/iu.test(trimmed)) return null + const match = trimmed.match(/^(\d{2})-(\d{2})-(\d{4})/) + return match ? `${match[3]}-${match[2]}-${match[1]}` : toContractDate(trimmed) +} + function overviewValue(root: ReturnType, label: string): string | null { const normalizedLabel = label.toLowerCase() for (const item of root.querySelectorAll(".job-overview li")) { @@ -174,8 +190,8 @@ function fromRenderedHtml(root: ReturnType, slug: string, url: str slug, url, title, - datePosted: overviewValue(root, "Udgivet") ?? "", - validThrough: overviewValue(root, "Ansøgningsfrist"), + datePosted: normalizeOverviewDate(overviewValue(root, "Udgivet")) ?? "", + validThrough: normalizeOverviewDate(overviewValue(root, "Ansøgningsfrist")), employmentType: employmentType ? [employmentType] : [], hiringOrganization: { name: companyName, @@ -183,7 +199,7 @@ function fromRenderedHtml(root: ReturnType, slug: string, url: str }, jobLocation: { streetAddress: workplace, - addressLocality: null, + addressLocality: extractCity(workplace), addressRegion: null, postalCode: null, addressCountry: "DK", diff --git a/.agents/skills/jobdanmark-search/cli/src/commands/search.ts b/.agents/skills/jobdanmark-search/cli/src/commands/search.ts index 304399e..07a1f33 100644 --- a/.agents/skills/jobdanmark-search/cli/src/commands/search.ts +++ b/.agents/skills/jobdanmark-search/cli/src/commands/search.ts @@ -34,7 +34,7 @@ interface ApiSearchResponse { totalPages: number } -function toContractDate(value: string | null): string | null { +export function toContractDate(value: string | null): string | null { const match = value?.match(/^(\d{2})-(\d{2})-(\d{4})$/) return match ? `${match[3]}-${match[2]}-${match[1]}` : (value ?? null) } @@ -43,7 +43,7 @@ function toContractDate(value: string | null): string | null { // "Lautruphoej 2, 2750 Ballerup" or "2670, Greve". The comma fallback // requires a non-digit after the comma so a 4-digit street number // ("Vejlevej 1234, 7100 Vejle") never wins over the real postcode. -function extractCity(address: string | null): string | null { +export function extractCity(address: string | null): string | null { if (!address) return null const city = address.match(/\d{4}\s+(.+)$/)?.[1] ?? address.match(/\d{4}\s*,\s*([^\d,].*)$/)?.[1] diff --git a/.agents/skills/jobdanmark-search/cli/tests/detail-parsing.test.ts b/.agents/skills/jobdanmark-search/cli/tests/detail-parsing.test.ts index ed8f453..8c1fa25 100644 --- a/.agents/skills/jobdanmark-search/cli/tests/detail-parsing.test.ts +++ b/.agents/skills/jobdanmark-search/cli/tests/detail-parsing.test.ts @@ -40,16 +40,31 @@ describe("parseJobPostingFromHtml", () => { ); expect(parsed.title).toBe("Journalistisk udvikler søges"); - expect(parsed.datePosted).toBe("03-07-2026"); - expect(parsed.validThrough).toBe("02-08-2026 23.59"); + // The fallback must emit the same shapes as the JSON-LD branch: contract + // dates, not the page's raw DD-MM-YYYY text (review finding F25, 2026-08-19). + expect(parsed.datePosted).toBe("2026-07-03"); + expect(parsed.validThrough).toBe("2026-08-02"); expect(parsed.employmentType).toEqual(["Fuldtid"]); expect(parsed.hiringOrganization.name).toBe("JFM"); expect(parsed.hiringOrganization.logo).toBe("https://jobdanmark.dk/media/jfm-logo.png?width=100"); expect(parsed.jobLocation.streetAddress).toBe("Banegårdspladsen 1, 5000 Odense C"); + expect(parsed.jobLocation.addressLocality).toBe("Odense C"); expect(parsed.description).toContain("identificere relevante datasæt"); expect(parsed.applyUrl).toBe("https://jfm.career.emply.com/da/apply/example"); }); + test("maps a rolling deadline (Løbende) to null in the HTML fallback", () => { + // "Løbende" is free text meaning rolling/ongoing - jobbank's parser maps + // its equivalent to null, and a stored "Løbende" deadline would hit every + // date-arithmetic consumer (review finding F25, 2026-08-19). + const html = HTML_WITHOUT_JSON_LD.replace( + "
  • Ansøgningsfrist: 02-08-2026 23.59
  • ", + "
  • Ansøgningsfrist: Løbende
  • ", + ); + const parsed = parseJobPostingFromHtml(html, "s", "https://jobdanmark.dk/job/s"); + expect(parsed.validThrough).toBeNull(); + }); + test("does not reject titles containing '404' mid-phrase", () => { const htmlWith404InTitle = HTML_WITHOUT_JSON_LD.replace( "Journalistisk udvikler søges | jobdanmark", diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f38d6f..8a97fcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,6 +120,14 @@ per-file diff commands. ### Fixed +- **`jobdanmark-search detail`'s HTML fallback emits the same shapes as its JSON-LD + branch** - a posting without JSON-LD returned `datePosted` as the page's raw + `DD-MM-YYYY` text, `validThrough` as free text (including the literal `"Løbende"`, + which would flow into stored data as a deadline), and a hardcoded `null` + `addressLocality`. The fallback now converts overview dates to `YYYY-MM-DD`, maps + `Løbende` to `null` (jobbank's precedent for the equivalent), and derives the locality + from the workplace address with the same postcode extraction search uses. Pinned in + `tests/detail-parsing.test.ts`. - **`jobnet-search detail` no longer leaks the `1900-01-01` undisclosed-deadline sentinel** - `search` maps the API's sentinel to `null` (with a test pinning it), but `detail` dumped the raw response, so a posting whose deadline is simply not disclosed