* test(cli): pin the 429/5xx retry contract in all six portal CLIs The portal-skill contract requires backoff on 429/5xx, and every CLI implements it - a retry loop with exponential delay and jitter - but nothing verified the loops actually retry, stop retrying on plain 4xx, or give up after the documented attempt budget. A regression here is invisible: a CLI that stops retrying still works on every healthy request. Each CLI gains tests/retry-backoff.test.ts, network-free, using the request-timeout.test.ts pattern from #197 (import the fetch wrapper, stub globalThis.fetch): a stubbed fetch counts attempts, and a stubbed setTimeout fires immediately so the exhaustion case does not sleep through the real 500ms -> 5s/8s backoff schedule (tests run in milliseconds, not ~17s). Three assertions per fetch wrapper, adapted to each CLI's documented semantics: - a 429 is retried and the next attempt's result is returned - a plain 4xx is not retried (jobbank's fetchWithUA RETURNS the response for callers to handle - pinned as such; linkedin's htmlFetch returns "" on 404; freehire's apiGet returns null) - persistent 5xx gives up after the initial attempt plus six retries (7 fetch calls) with the status in the error freehire additionally pins its documented graceful-degradation contract: a connection failure fails fast with no retry. jobdanmark exercises both apiFetch and apiPost, which carry separate copies of the loop that could drift apart. Mutation-checked: changing maxRetries in jobindex makes the exhaustion test fail, so the tests distinguish the current behavior from a silently altered one. Verified: bun test green in all six CLIs (jobindex 19, jobnet 20, jobbank 20, jobdanmark 21, linkedin 21, freehire 31 - 0 fail); tsc --noEmit clean in all six; python3 tools/lint_skills.py OK. * test(jobindex): pin apiFetch's retry loop alongside htmlFetch's Review parity gap: jobindex carries two separate copies of the retry loop and only htmlFetch was exercised, so apiFetch's retry budget could drift silently - the same situation jobdanmark's test already handles for its apiFetch/apiPost pair. apiFetch gets the same three assertions, adapted to its documented semantics (JSON return on success, throw on plain 4xx): a 429 is retried and the next attempt's parsed body returned, a 400 is not retried, persistent 5xx gives up after the initial attempt plus six retries (7 calls). Mutation-checked on the new axis: changing apiFetch's maxRetries (the file's first copy of the loop) fails its exhaustion test while htmlFetch's tests stay green, so each wrapper is now pinned independently. Verified: bun test 30 pass / 0 fail (full jobindex suite); tsc --noEmit clean.
jobnet-cli
CLI for the Jobnet.dk Danish government job portal API.
Base URL: https://jobnet.dk/bff
Authentication: No credentials required. All public endpoints only need the x-csrf: 1 request header.
Format: All responses are JSON.
Installation
cd skills/jobnet-search/cli
bun install
Commands
| Command | Description |
|---|---|
search |
Search for job ads with filters |
detail |
Full detail for a single job ad |
occupations |
Search occupation types (for building filters) |
suggestions |
Typeahead suggestions for job title / keyword search |
All commands accept --format json|table|plain (default: json).
All errors are written to stderr as { "error": "...", "code": "..." } and the process exits with code 1.
Regions
| Value | Danish region |
|---|---|
HovedstadenOgBornholm |
Hovedstaden og Bornholm |
Midtjylland |
Midtjylland |
Syddanmark |
Syddanmark |
OevrigeSjaelland |
Øvrige Sjælland |
Nordjylland |
Nordjylland |
Order types
| Value | Description |
|---|---|
PublicationDate |
Newest postings first (default) |
BestMatch |
Relevance score (requires --search-string) |
ApplicationDate |
Earliest deadline first |
search — Search for job ads
Endpoint: GET /FindJob/Search
bun run src/cli.ts search [flags]
Flags
| Flag | Type | Default | Description |
|---|---|---|---|
--search-string |
string | — | Free-text keyword search (job title, skills, employer) |
--region |
string | — | One region value (see Regions table) |
--postal-code |
string | — | Postal code for radius search, e.g. 2100 |
--radius |
number | 50 |
Radius in km from postal code (requires --postal-code) |
--work-hours |
string | — | FullTime or PartTime |
--duration |
string | — | Permanent or Temporary |
--job-type |
string | — | Announcement type: Ordinaert, Efterloenner, Foertidspension |
--occupation-area |
string | — | Occupation area identifier, e.g. 10000 |
--occupation-group |
string | — | Occupation group identifier, e.g. 10060 |
--page |
number | 1 |
Page number (1-indexed) |
--per-page |
number | 10 |
Results per page |
--limit |
number | — | Cap total results returned by CLI |
--order |
string | PublicationDate |
Sort order (see Order types table) |
--format |
string | json |
Output format: json, table, plain |
Example
bun run src/cli.ts search \
--search-string "sygeplejerske" \
--region HovedstadenOgBornholm \
--work-hours FullTime \
--duration Permanent \
--per-page 5 \
--format table
bun run src/cli.ts search \
--postal-code 8000 \
--radius 25 \
--per-page 10
Response shape
{
"meta": {
"totalJobAdCount": 21452,
"pageNumber": 1,
"resultsPerPage": 10,
"searchString": "developer"
},
"facets": {
"regions": [
{ "type": "HovedstadenOgBornholm", "jobAdCount": 6642 }
],
"workHours": [
{ "type": "FullTime", "jobAdCount": 17919 },
{ "type": "PartTime", "jobAdCount": 3533 }
],
"employmentDurations": [
{ "type": "Permanent", "jobAdCount": 18533 },
{ "type": "Temporary", "jobAdCount": 2919 }
],
"occupationAreas": [
{ "identifier": "10000", "jobAdCount": 3235 }
],
"countries": [
{ "label": "Danmark", "identifier": "DK", "jobAdCount": 21164 }
]
},
"results": [
{
"jobAdId": "9ef43bce-d82b-4ea1-a098-7ff6520f99be",
"title": "Akademisk medarbejder med interesse for arbejdsmiljø og uddannelse",
"hiringOrgName": "Region Midtjylland",
"occupation": "Personalekonsulent",
"municipality": "Viborg",
"postalCode": 8800,
"postalDistrictName": "Viborg",
"country": "Danmark",
"publicationDate": "2026-03-13T00:00:00+01:00",
"applicationDeadline": "2026-04-05T21:59:00+02:00",
"applicationDeadlineStatus": "ExpirationDate",
"workHourPartTime": false,
"isExternal": false,
"hasLogo": true,
"logoUrl": "/bff/SharedComponents/JobAdCard/CompanyLogo/ByJobAdId/9ef43bce-d82b-4ea1-a098-7ff6520f99be",
"cvr": "29190925",
"workPlaceAddress": "",
"conceptUriDa": "http://data.star.dk/esco/occupation/426e017f-ebe5-4bea-b1eb-7d2d5ab3c6db",
"isSeen": false,
"isFavorite": false
}
]
}
Note
: The
descriptionfield (raw HTML) is intentionally omitted fromsearchresults for brevity. Usedetailto retrieve the full job description.
Note
:
resultsPerPageandpageNumbermust always be provided — omitting them while also providingsearchStringcauses the API to return error 1014 ("Fejl i formatering af inputs").
detail — Full job ad detail
Endpoint: GET /FindJob/JobAdDetails/{id}
bun run src/cli.ts detail <id> [--format json|plain]
The id is the jobAdId UUID from search results.
By default the CLI passes incrementViews=false to avoid polluting view counts.
Example
bun run src/cli.ts detail 9ef43bce-d82b-4ea1-a098-7ff6520f99be
bun run src/cli.ts detail 9ef43bce-d82b-4ea1-a098-7ff6520f99be --format plain
Response shape
{
"id": "9ef43bce-d82b-4ea1-a098-7ff6520f99be",
"title": "Akademisk medarbejder med interesse for arbejdsmiljø og uddannelse",
"body": "<p>Full HTML job description...</p>",
"publicationDateTime": "2026-03-13T08:06:58.0578635+01:00",
"unpublicationDateTime": "2026-04-05T21:59:00+02:00",
"approvalStatus": "Godkendt",
"views": 2,
"createdDateTime": "2026-03-13T08:06:58.3936145+01:00",
"updatedDateTime": "2026-03-13T08:06:58.3936145+01:00",
"isAnonymousEmployer": false,
"hasLogo": true,
"logoUrl": "/bff/SharedComponents/JobAdCard/CompanyLogo/ByJobAdId/9ef43bce-d82b-4ea1-a098-7ff6520f99be",
"employer": {
"cvrNumber": "29190925",
"pNumber": "1003367314",
"name": "Region Midtjylland",
"hasCompanyLogo": true
},
"job": {
"type": "Ordinaert",
"address": {
"streetName": "Heibergs Alle 5A",
"city": "Viborg",
"postalCode": "8800",
"municipality": "Viborg",
"countryCode": "DK",
"countryName": "Danmark"
},
"noFixedWorkplace": false,
"isLimitedPeriod": false,
"isDisabilityFriendly": false,
"isPartTime": false,
"employmentDate": "2026-06-01T00:00:00+02:00",
"conceptUriDa": "http://data.star.dk/esco/occupation/426e017f-ebe5-4bea-b1eb-7d2d5ab3c6db",
"preferredLabelDa": "Personalekonsulent",
"driversLicenses": [],
"classifications": [],
"shifts": [],
"isFavorite": false
},
"application": {
"deadlineDate": "2026-04-05T21:59:00+02:00",
"availablePositions": 1,
"contactPersons": [
{
"firstNames": "Jane",
"lastName": "Doe",
"phoneNumber": "+4512345678"
}
],
"url": "https://midtjob.dk/ad/...",
"urlText": "",
"isApplicationDeadlineASAP": false
},
"organisationTypeId": 24,
"user": "Emply 31430747"
}
Note
:
bodycontains raw HTML. In--format plainoutput the CLI strips HTML tags to produce readable text.
Note
:
application.urlmay be empty for some job ads — the employer may only accept applications through Jobnet's internal system.
occupations — Search occupation types
Endpoint: GET /OccupationSearch
bun run src/cli.ts occupations --search-string <text> [flags]
Use this command to find occupation identifiers (ESCO concept URIs) to pass as filters to search.
Flags
| Flag | Type | Default | Description |
|---|---|---|---|
--search-string |
string | required | Search term for occupation, e.g. sygeplejerske |
--per-page |
number | 10 |
Max results to return |
--format |
string | json |
Output format: json, table, plain |
Example
bun run src/cli.ts occupations --search-string "sygeplejerske" --per-page 5
Response shape
[
{
"conceptUriDa": "http://data.star.dk/esco/occupation/56f5d45c-1234-4321-abcd-000000000000",
"preferredLabelDa": "Sygeplejerske",
"aliases": [
{
"aliasIdentifier": "some-uuid",
"conceptUriDa": "http://data.star.dk/esco/occupation/56f5d45c-1234-4321-abcd-000000000000",
"alternativeLabelDa": "Operationssygeplejerske"
}
]
}
]
Note
:
conceptUriDais the full ESCO concept URI. The UUID portion (last path segment) can be used to build occupation filters forsearch.
suggestions — Typeahead suggestions
Endpoint: GET /FindJob/GetTypeaheadSuggestions
bun run src/cli.ts suggestions --query <text> [flags]
Returns autocomplete strings for the search box — useful for exploring valid Danish job titles before running a search.
Flags
| Flag | Type | Default | Description |
|---|---|---|---|
--query |
string | required | Partial search string to complete |
--limit |
number | — | Cap number of suggestions returned |
--format |
string | json |
Output format: json, table, plain |
Example
bun run src/cli.ts suggestions --query "syge"
bun run src/cli.ts suggestions --query "ingeniør" --limit 5
Response shape
[
"sygepleje",
"Sygeplejerske",
"Sygeplejerske \"Lægeassistent\""
]
Note
: Suggestions are tuned for Danish job titles. English terms like "developer" may return an empty array.
Error handling
All errors are written to stderr in JSON format and exit with code 1:
{ "error": "Job ad not found", "code": "NOT_FOUND" }
{ "error": "API request failed: 500 Internal Server Error", "code": "API_ERROR" }
{ "error": "--query is required", "code": "MISSING_REQUIRED" }
{ "error": "--search-string is required", "code": "MISSING_REQUIRED" }
URL construction
Job ad detail pages on jobnet.dk:
https://jobnet.dk/job/{jobAdId}
Company logo images (prefix relative logoUrl from API):
https://jobnet.dk{logoUrl}
Example: https://jobnet.dk/bff/SharedComponents/JobAdCard/CompanyLogo/ByJobAdId/9ef43bce-d82b-4ea1-a098-7ff6520f99be
Notes
- All data is from the public
jobnet.dk/bffREST API — no credentials required. - The
x-csrf: 1header must be sent with every request. - Pagination is 1-indexed (
--page 1is the first page). searchresults intentionally omit the HTMLdescriptionfield — usedetailto fetch it.bodyindetailresponses is raw HTML; use--format plainto get stripped text.- The
occupationscommand helps discover ESCO occupation URIs usable as--occupation-area/--occupation-groupseeds for narrowing search results.