diff --git a/.agents/skills/freehire-search/SKILL.md b/.agents/skills/freehire-search/SKILL.md new file mode 100755 index 0000000..aa98c04 --- /dev/null +++ b/.agents/skills/freehire-search/SKILL.md @@ -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 jobs in ", 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 ""] [facet flags] +``` + +Key flags: +- `--query ` / `-q ` — keyword search (title, skill, role). Full-text; optional. +- `--jobage ` — posted within N days (maps to `posted_within_days`). +- `--page ` — 1-indexed page. Default 1. +- `--limit ` / `-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 ` — 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 ` — ISO-3166 alpha-2, e.g. `--country DE,GB` +- `--city ` — city name(s), e.g. `--city Berlin` +- `--seniority ` — `junior`, `middle`, `senior`, `staff`, `principal`, `lead`, … +- `--category ` — `backend`, `frontend`, `fullstack`, `devops`, `ml_ai`, `qa`, … +- `--skill ` — canonical skill(s), e.g. `--skill go,kubernetes` +- `--company ` — company slug (from a result's `company_slug`) +- `--remote ` — `remote` | `hybrid` | `onsite` (`work_mode` facet) +- `--facet ` — 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=` +> to scope it) — never invent facet values. + +### Fetch full job detail + +```bash +bun run .agents/skills/freehire-search/cli/src/cli.ts detail [--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/` 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. diff --git a/.agents/skills/freehire-search/cli/README.md b/.agents/skills/freehire-search/cli/README.md new file mode 100755 index 0000000..04047d5 --- /dev/null +++ b/.agents/skills/freehire-search/cli/README.md @@ -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`. diff --git a/.agents/skills/freehire-search/cli/package.json b/.agents/skills/freehire-search/cli/package.json new file mode 100755 index 0000000..5465410 --- /dev/null +++ b/.agents/skills/freehire-search/cli/package.json @@ -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" + } +} diff --git a/.agents/skills/freehire-search/cli/src/cli.ts b/.agents/skills/freehire-search/cli/src/cli.ts new file mode 100755 index 0000000..4766f58 --- /dev/null +++ b/.agents/skills/freehire-search/cli/src/cli.ts @@ -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 = { 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 ""] [facet flags] [--format json|table|plain] + bun run src/cli.ts detail [--format json|plain] + +SEARCH FLAGS + --query, -q Keywords (title, skill, role). Full-text; optional. + --jobage Posted within N days (maps to posted_within_days). + --page 1-indexed page. Default 1. + --limit, -n Results per page (API limit). Default 25. + --format 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 Macro-region: global, eu, us, apac, latam, cis, ... e.g. --region eu,us + --country ISO-3166 alpha-2, e.g. --country DE,GB + --city City name(s), e.g. --city Berlin + --seniority junior, middle, senior, staff, principal, lead, ... + --category backend, frontend, fullstack, devops, ml_ai, qa, ... + --skill Canonical skill(s), e.g. --skill go,kubernetes + --company Company slug (from a result's company_slug). + --remote remote | hybrid | onsite (work_mode facet). + --facet Any other facet param (repeatable), e.g. --facet salary_min=100000 + +DETAIL + A freehire public slug (from a search result's id/slug) + or a full https://freehire.me/jobs/ 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> = { + 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 { + 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 = {} + 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 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 ", 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) + }) diff --git a/.agents/skills/freehire-search/cli/src/commands/detail.ts b/.agents/skills/freehire-search/cli/src/commands/detail.ts new file mode 100755 index 0000000..405e0f9 --- /dev/null +++ b/.agents/skills/freehire-search/cli/src/commands/detail.ts @@ -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/ 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 { + 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(`/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 + } +} diff --git a/.agents/skills/freehire-search/cli/src/commands/search.ts b/.agents/skills/freehire-search/cli/src/commands/search.ts new file mode 100755 index 0000000..f2c928c --- /dev/null +++ b/.agents/skills/freehire-search/cli/src/commands/search.ts @@ -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 +} + +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 { + try { + const env = await apiGet(`${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 + } +} diff --git a/.agents/skills/freehire-search/cli/src/helpers.ts b/.agents/skills/freehire-search/cli/src/helpers.ts new file mode 100755 index 0000000..2d95056 --- /dev/null +++ b/.agents/skills/freehire-search/cli/src/helpers.ts @@ -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 { + 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(path: string): Promise | 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 | 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 { + 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 ` 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/ 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 +} diff --git a/.agents/skills/freehire-search/cli/tests/cli-flag-validation.test.ts b/.agents/skills/freehire-search/cli/tests/cli-flag-validation.test.ts new file mode 100755 index 0000000..f29637b --- /dev/null +++ b/.agents/skills/freehire-search/cli/tests/cli-flag-validation.test.ts @@ -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"); + }); +}); diff --git a/.agents/skills/freehire-search/cli/tests/commands.test.ts b/.agents/skills/freehire-search/cli/tests/commands.test.ts new file mode 100755 index 0000000..6edada6 --- /dev/null +++ b/.agents/skills/freehire-search/cli/tests/commands.test.ts @@ -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 { + 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: "

