mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
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:
co-authored by
Claude Fable 5
parent
fce2cf23c0
commit
f3d4448cca
@@ -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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
|
||||
.replace(/ /g, " ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags from text
|
||||
*/
|
||||
function stripTags(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, "").trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract job ID from URL or return as-is if already an ID
|
||||
*/
|
||||
function extractIdFromUrl(url: string): string {
|
||||
// Match IDs like h1647303, r13677312, etc.
|
||||
const match = url.match(/\/jobannonce\/([a-zA-Z]\d+)/)
|
||||
if (match) return match[1]
|
||||
return url
|
||||
}
|
||||
|
||||
function buildUrl(idOrUrl: string): { url: string; id: string } {
|
||||
if (idOrUrl.startsWith("http")) {
|
||||
const id = extractIdFromUrl(idOrUrl)
|
||||
return { url: idOrUrl, id }
|
||||
}
|
||||
// It's a bare ID
|
||||
const url = `${BASE_URL}/jobannonce/${idOrUrl}`
|
||||
return { url, id: idOrUrl }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
location: string | null
|
||||
date: string | null
|
||||
deadline: string | null
|
||||
url: string
|
||||
description: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Jobindex moved its search results client-side. The /jobsoegning.json endpoint
|
||||
* now returns 204 No Content. The HTML page (/jobsoegning) embeds the full result
|
||||
* payload in a `var Stash = {...}` script blob, under
|
||||
* jobsearch/result_app -> storeData -> searchResponse -> { hitcount, results[] }.
|
||||
* This extracts and parses that blob.
|
||||
*/
|
||||
export function extractStash(html: string): any {
|
||||
const marker = "var Stash = "
|
||||
const start = html.indexOf(marker)
|
||||
if (start === -1) throw new Error("Could not locate Stash blob in jobindex HTML")
|
||||
const open = start + marker.length
|
||||
let depth = 0
|
||||
let inStr = false
|
||||
let esc = false
|
||||
let end = -1
|
||||
for (let j = open; j < html.length; j++) {
|
||||
const c = html[j]
|
||||
if (inStr) {
|
||||
if (esc) esc = false
|
||||
else if (c === "\\") esc = true
|
||||
else if (c === '"') inStr = false
|
||||
} else {
|
||||
if (c === '"') inStr = true
|
||||
else if (c === "{") depth++
|
||||
else if (c === "}") {
|
||||
depth--
|
||||
if (depth === 0) {
|
||||
end = j + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (end === -1) throw new Error("Unterminated Stash blob in jobindex HTML")
|
||||
return JSON.parse(html.slice(open, end))
|
||||
}
|
||||
|
||||
function findSearchResponse(node: any): any {
|
||||
if (node && typeof node === "object") {
|
||||
if (!Array.isArray(node)) {
|
||||
if (
|
||||
node.searchResponse &&
|
||||
typeof node.searchResponse === "object" &&
|
||||
Array.isArray(node.searchResponse.results)
|
||||
) {
|
||||
return node.searchResponse
|
||||
}
|
||||
for (const key of Object.keys(node)) {
|
||||
const found = findSearchResponse(node[key])
|
||||
if (found) return found
|
||||
}
|
||||
} else {
|
||||
for (const item of node) {
|
||||
const found = findSearchResponse(item)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export interface SearchPageResult {
|
||||
total: number
|
||||
results: JobCard[]
|
||||
}
|
||||
|
||||
export function parseSearchPage(html: string): SearchPageResult {
|
||||
const stash = extractStash(html)
|
||||
const sr = findSearchResponse(stash)
|
||||
if (!sr) throw new Error("Could not locate searchResponse in jobindex Stash")
|
||||
|
||||
const results: JobCard[] = (sr.results ?? []).map((r: any): JobCard => {
|
||||
const tid: string = r.tid ?? ""
|
||||
let location: string | null = r.area ?? null
|
||||
if (!location) {
|
||||
try {
|
||||
location = r.geojson?.features?.[0]?.properties?.title ?? null
|
||||
} catch {
|
||||
location = null
|
||||
}
|
||||
}
|
||||
let deadline: string | null = null
|
||||
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
|
||||
*/
|
||||
@@ -181,6 +286,7 @@ export function parseJobCards(html: string): JobCard[] {
|
||||
companyUrl: companyUrl || null,
|
||||
location: location || null,
|
||||
date: date || null,
|
||||
deadline: null,
|
||||
url,
|
||||
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"]
|
||||
}
|
||||
Reference in New Issue
Block a user