mirror of
https://github.com/prdlk/jobyssey.git
synced 2026-11-08 16:07:38 +00:00
feat: add skills
This commit is contained in:
1 parent
6984212b17
commit
c50aa92862
150 files changed
+12702
No files matched your search
Executable
+180
@@ -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`.
|
||||
Executable
+400
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
Executable
+45
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user