fix(jobnet-search): keep canonical portal url and emit destination in applyUrl (closes #701) (#706)

* fix(jobnet-search): keep canonical portal url and emit destination in applyUrl (closes #701)

* docs(jobnet-search): document canonical url and applyUrl split (#701)

* changelog: place the #706 entry under [Unreleased]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-21 07:09:02 +02:00
1 parent c71c270765
commit 183c8fda20
4 files changed
+41 -16

No files matched your search

+7 -6
View File
@@ -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):
@@ -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<JobAdRaw, "isExternal" | "jobAdUrl">): 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,
}
})
@@ -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);
});