Files
ai-job-search/.agents/skills/jobdanmark-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
..

jobdanmark-cli

CLI for the Jobdanmark.dk public job search API.

Base URL: https://jobdanmark.dk Authentication: None required. Format: All responses are JSON.


Installation

cd skills/jobdanmark-search/cli
bun install

Commands

Command Description
search Search job listings with filters
detail Full detail for a single job posting (by slug)
categories List all job categories with live counts
autocomplete Suggest job titles and categories for a query
locations Suggest municipalities, zip codes, and regions for a query

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.


Job Types

Value Danish English
fuldtid Fuldtid Full-time
deltid Deltid Part-time
fleksjob Fleksjob Flex job
elev Elev Apprentice
studiejob Studiejob Student job
praktik Praktik Internship

Categories

ID Danish Title
227972 Pædagogik, Uddannelse og Forskning
227973 Håndværk, Industri, Transport og Landbrug
227974 Salg, Kommunikation, Marketing, og Design
227975 Pleje, Social og Sundhed
227976 Hotel, Service, Restauration og Sikkerhed
227977 Kontor, Finans og Økonomi
227978 IT, Ingeniør og Energi
227979 Ledelse, HR og projektstyring
543415 Kirke, Kultur og Underholdning
227980 Øvrige job

search — Search job listings

Endpoint: POST https://jobdanmark.dk/api/jobsearch/search/{page}

bun run src/cli.ts search [flags]

Flags

Flag Type Default Description
--text string Free-text keyword search (job title, keyword)
--category number Category ID (see table above)
--jobtitle-id number Job title ID from autocomplete results
--municipality string Municipality name, e.g. Odense, København
--zip string Zip code, e.g. 5000
--region string Region name
--job-type string Comma-separated job types: fuldtid,deltid,fleksjob,elev,studiejob,praktik
--page number 1 Page number (30 items per page, server-enforced)
--limit number Cap total results returned by CLI (client-side)
--format string json Output format: json, table, plain

Note

: Per-page is fixed at 30 by the API. There is no --per-page flag.

Note

: Multiple --job-type values are passed as a comma-separated string and split by the CLI into the jobTypes array in the request body.

Example

bun run src/cli.ts search --text "elektriker" --job-type fuldtid --municipality "Odense"

bun run src/cli.ts search --category 227978 --job-type "fuldtid,deltid" --page 2 --format table

bun run src/cli.ts search --text "sygeplejerske" --zip 8000 --limit 10

Request body sent to API

{
  "jobTypes": ["fuldtid"],
  "filters": [
    { "type": "freetext", "value": "elektriker", "displayText": "elektriker" },
    { "type": "municipality", "value": "Odense", "displayText": "Odense" }
  ],
  "locationMode": "Text",
  "distance": 50
}

Important

: All filter objects include a displayText field (required by the server). The value field is an integer for category and jobtitle filter types; a string for all others.

Response shape

{
  "meta": {
    "currentPage": 1,
    "totalItems": 15610,
    "itemsPrPage": 30,
    "totalPages": 521
  },
  "results": [
    {
      "title": "IT-chef søges til RAH",
      "companyName": "Rah Service A/S",
      "companyLogo": {
        "key": "71f1c950-abcd-1234-efgh-000000000000",
        "url": "https://jobdanmark.dk/media/k1epc2kk/rah-service-logo.jpg",
        "focalPoint": null
      },
      "companyLogoSvgMarkup": null,
      "overlayColor": "#FFFFFF1F",
      "companyAddress": "Ndr Ringvej 4 6950 Ringkøbing",
      "jobTypes": ["fuldtid"],
      "boostJob": true,
      "publishedDate": "12-03-2026",
      "applicationDeadline": "10-04-2026",
      "url": "https://jobdanmark.dk/job/it-chef-soeges-til-rah",
      "slug": "it-chef-soeges-til-rah",
      "coverImage": {
        "key": "cf06eb46-abcd-1234-efgh-000000000000",
        "url": "https://jobdanmark.dk/media/idvbnt4y/rah-service-as-billede.png",
        "focalPoint": { "top": 0.488, "left": 0.499 }
      },
      "silhouetteLogo": false
    }
  ]
}

