mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 08:36:25 +00:00
Initial release: AI-powered job application framework
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
Mads Lorentzen
co-authored by
Claude Opus 4.6
commit
c66d599d75
@@ -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`.
|
||||
@@ -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 300–500ms 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
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
|
||||
.replace(/ /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.
|
||||
@@ -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.
|
||||
@@ -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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user