mirror of
https://github.com/prdlk/jobyssey.git
synced 2026-11-02 13:12:49 +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
+385
@@ -0,0 +1,385 @@
|
||||
# jobnet-cli
|
||||
|
||||
CLI for the [Jobnet.dk](https://jobnet.dk) Danish government job portal API.
|
||||
|
||||
**Base URL**: `https://jobnet.dk/bff`
|
||||
**Authentication**: No credentials required. All public endpoints only need the `x-csrf: 1` request header.
|
||||
**Format**: All responses are JSON.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd skills/jobnet-search/cli
|
||||
bun install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `search` | Search for job ads with filters |
|
||||
| `detail` | Full detail for a single job ad |
|
||||
| `occupations` | Search occupation types (for building filters) |
|
||||
| `suggestions` | Typeahead suggestions for job title / keyword search |
|
||||
|
||||
All commands accept `--format json|table|plain` (default: `json`).
|
||||
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
|
||||
|
||||
---
|
||||
|
||||
## Regions
|
||||
|
||||
| Value | Danish region |
|
||||
|-------|--------------|
|
||||
| `HovedstadenOgBornholm` | Hovedstaden og Bornholm |
|
||||
| `Midtjylland` | Midtjylland |
|
||||
| `Syddanmark` | Syddanmark |
|
||||
| `OevrigeSjaelland` | Øvrige Sjælland |
|
||||
| `Nordjylland` | Nordjylland |
|
||||
|
||||
---
|
||||
|
||||
## Order types
|
||||
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| `PublicationDate` | Newest postings first (default) |
|
||||
| `BestMatch` | Relevance score (requires `--search-string`) |
|
||||
| `ApplicationDate` | Earliest deadline first |
|
||||
|
||||
---
|
||||
|
||||
## `search` — Search for job ads
|
||||
|
||||
**Endpoint**: `GET /FindJob/Search`
|
||||
|
||||
```bash
|
||||
bun run src/cli.ts search [flags]
|
||||
```
|
||||
|
||||
### Flags
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `--search-string` | string | — | Free-text keyword search (job title, skills, employer) |
|
||||
| `--region` | string | — | One region value (see Regions table) |
|
||||
| `--postal-code` | string | — | Postal code for radius search, e.g. `2100` |
|
||||
| `--radius` | number | `50` | Radius in km from postal code (requires `--postal-code`) |
|
||||
| `--work-hours` | string | — | `FullTime` or `PartTime` |
|
||||
| `--duration` | string | — | `Permanent` or `Temporary` |
|
||||
| `--job-type` | string | — | Announcement type: `Ordinaert`, `Efterloenner`, `Foertidspension` |
|
||||
| `--occupation-area` | string | — | Occupation area identifier, e.g. `10000` |
|
||||
| `--occupation-group` | string | — | Occupation group identifier, e.g. `10060` |
|
||||
| `--page` | number | `1` | Page number (1-indexed) |
|
||||
| `--per-page` | number | `10` | Results per page |
|
||||
| `--limit` | number | — | Cap total results returned by CLI |
|
||||
| `--order` | string | `PublicationDate` | Sort order (see Order types table) |
|
||||
| `--format` | string | `json` | Output format: `json`, `table`, `plain` |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
bun run src/cli.ts search \
|
||||
--search-string "sygeplejerske" \
|
||||
--region HovedstadenOgBornholm \
|
||||
--work-hours FullTime \
|
||||
--duration Permanent \
|
||||
--per-page 5 \
|
||||
--format table
|
||||
|
||||
bun run src/cli.ts search \
|
||||
--postal-code 8000 \
|
||||
--radius 25 \
|
||||
--per-page 10
|
||||
```
|
||||
|
||||
### Response shape
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"totalJobAdCount": 21452,
|
||||
"pageNumber": 1,
|
||||
"resultsPerPage": 10,
|
||||
"searchString": "developer"
|
||||
},
|
||||
"facets": {
|
||||
"regions": [
|
||||
{ "type": "HovedstadenOgBornholm", "jobAdCount": 6642 }
|
||||
],
|
||||
"workHours": [
|
||||
{ "type": "FullTime", "jobAdCount": 17919 },
|
||||
{ "type": "PartTime", "jobAdCount": 3533 }
|
||||
],
|
||||
"employmentDurations": [
|
||||
{ "type": "Permanent", "jobAdCount": 18533 },
|
||||
{ "type": "Temporary", "jobAdCount": 2919 }
|
||||
],
|
||||
"occupationAreas": [
|
||||
{ "identifier": "10000", "jobAdCount": 3235 }
|
||||
],
|
||||
"countries": [
|
||||
{ "label": "Danmark", "identifier": "DK", "jobAdCount": 21164 }
|
||||
]
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"jobAdId": "9ef43bce-d82b-4ea1-a098-7ff6520f99be",
|
||||
"title": "Akademisk medarbejder med interesse for arbejdsmiljø og uddannelse",
|
||||
"hiringOrgName": "Region Midtjylland",
|
||||
"occupation": "Personalekonsulent",
|
||||
"municipality": "Viborg",
|
||||
"postalCode": 8800,
|
||||
"postalDistrictName": "Viborg",
|
||||
"country": "Danmark",
|
||||
"publicationDate": "2026-03-13T00:00:00+01:00",
|
||||
"applicationDeadline": "2026-04-05T21:59:00+02:00",
|
||||
"applicationDeadlineStatus": "ExpirationDate",
|
||||
"workHourPartTime": false,
|
||||
"isExternal": false,
|
||||
"hasLogo": true,
|
||||
"logoUrl": "/bff/SharedComponents/JobAdCard/CompanyLogo/ByJobAdId/9ef43bce-d82b-4ea1-a098-7ff6520f99be",
|
||||
"cvr": "29190925",
|
||||
"workPlaceAddress": "",
|
||||
"conceptUriDa": "http://data.star.dk/esco/occupation/426e017f-ebe5-4bea-b1eb-7d2d5ab3c6db",
|
||||
"isSeen": false,
|
||||
"isFavorite": false,
|
||||
"company": "Region Midtjylland",
|
||||
"location": "Viborg",
|
||||
"date": "2026-03-13",
|
||||
"deadline": "2026-04-05",
|
||||
"url": "https://jobnet.dk/find-job/9ef43bce-d82b-4ea1-a098-7ff6520f99be"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **Note**: The `description` field (raw HTML) is intentionally omitted from `search` results for brevity. Use `detail` to retrieve the full job description.
|
||||
|
||||
> **Note**: Every result also carries the cross-portal contract fields `company`, `location`, `date`, `deadline` and `url` — derived respectively from `hiringOrgName`, `postalDistrictName`/`municipality`, and the jobnet detail page URL. `/scrape` Step 2 expects search output to include title, company, location, date, and URL, and dates follow the `YYYY-MM-DD` convention of the other portal CLIs. The API's `1900-01-01` deadline sentinel (deadline not disclosed) maps to `null`. The native fields above are preserved unchanged.
|
||||
|
||||
> **Note**: `resultsPerPage` and `pageNumber` must always be provided — omitting them while also providing `searchString` causes the API to return error 1014 ("Fejl i formatering af inputs").
|
||||
|
||||
---
|
||||
|
||||
## `detail` — Full job ad detail
|
||||
|
||||
**Endpoint**: `GET /FindJob/JobAdDetails/{id}`
|
||||
|
||||
```bash
|
||||
bun run src/cli.ts detail <id> [--format json|plain]
|
||||
```
|
||||
|
||||
The `id` is the `jobAdId` UUID from `search` results.
|
||||
|
||||
By default the CLI passes `incrementViews=false` to avoid polluting view counts.
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
bun run src/cli.ts detail 9ef43bce-d82b-4ea1-a098-7ff6520f99be
|
||||
bun run src/cli.ts detail 9ef43bce-d82b-4ea1-a098-7ff6520f99be --format plain
|
||||
```
|
||||
|
||||
### Response shape
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "9ef43bce-d82b-4ea1-a098-7ff6520f99be",
|
||||
"title": "Akademisk medarbejder med interesse for arbejdsmiljø og uddannelse",
|
||||
"body": "<p>Full HTML job description...</p>",
|
||||
"publicationDateTime": "2026-03-13T08:06:58.0578635+01:00",
|
||||
"unpublicationDateTime": "2026-04-05T21:59:00+02:00",
|
||||
"approvalStatus": "Godkendt",
|
||||
"views": 2,
|
||||
"createdDateTime": "2026-03-13T08:06:58.3936145+01:00",
|
||||
"updatedDateTime": "2026-03-13T08:06:58.3936145+01:00",
|
||||
"isAnonymousEmployer": false,
|
||||
"hasLogo": true,
|
||||
"logoUrl": "/bff/SharedComponents/JobAdCard/CompanyLogo/ByJobAdId/9ef43bce-d82b-4ea1-a098-7ff6520f99be",
|
||||
"employer": {
|
||||
"cvrNumber": "29190925",
|
||||
"pNumber": "1003367314",
|
||||
"name": "Region Midtjylland",
|
||||
"hasCompanyLogo": true
|
||||
},
|
||||
"job": {
|
||||
"type": "Ordinaert",
|
||||
"address": {
|
||||
"streetName": "Heibergs Alle 5A",
|
||||
"city": "Viborg",
|
||||
"postalCode": "8800",
|
||||
"municipality": "Viborg",
|
||||
"countryCode": "DK",
|
||||
"countryName": "Danmark"
|
||||
},
|
||||
"noFixedWorkplace": false,
|
||||
"isLimitedPeriod": false,
|
||||
"isDisabilityFriendly": false,
|
||||
"isPartTime": false,
|
||||
"employmentDate": "2026-06-01T00:00:00+02:00",
|
||||
"conceptUriDa": "http://data.star.dk/esco/occupation/426e017f-ebe5-4bea-b1eb-7d2d5ab3c6db",
|
||||
"preferredLabelDa": "Personalekonsulent",
|
||||
"driversLicenses": [],
|
||||
"classifications": [],
|
||||
"shifts": [],
|
||||
"isFavorite": false
|
||||
},
|
||||
"application": {
|
||||
"deadlineDate": "2026-04-05T21:59:00+02:00",
|
||||
"availablePositions": 1,
|
||||
"contactPersons": [
|
||||
{
|
||||
"firstNames": "Jane",
|
||||
"lastName": "Doe",
|
||||
"phoneNumber": "+4512345678"
|
||||
}
|
||||
],
|
||||
"url": "https://midtjob.dk/ad/...",
|
||||
"urlText": "",
|
||||
"isApplicationDeadlineASAP": false
|
||||
},
|
||||
"organisationTypeId": 24,
|
||||
"user": "Emply 31430747"
|
||||
}
|
||||
```
|
||||
|
||||
> **Note**: `body` contains raw HTML. In `--format plain` output the CLI strips HTML tags to produce readable text.
|
||||
|
||||
> **Note**: `application.url` may be empty for some job ads — the employer may only accept applications through Jobnet's internal system.
|
||||
|
||||
---
|
||||
|
||||
## `occupations` — Search occupation types
|
||||
|
||||
**Endpoint**: `GET /OccupationSearch`
|
||||
|
||||
```bash
|
||||
bun run src/cli.ts occupations --search-string <text> [flags]
|
||||
```
|
||||
|
||||
Use this command to find occupation identifiers (ESCO concept URIs) to pass as filters to `search`.
|
||||
|
||||
### Flags
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `--search-string` | string | **required** | Search term for occupation, e.g. `sygeplejerske` |
|
||||
| `--per-page` | number | `10` | Max results to return |
|
||||
| `--format` | string | `json` | Output format: `json`, `table`, `plain` |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
bun run src/cli.ts occupations --search-string "sygeplejerske" --per-page 5
|
||||
```
|
||||
|
||||
### Response shape
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"conceptUriDa": "http://data.star.dk/esco/occupation/56f5d45c-1234-4321-abcd-000000000000",
|
||||
"preferredLabelDa": "Sygeplejerske",
|
||||
"aliases": [
|
||||
{
|
||||
"aliasIdentifier": "some-uuid",
|
||||
"conceptUriDa": "http://data.star.dk/esco/occupation/56f5d45c-1234-4321-abcd-000000000000",
|
||||
"alternativeLabelDa": "Operationssygeplejerske"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
> **Note**: `conceptUriDa` is the full ESCO concept URI. The UUID portion (last path segment) can be used to build occupation filters for `search`.
|
||||
|
||||
---
|
||||
|
||||
## `suggestions` — Typeahead suggestions
|
||||
|
||||
**Endpoint**: `GET /FindJob/GetTypeaheadSuggestions`
|
||||
|
||||
```bash
|
||||
bun run src/cli.ts suggestions --query <text> [flags]
|
||||
```
|
||||
|
||||
Returns autocomplete strings for the search box — useful for exploring valid Danish job titles before running a `search`.
|
||||
|
||||
### Flags
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `--query` | string | **required** | Partial search string to complete |
|
||||
| `--limit` | number | — | Cap number of suggestions returned |
|
||||
| `--format` | string | `json` | Output format: `json`, `table`, `plain` |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
bun run src/cli.ts suggestions --query "syge"
|
||||
bun run src/cli.ts suggestions --query "ingeniør" --limit 5
|
||||
```
|
||||
|
||||
### Response shape
|
||||
|
||||
```json
|
||||
[
|
||||
"sygepleje",
|
||||
"Sygeplejerske",
|
||||
"Sygeplejerske \"Lægeassistent\""
|
||||
]
|
||||
```
|
||||
|
||||
> **Note**: Suggestions are tuned for Danish job titles. English terms like "developer" may return an empty array.
|
||||
|
||||
---
|
||||
|
||||
## Error handling
|
||||
|
||||
All errors are written to **stderr** in JSON format and exit with code `1`:
|
||||
|
||||
```json
|
||||
{ "error": "Job ad not found", "code": "NOT_FOUND" }
|
||||
{ "error": "API request failed: 500 Internal Server Error", "code": "API_ERROR" }
|
||||
{ "error": "--query is required", "code": "MISSING_REQUIRED" }
|
||||
{ "error": "--search-string is required", "code": "MISSING_REQUIRED" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## URL construction
|
||||
|
||||
Job ad detail pages on jobnet.dk:
|
||||
|
||||
```
|
||||
https://jobnet.dk/find-job/{jobAdId}
|
||||
```
|
||||
|
||||
The legacy `https://jobnet.dk/job/{jobAdId}` route redirects anonymous visitors into the
|
||||
MitID login flow, so it is never emitted. External ads (`isExternal: true`, jobAdIds with an
|
||||
`E` prefix) 404 on `/find-job/` and hit the login wall on `/job/` - neither route serves them
|
||||
anonymously; `/find-job/` is still strictly better and external ads are left as-is.
|
||||
|
||||
Company logo images (prefix relative logoUrl from API):
|
||||
|
||||
```
|
||||
https://jobnet.dk{logoUrl}
|
||||
```
|
||||
|
||||
Example: `https://jobnet.dk/bff/SharedComponents/JobAdCard/CompanyLogo/ByJobAdId/9ef43bce-d82b-4ea1-a098-7ff6520f99be`
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All data is from the public `jobnet.dk/bff` REST API — no credentials required.
|
||||
- The `x-csrf: 1` header must be sent with every request.
|
||||
- Pagination is 1-indexed (`--page 1` is the first page).
|
||||
- `search` results intentionally omit the HTML `description` field — use `detail` to fetch it.
|
||||
- `body` in `detail` responses is raw HTML; use `--format plain` to get stripped text.
|
||||
- The `occupations` command helps discover ESCO occupation URIs usable as `--occupation-area` / `--occupation-group` seeds for narrowing search results.
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "jobnet-cli",
|
||||
"version": "1.0.0",
|
||||
"description": "CLI for the Jobnet.dk Danish government job portal API",
|
||||
"type": "module",
|
||||
"main": "src/cli.ts",
|
||||
"bin": {
|
||||
"jobnet": "src/cli.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "bun run src/cli.ts",
|
||||
"test": "bun test --timeout 30000",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bunli/core": "0.9.1",
|
||||
"@bunli/utils": "0.6.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0",
|
||||
"@types/bun": "1.3.14"
|
||||
}
|
||||
}
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
import { createCLI } from "@bunli/core"
|
||||
import { writeError } from "./helpers.js"
|
||||
import { search } from "./commands/search.js"
|
||||
import { detail } from "./commands/detail.js"
|
||||
import { occupations } from "./commands/occupations.js"
|
||||
import { suggestions } from "./commands/suggestions.js"
|
||||
|
||||
const cli = await createCLI({
|
||||
name: "jobnet-cli",
|
||||
version: "0.1.0",
|
||||
description: "CLI for the Jobnet.dk Danish government job portal API",
|
||||
})
|
||||
|
||||
const commands = [search, detail, occupations, suggestions]
|
||||
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()
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
import { defineCommand, option } from "@bunli/core"
|
||||
import { z } from "zod"
|
||||
import { apiFetch, writeError, stripHtml } from "../helpers.js"
|
||||
|
||||
export interface DetailApiResponse {
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
publicationDateTime: string
|
||||
unpublicationDateTime: string | null
|
||||
approvalStatus: string
|
||||
views: number
|
||||
createdDateTime: string
|
||||
updatedDateTime: string
|
||||
isAnonymousEmployer: boolean
|
||||
hasLogo: boolean
|
||||
logoUrl: string | null
|
||||
employer: {
|
||||
cvrNumber: string | null
|
||||
pNumber: string | null
|
||||
name: string
|
||||
hasCompanyLogo: boolean
|
||||
}
|
||||
job: {
|
||||
type: string
|
||||
address: {
|
||||
streetName: string | null
|
||||
city: string | null
|
||||
postalCode: string | null
|
||||
municipality: string | null
|
||||
countryCode: string
|
||||
countryName: string
|
||||
}
|
||||
noFixedWorkplace: boolean
|
||||
isLimitedPeriod: boolean
|
||||
isDisabilityFriendly: boolean
|
||||
isPartTime: boolean
|
||||
employmentDate: string | null
|
||||
conceptUriDa: string | null
|
||||
preferredLabelDa: string | null
|
||||
driversLicenses: unknown[]
|
||||
classifications: unknown[]
|
||||
shifts: unknown[]
|
||||
isFavorite: boolean
|
||||
}
|
||||
application: {
|
||||
deadlineDate: string | null
|
||||
availablePositions: number
|
||||
contactPersons: Array<{
|
||||
firstNames: string | null
|
||||
lastName: string | null
|
||||
phoneNumber: string | null
|
||||
}>
|
||||
url: string | null
|
||||
urlText: string | null
|
||||
isApplicationDeadlineASAP: boolean
|
||||
}
|
||||
organisationTypeId: number | null
|
||||
user: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw detail response before any output format sees it.
|
||||
*
|
||||
* The API's "deadline not disclosed" sentinel is 1900-01-01 (it arrives with
|
||||
* isApplicationDeadlineASAP / an applicationDeadlineStatus of NotDisclosed).
|
||||
* The search command already maps that sentinel to null; detail must agree,
|
||||
* or an undisclosed deadline reads as 126 years expired and /rank's expiry
|
||||
* sweep retires the job the moment it is stored.
|
||||
*/
|
||||
export function prepareDetail(data: DetailApiResponse): DetailApiResponse {
|
||||
const deadline = data.application.deadlineDate
|
||||
if (deadline && deadline.startsWith("1900-01-01")) {
|
||||
data.application.deadlineDate = null
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export const detail = defineCommand({
|
||||
name: "detail",
|
||||
description: "Full detail for a single job ad",
|
||||
options: {
|
||||
format: option(z.enum(["json", "table", "plain"]).default("json"), {
|
||||
description: "Output format: json, table, plain",
|
||||
}),
|
||||
},
|
||||
handler: async ({ positional, flags, signal }) => {
|
||||
if (signal.aborted) return
|
||||
|
||||
const id = positional[0] as string | undefined
|
||||
if (!id) {
|
||||
writeError("Job ad ID is required", "MISSING_REQUIRED")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = prepareDetail(
|
||||
await apiFetch<DetailApiResponse>(`/FindJob/JobAdDetails/${id}`, {
|
||||
incrementViews: "false",
|
||||
}),
|
||||
)
|
||||
|
||||
if (signal.aborted) return
|
||||
|
||||
if (flags.format === "json") {
|
||||
console.log(JSON.stringify(data, null, 2))
|
||||
} else if (flags.format === "table") {
|
||||
outputTable(data)
|
||||
} else {
|
||||
outputPlain(data)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (message.includes("404") || message.includes("Not Found")) {
|
||||
writeError("Job ad not found", "NOT_FOUND")
|
||||
} else {
|
||||
writeError(message, "API_ERROR")
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function outputTable(data: DetailApiResponse): void {
|
||||
console.log(`ID: ${data.id}`)
|
||||
console.log(`Title: ${data.title}`)
|
||||
console.log(`Employer: ${data.employer.name}`)
|
||||
console.log(`Type: ${data.job.type}`)
|
||||
console.log(`City: ${data.job.address.city ?? "-"}`)
|
||||
console.log(`Postal: ${data.job.address.postalCode ?? "-"}`)
|
||||
console.log(`Country: ${data.job.address.countryName}`)
|
||||
console.log(`Published: ${data.publicationDateTime}`)
|
||||
console.log(`Deadline: ${data.application.deadlineDate ?? "-"}`)
|
||||
console.log(`Positions: ${data.application.availablePositions}`)
|
||||
console.log(`Apply URL: ${data.application.url ?? "-"}`)
|
||||
}
|
||||
|
||||
function outputPlain(data: DetailApiResponse): void {
|
||||
console.log(formatDetailPlain(data))
|
||||
}
|
||||
|
||||
export function formatDetailPlain(data: DetailApiResponse): string {
|
||||
const lines = [
|
||||
`Title: ${data.title}`,
|
||||
`Employer: ${data.employer.name}`,
|
||||
`Location: ${data.job.address.city ?? "-"}, ${data.job.address.countryName}`,
|
||||
`Published: ${data.publicationDateTime}`,
|
||||
`Deadline: ${data.application.deadlineDate ?? "-"}`,
|
||||
`Positions: ${data.application.availablePositions}`,
|
||||
]
|
||||
|
||||
if (data.application.url) {
|
||||
lines.push(`Apply: ${data.application.url}`)
|
||||
}
|
||||
|
||||
lines.push("", stripHtml(data.body))
|
||||
return lines.join("\n")
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { defineCommand, option } from "@bunli/core"
|
||||
import { z } from "zod"
|
||||
import { apiFetch, writeError } from "../helpers.js"
|
||||
|
||||
interface OccupationAlias {
|
||||
aliasIdentifier: string
|
||||
conceptUriDa: string
|
||||
alternativeLabelDa: string
|
||||
}
|
||||
|
||||
interface Occupation {
|
||||
conceptUriDa: string
|
||||
preferredLabelDa: string
|
||||
aliases: OccupationAlias[]
|
||||
}
|
||||
|
||||
export const occupations = defineCommand({
|
||||
name: "occupations",
|
||||
description: "Search occupation types (for building filters)",
|
||||
options: {
|
||||
"search-string": option(z.string().optional(), {
|
||||
description: "Search term for occupation, e.g. sygeplejerske",
|
||||
}),
|
||||
"per-page": option(z.coerce.number().int().min(1).default(10), {
|
||||
description: "Max results to return",
|
||||
}),
|
||||
format: option(z.enum(["json", "table", "plain"]).default("json"), {
|
||||
description: "Output format: json, table, plain",
|
||||
}),
|
||||
},
|
||||
handler: async ({ flags, signal }) => {
|
||||
if (signal.aborted) return
|
||||
|
||||
if (!flags["search-string"]) {
|
||||
writeError("--search-string is required", "MISSING_REQUIRED")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {
|
||||
searchString: flags["search-string"],
|
||||
pageSize: String(flags["per-page"]),
|
||||
}
|
||||
|
||||
try {
|
||||
const rawData = await apiFetch<Occupation[]>("/OccupationSearch", params)
|
||||
|
||||
if (signal.aborted) return
|
||||
|
||||
// Client-side filter — API does not enforce pageSize reliably
|
||||
const data = rawData.slice(0, flags["per-page"])
|
||||
|
||||
if (flags.format === "json") {
|
||||
console.log(JSON.stringify(data, null, 2))
|
||||
} else if (flags.format === "table") {
|
||||
outputTable(data)
|
||||
} else {
|
||||
outputPlain(data)
|
||||
}
|
||||
} catch (err) {
|
||||
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
|
||||
process.exit(1)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function outputTable(data: Occupation[]): void {
|
||||
console.log("conceptUriDa preferredLabelDa")
|
||||
for (const o of data) {
|
||||
const uri = o.conceptUriDa.substring(0, 70).padEnd(70)
|
||||
const label = o.preferredLabelDa
|
||||
console.log(`${uri} ${label}`)
|
||||
}
|
||||
}
|
||||
|
||||
function outputPlain(data: Occupation[]): void {
|
||||
for (const o of data) {
|
||||
console.log(`label: ${o.preferredLabelDa}`)
|
||||
console.log(`uri: ${o.conceptUriDa}`)
|
||||
if (o.aliases.length > 0) {
|
||||
console.log(`aliases: ${o.aliases.map((a) => a.alternativeLabelDa).join(", ")}`)
|
||||
}
|
||||
console.log("")
|
||||
}
|
||||
}
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
import { defineCommand, option } from "@bunli/core"
|
||||
import { z } from "zod"
|
||||
import { apiFetch, writeError } from "../helpers.js"
|
||||
|
||||
export interface SearchApiResponse {
|
||||
jobAds: JobAdRaw[]
|
||||
searchFacets: SearchFacetsRaw
|
||||
totalJobAdCount: number
|
||||
searchString: string | null
|
||||
}
|
||||
|
||||
export interface JobAdRaw {
|
||||
jobAdId: string
|
||||
title: string
|
||||
hiringOrgName: string
|
||||
occupation: string | null
|
||||
municipality: string | null
|
||||
postalCode: number | null
|
||||
postalDistrictName: string | null
|
||||
country: string
|
||||
publicationDate: string
|
||||
applicationDeadline: string | null
|
||||
applicationDeadlineStatus: string | null
|
||||
workHourPartTime: boolean
|
||||
isExternal: boolean
|
||||
hasLogo: boolean
|
||||
logoUrl: string | null
|
||||
cvr: string | null
|
||||
workPlaceAddress: string
|
||||
conceptUriDa?: string | null
|
||||
isSeen: boolean
|
||||
isFavorite: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface SearchFacetsRaw {
|
||||
regions: Array<{ type: string; jobAdCount: number }>
|
||||
workHours: Array<{ type: string; jobAdCount: number }>
|
||||
employmentDurations: Array<{ type: string; jobAdCount: number }>
|
||||
occupationAreas: Array<{ identifier: string; jobAdCount: number }>
|
||||
countries: Array<{ label: string; identifier: string; jobAdCount: number }>
|
||||
}
|
||||
|
||||
export interface SearchFlags {
|
||||
"search-string"?: string
|
||||
page: number
|
||||
"per-page": number
|
||||
order: string
|
||||
region?: string
|
||||
"work-hours"?: string
|
||||
duration?: string
|
||||
"job-type"?: string
|
||||
"postal-code"?: string
|
||||
radius: number
|
||||
"occupation-area"?: string
|
||||
"occupation-group"?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export function buildSearchParams(flags: SearchFlags): Record<string, string> {
|
||||
const params: Record<string, string> = {
|
||||
resultsPerPage: String(flags["per-page"]),
|
||||
pageNumber: String(flags.page),
|
||||
orderType: flags.order,
|
||||
}
|
||||
|
||||
if (flags["search-string"]) params["searchString"] = flags["search-string"]
|
||||
if (flags.region) params["regions"] = flags.region
|
||||
if (flags["work-hours"]) params["workHoursType"] = flags["work-hours"]
|
||||
if (flags.duration) params["employmentDurationType"] = flags.duration
|
||||
if (flags["job-type"]) params["jobAnnouncementType"] = flags["job-type"]
|
||||
if (flags["postal-code"]) {
|
||||
params["postalCode"] = flags["postal-code"]
|
||||
params["kmRadius"] = String(flags.radius)
|
||||
}
|
||||
if (flags["occupation-area"]) params["occupationAreas"] = flags["occupation-area"]
|
||||
if (flags["occupation-group"]) params["occupationGroups"] = flags["occupation-group"]
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
export function createSearchOutput(data: SearchApiResponse, flags: SearchFlags) {
|
||||
let results = data.jobAds.map((job) => ({
|
||||
jobAdId: job.jobAdId,
|
||||
title: job.title,
|
||||
hiringOrgName: job.hiringOrgName,
|
||||
occupation: job.occupation ?? null,
|
||||
municipality: job.municipality ?? null,
|
||||
postalCode: job.postalCode ?? null,
|
||||
postalDistrictName: job.postalDistrictName ?? null,
|
||||
country: job.country,
|
||||
publicationDate: job.publicationDate,
|
||||
applicationDeadline: job.applicationDeadline ?? null,
|
||||
applicationDeadlineStatus: job.applicationDeadlineStatus ?? null,
|
||||
workHourPartTime: job.workHourPartTime,
|
||||
isExternal: job.isExternal,
|
||||
hasLogo: job.hasLogo,
|
||||
logoUrl: job.logoUrl ?? null,
|
||||
cvr: job.cvr ?? null,
|
||||
workPlaceAddress: job.workPlaceAddress ?? "",
|
||||
isSeen: job.isSeen,
|
||||
isFavorite: job.isFavorite,
|
||||
company: job.hiringOrgName,
|
||||
location: job.postalDistrictName ?? job.municipality ?? null,
|
||||
date: job.publicationDate.slice(0, 10),
|
||||
deadline: job.applicationDeadline && !job.applicationDeadline.startsWith("1900-01-01")
|
||||
? job.applicationDeadline.slice(0, 10)
|
||||
: null,
|
||||
url: `https://jobnet.dk/find-job/${job.jobAdId}`,
|
||||
}))
|
||||
|
||||
if (flags.limit !== undefined) {
|
||||
results = results.slice(0, flags.limit)
|
||||
}
|
||||
|
||||
const facets = {
|
||||
regions: data.searchFacets.regions ?? [],
|
||||
workHours: data.searchFacets.workHours ?? [],
|
||||
employmentDurations: data.searchFacets.employmentDurations ?? [],
|
||||
occupationAreas: data.searchFacets.occupationAreas ?? [],
|
||||
countries: data.searchFacets.countries ?? [],
|
||||
}
|
||||
|
||||
const meta = {
|
||||
totalJobAdCount: data.totalJobAdCount,
|
||||
pageNumber: flags.page,
|
||||
resultsPerPage: flags["per-page"],
|
||||
searchString: data.searchString ?? null,
|
||||
}
|
||||
|
||||
return { meta, facets, results }
|
||||
}
|
||||
|
||||
export const search = defineCommand({
|
||||
name: "search",
|
||||
description: "Search for job ads with filters",
|
||||
options: {
|
||||
"search-string": option(z.string().optional(), {
|
||||
description: "Free-text keyword search (job title, skills, employer)",
|
||||
}),
|
||||
page: option(z.coerce.number().int().min(1).default(1), {
|
||||
description: "Page number (1-indexed)",
|
||||
}),
|
||||
"per-page": option(z.coerce.number().int().min(1).default(10), {
|
||||
description: "Results per page",
|
||||
}),
|
||||
order: option(z.string().default("PublicationDate"), {
|
||||
description: "Sort order: PublicationDate, BestMatch, ApplicationDate",
|
||||
}),
|
||||
region: option(z.string().optional(), {
|
||||
description: "One region value",
|
||||
}),
|
||||
"work-hours": option(z.string().optional(), {
|
||||
description: "FullTime or PartTime",
|
||||
}),
|
||||
duration: option(z.string().optional(), {
|
||||
description: "Permanent or Temporary",
|
||||
}),
|
||||
"job-type": option(z.string().optional(), {
|
||||
description: "Announcement type: Ordinaert, Efterloenner, Foertidspension",
|
||||
}),
|
||||
"postal-code": option(z.string().optional(), {
|
||||
description: "Postal code for radius search",
|
||||
}),
|
||||
radius: option(z.coerce.number().int().min(1).default(50), {
|
||||
description: "Radius in km from postal code",
|
||||
}),
|
||||
"occupation-area": option(z.string().optional(), {
|
||||
description: "Occupation area identifier, e.g. 10000",
|
||||
}),
|
||||
"occupation-group": option(z.string().optional(), {
|
||||
description: "Occupation group identifier, e.g. 10060",
|
||||
}),
|
||||
limit: option(z.coerce.number().int().min(1).optional(), {
|
||||
description: "Cap total results returned by CLI",
|
||||
}),
|
||||
format: option(z.enum(["json", "table", "plain"]).default("json"), {
|
||||
description: "Output format: json, table, plain",
|
||||
}),
|
||||
},
|
||||
handler: async ({ flags, signal }) => {
|
||||
if (signal.aborted) return
|
||||
|
||||
const params = buildSearchParams(flags)
|
||||
|
||||
try {
|
||||
const data = await apiFetch<SearchApiResponse>("/FindJob/Search", params)
|
||||
|
||||
if (signal.aborted) return
|
||||
|
||||
const output = createSearchOutput(data, flags)
|
||||
|
||||
if (flags.format === "json") {
|
||||
console.log(JSON.stringify(output, null, 2))
|
||||
} else if (flags.format === "table") {
|
||||
outputTable(output.results)
|
||||
} else {
|
||||
outputPlain(output.results)
|
||||
}
|
||||
} catch (err) {
|
||||
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
|
||||
process.exit(1)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type JobAdResult = {
|
||||
jobAdId: string
|
||||
title: string
|
||||
hiringOrgName: string
|
||||
occupation: string | null
|
||||
municipality: string | null
|
||||
postalCode: number | null
|
||||
publicationDate: string
|
||||
applicationDeadline: string | null
|
||||
}
|
||||
|
||||
function outputTable(results: JobAdResult[]): void {
|
||||
console.log("jobAdId title employer municipality")
|
||||
for (const r of results) {
|
||||
const id = r.jobAdId.padEnd(36)
|
||||
const title = (r.title ?? "-").substring(0, 36).padEnd(36)
|
||||
const employer = (r.hiringOrgName ?? "-").substring(0, 26).padEnd(26)
|
||||
const municipality = String(r.municipality ?? "-")
|
||||
console.log(`${id} ${title} ${employer} ${municipality}`)
|
||||
}
|
||||
}
|
||||
|
||||
function outputPlain(results: JobAdResult[]): void {
|
||||
for (const r of results) {
|
||||
console.log(`id: ${r.jobAdId}`)
|
||||
console.log(`title: ${r.title}`)
|
||||
console.log(`employer: ${r.hiringOrgName}`)
|
||||
console.log(`occupation: ${r.occupation ?? "-"}`)
|
||||
console.log(`municipality: ${r.municipality ?? "-"}`)
|
||||
console.log(`published: ${r.publicationDate}`)
|
||||
console.log(`deadline: ${r.applicationDeadline ?? "-"}`)
|
||||
console.log("")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { defineCommand, option } from "@bunli/core"
|
||||
import { z } from "zod"
|
||||
import { apiFetch, writeError } from "../helpers.js"
|
||||
|
||||
export const suggestions = defineCommand({
|
||||
name: "suggestions",
|
||||
description: "Typeahead suggestions for job title / keyword search",
|
||||
options: {
|
||||
query: option(z.string().optional(), {
|
||||
description: "Partial search string to complete",
|
||||
}),
|
||||
limit: option(z.coerce.number().int().min(1).optional(), {
|
||||
description: "Cap number of suggestions returned",
|
||||
}),
|
||||
format: option(z.enum(["json", "table", "plain"]).default("json"), {
|
||||
description: "Output format: json, table, plain",
|
||||
}),
|
||||
},
|
||||
handler: async ({ flags, signal }) => {
|
||||
if (signal.aborted) return
|
||||
|
||||
if (!flags.query) {
|
||||
writeError("--query is required", "MISSING_REQUIRED")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {
|
||||
query: flags.query,
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await apiFetch<string[]>("/FindJob/GetTypeaheadSuggestions", params)
|
||||
|
||||
if (signal.aborted) return
|
||||
|
||||
let results = data
|
||||
if (flags.limit !== undefined) {
|
||||
results = results.slice(0, flags.limit)
|
||||
}
|
||||
|
||||
if (flags.format === "json") {
|
||||
console.log(JSON.stringify(results, 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(data: string[]): void {
|
||||
console.log("suggestion")
|
||||
for (const s of data) {
|
||||
console.log(s)
|
||||
}
|
||||
}
|
||||
|
||||
function outputPlain(data: string[]): void {
|
||||
for (const s of data) {
|
||||
console.log(s)
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
export const BASE_URL = "https://jobnet.dk/bff"
|
||||
export const USER_AGENT = "Mozilla/5.0 (compatible; jobnet-cli/1.0)"
|
||||
|
||||
export async function apiFetch<T>(path: string, params?: Record<string, string>): Promise<T> {
|
||||
let url = `${BASE_URL}${path}`
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
const qs = new URLSearchParams(params)
|
||||
url += `?${qs.toString()}`
|
||||
}
|
||||
|
||||
const maxRetries = 6
|
||||
let delay = 500
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": USER_AGENT,
|
||||
"x-csrf": "1",
|
||||
},
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
if (attempt === maxRetries) {
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
// Add jitter to spread out retries: base delay + random 0-500ms
|
||||
const jitter = Math.floor(Math.random() * 500)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay + jitter))
|
||||
delay = Math.min(delay * 2, 5000)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
throw new Error("API request failed after max retries")
|
||||
}
|
||||
|
||||
export function writeError(error: string, code: string): void {
|
||||
process.stderr.write(JSON.stringify({ error, code }) + "\n")
|
||||
}
|
||||
|
||||
export function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { runCLI } from "./helpers";
|
||||
|
||||
describe("Jobnet CLI error contract", () => {
|
||||
test("detail without an ID fails with JSON on stderr", async () => {
|
||||
const result = await runCLI(["detail"]);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stdout).toBe("");
|
||||
expect(JSON.parse(result.stderr)).toEqual({
|
||||
error: "Job ad ID is required",
|
||||
code: "MISSING_REQUIRED",
|
||||
});
|
||||
});
|
||||
|
||||
test("occupations without a search string fails before making a request", async () => {
|
||||
const result = await runCLI(["occupations"]);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stdout).toBe("");
|
||||
expect(JSON.parse(result.stderr)).toEqual({
|
||||
error: "--search-string is required",
|
||||
code: "MISSING_REQUIRED",
|
||||
});
|
||||
});
|
||||
|
||||
test("suggestions without a query fails before making a request", async () => {
|
||||
const result = await runCLI(["suggestions"]);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stdout).toBe("");
|
||||
expect(JSON.parse(result.stderr)).toEqual({
|
||||
error: "--query is required",
|
||||
code: "MISSING_REQUIRED",
|
||||
});
|
||||
});
|
||||
|
||||
test("an invalid numeric option fails before making a request", async () => {
|
||||
const result = await runCLI(["search", "--page", "not-a-number"]);
|
||||
const error = JSON.parse(result.stderr);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stdout).toBe("");
|
||||
expect(error.ok).toBe(false);
|
||||
expect(error.error.kind).toBe("validation");
|
||||
expect(error.error.option).toBe("page");
|
||||
expect(error.error.message).toContain("Expected number");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
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 / --per-page=-1, and slice(0, -1)
|
||||
// then silently dropped the last result instead of erroring. The --radius
|
||||
// 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("Jobnet CLI flag validation", () => {
|
||||
test("search --limit=-1 is rejected instead of silently dropping the last result", async () => {
|
||||
const result = await runCLI(["search", "--limit=-1"]);
|
||||
expectValidationError(result, "limit");
|
||||
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
|
||||
});
|
||||
|
||||
test("search --page=0 is rejected on the 1-indexed API", async () => {
|
||||
const result = await runCLI(["search", "--page=0"]);
|
||||
expectValidationError(result, "page");
|
||||
});
|
||||
|
||||
test("search --per-page=-5 is rejected", async () => {
|
||||
const result = await runCLI(["search", "--per-page=-5"]);
|
||||
expectValidationError(result, "per-page");
|
||||
});
|
||||
|
||||
test("search --limit=1.5 is rejected as non-integer", async () => {
|
||||
const result = await runCLI(["search", "--limit=1.5"]);
|
||||
expectValidationError(result, "limit");
|
||||
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
|
||||
});
|
||||
|
||||
test("search --radius=-10 is rejected", async () => {
|
||||
const result = await runCLI(["search", "--radius=-10"]);
|
||||
expectValidationError(result, "radius");
|
||||
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
|
||||
});
|
||||
|
||||
test("search --radius=2.5 is rejected as non-integer", async () => {
|
||||
const result = await runCLI(["search", "--radius=2.5"]);
|
||||
expectValidationError(result, "radius");
|
||||
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
|
||||
});
|
||||
|
||||
test("occupations --per-page=-1 is rejected", async () => {
|
||||
const result = await runCLI(["occupations", "--per-page=-1"]);
|
||||
expectValidationError(result, "per-page");
|
||||
});
|
||||
|
||||
test("suggestions --limit=-1 is rejected", async () => {
|
||||
const result = await runCLI(["suggestions", "--limit=-1"]);
|
||||
expectValidationError(result, "limit");
|
||||
});
|
||||
|
||||
test("valid numeric flags pass schema validation (proven offline via the required-flag guard)", async () => {
|
||||
const result = await runCLI(["occupations", "--per-page=5"]);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(JSON.parse(result.stderr)).toEqual({
|
||||
error: "--search-string 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", "--search-string", "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,121 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { formatDetailPlain, prepareDetail, type DetailApiResponse } from "../src/commands/detail";
|
||||
|
||||
function detail(overrides: Partial<DetailApiResponse> = {}): DetailApiResponse {
|
||||
return {
|
||||
id: "job-1",
|
||||
title: "Data Engineer",
|
||||
body: "<p>Build ETL & analytics flows.</p><p>Use SQL daily.</p>",
|
||||
publicationDateTime: "2026-07-01T09:00:00+02:00",
|
||||
unpublicationDateTime: "2026-08-01T23:59:00+02:00",
|
||||
approvalStatus: "Godkendt",
|
||||
views: 10,
|
||||
createdDateTime: "2026-07-01T08:00:00+02:00",
|
||||
updatedDateTime: "2026-07-01T08:30:00+02:00",
|
||||
isAnonymousEmployer: false,
|
||||
hasLogo: true,
|
||||
logoUrl: "/logo/job-1",
|
||||
employer: {
|
||||
cvrNumber: "12345678",
|
||||
pNumber: "87654321",
|
||||
name: "Acme",
|
||||
hasCompanyLogo: true,
|
||||
},
|
||||
job: {
|
||||
type: "FullTime",
|
||||
address: {
|
||||
streetName: "Examplevej 1",
|
||||
city: "København",
|
||||
postalCode: "2100",
|
||||
municipality: "København",
|
||||
countryCode: "DK",
|
||||
countryName: "Danmark",
|
||||
},
|
||||
noFixedWorkplace: false,
|
||||
isLimitedPeriod: false,
|
||||
isDisabilityFriendly: false,
|
||||
isPartTime: false,
|
||||
employmentDate: null,
|
||||
conceptUriDa: null,
|
||||
preferredLabelDa: null,
|
||||
driversLicenses: [],
|
||||
classifications: [],
|
||||
shifts: [],
|
||||
isFavorite: false,
|
||||
},
|
||||
application: {
|
||||
deadlineDate: "2026-08-01T23:59:00+02:00",
|
||||
availablePositions: 2,
|
||||
contactPersons: [],
|
||||
url: "https://example.test/apply",
|
||||
urlText: "Apply",
|
||||
isApplicationDeadlineASAP: false,
|
||||
},
|
||||
organisationTypeId: null,
|
||||
user: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("formatDetailPlain", () => {
|
||||
test("renders the plain view with cleaned body text and apply URL", () => {
|
||||
const formatted = formatDetailPlain(detail());
|
||||
|
||||
expect(formatted).toContain("Title: Data Engineer");
|
||||
expect(formatted).toContain("Employer: Acme");
|
||||
expect(formatted).toContain("Location: København, Danmark");
|
||||
expect(formatted).toContain("Apply: https://example.test/apply");
|
||||
expect(formatted).toContain("Build ETL & analytics flows. Use SQL daily.");
|
||||
expect(formatted).not.toContain("<p>");
|
||||
expect(formatted).not.toContain("&");
|
||||
});
|
||||
|
||||
test("uses placeholders when optional city, deadline, and apply URL are absent", () => {
|
||||
const formatted = formatDetailPlain(
|
||||
detail({
|
||||
job: {
|
||||
...detail().job,
|
||||
address: {
|
||||
...detail().job.address,
|
||||
city: null,
|
||||
},
|
||||
},
|
||||
application: {
|
||||
...detail().application,
|
||||
deadlineDate: null,
|
||||
url: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(formatted).toContain("Location: -, Danmark");
|
||||
expect(formatted).toContain("Deadline: -");
|
||||
expect(formatted).not.toContain("Apply:");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("prepareDetail deadline sentinel", () => {
|
||||
// The API's "deadline not disclosed" sentinel is 1900-01-01 (paired with
|
||||
// isApplicationDeadlineASAP / applicationDeadlineStatus). search maps it to
|
||||
// null and has a test pinning that; detail dumped the raw response, so an
|
||||
// undisclosed deadline read as 126 years expired and /rank's sweep would
|
||||
// retire the job instantly (review finding F33, 2026-08-19).
|
||||
test("maps the 1900-01-01 undisclosed sentinel to null", () => {
|
||||
const data = detail();
|
||||
data.application.deadlineDate = "1900-01-01T00:00:00+01:00";
|
||||
expect(prepareDetail(data).application.deadlineDate).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps a real deadline unchanged", () => {
|
||||
const data = detail();
|
||||
data.application.deadlineDate = "2026-09-01T00:00:00+02:00";
|
||||
expect(prepareDetail(data).application.deadlineDate).toBe("2026-09-01T00:00:00+02:00");
|
||||
});
|
||||
|
||||
test("keeps a null deadline null", () => {
|
||||
const data = detail();
|
||||
data.application.deadlineDate = null;
|
||||
expect(prepareDetail(data).application.deadlineDate).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 { apiFetch } from "../src/helpers";
|
||||
|
||||
// A stalled upstream connection (accepted socket, no response) would otherwise
|
||||
// hang the CLI forever - fetch has no default timeout. Assert every request
|
||||
// carries an AbortSignal timeout. This fails on the pre-fix code (no signal).
|
||||
const originalFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("apiFetch 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("{}", { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await apiFetch("/search");
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { apiFetch } 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.
|
||||
|
||||
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("apiFetch retry/backoff", () => {
|
||||
test("retries a 429 and succeeds on the next attempt", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([
|
||||
() => new Response("", { status: 429 }),
|
||||
() => new Response('{"ok":true}', { status: 200 }),
|
||||
]);
|
||||
|
||||
const data = await apiFetch<{ ok: boolean }>("/x");
|
||||
expect(data.ok).toBe(true);
|
||||
expect(state.calls).toBe(2);
|
||||
});
|
||||
|
||||
test("does not retry a plain 4xx", async () => {
|
||||
const state = stubFetch([() => new Response("", { status: 400 })]);
|
||||
|
||||
await expect(apiFetch("/x")).rejects.toThrow(/400/);
|
||||
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(apiFetch("/x")).rejects.toThrow(/500/);
|
||||
expect(state.calls).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
buildSearchParams,
|
||||
createSearchOutput,
|
||||
type SearchApiResponse,
|
||||
type SearchFlags,
|
||||
} from "../src/commands/search";
|
||||
|
||||
const flags: SearchFlags = {
|
||||
"search-string": "data engineer",
|
||||
page: 2,
|
||||
"per-page": 25,
|
||||
order: "BestMatch",
|
||||
region: "HovedstadenOgBornholm",
|
||||
"work-hours": "FullTime",
|
||||
duration: "Permanent",
|
||||
"job-type": "Ordinaert",
|
||||
"postal-code": "2100",
|
||||
radius: 25,
|
||||
"occupation-area": "10000",
|
||||
"occupation-group": "10060",
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
function apiResponse(): SearchApiResponse {
|
||||
return {
|
||||
totalJobAdCount: 2,
|
||||
searchString: "data engineer",
|
||||
searchFacets: {
|
||||
regions: [{ type: "HovedstadenOgBornholm", jobAdCount: 2 }],
|
||||
workHours: [{ type: "FullTime", jobAdCount: 2 }],
|
||||
employmentDurations: [{ type: "Permanent", jobAdCount: 1 }],
|
||||
occupationAreas: [{ identifier: "10000", jobAdCount: 2 }],
|
||||
countries: [{ label: "Danmark", identifier: "DK", jobAdCount: 2 }],
|
||||
},
|
||||
jobAds: [
|
||||
{
|
||||
jobAdId: "job-1",
|
||||
title: "Data Engineer",
|
||||
hiringOrgName: "Acme",
|
||||
occupation: null,
|
||||
municipality: null,
|
||||
postalCode: null,
|
||||
postalDistrictName: null,
|
||||
country: "Danmark",
|
||||
publicationDate: "2026-07-01T00:00:00+02:00",
|
||||
applicationDeadline: null,
|
||||
applicationDeadlineStatus: null,
|
||||
workHourPartTime: false,
|
||||
isExternal: false,
|
||||
hasLogo: false,
|
||||
logoUrl: null,
|
||||
cvr: null,
|
||||
workPlaceAddress: "",
|
||||
conceptUriDa: "http://example.test/occupation",
|
||||
isSeen: false,
|
||||
isFavorite: false,
|
||||
description: "<p>Search results should not include this bulky HTML.</p>",
|
||||
},
|
||||
{
|
||||
jobAdId: "job-2",
|
||||
title: "Analytics Engineer",
|
||||
hiringOrgName: "Example Co",
|
||||
occupation: "Softwareudvikler",
|
||||
municipality: "København",
|
||||
postalCode: 2100,
|
||||
postalDistrictName: "København Ø",
|
||||
country: "Danmark",
|
||||
publicationDate: "2026-07-02T00:00:00+02:00",
|
||||
applicationDeadline: "2026-08-01T23:59:00+02:00",
|
||||
applicationDeadlineStatus: "ExpirationDate",
|
||||
workHourPartTime: false,
|
||||
isExternal: true,
|
||||
hasLogo: true,
|
||||
logoUrl: "/logo/job-2",
|
||||
cvr: "12345678",
|
||||
workPlaceAddress: "Examplevej 1",
|
||||
isSeen: false,
|
||||
isFavorite: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("Jobnet search normalization", () => {
|
||||
test("builds the API query with required paging and optional filters", () => {
|
||||
expect(buildSearchParams(flags)).toEqual({
|
||||
resultsPerPage: "25",
|
||||
pageNumber: "2",
|
||||
orderType: "BestMatch",
|
||||
searchString: "data engineer",
|
||||
regions: "HovedstadenOgBornholm",
|
||||
workHoursType: "FullTime",
|
||||
employmentDurationType: "Permanent",
|
||||
jobAnnouncementType: "Ordinaert",
|
||||
postalCode: "2100",
|
||||
kmRadius: "25",
|
||||
occupationAreas: "10000",
|
||||
occupationGroups: "10060",
|
||||
});
|
||||
});
|
||||
|
||||
test("omits radius when postal-code is absent", () => {
|
||||
const params = buildSearchParams({ ...flags, "postal-code": undefined });
|
||||
expect(params.postalCode).toBeUndefined();
|
||||
expect(params.kmRadius).toBeUndefined();
|
||||
});
|
||||
|
||||
test("creates the documented output envelope and omits bulky descriptions", () => {
|
||||
const output = createSearchOutput(apiResponse(), flags);
|
||||
|
||||
expect(output.meta).toEqual({
|
||||
totalJobAdCount: 2,
|
||||
pageNumber: 2,
|
||||
resultsPerPage: 25,
|
||||
searchString: "data engineer",
|
||||
});
|
||||
expect(output.facets.regions).toEqual([{ type: "HovedstadenOgBornholm", jobAdCount: 2 }]);
|
||||
expect(output.results).toHaveLength(1);
|
||||
expect(output.results[0]).toMatchObject({
|
||||
jobAdId: "job-1",
|
||||
occupation: null,
|
||||
municipality: null,
|
||||
postalCode: null,
|
||||
applicationDeadline: null,
|
||||
workPlaceAddress: "",
|
||||
});
|
||||
expect("description" in output.results[0]).toBe(false);
|
||||
});
|
||||
|
||||
test("additively emits the /scrape contract fields (company, location, date, deadline, url)", () => {
|
||||
const output = createSearchOutput(apiResponse(), { ...flags, limit: undefined });
|
||||
|
||||
expect(output.results).toHaveLength(2);
|
||||
expect(output.results[0]).toMatchObject({
|
||||
company: "Acme",
|
||||
location: null,
|
||||
date: "2026-07-01",
|
||||
deadline: null,
|
||||
url: "https://jobnet.dk/find-job/job-1",
|
||||
});
|
||||
expect(output.results[1]).toMatchObject({
|
||||
company: "Example Co",
|
||||
location: "København Ø",
|
||||
date: "2026-07-02",
|
||||
deadline: "2026-08-01",
|
||||
url: "https://jobnet.dk/find-job/job-2",
|
||||
});
|
||||
expect(output.results[0].hiringOrgName).toBe("Acme");
|
||||
expect(output.results[1].applicationDeadline).toBe("2026-08-01T23:59:00+02:00");
|
||||
});
|
||||
|
||||
test("maps Jobnet's undisclosed-deadline sentinel (1900-01-01) to null", () => {
|
||||
const response = apiResponse();
|
||||
response.jobAds[0].applicationDeadline = "1900-01-01T00:00:00+01:00";
|
||||
response.jobAds[0].applicationDeadlineStatus = "NotDisclosed";
|
||||
|
||||
const output = createSearchOutput(response, { ...flags, limit: undefined });
|
||||
|
||||
expect(output.results[0].deadline).toBeNull();
|
||||
expect(output.results[1].deadline).toBe("2026-08-01");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { apiFetch, USER_AGENT } from "../src/helpers";
|
||||
|
||||
// Bun's fetch injects an anonymous default User-Agent (Bun/1.3.10) when code
|
||||
// sets none. This CLI should say who is asking, in the honest style jobindex
|
||||
// already uses on htmlFetch ("Mozilla/5.0 (compatible; jobindex-cli/1.0)").
|
||||
// Assert the header is present on every request. Fails on the pre-change code.
|
||||
const originalFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("apiFetch user agent", () => {
|
||||
test("sends a User-Agent header", async () => {
|
||||
let init: RequestInit | undefined;
|
||||
globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => {
|
||||
init = i;
|
||||
return new Response("{}", { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await apiFetch("/search");
|
||||
const headers = init?.headers as Record<string, string> | Headers | undefined;
|
||||
const value =
|
||||
headers instanceof Headers ? headers.get("User-Agent") : headers?.["User-Agent"];
|
||||
expect(value).toBe(USER_AGENT);
|
||||
});
|
||||
});
|
||||
+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"]
|
||||
}
|
||||
Reference in new issue
Block a user