Build things

", + 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, +}; + +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"); + }); +}); diff --git a/.agents/skills/freehire-search/cli/tests/helpers.ts b/.agents/skills/freehire-search/cli/tests/helpers.ts new file mode 100755 index 0000000..ff2b9a0 --- /dev/null +++ b/.agents/skills/freehire-search/cli/tests/helpers.ts @@ -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 = {}): Promise { + 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(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}` + ); + } +} diff --git a/.agents/skills/freehire-search/cli/tests/parsing.test.ts b/.agents/skills/freehire-search/cli/tests/parsing.test.ts new file mode 100755 index 0000000..2c6910f --- /dev/null +++ b/.agents/skills/freehire-search/cli/tests/parsing.test.ts @@ -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 { + 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: "
  • Build APIs
  • Ship & iterate
", + 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("

One

Two

")).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/ 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(); + }); +}); diff --git a/.agents/skills/freehire-search/cli/tests/request-timeout.test.ts b/.agents/skills/freehire-search/cli/tests/request-timeout.test.ts new file mode 100755 index 0000000..a0baebc --- /dev/null +++ b/.agents/skills/freehire-search/cli/tests/request-timeout.test.ts @@ -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); + }); +}); diff --git a/.agents/skills/freehire-search/cli/tests/retry-backoff.test.ts b/.agents/skills/freehire-search/cli/tests/retry-backoff.test.ts new file mode 100755 index 0000000..fad5654 --- /dev/null +++ b/.agents/skills/freehire-search/cli/tests/retry-backoff.test.ts @@ -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("/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); + }); +}); diff --git a/.agents/skills/freehire-search/cli/tsconfig.json b/.agents/skills/freehire-search/cli/tsconfig.json new file mode 100755 index 0000000..9e46917 --- /dev/null +++ b/.agents/skills/freehire-search/cli/tsconfig.json @@ -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"] +} diff --git a/.agents/skills/freehire-search/url-reference.md b/.agents/skills/freehire-search/url-reference.md new file mode 100755 index 0000000..e5c1d6a --- /dev/null +++ b/.agents/skills/freehire-search/url-reference.md @@ -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": "" }` 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 + "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 `{ : { : } }`. This skill +does not call it programmatically, but it is the vocabulary source the SKILL.md +points users to (`?q=` 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. diff --git a/.agents/skills/jobbank-search/SKILL.md b/.agents/skills/jobbank-search/SKILL.md new file mode 100755 index 0000000..f747c2d --- /dev/null +++ b/.agents/skills/jobbank-search/SKILL.md @@ -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 ` — keyword search (title, company, keyword) +- `--exclude ` — exclude keywords from results +- `--type ` — job type: `3`=Fuldtidsjob, `6`=Graduate/trainee, `13`=Deltidsjob, `8`=Vikariat, `12`=Ph.d. & Postdoc, `11`=Freelance, `9`=Praktikplads, `4`=Studiejob (repeatable) +- `--location ` — region: `2`=Storkøbenhavn, `8`=Østjylland (Aarhus), `7`=Midtjylland, `6`=Nordjylland, `13`=Fyn (repeatable) +- `--work-area ` — function: `31`=IT-Software, `43`=Data & Analyse, `26`=Ledelse, `29`=Marketing (repeatable) +- `--industry ` — sector: `10331`=IT & Tele, `10442`=Forskning & Uddannelse, `10358`=Finans (repeatable) +- `--education ` — education field: `24`=IT, `21`=Økonomi & Revision, `34`=Samfundsvidenskab (repeatable) +- `--remote ` — `helt` (fully remote) or `delvist` (partially remote) +- `--suitable-for ` — `2`=Nyuddannede, `4`=International baggrund, `5`=Erfarne +- `--company ` — filter by company ID +- `--since ` — jobs posted on or after this date +- `--limit ` — 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 [--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 ` 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`. diff --git a/.agents/skills/jobbank-search/cli/README.md b/.agents/skills/jobbank-search/cli/README.md new file mode 100755 index 0000000..e504758 --- /dev/null +++ b/.agents/skills/jobbank-search/cli/README.md @@ -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 `` as follows: + +- **id**: extracted from the URL path — `/job/{id}/{company-slug}/{title-slug}` — the first numeric segment after `/job/` +- **title**: from `` (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). diff --git a/.agents/skills/jobbank-search/cli/package.json b/.agents/skills/jobbank-search/cli/package.json new file mode 100755 index 0000000..6c1128a --- /dev/null +++ b/.agents/skills/jobbank-search/cli/package.json @@ -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" + } +} diff --git a/.agents/skills/jobbank-search/cli/src/cli.ts b/.agents/skills/jobbank-search/cli/src/cli.ts new file mode 100755 index 0000000..da7ab5d --- /dev/null +++ b/.agents/skills/jobbank-search/cli/src/cli.ts @@ -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() diff --git a/.agents/skills/jobbank-search/cli/src/commands/detail.ts b/.agents/skills/jobbank-search/cli/src/commands/detail.ts new file mode 100755 index 0000000..5da2d6e --- /dev/null +++ b/.agents/skills/jobbank-search/cli/src/commands/detail.ts @@ -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) +} diff --git a/.agents/skills/jobbank-search/cli/src/commands/search.ts b/.agents/skills/jobbank-search/cli/src/commands/search.ts new file mode 100755 index 0000000..9f4664a --- /dev/null +++ b/.agents/skills/jobbank-search/cli/src/commands/search.ts @@ -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("") + } +} diff --git a/.agents/skills/jobbank-search/cli/src/helpers.ts b/.agents/skills/jobbank-search/cli/src/helpers.ts new file mode 100755 index 0000000..d2a5089 --- /dev/null +++ b/.agents/skills/jobbank-search/cli/src/helpers.ts @@ -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 +} diff --git a/.agents/skills/jobbank-search/cli/tests/cli-contract.test.ts b/.agents/skills/jobbank-search/cli/tests/cli-contract.test.ts new file mode 100755 index 0000000..5ede28f --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tests/cli-contract.test.ts @@ -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", + }); + }); +}); diff --git a/.agents/skills/jobbank-search/cli/tests/cli-flag-validation.test.ts b/.agents/skills/jobbank-search/cli/tests/cli-flag-validation.test.ts new file mode 100755 index 0000000..2bc133f --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tests/cli-flag-validation.test.ts @@ -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"); + }); +}); diff --git a/.agents/skills/jobbank-search/cli/tests/detail-jsonld.test.ts b/.agents/skills/jobbank-search/cli/tests/detail-jsonld.test.ts new file mode 100755 index 0000000..34a1f11 --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tests/detail-jsonld.test.ts @@ -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() + }) +}) diff --git a/.agents/skills/jobbank-search/cli/tests/helpers.ts b/.agents/skills/jobbank-search/cli/tests/helpers.ts new file mode 100755 index 0000000..75b82b9 --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tests/helpers.ts @@ -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}` + ); + } +} diff --git a/.agents/skills/jobbank-search/cli/tests/request-timeout.test.ts b/.agents/skills/jobbank-search/cli/tests/request-timeout.test.ts new file mode 100755 index 0000000..f60917c --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tests/request-timeout.test.ts @@ -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); + }); +}); diff --git a/.agents/skills/jobbank-search/cli/tests/retry-backoff.test.ts b/.agents/skills/jobbank-search/cli/tests/retry-backoff.test.ts new file mode 100755 index 0000000..8af008d --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tests/retry-backoff.test.ts @@ -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); + }); +}); diff --git a/.agents/skills/jobbank-search/cli/tests/rss-fetch.test.ts b/.agents/skills/jobbank-search/cli/tests/rss-fetch.test.ts new file mode 100755 index 0000000..e11d402 --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tests/rss-fetch.test.ts @@ -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...", { + status: 403, + statusText: "Forbidden", + })) as unknown as typeof fetch; + + await expect(rssFetch({ key: "data" })).rejects.toThrow(/Cloudflare bot protection/); + }); +}); diff --git a/.agents/skills/jobbank-search/cli/tests/rss-parsing.test.ts b/.agents/skills/jobbank-search/cli/tests/rss-parsing.test.ts new file mode 100755 index 0000000..dc70a8e --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tests/rss-parsing.test.ts @@ -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(` + + <![CDATA[Data Scientist]]> + + https://jobbank.dk/job/12345/acme/data-scientist + Mon, 13 Jul 2026 08:00:00 GMT + `); + }) 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(""); + }); +}); diff --git a/.agents/skills/jobbank-search/cli/tests/search-normalization.test.ts b/.agents/skills/jobbank-search/cli/tests/search-normalization.test.ts new file mode 100755 index 0000000..d41f42c --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tests/search-normalization.test.ts @@ -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"); + }); +}); \ No newline at end of file diff --git a/.agents/skills/jobbank-search/cli/tsconfig.json b/.agents/skills/jobbank-search/cli/tsconfig.json new file mode 100755 index 0000000..52e8a46 --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tsconfig.json @@ -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"] +} diff --git a/.agents/skills/jobbank-search/url-reference.md b/.agents/skills/jobbank-search/url-reference.md new file mode 100755 index 0000000..8f5cd6e --- /dev/null +++ b/.agents/skills/jobbank-search/url-reference.md @@ -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 diff --git a/.agents/skills/jobdanmark-search/SKILL.md b/.agents/skills/jobdanmark-search/SKILL.md new file mode 100755 index 0000000..fec0025 --- /dev/null +++ b/.agents/skills/jobdanmark-search/SKILL.md @@ -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 ` — free-text search, e.g. `elektriker`, `sygeplejerske`, `softwareudvikler` +- `--category ` — category ID (see Categories table below) +- `--jobtitle-id ` — job title ID from `autocomplete` results +- `--municipality ` — e.g. `Odense`, `København`, `Aarhus` +- `--zip ` — zip code, e.g. `5000`, `8000`, `2100` +- `--region ` — region name, e.g. `Midtjylland`, `Sjælland` +- `--job-type ` — comma-separated: `fuldtid`, `deltid`, `fleksjob`, `elev`, `studiejob`, `praktik` +- `--page ` — page number (30 items per page, server-enforced) +- `--limit ` — 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 [--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 "" [--limit ] +``` + +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 "" [--limit ] +``` + +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 ` 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`. diff --git a/.agents/skills/jobdanmark-search/cli/README.md b/.agents/skills/jobdanmark-search/cli/README.md new file mode 100755 index 0000000..5504907 --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/README.md @@ -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 ``) + .join("")}`; +} + +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: "

