fix(portal-clis): emit the contract's id field from jobnet and jobdanmark search results (#518)

/add-portal's contract says every search result carries at least id,
title, company, location, date, url, and the four other shipped CLIs
emit id. When #340 added the company/location/date/url aliases to these
two, id was left out: jobnet exposed the value only as jobAdId,
jobdanmark only as slug, so a consumer reading every portal's JSON
through one shape had to special-case both to call detail.

Purely additive: id equals jobAdId on jobnet and slug on jobdanmark, the
native keys stay, nothing is renamed. Each SKILL.md notes the alias. The
additive-contract test in each CLI now asserts id and its equality with
the native key; both fail on master.
This commit is contained in:
Ayobami Adegoke authored and GitHub committed 2026-10-05 19:27:43 +02:00
1 parent e9115b02f8
commit 895c02191d
7 files changed
+30 -4

No files matched your search

+1 -1
View File
@@ -224,6 +224,6 @@ All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and
- All data is from the public Jobdanmark.dk API — no credentials required. - All data is from the public Jobdanmark.dk API — no credentials required.
- Pagination is 1-indexed (`--page 1` is the first page). 30 items per page, server-enforced. - Pagination is 1-indexed (`--page 1` is the first page). 30 items per page, server-enforced.
- The `detail` command fetches the HTML job page and parses embedded JSON-LD when available, with a rendered-HTML fallback for pages that omit structured data. It does not use a separate JSON API. - The `detail` command fetches the HTML job page and parses embedded JSON-LD when available, with a rendered-HTML fallback for pages that omit structured data. It does not use a separate JSON API.
- `slug` in search results is extracted from the API's relative `url` field (the path after `/job/`). - `slug` in search results is extracted from the API's relative `url` field (the path after `/job/`); it is also emitted as `id`, the portal-contract name every search CLI shares.
- `applicationDeadline` in search results can be `null` (no deadline set). - `applicationDeadline` in search results can be `null` (no deadline set).
- Job type values for filters: `fuldtid`, `deltid`, `fleksjob`, `elev`, `studiejob`, `praktik`. - Job type values for filters: `fuldtid`, `deltid`, `fleksjob`, `elev`, `studiejob`, `praktik`.
@@ -74,6 +74,10 @@ export function normalizeItem(item: ApiSearchItem): Record<string, unknown> {
applicationDeadline: item.applicationDeadline ?? null, applicationDeadline: item.applicationDeadline ?? null,
url: fullUrl, url: fullUrl,
slug, slug,
// /scrape contract aliases (add-portal.md: every result carries at least
// id, title, company, location, date, url). `id` is the slug - the value
// `detail` takes - under the name every other portal CLI uses.
id: slug,
company: item.companyName, company: item.companyName,
location: extractCity(item.companyAddress), location: extractCity(item.companyAddress),
date: toContractDate(item.publishedDate), date: toContractDate(item.publishedDate),
@@ -20,16 +20,19 @@ function item(): ApiSearchItem {
} }
describe("Jobdanmark search normalization", () => { describe("Jobdanmark search normalization", () => {
test("additively emits the /scrape contract fields (company, location, date, deadline)", () => { test("additively emits the /scrape contract fields (id, company, location, date, deadline, url)", () => {
const result = normalizeItem(item()); const result = normalizeItem(item());
expect(result).toMatchObject({ expect(result).toMatchObject({
id: "softwareudvikler-til-statens-it",
company: "Statens It", company: "Statens It",
location: "Ballerup", location: "Ballerup",
date: "2026-07-27", date: "2026-07-27",
deadline: "2026-08-17", deadline: "2026-08-17",
url: "https://jobdanmark.dk/job/softwareudvikler-til-statens-it", url: "https://jobdanmark.dk/job/softwareudvikler-til-statens-it",
}); });
// `id` is the contract name for the value `detail <slug>` consumes.
expect(result.id).toBe(result.slug);
}); });
test("maps a missing address zip and a null deadline to null", () => { test("maps a missing address zip and a null deadline to null", () => {
+1 -1
View File
@@ -69,7 +69,7 @@ Key flags:
bun run .agents/skills/jobnet-search/cli/src/cli.ts detail <jobAdId> [--format json|plain] bun run .agents/skills/jobnet-search/cli/src/cli.ts detail <jobAdId> [--format json|plain]
``` ```
`jobAdId` is the UUID from `search` results (the `jobAdId` field). Returns the complete job `jobAdId` is the UUID from `search` results (the `jobAdId` field, also emitted as `id`, the portal-contract name every search CLI shares). Returns the complete job
description, contact persons, application deadline, employer details, and direct application URL. description, contact persons, application deadline, employer details, and direct application URL.
### Search occupation types ### Search occupation types
@@ -104,6 +104,10 @@ export function createSearchOutput(data: SearchApiResponse, flags: SearchFlags)
workPlaceAddress: job.workPlaceAddress ?? "", workPlaceAddress: job.workPlaceAddress ?? "",
isSeen: job.isSeen, isSeen: job.isSeen,
isFavorite: job.isFavorite, isFavorite: job.isFavorite,
// /scrape contract aliases (add-portal.md: every result carries at least
// id, title, company, location, date, url). `id` is the jobAdId - the
// value `detail` takes - under the name every other portal CLI uses.
id: job.jobAdId,
company: job.hiringOrgName, company: job.hiringOrgName,
location: job.postalDistrictName ?? job.municipality ?? null, location: job.postalDistrictName ?? job.municipality ?? null,
date: job.publicationDate ? job.publicationDate.slice(0, 10) : null, date: job.publicationDate ? job.publicationDate.slice(0, 10) : null,
@@ -128,11 +128,12 @@ describe("Jobnet search normalization", () => {
expect("description" in output.results[0]).toBe(false); expect("description" in output.results[0]).toBe(false);
}); });
test("additively emits the /scrape contract fields (company, location, date, deadline, url)", () => { test("additively emits the /scrape contract fields (id, company, location, date, deadline, url)", () => {
const output = createSearchOutput(apiResponse(), { ...flags, limit: undefined }); const output = createSearchOutput(apiResponse(), { ...flags, limit: undefined });
expect(output.results).toHaveLength(2); expect(output.results).toHaveLength(2);
expect(output.results[0]).toMatchObject({ expect(output.results[0]).toMatchObject({
id: "job-1",
company: "Acme", company: "Acme",
location: null, location: null,
date: "2026-07-01", date: "2026-07-01",
@@ -140,6 +141,7 @@ describe("Jobnet search normalization", () => {
url: "https://jobnet.dk/find-job/job-1", url: "https://jobnet.dk/find-job/job-1",
}); });
expect(output.results[1]).toMatchObject({ expect(output.results[1]).toMatchObject({
id: "job-2",
company: "Example Co", company: "Example Co",
location: "København Ø", location: "København Ø",
date: "2026-07-02", date: "2026-07-02",
@@ -148,6 +150,8 @@ describe("Jobnet search normalization", () => {
}); });
expect(output.results[0].hiringOrgName).toBe("Acme"); expect(output.results[0].hiringOrgName).toBe("Acme");
expect(output.results[1].applicationDeadline).toBe("2026-08-01T23:59:00+02:00"); expect(output.results[1].applicationDeadline).toBe("2026-08-01T23:59:00+02:00");
// `id` is the contract name for the value `detail <jobAdId>` consumes.
expect(output.results[0].id).toBe(output.results[0].jobAdId);
}); });
test("maps Jobnet's undisclosed-deadline sentinel (1900-01-01) to null", () => { test("maps Jobnet's undisclosed-deadline sentinel (1900-01-01) to null", () => {
+11
View File
@@ -15,6 +15,17 @@ per-file diff commands.
### Fixed ### Fixed
- **`jobnet-search` and `jobdanmark-search` search results now carry the contract's `id`
field** (`search.ts` and `search-normalization.test.ts` in both CLIs, both `SKILL.md`s) -
`/add-portal`'s contract says every search result has at least `id`, `title`, `company`,
`location`, `date`, `url`, and the four other shipped CLIs emit `id`. When #340 added the
`company`/`location`/`date`/`url` aliases to these two, `id` was left out: jobnet exposed the
value only as `jobAdId`, jobdanmark only as `slug`, so a consumer reading every portal's
JSON through one shape had to special-case both to call `detail`. Purely additive: `id`
equals `jobAdId` on jobnet and `slug` on jobdanmark, the native keys stay, nothing is
renamed. The additive-contract test in each CLI now asserts `id` and its equality with the
native key; both fail on master.
- **`jobdanmark-search detail` renders a JSON-LD description as text instead of passing - **`jobdanmark-search detail` renders a JSON-LD description as text instead of passing
the markup through** (`.agents/skills/jobdanmark-search/cli/src/commands/detail.ts`, the markup through** (`.agents/skills/jobdanmark-search/cli/src/commands/detail.ts`,
`tests/detail-jsonld.test.ts`) - the JSON-LD branch emitted `jobPosting.description` `tests/detail-jsonld.test.ts`) - the JSON-LD branch emitted `jobPosting.description`