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

jobbank-cli

CLI for Akademikernes Jobbank — Denmark's job portal for highly educated candidates.

Data sources:

  • RSS feed: https://jobbank.dk/job/rss?{params} — 100 items max, all search filters work
  • Job detail: https://jobbank.dk/job/{id}/ — JSON-LD (Schema.org JobPosting) embedded in page HTML

Authentication: None required. A browser User-Agent header is sent, but Jobbank may still block automated requests with Cloudflare bot protection. In that case the CLI exits with a clear error and callers should use a WebSearch fallback rather than retrying. Format: RSS XML (search), HTML with embedded JSON-LD (detail).


Installation

cd skills/jobbank-search/cli
bun install

Commands

Command Description
search Search job listings via RSS feed
detail Full detail for a single job posting

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.


Filter Reference Tables

Job Types (--type / cvtype)

Code Label
3 Fuldtidsjob
6 Graduate/trainee
13 Deltidsjob
8 Vikariat
12 Ph.d. & Postdoc
11 Freelance
15 Iværksætterprojekt
14 Event
9 Praktikplads
4 Studiejob
5 Studieprojekt/speciale

Location / Region (--location / amt)

Code Label
2 Storkøbenhavn
3 Nordsjælland
14 Østsjælland
4 Vestsjælland
5 Sydsjælland & Øer
13 Fyn
12 Sønderjylland
11 Sydvestjylland
9 Vestjylland
10 Sydøstjylland
7 Midtjylland
8 Østjylland (Aarhus)
6 Nordjylland
20 Bornholm
21 Øresundsregionen
22 Grønland & Færøerne
23 Udlandet (Sverige)
24 Udlandet (Norge)
19 Udlandet (øvrige)

Work Area / Function (--work-area / erf)

Code Label
20 Administration
38 Arkitektur & Design
22 Bank & Forsikring
43 Data & Analyse
47 Eksport
41 Forskning & Udvikling
28 Human Resources
49 Indkøb
34 Internet & Multimedia
32 IT - Hardware
33 IT - Netværk & Telekomm.
31 IT - Software
24 Jura
35 Kommunikation, Media & SoMe
46 Konstruktion & Beregning
37 Kunst & Kultur
26 Ledelse & Planlægning
29 Marketing & Reklame
40 Medicinal & Sundhed
45 Naturvidenskab
23 Organisation & Forening
52 Politik & Samfund
44 Produktion
27 Projektledelse
50 Rådgivning & Support
30 Salg
39 Socialvæsen
42 Teknik
25 Topledelse
48 Transport & Logistik
36 Undervisning
21 Økonomi & Forvaltning

Education Field (--education / udd)

Code Label
20 Administration
43 Anlæg, Byggeri & Konstruktion
29 Arkitektur, Kunst & Design
47 Elektro & Telekommunikation
32 Fødevarer & Veterinær
38 Human Resources
28 Humaniora
24 IT
23 Jura
44 Kemi, Biotek & Materialer
45 Klima, Miljø & Energi
37 Landbrug & Natur
22 Marketing & Business
48 Maskin & Design
46 Matematik, Fysik & Nano
31 Medicinal & Sundhed
30 Naturvidenskab
41 Organisation & Ledelse
35 Produktion, Logistik & Transport
34 Samfundsvidenskab
25 Sprog, Media & Kommunikation
33 Teknik & Teknologi
26 Undervisning & Pædagogik
21 Økonomi & Revision

Industry (--industry / branche)

Code Label
10359 Advokat & Revision
11669 Byggeri & Anlæg
11634 Elektronik & Maskin
16791 Fagforeninger, A-kasser & Pensionskasser
10358 Finans, Forsikring & Pension
10442 Forskning & Uddannelse
15407 Fødevarer & Dagligvarer
10364 Handel & Service
10331 IT & Tele
17209 Klima, Energi & Forsyning
16826 Kommuner
10341 Kultur, Medier & Underholdning
10333 Medicinal, Biotek & Kemi
10363 Papir, Møbel & Materialer
11626 Regioner, Sundhed- & Socialvæsen
15586 Rådgivning & Konsulentservice
10362 Stat, Politik & Samfund
10440 Transport
12450 Vikar & Rekruttering

