Initial release: AI-powered job application framework

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
AI Job Search
2026-03-23 08:34:06 +01:00
committed by Mads Lorentzen
co-authored by Claude Opus 4.6
commit c66d599d75
73 changed files with 6539 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
---
name: jobnet-search
version: 1.0.0
description: >
Make sure to use this skill whenever the user mentions anything related to Danish
job searching, job listings, job vacancies, employment opportunities in Denmark, or
the Danish government job portal — even if they don't mention jobnet.dk explicitly.
Also invoke this skill for questions about specific job titles, occupations, employers,
or regions in a Danish employment context. This skill covers the official Danish
public job portal operated by STAR (Styrelsen for Arbejdsmarked og Rekruttering).
Trigger phrases include: danish jobs, danish job search, jobnet, jobnet.dk, find job
denmark, danish employment, job i danmark, job på jobnet, offentlige job, stillinger
i det offentlige, public sector jobs denmark, government jobs denmark, STAR jobs,
job ledige stillinger, ledig stilling, søg job, job opslag, job vacancy denmark,
stillingopslag, jobopslag, sygepleje job, ingeniør job, lærer job, pædagog job,
it-job denmark, jobs in copenhagen, jobs in aarhus, jobs in odense, deltidsjob,
fuldtidsjob, fastansættelse, tidsbegrænset ansættelse, fleksjob, sygeplejerske job,
social worker job denmark, occupation search denmark, esco occupation, job deadline,
ansøgningsfrist, søg efter job, full time job denmark, part time job denmark.
context: fork
allowed-tools: Bash(bun run skills/jobnet-search/cli/src/cli.ts *)
---
# Jobnet-Search Skill
Access live Danish job listings from the Jobnet.dk public API. No authentication needed.
Jobnet is operated by STAR (Styrelsen for Arbejdsmarked og Rekruttering) and is Denmark's
official government job portal — covering public sector positions as well as many private
sector listings. Approximately 21,000+ active jobs at any time.
## When to use this skill
Invoke this skill when the user wants to:
- Search for job openings in Denmark by keyword, title, or employer
- Filter jobs by region, work hours (full/part time), or employment duration (permanent/temporary)
- Find jobs near a specific postal code within a given radius
- Get full details for a specific job ad including description, contact persons, and application URL
- Discover occupation types and ESCO categories for more precise job filtering
- Explore autocomplete suggestions for Danish job titles or keywords
- Find jobs in the public sector, healthcare, IT, education, or any other Danish industry
## Commands
### Search for job ads
```bash
bun run skills/jobnet-search/cli/src/cli.ts search [flags]
```
Key flags:
- `--search-string <text>` — keyword search, e.g. `sygeplejerske`, `ingeniør`, `pædagog`
- `--region <region>``HovedstadenOgBornholm`, `Midtjylland`, `Syddanmark`, `OevrigeSjaelland`, `Nordjylland`
- `--postal-code <code>` — postal code for radius-based search, e.g. `2100`
- `--radius <km>` — radius in km from postal code (default: `50`)
- `--work-hours <type>``FullTime` or `PartTime`
- `--duration <type>``Permanent` or `Temporary`
- `--job-type <type>``Ordinaert`, `Efterloenner`, `Foertidspension`
- `--occupation-area <id>` — occupation area identifier, e.g. `10000`
- `--occupation-group <id>` — occupation group identifier, e.g. `10060`
- `--order <type>``PublicationDate` (default), `BestMatch`, `ApplicationDate`
- `--page / --per-page / --limit`
- `--format json|table|plain`
### Full job ad detail
```bash
bun run skills/jobnet-search/cli/src/cli.ts detail <jobAdId> [--format json|plain]
```
`jobAdId` is the UUID from `search` results (the `jobAdId` field). Returns the complete job
description, contact persons, application deadline, employer details, and direct application URL.
### Search occupation types
```bash
bun run skills/jobnet-search/cli/src/cli.ts occupations --search-string <text> [--per-page <n>]
```
Use this to discover ESCO occupation identifiers before passing them to `search` with
`--occupation-area` or `--occupation-group`.
### Typeahead suggestions
```bash
bun run skills/jobnet-search/cli/src/cli.ts suggestions --query <text> [--limit <n>]
```
Returns Danish job title autocomplete strings. Useful for exploring valid Danish
job titles before constructing a `search` query.
---
## How to use effectively
**Discover occupations first.** Use `occupations` or `suggestions` to find the right Danish
term or ESCO identifier before running a `search`:
```bash
bun run skills/jobnet-search/cli/src/cli.ts suggestions --query "syge"
bun run skills/jobnet-search/cli/src/cli.ts occupations --search-string "sygeplejerske"
```
**Natural workflow: `search` → `detail`.**
1. Use `search` to get a list of matching jobs with their `jobAdId`.
2. Call `detail <jobAdId>` to get the full job description, contact persons, and direct application link.
**Use `--format table` for comparisons**, `--format json` for data processing, and
`--format plain` for single-record detail views (strips HTML from job body).
**Pagination**: `--per-page` controls server-side results per page. `--limit` caps what the
CLI outputs. Use `--page` + `--per-page` to iterate through large result sets.
**Geographic search modes:**
- Use `--region` for broad regional filtering (e.g. all of Midtjylland)
- Use `--postal-code` + `--radius` for jobs near a specific location
- Do not combine `--region` and `--postal-code` in the same query
**Order matters for intent:**
- `PublicationDate` — newest postings first (default, good for "what's new")
- `BestMatch` — relevance score (best when `--search-string` is provided)
- `ApplicationDate` — earliest deadline first (good for urgent applications)
---
## Usage examples
### Jobs in Copenhagen area
```bash
bun run skills/jobnet-search/cli/src/cli.ts search \
--region HovedstadenOgBornholm \
--per-page 10 \
--format table
```
### Nurse jobs nationwide
```bash
bun run skills/jobnet-search/cli/src/cli.ts search \
--search-string "sygeplejerske" \
--work-hours FullTime \
--duration Permanent \
--order BestMatch \
--per-page 10 \
--format table
```
### IT jobs near Aarhus within 30km
```bash
bun run skills/jobnet-search/cli/src/cli.ts search \
--search-string "udvikler" \
--postal-code 8000 \
--radius 30 \
--work-hours FullTime \
--format table
```
### Full details of a job ad
```bash
bun run skills/jobnet-search/cli/src/cli.ts detail 9ef43bce-d82b-4ea1-a098-7ff6520f99be --format plain
```
### Jobs sorted by application deadline (urgent first)
```bash
bun run skills/jobnet-search/cli/src/cli.ts search \
--search-string "pædagog" \
--region OevrigeSjaelland \
--order ApplicationDate \
--per-page 10
```
### Discover occupation terms
```bash
bun run skills/jobnet-search/cli/src/cli.ts suggestions --query "ingeniør" --limit 5
bun run skills/jobnet-search/cli/src/cli.ts occupations --search-string "lærer" --per-page 5
```
---
## Output formats
| Format | Best for |
|--------|----------|
| `json` | Default — programmatic use, data processing, passing IDs between commands |
| `table` | Quick human-readable overviews and comparisons |
| `plain` | Single-record detail views (`detail`), strips HTML from job descriptions |
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
---
## Notes
- All data is from the public `jobnet.dk/bff` REST API — no credentials required.
- The API requires the `x-csrf: 1` header; the CLI adds this automatically.
- Pagination is 1-indexed (`--page 1` is the first page).
- `search` results omit the HTML job description — use `detail` to get it.
- `detail --format plain` strips HTML tags for readable text output.
- Job ad detail pages on jobnet.dk: `https://jobnet.dk/job/{jobAdId}`
- `suggestions` is tuned for Danish job titles — English terms may return empty results.
+373
View File
@@ -0,0 +1,373 @@
# jobnet-cli
CLI for the [Jobnet.dk](https://jobnet.dk) Danish government job portal API.
**Base URL**: `https://jobnet.dk/bff`
**Authentication**: No credentials required. All public endpoints only need the `x-csrf: 1` request header.
**Format**: All responses are JSON.
---
## Installation
```bash
cd skills/jobnet-search/cli
bun install
```
---
## Commands
| Command | Description |
|---------|-------------|
| `search` | Search for job ads with filters |
| `detail` | Full detail for a single job ad |
| `occupations` | Search occupation types (for building filters) |
| `suggestions` | Typeahead suggestions for job title / keyword search |
All commands accept `--format json|table|plain` (default: `json`).
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
---
## Regions
| Value | Danish region |
|-------|--------------|
| `HovedstadenOgBornholm` | Hovedstaden og Bornholm |
| `Midtjylland` | Midtjylland |
| `Syddanmark` | Syddanmark |
| `OevrigeSjaelland` | Øvrige Sjælland |
| `Nordjylland` | Nordjylland |
---
## Order types
| Value | Description |
|-------|-------------|
| `PublicationDate` | Newest postings first (default) |
| `BestMatch` | Relevance score (requires `--search-string`) |
| `ApplicationDate` | Earliest deadline first |
---
## `search` — Search for job ads
**Endpoint**: `GET /FindJob/Search`
```bash
bun run src/cli.ts search [flags]
```
### Flags
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--search-string` | string | — | Free-text keyword search (job title, skills, employer) |
| `--region` | string | — | One region value (see Regions table) |
| `--postal-code` | string | — | Postal code for radius search, e.g. `2100` |
| `--radius` | number | `50` | Radius in km from postal code (requires `--postal-code`) |
| `--work-hours` | string | — | `FullTime` or `PartTime` |
| `--duration` | string | — | `Permanent` or `Temporary` |
| `--job-type` | string | — | Announcement type: `Ordinaert`, `Efterloenner`, `Foertidspension` |
| `--occupation-area` | string | — | Occupation area identifier, e.g. `10000` |
| `--occupation-group` | string | — | Occupation group identifier, e.g. `10060` |
| `--page` | number | `1` | Page number (1-indexed) |
| `--per-page` | number | `10` | Results per page |
| `--limit` | number | — | Cap total results returned by CLI |
| `--order` | string | `PublicationDate` | Sort order (see Order types table) |
| `--format` | string | `json` | Output format: `json`, `table`, `plain` |
### Example
```bash
bun run src/cli.ts search \
--search-string "sygeplejerske" \
--region HovedstadenOgBornholm \
--work-hours FullTime \
--duration Permanent \
--per-page 5 \
--format table
bun run src/cli.ts search \
--postal-code 8000 \
--radius 25 \
--per-page 10
```
### Response shape
```json
{
"meta": {
"totalJobAdCount": 21452,
"pageNumber": 1,
"resultsPerPage": 10,
"searchString": "developer"
},
"facets": {
"regions": [
{ "type": "HovedstadenOgBornholm", "jobAdCount": 6642 }
],
"workHours": [
{ "type": "FullTime", "jobAdCount": 17919 },
{ "type": "PartTime", "jobAdCount": 3533 }
],
"employmentDurations": [
{ "type": "Permanent", "jobAdCount": 18533 },
{ "type": "Temporary", "jobAdCount": 2919 }
],
"occupationAreas": [
{ "identifier": "10000", "jobAdCount": 3235 }
],
"countries": [
{ "label": "Danmark", "identifier": "DK", "jobAdCount": 21164 }
]
},
"results": [
{
"jobAdId": "9ef43bce-d82b-4ea1-a098-7ff6520f99be",
"title": "Akademisk medarbejder med interesse for arbejdsmiljø og uddannelse",
"hiringOrgName": "Region Midtjylland",
"occupation": "Personalekonsulent",
"municipality": "Viborg",
"postalCode": 8800,
"postalDistrictName": "Viborg",
"country": "Danmark",
"publicationDate": "2026-03-13T00:00:00+01:00",
"applicationDeadline": "2026-04-05T21:59:00+02:00",
"applicationDeadlineStatus": "ExpirationDate",
"workHourPartTime": false,
"isExternal": false,
"hasLogo": true,
"logoUrl": "/bff/SharedComponents/JobAdCard/CompanyLogo/ByJobAdId/9ef43bce-d82b-4ea1-a098-7ff6520f99be",
"cvr": "29190925",
"workPlaceAddress": "",
"conceptUriDa": "http://data.star.dk/esco/occupation/426e017f-ebe5-4bea-b1eb-7d2d5ab3c6db",
"isSeen": false,
"isFavorite": false
}
]
}
```
> **Note**: The `description` field (raw HTML) is intentionally omitted from `search` results for brevity. Use `detail` to retrieve the full job description.
> **Note**: `resultsPerPage` and `pageNumber` must always be provided — omitting them while also providing `searchString` causes the API to return error 1014 ("Fejl i formatering af inputs").
---
## `detail` — Full job ad detail
**Endpoint**: `GET /FindJob/JobAdDetails/{id}`
```bash
bun run src/cli.ts detail <id> [--format json|plain]
```
The `id` is the `jobAdId` UUID from `search` results.
By default the CLI passes `incrementViews=false` to avoid polluting view counts.
### Example
```bash
bun run src/cli.ts detail 9ef43bce-d82b-4ea1-a098-7ff6520f99be
bun run src/cli.ts detail 9ef43bce-d82b-4ea1-a098-7ff6520f99be --format plain
```
### Response shape
```json
{
"id": "9ef43bce-d82b-4ea1-a098-7ff6520f99be",
"title": "Akademisk medarbejder med interesse for arbejdsmiljø og uddannelse",
"body": "<p>Full HTML job description...</p>",
"publicationDateTime": "2026-03-13T08:06:58.0578635+01:00",
"unpublicationDateTime": "2026-04-05T21:59:00+02:00",
"approvalStatus": "Godkendt",
"views": 2,
"createdDateTime": "2026-03-13T08:06:58.3936145+01:00",
"updatedDateTime": "2026-03-13T08:06:58.3936145+01:00",
"isAnonymousEmployer": false,
"hasLogo": true,
"logoUrl": "/bff/SharedComponents/JobAdCard/CompanyLogo/ByJobAdId/9ef43bce-d82b-4ea1-a098-7ff6520f99be",
"employer": {
"cvrNumber": "29190925",
"pNumber": "1003367314",
"name": "Region Midtjylland",
"hasCompanyLogo": true
},
"job": {
"type": "Ordinaert",
"address": {
"streetName": "Heibergs Alle 5A",
"city": "Viborg",
"postalCode": "8800",
"municipality": "Viborg",
"countryCode": "DK",
"countryName": "Danmark"
},
"noFixedWorkplace": false,
"isLimitedPeriod": false,
"isDisabilityFriendly": false,
"isPartTime": false,
"employmentDate": "2026-06-01T00:00:00+02:00",
"conceptUriDa": "http://data.star.dk/esco/occupation/426e017f-ebe5-4bea-b1eb-7d2d5ab3c6db",
"preferredLabelDa": "Personalekonsulent",
"driversLicenses": [],
"classifications": [],
"shifts": [],
"isFavorite": false
},
"application": {
"deadlineDate": "2026-04-05T21:59:00+02:00",
"availablePositions": 1,
"contactPersons": [
{
"firstNames": "Jane",
"lastName": "Doe",
"phoneNumber": "+4512345678"
}
],
"url": "https://midtjob.dk/ad/...",
"urlText": "",
"isApplicationDeadlineASAP": false
},
"organisationTypeId": 24,
"user": "Emply 31430747"
}
```
> **Note**: `body` contains raw HTML. In `--format plain` output the CLI strips HTML tags to produce readable text.
> **Note**: `application.url` may be empty for some job ads — the employer may only accept applications through Jobnet's internal system.
---
## `occupations` — Search occupation types
**Endpoint**: `GET /OccupationSearch`
```bash
bun run src/cli.ts occupations --search-string <text> [flags]
```
Use this command to find occupation identifiers (ESCO concept URIs) to pass as filters to `search`.
### Flags
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--search-string` | string | **required** | Search term for occupation, e.g. `sygeplejerske` |
| `--per-page` | number | `10` | Max results to return |
| `--format` | string | `json` | Output format: `json`, `table`, `plain` |
### Example
```bash
bun run src/cli.ts occupations --search-string "sygeplejerske" --per-page 5
```
### Response shape
```json
[
{
"conceptUriDa": "http://data.star.dk/esco/occupation/56f5d45c-1234-4321-abcd-000000000000",
"preferredLabelDa": "Sygeplejerske",
"aliases": [
{
"aliasIdentifier": "some-uuid",
"conceptUriDa": "http://data.star.dk/esco/occupation/56f5d45c-1234-4321-abcd-000000000000",
"alternativeLabelDa": "Operationssygeplejerske"
}
]
}
]
```
> **Note**: `conceptUriDa` is the full ESCO concept URI. The UUID portion (last path segment) can be used to build occupation filters for `search`.
---
## `suggestions` — Typeahead suggestions
**Endpoint**: `GET /FindJob/GetTypeaheadSuggestions`
```bash
bun run src/cli.ts suggestions --query <text> [flags]
```
Returns autocomplete strings for the search box — useful for exploring valid Danish job titles before running a `search`.
### Flags
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--query` | string | **required** | Partial search string to complete |
| `--limit` | number | — | Cap number of suggestions returned |
| `--format` | string | `json` | Output format: `json`, `table`, `plain` |
### Example
```bash
bun run src/cli.ts suggestions --query "syge"
bun run src/cli.ts suggestions --query "ingeniør" --limit 5
```
### Response shape
```json
[
"sygepleje",
"Sygeplejerske",
"Sygeplejerske \"Lægeassistent\""
]
```
> **Note**: Suggestions are tuned for Danish job titles. English terms like "developer" may return an empty array.
---
## Error handling
All errors are written to **stderr** in JSON format and exit with code `1`:
```json
{ "error": "Job ad not found", "code": "NOT_FOUND" }
{ "error": "API request failed: 500 Internal Server Error", "code": "API_ERROR" }
{ "error": "--query is required", "code": "MISSING_REQUIRED" }
{ "error": "--search-string is required", "code": "MISSING_REQUIRED" }
```
---
## URL construction
Job ad detail pages on jobnet.dk:
```
https://jobnet.dk/job/{jobAdId}
```
Company logo images (prefix relative logoUrl from API):
```
https://jobnet.dk{logoUrl}
```
Example: `https://jobnet.dk/bff/SharedComponents/JobAdCard/CompanyLogo/ByJobAdId/9ef43bce-d82b-4ea1-a098-7ff6520f99be`
---
## Notes
- All data is from the public `jobnet.dk/bff` REST API — no credentials required.
- The `x-csrf: 1` header must be sent with every request.
- Pagination is 1-indexed (`--page 1` is the first page).
- `search` results intentionally omit the HTML `description` field — use `detail` to fetch it.
- `body` in `detail` responses is raw HTML; use `--format plain` to get stripped text.
- The `occupations` command helps discover ESCO occupation URIs usable as `--occupation-area` / `--occupation-group` seeds for narrowing search results.
@@ -0,0 +1,24 @@
{
"name": "jobnet-cli",
"version": "1.0.0",
"description": "CLI for the Jobnet.dk Danish government job portal API",
"type": "module",
"main": "src/cli.ts",
"bin": {
"jobnet": "src/cli.ts"
},
"scripts": {
"start": "bun run src/cli.ts",
"test": "bun test --timeout 30000",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@bunli/core": "latest",
"@bunli/utils": "latest",
"zod": "^3.23.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"@types/bun": "latest"
}
}
@@ -0,0 +1,18 @@
import { createCLI } from "@bunli/core"
import { search } from "./commands/search.js"
import { detail } from "./commands/detail.js"
import { occupations } from "./commands/occupations.js"
import { suggestions } from "./commands/suggestions.js"
const cli = await createCLI({
name: "jobnet-cli",
version: "0.1.0",
description: "CLI for the Jobnet.dk Danish government job portal API",
})
cli.command(search)
cli.command(detail)
cli.command(occupations)
cli.command(suggestions)
await cli.run()
@@ -0,0 +1,54 @@
export const BASE_URL = "https://jobnet.dk/bff"
export async function apiFetch<T>(path: string, params?: Record<string, string>): Promise<T> {
let url = `${BASE_URL}${path}`
if (params && Object.keys(params).length > 0) {
const qs = new URLSearchParams(params)
url += `?${qs.toString()}`
}
const maxRetries = 6
let delay = 500
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, {
headers: {
"x-csrf": "1",
},
})
if (response.status === 429 || response.status >= 500) {
if (attempt === maxRetries) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
// Add jitter to spread out retries: base delay + random 0-500ms
const jitter = Math.floor(Math.random() * 500)
await new Promise((resolve) => setTimeout(resolve, delay + jitter))
delay = Math.min(delay * 2, 5000)
continue
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
return response.json() as Promise<T>
}
throw new Error("API request failed after max retries")
}
export function writeError(error: string, code: string): void {
process.stderr.write(JSON.stringify({ error, code }) + "\n")
}
export function stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim()
}
@@ -0,0 +1,39 @@
import { join } from "path";
const CLI_PATH = join(import.meta.dir, "../src/cli.ts");
export interface CLIResult {
stdout: string;
stderr: string;
exitCode: number;
}
export async function runCLI(args: string[]): Promise<CLIResult> {
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
}
export function parseJSON<T = unknown>(result: CLIResult): T {
if (result.exitCode !== 0) {
throw new Error(
`CLI exited with code ${result.exitCode}. stderr: ${result.stderr}`
);
}
try {
return JSON.parse(result.stdout) as T;
} catch {
throw new Error(
`Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}`
);
}
}