fix: restore Danish CLI commands/ and tsconfig files dropped by old .gitignore (#53)

The pre-#21 .gitignore's unanchored 'commands/' rule silently excluded
.agents/skills/*/cli/src/commands/ (and the tsconfigs) from the initial
release, so every clone's four Danish portal CLIs failed on import with
'Cannot find module ./commands/search.js'. #21 fixed the rule but the
files were never restored - git history has no trace of them.

Restored from the maintainer's working copies, including the updated
jobindex helpers.ts (Jobindex moved search results from the JSON
endpoint, which now returns 204, into an embedded HTML Stash blob).

Verified: all four CLIs typecheck and return live results with their
documented flags. Surfaced while reviewing #52.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mads Lorentzen
2026-07-07 17:39:47 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent fce2cf23c0
commit f3d4448cca
18 changed files with 2085 additions and 0 deletions
@@ -0,0 +1,164 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { fetchWithUA, writeError, BASE_URL } from "../helpers.js"
import { parse as parseHtml } from "node-html-parser"
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 root = parseHtml(html)
// Find all <script type="application/ld+json"> tags
const scripts = root.querySelectorAll('script[type="application/ld+json"]')
let jobPosting: Record<string, unknown> | null = null
for (const script of scripts) {
try {
const json = JSON.parse(script.text)
if (json["@type"] === "JobPosting") {
jobPosting = json
break
}
// Could be an array
if (Array.isArray(json)) {
const found = json.find((item) => item["@type"] === "JobPosting")
if (found) {
jobPosting = found
break
}
}
} catch {
// not valid JSON — skip
}
}
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)
}
@@ -0,0 +1,199 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { rssFetch, fetchWithUA, writeError, parseRssDescription, extractJobIdFromUrl, BASE_URL } from "../helpers.js"
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().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().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((item) => {
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,
deadline: parsed.deadline,
}
})
// 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("")
}
}
@@ -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"]
}
@@ -0,0 +1,111 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { apiFetch, writeError } from "../helpers.js"
interface AutocompleteItem {
id: string
text: string
value: number
category: string
slug: string
}
interface AutocompleteGroup {
title: string
items: AutocompleteItem[]
}
export const autocomplete = defineCommand({
name: "autocomplete",
description: "Suggest job titles and categories for a query",
options: {
query: option(z.string().optional(), {
description: "Search text to autocomplete (required)",
}),
limit: option(z.coerce.number().optional(), {
description: "Cap total suggestions returned",
}),
format: option(z.enum(["json", "table", "plain"]).default("json"), {
description: "Output format: json, table, plain",
}),
},
handler: async ({ flags, signal }) => {
if (signal.aborted) return
if (!flags.query) {
writeError("--query is required", "MISSING_REQUIRED")
process.exit(1)
}
try {
const raw = await apiFetch<AutocompleteGroup[]>("/api/search/autocomplete", {
q: flags.query,
})
if (signal.aborted) return
const queryLower = flags.query.toLowerCase()
// Filter groups: only include items whose text matches the query (API always returns all categories)
// This ensures a nonsense query returns []
const filtered = raw
.map((g) => ({
title: g.title,
items: (g.items ?? []).filter((item) =>
item.text.toLowerCase().includes(queryLower)
),
}))
.filter((g) => g.items.length > 0)
let result = filtered
if (flags.limit !== undefined) {
// Apply limit across all groups, distributing across groups
let remaining = flags.limit
result = []
for (const group of filtered) {
if (remaining <= 0) break
const items = group.items.slice(0, remaining)
remaining -= items.length
if (items.length > 0) {
result.push({ title: group.title, items })
}
}
}
if (flags.format === "json") {
console.log(JSON.stringify(result, null, 2))
} else if (flags.format === "table") {
outputTable(result)
} else {
outputPlain(result)
}
} catch (err) {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
process.exit(1)
}
},
})
function outputTable(data: AutocompleteGroup[]): void {
console.log("category id text value slug")
for (const group of data) {
for (const item of group.items) {
const cat = item.category.padEnd(10)
const id = item.id.substring(0, 20).padEnd(20)
const text = item.text.substring(0, 32).padEnd(32)
const value = String(item.value).padEnd(6)
const slug = item.slug
console.log(`${cat} ${id} ${text} ${value} ${slug}`)
}
}
}
function outputPlain(data: AutocompleteGroup[]): void {
for (const group of data) {
console.log(`=== ${group.title} ===`)
for (const item of group.items) {
console.log(` ${item.text} (${item.category}, id=${item.value}, slug=${item.slug})`)
}
}
}
@@ -0,0 +1,67 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { apiFetch, writeError } from "../helpers.js"
interface Category {
id: number
title: string
helpText: string
count: number
}
export const categories = defineCommand({
name: "categories",
description: "List all job categories with live counts",
options: {
limit: option(z.coerce.number().optional(), {
description: "Cap number of categories returned",
}),
format: option(z.enum(["json", "table", "plain"]).default("json"), {
description: "Output format: json, table, plain",
}),
},
handler: async ({ flags, signal }) => {
if (signal.aborted) return
try {
let data = await apiFetch<Category[]>("/api/categorycount/getcounts")
if (signal.aborted) return
if (flags.limit !== undefined) {
data = data.slice(0, flags.limit)
}
if (flags.format === "json") {
console.log(JSON.stringify(data, null, 2))
} else if (flags.format === "table") {
outputTable(data)
} else {
outputPlain(data)
}
} catch (err) {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
process.exit(1)
}
},
})
function outputTable(data: Category[]): void {
console.log("id title count")
for (const cat of data) {
const id = String(cat.id).padEnd(9)
const title = cat.title.substring(0, 48).padEnd(48)
const count = String(cat.count)
console.log(`${id} ${title} ${count}`)
}
}
function outputPlain(data: Category[]): void {
for (const cat of data) {
console.log(`id: ${cat.id}`)
console.log(`title: ${cat.title}`)
console.log(`helpText: ${cat.helpText}`)
console.log(`count: ${cat.count}`)
console.log("")
}
}
@@ -0,0 +1,165 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { parse } from "node-html-parser"
import { BASE_URL, writeError } from "../helpers.js"
interface JsonLdJobPosting {
"@context"?: string
"@type"?: string
title?: string
datePosted?: string
validThrough?: string
employmentType?: string | string[]
hiringOrganization?: {
"@type"?: string
name?: string
logo?: string
}
jobLocation?: {
"@type"?: string
address?: {
"@type"?: string
streetAddress?: string
addressLocality?: string
addressRegion?: string
postalCode?: string
addressCountry?: string
}
}
description?: string
}
export const detail = defineCommand({
name: "detail",
description: "Full detail for a single job posting (by slug)",
options: {
format: option(z.enum(["json", "plain"]).default("json"), {
description: "Output format: json, plain",
}),
},
handler: async ({ flags, positional, signal }) => {
if (signal.aborted) return
const slug = positional[0]
if (!slug) {
writeError("slug argument is required", "MISSING_REQUIRED")
process.exit(1)
}
const url = `${BASE_URL}/job/${slug}`
try {
const response = await fetch(url, {
headers: {
"Accept": "text/html,application/xhtml+xml",
"User-Agent": "Mozilla/5.0",
},
})
if (response.status === 404) {
writeError("Job not found", "NOT_FOUND")
process.exit(1)
}
if (!response.ok) {
writeError(`API request failed: ${response.status} ${response.statusText}`, "API_ERROR")
process.exit(1)
}
const html = await response.text()
const root = parse(html)
// Find JSON-LD script tag
const ldJsonScripts = root.querySelectorAll('script[type="application/ld+json"]')
let jobPosting: JsonLdJobPosting | null = null
for (const script of ldJsonScripts) {
try {
const parsed = JSON.parse(script.text)
if (parsed["@type"] === "JobPosting") {
jobPosting = parsed
break
}
} catch {
// continue to next script
}
}
if (!jobPosting) {
// Check for 404 by looking at page content
const pageTitle = root.querySelector("title")?.text ?? ""
if (pageTitle.toLowerCase().includes("404") || html.toLowerCase().includes("siden blev ikke fundet")) {
writeError("Job not found", "NOT_FOUND")
} else {
writeError("Failed to parse JSON-LD from job page", "PARSE_ERROR")
}
process.exit(1)
}
if (signal.aborted) return
const hiringOrg = jobPosting.hiringOrganization
const address = jobPosting.jobLocation?.address
const employmentType = Array.isArray(jobPosting.employmentType)
? jobPosting.employmentType
: jobPosting.employmentType
? [jobPosting.employmentType]
: []
const output = {
slug,
url,
title: jobPosting.title ?? "",
datePosted: jobPosting.datePosted ?? "",
validThrough: jobPosting.validThrough ?? null,
employmentType,
hiringOrganization: {
name: hiringOrg?.name ?? "",
logo: hiringOrg?.logo ?? null,
},
jobLocation: {
streetAddress: address?.streetAddress ?? null,
addressLocality: address?.addressLocality ?? null,
addressRegion: address?.addressRegion ?? null,
postalCode: address?.postalCode ?? null,
addressCountry: address?.addressCountry ?? null,
},
description: jobPosting.description ?? "",
}
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: Record<string, unknown>): void {
console.log(`slug: ${data.slug}`)
console.log(`url: ${data.url}`)
console.log(`title: ${data.title}`)
console.log(`datePosted: ${data.datePosted}`)
console.log(`validThrough: ${data.validThrough ?? "N/A"}`)
const empType = Array.isArray(data.employmentType) ? data.employmentType.join(", ") : "-"
console.log(`employmentType: ${empType}`)
const org = data.hiringOrganization as { name: string; logo: string | null }
console.log(`company: ${org.name}`)
const loc = data.jobLocation as {
streetAddress: string | null
addressLocality: string | null
postalCode: string | null
addressCountry: string | null
}
console.log(`location: ${[loc.streetAddress, loc.addressLocality, loc.postalCode, loc.addressCountry].filter(Boolean).join(", ")}`)
console.log(`description: ${data.description}`)
}
@@ -0,0 +1,101 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { apiFetch, writeError } from "../helpers.js"
interface LocationItem {
id: string
text: string
value: string
category: string
slug: string
}
interface LocationGroup {
title: string
items: LocationItem[]
}
export const locations = defineCommand({
name: "locations",
description: "Suggest municipalities, zip codes, and regions for a query",
options: {
query: option(z.string().optional(), {
description: "Location text to search (city, zip code, region) (required)",
}),
limit: option(z.coerce.number().optional(), {
description: "Cap total suggestions returned",
}),
format: option(z.enum(["json", "table", "plain"]).default("json"), {
description: "Output format: json, table, plain",
}),
},
handler: async ({ flags, signal }) => {
if (signal.aborted) return
if (!flags.query) {
writeError("--query is required", "MISSING_REQUIRED")
process.exit(1)
}
try {
const raw = await apiFetch<LocationGroup[]>("/api/search/locations", {
q: flags.query,
})
if (signal.aborted) return
// Filter out groups with no items
const filtered = raw.filter((g) => g.items && g.items.length > 0)
let result = filtered
if (flags.limit !== undefined) {
// Apply limit across all groups
let remaining = flags.limit
result = []
for (const group of filtered) {
if (remaining <= 0) break
const items = group.items.slice(0, remaining)
remaining -= items.length
if (items.length > 0) {
result.push({ title: group.title, items })
}
}
}
if (flags.format === "json") {
console.log(JSON.stringify(result, null, 2))
} else if (flags.format === "table") {
outputTable(result)
} else {
outputPlain(result)
}
} catch (err) {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
process.exit(1)
}
},
})
function outputTable(data: LocationGroup[]): void {
console.log("category id text value slug")
for (const group of data) {
for (const item of group.items) {
const cat = item.category.padEnd(12)
const id = item.id.substring(0, 22).padEnd(22)
const text = item.text.substring(0, 28).padEnd(28)
const value = String(item.value).padEnd(11)
const slug = item.slug
console.log(`${cat} ${id} ${text} ${value} ${slug}`)
}
}
}
function outputPlain(data: LocationGroup[]): void {
for (const group of data) {
console.log(`=== ${group.title} ===`)
for (const item of group.items) {
console.log(` ${item.text} (${item.category}, value=${item.value}, slug=${item.slug})`)
}
}
}
@@ -0,0 +1,208 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { apiPost, writeError, BASE_URL } from "../helpers.js"
interface ApiSearchItem {
title: string
companyName: string
companyLogo: {
key: string
url: string
focalPoint: { top: number; left: number } | null
} | null
companyLogoSvgMarkup: string | null
overlayColor: string | null
companyAddress: string
jobTypes: string[]
boostJob: boolean
publishedDate: string
applicationDeadline: string | null
url: string
coverImage: {
key: string
url: string
focalPoint: { top: number; left: number } | null
} | null
silhouetteLogo: boolean
}
interface ApiSearchResponse {
items: ApiSearchItem[]
currentPage: number
totalItems: number
itemsPrPage: number
totalPages: number
}
function normalizeItem(item: ApiSearchItem): Record<string, unknown> {
const relativeUrl = item.url
const fullUrl = relativeUrl.startsWith("http")
? relativeUrl
: `${BASE_URL}${relativeUrl}`
// Extract slug from url path: /job/<slug>
const slug = relativeUrl.replace(/^\/job\//, "")
const companyLogo = item.companyLogo
? {
key: item.companyLogo.key,
url: item.companyLogo.url.startsWith("http")
? item.companyLogo.url
: `${BASE_URL}${item.companyLogo.url}`,
focalPoint: item.companyLogo.focalPoint,
}
: null
const coverImage = item.coverImage
? {
key: item.coverImage.key,
url: item.coverImage.url.startsWith("http")
? item.coverImage.url
: `${BASE_URL}${item.coverImage.url}`,
focalPoint: item.coverImage.focalPoint,
}
: null
return {
title: item.title,
companyName: item.companyName,
companyLogo,
companyLogoSvgMarkup: item.companyLogoSvgMarkup ?? null,
overlayColor: item.overlayColor ?? null,
companyAddress: item.companyAddress,
jobTypes: item.jobTypes,
boostJob: item.boostJob,
publishedDate: item.publishedDate,
applicationDeadline: item.applicationDeadline ?? null,
url: fullUrl,
slug,
coverImage,
silhouetteLogo: item.silhouetteLogo,
}
}
export const search = defineCommand({
name: "search",
description: "Search job listings with filters",
options: {
text: option(z.string().optional(), {
description: "Free-text keyword search (job title, keyword)",
}),
category: option(z.coerce.number().optional(), {
description: "Category ID",
}),
"jobtitle-id": option(z.coerce.number().optional(), {
description: "Job title ID from autocomplete results",
}),
municipality: option(z.string().optional(), {
description: "Municipality name, e.g. Odense, København",
}),
zip: option(z.string().optional(), {
description: "Zip code, e.g. 5000",
}),
region: option(z.string().optional(), {
description: "Region name",
}),
"job-type": option(z.string().optional(), {
description: "Comma-separated job types: fuldtid,deltid,fleksjob,elev,studiejob,praktik",
}),
page: option(z.coerce.number().default(1), {
description: "Page number (30 items per page, server-enforced)",
}),
limit: option(z.coerce.number().optional(), {
description: "Cap total results returned by CLI (client-side)",
}),
format: option(z.enum(["json", "table", "plain"]).default("json"), {
description: "Output format: json, table, plain",
}),
},
handler: async ({ flags, signal }) => {
if (signal.aborted) return
const filters: Array<{ type: string; value: string | number; displayText: string }> = []
if (flags.text) {
filters.push({ type: "freetext", value: flags.text, displayText: flags.text })
}
if (flags.category !== undefined) {
filters.push({ type: "category", value: flags.category, displayText: String(flags.category) })
}
if (flags["jobtitle-id"] !== undefined) {
filters.push({ type: "jobtitle", value: flags["jobtitle-id"], displayText: String(flags["jobtitle-id"]) })
}
if (flags.municipality) {
filters.push({ type: "municipality", value: flags.municipality, displayText: flags.municipality })
}
if (flags.zip) {
filters.push({ type: "zip", value: flags.zip, displayText: flags.zip })
}
if (flags.region) {
filters.push({ type: "region", value: flags.region, displayText: flags.region })
}
const jobTypes = flags["job-type"]
? flags["job-type"].split(",").map((t) => t.trim()).filter(Boolean)
: []
const body = {
jobTypes,
filters,
locationMode: "Text",
distance: 50,
}
try {
const data = await apiPost<ApiSearchResponse>(`/api/jobsearch/search/${flags.page}`, body)
if (signal.aborted) return
let results = data.items.map(normalizeItem)
if (flags.limit !== undefined) {
results = results.slice(0, flags.limit)
}
const meta = {
currentPage: data.currentPage,
totalItems: data.totalItems,
itemsPrPage: data.itemsPrPage,
totalPages: data.totalPages,
}
const output = { meta, results }
if (flags.format === "json") {
console.log(JSON.stringify(output, null, 2))
} else if (flags.format === "table") {
outputTable(results)
} else {
outputPlain(results)
}
} catch (err) {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
process.exit(1)
}
},
})
function outputTable(results: Record<string, unknown>[]): void {
console.log("title company date url")
for (const r of results) {
const title = String(r.title ?? "-").substring(0, 35).padEnd(35)
const company = String(r.companyName ?? "-").substring(0, 25).padEnd(25)
const date = String(r.publishedDate ?? "-").padEnd(10)
const url = String(r.url ?? "-")
console.log(`${title} ${company} ${date} ${url}`)
}
}
function outputPlain(results: Record<string, unknown>[]): void {
for (const r of results) {
console.log(`title: ${r.title}`)
console.log(`company: ${r.companyName}`)
console.log(`address: ${r.companyAddress}`)
console.log(`jobTypes: ${Array.isArray(r.jobTypes) ? r.jobTypes.join(", ") : "-"}`)
console.log(`published: ${r.publishedDate}`)
console.log(`deadline: ${r.applicationDeadline ?? "N/A"}`)
console.log(`url: ${r.url}`)
console.log("")
}
}
@@ -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"]
}
@@ -0,0 +1,291 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { htmlFetch, writeError } from "../helpers.js"
const BASE_URL = "https://www.jobindex.dk"
interface DetailResult {
id: string
title: string
company: string | null
companyUrl: string | null
location: string | null
date: string | null
deadline: string | null
employmentType: string | null
hours: string | null
applyUrl: string | null
url: string
description: string | null
}
/**
* Decode HTML entities in text
*/
function decodeHtmlEntities(text: string): string {
return text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
.replace(/&nbsp;/g, " ")
}
/**
* Strip HTML tags from text
*/
function stripTags(html: string): string {
return html.replace(/<[^>]+>/g, "").trim()
}
/**
* Extract job ID from URL or return as-is if already an ID
*/
function extractIdFromUrl(url: string): string {
// Match IDs like h1647303, r13677312, etc.
const match = url.match(/\/jobannonce\/([a-zA-Z]\d+)/)
if (match) return match[1]
return url
}
function buildUrl(idOrUrl: string): { url: string; id: string } {
if (idOrUrl.startsWith("http")) {
const id = extractIdFromUrl(idOrUrl)
return { url: idOrUrl, id }
}
// It's a bare ID
const url = `${BASE_URL}/jobannonce/${idOrUrl}`
return { url, id: idOrUrl }
}
/**
* Parse the detail HTML page using regex to avoid node-html-parser nesting bugs.
*/
function parseDetailPage(html: string, url: string, id: string): DetailResult {
// Title: extract from <h1> tag
const h1Match = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)
const title = h1Match ? decodeHtmlEntities(stripTags(h1Match[1])) : ""
if (!title) {
throw new Error("Failed to parse job listing HTML")
}
// Company and companyUrl from jix-toolbar-top__company section
let company: string | null = null
let companyUrl: string | null = null
const companySection = html.match(/class="jix-toolbar-top__company"[^>]*>([\s\S]*?)<\/div>/i)
if (companySection) {
const linkMatch = companySection[1].match(/<[Aa][^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/[Aa]>/i)
if (linkMatch) {
company = decodeHtmlEntities(stripTags(linkMatch[2])) || null
companyUrl = linkMatch[1] || null
}
}
// Location from jix_robotjob--area span
let location: string | null = null
const locMatch = html.match(/<span[^>]+class="jix_robotjob--area"[^>]*>([\s\S]*?)<\/span>/i)
if (locMatch) {
location = decodeHtmlEntities(stripTags(locMatch[1])) || null
}
// Date from <time datetime="..."> element
let date: string | null = null
const timeMatch = html.match(/<time[^>]+datetime="([^"]+)"/)
if (timeMatch) {
date = timeMatch[1] || null
}
// Employment type and hours from jix-info section
let employmentType: string | null = null
let hours: string | null = null
let deadline: string | null = null
const jixInfoMatch = html.match(/class="jix-info"[^>]*>([\s\S]*?)<\/div>/i)
if (jixInfoMatch) {
const jixInfoHtml = jixInfoMatch[1]
// Parse p elements with bold labels
const pMatches = [...jixInfoHtml.matchAll(/<p[^>]*><b>([^<]+)<\/b>\s*([\s\S]*?)<\/p>/gi)]
for (const pm of pMatches) {
const label = pm[1].toLowerCase().trim()
const value = stripTags(pm[2]).trim()
if (label.includes("ansættelsestype") || label.includes("employment type")) {
employmentType = decodeHtmlEntities(value) || null
} else if (label.includes("ugentlig arbejdstid") || label.includes("weekly working time") || label.includes("arbejdstid")) {
hours = decodeHtmlEntities(value) || null
} else if (label.includes("ansøgningsfrist") || label.includes("deadline") || label.includes("application deadline")) {
deadline = decodeHtmlEntities(value) || null
}
}
}
// If not found in jix-info, try broader text patterns
if (!employmentType) {
const emtMatch = html.match(/<b>(?:Ansættelsestype|Employment\s*type):<\/b>\s*([^<\n]+)/i)
if (emtMatch) {
employmentType = decodeHtmlEntities(emtMatch[1].trim()) || null
}
}
if (!hours) {
const hoursMatch = html.match(/<b>(?:Ugentlig\s*arbejdstid|Weekly\s*working\s*time):<\/b>\s*([^<\n]+)/i)
if (hoursMatch) {
hours = decodeHtmlEntities(hoursMatch[1].trim()) || null
}
}
// Deadline from application section
if (!deadline) {
// Look for "senest den" or "Ansøgningsfrist" patterns in text
const deadlineMatch = html.match(/Ansøgningsfrist[^:]*:\s*([^<\n,]+)/i)
if (deadlineMatch) {
deadline = decodeHtmlEntities(deadlineMatch[1].trim()) || null
}
}
// Apply URL: look for /c?t= redirect links in jix_onlineapplication_button
let applyUrl: string | null = null
const applySection = html.match(/class="jix_onlineapplication_button"[^>]*>[\s\S]*?href="([^"]+)"/i)
if (applySection) {
const href = decodeHtmlEntities(applySection[1])
applyUrl = href.startsWith("http") ? href : `${BASE_URL}${href}`
}
// If not found, look for any /c?t= link
if (!applyUrl) {
const ctMatch = html.match(/href="(\/c\?t=[^"]+)"/)
if (ctMatch) {
applyUrl = `${BASE_URL}${decodeHtmlEntities(ctMatch[1])}`
}
}
// Description: job text section
let description: string | null = null
// Try job-text class first
const jobTextMatch = html.match(/class="job-text"[^>]*>([\s\S]*?)<\/div>\s*(?:<div|<\/div>)/i)
if (jobTextMatch) {
description = decodeHtmlEntities(stripTags(jobTextMatch[1])).replace(/\s+/g, " ").trim() || null
}
// Fallback: try og:description meta tag for a brief description
if (!description) {
const ogDescMatch = html.match(/property="og:description"[^>]+content="([^"]+)"/i) ||
html.match(/content="([^"]+)"[^>]+property="og:description"/i)
if (ogDescMatch) {
description = decodeHtmlEntities(ogDescMatch[1]) || null
}
}
// Get canonical URL or use the fetched URL
const canonicalMatch = html.match(/<link[^>]+rel="canonical"[^>]+href="([^"]+)"/i) ||
html.match(/property="og:url"[^>]+content="([^"]+)"/i) ||
html.match(/content="([^"]+)"[^>]+property="og:url"/i)
const canonicalUrl = canonicalMatch ? canonicalMatch[1] : url
// Extract ID from canonical URL, fall back to the provided ID
const canonicalId = extractIdFromUrl(canonicalUrl) || id
return {
id: canonicalId,
title,
company: company || null,
companyUrl: companyUrl || null,
location: location || null,
date: date || null,
deadline: deadline || null,
employmentType: employmentType || null,
hours: hours || null,
applyUrl: applyUrl || null,
url: canonicalUrl,
description: description || null,
}
}
export const detail = defineCommand({
name: "detail",
description: "Fetch full job listing detail by ID or URL",
options: {
format: option(z.enum(["json", "plain"]).default("json"), {
description: "Output format: json, plain",
}),
},
handler: async ({ positional, flags, signal }) => {
if (signal.aborted) return
const idArg = positional[0]
if (!idArg) {
writeError("Job ID or URL is required", "MISSING_REQUIRED")
process.exit(1)
}
const { url, id } = buildUrl(idArg)
try {
const html = await htmlFetch(url)
if (signal.aborted) return
// Check if page is not a valid job listing
// A valid job listing has an <h1> tag
if (!html.includes("<h1>") && !html.includes("<h1 ")) {
writeError("Job not found", "NOT_FOUND")
process.exit(1)
}
let data: DetailResult
try {
data = parseDetailPage(html, url, id)
} catch (parseErr) {
const msg = parseErr instanceof Error ? parseErr.message : String(parseErr)
writeError(msg, "PARSE_ERROR")
process.exit(1)
}
// Verify it's a valid job page (has a title)
if (!data.title) {
writeError("Failed to parse job listing HTML", "PARSE_ERROR")
process.exit(1)
}
if (flags.format === "json") {
console.log(JSON.stringify(data, null, 2))
} else {
outputPlain(data)
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.includes("Job not found") || message.includes("404") || message.includes("NOT_FOUND")) {
writeError("Job not found", "NOT_FOUND")
} else if (message.includes("Failed to parse") || message.includes("PARSE_ERROR")) {
writeError(message, "PARSE_ERROR")
} else {
writeError(message, "API_ERROR")
}
process.exit(1)
}
},
})
function outputPlain(data: DetailResult): void {
console.log(`id: ${data.id}`)
console.log(`title: ${data.title}`)
console.log(`company: ${data.company ?? "-"}`)
console.log(`location: ${data.location ?? "-"}`)
console.log(`date: ${data.date ?? "-"}`)
console.log(`deadline: ${data.deadline ?? "-"}`)
console.log(`employmentType: ${data.employmentType ?? "-"}`)
console.log(`hours: ${data.hours ?? "-"}`)
console.log(`applyUrl: ${data.applyUrl ?? "-"}`)
console.log(`url: ${data.url}`)
console.log("")
if (data.description) {
console.log(data.description)
}
}
@@ -0,0 +1,103 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { BASE_URL, htmlFetch, parseSearchPage, writeError, type JobCard } from "../helpers.js"
export const search = defineCommand({
name: "search",
description: "Search for job listings on Jobindex.dk",
options: {
query: option(z.string().optional(), {
short: "q",
description: "Keyword search query (e.g. python, grafisk designer)",
}),
page: option(z.coerce.number().default(1), {
description: "Page number (1-indexed)",
}),
jobage: option(z.coerce.number().default(9999), {
description: "Max age of posting in days: 1, 7, 14, 30, or 9999 (all)",
}),
sort: option(z.string().default("score"), {
description: "Sort order: score (relevance) or date (newest first)",
}),
limit: option(z.coerce.number().optional(), {
description: "Cap total results returned by the CLI (client-side)",
}),
format: option(z.enum(["json", "table", "plain"]).default("json"), {
description: "Output format: json, table, plain",
}),
},
handler: async ({ flags, signal }) => {
if (!flags.query) {
writeError("--query is required", "MISSING_REQUIRED")
process.exit(1)
}
if (signal.aborted) return
const params = new URLSearchParams({
q: flags.query,
page: String(flags.page),
jobage: String(flags.jobage),
sort: flags.sort,
})
try {
const html = await htmlFetch(`${BASE_URL}/jobsoegning?${params.toString()}`)
if (signal.aborted) return
const parsed = parseSearchPage(html)
const total = parsed.total
let results = parsed.results
if (flags.limit !== undefined) {
results = results.slice(0, flags.limit)
}
const output = {
meta: {
total,
page: flags.page,
perPage: 20,
},
results,
}
if (flags.format === "json") {
console.log(JSON.stringify(output, null, 2))
} else if (flags.format === "table") {
outputTable(results)
} else {
outputPlain(results)
}
} catch (err) {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
process.exit(1)
}
},
})
function outputTable(results: JobCard[]): void {
console.log("id title company location")
for (const r of results) {
const id = r.id.padEnd(11)
const title = r.title.substring(0, 40).padEnd(40)
const company = (r.company ?? "-").substring(0, 20).padEnd(20)
const location = r.location ?? "-"
console.log(`${id} ${title} ${company} ${location}`)
}
}
function outputPlain(results: JobCard[]): void {
for (const r of results) {
console.log(`id: ${r.id}`)
console.log(`title: ${r.title}`)
console.log(`company: ${r.company ?? "-"}`)
console.log(`location: ${r.location ?? "-"}`)
console.log(`date: ${r.date ?? "-"}`)
console.log(`deadline: ${r.deadline ?? "-"}`)
console.log(`url: ${r.url}`)
if (r.description) console.log(`description: ${r.description}`)
console.log("")
}
}
@@ -71,10 +71,115 @@ export interface JobCard {
companyUrl: string | null companyUrl: string | null
location: string | null location: string | null
date: string | null date: string | null
deadline: string | null
url: string url: string
description: string | null description: string | null
} }
/**
* Jobindex moved its search results client-side. The /jobsoegning.json endpoint
* now returns 204 No Content. The HTML page (/jobsoegning) embeds the full result
* payload in a `var Stash = {...}` script blob, under
* jobsearch/result_app -> storeData -> searchResponse -> { hitcount, results[] }.
* This extracts and parses that blob.
*/
export function extractStash(html: string): any {
const marker = "var Stash = "
const start = html.indexOf(marker)
if (start === -1) throw new Error("Could not locate Stash blob in jobindex HTML")
const open = start + marker.length
let depth = 0
let inStr = false
let esc = false
let end = -1
for (let j = open; j < html.length; j++) {
const c = html[j]
if (inStr) {
if (esc) esc = false
else if (c === "\\") esc = true
else if (c === '"') inStr = false
} else {
if (c === '"') inStr = true
else if (c === "{") depth++
else if (c === "}") {
depth--
if (depth === 0) {
end = j + 1
break
}
}
}
}
if (end === -1) throw new Error("Unterminated Stash blob in jobindex HTML")
return JSON.parse(html.slice(open, end))
}
function findSearchResponse(node: any): any {
if (node && typeof node === "object") {
if (!Array.isArray(node)) {
if (
node.searchResponse &&
typeof node.searchResponse === "object" &&
Array.isArray(node.searchResponse.results)
) {
return node.searchResponse
}
for (const key of Object.keys(node)) {
const found = findSearchResponse(node[key])
if (found) return found
}
} else {
for (const item of node) {
const found = findSearchResponse(item)
if (found) return found
}
}
}
return null
}
export interface SearchPageResult {
total: number
results: JobCard[]
}
export function parseSearchPage(html: string): SearchPageResult {
const stash = extractStash(html)
const sr = findSearchResponse(stash)
if (!sr) throw new Error("Could not locate searchResponse in jobindex Stash")
const results: JobCard[] = (sr.results ?? []).map((r: any): JobCard => {
const tid: string = r.tid ?? ""
let location: string | null = r.area ?? null
if (!location) {
try {
location = r.geojson?.features?.[0]?.properties?.title ?? null
} catch {
location = null
}
}
let deadline: string | null = null
if (r.apply_deadline_asap) deadline = "ASAP"
else if (typeof r.apply_deadline === "string") deadline = r.apply_deadline.slice(0, 10)
else if (typeof r.lastdate === "string") deadline = r.lastdate
return {
id: tid,
title: r.headline ?? "",
company: r.company?.name ?? r.companytext ?? null,
companyUrl: r.company?.homeurl ?? null,
location,
date: r.firstdate ?? null,
deadline,
url: tid ? `${BASE_URL}/jobannonce/${tid}` : (r.share_url ?? r.url ?? ""),
description: null,
}
})
const total = typeof sr.hitcount === "number" ? sr.hitcount : results.length
return { total, results }
}
/** /**
* Decode HTML entities in text * Decode HTML entities in text
*/ */
@@ -181,6 +286,7 @@ export function parseJobCards(html: string): JobCard[] {
companyUrl: companyUrl || null, companyUrl: companyUrl || null,
location: location || null, location: location || null,
date: date || null, date: date || null,
deadline: null,
url, url,
description: description || null, description: description || null,
}) })
@@ -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"]
}
@@ -0,0 +1,132 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { apiFetch, writeError, stripHtml } from "../helpers.js"
interface DetailApiResponse {
id: string
title: string
body: string
publicationDateTime: string
unpublicationDateTime: string | null
approvalStatus: string
views: number
createdDateTime: string
updatedDateTime: string
isAnonymousEmployer: boolean
hasLogo: boolean
logoUrl: string | null
employer: {
cvrNumber: string | null
pNumber: string | null
name: string
hasCompanyLogo: boolean
}
job: {
type: string
address: {
streetName: string | null
city: string | null
postalCode: string | null
municipality: string | null
countryCode: string
countryName: string
}
noFixedWorkplace: boolean
isLimitedPeriod: boolean
isDisabilityFriendly: boolean
isPartTime: boolean
employmentDate: string | null
conceptUriDa: string | null
preferredLabelDa: string | null
driversLicenses: unknown[]
classifications: unknown[]
shifts: unknown[]
isFavorite: boolean
}
application: {
deadlineDate: string | null
availablePositions: number
contactPersons: Array<{
firstNames: string | null
lastName: string | null
phoneNumber: string | null
}>
url: string | null
urlText: string | null
isApplicationDeadlineASAP: boolean
}
organisationTypeId: number | null
user: string | null
}
export const detail = defineCommand({
name: "detail",
description: "Full detail for a single job ad",
options: {
format: option(z.enum(["json", "table", "plain"]).default("json"), {
description: "Output format: json, table, plain",
}),
},
handler: async ({ positional, flags, signal }) => {
if (signal.aborted) return
const id = positional[0] as string | undefined
if (!id) {
writeError("Job ad ID is required", "MISSING_REQUIRED")
process.exit(1)
}
try {
const data = await apiFetch<DetailApiResponse>(
`/FindJob/JobAdDetails/${id}`,
{ incrementViews: "false" }
)
if (signal.aborted) return
if (flags.format === "json") {
console.log(JSON.stringify(data, null, 2))
} else if (flags.format === "table") {
outputTable(data)
} else {
outputPlain(data)
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.includes("404") || message.includes("Not Found")) {
writeError("Job ad not found", "NOT_FOUND")
} else {
writeError(message, "API_ERROR")
}
process.exit(1)
}
},
})
function outputTable(data: DetailApiResponse): void {
console.log(`ID: ${data.id}`)
console.log(`Title: ${data.title}`)
console.log(`Employer: ${data.employer.name}`)
console.log(`Type: ${data.job.type}`)
console.log(`City: ${data.job.address.city ?? "-"}`)
console.log(`Postal: ${data.job.address.postalCode ?? "-"}`)
console.log(`Country: ${data.job.address.countryName}`)
console.log(`Published: ${data.publicationDateTime}`)
console.log(`Deadline: ${data.application.deadlineDate ?? "-"}`)
console.log(`Positions: ${data.application.availablePositions}`)
console.log(`Apply URL: ${data.application.url ?? "-"}`)
}
function outputPlain(data: DetailApiResponse): void {
console.log(`Title: ${data.title}`)
console.log(`Employer: ${data.employer.name}`)
console.log(`Location: ${data.job.address.city ?? "-"}, ${data.job.address.countryName}`)
console.log(`Published: ${data.publicationDateTime}`)
console.log(`Deadline: ${data.application.deadlineDate ?? "-"}`)
console.log(`Positions: ${data.application.availablePositions}`)
if (data.application.url) {
console.log(`Apply: ${data.application.url}`)
}
console.log("")
console.log(stripHtml(data.body))
}
@@ -0,0 +1,84 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { apiFetch, writeError } from "../helpers.js"
interface OccupationAlias {
aliasIdentifier: string
conceptUriDa: string
alternativeLabelDa: string
}
interface Occupation {
conceptUriDa: string
preferredLabelDa: string
aliases: OccupationAlias[]
}
export const occupations = defineCommand({
name: "occupations",
description: "Search occupation types (for building filters)",
options: {
"search-string": option(z.string().optional(), {
description: "Search term for occupation, e.g. sygeplejerske",
}),
"per-page": option(z.coerce.number().default(10), {
description: "Max results to return",
}),
format: option(z.enum(["json", "table", "plain"]).default("json"), {
description: "Output format: json, table, plain",
}),
},
handler: async ({ flags, signal }) => {
if (signal.aborted) return
if (!flags["search-string"]) {
writeError("--search-string is required", "MISSING_REQUIRED")
process.exit(1)
}
const params: Record<string, string> = {
searchString: flags["search-string"],
pageSize: String(flags["per-page"]),
}
try {
const rawData = await apiFetch<Occupation[]>("/OccupationSearch", params)
if (signal.aborted) return
// Client-side filter — API does not enforce pageSize reliably
const data = rawData.slice(0, flags["per-page"])
if (flags.format === "json") {
console.log(JSON.stringify(data, null, 2))
} else if (flags.format === "table") {
outputTable(data)
} else {
outputPlain(data)
}
} catch (err) {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
process.exit(1)
}
},
})
function outputTable(data: Occupation[]): void {
console.log("conceptUriDa preferredLabelDa")
for (const o of data) {
const uri = o.conceptUriDa.substring(0, 70).padEnd(70)
const label = o.preferredLabelDa
console.log(`${uri} ${label}`)
}
}
function outputPlain(data: Occupation[]): void {
for (const o of data) {
console.log(`label: ${o.preferredLabelDa}`)
console.log(`uri: ${o.conceptUriDa}`)
if (o.aliases.length > 0) {
console.log(`aliases: ${o.aliases.map((a) => a.alternativeLabelDa).join(", ")}`)
}
console.log("")
}
}
@@ -0,0 +1,208 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { apiFetch, writeError } from "../helpers.js"
interface SearchApiResponse {
jobAds: JobAdRaw[]
searchFacets: SearchFacetsRaw
totalJobAdCount: number
searchString: string | null
}
interface JobAdRaw {
jobAdId: string
title: string
hiringOrgName: string
occupation: string | null
municipality: string | null
postalCode: number | null
postalDistrictName: string | null
country: string
publicationDate: string
applicationDeadline: string | null
applicationDeadlineStatus: string | null
workHourPartTime: boolean
isExternal: boolean
hasLogo: boolean
logoUrl: string | null
cvr: string | null
workPlaceAddress: string
conceptUriDa?: string | null
isSeen: boolean
isFavorite: boolean
description?: string
}
interface SearchFacetsRaw {
regions: Array<{ type: string; jobAdCount: number }>
workHours: Array<{ type: string; jobAdCount: number }>
employmentDurations: Array<{ type: string; jobAdCount: number }>
occupationAreas: Array<{ identifier: string; jobAdCount: number }>
countries: Array<{ label: string; identifier: string; jobAdCount: number }>
}
export const search = defineCommand({
name: "search",
description: "Search for job ads with filters",
options: {
"search-string": option(z.string().optional(), {
description: "Free-text keyword search (job title, skills, employer)",
}),
page: option(z.coerce.number().default(1), {
description: "Page number (1-indexed)",
}),
"per-page": option(z.coerce.number().default(10), {
description: "Results per page",
}),
order: option(z.string().default("PublicationDate"), {
description: "Sort order: PublicationDate, BestMatch, ApplicationDate",
}),
region: option(z.string().optional(), {
description: "One region value",
}),
"work-hours": option(z.string().optional(), {
description: "FullTime or PartTime",
}),
duration: option(z.string().optional(), {
description: "Permanent or Temporary",
}),
"job-type": option(z.string().optional(), {
description: "Announcement type: Ordinaert, Efterloenner, Foertidspension",
}),
"postal-code": option(z.string().optional(), {
description: "Postal code for radius search",
}),
radius: option(z.coerce.number().default(50), {
description: "Radius in km from postal code",
}),
"occupation-area": option(z.string().optional(), {
description: "Occupation area identifier, e.g. 10000",
}),
"occupation-group": option(z.string().optional(), {
description: "Occupation group identifier, e.g. 10060",
}),
limit: option(z.coerce.number().optional(), {
description: "Cap total results returned by CLI",
}),
format: option(z.enum(["json", "table", "plain"]).default("json"), {
description: "Output format: json, table, plain",
}),
},
handler: async ({ flags, signal }) => {
if (signal.aborted) return
const params: Record<string, string> = {
resultsPerPage: String(flags["per-page"]),
pageNumber: String(flags.page),
orderType: flags.order,
}
if (flags["search-string"]) params["searchString"] = flags["search-string"]
if (flags.region) params["regions"] = flags.region
if (flags["work-hours"]) params["workHoursType"] = flags["work-hours"]
if (flags.duration) params["employmentDurationType"] = flags.duration
if (flags["job-type"]) params["jobAnnouncementType"] = flags["job-type"]
if (flags["postal-code"]) {
params["postalCode"] = flags["postal-code"]
params["kmRadius"] = String(flags.radius)
}
if (flags["occupation-area"]) params["occupationAreas"] = flags["occupation-area"]
if (flags["occupation-group"]) params["occupationGroups"] = flags["occupation-group"]
try {
const data = await apiFetch<SearchApiResponse>("/FindJob/Search", params)
if (signal.aborted) return
// Map raw job ads to documented output shape (omit description)
let results = data.jobAds.map((job) => ({
jobAdId: job.jobAdId,
title: job.title,
hiringOrgName: job.hiringOrgName,
occupation: job.occupation ?? null,
municipality: job.municipality ?? null,
postalCode: job.postalCode ?? null,
postalDistrictName: job.postalDistrictName ?? null,
country: job.country,
publicationDate: job.publicationDate,
applicationDeadline: job.applicationDeadline ?? null,
applicationDeadlineStatus: job.applicationDeadlineStatus ?? null,
workHourPartTime: job.workHourPartTime,
isExternal: job.isExternal,
hasLogo: job.hasLogo,
logoUrl: job.logoUrl ?? null,
cvr: job.cvr ?? null,
workPlaceAddress: job.workPlaceAddress ?? "",
isSeen: job.isSeen,
isFavorite: job.isFavorite,
}))
if (flags.limit !== undefined) {
results = results.slice(0, flags.limit)
}
const facets = {
regions: data.searchFacets.regions ?? [],
workHours: data.searchFacets.workHours ?? [],
employmentDurations: data.searchFacets.employmentDurations ?? [],
occupationAreas: data.searchFacets.occupationAreas ?? [],
countries: data.searchFacets.countries ?? [],
}
const meta = {
totalJobAdCount: data.totalJobAdCount,
pageNumber: flags.page,
resultsPerPage: flags["per-page"],
searchString: data.searchString ?? null,
}
const output = { meta, facets, 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)
}
},
})
type JobAdResult = {
jobAdId: string
title: string
hiringOrgName: string
occupation: string | null
municipality: string | null
postalCode: number | null
publicationDate: string
applicationDeadline: string | null
}
function outputTable(results: JobAdResult[]): void {
console.log("jobAdId title employer municipality")
for (const r of results) {
const id = r.jobAdId.padEnd(36)
const title = (r.title ?? "-").substring(0, 36).padEnd(36)
const employer = (r.hiringOrgName ?? "-").substring(0, 26).padEnd(26)
const municipality = String(r.municipality ?? "-")
console.log(`${id} ${title} ${employer} ${municipality}`)
}
}
function outputPlain(results: JobAdResult[]): void {
for (const r of results) {
console.log(`id: ${r.jobAdId}`)
console.log(`title: ${r.title}`)
console.log(`employer: ${r.hiringOrgName}`)
console.log(`occupation: ${r.occupation ?? "-"}`)
console.log(`municipality: ${r.municipality ?? "-"}`)
console.log(`published: ${r.publicationDate}`)
console.log(`deadline: ${r.applicationDeadline ?? "-"}`)
console.log("")
}
}
@@ -0,0 +1,66 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { apiFetch, writeError } from "../helpers.js"
export const suggestions = defineCommand({
name: "suggestions",
description: "Typeahead suggestions for job title / keyword search",
options: {
query: option(z.string().optional(), {
description: "Partial search string to complete",
}),
limit: option(z.coerce.number().optional(), {
description: "Cap number of suggestions returned",
}),
format: option(z.enum(["json", "table", "plain"]).default("json"), {
description: "Output format: json, table, plain",
}),
},
handler: async ({ flags, signal }) => {
if (signal.aborted) return
if (!flags.query) {
writeError("--query is required", "MISSING_REQUIRED")
process.exit(1)
}
const params: Record<string, string> = {
query: flags.query,
}
try {
const data = await apiFetch<string[]>("/FindJob/GetTypeaheadSuggestions", params)
if (signal.aborted) return
let results = data
if (flags.limit !== undefined) {
results = results.slice(0, flags.limit)
}
if (flags.format === "json") {
console.log(JSON.stringify(results, null, 2))
} else if (flags.format === "table") {
outputTable(results)
} else {
outputPlain(results)
}
} catch (err) {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
process.exit(1)
}
},
})
function outputTable(data: string[]): void {
console.log("suggestion")
for (const s of data) {
console.log(s)
}
}
function outputPlain(data: string[]): void {
for (const s of data) {
console.log(s)
}
}
@@ -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"]
}