Files
ai-job-search/.agents/skills/jobnet-search/cli
Ayobami Adegoke 1c74a57c5e test(cli): pin the 429/5xx retry contract in all six portal CLIs (#246)
* 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.
2026-07-28 20:11:46 +02:00
..

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 description field (raw HTML) is intentionally omitted from search results for brevity. Use detail to retrieve the full job description.

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").


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

: body contains raw HTML. In --format plain output the CLI strips HTML tags to produce readable text.

Note

: application.url may 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

: conceptUriDa is the full ESCO concept URI. The UUID portion (last path segment) can be used to build occupation filters for search.


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/bff REST API — no credentials required.
  • The x-csrf: 1 header must be sent with every request.
  • Pagination is 1-indexed (--page 1 is the first page).
  • search results intentionally omit the HTML description field — use detail to fetch it.
  • body in detail responses is raw HTML; use --format plain to get stripped text.
  • The occupations command helps discover ESCO occupation URIs usable as --occupation-area / --occupation-group seeds for narrowing search results.