Notes:

  • url is normalized to a full URL (CLI prepends https://jobdanmark.dk to the relative path from the API).
  • slug is extracted from the relative url field (the path segment after /job/).
  • applicationDeadline can be null.
  • companyLogo can be null.
  • publishedDate format: "DD-MM-YYYY".
  • coverImage can be null.

detail — Full job posting detail

Method: Fetch HTML from https://jobdanmark.dk/job/{slug}. The CLI extracts a <script type="application/ld+json"> JobPosting block when present, and falls back to parsing the rendered job page HTML when Jobdanmark omits JSON-LD.

bun run src/cli.ts detail <slug> [--format json|plain]

The slug is the URL path segment returned as slug in search results (e.g. it-chef-soeges-til-rah).

Example

bun run src/cli.ts detail it-chef-soeges-til-rah
bun run src/cli.ts detail it-chef-soeges-til-rah --format plain

Response shape

{
  "slug": "it-chef-soeges-til-rah",
  "url": "https://jobdanmark.dk/job/it-chef-soeges-til-rah",
  "title": "IT-chef søges til RAH",
  "datePosted": "2026-03-12",
  "validThrough": "2026-04-10",
  "employmentType": ["FULL_TIME"],
  "hiringOrganization": {
    "name": "Rah Service A/S",
    "logo": "https://jobdanmark.dk/media/k1epc2kk/rah-service-logo.jpg?width=50"
  },
  "jobLocation": {
    "streetAddress": "Ndr Ringvej 4 6950 Ringkøbing",
    "addressLocality": "Ringkøbing",
    "addressRegion": "Vestjylland",
    "postalCode": "6950",
    "addressCountry": "DK"
  },
  "description": "<p>Full HTML description...</p>",
  "applyUrl": "https://example.com/apply"
}

Note

: The hiringOrganization.logo, validThrough, and applyUrl fields may be null if not present in the structured data or rendered page. jobLocation fields may be null if the location data is absent.


categories — List categories with live job counts

Endpoint: GET https://jobdanmark.dk/api/categorycount/getcounts

bun run src/cli.ts categories [flags]

Flags

Flag Type Default Description
--limit number Cap number of categories returned
--format string json Output format: json, table, plain

Example

bun run src/cli.ts categories
bun run src/cli.ts categories --format table

Response shape

[
  {
    "id": 227972,
    "title": "Pædagogik, Uddannelse og Forskning",
    "helpText": "Pædagog, lærer, forsker og meget mere...",
    "count": 1856
  },
  {
    "id": 227973,
    "title": "Håndværk, Industri, Transport og Landbrug",
    "helpText": "Elektriker, VVS, maler og meget mere...",
    "count": 2816
  },
  {
    "id": 227974,
    "title": "Salg, Kommunikation, Marketing, og Design",
    "helpText": "Sælger, marketingkoordinator og meget mere...",
    "count": 1423
  },
  {
    "id": 227975,
    "title": "Pleje, Social og Sundhed",
    "helpText": "Sygeplejerske, social- og sundhedsassistent og meget mere...",
    "count": 2104
  },
  {
    "id": 227976,
    "title": "Hotel, Service, Restauration og Sikkerhed",
    "helpText": "Tjener, kok, receptionist og meget mere...",
    "count": 987
  },
  {
    "id": 227977,
    "title": "Kontor, Finans og Økonomi",
    "helpText": "Bogholder, revisor, kontorassistent og meget mere...",
    "count": 1234
  },
  {
    "id": 227978,
    "title": "IT, Ingeniør og Energi",
    "helpText": "Programmør, datamatiker, ingeniør og meget mere...",
    "count": 1567
  },
  {
    "id": 227979,
    "title": "Ledelse, HR og projektstyring",
    "helpText": "Leder, HR-konsulent, projektleder og meget mere...",
    "count": 876
  },
  {
    "id": 543415,
    "title": "Kirke, Kultur og Underholdning",
    "helpText": "Præst, musiker, skuespiller og meget mere...",
    "count": 234
  },
  {
    "id": 227980,
    "title": "Øvrige job",
    "helpText": "Andre job der ikke passer i de øvrige kategorier...",
    "count": 2329
  }
]

autocomplete — Suggest job titles and categories

Endpoint: GET https://jobdanmark.dk/api/search/autocomplete?q={query}

bun run src/cli.ts autocomplete --query <text> [flags]

Flags

Flag Type Default Description
--query string required Search text to autocomplete
--limit number Cap total suggestions returned
--format string json Output format: json, table, plain

Example

bun run src/cli.ts autocomplete --query "it"
bun run src/cli.ts autocomplete --query "sygeplejerske" --format table

Response shape

[
  {
    "title": "Jobtitler",
    "items": [
      {
        "id": "title__454",
        "text": "IT-konsulent",
        "value": 454,
        "category": "jobtitle",
        "slug": "it-konsulent"
      },
      {
        "id": "title__123",
        "text": "IT-supporter",
        "value": 123,
        "category": "jobtitle",
        "slug": "it-supporter"
      }
    ]
  },
  {
    "title": "Kategorier",
    "items": [
      {
        "id": "category__227978",
        "text": "IT, Ingeniør og Energi",
        "value": 227978,
        "category": "category",
        "slug": "it-ingenioer-og-energi"
      }
    ]
  }
]

Note

: Returns an empty array [] when no suggestions match. value is a numeric ID usable as --jobtitle-id or --category in search. Groups with no matching items are omitted from the response.


locations — Suggest location filters

Endpoint: GET https://jobdanmark.dk/api/search/locations?q={query}

bun run src/cli.ts locations --query <text> [flags]

Flags

Flag Type Default Description
--query string required Location text to search (city, zip code, region)
--limit number Cap total suggestions returned
--format string json Output format: json, table, plain

Example

bun run src/cli.ts locations --query "Odense"
bun run src/cli.ts locations --query "8000" --format table
bun run src/cli.ts locations --query "Sjælland"

Response shape

[
  {
    "title": "Kommune",
    "items": [
      {
        "id": "municipality__0461",
        "text": "Odense",
        "value": "Odense",
        "category": "municipality",
        "slug": "odense"
      }
    ]
  },
  {
    "title": "Postnummer",
    "items": [
      {
        "id": "zip__5000",
        "text": "5000 Odense C",
        "value": "5000",
        "category": "zip",
        "slug": "5000-odense-c"
      },
      {
        "id": "zip__5200",
        "text": "5200 Odense V",
        "value": "5200",
        "category": "zip",
        "slug": "5200-odense-v"
      }
    ]
  }
]

Note

: Returns [] when no locations match. The value field for municipalities is the display name (string); for zip codes it is the numeric string. Use value directly as --municipality or --zip in search.


Error handling

All errors are written to stderr in JSON format and exit with code 1:

{ "error": "Job not found", "code": "NOT_FOUND" }
{ "error": "API request failed: 400 Bad Request", "code": "API_ERROR" }
{ "error": "--query is required", "code": "MISSING_REQUIRED" }
{ "error": "Failed to parse job page HTML", "code": "PARSE_ERROR" }

URL construction

  • Job detail pages: https://jobdanmark.dk/job/{slug}
  • Company logo images: https://jobdanmark.dk{companyLogo.url} (prepend base URL to relative path)
  • Cover images: https://jobdanmark.dk{coverImage.url} (prepend base URL to relative path)