Build reliable pipelines.

", + }; + + 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: "

Build reliable pipelines.

", + 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( + "404 | Jobdanmark", + "missing", + "https://jobdanmark.dk/job/missing", + ), + ).toThrow("NOT_FOUND"); + + expect(() => + parseJobPostingFromHtml( + "Jobdanmark", + "broken", + "https://jobdanmark.dk/job/broken", + ), + ).toThrow("Failed to parse job page HTML"); + }); +}); diff --git a/.agents/skills/jobdanmark-search/cli/tests/detail-parsing.test.ts b/.agents/skills/jobdanmark-search/cli/tests/detail-parsing.test.ts new file mode 100755 index 0000000..8c1fa25 --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/tests/detail-parsing.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { parseJobPostingFromHtml } from "../src/commands/detail"; + +const HTML_WITHOUT_JSON_LD = ` + + + Journalistisk udvikler søges | jobdanmark + + +
+
+ +

Journalistisk udvikler søges

+ JFM + Banegårdspladsen 1, 5000 Odense C +
+

Hvad nu hvis du med få klik kunne undersøge dit lokalområde?

+
    +
  • identificere relevante datasæt og muligheder
  • +
+
+ Ansøg nu +
    +
  • Udgivet: 03-07-2026
  • +
  • Jobtype: Fuldtid
  • +
  • Arbejdssted: Banegårdspladsen 1, 5000 Odense C
  • +
  • Ansøgningsfrist: 02-08-2026 23.59
  • +
