mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-08 07:57:40 +00:00
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:
1 parent
e2b966d0e7
commit
4a8e63ea7c
5 files changed
+88
-9
No files matched your search
@@ -202,5 +202,5 @@ All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and
|
|||||||
- Pagination is 1-indexed (`--page 1` is the first page).
|
- Pagination is 1-indexed (`--page 1` is the first page).
|
||||||
- `search` results omit the HTML job description — use `detail` to get it.
|
- `search` results omit the HTML job description — use `detail` to get it.
|
||||||
- `detail --format plain` strips HTML tags for readable text output.
|
- `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.
|
- `suggestions` is tuned for Danish job titles — English terms may return empty results.
|
||||||
@@ -141,6 +141,7 @@ bun run src/cli.ts search \
|
|||||||
"applicationDeadlineStatus": "ExpirationDate",
|
"applicationDeadlineStatus": "ExpirationDate",
|
||||||
"workHourPartTime": false,
|
"workHourPartTime": false,
|
||||||
"isExternal": false,
|
"isExternal": false,
|
||||||
|
"jobAdUrl": null,
|
||||||
"hasLogo": true,
|
"hasLogo": true,
|
||||||
"logoUrl": "/bff/SharedComponents/JobAdCard/CompanyLogo/ByJobAdId/9ef43bce-d82b-4ea1-a098-7ff6520f99be",
|
"logoUrl": "/bff/SharedComponents/JobAdCard/CompanyLogo/ByJobAdId/9ef43bce-d82b-4ea1-a098-7ff6520f99be",
|
||||||
"cvr": "29190925",
|
"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**: 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").
|
> **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
|
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
|
MitID login flow, so it is never emitted. External ads (`isExternal: true`) 404 on `/find-job/`
|
||||||
`E` prefix) 404 on `/find-job/` and hit the login wall on `/job/` - neither route serves them
|
and hit the login wall on `/job/` - the search command routes external ads to their destination
|
||||||
anonymously; `/find-job/` is still strictly better and external ads are left as-is.
|
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):
|
Company logo images (prefix relative logoUrl from API):
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface JobAdRaw {
|
|||||||
applicationDeadlineStatus: string | null
|
applicationDeadlineStatus: string | null
|
||||||
workHourPartTime: boolean
|
workHourPartTime: boolean
|
||||||
isExternal: boolean
|
isExternal: boolean
|
||||||
|
jobAdUrl?: string | null
|
||||||
hasLogo: boolean
|
hasLogo: boolean
|
||||||
logoUrl: string | null
|
logoUrl: string | null
|
||||||
cvr: string | null
|
cvr: string | null
|
||||||
@@ -83,8 +84,27 @@ export function buildSearchParams(flags: SearchFlags): Record<string, string> {
|
|||||||
return params
|
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) {
|
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,
|
jobAdId: job.jobAdId,
|
||||||
title: job.title,
|
title: job.title,
|
||||||
hiringOrgName: job.hiringOrgName,
|
hiringOrgName: job.hiringOrgName,
|
||||||
@@ -98,6 +118,7 @@ export function createSearchOutput(data: SearchApiResponse, flags: SearchFlags)
|
|||||||
applicationDeadlineStatus: job.applicationDeadlineStatus ?? null,
|
applicationDeadlineStatus: job.applicationDeadlineStatus ?? null,
|
||||||
workHourPartTime: job.workHourPartTime,
|
workHourPartTime: job.workHourPartTime,
|
||||||
isExternal: job.isExternal,
|
isExternal: job.isExternal,
|
||||||
|
jobAdUrl: destination,
|
||||||
hasLogo: job.hasLogo,
|
hasLogo: job.hasLogo,
|
||||||
logoUrl: job.logoUrl ?? null,
|
logoUrl: job.logoUrl ?? null,
|
||||||
cvr: job.cvr ?? 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")
|
deadline: job.applicationDeadline && !job.applicationDeadline.startsWith("1900-01-01")
|
||||||
? job.applicationDeadline.slice(0, 10)
|
? job.applicationDeadline.slice(0, 10)
|
||||||
: null,
|
: null,
|
||||||
url: `https://jobnet.dk/find-job/${job.jobAdId}`,
|
url: destination ?? `https://jobnet.dk/find-job/${job.jobAdId}`,
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (flags.limit !== undefined) {
|
if (flags.limit !== undefined) {
|
||||||
results = results.slice(0, flags.limit)
|
results = results.slice(0, flags.limit)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|||||||
import {
|
import {
|
||||||
buildSearchParams,
|
buildSearchParams,
|
||||||
createSearchOutput,
|
createSearchOutput,
|
||||||
|
externalAdUrl,
|
||||||
type SearchApiResponse,
|
type SearchApiResponse,
|
||||||
type SearchFlags,
|
type SearchFlags,
|
||||||
} from "../src/commands/search";
|
} from "../src/commands/search";
|
||||||
@@ -71,6 +72,7 @@ function apiResponse(): SearchApiResponse {
|
|||||||
applicationDeadlineStatus: "ExpirationDate",
|
applicationDeadlineStatus: "ExpirationDate",
|
||||||
workHourPartTime: false,
|
workHourPartTime: false,
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
|
jobAdUrl: "https://example.com/jobs/job-2",
|
||||||
hasLogo: true,
|
hasLogo: true,
|
||||||
logoUrl: "/logo/job-2",
|
logoUrl: "/logo/job-2",
|
||||||
cvr: "12345678",
|
cvr: "12345678",
|
||||||
@@ -146,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://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[0].hiringOrgName).toBe("Acme");
|
||||||
expect(output.results[1].applicationDeadline).toBe("2026-08-01T23:59:00+02:00");
|
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);
|
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", () => {
|
test("maps Jobnet's undisclosed-deadline sentinel (1900-01-01) to null", () => {
|
||||||
const response = apiResponse();
|
const response = apiResponse();
|
||||||
response.jobAds[0].applicationDeadline = "1900-01-01T00:00:00+01:00";
|
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");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,6 +46,15 @@ per-file diff commands.
|
|||||||
|
|
||||||
### Fixed
|
### 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`,
|
- **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_placeholder_integrity.py`, `tests/test_setup_command.py`,
|
||||||
`tests/test_upstream_checkout.py`) - the fork skip read
|
`tests/test_upstream_checkout.py`) - the fork skip read
|
||||||
|
|||||||
Reference in new issue
Block a user