feat: add skills

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

No files matched your search

+200
View File
@@ -0,0 +1,200 @@
---
name: freehire-search
version: 1.0.0
description: >
Use this skill to search live software / tech / data / engineering job listings
across many countries and markets (and remote) via the freehire.me aggregator's
public API, or to look up a specific posting. It aggregates roles from ~50 ATS
platforms into one schema, so a single skill covers many markets — but its faceted
filtering (skills, category, seniority) is tuned tech-first, so scope triggers to
technical roles. Trigger phrases: find a tech job, software job search, developer
jobs, engineering vacancies, data/ML jobs, DevOps roles, remote developer jobs,
"are there any <tech role> jobs in <place>", look up this freehire job posting.
context: fork
enabled: true # set to false to keep this portal installed but have /scrape skip it
allowed-tools: Bash(bun run .agents/skills/freehire-search/cli/src/cli.ts *)
---
# freehire Search Skill
Search live job listings from the **[freehire.me](https://freehire.me)** job
aggregator — an open-source IT job board that normalizes postings from ~50 ATS
platforms across many countries into one schema. No authentication, no API key,
and **zero runtime dependencies** — it runs with just `bun`. The market is chosen
per query via facet flags (`--region`, `--country`), so the same skill works for a
forker in any market out of the box.
> This is a country-agnostic worked example of the repo's job-portal-skill pattern,
> like `linkedin-search`. Unlike the HTML-scraping portals, it queries freehire's
> public JSON API, so results are structured (skills, seniority, region facets)
> rather than parsed from markup.
## ⚠️ Scope: tech-focused
freehire's corpus already includes some non-tech postings (it crawls whole company
career pages), **but its faceted filtering — skills, categories, and seniority
dictionaries — is tuned tech-first today**, so this skill scopes its triggers to
software / data / engineering / tech roles, where the filtering is strong. Non-tech
coverage exists but is still maturing; don't rely on this skill for general
(non-technical) job coverage yet.
## ℹ️ Hosted-service dependency (best-effort, no SLA)
This skill depends on a third-party hosted service, freehire.me. Reads are
**public and unauthenticated** — the same zero-signup bar as `linkedin-search`.
**freehire.me is a personal project but actively maintained; it runs on a
best-effort basis (no formal SLA).** If the API is unreachable, the CLI fails
gracefully — a non-zero exit with a clear error message — so an outage degrades
this source rather than breaking the surrounding workflow.
**Self-hosting / swappable base URL.** The freehire backend is a separate
MIT-licensed repo — [`strelov1/freehire`](https://github.com/strelov1/freehire)
(Go + PostgreSQL + Meilisearch) — that stands up with one command via Docker
Compose (`make up` → API on `:8080`, same `/api/v1/...` paths). The skill honors a
base-URL env var, `FREEHIRE_API_URL` (default `https://freehire.me`), so pointing
it at a local instance is a one-line change:
```bash
FREEHIRE_API_URL=http://localhost:8080 bun run .agents/skills/freehire-search/cli/src/cli.ts search -q "go"
```
Caveat: standing up the *API* is light, but keeping a *full, continuously-fresh*
mirror (millions of postings across ~50 platforms) is resource-heavy — a
self-hoster would either crawl a scoped subset of sources or point the env var back
at the hosted API.
## When to use this skill
- Search for tech job openings by keyword, in a given region/country or remotely —
each result comes back with its **full description**, no per-hit follow-up needed
- Filter by seniority, category, skills, or recency (posted within N days)
- Look one freehire posting up by its slug (including a closed one)
## Commands
### Search job listings
```bash
bun run .agents/skills/freehire-search/cli/src/cli.ts search [-q "<keywords>"] [facet flags]
```
Key flags:
- `--query <text>` / `-q <text>` — keyword search (title, skill, role). Full-text; optional.
- `--jobage <days>` — posted within N days (maps to `posted_within_days`).
- `--page <n>` — 1-indexed page. Default 1.
- `--limit <n>` / `-n <n>` — results per page (API limit). Default 25.
- `--format json|table|plain` — default `json`.
- `--description-format markdown|text|html` — how each result's full description is
rendered. Default `markdown`, which keeps the posting's headings and requirement
lists intact. `json` output only.
**Search results already carry the full description.** This skill queries freehire's
agent search endpoint, which replaces the index's truncated preview with each
posting's complete text, so a search of 20 roles is 1 request rather than 1 + 20.
Do **not** loop `detail` over search hits to read their descriptions — reach for
`detail` only to look one posting up by slug (e.g. from the tracker, or a posting
already closed and therefore absent from search). Full descriptions are verbose:
keep `--limit` modest, and pre-filter on title/company before reading bodies -
or pass `--no-description` for a cheap discovery pass that keeps every other
field and drops the bodies entirely (fetch a shortlisted job's body with
`detail`, or re-run the search without the flag).
Facet filters (values come from freehire's controlled vocabularies; comma-separate for OR within a facet):
- `--region <codes>` — macro-region, e.g. `global`, `eu`, `us`, `apac`, `latam`, `cis`. `--region eu,us`. Use `none` to match jobs whose region could **not** be resolved (see "Partial data" below).
- `--country <codes>` — ISO-3166 alpha-2, e.g. `--country DE,GB`
- `--city <names>` — city name(s), e.g. `--city Berlin`
- `--seniority <levels>` — `junior`, `middle`, `senior`, `staff`, `principal`, `lead`, …
- `--category <cats>` — `backend`, `frontend`, `fullstack`, `devops`, `ml_ai`, `qa`, …
- `--skill <names>` — canonical skill(s), e.g. `--skill go,kubernetes`
- `--company <slug>` — company slug (from a result's `company_slug`)
- `--remote <mode>` — `remote` | `hybrid` | `onsite` (`work_mode` facet)
- `--facet <key=value>` — any other facet param (repeatable), e.g. `--facet salary_min=100000`
> **Location is a facet, not free text.** Unlike `linkedin-search`'s `--location`,
> freehire filters geography through the structured `--region`/`--country`/`--city`
> facets. Discover the live values for a market at
> [`/api/v1/jobs/facets`](https://freehire.me/api/v1/jobs/facets) (append `?q=<role>`
> to scope it) — never invent facet values.
### Fetch full job detail
```bash
bun run .agents/skills/freehire-search/cli/src/cli.ts detail <slug|url> [--format json|plain]
```
`slug` is the `id` from a `search` result (e.g. `golang-zensar-2bxu6dxm`). You may
also pass a full `https://freehire.me/jobs/<slug>` URL. Returns the full (HTML-stripped)
description, skills, region/country, and — when the posting is enriched — seniority,
category, employment type, and salary.
Use it for a posting you already have a slug for — a tracked application, a shared
link, or a closed posting search no longer lists. Re-fetching a hit that `search`
just returned only re-reads a description you already have.
## Usage examples
```bash
# Senior backend roles, table view
bun run .agents/skills/freehire-search/cli/src/cli.ts search -q "backend engineer" --seniority senior --limit 10 --format table
# Remote React roles in the EU
bun run .agents/skills/freehire-search/cli/src/cli.ts search -q "react" --remote remote --region eu --format table
# DevOps roles in Germany posted in the last 14 days
bun run .agents/skills/freehire-search/cli/src/cli.ts search --category devops --country DE --jobage 14 --format table
# ML/AI roles anywhere, fully remote
bun run .agents/skills/freehire-search/cli/src/cli.ts search -q "machine learning" --category ml_ai --remote remote --format table
# Descriptions as plain text instead of Markdown
bun run .agents/skills/freehire-search/cli/src/cli.ts search -q "platform engineer" --limit 5 --description-format text
# Full details for a specific job
bun run .agents/skills/freehire-search/cli/src/cli.ts detail golang-zensar-2bxu6dxm --format plain
```
## Output formats
| Format | Best for |
|--------|----------|
| `json` | Default — programmatic use; the only format carrying each hit's description |
| `table` | Quick human-readable scanning |
| `plain` | Reading a single job's full detail (`detail` command) |
Search JSON is `{ "meta": { "count", "page", "total" }, "results": [...] }`; each
result carries at least `id` (the freehire slug), `title`, `company`, `location`,
`date`, `url`, and `description` (missing values are `null`). `table` and `plain`
omit the description — it would swamp a scannable list. All errors are written to
**stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
## Partial data
Facets are derived per-posting and can be **incomplete** — geography especially.
A job may resolve its `work_mode` (e.g. `remote`) but leave its **region or
country undetermined** when the source's location text is ambiguous (freehire's
dictionaries never guess). So:
- A missing region/country means "not resolved", **not** "not applicable" —
filtering on `--region eu` silently drops jobs whose region wasn't resolved,
even if they are in fact EU. Widen or drop the facet if you need those back.
- There is a dedicated facet value for the unresolved bucket: `--region none`
matches jobs with **no** resolved region — useful to sweep up remote roles that
never pinned a geography. It ORs with real regions, e.g. `--region eu,none`.
- `result.regions` / `countries` / `cities` may be empty arrays for the same
reason; treat empty as unknown, not as "none of the above".
## Notes
- Data is from freehire.me's public API — no credentials required. Only per-user
tracking (apply/save) needs a key, and this skill deliberately does not touch it:
it is **search + detail only**.
- `id` in search results is the freehire `public_slug` — pass it as-is to `detail`.
- `date` is the posting date (`posted_at`); it may be `null` for undated postings.
- Facet values are controlled vocabularies. Use `/api/v1/jobs/facets` to see the
live values (with counts) for a query before filtering.
- The API retries 429/5xx with exponential backoff; an unreachable API exits
non-zero with a clear message (best-effort service, see the dependency note above).
- `search` calls `/api/v1/agent/jobs/search` (public, like the rest). A self-hosted
instance older than that endpoint answers 404, and the CLI reports it as an error
naming the endpoint — never as an empty result set.
+93
View File
@@ -0,0 +1,93 @@
# freehire-cli
CLI for searching the [freehire.me](https://freehire.me) job aggregator across
**many markets** (tech-focused), via its public JSON API.
**Data source**: freehire.me REST API (`/api/v1/agent/jobs/search`, `/api/v1/jobs/facets`, `/api/v1/jobs/{slug}`).
**Authentication**: None required — reads are public (only tracking mutations need a key, and those are out of scope here).
**Dependencies**: None (plain `bun` + `fetch`). `bun install` is optional and only pulls dev type defs.
> **Hosted-service dependency.** This skill talks to freehire.me, a personal
> project maintained on a **best-effort basis with no formal SLA**. If the API is
> unreachable the CLI exits non-zero with a clear error rather than hanging, so an
> outage degrades gracefully instead of breaking the caller. Point `FREEHIRE_API_URL`
> at a self-hosted [freehire](https://github.com/strelov1/freehire) backend to swap
> the source.
## Installation
```bash
cd .agents/skills/freehire-search/cli
bun install # optional — only installs TypeScript dev types
```
The CLI runs without any install because it has zero runtime dependencies.
## Self-hosting / base URL
The base URL defaults to `https://freehire.me` and is overridable with an env var:
```bash
FREEHIRE_API_URL=http://localhost:8080 bun run src/cli.ts search -q "go"
```
The freehire backend is MIT-licensed and stands up with one command via Docker
Compose (`make up` → API on `:8080`, same `/api/v1/...` paths).
## Commands
| Command | Description |
|---------|-------------|
| `search` | Search jobs by keyword and facet filters |
| `detail` | Fetch full detail for a single job by its slug |
`search` accepts `--format json|table|plain` (default `json`); `detail` accepts `--format json|plain`.
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` with exit code `1`.
`search` hits the API's agent endpoint, so every JSON result already carries the
posting's **full** description (Markdown by default, `--description-format
text|html` to change it). `detail` remains for looking a single posting up by
slug — including a closed one, which search does not return.
## Quick examples
```bash
# Senior backend roles, table view
bun run src/cli.ts search -q "backend engineer" --seniority senior --limit 10 --format table
# Remote React roles in the EU
bun run src/cli.ts search -q "react" --remote remote --region eu --format table
# DevOps roles in Germany posted in the last 14 days
bun run src/cli.ts search --category devops --country DE --jobage 14 --format table
# Full detail for one job (slug from a search result's id)
bun run src/cli.ts detail golang-zensar-2bxu6dxm --format plain
```
See `../SKILL.md` for the full flag reference and the hosted-dependency note.
## Search flags
| Flag | Alias | Description |
|------|-------|-------------|
| `--query` | `-q` | Keywords (title / skill / role). Full-text; optional. |
| `--jobage` | | Posted within N days (`posted_within_days`). |
| `--page` | | 1-indexed page. Default 1. |
| `--limit` | `-n` | Results per page (API limit). Default 25. |
| `--region` | | Macro-region(s), comma = OR (e.g. `eu,us`). |
| `--country` | | ISO-3166 alpha-2 code(s). |
| `--city` | | City name(s). |
| `--seniority` | | Seniority level(s). |
| `--category` | | Role category(ies). |
| `--skill` | | Canonical skill(s). |
| `--company` | | Company slug. |
| `--remote` | | `remote` \| `hybrid` \| `onsite` (`work_mode`). |
| `--facet` | | Any other facet as `key=value` (repeatable). |
| `--format` | | `json` \| `table` \| `plain`. |
| `--description-format` | | `markdown` (default) \| `text` \| `html` — how each result's full description is rendered (`json` output only). |
Facet values come from freehire's controlled vocabularies. Discover the live
values (with counts) for a market at
[`/api/v1/jobs/facets`](https://freehire.me/api/v1/jobs/facets), or narrow it,
e.g. `https://freehire.me/api/v1/jobs/facets?q=react`.
+20
View File
@@ -0,0 +1,20 @@
{
"name": "freehire-cli",
"version": "1.0.0",
"description": "CLI for searching the freehire.me job aggregator's public JSON API across many markets (tech-focused) — no authentication, zero runtime dependencies. Base URL is swappable via FREEHIRE_API_URL for self-hosting.",
"type": "module",
"main": "src/cli.ts",
"bin": {
"freehire-search": "src/cli.ts"
},
"scripts": {
"start": "bun run src/cli.ts",
"test": "bun test --timeout 30000",
"typecheck": "tsc --noEmit"
},
"dependencies": {},
"devDependencies": {
"typescript": "^5.4.0",
"@types/bun": "1.3.14"
}
}
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env bun
// Self-contained CLI for searching the freehire.me aggregator's public JSON API.
// No external CLI framework and zero runtime dependencies, so it runs anywhere
// `bun` is available with nothing installed beyond the repo clone.
//
// Hosted-service dependency: reads are public (no API key), but they hit
// freehire.me — a personal project maintained best-effort (no formal SLA). Point
// FREEHIRE_API_URL at a self-hosted freehire backend to swap the source.
import { runSearch, DESCRIPTION_FORMATS, type DescriptionFormat, type SearchOpts } from "./commands/search.js"
import { runDetail, type DetailOpts } from "./commands/detail.js"
import { baseUrl } from "./helpers.js"
interface Flags {
_: string[]
[k: string]: string | boolean | string[]
}
// Short-flag aliases.
const ALIAS: Record<string, string> = { q: "query", n: "limit" }
function parseFlags(argv: string[]): Flags {
const flags: Flags = { _: [] }
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (!a.startsWith("-")) {
;(flags._ as string[]).push(a)
continue
}
const name = a.replace(/^-+/, "")
const key = ALIAS[name] ?? name
const next = argv[i + 1]
// A flag with no following value (or another flag next) is a boolean.
let value: string | boolean = true
if (next !== undefined && !next.startsWith("-")) {
value = next
i++
}
// --facet repeats; collect into an array. Everything else is last-wins.
if (key === "facet") {
const acc = Array.isArray(flags.facet) ? flags.facet : []
if (typeof value === "string") acc.push(value)
flags.facet = acc
} else {
flags[key] = value
}
}
return flags
}
type FlagValue = string | boolean | string[] | undefined
/**
* A flag's string value. A bare flag (set without a value, i.e. `true`) yields
* `whenBare` — e.g. `--remote` alone means work_mode "remote".
*/
function stringFlag(raw: FlagValue, whenBare?: string): string | undefined {
if (typeof raw === "string") return raw
if (raw === true) return whenBare
return undefined
}
/** Split a comma-separated facet value ("eu,us") into a trimmed value list. */
function commaList(raw: FlagValue): string[] {
if (typeof raw !== "string") return []
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
}
const HELP = `freehire-cli — search the freehire.me job aggregator (many markets, tech-focused)
USAGE
bun run src/cli.ts search [-q "<keywords>"] [facet flags] [--format json|table|plain]
bun run src/cli.ts detail <slug|url> [--format json|plain]
SEARCH FLAGS
--query, -q <text> Keywords (title, skill, role). Full-text; optional.
--jobage <days> Posted within N days (maps to posted_within_days).
--page <n> 1-indexed page. Default 1.
--limit, -n <n> Results per page (API limit). Default 25.
--format <fmt> json (default) | table | plain.
--no-description Skip description hydration for a cheap discovery pass
(results keep every other field; detail fetches the body).
--description-format markdown (default) | text | html — how each result's
full description is rendered (json output only).
FACET FILTERS (values from freehire.me's controlled vocabularies; comma = OR)
--region <codes> Macro-region: global, eu, us, apac, latam, cis, ... e.g. --region eu,us
--country <codes> ISO-3166 alpha-2, e.g. --country DE,GB
--city <names> City name(s), e.g. --city Berlin
--seniority <levels> junior, middle, senior, staff, principal, lead, ...
--category <cats> backend, frontend, fullstack, devops, ml_ai, qa, ...
--skill <names> Canonical skill(s), e.g. --skill go,kubernetes
--company <slug> Company slug (from a result's company_slug).
--remote <mode> remote | hybrid | onsite (work_mode facet).
--facet <key=value> Any other facet param (repeatable), e.g. --facet salary_min=100000
DETAIL
<slug|url> A freehire public slug (from a search result's id/slug)
or a full https://freehire.me/jobs/<slug> URL.
EXAMPLES
bun run src/cli.ts search -q "backend engineer" --seniority senior --limit 10 --format table
bun run src/cli.ts search -q "react" --remote remote --region eu --format table
bun run src/cli.ts search --category devops --country DE --jobage 14 --format table
bun run src/cli.ts detail golang-zensar-2bxu6dxm --format plain
Reads are public (no API key). Source: ${baseUrl()} — a personal project,
best-effort, no SLA. Override with FREEHIRE_API_URL to use a self-hosted backend.
`
function parseIntFlag(name: string, raw: string | boolean | string[]): number | null {
const val = parseInt(raw as string, 10)
if (isNaN(val)) {
process.stderr.write(JSON.stringify({ error: `--${name} must be a number, got "${raw}"`, code: "BAD_ARG" }) + "\n")
return null
}
return val
}
// Long-form flag names each command accepts (parseFlags resolves the short
// aliases q/n to these before validation). "help"/"h" pass so `search --help`
// still prints usage.
const KNOWN_FLAGS: Record<string, Set<string>> = {
search: new Set([
"query", "category", "city", "company", "country", "facet", "format", "jobage", "limit",
"page", "region", "remote", "seniority", "skill", "description-format", "no-description", "help", "h",
]),
detail: new Set(["format", "description-format", "help", "h"]),
}
async function main(): Promise<number> {
const argv = process.argv.slice(2)
const flags = parseFlags(argv)
const cmd = (flags._ as string[])[0]
if (!cmd || flags.help || flags.h) {
process.stdout.write(HELP)
return cmd ? 0 : 1
}
// Reject unknown flags instead of silently discarding them: a discarded
// filter changes what the search returns with no 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.
const knownFlags = KNOWN_FLAGS[cmd]
if (knownFlags) {
for (const key of Object.keys(flags)) {
if (key === "_" || knownFlags.has(key)) continue
process.stderr.write(
JSON.stringify({
error: `unknown flag --${key} for '${cmd}' - flags are never silently ignored, because a discarded filter changes what the search returns; see --help for the supported flags`,
code: "UNKNOWN_FLAG",
}) + "\n",
)
return 1
}
}
if (cmd === "search") {
const fmt = (flags.format as string) || "json"
// Validated here rather than server-side: the API answers an unrecognized
// format with raw HTML instead of an error, so a typo would silently change
// the output rather than fail.
const descFmt = stringFlag(flags["description-format"]) ?? "markdown"
if (!DESCRIPTION_FORMATS.includes(descFmt as DescriptionFormat)) {
const supported = DESCRIPTION_FORMATS.join("|")
process.stderr.write(
JSON.stringify({ error: `--description-format must be one of ${supported}, got "${descFmt}"`, code: "BAD_ARG" }) + "\n",
)
return 1
}
for (const name of ["jobage", "page", "limit"] as const) {
if (flags[name] !== undefined) {
const v = parseIntFlag(name, flags[name])
if (v === null) return 1
flags[name] = String(v)
}
}
// Generic --facet key=value list -> param -> values.
const facets: Record<string, string[]> = {}
const rawFacets = Array.isArray(flags.facet) ? flags.facet : []
for (const kv of rawFacets) {
const eq = kv.indexOf("=")
if (eq <= 0) {
process.stderr.write(JSON.stringify({ error: `invalid --facet "${kv}", want key=value`, code: "BAD_ARG" }) + "\n")
return 1
}
const key = kv.slice(0, eq)
const vals = commaList(kv.slice(eq + 1))
facets[key] = (facets[key] ?? []).concat(vals)
}
const opts: SearchOpts = {
query: stringFlag(flags.query),
jobage: flags.jobage ? parseInt(flags.jobage as string, 10) : 9999,
page: flags.page ? Math.max(1, parseInt(flags.page as string, 10)) : 1,
limit: flags.limit ? Math.max(1, parseInt(flags.limit as string, 10)) : 25,
format: (["json", "table", "plain"].includes(fmt) ? fmt : "json") as SearchOpts["format"],
descriptionFormat: descFmt as DescriptionFormat,
includeDescription: flags["no-description"] === undefined,
regions: commaList(flags.region),
countries: commaList(flags.country),
cities: commaList(flags.city),
seniority: commaList(flags.seniority),
category: commaList(flags.category),
skills: commaList(flags.skill),
company: stringFlag(flags.company),
// --remote <mode> takes the given work_mode; a bare --remote means "remote".
workMode: stringFlag(flags.remote, "remote"),
facets,
}
return runSearch(opts)
}
if (cmd === "detail") {
const id = (flags._ as string[])[1]
if (!id) {
process.stderr.write(JSON.stringify({ error: "detail requires a <slug|url>", code: "NO_ID" }) + "\n")
return 1
}
const fmt = (flags.format as string) || "json"
const opts: DetailOpts = { id, format: fmt === "plain" ? "plain" : "json" }
return runDetail(opts)
}
process.stderr.write(JSON.stringify({ error: `Unknown command "${cmd}"`, code: "BAD_CMD" }) + "\n")
return 1
}
main()
.then((code) => process.exit(code))
.catch((e) => {
process.stderr.write(
JSON.stringify({
error: e instanceof Error ? e.message : String(e),
code: "INTERNAL_ERROR",
}) + "\n",
)
process.exit(1)
})
+65
View File
@@ -0,0 +1,65 @@
import { apiGet, normalizeSlug, toDetail, writeError, type FreehireJob, type JobDetailResult } from "../helpers.js"
export interface DetailOpts {
id: string // a freehire public slug or a /jobs/<slug> URL
format: "json" | "plain"
}
/** A human-readable rendering of one job: header, present fields, description. */
function renderPlain(job: JobDetailResult): string {
const lines = [job.title, `${job.company ?? "—"} · ${job.location ?? "—"}`]
const field = (label: string, value: string | null) => {
if (value) lines.push(`${label}: ${value}`)
}
field("Posted", job.date && job.date.slice(0, 10))
field("Seniority", job.seniority)
field("Category", job.category)
field("Employment", job.employment_type)
field("Salary", job.salary)
field("Skills", job.skills.length ? job.skills.join(", ") : null)
lines.push("", job.description ?? "(no description)", "", `URL: ${job.url}`, `slug: ${job.id}`)
return lines.join("\n")
}
export async function runDetail(opts: DetailOpts): Promise<number> {
const slug = normalizeSlug(opts.id)
if (!slug) {
writeError(`could not parse a freehire slug from "${opts.id}"`, "BAD_ID")
return 1
}
try {
const env = await apiGet<FreehireJob>(`/api/v1/jobs/${encodeURIComponent(slug)}`)
if (!env) {
writeError("job not found", "NOT_FOUND")
return 1
}
const job = toDetail(env.data)
if (opts.format === "plain") {
const lines = [
job.title,
`${job.company || "—"} · ${job.location || "—"}`,
job.date ? `Posted: ${job.date.slice(0, 10)}` : "",
job.seniority ? `Seniority: ${job.seniority}` : "",
job.category ? `Category: ${job.category}` : "",
job.employment_type ? `Employment: ${job.employment_type}` : "",
job.salary ? `Salary: ${job.salary}` : "",
job.skills.length ? `Skills: ${job.skills.join(", ")}` : "",
"",
job.description || "(no description)",
"",
`URL: ${job.url}`,
`slug: ${job.id}`,
].filter((l) => l !== "")
process.stdout.write(lines.join("\n") + "\n")
} else {
process.stdout.write(JSON.stringify(job, null, 2) + "\n")
}
return 0
} catch (e) {
writeError(e instanceof Error ? e.message : String(e), "DETAIL_FAILED")
return 1
}
}
+154
View File
@@ -0,0 +1,154 @@
import { apiGet, toResult, writeError, type FreehireJob, type JobResult } from "../helpers.js"
// The agent variant of the job search: the same query, ranking, and facets as the
// web's /jobs/search, but each hit carries the posting's full description instead
// of the search index's truncated preview — so a run reads every result without a
// follow-up `detail` per hit.
const SEARCH_PATH = "/api/v1/agent/jobs/search"
/** How the API renders each result's full description. */
export type DescriptionFormat = "markdown" | "text" | "html"
export const DESCRIPTION_FORMATS: DescriptionFormat[] = ["markdown", "text", "html"]
export interface SearchOpts {
query?: string
jobage: number
page: number
limit: number
format: "json" | "table" | "plain"
descriptionFormat: DescriptionFormat
// Hydrate full description bodies (the documented default). False keeps a
// discovery pass cheap: bodies are ~73% of a default search payload, and
// /scrape pre-filters by title before reading bodies anyway.
includeDescription?: boolean
// Facet filters (already parsed into value lists; empty means unset).
regions: string[]
countries: string[]
cities: string[]
seniority: string[]
category: string[]
skills: string[]
company?: string
workMode?: string // work_mode facet: remote | hybrid | onsite
// Arbitrary facet escape hatch: param -> values, for the long tail of the vocabulary.
facets: Record<string, string[]>
}
function buildQuery(opts: SearchOpts): URLSearchParams {
const p = new URLSearchParams()
if (opts.query) p.set("q", opts.query)
p.set("limit", String(opts.limit))
p.set("offset", String((opts.page - 1) * opts.limit))
p.set("semantic_ratio", "0") // keyword search; the semantic index is opt-in
// The agent endpoint serves the index's truncated preview unless asked to
// rehydrate each hit from the database, so both params travel together -
// unless the caller opted out of hydration entirely (--no-description).
const hydrate = opts.includeDescription !== false
p.set("include_description", hydrate ? "true" : "false")
if (hydrate) p.set("description_format", opts.descriptionFormat)
if (opts.jobage > 0 && opts.jobage < 9999) p.set("posted_within_days", String(opts.jobage))
if (opts.workMode) p.set("work_mode", opts.workMode)
if (opts.company) p.set("company_slug", opts.company)
// Named facets and the generic --facet escape hatch append the same way; values
// are already split into lists, so each becomes one repeated query param.
const facets: Array<[string, string[]]> = [
["regions", opts.regions],
["countries", opts.countries],
["cities", opts.cities],
["seniority", opts.seniority],
["category", opts.category],
["skills", opts.skills],
...Object.entries(opts.facets),
]
for (const [param, values] of facets) {
for (const value of values) p.append(param, value)
}
return p
}
/** The date portion (YYYY-MM-DD) of an ISO timestamp, or "—" when absent. */
function shortDate(date: string | null): string {
return date ? date.slice(0, 10) : "—"
}
// Table columns: header, width, and the cell value. The SLUG column is sized to
// the longest slug so it is never truncated — a cut slug can't be looked up in
// `detail`; the fixed-width columns truncate for scanning.
interface Column {
header: string
width: number
cell: (r: JobResult) => string
}
function renderTable(rows: JobResult[]): string {
if (rows.length === 0) return "No results."
const columns: Column[] = [
{ header: "SLUG", width: Math.max(4, ...rows.map((r) => r.id.length)), cell: (r) => r.id },
{ header: "TITLE", width: 38, cell: (r) => r.title },
{ header: "COMPANY", width: 22, cell: (r) => r.company ?? "—" },
{ header: "LOCATION", width: 20, cell: (r) => r.location ?? "—" },
{ header: "DATE", width: 10, cell: (r) => shortDate(r.date) },
]
const row = (cells: string[]) => cells.map((c, i) => c.slice(0, columns[i].width).padEnd(columns[i].width)).join(" ")
const header = row(columns.map((c) => c.header))
const body = rows.map((r) => row(columns.map((c) => c.cell(r))))
return [header, "-".repeat(header.length), ...body].join("\n")
}
function renderPlain(rows: JobResult[]): string {
if (rows.length === 0) return "No results."
const block = (r: JobResult) =>
[
r.title,
` ${r.company ?? "—"} · ${r.location ?? "—"} · ${shortDate(r.date)}`,
` slug: ${r.id}`,
` ${r.url}`,
].join("\n")
return rows.map(block).join("\n\n")
}
export async function runSearch(opts: SearchOpts): Promise<number> {
try {
const env = await apiGet<FreehireJob[]>(`${SEARCH_PATH}?${buildQuery(opts).toString()}`)
// A 404 here is a missing endpoint, not a missing job: a freehire instance
// older than the agent search surface answers that way, and reporting it as
// an empty result set would hide the misconfiguration behind plausible output.
if (!env) {
writeError(
`${SEARCH_PATH} not found — this freehire instance predates the agent search endpoint; upgrade it or unset FREEHIRE_API_URL to use the hosted API`,
"SEARCH_FAILED",
)
return 1
}
let rows = (env.data ?? []).map(toResult)
// The API currently returns description bodies regardless of
// include_description=false (verified live 2026-08-19), and the cost this
// flag exists to avoid is the ~73% of CLI output the bodies occupy in
// agent context - so the lean mode strips them client-side either way.
if (opts.includeDescription === false) {
rows = rows.map((r) => ({ ...r, description: null }))
}
const total = env.meta?.total ?? rows.length
if (opts.format === "table") {
process.stdout.write(renderTable(rows) + "\n")
} else if (opts.format === "plain") {
process.stdout.write(renderPlain(rows) + "\n")
} else {
process.stdout.write(
JSON.stringify(
{ meta: { count: rows.length, page: opts.page, total }, results: rows },
null,
2,
) + "\n",
)
}
return 0
} catch (e) {
writeError(e instanceof Error ? e.message : String(e), "SEARCH_FAILED")
return 1
}
}
+230
View File
@@ -0,0 +1,230 @@
// Data source: the freehire.me public REST API (JSON, `{data, meta}` envelope).
// Reads are unauthenticated — no API key, the same bar as linkedin-search — and
// unlike the HTML-scraping portals there is no markup to parse: we fetch JSON and
// reshape it into the portal-skill contract's result fields. The base URL is
// swappable via FREEHIRE_API_URL for self-hosting.
export const DEFAULT_BASE_URL = "https://freehire.me"
/** API base URL: FREEHIRE_API_URL (for a self-hosted instance) or the default. */
export function baseUrl(): string {
const raw = (process.env.FREEHIRE_API_URL ?? "").trim()
return (raw || DEFAULT_BASE_URL).replace(/\/+$/, "")
}
export function writeError(error: string, code: string): void {
process.stderr.write(JSON.stringify({ error, code }) + "\n")
}
const UA = "freehire-search-skill/1.0 (+https://freehire.me)"
/** The shared API response envelope: {data, meta, error}. */
export interface Envelope<T> {
data: T
meta?: { total?: number; limit?: number; offset?: number }
error?: string
}
/**
* GET a JSON envelope from the freehire API. Retries 429/5xx (transient server
* states) with backoff; returns `null` on a 404. A connection failure fails fast
* with a clear message — no retry, so an outage degrades this source quickly
* rather than hanging the caller (the graceful-degradation contract).
*/
export async function apiGet<T>(path: string): Promise<Envelope<T> | null> {
const url = `${baseUrl()}${path}`
const maxRetries = 6
let delay = 500
for (let attempt = 0; attempt <= maxRetries; attempt++) {
let response: Response
try {
response = await fetch(url, {
headers: { "User-Agent": UA, Accept: "application/json" },
redirect: "follow",
signal: AbortSignal.timeout(15000),
})
} catch (e) {
// Connection refused / DNS failure / timeout: the API is unreachable.
throw new Error(
`could not reach the freehire API at ${baseUrl()} (${e instanceof Error ? e.message : String(e)})`,
)
}
if (response.status === 429 || response.status >= 500) {
if (attempt === maxRetries) {
throw new Error(`freehire API request failed: ${response.status} ${response.statusText}`)
}
await sleep(delay + Math.floor(Math.random() * 500))
delay = Math.min(delay * 2, 8000)
continue
}
if (response.status === 404) return null
// Read the body once, tolerantly: an error response's JSON gives us its
// `error` message; a 2xx must parse (a malformed one is surfaced, not swallowed).
const body = (await response.json().catch(() => null)) as Envelope<T> | null
if (!response.ok) {
throw new Error(body?.error || `freehire API request failed: ${response.status} ${response.statusText}`)
}
if (!body) throw new Error("freehire API returned an unparseable response body")
return body
}
// Unreachable in practice; the loop returns or throws on the last attempt.
throw new Error("freehire API request failed after retries")
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms))
}
/**
* A freehire job — the fields this skill reads (the wire shape carries more).
*/
export interface FreehireJob {
public_slug: string
source: string
external_id: string
url: string
title: string
company: string
company_slug: string
location: string
description: string
skills: string[]
work_mode?: string
regions: string[]
countries: string[]
cities: string[]
posted_at: string | null
created_at: string | null
// Always present in the wire shape (an unenriched job serializes it as `{}`);
// the individual fields are what may be absent.
enrichment: {
seniority?: string
category?: string
employment_type?: string
salary_min?: number
salary_max?: number
salary_currency?: string
}
}
/**
* A search result in the portal-skill contract shape. `id` is the public_slug
* (what `detail <slug>` consumes) and `date` is the posting date; missing values
* are `null`, never omitted. The extra facet fields are a permitted superset.
*
* `description` is the posting's full text in the format the search asked the API
* for — the agent search endpoint hydrates it server-side, so it arrives already
* rendered and is passed through verbatim rather than run through `cleanHtml`.
*/
export interface JobResult {
id: string
title: string
company: string | null
company_slug: string | null
location: string | null
date: string | null
url: string
work_mode: string | null
regions: string[]
countries: string[]
skills: string[]
description: string | null
}
/** A job detail: the search result plus the cleaned description and enrichment. */
export interface JobDetailResult extends JobResult {
cities: string[]
seniority: string | null
category: string | null
employment_type: string | null
salary: string | null
description: string | null
}
/** Reshape a freehire job into the contract search-result fields. */
export function toResult(j: FreehireJob): JobResult {
return {
id: j.public_slug,
title: j.title || "(untitled)",
company: j.company || null,
company_slug: j.company_slug || null,
location: j.location || null,
date: j.posted_at,
url: j.url,
work_mode: j.work_mode || null,
regions: j.regions,
countries: j.countries,
skills: j.skills,
description: j.description || null,
}
}
/** Reshape a freehire job into the detail result (adds cleaned description + enrichment). */
export function toDetail(j: FreehireJob): JobDetailResult {
const e = j.enrichment
return {
...toResult(j),
cities: j.cities,
seniority: e.seniority || null,
category: e.category || null,
employment_type: e.employment_type || null,
salary: formatSalary(e),
description: cleanHtml(j.description),
}
}
/** Human-readable salary line from the enrichment fields, or null when absent. */
function formatSalary(e: FreehireJob["enrichment"]): string | null {
if (e.salary_min == null && e.salary_max == null) return null
const cur = e.salary_currency ? `${e.salary_currency} ` : ""
if (e.salary_min != null && e.salary_max != null) return `${cur}${e.salary_min}–${e.salary_max}`
return `${cur}${e.salary_min ?? e.salary_max}`
}
function numericEntity(cp: number): string {
return cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : ""
}
function decodeHtmlEntities(text: string): string {
return text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, dec) => numericEntity(parseInt(dec, 10)))
.replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex) => numericEntity(parseInt(hex, 16)))
.replace(/&nbsp;/g, " ")
}
/**
* Strip a freehire description's HTML into readable prose: block/line-break tags
* become newlines, entities are decoded, tags removed. Null for empty input.
*/
export function cleanHtml(html: string | null | undefined): string | null {
if (!html) return null
const withBreaks = html
.replace(/<\s*br\s*\/?>/gi, "\n")
.replace(/<\/(p|li|ul|ol|div|h\d)>/gi, "\n")
const text = decodeHtmlEntities(withBreaks.replace(/<[^>]+>/g, " "))
.replace(/[ \t]+/g, " ")
.replace(/ *\n */g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim()
return text || null
}
/** Extract a freehire public slug from a bare slug or a /jobs/<slug> URL. */
export function normalizeSlug(input: string): string | null {
const trimmed = input.trim()
if (!trimmed) return null
const m = trimmed.match(/\/jobs\/([^/?#]+)/)
if (m) return m[1]
// A bare slug: lowercase alphanumerics and hyphens (no path/scheme).
if (/^[a-z0-9][a-z0-9-]*$/i.test(trimmed)) return trimmed
return null
}
@@ -0,0 +1,96 @@
import { describe, test, expect } from "bun:test";
import { runCLI } from "./helpers";
// These assert on validation error codes that are emitted BEFORE any network
// call (or independently of it), so the suite is network-free: a valid-flag case
// still runs offline because it only checks the ABSENCE of a validation error.
function parsedStderr(stderr: string): { error?: string; code?: string } {
try {
return JSON.parse(stderr);
} catch {
return {};
}
}
describe("freehire CLI flag validation", () => {
describe("numeric flag validation", () => {
for (const name of ["jobage", "page", "limit"]) {
test(`--${name} non-numeric exits 1 with BAD_ARG`, async () => {
const result = await runCLI(["search", `--${name}`, "foo"]);
expect(result.exitCode).not.toBe(0);
const err = parsedStderr(result.stderr);
expect(err.code).toBe("BAD_ARG");
expect(err.error).toMatch(new RegExp(name));
});
}
test("valid integers produce no BAD_ARG", async () => {
const result = await runCLI(["search", "--jobage", "7", "--page", "1", "--limit", "1"]);
expect(parsedStderr(result.stderr).code).not.toBe("BAD_ARG");
});
});
describe("--description-format validation", () => {
test("an unsupported format exits 1 with BAD_ARG", async () => {
const result = await runCLI(["search", "--description-format", "tekst"]);
expect(result.exitCode).not.toBe(0);
const err = parsedStderr(result.stderr);
expect(err.code).toBe("BAD_ARG");
expect(err.error).toMatch(/description-format/);
});
});
describe("--facet validation", () => {
test("a facet without '=' exits 1 with BAD_ARG", async () => {
const result = await runCLI(["search", "--facet", "novalue"]);
expect(result.exitCode).not.toBe(0);
expect(parsedStderr(result.stderr).code).toBe("BAD_ARG");
});
});
describe("detail argument validation", () => {
test("missing slug exits 1 with NO_ID", async () => {
const result = await runCLI(["detail"]);
expect(result.exitCode).not.toBe(0);
expect(parsedStderr(result.stderr).code).toBe("NO_ID");
});
test("an unparseable slug exits 1 with BAD_ID (no network)", async () => {
const result = await runCLI(["detail", "not a slug!"]);
expect(result.exitCode).not.toBe(0);
expect(parsedStderr(result.stderr).code).toBe("BAD_ID");
});
});
describe("command dispatch", () => {
test("unknown command exits 1 with BAD_CMD", async () => {
const result = await runCLI(["frobnicate"]);
expect(result.exitCode).not.toBe(0);
expect(parsedStderr(result.stderr).code).toBe("BAD_CMD");
});
test("no command prints help and exits 1", async () => {
const result = await runCLI([]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toMatch(/USAGE/);
});
});
});
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", "--query", "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");
});
});
+234
View File
@@ -0,0 +1,234 @@
import { afterEach, describe, expect, test } from "bun:test";
import { runSearch } from "../src/commands/search";
import { runDetail } from "../src/commands/detail";
import type { FreehireJob } from "../src/helpers";
const originalFetch = globalThis.fetch;
const originalStdoutWrite = process.stdout.write;
function captureStdout(): { get: () => string } {
let buf = "";
process.stdout.write = ((chunk: string | Uint8Array) => {
buf += chunk.toString();
return true;
}) as typeof process.stdout.write;
return { get: () => buf };
}
/** Stub fetch with a canned response; the return value exposes the URL it was called with. */
function mockFetch(status: number, body: unknown): { url: () => string } {
let requested = "";
globalThis.fetch = (async (input: string | URL | Request) => {
requested = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
return new Response(typeof body === "string" ? body : JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
return { url: () => requested };
}
/** The query params of the URL the mocked fetch was called with. */
function requestedParams(mock: { url: () => string }): URLSearchParams {
return new URL(mock.url()).searchParams;
}
function captureStderr(): { get: () => string; restore: () => void } {
let buf = "";
const original = process.stderr.write;
process.stderr.write = ((chunk: string | Uint8Array) => {
buf += chunk.toString();
return true;
}) as typeof process.stderr.write;
return { get: () => buf, restore: () => (process.stderr.write = original) };
}
function job(overrides: Partial<FreehireJob> = {}): FreehireJob {
return {
public_slug: "backend-engineer-acme-ab12cd34",
source: "greenhouse",
external_id: "acme:1",
url: "https://boards.greenhouse.io/acme/jobs/1",
title: "Backend Engineer",
company: "Acme",
company_slug: "acme",
location: "Berlin, Germany",
description: "<p>Build things</p>",
skills: ["go"],
work_mode: "remote",
regions: ["eu"],
countries: ["de"],
cities: ["Berlin"],
posted_at: "2026-07-06T00:00:00Z",
created_at: "2026-07-06T15:00:00Z",
enrichment: {},
...overrides,
};
}
afterEach(() => {
globalThis.fetch = originalFetch;
process.stdout.write = originalStdoutWrite;
});
const searchOpts = {
jobage: 9999,
page: 1,
limit: 25,
format: "json" as const,
descriptionFormat: "markdown" as const,
regions: [] as string[],
countries: [] as string[],
cities: [] as string[],
seniority: [] as string[],
category: [] as string[],
skills: [] as string[],
facets: {} as Record<string, string[]>,
};
describe("runSearch (mocked fetch)", () => {
test("emits the contract envelope with meta.count/page/total", async () => {
mockFetch(200, { data: [job()], meta: { total: 42, limit: 25, offset: 0 } });
const out = captureStdout();
const code = await runSearch({ ...searchOpts, query: "backend" });
expect(code).toBe(0);
const parsed = JSON.parse(out.get());
expect(parsed.meta).toEqual({ count: 1, page: 1, total: 42 });
expect(parsed.results).toHaveLength(1);
expect(parsed.results[0].id).toBe("backend-engineer-acme-ab12cd34");
expect(parsed.results[0].date).toBe("2026-07-06T00:00:00Z");
});
test("queries the agent endpoint asking for full descriptions", async () => {
const mock = mockFetch(200, { data: [job()], meta: { total: 1 } });
captureStdout();
await runSearch({ ...searchOpts, query: "backend" });
expect(new URL(mock.url()).pathname).toBe("/api/v1/agent/jobs/search");
expect(requestedParams(mock).get("include_description")).toBe("true");
expect(requestedParams(mock).get("description_format")).toBe("markdown");
});
test("skips description hydration when includeDescription is false", async () => {
// A default search hydrates ~20k tokens of description bodies per query,
// while /scrape's Step 2 says to pre-filter by title/snippet before
// reading bodies. --no-description keeps the discovery pass cheap;
// hydration stays the default (review opportunity O1, 2026-08-19).
const mock = mockFetch(200, { data: [job()], meta: { total: 1 } });
const out = captureStdout();
await runSearch({ ...searchOpts, query: "backend", includeDescription: false });
expect(requestedParams(mock).get("include_description")).toBe("false");
expect(requestedParams(mock).get("description_format")).toBeNull();
// The live API ignores include_description=false and sends bodies anyway
// (verified 2026-08-19), so the lean guarantee is enforced client-side.
const parsed = JSON.parse(out.get());
expect(parsed.results[0].description).toBeNull();
});
test("asks for the requested description format", async () => {
const mock = mockFetch(200, { data: [job()], meta: { total: 1 } });
captureStdout();
await runSearch({ ...searchOpts, descriptionFormat: "text", query: "backend" });
expect(requestedParams(mock).get("description_format")).toBe("text");
});
test("carries each hit's description verbatim, in the server's format", async () => {
const markdown = "## About the role\n\n- Write Go\n- Ship things";
mockFetch(200, { data: [job({ description: markdown })], meta: { total: 1 } });
const out = captureStdout();
await runSearch({ ...searchOpts, query: "backend" });
expect(JSON.parse(out.get()).results[0].description).toBe(markdown);
});
test("a hit with no description carries null, not an empty string", async () => {
mockFetch(200, { data: [job({ description: "" })], meta: { total: 1 } });
const out = captureStdout();
await runSearch({ ...searchOpts, query: "backend" });
expect(JSON.parse(out.get()).results[0].description).toBeNull();
});
// A self-hosted freehire predating /agent/jobs/search answers 404, which apiGet
// maps to null. Reporting that as "no results" would hide a broken endpoint
// behind an empty, plausible-looking result set.
test("a 404 from the search endpoint is an error, not an empty result set", async () => {
mockFetch(404, { error: "not found" });
const err = captureStderr();
const out = captureStdout();
const code = await runSearch({ ...searchOpts, query: "backend" });
err.restore();
expect(code).toBe(1);
expect(out.get()).toBe("");
expect(JSON.parse(err.get()).error).toMatch(/agent\/jobs\/search/);
});
test("empty result set yields an empty results array", async () => {
mockFetch(200, { data: [], meta: { total: 0 } });
const out = captureStdout();
const code = await runSearch({ ...searchOpts, query: "nothing-matches-xyz" });
expect(code).toBe(0);
expect(JSON.parse(out.get()).results).toHaveLength(0);
});
test("network failure exits 1 with SEARCH_FAILED", async () => {
globalThis.fetch = (async () => {
throw new Error("ECONNREFUSED");
}) as typeof fetch;
let err = "";
const origErr = process.stderr.write;
process.stderr.write = ((chunk: string | Uint8Array) => {
err += chunk.toString();
return true;
}) as typeof process.stderr.write;
const code = await runSearch({ ...searchOpts, query: "backend" });
process.stderr.write = origErr;
expect(code).toBe(1);
expect(JSON.parse(err).code).toBe("SEARCH_FAILED");
});
});
describe("runDetail (mocked fetch)", () => {
test("prints the reshaped detail with a cleaned description", async () => {
mockFetch(200, { data: job() });
const out = captureStdout();
const code = await runDetail({ id: "backend-engineer-acme-ab12cd34", format: "json" });
expect(code).toBe(0);
const parsed = JSON.parse(out.get());
expect(parsed.id).toBe("backend-engineer-acme-ab12cd34");
expect(parsed.description).toBe("Build things");
expect(parsed.cities).toEqual(["Berlin"]);
});
test("404 exits 1 with NOT_FOUND", async () => {
mockFetch(404, { error: "not found" });
let err = "";
const origErr = process.stderr.write;
process.stderr.write = ((chunk: string | Uint8Array) => {
err += chunk.toString();
return true;
}) as typeof process.stderr.write;
const code = await runDetail({ id: "does-not-exist", format: "json" });
process.stderr.write = origErr;
expect(code).toBe(1);
expect(JSON.parse(err).code).toBe("NOT_FOUND");
});
});
+40
View File
@@ -0,0 +1,40 @@
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[], env: Record<string, string> = {}): Promise<CLIResult> {
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
stdout: "pipe",
stderr: "pipe",
env: { ...process.env, ...env },
});
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}`
);
}
}
+102
View File
@@ -0,0 +1,102 @@
import { describe, test, expect } from "bun:test";
import { cleanHtml, normalizeSlug, toResult, toDetail, type FreehireJob } from "../src/helpers";
function job(overrides: Partial<FreehireJob> = {}): FreehireJob {
return {
public_slug: "backend-engineer-acme-ab12cd34",
source: "greenhouse",
external_id: "acme:1",
url: "https://boards.greenhouse.io/acme/jobs/1",
title: "Backend Engineer",
company: "Acme",
company_slug: "acme",
location: "Berlin, Germany",
description: "<ul><li>Build APIs</li><li>Ship &amp; iterate</li></ul>",
skills: ["go", "kubernetes"],
work_mode: "remote",
regions: ["eu"],
countries: ["de"],
cities: ["Berlin"],
posted_at: "2026-07-06T00:00:00Z",
created_at: "2026-07-06T15:00:00Z",
enrichment: {},
...overrides,
};
}
describe("toResult — reshape into the portal-skill contract", () => {
test("maps public_slug -> id and posted_at -> date", () => {
const r = toResult(job());
expect(r.id).toBe("backend-engineer-acme-ab12cd34");
expect(r.date).toBe("2026-07-06T00:00:00Z");
});
test("carries the required contract fields", () => {
const r = toResult(job());
expect(r).toMatchObject({
title: "Backend Engineer",
company: "Acme",
company_slug: "acme",
location: "Berlin, Germany",
url: "https://boards.greenhouse.io/acme/jobs/1",
});
});
test("missing values are null, not omitted", () => {
const r = toResult(job({ company: "", location: "", posted_at: null, work_mode: undefined }));
expect(r.company).toBeNull();
expect(r.location).toBeNull();
expect(r.date).toBeNull();
expect(r.work_mode).toBeNull();
});
});
describe("toDetail — adds cleaned description + enrichment", () => {
test("strips HTML and decodes entities in the description", () => {
const d = toDetail(job());
expect(d.description).toBe("Build APIs\nShip & iterate");
});
test("surfaces enrichment fields and a formatted salary", () => {
const d = toDetail(
job({
enrichment: { seniority: "senior", category: "backend", employment_type: "full_time", salary_min: 90000, salary_max: 120000, salary_currency: "EUR" },
}),
);
expect(d.seniority).toBe("senior");
expect(d.category).toBe("backend");
expect(d.salary).toBe("EUR 90000–120000");
});
test("null enrichment fields when the enrichment object is empty", () => {
const d = toDetail(job({ enrichment: {} }));
expect(d.seniority).toBeNull();
expect(d.salary).toBeNull();
});
});
describe("cleanHtml", () => {
test("preserves paragraph breaks between blocks", () => {
expect(cleanHtml("<p>One</p><p>Two</p>")).toBe("One\nTwo");
});
test("decodes hex numeric entities", () => {
expect(cleanHtml("Caf&#xE9;")).toBe("Café");
});
test("returns null for empty input", () => {
expect(cleanHtml("")).toBeNull();
expect(cleanHtml(null)).toBeNull();
});
});
describe("normalizeSlug", () => {
test("accepts a bare slug", () => {
expect(normalizeSlug("golang-zensar-2bxu6dxm")).toBe("golang-zensar-2bxu6dxm");
});
test("extracts the slug from a /jobs/<slug> URL", () => {
expect(normalizeSlug("https://freehire.me/jobs/golang-zensar-2bxu6dxm")).toBe("golang-zensar-2bxu6dxm");
});
test("rejects a non-slug string", () => {
expect(normalizeSlug("not a slug!")).toBeNull();
expect(normalizeSlug("")).toBeNull();
});
});
@@ -0,0 +1,26 @@
import { afterEach, describe, expect, test } from "bun:test";
import { apiGet } 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("apiGet 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(JSON.stringify({ data: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as unknown as typeof fetch;
await apiGet("/jobs");
expect(init?.signal).toBeInstanceOf(AbortSignal);
});
});
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, test } from "bun:test";
import { apiGet } 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 -> 8s backoff schedule. apiGet's documented graceful-degradation
// contract (connection failures fail fast, no retry) is pinned too.
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("apiGet retry/backoff", () => {
test("retries a 429 and succeeds on the next attempt", async () => {
instantTimers();
const state = stubFetch([
() => new Response("", { status: 429 }),
() => new Response('{"data":[]}', { status: 200 }),
]);
const envelope = await apiGet<unknown[]>("/x");
expect(envelope).not.toBeNull();
expect(state.calls).toBe(2);
});
test("returns the documented null on 404 without retrying", async () => {
const state = stubFetch([() => new Response("", { status: 404 })]);
const envelope = await apiGet("/x");
expect(envelope).toBeNull();
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(apiGet("/x")).rejects.toThrow(/500/);
expect(state.calls).toBe(7);
});
test("fails fast on a connection error - no retry, per the graceful-degradation contract", async () => {
const state = { calls: 0 };
globalThis.fetch = (async () => {
state.calls++;
throw new TypeError("Unable to connect");
}) as unknown as typeof fetch;
await expect(apiGet("/x")).rejects.toThrow(/could not reach the freehire API/);
expect(state.calls).toBe(1);
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["bun"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"]
}
+145
View File
@@ -0,0 +1,145 @@
# freehire.me API reference
The endpoints, parameters, and response shapes this skill depends on. This is the
file to update if the freehire API changes. Base URL defaults to
`https://freehire.me` and is overridable via the `FREEHIRE_API_URL` env var.
## Authentication
None for reads. `GET /api/v1/jobs/*` and `/companies/*` are public; only per-user
tracking mutations (`apply`/`save`/`me`) require a bearer API key, and this skill
does not use them.
Verified against the live API:
| Endpoint | Status |
|----------|--------|
| `GET /api/v1/agent/jobs/search` | 200 |
| `GET /api/v1/jobs/search` | 200 (the web variant; not used by this skill) |
| `GET /api/v1/jobs/facets` | 200 |
| `GET /api/v1/jobs/{slug}` | 200 |
| `GET /api/v1/auth/me` | 401 (auth required — not used here) |
## Envelope
Every response is `{ "data": ..., "meta": {...}, "error": "..." }`. Lists put the
array in `data` and pagination in `meta` (`{ total, limit, offset }`); a single
item puts the object in `data`. Errors are `{ "error": "<message>" }` with a 4xx/5xx
status (e.g. 404 → `{ "error": "not found" }`).
## `GET /api/v1/agent/jobs/search`
The endpoint the skill's `search` command uses. Full-text + facet search over open
jobs, returning `data: [job, …]` with `meta.total` = the estimated match count.
It runs the **same query** as the web-facing `/api/v1/jobs/search` — same `q`, same
facets, same ranking, same pagination guard (`offset + limit ≤ 10000`) — and differs
in one respect: asked to, it replaces the search index's truncated `description`
preview with the posting's **full** description read from the database. That is what
lets a search of N roles stay one request instead of N + 1.
Two extra parameters control it:
| Param | Maps to CLI flag | Notes |
|-------|------------------|-------|
| `include_description` | (always `true`) | Without it the endpoint serves the index preview, same as the web search. |
| `description_format` | `--description-format` | `markdown` (the skill's default), `text`, or `html`. **An unrecognized value is not an error** — the API falls back to `html`, so the CLI validates the flag itself. |
Hydration is best-effort per hit: a result whose row has vanished from the database
(the index lagging a just-removed job) keeps the preview rather than being dropped,
so `description` is a full text in practice but never guaranteed to be.
A `404` from this path means the instance predates the endpoint (a self-hosted
freehire behind `FREEHIRE_API_URL`), not a missing job; the CLI reports it as an
error naming the path rather than as an empty result set.
## `GET /api/v1/jobs/search`
The web variant of the same search — identical query surface, but `description` is
always the index's truncated preview. The skill does not call it; it is listed here
because the shared parameters below are documented against both.
Query parameters used by the skill:
| Param | Maps to CLI flag | Notes |
|-------|------------------|-------|
| `q` | `--query` / `-q` | Keyword full-text query. |
| `limit` | `--limit` / `-n` | Page size. Default 25 in the CLI. |
| `offset` | (derived) | `offset = (page - 1) * limit`. |
| `semantic_ratio` | (fixed `0`) | Keyword search; the semantic index is opt-in. |
| `posted_within_days` | `--jobage` | Restrict to postings from the last N days. |
| `regions` | `--region` | Repeatable; OR within the facet. Values like `global`, `eu`, `us`, `apac`, `latam`, `cis`. |
| `countries` | `--country` | Repeatable; ISO-3166 alpha-2 (lowercased). |
| `cities` | `--city` | Repeatable; display-name city. |
| `seniority` | `--seniority` | Repeatable; `junior`, `middle`, `senior`, `staff`, … |
| `category` | `--category` | Repeatable; `backend`, `frontend`, `fullstack`, `devops`, `ml_ai`, … |
| `skills` | `--skill` | Repeatable; canonical skill names. |
| `company_slug` | `--company` | Single company. |
| `work_mode` | `--remote` | `remote` \| `hybrid` \| `onsite`. |
| any facet param | `--facet key=value` | Escape hatch for the long tail (e.g. `salary_min`, `visa_sponsorship`, `employment_type`, `english_level`). |
Repeated params (`?seniority=senior&seniority=staff`) are ORed within a facet;
different facets are ANDed (geography ORs into one location group). Deep paging is
bounded server-side (`offset + limit ≤ 10000`).
### Job object (the fields the skill reads)
```jsonc
{
"public_slug": "golang-zensar-2bxu6dxm", // -> result.id, and detail's <slug>
"source": "oracle",
"external_id": "…",
"url": "https://…", // the real posting URL (ATS host)
"title": "GOLANG",
"company": "Zensar",
"company_slug": "zensar",
"location": "India", // free-text ATS location
"description": "- …", // agent search: full text in the requested
// format; elsewhere HTML, stripped client-side
"skills": ["go", "kubernetes", …], // dictionary facet (top-level)
"work_mode": "remote", // may be absent
"regions": ["apac"], // dictionary/hybrid facet
"countries": ["in"],
"cities": [],
"collections": [],
"posted_at": "2026-07-06T00:00:00Z", // -> result.date (nullable)
"created_at": "2026-07-06T15:25:…Z",
"enrichment": { // nested, typed; {} when unenriched
"seniority": "senior",
"category": "backend",
"employment_type": "full_time",
"salary_min": 90000, "salary_max": 120000, "salary_currency": "EUR"
}
}
```
The internal numeric id is deliberately never exposed; `public_slug` is the stable
identifier.
## `GET /api/v1/jobs/{slug}`
A single job by its `public_slug`. Returns the same job object in `data`. A closed
posting is still served here (with a non-null `closed_at`); a missing slug is 404
`{ "error": "not found" }`. The skill's `detail` command maps a 404 to a
`NOT_FOUND` error on stderr.
## `GET /api/v1/jobs/facets`
The market's facet-value distributions under an optional filter — each facet's live
values with counts. `data.facets` is `{ <facet>: { <value>: <count> } }`. This skill
does not call it programmatically, but it is the vocabulary source the SKILL.md
points users to (`?q=<role>` scopes the counts). Example:
`GET /api/v1/jobs/facets?q=react`.
## Parsing notes
- The response is JSON, so there is no HTML card parsing (unlike the scraping
portals). The only markup handling left client-side is `detail`'s: `/jobs/{slug}`
serves HTML, which `cleanHtml` (`cli/src/helpers.ts`) strips into readable text.
Search descriptions arrive already rendered by the API and are passed through
verbatim — stripping them again would undo the Markdown structure.
- Fetch uses a browser-ish User-Agent, `Accept: application/json`, and exponential
backoff with jitter on 429/5xx (max 6 retries). A connection error (API
unreachable) fails fast with a clear message — no retry, since it is not
transient server load — which is the graceful-degradation contract: an outage
degrades this source quickly instead of hanging the caller.
+180
View File
@@ -0,0 +1,180 @@
---
name: jobbank-search
version: 1.0.0
description: >
Make sure to use this skill whenever the user mentions anything related to job
searching on Akademikernes Jobbank, jobbank.dk, or looking for academic or
highly educated positions in Denmark — even if they don't mention jobbank.dk
explicitly. Also invoke this skill for questions about Danish job listings,
graduate trainee positions, Ph.d. jobs, or finding work in specific industries
or regions in Denmark. Trigger phrases include:
jobbank, akademikernes jobbank, jobs denmark, academic jobs denmark, find job
denmark, highly educated jobs, graduate job denmark, trainee position denmark,
ph.d. position denmark, postdoc denmark, studiejob, fuldtidsjob, deltidsjob,
vikariat, freelance job, praktikplads, job søgning, jobsøgning, søg job,
ledige stillinger, nye jobs, it jobs denmark, engineering jobs denmark,
marketing jobs denmark, finance jobs denmark, healthcare jobs denmark,
remote job denmark, fjernarbejde, job københavn, job aarhus, job odense,
nyuddannede job, job til nyuddannede, international job denmark,
jobbank søgning, find stilling, data scientist job, software developer job,
projektleder stilling, konsulent job, data analyse job.
context: fork
enabled: false # Danish demo portal - ships opt-in; /setup enables it when your market is Denmark, or set true here yourself
allowed-tools: Bash(bun run .agents/skills/jobbank-search/cli/src/cli.ts *)
---
# Jobbank Search Skill
Search live Danish job listings from [Akademikernes Jobbank](https://jobbank.dk) — Denmark's primary job portal for highly educated candidates. Uses the RSS feed for search (up to 100 results) and JSON-LD parsing for detailed job information. Jobbank may block automated requests with Cloudflare bot protection; if that happens, report the portal as unavailable and use WebSearch fallback instead of retrying.
## When to use this skill
Invoke this skill when the user wants to:
- Search for jobs, positions, or career opportunities in Denmark
- Find academic, graduate, trainee, Ph.d., or postdoc positions
- Look for jobs by keyword, industry, location, education background, or work function
- Find remote or hybrid positions in Denmark
- Get full details for a specific job posting on jobbank.dk
- Check what positions are available at a specific company on jobbank.dk
- Browse jobs suitable for new graduates or people with international backgrounds
## Commands
### Search jobs
```bash
bun run .agents/skills/jobbank-search/cli/src/cli.ts search [flags]
```
Key flags:
- `--key <text>` — keyword search (title, company, keyword)
- `--exclude <text>` — exclude keywords from results
- `--type <code>` — job type: `3`=Fuldtidsjob, `6`=Graduate/trainee, `13`=Deltidsjob, `8`=Vikariat, `12`=Ph.d. & Postdoc, `11`=Freelance, `9`=Praktikplads, `4`=Studiejob (repeatable)
- `--location <code>` — region: `2`=Storkøbenhavn, `8`=Østjylland (Aarhus), `7`=Midtjylland, `6`=Nordjylland, `13`=Fyn (repeatable)
- `--work-area <code>` — function: `31`=IT-Software, `43`=Data & Analyse, `26`=Ledelse, `29`=Marketing (repeatable)
- `--industry <code>` — sector: `10331`=IT & Tele, `10442`=Forskning & Uddannelse, `10358`=Finans (repeatable)
- `--education <code>` — education field: `24`=IT, `21`=Økonomi & Revision, `34`=Samfundsvidenskab (repeatable)
- `--remote <value>` — `helt` (fully remote) or `delvist` (partially remote)
- `--suitable-for <code>` — `2`=Nyuddannede, `4`=International baggrund, `5`=Erfarne
- `--company <id>` — filter by company ID
- `--since <YYYY-MM-DD>` — jobs posted on or after this date
- `--limit <n>` — cap results returned by CLI
- `--format json|table|plain`
> **RSS limitation:** The RSS feed returns max 100 items per request. No pagination is available via RSS. `meta.total` shows the true count; `results` is capped at 100.
### Full job detail
```bash
bun run .agents/skills/jobbank-search/cli/src/cli.ts detail <id> [--format json|plain]
```
`id` is the numeric job ID from `search` results. Fetches the job page and parses the embedded Schema.org `JobPosting` JSON-LD for structured data.
---
## How to use effectively
**Start with `search`, then use `detail` for full description.**
1. Use `search` with `--key` and/or filters to find matching jobs with IDs
2. Call `detail <id>` to get the full HTML job description, exact deadline, and company details
**Use repeatable flags for multi-value filters.** Most filter flags can be repeated to match any of the values:
```bash
# IT or Finance industry, Copenhagen or Aarhus
bun run .agents/skills/jobbank-search/cli/src/cli.ts search \
--industry 10331 --industry 10358 \
--location 2 --location 8
```
**Filter codes are documented in the README** at `skills/jobbank-search/cli/README.md`.
---
## Usage examples
### Find data scientist jobs in Copenhagen
```bash
bun run .agents/skills/jobbank-search/cli/src/cli.ts search \
--key "data scientist" \
--location 2 \
--format table
```
### Graduate trainee positions for new graduates
```bash
bun run .agents/skills/jobbank-search/cli/src/cli.ts search \
--type 6 \
--suitable-for 2 \
--format table
```
### Remote IT software jobs
```bash
bun run .agents/skills/jobbank-search/cli/src/cli.ts search \
--work-area 31 \
--remote helt \
--format table
```
### Ph.d. and postdoc positions in research
```bash
bun run .agents/skills/jobbank-search/cli/src/cli.ts search \
--type 12 \
--industry 10442 \
--format table
```
### Recent full-time jobs posted since March 1
```bash
bun run .agents/skills/jobbank-search/cli/src/cli.ts search \
--type 3 \
--since 2026-03-01 \
--format table
```
### Full details for a specific job
```bash
bun run .agents/skills/jobbank-search/cli/src/cli.ts detail 1234567 --format plain
```
### IT jobs in Aarhus or Copenhagen
```bash
bun run .agents/skills/jobbank-search/cli/src/cli.ts search \
--key developer \
--location 2 --location 8 \
--work-area 31 \
--format table
```
---
## Output formats
| Format | Best for |
|--------|----------|
| `json` | Default — programmatic use, passing IDs to `detail` |
| `table` | Quick human-readable list of results |
| `plain` | Single-job detail views (`detail` command) |
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
---
## Notes
- Data is from the public jobbank.dk RSS feed and HTML pages. Jobbank may still block automated CLI requests with Cloudflare bot protection; treat that as a portal availability failure and fall back to WebSearch.
- RSS feed returns max 100 results per query. For higher counts, `meta.total` shows the true total.
- The `detail` command fetches a full job page and extracts the JSON-LD structured data block.
- `location` values are region codes (e.g. `2` = Storkøbenhavn), not city names.
- All filter codes are documented in `skills/jobbank-search/cli/README.md`.
+400
View File
@@ -0,0 +1,400 @@
# jobbank-cli
CLI for [Akademikernes Jobbank](https://jobbank.dk) — Denmark's job portal for highly educated candidates.
**Data sources:**
- **RSS feed**: `https://jobbank.dk/job/rss?{params}` — 100 items max, all search filters work
- **Job detail**: `https://jobbank.dk/job/{id}/` — JSON-LD (`Schema.org JobPosting`) embedded in page HTML
**Authentication**: None required. A browser User-Agent header is sent, but Jobbank may still block automated requests with Cloudflare bot protection. In that case the CLI exits with a clear error and callers should use a WebSearch fallback rather than retrying.
**Format**: RSS XML (search), HTML with embedded JSON-LD (detail).
---
## Installation
```bash
cd skills/jobbank-search/cli
bun install
```
---
## Commands
| Command | Description |
|---------|-------------|
| `search` | Search job listings via RSS feed |
| `detail` | Full detail for a single job posting |
All commands accept `--format json|table|plain` (default: `json`).
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
---
## Filter Reference Tables
### Job Types (`--type` / `cvtype`)
| Code | Label |
|------|-------|
| 3 | Fuldtidsjob |
| 6 | Graduate/trainee |
| 13 | Deltidsjob |
| 8 | Vikariat |
| 12 | Ph.d. & Postdoc |
| 11 | Freelance |
| 15 | Iværksætterprojekt |
| 14 | Event |
| 9 | Praktikplads |
| 4 | Studiejob |
| 5 | Studieprojekt/speciale |
### Location / Region (`--location` / `amt`)
| Code | Label |
|------|-------|
| 2 | Storkøbenhavn |
| 3 | Nordsjælland |
| 14 | Østsjælland |
| 4 | Vestsjælland |
| 5 | Sydsjælland & Øer |
| 13 | Fyn |
| 12 | Sønderjylland |
| 11 | Sydvestjylland |
| 9 | Vestjylland |
| 10 | Sydøstjylland |
| 7 | Midtjylland |
| 8 | Østjylland (Aarhus) |
| 6 | Nordjylland |
| 20 | Bornholm |
| 21 | Øresundsregionen |
| 22 | Grønland & Færøerne |
| 23 | Udlandet (Sverige) |
| 24 | Udlandet (Norge) |
| 19 | Udlandet (øvrige) |
### Work Area / Function (`--work-area` / `erf`)
| Code | Label |
|------|-------|
| 20 | Administration |
| 38 | Arkitektur & Design |
| 22 | Bank & Forsikring |
| 43 | Data & Analyse |
| 47 | Eksport |
| 41 | Forskning & Udvikling |
| 28 | Human Resources |
| 49 | Indkøb |
| 34 | Internet & Multimedia |
| 32 | IT - Hardware |
| 33 | IT - Netværk & Telekomm. |
| 31 | IT - Software |
| 24 | Jura |
| 35 | Kommunikation, Media & SoMe |
| 46 | Konstruktion & Beregning |
| 37 | Kunst & Kultur |
| 26 | Ledelse & Planlægning |
| 29 | Marketing & Reklame |
| 40 | Medicinal & Sundhed |
| 45 | Naturvidenskab |
| 23 | Organisation & Forening |
| 52 | Politik & Samfund |
| 44 | Produktion |
| 27 | Projektledelse |
| 50 | Rådgivning & Support |
| 30 | Salg |
| 39 | Socialvæsen |
| 42 | Teknik |
| 25 | Topledelse |
| 48 | Transport & Logistik |
| 36 | Undervisning |
| 21 | Økonomi & Forvaltning |
### Education Field (`--education` / `udd`)
| Code | Label |
|------|-------|
| 20 | Administration |
| 43 | Anlæg, Byggeri & Konstruktion |
| 29 | Arkitektur, Kunst & Design |
| 47 | Elektro & Telekommunikation |
| 32 | Fødevarer & Veterinær |
| 38 | Human Resources |
| 28 | Humaniora |
| 24 | IT |
| 23 | Jura |
| 44 | Kemi, Biotek & Materialer |
| 45 | Klima, Miljø & Energi |
| 37 | Landbrug & Natur |
| 22 | Marketing & Business |
| 48 | Maskin & Design |
| 46 | Matematik, Fysik & Nano |
| 31 | Medicinal & Sundhed |
| 30 | Naturvidenskab |
| 41 | Organisation & Ledelse |
| 35 | Produktion, Logistik & Transport |
| 34 | Samfundsvidenskab |
| 25 | Sprog, Media & Kommunikation |
| 33 | Teknik & Teknologi |
| 26 | Undervisning & Pædagogik |
| 21 | Økonomi & Revision |
### Industry (`--industry` / `branche`)
| Code | Label |
|------|-------|
| 10359 | Advokat & Revision |
| 11669 | Byggeri & Anlæg |
| 11634 | Elektronik & Maskin |
| 16791 | Fagforeninger, A-kasser & Pensionskasser |
| 10358 | Finans, Forsikring & Pension |
| 10442 | Forskning & Uddannelse |
| 15407 | Fødevarer & Dagligvarer |
| 10364 | Handel & Service |
| 10331 | IT & Tele |
| 17209 | Klima, Energi & Forsyning |
| 16826 | Kommuner |
| 10341 | Kultur, Medier & Underholdning |
| 10333 | Medicinal, Biotek & Kemi |
| 10363 | Papir, Møbel & Materialer |
| 11626 | Regioner, Sundhed- & Socialvæsen |
| 15586 | Rådgivning & Konsulentservice |
| 10362 | Stat, Politik & Samfund |
| 10440 | Transport |
| 12450 | Vikar & Rekruttering |
### Remote Work (`--remote` / `fjernarbejde`)
| Value | Label |
|-------|-------|
| `helt` | Fully remote |
| `delvist` | Partially remote |
### Suitable For (`--suitable-for` / `andet`)
| Code | Label |
|------|-------|
| 2 | Nyuddannede |
| 4 | International baggrund |
| 5 | Erfarne |
---
## `search` — Search job listings
**Endpoint**: `GET https://jobbank.dk/job/rss?{params}`
```bash
bun run src/cli.ts search [flags]
```
### Flags
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--key` | string | — | Keyword search (title, company, keyword) |
| `--exclude` | string | — | Exclude keywords (`antikey`) |
| `--type` | number | — | Job type code (`cvtype`). Repeatable for multiple: `--type 3 --type 6` |
| `--education` | number | — | Education field code (`udd`). Repeatable. |
| `--location` | number | — | Region code (`amt`). Repeatable. |
| `--work-area` | number | — | Work area / function code (`erf`). Repeatable. |
| `--industry` | number | — | Industry code (`branche`). Repeatable. |
| `--suitable-for` | number | — | Suitable-for code (`andet`). Repeatable. |
| `--company` | number | — | Company ID (`virk`) |
| `--remote` | string | — | Remote work: `helt` or `delvist` |
| `--since` | string | — | Posted on or after date, format `YYYY-MM-DD` (`oprettet`) |
| `--limit` | number | — | Cap total results returned by CLI (client-side) |
| `--format` | string | `json` | Output format: `json`, `table`, `plain` |
> **Important limitation:** The RSS feed returns a maximum of **100 items** per request. There is no pagination via RSS — the `page=` parameter has no effect on the RSS endpoint. If your query matches more than 100 jobs, only the first 100 are returned. The `meta.total` field reflects the true total count (fetched separately from the HTML search page title), while `results` is capped at 100.
> **Multi-value flags**: Flags marked "Repeatable" map to params that accept multiple values in the API (repeated query params). Pass them multiple times: `--type 3 --type 6` sends `cvtype=3&cvtype=6`.
### RSS Parsing
The CLI fetches the RSS feed and parses each `<item>` as follows:
- **id**: extracted from the URL path — `/job/{id}/{company-slug}/{title-slug}` — the first numeric segment after `/job/`
- **title**: from `<title>` (CDATA)
- **description**, **company**, **location**, **jobType**, **deadline**: parsed from the `<description>` field, which has the format: `"JobType hos Company, Location (Ansøgningsfrist: DD.MM.YYYY)"` or `"JobType hos Company, Location (Ansøgningsfrist: løbende)"`
- **url**: from `<link>`
- **posted**: from `<pubDate>`, normalized to ISO 8601
### Example
```bash
bun run src/cli.ts search --key python --location 2 --type 3 --limit 10
bun run src/cli.ts search --key "data scientist" --remote helt
bun run src/cli.ts search --industry 10331 --work-area 31 --format table
bun run src/cli.ts search --education 24 --suitable-for 2 --since 2026-03-01
```
### Response shape
```json
{
"meta": {
"total": 457
},
"results": [
{
"id": "1234567",
"title": "Senior Data Scientist",
"company": "Novo Nordisk",
"location": "Bagsværd",
"jobType": "Fuldtidsjob",
"description": "Fuldtidsjob hos Novo Nordisk, Bagsværd (Ansøgningsfrist: 12.04.2026)",
"url": "https://jobbank.dk/job/1234567/novo-nordisk/senior-data-scientist",
"posted": "2026-03-02T00:00:00+01:00",
"date": "2026-03-02",
"deadline": "2026-04-12"
}
]
}
```
#### Field details
| Field | Type | Notes |
|-------|------|-------|
| `id` | string | Numeric job ID extracted from URL |
| `title` | string | Job title |
| `company` | string | Company name, parsed from description |
| `location` | string | Location string, parsed from description |
| `jobType` | string | Employment type (e.g. "Fuldtidsjob", "Graduate/trainee"), parsed from description |
| `description` | string | Raw RSS description field (single-line summary) |
| `url` | string | Full URL to job posting |
| `posted` | string | Publication date in ISO 8601 |
| `date` | string \| null | Publication date as `YYYY-MM-DD` (derived from `posted`), or `null` if absent |
| `deadline` | string \| null | Application deadline as `YYYY-MM-DD` (converted from the feed's `DD.MM.YYYY`), or `null` if "løbende" / not present |
> `meta.total` is fetched from the HTML page `<title>` in a secondary request (pattern: `"{N} relevante job og karriereopslag"`). If the secondary request fails, `meta.total` is `null`.
---
## `detail` — Full job detail
**Endpoint**: `GET https://jobbank.dk/job/{id}/`
```bash
bun run src/cli.ts detail <id> [--format json|plain]
```
The `id` is the numeric job ID from `search` results (the `id` field). The short URL `https://jobbank.dk/job/{id}/` redirects to the full slug URL and returns HTTP 200.
The CLI fetches the HTML page and extracts the `<script type="application/ld+json">` block containing a Schema.org `JobPosting` object.
### Example
```bash
bun run src/cli.ts detail 1234567
bun run src/cli.ts detail 1234567 --format plain
```
### Response shape
```json
{
"id": "1234567",
"url": "https://jobbank.dk/job/1234567/",
"title": "Senior Data Scientist",
"description": "<p>Full HTML description of the role...</p>",
"datePosted": "2026-03-02",
"deadline": "2026-04-12",
"employmentType": ["FULL_TIME"],
"company": {
"name": "Novo Nordisk",
"logo": "https://jobbank.dk/images/dynamic/company/logo/12345/"
},
"location": {
"streetAddress": "",
"city": "Bagsværd",
"postalCode": "",
"country": "DK"
}
}
```
#### Field details
| Field | Type | Notes |
|-------|------|-------|
| `id` | string | Numeric job ID (from `identifier.value` in JSON-LD) |
| `url` | string | Canonical URL of the job posting |
| `title` | string | Job title |
| `description` | string | Full HTML job description body |
| `datePosted` | string | Publication date in ISO 8601 format (`YYYY-MM-DD`) |
| `deadline` | string \| null | Application deadline (`validThrough` in JSON-LD), ISO 8601, or `null` if absent |
| `employmentType` | string[] | Schema.org employment type values, e.g. `["FULL_TIME"]` |
| `company.name` | string | Hiring organization name |
| `company.logo` | string \| null | URL to company logo, or `null` if absent |
| `location.streetAddress` | string | Street address (may be empty) |
| `location.city` | string | City (may be empty for international jobs) |
| `location.postalCode` | string | Postal code (may be empty) |
| `location.country` | string | Country code (may be empty) |
> `location` fields may be empty strings for international jobs or postings that do not specify a physical location.
---
## Error handling
All errors are written to **stderr** in JSON format and exit with code `1`:
```json
{ "error": "Job not found", "code": "NOT_FOUND" }
{ "error": "Jobbank is blocking automated requests with Cloudflare bot protection. Skip this portal or use the WebSearch fallback.", "code": "API_ERROR" }
{ "error": "No JSON-LD found on job page", "code": "PARSE_ERROR" }
{ "error": "--key or at least one filter is required", "code": "MISSING_REQUIRED" }
```
---
## Implementation notes
### User-Agent
All HTTP requests include a browser User-Agent header:
```
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
```
This is not guaranteed to bypass Cloudflare bot protection. If Jobbank returns a Cloudflare challenge page, the CLI reports that condition and callers should skip the portal or use a WebSearch fallback.
### RSS description parsing
The RSS `<description>` field follows this pattern:
```
Fuldtidsjob, Graduate/trainee hos Novo Nordisk, Bagsværd (Ansøgningsfrist: 12.04.2026)
Fuldtidsjob hos DTU, Lyngby (Ansøgningsfrist: løbende)
```
Parse strategy:
1. Split on ` hos ` — left side is job type(s), right side is `Company, Location (Ansøgningsfrist: Deadline)`
2. From the right side, extract the parenthetical `(Ansøgningsfrist: ...)` for deadline
3. Remaining text is `Company, Location` — split on first `, ` to separate company from location
### JSON-LD extraction
On detail pages, find `<script type="application/ld+json">` containing `"@type": "JobPosting"` and parse the JSON. Map fields:
- `identifier.value` → `id`
- `url` → `url`
- `title` → `title`
- `description` → `description` (HTML)
- `datePosted` → `datePosted`
- `validThrough` → `deadline` (may be absent → `null`)
- `employmentType` → `employmentType` (array)
- `hiringOrganization.name` → `company.name`
- `hiringOrganization.logo` → `company.logo`
- `jobLocation.address.streetAddress` → `location.streetAddress`
- `jobLocation.address.addressLocality` → `location.city`
- `jobLocation.address.postalCode` → `location.postalCode`
- `jobLocation.address.addressCountry` → `location.country`
### Rate limiting
No explicit rate limits are enforced, but Cloudflare is present. Add a 300–500ms delay between sequential requests (e.g. when fetching total count from HTML in addition to the RSS feed). The `search` command makes at most 2 requests (RSS + HTML for total count).
+25
View File
@@ -0,0 +1,25 @@
{
"name": "jobbank-cli",
"version": "1.0.0",
"description": "CLI for Akademikernes Jobbank (jobbank.dk) — job search for highly educated candidates",
"type": "module",
"main": "src/cli.ts",
"bin": {
"jobbank": "src/cli.ts"
},
"scripts": {
"start": "bun run src/cli.ts",
"test": "bun test --timeout 30000",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@bunli/core": "0.9.1",
"@bunli/utils": "0.6.0",
"node-html-parser": "^6.1.13",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/bun": "1.3.14",
"typescript": "^5.4.0"
}
}
+45
View File
@@ -0,0 +1,45 @@
import { createCLI } from "@bunli/core"
import { writeError } from "./helpers.js"
import { search } from "./commands/search.js"
import { detail } from "./commands/detail.js"
const cli = await createCLI({
name: "jobbank-cli",
version: "1.0.0",
description: "CLI for Akademikernes Jobbank (jobbank.dk) — job search for highly educated candidates",
})
const commands = [search, detail]
for (const command of commands) {
cli.command(command)
}
// Reject unknown --flags before dispatch. bunli silently discards them, and a
// silently discarded filter changes what the search returns without any error
// (a wrong flag name once returned an entire portal's database as if it
// matched the query). add-portal.md's contract requires a bogus flag to exit 1
// with a JSON error on stderr; this enforces it for the reference CLIs too.
const argv = process.argv.slice(2)
const invoked = commands.find((c) => (c as { name?: string }).name === argv[0])
if (invoked) {
const known = new Set([
...Object.keys((invoked as { options?: Record<string, unknown> }).options ?? {}),
"help",
"version",
])
for (const token of argv.slice(1)) {
if (token === "--") break
if (token.startsWith("--")) {
const flag = token.slice(2).split("=")[0]
if (!known.has(flag)) {
writeError(
`unknown flag --${flag} for '${argv[0]}' - flags are never silently ignored, because a discarded filter changes what the search returns; see --help for the supported flags`,
"UNKNOWN_FLAG",
)
process.exit(1)
}
}
}
}
await cli.run()
+139
View File
@@ -0,0 +1,139 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { fetchWithUA, parseJobPostingJsonLd, writeError, BASE_URL } from "../helpers.js"
export const detail = defineCommand({
name: "detail",
description: "Full detail for a single job posting",
options: {
format: option(z.enum(["json", "plain"]).default("json"), {
description: "Output format: json, plain",
}),
},
handler: async ({ positional, flags, signal }) => {
if (signal.aborted) return
const id = positional[0]
if (!id) {
writeError("Job ID is required", "MISSING_REQUIRED")
process.exit(1)
}
const url = `${BASE_URL}/job/${id}/`
try {
const response = await fetchWithUA(url)
if (response.status === 404) {
writeError("Job not found", "NOT_FOUND")
process.exit(1)
}
if (!response.ok) {
writeError(`Failed to fetch job page: ${response.status} ${response.statusText}`, "API_ERROR")
process.exit(1)
}
const html = await response.text()
if (signal.aborted) return
const jobPosting = parseJobPostingJsonLd(html)
if (!jobPosting) {
writeError("No JSON-LD found on job page", "PARSE_ERROR")
process.exit(1)
}
// Extract fields
const identifier = jobPosting["identifier"] as Record<string, unknown> | undefined
const jobId = identifier?.["value"] ? String(identifier["value"]) : id
// Check if the returned job ID doesn't match what was requested — indicates not found / redirect to different job
// We skip this check since short URL redirects to the actual job and returns 200
const hiringOrg = jobPosting["hiringOrganization"] as Record<string, unknown> | undefined
const jobLocation = jobPosting["jobLocation"] as Record<string, unknown> | undefined
const address = (jobLocation?.["address"] as Record<string, unknown>) ?? {}
const employmentType = jobPosting["employmentType"]
const empTypeArr: string[] = Array.isArray(employmentType)
? employmentType.map(String)
: employmentType
? [String(employmentType)]
: []
const validThrough = jobPosting["validThrough"]
let deadline: string | null = null
if (validThrough && String(validThrough).length > 0) {
// Normalize to YYYY-MM-DD if it's an ISO datetime
const dtStr = String(validThrough)
deadline = dtStr.substring(0, 10) // take first 10 chars = YYYY-MM-DD
if (deadline === "0001-01-01") deadline = null // invalid date
}
const output = {
id: jobId,
url: String(jobPosting["url"] ?? url),
title: String(jobPosting["title"] ?? ""),
description: String(jobPosting["description"] ?? ""),
datePosted: String(jobPosting["datePosted"] ?? ""),
deadline,
employmentType: empTypeArr,
company: {
name: String(hiringOrg?.["name"] ?? ""),
logo: hiringOrg?.["logo"] ? String(hiringOrg["logo"]) : null,
},
location: {
streetAddress: String(address["streetAddress"] ?? ""),
city: String(address["addressLocality"] ?? ""),
postalCode: String(address["postalCode"] ?? ""),
country: String(address["addressCountry"] ?? ""),
},
}
// Verify the job ID matches if possible — if the page 404'd or redirected to a different job
// For invalid IDs that redirect to a generic page, the JSON-LD may be absent
// We already handle the "No JSON-LD" case above
if (flags.format === "json") {
console.log(JSON.stringify(output, null, 2))
} else {
outputPlain(output)
}
} catch (err) {
if (err instanceof Error && err.message.includes("NOT_FOUND")) {
writeError("Job not found", "NOT_FOUND")
} else {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
}
process.exit(1)
}
},
})
function outputPlain(data: {
id: string
url: string
title: string
description: string
datePosted: string
deadline: string | null
employmentType: string[]
company: { name: string; logo: string | null }
location: { streetAddress: string; city: string; postalCode: string; country: string }
}): void {
console.log(`id: ${data.id}`)
console.log(`title: ${data.title}`)
console.log(`company: ${data.company.name}`)
if (data.company.logo) console.log(`logo: ${data.company.logo}`)
console.log(`location: ${[data.location.streetAddress, data.location.city, data.location.country].filter(Boolean).join(", ")}`)
console.log(`datePosted: ${data.datePosted}`)
console.log(`deadline: ${data.deadline ?? "none"}`)
console.log(`employmentType: ${data.employmentType.join(", ")}`)
console.log(`url: ${data.url}`)
console.log("")
// Strip HTML tags for plain description
const plainDescription = data.description.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
console.log(plainDescription)
}
+202
View File
@@ -0,0 +1,202 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { rssFetch, fetchWithUA, writeError, parseRssDescription, extractJobIdFromUrl, BASE_URL, type RssItem } from "../helpers.js"
export function normalizeSearchItem(item: RssItem): Record<string, unknown> {
const parsed = parseRssDescription(item.description)
const id = extractJobIdFromUrl(item.link)
const posted = item.pubDate ? new Date(item.pubDate).toISOString() : ""
return {
id,
title: item.title,
company: parsed.company,
location: parsed.location,
jobType: parsed.jobType,
description: item.description,
url: item.link,
posted,
date: posted ? posted.slice(0, 10) : null,
deadline: parsed.deadline,
}
}
export const search = defineCommand({
name: "search",
description: "Search job listings via RSS feed",
options: {
key: option(z.string().optional(), {
description: "Keyword search (title, company, keyword)",
}),
exclude: option(z.string().optional(), {
description: "Exclude keywords (antikey)",
}),
type: option(z.union([z.string(), z.array(z.string())]).optional(), {
description: "Job type code (cvtype). Repeatable: --type 3 --type 6",
}),
education: option(z.union([z.string(), z.array(z.string())]).optional(), {
description: "Education field code (udd). Repeatable.",
}),
location: option(z.union([z.string(), z.array(z.string())]).optional(), {
description: "Region code (amt). Repeatable.",
}),
"work-area": option(z.union([z.string(), z.array(z.string())]).optional(), {
description: "Work area / function code (erf). Repeatable.",
}),
industry: option(z.union([z.string(), z.array(z.string())]).optional(), {
description: "Industry code (branche). Repeatable.",
}),
"suitable-for": option(z.union([z.string(), z.array(z.string())]).optional(), {
description: "Suitable-for code (andet). Repeatable.",
}),
company: option(z.coerce.number().int().min(1).optional(), {
description: "Company ID (virk)",
}),
remote: option(z.string().optional(), {
description: "Remote work: helt or delvist (fjernarbejde)",
}),
since: option(z.string().optional(), {
description: "Posted on or after date, format YYYY-MM-DD (oprettet)",
}),
limit: option(z.coerce.number().int().min(1).optional(), {
description: "Cap total results returned by CLI (client-side)",
}),
format: option(z.enum(["json", "table", "plain"]).default("json"), {
description: "Output format: json, table, plain",
}),
},
handler: async ({ flags, signal }) => {
if (signal.aborted) return
// Require at least one filter
const hasFilter =
flags.key ||
flags.exclude ||
flags.type ||
flags.education ||
flags.location ||
flags["work-area"] ||
flags.industry ||
flags["suitable-for"] ||
flags.company !== undefined ||
flags.remote ||
flags.since
if (!hasFilter) {
writeError("--key or at least one filter is required", "MISSING_REQUIRED")
process.exit(1)
}
const params: Record<string, string | string[]> = {}
if (flags.key) params["key"] = flags.key
if (flags.exclude) params["antikey"] = flags.exclude
if (flags.type) {
const vals = Array.isArray(flags.type) ? flags.type : [flags.type]
params["cvtype"] = vals.flatMap((v) => v.split(","))
}
if (flags.education) {
const vals = Array.isArray(flags.education) ? flags.education : [flags.education]
params["udd"] = vals.flatMap((v) => v.split(","))
}
if (flags.location) {
const vals = Array.isArray(flags.location) ? flags.location : [flags.location]
params["amt"] = vals.flatMap((v) => v.split(","))
}
if (flags["work-area"]) {
const vals = Array.isArray(flags["work-area"]) ? flags["work-area"] : [flags["work-area"]]
params["erf"] = vals.flatMap((v) => v.split(","))
}
if (flags.industry) {
const vals = Array.isArray(flags.industry) ? flags.industry : [flags.industry]
params["branche"] = vals.flatMap((v) => v.split(","))
}
if (flags["suitable-for"]) {
const vals = Array.isArray(flags["suitable-for"]) ? flags["suitable-for"] : [flags["suitable-for"]]
params["andet"] = vals.flatMap((v) => v.split(","))
}
if (flags.company !== undefined) params["virk"] = String(flags.company)
if (flags.remote) params["fjernarbejde"] = flags.remote
if (flags.since) params["oprettet"] = flags.since
try {
// Fetch RSS feed
const items = await rssFetch(params)
if (signal.aborted) return
// Also fetch total count from HTML page (secondary request)
let total: number | null = null
try {
// Small delay to be polite
await new Promise((resolve) => setTimeout(resolve, 300))
const searchParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (Array.isArray(value)) {
for (const v of value) searchParams.append(key, v)
} else {
searchParams.append(key, value)
}
}
const htmlUrl = `${BASE_URL}/job/?${searchParams.toString()}`
const htmlResp = await fetchWithUA(htmlUrl)
if (htmlResp.ok) {
const html = await htmlResp.text()
// Extract from <title> tag: "457 relevante job og karriereopslag i Akademikernes Jobbank"
const titleMatch = html.match(/<title[^>]*>\s*(\d[\d.,]*)\s+relevante job/i)
if (titleMatch) {
total = parseInt(titleMatch[1].replace(/[.,]/g, ""), 10)
}
}
} catch {
// Secondary request failed — total stays null
}
// Normalize items
let results = items.map(normalizeSearchItem)
// Apply limit
if (flags.limit !== undefined) {
results = results.slice(0, flags.limit)
}
const output = { meta: { total }, results }
if (flags.format === "json") {
console.log(JSON.stringify(output, null, 2))
} else if (flags.format === "table") {
outputTable(results)
} else {
outputPlain(results)
}
} catch (err) {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
process.exit(1)
}
},
})
function outputTable(results: Array<Record<string, unknown>>): void {
console.log("id title company location deadline")
for (const r of results) {
const id = String(r.id ?? "-").padEnd(9)
const title = String(r.title ?? "-").substring(0, 36).padEnd(36)
const company = String(r.company ?? "-").substring(0, 22).padEnd(22)
const location = String(r.location ?? "-").substring(0, 18).padEnd(18)
const deadline = String(r.deadline ?? "-")
console.log(`${id} ${title} ${company} ${location} ${deadline}`)
}
}
function outputPlain(results: Array<Record<string, unknown>>): void {
for (const r of results) {
console.log(`id: ${r.id}`)
console.log(`title: ${r.title}`)
console.log(`company: ${r.company}`)
console.log(`location: ${r.location}`)
console.log(`jobType: ${r.jobType}`)
console.log(`posted: ${r.posted}`)
console.log(`deadline: ${r.deadline ?? "none"}`)
console.log(`url: ${r.url}`)
console.log("")
}
}
+199
View File
@@ -0,0 +1,199 @@
import { parse as parseHtml } from "node-html-parser"
export const BASE_URL = "https://jobbank.dk"
export const USER_AGENT = "Mozilla/5.0 (compatible; jobbank-cli/1.0)"
export function writeError(error: string, code: string): void {
process.stderr.write(JSON.stringify({ error, code }) + "\n")
}
export async function fetchWithUA(url: string): Promise<Response> {
const maxRetries = 6
let delay = 500
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, {
headers: { "User-Agent": USER_AGENT },
signal: AbortSignal.timeout(15000),
})
if (response.status === 429 || response.status >= 500) {
if (attempt === maxRetries) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`)
}
const jitter = Math.floor(Math.random() * 500)
await new Promise((resolve) => setTimeout(resolve, delay + jitter))
delay = Math.min(delay * 2, 5000)
continue
}
return response
}
throw new Error("Request failed after max retries")
}
export interface RssItem {
title: string
description: string
link: string
pubDate: string
}
function extractCdata(xml: string, tag: string): string {
// Try CDATA first
const cdataRe = new RegExp(`<${tag}><\\!\\[CDATA\\[(.*?)\\]\\]><\\/${tag}>`, "s")
const cdataMatch = xml.match(cdataRe)
if (cdataMatch) return cdataMatch[1].trim()
// Plain content
const plainRe = new RegExp(`<${tag}>(.*?)<\\/${tag}>`, "s")
const plainMatch = xml.match(plainRe)
return plainMatch ? plainMatch[1].trim() : ""
}
function extractLink(xml: string): string {
// <link> in RSS can conflict with atom namespace — extract text node after <link>
// Try CDATA variant first
const cdataMatch = xml.match(/<link><!\[CDATA\[(.*?)\]\]><\/link>/s)
if (cdataMatch) return cdataMatch[1].trim()
// Plain link
const plainMatch = xml.match(/<link>(.*?)<\/link>/s)
if (plainMatch) return plainMatch[1].trim()
// Some RSS feeds put the URL as text after <link> without a closing tag (self-closing style)
// Try matching href in atom:link
return ""
}
function parseRssItems(xml: string): RssItem[] {
const items: RssItem[] = []
// Split on <item> boundaries
const itemMatches = xml.matchAll(/<item>([\s\S]*?)<\/item>/g)
for (const match of itemMatches) {
const itemXml = match[1]
const title = extractCdata(itemXml, "title")
const description = extractCdata(itemXml, "description")
const link = extractLink(itemXml)
const pubDate = extractCdata(itemXml, "pubDate") || itemXml.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() || ""
items.push({ title, description, link, pubDate })
}
return items
}
export async function rssFetch(params: Record<string, string | string[]>): Promise<RssItem[]> {
const searchParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (Array.isArray(value)) {
for (const v of value) {
searchParams.append(key, v)
}
} else {
searchParams.append(key, value)
}
}
const url = `${BASE_URL}/job/rss?${searchParams.toString()}`
const response = await fetchWithUA(url)
if (!response.ok) {
const body = await response.clone().text()
if (response.status === 403 && /just a moment|cloudflare|cf-chl/i.test(body)) {
throw new Error(
"Jobbank is blocking automated requests with Cloudflare bot protection. Skip this portal or use the WebSearch fallback."
)
}
throw new Error(`Failed to fetch RSS feed: ${response.status} ${response.statusText}`)
}
const xml = await response.text()
return parseRssItems(xml)
}
export interface ParsedDescription {
jobType: string
company: string
location: string
deadline: string | null
}
export function parseRssDescription(desc: string): ParsedDescription {
// Format: "JobType hos Company, Location (Ansøgningsfrist: DD.MM.YYYY)"
// or: "JobType hos Company, Location (Ansøgningsfrist: løbende)"
// or multiple types: "Fuldtidsjob, Graduate/trainee hos Company, Location (Ansøgningsfrist: ...)"
let jobType = ""
let company = ""
let location = ""
let deadline: string | null = null
const hosIdx = desc.indexOf(" hos ")
if (hosIdx === -1) {
// Can't parse — return desc as company
return { jobType: "", company: desc, location: "", deadline: null }
}
jobType = desc.substring(0, hosIdx).trim()
let rest = desc.substring(hosIdx + 5) // skip " hos "
// Extract deadline from parenthetical at the end
const deadlineMatch = rest.match(/\(Ans[øo]gningsfrist:\s*(.*?)\)\s*$/)
if (deadlineMatch) {
const deadlineStr = deadlineMatch[1].trim()
if (deadlineStr.toLowerCase() === "løbende" || deadlineStr.toLowerCase() === "lobende") {
deadline = null
} else {
// The feed writes DD.MM.YYYY; the /scrape contract (and this CLI's own
// detail command) use YYYY-MM-DD. Convert the known shape; anything else
// passes through so an unexpected value stays visible downstream.
const dmy = deadlineStr.match(/^(\d{2})\.(\d{2})\.(\d{4})$/)
deadline = dmy ? `${dmy[3]}-${dmy[2]}-${dmy[1]}` : deadlineStr
}
// Remove the deadline portion from rest
rest = rest.substring(0, deadlineMatch.index).trim()
}
// rest is now "Company, Location"
// Split on first ", " to get company and location
const firstComma = rest.indexOf(", ")
if (firstComma !== -1) {
company = rest.substring(0, firstComma).trim()
location = rest.substring(firstComma + 2).trim()
} else {
company = rest.trim()
location = ""
}
return { jobType, company, location, deadline }
}
export function extractJobIdFromUrl(url: string): string {
// URL format: https://jobbank.dk/job/{id}/{company-slug}/{title-slug}
const match = url.match(/\/job\/(\d+)\//)
return match ? match[1] : ""
}
function findJobPosting(value: unknown): Record<string, unknown> | null {
if (Array.isArray(value)) {
for (const item of value) {
const jobPosting = findJobPosting(item)
if (jobPosting) return jobPosting
}
return null
}
if (!value || typeof value !== "object") return null
const record = value as Record<string, unknown>
if (record["@type"] === "JobPosting") return record
return findJobPosting(record["@graph"])
}
export function parseJobPostingJsonLd(html: string): Record<string, unknown> | null {
const root = parseHtml(html)
const scripts = root.querySelectorAll('script[type="application/ld+json"]')
for (const script of scripts) {
try {
const jobPosting = findJobPosting(JSON.parse(script.text) as unknown)
if (jobPosting) return jobPosting
} catch {
// Invalid JSON-LD should not prevent later scripts from being checked.
}
}
return null
}
@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test";
import { runCLI } from "./helpers";
function parseError(stderr: string): { error: string; code: string } {
return JSON.parse(stderr);
}
describe("Jobbank CLI error contract", () => {
test("search without a filter fails with JSON on stderr", async () => {
const result = await runCLI(["search"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
expect(parseError(result.stderr)).toEqual({
error: "--key or at least one filter is required",
code: "MISSING_REQUIRED",
});
});
test("detail without an ID fails before making a request", async () => {
const result = await runCLI(["detail"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
expect(parseError(result.stderr)).toEqual({
error: "Job ID is required",
code: "MISSING_REQUIRED",
});
});
});
@@ -0,0 +1,82 @@
import { describe, expect, test } from "bun:test";
import { runCLI } from "./helpers";
// All cases fail schema validation (or the required-flag guard) before any
// network request, so the suite is network-free. Regression context: a bare
// z.coerce.number() accepted --limit=-1, and slice(0, -1) then silently
// dropped the last result instead of erroring. The --company filter flag
// also accepted negative and fractional values that were sent raw to the
// portal.
function expectValidationError(result: { exitCode: number; stdout: string; stderr: string }, option: string) {
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
const error = JSON.parse(result.stderr);
expect(error.ok).toBe(false);
expect(error.error.kind).toBe("validation");
expect(error.error.option).toBe(option);
}
describe("Jobbank CLI flag validation", () => {
test("search --limit=-1 is rejected instead of silently dropping the last result", async () => {
const result = await runCLI(["search", "--key", "test", "--limit=-1"]);
expectValidationError(result, "limit");
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
});
test("search --limit=0 is rejected", async () => {
const result = await runCLI(["search", "--key", "test", "--limit=0"]);
expectValidationError(result, "limit");
});
test("search --limit=1.5 is rejected as non-integer", async () => {
const result = await runCLI(["search", "--key", "test", "--limit=1.5"]);
expectValidationError(result, "limit");
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
});
test("search --company=-1 is rejected", async () => {
const result = await runCLI(["search", "--key", "test", "--company=-1"]);
expectValidationError(result, "company");
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
});
test("search --company=1.5 is rejected as non-integer", async () => {
const result = await runCLI(["search", "--key", "test", "--company=1.5"]);
expectValidationError(result, "company");
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
});
test("valid --limit passes schema validation (proven offline via the required-filter guard)", async () => {
const result = await runCLI(["search", "--limit=5"]);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stderr)).toEqual({
error: "--key or at least one filter is required",
code: "MISSING_REQUIRED",
});
});
});
describe("unknown flag rejection", () => {
// add-portal.md's contract: "a bogus flag or missing required arg exits 1
// with a JSON error on stderr". A silently discarded flag is worse than an
// error: on jobdanmark a wrong flag name returned the entire database
// (13,862 results) as if it matched the query (review finding F13,
// 2026-08-19). Rejection happens before dispatch, so these are network-free.
test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => {
const result = await runCLI(["search", "--key", "test", "--bogus-flag", "xyz"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
const error = JSON.parse(result.stderr);
expect(error.code).toBe("UNKNOWN_FLAG");
expect(error.error).toContain("--bogus-flag");
});
test("--query (another portal's free-text flag) is rejected, not treated as no filter", async () => {
const result = await runCLI(["search", "--query", "test"]);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stderr).code).toBe("UNKNOWN_FLAG");
});
});
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test"
import { parseJobPostingJsonLd } from "../src/helpers"
const script = (value: string) => `<script type="application/ld+json">${value}</script>`
describe("parseJobPostingJsonLd", () => {
test("finds a JobPosting inside an @graph", () => {
const html = script(
JSON.stringify({
"@context": "https://schema.org",
"@graph": [
{ "@type": "WebPage", name: "Jobs" },
{ "@type": "JobPosting", title: "Data Engineer" },
],
}),
)
expect(parseJobPostingJsonLd(html)).toEqual({
"@type": "JobPosting",
title: "Data Engineer",
})
})
test("preserves top-level object and array support", () => {
expect(parseJobPostingJsonLd(script('{"@type":"JobPosting","title":"One"}'))?.title).toBe("One")
expect(
parseJobPostingJsonLd(script('[{"@type":"WebPage"},{"@type":"JobPosting","title":"Two"}]'))?.title,
).toBe("Two")
})
test("skips malformed scripts and checks later JSON-LD", () => {
const html = `${script("{not-json")}${script('{"@type":"JobPosting","title":"Valid"}')}`
expect(parseJobPostingJsonLd(html)?.title).toBe("Valid")
})
test("returns null when no JobPosting exists", () => {
expect(parseJobPostingJsonLd(script('{"@graph":[{"@type":"WebPage"}]}'))).toBeNull()
})
})
+39
View File
@@ -0,0 +1,39 @@
import { join } from "path";
const CLI_PATH = join(import.meta.dir, "../src/cli.ts");
export interface CLIResult {
stdout: string;
stderr: string;
exitCode: number;
}
export async function runCLI(args: string[]): Promise<CLIResult> {
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
}
export function parseJSON<T = unknown>(result: CLIResult): T {
if (result.exitCode !== 0) {
throw new Error(
`CLI exited with code ${result.exitCode}. stderr: ${result.stderr}`
);
}
try {
return JSON.parse(result.stdout) as T;
} catch {
throw new Error(
`Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}`
);
}
}
@@ -0,0 +1,23 @@
import { afterEach, describe, expect, test } from "bun:test";
import { fetchWithUA } from "../src/helpers";
// A stalled upstream connection (accepted socket, no response) would otherwise
// hang the CLI forever - fetch has no default timeout. Assert the request
// wrapper carries an AbortSignal timeout. Fails on the pre-fix code (no signal).
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("fetchWithUA request timeout", () => {
test("passes an AbortSignal timeout to fetch", async () => {
let init: RequestInit | undefined;
globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => {
init = i;
return new Response("<rss></rss>", { status: 200 });
}) as unknown as typeof fetch;
await fetchWithUA("https://jobbank.dk/job/rss");
expect(init?.signal).toBeInstanceOf(AbortSignal);
});
});
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, test } from "bun:test";
import { fetchWithUA } from "../src/helpers";
// The portal contract requires backoff on 429/5xx. These tests pin the retry
// loop offline: a stubbed fetch counts attempts, and a stubbed setTimeout
// fires immediately so the exhaustion case does not sleep through the real
// 500ms -> 5s backoff schedule.
//
// fetchWithUA deliberately RETURNS non-retry statuses instead of throwing -
// callers own 4xx handling (e.g. rssFetch's Cloudflare 403 message). The 4xx
// test pins that contract.
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.setTimeout;
afterEach(() => {
globalThis.fetch = originalFetch;
globalThis.setTimeout = originalSetTimeout;
});
function instantTimers() {
globalThis.setTimeout = ((fn: () => void) =>
originalSetTimeout(fn, 0)) as unknown as typeof setTimeout;
}
function stubFetch(responses: Array<() => Response>): { calls: number } {
const state = { calls: 0 };
globalThis.fetch = (async () => {
const i = Math.min(state.calls, responses.length - 1);
state.calls++;
return responses[i]();
}) as unknown as typeof fetch;
return state;
}
describe("fetchWithUA retry/backoff", () => {
test("retries a 429 and succeeds on the next attempt", async () => {
instantTimers();
const state = stubFetch([
() => new Response("", { status: 429 }),
() => new Response("ok", { status: 200 }),
]);
const response = await fetchWithUA("https://jobbank.dk/x");
expect(response.status).toBe(200);
expect(state.calls).toBe(2);
});
test("returns a plain 4xx to the caller without retrying", async () => {
const state = stubFetch([() => new Response("", { status: 403 })]);
const response = await fetchWithUA("https://jobbank.dk/x");
expect(response.status).toBe(403);
expect(state.calls).toBe(1);
});
test("gives up after the initial attempt plus six retries on persistent 5xx", async () => {
instantTimers();
const state = stubFetch([() => new Response("", { status: 500 })]);
await expect(fetchWithUA("https://jobbank.dk/x")).rejects.toThrow(/500/);
expect(state.calls).toBe(7);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { afterEach, describe, expect, test } from "bun:test";
import { rssFetch } from "../src/helpers";
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("rssFetch", () => {
test("reports Cloudflare bot protection clearly", async () => {
globalThis.fetch = (async () =>
new Response("<html><title>Just a moment...</title></html>", {
status: 403,
statusText: "Forbidden",
})) as unknown as typeof fetch;
await expect(rssFetch({ key: "data" })).rejects.toThrow(/Cloudflare bot protection/);
});
});
@@ -0,0 +1,96 @@
import { afterEach, describe, expect, test } from "bun:test";
import {
extractJobIdFromUrl,
parseRssDescription,
rssFetch,
USER_AGENT,
} from "../src/helpers";
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("rssFetch", () => {
test("serializes repeated filters and parses CDATA and plain RSS fields", async () => {
let requestedUrl = "";
let requestedUserAgent = "";
globalThis.fetch = (async (input, init) => {
requestedUrl = String(input);
requestedUserAgent = new Headers(init?.headers).get("User-Agent") ?? "";
return new Response(`<?xml version="1.0"?>
<rss><channel><item>
<title><![CDATA[Data Scientist]]></title>
<description><![CDATA[Fuldtidsjob hos Acme A/S, København (Ansøgningsfrist: 31.07.2026)]]></description>
<link>https://jobbank.dk/job/12345/acme/data-scientist</link>
<pubDate>Mon, 13 Jul 2026 08:00:00 GMT</pubDate>
</item></channel></rss>`);
}) as typeof fetch;
const items = await rssFetch({ key: "data science", cvtype: ["3", "6"] });
const url = new URL(requestedUrl);
expect(url.pathname).toBe("/job/rss");
expect(url.searchParams.get("key")).toBe("data science");
expect(url.searchParams.getAll("cvtype")).toEqual(["3", "6"]);
expect(requestedUserAgent).toBe(USER_AGENT);
expect(items).toEqual([
{
title: "Data Scientist",
description: "Fuldtidsjob hos Acme A/S, København (Ansøgningsfrist: 31.07.2026)",
link: "https://jobbank.dk/job/12345/acme/data-scientist",
pubDate: "Mon, 13 Jul 2026 08:00:00 GMT",
},
]);
});
});
describe("parseRssDescription", () => {
test("separates multiple job types, company, location, and deadline", () => {
expect(
parseRssDescription(
"Fuldtidsjob, Graduate/trainee hos Acme A/S, København (Ansøgningsfrist: 31.07.2026)",
),
).toEqual({
jobType: "Fuldtidsjob, Graduate/trainee",
company: "Acme A/S",
location: "København",
deadline: "2026-07-31",
});
});
test("passes an unrecognized deadline shape through for downstream defensive parsing", () => {
const parsed = parseRssDescription(
"Fuldtidsjob hos Acme A/S, Odense (Ansøgningsfrist: snarest muligt)",
);
expect(parsed.deadline).toBe("snarest muligt");
});
test("normalizes a rolling deadline to null", () => {
expect(
parseRssDescription("Deltidsjob hos Example ApS, Aarhus (Ansøgningsfrist: løbende)"),
).toEqual({
jobType: "Deltidsjob",
company: "Example ApS",
location: "Aarhus",
deadline: null,
});
});
test("keeps an unstructured description as the company fallback", () => {
expect(parseRssDescription("Unstructured feed value")).toEqual({
jobType: "",
company: "Unstructured feed value",
location: "",
deadline: null,
});
});
});
describe("extractJobIdFromUrl", () => {
test("extracts numeric IDs and rejects unsupported URL shapes", () => {
expect(extractJobIdFromUrl("https://jobbank.dk/job/12345/acme/role")).toBe("12345");
expect(extractJobIdFromUrl("https://jobbank.dk/job/not-a-number/acme/role")).toBe("");
});
});
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import { normalizeSearchItem } from "../src/commands/search";
import type { RssItem } from "../src/helpers";
function rssItem(): RssItem {
return {
title: "Data Scientist",
description: "Fuldtidsjob hos Acme A/S, København (Ansøgningsfrist: 31.07.2026)",
link: "https://jobbank.dk/job/12345/acme/data-scientist",
pubDate: "Fri, 14 Aug 2026 09:30:00 +0200",
};
}
describe("Jobbank search normalization", () => {
test("derives the /scrape contract date from posted as YYYY-MM-DD", () => {
const result = normalizeSearchItem(rssItem());
expect(result.posted).toBe("2026-08-14T07:30:00.000Z");
expect(result.date).toBe((result.posted as string).slice(0, 10));
expect(result.date).toBe("2026-08-14");
});
test("emits a null date when pubDate is absent (posted is empty)", () => {
const result = normalizeSearchItem({ ...rssItem(), pubDate: "" });
expect(result.posted).toBe("");
expect(result.date).toBeNull();
});
test("keeps the native fields alongside the contract date (additive)", () => {
const result = normalizeSearchItem(rssItem());
expect(result.company).toBe("Acme A/S");
expect(result.location).toBe("København");
expect(result.url).toBe("https://jobbank.dk/job/12345/acme/data-scientist");
expect(result.deadline).toBe("2026-07-31");
});
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext"],
"types": ["bun-types"],
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "."
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}
+327
View File
@@ -0,0 +1,327 @@
# Jobbank.dk URL Reference
Complete reference for constructing search URLs on Akademikernes Jobbank.
## Base URL
```
https://jobbank.dk/job/
```
## Full URL Pattern
```
https://jobbank.dk/job/?key={search}&antikey={exclude}&cvtype={type_id}&udd={edu_id}&amt={location_id}&erf={workarea_id}&branche={industry_id}&andet={suitability_id}&virk={company}&fjernarbejde={remote}&oprettet={YYYY-MM-DD}&page={num}
```
**Multiple values:** Repeat the parameter name
```
cvtype=3&cvtype=13&amt=2&amt=3
```
## Query Parameters
| Parameter | Description | Format |
|-----------|-------------|--------|
| `key` | Search keywords | URL-encoded string (use `+` for spaces) |
| `antikey` | Exclude keywords | URL-encoded string |
| `cvtype` | Job type ID | Numeric ID (see table below) |
| `udd` | Education area ID | Numeric ID (see table below) |
| `amt` | Location/region ID | Numeric ID (see table below) |
| `erf` | Work area ID | Numeric ID (see table below) |
| `branche` | Industry ID | Numeric ID (see table below) |
| `andet` | Suitability ID | Numeric ID (see table below) |
| `virk` | Company name | URL-encoded string |
| `fjernarbejde` | Remote work | `helt` (fully) or `delvist` (partially) |
| `oprettet` | Posted since date | `YYYY-MM-DD` format |
| `page` | Page number | Integer (1-indexed) |
## Filter Tables
### Job Type (cvtype)
| Label | ID |
|-------|-----|
| Fuldtidsjob (Full-time) | 3 |
| Graduate/trainee | 6 |
| Deltidsjob (Part-time) | 13 |
| Vikariat (Temporary) | 8 |
| Ph.d. & Postdoc | 12 |
| Freelance | 11 |
| Iværksætterprojekt (Entrepreneurship) | 15 |
| Event | 14 |
| Praktikplads (Internship) | 9 |
| Studiejob (Student job) | 4 |
| Studieprojekt/speciale (Study project/thesis) | 5 |
### Education Area (udd)
| Label | ID |
|-------|-----|
| Administration | 20 |
| Anlæg/Byggeri/Konstruktion (Construction) | 43 |
| Arkitektur/Kunst/Design (Architecture/Art/Design) | 29 |
| Elektro/Telekommunikation (Electronics/Telecom) | 47 |
| Fødevarer/Veterinær (Food/Veterinary) | 32 |
| Human Resources | 38 |
| Humaniora (Humanities) | 28 |
| IT | 24 |
| Jura (Law) | 23 |
| Kemi/Biotek/Materialer (Chemistry/Biotech/Materials) | 44 |
| Klima/Miljø/Energi (Climate/Environment/Energy) | 45 |
| Landbrug/Natur (Agriculture/Nature) | 37 |
| Marketing/Business | 22 |
| Maskin/Design (Mechanical/Design) | 48 |
| Matematik/Fysik/Nano (Math/Physics/Nano) | 46 |
| Medicinal/Sundhed (Medicine/Health) | 31 |
| Naturvidenskab (Natural Sciences) | 30 |
| Organisation/Ledelse (Organization/Management) | 41 |
| Produktion/Logistik/Transport (Production/Logistics/Transport) | 35 |
| Samfundsvidenskab (Social Sciences) | 34 |
| Sprog/Media/Kommunikation (Language/Media/Communication) | 25 |
| Teknik/Teknologi (Engineering/Technology) | 33 |
| Undervisning/Pædagogik (Education/Pedagogy) | 26 |
| Økonomi/Revision (Economics/Accounting) | 21 |
### Location/Region (amt)
| Label | ID |
|-------|-----|
| Storkøbenhavn (Greater Copenhagen) | 2 |
| Nordsjælland (North Zealand) | 3 |
| Østsjælland (East Zealand) | 14 |
| Vestsjælland (West Zealand) | 4 |
| Sydsjælland & Øer (South Zealand & Islands) | 5 |
| Fyn (Funen) | 13 |
| Sønderjylland (South Jutland) | 12 |
| Sydvestjylland (Esbjerg) | 11 |
| Vestjylland (West Jutland) | 9 |
| Sydøstjylland (Southeast Jutland) | 10 |
| Midtjylland (Central Jutland) | 7 |
| Østjylland (Aarhus) | 8 |
| Nordjylland (North Jutland) | 6 |
| Bornholm | 20 |
| Øresundsregionen (Øresund Region) | 21 |
| Grønland & Færøerne (Greenland & Faroe Islands) | 22 |
| Udlandet - Sverige (Abroad - Sweden) | 23 |
| Udlandet - Norge (Abroad - Norway) | 24 |
| Udlandet - øvrige (Abroad - other) | 19 |
### Work Area (erf)
| Label | ID |
|-------|-----|
| Administration | 20 |
| Arkitektur/Design (Architecture/Design) | 38 |
| Bank/Forsikring (Banking/Insurance) | 22 |
| Data/Analyse (Data/Analysis) | 43 |
| Eksport (Export) | 47 |
| Forskning/Udvikling (Research/Development) | 41 |
| Human Resources | 28 |
| Indkøb (Procurement) | 49 |
| Internet/Multimedia | 34 |
| IT-Hardware | 32 |
| IT-Netværk/Telekomm. (IT-Network/Telecom) | 33 |
| IT-Software | 31 |
| Jura (Law) | 24 |
| Kommunikation/Media/SoMe (Communication/Media/Social Media) | 35 |
| Konstruktion/Beregning (Construction/Calculation) | 46 |
| Kunst/Kultur (Art/Culture) | 37 |
| Ledelse/Planlægning (Management/Planning) | 26 |
| Marketing/Reklame (Marketing/Advertising) | 29 |
| Medicinal/Sundhed (Medicine/Health) | 40 |
| Naturvidenskab (Natural Sciences) | 45 |
| Organisation/Forening (Organization/Association) | 23 |
| Politik/Samfund (Politics/Society) | 52 |
| Produktion (Production) | 44 |
| Projektledelse (Project Management) | 27 |
| Rådgivning/Support (Consulting/Support) | 50 |
| Salg (Sales) | 30 |
| Socialvæsen (Social Services) | 39 |
| Teknik (Engineering) | 42 |
| Topledelse (Executive Management) | 25 |
| Transport/Logistik (Transport/Logistics) | 48 |
| Undervisning (Education) | 36 |
| Økonomi/Forvaltning (Finance/Administration) | 21 |
### Industry (branche)
| Label | ID |
|-------|-----|
| Advokat/Revision (Law/Accounting) | 10359 |
| Byggeri/Anlæg (Construction) | 11669 |
| Elektronik/Maskin (Electronics/Machinery) | 11634 |
| Fagforeninger/A-kasser/Pensionskasser (Unions/Unemployment funds/Pension funds) | 16791 |
| Finans/Forsikring/Pension (Finance/Insurance/Pension) | 10358 |
| Forskning/Uddannelse (Research/Education) | 10442 |
| Fødevarer/Dagligvarer (Food/Groceries) | 15407 |
| Handel/Service (Trade/Service) | 10364 |
| IT/Tele | 10331 |
| Klima/Energi/Forsyning (Climate/Energy/Utilities) | 17209 |
| Kommuner (Municipalities) | 16826 |
| Kultur/Medier/Underholdning (Culture/Media/Entertainment) | 10341 |
| Medicinal/Biotek/Kemi (Pharmaceuticals/Biotech/Chemistry) | 10333 |
| Papir/Møbel/Materialer (Paper/Furniture/Materials) | 10363 |
| Regioner/Sundhed/Socialvæsen (Regions/Health/Social services) | 11626 |
| Rådgivning/Konsulentservice (Consulting services) | 15586 |
| Stat/Politik/Samfund (Government/Politics/Society) | 10362 |
| Transport | 10440 |
| Vikar/Rekruttering (Temp/Recruitment) | 12450 |
### Suitability (andet)
| Label | ID |
|-------|-----|
| Nyuddannede (New graduates) | 2 |
| Personer med international baggrund (People with international background) | 4 |
| Erfarne (Experienced) | 5 |
### Remote Work (fjernarbejde)
| Label | Value |
|-------|-------|
| Helt hjemmearbejde (Fully remote) | `helt` |
| Delvist hjemmearbejde (Partially remote) | `delvist` |
## Job Detail URL Pattern
```
https://jobbank.dk/job/{id}/{company-slug}/{title-slug}/
```
Example: `https://jobbank.dk/job/12345/novo-nordisk/senior-data-scientist/`
## RSS Feed URL
Every search has an RSS equivalent:
```
https://jobbank.dk/job/rss?{same_query_params}
```
Example:
```
https://jobbank.dk/job/rss?key=python&amt=2&cvtype=3
```
## Pagination
- Pages are 1-indexed (first page is `page=1` or no page parameter)
- Approximately 20 results per page
- Use `&page=2`, `&page=3`, etc. to navigate
## Job Card Extraction
### CSS Selectors
| Element | Selector |
|---------|----------|
| Job card container | `div.job-item` |
| Job ID | `div.job-item[name]` (use `getAttribute("name")`) |
| Job title | `.job-header` |
| Job type/company/location | `.job-teaser` |
| Job description excerpt | `.job-description` |
| Date updated | `.job-date-updated` |
| Application deadline | `.job-date-application` |
| Job detail link | `a[href^="/job/"]` (prepend `https://jobbank.dk`) |
### Working Extraction Code
```javascript
async page => {
const jobs = await page.evaluate(() => {
return Array.from(document.querySelectorAll("div.job-item")).map(item => {
const id = item.getAttribute("name");
const link = item.querySelector("a[href^='/job/']");
const url = link ? "https://jobbank.dk" + link.getAttribute("href") : null;
const header = item.querySelector(".job-header");
const teaser = item.querySelector(".job-teaser");
const desc = item.querySelector(".job-description");
const dateUpdated = item.querySelector(".job-date-updated");
const deadline = item.querySelector(".job-date-application");
return {
id,
title: header ? header.textContent.trim() : null,
teaser: teaser ? teaser.textContent.trim() : null,
description: desc ? desc.textContent.trim() : null,
url,
dateUpdated: dateUpdated ? dateUpdated.textContent.trim() : null,
deadline: deadline ? deadline.textContent.trim() : null,
};
});
});
return JSON.stringify(jobs, null, 2);
}
```
## Examples
### Simple Keyword Search
```
https://jobbank.dk/job/?key=data+scientist
```
### Full-Time IT Jobs in Copenhagen
```
https://jobbank.dk/job/?key=developer&cvtype=3&udd=24&amt=2
```
### Remote Software Development Positions
```
https://jobbank.dk/job/?erf=31&fjernarbejde=helt
```
### Graduate/Trainee Positions in Multiple Cities
```
https://jobbank.dk/job/?cvtype=6&amt=2&amt=8
```
### Project Management Jobs Suitable for New Graduates
```
https://jobbank.dk/job/?key=project+manager&erf=27&andet=2
```
### Jobs in IT/Telecom Industry, Excluding Senior Positions
```
https://jobbank.dk/job/?key=developer&antikey=senior&branche=10331
```
### Data Analysis Jobs Posted in Last 7 Days
```
https://jobbank.dk/job/?key=data+analysis&erf=43&oprettet=2026-02-03
```
### Second Page of Python Jobs in Aarhus
```
https://jobbank.dk/job/?key=python&amt=8&page=2
```
### Specific Company Search (Novo Nordisk)
```
https://jobbank.dk/job/?virk=novo+nordisk
```
### Ph.D. and Postdoc Positions in Natural Sciences
```
https://jobbank.dk/job/?cvtype=12&udd=30
```
## Notes
- **URL encoding:** Use `+` or `%20` for spaces in text parameters (`key`, `antikey`, `virk`)
- **Multiple filters:** Repeat parameter names for multiple values in same category
- **Date format:** Use ISO format `YYYY-MM-DD` for `oprettet` parameter
- **Remote work:** Only two values: `helt` (fully) or `delvist` (partially)
- **Case sensitivity:** Filter IDs are case-sensitive, use exact values from tables
- **Empty results:** Invalid filter combinations may return zero results
- **RSS monitoring:** Use RSS feeds for automated job monitoring and alerts
+229
View File
@@ -0,0 +1,229 @@
---
name: jobdanmark-search
version: 1.0.0
description: >
Make sure to use this skill whenever the user mentions anything related to Danish
job listings, job search in Denmark, finding work in Denmark, or job vacancies on
Jobdanmark — even if they don't explicitly mention jobdanmark.dk. Also invoke this
skill for questions about specific Danish job categories, municipalities, job types,
or salaries in a job-search context. Trigger phrases include:
danish jobs, jobs in denmark, find job denmark, job search denmark, danish job listings,
jobdanmark, job opslag, find job, jobsøgning, ledige stillinger, stillingsopslag,
job i Danmark, fuldtidsjob, deltidsjob, studiejob, praktikplads, elev, fleksjob,
IT job denmark, sygeplejersker job, håndværker job, ingeniør job, pædagog job,
kontor job, leder job, salg job, hotel job, kirke job, job aarhus, job københavn,
job odense, job aalborg, job sjælland, job jylland, job fyn, jobkategorier denmark,
ledige job, ansøgningsfrist, søg job, job opslaget, jobopslag, danish vacancies,
work in denmark, employment denmark, job denmark, jobs near me denmark,
apprentice denmark, internship denmark, part-time denmark, full-time denmark.
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/jobdanmark-search/cli/src/cli.ts *)
---
# Jobdanmark Search Skill
Access live Danish job listings from the Jobdanmark.dk public API. No authentication needed.
Covers ~15,000+ active job listings across 10 categories and all Danish municipalities.
## When to use this skill
Invoke this skill when the user wants to:
- Search for job listings in Denmark (by keyword, location, category, or job type)
- Browse available jobs in a specific Danish city, municipality, zip code, or region
- Filter jobs by employment type (full-time, part-time, student job, apprentice, etc.)
- Look up the full details of a specific job posting including description and deadline
- List all job categories with current live job counts
- Resolve a job search term into a job title ID or category ID for precise filtering
- Find location suggestions (municipalities, zip codes, regions) for job search filters
## Commands
### Search job listings
```bash
bun run .agents/skills/jobdanmark-search/cli/src/cli.ts search [flags]
```
Key flags:
- `--text <keyword>` — free-text search, e.g. `elektriker`, `sygeplejerske`, `softwareudvikler`
- `--category <id>` — category ID (see Categories table below)
- `--jobtitle-id <id>` — job title ID from `autocomplete` results
- `--municipality <name>` — e.g. `Odense`, `København`, `Aarhus`
- `--zip <code>` — zip code, e.g. `5000`, `8000`, `2100`
- `--region <name>` — region name, e.g. `Midtjylland`, `Sjælland`
- `--job-type <types>` — comma-separated: `fuldtid`, `deltid`, `fleksjob`, `elev`, `studiejob`, `praktik`
- `--page <n>` — page number (30 items per page, server-enforced)
- `--limit <n>` — cap total results returned by CLI
- `--format json|table|plain`
> Per-page is fixed at 30 by the API. Use `--page` to paginate.
### Full job detail
```bash
bun run .agents/skills/jobdanmark-search/cli/src/cli.ts detail <slug> [--format json|plain]
```
`slug` is the URL path segment returned as `slug` in `search` results (e.g. `it-chef-soeges-til-rah`).
Returns full structured job data from the job page. The CLI prefers Schema.org JSON-LD when present and falls back to parsing the rendered HTML when Jobdanmark omits JSON-LD.
### List categories with live counts
```bash
bun run .agents/skills/jobdanmark-search/cli/src/cli.ts categories [--format json|table|plain]
```
Returns all 10 job categories with current live job counts. Useful for giving the user an overview of the job market.
### Autocomplete job titles and categories
```bash
bun run .agents/skills/jobdanmark-search/cli/src/cli.ts autocomplete --query "<text>" [--limit <n>]
```
Use this to resolve search terms into precise job title IDs (`--jobtitle-id`) or category IDs (`--category`) for `search`. The `value` field in results is the ID to pass to `search`.
### Suggest locations
```bash
bun run .agents/skills/jobdanmark-search/cli/src/cli.ts locations --query "<text>" [--limit <n>]
```
Returns matching municipalities, zip codes, and regions. Use `value` from results as `--municipality` or `--zip` in `search`.
---
## Categories
| ID | Danish | English |
|----|--------|---------|
| 227972 | Pædagogik, Uddannelse og Forskning | Education, Research |
| 227973 | Håndværk, Industri, Transport og Landbrug | Crafts, Industry, Transport, Agriculture |
| 227974 | Salg, Kommunikation, Marketing, og Design | Sales, Communication, Marketing, Design |
| 227975 | Pleje, Social og Sundhed | Care, Social, Health |
| 227976 | Hotel, Service, Restauration og Sikkerhed | Hotel, Service, Restaurant, Security |
| 227977 | Kontor, Finans og Økonomi | Office, Finance, Economy |
| 227978 | IT, Ingeniør og Energi | IT, Engineering, Energy |
| 227979 | Ledelse, HR og projektstyring | Management, HR, Project Management |
| 543415 | Kirke, Kultur og Underholdning | Church, Culture, Entertainment |
| 227980 | Øvrige job | Other jobs |
---
## How to use effectively
**Resolve locations first.** Use `locations` to find the correct municipality name or zip code before passing them to `search`:
```bash
bun run .agents/skills/jobdanmark-search/cli/src/cli.ts locations --query "Aarhus" --format plain
```
**Resolve job titles for precision.** Use `autocomplete` to get the exact job title ID when the user wants a specific role:
```bash
bun run .agents/skills/jobdanmark-search/cli/src/cli.ts autocomplete --query "sygeplejerske" --format plain
```
**Natural workflow: `search` → `detail`.**
1. Use `search` to get a list of matching jobs with their `slug`.
2. Call `detail <slug>` to get the full job posting with description, deadline, and organization details.
**Use `--format table` for comparisons**, `--format json` for data processing, and `--format plain` for single-record detail views.
**Pagination**: The API returns 30 items per page (server-enforced). Use `--page` to navigate through results. Use `--limit` to cap CLI output regardless of page size.
**Combine filters** for precise results — e.g. `--text` + `--job-type` + `--municipality` all work together in a single search request.
---
## Usage examples
### IT jobs in Copenhagen
```bash
bun run .agents/skills/jobdanmark-search/cli/src/cli.ts search \
--category 227978 \
--municipality "København" \
--job-type fuldtid \
--format table
```
### Nursing jobs anywhere in Denmark
```bash
bun run .agents/skills/jobdanmark-search/cli/src/cli.ts search \
--text "sygeplejerske" \
--category 227975 \
--format table
```
### Student jobs in Aarhus
```bash
bun run .agents/skills/jobdanmark-search/cli/src/cli.ts search \
--municipality "Aarhus" \
--job-type studiejob \
--format table
```
### What job categories are most active right now?
```bash
bun run .agents/skills/jobdanmark-search/cli/src/cli.ts categories --format table
```
### Full details for a specific job posting
```bash
bun run .agents/skills/jobdanmark-search/cli/src/cli.ts detail it-chef-soeges-til-rah --format plain
```
### Find jobs in zip code 8000
```bash
bun run .agents/skills/jobdanmark-search/cli/src/cli.ts search --zip 8000 --format table
```
### What electrician jobs are available?
```bash
bun run .agents/skills/jobdanmark-search/cli/src/cli.ts search \
--text "elektriker" \
--category 227973 \
--job-type "fuldtid,deltid" \
--format table
```
### Apprentice positions in all of Denmark
```bash
bun run .agents/skills/jobdanmark-search/cli/src/cli.ts search \
--job-type elev \
--page 1 \
--format table
```
---
## Output formats
| Format | Best for |
|--------|----------|
| `json` | Default — programmatic use, data processing, passing IDs between commands |
| `table` | Quick human-readable overviews and comparisons |
| `plain` | Single-record detail views (`detail`) |
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
---
## Notes
- All data is from the public Jobdanmark.dk API — no credentials required.
- Pagination is 1-indexed (`--page 1` is the first page). 30 items per page, server-enforced.
- The `detail` command fetches the HTML job page and parses embedded JSON-LD when available, with a rendered-HTML fallback for pages that omit structured data. It does not use a separate JSON API.
- `slug` in search results is extracted from the API's relative `url` field (the path after `/job/`).
- `applicationDeadline` in search results can be `null` (no deadline set).
- Job type values for filters: `fuldtid`, `deltid`, `fleksjob`, `elev`, `studiejob`, `praktik`.
+443
View File
@@ -0,0 +1,443 @@
# jobdanmark-cli
CLI for the [Jobdanmark.dk](https://www.jobdanmark.dk) public job search API.
**Base URL**: `https://jobdanmark.dk`
**Authentication**: None required.
**Format**: All responses are JSON.
---
## Installation
```bash
cd skills/jobdanmark-search/cli
bun install
```
---
## Commands
| Command | Description |
|---------|-------------|
| `search` | Search job listings with filters |
| `detail` | Full detail for a single job posting (by slug) |
| `categories` | List all job categories with live counts |
| `autocomplete` | Suggest job titles and categories for a query |
| `locations` | Suggest municipalities, zip codes, and regions for a query |
All commands accept `--format json|table|plain` (default: `json`).
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
---
## Job Types
| Value | Danish | English |
|-------|--------|---------|
| `fuldtid` | Fuldtid | Full-time |
| `deltid` | Deltid | Part-time |
| `fleksjob` | Fleksjob | Flex job |
| `elev` | Elev | Apprentice |
| `studiejob` | Studiejob | Student job |
| `praktik` | Praktik | Internship |
---
## Categories
| ID | Danish Title |
|----|-------------|
| 227972 | Pædagogik, Uddannelse og Forskning |
| 227973 | Håndværk, Industri, Transport og Landbrug |
| 227974 | Salg, Kommunikation, Marketing, og Design |
| 227975 | Pleje, Social og Sundhed |
| 227976 | Hotel, Service, Restauration og Sikkerhed |
| 227977 | Kontor, Finans og Økonomi |
| 227978 | IT, Ingeniør og Energi |
| 227979 | Ledelse, HR og projektstyring |
| 543415 | Kirke, Kultur og Underholdning |
| 227980 | Øvrige job |
---
## `search` — Search job listings
**Endpoint**: `POST https://jobdanmark.dk/api/jobsearch/search/{page}`
```bash
bun run src/cli.ts search [flags]
```
### Flags
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--text` | string | — | Free-text keyword search (job title, keyword) |
| `--category` | number | — | Category ID (see table above) |
| `--jobtitle-id` | number | — | Job title ID from `autocomplete` results |
| `--municipality` | string | — | Municipality name, e.g. `Odense`, `København` |
| `--zip` | string | — | Zip code, e.g. `5000` |
| `--region` | string | — | Region name |
| `--job-type` | string | — | Comma-separated job types: `fuldtid,deltid,fleksjob,elev,studiejob,praktik` |
| `--page` | number | `1` | Page number (30 items per page, server-enforced) |
| `--limit` | number | — | Cap total results returned by CLI (client-side) |
| `--format` | string | `json` | Output format: `json`, `table`, `plain` |
> **Note**: Per-page is fixed at 30 by the API. There is no `--per-page` flag.
> **Note**: Multiple `--job-type` values are passed as a comma-separated string and split by the CLI into the `jobTypes` array in the request body.
### Example
```bash
bun run src/cli.ts search --text "elektriker" --job-type fuldtid --municipality "Odense"
bun run src/cli.ts search --category 227978 --job-type "fuldtid,deltid" --page 2 --format table
bun run src/cli.ts search --text "sygeplejerske" --zip 8000 --limit 10
```
### Request body sent to API
```json
{
"jobTypes": ["fuldtid"],
"filters": [
{ "type": "freetext", "value": "elektriker", "displayText": "elektriker" },
{ "type": "municipality", "value": "Odense", "displayText": "Odense" }
],
"locationMode": "Text",
"distance": 50
}
```
> **Important**: All filter objects include a `displayText` field (required by the server). The `value` field is an integer for `category` and `jobtitle` filter types; a string for all others.
### Response shape
```json
{
"meta": {
"currentPage": 1,
"totalItems": 15610,
"itemsPrPage": 30,
"totalPages": 521
},
"results": [
{
"title": "IT-chef søges til RAH",
"companyName": "Rah Service A/S",
"companyAddress": "Ndr Ringvej 4 6950 Ringkøbing",
"jobTypes": ["fuldtid"],
"boostJob": true,
"publishedDate": "12-03-2026",
"applicationDeadline": "10-04-2026",
"url": "https://jobdanmark.dk/job/it-chef-soeges-til-rah",
"slug": "it-chef-soeges-til-rah",
"company": "Rah Service A/S",
"location": "Ringkøbing",
"date": "2026-03-12",
"deadline": "2026-04-10"
}
]
}
```
> **Notes**:
> - `url` is normalized to a full URL (CLI prepends `https://jobdanmark.dk` to the relative path from the API).
> - `slug` is extracted from the relative `url` field (the path segment after `/job/`).
> - `applicationDeadline` can be `null`.
> - `publishedDate` format: `"DD-MM-YYYY"`.
> - Presentation-only keys the API sends (`coverImage`, `companyLogo`, `companyLogoSvgMarkup`, `overlayColor`, `silhouetteLogo`) are dropped from search output — they were ~40% of a live payload and an agent can never use them.
> - Every result also carries the cross-portal contract fields `company`, `location`, `date` and `deadline`, derived from `companyName`, the city after the postal code in `companyAddress`, and the day-first dates converted to `YYYY-MM-DD` — `/scrape` Step 2 expects search output to include title, company, location, date, and URL. Native fields are preserved unchanged.
---
## `detail` — Full job posting detail
**Method**: Fetch HTML from `https://jobdanmark.dk/job/{slug}`. The CLI extracts a `<script type="application/ld+json">` JobPosting block when present, and falls back to parsing the rendered job page HTML when Jobdanmark omits JSON-LD.
```bash
bun run src/cli.ts detail <slug> [--format json|plain]
```
The `slug` is the URL path segment returned as `slug` in `search` results (e.g. `it-chef-soeges-til-rah`).
### Example
```bash
bun run src/cli.ts detail it-chef-soeges-til-rah
bun run src/cli.ts detail it-chef-soeges-til-rah --format plain
```
### Response shape
```json
{
"slug": "it-chef-soeges-til-rah",
"url": "https://jobdanmark.dk/job/it-chef-soeges-til-rah",
"title": "IT-chef søges til RAH",
"datePosted": "2026-03-12",
"validThrough": "2026-04-10",
"employmentType": ["FULL_TIME"],
"hiringOrganization": {
"name": "Rah Service A/S",
"logo": "https://jobdanmark.dk/media/k1epc2kk/rah-service-logo.jpg?width=50"
},
"jobLocation": {
"streetAddress": "Ndr Ringvej 4 6950 Ringkøbing",
"addressLocality": "Ringkøbing",
"addressRegion": "Vestjylland",
"postalCode": "6950",
"addressCountry": "DK"
},
"description": "<p>Full HTML description...</p>",
"applyUrl": "https://example.com/apply"
}
```
> **Note**: The `hiringOrganization.logo`, `validThrough`, and `applyUrl` fields may be `null` if not present in the structured data or rendered page. `jobLocation` fields may be `null` if the location data is absent.
---
## `categories` — List categories with live job counts
**Endpoint**: `GET https://jobdanmark.dk/api/categorycount/getcounts`
```bash
bun run src/cli.ts categories [flags]
```
### Flags
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--limit` | number | — | Cap number of categories returned |
| `--format` | string | `json` | Output format: `json`, `table`, `plain` |
### Example
```bash
bun run src/cli.ts categories
bun run src/cli.ts categories --format table
```
### Response shape
```json
[
{
"id": 227972,
"title": "Pædagogik, Uddannelse og Forskning",
"helpText": "Pædagog, lærer, forsker og meget mere...",
"count": 1856
},
{
"id": 227973,
"title": "Håndværk, Industri, Transport og Landbrug",
"helpText": "Elektriker, VVS, maler og meget mere...",
"count": 2816
},
{
"id": 227974,
"title": "Salg, Kommunikation, Marketing, og Design",
"helpText": "Sælger, marketingkoordinator og meget mere...",
"count": 1423
},
{
"id": 227975,
"title": "Pleje, Social og Sundhed",
"helpText": "Sygeplejerske, social- og sundhedsassistent og meget mere...",
"count": 2104
},
{
"id": 227976,
"title": "Hotel, Service, Restauration og Sikkerhed",
"helpText": "Tjener, kok, receptionist og meget mere...",
"count": 987
},
{
"id": 227977,
"title": "Kontor, Finans og Økonomi",
"helpText": "Bogholder, revisor, kontorassistent og meget mere...",
"count": 1234
},
{
"id": 227978,
"title": "IT, Ingeniør og Energi",
"helpText": "Programmør, datamatiker, ingeniør og meget mere...",
"count": 1567
},
{
"id": 227979,
"title": "Ledelse, HR og projektstyring",
"helpText": "Leder, HR-konsulent, projektleder og meget mere...",
"count": 876
},
{
"id": 543415,
"title": "Kirke, Kultur og Underholdning",
"helpText": "Præst, musiker, skuespiller og meget mere...",
"count": 234
},
{
"id": 227980,
"title": "Øvrige job",
"helpText": "Andre job der ikke passer i de øvrige kategorier...",
"count": 2329
}
]
```
---
## `autocomplete` — Suggest job titles and categories
**Endpoint**: `GET https://jobdanmark.dk/api/search/autocomplete?q={query}`
```bash
bun run src/cli.ts autocomplete --query <text> [flags]
```
### Flags
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--query` | string | **required** | Search text to autocomplete |
| `--limit` | number | — | Cap total suggestions returned |
| `--format` | string | `json` | Output format: `json`, `table`, `plain` |
### Example
```bash
bun run src/cli.ts autocomplete --query "it"
bun run src/cli.ts autocomplete --query "sygeplejerske" --format table
```
### Response shape
```json
[
{
"title": "Jobtitler",
"items": [
{
"id": "title__454",
"text": "IT-konsulent",
"value": 454,
"category": "jobtitle",
"slug": "it-konsulent"
},
{
"id": "title__123",
"text": "IT-supporter",
"value": 123,
"category": "jobtitle",
"slug": "it-supporter"
}
]
},
{
"title": "Kategorier",
"items": [
{
"id": "category__227978",
"text": "IT, Ingeniør og Energi",
"value": 227978,
"category": "category",
"slug": "it-ingenioer-og-energi"
}
]
}
]
```
> **Note**: Returns an empty array `[]` when no suggestions match. `value` is a numeric ID usable as `--jobtitle-id` or `--category` in `search`. Groups with no matching items are omitted from the response.
---
## `locations` — Suggest location filters
**Endpoint**: `GET https://jobdanmark.dk/api/search/locations?q={query}`
```bash
bun run src/cli.ts locations --query <text> [flags]
```
### Flags
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--query` | string | **required** | Location text to search (city, zip code, region) |
| `--limit` | number | — | Cap total suggestions returned |
| `--format` | string | `json` | Output format: `json`, `table`, `plain` |
### Example
```bash
bun run src/cli.ts locations --query "Odense"
bun run src/cli.ts locations --query "8000" --format table
bun run src/cli.ts locations --query "Sjælland"
```
### Response shape
```json
[
{
"title": "Kommune",
"items": [
{
"id": "municipality__0461",
"text": "Odense",
"value": "Odense",
"category": "municipality",
"slug": "odense"
}
]
},
{
"title": "Postnummer",
"items": [
{
"id": "zip__5000",
"text": "5000 Odense C",
"value": "5000",
"category": "zip",
"slug": "5000-odense-c"
},
{
"id": "zip__5200",
"text": "5200 Odense V",
"value": "5200",
"category": "zip",
"slug": "5200-odense-v"
}
]
}
]
```
> **Note**: Returns `[]` when no locations match. The `value` field for municipalities is the display name (string); for zip codes it is the numeric string. Use `value` directly as `--municipality` or `--zip` in `search`.
---
## Error handling
All errors are written to **stderr** in JSON format and exit with code `1`:
```json
{ "error": "Job not found", "code": "NOT_FOUND" }
{ "error": "API request failed: 400 Bad Request", "code": "API_ERROR" }
{ "error": "--query is required", "code": "MISSING_REQUIRED" }
{ "error": "Failed to parse job page HTML", "code": "PARSE_ERROR" }
```
---
## URL construction
- Job detail pages: `https://jobdanmark.dk/job/{slug}`
- Image URLs from the raw API (`companyLogo.url`, `coverImage.url`) are relative; prepend `https://jobdanmark.dk` if you consume the API directly (the CLI drops these keys)
+25
View File
@@ -0,0 +1,25 @@
{
"name": "jobdanmark-cli",
"version": "1.0.0",
"description": "CLI for the Jobdanmark.dk public job search API",
"type": "module",
"main": "src/cli.ts",
"bin": {
"jobdanmark": "src/cli.ts"
},
"scripts": {
"start": "bun run src/cli.ts",
"test": "bun test --timeout 30000",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@bunli/core": "0.9.1",
"@bunli/utils": "0.6.0",
"node-html-parser": "^6.1.0",
"zod": "^3.23.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"@types/bun": "1.3.14"
}
}
+48
View File
@@ -0,0 +1,48 @@
import { createCLI } from "@bunli/core"
import { writeError } from "./helpers.js"
import { search } from "./commands/search.js"
import { detail } from "./commands/detail.js"
import { categories } from "./commands/categories.js"
import { autocomplete } from "./commands/autocomplete.js"
import { locations } from "./commands/locations.js"
const cli = await createCLI({
name: "jobdanmark-cli",
version: "1.0.0",
description: "CLI for the Jobdanmark.dk public job search API",
})
const commands = [search, detail, categories, autocomplete, locations]
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()
@@ -0,0 +1,111 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { apiFetch, writeError } from "../helpers.js"
interface AutocompleteItem {
id: string
text: string
value: number
category: string
slug: string
}
interface AutocompleteGroup {
title: string
items: AutocompleteItem[]
}
export const autocomplete = defineCommand({
name: "autocomplete",
description: "Suggest job titles and categories for a query",
options: {
query: option(z.string().optional(), {
description: "Search text to autocomplete (required)",
}),
limit: option(z.coerce.number().int().min(1).optional(), {
description: "Cap total 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)
}
try {
const raw = await apiFetch<AutocompleteGroup[]>("/api/search/autocomplete", {
q: flags.query,
})
if (signal.aborted) return
const queryLower = flags.query.toLowerCase()
// Filter groups: only include items whose text matches the query (API always returns all categories)
// This ensures a nonsense query returns []
const filtered = raw
.map((g) => ({
title: g.title,
items: (g.items ?? []).filter((item) =>
item.text.toLowerCase().includes(queryLower)
),
}))
.filter((g) => g.items.length > 0)
let result = filtered
if (flags.limit !== undefined) {
// Apply limit across all groups, distributing across groups
let remaining = flags.limit
result = []
for (const group of filtered) {
if (remaining <= 0) break
const items = group.items.slice(0, remaining)
remaining -= items.length
if (items.length > 0) {
result.push({ title: group.title, items })
}
}
}
if (flags.format === "json") {
console.log(JSON.stringify(result, null, 2))
} else if (flags.format === "table") {
outputTable(result)
} else {
outputPlain(result)
}
} catch (err) {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
process.exit(1)
}
},
})
function outputTable(data: AutocompleteGroup[]): void {
console.log("category id text value slug")
for (const group of data) {
for (const item of group.items) {
const cat = item.category.padEnd(10)
const id = item.id.substring(0, 20).padEnd(20)
const text = item.text.substring(0, 32).padEnd(32)
const value = String(item.value).padEnd(6)
const slug = item.slug
console.log(`${cat} ${id} ${text} ${value} ${slug}`)
}
}
}
function outputPlain(data: AutocompleteGroup[]): void {
for (const group of data) {
console.log(`=== ${group.title} ===`)
for (const item of group.items) {
console.log(` ${item.text} (${item.category}, id=${item.value}, slug=${item.slug})`)
}
}
}
@@ -0,0 +1,67 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { apiFetch, writeError } from "../helpers.js"
interface Category {
id: number
title: string
helpText: string
count: number
}
export const categories = defineCommand({
name: "categories",
description: "List all job categories with live counts",
options: {
limit: option(z.coerce.number().int().min(1).optional(), {
description: "Cap number of categories returned",
}),
format: option(z.enum(["json", "table", "plain"]).default("json"), {
description: "Output format: json, table, plain",
}),
},
handler: async ({ flags, signal }) => {
if (signal.aborted) return
try {
let data = await apiFetch<Category[]>("/api/categorycount/getcounts")
if (signal.aborted) return
if (flags.limit !== undefined) {
data = data.slice(0, flags.limit)
}
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: Category[]): void {
console.log("id title count")
for (const cat of data) {
const id = String(cat.id).padEnd(9)
const title = cat.title.substring(0, 48).padEnd(48)
const count = String(cat.count)
console.log(`${id} ${title} ${count}`)
}
}
function outputPlain(data: Category[]): void {
for (const cat of data) {
console.log(`id: ${cat.id}`)
console.log(`title: ${cat.title}`)
console.log(`helpText: ${cat.helpText}`)
console.log(`count: ${cat.count}`)
console.log("")
}
}
+294
View File
@@ -0,0 +1,294 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { parse } from "node-html-parser"
import { BASE_URL, writeError } from "../helpers.js"
import { extractCity, toContractDate } from "./search.js"
interface JsonLdJobPosting {
"@context"?: string
"@type"?: string
title?: string
datePosted?: string
validThrough?: string
employmentType?: string | string[]
hiringOrganization?: {
"@type"?: string
name?: string
logo?: string
}
jobLocation?: {
"@type"?: string
address?: {
"@type"?: string
streetAddress?: string
addressLocality?: string
addressRegion?: string
postalCode?: string
addressCountry?: string
}
}
description?: string
}
interface DetailResult {
slug: string
url: string
title: string
datePosted: string
validThrough: string | null
employmentType: string[]
hiringOrganization: {
name: string
logo: string | null
}
jobLocation: {
streetAddress: string | null
addressLocality: string | null
addressRegion: string | null
postalCode: string | null
addressCountry: string | null
}
description: string
applyUrl: string | null
}
function cleanText(text: string): string {
return text.replace(/\s+/g, " ").trim()
}
function normalizeUrl(value: string | null | undefined): string | null {
if (!value) return null
const decoded = value.replace(/&amp;/g, "&")
if (decoded.startsWith("http")) return decoded
if (decoded.startsWith("/")) return `${BASE_URL}${decoded}`
return decoded
}
function findJobPostingJsonLd(root: ReturnType<typeof parse>): JsonLdJobPosting | null {
const ldJsonScripts = root.querySelectorAll('script[type="application/ld+json"]')
for (const script of ldJsonScripts) {
try {
const parsed = JSON.parse(script.text) as unknown
if (isJobPosting(parsed)) return parsed
if (Array.isArray(parsed)) {
const found = parsed.find(isJobPosting)
if (found) return found
}
} catch {
// Continue to next script.
}
}
return null
}
function isJobPosting(value: unknown): value is JsonLdJobPosting {
return Boolean(value && typeof value === "object" && (value as JsonLdJobPosting)["@type"] === "JobPosting")
}
function fromJsonLd(jobPosting: JsonLdJobPosting, slug: string, url: string): DetailResult {
const hiringOrg = jobPosting.hiringOrganization
const address = jobPosting.jobLocation?.address
const employmentType = Array.isArray(jobPosting.employmentType)
? jobPosting.employmentType
: jobPosting.employmentType
? [jobPosting.employmentType]
: []
return {
slug,
url,
title: jobPosting.title ?? "",
datePosted: jobPosting.datePosted ?? "",
validThrough: jobPosting.validThrough ?? null,
employmentType,
hiringOrganization: {
name: hiringOrg?.name ?? "",
logo: hiringOrg?.logo ?? null,
},
jobLocation: {
streetAddress: address?.streetAddress ?? null,
addressLocality: address?.addressLocality ?? null,
addressRegion: address?.addressRegion ?? null,
postalCode: address?.postalCode ?? null,
addressCountry: address?.addressCountry ?? null,
},
description: jobPosting.description ?? "",
applyUrl: null,
}
}
/**
* Normalize a date read from the rendered page's overview list. The page
* writes DD-MM-YYYY (sometimes with a trailing time, "02-08-2026 23.59"),
* and the deadline can be the free-text "Løbende" (rolling) - jobbank maps
* its equivalent to null, and every consumer does date arithmetic on the
* value. The JSON-LD branch gets schema.org ISO dates and needs none of this.
*/
function normalizeOverviewDate(value: string | null): string | null {
if (!value) return null
const trimmed = value.trim()
if (/^løbende$/iu.test(trimmed)) return null
const match = trimmed.match(/^(\d{2})-(\d{2})-(\d{4})/)
return match ? `${match[3]}-${match[2]}-${match[1]}` : toContractDate(trimmed)
}
function overviewValue(root: ReturnType<typeof parse>, label: string): string | null {
const normalizedLabel = label.toLowerCase()
for (const item of root.querySelectorAll(".job-overview li")) {
const strong = item.querySelector("strong")
const itemLabel = cleanText(strong?.text ?? "").replace(/:$/, "").toLowerCase()
if (itemLabel !== normalizedLabel) continue
const value = cleanText(item.text.replace(strong?.text ?? "", ""))
return value.replace(/^:\s*/, "") || null
}
return null
}
function fromRenderedHtml(root: ReturnType<typeof parse>, slug: string, url: string): DetailResult {
const pageTitle = cleanText(root.querySelector("title")?.text ?? "")
const titleLower = pageTitle.toLowerCase()
const bodyText = root.text.toLowerCase()
if (
bodyText.includes("siden blev ikke fundet") ||
titleLower.startsWith("404") ||
titleLower.includes("page not found") ||
titleLower.includes("siden blev ikke fundet")
) {
throw new Error("NOT_FOUND")
}
const title =
cleanText(root.querySelector(".job-list-details .title")?.text ?? "") ||
cleanText(root.querySelector("h1")?.text ?? "") ||
cleanText(root.querySelector("h3")?.text ?? "").replace(/\s+\|\s+jobdanmark$/, "")
if (!title) {
throw new Error("Failed to parse job page HTML")
}
const companyLink = root.querySelector('.job-details-head a[href^="/virksomheder/"]')
const companyName = cleanText(companyLink?.text ?? "")
const logoSrc =
root.querySelector(".company-logo img")?.getAttribute("src") ??
root.querySelector(".company-logo source")?.getAttribute("srcset")?.split(/\s+/)[0]
const workplace = overviewValue(root, "Arbejdssted")
const description = root
.querySelectorAll(".job-list-details p, .job-list-details li")
.map((node) => cleanText(node.text))
.filter(Boolean)
.join("\n")
const employmentType = overviewValue(root, "Jobtype")
const applyUrl = normalizeUrl(root.querySelector("a.action.primary")?.getAttribute("href"))
return {
slug,
url,
title,
datePosted: normalizeOverviewDate(overviewValue(root, "Udgivet")) ?? "",
validThrough: normalizeOverviewDate(overviewValue(root, "Ansøgningsfrist")),
employmentType: employmentType ? [employmentType] : [],
hiringOrganization: {
name: companyName,
logo: normalizeUrl(logoSrc),
},
jobLocation: {
streetAddress: workplace,
addressLocality: extractCity(workplace),
addressRegion: null,
postalCode: null,
addressCountry: "DK",
},
description,
applyUrl,
}
}
export function parseJobPostingFromHtml(html: string, slug: string, url: string): DetailResult {
const root = parse(html)
const jobPosting = findJobPostingJsonLd(root)
return jobPosting ? fromJsonLd(jobPosting, slug, url) : fromRenderedHtml(root, slug, url)
}
export const detail = defineCommand({
name: "detail",
description: "Full detail for a single job posting (by slug)",
options: {
format: option(z.enum(["json", "plain"]).default("json"), {
description: "Output format: json, plain",
}),
},
handler: async ({ flags, positional, signal }) => {
if (signal.aborted) return
const slug = positional[0]
if (!slug) {
writeError("slug argument is required", "MISSING_REQUIRED")
process.exit(1)
}
const url = `${BASE_URL}/job/${slug}`
try {
const response = await fetch(url, {
headers: {
"Accept": "text/html,application/xhtml+xml",
"User-Agent": "Mozilla/5.0 (compatible; jobdanmark-cli/1.0)",
},
signal: AbortSignal.timeout(15000),
})
if (response.status === 404) {
writeError("Job not found", "NOT_FOUND")
process.exit(1)
}
if (!response.ok) {
writeError(`API request failed: ${response.status} ${response.statusText}`, "API_ERROR")
process.exit(1)
}
const html = await response.text()
if (signal.aborted) return
const output = parseJobPostingFromHtml(html, slug, url)
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 if (err instanceof Error && err.message.includes("Failed to parse")) {
writeError(err.message, "PARSE_ERROR")
} else {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
}
process.exit(1)
}
},
})
function outputPlain(data: DetailResult): void {
console.log(`slug: ${data.slug}`)
console.log(`url: ${data.url}`)
console.log(`title: ${data.title}`)
console.log(`datePosted: ${data.datePosted}`)
console.log(`validThrough: ${data.validThrough ?? "N/A"}`)
const empType = Array.isArray(data.employmentType) ? data.employmentType.join(", ") : "-"
console.log(`employmentType: ${empType}`)
const org = data.hiringOrganization
console.log(`company: ${org.name}`)
const loc = data.jobLocation
console.log(`location: ${[loc.streetAddress, loc.addressLocality, loc.postalCode, loc.addressCountry].filter(Boolean).join(", ")}`)
if (data.applyUrl) console.log(`applyUrl: ${data.applyUrl}`)
console.log(`description: ${data.description}`)
}
@@ -0,0 +1,101 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { apiFetch, writeError } from "../helpers.js"
interface LocationItem {
id: string
text: string
value: string
category: string
slug: string
}
interface LocationGroup {
title: string
items: LocationItem[]
}
export const locations = defineCommand({
name: "locations",
description: "Suggest municipalities, zip codes, and regions for a query",
options: {
query: option(z.string().optional(), {
description: "Location text to search (city, zip code, region) (required)",
}),
limit: option(z.coerce.number().int().min(1).optional(), {
description: "Cap total 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)
}
try {
const raw = await apiFetch<LocationGroup[]>("/api/search/locations", {
q: flags.query,
})
if (signal.aborted) return
// Filter out groups with no items
const filtered = raw.filter((g) => g.items && g.items.length > 0)
let result = filtered
if (flags.limit !== undefined) {
// Apply limit across all groups
let remaining = flags.limit
result = []
for (const group of filtered) {
if (remaining <= 0) break
const items = group.items.slice(0, remaining)
remaining -= items.length
if (items.length > 0) {
result.push({ title: group.title, items })
}
}
}
if (flags.format === "json") {
console.log(JSON.stringify(result, null, 2))
} else if (flags.format === "table") {
outputTable(result)
} else {
outputPlain(result)
}
} catch (err) {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
process.exit(1)
}
},
})
function outputTable(data: LocationGroup[]): void {
console.log("category id text value slug")
for (const group of data) {
for (const item of group.items) {
const cat = item.category.padEnd(12)
const id = item.id.substring(0, 22).padEnd(22)
const text = item.text.substring(0, 28).padEnd(28)
const value = String(item.value).padEnd(11)
const slug = item.slug
console.log(`${cat} ${id} ${text} ${value} ${slug}`)
}
}
}
function outputPlain(data: LocationGroup[]): void {
for (const group of data) {
console.log(`=== ${group.title} ===`)
for (const item of group.items) {
console.log(` ${item.text} (${item.category}, value=${item.value}, slug=${item.slug})`)
}
}
}
+209
View File
@@ -0,0 +1,209 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { apiPost, writeError, BASE_URL } from "../helpers.js"
export interface ApiSearchItem {
title: string
companyName: string
companyLogo: {
key: string
url: string
focalPoint: { top: number; left: number } | null
} | null
companyLogoSvgMarkup: string | null
overlayColor: string | null
companyAddress: string | null
jobTypes: string[]
boostJob: boolean
publishedDate: string
applicationDeadline: string | null
url: string
coverImage: {
key: string
url: string
focalPoint: { top: number; left: number } | null
} | null
silhouetteLogo: boolean
}
interface ApiSearchResponse {
items: ApiSearchItem[]
currentPage: number
totalItems: number
itemsPrPage: number
totalPages: number
}
export function toContractDate(value: string | null): string | null {
const match = value?.match(/^(\d{2})-(\d{2})-(\d{4})$/)
return match ? `${match[3]}-${match[2]}-${match[1]}` : (value ?? null)
}
// Live companyAddress values put the city after the postcode either as
// "Lautruphoej 2, 2750 Ballerup" or "2670, Greve". The comma fallback
// requires a non-digit after the comma so a 4-digit street number
// ("Vejlevej 1234, 7100 Vejle") never wins over the real postcode.
export function extractCity(address: string | null): string | null {
if (!address) return null
const city =
address.match(/\d{4}\s+(.+)$/)?.[1] ?? address.match(/\d{4}\s*,\s*([^\d,].*)$/)?.[1]
const trimmed = city?.trim()
return trimmed ? trimmed : null
}
export function normalizeItem(item: ApiSearchItem): Record<string, unknown> {
const relativeUrl = item.url
const fullUrl = relativeUrl.startsWith("http")
? relativeUrl
: `${BASE_URL}${relativeUrl}`
// Extract slug from url path: /job/<slug>
const slug = relativeUrl.replace(/^\/job\//, "")
// Presentation-only keys (coverImage, companyLogo, companyLogoSvgMarkup,
// overlayColor, silhouetteLogo) are dropped: they were ~40% of a live
// payload and the /scrape agent can never use an image or overlay colour.
// The #340 compatibility duplicates (companyName, publishedDate,
// applicationDeadline) stay.
return {
title: item.title,
companyName: item.companyName,
companyAddress: item.companyAddress,
jobTypes: item.jobTypes,
boostJob: item.boostJob,
publishedDate: item.publishedDate,
applicationDeadline: item.applicationDeadline ?? null,
url: fullUrl,
slug,
company: item.companyName,
location: extractCity(item.companyAddress),
date: toContractDate(item.publishedDate),
deadline: toContractDate(item.applicationDeadline),
}
}
export const search = defineCommand({
name: "search",
description: "Search job listings with filters",
options: {
text: option(z.string().optional(), {
description: "Free-text keyword search (job title, keyword)",
}),
category: option(z.coerce.number().int().min(1).optional(), {
description: "Category ID",
}),
"jobtitle-id": option(z.coerce.number().int().min(1).optional(), {
description: "Job title ID from autocomplete results",
}),
municipality: option(z.string().optional(), {
description: "Municipality name, e.g. Odense, København",
}),
zip: option(z.string().optional(), {
description: "Zip code, e.g. 5000",
}),
region: option(z.string().optional(), {
description: "Region name",
}),
"job-type": option(z.string().optional(), {
description: "Comma-separated job types: fuldtid,deltid,fleksjob,elev,studiejob,praktik",
}),
page: option(z.coerce.number().int().min(1).default(1), {
description: "Page number (30 items per page, server-enforced)",
}),
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
const filters: Array<{ type: string; value: string | number; displayText: string }> = []
if (flags.text) {
filters.push({ type: "freetext", value: flags.text, displayText: flags.text })
}
if (flags.category !== undefined) {
filters.push({ type: "category", value: flags.category, displayText: String(flags.category) })
}
if (flags["jobtitle-id"] !== undefined) {
filters.push({ type: "jobtitle", value: flags["jobtitle-id"], displayText: String(flags["jobtitle-id"]) })
}
if (flags.municipality) {
filters.push({ type: "municipality", value: flags.municipality, displayText: flags.municipality })
}
if (flags.zip) {
filters.push({ type: "zip", value: flags.zip, displayText: flags.zip })
}
if (flags.region) {
filters.push({ type: "region", value: flags.region, displayText: flags.region })
}
const jobTypes = flags["job-type"]
? flags["job-type"].split(",").map((t) => t.trim()).filter(Boolean)
: []
const body = {
jobTypes,
filters,
locationMode: "Text",
distance: 50,
}
try {
const data = await apiPost<ApiSearchResponse>(`/api/jobsearch/search/${flags.page}`, body)
if (signal.aborted) return
let results = data.items.map(normalizeItem)
if (flags.limit !== undefined) {
results = results.slice(0, flags.limit)
}
const meta = {
currentPage: data.currentPage,
totalItems: data.totalItems,
itemsPrPage: data.itemsPrPage,
totalPages: data.totalPages,
}
const output = { meta, 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: Record<string, unknown>[]): void {
console.log("title company date url")
for (const r of results) {
const title = String(r.title ?? "-").substring(0, 35).padEnd(35)
const company = String(r.companyName ?? "-").substring(0, 25).padEnd(25)
const date = String(r.publishedDate ?? "-").padEnd(10)
const url = String(r.url ?? "-")
console.log(`${title} ${company} ${date} ${url}`)
}
}
function outputPlain(results: Record<string, unknown>[]): void {
for (const r of results) {
console.log(`title: ${r.title}`)
console.log(`company: ${r.companyName}`)
console.log(`address: ${r.companyAddress}`)
console.log(`jobTypes: ${Array.isArray(r.jobTypes) ? r.jobTypes.join(", ") : "-"}`)
console.log(`published: ${r.publishedDate}`)
console.log(`deadline: ${r.applicationDeadline ?? "N/A"}`)
console.log(`url: ${r.url}`)
console.log("")
}
}
+73
View File
@@ -0,0 +1,73 @@
export const BASE_URL = "https://jobdanmark.dk"
export const USER_AGENT = "Mozilla/5.0 (compatible; jobdanmark-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 },
signal: AbortSignal.timeout(15000),
})
if (response.status === 429 || response.status >= 500) {
if (attempt === maxRetries) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
const jitter = Math.floor(Math.random() * 500)
await new Promise((resolve) => setTimeout(resolve, delay + jitter))
delay = Math.min(delay * 2, 5000)
continue
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
return response.json() as Promise<T>
}
throw new Error("API request failed after max retries")
}
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
const url = `${BASE_URL}${path}`
const maxRetries = 6
let delay = 500
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(15000),
})
if (response.status === 429 || response.status >= 500) {
if (attempt === maxRetries) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
const jitter = Math.floor(Math.random() * 500)
await new Promise((resolve) => setTimeout(resolve, delay + jitter))
delay = Math.min(delay * 2, 5000)
continue
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
return response.json() as Promise<T>
}
throw new Error("API request failed after max retries")
}
export function writeError(error: string, code: string): void {
process.stderr.write(JSON.stringify({ error, code }) + "\n")
}
export function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim()
}
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test";
import { runCLI } from "./helpers";
describe("Jobdanmark CLI error contract", () => {
test("detail without a slug 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: "slug argument 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, and slice(0, -1) then silently
// dropped the last result instead of erroring. Filter flags (--category,
// --jobtitle-id) 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("Jobdanmark 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 portal", async () => {
const result = await runCLI(["search", "--page=0"]);
expectValidationError(result, "page");
});
test("search --category=-1 is rejected", async () => {
const result = await runCLI(["search", "--category=-1"]);
expectValidationError(result, "category");
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
});
test("search --jobtitle-id=1.5 is rejected as non-integer", async () => {
const result = await runCLI(["search", "--jobtitle-id=1.5"]);
expectValidationError(result, "jobtitle-id");
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
});
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("categories --limit=-1 is rejected", async () => {
const result = await runCLI(["categories", "--limit=-1"]);
expectValidationError(result, "limit");
});
test("locations --limit=-1 is rejected", async () => {
const result = await runCLI(["locations", "--query", "test", "--limit=-1"]);
expectValidationError(result, "limit");
});
test("autocomplete --limit=-1 is rejected", async () => {
const result = await runCLI(["autocomplete", "--query", "test", "--limit=-1"]);
expectValidationError(result, "limit");
});
test("valid numeric flags pass schema validation (proven offline via the required-flag guard)", async () => {
const result = await runCLI(["autocomplete", "--limit=3"]);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stderr)).toEqual({
error: "--query 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", "--text", "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,96 @@
import { describe, expect, test } from "bun:test";
import { parseJobPostingFromHtml } from "../src/commands/detail";
function pageWithScripts(...scripts: string[]): string {
return `<html><head>${scripts
.map((content) => `<script type="application/ld+json">${content}</script>`)
.join("")}</head><body></body></html>`;
}
describe("parseJobPostingFromHtml JSON-LD", () => {
test("normalizes a JobPosting object and scalar employment type", () => {
const posting = {
"@context": "https://schema.org",
"@type": "JobPosting",
title: "Data Engineer",
datePosted: "2026-07-13",
validThrough: "2026-08-13",
employmentType: "FULL_TIME",
hiringOrganization: { name: "Acme A/S", logo: "https://cdn.example/logo.png" },
jobLocation: {
address: {
streetAddress: "Testvej 1",
addressLocality: "Odense",
addressRegion: "Syddanmark",
postalCode: "5000",
addressCountry: "DK",
},
},
description: "<p>Build reliable pipelines.</p>",
};
const parsed = parseJobPostingFromHtml(
pageWithScripts(JSON.stringify(posting)),
"data-engineer",
"https://jobdanmark.dk/job/data-engineer",
);
expect(parsed).toEqual({
slug: "data-engineer",
url: "https://jobdanmark.dk/job/data-engineer",
title: "Data Engineer",
datePosted: "2026-07-13",
validThrough: "2026-08-13",
employmentType: ["FULL_TIME"],
hiringOrganization: { name: "Acme A/S", logo: "https://cdn.example/logo.png" },
jobLocation: {
streetAddress: "Testvej 1",
addressLocality: "Odense",
addressRegion: "Syddanmark",
postalCode: "5000",
addressCountry: "DK",
},
description: "<p>Build reliable pipelines.</p>",
applyUrl: null,
});
});
test("skips malformed scripts and finds a JobPosting in an array", () => {
const scripts = [
"{not-json",
JSON.stringify([
{ "@type": "BreadcrumbList" },
{ "@type": "JobPosting", title: "Analyst", employmentType: ["FULL_TIME", "PART_TIME"] },
]),
];
const parsed = parseJobPostingFromHtml(
pageWithScripts(...scripts),
"analyst",
"https://jobdanmark.dk/job/analyst",
);
expect(parsed.title).toBe("Analyst");
expect(parsed.employmentType).toEqual(["FULL_TIME", "PART_TIME"]);
expect(parsed.validThrough).toBeNull();
expect(parsed.hiringOrganization).toEqual({ name: "", logo: null });
});
test("recognizes rendered not-found and unparseable pages", () => {
expect(() =>
parseJobPostingFromHtml(
"<html><head><title>404 | Jobdanmark</title></head><body></body></html>",
"missing",
"https://jobdanmark.dk/job/missing",
),
).toThrow("NOT_FOUND");
expect(() =>
parseJobPostingFromHtml(
"<html><head><title>Jobdanmark</title></head><body></body></html>",
"broken",
"https://jobdanmark.dk/job/broken",
),
).toThrow("Failed to parse job page HTML");
});
});
@@ -0,0 +1,86 @@
import { describe, expect, test } from "bun:test";
import { parseJobPostingFromHtml } from "../src/commands/detail";
const HTML_WITHOUT_JSON_LD = `<!doctype html>
<html lang="da">
<head>
<title>Journalistisk udvikler s&#xF8;ges | jobdanmark</title>
</head>
<body>
<div class="job-list-details">
<div class="job-details-head row mx-0">
<div class="company-logo col-auto">
<img src="/media/jfm-logo.png?width=100" alt="JFM">
</div>
<h3 class="title">Journalistisk udvikler s&#xF8;ges</h3>
<a href="/virksomheder/jfm">JFM</a>
<span>Baneg&#xE5;rdspladsen 1, 5000 Odense C</span>
</div>
<p>Hvad nu hvis du med f&#xE5; klik kunne unders&#xF8;ge dit lokalomr&#xE5;de?</p>
<ul>
<li>identificere relevante datas&#xE6;t og muligheder</li>
</ul>
</div>
<a href="https://jfm.career.emply.com/da/apply/example" class="action primary count-click">Ans&#xF8;g nu</a>
<ul class="job-overview list-unstyled">
<li><strong>Udgivet:</strong> 03-07-2026</li>
<li><strong>Jobtype:</strong> Fuldtid</li>
<li><strong>Arbejdssted:</strong> Baneg&#xE5;rdspladsen 1, 5000 Odense C</li>
<li><strong>Ansøgningsfrist:</strong> 02-08-2026 23.59</li>
</ul>
</body>
</html>`;
describe("parseJobPostingFromHtml", () => {
test("falls back to rendered Jobdanmark HTML when JSON-LD is absent", () => {
const parsed = parseJobPostingFromHtml(
HTML_WITHOUT_JSON_LD,
"journalistisk-udvikler",
"https://jobdanmark.dk/job/journalistisk-udvikler",
);
expect(parsed.title).toBe("Journalistisk udvikler søges");
// The fallback must emit the same shapes as the JSON-LD branch: contract
// dates, not the page's raw DD-MM-YYYY text (review finding F25, 2026-08-19).
expect(parsed.datePosted).toBe("2026-07-03");
expect(parsed.validThrough).toBe("2026-08-02");
expect(parsed.employmentType).toEqual(["Fuldtid"]);
expect(parsed.hiringOrganization.name).toBe("JFM");
expect(parsed.hiringOrganization.logo).toBe("https://jobdanmark.dk/media/jfm-logo.png?width=100");
expect(parsed.jobLocation.streetAddress).toBe("Banegårdspladsen 1, 5000 Odense C");
expect(parsed.jobLocation.addressLocality).toBe("Odense C");
expect(parsed.description).toContain("identificere relevante datasæt");
expect(parsed.applyUrl).toBe("https://jfm.career.emply.com/da/apply/example");
});
test("maps a rolling deadline (Løbende) to null in the HTML fallback", () => {
// "Løbende" is free text meaning rolling/ongoing - jobbank's parser maps
// its equivalent to null, and a stored "Løbende" deadline would hit every
// date-arithmetic consumer (review finding F25, 2026-08-19).
const html = HTML_WITHOUT_JSON_LD.replace(
"<li><strong>Ansøgningsfrist:</strong> 02-08-2026 23.59</li>",
"<li><strong>Ansøgningsfrist:</strong> Løbende</li>",
);
const parsed = parseJobPostingFromHtml(html, "s", "https://jobdanmark.dk/job/s");
expect(parsed.validThrough).toBeNull();
});
test("does not reject titles containing '404' mid-phrase", () => {
const htmlWith404InTitle = HTML_WITHOUT_JSON_LD.replace(
"<title>Journalistisk udvikler s&#xF8;ges | jobdanmark</title>",
"<title>HTTP 404 Page Designer | jobdanmark</title>",
).replace(
'<h3 class="title">Journalistisk udvikler s&#xF8;ges</h3>',
'<h3 class="title">HTTP 404 Page Designer</h3>',
);
const parsed = parseJobPostingFromHtml(
htmlWith404InTitle,
"http-404-designer",
"https://jobdanmark.dk/job/http-404-designer",
);
expect(parsed.title).toBe("HTTP 404 Page Designer");
expect(parsed.hiringOrganization.name).toBe("JFM");
});
});
+39
View File
@@ -0,0 +1,39 @@
import { join } from "path";
const CLI_PATH = join(import.meta.dir, "../src/cli.ts");
export interface CLIResult {
stdout: string;
stderr: string;
exitCode: number;
}
export async function runCLI(args: string[]): Promise<CLIResult> {
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
}
export function parseJSON<T = unknown>(result: CLIResult): T {
if (result.exitCode !== 0) {
throw new Error(
`CLI exited with code ${result.exitCode}. stderr: ${result.stderr}`
);
}
try {
return JSON.parse(result.stdout) as T;
} catch {
throw new Error(
`Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}`
);
}
}
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, test } from "bun:test";
import { apiFetch, apiPost } from "../src/helpers";
// A stalled upstream connection (accepted socket, no response) would otherwise
// hang the CLI forever - fetch has no default timeout. Assert both request
// wrappers carry an AbortSignal timeout. Fails on the pre-fix code (no signal).
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("request timeout", () => {
test("apiFetch 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);
});
test("apiPost 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 apiPost("/search", { q: "x" });
expect(init?.signal).toBeInstanceOf(AbortSignal);
});
});
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, test } from "bun:test";
import { apiFetch, apiPost } 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. apiFetch and apiPost carry separate copies of
// the loop, so both are exercised to keep them from drifting apart.
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;
}
const wrappers: Array<[string, () => Promise<{ ok: boolean }>]> = [
["apiFetch", () => apiFetch<{ ok: boolean }>("/x")],
["apiPost", () => apiPost<{ ok: boolean }>("/x", {})],
];
for (const [name, call] of wrappers) {
describe(`${name} 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 call();
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(call()).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(call()).rejects.toThrow(/500/);
expect(state.calls).toBe(7);
});
});
}
@@ -0,0 +1,105 @@
import { describe, expect, test } from "bun:test";
import { normalizeItem, type ApiSearchItem } from "../src/commands/search";
function item(): ApiSearchItem {
return {
title: "Softwareudvikler",
companyName: "Statens It",
companyLogo: null,
companyLogoSvgMarkup: null,
overlayColor: null,
companyAddress: "Lautruphøj 2, 2750 Ballerup",
jobTypes: ["fuldtid"],
boostJob: false,
publishedDate: "27-07-2026",
applicationDeadline: "17-08-2026",
url: "/job/softwareudvikler-til-statens-it",
coverImage: null,
silhouetteLogo: false,
};
}
describe("Jobdanmark search normalization", () => {
test("additively emits the /scrape contract fields (company, location, date, deadline)", () => {
const result = normalizeItem(item());
expect(result).toMatchObject({
company: "Statens It",
location: "Ballerup",
date: "2026-07-27",
deadline: "2026-08-17",
url: "https://jobdanmark.dk/job/softwareudvikler-til-statens-it",
});
});
test("maps a missing address zip and a null deadline to null", () => {
const result = normalizeItem({
...item(),
companyAddress: "Lautruphøj 2",
applicationDeadline: null,
});
expect(result.location).toBeNull();
expect(result.deadline).toBeNull();
expect(result.company).toBe("Statens It");
});
test("extracts the city when a comma follows the postcode (live jobdanmark shape)", () => {
const result = normalizeItem({
...item(),
companyAddress: "2670, Greve",
});
expect(result.location).toBe("Greve");
});
test("trims trailing whitespace from the extracted city", () => {
const result = normalizeItem({
...item(),
companyAddress: "7100, Vejle ",
});
expect(result.location).toBe("Vejle");
});
test("does not mistake a 4-digit street number for the postcode", () => {
const result = normalizeItem({
...item(),
companyAddress: "Vejlevej 1234, 7100 Vejle",
});
expect(result.location).toBe("Vejle");
});
test("survives a null companyAddress from the API", () => {
const result = normalizeItem({
...item(),
companyAddress: null,
});
expect(result.location).toBeNull();
expect(result.company).toBe("Statens It");
});
test("keeps native fields unchanged (additive contract)", () => {
const result = normalizeItem(item());
expect(result.companyName).toBe("Statens It");
expect(result.publishedDate).toBe("27-07-2026");
expect(result.applicationDeadline).toBe("17-08-2026");
});
test("omits presentation-only keys the agent can never use", () => {
// coverImage/companyLogo/companyLogoSvgMarkup/overlayColor/silhouetteLogo
// were ~40% of a live search payload, fed into agent context on every
// /scrape query (review finding F3, 2026-08-19). The #340 compatibility
// duplicates (companyName, publishedDate, applicationDeadline) stay.
const result = normalizeItem(item());
expect(result).not.toHaveProperty("coverImage");
expect(result).not.toHaveProperty("companyLogo");
expect(result).not.toHaveProperty("companyLogoSvgMarkup");
expect(result).not.toHaveProperty("overlayColor");
expect(result).not.toHaveProperty("silhouetteLogo");
});
});
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, test } from "bun:test";
import { apiFetch, apiPost, 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;
});
function headerValue(headers: RequestInit["headers"], name: string): string | null {
if (headers instanceof Headers) return headers.get(name);
if (Array.isArray(headers)) {
const found = headers.find(([k]) => k === name);
return found ? String(found[1]) : null;
}
const value = headers?.[name];
return typeof value === "string" ? value : null;
}
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("/api/search/autocomplete", { q: "it" });
expect(headerValue(init?.headers, "User-Agent")).toBe(USER_AGENT);
});
});
describe("apiPost user agent", () => {
test("sends a User-Agent header alongside Content-Type", 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 apiPost("/api/jobsearch/search/1", { q: "it" });
expect(headerValue(init?.headers, "User-Agent")).toBe(USER_AGENT);
expect(headerValue(init?.headers, "Content-Type")).toBe("application/json");
});
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext"],
"types": ["bun-types"],
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "."
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}
+171
View File
@@ -0,0 +1,171 @@
---
name: jobindex-search
version: 1.0.0
description: >
Make sure to use this skill whenever the user wants to search for jobs in Denmark,
find Danish job listings, look up a specific job posting, or asks anything about
the Danish job market — even if they don't mention jobindex.dk explicitly. Invoke
this skill for questions about open positions, job vacancies, hiring in Denmark,
job opportunities in Danish cities or sectors, or when the user wants to find work
in Denmark. Also trigger for phrases like "find me a job", "are there any jobs for
X in Copenhagen", or "what jobs are available in Aarhus" when the context is Denmark.
Trigger phrases include: jobindex, jobsøgning, job i Danmark, ledige stillinger,
job opslag, find job, stillingopslag, jobannonce, job vacancy denmark, danish jobs,
jobs in denmark, job search denmark, work in denmark, find work denmark, IT jobs
denmark, engineer jobs denmark, developer jobs copenhagen, marketing jobs aarhus,
jobs aarhus, jobs copenhagen, jobs odense, jobs aalborg, job openings denmark,
hiring denmark, job listings denmark, python jobs denmark, grafisk designer job,
data engineer job, softwareudvikler job, full stack developer job danmark.
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/jobindex-search/cli/src/cli.ts *)
---
# Jobindex Search Skill
Search live Danish job listings from Jobindex.dk. No authentication needed.
Covers thousands of job postings across all sectors, updated in real time.
## When to use this skill
Invoke this skill when the user wants to:
- Search for job openings in Denmark by keyword, job title, or technology
- Find jobs in a specific Danish city (use keyword with city name, e.g. `python aarhus`)
- Filter jobs by recency (posted today, last 7 days, last 30 days)
- Get the full description of a specific job listing
- Explore the Danish job market for a given profession or skill set
## Commands
### Search job listings
```bash
bun run .agents/skills/jobindex-search/cli/src/cli.ts search [flags]
```
Key flags:
- `--query <text>` / `-q <text>` — keyword search (job title, skill, company, city). **Required** for meaningful results.
- `--jobage <days>` — filter by posting age: `1` (today), `7`, `14`, `30`, or `9999` (all, default)
- `--sort <order>` — `score` (relevance, default) or `date` (newest first)
- `--page <n>` — page number (1-indexed, 20 results per page, fixed)
- `--limit <n>` — cap total results the CLI outputs (client-side)
- `--format json|table|plain`
> **Area note**: The Jobindex API does not support area filtering via params. To find jobs in a specific city, include the city in `--query` (e.g. `--query "data engineer københavn"` or `--query "python aarhus"`).
### Fetch full job detail
```bash
bun run .agents/skills/jobindex-search/cli/src/cli.ts detail <id> [--format json|plain]
```
`id` is the job ID from `search` results (e.g. `h1647303`). You may also pass the full Jobindex URL. Returns the full job description, deadline, employment type, hours, and apply link.
---
## How to use effectively
**Always start with `search`.** Pass the job title, skill, or profession as `--query`. Combine with a city name in the query to narrow by location (e.g. `--query "frontend developer odense"`).
**Use `--jobage 7` or `--jobage 1` for fresh listings.** Without it, results include all historical postings.
**Use `--sort date` to see the most recently posted jobs first.** Default `score` sorts by relevance.
**Natural workflow: `search` → `detail`.**
1. Use `search` to find matching jobs and their `id` values.
2. Call `detail <id>` to get the full description, deadline, and apply link.
**Use `--format table` for quick scanning**, `--format json` for data processing, and `--format plain` for reading a single job's full details.
**Pagination**: The API always returns 20 results per page. Use `--page` to navigate pages. Use `--limit` to cap results across one page fetch.
---
## Usage examples
### Find Python jobs posted in the last 7 days
```bash
bun run .agents/skills/jobindex-search/cli/src/cli.ts search \
--query python \
--jobage 7 \
--sort date \
--format table
```
### Data engineer jobs in Copenhagen
```bash
bun run .agents/skills/jobindex-search/cli/src/cli.ts search \
--query "data engineer københavn" \
--sort score \
--format table
```
### Graphic designer jobs — all time, by relevance
```bash
bun run .agents/skills/jobindex-search/cli/src/cli.ts search \
--query "grafisk designer" \
--limit 10 \
--format table
```
### Full-stack developer jobs, page 2
```bash
bun run .agents/skills/jobindex-search/cli/src/cli.ts search \
--query "full stack developer" \
--page 2 \
--format json
```
### Jobs posted today across all sectors
```bash
bun run .agents/skills/jobindex-search/cli/src/cli.ts search \
--jobage 1 \
--sort date \
--limit 20 \
--format table
```
### Get full details for a specific job
```bash
bun run .agents/skills/jobindex-search/cli/src/cli.ts detail h1647303 --format plain
```
### Marketing jobs in Aarhus
```bash
bun run .agents/skills/jobindex-search/cli/src/cli.ts search \
--query "marketing aarhus" \
--jobage 30 \
--sort date \
--format table
```
---
## Output formats
| Format | Best for |
|--------|----------|
| `json` | Default — programmatic use, data processing, passing IDs to `detail` |
| `table` | Quick human-readable overview and scanning |
| `plain` | Reading a single job's full detail (`detail` command) |
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
---
## Notes
- All data is from the public `jobindex.dk` API — no credentials required.
- Page size is fixed at 20 results per page (Jobindex API limitation).
- Area/region filtering via API params does not work — include city names in `--query` instead.
- `--jobage 9999` is the default and includes all postings regardless of age.
- Total count in `meta.total` uses Danish dot-thousands notation internally (e.g. `18.903`) — the CLI normalizes this to a plain integer.
- Job IDs are string-prefixed (e.g. `h1647303`) — pass them as-is to `detail`.
+247
View File
@@ -0,0 +1,247 @@
# jobindex-cli
CLI for searching jobs on [Jobindex.dk](https://www.jobindex.dk).
**Base URL**: `https://www.jobindex.dk/`
**Authentication**: None required.
**Format**: The API returns JSON with embedded HTML blobs. The CLI parses the HTML internally and emits clean JSON.
---
## Installation
```bash
cd skills/jobindex-search/cli
bun install
```
---
## Commands
| Command | Description |
|---------|-------------|
| `search` | Search for job listings |
| `detail` | Fetch full detail for a single job listing |
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`.
---
## `search` — Search for job listings
**Endpoint**: `GET https://www.jobindex.dk/jobsoegning.json`
```bash
bun run src/cli.ts search [flags]
```
The API always returns 20 results per page (fixed — no `--per-page` flag). The CLI parses the `result_list_box_html` HTML blob from the response to extract structured job records.
### Flags
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--query` / `-q` | string | — | Keyword search query (e.g. `python`, `grafisk designer`) |
| `--page` | number | `1` | Page number (1-indexed) |
| `--jobage` | number | `9999` | Max age of posting in days: `1`, `7`, `14`, `30`, or `9999` (all) |
| `--sort` | string | `score` | Sort order: `score` (relevance) or `date` (newest first) |
| `--limit` | number | — | Cap total results returned by the CLI (client-side) |
| `--format` | string | `json` | Output format: `json`, `table`, `plain` |
### Sort options
| Value | Description |
|-------|-------------|
| `score` | Relevance / best match (default) |
| `date` | Newest postings first |
### jobage options
| Value | Description |
|-------|-------------|
| `1` | Posted today |
| `7` | Last 7 days |
| `14` | Last 14 days |
| `30` | Last 30 days |
| `9999` | All time (default) |
### Example
```bash
# Search for Python jobs posted in the last 7 days, sorted by date
bun run src/cli.ts search --query python --jobage 7 --sort date
# Search for "grafisk designer" jobs — show first 5 results
bun run src/cli.ts search --query "grafisk designer" --limit 5
# Page 2 of results for data engineer
bun run src/cli.ts search --query "data engineer" --page 2 --format table
```
### Response shape
```json
{
"meta": {
"total": 237,
"page": 1,
"perPage": 20
},
"results": [
{
"id": "h1647303",
"title": "Data Engineer til opbygning af Gavefabrikkens dataplatform",
"company": "Gavefabrikken",
"companyUrl": "https://www.gavefabrikken.dk/",
"location": "Valby",
"date": "2026-03-12",
"url": "https://www.jobindex.dk/jobannonce/h1647303/data-engineer-til-opbygning-af-gavefabrikkens-dataplatform",
"description": "Vi søger en dygtig Data Engineer til at opbygge og vedligeholde vores dataplatform..."
}
]
}
```
**Field notes:**
- `id` — string ID prefixed with `h` (e.g. `h1647303`). Use this with the `detail` command.
- `company` — company name; may be `null` for some aggregated listings.
- `companyUrl` — company homepage URL; may be `null` if not present.
- `location` — city or area; may be `null` if not listed.
- `date` — ISO date string (`YYYY-MM-DD`) from the `datetime` attribute on the `<time>` element; may be `null`.
- `description` — short excerpt from the listing; may be `null` or empty.
- `url` — full Jobindex.dk URL for the listing.
- `total` in `meta` — parsed from `hitcount_html` (Danish thousands separator `.` is stripped before parsing, e.g. `18.903` → `18903`).
> **Note on area filtering**: The Jobindex API does not reliably support area/region filtering via query parameters. `area` and `geoareaid` params are silently ignored. To filter by location, use `--query` with a city name (e.g. `--query "python aarhus"`) or apply `--limit` and filter the JSON output externally.
---
## `detail` — Fetch full job listing detail
**URL**: `https://www.jobindex.dk/jobannonce/{id}/{slug}`
```bash
bun run src/cli.ts detail <id> [--format json|plain]
```
The `id` is the job ID from `search` results (e.g. `h1647303`). The slug is optional — the CLI fetches the canonical URL by first constructing `https://www.jobindex.dk/jobannonce/{id}` and following any redirect, or by using the full URL from the `url` field in `search` results.
You may also pass the full URL directly as the `id` argument.
### Flags
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--format` | string | `json` | Output format: `json`, `plain` |
### Example
```bash
# Using ID from search results
bun run src/cli.ts detail h1647303
# Using full URL
bun run src/cli.ts detail "https://www.jobindex.dk/jobannonce/h1647303/data-engineer-til-opbygning-af-gavefabrikkens-dataplatform"
# Plain text output
bun run src/cli.ts detail h1647303 --format plain
```
### Response shape
```json
{
"id": "h1647303",
"title": "Data Engineer til opbygning af Gavefabrikkens dataplatform",
"company": "Gavefabrikken",
"companyUrl": "https://www.gavefabrikken.dk/",
"location": "Valby, København",
"date": "2026-03-12",
"deadline": "2026-04-01",
"employmentType": "Fastansættelse",
"hours": "Fuldtid",
"applyUrl": "https://www.gavefabrikken.dk/jobs/apply/123",
"url": "https://www.jobindex.dk/jobannonce/h1647303/data-engineer-til-opbygning-af-gavefabrikkens-dataplatform",
"description": "Full job description text here..."
}
```
**Field notes:**
Jobindex serves detail pages in two shapes, and field availability differs:
a **jobindex-native** page (recognisable by its `jd-*` facts blocks) carries
company, location, an ISO deadline, employment type and hours; an **external
ATS passthrough** (the employer's hosted ad, e.g. hr-manager/Talentech, served
through jobindex) has no reliable company anchor, so `company` is `null` there
rather than the ATS brand, and location/deadline come from the ad's own
widgets when present.
- `id` / `url` — always the jobindex id and its `jobannonce` URL, never the
page's `og:url`/canonical (on passthrough pages those point at the external
ATS, not the posting).
- `deadline` — `YYYY-MM-DD` or `null`; Danish long dates ("13. september
2026") and `DD-MM-YYYY` widget dates are converted.
- `employmentType` / `hours` — from the native facts blocks; `null` on
passthrough pages.
- `companyUrl` — currently always `null`; no page shape carries a usable
company link.
- `applyUrl` — the Jobindex redirect link (`/c?t=...`) when present; `null`
otherwise.
- `description` — plain text of the ad body (HTML stripped), falling back to
the page's meta description when the body is empty.
- All fields except `id`, `title`, and `url` may be `null`.
---
## Error handling
All errors are written to **stderr** in JSON format and exit with code `1`:
```json
{ "error": "Job not found", "code": "NOT_FOUND" }
{ "error": "API request failed: 500 Internal Server Error", "code": "API_ERROR" }
{ "error": "Failed to parse job listing HTML", "code": "PARSE_ERROR" }
{ "error": "--query is required", "code": "MISSING_REQUIRED" }
```
---
## URL construction
Job detail pages on jobindex.dk:
- `https://www.jobindex.dk/jobannonce/{id}/{slug}`
The slug is part of the `url` returned by `search`. When calling `detail` with just an ID, the CLI fetches `https://www.jobindex.dk/jobannonce/{id}` which redirects to the full URL.
---
## Parsing notes
### Total count from `hitcount_html`
The API returns pagination info as an HTML string like:
```html
<div class="jix_pagination_total"><strong>1</strong> til <strong>20</strong> af <strong>18.903</strong> resultater.</div>
```
Parse total with: `/af <strong>([\d.]+)<\/strong>/` and strip `.` before converting to integer.
### Job card selectors
Each job card is wrapped in `[data-beacon-tid]`. Inside, select:
| Field | Selector |
|-------|----------|
| `id` | `[data-beacon-tid]` attribute value |
| `title` | `h4 > a` text content |
| `url` | `h4 > a[href]` |
| `company` | `.jix-toolbar-top__company a` text |
| `companyUrl` | `.jix-toolbar-top__company a[href]` |
| `location` | `span.jix_robotjob--area` text |
| `date` | `time[datetime]` attribute value |
| `description` | `p` text content (first `<p>` in card) |
Two card types exist: `div.PaidJob` (sponsored) and `div.jix_robotjob` (aggregated). Both use the same selector pattern.
+25
View File
@@ -0,0 +1,25 @@
{
"name": "jobindex-cli",
"version": "1.0.0",
"description": "CLI for searching jobs on Jobindex.dk",
"type": "module",
"main": "src/cli.ts",
"bin": {
"jobindex": "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": {
"typescript": "^5.4.0",
"@types/bun": "1.3.14"
}
}
+45
View File
@@ -0,0 +1,45 @@
import { createCLI } from "@bunli/core"
import { writeError } from "./helpers.js"
import { search } from "./commands/search.js"
import { detail } from "./commands/detail.js"
const cli = await createCLI({
name: "jobindex-cli",
version: "0.1.0",
description: "CLI for searching jobs on Jobindex.dk",
})
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()
+321
View File
@@ -0,0 +1,321 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { htmlFetch, writeError } from "../helpers.js"
const BASE_URL = "https://www.jobindex.dk"
interface DetailResult {
id: string
title: string
company: string | null
companyUrl: string | null
location: string | null
date: string | null
deadline: string | null
employmentType: string | null
hours: string | null
applyUrl: string | null
url: string
description: string | null
}
/**
* Convert a Unicode code point to a string. Uses `fromCodePoint` (not
* `fromCharCode`) so supplementary-plane code points (e.g. emoji, U+1F600)
* decode correctly, and drops out-of-range values instead of throwing.
*/
function numericEntity(cp: number): string {
return cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : ""
}
/**
* Decode HTML entities in text
*/
function decodeHtmlEntities(text: string): string {
return text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
// Danish letters appear as named entities in employer-hosted ad markup.
.replace(/&oslash;/g, "ø")
.replace(/&Oslash;/g, "Ø")
.replace(/&aelig;/g, "æ")
.replace(/&AElig;/g, "Æ")
.replace(/&aring;/g, "å")
.replace(/&Aring;/g, "Å")
// Numeric character references: decimal (&#233;) and hexadecimal (&#xE9;).
.replace(/&#(\d+);/g, (_, dec) => numericEntity(parseInt(dec, 10)))
.replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex) => numericEntity(parseInt(hex, 16)))
.replace(/&nbsp;/g, " ")
}
/**
* Strip HTML tags from text
*/
function stripTags(html: string): string {
return html.replace(/<[^>]+>/g, "").trim()
}
/**
* Extract job ID from URL or return as-is if already an ID
*/
function extractIdFromUrl(url: string): string {
// Match IDs like h1647303, r13677312, etc.
const match = url.match(/\/jobannonce\/([a-zA-Z]\d+)/)
if (match) return match[1]
return url
}
function buildUrl(idOrUrl: string): { url: string; id: string } {
if (idOrUrl.startsWith("http")) {
const id = extractIdFromUrl(idOrUrl)
return { url: idOrUrl, id }
}
// It's a bare ID
const url = `${BASE_URL}/jobannonce/${idOrUrl}`
return { url, id: idOrUrl }
}
const DANISH_MONTHS: Record<string, string> = {
januar: "01", februar: "02", marts: "03", april: "04", maj: "05", juni: "06",
juli: "07", august: "08", september: "09", oktober: "10", november: "11", december: "12",
}
/**
* Normalize a date found on a detail page to YYYY-MM-DD, or null.
* Live pages carry three shapes: ISO, DD-MM-YYYY (the hr-manager widget),
* and Danish long form ("13. september 2026", the jobindex-native facts box).
*/
export function toIsoDate(value: string | null | undefined): string | null {
if (!value) return null
const text = value.trim()
let m = text.match(/^(\d{4})-(\d{2})-(\d{2})/)
if (m) return `${m[1]}-${m[2]}-${m[3]}`
m = text.match(/^(\d{2})-(\d{2})-(\d{4})/)
if (m) return `${m[3]}-${m[2]}-${m[1]}`
m = text.match(/^(\d{1,2})\.?\s+([a-zæøå]+)\s+(\d{4})/i)
if (m) {
const month = DANISH_MONTHS[m[2].toLowerCase()]
if (month) return `${m[3]}-${month}-${m[1].padStart(2, "0")}`
}
return null
}
function metaContent(html: string, matcher: string): string | null {
const re = new RegExp(
`<meta[^>]+(?:property|name|itemprop)="${matcher}"[^>]+content="([^"]*)"|<meta[^>]+content="([^"]*)"[^>]+(?:property|name|itemprop)="${matcher}"`,
"i",
)
const m = html.match(re)
const value = m ? (m[1] ?? m[2]) : null
return value ? decodeHtmlEntities(value).trim() || null : null
}
/** The text of the <p> inside a jobindex-native jd-* facts block. */
function jdBlockValue(html: string, cls: string): string | null {
const m = html.match(new RegExp(`class="${cls}"[^>]*>[\\s\\S]*?<p[^>]*>([\\s\\S]*?)</p>`, "i"))
return m ? decodeHtmlEntities(stripTags(m[1])).replace(/\s+/g, " ").trim() || null : null
}
/** Drop head/script/style content so text scans never read CSS or JS. */
function visibleHtml(html: string): string {
return html
.replace(/<head[\s\S]*?<\/head>/gi, "")
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<!--[\s\S]*?-->/g, "")
}
function bodyText(html: string): string | null {
const text = decodeHtmlEntities(stripTags(visibleHtml(html).replace(/<(br|\/p|\/div|\/li|\/h[1-6])[^>]*>/gi, "\n")))
.split("\n")
.map((line) => line.replace(/\s+/g, " ").trim())
.filter(Boolean)
.join("\n")
return text || null
}
/**
* Parse a live detail page. Jobindex serves two shapes (verified live
* 2026-08-19; the selectors the previous parser used exist in neither):
*
* - the jobindex-native shape, recognisable by its `jd-*` facts blocks
* (jd-deadline, jd-location, ...), with the company as the `<title>`
* prefix ("COMPANY - Job title");
* - an external ATS passthrough (hr-manager/Talentech and similar), where
* the page IS the employer's hosted ad: `og:url` points at the ATS,
* `og:site_name` is the ATS brand, and there is no reliable company
* anchor at all - so `company` is honestly null there, never the ATS.
*
* `id` and `url` are always the caller's jobindex id and its jobannonce
* URL: the canonical/og:url on these pages is the external ATS, and
* storing that broke /scrape's "store a URL that resolves to the posting".
*/
export function parseDetailPage(html: string, url: string, id: string): DetailResult {
const isNative = html.includes('class="jd-')
const ogTitle = metaContent(html, "og:title")
const itempropName = metaContent(html, "name")
const h1 = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)
const h1Title = h1 ? decodeHtmlEntities(stripTags(h1[1])).replace(/\s+/g, " ").trim() : null
const title = ogTitle ?? itempropName ?? h1Title ?? ""
if (!title) {
throw new Error("Failed to parse job listing HTML")
}
// Company: only the native shape carries one - as the <title> prefix,
// "VELLIV - Udvikler til Camunda/AWS". Require the suffix to be the job
// title so an unrelated <title> never becomes a company name.
let company: string | null = null
if (isNative) {
const titleTag = html.match(/<title>([\s\S]*?)<\/title>/i)
const pageTitle = titleTag ? decodeHtmlEntities(titleTag[1]).replace(/\s+/g, " ").trim() : ""
if (pageTitle.endsWith(` - ${title}`)) {
company = pageTitle.slice(0, -(title.length + 3)).trim() || null
}
}
let location: string | null = null
let deadline: string | null = null
let employmentType: string | null = null
let hours: string | null = null
let description: string | null = null
if (isNative) {
location = jdBlockValue(html, "jd-location")
deadline = toIsoDate(jdBlockValue(html, "jd-deadline"))
employmentType = jdBlockValue(html, "jd-type")
hours = jdBlockValue(html, "jd-workhours")
const desc = html.match(/class="jd-description"[^>]*>([\s\S]*?)<\/div>/i)
description = desc
? decodeHtmlEntities(stripTags(desc[1])).replace(/\s+/g, " ").trim() || null
: null
} else {
// hr-manager-style widget: a rowheader label followed by the value span.
const workplace = visibleHtml(html).match(
/class="workplace[^"]*"[\s\S]*?<span class="empty">([\s\S]*?)<\/span>/i,
)
location = workplace
? decodeHtmlEntities(stripTags(workplace[1])).replace(/\s+/g, " ").trim() || null
: null
// Deadline: label + a real date within range, scanned only over visible
// markup - the label also appears inside a CSS comment on these pages,
// which the previous parser captured verbatim as the deadline.
const due = visibleHtml(html).match(
/(?:Ansøgningsfrist|Application\s*due|Frist)[\s\S]{0,300}?(\d{2}-\d{2}-\d{4}|\d{4}-\d{2}-\d{2}|\d{1,2}\.?\s+[a-zæøå]+\s+\d{4})/i,
)
deadline = due ? toIsoDate(due[1]) : null
description = bodyText(html)
if (!description || description.length < 100) {
description = metaContent(html, "og:description") ?? metaContent(html, "description") ?? description
}
}
if (!description) {
description = metaContent(html, "og:description")
}
// Apply URL: jobindex's own /c?t= redirect when present.
let applyUrl: string | null = null
const ctMatch = html.match(/href="(\/c\?t=[^"]+)"/)
if (ctMatch) {
applyUrl = `${BASE_URL}${decodeHtmlEntities(ctMatch[1])}`
}
const timeMatch = html.match(/<time[^>]+datetime="([^"]+)"/)
return {
id,
title,
company,
companyUrl: null,
location,
date: timeMatch ? toIsoDate(timeMatch[1]) : null,
deadline,
employmentType,
hours,
applyUrl,
url,
description,
}
}
export const detail = defineCommand({
name: "detail",
description: "Fetch full job listing detail by ID or URL",
options: {
format: option(z.enum(["json", "plain"]).default("json"), {
description: "Output format: json, plain",
}),
},
handler: async ({ positional, flags, signal }) => {
if (signal.aborted) return
const idArg = positional[0]
if (!idArg) {
writeError("Job ID or URL is required", "MISSING_REQUIRED")
process.exit(1)
}
const { url, id } = buildUrl(idArg)
try {
const html = await htmlFetch(url)
if (signal.aborted) return
let data: DetailResult
try {
data = parseDetailPage(html, url, id)
} catch (parseErr) {
const msg = parseErr instanceof Error ? parseErr.message : String(parseErr)
writeError(msg, "PARSE_ERROR")
process.exit(1)
}
// Verify it's a valid job page (has a title)
if (!data.title) {
writeError("Failed to parse job listing HTML", "PARSE_ERROR")
process.exit(1)
}
if (flags.format === "json") {
console.log(JSON.stringify(data, null, 2))
} else {
outputPlain(data)
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.includes("Job not found") || message.includes("404") || message.includes("NOT_FOUND")) {
writeError("Job not found", "NOT_FOUND")
} else if (message.includes("Failed to parse") || message.includes("PARSE_ERROR")) {
writeError(message, "PARSE_ERROR")
} else {
writeError(message, "API_ERROR")
}
process.exit(1)
}
},
})
function outputPlain(data: DetailResult): void {
console.log(`id: ${data.id}`)
console.log(`title: ${data.title}`)
console.log(`company: ${data.company ?? "-"}`)
console.log(`location: ${data.location ?? "-"}`)
console.log(`date: ${data.date ?? "-"}`)
console.log(`deadline: ${data.deadline ?? "-"}`)
console.log(`employmentType: ${data.employmentType ?? "-"}`)
console.log(`hours: ${data.hours ?? "-"}`)
console.log(`applyUrl: ${data.applyUrl ?? "-"}`)
console.log(`url: ${data.url}`)
console.log("")
if (data.description) {
console.log(data.description)
}
}
+103
View File
@@ -0,0 +1,103 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { BASE_URL, htmlFetch, parseSearchPage, writeError, type JobCard } from "../helpers.js"
export const search = defineCommand({
name: "search",
description: "Search for job listings on Jobindex.dk",
options: {
query: option(z.string().optional(), {
short: "q",
description: "Keyword search query (e.g. python, grafisk designer)",
}),
page: option(z.coerce.number().int().min(1).default(1), {
description: "Page number (1-indexed)",
}),
jobage: option(z.coerce.number().int().min(1).default(9999), {
description: "Max age of posting in days: 1, 7, 14, 30, or 9999 (all)",
}),
sort: option(z.string().default("score"), {
description: "Sort order: score (relevance) or date (newest first)",
}),
limit: option(z.coerce.number().int().min(1).optional(), {
description: "Cap total results returned by the CLI (client-side)",
}),
format: option(z.enum(["json", "table", "plain"]).default("json"), {
description: "Output format: json, table, plain",
}),
},
handler: async ({ flags, signal }) => {
if (!flags.query) {
writeError("--query is required", "MISSING_REQUIRED")
process.exit(1)
}
if (signal.aborted) return
const params = new URLSearchParams({
q: flags.query,
page: String(flags.page),
jobage: String(flags.jobage),
sort: flags.sort,
})
try {
const html = await htmlFetch(`${BASE_URL}/jobsoegning?${params.toString()}`)
if (signal.aborted) return
const parsed = parseSearchPage(html)
const total = parsed.total
let results = parsed.results
if (flags.limit !== undefined) {
results = results.slice(0, flags.limit)
}
const output = {
meta: {
total,
page: flags.page,
perPage: 20,
},
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: JobCard[]): void {
console.log("id title company location")
for (const r of results) {
const id = r.id.padEnd(11)
const title = r.title.substring(0, 40).padEnd(40)
const company = (r.company ?? "-").substring(0, 20).padEnd(20)
const location = r.location ?? "-"
console.log(`${id} ${title} ${company} ${location}`)
}
}
function outputPlain(results: JobCard[]): 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(`date: ${r.date ?? "-"}`)
console.log(`deadline: ${r.deadline ?? "-"}`)
console.log(`url: ${r.url}`)
if (r.description) console.log(`description: ${r.description}`)
console.log("")
}
}
+346
View File
@@ -0,0 +1,346 @@
export const BASE_URL = "https://www.jobindex.dk"
export function writeError(error: string, code: string): void {
process.stderr.write(JSON.stringify({ error, code }) + "\n")
}
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, { signal: AbortSignal.timeout(15000) })
if (response.status === 429 || response.status >= 500) {
if (attempt === maxRetries) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
const jitter = Math.floor(Math.random() * 500)
await new Promise((resolve) => setTimeout(resolve, delay + jitter))
delay = Math.min(delay * 2, 5000)
continue
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
return response.json() as Promise<T>
}
throw new Error("API request failed after max retries")
}
export async function htmlFetch(url: string): Promise<string> {
const maxRetries = 6
let delay = 500
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, {
headers: {
"User-Agent": "Mozilla/5.0 (compatible; jobindex-cli/1.0)",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "da,en;q=0.9",
},
redirect: "follow",
signal: AbortSignal.timeout(15000),
})
if (response.status === 429 || response.status >= 500) {
if (attempt === maxRetries) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
const jitter = Math.floor(Math.random() * 500)
await new Promise((resolve) => setTimeout(resolve, delay + jitter))
delay = Math.min(delay * 2, 5000)
continue
}
if (response.status === 404) {
throw new Error(`Job not found`)
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
return response.text()
}
throw new Error("Request failed after max retries")
}
export interface JobCard {
id: string
title: string
company: string | null
companyUrl: string | null
location: string | null
date: string | null
deadline: string | null
url: string
description: string | null
}
/**
* Jobindex moved its search results client-side. The /jobsoegning.json endpoint
* now returns 204 No Content. The HTML page (/jobsoegning) embeds the full result
* payload in a `var Stash = {...}` script blob, under
* jobsearch/result_app -> storeData -> searchResponse -> { hitcount, results[] }.
* This extracts and parses that blob.
*/
export function extractStash(html: string): any {
const marker = "var Stash = "
const start = html.indexOf(marker)
if (start === -1) throw new Error("Could not locate Stash blob in jobindex HTML")
const open = start + marker.length
let depth = 0
let inStr = false
let esc = false
let end = -1
for (let j = open; j < html.length; j++) {
const c = html[j]
if (inStr) {
if (esc) esc = false
else if (c === "\\") esc = true
else if (c === '"') inStr = false
} else {
if (c === '"') inStr = true
else if (c === "{") depth++
else if (c === "}") {
depth--
if (depth === 0) {
end = j + 1
break
}
}
}
}
if (end === -1) throw new Error("Unterminated Stash blob in jobindex HTML")
return JSON.parse(html.slice(open, end))
}
function findSearchResponse(node: any): any {
if (node && typeof node === "object") {
if (!Array.isArray(node)) {
if (
node.searchResponse &&
typeof node.searchResponse === "object" &&
Array.isArray(node.searchResponse.results)
) {
return node.searchResponse
}
for (const key of Object.keys(node)) {
const found = findSearchResponse(node[key])
if (found) return found
}
} else {
for (const item of node) {
const found = findSearchResponse(item)
if (found) return found
}
}
}
return null
}
export interface SearchPageResult {
total: number
results: JobCard[]
}
export function parseSearchPage(html: string): SearchPageResult {
const stash = extractStash(html)
const sr = findSearchResponse(stash)
if (!sr) throw new Error("Could not locate searchResponse in jobindex Stash")
const results: JobCard[] = (sr.results ?? []).map((r: any): JobCard => {
const tid: string = r.tid ?? ""
let location: string | null = r.area ?? null
if (!location) {
try {
location = r.geojson?.features?.[0]?.properties?.title ?? null
} catch {
location = null
}
}
let deadline: string | null = null
// apply_deadline_asap means the posting states no fixed deadline ("apply
// now"). The /scrape contract represents that as null, and consumers do
// date arithmetic on this field - so the flag maps to null, and wins over
// any date field that happens to be present.
if (r.apply_deadline_asap) deadline = null
else if (typeof r.apply_deadline === "string") deadline = r.apply_deadline.slice(0, 10)
else if (typeof r.lastdate === "string") deadline = r.lastdate
return {
id: tid,
title: r.headline ?? "",
company: r.company?.name ?? r.companytext ?? null,
companyUrl: r.company?.homeurl ?? null,
location,
date: r.firstdate ?? null,
deadline,
url: tid ? `${BASE_URL}/jobannonce/${tid}` : (r.share_url ?? r.url ?? ""),
description: null,
}
})
const total = typeof sr.hitcount === "number" ? sr.hitcount : results.length
return { total, results }
}
/**
* Convert a Unicode code point to a string. Uses `fromCodePoint` (not
* `fromCharCode`) so supplementary-plane code points (e.g. emoji, U+1F600)
* decode correctly, and drops out-of-range values instead of throwing.
*/
function numericEntity(cp: number): string {
return cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : ""
}
/**
* Decode HTML entities in text
*/
function decodeHtmlEntities(text: string): string {
return text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
// Numeric character references: decimal (&#233;) and hexadecimal (&#xE9;).
.replace(/&#(\d+);/g, (_, dec) => numericEntity(parseInt(dec, 10)))
.replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex) => numericEntity(parseInt(hex, 16)))
.replace(/&nbsp;/g, " ")
}
/**
* Strip HTML tags from text
*/
function stripTags(html: string): string {
return html.replace(/<[^>]+>/g, "").trim()
}
/**
* Parse job cards from result_list_box_html using regex.
* node-html-parser has nesting bugs with this specific HTML structure
* (unclosed tags inside buttons cause incorrect DOM tree).
* Regex parsing is more reliable for this specific HTML format.
*/
export function parseJobCards(html: string): JobCard[] {
const results: JobCard[] = []
// Split HTML by jobad-wrapper to get individual card HTML chunks
const wrapperPattern = /<div[^>]+id="jobad-wrapper-(h\d+|r\d+)"[^>]*>([\s\S]*?)(?=<div[^>]+id="jobad-wrapper-|$)/g
let match: RegExpExecArray | null
while ((match = wrapperPattern.exec(html)) !== null) {
const id = match[1]
const cardHtml = match[2]
// Extract title: look for <h4>...<a|A href="...">Title</a>...</h4>
const titleMatch = cardHtml.match(/<h4[^>]*>[\s\S]*?<[Aa][^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/[Aa]>/i)
if (!titleMatch) continue
const rawTitle = stripTags(titleMatch[2])
const title = decodeHtmlEntities(rawTitle)
if (!title) continue
// Determine URL: prefer jobindex.dk /jobannonce/ URL, fallback to constructed URL
let url: string
const jobannonce = cardHtml.match(/href="(https:\/\/www\.jobindex\.dk\/jobannonce\/[^"]+)"/)
if (jobannonce) {
url = jobannonce[1]
} else {
// Construct canonical URL from ID
url = `${BASE_URL}/jobannonce/${id}`
}
// Extract company: <a ...> inside jix-toolbar-top__company
let company: string | null = null
let companyUrl: string | null = null
const companySection = cardHtml.match(/class="jix-toolbar-top__company"[^>]*>([\s\S]*?)<\/div>/i)
if (companySection) {
const companyLinkMatch = companySection[1].match(/<[Aa][^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/[Aa]>/i)
if (companyLinkMatch) {
company = decodeHtmlEntities(stripTags(companyLinkMatch[2])) || null
companyUrl = companyLinkMatch[1] || null
}
}
// Extract location: <span class="jix_robotjob--area">Location</span>
const locMatch = cardHtml.match(/<span[^>]+class="jix_robotjob--area"[^>]*>([\s\S]*?)<\/span>/i)
const location = locMatch ? decodeHtmlEntities(stripTags(locMatch[1])) || null : null
// Extract date: <time datetime="YYYY-MM-DD">
const dateMatch = cardHtml.match(/<time[^>]+datetime="([^"]+)"/)
const date = dateMatch ? dateMatch[1] : null
// Extract description: first <p class="..."> or first standalone <p> (not in toolbar)
// Skip the toolbar/menu section and look for the description paragraph
let description: string | null = null
const innerSection = cardHtml.match(/class="PaidJob-inner"[^>]*>([\s\S]*?)(?:<\/div>\s*<\/div>|$)/i) ||
cardHtml.match(/class="jix_robotjob-inner"[^>]*>([\s\S]*?)(?:<\/div>\s*<\/div>|$)/i)
if (innerSection) {
const pMatch = innerSection[1].match(/<p[^>]*>([\s\S]*?)<\/p>/i)
if (pMatch) {
const text = decodeHtmlEntities(stripTags(pMatch[1]))
description = text.length > 0 ? text.substring(0, 300) : null
}
} else {
// Fallback: look for p after the jobannonce link
const pMatches = [...cardHtml.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)]
for (const pm of pMatches) {
const text = decodeHtmlEntities(stripTags(pm[1]))
if (text.length > 20) {
description = text.substring(0, 300)
break
}
}
}
results.push({
id,
title,
company: company || null,
companyUrl: companyUrl || null,
location: location || null,
date: date || null,
deadline: null,
url,
description: description || null,
})
}
return results
}
export function extractDivContent(html: string, className: string): string | null {
const escaped = className.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const openRe = new RegExp(`<div[^>]*class="[^"]*${escaped}[^"]*"[^>]*>`, 'i')
const open = openRe.exec(html)
if (!open) return null
let i = open.index + open[0].length
let depth = 1
while (depth > 0 && i < html.length) {
const nextOpen = html.indexOf('<div', i)
const nextClose = html.indexOf('</div>', i)
if (nextClose === -1) return null
if (nextOpen !== -1 && nextOpen < nextClose) {
depth++
i = nextOpen + 4
} else {
depth--
i = nextClose + 6
}
}
return html.slice(open.index + open[0].length, i - 6)
}
export function parseHitCount(html: string): number {
const match = html.match(/af <strong>([\d.]+)<\/strong>/)
if (!match) return 0
const numStr = match[1].replace(/\./g, "")
return parseInt(numStr, 10) || 0
}
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import { runCLI } from "./helpers";
describe("Jobindex CLI error contract", () => {
test("search without a query fails with JSON on stderr", async () => {
const result = await runCLI(["search"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
expect(JSON.parse(result.stderr)).toEqual({
error: "--query 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(JSON.parse(result.stderr)).toEqual({
error: "Job ID or URL is required",
code: "MISSING_REQUIRED",
});
});
test("an invalid numeric option fails before making a request", async () => {
const result = await runCLI(["search", "--query", "test", "--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,80 @@
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. Filter flags (--jobage) 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("Jobindex CLI flag validation", () => {
test("--limit=-1 is rejected instead of silently dropping the last result", async () => {
const result = await runCLI(["search", "--query", "test", "--limit=-1"]);
expectValidationError(result, "limit");
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
});
test("--limit=0 is rejected", async () => {
const result = await runCLI(["search", "--query", "test", "--limit=0"]);
expectValidationError(result, "limit");
});
test("--limit=1.5 is rejected as non-integer", async () => {
const result = await runCLI(["search", "--query", "test", "--limit=1.5"]);
expectValidationError(result, "limit");
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
});
test("--page=0 is rejected on the 1-indexed portal", async () => {
const result = await runCLI(["search", "--query", "test", "--page=0"]);
expectValidationError(result, "page");
});
test("--jobage=-5 is rejected", async () => {
const result = await runCLI(["search", "--query", "test", "--jobage=-5"]);
expectValidationError(result, "jobage");
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
});
test("--jobage=1.5 is rejected as non-integer", async () => {
const result = await runCLI(["search", "--query", "test", "--jobage=1.5"]);
expectValidationError(result, "jobage");
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
});
test("valid numeric flags pass schema validation (proven offline via the required-flag guard)", async () => {
const result = await runCLI(["search", "--page=2", "--limit=5"]);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stderr)).toEqual({
error: "--query 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", "--query", "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");
});
});
@@ -0,0 +1,152 @@
import { describe, expect, test } from "bun:test";
import { parseDetailPage } from "../src/commands/detail";
// Jobindex redesigned its detail pages; every selector the old parser used
// (job-text, jix-info, jix_robotjob--area, jix-toolbar-top__company) is gone
// from live pages, so detail returned null company/location/date, CSS-comment
// text as the deadline, and an external ATS URL as its own id/url - exit 0,
// nothing signalling breakage (review finding F14, 2026-08-19, measured on
// 5/5 live postings). These fixtures are trimmed from live pages captured
// 2026-08-19: the jobindex-native "jd-*" shape and the external-ATS
// (hr-manager/Talentech) passthrough shape.
const NATIVE_PAGE = `<!DOCTYPE html>
<html lang="da">
<head>
<title>VELLIV - Udvikler til Camunda/AWS</title>
<meta property="og:title" content="Udvikler til Camunda/AWS" />
<meta property="og:url" content="https://www.jobindex.dk/jobannonce/h1690934" />
<meta property="og:description" content="VELLIV" />
</head>
<body>
<div class="jd-appetizer">
<h1>Udvikler til Camunda/AWS</h1>
</div>
<div id="container" class="container">
<div class="row">
<div class="twelve columns jd-details">
<div class="jd-description">
<p class="appetizer">En virksomhed med mere end 100 &aring;rs historie, der samtidig er cloud-only, er ikke hverdagskost.</p>
<p>Hos Velliv f&aring;r du mulighed for at arbejde med Camunda, AWS og automatisering af processer.</p>
</div>
</div>
<div class="four columns jd-facts">
<div class="jd-type">
<h3>Jobtype:</h3>
<p>Fast</p>
</div>
<div class="jd-workhours">
<h3>Arbejdstid:</h3>
<p>Fuldtid</p>
</div>
<div class="jd-worktime">
<h3>Arbejdsdage:</h3>
<p>Dag</p>
</div>
<div class="jd-deadline">
<h3>Ans&oslash;gningsfrist:</h3>
<p>13. september 2026</p>
</div>
<div class="jd-location">
<h3>Arbejdssted:</h3>
<p>Ballerup</p>
</div>
</div>
</div>
</div>
</body>
</html>`;
// The external shape: an employer's ATS-hosted ad served through jobindex.
// og:url points at the ATS (NOT the posting), og:site_name is the ATS brand,
// and the only occurrence of the deadline label outside the widget is inside
// a CSS comment - the exact text the old regex captured as the deadline.
const EXTERNAL_PAGE = `<!DOCTYPE html>
<html>
<head>
<title>
Talentech - C#-udvikler til kritiske analysel&oslash;sninger i elnettet
</title>
<meta name="description" content="Vi s&oslash;ger en udvikler til elnettet" />
<meta itemprop="name" content="C#-udvikler til kritiske analysel&oslash;sninger i elnettet" />
<meta property="og:title" content="C#-udvikler til kritiske analysel&oslash;sninger i elnettet" />
<meta property="og:site_name" content="Talentech" />
<meta property="og:url" content="https://candidate.hr-manager.net/ApplicationInit.aspx?cid=316&amp;ProjectId=188792" />
<style>
/* Defines the style of the Application Due text */
/* DK: Ansøgningsfrist */
/* BOKSTAV: K */
.frist { padding-bottom: 15px; }
</style>
</head>
<body>
<h1>C#-udvikler til kritiske analysel&oslash;sninger i elnettet</h1>
<p>Vil du v&aelig;re med til at udvikle og drifte de systemer, der underst&oslash;tter udbygningen af Danmarks kommende elnet? Vi arbejder i krydsfeltet mellem IT og energi.</p>
<div class="workplacelist emptyparent"><div id="workplacelist_lang" class="rowheader">Workplace</div><div class="widget-line"></div><span class="empty">Fredericia</span><br></div>
<div class="frist emptyparent"><div id="frist_lang" class="rowheader">Application due</div><div class="widget-line"></div><span class="empty">21-09-2026</span><br></div>
</body>
</html>`;
const JOBANNONCE_URL = "https://www.jobindex.dk/jobannonce/";
describe("parseDetailPage - jobindex-native (jd-*) shape", () => {
const job = parseDetailPage(NATIVE_PAGE, `${JOBANNONCE_URL}h1690934`, "h1690934");
test("extracts the contract fields", () => {
expect(job).toMatchObject({
id: "h1690934",
title: "Udvikler til Camunda/AWS",
company: "VELLIV",
location: "Ballerup",
deadline: "2026-09-13",
url: `${JOBANNONCE_URL}h1690934`,
});
});
test("converts the Danish long date to ISO", () => {
expect(job.deadline).toBe("2026-09-13");
});
test("extracts employment metadata and the description body", () => {
expect(job.employmentType).toBe("Fast");
expect(job.hours).toBe("Fuldtid");
expect(job.description).toContain("Camunda, AWS og automatisering");
});
});
describe("parseDetailPage - external ATS passthrough shape", () => {
const job = parseDetailPage(EXTERNAL_PAGE, `${JOBANNONCE_URL}h1690445`, "h1690445");
test("keeps the jobindex id and jobannonce URL, never the ATS og:url", () => {
expect(job.id).toBe("h1690445");
expect(job.url).toBe(`${JOBANNONCE_URL}h1690445`);
expect(job.url).not.toContain("hr-manager.net");
});
test("extracts the title and never reports the ATS brand as the company", () => {
expect(job.title).toBe("C#-udvikler til kritiske analyseløsninger i elnettet");
expect(job.company).toBeNull();
});
test("extracts the workplace widget location", () => {
expect(job.location).toBe("Fredericia");
});
test("finds the real deadline, not the CSS comment", () => {
expect(job.deadline).toBe("2026-09-21");
});
test("description is readable body text, not a stylesheet", () => {
expect(job.description).toContain("udbygningen af Danmarks kommende elnet");
expect(job.description).not.toContain("padding-bottom");
});
test("deadline is null when only the CSS comment mentions the label", () => {
const noDueWidget = EXTERNAL_PAGE.replace(
/<div class="frist emptyparent">[\s\S]*?<br><\/div>/,
"",
);
const parsed = parseDetailPage(noDueWidget, `${JOBANNONCE_URL}h9`, "h9");
expect(parsed.deadline).toBeNull();
});
});
+39
View File
@@ -0,0 +1,39 @@
import { join } from "path";
const CLI_PATH = join(import.meta.dir, "../src/cli.ts");
export interface CLIResult {
stdout: string;
stderr: string;
exitCode: number;
}
export async function runCLI(args: string[]): Promise<CLIResult> {
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
}
export function parseJSON<T = unknown>(result: CLIResult): T {
if (result.exitCode !== 0) {
throw new Error(
`CLI exited with code ${result.exitCode}. stderr: ${result.stderr}`
);
}
try {
return JSON.parse(result.stdout) as T;
} catch {
throw new Error(
`Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}`
);
}
}
+98
View File
@@ -0,0 +1,98 @@
import { describe, test, expect } from "bun:test";
import { parseJobCards, extractDivContent } from "../src/helpers";
// Minimal jobad-wrapper markup: parseJobCards splits on `jobad-wrapper-<id>`
// and reads the title from the <h4><a href>…</a></h4> and the company from the
// jix-toolbar-top__company link. We inject HTML entities to exercise decoding.
function card(id: string, title: string, company = "Acme A/S"): string {
return `<div id="jobad-wrapper-${id}" class="PaidJob">
<h4><a href="https://www.jobindex.dk/jobannonce/${id}">${title}</a></h4>
<div class="jix-toolbar-top__company">
<a href="https://www.jobindex.dk/virksomhed/acme">${company}</a>
</div>
</div>`;
}
describe("decodeHtmlEntities (via parseJobCards)", () => {
test("decodes hexadecimal numeric entities (&#xF8; -> ø)", () => {
const [c] = parseJobCards(card("h1", "Sm&#xF8;rrebro Chef"));
expect(c.title).toBe("Smørrebro Chef");
});
test("decodes uppercase-X hexadecimal entities (&#XE6; -> æ)", () => {
const [c] = parseJobCards(card("h2", "K&#XE6;re Kollega"));
expect(c.title).toBe("Kære Kollega");
});
test("still decodes decimal numeric entities (&#229; -> å) — regression", () => {
const [c] = parseJobCards(card("h3", "&#229;rhus Lead"));
expect(c.title).toBe("århus Lead");
});
test("decodes supplementary-plane code points with fromCodePoint (&#128512;)", () => {
const [c] = parseJobCards(card("h4", "Growth &#128512;"));
expect(c.title).toBe("Growth 😀");
});
test("decodes hex supplementary-plane code points (&#x1F600;)", () => {
const [c] = parseJobCards(card("h5", "Growth &#x1F600;"));
expect(c.title).toBe("Growth 😀");
});
test("decodes hex entities in the company name too", () => {
const [c] = parseJobCards(card("h6", "Engineer", "N&#xF8;rrebro ApS"));
expect(c.company).toBe("Nørrebro ApS");
});
});
describe("extractDivContent", () => {
test("extracts content from simple div", () => {
const html = '<div class="job-text">Simple text</div>';
expect(extractDivContent(html, "job-text")).toBe("Simple text");
});
test("extracts content with nested divs — the regression case", () => {
const html = `<div class="job-text">
<div class="highlight">First section</div>
<div class="details">Second section with important info</div>
</div>`;
expect(extractDivContent(html, "job-text")).toBe(
'\n <div class="highlight">First section</div>\n <div class="details">Second section with important info</div>\n ',
);
});
test("returns null when class not found", () => {
expect(extractDivContent("<div>no class</div>", "nonexistent")).toBeNull();
});
test("works with extra attributes on the div", () => {
const html = '<div id="desc" class="job-text" data-x="1">Content</div>';
expect(extractDivContent(html, "job-text")).toBe("Content");
});
test("handles deeply nested divs (3 levels)", () => {
const html = `<div class="job-text">
<div>
<div>Deep content</div>
</div>
</div>`;
expect(extractDivContent(html, "job-text")).toBe(
'\n <div>\n <div>Deep content</div>\n </div>\n ',
);
});
test("handles empty content", () => {
const html = '<div class="job-text"></div>';
expect(extractDivContent(html, "job-text")).toBe("");
});
test("handles br and other non-div tags", () => {
const html = '<div class="job-text">Line1<br>Line2<br>Line3</div>';
expect(extractDivContent(html, "job-text")).toBe("Line1<br>Line2<br>Line3");
});
test("escapes special regex characters in class name", () => {
const html = '<div class="job-text (special)">Content</div>';
expect(extractDivContent(html, "job-text (special)")).toBe("Content");
});
});
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, test } from "bun:test";
import { apiFetch, htmlFetch } from "../src/helpers";
// A stalled upstream connection (accepted socket, no response) would otherwise
// hang the CLI forever - fetch has no default timeout. Assert both request
// wrappers carry an AbortSignal timeout. Fails on the pre-fix code (no signal).
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("request timeout", () => {
test("apiFetch 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);
});
test("htmlFetch 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("<html></html>", { status: 200 });
}) as unknown as typeof fetch;
await htmlFetch("https://www.jobindex.dk/x");
expect(init?.signal).toBeInstanceOf(AbortSignal);
});
});
@@ -0,0 +1,89 @@
import { afterEach, describe, expect, test } from "bun:test";
import { apiFetch, htmlFetch } 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. apiFetch and htmlFetch carry separate copies
// of the loop, so both are exercised to keep them from drifting apart.
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("htmlFetch retry/backoff", () => {
test("retries a 429 and succeeds on the next attempt", async () => {
instantTimers();
const state = stubFetch([
() => new Response("", { status: 429 }),
() => new Response("<html>ok</html>", { status: 200 }),
]);
const html = await htmlFetch("https://www.jobindex.dk/x");
expect(html).toContain("ok");
expect(state.calls).toBe(2);
});
test("does not retry a plain 4xx", async () => {
const state = stubFetch([() => new Response("", { status: 400 })]);
await expect(htmlFetch("https://www.jobindex.dk/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(htmlFetch("https://www.jobindex.dk/x")).rejects.toThrow(/500/);
expect(state.calls).toBe(7);
});
});
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,69 @@
import { describe, expect, test } from "bun:test";
import { parseSearchPage } from "../src/helpers";
// parseSearchPage had no tests at all: mutating total to stop using
// sr.hitcount survived the whole suite (review finding F35, 2026-08-19).
// The fixture mirrors the real Stash nesting documented in helpers.ts:
// jobsearch/result_app -> storeData -> searchResponse -> { hitcount, results[] }.
function stashPage(searchResponse: object): string {
const stash = { jobsearch: { result_app: { storeData: { searchResponse } } } };
return `<html><head><script>var Stash = ${JSON.stringify(stash)};</script></head></html>`;
}
const RESULT = {
tid: "h1689961",
headline: "Softwareudvikler",
company: { name: "Acme A/S", homeurl: "https://acme.example" },
area: "Aarhus",
firstdate: "2026-08-10",
apply_deadline: "2026-09-11T00:00:00",
};
describe("parseSearchPage", () => {
test("total comes from hitcount, not the page's result count", () => {
const page = stashPage({ hitcount: 435, results: [RESULT] });
const parsed = parseSearchPage(page);
expect(parsed.total).toBe(435);
expect(parsed.results).toHaveLength(1);
});
test("total falls back to the result count when hitcount is absent", () => {
const page = stashPage({ results: [RESULT, { ...RESULT, tid: "h2" }] });
expect(parseSearchPage(page).total).toBe(2);
});
test("maps the contract fields from a Stash result", () => {
const [job] = parseSearchPage(stashPage({ hitcount: 1, results: [RESULT] })).results;
expect(job).toMatchObject({
id: "h1689961",
title: "Softwareudvikler",
company: "Acme A/S",
location: "Aarhus",
date: "2026-08-10",
deadline: "2026-09-11",
url: "https://www.jobindex.dk/jobannonce/h1689961",
});
});
test("maps an ASAP posting's deadline to null (no stated deadline)", () => {
// apply_deadline_asap means "no fixed deadline, apply now". The /scrape
// schema defines null as exactly that, and every consumer (rank's expiry
// sweep, notion-sync's typed date column) does date arithmetic on this
// field - a bare "ASAP" string broke all of them on half of live results
// (review finding F12, 2026-08-19). lastdate present too: the flag wins.
const [job] = parseSearchPage(
stashPage({
hitcount: 1,
results: [{ ...RESULT, apply_deadline: undefined, apply_deadline_asap: true, lastdate: "2026-09-30" }],
}),
).results;
expect(job.deadline).toBeNull();
});
test("falls back to lastdate when apply_deadline is absent", () => {
const [job] = parseSearchPage(
stashPage({ hitcount: 1, results: [{ ...RESULT, apply_deadline: undefined, lastdate: "2026-09-30" }] }),
).results;
expect(job.deadline).toBe("2026-09-30");
});
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext"],
"types": ["bun-types"],
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "."
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}
+118
View File
@@ -0,0 +1,118 @@
# Jobindex URL Reference
## Base URL
```
https://www.jobindex.dk/jobsoegning
```
## Full URL Pattern
```
https://www.jobindex.dk/jobsoegning/{category-group}/{category-slug}/{area-slug}?q={query}&jobage={days}&page={num}
```
All path segments and query params are optional. Category MUST come before area in the path.
## Path Segments
### Areas (geography)
Area slug goes at the end of the path:
| Area | Slug |
|------|------|
| Storkøbenhavn | `storkoebenhavn` |
| Københavnsområdet | `storkoebenhavn` |
| Nordsjælland | `nordsjaelland` |
| Sjælland | `sjaelland` |
| Fyn | `fyn` |
| Nordjylland | `nordjylland` |
| Midtjylland | `midtjylland` |
| Sydjylland | `sydjylland` |
| Bornholm | `bornholm` |
**Note:** If the exact slug is unknown, use the Geografi filter UI instead:
1. Click the "Geografi" button
2. Click "Tilføj område"
3. Type city/region name in the textbox
4. Select from autocomplete (treeitem)
5. Click "Vis X job" button
6. Note the URL path that results
### Categories
Category uses a two-part path: `{group}/{slug}`:
| Category | Path |
|----------|------|
| IT-drift og support | `it/itdrift` |
**Note:** Category slugs are not fully mapped. To discover a category slug:
1. Click the "Kategorier" button
2. Type category name in the search field
3. Select from autocomplete (treeitem)
4. Click "Vis X job" button
5. Note the URL path that results
## Query Parameters
| Parameter | Description | Examples |
|-----------|-------------|----------|
| `q` | Search query | `q=data+engineer` (broad), `q=%27data+engineer%27` (exact match) |
| `jobage` | Max age in days | `jobage=1` (today), `jobage=3`, `jobage=7`, `jobage=30` |
| `page` | Page number (1-indexed) | `page=1`, `page=2` |
## Examples
```bash
# Basic keyword search
playwright-cli goto "https://www.jobindex.dk/jobsoegning?q=python+developer"
# Exact match search (single quotes around query)
playwright-cli goto "https://www.jobindex.dk/jobsoegning?q=%27python+developer%27"
# Search in Storkøbenhavn area
playwright-cli goto "https://www.jobindex.dk/jobsoegning/storkoebenhavn?q=python+developer"
# Search with category + area
playwright-cli goto "https://www.jobindex.dk/jobsoegning/it/itdrift/storkoebenhavn?q=data+engineer"
# Last 7 days only
playwright-cli goto "https://www.jobindex.dk/jobsoegning?q=data+engineer&jobage=7"
# Page 2 of results
playwright-cli goto "https://www.jobindex.dk/jobsoegning?q=data+engineer&page=2"
# Everything combined
playwright-cli goto "https://www.jobindex.dk/jobsoegning/storkoebenhavn?q=data+engineer&jobage=7&page=1"
```
## Filters Available via UI Only
These filters require clicking through the filter panel (not URL-constructable):
**Ansættelsestype (Employment type):**
Fastansættelse, Tidsbegrænset, Studiejob, Graduate/trainee, Freelance, etc.
**Arbejdstid (Working hours):**
Fuldtid, Deltid
**Hjemmearbejde (Remote work):**
Muligt, Tilbydes ikke, 100% hjemmearbejde
To use these: click "Filtre" button → check desired options → click "Vis X job".
## Job Card Extraction
CSS selectors for job cards: `div.PaidJob, div.jix_robotjob`
Each card contains:
- Title: `h4 a` (text + href)
- Company: first `a` link text
- Location: div after h4 (clean by removing "Se rejsetid")
- Posted date: `time` element (format: DD-MM-YYYY)
- Description: `p` elements
- Job URL: `h4 a` href (may be external or jobindex-hosted)
~20 results per page.
+206
View File
@@ -0,0 +1,206 @@
---
name: jobnet-search
version: 1.0.0
description: >
Make sure to use this skill whenever the user mentions anything related to Danish
job searching, job listings, job vacancies, employment opportunities in Denmark, or
the Danish government job portal — even if they don't mention jobnet.dk explicitly.
Also invoke this skill for questions about specific job titles, occupations, employers,
or regions in a Danish employment context. This skill covers the official Danish
public job portal operated by STAR (Styrelsen for Arbejdsmarked og Rekruttering).
Trigger phrases include: danish jobs, danish job search, jobnet, jobnet.dk, find job
denmark, danish employment, job i danmark, job på jobnet, offentlige job, stillinger
i det offentlige, public sector jobs denmark, government jobs denmark, STAR jobs,
job ledige stillinger, ledig stilling, søg job, job opslag, job vacancy denmark,
stillingopslag, jobopslag, sygepleje job, ingeniør job, lærer job, pædagog job,
it-job denmark, jobs in copenhagen, jobs in aarhus, jobs in odense, deltidsjob,
fuldtidsjob, fastansættelse, tidsbegrænset ansættelse, fleksjob, sygeplejerske job,
social worker job denmark, occupation search denmark, esco occupation, job deadline,
ansøgningsfrist, søg efter job, full time job denmark, part time job denmark.
context: fork
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/jobnet-search/cli/src/cli.ts *)
---
# Jobnet-Search Skill
Access live Danish job listings from the Jobnet.dk public API. No authentication needed.
Jobnet is operated by STAR (Styrelsen for Arbejdsmarked og Rekruttering) and is Denmark's
official government job portal — covering public sector positions as well as many private
sector listings. Approximately 21,000+ active jobs at any time.
## When to use this skill
Invoke this skill when the user wants to:
- Search for job openings in Denmark by keyword, title, or employer
- Filter jobs by region, work hours (full/part time), or employment duration (permanent/temporary)
- Find jobs near a specific postal code within a given radius
- Get full details for a specific job ad including description, contact persons, and application URL
- Discover occupation types and ESCO categories for more precise job filtering
- Explore autocomplete suggestions for Danish job titles or keywords
- Find jobs in the public sector, healthcare, IT, education, or any other Danish industry
## Commands
### Search for job ads
```bash
bun run .agents/skills/jobnet-search/cli/src/cli.ts search [flags]
```
Key flags:
- `--search-string <text>` — keyword search, e.g. `sygeplejerske`, `ingeniør`, `pædagog`
- `--region <region>` — `HovedstadenOgBornholm`, `Midtjylland`, `Syddanmark`, `OevrigeSjaelland`, `Nordjylland`
- `--postal-code <code>` — postal code for radius-based search, e.g. `2100`
- `--radius <km>` — radius in km from postal code (default: `50`)
- `--work-hours <type>` — `FullTime` or `PartTime`
- `--duration <type>` — `Permanent` or `Temporary`
- `--job-type <type>` — `Ordinaert`, `Efterloenner`, `Foertidspension`
- `--occupation-area <id>` — occupation area identifier, e.g. `10000`
- `--occupation-group <id>` — occupation group identifier, e.g. `10060`
- `--order <type>` — `PublicationDate` (default), `BestMatch`, `ApplicationDate`
- `--page / --per-page / --limit`
- `--format json|table|plain`
### Full job ad detail
```bash
bun run .agents/skills/jobnet-search/cli/src/cli.ts detail <jobAdId> [--format json|plain]
```
`jobAdId` is the UUID from `search` results (the `jobAdId` field). Returns the complete job
description, contact persons, application deadline, employer details, and direct application URL.
### Search occupation types
```bash
bun run .agents/skills/jobnet-search/cli/src/cli.ts occupations --search-string <text> [--per-page <n>]
```
Use this to discover ESCO occupation identifiers before passing them to `search` with
`--occupation-area` or `--occupation-group`.
### Typeahead suggestions
```bash
bun run .agents/skills/jobnet-search/cli/src/cli.ts suggestions --query <text> [--limit <n>]
```
Returns Danish job title autocomplete strings. Useful for exploring valid Danish
job titles before constructing a `search` query.
---
## How to use effectively
**Discover occupations first.** Use `occupations` or `suggestions` to find the right Danish
term or ESCO identifier before running a `search`:
```bash
bun run .agents/skills/jobnet-search/cli/src/cli.ts suggestions --query "syge"
bun run .agents/skills/jobnet-search/cli/src/cli.ts occupations --search-string "sygeplejerske"
```
**Natural workflow: `search` → `detail`.**
1. Use `search` to get a list of matching jobs with their `jobAdId`.
2. Call `detail <jobAdId>` to get the full job description, contact persons, and direct application link.
**Use `--format table` for comparisons**, `--format json` for data processing, and
`--format plain` for single-record detail views (strips HTML from job body).
**Pagination**: `--per-page` controls server-side results per page. `--limit` caps what the
CLI outputs. Use `--page` + `--per-page` to iterate through large result sets.
**Geographic search modes:**
- Use `--region` for broad regional filtering (e.g. all of Midtjylland)
- Use `--postal-code` + `--radius` for jobs near a specific location
- Do not combine `--region` and `--postal-code` in the same query
**Order matters for intent:**
- `PublicationDate` — newest postings first (default, good for "what's new")
- `BestMatch` — relevance score (best when `--search-string` is provided)
- `ApplicationDate` — earliest deadline first (good for urgent applications)
---
## Usage examples
### Jobs in Copenhagen area
```bash
bun run .agents/skills/jobnet-search/cli/src/cli.ts search \
--region HovedstadenOgBornholm \
--per-page 10 \
--format table
```
### Nurse jobs nationwide
```bash
bun run .agents/skills/jobnet-search/cli/src/cli.ts search \
--search-string "sygeplejerske" \
--work-hours FullTime \
--duration Permanent \
--order BestMatch \
--per-page 10 \
--format table
```
### IT jobs near Aarhus within 30km
```bash
bun run .agents/skills/jobnet-search/cli/src/cli.ts search \
--search-string "udvikler" \
--postal-code 8000 \
--radius 30 \
--work-hours FullTime \
--format table
```
### Full details of a job ad
```bash
bun run .agents/skills/jobnet-search/cli/src/cli.ts detail 9ef43bce-d82b-4ea1-a098-7ff6520f99be --format plain
```
### Jobs sorted by application deadline (urgent first)
```bash
bun run .agents/skills/jobnet-search/cli/src/cli.ts search \
--search-string "pædagog" \
--region OevrigeSjaelland \
--order ApplicationDate \
--per-page 10
```
### Discover occupation terms
```bash
bun run .agents/skills/jobnet-search/cli/src/cli.ts suggestions --query "ingeniør" --limit 5
bun run .agents/skills/jobnet-search/cli/src/cli.ts occupations --search-string "lærer" --per-page 5
```
---
## Output formats
| Format | Best for |
|--------|----------|
| `json` | Default — programmatic use, data processing, passing IDs between commands |
| `table` | Quick human-readable overviews and comparisons |
| `plain` | Single-record detail views (`detail`), strips HTML from job descriptions |
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
---
## Notes
- All data is from the public `jobnet.dk/bff` REST API — no credentials required.
- The API requires the `x-csrf: 1` header; the CLI adds this automatically.
- Pagination is 1-indexed (`--page 1` is the first page).
- `search` results omit the HTML job description — use `detail` to get it.
- `detail --format plain` strips HTML tags for readable text output.
- Job ad detail pages on jobnet.dk: `https://jobnet.dk/find-job/{jobAdId}`
- `suggestions` is tuned for Danish job titles — English terms may return empty results.
+385
View File
@@ -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
View File
@@ -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"
}
}
+47
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/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 &amp; analytics flows.</p><p>Use SQL&nbsp;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("&amp;");
});
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
View File
@@ -0,0 +1,39 @@
import { join } from "path";
const CLI_PATH = join(import.meta.dir, "../src/cli.ts");
export interface CLIResult {
stdout: string;
stderr: string;
exitCode: number;
}
export async function runCLI(args: string[]): Promise<CLIResult> {
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
}
export function parseJSON<T = unknown>(result: CLIResult): T {
if (result.exitCode !== 0) {
throw new Error(
`CLI exited with code ${result.exitCode}. stderr: ${result.stderr}`
);
}
try {
return JSON.parse(result.stdout) as T;
} catch {
throw new Error(
`Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}`
);
}
}
@@ -0,0 +1,23 @@
import { afterEach, describe, expect, test } from "bun:test";
import { 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");
});
});
+27
View File
@@ -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
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext"],
"types": ["bun-types"],
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "."
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}
+98
View File
@@ -0,0 +1,98 @@
---
name: jobsdb-search
version: 1.0.0
description: >
Use this skill to search the local SQLite snapshot of job postings at
documents/postings/jobs.db (~87k postings across ~10k companies, scraped from
Greenhouse/Ashby/Lever/Workday boards, enriched with company sector, funding
stage, crypto-native flag, and a priority tier). Trigger phrases: search the
postings database, jobs.db, local job snapshot, which target companies are
hiring, crypto/fintech roles in New York or San Francisco.
context: fork
enabled: true # set to false to keep this portal installed but have /scrape skip it
allowed-tools: Bash(bun run .agents/skills/jobsdb-search/cli/src/cli.ts *)
---
# jobsdb Search Skill
Searches a **local SQLite snapshot** of job postings with `bun:sqlite`. No network,
no credentials, zero dependencies. The database is user-supplied at
`documents/postings/jobs.db` (override with `JOBSDB_PATH`); it is git-ignored
personal data, not part of the framework.
## ⚠️ This is a static snapshot, not a live board
Rows carry the `scrape_timestamp` they were captured at (currently May 16 to
Jun 1, 2026). Two consequences for `/scrape`:
- **Recency is relative to the snapshot.** `--jobage N` means "posted within N
days of the row's own scrape date". Do **not** apply the calendar 14-day cutoff
to this portal's `date` field; it would drop everything. Treat `meta.snapshot`
as "today" for this source and say so in the results table.
- **Postings may have closed.** Every hit must be confirmed live by fetching its
`url` before it is presented or ranked. A 404 or "job closed" page means skip.
Refresh the snapshot by replacing the `.db` file; the CLI needs no changes.
## Commands
### Search
```bash
bun run .agents/skills/jobsdb-search/cli/src/cli.ts search [flags]
```
- `-q, --query <text>`: FTS5 full-text match on title, company, and location. Tokens are AND-ed; quoting is handled for you.
- `--company <text>`: company name contains text.
- `--location <text>`: location text contains text (`"New York"`, `"San Francisco"`, `Remote`).
- `--country <code>`: exact `location_country` (e.g. `US`).
- `--remote <list>`: comma list of `remote` (global), `remote-regional`, `hybrid`, `onsite`.
- `--seniority <list>`: comma list of `senior`, `staff_principal`, `manager`, `director`, `head_of`, `vp`, `c_level_founder`, `mid_unclassified`, `junior_associate`, `intern`, `unclassified`.
- `--sector <list>`: company `target_sector`: `crypto`, `fintech-nc`, `ai-ml`, `devtools`, `enterprise-saas`, `hardware`, `other`. Most companies are untagged; omit this flag for a wide net.
- `--crypto-native`: only companies flagged crypto-native (~4.4k postings).
- `--tier <n>`: company `priority_tier` at or below n (1 = highest-priority target list).
- `--jobage <days>`: posted within N days of the snapshot (see warning above).
- `--page <n>`, `-n, --limit <n>` (default 20), `--format json|table`.
Unknown flags exit 1 with `{"error":...,"code":"BAD_ARG"}` on stderr.
### Detail
```bash
bun run .agents/skills/jobsdb-search/cli/src/cli.ts detail <posting_id|url> [--format json|plain]
```
The snapshot stores **no description text**. `detail` returns the structured row
(title, company, location, remote class, seniority, salary range when stated,
sector, tier, ATS, resolved date) and the URL; fetch the URL for requirements.
## Usage examples
```bash
# Senior/staff backend roles at crypto-native companies in NYC, snapshot-recent
bun run .agents/skills/jobsdb-search/cli/src/cli.ts search -q "backend engineer" --crypto-native --location "New York" --seniority senior,staff_principal --jobage 30 --format table
# Everything at tier-1 target companies, remote or hybrid
bun run .agents/skills/jobsdb-search/cli/src/cli.ts search --tier 1 --remote remote,hybrid -n 50 --format table
# Founding engineer roles anywhere
bun run .agents/skills/jobsdb-search/cli/src/cli.ts search -q "founding engineer" --format table
# Go / protocol roles
bun run .agents/skills/jobsdb-search/cli/src/cli.ts search -q "protocol engineer" --sector crypto -n 20
```
## Output
Search JSON is `{ "meta": { "count", "page", "total", "snapshot", "db" }, "results": [...] }`.
Each result carries `id`, `title`, `company`, `location`, `date` (ISO, resolved
from ISO or "Posted N Days Ago" text; `date_approximate: true` when the source
said "30+ days"), `url`, `description` (always `null`, see Detail), plus `remote`,
`seniority`, `salary`, `sector`, `crypto_native`, `priority_tier`, `ats`, `snapshot`.
Results are ordered by company priority tier, then newest scrape first.
## Notes
- Workday rows often have no posted date at all; they come back with `date: null` and are dropped by `--jobage`. Search without `--jobage` to include them.
- `location_text` is free text from each ATS ("2 Locations", "US-TX-HOUSTON-011"); `--location` is a substring match, so try the city name without the state.
- The `companies` table also holds funding, headcount growth, GitHub activity, and investor data; this CLI exposes only sector, crypto flag, and tier. Query the `.db` directly with `sqlite3` for anything else.
+11
View File
@@ -0,0 +1,11 @@
{
"name": "jobsdb-cli",
"version": "1.0.0",
"description": "CLI for searching a local SQLite snapshot of job postings (documents/postings/jobs.db). Zero runtime dependencies: bun:sqlite only.",
"type": "module",
"main": "src/cli.ts",
"bin": { "jobsdb-search": "src/cli.ts" },
"scripts": { "start": "bun run src/cli.ts", "typecheck": "tsc --noEmit" },
"dependencies": {},
"devDependencies": { "typescript": "^5.4.0", "@types/bun": "1.3.14" }
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bun
// jobsdb-cli: search a local SQLite snapshot of job postings. Zero dependencies (bun:sqlite).
import { runSearch, type SearchOpts } from "./commands/search.js"
import { runDetail } from "./commands/detail.js"
import { writeError } from "./helpers.js"
const HELP = `jobsdb-cli - search the local postings snapshot (documents/postings/jobs.db)
Usage:
cli.ts search [flags]
cli.ts detail <posting_id|url> [--format json|plain]
Search flags:
-q, --query <text> Full-text match on title, company, location (FTS5)
--company <text> Company name contains <text>
--location <text> Location text contains <text> (e.g. "New York", "San Francisco")
--country <code> location_country equals <code> (e.g. US)
--remote <list> Comma list of: remote, remote-regional, hybrid, onsite
--seniority <list> Comma list of: senior, staff_principal, manager, director, head_of, vp, c_level_founder, mid_unclassified, junior_associate, intern, unclassified
--sector <list> Comma list of company target_sector values (crypto, fintech-nc, ai-ml, devtools, enterprise-saas, hardware, other)
--crypto-native Only companies flagged crypto-native
--tier <n> Company priority_tier <= n (1 is best)
--jobage <days> Posted within N days of the row's scrape snapshot (not today)
--page <n> 1-indexed page. Default 1
-n, --limit <n> Results per page. Default 20
--format json|table Default json
Env:
JOBSDB_PATH Override the database path
`
type Flags = Record<string, string | boolean>
function parse(argv: string[]): { cmd: string; pos: string[]; flags: Flags } {
const [cmd = "", ...rest] = argv
const pos: string[] = []
const flags: Flags = {}
for (let i = 0; i < rest.length; i++) {
const a = rest[i]
if (a === "-q") { flags.query = rest[++i] ?? ""; continue }
if (a === "-n") { flags.limit = rest[++i] ?? ""; continue }
if (a.startsWith("--")) {
const [k, inline] = a.slice(2).split("=", 2)
if (inline !== undefined) { flags[k] = inline; continue }
const next = rest[i + 1]
if (next === undefined || next.startsWith("-")) flags[k] = true
else flags[k] = rest[++i]
continue
}
pos.push(a)
}
return { cmd, pos, flags }
}
function num(flags: Flags, name: string, dflt?: number): number | undefined {
const raw = flags[name]
if (raw === undefined) return dflt
const n = Number(raw)
if (!Number.isFinite(n) || n < 0) { writeError(`--${name} must be a non-negative number, got "${raw}"`, "BAD_ARG"); process.exit(1) }
return n
}
const list = (v: string | boolean | undefined) => (typeof v === "string" && v ? v.split(",").map((s) => s.trim()).filter(Boolean) : undefined)
const str = (v: string | boolean | undefined) => (typeof v === "string" && v ? v : undefined)
const { cmd, pos, flags } = parse(process.argv.slice(2))
const KNOWN = new Set(["query", "company", "location", "country", "remote", "seniority", "sector", "crypto-native", "tier", "jobage", "page", "limit", "format"])
for (const k of Object.keys(flags)) if (!KNOWN.has(k)) { writeError(`Unknown flag --${k}`, "BAD_ARG"); process.exit(1) }
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") { process.stdout.write(HELP); process.exit(0) }
if (cmd === "search") {
const format = (str(flags.format) ?? "json") as SearchOpts["format"]
if (format !== "json" && format !== "table") { writeError(`--format must be json or table`, "BAD_ARG"); process.exit(1) }
runSearch({
query: str(flags.query), company: str(flags.company), location: str(flags.location), country: str(flags.country),
remote: list(flags.remote), seniority: list(flags.seniority), sector: list(flags.sector),
cryptoNative: flags["crypto-native"] === true, tier: num(flags, "tier"), jobage: num(flags, "jobage"),
page: Math.max(1, num(flags, "page", 1)!), limit: Math.max(1, num(flags, "limit", 20)!), format,
})
} else if (cmd === "detail") {
if (!pos[0]) { writeError("detail requires a <posting_id|url>", "NO_ID"); process.exit(1) }
const format = (str(flags.format) ?? "json") as "json" | "plain"
runDetail(pos[0], format)
} else {
writeError(`Unknown command "${cmd}"`, "BAD_CMD"); process.exit(1)
}
+17
View File
@@ -0,0 +1,17 @@
import { openDb, toJob, writeError, type Row } from "../helpers.js"
export function runDetail(idOrUrl: string, format: "json" | "plain"): void {
const db = openDb()
const row = db
.query("SELECT p.*, c.target_sector, c.crypto_native, c.priority_tier FROM postings p LEFT JOIN companies c ON c.company_id = p.company_id WHERE p.posting_id = ? OR p.url = ? LIMIT 1")
.get(idOrUrl, idOrUrl) as Row | null
if (!row) { writeError(`no posting with id or url ${idOrUrl}`, "NOT_FOUND"); process.exit(1) }
const job = toJob(row)
if (format === "plain") {
process.stdout.write(
[`${job.title} - ${job.company}`, `${job.location} | ${job.remote ?? "?"} | ${job.seniority ?? "?"}`, `Posted ${job.date ?? "?"}${job.date_approximate ? " (approx.)" : ""}, snapshot ${job.snapshot}`, job.salary ? `Salary ${job.salary}` : "", `URL: ${job.url}`, "", "The snapshot stores no description. Fetch the URL above for the full posting text."].filter(Boolean).join("\n") + "\n",
)
return
}
process.stdout.write(JSON.stringify(job, null, 2) + "\n")
}
+72
View File
@@ -0,0 +1,72 @@
import { openDb, toJob, renderTable, writeError, REMOTE_CLASS, type Row } from "../helpers.js"
export interface SearchOpts {
query?: string
company?: string
location?: string
country?: string
remote?: string[]
seniority?: string[]
sector?: string[]
cryptoNative?: boolean
tier?: number
jobage?: number
page: number
limit: number
format: "json" | "table"
}
function ftsMatch(q: string): string {
// Quote every token so user text can never be parsed as FTS5 syntax.
return q.split(/\s+/).filter(Boolean).map((t) => `"${t.replace(/"/g, '""')}"`).join(" ")
}
export function runSearch(opts: SearchOpts): void {
const db = openDb()
const where: string[] = []
const params: (string | number)[] = []
let from = "postings p LEFT JOIN companies c ON c.company_id = p.company_id"
if (opts.query) {
from = "postings_fts f JOIN postings p ON p.rowid = f.rowid LEFT JOIN companies c ON c.company_id = p.company_id"
where.push("postings_fts MATCH ?")
params.push(ftsMatch(opts.query))
}
if (opts.company) { where.push("p.company_name LIKE ?"); params.push(`%${opts.company}%`) }
if (opts.location) { where.push("p.location_text LIKE ?"); params.push(`%${opts.location}%`) }
if (opts.country) { where.push("p.location_country = ?"); params.push(opts.country) }
if (opts.remote?.length) {
const classes = opts.remote.map((r) => REMOTE_CLASS[r.toLowerCase()]).filter(Boolean)
if (classes.length !== opts.remote.length) {
writeError(`--remote accepts ${Object.keys(REMOTE_CLASS).join(", ")}`, "BAD_ARG"); process.exit(1)
}
where.push(`p.location_class IN (${classes.map(() => "?").join(",")})`); params.push(...classes)
}
if (opts.seniority?.length) { where.push(`p.seniority IN (${opts.seniority.map(() => "?").join(",")})`); params.push(...opts.seniority) }
if (opts.sector?.length) { where.push(`c.target_sector IN (${opts.sector.map(() => "?").join(",")})`); params.push(...opts.sector) }
if (opts.cryptoNative) where.push("c.crypto_native = 'True'")
if (opts.tier !== undefined) { where.push("c.priority_tier <= ?"); params.push(opts.tier) }
const sql = `SELECT p.*, c.target_sector, c.crypto_native, c.priority_tier FROM ${from}
${where.length ? "WHERE " + where.join(" AND ") : ""}
ORDER BY COALESCE(c.priority_tier, 9), p.scrape_timestamp DESC`
let rows: Row[]
try { rows = db.query(sql).all(...params) as Row[] } catch (e) {
writeError(`query failed: ${(e as Error).message}`, "QUERY_FAILED"); process.exit(1)
}
let jobs = rows.map(toJob)
if (opts.jobage !== undefined) {
// Recency relative to each row's own scrape snapshot, not today's calendar: the DB is a static export.
jobs = jobs.filter((j) => {
if (!j.date || !j.snapshot) return false
const age = (Date.parse(j.snapshot) - Date.parse(j.date)) / 86_400_000
return age <= opts.jobage!
})
}
const total = jobs.length
const start = (opts.page - 1) * opts.limit
const pageJobs = jobs.slice(start, start + opts.limit)
const snapshot = pageJobs.reduce<string | null>((m, j) => (j.snapshot && (!m || j.snapshot > m) ? j.snapshot : m), null)
if (opts.format === "table") { process.stdout.write(renderTable(pageJobs) + "\n"); return }
process.stdout.write(JSON.stringify({ meta: { count: pageJobs.length, page: opts.page, total, snapshot, db: process.env.JOBSDB_PATH ?? "documents/postings/jobs.db" }, results: pageJobs }, null, 2) + "\n")
}
+116
View File
@@ -0,0 +1,116 @@
import { Database } from "bun:sqlite"
import { existsSync } from "node:fs"
export const DB_PATH = process.env.JOBSDB_PATH ?? "documents/postings/jobs.db"
export const REMOTE_CLASS: Record<string, string> = { remote: "R1", "remote-global": "R1", "remote-regional": "R2", hybrid: "H", onsite: "O" }
export const CLASS_LABEL: Record<string, string> = { R1: "Remote (global)", R2: "Remote (regional)", H: "Hybrid", O: "Onsite" }
export interface Row {
posting_id: string
company_id: string | null
company_name: string | null
title: string | null
seniority: string | null
posted_date: string | null
location_text: string | null
location_class: string | null
location_country: string | null
salary_min: string | null
salary_max: string | null
salary_currency: string | null
url: string | null
source_ats: string | null
scrape_timestamp: string | null
target_sector: string | null
crypto_native: string | null
priority_tier: number | null
}
export interface Job {
id: string
title: string | null
company: string | null
location: string | null
date: string | null
date_approximate: boolean
url: string | null
description: null
remote: string | null
seniority: string | null
salary: string | null
sector: string | null
crypto_native: boolean
priority_tier: number | null
ats: string | null
snapshot: string | null
}
export function openDb(): Database {
if (!existsSync(DB_PATH)) {
writeError(`database not found at ${DB_PATH} (set JOBSDB_PATH)`, "NO_DB")
process.exit(1)
}
return new Database(DB_PATH, { readonly: true })
}
/** "Posted 8 Days Ago" + scrape_timestamp -> ISO date. "30+" is a floor, flagged approximate. */
export function resolveDate(posted: string | null, scraped: string | null): { date: string | null; approximate: boolean } {
if (!posted) return { date: null, approximate: false }
// greenhouse/ashby/lever store ISO timestamps; workday stores relative text.
if (/^\d{4}-\d{2}-\d{2}/.test(posted)) return { date: posted.slice(0, 10), approximate: false }
if (!scraped) return { date: null, approximate: false }
const base = new Date(scraped)
if (isNaN(base.getTime())) return { date: null, approximate: false }
const m = /(\d+)(\+?)\s*Days?/i.exec(posted)
let days: number
let approximate = false
if (/Today/i.test(posted)) days = 0
else if (/Yesterday/i.test(posted)) days = 1
else if (m) { days = Number(m[1]); approximate = m[2] === "+" }
else return { date: null, approximate: false }
base.setUTCDate(base.getUTCDate() - days)
return { date: base.toISOString().slice(0, 10), approximate }
}
export function toJob(r: Row): Job {
const { date, approximate } = resolveDate(r.posted_date, r.scrape_timestamp)
const salary = r.salary_min && r.salary_max ? `${r.salary_min}-${r.salary_max} ${r.salary_currency ?? ""}`.trim() : null
return {
id: r.posting_id,
title: r.title || null,
company: r.company_name || null,
location: r.location_text || null,
date,
date_approximate: approximate,
url: r.url || null,
description: null,
remote: r.location_class ? CLASS_LABEL[r.location_class] ?? null : null,
seniority: r.seniority || null,
salary,
sector: r.target_sector || null,
crypto_native: r.crypto_native === "True",
priority_tier: r.priority_tier ?? null,
ats: r.source_ats || null,
snapshot: r.scrape_timestamp ? r.scrape_timestamp.slice(0, 10) : null,
}
}
export function writeError(error: string, code: string): void {
process.stderr.write(JSON.stringify({ error, code }) + "\n")
}
export function renderTable(jobs: Job[]): string {
if (jobs.length === 0) return "No results."
const rows = jobs.map((j) =>
[
(j.title ?? "").slice(0, 44).padEnd(44),
(j.company ?? "-").slice(0, 22).padEnd(22),
(j.location ?? "-").slice(0, 22).padEnd(22),
(j.remote ?? "-").slice(0, 16).padEnd(16),
j.date ? j.date + (j.date_approximate ? "~" : " ") : "-".padEnd(11),
j.url ?? "",
].join(" "),
)
return rows.join("\n")
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["bun"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"]
}
+103
View File
@@ -0,0 +1,103 @@
---
name: linkedin-search
version: 1.0.0
description: >
Use this skill whenever the user wants to search for jobs in any location or
market, find job listings, or look up a specific job posting — in any country,
city, or remotely. Invoke for open positions, vacancies, and hiring across any
sector or role (software, data, design, marketing, finance, legal, operations,
etc.). The location is always supplied explicitly by the user. Trigger phrases:
find a job, job search, search for jobs, job openings, vacancies, hiring,
positions open, remote jobs, "are there any X jobs in <place>", look up this
job posting.
context: fork
enabled: true # set to false to keep this portal installed but have /scrape skip it
allowed-tools: Bash(bun run .agents/skills/linkedin-search/cli/src/cli.ts *)
---
# LinkedIn Search Skill
Search live job listings from LinkedIn's public job board for **any country/region**
(and remote). No authentication, no API key, and **zero runtime dependencies** — it runs
with just `bun`. The location is always passed explicitly, so the same skill works for a
forker in any market out of the box.
> This is a country-agnostic worked example of the repo's job-portal-skill pattern.
> LinkedIn's `jobs-guest` endpoints are global and the HTML parsing is country-independent;
> only the `--location` you pass changes per market.
## ⚠️ Personal use only
This uses LinkedIn's public job pages; automated access is against LinkedIn's Terms of
Service, so **keep volume low and don't use it commercially or for bulk data collection.**
Run it on your own responsibility.
## When to use this skill
- Search for job openings in a given location (any country/city) or remotely
- Filter by recency (posted today / last 7 / 14 / 30 days) or workplace type (remote/hybrid/onsite)
- Get the full description of a specific job listing
## Commands
### Search job listings
```bash
bun run .agents/skills/linkedin-search/cli/src/cli.ts search --location "<place>" [flags]
```
Key flags:
- `--location <text>` / `-l <text>` — **required.** A LinkedIn place string, e.g. `"Mumbai, Maharashtra, India"`, `"Berlin, Germany"`, `"London, United Kingdom"`, or `"Remote"`.
- `--query <text>` / `-q <text>` — keyword search (title, skill, role). Recommended.
- `--jobage <days>` — posted within N days: `1`, `7`, `14`, `30`. Omit for all postings.
- `--jobage-minutes <n>` — posted within N minutes (sub-day precision, e.g. `30`). Conflicts with `--jobage` — pass only one.
- `--remote <mode>` — `remote`, `hybrid`, or `onsite` (workplace-type filter).
- `--page <n>` — page number (1-indexed, 10 results per page).
- `--limit <n>` / `-n <n>` — cap total results emitted (client-side).
- `--format json|table|plain` — default `json`.
### Fetch full job detail
```bash
bun run .agents/skills/linkedin-search/cli/src/cli.ts detail <id|url> [--format json|plain]
```
`id` is the job ID from `search` results (e.g. `4426311357`). You may also pass a full
LinkedIn `jobs/view/...` URL or a `urn:li:jobPosting:...` URN. Returns the full description,
seniority, employment type, job function, and industries.
## Usage examples
```bash
# Data engineer roles in Bengaluru, last 30 days
bun run .agents/skills/linkedin-search/cli/src/cli.ts search -q "data engineer" -l "Bengaluru, Karnataka, India" --jobage 30 --format table
# Product manager roles in Berlin, remote
bun run .agents/skills/linkedin-search/cli/src/cli.ts search -q "product manager" -l "Berlin, Germany" --remote remote --format table
# Any role, fully remote
bun run .agents/skills/linkedin-search/cli/src/cli.ts search -q "paralegal" -l "Remote" --format table
# Engineer roles, remote, posted in the last 30 minutes
bun run .agents/skills/linkedin-search/cli/src/cli.ts search -q "engineer" -l "Remote" --jobage-minutes 30 --format table
# Full details for a specific job
bun run .agents/skills/linkedin-search/cli/src/cli.ts detail 4426311357 --format plain
```
## Output formats
| Format | Best for |
|--------|----------|
| `json` | Default — programmatic use, passing IDs to `detail` |
| `table` | Quick human-readable scanning |
| `plain` | Reading a single job's full detail (`detail` command) |
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
## Notes
- Data is from LinkedIn's public `jobs-guest` endpoints — no credentials required.
- Page size is fixed at 10 results per page.
- LinkedIn may rate-limit; the CLI retries 429/5xx with exponential backoff. Keep volume low (see ToS note above).
- Job IDs are numeric (e.g. `4426311357`) — pass them as-is to `detail`.
+61
View File
@@ -0,0 +1,61 @@
# linkedin-cli
CLI for searching jobs on LinkedIn's public job listings, for **any country/region**
(and remote), across any sector.
**Data source**: LinkedIn `jobs-guest` endpoints (`seeMoreJobPostings/search` and `jobPosting/<id>`).
**Authentication**: None required.
**Dependencies**: None (plain `bun` + `fetch`). `bun install` is optional and only pulls dev type defs.
> **Personal use only.** This uses LinkedIn's public job pages; automated access is against
> LinkedIn's Terms of Service. Keep volume low, don't use it commercially or for bulk data
> collection, and run it on your own responsibility.
## Installation
```bash
cd .agents/skills/linkedin-search/cli
bun install # optional — only installs TypeScript dev types
```
The CLI runs without any install because it has zero runtime dependencies.
## Commands
| Command | Description |
|---------|-------------|
| `search` | Search for job listings (`--location` required) |
| `detail` | Fetch full detail for a single job listing |
`search` accepts `--format json|table|plain` (default `json`); `detail` accepts `--format json|plain`.
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` with exit code `1`.
## Quick examples
```bash
# Software roles in Hyderabad, last 7 days
bun run src/cli.ts search -q "backend engineer" -l "Hyderabad, Telangana, India" --jobage 7 --format table
# Design roles in London
bun run src/cli.ts search -q "product designer" -l "London, United Kingdom" --format table
# Fully remote
bun run src/cli.ts search -q "technical writer" -l "Remote" --remote remote --format table
# Full detail for one job
bun run src/cli.ts detail 4426311357 --format plain
```
See `../SKILL.md` for the full flag reference and the Terms-of-Service note.
## Search flags
| Flag | Alias | Description |
|------|-------|-------------|
| `--location` | `-l` | **Required.** Place string, e.g. `"Mumbai, Maharashtra, India"`, `"Berlin, Germany"`, `"Remote"`. |
| `--query` | `-q` | Keywords (title / skill / role). Recommended. |
| `--jobage` | | Posted within N days: `1`, `7`, `14`, `30`. |
| `--remote` | | `remote` \| `hybrid` \| `onsite`. |
| `--page` | | 1-indexed page (10 results/page). |
| `--limit` | `-n` | Cap results emitted. |
| `--format` | | `json` \| `table` \| `plain`. |
+20
View File
@@ -0,0 +1,20 @@
{
"name": "linkedin-cli",
"version": "1.0.0",
"description": "CLI for searching jobs on LinkedIn's public job listings, for any country/region (and remote) — no authentication, zero runtime dependencies. Personal use only (LinkedIn ToS).",
"type": "module",
"main": "src/cli.ts",
"bin": {
"linkedin-search": "src/cli.ts"
},
"scripts": {
"start": "bun run src/cli.ts",
"test": "bun test --timeout 30000",
"typecheck": "tsc --noEmit"
},
"dependencies": {},
"devDependencies": {
"typescript": "^5.4.0",
"@types/bun": "1.3.14"
}
}
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env bun
// Self-contained CLI for searching jobs on LinkedIn's public jobs-guest endpoints,
// for any country/region (plus remote). No external CLI framework, so it runs
// anywhere `bun` is available with zero install beyond the repo clone.
//
// Personal use only. This reads LinkedIn's public job pages; automated access is
// against LinkedIn's Terms of Service, so keep volume low and do not use it
// commercially or for bulk data collection. Run it on your own responsibility.
import { runSearch, type SearchOpts } from "./commands/search.js"
import { runDetail, type DetailOpts } from "./commands/detail.js"
interface Flags {
_: string[]
[k: string]: string | boolean | string[]
}
function parseFlags(argv: string[]): Flags {
const flags: Flags = { _: [] }
const alias: Record<string, string> = { q: "query", l: "location", n: "limit" }
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a.startsWith("--") || a.startsWith("-")) {
const key = alias[a.replace(/^-+/, "")] ?? a.replace(/^-+/, "")
const next = argv[i + 1]
if (next === undefined || next.startsWith("-")) {
flags[key] = true
} else {
flags[key] = next
i++
}
} else {
;(flags._ as string[]).push(a)
}
}
return flags
}
const HELP = `linkedin-cli — search jobs on LinkedIn (any country/region, plus remote)
USAGE
bun run src/cli.ts search --location "<place>" [flags]
bun run src/cli.ts detail <id|url> [--format json|plain]
SEARCH FLAGS
--location, -l <text> Location to search. REQUIRED. e.g. "Mumbai, Maharashtra, India",
"Berlin, Germany", "London, United Kingdom", or "Remote".
--query, -q <text> Keywords (job title, skill, or role). Recommended.
--jobage <days> Posted within N days: 1, 7, 14, 30. Default: all.
--jobage-minutes <n> Posted within N minutes (sub-day precision). Conflicts with --jobage.
--remote <mode> remote | hybrid | onsite. Filter by workplace type.
--page <n> 1-indexed page (10 results/page). Default 1.
--limit, -n <n> Cap results emitted (client-side).
--format <fmt> json (default) | table | plain.
EXAMPLES
bun run src/cli.ts search -q "data engineer" -l "Bengaluru, Karnataka, India" --jobage 30 --format table
bun run src/cli.ts search -q "product manager" -l "Berlin, Germany" --remote remote --format table
bun run src/cli.ts search -q "paralegal" -l "Remote" --format table
bun run src/cli.ts search -q "engineer" -l "Remote" --jobage-minutes 30 --format table
bun run src/cli.ts detail 4300011451 --format plain
Personal use only — uses LinkedIn's public pages; keep volume low (LinkedIn ToS).
`
// Long-form flag names each command accepts (parseFlags resolves the short
// aliases q/l/n to these before validation). "help"/"h" pass so `search --help`
// still prints usage.
const KNOWN_FLAGS: Record<string, Set<string>> = {
search: new Set([
"location", "query", "jobage", "jobage-minutes", "remote", "page", "limit", "format", "help", "h",
]),
detail: new Set(["format", "help", "h"]),
}
async function main(): Promise<number> {
const argv = process.argv.slice(2)
const flags = parseFlags(argv)
const cmd = (flags._ as string[])[0]
if (!cmd || flags.help || flags.h) {
process.stdout.write(HELP)
return cmd ? 0 : 1
}
// Reject unknown flags instead of silently discarding them: a discarded
// filter changes what the search returns with no 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.
const knownFlags = KNOWN_FLAGS[cmd]
if (knownFlags) {
for (const key of Object.keys(flags)) {
if (key === "_" || knownFlags.has(key)) continue
process.stderr.write(
JSON.stringify({
error: `unknown flag --${key} for '${cmd}' - flags are never silently ignored, because a discarded filter changes what the search returns; see --help for the supported flags`,
code: "UNKNOWN_FLAG",
}) + "\n",
)
return 1
}
}
if (cmd === "search") {
const location = typeof flags.location === "string" ? flags.location : undefined
if (!location) {
process.stderr.write(
JSON.stringify({
error: 'the --location/-l flag is required (e.g. -l "Mumbai, Maharashtra, India", -l "Berlin, Germany", or -l "Remote")',
code: "NO_LOCATION",
}) + "\n",
)
return 1
}
const fmt = (flags.format as string) || "json"
if (flags.jobage !== undefined && flags["jobage-minutes"] !== undefined) {
process.stderr.write(
JSON.stringify({
error: "--jobage and --jobage-minutes both set a freshness window; pass only one",
code: "CONFLICTING_AGE_FLAGS",
}) + "\n",
)
return 1
}
const parseIntFlag = (name: string, raw: string | boolean | string[]): number | null => {
const val = parseInt(raw as string, 10)
if (isNaN(val)) {
process.stderr.write(JSON.stringify({ error: `--${name} must be a number, got "${raw}"`, code: "BAD_ARG" }) + "\n")
return null
}
return val
}
if (flags.jobage !== undefined) {
const v = parseIntFlag("jobage", flags.jobage)
if (v === null) return 1
flags.jobage = String(v)
}
if (flags["jobage-minutes"] !== undefined) {
const raw = flags["jobage-minutes"]
const v = parseIntFlag("jobage-minutes", raw)
if (v === null) return 1
if (v <= 0) {
process.stderr.write(
JSON.stringify({ error: `--jobage-minutes must be a positive number, got "${raw}"`, code: "BAD_ARG" }) + "\n",
)
return 1
}
flags["jobage-minutes"] = String(v)
}
if (flags.page !== undefined) {
const v = parseIntFlag("page", flags.page)
if (v === null) return 1
flags.page = String(v)
}
if (flags.limit !== undefined) {
const v = parseIntFlag("limit", flags.limit)
if (v === null) return 1
flags.limit = String(v)
}
const opts: SearchOpts = {
query: typeof flags.query === "string" ? flags.query : undefined,
location,
jobage: flags.jobage ? parseInt(flags.jobage as string, 10) : 9999,
jobageMinutes: flags["jobage-minutes"] ? parseInt(flags["jobage-minutes"] as string, 10) : undefined,
remote: typeof flags.remote === "string" ? flags.remote : undefined,
page: flags.page ? Math.max(1, parseInt(flags.page as string, 10)) : 1,
limit: flags.limit ? parseInt(flags.limit as string, 10) : undefined,
format: (["json", "table", "plain"].includes(fmt) ? fmt : "json") as SearchOpts["format"],
}
return runSearch(opts)
}
if (cmd === "detail") {
const id = (flags._ as string[])[1]
if (!id) {
process.stderr.write(JSON.stringify({ error: "detail requires an <id|url>", code: "NO_ID" }) + "\n")
return 1
}
const fmt = (flags.format as string) || "json"
const opts: DetailOpts = {
id,
format: (fmt === "plain" ? "plain" : "json") as DetailOpts["format"],
}
return runDetail(opts)
}
process.stderr.write(JSON.stringify({ error: `Unknown command "${cmd}"`, code: "BAD_CMD" }) + "\n")
return 1
}
main()
.then((code) => process.exit(code))
.catch((e) => {
process.stderr.write(
JSON.stringify({
error: e instanceof Error ? e.message : String(e),
code: "INTERNAL_ERROR",
}) + "\n",
)
process.exit(1)
})
+56
View File
@@ -0,0 +1,56 @@
import { DETAIL_URL, htmlFetch, parseJobDetail, writeError } from "../helpers.js"
export interface DetailOpts {
id: string
format: "json" | "plain"
}
/** Accept a raw job ID, a job-view URL, or a job URN. */
function normalizeId(input: string): string | null {
const urn = input.match(/urn:li:jobPosting:(\d+)/)
if (urn) return urn[1]
const url = input.match(/-(\d{6,})(?:\?|$)/) || input.match(/\/(\d{6,})(?:\?|$)/)
if (url) return url[1]
const bare = input.match(/^\d{6,}$/)
if (bare) return input
return null
}
export async function runDetail(opts: DetailOpts): Promise<number> {
const id = normalizeId(opts.id)
if (!id) {
writeError(`Could not parse a job ID from "${opts.id}"`, "BAD_ID")
return 1
}
try {
const html = await htmlFetch(`${DETAIL_URL}/${id}`)
if (!html) {
writeError("Job not found", "NOT_FOUND")
return 1
}
const job = parseJobDetail(html, id)
if (opts.format === "plain") {
const lines = [
job.title,
`${job.company || "—"} · ${job.location || "—"}`,
"",
job.seniority ? `Seniority: ${job.seniority}` : "",
job.employmentType ? `Employment: ${job.employmentType}` : "",
job.jobFunction ? `Function: ${job.jobFunction}` : "",
job.industries ? `Industries: ${job.industries}` : "",
"",
job.description || "(no description)",
"",
`URL: ${job.url}`,
].filter((l) => l !== "")
process.stdout.write(lines.join("\n") + "\n")
} else {
process.stdout.write(JSON.stringify(job, null, 2) + "\n")
}
return 0
} catch (e) {
writeError(e instanceof Error ? e.message : String(e), "DETAIL_FAILED")
return 1
}
}
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more