mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 08:36:25 +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,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("")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user