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,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"]
|
||||
}
|
||||
Reference in New Issue
Block a user