Remote Work (--remote / fjernarbejde)

Value Label
helt Fully remote
delvist Partially remote

Suitable For (--suitable-for / andet)

Code Label
2 Nyuddannede
4 International baggrund
5 Erfarne

search — Search job listings

Endpoint: GET https://jobbank.dk/job/rss?{params}

bun run src/cli.ts search [flags]

Flags

Flag Type Default Description
--key string Keyword search (title, company, keyword)
--exclude string Exclude keywords (antikey)
--type number Job type code (cvtype). Repeatable for multiple: --type 3 --type 6
--education number Education field code (udd). Repeatable.
--location number Region code (amt). Repeatable.
--work-area number Work area / function code (erf). Repeatable.
--industry number Industry code (branche). Repeatable.
--suitable-for number Suitable-for code (andet). Repeatable.
--company number Company ID (virk)
--remote string Remote work: helt or delvist
--since string Posted on or after date, format YYYY-MM-DD (oprettet)
--limit number Cap total results returned by CLI (client-side)
--format string json Output format: json, table, plain

Important limitation: The RSS feed returns a maximum of 100 items per request. There is no pagination via RSS — the page= parameter has no effect on the RSS endpoint. If your query matches more than 100 jobs, only the first 100 are returned. The meta.total field reflects the true total count (fetched separately from the HTML search page title), while results is capped at 100.

Multi-value flags: Flags marked "Repeatable" map to params that accept multiple values in the API (repeated query params). Pass them multiple times: --type 3 --type 6 sends cvtype=3&cvtype=6.

RSS Parsing

The CLI fetches the RSS feed and parses each <item> as follows:

  • id: extracted from the URL path — /job/{id}/{company-slug}/{title-slug} — the first numeric segment after /job/
  • title: from <title> (CDATA)
  • description, company, location, jobType, deadline: parsed from the <description> field, which has the format: "JobType hos Company, Location (Ansøgningsfrist: DD.MM.YYYY)" or "JobType hos Company, Location (Ansøgningsfrist: løbende)"
  • url: from <link>
  • posted: from <pubDate>, normalized to ISO 8601

Example

bun run src/cli.ts search --key python --location 2 --type 3 --limit 10
bun run src/cli.ts search --key "data scientist" --remote helt
bun run src/cli.ts search --industry 10331 --work-area 31 --format table
bun run src/cli.ts search --education 24 --suitable-for 2 --since 2026-03-01

Response shape

{
  "meta": {
    "total": 457
  },
  "results": [
    {
      "id": "1234567",
      "title": "Senior Data Scientist",
      "company": "Novo Nordisk",
      "location": "Bagsværd",
      "jobType": "Fuldtidsjob",
      "description": "Fuldtidsjob hos Novo Nordisk, Bagsværd (Ansøgningsfrist: 12.04.2026)",
      "url": "https://jobbank.dk/job/1234567/novo-nordisk/senior-data-scientist",
      "posted": "2026-03-02T00:00:00+01:00",
      "deadline": "2026-04-12"
    }
  ]
}

Field details

Field Type Notes
id string Numeric job ID extracted from URL
title string Job title
company string Company name, parsed from description
location string Location string, parsed from description
jobType string Employment type (e.g. "Fuldtidsjob", "Graduate/trainee"), parsed from description
description string Raw RSS description field (single-line summary)
url string Full URL to job posting
posted string Publication date in ISO 8601
deadline string | null Application deadline as DD.MM.YYYY string, or null if "løbende" / not present

meta.total is fetched from the HTML page <title> in a secondary request (pattern: "{N} relevante job og karriereopslag"). If the secondary request fails, meta.total is null.


detail — Full job detail

