mirror of
https://github.com/prdlk/jobyssey.git
synced 2026-11-02 13:12:49 +00:00
feat: add skills
This commit is contained in:
1 parent
6984212b17
commit
c50aa92862
150 files changed
+12702
No files matched your search
Executable
+200
@@ -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.
|
||||||
Executable
+93
@@ -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
@@ -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
@@ -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)
|
||||||
|
})
|
||||||
@@ -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
@@ -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
@@ -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(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&#(\d+);/g, (_, dec) => numericEntity(parseInt(dec, 10)))
|
||||||
|
.replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex) => numericEntity(parseInt(hex, 16)))
|
||||||
|
.replace(/ /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
@@ -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
@@ -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
@@ -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 & 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é")).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
@@ -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
@@ -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.
|
||||||
Executable
+180
@@ -0,0 +1,180 @@
|
|||||||
|
---
|
||||||
|
name: jobbank-search
|
||||||
|
version: 1.0.0
|
||||||
|
description: >
|
||||||
|
Make sure to use this skill whenever the user mentions anything related to job
|
||||||
|
searching on Akademikernes Jobbank, jobbank.dk, or looking for academic or
|
||||||
|
highly educated positions in Denmark — even if they don't mention jobbank.dk
|
||||||
|
explicitly. Also invoke this skill for questions about Danish job listings,
|
||||||
|
graduate trainee positions, Ph.d. jobs, or finding work in specific industries
|
||||||
|
or regions in Denmark. Trigger phrases include:
|
||||||
|
jobbank, akademikernes jobbank, jobs denmark, academic jobs denmark, find job
|
||||||
|
denmark, highly educated jobs, graduate job denmark, trainee position denmark,
|
||||||
|
ph.d. position denmark, postdoc denmark, studiejob, fuldtidsjob, deltidsjob,
|
||||||
|
vikariat, freelance job, praktikplads, job søgning, jobsøgning, søg job,
|
||||||
|
ledige stillinger, nye jobs, it jobs denmark, engineering jobs denmark,
|
||||||
|
marketing jobs denmark, finance jobs denmark, healthcare jobs denmark,
|
||||||
|
remote job denmark, fjernarbejde, job københavn, job aarhus, job odense,
|
||||||
|
nyuddannede job, job til nyuddannede, international job denmark,
|
||||||
|
jobbank søgning, find stilling, data scientist job, software developer job,
|
||||||
|
projektleder stilling, konsulent job, data analyse job.
|
||||||
|
context: fork
|
||||||
|
enabled: false # Danish demo portal - ships opt-in; /setup enables it when your market is Denmark, or set true here yourself
|
||||||
|
allowed-tools: Bash(bun run .agents/skills/jobbank-search/cli/src/cli.ts *)
|
||||||
|
---
|
||||||
|
|
||||||
|
# Jobbank Search Skill
|
||||||
|
|
||||||
|
Search live Danish job listings from [Akademikernes Jobbank](https://jobbank.dk) — Denmark's primary job portal for highly educated candidates. Uses the RSS feed for search (up to 100 results) and JSON-LD parsing for detailed job information. Jobbank may block automated requests with Cloudflare bot protection; if that happens, report the portal as unavailable and use WebSearch fallback instead of retrying.
|
||||||
|
|
||||||
|
## When to use this skill
|
||||||
|
|
||||||
|
Invoke this skill when the user wants to:
|
||||||
|
|
||||||
|
- Search for jobs, positions, or career opportunities in Denmark
|
||||||
|
- Find academic, graduate, trainee, Ph.d., or postdoc positions
|
||||||
|
- Look for jobs by keyword, industry, location, education background, or work function
|
||||||
|
- Find remote or hybrid positions in Denmark
|
||||||
|
- Get full details for a specific job posting on jobbank.dk
|
||||||
|
- Check what positions are available at a specific company on jobbank.dk
|
||||||
|
- Browse jobs suitable for new graduates or people with international backgrounds
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Search jobs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run .agents/skills/jobbank-search/cli/src/cli.ts search [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
Key flags:
|
||||||
|
- `--key <text>` — keyword search (title, company, keyword)
|
||||||
|
- `--exclude <text>` — exclude keywords from results
|
||||||
|
- `--type <code>` — job type: `3`=Fuldtidsjob, `6`=Graduate/trainee, `13`=Deltidsjob, `8`=Vikariat, `12`=Ph.d. & Postdoc, `11`=Freelance, `9`=Praktikplads, `4`=Studiejob (repeatable)
|
||||||
|
- `--location <code>` — region: `2`=Storkøbenhavn, `8`=Østjylland (Aarhus), `7`=Midtjylland, `6`=Nordjylland, `13`=Fyn (repeatable)
|
||||||
|
- `--work-area <code>` — function: `31`=IT-Software, `43`=Data & Analyse, `26`=Ledelse, `29`=Marketing (repeatable)
|
||||||
|
- `--industry <code>` — sector: `10331`=IT & Tele, `10442`=Forskning & Uddannelse, `10358`=Finans (repeatable)
|
||||||
|
- `--education <code>` — education field: `24`=IT, `21`=Økonomi & Revision, `34`=Samfundsvidenskab (repeatable)
|
||||||
|
- `--remote <value>` — `helt` (fully remote) or `delvist` (partially remote)
|
||||||
|
- `--suitable-for <code>` — `2`=Nyuddannede, `4`=International baggrund, `5`=Erfarne
|
||||||
|
- `--company <id>` — filter by company ID
|
||||||
|
- `--since <YYYY-MM-DD>` — jobs posted on or after this date
|
||||||
|
- `--limit <n>` — cap results returned by CLI
|
||||||
|
- `--format json|table|plain`
|
||||||
|
|
||||||
|
> **RSS limitation:** The RSS feed returns max 100 items per request. No pagination is available via RSS. `meta.total` shows the true count; `results` is capped at 100.
|
||||||
|
|
||||||
|
### Full job detail
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run .agents/skills/jobbank-search/cli/src/cli.ts detail <id> [--format json|plain]
|
||||||
|
```
|
||||||
|
|
||||||
|
`id` is the numeric job ID from `search` results. Fetches the job page and parses the embedded Schema.org `JobPosting` JSON-LD for structured data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to use effectively
|
||||||
|
|
||||||
|
**Start with `search`, then use `detail` for full description.**
|
||||||
|
|
||||||
|
1. Use `search` with `--key` and/or filters to find matching jobs with IDs
|
||||||
|
2. Call `detail <id>` to get the full HTML job description, exact deadline, and company details
|
||||||
|
|
||||||
|
**Use repeatable flags for multi-value filters.** Most filter flags can be repeated to match any of the values:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# IT or Finance industry, Copenhagen or Aarhus
|
||||||
|
bun run .agents/skills/jobbank-search/cli/src/cli.ts search \
|
||||||
|
--industry 10331 --industry 10358 \
|
||||||
|
--location 2 --location 8
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter codes are documented in the README** at `skills/jobbank-search/cli/README.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage examples
|
||||||
|
|
||||||
|
### Find data scientist jobs in Copenhagen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run .agents/skills/jobbank-search/cli/src/cli.ts search \
|
||||||
|
--key "data scientist" \
|
||||||
|
--location 2 \
|
||||||
|
--format table
|
||||||
|
```
|
||||||
|
|
||||||
|
### Graduate trainee positions for new graduates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run .agents/skills/jobbank-search/cli/src/cli.ts search \
|
||||||
|
--type 6 \
|
||||||
|
--suitable-for 2 \
|
||||||
|
--format table
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remote IT software jobs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run .agents/skills/jobbank-search/cli/src/cli.ts search \
|
||||||
|
--work-area 31 \
|
||||||
|
--remote helt \
|
||||||
|
--format table
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ph.d. and postdoc positions in research
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run .agents/skills/jobbank-search/cli/src/cli.ts search \
|
||||||
|
--type 12 \
|
||||||
|
--industry 10442 \
|
||||||
|
--format table
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recent full-time jobs posted since March 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run .agents/skills/jobbank-search/cli/src/cli.ts search \
|
||||||
|
--type 3 \
|
||||||
|
--since 2026-03-01 \
|
||||||
|
--format table
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full details for a specific job
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run .agents/skills/jobbank-search/cli/src/cli.ts detail 1234567 --format plain
|
||||||
|
```
|
||||||
|
|
||||||
|
### IT jobs in Aarhus or Copenhagen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run .agents/skills/jobbank-search/cli/src/cli.ts search \
|
||||||
|
--key developer \
|
||||||
|
--location 2 --location 8 \
|
||||||
|
--work-area 31 \
|
||||||
|
--format table
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output formats
|
||||||
|
|
||||||
|
| Format | Best for |
|
||||||
|
|--------|----------|
|
||||||
|
| `json` | Default — programmatic use, passing IDs to `detail` |
|
||||||
|
| `table` | Quick human-readable list of results |
|
||||||
|
| `plain` | Single-job detail views (`detail` command) |
|
||||||
|
|
||||||
|
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Data is from the public jobbank.dk RSS feed and HTML pages. Jobbank may still block automated CLI requests with Cloudflare bot protection; treat that as a portal availability failure and fall back to WebSearch.
|
||||||
|
- RSS feed returns max 100 results per query. For higher counts, `meta.total` shows the true total.
|
||||||
|
- The `detail` command fetches a full job page and extracts the JSON-LD structured data block.
|
||||||
|
- `location` values are region codes (e.g. `2` = Storkøbenhavn), not city names.
|
||||||
|
- All filter codes are documented in `skills/jobbank-search/cli/README.md`.
|
||||||
Executable
+400
@@ -0,0 +1,400 @@
|
|||||||
|
# jobbank-cli
|
||||||
|
|
||||||
|
CLI for [Akademikernes Jobbank](https://jobbank.dk) — Denmark's job portal for highly educated candidates.
|
||||||
|
|
||||||
|
**Data sources:**
|
||||||
|
- **RSS feed**: `https://jobbank.dk/job/rss?{params}` — 100 items max, all search filters work
|
||||||
|
- **Job detail**: `https://jobbank.dk/job/{id}/` — JSON-LD (`Schema.org JobPosting`) embedded in page HTML
|
||||||
|
|
||||||
|
**Authentication**: None required. A browser User-Agent header is sent, but Jobbank may still block automated requests with Cloudflare bot protection. In that case the CLI exits with a clear error and callers should use a WebSearch fallback rather than retrying.
|
||||||
|
**Format**: RSS XML (search), HTML with embedded JSON-LD (detail).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd skills/jobbank-search/cli
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `search` | Search job listings via RSS feed |
|
||||||
|
| `detail` | Full detail for a single job posting |
|
||||||
|
|
||||||
|
All commands accept `--format json|table|plain` (default: `json`).
|
||||||
|
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filter Reference Tables
|
||||||
|
|
||||||
|
### Job Types (`--type` / `cvtype`)
|
||||||
|
|
||||||
|
| Code | Label |
|
||||||
|
|------|-------|
|
||||||
|
| 3 | Fuldtidsjob |
|
||||||
|
| 6 | Graduate/trainee |
|
||||||
|
| 13 | Deltidsjob |
|
||||||
|
| 8 | Vikariat |
|
||||||
|
| 12 | Ph.d. & Postdoc |
|
||||||
|
| 11 | Freelance |
|
||||||
|
| 15 | Iværksætterprojekt |
|
||||||
|
| 14 | Event |
|
||||||
|
| 9 | Praktikplads |
|
||||||
|
| 4 | Studiejob |
|
||||||
|
| 5 | Studieprojekt/speciale |
|
||||||
|
|
||||||
|
### Location / Region (`--location` / `amt`)
|
||||||
|
|
||||||
|
| Code | Label |
|
||||||
|
|------|-------|
|
||||||
|
| 2 | Storkøbenhavn |
|
||||||
|
| 3 | Nordsjælland |
|
||||||
|
| 14 | Østsjælland |
|
||||||
|
| 4 | Vestsjælland |
|
||||||
|
| 5 | Sydsjælland & Øer |
|
||||||
|
| 13 | Fyn |
|
||||||
|
| 12 | Sønderjylland |
|
||||||
|
| 11 | Sydvestjylland |
|
||||||
|
| 9 | Vestjylland |
|
||||||
|
| 10 | Sydøstjylland |
|
||||||
|
| 7 | Midtjylland |
|
||||||
|
| 8 | Østjylland (Aarhus) |
|
||||||
|
| 6 | Nordjylland |
|
||||||
|
| 20 | Bornholm |
|
||||||
|
| 21 | Øresundsregionen |
|
||||||
|
| 22 | Grønland & Færøerne |
|
||||||
|
| 23 | Udlandet (Sverige) |
|
||||||
|
| 24 | Udlandet (Norge) |
|
||||||
|
| 19 | Udlandet (øvrige) |
|
||||||
|
|
||||||
|
### Work Area / Function (`--work-area` / `erf`)
|
||||||
|
|
||||||
|
| Code | Label |
|
||||||
|
|------|-------|
|
||||||
|
| 20 | Administration |
|
||||||
|
| 38 | Arkitektur & Design |
|
||||||
|
| 22 | Bank & Forsikring |
|
||||||
|
| 43 | Data & Analyse |
|
||||||
|
| 47 | Eksport |
|
||||||
|
| 41 | Forskning & Udvikling |
|
||||||
|
| 28 | Human Resources |
|
||||||
|
| 49 | Indkøb |
|
||||||
|
| 34 | Internet & Multimedia |
|
||||||
|
| 32 | IT - Hardware |
|
||||||
|
| 33 | IT - Netværk & Telekomm. |
|
||||||
|
| 31 | IT - Software |
|
||||||
|
| 24 | Jura |
|
||||||
|
| 35 | Kommunikation, Media & SoMe |
|
||||||
|
| 46 | Konstruktion & Beregning |
|
||||||
|
| 37 | Kunst & Kultur |
|
||||||
|
| 26 | Ledelse & Planlægning |
|
||||||
|
| 29 | Marketing & Reklame |
|
||||||
|
| 40 | Medicinal & Sundhed |
|
||||||
|
| 45 | Naturvidenskab |
|
||||||
|
| 23 | Organisation & Forening |
|
||||||
|
| 52 | Politik & Samfund |
|
||||||
|
| 44 | Produktion |
|
||||||
|
| 27 | Projektledelse |
|
||||||
|
| 50 | Rådgivning & Support |
|
||||||
|
| 30 | Salg |
|
||||||
|
| 39 | Socialvæsen |
|
||||||
|
| 42 | Teknik |
|
||||||
|
| 25 | Topledelse |
|
||||||
|
| 48 | Transport & Logistik |
|
||||||
|
| 36 | Undervisning |
|
||||||
|
| 21 | Økonomi & Forvaltning |
|
||||||
|
|
||||||
|
### Education Field (`--education` / `udd`)
|
||||||
|
|
||||||
|
| Code | Label |
|
||||||
|
|------|-------|
|
||||||
|
| 20 | Administration |
|
||||||
|
| 43 | Anlæg, Byggeri & Konstruktion |
|
||||||
|
| 29 | Arkitektur, Kunst & Design |
|
||||||
|
| 47 | Elektro & Telekommunikation |
|
||||||
|
| 32 | Fødevarer & Veterinær |
|
||||||
|
| 38 | Human Resources |
|
||||||
|
| 28 | Humaniora |
|
||||||
|
| 24 | IT |
|
||||||
|
| 23 | Jura |
|
||||||
|
| 44 | Kemi, Biotek & Materialer |
|
||||||
|
| 45 | Klima, Miljø & Energi |
|
||||||
|
| 37 | Landbrug & Natur |
|
||||||
|
| 22 | Marketing & Business |
|
||||||
|
| 48 | Maskin & Design |
|
||||||
|
| 46 | Matematik, Fysik & Nano |
|
||||||
|
| 31 | Medicinal & Sundhed |
|
||||||
|
| 30 | Naturvidenskab |
|
||||||
|
| 41 | Organisation & Ledelse |
|
||||||
|
| 35 | Produktion, Logistik & Transport |
|
||||||
|
| 34 | Samfundsvidenskab |
|
||||||
|
| 25 | Sprog, Media & Kommunikation |
|
||||||
|
| 33 | Teknik & Teknologi |
|
||||||
|
| 26 | Undervisning & Pædagogik |
|
||||||
|
| 21 | Økonomi & Revision |
|
||||||
|
|
||||||
|
### Industry (`--industry` / `branche`)
|
||||||
|
|
||||||
|
| Code | Label |
|
||||||
|
|------|-------|
|
||||||
|
| 10359 | Advokat & Revision |
|
||||||
|
| 11669 | Byggeri & Anlæg |
|
||||||
|
| 11634 | Elektronik & Maskin |
|
||||||
|
| 16791 | Fagforeninger, A-kasser & Pensionskasser |
|
||||||
|
| 10358 | Finans, Forsikring & Pension |
|
||||||
|
| 10442 | Forskning & Uddannelse |
|
||||||
|
| 15407 | Fødevarer & Dagligvarer |
|
||||||
|
| 10364 | Handel & Service |
|
||||||
|
| 10331 | IT & Tele |
|
||||||
|
| 17209 | Klima, Energi & Forsyning |
|
||||||
|
| 16826 | Kommuner |
|
||||||
|
| 10341 | Kultur, Medier & Underholdning |
|
||||||
|
| 10333 | Medicinal, Biotek & Kemi |
|
||||||
|
| 10363 | Papir, Møbel & Materialer |
|
||||||
|
| 11626 | Regioner, Sundhed- & Socialvæsen |
|
||||||
|
| 15586 | Rådgivning & Konsulentservice |
|
||||||
|
| 10362 | Stat, Politik & Samfund |
|
||||||
|
| 10440 | Transport |
|
||||||
|
| 12450 | Vikar & Rekruttering |
|
||||||
|
|
||||||
|
### Remote Work (`--remote` / `fjernarbejde`)
|
||||||
|
|
||||||
|
| Value | Label |
|
||||||
|
|-------|-------|
|
||||||
|
| `helt` | Fully remote |
|
||||||
|
| `delvist` | Partially remote |
|
||||||
|
|
||||||
|
### Suitable For (`--suitable-for` / `andet`)
|
||||||
|
|
||||||
|
| Code | Label |
|
||||||
|
|------|-------|
|
||||||
|
| 2 | Nyuddannede |
|
||||||
|
| 4 | International baggrund |
|
||||||
|
| 5 | Erfarne |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `search` — Search job listings
|
||||||
|
|
||||||
|
**Endpoint**: `GET https://jobbank.dk/job/rss?{params}`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run src/cli.ts search [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flags
|
||||||
|
|
||||||
|
| Flag | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `--key` | string | — | Keyword search (title, company, keyword) |
|
||||||
|
| `--exclude` | string | — | Exclude keywords (`antikey`) |
|
||||||
|
| `--type` | number | — | Job type code (`cvtype`). Repeatable for multiple: `--type 3 --type 6` |
|
||||||
|
| `--education` | number | — | Education field code (`udd`). Repeatable. |
|
||||||
|
| `--location` | number | — | Region code (`amt`). Repeatable. |
|
||||||
|
| `--work-area` | number | — | Work area / function code (`erf`). Repeatable. |
|
||||||
|
| `--industry` | number | — | Industry code (`branche`). Repeatable. |
|
||||||
|
| `--suitable-for` | number | — | Suitable-for code (`andet`). Repeatable. |
|
||||||
|
| `--company` | number | — | Company ID (`virk`) |
|
||||||
|
| `--remote` | string | — | Remote work: `helt` or `delvist` |
|
||||||
|
| `--since` | string | — | Posted on or after date, format `YYYY-MM-DD` (`oprettet`) |
|
||||||
|
| `--limit` | number | — | Cap total results returned by CLI (client-side) |
|
||||||
|
| `--format` | string | `json` | Output format: `json`, `table`, `plain` |
|
||||||
|
|
||||||
|
> **Important limitation:** The RSS feed returns a maximum of **100 items** per request. There is no pagination via RSS — the `page=` parameter has no effect on the RSS endpoint. If your query matches more than 100 jobs, only the first 100 are returned. The `meta.total` field reflects the true total count (fetched separately from the HTML search page title), while `results` is capped at 100.
|
||||||
|
|
||||||
|
> **Multi-value flags**: Flags marked "Repeatable" map to params that accept multiple values in the API (repeated query params). Pass them multiple times: `--type 3 --type 6` sends `cvtype=3&cvtype=6`.
|
||||||
|
|
||||||
|
### RSS Parsing
|
||||||
|
|
||||||
|
The CLI fetches the RSS feed and parses each `<item>` as follows:
|
||||||
|
|
||||||
|
- **id**: extracted from the URL path — `/job/{id}/{company-slug}/{title-slug}` — the first numeric segment after `/job/`
|
||||||
|
- **title**: from `<title>` (CDATA)
|
||||||
|
- **description**, **company**, **location**, **jobType**, **deadline**: parsed from the `<description>` field, which has the format: `"JobType hos Company, Location (Ansøgningsfrist: DD.MM.YYYY)"` or `"JobType hos Company, Location (Ansøgningsfrist: løbende)"`
|
||||||
|
- **url**: from `<link>`
|
||||||
|
- **posted**: from `<pubDate>`, normalized to ISO 8601
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run src/cli.ts search --key python --location 2 --type 3 --limit 10
|
||||||
|
bun run src/cli.ts search --key "data scientist" --remote helt
|
||||||
|
bun run src/cli.ts search --industry 10331 --work-area 31 --format table
|
||||||
|
bun run src/cli.ts search --education 24 --suitable-for 2 --since 2026-03-01
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"total": 457
|
||||||
|
},
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "1234567",
|
||||||
|
"title": "Senior Data Scientist",
|
||||||
|
"company": "Novo Nordisk",
|
||||||
|
"location": "Bagsværd",
|
||||||
|
"jobType": "Fuldtidsjob",
|
||||||
|
"description": "Fuldtidsjob hos Novo Nordisk, Bagsværd (Ansøgningsfrist: 12.04.2026)",
|
||||||
|
"url": "https://jobbank.dk/job/1234567/novo-nordisk/senior-data-scientist",
|
||||||
|
"posted": "2026-03-02T00:00:00+01:00",
|
||||||
|
"date": "2026-03-02",
|
||||||
|
"deadline": "2026-04-12"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Field details
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `id` | string | Numeric job ID extracted from URL |
|
||||||
|
| `title` | string | Job title |
|
||||||
|
| `company` | string | Company name, parsed from description |
|
||||||
|
| `location` | string | Location string, parsed from description |
|
||||||
|
| `jobType` | string | Employment type (e.g. "Fuldtidsjob", "Graduate/trainee"), parsed from description |
|
||||||
|
| `description` | string | Raw RSS description field (single-line summary) |
|
||||||
|
| `url` | string | Full URL to job posting |
|
||||||
|
| `posted` | string | Publication date in ISO 8601 |
|
||||||
|
| `date` | string \| null | Publication date as `YYYY-MM-DD` (derived from `posted`), or `null` if absent |
|
||||||
|
| `deadline` | string \| null | Application deadline as `YYYY-MM-DD` (converted from the feed's `DD.MM.YYYY`), or `null` if "løbende" / not present |
|
||||||
|
|
||||||
|
> `meta.total` is fetched from the HTML page `<title>` in a secondary request (pattern: `"{N} relevante job og karriereopslag"`). If the secondary request fails, `meta.total` is `null`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `detail` — Full job detail
|
||||||
|
|
||||||
|
**Endpoint**: `GET https://jobbank.dk/job/{id}/`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run src/cli.ts detail <id> [--format json|plain]
|
||||||
|
```
|
||||||
|
|
||||||
|
The `id` is the numeric job ID from `search` results (the `id` field). The short URL `https://jobbank.dk/job/{id}/` redirects to the full slug URL and returns HTTP 200.
|
||||||
|
|
||||||
|
The CLI fetches the HTML page and extracts the `<script type="application/ld+json">` block containing a Schema.org `JobPosting` object.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run src/cli.ts detail 1234567
|
||||||
|
bun run src/cli.ts detail 1234567 --format plain
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "1234567",
|
||||||
|
"url": "https://jobbank.dk/job/1234567/",
|
||||||
|
"title": "Senior Data Scientist",
|
||||||
|
"description": "<p>Full HTML description of the role...</p>",
|
||||||
|
"datePosted": "2026-03-02",
|
||||||
|
"deadline": "2026-04-12",
|
||||||
|
"employmentType": ["FULL_TIME"],
|
||||||
|
"company": {
|
||||||
|
"name": "Novo Nordisk",
|
||||||
|
"logo": "https://jobbank.dk/images/dynamic/company/logo/12345/"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"streetAddress": "",
|
||||||
|
"city": "Bagsværd",
|
||||||
|
"postalCode": "",
|
||||||
|
"country": "DK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Field details
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `id` | string | Numeric job ID (from `identifier.value` in JSON-LD) |
|
||||||
|
| `url` | string | Canonical URL of the job posting |
|
||||||
|
| `title` | string | Job title |
|
||||||
|
| `description` | string | Full HTML job description body |
|
||||||
|
| `datePosted` | string | Publication date in ISO 8601 format (`YYYY-MM-DD`) |
|
||||||
|
| `deadline` | string \| null | Application deadline (`validThrough` in JSON-LD), ISO 8601, or `null` if absent |
|
||||||
|
| `employmentType` | string[] | Schema.org employment type values, e.g. `["FULL_TIME"]` |
|
||||||
|
| `company.name` | string | Hiring organization name |
|
||||||
|
| `company.logo` | string \| null | URL to company logo, or `null` if absent |
|
||||||
|
| `location.streetAddress` | string | Street address (may be empty) |
|
||||||
|
| `location.city` | string | City (may be empty for international jobs) |
|
||||||
|
| `location.postalCode` | string | Postal code (may be empty) |
|
||||||
|
| `location.country` | string | Country code (may be empty) |
|
||||||
|
|
||||||
|
> `location` fields may be empty strings for international jobs or postings that do not specify a physical location.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
All errors are written to **stderr** in JSON format and exit with code `1`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "Job not found", "code": "NOT_FOUND" }
|
||||||
|
{ "error": "Jobbank is blocking automated requests with Cloudflare bot protection. Skip this portal or use the WebSearch fallback.", "code": "API_ERROR" }
|
||||||
|
{ "error": "No JSON-LD found on job page", "code": "PARSE_ERROR" }
|
||||||
|
{ "error": "--key or at least one filter is required", "code": "MISSING_REQUIRED" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation notes
|
||||||
|
|
||||||
|
### User-Agent
|
||||||
|
|
||||||
|
All HTTP requests include a browser User-Agent header:
|
||||||
|
|
||||||
|
```
|
||||||
|
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
|
||||||
|
```
|
||||||
|
|
||||||
|
This is not guaranteed to bypass Cloudflare bot protection. If Jobbank returns a Cloudflare challenge page, the CLI reports that condition and callers should skip the portal or use a WebSearch fallback.
|
||||||
|
|
||||||
|
### RSS description parsing
|
||||||
|
|
||||||
|
The RSS `<description>` field follows this pattern:
|
||||||
|
|
||||||
|
```
|
||||||
|
Fuldtidsjob, Graduate/trainee hos Novo Nordisk, Bagsværd (Ansøgningsfrist: 12.04.2026)
|
||||||
|
Fuldtidsjob hos DTU, Lyngby (Ansøgningsfrist: løbende)
|
||||||
|
```
|
||||||
|
|
||||||
|
Parse strategy:
|
||||||
|
1. Split on ` hos ` — left side is job type(s), right side is `Company, Location (Ansøgningsfrist: Deadline)`
|
||||||
|
2. From the right side, extract the parenthetical `(Ansøgningsfrist: ...)` for deadline
|
||||||
|
3. Remaining text is `Company, Location` — split on first `, ` to separate company from location
|
||||||
|
|
||||||
|
### JSON-LD extraction
|
||||||
|
|
||||||
|
On detail pages, find `<script type="application/ld+json">` containing `"@type": "JobPosting"` and parse the JSON. Map fields:
|
||||||
|
|
||||||
|
- `identifier.value` → `id`
|
||||||
|
- `url` → `url`
|
||||||
|
- `title` → `title`
|
||||||
|
- `description` → `description` (HTML)
|
||||||
|
- `datePosted` → `datePosted`
|
||||||
|
- `validThrough` → `deadline` (may be absent → `null`)
|
||||||
|
- `employmentType` → `employmentType` (array)
|
||||||
|
- `hiringOrganization.name` → `company.name`
|
||||||
|
- `hiringOrganization.logo` → `company.logo`
|
||||||
|
- `jobLocation.address.streetAddress` → `location.streetAddress`
|
||||||
|
- `jobLocation.address.addressLocality` → `location.city`
|
||||||
|
- `jobLocation.address.postalCode` → `location.postalCode`
|
||||||
|
- `jobLocation.address.addressCountry` → `location.country`
|
||||||
|
|
||||||
|
### Rate limiting
|
||||||
|
|
||||||
|
No explicit rate limits are enforced, but Cloudflare is present. Add a 300–500ms delay between sequential requests (e.g. when fetching total count from HTML in addition to the RSS feed). The `search` command makes at most 2 requests (RSS + HTML for total count).
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "jobbank-cli",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "CLI for Akademikernes Jobbank (jobbank.dk) — job search for highly educated candidates",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/cli.ts",
|
||||||
|
"bin": {
|
||||||
|
"jobbank": "src/cli.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun run src/cli.ts",
|
||||||
|
"test": "bun test --timeout 30000",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@bunli/core": "0.9.1",
|
||||||
|
"@bunli/utils": "0.6.0",
|
||||||
|
"node-html-parser": "^6.1.13",
|
||||||
|
"zod": "^3.23.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "1.3.14",
|
||||||
|
"typescript": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+45
@@ -0,0 +1,45 @@
|
|||||||
|
import { createCLI } from "@bunli/core"
|
||||||
|
import { writeError } from "./helpers.js"
|
||||||
|
import { search } from "./commands/search.js"
|
||||||
|
import { detail } from "./commands/detail.js"
|
||||||
|
|
||||||
|
const cli = await createCLI({
|
||||||
|
name: "jobbank-cli",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "CLI for Akademikernes Jobbank (jobbank.dk) — job search for highly educated candidates",
|
||||||
|
})
|
||||||
|
|
||||||
|
const commands = [search, detail]
|
||||||
|
for (const command of commands) {
|
||||||
|
cli.command(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject unknown --flags before dispatch. bunli silently discards them, and a
|
||||||
|
// silently discarded filter changes what the search returns without any error
|
||||||
|
// (a wrong flag name once returned an entire portal's database as if it
|
||||||
|
// matched the query). add-portal.md's contract requires a bogus flag to exit 1
|
||||||
|
// with a JSON error on stderr; this enforces it for the reference CLIs too.
|
||||||
|
const argv = process.argv.slice(2)
|
||||||
|
const invoked = commands.find((c) => (c as { name?: string }).name === argv[0])
|
||||||
|
if (invoked) {
|
||||||
|
const known = new Set([
|
||||||
|
...Object.keys((invoked as { options?: Record<string, unknown> }).options ?? {}),
|
||||||
|
"help",
|
||||||
|
"version",
|
||||||
|
])
|
||||||
|
for (const token of argv.slice(1)) {
|
||||||
|
if (token === "--") break
|
||||||
|
if (token.startsWith("--")) {
|
||||||
|
const flag = token.slice(2).split("=")[0]
|
||||||
|
if (!known.has(flag)) {
|
||||||
|
writeError(
|
||||||
|
`unknown flag --${flag} for '${argv[0]}' - flags are never silently ignored, because a discarded filter changes what the search returns; see --help for the supported flags`,
|
||||||
|
"UNKNOWN_FLAG",
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await cli.run()
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
import { defineCommand, option } from "@bunli/core"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { fetchWithUA, parseJobPostingJsonLd, writeError, BASE_URL } from "../helpers.js"
|
||||||
|
|
||||||
|
export const detail = defineCommand({
|
||||||
|
name: "detail",
|
||||||
|
description: "Full detail for a single job posting",
|
||||||
|
options: {
|
||||||
|
format: option(z.enum(["json", "plain"]).default("json"), {
|
||||||
|
description: "Output format: json, plain",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
handler: async ({ positional, flags, signal }) => {
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
const id = positional[0]
|
||||||
|
if (!id) {
|
||||||
|
writeError("Job ID is required", "MISSING_REQUIRED")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${BASE_URL}/job/${id}/`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithUA(url)
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
writeError("Job not found", "NOT_FOUND")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
writeError(`Failed to fetch job page: ${response.status} ${response.statusText}`, "API_ERROR")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await response.text()
|
||||||
|
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
const jobPosting = parseJobPostingJsonLd(html)
|
||||||
|
|
||||||
|
if (!jobPosting) {
|
||||||
|
writeError("No JSON-LD found on job page", "PARSE_ERROR")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract fields
|
||||||
|
const identifier = jobPosting["identifier"] as Record<string, unknown> | undefined
|
||||||
|
const jobId = identifier?.["value"] ? String(identifier["value"]) : id
|
||||||
|
|
||||||
|
// Check if the returned job ID doesn't match what was requested — indicates not found / redirect to different job
|
||||||
|
// We skip this check since short URL redirects to the actual job and returns 200
|
||||||
|
|
||||||
|
const hiringOrg = jobPosting["hiringOrganization"] as Record<string, unknown> | undefined
|
||||||
|
const jobLocation = jobPosting["jobLocation"] as Record<string, unknown> | undefined
|
||||||
|
const address = (jobLocation?.["address"] as Record<string, unknown>) ?? {}
|
||||||
|
|
||||||
|
const employmentType = jobPosting["employmentType"]
|
||||||
|
const empTypeArr: string[] = Array.isArray(employmentType)
|
||||||
|
? employmentType.map(String)
|
||||||
|
: employmentType
|
||||||
|
? [String(employmentType)]
|
||||||
|
: []
|
||||||
|
|
||||||
|
const validThrough = jobPosting["validThrough"]
|
||||||
|
let deadline: string | null = null
|
||||||
|
if (validThrough && String(validThrough).length > 0) {
|
||||||
|
// Normalize to YYYY-MM-DD if it's an ISO datetime
|
||||||
|
const dtStr = String(validThrough)
|
||||||
|
deadline = dtStr.substring(0, 10) // take first 10 chars = YYYY-MM-DD
|
||||||
|
if (deadline === "0001-01-01") deadline = null // invalid date
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
id: jobId,
|
||||||
|
url: String(jobPosting["url"] ?? url),
|
||||||
|
title: String(jobPosting["title"] ?? ""),
|
||||||
|
description: String(jobPosting["description"] ?? ""),
|
||||||
|
datePosted: String(jobPosting["datePosted"] ?? ""),
|
||||||
|
deadline,
|
||||||
|
employmentType: empTypeArr,
|
||||||
|
company: {
|
||||||
|
name: String(hiringOrg?.["name"] ?? ""),
|
||||||
|
logo: hiringOrg?.["logo"] ? String(hiringOrg["logo"]) : null,
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
streetAddress: String(address["streetAddress"] ?? ""),
|
||||||
|
city: String(address["addressLocality"] ?? ""),
|
||||||
|
postalCode: String(address["postalCode"] ?? ""),
|
||||||
|
country: String(address["addressCountry"] ?? ""),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the job ID matches if possible — if the page 404'd or redirected to a different job
|
||||||
|
// For invalid IDs that redirect to a generic page, the JSON-LD may be absent
|
||||||
|
// We already handle the "No JSON-LD" case above
|
||||||
|
|
||||||
|
if (flags.format === "json") {
|
||||||
|
console.log(JSON.stringify(output, null, 2))
|
||||||
|
} else {
|
||||||
|
outputPlain(output)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message.includes("NOT_FOUND")) {
|
||||||
|
writeError("Job not found", "NOT_FOUND")
|
||||||
|
} else {
|
||||||
|
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function outputPlain(data: {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
datePosted: string
|
||||||
|
deadline: string | null
|
||||||
|
employmentType: string[]
|
||||||
|
company: { name: string; logo: string | null }
|
||||||
|
location: { streetAddress: string; city: string; postalCode: string; country: string }
|
||||||
|
}): void {
|
||||||
|
console.log(`id: ${data.id}`)
|
||||||
|
console.log(`title: ${data.title}`)
|
||||||
|
console.log(`company: ${data.company.name}`)
|
||||||
|
if (data.company.logo) console.log(`logo: ${data.company.logo}`)
|
||||||
|
console.log(`location: ${[data.location.streetAddress, data.location.city, data.location.country].filter(Boolean).join(", ")}`)
|
||||||
|
console.log(`datePosted: ${data.datePosted}`)
|
||||||
|
console.log(`deadline: ${data.deadline ?? "none"}`)
|
||||||
|
console.log(`employmentType: ${data.employmentType.join(", ")}`)
|
||||||
|
console.log(`url: ${data.url}`)
|
||||||
|
console.log("")
|
||||||
|
// Strip HTML tags for plain description
|
||||||
|
const plainDescription = data.description.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
|
||||||
|
console.log(plainDescription)
|
||||||
|
}
|
||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
import { defineCommand, option } from "@bunli/core"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { rssFetch, fetchWithUA, writeError, parseRssDescription, extractJobIdFromUrl, BASE_URL, type RssItem } from "../helpers.js"
|
||||||
|
|
||||||
|
export function normalizeSearchItem(item: RssItem): Record<string, unknown> {
|
||||||
|
const parsed = parseRssDescription(item.description)
|
||||||
|
const id = extractJobIdFromUrl(item.link)
|
||||||
|
const posted = item.pubDate ? new Date(item.pubDate).toISOString() : ""
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: item.title,
|
||||||
|
company: parsed.company,
|
||||||
|
location: parsed.location,
|
||||||
|
jobType: parsed.jobType,
|
||||||
|
description: item.description,
|
||||||
|
url: item.link,
|
||||||
|
posted,
|
||||||
|
date: posted ? posted.slice(0, 10) : null,
|
||||||
|
deadline: parsed.deadline,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const search = defineCommand({
|
||||||
|
name: "search",
|
||||||
|
description: "Search job listings via RSS feed",
|
||||||
|
options: {
|
||||||
|
key: option(z.string().optional(), {
|
||||||
|
description: "Keyword search (title, company, keyword)",
|
||||||
|
}),
|
||||||
|
exclude: option(z.string().optional(), {
|
||||||
|
description: "Exclude keywords (antikey)",
|
||||||
|
}),
|
||||||
|
type: option(z.union([z.string(), z.array(z.string())]).optional(), {
|
||||||
|
description: "Job type code (cvtype). Repeatable: --type 3 --type 6",
|
||||||
|
}),
|
||||||
|
education: option(z.union([z.string(), z.array(z.string())]).optional(), {
|
||||||
|
description: "Education field code (udd). Repeatable.",
|
||||||
|
}),
|
||||||
|
location: option(z.union([z.string(), z.array(z.string())]).optional(), {
|
||||||
|
description: "Region code (amt). Repeatable.",
|
||||||
|
}),
|
||||||
|
"work-area": option(z.union([z.string(), z.array(z.string())]).optional(), {
|
||||||
|
description: "Work area / function code (erf). Repeatable.",
|
||||||
|
}),
|
||||||
|
industry: option(z.union([z.string(), z.array(z.string())]).optional(), {
|
||||||
|
description: "Industry code (branche). Repeatable.",
|
||||||
|
}),
|
||||||
|
"suitable-for": option(z.union([z.string(), z.array(z.string())]).optional(), {
|
||||||
|
description: "Suitable-for code (andet). Repeatable.",
|
||||||
|
}),
|
||||||
|
company: option(z.coerce.number().int().min(1).optional(), {
|
||||||
|
description: "Company ID (virk)",
|
||||||
|
}),
|
||||||
|
remote: option(z.string().optional(), {
|
||||||
|
description: "Remote work: helt or delvist (fjernarbejde)",
|
||||||
|
}),
|
||||||
|
since: option(z.string().optional(), {
|
||||||
|
description: "Posted on or after date, format YYYY-MM-DD (oprettet)",
|
||||||
|
}),
|
||||||
|
limit: option(z.coerce.number().int().min(1).optional(), {
|
||||||
|
description: "Cap total results returned by CLI (client-side)",
|
||||||
|
}),
|
||||||
|
format: option(z.enum(["json", "table", "plain"]).default("json"), {
|
||||||
|
description: "Output format: json, table, plain",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
handler: async ({ flags, signal }) => {
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
// Require at least one filter
|
||||||
|
const hasFilter =
|
||||||
|
flags.key ||
|
||||||
|
flags.exclude ||
|
||||||
|
flags.type ||
|
||||||
|
flags.education ||
|
||||||
|
flags.location ||
|
||||||
|
flags["work-area"] ||
|
||||||
|
flags.industry ||
|
||||||
|
flags["suitable-for"] ||
|
||||||
|
flags.company !== undefined ||
|
||||||
|
flags.remote ||
|
||||||
|
flags.since
|
||||||
|
|
||||||
|
if (!hasFilter) {
|
||||||
|
writeError("--key or at least one filter is required", "MISSING_REQUIRED")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: Record<string, string | string[]> = {}
|
||||||
|
|
||||||
|
if (flags.key) params["key"] = flags.key
|
||||||
|
if (flags.exclude) params["antikey"] = flags.exclude
|
||||||
|
if (flags.type) {
|
||||||
|
const vals = Array.isArray(flags.type) ? flags.type : [flags.type]
|
||||||
|
params["cvtype"] = vals.flatMap((v) => v.split(","))
|
||||||
|
}
|
||||||
|
if (flags.education) {
|
||||||
|
const vals = Array.isArray(flags.education) ? flags.education : [flags.education]
|
||||||
|
params["udd"] = vals.flatMap((v) => v.split(","))
|
||||||
|
}
|
||||||
|
if (flags.location) {
|
||||||
|
const vals = Array.isArray(flags.location) ? flags.location : [flags.location]
|
||||||
|
params["amt"] = vals.flatMap((v) => v.split(","))
|
||||||
|
}
|
||||||
|
if (flags["work-area"]) {
|
||||||
|
const vals = Array.isArray(flags["work-area"]) ? flags["work-area"] : [flags["work-area"]]
|
||||||
|
params["erf"] = vals.flatMap((v) => v.split(","))
|
||||||
|
}
|
||||||
|
if (flags.industry) {
|
||||||
|
const vals = Array.isArray(flags.industry) ? flags.industry : [flags.industry]
|
||||||
|
params["branche"] = vals.flatMap((v) => v.split(","))
|
||||||
|
}
|
||||||
|
if (flags["suitable-for"]) {
|
||||||
|
const vals = Array.isArray(flags["suitable-for"]) ? flags["suitable-for"] : [flags["suitable-for"]]
|
||||||
|
params["andet"] = vals.flatMap((v) => v.split(","))
|
||||||
|
}
|
||||||
|
if (flags.company !== undefined) params["virk"] = String(flags.company)
|
||||||
|
if (flags.remote) params["fjernarbejde"] = flags.remote
|
||||||
|
if (flags.since) params["oprettet"] = flags.since
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch RSS feed
|
||||||
|
const items = await rssFetch(params)
|
||||||
|
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
// Also fetch total count from HTML page (secondary request)
|
||||||
|
let total: number | null = null
|
||||||
|
try {
|
||||||
|
// Small delay to be polite
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||||
|
const searchParams = new URLSearchParams()
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const v of value) searchParams.append(key, v)
|
||||||
|
} else {
|
||||||
|
searchParams.append(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const htmlUrl = `${BASE_URL}/job/?${searchParams.toString()}`
|
||||||
|
const htmlResp = await fetchWithUA(htmlUrl)
|
||||||
|
if (htmlResp.ok) {
|
||||||
|
const html = await htmlResp.text()
|
||||||
|
// Extract from <title> tag: "457 relevante job og karriereopslag i Akademikernes Jobbank"
|
||||||
|
const titleMatch = html.match(/<title[^>]*>\s*(\d[\d.,]*)\s+relevante job/i)
|
||||||
|
if (titleMatch) {
|
||||||
|
total = parseInt(titleMatch[1].replace(/[.,]/g, ""), 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Secondary request failed — total stays null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize items
|
||||||
|
let results = items.map(normalizeSearchItem)
|
||||||
|
|
||||||
|
// Apply limit
|
||||||
|
if (flags.limit !== undefined) {
|
||||||
|
results = results.slice(0, flags.limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = { meta: { total }, results }
|
||||||
|
|
||||||
|
if (flags.format === "json") {
|
||||||
|
console.log(JSON.stringify(output, null, 2))
|
||||||
|
} else if (flags.format === "table") {
|
||||||
|
outputTable(results)
|
||||||
|
} else {
|
||||||
|
outputPlain(results)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function outputTable(results: Array<Record<string, unknown>>): void {
|
||||||
|
console.log("id title company location deadline")
|
||||||
|
for (const r of results) {
|
||||||
|
const id = String(r.id ?? "-").padEnd(9)
|
||||||
|
const title = String(r.title ?? "-").substring(0, 36).padEnd(36)
|
||||||
|
const company = String(r.company ?? "-").substring(0, 22).padEnd(22)
|
||||||
|
const location = String(r.location ?? "-").substring(0, 18).padEnd(18)
|
||||||
|
const deadline = String(r.deadline ?? "-")
|
||||||
|
console.log(`${id} ${title} ${company} ${location} ${deadline}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputPlain(results: Array<Record<string, unknown>>): void {
|
||||||
|
for (const r of results) {
|
||||||
|
console.log(`id: ${r.id}`)
|
||||||
|
console.log(`title: ${r.title}`)
|
||||||
|
console.log(`company: ${r.company}`)
|
||||||
|
console.log(`location: ${r.location}`)
|
||||||
|
console.log(`jobType: ${r.jobType}`)
|
||||||
|
console.log(`posted: ${r.posted}`)
|
||||||
|
console.log(`deadline: ${r.deadline ?? "none"}`)
|
||||||
|
console.log(`url: ${r.url}`)
|
||||||
|
console.log("")
|
||||||
|
}
|
||||||
|
}
|
||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
import { parse as parseHtml } from "node-html-parser"
|
||||||
|
|
||||||
|
export const BASE_URL = "https://jobbank.dk"
|
||||||
|
|
||||||
|
export const USER_AGENT = "Mozilla/5.0 (compatible; jobbank-cli/1.0)"
|
||||||
|
|
||||||
|
export function writeError(error: string, code: string): void {
|
||||||
|
process.stderr.write(JSON.stringify({ error, code }) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWithUA(url: string): Promise<Response> {
|
||||||
|
const maxRetries = 6
|
||||||
|
let delay = 500
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { "User-Agent": USER_AGENT },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
})
|
||||||
|
if (response.status === 429 || response.status >= 500) {
|
||||||
|
if (attempt === maxRetries) {
|
||||||
|
throw new Error(`Request failed: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
const jitter = Math.floor(Math.random() * 500)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay + jitter))
|
||||||
|
delay = Math.min(delay * 2, 5000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
throw new Error("Request failed after max retries")
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RssItem {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
link: string
|
||||||
|
pubDate: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCdata(xml: string, tag: string): string {
|
||||||
|
// Try CDATA first
|
||||||
|
const cdataRe = new RegExp(`<${tag}><\\!\\[CDATA\\[(.*?)\\]\\]><\\/${tag}>`, "s")
|
||||||
|
const cdataMatch = xml.match(cdataRe)
|
||||||
|
if (cdataMatch) return cdataMatch[1].trim()
|
||||||
|
// Plain content
|
||||||
|
const plainRe = new RegExp(`<${tag}>(.*?)<\\/${tag}>`, "s")
|
||||||
|
const plainMatch = xml.match(plainRe)
|
||||||
|
return plainMatch ? plainMatch[1].trim() : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLink(xml: string): string {
|
||||||
|
// <link> in RSS can conflict with atom namespace — extract text node after <link>
|
||||||
|
// Try CDATA variant first
|
||||||
|
const cdataMatch = xml.match(/<link><!\[CDATA\[(.*?)\]\]><\/link>/s)
|
||||||
|
if (cdataMatch) return cdataMatch[1].trim()
|
||||||
|
// Plain link
|
||||||
|
const plainMatch = xml.match(/<link>(.*?)<\/link>/s)
|
||||||
|
if (plainMatch) return plainMatch[1].trim()
|
||||||
|
// Some RSS feeds put the URL as text after <link> without a closing tag (self-closing style)
|
||||||
|
// Try matching href in atom:link
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRssItems(xml: string): RssItem[] {
|
||||||
|
const items: RssItem[] = []
|
||||||
|
// Split on <item> boundaries
|
||||||
|
const itemMatches = xml.matchAll(/<item>([\s\S]*?)<\/item>/g)
|
||||||
|
for (const match of itemMatches) {
|
||||||
|
const itemXml = match[1]
|
||||||
|
const title = extractCdata(itemXml, "title")
|
||||||
|
const description = extractCdata(itemXml, "description")
|
||||||
|
const link = extractLink(itemXml)
|
||||||
|
const pubDate = extractCdata(itemXml, "pubDate") || itemXml.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() || ""
|
||||||
|
items.push({ title, description, link, pubDate })
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rssFetch(params: Record<string, string | string[]>): Promise<RssItem[]> {
|
||||||
|
const searchParams = new URLSearchParams()
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const v of value) {
|
||||||
|
searchParams.append(key, v)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
searchParams.append(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const url = `${BASE_URL}/job/rss?${searchParams.toString()}`
|
||||||
|
const response = await fetchWithUA(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.clone().text()
|
||||||
|
if (response.status === 403 && /just a moment|cloudflare|cf-chl/i.test(body)) {
|
||||||
|
throw new Error(
|
||||||
|
"Jobbank is blocking automated requests with Cloudflare bot protection. Skip this portal or use the WebSearch fallback."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch RSS feed: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
const xml = await response.text()
|
||||||
|
return parseRssItems(xml)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedDescription {
|
||||||
|
jobType: string
|
||||||
|
company: string
|
||||||
|
location: string
|
||||||
|
deadline: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRssDescription(desc: string): ParsedDescription {
|
||||||
|
// Format: "JobType hos Company, Location (Ansøgningsfrist: DD.MM.YYYY)"
|
||||||
|
// or: "JobType hos Company, Location (Ansøgningsfrist: løbende)"
|
||||||
|
// or multiple types: "Fuldtidsjob, Graduate/trainee hos Company, Location (Ansøgningsfrist: ...)"
|
||||||
|
|
||||||
|
let jobType = ""
|
||||||
|
let company = ""
|
||||||
|
let location = ""
|
||||||
|
let deadline: string | null = null
|
||||||
|
|
||||||
|
const hosIdx = desc.indexOf(" hos ")
|
||||||
|
if (hosIdx === -1) {
|
||||||
|
// Can't parse — return desc as company
|
||||||
|
return { jobType: "", company: desc, location: "", deadline: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
jobType = desc.substring(0, hosIdx).trim()
|
||||||
|
let rest = desc.substring(hosIdx + 5) // skip " hos "
|
||||||
|
|
||||||
|
// Extract deadline from parenthetical at the end
|
||||||
|
const deadlineMatch = rest.match(/\(Ans[øo]gningsfrist:\s*(.*?)\)\s*$/)
|
||||||
|
if (deadlineMatch) {
|
||||||
|
const deadlineStr = deadlineMatch[1].trim()
|
||||||
|
if (deadlineStr.toLowerCase() === "løbende" || deadlineStr.toLowerCase() === "lobende") {
|
||||||
|
deadline = null
|
||||||
|
} else {
|
||||||
|
// The feed writes DD.MM.YYYY; the /scrape contract (and this CLI's own
|
||||||
|
// detail command) use YYYY-MM-DD. Convert the known shape; anything else
|
||||||
|
// passes through so an unexpected value stays visible downstream.
|
||||||
|
const dmy = deadlineStr.match(/^(\d{2})\.(\d{2})\.(\d{4})$/)
|
||||||
|
deadline = dmy ? `${dmy[3]}-${dmy[2]}-${dmy[1]}` : deadlineStr
|
||||||
|
}
|
||||||
|
// Remove the deadline portion from rest
|
||||||
|
rest = rest.substring(0, deadlineMatch.index).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// rest is now "Company, Location"
|
||||||
|
// Split on first ", " to get company and location
|
||||||
|
const firstComma = rest.indexOf(", ")
|
||||||
|
if (firstComma !== -1) {
|
||||||
|
company = rest.substring(0, firstComma).trim()
|
||||||
|
location = rest.substring(firstComma + 2).trim()
|
||||||
|
} else {
|
||||||
|
company = rest.trim()
|
||||||
|
location = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return { jobType, company, location, deadline }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractJobIdFromUrl(url: string): string {
|
||||||
|
// URL format: https://jobbank.dk/job/{id}/{company-slug}/{title-slug}
|
||||||
|
const match = url.match(/\/job\/(\d+)\//)
|
||||||
|
return match ? match[1] : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function findJobPosting(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
const jobPosting = findJobPosting(item)
|
||||||
|
if (jobPosting) return jobPosting
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value || typeof value !== "object") return null
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>
|
||||||
|
if (record["@type"] === "JobPosting") return record
|
||||||
|
|
||||||
|
return findJobPosting(record["@graph"])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseJobPostingJsonLd(html: string): Record<string, unknown> | null {
|
||||||
|
const root = parseHtml(html)
|
||||||
|
const scripts = root.querySelectorAll('script[type="application/ld+json"]')
|
||||||
|
|
||||||
|
for (const script of scripts) {
|
||||||
|
try {
|
||||||
|
const jobPosting = findJobPosting(JSON.parse(script.text) as unknown)
|
||||||
|
if (jobPosting) return jobPosting
|
||||||
|
} catch {
|
||||||
|
// Invalid JSON-LD should not prevent later scripts from being checked.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { runCLI } from "./helpers";
|
||||||
|
|
||||||
|
function parseError(stderr: string): { error: string; code: string } {
|
||||||
|
return JSON.parse(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Jobbank CLI error contract", () => {
|
||||||
|
test("search without a filter fails with JSON on stderr", async () => {
|
||||||
|
const result = await runCLI(["search"]);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.stdout).toBe("");
|
||||||
|
expect(parseError(result.stderr)).toEqual({
|
||||||
|
error: "--key or at least one filter is required",
|
||||||
|
code: "MISSING_REQUIRED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detail without an ID fails before making a request", async () => {
|
||||||
|
const result = await runCLI(["detail"]);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.stdout).toBe("");
|
||||||
|
expect(parseError(result.stderr)).toEqual({
|
||||||
|
error: "Job ID is required",
|
||||||
|
code: "MISSING_REQUIRED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { runCLI } from "./helpers";
|
||||||
|
|
||||||
|
// All cases fail schema validation (or the required-flag guard) before any
|
||||||
|
// network request, so the suite is network-free. Regression context: a bare
|
||||||
|
// z.coerce.number() accepted --limit=-1, and slice(0, -1) then silently
|
||||||
|
// dropped the last result instead of erroring. The --company filter flag
|
||||||
|
// also accepted negative and fractional values that were sent raw to the
|
||||||
|
// portal.
|
||||||
|
|
||||||
|
function expectValidationError(result: { exitCode: number; stdout: string; stderr: string }, option: string) {
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.stdout).toBe("");
|
||||||
|
const error = JSON.parse(result.stderr);
|
||||||
|
expect(error.ok).toBe(false);
|
||||||
|
expect(error.error.kind).toBe("validation");
|
||||||
|
expect(error.error.option).toBe(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Jobbank CLI flag validation", () => {
|
||||||
|
test("search --limit=-1 is rejected instead of silently dropping the last result", async () => {
|
||||||
|
const result = await runCLI(["search", "--key", "test", "--limit=-1"]);
|
||||||
|
expectValidationError(result, "limit");
|
||||||
|
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search --limit=0 is rejected", async () => {
|
||||||
|
const result = await runCLI(["search", "--key", "test", "--limit=0"]);
|
||||||
|
expectValidationError(result, "limit");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search --limit=1.5 is rejected as non-integer", async () => {
|
||||||
|
const result = await runCLI(["search", "--key", "test", "--limit=1.5"]);
|
||||||
|
expectValidationError(result, "limit");
|
||||||
|
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search --company=-1 is rejected", async () => {
|
||||||
|
const result = await runCLI(["search", "--key", "test", "--company=-1"]);
|
||||||
|
expectValidationError(result, "company");
|
||||||
|
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search --company=1.5 is rejected as non-integer", async () => {
|
||||||
|
const result = await runCLI(["search", "--key", "test", "--company=1.5"]);
|
||||||
|
expectValidationError(result, "company");
|
||||||
|
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("valid --limit passes schema validation (proven offline via the required-filter guard)", async () => {
|
||||||
|
const result = await runCLI(["search", "--limit=5"]);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(JSON.parse(result.stderr)).toEqual({
|
||||||
|
error: "--key or at least one filter is required",
|
||||||
|
code: "MISSING_REQUIRED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("unknown flag rejection", () => {
|
||||||
|
// add-portal.md's contract: "a bogus flag or missing required arg exits 1
|
||||||
|
// with a JSON error on stderr". A silently discarded flag is worse than an
|
||||||
|
// error: on jobdanmark a wrong flag name returned the entire database
|
||||||
|
// (13,862 results) as if it matched the query (review finding F13,
|
||||||
|
// 2026-08-19). Rejection happens before dispatch, so these are network-free.
|
||||||
|
test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => {
|
||||||
|
const result = await runCLI(["search", "--key", "test", "--bogus-flag", "xyz"]);
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.stdout).toBe("");
|
||||||
|
const error = JSON.parse(result.stderr);
|
||||||
|
expect(error.code).toBe("UNKNOWN_FLAG");
|
||||||
|
expect(error.error).toContain("--bogus-flag");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("--query (another portal's free-text flag) is rejected, not treated as no filter", async () => {
|
||||||
|
const result = await runCLI(["search", "--query", "test"]);
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(JSON.parse(result.stderr).code).toBe("UNKNOWN_FLAG");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { parseJobPostingJsonLd } from "../src/helpers"
|
||||||
|
|
||||||
|
const script = (value: string) => `<script type="application/ld+json">${value}</script>`
|
||||||
|
|
||||||
|
describe("parseJobPostingJsonLd", () => {
|
||||||
|
test("finds a JobPosting inside an @graph", () => {
|
||||||
|
const html = script(
|
||||||
|
JSON.stringify({
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
{ "@type": "WebPage", name: "Jobs" },
|
||||||
|
{ "@type": "JobPosting", title: "Data Engineer" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(parseJobPostingJsonLd(html)).toEqual({
|
||||||
|
"@type": "JobPosting",
|
||||||
|
title: "Data Engineer",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves top-level object and array support", () => {
|
||||||
|
expect(parseJobPostingJsonLd(script('{"@type":"JobPosting","title":"One"}'))?.title).toBe("One")
|
||||||
|
expect(
|
||||||
|
parseJobPostingJsonLd(script('[{"@type":"WebPage"},{"@type":"JobPosting","title":"Two"}]'))?.title,
|
||||||
|
).toBe("Two")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips malformed scripts and checks later JSON-LD", () => {
|
||||||
|
const html = `${script("{not-json")}${script('{"@type":"JobPosting","title":"Valid"}')}`
|
||||||
|
|
||||||
|
expect(parseJobPostingJsonLd(html)?.title).toBe("Valid")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns null when no JobPosting exists", () => {
|
||||||
|
expect(parseJobPostingJsonLd(script('{"@graph":[{"@type":"WebPage"}]}'))).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
const CLI_PATH = join(import.meta.dir, "../src/cli.ts");
|
||||||
|
|
||||||
|
export interface CLIResult {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
exitCode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCLI(args: string[]): Promise<CLIResult> {
|
||||||
|
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [stdout, stderr, exitCode] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
proc.exited,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseJSON<T = unknown>(result: CLIResult): T {
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`CLI exited with code ${result.exitCode}. stderr: ${result.stderr}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(result.stdout) as T;
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import { fetchWithUA } from "../src/helpers";
|
||||||
|
|
||||||
|
// A stalled upstream connection (accepted socket, no response) would otherwise
|
||||||
|
// hang the CLI forever - fetch has no default timeout. Assert the request
|
||||||
|
// wrapper carries an AbortSignal timeout. Fails on the pre-fix code (no signal).
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchWithUA request timeout", () => {
|
||||||
|
test("passes an AbortSignal timeout to fetch", async () => {
|
||||||
|
let init: RequestInit | undefined;
|
||||||
|
globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => {
|
||||||
|
init = i;
|
||||||
|
return new Response("<rss></rss>", { status: 200 });
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
await fetchWithUA("https://jobbank.dk/job/rss");
|
||||||
|
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import { fetchWithUA } from "../src/helpers";
|
||||||
|
|
||||||
|
// The portal contract requires backoff on 429/5xx. These tests pin the retry
|
||||||
|
// loop offline: a stubbed fetch counts attempts, and a stubbed setTimeout
|
||||||
|
// fires immediately so the exhaustion case does not sleep through the real
|
||||||
|
// 500ms -> 5s backoff schedule.
|
||||||
|
//
|
||||||
|
// fetchWithUA deliberately RETURNS non-retry statuses instead of throwing -
|
||||||
|
// callers own 4xx handling (e.g. rssFetch's Cloudflare 403 message). The 4xx
|
||||||
|
// test pins that contract.
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalSetTimeout = globalThis.setTimeout;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
globalThis.setTimeout = originalSetTimeout;
|
||||||
|
});
|
||||||
|
|
||||||
|
function instantTimers() {
|
||||||
|
globalThis.setTimeout = ((fn: () => void) =>
|
||||||
|
originalSetTimeout(fn, 0)) as unknown as typeof setTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubFetch(responses: Array<() => Response>): { calls: number } {
|
||||||
|
const state = { calls: 0 };
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
const i = Math.min(state.calls, responses.length - 1);
|
||||||
|
state.calls++;
|
||||||
|
return responses[i]();
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("fetchWithUA retry/backoff", () => {
|
||||||
|
test("retries a 429 and succeeds on the next attempt", async () => {
|
||||||
|
instantTimers();
|
||||||
|
const state = stubFetch([
|
||||||
|
() => new Response("", { status: 429 }),
|
||||||
|
() => new Response("ok", { status: 200 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = await fetchWithUA("https://jobbank.dk/x");
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(state.calls).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns a plain 4xx to the caller without retrying", async () => {
|
||||||
|
const state = stubFetch([() => new Response("", { status: 403 })]);
|
||||||
|
|
||||||
|
const response = await fetchWithUA("https://jobbank.dk/x");
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(state.calls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gives up after the initial attempt plus six retries on persistent 5xx", async () => {
|
||||||
|
instantTimers();
|
||||||
|
const state = stubFetch([() => new Response("", { status: 500 })]);
|
||||||
|
|
||||||
|
await expect(fetchWithUA("https://jobbank.dk/x")).rejects.toThrow(/500/);
|
||||||
|
expect(state.calls).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import { rssFetch } from "../src/helpers";
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rssFetch", () => {
|
||||||
|
test("reports Cloudflare bot protection clearly", async () => {
|
||||||
|
globalThis.fetch = (async () =>
|
||||||
|
new Response("<html><title>Just a moment...</title></html>", {
|
||||||
|
status: 403,
|
||||||
|
statusText: "Forbidden",
|
||||||
|
})) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
await expect(rssFetch({ key: "data" })).rejects.toThrow(/Cloudflare bot protection/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
extractJobIdFromUrl,
|
||||||
|
parseRssDescription,
|
||||||
|
rssFetch,
|
||||||
|
USER_AGENT,
|
||||||
|
} from "../src/helpers";
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rssFetch", () => {
|
||||||
|
test("serializes repeated filters and parses CDATA and plain RSS fields", async () => {
|
||||||
|
let requestedUrl = "";
|
||||||
|
let requestedUserAgent = "";
|
||||||
|
globalThis.fetch = (async (input, init) => {
|
||||||
|
requestedUrl = String(input);
|
||||||
|
requestedUserAgent = new Headers(init?.headers).get("User-Agent") ?? "";
|
||||||
|
return new Response(`<?xml version="1.0"?>
|
||||||
|
<rss><channel><item>
|
||||||
|
<title><![CDATA[Data Scientist]]></title>
|
||||||
|
<description><![CDATA[Fuldtidsjob hos Acme A/S, København (Ansøgningsfrist: 31.07.2026)]]></description>
|
||||||
|
<link>https://jobbank.dk/job/12345/acme/data-scientist</link>
|
||||||
|
<pubDate>Mon, 13 Jul 2026 08:00:00 GMT</pubDate>
|
||||||
|
</item></channel></rss>`);
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const items = await rssFetch({ key: "data science", cvtype: ["3", "6"] });
|
||||||
|
const url = new URL(requestedUrl);
|
||||||
|
|
||||||
|
expect(url.pathname).toBe("/job/rss");
|
||||||
|
expect(url.searchParams.get("key")).toBe("data science");
|
||||||
|
expect(url.searchParams.getAll("cvtype")).toEqual(["3", "6"]);
|
||||||
|
expect(requestedUserAgent).toBe(USER_AGENT);
|
||||||
|
expect(items).toEqual([
|
||||||
|
{
|
||||||
|
title: "Data Scientist",
|
||||||
|
description: "Fuldtidsjob hos Acme A/S, København (Ansøgningsfrist: 31.07.2026)",
|
||||||
|
link: "https://jobbank.dk/job/12345/acme/data-scientist",
|
||||||
|
pubDate: "Mon, 13 Jul 2026 08:00:00 GMT",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseRssDescription", () => {
|
||||||
|
test("separates multiple job types, company, location, and deadline", () => {
|
||||||
|
expect(
|
||||||
|
parseRssDescription(
|
||||||
|
"Fuldtidsjob, Graduate/trainee hos Acme A/S, København (Ansøgningsfrist: 31.07.2026)",
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
jobType: "Fuldtidsjob, Graduate/trainee",
|
||||||
|
company: "Acme A/S",
|
||||||
|
location: "København",
|
||||||
|
deadline: "2026-07-31",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes an unrecognized deadline shape through for downstream defensive parsing", () => {
|
||||||
|
const parsed = parseRssDescription(
|
||||||
|
"Fuldtidsjob hos Acme A/S, Odense (Ansøgningsfrist: snarest muligt)",
|
||||||
|
);
|
||||||
|
expect(parsed.deadline).toBe("snarest muligt");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes a rolling deadline to null", () => {
|
||||||
|
expect(
|
||||||
|
parseRssDescription("Deltidsjob hos Example ApS, Aarhus (Ansøgningsfrist: løbende)"),
|
||||||
|
).toEqual({
|
||||||
|
jobType: "Deltidsjob",
|
||||||
|
company: "Example ApS",
|
||||||
|
location: "Aarhus",
|
||||||
|
deadline: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps an unstructured description as the company fallback", () => {
|
||||||
|
expect(parseRssDescription("Unstructured feed value")).toEqual({
|
||||||
|
jobType: "",
|
||||||
|
company: "Unstructured feed value",
|
||||||
|
location: "",
|
||||||
|
deadline: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractJobIdFromUrl", () => {
|
||||||
|
test("extracts numeric IDs and rejects unsupported URL shapes", () => {
|
||||||
|
expect(extractJobIdFromUrl("https://jobbank.dk/job/12345/acme/role")).toBe("12345");
|
||||||
|
expect(extractJobIdFromUrl("https://jobbank.dk/job/not-a-number/acme/role")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { normalizeSearchItem } from "../src/commands/search";
|
||||||
|
import type { RssItem } from "../src/helpers";
|
||||||
|
|
||||||
|
function rssItem(): RssItem {
|
||||||
|
return {
|
||||||
|
title: "Data Scientist",
|
||||||
|
description: "Fuldtidsjob hos Acme A/S, København (Ansøgningsfrist: 31.07.2026)",
|
||||||
|
link: "https://jobbank.dk/job/12345/acme/data-scientist",
|
||||||
|
pubDate: "Fri, 14 Aug 2026 09:30:00 +0200",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Jobbank search normalization", () => {
|
||||||
|
test("derives the /scrape contract date from posted as YYYY-MM-DD", () => {
|
||||||
|
const result = normalizeSearchItem(rssItem());
|
||||||
|
|
||||||
|
expect(result.posted).toBe("2026-08-14T07:30:00.000Z");
|
||||||
|
expect(result.date).toBe((result.posted as string).slice(0, 10));
|
||||||
|
expect(result.date).toBe("2026-08-14");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits a null date when pubDate is absent (posted is empty)", () => {
|
||||||
|
const result = normalizeSearchItem({ ...rssItem(), pubDate: "" });
|
||||||
|
|
||||||
|
expect(result.posted).toBe("");
|
||||||
|
expect(result.date).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the native fields alongside the contract date (additive)", () => {
|
||||||
|
const result = normalizeSearchItem(rssItem());
|
||||||
|
|
||||||
|
expect(result.company).toBe("Acme A/S");
|
||||||
|
expect(result.location).toBe("København");
|
||||||
|
expect(result.url).toBe("https://jobbank.dk/job/12345/acme/data-scientist");
|
||||||
|
expect(result.deadline).toBe("2026-07-31");
|
||||||
|
});
|
||||||
|
});
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"types": ["bun-types"],
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "tests/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
+327
@@ -0,0 +1,327 @@
|
|||||||
|
# Jobbank.dk URL Reference
|
||||||
|
|
||||||
|
Complete reference for constructing search URLs on Akademikernes Jobbank.
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
|
||||||
|
```
|
||||||
|
https://jobbank.dk/job/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full URL Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
https://jobbank.dk/job/?key={search}&antikey={exclude}&cvtype={type_id}&udd={edu_id}&amt={location_id}&erf={workarea_id}&branche={industry_id}&andet={suitability_id}&virk={company}&fjernarbejde={remote}&oprettet={YYYY-MM-DD}&page={num}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multiple values:** Repeat the parameter name
|
||||||
|
```
|
||||||
|
cvtype=3&cvtype=13&amt=2&amt=3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query Parameters
|
||||||
|
|
||||||
|
| Parameter | Description | Format |
|
||||||
|
|-----------|-------------|--------|
|
||||||
|
| `key` | Search keywords | URL-encoded string (use `+` for spaces) |
|
||||||
|
| `antikey` | Exclude keywords | URL-encoded string |
|
||||||
|
| `cvtype` | Job type ID | Numeric ID (see table below) |
|
||||||
|
| `udd` | Education area ID | Numeric ID (see table below) |
|
||||||
|
| `amt` | Location/region ID | Numeric ID (see table below) |
|
||||||
|
| `erf` | Work area ID | Numeric ID (see table below) |
|
||||||
|
| `branche` | Industry ID | Numeric ID (see table below) |
|
||||||
|
| `andet` | Suitability ID | Numeric ID (see table below) |
|
||||||
|
| `virk` | Company name | URL-encoded string |
|
||||||
|
| `fjernarbejde` | Remote work | `helt` (fully) or `delvist` (partially) |
|
||||||
|
| `oprettet` | Posted since date | `YYYY-MM-DD` format |
|
||||||
|
| `page` | Page number | Integer (1-indexed) |
|
||||||
|
|
||||||
|
## Filter Tables
|
||||||
|
|
||||||
|
### Job Type (cvtype)
|
||||||
|
|
||||||
|
| Label | ID |
|
||||||
|
|-------|-----|
|
||||||
|
| Fuldtidsjob (Full-time) | 3 |
|
||||||
|
| Graduate/trainee | 6 |
|
||||||
|
| Deltidsjob (Part-time) | 13 |
|
||||||
|
| Vikariat (Temporary) | 8 |
|
||||||
|
| Ph.d. & Postdoc | 12 |
|
||||||
|
| Freelance | 11 |
|
||||||
|
| Iværksætterprojekt (Entrepreneurship) | 15 |
|
||||||
|
| Event | 14 |
|
||||||
|
| Praktikplads (Internship) | 9 |
|
||||||
|
| Studiejob (Student job) | 4 |
|
||||||
|
| Studieprojekt/speciale (Study project/thesis) | 5 |
|
||||||
|
|
||||||
|
### Education Area (udd)
|
||||||
|
|
||||||
|
| Label | ID |
|
||||||
|
|-------|-----|
|
||||||
|
| Administration | 20 |
|
||||||
|
| Anlæg/Byggeri/Konstruktion (Construction) | 43 |
|
||||||
|
| Arkitektur/Kunst/Design (Architecture/Art/Design) | 29 |
|
||||||
|
| Elektro/Telekommunikation (Electronics/Telecom) | 47 |
|
||||||
|
| Fødevarer/Veterinær (Food/Veterinary) | 32 |
|
||||||
|
| Human Resources | 38 |
|
||||||
|
| Humaniora (Humanities) | 28 |
|
||||||
|
| IT | 24 |
|
||||||
|
| Jura (Law) | 23 |
|
||||||
|
| Kemi/Biotek/Materialer (Chemistry/Biotech/Materials) | 44 |
|
||||||
|
| Klima/Miljø/Energi (Climate/Environment/Energy) | 45 |
|
||||||
|
| Landbrug/Natur (Agriculture/Nature) | 37 |
|
||||||
|
| Marketing/Business | 22 |
|
||||||
|
| Maskin/Design (Mechanical/Design) | 48 |
|
||||||
|
| Matematik/Fysik/Nano (Math/Physics/Nano) | 46 |
|
||||||
|
| Medicinal/Sundhed (Medicine/Health) | 31 |
|
||||||
|
| Naturvidenskab (Natural Sciences) | 30 |
|
||||||
|
| Organisation/Ledelse (Organization/Management) | 41 |
|
||||||
|
| Produktion/Logistik/Transport (Production/Logistics/Transport) | 35 |
|
||||||
|
| Samfundsvidenskab (Social Sciences) | 34 |
|
||||||
|
| Sprog/Media/Kommunikation (Language/Media/Communication) | 25 |
|
||||||
|
| Teknik/Teknologi (Engineering/Technology) | 33 |
|
||||||
|
| Undervisning/Pædagogik (Education/Pedagogy) | 26 |
|
||||||
|
| Økonomi/Revision (Economics/Accounting) | 21 |
|
||||||
|
|
||||||
|
### Location/Region (amt)
|
||||||
|
|
||||||
|
| Label | ID |
|
||||||
|
|-------|-----|
|
||||||
|
| Storkøbenhavn (Greater Copenhagen) | 2 |
|
||||||
|
| Nordsjælland (North Zealand) | 3 |
|
||||||
|
| Østsjælland (East Zealand) | 14 |
|
||||||
|
| Vestsjælland (West Zealand) | 4 |
|
||||||
|
| Sydsjælland & Øer (South Zealand & Islands) | 5 |
|
||||||
|
| Fyn (Funen) | 13 |
|
||||||
|
| Sønderjylland (South Jutland) | 12 |
|
||||||
|
| Sydvestjylland (Esbjerg) | 11 |
|
||||||
|
| Vestjylland (West Jutland) | 9 |
|
||||||
|
| Sydøstjylland (Southeast Jutland) | 10 |
|
||||||
|
| Midtjylland (Central Jutland) | 7 |
|
||||||
|
| Østjylland (Aarhus) | 8 |
|
||||||
|
| Nordjylland (North Jutland) | 6 |
|
||||||
|
| Bornholm | 20 |
|
||||||
|
| Øresundsregionen (Øresund Region) | 21 |
|
||||||
|
| Grønland & Færøerne (Greenland & Faroe Islands) | 22 |
|
||||||
|
| Udlandet - Sverige (Abroad - Sweden) | 23 |
|
||||||
|
| Udlandet - Norge (Abroad - Norway) | 24 |
|
||||||
|
| Udlandet - øvrige (Abroad - other) | 19 |
|
||||||
|
|
||||||
|
### Work Area (erf)
|
||||||
|
|
||||||
|
| Label | ID |
|
||||||
|
|-------|-----|
|
||||||
|
| Administration | 20 |
|
||||||
|
| Arkitektur/Design (Architecture/Design) | 38 |
|
||||||
|
| Bank/Forsikring (Banking/Insurance) | 22 |
|
||||||
|
| Data/Analyse (Data/Analysis) | 43 |
|
||||||
|
| Eksport (Export) | 47 |
|
||||||
|
| Forskning/Udvikling (Research/Development) | 41 |
|
||||||
|
| Human Resources | 28 |
|
||||||
|
| Indkøb (Procurement) | 49 |
|
||||||
|
| Internet/Multimedia | 34 |
|
||||||
|
| IT-Hardware | 32 |
|
||||||
|
| IT-Netværk/Telekomm. (IT-Network/Telecom) | 33 |
|
||||||
|
| IT-Software | 31 |
|
||||||
|
| Jura (Law) | 24 |
|
||||||
|
| Kommunikation/Media/SoMe (Communication/Media/Social Media) | 35 |
|
||||||
|
| Konstruktion/Beregning (Construction/Calculation) | 46 |
|
||||||
|
| Kunst/Kultur (Art/Culture) | 37 |
|
||||||
|
| Ledelse/Planlægning (Management/Planning) | 26 |
|
||||||
|
| Marketing/Reklame (Marketing/Advertising) | 29 |
|
||||||
|
| Medicinal/Sundhed (Medicine/Health) | 40 |
|
||||||
|
| Naturvidenskab (Natural Sciences) | 45 |
|
||||||
|
| Organisation/Forening (Organization/Association) | 23 |
|
||||||
|
| Politik/Samfund (Politics/Society) | 52 |
|
||||||
|
| Produktion (Production) | 44 |
|
||||||
|
| Projektledelse (Project Management) | 27 |
|
||||||
|
| Rådgivning/Support (Consulting/Support) | 50 |
|
||||||
|
| Salg (Sales) | 30 |
|
||||||
|
| Socialvæsen (Social Services) | 39 |
|
||||||
|
| Teknik (Engineering) | 42 |
|
||||||
|
| Topledelse (Executive Management) | 25 |
|
||||||
|
| Transport/Logistik (Transport/Logistics) | 48 |
|
||||||
|
| Undervisning (Education) | 36 |
|
||||||
|
| Økonomi/Forvaltning (Finance/Administration) | 21 |
|
||||||
|
|
||||||
|
### Industry (branche)
|
||||||
|
|
||||||
|
| Label | ID |
|
||||||
|
|-------|-----|
|
||||||
|
| Advokat/Revision (Law/Accounting) | 10359 |
|
||||||
|
| Byggeri/Anlæg (Construction) | 11669 |
|
||||||
|
| Elektronik/Maskin (Electronics/Machinery) | 11634 |
|
||||||
|
| Fagforeninger/A-kasser/Pensionskasser (Unions/Unemployment funds/Pension funds) | 16791 |
|
||||||
|
| Finans/Forsikring/Pension (Finance/Insurance/Pension) | 10358 |
|
||||||
|
| Forskning/Uddannelse (Research/Education) | 10442 |
|
||||||
|
| Fødevarer/Dagligvarer (Food/Groceries) | 15407 |
|
||||||
|
| Handel/Service (Trade/Service) | 10364 |
|
||||||
|
| IT/Tele | 10331 |
|
||||||
|
| Klima/Energi/Forsyning (Climate/Energy/Utilities) | 17209 |
|
||||||
|
| Kommuner (Municipalities) | 16826 |
|
||||||
|
| Kultur/Medier/Underholdning (Culture/Media/Entertainment) | 10341 |
|
||||||
|
| Medicinal/Biotek/Kemi (Pharmaceuticals/Biotech/Chemistry) | 10333 |
|
||||||
|
| Papir/Møbel/Materialer (Paper/Furniture/Materials) | 10363 |
|
||||||
|
| Regioner/Sundhed/Socialvæsen (Regions/Health/Social services) | 11626 |
|
||||||
|
| Rådgivning/Konsulentservice (Consulting services) | 15586 |
|
||||||
|
| Stat/Politik/Samfund (Government/Politics/Society) | 10362 |
|
||||||
|
| Transport | 10440 |
|
||||||
|
| Vikar/Rekruttering (Temp/Recruitment) | 12450 |
|
||||||
|
|
||||||
|
### Suitability (andet)
|
||||||
|
|
||||||
|
| Label | ID |
|
||||||
|
|-------|-----|
|
||||||
|
| Nyuddannede (New graduates) | 2 |
|
||||||
|
| Personer med international baggrund (People with international background) | 4 |
|
||||||
|
| Erfarne (Experienced) | 5 |
|
||||||
|
|
||||||
|
### Remote Work (fjernarbejde)
|
||||||
|
|
||||||
|
| Label | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Helt hjemmearbejde (Fully remote) | `helt` |
|
||||||
|
| Delvist hjemmearbejde (Partially remote) | `delvist` |
|
||||||
|
|
||||||
|
## Job Detail URL Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
https://jobbank.dk/job/{id}/{company-slug}/{title-slug}/
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: `https://jobbank.dk/job/12345/novo-nordisk/senior-data-scientist/`
|
||||||
|
|
||||||
|
## RSS Feed URL
|
||||||
|
|
||||||
|
Every search has an RSS equivalent:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://jobbank.dk/job/rss?{same_query_params}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
https://jobbank.dk/job/rss?key=python&amt=2&cvtype=3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
- Pages are 1-indexed (first page is `page=1` or no page parameter)
|
||||||
|
- Approximately 20 results per page
|
||||||
|
- Use `&page=2`, `&page=3`, etc. to navigate
|
||||||
|
|
||||||
|
## Job Card Extraction
|
||||||
|
|
||||||
|
### CSS Selectors
|
||||||
|
|
||||||
|
| Element | Selector |
|
||||||
|
|---------|----------|
|
||||||
|
| Job card container | `div.job-item` |
|
||||||
|
| Job ID | `div.job-item[name]` (use `getAttribute("name")`) |
|
||||||
|
| Job title | `.job-header` |
|
||||||
|
| Job type/company/location | `.job-teaser` |
|
||||||
|
| Job description excerpt | `.job-description` |
|
||||||
|
| Date updated | `.job-date-updated` |
|
||||||
|
| Application deadline | `.job-date-application` |
|
||||||
|
| Job detail link | `a[href^="/job/"]` (prepend `https://jobbank.dk`) |
|
||||||
|
|
||||||
|
### Working Extraction Code
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async page => {
|
||||||
|
const jobs = await page.evaluate(() => {
|
||||||
|
return Array.from(document.querySelectorAll("div.job-item")).map(item => {
|
||||||
|
const id = item.getAttribute("name");
|
||||||
|
const link = item.querySelector("a[href^='/job/']");
|
||||||
|
const url = link ? "https://jobbank.dk" + link.getAttribute("href") : null;
|
||||||
|
const header = item.querySelector(".job-header");
|
||||||
|
const teaser = item.querySelector(".job-teaser");
|
||||||
|
const desc = item.querySelector(".job-description");
|
||||||
|
const dateUpdated = item.querySelector(".job-date-updated");
|
||||||
|
const deadline = item.querySelector(".job-date-application");
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: header ? header.textContent.trim() : null,
|
||||||
|
teaser: teaser ? teaser.textContent.trim() : null,
|
||||||
|
description: desc ? desc.textContent.trim() : null,
|
||||||
|
url,
|
||||||
|
dateUpdated: dateUpdated ? dateUpdated.textContent.trim() : null,
|
||||||
|
deadline: deadline ? deadline.textContent.trim() : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return JSON.stringify(jobs, null, 2);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Simple Keyword Search
|
||||||
|
|
||||||
|
```
|
||||||
|
https://jobbank.dk/job/?key=data+scientist
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full-Time IT Jobs in Copenhagen
|
||||||
|
|
||||||
|
```
|
||||||
|
https://jobbank.dk/job/?key=developer&cvtype=3&udd=24&amt=2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remote Software Development Positions
|
||||||
|
|
||||||
|
```
|
||||||
|
https://jobbank.dk/job/?erf=31&fjernarbejde=helt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Graduate/Trainee Positions in Multiple Cities
|
||||||
|
|
||||||
|
```
|
||||||
|
https://jobbank.dk/job/?cvtype=6&amt=2&amt=8
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Management Jobs Suitable for New Graduates
|
||||||
|
|
||||||
|
```
|
||||||
|
https://jobbank.dk/job/?key=project+manager&erf=27&andet=2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Jobs in IT/Telecom Industry, Excluding Senior Positions
|
||||||
|
|
||||||
|
```
|
||||||
|
https://jobbank.dk/job/?key=developer&antikey=senior&branche=10331
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Analysis Jobs Posted in Last 7 Days
|
||||||
|
|
||||||
|
```
|
||||||
|
https://jobbank.dk/job/?key=data+analysis&erf=43&oprettet=2026-02-03
|
||||||
|
```
|
||||||
|
|
||||||
|
### Second Page of Python Jobs in Aarhus
|
||||||
|
|
||||||
|
```
|
||||||
|
https://jobbank.dk/job/?key=python&amt=8&page=2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Specific Company Search (Novo Nordisk)
|
||||||
|
|
||||||
|
```
|
||||||
|
https://jobbank.dk/job/?virk=novo+nordisk
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ph.D. and Postdoc Positions in Natural Sciences
|
||||||
|
|
||||||
|
```
|
||||||
|
https://jobbank.dk/job/?cvtype=12&udd=30
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **URL encoding:** Use `+` or `%20` for spaces in text parameters (`key`, `antikey`, `virk`)
|
||||||
|
- **Multiple filters:** Repeat parameter names for multiple values in same category
|
||||||
|
- **Date format:** Use ISO format `YYYY-MM-DD` for `oprettet` parameter
|
||||||
|
- **Remote work:** Only two values: `helt` (fully) or `delvist` (partially)
|
||||||
|
- **Case sensitivity:** Filter IDs are case-sensitive, use exact values from tables
|
||||||
|
- **Empty results:** Invalid filter combinations may return zero results
|
||||||
|
- **RSS monitoring:** Use RSS feeds for automated job monitoring and alerts
|
||||||
Executable
+229
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(/&/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
@@ -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
@@ -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ø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øges</h3>
|
||||||
|
<a href="/virksomheder/jfm">JFM</a>
|
||||||
|
<span>Banegårdspladsen 1, 5000 Odense C</span>
|
||||||
|
</div>
|
||||||
|
<p>Hvad nu hvis du med få klik kunne undersøge dit lokalområde?</p>
|
||||||
|
<ul>
|
||||||
|
<li>identificere relevante datasæt og muligheder</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<a href="https://jfm.career.emply.com/da/apply/example" class="action primary count-click">Ansø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å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øges | jobdanmark</title>",
|
||||||
|
"<title>HTTP 404 Page Designer | jobdanmark</title>",
|
||||||
|
).replace(
|
||||||
|
'<h3 class="title">Journalistisk udvikler sø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
@@ -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
@@ -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"]
|
||||||
|
}
|
||||||
Executable
+171
@@ -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`.
|
||||||
Executable
+247
@@ -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
@@ -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
@@ -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
@@ -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(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
// Danish letters appear as named entities in employer-hosted ad markup.
|
||||||
|
.replace(/ø/g, "ø")
|
||||||
|
.replace(/Ø/g, "Ø")
|
||||||
|
.replace(/æ/g, "æ")
|
||||||
|
.replace(/Æ/g, "Æ")
|
||||||
|
.replace(/å/g, "å")
|
||||||
|
.replace(/Å/g, "Å")
|
||||||
|
// Numeric character references: decimal (é) and hexadecimal (é).
|
||||||
|
.replace(/&#(\d+);/g, (_, dec) => numericEntity(parseInt(dec, 10)))
|
||||||
|
.replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex) => numericEntity(parseInt(hex, 16)))
|
||||||
|
.replace(/ /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
@@ -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
@@ -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(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
// Numeric character references: decimal (é) and hexadecimal (é).
|
||||||
|
.replace(/&#(\d+);/g, (_, dec) => numericEntity(parseInt(dec, 10)))
|
||||||
|
.replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex) => numericEntity(parseInt(hex, 16)))
|
||||||
|
.replace(/ /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 års historie, der samtidig er cloud-only, er ikke hverdagskost.</p>
|
||||||
|
<p>Hos Velliv få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ø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øsninger i elnettet
|
||||||
|
</title>
|
||||||
|
<meta name="description" content="Vi søger en udvikler til elnettet" />
|
||||||
|
<meta itemprop="name" content="C#-udvikler til kritiske analyseløsninger i elnettet" />
|
||||||
|
<meta property="og:title" content="C#-udvikler til kritiske analyseløsninger i elnettet" />
|
||||||
|
<meta property="og:site_name" content="Talentech" />
|
||||||
|
<meta property="og:url" content="https://candidate.hr-manager.net/ApplicationInit.aspx?cid=316&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øsninger i elnettet</h1>
|
||||||
|
<p>Vil du være med til at udvikle og drifte de systemer, der understø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
@@ -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,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 (ø -> ø)", () => {
|
||||||
|
const [c] = parseJobCards(card("h1", "Smørrebro Chef"));
|
||||||
|
expect(c.title).toBe("Smørrebro Chef");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decodes uppercase-X hexadecimal entities (æ -> æ)", () => {
|
||||||
|
const [c] = parseJobCards(card("h2", "Kære Kollega"));
|
||||||
|
expect(c.title).toBe("Kære Kollega");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still decodes decimal numeric entities (å -> å) — regression", () => {
|
||||||
|
const [c] = parseJobCards(card("h3", "århus Lead"));
|
||||||
|
expect(c.title).toBe("århus Lead");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decodes supplementary-plane code points with fromCodePoint (😀)", () => {
|
||||||
|
const [c] = parseJobCards(card("h4", "Growth 😀"));
|
||||||
|
expect(c.title).toBe("Growth 😀");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decodes hex supplementary-plane code points (😀)", () => {
|
||||||
|
const [c] = parseJobCards(card("h5", "Growth 😀"));
|
||||||
|
expect(c.title).toBe("Growth 😀");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decodes hex entities in the company name too", () => {
|
||||||
|
const [c] = parseJobCards(card("h6", "Engineer", "Nø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
@@ -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
@@ -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.
|
||||||
Executable
+206
@@ -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.
|
||||||
Executable
+385
@@ -0,0 +1,385 @@
|
|||||||
|
# jobnet-cli
|
||||||
|
|
||||||
|
CLI for the [Jobnet.dk](https://jobnet.dk) Danish government job portal API.
|
||||||
|
|
||||||
|
**Base URL**: `https://jobnet.dk/bff`
|
||||||
|
**Authentication**: No credentials required. All public endpoints only need the `x-csrf: 1` request header.
|
||||||
|
**Format**: All responses are JSON.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd skills/jobnet-search/cli
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `search` | Search for job ads with filters |
|
||||||
|
| `detail` | Full detail for a single job ad |
|
||||||
|
| `occupations` | Search occupation types (for building filters) |
|
||||||
|
| `suggestions` | Typeahead suggestions for job title / keyword search |
|
||||||
|
|
||||||
|
All commands accept `--format json|table|plain` (default: `json`).
|
||||||
|
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regions
|
||||||
|
|
||||||
|
| Value | Danish region |
|
||||||
|
|-------|--------------|
|
||||||
|
| `HovedstadenOgBornholm` | Hovedstaden og Bornholm |
|
||||||
|
| `Midtjylland` | Midtjylland |
|
||||||
|
| `Syddanmark` | Syddanmark |
|
||||||
|
| `OevrigeSjaelland` | Øvrige Sjælland |
|
||||||
|
| `Nordjylland` | Nordjylland |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Order types
|
||||||
|
|
||||||
|
| Value | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `PublicationDate` | Newest postings first (default) |
|
||||||
|
| `BestMatch` | Relevance score (requires `--search-string`) |
|
||||||
|
| `ApplicationDate` | Earliest deadline first |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `search` — Search for job ads
|
||||||
|
|
||||||
|
**Endpoint**: `GET /FindJob/Search`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run src/cli.ts search [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flags
|
||||||
|
|
||||||
|
| Flag | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `--search-string` | string | — | Free-text keyword search (job title, skills, employer) |
|
||||||
|
| `--region` | string | — | One region value (see Regions table) |
|
||||||
|
| `--postal-code` | string | — | Postal code for radius search, e.g. `2100` |
|
||||||
|
| `--radius` | number | `50` | Radius in km from postal code (requires `--postal-code`) |
|
||||||
|
| `--work-hours` | string | — | `FullTime` or `PartTime` |
|
||||||
|
| `--duration` | string | — | `Permanent` or `Temporary` |
|
||||||
|
| `--job-type` | string | — | Announcement type: `Ordinaert`, `Efterloenner`, `Foertidspension` |
|
||||||
|
| `--occupation-area` | string | — | Occupation area identifier, e.g. `10000` |
|
||||||
|
| `--occupation-group` | string | — | Occupation group identifier, e.g. `10060` |
|
||||||
|
| `--page` | number | `1` | Page number (1-indexed) |
|
||||||
|
| `--per-page` | number | `10` | Results per page |
|
||||||
|
| `--limit` | number | — | Cap total results returned by CLI |
|
||||||
|
| `--order` | string | `PublicationDate` | Sort order (see Order types table) |
|
||||||
|
| `--format` | string | `json` | Output format: `json`, `table`, `plain` |
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run src/cli.ts search \
|
||||||
|
--search-string "sygeplejerske" \
|
||||||
|
--region HovedstadenOgBornholm \
|
||||||
|
--work-hours FullTime \
|
||||||
|
--duration Permanent \
|
||||||
|
--per-page 5 \
|
||||||
|
--format table
|
||||||
|
|
||||||
|
bun run src/cli.ts search \
|
||||||
|
--postal-code 8000 \
|
||||||
|
--radius 25 \
|
||||||
|
--per-page 10
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"totalJobAdCount": 21452,
|
||||||
|
"pageNumber": 1,
|
||||||
|
"resultsPerPage": 10,
|
||||||
|
"searchString": "developer"
|
||||||
|
},
|
||||||
|
"facets": {
|
||||||
|
"regions": [
|
||||||
|
{ "type": "HovedstadenOgBornholm", "jobAdCount": 6642 }
|
||||||
|
],
|
||||||
|
"workHours": [
|
||||||
|
{ "type": "FullTime", "jobAdCount": 17919 },
|
||||||
|
{ "type": "PartTime", "jobAdCount": 3533 }
|
||||||
|
],
|
||||||
|
"employmentDurations": [
|
||||||
|
{ "type": "Permanent", "jobAdCount": 18533 },
|
||||||
|
{ "type": "Temporary", "jobAdCount": 2919 }
|
||||||
|
],
|
||||||
|
"occupationAreas": [
|
||||||
|
{ "identifier": "10000", "jobAdCount": 3235 }
|
||||||
|
],
|
||||||
|
"countries": [
|
||||||
|
{ "label": "Danmark", "identifier": "DK", "jobAdCount": 21164 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"jobAdId": "9ef43bce-d82b-4ea1-a098-7ff6520f99be",
|
||||||
|
"title": "Akademisk medarbejder med interesse for arbejdsmiljø og uddannelse",
|
||||||
|
"hiringOrgName": "Region Midtjylland",
|
||||||
|
"occupation": "Personalekonsulent",
|
||||||
|
"municipality": "Viborg",
|
||||||
|
"postalCode": 8800,
|
||||||
|
"postalDistrictName": "Viborg",
|
||||||
|
"country": "Danmark",
|
||||||
|
"publicationDate": "2026-03-13T00:00:00+01:00",
|
||||||
|
"applicationDeadline": "2026-04-05T21:59:00+02:00",
|
||||||
|
"applicationDeadlineStatus": "ExpirationDate",
|
||||||
|
"workHourPartTime": false,
|
||||||
|
"isExternal": false,
|
||||||
|
"hasLogo": true,
|
||||||
|
"logoUrl": "/bff/SharedComponents/JobAdCard/CompanyLogo/ByJobAdId/9ef43bce-d82b-4ea1-a098-7ff6520f99be",
|
||||||
|
"cvr": "29190925",
|
||||||
|
"workPlaceAddress": "",
|
||||||
|
"conceptUriDa": "http://data.star.dk/esco/occupation/426e017f-ebe5-4bea-b1eb-7d2d5ab3c6db",
|
||||||
|
"isSeen": false,
|
||||||
|
"isFavorite": false,
|
||||||
|
"company": "Region Midtjylland",
|
||||||
|
"location": "Viborg",
|
||||||
|
"date": "2026-03-13",
|
||||||
|
"deadline": "2026-04-05",
|
||||||
|
"url": "https://jobnet.dk/find-job/9ef43bce-d82b-4ea1-a098-7ff6520f99be"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: The `description` field (raw HTML) is intentionally omitted from `search` results for brevity. Use `detail` to retrieve the full job description.
|
||||||
|
|
||||||
|
> **Note**: Every result also carries the cross-portal contract fields `company`, `location`, `date`, `deadline` and `url` — derived respectively from `hiringOrgName`, `postalDistrictName`/`municipality`, and the jobnet detail page URL. `/scrape` Step 2 expects search output to include title, company, location, date, and URL, and dates follow the `YYYY-MM-DD` convention of the other portal CLIs. The API's `1900-01-01` deadline sentinel (deadline not disclosed) maps to `null`. The native fields above are preserved unchanged.
|
||||||
|
|
||||||
|
> **Note**: `resultsPerPage` and `pageNumber` must always be provided — omitting them while also providing `searchString` causes the API to return error 1014 ("Fejl i formatering af inputs").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `detail` — Full job ad detail
|
||||||
|
|
||||||
|
**Endpoint**: `GET /FindJob/JobAdDetails/{id}`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run src/cli.ts detail <id> [--format json|plain]
|
||||||
|
```
|
||||||
|
|
||||||
|
The `id` is the `jobAdId` UUID from `search` results.
|
||||||
|
|
||||||
|
By default the CLI passes `incrementViews=false` to avoid polluting view counts.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run src/cli.ts detail 9ef43bce-d82b-4ea1-a098-7ff6520f99be
|
||||||
|
bun run src/cli.ts detail 9ef43bce-d82b-4ea1-a098-7ff6520f99be --format plain
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "9ef43bce-d82b-4ea1-a098-7ff6520f99be",
|
||||||
|
"title": "Akademisk medarbejder med interesse for arbejdsmiljø og uddannelse",
|
||||||
|
"body": "<p>Full HTML job description...</p>",
|
||||||
|
"publicationDateTime": "2026-03-13T08:06:58.0578635+01:00",
|
||||||
|
"unpublicationDateTime": "2026-04-05T21:59:00+02:00",
|
||||||
|
"approvalStatus": "Godkendt",
|
||||||
|
"views": 2,
|
||||||
|
"createdDateTime": "2026-03-13T08:06:58.3936145+01:00",
|
||||||
|
"updatedDateTime": "2026-03-13T08:06:58.3936145+01:00",
|
||||||
|
"isAnonymousEmployer": false,
|
||||||
|
"hasLogo": true,
|
||||||
|
"logoUrl": "/bff/SharedComponents/JobAdCard/CompanyLogo/ByJobAdId/9ef43bce-d82b-4ea1-a098-7ff6520f99be",
|
||||||
|
"employer": {
|
||||||
|
"cvrNumber": "29190925",
|
||||||
|
"pNumber": "1003367314",
|
||||||
|
"name": "Region Midtjylland",
|
||||||
|
"hasCompanyLogo": true
|
||||||
|
},
|
||||||
|
"job": {
|
||||||
|
"type": "Ordinaert",
|
||||||
|
"address": {
|
||||||
|
"streetName": "Heibergs Alle 5A",
|
||||||
|
"city": "Viborg",
|
||||||
|
"postalCode": "8800",
|
||||||
|
"municipality": "Viborg",
|
||||||
|
"countryCode": "DK",
|
||||||
|
"countryName": "Danmark"
|
||||||
|
},
|
||||||
|
"noFixedWorkplace": false,
|
||||||
|
"isLimitedPeriod": false,
|
||||||
|
"isDisabilityFriendly": false,
|
||||||
|
"isPartTime": false,
|
||||||
|
"employmentDate": "2026-06-01T00:00:00+02:00",
|
||||||
|
"conceptUriDa": "http://data.star.dk/esco/occupation/426e017f-ebe5-4bea-b1eb-7d2d5ab3c6db",
|
||||||
|
"preferredLabelDa": "Personalekonsulent",
|
||||||
|
"driversLicenses": [],
|
||||||
|
"classifications": [],
|
||||||
|
"shifts": [],
|
||||||
|
"isFavorite": false
|
||||||
|
},
|
||||||
|
"application": {
|
||||||
|
"deadlineDate": "2026-04-05T21:59:00+02:00",
|
||||||
|
"availablePositions": 1,
|
||||||
|
"contactPersons": [
|
||||||
|
{
|
||||||
|
"firstNames": "Jane",
|
||||||
|
"lastName": "Doe",
|
||||||
|
"phoneNumber": "+4512345678"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": "https://midtjob.dk/ad/...",
|
||||||
|
"urlText": "",
|
||||||
|
"isApplicationDeadlineASAP": false
|
||||||
|
},
|
||||||
|
"organisationTypeId": 24,
|
||||||
|
"user": "Emply 31430747"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: `body` contains raw HTML. In `--format plain` output the CLI strips HTML tags to produce readable text.
|
||||||
|
|
||||||
|
> **Note**: `application.url` may be empty for some job ads — the employer may only accept applications through Jobnet's internal system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `occupations` — Search occupation types
|
||||||
|
|
||||||
|
**Endpoint**: `GET /OccupationSearch`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run src/cli.ts occupations --search-string <text> [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this command to find occupation identifiers (ESCO concept URIs) to pass as filters to `search`.
|
||||||
|
|
||||||
|
### Flags
|
||||||
|
|
||||||
|
| Flag | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `--search-string` | string | **required** | Search term for occupation, e.g. `sygeplejerske` |
|
||||||
|
| `--per-page` | number | `10` | Max results to return |
|
||||||
|
| `--format` | string | `json` | Output format: `json`, `table`, `plain` |
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run src/cli.ts occupations --search-string "sygeplejerske" --per-page 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"conceptUriDa": "http://data.star.dk/esco/occupation/56f5d45c-1234-4321-abcd-000000000000",
|
||||||
|
"preferredLabelDa": "Sygeplejerske",
|
||||||
|
"aliases": [
|
||||||
|
{
|
||||||
|
"aliasIdentifier": "some-uuid",
|
||||||
|
"conceptUriDa": "http://data.star.dk/esco/occupation/56f5d45c-1234-4321-abcd-000000000000",
|
||||||
|
"alternativeLabelDa": "Operationssygeplejerske"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: `conceptUriDa` is the full ESCO concept URI. The UUID portion (last path segment) can be used to build occupation filters for `search`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `suggestions` — Typeahead suggestions
|
||||||
|
|
||||||
|
**Endpoint**: `GET /FindJob/GetTypeaheadSuggestions`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run src/cli.ts suggestions --query <text> [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns autocomplete strings for the search box — useful for exploring valid Danish job titles before running a `search`.
|
||||||
|
|
||||||
|
### Flags
|
||||||
|
|
||||||
|
| Flag | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `--query` | string | **required** | Partial search string to complete |
|
||||||
|
| `--limit` | number | — | Cap number of suggestions returned |
|
||||||
|
| `--format` | string | `json` | Output format: `json`, `table`, `plain` |
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run src/cli.ts suggestions --query "syge"
|
||||||
|
bun run src/cli.ts suggestions --query "ingeniør" --limit 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
"sygepleje",
|
||||||
|
"Sygeplejerske",
|
||||||
|
"Sygeplejerske \"Lægeassistent\""
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: Suggestions are tuned for Danish job titles. English terms like "developer" may return an empty array.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
All errors are written to **stderr** in JSON format and exit with code `1`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "Job ad not found", "code": "NOT_FOUND" }
|
||||||
|
{ "error": "API request failed: 500 Internal Server Error", "code": "API_ERROR" }
|
||||||
|
{ "error": "--query is required", "code": "MISSING_REQUIRED" }
|
||||||
|
{ "error": "--search-string is required", "code": "MISSING_REQUIRED" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URL construction
|
||||||
|
|
||||||
|
Job ad detail pages on jobnet.dk:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://jobnet.dk/find-job/{jobAdId}
|
||||||
|
```
|
||||||
|
|
||||||
|
The legacy `https://jobnet.dk/job/{jobAdId}` route redirects anonymous visitors into the
|
||||||
|
MitID login flow, so it is never emitted. External ads (`isExternal: true`, jobAdIds with an
|
||||||
|
`E` prefix) 404 on `/find-job/` and hit the login wall on `/job/` - neither route serves them
|
||||||
|
anonymously; `/find-job/` is still strictly better and external ads are left as-is.
|
||||||
|
|
||||||
|
Company logo images (prefix relative logoUrl from API):
|
||||||
|
|
||||||
|
```
|
||||||
|
https://jobnet.dk{logoUrl}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: `https://jobnet.dk/bff/SharedComponents/JobAdCard/CompanyLogo/ByJobAdId/9ef43bce-d82b-4ea1-a098-7ff6520f99be`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All data is from the public `jobnet.dk/bff` REST API — no credentials required.
|
||||||
|
- The `x-csrf: 1` header must be sent with every request.
|
||||||
|
- Pagination is 1-indexed (`--page 1` is the first page).
|
||||||
|
- `search` results intentionally omit the HTML `description` field — use `detail` to fetch it.
|
||||||
|
- `body` in `detail` responses is raw HTML; use `--format plain` to get stripped text.
|
||||||
|
- The `occupations` command helps discover ESCO occupation URIs usable as `--occupation-area` / `--occupation-group` seeds for narrowing search results.
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "jobnet-cli",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "CLI for the Jobnet.dk Danish government job portal API",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/cli.ts",
|
||||||
|
"bin": {
|
||||||
|
"jobnet": "src/cli.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun run src/cli.ts",
|
||||||
|
"test": "bun test --timeout 30000",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@bunli/core": "0.9.1",
|
||||||
|
"@bunli/utils": "0.6.0",
|
||||||
|
"zod": "^3.23.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"@types/bun": "1.3.14"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
import { createCLI } from "@bunli/core"
|
||||||
|
import { writeError } from "./helpers.js"
|
||||||
|
import { search } from "./commands/search.js"
|
||||||
|
import { detail } from "./commands/detail.js"
|
||||||
|
import { occupations } from "./commands/occupations.js"
|
||||||
|
import { suggestions } from "./commands/suggestions.js"
|
||||||
|
|
||||||
|
const cli = await createCLI({
|
||||||
|
name: "jobnet-cli",
|
||||||
|
version: "0.1.0",
|
||||||
|
description: "CLI for the Jobnet.dk Danish government job portal API",
|
||||||
|
})
|
||||||
|
|
||||||
|
const commands = [search, detail, occupations, suggestions]
|
||||||
|
for (const command of commands) {
|
||||||
|
cli.command(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject unknown --flags before dispatch. bunli silently discards them, and a
|
||||||
|
// silently discarded filter changes what the search returns without any error
|
||||||
|
// (a wrong flag name once returned an entire portal's database as if it
|
||||||
|
// matched the query). add-portal.md's contract requires a bogus flag to exit 1
|
||||||
|
// with a JSON error on stderr; this enforces it for the reference CLIs too.
|
||||||
|
const argv = process.argv.slice(2)
|
||||||
|
const invoked = commands.find((c) => (c as { name?: string }).name === argv[0])
|
||||||
|
if (invoked) {
|
||||||
|
const known = new Set([
|
||||||
|
...Object.keys((invoked as { options?: Record<string, unknown> }).options ?? {}),
|
||||||
|
"help",
|
||||||
|
"version",
|
||||||
|
])
|
||||||
|
for (const token of argv.slice(1)) {
|
||||||
|
if (token === "--") break
|
||||||
|
if (token.startsWith("--")) {
|
||||||
|
const flag = token.slice(2).split("=")[0]
|
||||||
|
if (!known.has(flag)) {
|
||||||
|
writeError(
|
||||||
|
`unknown flag --${flag} for '${argv[0]}' - flags are never silently ignored, because a discarded filter changes what the search returns; see --help for the supported flags`,
|
||||||
|
"UNKNOWN_FLAG",
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await cli.run()
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
import { defineCommand, option } from "@bunli/core"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { apiFetch, writeError, stripHtml } from "../helpers.js"
|
||||||
|
|
||||||
|
export interface DetailApiResponse {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
publicationDateTime: string
|
||||||
|
unpublicationDateTime: string | null
|
||||||
|
approvalStatus: string
|
||||||
|
views: number
|
||||||
|
createdDateTime: string
|
||||||
|
updatedDateTime: string
|
||||||
|
isAnonymousEmployer: boolean
|
||||||
|
hasLogo: boolean
|
||||||
|
logoUrl: string | null
|
||||||
|
employer: {
|
||||||
|
cvrNumber: string | null
|
||||||
|
pNumber: string | null
|
||||||
|
name: string
|
||||||
|
hasCompanyLogo: boolean
|
||||||
|
}
|
||||||
|
job: {
|
||||||
|
type: string
|
||||||
|
address: {
|
||||||
|
streetName: string | null
|
||||||
|
city: string | null
|
||||||
|
postalCode: string | null
|
||||||
|
municipality: string | null
|
||||||
|
countryCode: string
|
||||||
|
countryName: string
|
||||||
|
}
|
||||||
|
noFixedWorkplace: boolean
|
||||||
|
isLimitedPeriod: boolean
|
||||||
|
isDisabilityFriendly: boolean
|
||||||
|
isPartTime: boolean
|
||||||
|
employmentDate: string | null
|
||||||
|
conceptUriDa: string | null
|
||||||
|
preferredLabelDa: string | null
|
||||||
|
driversLicenses: unknown[]
|
||||||
|
classifications: unknown[]
|
||||||
|
shifts: unknown[]
|
||||||
|
isFavorite: boolean
|
||||||
|
}
|
||||||
|
application: {
|
||||||
|
deadlineDate: string | null
|
||||||
|
availablePositions: number
|
||||||
|
contactPersons: Array<{
|
||||||
|
firstNames: string | null
|
||||||
|
lastName: string | null
|
||||||
|
phoneNumber: string | null
|
||||||
|
}>
|
||||||
|
url: string | null
|
||||||
|
urlText: string | null
|
||||||
|
isApplicationDeadlineASAP: boolean
|
||||||
|
}
|
||||||
|
organisationTypeId: number | null
|
||||||
|
user: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a raw detail response before any output format sees it.
|
||||||
|
*
|
||||||
|
* The API's "deadline not disclosed" sentinel is 1900-01-01 (it arrives with
|
||||||
|
* isApplicationDeadlineASAP / an applicationDeadlineStatus of NotDisclosed).
|
||||||
|
* The search command already maps that sentinel to null; detail must agree,
|
||||||
|
* or an undisclosed deadline reads as 126 years expired and /rank's expiry
|
||||||
|
* sweep retires the job the moment it is stored.
|
||||||
|
*/
|
||||||
|
export function prepareDetail(data: DetailApiResponse): DetailApiResponse {
|
||||||
|
const deadline = data.application.deadlineDate
|
||||||
|
if (deadline && deadline.startsWith("1900-01-01")) {
|
||||||
|
data.application.deadlineDate = null
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const detail = defineCommand({
|
||||||
|
name: "detail",
|
||||||
|
description: "Full detail for a single job ad",
|
||||||
|
options: {
|
||||||
|
format: option(z.enum(["json", "table", "plain"]).default("json"), {
|
||||||
|
description: "Output format: json, table, plain",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
handler: async ({ positional, flags, signal }) => {
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
const id = positional[0] as string | undefined
|
||||||
|
if (!id) {
|
||||||
|
writeError("Job ad ID is required", "MISSING_REQUIRED")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = prepareDetail(
|
||||||
|
await apiFetch<DetailApiResponse>(`/FindJob/JobAdDetails/${id}`, {
|
||||||
|
incrementViews: "false",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
if (flags.format === "json") {
|
||||||
|
console.log(JSON.stringify(data, null, 2))
|
||||||
|
} else if (flags.format === "table") {
|
||||||
|
outputTable(data)
|
||||||
|
} else {
|
||||||
|
outputPlain(data)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
if (message.includes("404") || message.includes("Not Found")) {
|
||||||
|
writeError("Job ad not found", "NOT_FOUND")
|
||||||
|
} else {
|
||||||
|
writeError(message, "API_ERROR")
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function outputTable(data: DetailApiResponse): void {
|
||||||
|
console.log(`ID: ${data.id}`)
|
||||||
|
console.log(`Title: ${data.title}`)
|
||||||
|
console.log(`Employer: ${data.employer.name}`)
|
||||||
|
console.log(`Type: ${data.job.type}`)
|
||||||
|
console.log(`City: ${data.job.address.city ?? "-"}`)
|
||||||
|
console.log(`Postal: ${data.job.address.postalCode ?? "-"}`)
|
||||||
|
console.log(`Country: ${data.job.address.countryName}`)
|
||||||
|
console.log(`Published: ${data.publicationDateTime}`)
|
||||||
|
console.log(`Deadline: ${data.application.deadlineDate ?? "-"}`)
|
||||||
|
console.log(`Positions: ${data.application.availablePositions}`)
|
||||||
|
console.log(`Apply URL: ${data.application.url ?? "-"}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputPlain(data: DetailApiResponse): void {
|
||||||
|
console.log(formatDetailPlain(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDetailPlain(data: DetailApiResponse): string {
|
||||||
|
const lines = [
|
||||||
|
`Title: ${data.title}`,
|
||||||
|
`Employer: ${data.employer.name}`,
|
||||||
|
`Location: ${data.job.address.city ?? "-"}, ${data.job.address.countryName}`,
|
||||||
|
`Published: ${data.publicationDateTime}`,
|
||||||
|
`Deadline: ${data.application.deadlineDate ?? "-"}`,
|
||||||
|
`Positions: ${data.application.availablePositions}`,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (data.application.url) {
|
||||||
|
lines.push(`Apply: ${data.application.url}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("", stripHtml(data.body))
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { defineCommand, option } from "@bunli/core"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { apiFetch, writeError } from "../helpers.js"
|
||||||
|
|
||||||
|
interface OccupationAlias {
|
||||||
|
aliasIdentifier: string
|
||||||
|
conceptUriDa: string
|
||||||
|
alternativeLabelDa: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Occupation {
|
||||||
|
conceptUriDa: string
|
||||||
|
preferredLabelDa: string
|
||||||
|
aliases: OccupationAlias[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const occupations = defineCommand({
|
||||||
|
name: "occupations",
|
||||||
|
description: "Search occupation types (for building filters)",
|
||||||
|
options: {
|
||||||
|
"search-string": option(z.string().optional(), {
|
||||||
|
description: "Search term for occupation, e.g. sygeplejerske",
|
||||||
|
}),
|
||||||
|
"per-page": option(z.coerce.number().int().min(1).default(10), {
|
||||||
|
description: "Max results to return",
|
||||||
|
}),
|
||||||
|
format: option(z.enum(["json", "table", "plain"]).default("json"), {
|
||||||
|
description: "Output format: json, table, plain",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
handler: async ({ flags, signal }) => {
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
if (!flags["search-string"]) {
|
||||||
|
writeError("--search-string is required", "MISSING_REQUIRED")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
searchString: flags["search-string"],
|
||||||
|
pageSize: String(flags["per-page"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawData = await apiFetch<Occupation[]>("/OccupationSearch", params)
|
||||||
|
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
// Client-side filter — API does not enforce pageSize reliably
|
||||||
|
const data = rawData.slice(0, flags["per-page"])
|
||||||
|
|
||||||
|
if (flags.format === "json") {
|
||||||
|
console.log(JSON.stringify(data, null, 2))
|
||||||
|
} else if (flags.format === "table") {
|
||||||
|
outputTable(data)
|
||||||
|
} else {
|
||||||
|
outputPlain(data)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function outputTable(data: Occupation[]): void {
|
||||||
|
console.log("conceptUriDa preferredLabelDa")
|
||||||
|
for (const o of data) {
|
||||||
|
const uri = o.conceptUriDa.substring(0, 70).padEnd(70)
|
||||||
|
const label = o.preferredLabelDa
|
||||||
|
console.log(`${uri} ${label}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputPlain(data: Occupation[]): void {
|
||||||
|
for (const o of data) {
|
||||||
|
console.log(`label: ${o.preferredLabelDa}`)
|
||||||
|
console.log(`uri: ${o.conceptUriDa}`)
|
||||||
|
if (o.aliases.length > 0) {
|
||||||
|
console.log(`aliases: ${o.aliases.map((a) => a.alternativeLabelDa).join(", ")}`)
|
||||||
|
}
|
||||||
|
console.log("")
|
||||||
|
}
|
||||||
|
}
|
||||||
+240
@@ -0,0 +1,240 @@
|
|||||||
|
import { defineCommand, option } from "@bunli/core"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { apiFetch, writeError } from "../helpers.js"
|
||||||
|
|
||||||
|
export interface SearchApiResponse {
|
||||||
|
jobAds: JobAdRaw[]
|
||||||
|
searchFacets: SearchFacetsRaw
|
||||||
|
totalJobAdCount: number
|
||||||
|
searchString: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobAdRaw {
|
||||||
|
jobAdId: string
|
||||||
|
title: string
|
||||||
|
hiringOrgName: string
|
||||||
|
occupation: string | null
|
||||||
|
municipality: string | null
|
||||||
|
postalCode: number | null
|
||||||
|
postalDistrictName: string | null
|
||||||
|
country: string
|
||||||
|
publicationDate: string
|
||||||
|
applicationDeadline: string | null
|
||||||
|
applicationDeadlineStatus: string | null
|
||||||
|
workHourPartTime: boolean
|
||||||
|
isExternal: boolean
|
||||||
|
hasLogo: boolean
|
||||||
|
logoUrl: string | null
|
||||||
|
cvr: string | null
|
||||||
|
workPlaceAddress: string
|
||||||
|
conceptUriDa?: string | null
|
||||||
|
isSeen: boolean
|
||||||
|
isFavorite: boolean
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchFacetsRaw {
|
||||||
|
regions: Array<{ type: string; jobAdCount: number }>
|
||||||
|
workHours: Array<{ type: string; jobAdCount: number }>
|
||||||
|
employmentDurations: Array<{ type: string; jobAdCount: number }>
|
||||||
|
occupationAreas: Array<{ identifier: string; jobAdCount: number }>
|
||||||
|
countries: Array<{ label: string; identifier: string; jobAdCount: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchFlags {
|
||||||
|
"search-string"?: string
|
||||||
|
page: number
|
||||||
|
"per-page": number
|
||||||
|
order: string
|
||||||
|
region?: string
|
||||||
|
"work-hours"?: string
|
||||||
|
duration?: string
|
||||||
|
"job-type"?: string
|
||||||
|
"postal-code"?: string
|
||||||
|
radius: number
|
||||||
|
"occupation-area"?: string
|
||||||
|
"occupation-group"?: string
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSearchParams(flags: SearchFlags): Record<string, string> {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
resultsPerPage: String(flags["per-page"]),
|
||||||
|
pageNumber: String(flags.page),
|
||||||
|
orderType: flags.order,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flags["search-string"]) params["searchString"] = flags["search-string"]
|
||||||
|
if (flags.region) params["regions"] = flags.region
|
||||||
|
if (flags["work-hours"]) params["workHoursType"] = flags["work-hours"]
|
||||||
|
if (flags.duration) params["employmentDurationType"] = flags.duration
|
||||||
|
if (flags["job-type"]) params["jobAnnouncementType"] = flags["job-type"]
|
||||||
|
if (flags["postal-code"]) {
|
||||||
|
params["postalCode"] = flags["postal-code"]
|
||||||
|
params["kmRadius"] = String(flags.radius)
|
||||||
|
}
|
||||||
|
if (flags["occupation-area"]) params["occupationAreas"] = flags["occupation-area"]
|
||||||
|
if (flags["occupation-group"]) params["occupationGroups"] = flags["occupation-group"]
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSearchOutput(data: SearchApiResponse, flags: SearchFlags) {
|
||||||
|
let results = data.jobAds.map((job) => ({
|
||||||
|
jobAdId: job.jobAdId,
|
||||||
|
title: job.title,
|
||||||
|
hiringOrgName: job.hiringOrgName,
|
||||||
|
occupation: job.occupation ?? null,
|
||||||
|
municipality: job.municipality ?? null,
|
||||||
|
postalCode: job.postalCode ?? null,
|
||||||
|
postalDistrictName: job.postalDistrictName ?? null,
|
||||||
|
country: job.country,
|
||||||
|
publicationDate: job.publicationDate,
|
||||||
|
applicationDeadline: job.applicationDeadline ?? null,
|
||||||
|
applicationDeadlineStatus: job.applicationDeadlineStatus ?? null,
|
||||||
|
workHourPartTime: job.workHourPartTime,
|
||||||
|
isExternal: job.isExternal,
|
||||||
|
hasLogo: job.hasLogo,
|
||||||
|
logoUrl: job.logoUrl ?? null,
|
||||||
|
cvr: job.cvr ?? null,
|
||||||
|
workPlaceAddress: job.workPlaceAddress ?? "",
|
||||||
|
isSeen: job.isSeen,
|
||||||
|
isFavorite: job.isFavorite,
|
||||||
|
company: job.hiringOrgName,
|
||||||
|
location: job.postalDistrictName ?? job.municipality ?? null,
|
||||||
|
date: job.publicationDate.slice(0, 10),
|
||||||
|
deadline: job.applicationDeadline && !job.applicationDeadline.startsWith("1900-01-01")
|
||||||
|
? job.applicationDeadline.slice(0, 10)
|
||||||
|
: null,
|
||||||
|
url: `https://jobnet.dk/find-job/${job.jobAdId}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (flags.limit !== undefined) {
|
||||||
|
results = results.slice(0, flags.limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const facets = {
|
||||||
|
regions: data.searchFacets.regions ?? [],
|
||||||
|
workHours: data.searchFacets.workHours ?? [],
|
||||||
|
employmentDurations: data.searchFacets.employmentDurations ?? [],
|
||||||
|
occupationAreas: data.searchFacets.occupationAreas ?? [],
|
||||||
|
countries: data.searchFacets.countries ?? [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
totalJobAdCount: data.totalJobAdCount,
|
||||||
|
pageNumber: flags.page,
|
||||||
|
resultsPerPage: flags["per-page"],
|
||||||
|
searchString: data.searchString ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
|
return { meta, facets, results }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const search = defineCommand({
|
||||||
|
name: "search",
|
||||||
|
description: "Search for job ads with filters",
|
||||||
|
options: {
|
||||||
|
"search-string": option(z.string().optional(), {
|
||||||
|
description: "Free-text keyword search (job title, skills, employer)",
|
||||||
|
}),
|
||||||
|
page: option(z.coerce.number().int().min(1).default(1), {
|
||||||
|
description: "Page number (1-indexed)",
|
||||||
|
}),
|
||||||
|
"per-page": option(z.coerce.number().int().min(1).default(10), {
|
||||||
|
description: "Results per page",
|
||||||
|
}),
|
||||||
|
order: option(z.string().default("PublicationDate"), {
|
||||||
|
description: "Sort order: PublicationDate, BestMatch, ApplicationDate",
|
||||||
|
}),
|
||||||
|
region: option(z.string().optional(), {
|
||||||
|
description: "One region value",
|
||||||
|
}),
|
||||||
|
"work-hours": option(z.string().optional(), {
|
||||||
|
description: "FullTime or PartTime",
|
||||||
|
}),
|
||||||
|
duration: option(z.string().optional(), {
|
||||||
|
description: "Permanent or Temporary",
|
||||||
|
}),
|
||||||
|
"job-type": option(z.string().optional(), {
|
||||||
|
description: "Announcement type: Ordinaert, Efterloenner, Foertidspension",
|
||||||
|
}),
|
||||||
|
"postal-code": option(z.string().optional(), {
|
||||||
|
description: "Postal code for radius search",
|
||||||
|
}),
|
||||||
|
radius: option(z.coerce.number().int().min(1).default(50), {
|
||||||
|
description: "Radius in km from postal code",
|
||||||
|
}),
|
||||||
|
"occupation-area": option(z.string().optional(), {
|
||||||
|
description: "Occupation area identifier, e.g. 10000",
|
||||||
|
}),
|
||||||
|
"occupation-group": option(z.string().optional(), {
|
||||||
|
description: "Occupation group identifier, e.g. 10060",
|
||||||
|
}),
|
||||||
|
limit: option(z.coerce.number().int().min(1).optional(), {
|
||||||
|
description: "Cap total results returned by CLI",
|
||||||
|
}),
|
||||||
|
format: option(z.enum(["json", "table", "plain"]).default("json"), {
|
||||||
|
description: "Output format: json, table, plain",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
handler: async ({ flags, signal }) => {
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
const params = buildSearchParams(flags)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<SearchApiResponse>("/FindJob/Search", params)
|
||||||
|
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
const output = createSearchOutput(data, flags)
|
||||||
|
|
||||||
|
if (flags.format === "json") {
|
||||||
|
console.log(JSON.stringify(output, null, 2))
|
||||||
|
} else if (flags.format === "table") {
|
||||||
|
outputTable(output.results)
|
||||||
|
} else {
|
||||||
|
outputPlain(output.results)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type JobAdResult = {
|
||||||
|
jobAdId: string
|
||||||
|
title: string
|
||||||
|
hiringOrgName: string
|
||||||
|
occupation: string | null
|
||||||
|
municipality: string | null
|
||||||
|
postalCode: number | null
|
||||||
|
publicationDate: string
|
||||||
|
applicationDeadline: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputTable(results: JobAdResult[]): void {
|
||||||
|
console.log("jobAdId title employer municipality")
|
||||||
|
for (const r of results) {
|
||||||
|
const id = r.jobAdId.padEnd(36)
|
||||||
|
const title = (r.title ?? "-").substring(0, 36).padEnd(36)
|
||||||
|
const employer = (r.hiringOrgName ?? "-").substring(0, 26).padEnd(26)
|
||||||
|
const municipality = String(r.municipality ?? "-")
|
||||||
|
console.log(`${id} ${title} ${employer} ${municipality}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputPlain(results: JobAdResult[]): void {
|
||||||
|
for (const r of results) {
|
||||||
|
console.log(`id: ${r.jobAdId}`)
|
||||||
|
console.log(`title: ${r.title}`)
|
||||||
|
console.log(`employer: ${r.hiringOrgName}`)
|
||||||
|
console.log(`occupation: ${r.occupation ?? "-"}`)
|
||||||
|
console.log(`municipality: ${r.municipality ?? "-"}`)
|
||||||
|
console.log(`published: ${r.publicationDate}`)
|
||||||
|
console.log(`deadline: ${r.applicationDeadline ?? "-"}`)
|
||||||
|
console.log("")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { defineCommand, option } from "@bunli/core"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { apiFetch, writeError } from "../helpers.js"
|
||||||
|
|
||||||
|
export const suggestions = defineCommand({
|
||||||
|
name: "suggestions",
|
||||||
|
description: "Typeahead suggestions for job title / keyword search",
|
||||||
|
options: {
|
||||||
|
query: option(z.string().optional(), {
|
||||||
|
description: "Partial search string to complete",
|
||||||
|
}),
|
||||||
|
limit: option(z.coerce.number().int().min(1).optional(), {
|
||||||
|
description: "Cap number of suggestions returned",
|
||||||
|
}),
|
||||||
|
format: option(z.enum(["json", "table", "plain"]).default("json"), {
|
||||||
|
description: "Output format: json, table, plain",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
handler: async ({ flags, signal }) => {
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
if (!flags.query) {
|
||||||
|
writeError("--query is required", "MISSING_REQUIRED")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
query: flags.query,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<string[]>("/FindJob/GetTypeaheadSuggestions", params)
|
||||||
|
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
let results = data
|
||||||
|
if (flags.limit !== undefined) {
|
||||||
|
results = results.slice(0, flags.limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flags.format === "json") {
|
||||||
|
console.log(JSON.stringify(results, null, 2))
|
||||||
|
} else if (flags.format === "table") {
|
||||||
|
outputTable(results)
|
||||||
|
} else {
|
||||||
|
outputPlain(results)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function outputTable(data: string[]): void {
|
||||||
|
console.log("suggestion")
|
||||||
|
for (const s of data) {
|
||||||
|
console.log(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputPlain(data: string[]): void {
|
||||||
|
for (const s of data) {
|
||||||
|
console.log(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
export const BASE_URL = "https://jobnet.dk/bff"
|
||||||
|
export const USER_AGENT = "Mozilla/5.0 (compatible; jobnet-cli/1.0)"
|
||||||
|
|
||||||
|
export async function apiFetch<T>(path: string, params?: Record<string, string>): Promise<T> {
|
||||||
|
let url = `${BASE_URL}${path}`
|
||||||
|
if (params && Object.keys(params).length > 0) {
|
||||||
|
const qs = new URLSearchParams(params)
|
||||||
|
url += `?${qs.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxRetries = 6
|
||||||
|
let delay = 500
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
"x-csrf": "1",
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 429 || response.status >= 500) {
|
||||||
|
if (attempt === maxRetries) {
|
||||||
|
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
// Add jitter to spread out retries: base delay + random 0-500ms
|
||||||
|
const jitter = Math.floor(Math.random() * 500)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay + jitter))
|
||||||
|
delay = Math.min(delay * 2, 5000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>
|
||||||
|
}
|
||||||
|
throw new Error("API request failed after max retries")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeError(error: string, code: string): void {
|
||||||
|
process.stderr.write(JSON.stringify({ error, code }) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripHtml(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/<[^>]*>/g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { runCLI } from "./helpers";
|
||||||
|
|
||||||
|
describe("Jobnet CLI error contract", () => {
|
||||||
|
test("detail without an ID fails with JSON on stderr", async () => {
|
||||||
|
const result = await runCLI(["detail"]);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.stdout).toBe("");
|
||||||
|
expect(JSON.parse(result.stderr)).toEqual({
|
||||||
|
error: "Job ad ID is required",
|
||||||
|
code: "MISSING_REQUIRED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("occupations without a search string fails before making a request", async () => {
|
||||||
|
const result = await runCLI(["occupations"]);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.stdout).toBe("");
|
||||||
|
expect(JSON.parse(result.stderr)).toEqual({
|
||||||
|
error: "--search-string is required",
|
||||||
|
code: "MISSING_REQUIRED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("suggestions without a query fails before making a request", async () => {
|
||||||
|
const result = await runCLI(["suggestions"]);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.stdout).toBe("");
|
||||||
|
expect(JSON.parse(result.stderr)).toEqual({
|
||||||
|
error: "--query is required",
|
||||||
|
code: "MISSING_REQUIRED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an invalid numeric option fails before making a request", async () => {
|
||||||
|
const result = await runCLI(["search", "--page", "not-a-number"]);
|
||||||
|
const error = JSON.parse(result.stderr);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.stdout).toBe("");
|
||||||
|
expect(error.ok).toBe(false);
|
||||||
|
expect(error.error.kind).toBe("validation");
|
||||||
|
expect(error.error.option).toBe("page");
|
||||||
|
expect(error.error.message).toContain("Expected number");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { runCLI } from "./helpers";
|
||||||
|
|
||||||
|
// All cases fail schema validation (or the required-flag guard) before any
|
||||||
|
// network request, so the suite is network-free. Regression context: a bare
|
||||||
|
// z.coerce.number() accepted --limit=-1 / --per-page=-1, and slice(0, -1)
|
||||||
|
// then silently dropped the last result instead of erroring. The --radius
|
||||||
|
// filter flag also accepted negative and fractional values that were sent
|
||||||
|
// raw to the portal.
|
||||||
|
|
||||||
|
function expectValidationError(result: { exitCode: number; stdout: string; stderr: string }, option: string) {
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.stdout).toBe("");
|
||||||
|
const error = JSON.parse(result.stderr);
|
||||||
|
expect(error.ok).toBe(false);
|
||||||
|
expect(error.error.kind).toBe("validation");
|
||||||
|
expect(error.error.option).toBe(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Jobnet CLI flag validation", () => {
|
||||||
|
test("search --limit=-1 is rejected instead of silently dropping the last result", async () => {
|
||||||
|
const result = await runCLI(["search", "--limit=-1"]);
|
||||||
|
expectValidationError(result, "limit");
|
||||||
|
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search --page=0 is rejected on the 1-indexed API", async () => {
|
||||||
|
const result = await runCLI(["search", "--page=0"]);
|
||||||
|
expectValidationError(result, "page");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search --per-page=-5 is rejected", async () => {
|
||||||
|
const result = await runCLI(["search", "--per-page=-5"]);
|
||||||
|
expectValidationError(result, "per-page");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search --limit=1.5 is rejected as non-integer", async () => {
|
||||||
|
const result = await runCLI(["search", "--limit=1.5"]);
|
||||||
|
expectValidationError(result, "limit");
|
||||||
|
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search --radius=-10 is rejected", async () => {
|
||||||
|
const result = await runCLI(["search", "--radius=-10"]);
|
||||||
|
expectValidationError(result, "radius");
|
||||||
|
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search --radius=2.5 is rejected as non-integer", async () => {
|
||||||
|
const result = await runCLI(["search", "--radius=2.5"]);
|
||||||
|
expectValidationError(result, "radius");
|
||||||
|
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("occupations --per-page=-1 is rejected", async () => {
|
||||||
|
const result = await runCLI(["occupations", "--per-page=-1"]);
|
||||||
|
expectValidationError(result, "per-page");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("suggestions --limit=-1 is rejected", async () => {
|
||||||
|
const result = await runCLI(["suggestions", "--limit=-1"]);
|
||||||
|
expectValidationError(result, "limit");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("valid numeric flags pass schema validation (proven offline via the required-flag guard)", async () => {
|
||||||
|
const result = await runCLI(["occupations", "--per-page=5"]);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(JSON.parse(result.stderr)).toEqual({
|
||||||
|
error: "--search-string is required",
|
||||||
|
code: "MISSING_REQUIRED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("unknown flag rejection", () => {
|
||||||
|
// add-portal.md's contract: "a bogus flag or missing required arg exits 1
|
||||||
|
// with a JSON error on stderr". A silently discarded flag is worse than an
|
||||||
|
// error: on jobdanmark a wrong flag name returned the entire database
|
||||||
|
// (13,862 results) as if it matched the query (review finding F13,
|
||||||
|
// 2026-08-19). Rejection happens before dispatch, so these are network-free.
|
||||||
|
test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => {
|
||||||
|
const result = await runCLI(["search", "--search-string", "test", "--bogus-flag", "xyz"]);
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.stdout).toBe("");
|
||||||
|
const error = JSON.parse(result.stderr);
|
||||||
|
expect(error.code).toBe("UNKNOWN_FLAG");
|
||||||
|
expect(error.error).toContain("--bogus-flag");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("--query (another portal's free-text flag) is rejected, not treated as no filter", async () => {
|
||||||
|
const result = await runCLI(["search", "--query", "test"]);
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(JSON.parse(result.stderr).code).toBe("UNKNOWN_FLAG");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { formatDetailPlain, prepareDetail, type DetailApiResponse } from "../src/commands/detail";
|
||||||
|
|
||||||
|
function detail(overrides: Partial<DetailApiResponse> = {}): DetailApiResponse {
|
||||||
|
return {
|
||||||
|
id: "job-1",
|
||||||
|
title: "Data Engineer",
|
||||||
|
body: "<p>Build ETL & analytics flows.</p><p>Use SQL daily.</p>",
|
||||||
|
publicationDateTime: "2026-07-01T09:00:00+02:00",
|
||||||
|
unpublicationDateTime: "2026-08-01T23:59:00+02:00",
|
||||||
|
approvalStatus: "Godkendt",
|
||||||
|
views: 10,
|
||||||
|
createdDateTime: "2026-07-01T08:00:00+02:00",
|
||||||
|
updatedDateTime: "2026-07-01T08:30:00+02:00",
|
||||||
|
isAnonymousEmployer: false,
|
||||||
|
hasLogo: true,
|
||||||
|
logoUrl: "/logo/job-1",
|
||||||
|
employer: {
|
||||||
|
cvrNumber: "12345678",
|
||||||
|
pNumber: "87654321",
|
||||||
|
name: "Acme",
|
||||||
|
hasCompanyLogo: true,
|
||||||
|
},
|
||||||
|
job: {
|
||||||
|
type: "FullTime",
|
||||||
|
address: {
|
||||||
|
streetName: "Examplevej 1",
|
||||||
|
city: "København",
|
||||||
|
postalCode: "2100",
|
||||||
|
municipality: "København",
|
||||||
|
countryCode: "DK",
|
||||||
|
countryName: "Danmark",
|
||||||
|
},
|
||||||
|
noFixedWorkplace: false,
|
||||||
|
isLimitedPeriod: false,
|
||||||
|
isDisabilityFriendly: false,
|
||||||
|
isPartTime: false,
|
||||||
|
employmentDate: null,
|
||||||
|
conceptUriDa: null,
|
||||||
|
preferredLabelDa: null,
|
||||||
|
driversLicenses: [],
|
||||||
|
classifications: [],
|
||||||
|
shifts: [],
|
||||||
|
isFavorite: false,
|
||||||
|
},
|
||||||
|
application: {
|
||||||
|
deadlineDate: "2026-08-01T23:59:00+02:00",
|
||||||
|
availablePositions: 2,
|
||||||
|
contactPersons: [],
|
||||||
|
url: "https://example.test/apply",
|
||||||
|
urlText: "Apply",
|
||||||
|
isApplicationDeadlineASAP: false,
|
||||||
|
},
|
||||||
|
organisationTypeId: null,
|
||||||
|
user: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("formatDetailPlain", () => {
|
||||||
|
test("renders the plain view with cleaned body text and apply URL", () => {
|
||||||
|
const formatted = formatDetailPlain(detail());
|
||||||
|
|
||||||
|
expect(formatted).toContain("Title: Data Engineer");
|
||||||
|
expect(formatted).toContain("Employer: Acme");
|
||||||
|
expect(formatted).toContain("Location: København, Danmark");
|
||||||
|
expect(formatted).toContain("Apply: https://example.test/apply");
|
||||||
|
expect(formatted).toContain("Build ETL & analytics flows. Use SQL daily.");
|
||||||
|
expect(formatted).not.toContain("<p>");
|
||||||
|
expect(formatted).not.toContain("&");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses placeholders when optional city, deadline, and apply URL are absent", () => {
|
||||||
|
const formatted = formatDetailPlain(
|
||||||
|
detail({
|
||||||
|
job: {
|
||||||
|
...detail().job,
|
||||||
|
address: {
|
||||||
|
...detail().job.address,
|
||||||
|
city: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
application: {
|
||||||
|
...detail().application,
|
||||||
|
deadlineDate: null,
|
||||||
|
url: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(formatted).toContain("Location: -, Danmark");
|
||||||
|
expect(formatted).toContain("Deadline: -");
|
||||||
|
expect(formatted).not.toContain("Apply:");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("prepareDetail deadline sentinel", () => {
|
||||||
|
// The API's "deadline not disclosed" sentinel is 1900-01-01 (paired with
|
||||||
|
// isApplicationDeadlineASAP / applicationDeadlineStatus). search maps it to
|
||||||
|
// null and has a test pinning that; detail dumped the raw response, so an
|
||||||
|
// undisclosed deadline read as 126 years expired and /rank's sweep would
|
||||||
|
// retire the job instantly (review finding F33, 2026-08-19).
|
||||||
|
test("maps the 1900-01-01 undisclosed sentinel to null", () => {
|
||||||
|
const data = detail();
|
||||||
|
data.application.deadlineDate = "1900-01-01T00:00:00+01:00";
|
||||||
|
expect(prepareDetail(data).application.deadlineDate).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps a real deadline unchanged", () => {
|
||||||
|
const data = detail();
|
||||||
|
data.application.deadlineDate = "2026-09-01T00:00:00+02:00";
|
||||||
|
expect(prepareDetail(data).application.deadlineDate).toBe("2026-09-01T00:00:00+02:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps a null deadline null", () => {
|
||||||
|
const data = detail();
|
||||||
|
data.application.deadlineDate = null;
|
||||||
|
expect(prepareDetail(data).application.deadlineDate).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
const CLI_PATH = join(import.meta.dir, "../src/cli.ts");
|
||||||
|
|
||||||
|
export interface CLIResult {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
exitCode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCLI(args: string[]): Promise<CLIResult> {
|
||||||
|
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [stdout, stderr, exitCode] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
proc.exited,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseJSON<T = unknown>(result: CLIResult): T {
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`CLI exited with code ${result.exitCode}. stderr: ${result.stderr}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(result.stdout) as T;
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import { apiFetch } from "../src/helpers";
|
||||||
|
|
||||||
|
// A stalled upstream connection (accepted socket, no response) would otherwise
|
||||||
|
// hang the CLI forever - fetch has no default timeout. Assert every request
|
||||||
|
// carries an AbortSignal timeout. This fails on the pre-fix code (no signal).
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("apiFetch request timeout", () => {
|
||||||
|
test("passes an AbortSignal timeout to fetch", async () => {
|
||||||
|
let init: RequestInit | undefined;
|
||||||
|
globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => {
|
||||||
|
init = i;
|
||||||
|
return new Response("{}", { status: 200 });
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
await apiFetch("/search");
|
||||||
|
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import { apiFetch } from "../src/helpers";
|
||||||
|
|
||||||
|
// The portal contract requires backoff on 429/5xx. These tests pin the retry
|
||||||
|
// loop offline: a stubbed fetch counts attempts, and a stubbed setTimeout
|
||||||
|
// fires immediately so the exhaustion case does not sleep through the real
|
||||||
|
// 500ms -> 5s backoff schedule.
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalSetTimeout = globalThis.setTimeout;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
globalThis.setTimeout = originalSetTimeout;
|
||||||
|
});
|
||||||
|
|
||||||
|
function instantTimers() {
|
||||||
|
globalThis.setTimeout = ((fn: () => void) =>
|
||||||
|
originalSetTimeout(fn, 0)) as unknown as typeof setTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubFetch(responses: Array<() => Response>): { calls: number } {
|
||||||
|
const state = { calls: 0 };
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
const i = Math.min(state.calls, responses.length - 1);
|
||||||
|
state.calls++;
|
||||||
|
return responses[i]();
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("apiFetch retry/backoff", () => {
|
||||||
|
test("retries a 429 and succeeds on the next attempt", async () => {
|
||||||
|
instantTimers();
|
||||||
|
const state = stubFetch([
|
||||||
|
() => new Response("", { status: 429 }),
|
||||||
|
() => new Response('{"ok":true}', { status: 200 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data = await apiFetch<{ ok: boolean }>("/x");
|
||||||
|
expect(data.ok).toBe(true);
|
||||||
|
expect(state.calls).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not retry a plain 4xx", async () => {
|
||||||
|
const state = stubFetch([() => new Response("", { status: 400 })]);
|
||||||
|
|
||||||
|
await expect(apiFetch("/x")).rejects.toThrow(/400/);
|
||||||
|
expect(state.calls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gives up after the initial attempt plus six retries on persistent 5xx", async () => {
|
||||||
|
instantTimers();
|
||||||
|
const state = stubFetch([() => new Response("", { status: 500 })]);
|
||||||
|
|
||||||
|
await expect(apiFetch("/x")).rejects.toThrow(/500/);
|
||||||
|
expect(state.calls).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
buildSearchParams,
|
||||||
|
createSearchOutput,
|
||||||
|
type SearchApiResponse,
|
||||||
|
type SearchFlags,
|
||||||
|
} from "../src/commands/search";
|
||||||
|
|
||||||
|
const flags: SearchFlags = {
|
||||||
|
"search-string": "data engineer",
|
||||||
|
page: 2,
|
||||||
|
"per-page": 25,
|
||||||
|
order: "BestMatch",
|
||||||
|
region: "HovedstadenOgBornholm",
|
||||||
|
"work-hours": "FullTime",
|
||||||
|
duration: "Permanent",
|
||||||
|
"job-type": "Ordinaert",
|
||||||
|
"postal-code": "2100",
|
||||||
|
radius: 25,
|
||||||
|
"occupation-area": "10000",
|
||||||
|
"occupation-group": "10060",
|
||||||
|
limit: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
function apiResponse(): SearchApiResponse {
|
||||||
|
return {
|
||||||
|
totalJobAdCount: 2,
|
||||||
|
searchString: "data engineer",
|
||||||
|
searchFacets: {
|
||||||
|
regions: [{ type: "HovedstadenOgBornholm", jobAdCount: 2 }],
|
||||||
|
workHours: [{ type: "FullTime", jobAdCount: 2 }],
|
||||||
|
employmentDurations: [{ type: "Permanent", jobAdCount: 1 }],
|
||||||
|
occupationAreas: [{ identifier: "10000", jobAdCount: 2 }],
|
||||||
|
countries: [{ label: "Danmark", identifier: "DK", jobAdCount: 2 }],
|
||||||
|
},
|
||||||
|
jobAds: [
|
||||||
|
{
|
||||||
|
jobAdId: "job-1",
|
||||||
|
title: "Data Engineer",
|
||||||
|
hiringOrgName: "Acme",
|
||||||
|
occupation: null,
|
||||||
|
municipality: null,
|
||||||
|
postalCode: null,
|
||||||
|
postalDistrictName: null,
|
||||||
|
country: "Danmark",
|
||||||
|
publicationDate: "2026-07-01T00:00:00+02:00",
|
||||||
|
applicationDeadline: null,
|
||||||
|
applicationDeadlineStatus: null,
|
||||||
|
workHourPartTime: false,
|
||||||
|
isExternal: false,
|
||||||
|
hasLogo: false,
|
||||||
|
logoUrl: null,
|
||||||
|
cvr: null,
|
||||||
|
workPlaceAddress: "",
|
||||||
|
conceptUriDa: "http://example.test/occupation",
|
||||||
|
isSeen: false,
|
||||||
|
isFavorite: false,
|
||||||
|
description: "<p>Search results should not include this bulky HTML.</p>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jobAdId: "job-2",
|
||||||
|
title: "Analytics Engineer",
|
||||||
|
hiringOrgName: "Example Co",
|
||||||
|
occupation: "Softwareudvikler",
|
||||||
|
municipality: "København",
|
||||||
|
postalCode: 2100,
|
||||||
|
postalDistrictName: "København Ø",
|
||||||
|
country: "Danmark",
|
||||||
|
publicationDate: "2026-07-02T00:00:00+02:00",
|
||||||
|
applicationDeadline: "2026-08-01T23:59:00+02:00",
|
||||||
|
applicationDeadlineStatus: "ExpirationDate",
|
||||||
|
workHourPartTime: false,
|
||||||
|
isExternal: true,
|
||||||
|
hasLogo: true,
|
||||||
|
logoUrl: "/logo/job-2",
|
||||||
|
cvr: "12345678",
|
||||||
|
workPlaceAddress: "Examplevej 1",
|
||||||
|
isSeen: false,
|
||||||
|
isFavorite: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Jobnet search normalization", () => {
|
||||||
|
test("builds the API query with required paging and optional filters", () => {
|
||||||
|
expect(buildSearchParams(flags)).toEqual({
|
||||||
|
resultsPerPage: "25",
|
||||||
|
pageNumber: "2",
|
||||||
|
orderType: "BestMatch",
|
||||||
|
searchString: "data engineer",
|
||||||
|
regions: "HovedstadenOgBornholm",
|
||||||
|
workHoursType: "FullTime",
|
||||||
|
employmentDurationType: "Permanent",
|
||||||
|
jobAnnouncementType: "Ordinaert",
|
||||||
|
postalCode: "2100",
|
||||||
|
kmRadius: "25",
|
||||||
|
occupationAreas: "10000",
|
||||||
|
occupationGroups: "10060",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omits radius when postal-code is absent", () => {
|
||||||
|
const params = buildSearchParams({ ...flags, "postal-code": undefined });
|
||||||
|
expect(params.postalCode).toBeUndefined();
|
||||||
|
expect(params.kmRadius).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates the documented output envelope and omits bulky descriptions", () => {
|
||||||
|
const output = createSearchOutput(apiResponse(), flags);
|
||||||
|
|
||||||
|
expect(output.meta).toEqual({
|
||||||
|
totalJobAdCount: 2,
|
||||||
|
pageNumber: 2,
|
||||||
|
resultsPerPage: 25,
|
||||||
|
searchString: "data engineer",
|
||||||
|
});
|
||||||
|
expect(output.facets.regions).toEqual([{ type: "HovedstadenOgBornholm", jobAdCount: 2 }]);
|
||||||
|
expect(output.results).toHaveLength(1);
|
||||||
|
expect(output.results[0]).toMatchObject({
|
||||||
|
jobAdId: "job-1",
|
||||||
|
occupation: null,
|
||||||
|
municipality: null,
|
||||||
|
postalCode: null,
|
||||||
|
applicationDeadline: null,
|
||||||
|
workPlaceAddress: "",
|
||||||
|
});
|
||||||
|
expect("description" in output.results[0]).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("additively emits the /scrape contract fields (company, location, date, deadline, url)", () => {
|
||||||
|
const output = createSearchOutput(apiResponse(), { ...flags, limit: undefined });
|
||||||
|
|
||||||
|
expect(output.results).toHaveLength(2);
|
||||||
|
expect(output.results[0]).toMatchObject({
|
||||||
|
company: "Acme",
|
||||||
|
location: null,
|
||||||
|
date: "2026-07-01",
|
||||||
|
deadline: null,
|
||||||
|
url: "https://jobnet.dk/find-job/job-1",
|
||||||
|
});
|
||||||
|
expect(output.results[1]).toMatchObject({
|
||||||
|
company: "Example Co",
|
||||||
|
location: "København Ø",
|
||||||
|
date: "2026-07-02",
|
||||||
|
deadline: "2026-08-01",
|
||||||
|
url: "https://jobnet.dk/find-job/job-2",
|
||||||
|
});
|
||||||
|
expect(output.results[0].hiringOrgName).toBe("Acme");
|
||||||
|
expect(output.results[1].applicationDeadline).toBe("2026-08-01T23:59:00+02:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps Jobnet's undisclosed-deadline sentinel (1900-01-01) to null", () => {
|
||||||
|
const response = apiResponse();
|
||||||
|
response.jobAds[0].applicationDeadline = "1900-01-01T00:00:00+01:00";
|
||||||
|
response.jobAds[0].applicationDeadlineStatus = "NotDisclosed";
|
||||||
|
|
||||||
|
const output = createSearchOutput(response, { ...flags, limit: undefined });
|
||||||
|
|
||||||
|
expect(output.results[0].deadline).toBeNull();
|
||||||
|
expect(output.results[1].deadline).toBe("2026-08-01");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import { apiFetch, USER_AGENT } from "../src/helpers";
|
||||||
|
|
||||||
|
// Bun's fetch injects an anonymous default User-Agent (Bun/1.3.10) when code
|
||||||
|
// sets none. This CLI should say who is asking, in the honest style jobindex
|
||||||
|
// already uses on htmlFetch ("Mozilla/5.0 (compatible; jobindex-cli/1.0)").
|
||||||
|
// Assert the header is present on every request. Fails on the pre-change code.
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("apiFetch user agent", () => {
|
||||||
|
test("sends a User-Agent header", async () => {
|
||||||
|
let init: RequestInit | undefined;
|
||||||
|
globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => {
|
||||||
|
init = i;
|
||||||
|
return new Response("{}", { status: 200 });
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
await apiFetch("/search");
|
||||||
|
const headers = init?.headers as Record<string, string> | Headers | undefined;
|
||||||
|
const value =
|
||||||
|
headers instanceof Headers ? headers.get("User-Agent") : headers?.["User-Agent"];
|
||||||
|
expect(value).toBe(USER_AGENT);
|
||||||
|
});
|
||||||
|
});
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"types": ["bun-types"],
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "tests/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Executable
+98
@@ -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
@@ -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" }
|
||||||
|
}
|
||||||
Executable
+86
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||||
|
}
|
||||||
Executable
+103
@@ -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`.
|
||||||
Executable
+61
@@ -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
@@ -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
@@ -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)
|
||||||
|
})
|
||||||
@@ -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
Reference in new issue
Block a user