+ +`; + +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( + "
  • Ansøgningsfrist: 02-08-2026 23.59
  • ", + "
  • Ansøgningsfrist: Løbende
  • ", + ); + 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( + "Journalistisk udvikler søges | jobdanmark", + "HTTP 404 Page Designer | jobdanmark", + ).replace( + '

    Journalistisk udvikler søges

    ', + '

    HTTP 404 Page Designer

    ', + ); + + 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"); + }); +}); diff --git a/.agents/skills/jobdanmark-search/cli/tests/helpers.ts b/.agents/skills/jobdanmark-search/cli/tests/helpers.ts new file mode 100755 index 0000000..75b82b9 --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/tests/helpers.ts @@ -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 { + 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(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}` + ); + } +} diff --git a/.agents/skills/jobdanmark-search/cli/tests/request-timeout.test.ts b/.agents/skills/jobdanmark-search/cli/tests/request-timeout.test.ts new file mode 100755 index 0000000..6de26a2 --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/tests/request-timeout.test.ts @@ -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); + }); +}); diff --git a/.agents/skills/jobdanmark-search/cli/tests/retry-backoff.test.ts b/.agents/skills/jobdanmark-search/cli/tests/retry-backoff.test.ts new file mode 100755 index 0000000..eac8fe4 --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/tests/retry-backoff.test.ts @@ -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); + }); + }); +} diff --git a/.agents/skills/jobdanmark-search/cli/tests/search-normalization.test.ts b/.agents/skills/jobdanmark-search/cli/tests/search-normalization.test.ts new file mode 100755 index 0000000..f52b538 --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/tests/search-normalization.test.ts @@ -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"); + }); +}); \ No newline at end of file diff --git a/.agents/skills/jobdanmark-search/cli/tests/user-agent.test.ts b/.agents/skills/jobdanmark-search/cli/tests/user-agent.test.ts new file mode 100755 index 0000000..67a105c --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/tests/user-agent.test.ts @@ -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"); + }); +}); diff --git a/.agents/skills/jobdanmark-search/cli/tsconfig.json b/.agents/skills/jobdanmark-search/cli/tsconfig.json new file mode 100755 index 0000000..52e8a46 --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/tsconfig.json @@ -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"] +} diff --git a/.agents/skills/jobindex-search/SKILL.md b/.agents/skills/jobindex-search/SKILL.md new file mode 100755 index 0000000..9a2a158 --- /dev/null +++ b/.agents/skills/jobindex-search/SKILL.md @@ -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 ` / `-q ` — keyword search (job title, skill, company, city). **Required** for meaningful results. +- `--jobage ` — filter by posting age: `1` (today), `7`, `14`, `30`, or `9999` (all, default) +- `--sort ` — `score` (relevance, default) or `date` (newest first) +- `--page ` — page number (1-indexed, 20 results per page, fixed) +- `--limit ` — 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 [--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 ` 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`. diff --git a/.agents/skills/jobindex-search/cli/README.md b/.agents/skills/jobindex-search/cli/README.md new file mode 100755 index 0000000..386ed78 --- /dev/null +++ b/.agents/skills/jobindex-search/cli/README.md @@ -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 `