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,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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user