diff --git a/.agents/skills/jobnet-search/SKILL.md b/.agents/skills/jobnet-search/SKILL.md index 695b9de2..f37abe94 100644 --- a/.agents/skills/jobnet-search/SKILL.md +++ b/.agents/skills/jobnet-search/SKILL.md @@ -202,5 +202,5 @@ All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and - Pagination is 1-indexed (`--page 1` is the first page). - `search` results omit the HTML job description — use `detail` to get it. - `detail --format plain` strips HTML tags for readable text output. -- Job ad detail pages on jobnet.dk: `https://jobnet.dk/find-job/{jobAdId}` +- Job ad detail pages on jobnet.dk: `https://jobnet.dk/find-job/{jobAdId}` (non-external ads; external ads use employer `jobAdUrl`). - `suggestions` is tuned for Danish job titles — English terms may return empty results. diff --git a/.agents/skills/jobnet-search/cli/README.md b/.agents/skills/jobnet-search/cli/README.md index 634b913b..aba26646 100644 --- a/.agents/skills/jobnet-search/cli/README.md +++ b/.agents/skills/jobnet-search/cli/README.md @@ -141,6 +141,7 @@ bun run src/cli.ts search \ "applicationDeadlineStatus": "ExpirationDate", "workHourPartTime": false, "isExternal": false, + "jobAdUrl": null, "hasLogo": true, "logoUrl": "/bff/SharedComponents/JobAdCard/CompanyLogo/ByJobAdId/9ef43bce-d82b-4ea1-a098-7ff6520f99be", "cvr": "29190925", @@ -160,7 +161,7 @@ bun run src/cli.ts search \ > **Note**: The `description` field (raw HTML) is intentionally omitted from `search` results for brevity. Use `detail` to retrieve the full job description. -> **Note**: Every result also carries the cross-portal contract fields `company`, `location`, `date`, `deadline` and `url` — derived respectively from `hiringOrgName`, `postalDistrictName`/`municipality`, and the jobnet detail page URL. `/scrape` Step 2 expects search output to include title, company, location, date, and URL, and dates follow the `YYYY-MM-DD` convention of the other portal CLIs. The API's `1900-01-01` deadline sentinel (deadline not disclosed) maps to `null`. The native fields above are preserved unchanged. +> **Note**: Every result also carries the cross-portal contract fields `company`, `location`, `date`, `deadline` and `url` - derived respectively from `hiringOrgName`, `postalDistrictName`/`municipality`, and the destination URL (the employer's `jobAdUrl` for external ads, or `/find-job/{jobAdId}` for internal ones). `/scrape` Step 2 expects search output to include title, company, location, date, and URL, and dates follow the `YYYY-MM-DD` convention of the other portal CLIs. The API's `1900-01-01` deadline sentinel (deadline not disclosed) maps to `null`. The native fields above are preserved unchanged. > **Note**: `resultsPerPage` and `pageNumber` must always be provided — omitting them while also providing `searchString` causes the API to return error 1014 ("Fejl i formatering af inputs"). @@ -361,9 +362,10 @@ https://jobnet.dk/find-job/{jobAdId} ``` The legacy `https://jobnet.dk/job/{jobAdId}` route redirects anonymous visitors into the -MitID login flow, so it is never emitted. External ads (`isExternal: true`, jobAdIds with an -`E` prefix) 404 on `/find-job/` and hit the login wall on `/job/` - neither route serves them -anonymously; `/find-job/` is still strictly better and external ads are left as-is. +MitID login flow, so it is never emitted. External ads (`isExternal: true`) 404 on `/find-job/` +and hit the login wall on `/job/` - the search command routes external ads to their destination +in `jobAdUrl` (falling back to `/find-job/{jobAdId}` when missing or not http(s)) instead of +emitting a broken link. Company logo images (prefix relative logoUrl from API): diff --git a/.agents/skills/jobnet-search/cli/src/commands/search.ts b/.agents/skills/jobnet-search/cli/src/commands/search.ts index 6625722e..849125a9 100644 --- a/.agents/skills/jobnet-search/cli/src/commands/search.ts +++ b/.agents/skills/jobnet-search/cli/src/commands/search.ts @@ -27,6 +27,7 @@ export interface JobAdRaw { applicationDeadlineStatus: string | null workHourPartTime: boolean isExternal: boolean + jobAdUrl?: string | null hasLogo: boolean logoUrl: string | null cvr: string | null @@ -83,39 +84,60 @@ export function buildSearchParams(flags: SearchFlags): Record { return params } +/** + * Return the destination URL for an external ad, if it has a valid http(s) + * jobAdUrl. Only an absolute http(s) URL is used: the value is employer-supplied + * and downstream steps fetch it. Anything else returns null and the caller keeps + * the jobnet.dk route to preserve a unique URL for /scrape deduplication. + */ +export function externalAdUrl(job: Pick): string | null { + const raw = job.jobAdUrl?.trim() + if (!job.isExternal || !raw) return null + try { + const { protocol } = new URL(raw) + return protocol === "https:" || protocol === "http:" ? raw : null + } catch { + return null + } +} + export function createSearchOutput(data: SearchApiResponse, flags: SearchFlags) { - let results = data.jobAds.map((job) => ({ - jobAdId: job.jobAdId, - title: job.title, - hiringOrgName: job.hiringOrgName, - occupation: job.occupation ?? null, - municipality: job.municipality ?? null, - postalCode: job.postalCode ?? null, - postalDistrictName: job.postalDistrictName ?? null, - country: job.country, - publicationDate: job.publicationDate, - applicationDeadline: job.applicationDeadline ?? null, - applicationDeadlineStatus: job.applicationDeadlineStatus ?? null, - workHourPartTime: job.workHourPartTime, - isExternal: job.isExternal, - hasLogo: job.hasLogo, - logoUrl: job.logoUrl ?? null, - cvr: job.cvr ?? null, - workPlaceAddress: job.workPlaceAddress ?? "", - isSeen: job.isSeen, - 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, - location: job.postalDistrictName ?? job.municipality ?? null, - date: job.publicationDate ? job.publicationDate.slice(0, 10) : null, - deadline: job.applicationDeadline && !job.applicationDeadline.startsWith("1900-01-01") - ? job.applicationDeadline.slice(0, 10) - : null, - url: `https://jobnet.dk/find-job/${job.jobAdId}`, - })) + let results = data.jobAds.map((job) => { + const destination = externalAdUrl(job) + return { + jobAdId: job.jobAdId, + title: job.title, + hiringOrgName: job.hiringOrgName, + occupation: job.occupation ?? null, + municipality: job.municipality ?? null, + postalCode: job.postalCode ?? null, + postalDistrictName: job.postalDistrictName ?? null, + country: job.country, + publicationDate: job.publicationDate, + applicationDeadline: job.applicationDeadline ?? null, + applicationDeadlineStatus: job.applicationDeadlineStatus ?? null, + workHourPartTime: job.workHourPartTime, + isExternal: job.isExternal, + jobAdUrl: destination, + hasLogo: job.hasLogo, + logoUrl: job.logoUrl ?? null, + cvr: job.cvr ?? null, + workPlaceAddress: job.workPlaceAddress ?? "", + isSeen: job.isSeen, + 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, + location: job.postalDistrictName ?? job.municipality ?? null, + date: job.publicationDate ? job.publicationDate.slice(0, 10) : null, + deadline: job.applicationDeadline && !job.applicationDeadline.startsWith("1900-01-01") + ? job.applicationDeadline.slice(0, 10) + : null, + url: destination ?? `https://jobnet.dk/find-job/${job.jobAdId}`, + } + }) if (flags.limit !== undefined) { results = results.slice(0, flags.limit) diff --git a/.agents/skills/jobnet-search/cli/tests/search-normalization.test.ts b/.agents/skills/jobnet-search/cli/tests/search-normalization.test.ts index 0df2dd21..01f5361b 100644 --- a/.agents/skills/jobnet-search/cli/tests/search-normalization.test.ts +++ b/.agents/skills/jobnet-search/cli/tests/search-normalization.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { buildSearchParams, createSearchOutput, + externalAdUrl, type SearchApiResponse, type SearchFlags, } from "../src/commands/search"; @@ -71,6 +72,7 @@ function apiResponse(): SearchApiResponse { applicationDeadlineStatus: "ExpirationDate", workHourPartTime: false, isExternal: true, + jobAdUrl: "https://example.com/jobs/job-2", hasLogo: true, logoUrl: "/logo/job-2", cvr: "12345678", @@ -146,7 +148,8 @@ describe("Jobnet search normalization", () => { location: "København Ø", date: "2026-07-02", deadline: "2026-08-01", - url: "https://jobnet.dk/find-job/job-2", + url: "https://example.com/jobs/job-2", + jobAdUrl: "https://example.com/jobs/job-2", }); expect(output.results[0].hiringOrgName).toBe("Acme"); expect(output.results[1].applicationDeadline).toBe("2026-08-01T23:59:00+02:00"); @@ -154,6 +157,33 @@ describe("Jobnet search normalization", () => { expect(output.results[0].id).toBe(output.results[0].jobAdId); }); + test("external ads emit destination jobAdUrl as url, or fallback to find-job link when missing/invalid", () => { + const response = apiResponse(); + // External ad with valid http(s) jobAdUrl emits that URL + response.jobAds[1].isExternal = true; + response.jobAds[1].jobAdUrl = "https://employer.example/apply"; + const withUrl = createSearchOutput(response, { ...flags, limit: undefined }); + expect(withUrl.results[1].url).toBe("https://employer.example/apply"); + expect(withUrl.results[1].jobAdUrl).toBe("https://employer.example/apply"); + + // External ad without jobAdUrl falls back to find-job link to preserve a unique URL for /scrape deduplication + response.jobAds[1].jobAdUrl = null; + const withoutUrl = createSearchOutput(response, { ...flags, limit: undefined }); + expect(withoutUrl.results[1].url).toBe("https://jobnet.dk/find-job/job-2"); + expect(withoutUrl.results[1].jobAdUrl).toBeNull(); + expect(withoutUrl.results[1].isExternal).toBe(true); + + // External ad with non-http(s) scheme falls back to find-job link and clears jobAdUrl + response.jobAds[1].jobAdUrl = "javascript:alert(1)"; + const invalidUrl = createSearchOutput(response, { ...flags, limit: undefined }); + expect(invalidUrl.results[1].url).toBe("https://jobnet.dk/find-job/job-2"); + expect(invalidUrl.results[1].jobAdUrl).toBeNull(); + + // Internal ad always keeps find-job link + expect(withoutUrl.results[0].url).toBe("https://jobnet.dk/find-job/job-1"); + expect(withoutUrl.results[0].isExternal).toBe(false); + }); + test("maps Jobnet's undisclosed-deadline sentinel (1900-01-01) to null", () => { const response = apiResponse(); response.jobAds[0].applicationDeadline = "1900-01-01T00:00:00+01:00"; @@ -185,3 +215,19 @@ describe("Jobnet null publicationDate degradation", () => { expect(output.results[1].date).toBe("2026-07-02"); }); }); + +describe("externalAdUrl helper", () => { + test("returns valid https or http URL when isExternal is true", () => { + expect(externalAdUrl({ isExternal: true, jobAdUrl: "https://example.com/apply" })).toBe("https://example.com/apply"); + expect(externalAdUrl({ isExternal: true, jobAdUrl: "http://example.com/apply" })).toBe("http://example.com/apply"); + }); + + test("returns null if not external, missing, or non-http(s) scheme", () => { + expect(externalAdUrl({ isExternal: false, jobAdUrl: "https://example.com/apply" })).toBeNull(); + expect(externalAdUrl({ isExternal: true, jobAdUrl: null })).toBeNull(); + expect(externalAdUrl({ isExternal: true, jobAdUrl: " " })).toBeNull(); + expect(externalAdUrl({ isExternal: true, jobAdUrl: "javascript:void(0)" })).toBeNull(); + expect(externalAdUrl({ isExternal: true, jobAdUrl: "file:///etc/passwd" })).toBeNull(); + expect(externalAdUrl({ isExternal: true, jobAdUrl: "not-a-valid-url" })).toBeNull(); + }); +}); diff --git a/CHANGELOG.md b/CHANGELOG.md index 81152492..521158ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,15 @@ per-file diff commands. ### Fixed +- **`jobnet-search` routes external ads to employer `jobAdUrl` rather than broken `find-job` links** + (#538, `.agents/skills/jobnet-search/cli/src/commands/search.ts`, `.agents/skills/jobnet-search/cli/README.md`, + `.agents/skills/jobnet-search/cli/tests/search-normalization.test.ts`) - external job postings on + Jobnet (`isExternal: true`) point to `/find-job/{jobAdId}`, which returns 404, while the Jobnet API + carries the destination in `jobAdUrl`. The search command now routes external ads to `jobAdUrl` when + a valid http(s) destination is present, falling back to `/find-job/{jobAdId}` when missing or + non-http(s) to preserve distinct URLs for `/scrape` deduplication. Both `url` and `jobAdUrl` + validate the scheme the same way. Reported with live testing by NishilRathod. + - **Fork-skip guards no longer run on a fork during a local test run** (`tests/_upstream.py`, `tests/test_placeholder_integrity.py`, `tests/test_setup_command.py`, `tests/test_upstream_checkout.py`) - the fork skip read