diff --git a/.agents/skills/jobnet-search/cli/README.md b/.agents/skills/jobnet-search/cli/README.md index 70eb54f5..a25365b8 100644 --- a/.agents/skills/jobnet-search/cli/README.md +++ b/.agents/skills/jobnet-search/cli/README.md @@ -154,7 +154,8 @@ bun run src/cli.ts search \ "location": "Viborg", "date": "2026-03-13", "deadline": "2026-04-05", - "url": "https://jobnet.dk/find-job/9ef43bce-d82b-4ea1-a098-7ff6520f99be" + "url": "https://jobnet.dk/find-job/9ef43bce-d82b-4ea1-a098-7ff6520f99be", + "applyUrl": null } ] } @@ -162,7 +163,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 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**: Every result also carries the cross-portal contract fields `company`, `location`, `date`, `deadline`, `url`, and `applyUrl` - derived respectively from `hiringOrgName`, `postalDistrictName`/`municipality`, and the canonical portal URL `https://jobnet.dk/find-job/{jobAdId}`. `url` is always canonical on jobnet.dk for all ads so that deduplication, health checks, and `/apply` host routing identify the portal correctly. External ads carry their employer ATS destination in `applyUrl` (and `jobAdUrl`), while internal ads set `applyUrl` to `null`. `/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"). @@ -365,10 +366,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`) 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. +MitID login flow, so it is never emitted. External ads (`isExternal: true`) return 404 in a browser +on `/find-job/` and hit the login wall on `/job/`. The search command keeps `url` canonical +as `https://jobnet.dk/find-job/{jobAdId}` for all ads to preserve portal identity and route +to `jobnet detail`, while providing the external destination in `applyUrl` (and `jobAdUrl`). 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 818386d4..ca5b07f7 100644 --- a/.agents/skills/jobnet-search/cli/src/commands/search.ts +++ b/.agents/skills/jobnet-search/cli/src/commands/search.ts @@ -99,8 +99,9 @@ export function occupationId(value: string): string { /** * 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. + * and downstream steps fetch it. Emitted in `applyUrl` (and `jobAdUrl`); `url` + * always keeps the canonical jobnet.dk route across all postings. Anything + * else returns null. */ export function externalAdUrl(job: Pick): string | null { const raw = job.jobAdUrl?.trim() @@ -140,6 +141,9 @@ export function createSearchOutput(data: SearchApiResponse, flags: SearchFlags) // /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. + // `url` stays canonical on jobnet.dk for all postings (internal and external) + // so deduplication, health checks, and /apply host routing (#562) identify + // the portal correctly; external ATS destinations are emitted as `applyUrl`. id: job.jobAdId, company: job.hiringOrgName, location: job.postalDistrictName ?? job.municipality ?? null, @@ -147,7 +151,8 @@ export function createSearchOutput(data: SearchApiResponse, flags: SearchFlags) deadline: job.applicationDeadline && !job.applicationDeadline.startsWith("1900-01-01") ? job.applicationDeadline.slice(0, 10) : null, - url: destination ?? `https://jobnet.dk/find-job/${job.jobAdId}`, + url: `https://jobnet.dk/find-job/${job.jobAdId}`, + applyUrl: destination, } }) 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 01f5361b..dec9ed25 100644 --- a/.agents/skills/jobnet-search/cli/tests/search-normalization.test.ts +++ b/.agents/skills/jobnet-search/cli/tests/search-normalization.test.ts @@ -148,7 +148,8 @@ describe("Jobnet search normalization", () => { location: "København Ø", date: "2026-07-02", deadline: "2026-08-01", - url: "https://example.com/jobs/job-2", + url: "https://jobnet.dk/find-job/job-2", + applyUrl: "https://example.com/jobs/job-2", jobAdUrl: "https://example.com/jobs/job-2", }); expect(output.results[0].hiringOrgName).toBe("Acme"); @@ -157,30 +158,35 @@ 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", () => { + test("external ads keep canonical find-job url and provide destination as applyUrl and jobAdUrl", () => { const response = apiResponse(); - // External ad with valid http(s) jobAdUrl emits that URL + // External ad with valid http(s) jobAdUrl emits canonical url and destination applyUrl/jobAdUrl 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].url).toBe("https://jobnet.dk/find-job/job-2"); + expect(withUrl.results[1].applyUrl).toBe("https://employer.example/apply"); expect(withUrl.results[1].jobAdUrl).toBe("https://employer.example/apply"); + expect(withUrl.results[1].isExternal).toBe(true); - // External ad without jobAdUrl falls back to find-job link to preserve a unique URL for /scrape deduplication + // External ad without jobAdUrl keeps canonical find-job url and null applyUrl/jobAdUrl 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].applyUrl).toBeNull(); 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 + // External ad with non-http(s) scheme keeps canonical find-job url and null applyUrl/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].applyUrl).toBeNull(); expect(invalidUrl.results[1].jobAdUrl).toBeNull(); - // Internal ad always keeps find-job link + // Internal ad always keeps find-job link and null applyUrl expect(withoutUrl.results[0].url).toBe("https://jobnet.dk/find-job/job-1"); + expect(withoutUrl.results[0].applyUrl).toBeNull(); expect(withoutUrl.results[0].isExternal).toBe(false); }); diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c0607c6..5482378b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -328,6 +328,19 @@ per-file diff commands. no total deadline. `size=` in the status line is now the saved file's size on both paths, and step 3 of `09-web-research.md` says an exit 1 can come from a redirect target's policy. +- **`jobnet-search` keeps canonical portal URLs for external ads and emits destination in `applyUrl`** + (#706, fixes #701, `.agents/skills/jobnet-search/cli/src/commands/search.ts`, + `tests/search-normalization.test.ts`) - external job ads in search results emitted the + external employer ATS link directly in `url` (#557), which tripped `/scrape`'s Step 4.75 degraded + scan ("URLs that do not point at the portal") and caused `/apply` and `/rank`'s host-based routing + (#562) to send the posting to `WebFetch` on an ATS application form rather than `jobnet detail`. + `createSearchOutput` now keeps `url` canonical as `https://jobnet.dk/find-job/` across all + ads (preserving identity, deduplication, and portal routing to `detail`), while emitting the + external employer ATS destination in `applyUrl` (matching the `applyUrl` field name on `detail` + in `jobindex` and `jobdanmark`) alongside `jobAdUrl`. For external ads, `url` is Jobnet's + canonical identity link, which 404s in a browser, and the employer page is `applyUrl` (search) or + the `Apply:` line of `detail`. + ### Security - **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a