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", "location": "Viborg",
"date": "2026-03-13", "date": "2026-03-13",
"deadline": "2026-04-05", "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**: 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"). > **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 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/` MitID login flow, so it is never emitted. External ads (`isExternal: true`) return 404 in a browser
and hit the login wall on `/job/` - the search command routes external ads to their destination on `/find-job/` and hit the login wall on `/job/`. The search command keeps `url` canonical
in `jobAdUrl` (falling back to `/find-job/{jobAdId}` when missing or not http(s)) instead of as `https://jobnet.dk/find-job/{jobAdId}` for all ads to preserve portal identity and route
emitting a broken link. to `jobnet detail`, while providing the external destination in `applyUrl` (and `jobAdUrl`).
Company logo images (prefix relative logoUrl from API): 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) * 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 * 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 * and downstream steps fetch it. Emitted in `applyUrl` (and `jobAdUrl`); `url`
* the jobnet.dk route to preserve a unique URL for /scrape deduplication. * always keeps the canonical jobnet.dk route across all postings. Anything
* else returns null.
*/ */
export function externalAdUrl(job: Pick<JobAdRaw, "isExternal" | "jobAdUrl">): string | null { export function externalAdUrl(job: Pick<JobAdRaw, "isExternal" | "jobAdUrl">): string | null {
const raw = job.jobAdUrl?.trim() 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 // /scrape contract aliases (add-portal.md: every result carries at least
// id, title, company, location, date, url). `id` is the jobAdId - the // id, title, company, location, date, url). `id` is the jobAdId - the
// value `detail` takes - under the name every other portal CLI uses. // 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, id: job.jobAdId,
company: job.hiringOrgName, company: job.hiringOrgName,
location: job.postalDistrictName ?? job.municipality ?? null, 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") deadline: job.applicationDeadline && !job.applicationDeadline.startsWith("1900-01-01")
? job.applicationDeadline.slice(0, 10) ? job.applicationDeadline.slice(0, 10)
: null, : 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 Ø", location: "København Ø",
date: "2026-07-02", date: "2026-07-02",
deadline: "2026-08-01", 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", jobAdUrl: "https://example.com/jobs/job-2",
}); });
expect(output.results[0].hiringOrgName).toBe("Acme"); 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); 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(); 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].isExternal = true;
response.jobAds[1].jobAdUrl = "https://employer.example/apply"; response.jobAds[1].jobAdUrl = "https://employer.example/apply";
const withUrl = createSearchOutput(response, { ...flags, limit: undefined }); 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].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; response.jobAds[1].jobAdUrl = null;
const withoutUrl = createSearchOutput(response, { ...flags, limit: undefined }); const withoutUrl = createSearchOutput(response, { ...flags, limit: undefined });
expect(withoutUrl.results[1].url).toBe("https://jobnet.dk/find-job/job-2"); 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].jobAdUrl).toBeNull();
expect(withoutUrl.results[1].isExternal).toBe(true); 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)"; response.jobAds[1].jobAdUrl = "javascript:alert(1)";
const invalidUrl = createSearchOutput(response, { ...flags, limit: undefined }); const invalidUrl = createSearchOutput(response, { ...flags, limit: undefined });
expect(invalidUrl.results[1].url).toBe("https://jobnet.dk/find-job/job-2"); 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(); 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].url).toBe("https://jobnet.dk/find-job/job-1");
expect(withoutUrl.results[0].applyUrl).toBeNull();
expect(withoutUrl.results[0].isExternal).toBe(false); expect(withoutUrl.results[0].isExternal).toBe(false);
}); });
+13
View File
@@ -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 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. 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/<id>` 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 ### Security
- **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a - **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a