fix(jobnet-search): route external ads to jobAdUrl with http(s) validation and fallback (#538) (#557)

External ads (isExternal: true) 404 on /find-job/{jobAdId} and hit the MitID login wall on /job/{jobAdId}. Route them to their employer-provided destination in jobAdUrl when an absolute http(s) URL is present.

When jobAdUrl is missing, empty, or carries an invalid/non-http scheme, fall back to https://jobnet.dk/find-job/{jobAdId} instead of emitting an empty string so downstream /scrape deduplication preserves unique URLs. Both url and jobAdUrl validate the scheme consistently.

Observation and test cases reported by NishilRathod in #557.
This commit is contained in:
Ahmed authored and GitHub committed 2026-10-15 18:15:21 +02:00
1 parent e2b966d0e7
commit 4a8e63ea7c
5 files changed
+88 -9

No files matched your search

+1 -1
View File
@@ -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.
+6 -4
View File
@@ -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):
@@ -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,8 +84,27 @@ export function buildSearchParams(flags: SearchFlags): Record<string, string> {
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<JobAdRaw, "isExternal" | "jobAdUrl">): 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) => ({
let results = data.jobAds.map((job) => {
const destination = externalAdUrl(job)
return {
jobAdId: job.jobAdId,
title: job.title,
hiringOrgName: job.hiringOrgName,
@@ -98,6 +118,7 @@ export function createSearchOutput(data: SearchApiResponse, flags: SearchFlags)
applicationDeadlineStatus: job.applicationDeadlineStatus ?? null,
workHourPartTime: job.workHourPartTime,
isExternal: job.isExternal,
jobAdUrl: destination,
hasLogo: job.hasLogo,
logoUrl: job.logoUrl ?? null,
cvr: job.cvr ?? null,
@@ -114,8 +135,9 @@ export function createSearchOutput(data: SearchApiResponse, flags: SearchFlags)
deadline: job.applicationDeadline && !job.applicationDeadline.startsWith("1900-01-01")
? job.applicationDeadline.slice(0, 10)
: null,
url: `https://jobnet.dk/find-job/${job.jobAdId}`,
}))
url: destination ?? `https://jobnet.dk/find-job/${job.jobAdId}`,
}
})
if (flags.limit !== undefined) {
results = results.slice(0, flags.limit)
@@ -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();
});
});
+9
View File
@@ -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