mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-02 13:12:50 +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).
|
||||
- `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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user