Initial release: AI-powered job application framework

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
AI Job Search
2026-03-23 08:34:06 +01:00
committed by Mads Lorentzen
co-authored by Claude Opus 4.6
commit c66d599d75
73 changed files with 6539 additions and 0 deletions
+179
View File
@@ -0,0 +1,179 @@
---
name: jobbank-search
version: 1.0.0
description: >
Make sure to use this skill whenever the user mentions anything related to job
searching on Akademikernes Jobbank, jobbank.dk, or looking for academic or
highly educated positions in Denmark — even if they don't mention jobbank.dk
explicitly. Also invoke this skill for questions about Danish job listings,
graduate trainee positions, Ph.d. jobs, or finding work in specific industries
or regions in Denmark. Trigger phrases include:
jobbank, akademikernes jobbank, jobs denmark, academic jobs denmark, find job
denmark, highly educated jobs, graduate job denmark, trainee position denmark,
ph.d. position denmark, postdoc denmark, studiejob, fuldtidsjob, deltidsjob,
vikariat, freelance job, praktikplads, job søgning, jobsøgning, søg job,
ledige stillinger, nye jobs, it jobs denmark, engineering jobs denmark,
marketing jobs denmark, finance jobs denmark, healthcare jobs denmark,
remote job denmark, fjernarbejde, job københavn, job aarhus, job odense,
nyuddannede job, job til nyuddannede, international job denmark,
jobbank søgning, find stilling, data scientist job, software developer job,
projektleder stilling, konsulent job, data analyse job.
context: fork
allowed-tools: Bash(bun run skills/jobbank-search/cli/src/cli.ts *)
---
# Jobbank Search Skill
Search live Danish job listings from [Akademikernes Jobbank](https://jobbank.dk) — Denmark's primary job portal for highly educated candidates. No authentication needed. Uses the RSS feed for search (up to 100 results) and JSON-LD parsing for detailed job information.
## When to use this skill
Invoke this skill when the user wants to:
- Search for jobs, positions, or career opportunities in Denmark
- Find academic, graduate, trainee, Ph.d., or postdoc positions
- Look for jobs by keyword, industry, location, education background, or work function
- Find remote or hybrid positions in Denmark
- Get full details for a specific job posting on jobbank.dk
- Check what positions are available at a specific company on jobbank.dk
- Browse jobs suitable for new graduates or people with international backgrounds
## Commands
### Search jobs
```bash
bun run skills/jobbank-search/cli/src/cli.ts search [flags]
```
Key flags:
- `--key <text>` — keyword search (title, company, keyword)
- `--exclude <text>` — exclude keywords from results
- `--type <code>` — job type: `3`=Fuldtidsjob, `6`=Graduate/trainee, `13`=Deltidsjob, `8`=Vikariat, `12`=Ph.d. & Postdoc, `11`=Freelance, `9`=Praktikplads, `4`=Studiejob (repeatable)
- `--location <code>` — region: `2`=Storkøbenhavn, `8`=Østjylland (Aarhus), `7`=Midtjylland, `6`=Nordjylland, `13`=Fyn (repeatable)
- `--work-area <code>` — function: `31`=IT-Software, `43`=Data & Analyse, `26`=Ledelse, `29`=Marketing (repeatable)
- `--industry <code>` — sector: `10331`=IT & Tele, `10442`=Forskning & Uddannelse, `10358`=Finans (repeatable)
- `--education <code>` — education field: `24`=IT, `21`=Økonomi & Revision, `34`=Samfundsvidenskab (repeatable)
- `--remote <value>``helt` (fully remote) or `delvist` (partially remote)
- `--suitable-for <code>``2`=Nyuddannede, `4`=International baggrund, `5`=Erfarne
- `--company <id>` — filter by company ID
- `--since <YYYY-MM-DD>` — jobs posted on or after this date
- `--limit <n>` — cap results returned by CLI
- `--format json|table|plain`
> **RSS limitation:** The RSS feed returns max 100 items per request. No pagination is available via RSS. `meta.total` shows the true count; `results` is capped at 100.
### Full job detail
```bash
bun run skills/jobbank-search/cli/src/cli.ts detail <id> [--format json|plain]
```
`id` is the numeric job ID from `search` results. Fetches the job page and parses the embedded Schema.org `JobPosting` JSON-LD for structured data.
---
## How to use effectively
**Start with `search`, then use `detail` for full description.**
1. Use `search` with `--key` and/or filters to find matching jobs with IDs
2. Call `detail <id>` to get the full HTML job description, exact deadline, and company details
**Use repeatable flags for multi-value filters.** Most filter flags can be repeated to match any of the values:
```bash
# IT or Finance industry, Copenhagen or Aarhus
bun run skills/jobbank-search/cli/src/cli.ts search \
--industry 10331 --industry 10358 \
--location 2 --location 8
```
**Filter codes are documented in the README** at `skills/jobbank-search/cli/README.md`.
---
## Usage examples
### Find data scientist jobs in Copenhagen
```bash
bun run skills/jobbank-search/cli/src/cli.ts search \
--key "data scientist" \
--location 2 \
--format table
```
### Graduate trainee positions for new graduates
```bash
bun run skills/jobbank-search/cli/src/cli.ts search \
--type 6 \
--suitable-for 2 \
--format table
```
### Remote IT software jobs
```bash
bun run skills/jobbank-search/cli/src/cli.ts search \
--work-area 31 \
--remote helt \
--format table
```
### Ph.d. and postdoc positions in research
```bash
bun run skills/jobbank-search/cli/src/cli.ts search \
--type 12 \
--industry 10442 \
--format table
```
### Recent full-time jobs posted since March 1
```bash
bun run skills/jobbank-search/cli/src/cli.ts search \
--type 3 \
--since 2026-03-01 \
--format table
```
### Full details for a specific job
```bash
bun run skills/jobbank-search/cli/src/cli.ts detail 1234567 --format plain
```
### IT jobs in Aarhus or Copenhagen
```bash
bun run skills/jobbank-search/cli/src/cli.ts search \
--key developer \
--location 2 --location 8 \
--work-area 31 \
--format table
```
---
## Output formats
| Format | Best for |
|--------|----------|
| `json` | Default — programmatic use, passing IDs to `detail` |
| `table` | Quick human-readable list of results |
| `plain` | Single-job detail views (`detail` command) |
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
---
## Notes
- Data is from the public jobbank.dk RSS feed and HTML pages — no credentials required.
- RSS feed returns max 100 results per query. For higher counts, `meta.total` shows the true total.
- The `detail` command fetches a full job page and extracts the JSON-LD structured data block.
- `location` values are region codes (e.g. `2` = Storkøbenhavn), not city names.
- All filter codes are documented in `skills/jobbank-search/cli/README.md`.
+396
View File
@@ -0,0 +1,396 @@
# jobbank-cli
CLI for [Akademikernes Jobbank](https://jobbank.dk) — 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 required to bypass bot protection.
**Format**: RSS XML (search), HTML with embedded JSON-LD (detail).
---
## Installation
```bash
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}`
```bash
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
```bash
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
```json
{
"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}/`
```bash
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
```bash
bun run src/cli.ts detail 1234567
bun run src/cli.ts detail 1234567 --format plain
```
### Response shape
```json
{
"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`:
```json
{ "error": "Job not found", "code": "NOT_FOUND" }
{ "error": "Failed to fetch RSS feed: 403 Forbidden", "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 must include a browser User-Agent header. Without it, Jobbank routes traffic through a bot protection layer that returns invalid responses:
```
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
```
### 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.value``id`
- `url``url`
- `title``title`
- `description``description` (HTML)
- `datePosted``datePosted`
- `validThrough``deadline` (may be absent → `null`)
- `employmentType``employmentType` (array)
- `hiringOrganization.name``company.name`
- `hiringOrganization.logo``company.logo`
- `jobLocation.address.streetAddress``location.streetAddress`
- `jobLocation.address.addressLocality``location.city`
- `jobLocation.address.postalCode``location.postalCode`
- `jobLocation.address.addressCountry``location.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).
@@ -0,0 +1,25 @@
{
"name": "jobbank-cli",
"version": "1.0.0",
"description": "CLI for Akademikernes Jobbank (jobbank.dk) — job search for highly educated candidates",
"type": "module",
"main": "src/cli.ts",
"bin": {
"jobbank": "src/cli.ts"
},
"scripts": {
"start": "bun run src/cli.ts",
"test": "bun test --timeout 30000",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@bunli/core": "latest",
"@bunli/utils": "latest",
"node-html-parser": "^6.1.13",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.4.0"
}
}
@@ -0,0 +1,14 @@
import { createCLI } from "@bunli/core"
import { search } from "./commands/search.js"
import { detail } from "./commands/detail.js"
const cli = await createCLI({
name: "jobbank-cli",
version: "1.0.0",
description: "CLI for Akademikernes Jobbank (jobbank.dk) — job search for highly educated candidates",
})
cli.command(search)
cli.command(detail)
await cli.run()
@@ -0,0 +1,154 @@
export const BASE_URL = "https://jobbank.dk"
export const USER_AGENT =
"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"
export function writeError(error: string, code: string): void {
process.stderr.write(JSON.stringify({ error, code }) + "\n")
}
export async function fetchWithUA(url: string): Promise<Response> {
const maxRetries = 6
let delay = 500
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, {
headers: { "User-Agent": USER_AGENT },
})
if (response.status === 429 || response.status >= 500) {
if (attempt === maxRetries) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`)
}
const jitter = Math.floor(Math.random() * 500)
await new Promise((resolve) => setTimeout(resolve, delay + jitter))
delay = Math.min(delay * 2, 5000)
continue
}
return response
}
throw new Error("Request failed after max retries")
}
export interface RssItem {
title: string
description: string
link: string
pubDate: string
}
function extractCdata(xml: string, tag: string): string {
// Try CDATA first
const cdataRe = new RegExp(`<${tag}><\\!\\[CDATA\\[(.*?)\\]\\]><\\/${tag}>`, "s")
const cdataMatch = xml.match(cdataRe)
if (cdataMatch) return cdataMatch[1].trim()
// Plain content
const plainRe = new RegExp(`<${tag}>(.*?)<\\/${tag}>`, "s")
const plainMatch = xml.match(plainRe)
return plainMatch ? plainMatch[1].trim() : ""
}
function extractLink(xml: string): string {
// <link> in RSS can conflict with atom namespace — extract text node after <link>
// Try CDATA variant first
const cdataMatch = xml.match(/<link><!\[CDATA\[(.*?)\]\]><\/link>/s)
if (cdataMatch) return cdataMatch[1].trim()
// Plain link
const plainMatch = xml.match(/<link>(.*?)<\/link>/s)
if (plainMatch) return plainMatch[1].trim()
// Some RSS feeds put the URL as text after <link> without a closing tag (self-closing style)
// Try matching href in atom:link
return ""
}
function parseRssItems(xml: string): RssItem[] {
const items: RssItem[] = []
// Split on <item> boundaries
const itemMatches = xml.matchAll(/<item>([\s\S]*?)<\/item>/g)
for (const match of itemMatches) {
const itemXml = match[1]
const title = extractCdata(itemXml, "title")
const description = extractCdata(itemXml, "description")
const link = extractLink(itemXml)
const pubDate = extractCdata(itemXml, "pubDate") || itemXml.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() || ""
items.push({ title, description, link, pubDate })
}
return items
}
export async function rssFetch(params: Record<string, string | string[]>): Promise<RssItem[]> {
const searchParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (Array.isArray(value)) {
for (const v of value) {
searchParams.append(key, v)
}
} else {
searchParams.append(key, value)
}
}
const url = `${BASE_URL}/job/rss?${searchParams.toString()}`
const response = await fetchWithUA(url)
if (!response.ok) {
throw new Error(`Failed to fetch RSS feed: ${response.status} ${response.statusText}`)
}
const xml = await response.text()
return parseRssItems(xml)
}
export interface ParsedDescription {
jobType: string
company: string
location: string
deadline: string | null
}
export function parseRssDescription(desc: string): ParsedDescription {
// Format: "JobType hos Company, Location (Ansøgningsfrist: DD.MM.YYYY)"
// or: "JobType hos Company, Location (Ansøgningsfrist: løbende)"
// or multiple types: "Fuldtidsjob, Graduate/trainee hos Company, Location (Ansøgningsfrist: ...)"
let jobType = ""
let company = ""
let location = ""
let deadline: string | null = null
const hosIdx = desc.indexOf(" hos ")
if (hosIdx === -1) {
// Can't parse — return desc as company
return { jobType: "", company: desc, location: "", deadline: null }
}
jobType = desc.substring(0, hosIdx).trim()
let rest = desc.substring(hosIdx + 5) // skip " hos "
// Extract deadline from parenthetical at the end
const deadlineMatch = rest.match(/\(Ans[øo]gningsfrist:\s*(.*?)\)\s*$/)
if (deadlineMatch) {
const deadlineStr = deadlineMatch[1].trim()
if (deadlineStr.toLowerCase() === "løbende" || deadlineStr.toLowerCase() === "lobende") {
deadline = null
} else {
deadline = deadlineStr
}
// Remove the deadline portion from rest
rest = rest.substring(0, deadlineMatch.index).trim()
}
// rest is now "Company, Location"
// Split on first ", " to get company and location
const firstComma = rest.indexOf(", ")
if (firstComma !== -1) {
company = rest.substring(0, firstComma).trim()
location = rest.substring(firstComma + 2).trim()
} else {
company = rest.trim()
location = ""
}
return { jobType, company, location, deadline }
}
export function extractJobIdFromUrl(url: string): string {
// URL format: https://jobbank.dk/job/{id}/{company-slug}/{title-slug}
const match = url.match(/\/job\/(\d+)\//)
return match ? match[1] : ""
}
@@ -0,0 +1,39 @@
import { join } from "path";
const CLI_PATH = join(import.meta.dir, "../src/cli.ts");
export interface CLIResult {
stdout: string;
stderr: string;
exitCode: number;
}
export async function runCLI(args: string[]): Promise<CLIResult> {
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
}
export function parseJSON<T = unknown>(result: CLIResult): T {
if (result.exitCode !== 0) {
throw new Error(
`CLI exited with code ${result.exitCode}. stderr: ${result.stderr}`
);
}
try {
return JSON.parse(result.stdout) as T;
} catch {
throw new Error(
`Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}`
);
}
}
@@ -0,0 +1,327 @@
# Jobbank.dk URL Reference
Complete reference for constructing search URLs on Akademikernes Jobbank.
## Base URL
```
https://jobbank.dk/job/
```
## Full URL Pattern
```
https://jobbank.dk/job/?key={search}&antikey={exclude}&cvtype={type_id}&udd={edu_id}&amt={location_id}&erf={workarea_id}&branche={industry_id}&andet={suitability_id}&virk={company}&fjernarbejde={remote}&oprettet={YYYY-MM-DD}&page={num}
```
**Multiple values:** Repeat the parameter name
```
cvtype=3&cvtype=13&amt=2&amt=3
```
## Query Parameters
| Parameter | Description | Format |
|-----------|-------------|--------|
| `key` | Search keywords | URL-encoded string (use `+` for spaces) |
| `antikey` | Exclude keywords | URL-encoded string |
| `cvtype` | Job type ID | Numeric ID (see table below) |
| `udd` | Education area ID | Numeric ID (see table below) |
| `amt` | Location/region ID | Numeric ID (see table below) |
| `erf` | Work area ID | Numeric ID (see table below) |
| `branche` | Industry ID | Numeric ID (see table below) |
| `andet` | Suitability ID | Numeric ID (see table below) |
| `virk` | Company name | URL-encoded string |
| `fjernarbejde` | Remote work | `helt` (fully) or `delvist` (partially) |
| `oprettet` | Posted since date | `YYYY-MM-DD` format |
| `page` | Page number | Integer (1-indexed) |
## Filter Tables
### Job Type (cvtype)
| Label | ID |
|-------|-----|
| Fuldtidsjob (Full-time) | 3 |
| Graduate/trainee | 6 |
| Deltidsjob (Part-time) | 13 |
| Vikariat (Temporary) | 8 |
| Ph.d. & Postdoc | 12 |
| Freelance | 11 |
| Iværksætterprojekt (Entrepreneurship) | 15 |
| Event | 14 |
| Praktikplads (Internship) | 9 |
| Studiejob (Student job) | 4 |
| Studieprojekt/speciale (Study project/thesis) | 5 |
### Education Area (udd)
| Label | ID |
|-------|-----|
| Administration | 20 |
| Anlæg/Byggeri/Konstruktion (Construction) | 43 |
| Arkitektur/Kunst/Design (Architecture/Art/Design) | 29 |
| Elektro/Telekommunikation (Electronics/Telecom) | 47 |
| Fødevarer/Veterinær (Food/Veterinary) | 32 |
| Human Resources | 38 |
| Humaniora (Humanities) | 28 |
| IT | 24 |
| Jura (Law) | 23 |
| Kemi/Biotek/Materialer (Chemistry/Biotech/Materials) | 44 |
| Klima/Miljø/Energi (Climate/Environment/Energy) | 45 |
| Landbrug/Natur (Agriculture/Nature) | 37 |
| Marketing/Business | 22 |
| Maskin/Design (Mechanical/Design) | 48 |
| Matematik/Fysik/Nano (Math/Physics/Nano) | 46 |
| Medicinal/Sundhed (Medicine/Health) | 31 |
| Naturvidenskab (Natural Sciences) | 30 |
| Organisation/Ledelse (Organization/Management) | 41 |
| Produktion/Logistik/Transport (Production/Logistics/Transport) | 35 |
| Samfundsvidenskab (Social Sciences) | 34 |
| Sprog/Media/Kommunikation (Language/Media/Communication) | 25 |
| Teknik/Teknologi (Engineering/Technology) | 33 |
| Undervisning/Pædagogik (Education/Pedagogy) | 26 |
| Økonomi/Revision (Economics/Accounting) | 21 |
### Location/Region (amt)
| Label | ID |
|-------|-----|
| Storkøbenhavn (Greater Copenhagen) | 2 |
| Nordsjælland (North Zealand) | 3 |
| Østsjælland (East Zealand) | 14 |
| Vestsjælland (West Zealand) | 4 |
| Sydsjælland & Øer (South Zealand & Islands) | 5 |
| Fyn (Funen) | 13 |
| Sønderjylland (South Jutland) | 12 |
| Sydvestjylland (Esbjerg) | 11 |
| Vestjylland (West Jutland) | 9 |
| Sydøstjylland (Southeast Jutland) | 10 |
| Midtjylland (Central Jutland) | 7 |
| Østjylland (Aarhus) | 8 |
| Nordjylland (North Jutland) | 6 |
| Bornholm | 20 |
| Øresundsregionen (Øresund Region) | 21 |
| Grønland & Færøerne (Greenland & Faroe Islands) | 22 |
| Udlandet - Sverige (Abroad - Sweden) | 23 |
| Udlandet - Norge (Abroad - Norway) | 24 |
| Udlandet - øvrige (Abroad - other) | 19 |
### Work Area (erf)
| Label | ID |
|-------|-----|
| Administration | 20 |
| Arkitektur/Design (Architecture/Design) | 38 |
| Bank/Forsikring (Banking/Insurance) | 22 |
| Data/Analyse (Data/Analysis) | 43 |
| Eksport (Export) | 47 |
| Forskning/Udvikling (Research/Development) | 41 |
| Human Resources | 28 |
| Indkøb (Procurement) | 49 |
| Internet/Multimedia | 34 |
| IT-Hardware | 32 |
| IT-Netværk/Telekomm. (IT-Network/Telecom) | 33 |
| IT-Software | 31 |
| Jura (Law) | 24 |
| Kommunikation/Media/SoMe (Communication/Media/Social Media) | 35 |
| Konstruktion/Beregning (Construction/Calculation) | 46 |
| Kunst/Kultur (Art/Culture) | 37 |
| Ledelse/Planlægning (Management/Planning) | 26 |
| Marketing/Reklame (Marketing/Advertising) | 29 |
| Medicinal/Sundhed (Medicine/Health) | 40 |
| Naturvidenskab (Natural Sciences) | 45 |
| Organisation/Forening (Organization/Association) | 23 |
| Politik/Samfund (Politics/Society) | 52 |
| Produktion (Production) | 44 |
| Projektledelse (Project Management) | 27 |
| Rådgivning/Support (Consulting/Support) | 50 |
| Salg (Sales) | 30 |
| Socialvæsen (Social Services) | 39 |
| Teknik (Engineering) | 42 |
| Topledelse (Executive Management) | 25 |
| Transport/Logistik (Transport/Logistics) | 48 |
| Undervisning (Education) | 36 |
| Økonomi/Forvaltning (Finance/Administration) | 21 |
### Industry (branche)
| Label | ID |
|-------|-----|
| Advokat/Revision (Law/Accounting) | 10359 |
| Byggeri/Anlæg (Construction) | 11669 |
| Elektronik/Maskin (Electronics/Machinery) | 11634 |
| Fagforeninger/A-kasser/Pensionskasser (Unions/Unemployment funds/Pension funds) | 16791 |
| Finans/Forsikring/Pension (Finance/Insurance/Pension) | 10358 |
| Forskning/Uddannelse (Research/Education) | 10442 |
| Fødevarer/Dagligvarer (Food/Groceries) | 15407 |
| Handel/Service (Trade/Service) | 10364 |
| IT/Tele | 10331 |
| Klima/Energi/Forsyning (Climate/Energy/Utilities) | 17209 |
| Kommuner (Municipalities) | 16826 |
| Kultur/Medier/Underholdning (Culture/Media/Entertainment) | 10341 |
| Medicinal/Biotek/Kemi (Pharmaceuticals/Biotech/Chemistry) | 10333 |
| Papir/Møbel/Materialer (Paper/Furniture/Materials) | 10363 |
| Regioner/Sundhed/Socialvæsen (Regions/Health/Social services) | 11626 |
| Rådgivning/Konsulentservice (Consulting services) | 15586 |
| Stat/Politik/Samfund (Government/Politics/Society) | 10362 |
| Transport | 10440 |
| Vikar/Rekruttering (Temp/Recruitment) | 12450 |
### Suitability (andet)
| Label | ID |
|-------|-----|
| Nyuddannede (New graduates) | 2 |
| Personer med international baggrund (People with international background) | 4 |
| Erfarne (Experienced) | 5 |
### Remote Work (fjernarbejde)
| Label | Value |
|-------|-------|
| Helt hjemmearbejde (Fully remote) | `helt` |
| Delvist hjemmearbejde (Partially remote) | `delvist` |
## Job Detail URL Pattern
```
https://jobbank.dk/job/{id}/{company-slug}/{title-slug}/
```
Example: `https://jobbank.dk/job/12345/novo-nordisk/senior-data-scientist/`
## RSS Feed URL
Every search has an RSS equivalent:
```
https://jobbank.dk/job/rss?{same_query_params}
```
Example:
```
https://jobbank.dk/job/rss?key=python&amt=2&cvtype=3
```
## Pagination
- Pages are 1-indexed (first page is `page=1` or no page parameter)
- Approximately 20 results per page
- Use `&page=2`, `&page=3`, etc. to navigate
## Job Card Extraction
### CSS Selectors
| Element | Selector |
|---------|----------|
| Job card container | `div.job-item` |
| Job ID | `div.job-item[name]` (use `getAttribute("name")`) |
| Job title | `.job-header` |
| Job type/company/location | `.job-teaser` |
| Job description excerpt | `.job-description` |
| Date updated | `.job-date-updated` |
| Application deadline | `.job-date-application` |
| Job detail link | `a[href^="/job/"]` (prepend `https://jobbank.dk`) |
### Working Extraction Code
```javascript
async page => {
const jobs = await page.evaluate(() => {
return Array.from(document.querySelectorAll("div.job-item")).map(item => {
const id = item.getAttribute("name");
const link = item.querySelector("a[href^='/job/']");
const url = link ? "https://jobbank.dk" + link.getAttribute("href") : null;
const header = item.querySelector(".job-header");
const teaser = item.querySelector(".job-teaser");
const desc = item.querySelector(".job-description");
const dateUpdated = item.querySelector(".job-date-updated");
const deadline = item.querySelector(".job-date-application");
return {
id,
title: header ? header.textContent.trim() : null,
teaser: teaser ? teaser.textContent.trim() : null,
description: desc ? desc.textContent.trim() : null,
url,
dateUpdated: dateUpdated ? dateUpdated.textContent.trim() : null,
deadline: deadline ? deadline.textContent.trim() : null,
};
});
});
return JSON.stringify(jobs, null, 2);
}
```
## Examples
### Simple Keyword Search
```
https://jobbank.dk/job/?key=data+scientist
```
### Full-Time IT Jobs in Copenhagen
```
https://jobbank.dk/job/?key=developer&cvtype=3&udd=24&amt=2
```
### Remote Software Development Positions
```
https://jobbank.dk/job/?erf=31&fjernarbejde=helt
```
### Graduate/Trainee Positions in Multiple Cities
```
https://jobbank.dk/job/?cvtype=6&amt=2&amt=8
```
### Project Management Jobs Suitable for New Graduates
```
https://jobbank.dk/job/?key=project+manager&erf=27&andet=2
```
### Jobs in IT/Telecom Industry, Excluding Senior Positions
```
https://jobbank.dk/job/?key=developer&antikey=senior&branche=10331
```
### Data Analysis Jobs Posted in Last 7 Days
```
https://jobbank.dk/job/?key=data+analysis&erf=43&oprettet=2026-02-03
```
### Second Page of Python Jobs in Aarhus
```
https://jobbank.dk/job/?key=python&amt=8&page=2
```
### Specific Company Search (Novo Nordisk)
```
https://jobbank.dk/job/?virk=novo+nordisk
```
### Ph.D. and Postdoc Positions in Natural Sciences
```
https://jobbank.dk/job/?cvtype=12&udd=30
```
## Notes
- **URL encoding:** Use `+` or `%20` for spaces in text parameters (`key`, `antikey`, `virk`)
- **Multiple filters:** Repeat parameter names for multiple values in same category
- **Date format:** Use ISO format `YYYY-MM-DD` for `oprettet` parameter
- **Remote work:** Only two values: `helt` (fully) or `delvist` (partially)
- **Case sensitivity:** Filter IDs are case-sensitive, use exact values from tables
- **Empty results:** Invalid filter combinations may return zero results
- **RSS monitoring:** Use RSS feeds for automated job monitoring and alerts
+228
View File
@@ -0,0 +1,228 @@
---
name: jobdanmark-search
version: 1.0.0
description: >
Make sure to use this skill whenever the user mentions anything related to Danish
job listings, job search in Denmark, finding work in Denmark, or job vacancies on
Jobdanmark — even if they don't explicitly mention jobdanmark.dk. Also invoke this
skill for questions about specific Danish job categories, municipalities, job types,
or salaries in a job-search context. Trigger phrases include:
danish jobs, jobs in denmark, find job denmark, job search denmark, danish job listings,
jobdanmark, job opslag, find job, jobsøgning, ledige stillinger, stillingsopslag,
job i Danmark, fuldtidsjob, deltidsjob, studiejob, praktikplads, elev, fleksjob,
IT job denmark, sygeplejersker job, håndværker job, ingeniør job, pædagog job,
kontor job, leder job, salg job, hotel job, kirke job, job aarhus, job københavn,
job odense, job aalborg, job sjælland, job jylland, job fyn, jobkategorier denmark,
ledige job, ansøgningsfrist, søg job, job opslaget, jobopslag, danish vacancies,
work in denmark, employment denmark, job denmark, jobs near me denmark,
apprentice denmark, internship denmark, part-time denmark, full-time denmark.
context: fork
allowed-tools: Bash(bun run skills/jobdanmark-search/cli/src/cli.ts *)
---
# Jobdanmark Search Skill
Access live Danish job listings from the Jobdanmark.dk public API. No authentication needed.
Covers ~15,000+ active job listings across 10 categories and all Danish municipalities.
## When to use this skill
Invoke this skill when the user wants to:
- Search for job listings in Denmark (by keyword, location, category, or job type)
- Browse available jobs in a specific Danish city, municipality, zip code, or region
- Filter jobs by employment type (full-time, part-time, student job, apprentice, etc.)
- Look up the full details of a specific job posting including description and deadline
- List all job categories with current live job counts
- Resolve a job search term into a job title ID or category ID for precise filtering
- Find location suggestions (municipalities, zip codes, regions) for job search filters
## Commands
### Search job listings
```bash
bun run skills/jobdanmark-search/cli/src/cli.ts search [flags]
```
Key flags:
- `--text <keyword>` — free-text search, e.g. `elektriker`, `sygeplejerske`, `softwareudvikler`
- `--category <id>` — category ID (see Categories table below)
- `--jobtitle-id <id>` — job title ID from `autocomplete` results
- `--municipality <name>` — e.g. `Odense`, `København`, `Aarhus`
- `--zip <code>` — zip code, e.g. `5000`, `8000`, `2100`
- `--region <name>` — region name, e.g. `Midtjylland`, `Sjælland`
- `--job-type <types>` — comma-separated: `fuldtid`, `deltid`, `fleksjob`, `elev`, `studiejob`, `praktik`
- `--page <n>` — page number (30 items per page, server-enforced)
- `--limit <n>` — cap total results returned by CLI
- `--format json|table|plain`
> Per-page is fixed at 30 by the API. Use `--page` to paginate.
### Full job detail
```bash
bun run skills/jobdanmark-search/cli/src/cli.ts detail <slug> [--format json|plain]
```
`slug` is the URL path segment returned as `slug` in `search` results (e.g. `it-chef-soeges-til-rah`).
Returns full structured job data from the job page's JSON-LD, including title, organization, location, employment type, deadline, and full HTML description.
### List categories with live counts
```bash
bun run skills/jobdanmark-search/cli/src/cli.ts categories [--format json|table|plain]
```
Returns all 10 job categories with current live job counts. Useful for giving the user an overview of the job market.
### Autocomplete job titles and categories
```bash
bun run skills/jobdanmark-search/cli/src/cli.ts autocomplete --query "<text>" [--limit <n>]
```
Use this to resolve search terms into precise job title IDs (`--jobtitle-id`) or category IDs (`--category`) for `search`. The `value` field in results is the ID to pass to `search`.
### Suggest locations
```bash
bun run skills/jobdanmark-search/cli/src/cli.ts locations --query "<text>" [--limit <n>]
```
Returns matching municipalities, zip codes, and regions. Use `value` from results as `--municipality` or `--zip` in `search`.
---
## Categories
| ID | Danish | English |
|----|--------|---------|
| 227972 | Pædagogik, Uddannelse og Forskning | Education, Research |
| 227973 | Håndværk, Industri, Transport og Landbrug | Crafts, Industry, Transport, Agriculture |
| 227974 | Salg, Kommunikation, Marketing, og Design | Sales, Communication, Marketing, Design |
| 227975 | Pleje, Social og Sundhed | Care, Social, Health |
| 227976 | Hotel, Service, Restauration og Sikkerhed | Hotel, Service, Restaurant, Security |
| 227977 | Kontor, Finans og Økonomi | Office, Finance, Economy |
| 227978 | IT, Ingeniør og Energi | IT, Engineering, Energy |
| 227979 | Ledelse, HR og projektstyring | Management, HR, Project Management |
| 543415 | Kirke, Kultur og Underholdning | Church, Culture, Entertainment |
| 227980 | Øvrige job | Other jobs |
---
## How to use effectively
**Resolve locations first.** Use `locations` to find the correct municipality name or zip code before passing them to `search`:
```bash
bun run skills/jobdanmark-search/cli/src/cli.ts locations --query "Aarhus" --format plain
```
**Resolve job titles for precision.** Use `autocomplete` to get the exact job title ID when the user wants a specific role:
```bash
bun run skills/jobdanmark-search/cli/src/cli.ts autocomplete --query "sygeplejerske" --format plain
```
**Natural workflow: `search` → `detail`.**
1. Use `search` to get a list of matching jobs with their `slug`.
2. Call `detail <slug>` to get the full job posting with description, deadline, and organization details.
**Use `--format table` for comparisons**, `--format json` for data processing, and `--format plain` for single-record detail views.
**Pagination**: The API returns 30 items per page (server-enforced). Use `--page` to navigate through results. Use `--limit` to cap CLI output regardless of page size.
**Combine filters** for precise results — e.g. `--text` + `--job-type` + `--municipality` all work together in a single search request.
---
## Usage examples
### IT jobs in Copenhagen
```bash
bun run skills/jobdanmark-search/cli/src/cli.ts search \
--category 227978 \
--municipality "København" \
--job-type fuldtid \
--format table
```
### Nursing jobs anywhere in Denmark
```bash
bun run skills/jobdanmark-search/cli/src/cli.ts search \
--text "sygeplejerske" \
--category 227975 \
--format table
```
### Student jobs in Aarhus
```bash
bun run skills/jobdanmark-search/cli/src/cli.ts search \
--municipality "Aarhus" \
--job-type studiejob \
--format table
```
### What job categories are most active right now?
```bash
bun run skills/jobdanmark-search/cli/src/cli.ts categories --format table
```
### Full details for a specific job posting
```bash
bun run skills/jobdanmark-search/cli/src/cli.ts detail it-chef-soeges-til-rah --format plain
```
### Find jobs in zip code 8000
```bash
bun run skills/jobdanmark-search/cli/src/cli.ts search --zip 8000 --format table
```
### What electrician jobs are available?
```bash
bun run skills/jobdanmark-search/cli/src/cli.ts search \
--text "elektriker" \
--category 227973 \
--job-type "fuldtid,deltid" \
--format table
```
### Apprentice positions in all of Denmark
```bash
bun run skills/jobdanmark-search/cli/src/cli.ts search \
--job-type elev \
--page 1 \
--format table
```
---
## Output formats
| Format | Best for |
|--------|----------|
| `json` | Default — programmatic use, data processing, passing IDs between commands |
| `table` | Quick human-readable overviews and comparisons |
| `plain` | Single-record detail views (`detail`) |
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
---
## Notes
- All data is from the public Jobdanmark.dk API — no credentials required.
- Pagination is 1-indexed (`--page 1` is the first page). 30 items per page, server-enforced.
- The `detail` command fetches the HTML job page and parses the embedded JSON-LD (schema.org/JobPosting). It does not use a separate JSON API.
- `slug` in search results is extracted from the API's relative `url` field (the path after `/job/`).
- `applicationDeadline` in search results can be `null` (no deadline set).
- Job type values for filters: `fuldtid`, `deltid`, `fleksjob`, `elev`, `studiejob`, `praktik`.
@@ -0,0 +1,452 @@
# jobdanmark-cli
CLI for the [Jobdanmark.dk](https://www.jobdanmark.dk) public job search API.
**Base URL**: `https://jobdanmark.dk`
**Authentication**: None required.
**Format**: All responses are JSON.
---
## Installation
```bash
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}`
```bash
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
```bash
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
```json
{
"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
```json
{
"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}`, extract `<script type="application/ld+json">` block.
```bash
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
```bash
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
```json
{
"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>"
}
```
> **Note**: The `hiringOrganization.logo` and `validThrough` fields may be `null` if not present in the JSON-LD. `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`
```bash
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
```bash
bun run src/cli.ts categories
bun run src/cli.ts categories --format table
```
### Response shape
```json
[
{
"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}`
```bash
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
```bash
bun run src/cli.ts autocomplete --query "it"
bun run src/cli.ts autocomplete --query "sygeplejerske" --format table
```
### Response shape
```json
[
{
"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}`
```bash
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
```bash
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
```json
[
{
"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`:
```json
{ "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 JSON-LD from job page", "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)
@@ -0,0 +1,25 @@
{
"name": "jobdanmark-cli",
"version": "1.0.0",
"description": "CLI for the Jobdanmark.dk public job search API",
"type": "module",
"main": "src/cli.ts",
"bin": {
"jobdanmark": "src/cli.ts"
},
"scripts": {
"start": "bun run src/cli.ts",
"test": "bun test --timeout 30000",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@bunli/core": "latest",
"@bunli/utils": "latest",
"node-html-parser": "^6.1.0",
"zod": "^3.23.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"@types/bun": "latest"
}
}
@@ -0,0 +1,20 @@
import { createCLI } from "@bunli/core"
import { search } from "./commands/search.js"
import { detail } from "./commands/detail.js"
import { categories } from "./commands/categories.js"
import { autocomplete } from "./commands/autocomplete.js"
import { locations } from "./commands/locations.js"
const cli = await createCLI({
name: "jobdanmark-cli",
version: "1.0.0",
description: "CLI for the Jobdanmark.dk public job search API",
})
cli.command(search)
cli.command(detail)
cli.command(categories)
cli.command(autocomplete)
cli.command(locations)
await cli.run()
@@ -0,0 +1,67 @@
export const BASE_URL = "https://jobdanmark.dk"
export async function apiFetch<T>(path: string, params?: Record<string, string>): Promise<T> {
let url = `${BASE_URL}${path}`
if (params && Object.keys(params).length > 0) {
const qs = new URLSearchParams(params)
url += `?${qs.toString()}`
}
const maxRetries = 6
let delay = 500
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url)
if (response.status === 429 || response.status >= 500) {
if (attempt === maxRetries) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
const jitter = Math.floor(Math.random() * 500)
await new Promise((resolve) => setTimeout(resolve, delay + jitter))
delay = Math.min(delay * 2, 5000)
continue
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
return response.json() as Promise<T>
}
throw new Error("API request failed after max retries")
}
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
const url = `${BASE_URL}${path}`
const maxRetries = 6
let delay = 500
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
})
if (response.status === 429 || response.status >= 500) {
if (attempt === maxRetries) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
const jitter = Math.floor(Math.random() * 500)
await new Promise((resolve) => setTimeout(resolve, delay + jitter))
delay = Math.min(delay * 2, 5000)
continue
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
return response.json() as Promise<T>
}
throw new Error("API request failed after max retries")
}
export function writeError(error: string, code: string): void {
process.stderr.write(JSON.stringify({ error, code }) + "\n")
}
export function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim()
}
@@ -0,0 +1,39 @@
import { join } from "path";
const CLI_PATH = join(import.meta.dir, "../src/cli.ts");
export interface CLIResult {
stdout: string;
stderr: string;
exitCode: number;
}
export async function runCLI(args: string[]): Promise<CLIResult> {
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
}
export function parseJSON<T = unknown>(result: CLIResult): T {
if (result.exitCode !== 0) {
throw new Error(
`CLI exited with code ${result.exitCode}. stderr: ${result.stderr}`
);
}
try {
return JSON.parse(result.stdout) as T;
} catch {
throw new Error(
`Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}`
);
}
}
+170
View File
@@ -0,0 +1,170 @@
---
name: jobindex-search
version: 1.0.0
description: >
Make sure to use this skill whenever the user wants to search for jobs in Denmark,
find Danish job listings, look up a specific job posting, or asks anything about
the Danish job market — even if they don't mention jobindex.dk explicitly. Invoke
this skill for questions about open positions, job vacancies, hiring in Denmark,
job opportunities in Danish cities or sectors, or when the user wants to find work
in Denmark. Also trigger for phrases like "find me a job", "are there any jobs for
X in Copenhagen", or "what jobs are available in Aarhus" when the context is Denmark.
Trigger phrases include: jobindex, jobsøgning, job i Danmark, ledige stillinger,
job opslag, find job, stillingopslag, jobannonce, job vacancy denmark, danish jobs,
jobs in denmark, job search denmark, work in denmark, find work denmark, IT jobs
denmark, engineer jobs denmark, developer jobs copenhagen, marketing jobs aarhus,
jobs aarhus, jobs copenhagen, jobs odense, jobs aalborg, job openings denmark,
hiring denmark, job listings denmark, python jobs denmark, grafisk designer job,
data engineer job, softwareudvikler job, full stack developer job danmark.
context: fork
allowed-tools: Bash(bun run skills/jobindex-search/cli/src/cli.ts *)
---
# Jobindex Search Skill
Search live Danish job listings from Jobindex.dk. No authentication needed.
Covers thousands of job postings across all sectors, updated in real time.
## When to use this skill
Invoke this skill when the user wants to:
- Search for job openings in Denmark by keyword, job title, or technology
- Find jobs in a specific Danish city (use keyword with city name, e.g. `python aarhus`)
- Filter jobs by recency (posted today, last 7 days, last 30 days)
- Get the full description of a specific job listing
- Explore the Danish job market for a given profession or skill set
## Commands
### Search job listings
```bash
bun run skills/jobindex-search/cli/src/cli.ts search [flags]
```
Key flags:
- `--query <text>` / `-q <text>` — keyword search (job title, skill, company, city). **Required** for meaningful results.
- `--jobage <days>` — filter by posting age: `1` (today), `7`, `14`, `30`, or `9999` (all, default)
- `--sort <order>``score` (relevance, default) or `date` (newest first)
- `--page <n>` — page number (1-indexed, 20 results per page, fixed)
- `--limit <n>` — cap total results the CLI outputs (client-side)
- `--format json|table|plain`
> **Area note**: The Jobindex API does not support area filtering via params. To find jobs in a specific city, include the city in `--query` (e.g. `--query "data engineer københavn"` or `--query "python aarhus"`).
### Fetch full job detail
```bash
bun run skills/jobindex-search/cli/src/cli.ts detail <id> [--format json|plain]
```
`id` is the job ID from `search` results (e.g. `h1647303`). You may also pass the full Jobindex URL. Returns the full job description, deadline, employment type, hours, and apply link.
---
## How to use effectively
**Always start with `search`.** Pass the job title, skill, or profession as `--query`. Combine with a city name in the query to narrow by location (e.g. `--query "frontend developer odense"`).
**Use `--jobage 7` or `--jobage 1` for fresh listings.** Without it, results include all historical postings.
**Use `--sort date` to see the most recently posted jobs first.** Default `score` sorts by relevance.
**Natural workflow: `search` → `detail`.**
1. Use `search` to find matching jobs and their `id` values.
2. Call `detail <id>` to get the full description, deadline, and apply link.
**Use `--format table` for quick scanning**, `--format json` for data processing, and `--format plain` for reading a single job's full details.
**Pagination**: The API always returns 20 results per page. Use `--page` to navigate pages. Use `--limit` to cap results across one page fetch.
---
## Usage examples
### Find Python jobs posted in the last 7 days
```bash
bun run skills/jobindex-search/cli/src/cli.ts search \
--query python \
--jobage 7 \
--sort date \
--format table
```
### Data engineer jobs in Copenhagen
```bash
bun run skills/jobindex-search/cli/src/cli.ts search \
--query "data engineer københavn" \
--sort score \
--format table
```
### Graphic designer jobs — all time, by relevance
```bash
bun run skills/jobindex-search/cli/src/cli.ts search \
--query "grafisk designer" \
--limit 10 \
--format table
```
### Full-stack developer jobs, page 2
```bash
bun run skills/jobindex-search/cli/src/cli.ts search \
--query "full stack developer" \
--page 2 \
--format json
```
### Jobs posted today across all sectors
```bash
bun run skills/jobindex-search/cli/src/cli.ts search \
--jobage 1 \
--sort date \
--limit 20 \
--format table
```
### Get full details for a specific job
```bash
bun run skills/jobindex-search/cli/src/cli.ts detail h1647303 --format plain
```
### Marketing jobs in Aarhus
```bash
bun run skills/jobindex-search/cli/src/cli.ts search \
--query "marketing aarhus" \
--jobage 30 \
--sort date \
--format table
```
---
## Output formats
| Format | Best for |
|--------|----------|
| `json` | Default — programmatic use, data processing, passing IDs to `detail` |
| `table` | Quick human-readable overview and scanning |
| `plain` | Reading a single job's full detail (`detail` command) |
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
---
## Notes
- All data is from the public `jobindex.dk` API — no credentials required.
- Page size is fixed at 20 results per page (Jobindex API limitation).
- Area/region filtering via API params does not work — include city names in `--query` instead.
- `--jobage 9999` is the default and includes all postings regardless of age.
- Total count in `meta.total` uses Danish dot-thousands notation internally (e.g. `18.903`) — the CLI normalizes this to a plain integer.
- Job IDs are string-prefixed (e.g. `h1647303`) — pass them as-is to `detail`.
@@ -0,0 +1,230 @@
# jobindex-cli
CLI for searching jobs on [Jobindex.dk](https://www.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
```bash
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`
```bash
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
```bash
# 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
```json
{
"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.903``18903`).
> **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}`
```bash
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
```bash
# 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
```json
{
"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`:
```json
{ "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:
```html
<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.
@@ -0,0 +1,25 @@
{
"name": "jobindex-cli",
"version": "1.0.0",
"description": "CLI for searching jobs on Jobindex.dk",
"type": "module",
"main": "src/cli.ts",
"bin": {
"jobindex": "src/cli.ts"
},
"scripts": {
"start": "bun run src/cli.ts",
"test": "bun test --timeout 30000",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@bunli/core": "latest",
"@bunli/utils": "latest",
"node-html-parser": "^6.1.13",
"zod": "^3.23.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"@types/bun": "latest"
}
}
@@ -0,0 +1,14 @@
import { createCLI } from "@bunli/core"
import { search } from "./commands/search.js"
import { detail } from "./commands/detail.js"
const cli = await createCLI({
name: "jobindex-cli",
version: "0.1.0",
description: "CLI for searching jobs on Jobindex.dk",
})
cli.command(search)
cli.command(detail)
await cli.run()
@@ -0,0 +1,197 @@
export const BASE_URL = "https://www.jobindex.dk"
export function writeError(error: string, code: string): void {
process.stderr.write(JSON.stringify({ error, code }) + "\n")
}
export async function apiFetch<T>(path: string, params?: Record<string, string>): Promise<T> {
let url = `${BASE_URL}${path}`
if (params && Object.keys(params).length > 0) {
const qs = new URLSearchParams(params)
url += `?${qs.toString()}`
}
const maxRetries = 6
let delay = 500
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url)
if (response.status === 429 || response.status >= 500) {
if (attempt === maxRetries) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
const jitter = Math.floor(Math.random() * 500)
await new Promise((resolve) => setTimeout(resolve, delay + jitter))
delay = Math.min(delay * 2, 5000)
continue
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
return response.json() as Promise<T>
}
throw new Error("API request failed after max retries")
}
export async function htmlFetch(url: string): Promise<string> {
const maxRetries = 6
let delay = 500
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, {
headers: {
"User-Agent": "Mozilla/5.0 (compatible; jobindex-cli/1.0)",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "da,en;q=0.9",
},
redirect: "follow",
})
if (response.status === 429 || response.status >= 500) {
if (attempt === maxRetries) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
const jitter = Math.floor(Math.random() * 500)
await new Promise((resolve) => setTimeout(resolve, delay + jitter))
delay = Math.min(delay * 2, 5000)
continue
}
if (response.status === 404) {
throw new Error(`Job not found`)
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
return response.text()
}
throw new Error("Request failed after max retries")
}
export interface JobCard {
id: string
title: string
company: string | null
companyUrl: string | null
location: string | null
date: string | null
url: string
description: string | null
}
/**
* Decode HTML entities in text
*/
function decodeHtmlEntities(text: string): string {
return text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
.replace(/&nbsp;/g, " ")
}
/**
* Strip HTML tags from text
*/
function stripTags(html: string): string {
return html.replace(/<[^>]+>/g, "").trim()
}
/**
* Parse job cards from result_list_box_html using regex.
* node-html-parser has nesting bugs with this specific HTML structure
* (unclosed tags inside buttons cause incorrect DOM tree).
* Regex parsing is more reliable for this specific HTML format.
*/
export function parseJobCards(html: string): JobCard[] {
const results: JobCard[] = []
// Split HTML by jobad-wrapper to get individual card HTML chunks
const wrapperPattern = /<div[^>]+id="jobad-wrapper-(h\d+|r\d+)"[^>]*>([\s\S]*?)(?=<div[^>]+id="jobad-wrapper-|$)/g
let match: RegExpExecArray | null
while ((match = wrapperPattern.exec(html)) !== null) {
const id = match[1]
const cardHtml = match[2]
// Extract title: look for <h4>...<a|A href="...">Title</a>...</h4>
const titleMatch = cardHtml.match(/<h4[^>]*>[\s\S]*?<[Aa][^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/[Aa]>/i)
if (!titleMatch) continue
const rawTitle = stripTags(titleMatch[2])
const title = decodeHtmlEntities(rawTitle)
if (!title) continue
// Determine URL: prefer jobindex.dk /jobannonce/ URL, fallback to constructed URL
let url: string
const jobannonce = cardHtml.match(/href="(https:\/\/www\.jobindex\.dk\/jobannonce\/[^"]+)"/)
if (jobannonce) {
url = jobannonce[1]
} else {
// Construct canonical URL from ID
url = `${BASE_URL}/jobannonce/${id}`
}
// Extract company: <a ...> inside jix-toolbar-top__company
let company: string | null = null
let companyUrl: string | null = null
const companySection = cardHtml.match(/class="jix-toolbar-top__company"[^>]*>([\s\S]*?)<\/div>/i)
if (companySection) {
const companyLinkMatch = companySection[1].match(/<[Aa][^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/[Aa]>/i)
if (companyLinkMatch) {
company = decodeHtmlEntities(stripTags(companyLinkMatch[2])) || null
companyUrl = companyLinkMatch[1] || null
}
}
// Extract location: <span class="jix_robotjob--area">Location</span>
const locMatch = cardHtml.match(/<span[^>]+class="jix_robotjob--area"[^>]*>([\s\S]*?)<\/span>/i)
const location = locMatch ? decodeHtmlEntities(stripTags(locMatch[1])) || null : null
// Extract date: <time datetime="YYYY-MM-DD">
const dateMatch = cardHtml.match(/<time[^>]+datetime="([^"]+)"/)
const date = dateMatch ? dateMatch[1] : null
// Extract description: first <p class="..."> or first standalone <p> (not in toolbar)
// Skip the toolbar/menu section and look for the description paragraph
let description: string | null = null
const innerSection = cardHtml.match(/class="PaidJob-inner"[^>]*>([\s\S]*?)(?:<\/div>\s*<\/div>|$)/i) ||
cardHtml.match(/class="jix_robotjob-inner"[^>]*>([\s\S]*?)(?:<\/div>\s*<\/div>|$)/i)
if (innerSection) {
const pMatch = innerSection[1].match(/<p[^>]*>([\s\S]*?)<\/p>/i)
if (pMatch) {
const text = decodeHtmlEntities(stripTags(pMatch[1]))
description = text.length > 0 ? text.substring(0, 300) : null
}
} else {
// Fallback: look for p after the jobannonce link
const pMatches = [...cardHtml.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)]
for (const pm of pMatches) {
const text = decodeHtmlEntities(stripTags(pm[1]))
if (text.length > 20) {
description = text.substring(0, 300)
break
}
}
}
results.push({
id,
title,
company: company || null,
companyUrl: companyUrl || null,
location: location || null,
date: date || null,
url,
description: description || null,
})
}
return results
}
export function parseHitCount(html: string): number {
const match = html.match(/af <strong>([\d.]+)<\/strong>/)
if (!match) return 0
const numStr = match[1].replace(/\./g, "")
return parseInt(numStr, 10) || 0
}
@@ -0,0 +1,39 @@
import { join } from "path";
const CLI_PATH = join(import.meta.dir, "../src/cli.ts");
export interface CLIResult {
stdout: string;
stderr: string;
exitCode: number;
}
export async function runCLI(args: string[]): Promise<CLIResult> {
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
}
export function parseJSON<T = unknown>(result: CLIResult): T {
if (result.exitCode !== 0) {
throw new Error(
`CLI exited with code ${result.exitCode}. stderr: ${result.stderr}`
);
}
try {
return JSON.parse(result.stdout) as T;
} catch {
throw new Error(
`Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}`
);
}
}
@@ -0,0 +1,118 @@
# Jobindex URL Reference
## Base URL
```
https://www.jobindex.dk/jobsoegning
```
## Full URL Pattern
```
https://www.jobindex.dk/jobsoegning/{category-group}/{category-slug}/{area-slug}?q={query}&jobage={days}&page={num}
```
All path segments and query params are optional. Category MUST come before area in the path.
## Path Segments
### Areas (geography)
Area slug goes at the end of the path:
| Area | Slug |
|------|------|
| Storkøbenhavn | `storkoebenhavn` |
| Københavnsområdet | `storkoebenhavn` |
| Nordsjælland | `nordsjaelland` |
| Sjælland | `sjaelland` |
| Fyn | `fyn` |
| Nordjylland | `nordjylland` |
| Midtjylland | `midtjylland` |
| Sydjylland | `sydjylland` |
| Bornholm | `bornholm` |
**Note:** If the exact slug is unknown, use the Geografi filter UI instead:
1. Click the "Geografi" button
2. Click "Tilføj område"
3. Type city/region name in the textbox
4. Select from autocomplete (treeitem)
5. Click "Vis X job" button
6. Note the URL path that results
### Categories
Category uses a two-part path: `{group}/{slug}`:
| Category | Path |
|----------|------|
| IT-drift og support | `it/itdrift` |
**Note:** Category slugs are not fully mapped. To discover a category slug:
1. Click the "Kategorier" button
2. Type category name in the search field
3. Select from autocomplete (treeitem)
4. Click "Vis X job" button
5. Note the URL path that results
## Query Parameters
| Parameter | Description | Examples |
|-----------|-------------|----------|
| `q` | Search query | `q=data+engineer` (broad), `q=%27data+engineer%27` (exact match) |
| `jobage` | Max age in days | `jobage=1` (today), `jobage=3`, `jobage=7`, `jobage=30` |
| `page` | Page number (1-indexed) | `page=1`, `page=2` |
## Examples
```bash
# Basic keyword search
playwright-cli goto "https://www.jobindex.dk/jobsoegning?q=python+developer"
# Exact match search (single quotes around query)
playwright-cli goto "https://www.jobindex.dk/jobsoegning?q=%27python+developer%27"
# Search in Storkøbenhavn area
playwright-cli goto "https://www.jobindex.dk/jobsoegning/storkoebenhavn?q=python+developer"
# Search with category + area
playwright-cli goto "https://www.jobindex.dk/jobsoegning/it/itdrift/storkoebenhavn?q=data+engineer"
# Last 7 days only
playwright-cli goto "https://www.jobindex.dk/jobsoegning?q=data+engineer&jobage=7"
# Page 2 of results
playwright-cli goto "https://www.jobindex.dk/jobsoegning?q=data+engineer&page=2"
# Everything combined
playwright-cli goto "https://www.jobindex.dk/jobsoegning/storkoebenhavn?q=data+engineer&jobage=7&page=1"
```
## Filters Available via UI Only
These filters require clicking through the filter panel (not URL-constructable):
**Ansættelsestype (Employment type):**
Fastansættelse, Tidsbegrænset, Studiejob, Graduate/trainee, Freelance, etc.
**Arbejdstid (Working hours):**
Fuldtid, Deltid
**Hjemmearbejde (Remote work):**
Muligt, Tilbydes ikke, 100% hjemmearbejde
To use these: click "Filtre" button → check desired options → click "Vis X job".
## Job Card Extraction
CSS selectors for job cards: `div.PaidJob, div.jix_robotjob`
Each card contains:
- Title: `h4 a` (text + href)
- Company: first `a` link text
- Location: div after h4 (clean by removing "Se rejsetid")
- Posted date: `time` element (format: DD-MM-YYYY)
- Description: `p` elements
- Job URL: `h4 a` href (may be external or jobindex-hosted)
~20 results per page.
+205
View File
@@ -0,0 +1,205 @@
---
name: jobnet-search
version: 1.0.0
description: >
Make sure to use this skill whenever the user mentions anything related to Danish
job searching, job listings, job vacancies, employment opportunities in Denmark, or
the Danish government job portal — even if they don't mention jobnet.dk explicitly.
Also invoke this skill for questions about specific job titles, occupations, employers,
or regions in a Danish employment context. This skill covers the official Danish
public job portal operated by STAR (Styrelsen for Arbejdsmarked og Rekruttering).
Trigger phrases include: danish jobs, danish job search, jobnet, jobnet.dk, find job
denmark, danish employment, job i danmark, job på jobnet, offentlige job, stillinger
i det offentlige, public sector jobs denmark, government jobs denmark, STAR jobs,
job ledige stillinger, ledig stilling, søg job, job opslag, job vacancy denmark,
stillingopslag, jobopslag, sygepleje job, ingeniør job, lærer job, pædagog job,
it-job denmark, jobs in copenhagen, jobs in aarhus, jobs in odense, deltidsjob,
fuldtidsjob, fastansættelse, tidsbegrænset ansættelse, fleksjob, sygeplejerske job,
social worker job denmark, occupation search denmark, esco occupation, job deadline,
ansøgningsfrist, søg efter job, full time job denmark, part time job denmark.
context: fork
allowed-tools: Bash(bun run skills/jobnet-search/cli/src/cli.ts *)
---
# Jobnet-Search Skill
Access live Danish job listings from the Jobnet.dk public API. No authentication needed.
Jobnet is operated by STAR (Styrelsen for Arbejdsmarked og Rekruttering) and is Denmark's
official government job portal — covering public sector positions as well as many private
sector listings. Approximately 21,000+ active jobs at any time.
## When to use this skill
Invoke this skill when the user wants to:
- Search for job openings in Denmark by keyword, title, or employer
- Filter jobs by region, work hours (full/part time), or employment duration (permanent/temporary)
- Find jobs near a specific postal code within a given radius
- Get full details for a specific job ad including description, contact persons, and application URL
- Discover occupation types and ESCO categories for more precise job filtering
- Explore autocomplete suggestions for Danish job titles or keywords
- Find jobs in the public sector, healthcare, IT, education, or any other Danish industry
## Commands
### Search for job ads
```bash
bun run skills/jobnet-search/cli/src/cli.ts search [flags]
```
Key flags:
- `--search-string <text>` — keyword search, e.g. `sygeplejerske`, `ingeniør`, `pædagog`
- `--region <region>``HovedstadenOgBornholm`, `Midtjylland`, `Syddanmark`, `OevrigeSjaelland`, `Nordjylland`
- `--postal-code <code>` — postal code for radius-based search, e.g. `2100`
- `--radius <km>` — radius in km from postal code (default: `50`)
- `--work-hours <type>``FullTime` or `PartTime`
- `--duration <type>``Permanent` or `Temporary`
- `--job-type <type>``Ordinaert`, `Efterloenner`, `Foertidspension`
- `--occupation-area <id>` — occupation area identifier, e.g. `10000`
- `--occupation-group <id>` — occupation group identifier, e.g. `10060`
- `--order <type>``PublicationDate` (default), `BestMatch`, `ApplicationDate`
- `--page / --per-page / --limit`
- `--format json|table|plain`
### Full job ad detail
```bash
bun run skills/jobnet-search/cli/src/cli.ts detail <jobAdId> [--format json|plain]
```
`jobAdId` is the UUID from `search` results (the `jobAdId` field). Returns the complete job
description, contact persons, application deadline, employer details, and direct application URL.
### Search occupation types
```bash
bun run skills/jobnet-search/cli/src/cli.ts occupations --search-string <text> [--per-page <n>]
```
Use this to discover ESCO occupation identifiers before passing them to `search` with
`--occupation-area` or `--occupation-group`.
### Typeahead suggestions
```bash
bun run skills/jobnet-search/cli/src/cli.ts suggestions --query <text> [--limit <n>]
```
Returns Danish job title autocomplete strings. Useful for exploring valid Danish
job titles before constructing a `search` query.
---
## How to use effectively
**Discover occupations first.** Use `occupations` or `suggestions` to find the right Danish
term or ESCO identifier before running a `search`:
```bash
bun run skills/jobnet-search/cli/src/cli.ts suggestions --query "syge"
bun run skills/jobnet-search/cli/src/cli.ts occupations --search-string "sygeplejerske"
```
**Natural workflow: `search` → `detail`.**
1. Use `search` to get a list of matching jobs with their `jobAdId`.
2. Call `detail <jobAdId>` to get the full job description, contact persons, and direct application link.
**Use `--format table` for comparisons**, `--format json` for data processing, and
`--format plain` for single-record detail views (strips HTML from job body).
**Pagination**: `--per-page` controls server-side results per page. `--limit` caps what the
CLI outputs. Use `--page` + `--per-page` to iterate through large result sets.
**Geographic search modes:**
- Use `--region` for broad regional filtering (e.g. all of Midtjylland)
- Use `--postal-code` + `--radius` for jobs near a specific location
- Do not combine `--region` and `--postal-code` in the same query
**Order matters for intent:**
- `PublicationDate` — newest postings first (default, good for "what's new")
- `BestMatch` — relevance score (best when `--search-string` is provided)
- `ApplicationDate` — earliest deadline first (good for urgent applications)
---
## Usage examples
### Jobs in Copenhagen area
```bash
bun run skills/jobnet-search/cli/src/cli.ts search \
--region HovedstadenOgBornholm \
--per-page 10 \
--format table
```
### Nurse jobs nationwide
```bash
bun run skills/jobnet-search/cli/src/cli.ts search \
--search-string "sygeplejerske" \
--work-hours FullTime \
--duration Permanent \
--order BestMatch \
--per-page 10 \
--format table
```
### IT jobs near Aarhus within 30km
```bash
bun run skills/jobnet-search/cli/src/cli.ts search \
--search-string "udvikler" \
--postal-code 8000 \
--radius 30 \
--work-hours FullTime \
--format table
```
### Full details of a job ad
```bash
bun run skills/jobnet-search/cli/src/cli.ts detail 9ef43bce-d82b-4ea1-a098-7ff6520f99be --format plain
```
### Jobs sorted by application deadline (urgent first)
```bash
bun run skills/jobnet-search/cli/src/cli.ts search \
--search-string "pædagog" \
--region OevrigeSjaelland \
--order ApplicationDate \
--per-page 10
```
### Discover occupation terms
```bash
bun run skills/jobnet-search/cli/src/cli.ts suggestions --query "ingeniør" --limit 5
bun run skills/jobnet-search/cli/src/cli.ts occupations --search-string "lærer" --per-page 5
```
---
## Output formats
| Format | Best for |
|--------|----------|
| `json` | Default — programmatic use, data processing, passing IDs between commands |
| `table` | Quick human-readable overviews and comparisons |
| `plain` | Single-record detail views (`detail`), strips HTML from job descriptions |
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
---
## Notes
- All data is from the public `jobnet.dk/bff` REST API — no credentials required.
- The API requires the `x-csrf: 1` header; the CLI adds this automatically.
- Pagination is 1-indexed (`--page 1` is the first page).
- `search` results omit the HTML job description — use `detail` to get it.
- `detail --format plain` strips HTML tags for readable text output.
- Job ad detail pages on jobnet.dk: `https://jobnet.dk/job/{jobAdId}`
- `suggestions` is tuned for Danish job titles — English terms may return empty results.
+373
View File
@@ -0,0 +1,373 @@
# jobnet-cli
CLI for the [Jobnet.dk](https://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
```bash
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`
```bash
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
```bash
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
```json
{
"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}`
```bash
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
```bash
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
```json
{
"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`
```bash
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
```bash
bun run src/cli.ts occupations --search-string "sygeplejerske" --per-page 5
```
### Response shape
```json
[
{
"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`
```bash
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
```bash
bun run src/cli.ts suggestions --query "syge"
bun run src/cli.ts suggestions --query "ingeniør" --limit 5
```
### Response shape
```json
[
"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`:
```json
{ "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.
@@ -0,0 +1,24 @@
{
"name": "jobnet-cli",
"version": "1.0.0",
"description": "CLI for the Jobnet.dk Danish government job portal API",
"type": "module",
"main": "src/cli.ts",
"bin": {
"jobnet": "src/cli.ts"
},
"scripts": {
"start": "bun run src/cli.ts",
"test": "bun test --timeout 30000",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@bunli/core": "latest",
"@bunli/utils": "latest",
"zod": "^3.23.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"@types/bun": "latest"
}
}
@@ -0,0 +1,18 @@
import { createCLI } from "@bunli/core"
import { search } from "./commands/search.js"
import { detail } from "./commands/detail.js"
import { occupations } from "./commands/occupations.js"
import { suggestions } from "./commands/suggestions.js"
const cli = await createCLI({
name: "jobnet-cli",
version: "0.1.0",
description: "CLI for the Jobnet.dk Danish government job portal API",
})
cli.command(search)
cli.command(detail)
cli.command(occupations)
cli.command(suggestions)
await cli.run()
@@ -0,0 +1,54 @@
export const BASE_URL = "https://jobnet.dk/bff"
export async function apiFetch<T>(path: string, params?: Record<string, string>): Promise<T> {
let url = `${BASE_URL}${path}`
if (params && Object.keys(params).length > 0) {
const qs = new URLSearchParams(params)
url += `?${qs.toString()}`
}
const maxRetries = 6
let delay = 500
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, {
headers: {
"x-csrf": "1",
},
})
if (response.status === 429 || response.status >= 500) {
if (attempt === maxRetries) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
// Add jitter to spread out retries: base delay + random 0-500ms
const jitter = Math.floor(Math.random() * 500)
await new Promise((resolve) => setTimeout(resolve, delay + jitter))
delay = Math.min(delay * 2, 5000)
continue
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
return response.json() as Promise<T>
}
throw new Error("API request failed after max retries")
}
export function writeError(error: string, code: string): void {
process.stderr.write(JSON.stringify({ error, code }) + "\n")
}
export function stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim()
}
@@ -0,0 +1,39 @@
import { join } from "path";
const CLI_PATH = join(import.meta.dir, "../src/cli.ts");
export interface CLIResult {
stdout: string;
stderr: string;
exitCode: number;
}
export async function runCLI(args: string[]): Promise<CLIResult> {
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
}
export function parseJSON<T = unknown>(result: CLIResult): T {
if (result.exitCode !== 0) {
throw new Error(
`CLI exited with code ${result.exitCode}. stderr: ${result.stderr}`
);
}
try {
return JSON.parse(result.stdout) as T;
} catch {
throw new Error(
`Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}`
);
}
}