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