feat: add skills

This commit is contained in:
Prad Nukala committed 2026-08-26 10:35:34 -04:00
1 parent 6984212b17
commit c50aa92862
150 files changed
+12702

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
---
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
enabled: false # Danish demo portal - ships opt-in; /setup enables it when your market is Denmark, or set true here yourself
allowed-tools: Bash(bun run .agents/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. Uses the RSS feed for search (up to 100 results) and JSON-LD parsing for detailed job information. Jobbank may block automated requests with Cloudflare bot protection; if that happens, report the portal as unavailable and use WebSearch fallback instead of retrying.
## 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 .agents/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 .agents/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 .agents/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 .agents/skills/jobbank-search/cli/src/cli.ts search \
--key "data scientist" \
--location 2 \
--format table
```
### Graduate trainee positions for new graduates
```bash
bun run .agents/skills/jobbank-search/cli/src/cli.ts search \
--type 6 \
--suitable-for 2 \
--format table
```
### Remote IT software jobs
```bash
bun run .agents/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 .agents/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 .agents/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 .agents/skills/jobbank-search/cli/src/cli.ts detail 1234567 --format plain
```
### IT jobs in Aarhus or Copenhagen
```bash
bun run .agents/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. Jobbank may still block automated CLI requests with Cloudflare bot protection; treat that as a portal availability failure and fall back to WebSearch.
- 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`.
+400
View File
@@ -0,0 +1,400 @@
# 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 sent, but Jobbank may still block automated requests with Cloudflare bot protection. In that case the CLI exits with a clear error and callers should use a WebSearch fallback rather than retrying.
**Format**: RSS XML (search), HTML with embedded JSON-LD (detail).
---
## Installation
```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",
"date": "2026-03-02",
"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 |
| `date` | string \| null | Publication date as `YYYY-MM-DD` (derived from `posted`), or `null` if absent |
| `deadline` | string \| null | Application deadline as `YYYY-MM-DD` (converted from the feed's `DD.MM.YYYY`), 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": "Jobbank is blocking automated requests with Cloudflare bot protection. Skip this portal or use the WebSearch fallback.", "code": "API_ERROR" }
{ "error": "No JSON-LD found on job page", "code": "PARSE_ERROR" }
{ "error": "--key or at least one filter is required", "code": "MISSING_REQUIRED" }
```
---
## Implementation notes
### User-Agent
All HTTP requests include a browser User-Agent header:
```
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
```
This is not guaranteed to bypass Cloudflare bot protection. If Jobbank returns a Cloudflare challenge page, the CLI reports that condition and callers should skip the portal or use a WebSearch fallback.
### RSS description parsing
The RSS `<description>` field follows this pattern:
```
Fuldtidsjob, Graduate/trainee hos Novo Nordisk, Bagsværd (Ansøgningsfrist: 12.04.2026)
Fuldtidsjob hos DTU, Lyngby (Ansøgningsfrist: løbende)
```
Parse strategy:
1. Split on ` hos ` — left side is job type(s), right side is `Company, Location (Ansøgningsfrist: Deadline)`
2. From the right side, extract the parenthetical `(Ansøgningsfrist: ...)` for deadline
3. Remaining text is `Company, Location` — split on first `, ` to separate company from location
### JSON-LD extraction
On detail pages, find `<script type="application/ld+json">` containing `"@type": "JobPosting"` and parse the JSON. Map fields:
- `identifier.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).
+25
View File
@@ -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": "0.9.1",
"@bunli/utils": "0.6.0",
"node-html-parser": "^6.1.13",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/bun": "1.3.14",
"typescript": "^5.4.0"
}
}
+45
View File
@@ -0,0 +1,45 @@
import { createCLI } from "@bunli/core"
import { writeError } from "./helpers.js"
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",
})
const commands = [search, detail]
for (const command of commands) {
cli.command(command)
}
// Reject unknown --flags before dispatch. bunli silently discards them, and a
// silently discarded filter changes what the search returns without any error
// (a wrong flag name once returned an entire portal's database as if it
// matched the query). add-portal.md's contract requires a bogus flag to exit 1
// with a JSON error on stderr; this enforces it for the reference CLIs too.
const argv = process.argv.slice(2)
const invoked = commands.find((c) => (c as { name?: string }).name === argv[0])
if (invoked) {
const known = new Set([
...Object.keys((invoked as { options?: Record<string, unknown> }).options ?? {}),
"help",
"version",
])
for (const token of argv.slice(1)) {
if (token === "--") break
if (token.startsWith("--")) {
const flag = token.slice(2).split("=")[0]
if (!known.has(flag)) {
writeError(
`unknown flag --${flag} for '${argv[0]}' - flags are never silently ignored, because a discarded filter changes what the search returns; see --help for the supported flags`,
"UNKNOWN_FLAG",
)
process.exit(1)
}
}
}
}
await cli.run()
+139
View File
@@ -0,0 +1,139 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { fetchWithUA, parseJobPostingJsonLd, writeError, BASE_URL } from "../helpers.js"
export const detail = defineCommand({
name: "detail",
description: "Full detail for a single job posting",
options: {
format: option(z.enum(["json", "plain"]).default("json"), {
description: "Output format: json, plain",
}),
},
handler: async ({ positional, flags, signal }) => {
if (signal.aborted) return
const id = positional[0]
if (!id) {
writeError("Job ID is required", "MISSING_REQUIRED")
process.exit(1)
}
const url = `${BASE_URL}/job/${id}/`
try {
const response = await fetchWithUA(url)
if (response.status === 404) {
writeError("Job not found", "NOT_FOUND")
process.exit(1)
}
if (!response.ok) {
writeError(`Failed to fetch job page: ${response.status} ${response.statusText}`, "API_ERROR")
process.exit(1)
}
const html = await response.text()
if (signal.aborted) return
const jobPosting = parseJobPostingJsonLd(html)
if (!jobPosting) {
writeError("No JSON-LD found on job page", "PARSE_ERROR")
process.exit(1)
}
// Extract fields
const identifier = jobPosting["identifier"] as Record<string, unknown> | undefined
const jobId = identifier?.["value"] ? String(identifier["value"]) : id
// Check if the returned job ID doesn't match what was requested — indicates not found / redirect to different job
// We skip this check since short URL redirects to the actual job and returns 200
const hiringOrg = jobPosting["hiringOrganization"] as Record<string, unknown> | undefined
const jobLocation = jobPosting["jobLocation"] as Record<string, unknown> | undefined
const address = (jobLocation?.["address"] as Record<string, unknown>) ?? {}
const employmentType = jobPosting["employmentType"]
const empTypeArr: string[] = Array.isArray(employmentType)
? employmentType.map(String)
: employmentType
? [String(employmentType)]
: []
const validThrough = jobPosting["validThrough"]
let deadline: string | null = null
if (validThrough && String(validThrough).length > 0) {
// Normalize to YYYY-MM-DD if it's an ISO datetime
const dtStr = String(validThrough)
deadline = dtStr.substring(0, 10) // take first 10 chars = YYYY-MM-DD
if (deadline === "0001-01-01") deadline = null // invalid date
}
const output = {
id: jobId,
url: String(jobPosting["url"] ?? url),
title: String(jobPosting["title"] ?? ""),
description: String(jobPosting["description"] ?? ""),
datePosted: String(jobPosting["datePosted"] ?? ""),
deadline,
employmentType: empTypeArr,
company: {
name: String(hiringOrg?.["name"] ?? ""),
logo: hiringOrg?.["logo"] ? String(hiringOrg["logo"]) : null,
},
location: {
streetAddress: String(address["streetAddress"] ?? ""),
city: String(address["addressLocality"] ?? ""),
postalCode: String(address["postalCode"] ?? ""),
country: String(address["addressCountry"] ?? ""),
},
}
// Verify the job ID matches if possible — if the page 404'd or redirected to a different job
// For invalid IDs that redirect to a generic page, the JSON-LD may be absent
// We already handle the "No JSON-LD" case above
if (flags.format === "json") {
console.log(JSON.stringify(output, null, 2))
} else {
outputPlain(output)
}
} catch (err) {
if (err instanceof Error && err.message.includes("NOT_FOUND")) {
writeError("Job not found", "NOT_FOUND")
} else {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
}
process.exit(1)
}
},
})
function outputPlain(data: {
id: string
url: string
title: string
description: string
datePosted: string
deadline: string | null
employmentType: string[]
company: { name: string; logo: string | null }
location: { streetAddress: string; city: string; postalCode: string; country: string }
}): void {
console.log(`id: ${data.id}`)
console.log(`title: ${data.title}`)
console.log(`company: ${data.company.name}`)
if (data.company.logo) console.log(`logo: ${data.company.logo}`)
console.log(`location: ${[data.location.streetAddress, data.location.city, data.location.country].filter(Boolean).join(", ")}`)
console.log(`datePosted: ${data.datePosted}`)
console.log(`deadline: ${data.deadline ?? "none"}`)
console.log(`employmentType: ${data.employmentType.join(", ")}`)
console.log(`url: ${data.url}`)
console.log("")
// Strip HTML tags for plain description
const plainDescription = data.description.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
console.log(plainDescription)
}
+202
View File
@@ -0,0 +1,202 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { rssFetch, fetchWithUA, writeError, parseRssDescription, extractJobIdFromUrl, BASE_URL, type RssItem } from "../helpers.js"
export function normalizeSearchItem(item: RssItem): Record<string, unknown> {
const parsed = parseRssDescription(item.description)
const id = extractJobIdFromUrl(item.link)
const posted = item.pubDate ? new Date(item.pubDate).toISOString() : ""
return {
id,
title: item.title,
company: parsed.company,
location: parsed.location,
jobType: parsed.jobType,
description: item.description,
url: item.link,
posted,
date: posted ? posted.slice(0, 10) : null,
deadline: parsed.deadline,
}
}
export const search = defineCommand({
name: "search",
description: "Search job listings via RSS feed",
options: {
key: option(z.string().optional(), {
description: "Keyword search (title, company, keyword)",
}),
exclude: option(z.string().optional(), {
description: "Exclude keywords (antikey)",
}),
type: option(z.union([z.string(), z.array(z.string())]).optional(), {
description: "Job type code (cvtype). Repeatable: --type 3 --type 6",
}),
education: option(z.union([z.string(), z.array(z.string())]).optional(), {
description: "Education field code (udd). Repeatable.",
}),
location: option(z.union([z.string(), z.array(z.string())]).optional(), {
description: "Region code (amt). Repeatable.",
}),
"work-area": option(z.union([z.string(), z.array(z.string())]).optional(), {
description: "Work area / function code (erf). Repeatable.",
}),
industry: option(z.union([z.string(), z.array(z.string())]).optional(), {
description: "Industry code (branche). Repeatable.",
}),
"suitable-for": option(z.union([z.string(), z.array(z.string())]).optional(), {
description: "Suitable-for code (andet). Repeatable.",
}),
company: option(z.coerce.number().int().min(1).optional(), {
description: "Company ID (virk)",
}),
remote: option(z.string().optional(), {
description: "Remote work: helt or delvist (fjernarbejde)",
}),
since: option(z.string().optional(), {
description: "Posted on or after date, format YYYY-MM-DD (oprettet)",
}),
limit: option(z.coerce.number().int().min(1).optional(), {
description: "Cap total results returned by CLI (client-side)",
}),
format: option(z.enum(["json", "table", "plain"]).default("json"), {
description: "Output format: json, table, plain",
}),
},
handler: async ({ flags, signal }) => {
if (signal.aborted) return
// Require at least one filter
const hasFilter =
flags.key ||
flags.exclude ||
flags.type ||
flags.education ||
flags.location ||
flags["work-area"] ||
flags.industry ||
flags["suitable-for"] ||
flags.company !== undefined ||
flags.remote ||
flags.since
if (!hasFilter) {
writeError("--key or at least one filter is required", "MISSING_REQUIRED")
process.exit(1)
}
const params: Record<string, string | string[]> = {}
if (flags.key) params["key"] = flags.key
if (flags.exclude) params["antikey"] = flags.exclude
if (flags.type) {
const vals = Array.isArray(flags.type) ? flags.type : [flags.type]
params["cvtype"] = vals.flatMap((v) => v.split(","))
}
if (flags.education) {
const vals = Array.isArray(flags.education) ? flags.education : [flags.education]
params["udd"] = vals.flatMap((v) => v.split(","))
}
if (flags.location) {
const vals = Array.isArray(flags.location) ? flags.location : [flags.location]
params["amt"] = vals.flatMap((v) => v.split(","))
}
if (flags["work-area"]) {
const vals = Array.isArray(flags["work-area"]) ? flags["work-area"] : [flags["work-area"]]
params["erf"] = vals.flatMap((v) => v.split(","))
}
if (flags.industry) {
const vals = Array.isArray(flags.industry) ? flags.industry : [flags.industry]
params["branche"] = vals.flatMap((v) => v.split(","))
}
if (flags["suitable-for"]) {
const vals = Array.isArray(flags["suitable-for"]) ? flags["suitable-for"] : [flags["suitable-for"]]
params["andet"] = vals.flatMap((v) => v.split(","))
}
if (flags.company !== undefined) params["virk"] = String(flags.company)
if (flags.remote) params["fjernarbejde"] = flags.remote
if (flags.since) params["oprettet"] = flags.since
try {
// Fetch RSS feed
const items = await rssFetch(params)
if (signal.aborted) return
// Also fetch total count from HTML page (secondary request)
let total: number | null = null
try {
// Small delay to be polite
await new Promise((resolve) => setTimeout(resolve, 300))
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 htmlUrl = `${BASE_URL}/job/?${searchParams.toString()}`
const htmlResp = await fetchWithUA(htmlUrl)
if (htmlResp.ok) {
const html = await htmlResp.text()
// Extract from <title> tag: "457 relevante job og karriereopslag i Akademikernes Jobbank"
const titleMatch = html.match(/<title[^>]*>\s*(\d[\d.,]*)\s+relevante job/i)
if (titleMatch) {
total = parseInt(titleMatch[1].replace(/[.,]/g, ""), 10)
}
}
} catch {
// Secondary request failed — total stays null
}
// Normalize items
let results = items.map(normalizeSearchItem)
// Apply limit
if (flags.limit !== undefined) {
results = results.slice(0, flags.limit)
}
const output = { meta: { total }, results }
if (flags.format === "json") {
console.log(JSON.stringify(output, null, 2))
} else if (flags.format === "table") {
outputTable(results)
} else {
outputPlain(results)
}
} catch (err) {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
process.exit(1)
}
},
})
function outputTable(results: Array<Record<string, unknown>>): void {
console.log("id title company location deadline")
for (const r of results) {
const id = String(r.id ?? "-").padEnd(9)
const title = String(r.title ?? "-").substring(0, 36).padEnd(36)
const company = String(r.company ?? "-").substring(0, 22).padEnd(22)
const location = String(r.location ?? "-").substring(0, 18).padEnd(18)
const deadline = String(r.deadline ?? "-")
console.log(`${id} ${title} ${company} ${location} ${deadline}`)
}
}
function outputPlain(results: Array<Record<string, unknown>>): void {
for (const r of results) {
console.log(`id: ${r.id}`)
console.log(`title: ${r.title}`)
console.log(`company: ${r.company}`)
console.log(`location: ${r.location}`)
console.log(`jobType: ${r.jobType}`)
console.log(`posted: ${r.posted}`)
console.log(`deadline: ${r.deadline ?? "none"}`)
console.log(`url: ${r.url}`)
console.log("")
}
}
+199
View File
@@ -0,0 +1,199 @@
import { parse as parseHtml } from "node-html-parser"
export const BASE_URL = "https://jobbank.dk"
export const USER_AGENT = "Mozilla/5.0 (compatible; jobbank-cli/1.0)"
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 },
signal: AbortSignal.timeout(15000),
})
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) {
const body = await response.clone().text()
if (response.status === 403 && /just a moment|cloudflare|cf-chl/i.test(body)) {
throw new Error(
"Jobbank is blocking automated requests with Cloudflare bot protection. Skip this portal or use the WebSearch fallback."
)
}
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 {
// The feed writes DD.MM.YYYY; the /scrape contract (and this CLI's own
// detail command) use YYYY-MM-DD. Convert the known shape; anything else
// passes through so an unexpected value stays visible downstream.
const dmy = deadlineStr.match(/^(\d{2})\.(\d{2})\.(\d{4})$/)
deadline = dmy ? `${dmy[3]}-${dmy[2]}-${dmy[1]}` : 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] : ""
}
function findJobPosting(value: unknown): Record<string, unknown> | null {
if (Array.isArray(value)) {
for (const item of value) {
const jobPosting = findJobPosting(item)
if (jobPosting) return jobPosting
}
return null
}
if (!value || typeof value !== "object") return null
const record = value as Record<string, unknown>
if (record["@type"] === "JobPosting") return record
return findJobPosting(record["@graph"])
}
export function parseJobPostingJsonLd(html: string): Record<string, unknown> | null {
const root = parseHtml(html)
const scripts = root.querySelectorAll('script[type="application/ld+json"]')
for (const script of scripts) {
try {
const jobPosting = findJobPosting(JSON.parse(script.text) as unknown)
if (jobPosting) return jobPosting
} catch {
// Invalid JSON-LD should not prevent later scripts from being checked.
}
}
return null
}
@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test";
import { runCLI } from "./helpers";
function parseError(stderr: string): { error: string; code: string } {
return JSON.parse(stderr);
}
describe("Jobbank CLI error contract", () => {
test("search without a filter fails with JSON on stderr", async () => {
const result = await runCLI(["search"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
expect(parseError(result.stderr)).toEqual({
error: "--key or at least one filter is required",
code: "MISSING_REQUIRED",
});
});
test("detail without an ID fails before making a request", async () => {
const result = await runCLI(["detail"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
expect(parseError(result.stderr)).toEqual({
error: "Job ID is required",
code: "MISSING_REQUIRED",
});
});
});
@@ -0,0 +1,82 @@
import { describe, expect, test } from "bun:test";
import { runCLI } from "./helpers";
// All cases fail schema validation (or the required-flag guard) before any
// network request, so the suite is network-free. Regression context: a bare
// z.coerce.number() accepted --limit=-1, and slice(0, -1) then silently
// dropped the last result instead of erroring. The --company filter flag
// also accepted negative and fractional values that were sent raw to the
// portal.
function expectValidationError(result: { exitCode: number; stdout: string; stderr: string }, option: string) {
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
const error = JSON.parse(result.stderr);
expect(error.ok).toBe(false);
expect(error.error.kind).toBe("validation");
expect(error.error.option).toBe(option);
}
describe("Jobbank CLI flag validation", () => {
test("search --limit=-1 is rejected instead of silently dropping the last result", async () => {
const result = await runCLI(["search", "--key", "test", "--limit=-1"]);
expectValidationError(result, "limit");
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
});
test("search --limit=0 is rejected", async () => {
const result = await runCLI(["search", "--key", "test", "--limit=0"]);
expectValidationError(result, "limit");
});
test("search --limit=1.5 is rejected as non-integer", async () => {
const result = await runCLI(["search", "--key", "test", "--limit=1.5"]);
expectValidationError(result, "limit");
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
});
test("search --company=-1 is rejected", async () => {
const result = await runCLI(["search", "--key", "test", "--company=-1"]);
expectValidationError(result, "company");
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
});
test("search --company=1.5 is rejected as non-integer", async () => {
const result = await runCLI(["search", "--key", "test", "--company=1.5"]);
expectValidationError(result, "company");
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
});
test("valid --limit passes schema validation (proven offline via the required-filter guard)", async () => {
const result = await runCLI(["search", "--limit=5"]);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stderr)).toEqual({
error: "--key or at least one filter is required",
code: "MISSING_REQUIRED",
});
});
});
describe("unknown flag rejection", () => {
// add-portal.md's contract: "a bogus flag or missing required arg exits 1
// with a JSON error on stderr". A silently discarded flag is worse than an
// error: on jobdanmark a wrong flag name returned the entire database
// (13,862 results) as if it matched the query (review finding F13,
// 2026-08-19). Rejection happens before dispatch, so these are network-free.
test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => {
const result = await runCLI(["search", "--key", "test", "--bogus-flag", "xyz"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
const error = JSON.parse(result.stderr);
expect(error.code).toBe("UNKNOWN_FLAG");
expect(error.error).toContain("--bogus-flag");
});
test("--query (another portal's free-text flag) is rejected, not treated as no filter", async () => {
const result = await runCLI(["search", "--query", "test"]);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stderr).code).toBe("UNKNOWN_FLAG");
});
});
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test"
import { parseJobPostingJsonLd } from "../src/helpers"
const script = (value: string) => `<script type="application/ld+json">${value}</script>`
describe("parseJobPostingJsonLd", () => {
test("finds a JobPosting inside an @graph", () => {
const html = script(
JSON.stringify({
"@context": "https://schema.org",
"@graph": [
{ "@type": "WebPage", name: "Jobs" },
{ "@type": "JobPosting", title: "Data Engineer" },
],
}),
)
expect(parseJobPostingJsonLd(html)).toEqual({
"@type": "JobPosting",
title: "Data Engineer",
})
})
test("preserves top-level object and array support", () => {
expect(parseJobPostingJsonLd(script('{"@type":"JobPosting","title":"One"}'))?.title).toBe("One")
expect(
parseJobPostingJsonLd(script('[{"@type":"WebPage"},{"@type":"JobPosting","title":"Two"}]'))?.title,
).toBe("Two")
})
test("skips malformed scripts and checks later JSON-LD", () => {
const html = `${script("{not-json")}${script('{"@type":"JobPosting","title":"Valid"}')}`
expect(parseJobPostingJsonLd(html)?.title).toBe("Valid")
})
test("returns null when no JobPosting exists", () => {
expect(parseJobPostingJsonLd(script('{"@graph":[{"@type":"WebPage"}]}'))).toBeNull()
})
})
+39
View File
@@ -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,23 @@
import { afterEach, describe, expect, test } from "bun:test";
import { fetchWithUA } from "../src/helpers";
// A stalled upstream connection (accepted socket, no response) would otherwise
// hang the CLI forever - fetch has no default timeout. Assert the request
// wrapper carries an AbortSignal timeout. Fails on the pre-fix code (no signal).
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("fetchWithUA request timeout", () => {
test("passes an AbortSignal timeout to fetch", async () => {
let init: RequestInit | undefined;
globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => {
init = i;
return new Response("<rss></rss>", { status: 200 });
}) as unknown as typeof fetch;
await fetchWithUA("https://jobbank.dk/job/rss");
expect(init?.signal).toBeInstanceOf(AbortSignal);
});
});
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, test } from "bun:test";
import { fetchWithUA } from "../src/helpers";
// The portal contract requires backoff on 429/5xx. These tests pin the retry
// loop offline: a stubbed fetch counts attempts, and a stubbed setTimeout
// fires immediately so the exhaustion case does not sleep through the real
// 500ms -> 5s backoff schedule.
//
// fetchWithUA deliberately RETURNS non-retry statuses instead of throwing -
// callers own 4xx handling (e.g. rssFetch's Cloudflare 403 message). The 4xx
// test pins that contract.
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.setTimeout;
afterEach(() => {
globalThis.fetch = originalFetch;
globalThis.setTimeout = originalSetTimeout;
});
function instantTimers() {
globalThis.setTimeout = ((fn: () => void) =>
originalSetTimeout(fn, 0)) as unknown as typeof setTimeout;
}
function stubFetch(responses: Array<() => Response>): { calls: number } {
const state = { calls: 0 };
globalThis.fetch = (async () => {
const i = Math.min(state.calls, responses.length - 1);
state.calls++;
return responses[i]();
}) as unknown as typeof fetch;
return state;
}
describe("fetchWithUA retry/backoff", () => {
test("retries a 429 and succeeds on the next attempt", async () => {
instantTimers();
const state = stubFetch([
() => new Response("", { status: 429 }),
() => new Response("ok", { status: 200 }),
]);
const response = await fetchWithUA("https://jobbank.dk/x");
expect(response.status).toBe(200);
expect(state.calls).toBe(2);
});
test("returns a plain 4xx to the caller without retrying", async () => {
const state = stubFetch([() => new Response("", { status: 403 })]);
const response = await fetchWithUA("https://jobbank.dk/x");
expect(response.status).toBe(403);
expect(state.calls).toBe(1);
});
test("gives up after the initial attempt plus six retries on persistent 5xx", async () => {
instantTimers();
const state = stubFetch([() => new Response("", { status: 500 })]);
await expect(fetchWithUA("https://jobbank.dk/x")).rejects.toThrow(/500/);
expect(state.calls).toBe(7);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { afterEach, describe, expect, test } from "bun:test";
import { rssFetch } from "../src/helpers";
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("rssFetch", () => {
test("reports Cloudflare bot protection clearly", async () => {
globalThis.fetch = (async () =>
new Response("<html><title>Just a moment...</title></html>", {
status: 403,
statusText: "Forbidden",
})) as unknown as typeof fetch;
await expect(rssFetch({ key: "data" })).rejects.toThrow(/Cloudflare bot protection/);
});
});
@@ -0,0 +1,96 @@
import { afterEach, describe, expect, test } from "bun:test";
import {
extractJobIdFromUrl,
parseRssDescription,
rssFetch,
USER_AGENT,
} from "../src/helpers";
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("rssFetch", () => {
test("serializes repeated filters and parses CDATA and plain RSS fields", async () => {
let requestedUrl = "";
let requestedUserAgent = "";
globalThis.fetch = (async (input, init) => {
requestedUrl = String(input);
requestedUserAgent = new Headers(init?.headers).get("User-Agent") ?? "";
return new Response(`<?xml version="1.0"?>
<rss><channel><item>
<title><![CDATA[Data Scientist]]></title>
<description><![CDATA[Fuldtidsjob hos Acme A/S, København (Ansøgningsfrist: 31.07.2026)]]></description>
<link>https://jobbank.dk/job/12345/acme/data-scientist</link>
<pubDate>Mon, 13 Jul 2026 08:00:00 GMT</pubDate>
</item></channel></rss>`);
}) as typeof fetch;
const items = await rssFetch({ key: "data science", cvtype: ["3", "6"] });
const url = new URL(requestedUrl);
expect(url.pathname).toBe("/job/rss");
expect(url.searchParams.get("key")).toBe("data science");
expect(url.searchParams.getAll("cvtype")).toEqual(["3", "6"]);
expect(requestedUserAgent).toBe(USER_AGENT);
expect(items).toEqual([
{
title: "Data Scientist",
description: "Fuldtidsjob hos Acme A/S, København (Ansøgningsfrist: 31.07.2026)",
link: "https://jobbank.dk/job/12345/acme/data-scientist",
pubDate: "Mon, 13 Jul 2026 08:00:00 GMT",
},
]);
});
});
describe("parseRssDescription", () => {
test("separates multiple job types, company, location, and deadline", () => {
expect(
parseRssDescription(
"Fuldtidsjob, Graduate/trainee hos Acme A/S, København (Ansøgningsfrist: 31.07.2026)",
),
).toEqual({
jobType: "Fuldtidsjob, Graduate/trainee",
company: "Acme A/S",
location: "København",
deadline: "2026-07-31",
});
});
test("passes an unrecognized deadline shape through for downstream defensive parsing", () => {
const parsed = parseRssDescription(
"Fuldtidsjob hos Acme A/S, Odense (Ansøgningsfrist: snarest muligt)",
);
expect(parsed.deadline).toBe("snarest muligt");
});
test("normalizes a rolling deadline to null", () => {
expect(
parseRssDescription("Deltidsjob hos Example ApS, Aarhus (Ansøgningsfrist: løbende)"),
).toEqual({
jobType: "Deltidsjob",
company: "Example ApS",
location: "Aarhus",
deadline: null,
});
});
test("keeps an unstructured description as the company fallback", () => {
expect(parseRssDescription("Unstructured feed value")).toEqual({
jobType: "",
company: "Unstructured feed value",
location: "",
deadline: null,
});
});
});
describe("extractJobIdFromUrl", () => {
test("extracts numeric IDs and rejects unsupported URL shapes", () => {
expect(extractJobIdFromUrl("https://jobbank.dk/job/12345/acme/role")).toBe("12345");
expect(extractJobIdFromUrl("https://jobbank.dk/job/not-a-number/acme/role")).toBe("");
});
});
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import { normalizeSearchItem } from "../src/commands/search";
import type { RssItem } from "../src/helpers";
function rssItem(): RssItem {
return {
title: "Data Scientist",
description: "Fuldtidsjob hos Acme A/S, København (Ansøgningsfrist: 31.07.2026)",
link: "https://jobbank.dk/job/12345/acme/data-scientist",
pubDate: "Fri, 14 Aug 2026 09:30:00 +0200",
};
}
describe("Jobbank search normalization", () => {
test("derives the /scrape contract date from posted as YYYY-MM-DD", () => {
const result = normalizeSearchItem(rssItem());
expect(result.posted).toBe("2026-08-14T07:30:00.000Z");
expect(result.date).toBe((result.posted as string).slice(0, 10));
expect(result.date).toBe("2026-08-14");
});
test("emits a null date when pubDate is absent (posted is empty)", () => {
const result = normalizeSearchItem({ ...rssItem(), pubDate: "" });
expect(result.posted).toBe("");
expect(result.date).toBeNull();
});
test("keeps the native fields alongside the contract date (additive)", () => {
const result = normalizeSearchItem(rssItem());
expect(result.company).toBe("Acme A/S");
expect(result.location).toBe("København");
expect(result.url).toBe("https://jobbank.dk/job/12345/acme/data-scientist");
expect(result.deadline).toBe("2026-07-31");
});
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext"],
"types": ["bun-types"],
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "."
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}
+327
View File
@@ -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