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

jobindex-cli

CLI for searching jobs on Jobindex.dk.

Base URL: https://www.jobindex.dk/ Authentication: None required. Format: The API returns JSON with embedded HTML blobs. The CLI parses the HTML internally and emits clean JSON.


Installation

cd skills/jobindex-search/cli
bun install

Commands

Command Description
search Search for job listings
detail Fetch full detail for a single job listing

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.


search — Search for job listings

Endpoint: GET https://www.jobindex.dk/jobsoegning.json

bun run src/cli.ts search [flags]

The API always returns 20 results per page (fixed — no --per-page flag). The CLI parses the result_list_box_html HTML blob from the response to extract structured job records.

Flags

Flag Type Default Description
--query / -q string Keyword search query (e.g. python, grafisk designer)
--page number 1 Page number (1-indexed)
--jobage number 9999 Max age of posting in days: 1, 7, 14, 30, or 9999 (all)
--sort string score Sort order: score (relevance) or date (newest first)
--limit number Cap total results returned by the CLI (client-side)
--format string json Output format: json, table, plain

Sort options

Value Description
score Relevance / best match (default)
date Newest postings first

jobage options

Value Description
1 Posted today
7 Last 7 days
14 Last 14 days
30 Last 30 days
9999 All time (default)

Example

# Search for Python jobs posted in the last 7 days, sorted by date
bun run src/cli.ts search --query python --jobage 7 --sort date

# Search for "grafisk designer" jobs — show first 5 results
bun run src/cli.ts search --query "grafisk designer" --limit 5

# Page 2 of results for data engineer
bun run src/cli.ts search --query "data engineer" --page 2 --format table

Response shape

{
  "meta": {
    "total": 237,
    "page": 1,
    "perPage": 20
  },
  "results": [
    {
      "id": "h1647303",
      "title": "Data Engineer til opbygning af Gavefabrikkens dataplatform",
      "company": "Gavefabrikken",
      "companyUrl": "https://www.gavefabrikken.dk/",
      "location": "Valby",
      "date": "2026-03-12",
      "url": "https://www.jobindex.dk/jobannonce/h1647303/data-engineer-til-opbygning-af-gavefabrikkens-dataplatform",
      "description": "Vi søger en dygtig Data Engineer til at opbygge og vedligeholde vores dataplatform..."
    }
  ]
}

Field notes:

  • id — string ID prefixed with h (e.g. h1647303). Use this with the detail command.
  • company — company name; may be null for some aggregated listings.
  • companyUrl — company homepage URL; may be null if not present.
  • location — city or area; may be null if not listed.
  • date — ISO date string (YYYY-MM-DD) from the datetime attribute on the <time> element; may be null.
  • description — short excerpt from the listing; may be null or empty.
  • url — full Jobindex.dk URL for the listing.
  • total in meta — parsed from hitcount_html (Danish thousands separator . is stripped before parsing, e.g. 18.90318903).

Note on area filtering: The Jobindex API does not reliably support area/region filtering via query parameters. area and geoareaid params are silently ignored. To filter by location, use --query with a city name (e.g. --query "python aarhus") or apply --limit and filter the JSON output externally.


detail — Fetch full job listing detail

URL: https://www.jobindex.dk/jobannonce/{id}/{slug}

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

The id is the job ID from search results (e.g. h1647303). The slug is optional — the CLI fetches the canonical URL by first constructing https://www.jobindex.dk/jobannonce/{id} and following any redirect, or by using the full URL from the url field in search results.

You may also pass the full URL directly as the id argument.

Flags

Flag Type Default Description
--format string json Output format: json, plain

Example

# Using ID from search results
bun run src/cli.ts detail h1647303

# Using full URL
bun run src/cli.ts detail "https://www.jobindex.dk/jobannonce/h1647303/data-engineer-til-opbygning-af-gavefabrikkens-dataplatform"

# Plain text output
bun run src/cli.ts detail h1647303 --format plain

Response shape

{
  "id": "h1647303",
  "title": "Data Engineer til opbygning af Gavefabrikkens dataplatform",
  "company": "Gavefabrikken",
  "companyUrl": "https://www.gavefabrikken.dk/",
  "location": "Valby, København",
  "date": "2026-03-12",
  "deadline": "2026-04-01",
  "employmentType": "Fastansættelse",
  "hours": "Fuldtid",
  "applyUrl": "https://www.gavefabrikken.dk/jobs/apply/123",
  "url": "https://www.jobindex.dk/jobannonce/h1647303/data-engineer-til-opbygning-af-gavefabrikkens-dataplatform",
  "description": "Full job description text here..."
}

Field notes:

  • deadline — application deadline date string; null if not listed.
  • employmentType — e.g. "Fastansættelse", "Midlertidig ansættelse"; null if not listed.
  • hours — e.g. "Fuldtid", "Deltid"; null if not listed.
  • applyUrl — the external application URL (resolved from the Jobindex redirect link /c?t=...); null if not available.
  • description — full plain-text job description (HTML stripped).
  • All fields may be null if not present in the HTML.

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: 500 Internal Server Error", "code": "API_ERROR" }
{ "error": "Failed to parse job listing HTML", "code": "PARSE_ERROR" }
{ "error": "--query is required", "code": "MISSING_REQUIRED" }

URL construction

Job detail pages on jobindex.dk:

  • https://www.jobindex.dk/jobannonce/{id}/{slug}

The slug is part of the url returned by search. When calling detail with just an ID, the CLI fetches https://www.jobindex.dk/jobannonce/{id} which redirects to the full URL.


Parsing notes

Total count from hitcount_html

The API returns pagination info as an HTML string like:

<div class="jix_pagination_total"><strong>1</strong> til <strong>20</strong> af <strong>18.903</strong> resultater.</div>

Parse total with: /af <strong>([\d.]+)<\/strong>/ and strip . before converting to integer.

Job card selectors

Each job card is wrapped in [data-beacon-tid]. Inside, select:

Field Selector
id [data-beacon-tid] attribute value
title h4 > a text content
url h4 > a[href]
company .jix-toolbar-top__company a text
companyUrl .jix-toolbar-top__company a[href]
location span.jix_robotjob--area text
date time[datetime] attribute value
description p text content (first <p> in card)

Two card types exist: div.PaidJob (sponsored) and div.jix_robotjob (aggregated). Both use the same selector pattern.