Endpoint: GET https://jobbank.dk/job/{id}/

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

The id is the numeric job ID from search results (the id field). The short URL https://jobbank.dk/job/{id}/ redirects to the full slug URL and returns HTTP 200.

The CLI fetches the HTML page and extracts the <script type="application/ld+json"> block containing a Schema.org JobPosting object.

Example

bun run src/cli.ts detail 1234567
bun run src/cli.ts detail 1234567 --format plain

Response shape

{
  "id": "1234567",
  "url": "https://jobbank.dk/job/1234567/",
  "title": "Senior Data Scientist",
  "description": "<p>Full HTML description of the role...</p>",
  "datePosted": "2026-03-02",
  "deadline": "2026-04-12",
  "employmentType": ["FULL_TIME"],
  "company": {
    "name": "Novo Nordisk",
    "logo": "https://jobbank.dk/images/dynamic/company/logo/12345/"
  },
  "location": {
    "streetAddress": "",
    "city": "Bagsværd",
    "postalCode": "",
    "country": "DK"
  }
}

Field details

Field Type Notes
id string Numeric job ID (from identifier.value in JSON-LD)
url string Canonical URL of the job posting
title string Job title
description string Full HTML job description body
datePosted string Publication date in ISO 8601 format (YYYY-MM-DD)
deadline string | null Application deadline (validThrough in JSON-LD), ISO 8601, or null if absent
employmentType string[] Schema.org employment type values, e.g. ["FULL_TIME"]
company.name string Hiring organization name
company.logo string | null URL to company logo, or null if absent
location.streetAddress string Street address (may be empty)
location.city string City (may be empty for international jobs)
location.postalCode string Postal code (may be empty)
location.country string Country code (may be empty)

location fields may be empty strings for international jobs or postings that do not specify a physical location.


Error handling

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

{ "error": "Job not found", "code": "NOT_FOUND" }
{ "error": "Jobbank is blocking automated requests with Cloudflare bot protection. Skip this portal or use the WebSearch fallback.", "code": "API_ERROR" }
{ "error": "No JSON-LD found on job page", "code": "PARSE_ERROR" }
{ "error": "--key or at least one filter is required", "code": "MISSING_REQUIRED" }

Implementation notes

User-Agent

All HTTP requests include a browser User-Agent header:

Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36

This is not guaranteed to bypass Cloudflare bot protection. If Jobbank returns a Cloudflare challenge page, the CLI reports that condition and callers should skip the portal or use a WebSearch fallback.

RSS description parsing

The RSS <description> field follows this pattern:

Fuldtidsjob, Graduate/trainee hos Novo Nordisk, Bagsværd (Ansøgningsfrist: 12.04.2026)
Fuldtidsjob hos DTU, Lyngby (Ansøgningsfrist: løbende)

Parse strategy:

  1. Split on hos — left side is job type(s), right side is Company, Location (Ansøgningsfrist: Deadline)
  2. From the right side, extract the parenthetical (Ansøgningsfrist: ...) for deadline
  3. Remaining text is Company, Location — split on first , to separate company from location

JSON-LD extraction

On detail pages, find <script type="application/ld+json"> containing "@type": "JobPosting" and parse the JSON. Map fields:

  • identifier.valueid
  • urlurl
  • titletitle
  • descriptiondescription (HTML)
  • datePosteddatePosted
  • validThroughdeadline (may be absent → null)
  • employmentTypeemploymentType (array)
  • hiringOrganization.namecompany.name
  • hiringOrganization.logocompany.logo
  • jobLocation.address.streetAddresslocation.streetAddress
  • jobLocation.address.addressLocalitylocation.city
  • jobLocation.address.postalCodelocation.postalCode
  • jobLocation.address.addressCountrylocation.country

Rate limiting

No explicit rate limits are enforced, but Cloudflare is present. Add a 300500ms delay between sequential requests (e.g. when fetching total count from HTML in addition to the RSS feed). The search command makes at most 2 requests (RSS + HTML for total count).