2026-07-07 17:39:47 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 13:05:40 +07:00
|
|
|
interface DetailResult {
|
|
|
|
|
slug: string
|
|
|
|
|
url: string
|
|
|
|
|
title: string
|
|
|
|
|
datePosted: string
|
|
|
|
|
validThrough: string | null
|
|
|
|
|
employmentType: string[]
|
|
|
|
|
hiringOrganization: {
|
|
|
|
|
name: string
|
|
|
|
|
logo: string | null
|
|
|
|
|
}
|
|
|
|
|
jobLocation: {
|
|
|
|
|
streetAddress: string | null
|
|
|
|
|
addressLocality: string | null
|
|
|
|
|
addressRegion: string | null
|
|
|
|
|
postalCode: string | null
|
|
|
|
|
addressCountry: string | null
|
|
|
|
|
}
|
|
|
|
|
description: string
|
|
|
|
|
applyUrl: string | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cleanText(text: string): string {
|
|
|
|
|
return text.replace(/\s+/g, " ").trim()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeUrl(value: string | null | undefined): string | null {
|
|
|
|
|
if (!value) return null
|
|
|
|
|
const decoded = value.replace(/&/g, "&")
|
|
|
|
|
if (decoded.startsWith("http")) return decoded
|
|
|
|
|
if (decoded.startsWith("/")) return `${BASE_URL}${decoded}`
|
|
|
|
|
return decoded
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findJobPostingJsonLd(root: ReturnType<typeof parse>): JsonLdJobPosting | null {
|
|
|
|
|
const ldJsonScripts = root.querySelectorAll('script[type="application/ld+json"]')
|
|
|
|
|
|
|
|
|
|
for (const script of ldJsonScripts) {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(script.text) as unknown
|
|
|
|
|
if (isJobPosting(parsed)) return parsed
|
|
|
|
|
if (Array.isArray(parsed)) {
|
|
|
|
|
const found = parsed.find(isJobPosting)
|
|
|
|
|
if (found) return found
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Continue to next script.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isJobPosting(value: unknown): value is JsonLdJobPosting {
|
|
|
|
|
return Boolean(value && typeof value === "object" && (value as JsonLdJobPosting)["@type"] === "JobPosting")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fromJsonLd(jobPosting: JsonLdJobPosting, slug: string, url: string): DetailResult {
|
|
|
|
|
const hiringOrg = jobPosting.hiringOrganization
|
|
|
|
|
const address = jobPosting.jobLocation?.address
|
|
|
|
|
|
|
|
|
|
const employmentType = Array.isArray(jobPosting.employmentType)
|
|
|
|
|
? jobPosting.employmentType
|
|
|
|
|
: jobPosting.employmentType
|
|
|
|
|
? [jobPosting.employmentType]
|
|
|
|
|
: []
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
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 ?? "",
|
|
|
|
|
applyUrl: null,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function overviewValue(root: ReturnType<typeof parse>, label: string): string | null {
|
|
|
|
|
const normalizedLabel = label.toLowerCase()
|
|
|
|
|
for (const item of root.querySelectorAll(".job-overview li")) {
|
|
|
|
|
const strong = item.querySelector("strong")
|
|
|
|
|
const itemLabel = cleanText(strong?.text ?? "").replace(/:$/, "").toLowerCase()
|
|
|
|
|
if (itemLabel !== normalizedLabel) continue
|
|
|
|
|
|
|
|
|
|
const value = cleanText(item.text.replace(strong?.text ?? "", ""))
|
|
|
|
|
return value.replace(/^:\s*/, "") || null
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fromRenderedHtml(root: ReturnType<typeof parse>, slug: string, url: string): DetailResult {
|
|
|
|
|
const pageTitle = cleanText(root.querySelector("title")?.text ?? "")
|
2026-07-21 01:11:20 -05:00
|
|
|
const titleLower = pageTitle.toLowerCase()
|
|
|
|
|
const bodyText = root.text.toLowerCase()
|
|
|
|
|
if (
|
|
|
|
|
bodyText.includes("siden blev ikke fundet") ||
|
|
|
|
|
titleLower.startsWith("404") ||
|
|
|
|
|
titleLower.includes("page not found") ||
|
|
|
|
|
titleLower.includes("siden blev ikke fundet")
|
|
|
|
|
) {
|
2026-07-10 13:05:40 +07:00
|
|
|
throw new Error("NOT_FOUND")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const title =
|
|
|
|
|
cleanText(root.querySelector(".job-list-details .title")?.text ?? "") ||
|
|
|
|
|
cleanText(root.querySelector("h1")?.text ?? "") ||
|
|
|
|
|
cleanText(root.querySelector("h3")?.text ?? "").replace(/\s+\|\s+jobdanmark$/, "")
|
|
|
|
|
|
|
|
|
|
if (!title) {
|
|
|
|
|
throw new Error("Failed to parse job page HTML")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const companyLink = root.querySelector('.job-details-head a[href^="/virksomheder/"]')
|
|
|
|
|
const companyName = cleanText(companyLink?.text ?? "")
|
|
|
|
|
const logoSrc =
|
|
|
|
|
root.querySelector(".company-logo img")?.getAttribute("src") ??
|
|
|
|
|
root.querySelector(".company-logo source")?.getAttribute("srcset")?.split(/\s+/)[0]
|
|
|
|
|
const workplace = overviewValue(root, "Arbejdssted")
|
|
|
|
|
|
|
|
|
|
const description = root
|
|
|
|
|
.querySelectorAll(".job-list-details p, .job-list-details li")
|
|
|
|
|
.map((node) => cleanText(node.text))
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join("\n")
|
|
|
|
|
|
|
|
|
|
const employmentType = overviewValue(root, "Jobtype")
|
|
|
|
|
const applyUrl = normalizeUrl(root.querySelector("a.action.primary")?.getAttribute("href"))
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
slug,
|
|
|
|
|
url,
|
|
|
|
|
title,
|
|
|
|
|
datePosted: overviewValue(root, "Udgivet") ?? "",
|
|
|
|
|
validThrough: overviewValue(root, "Ansøgningsfrist"),
|
|
|
|
|
employmentType: employmentType ? [employmentType] : [],
|
|
|
|
|
hiringOrganization: {
|
|
|
|
|
name: companyName,
|
|
|
|
|
logo: normalizeUrl(logoSrc),
|
|
|
|
|
},
|
|
|
|
|
jobLocation: {
|
|
|
|
|
streetAddress: workplace,
|
|
|
|
|
addressLocality: null,
|
|
|
|
|
addressRegion: null,
|
|
|
|
|
postalCode: null,
|
|
|
|
|
addressCountry: "DK",
|
|
|
|
|
},
|
|
|
|
|
description,
|
|
|
|
|
applyUrl,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function parseJobPostingFromHtml(html: string, slug: string, url: string): DetailResult {
|
|
|
|
|
const root = parse(html)
|
|
|
|
|
const jobPosting = findJobPostingJsonLd(root)
|
|
|
|
|
return jobPosting ? fromJsonLd(jobPosting, slug, url) : fromRenderedHtml(root, slug, url)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 17:39:47 +02:00
|
|
|
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",
|
2026-08-05 06:34:00 +02:00
|
|
|
"User-Agent": "Mozilla/5.0 (compatible; jobdanmark-cli/1.0)",
|
2026-07-07 17:39:47 +02:00
|
|
|
},
|
2026-07-20 11:19:27 -07:00
|
|
|
signal: AbortSignal.timeout(15000),
|
2026-07-07 17:39:47 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
if (signal.aborted) return
|
|
|
|
|
|
2026-07-10 13:05:40 +07:00
|
|
|
const output = parseJobPostingFromHtml(html, slug, url)
|
2026-07-07 17:39:47 +02:00
|
|
|
|
|
|
|
|
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")
|
2026-07-10 13:05:40 +07:00
|
|
|
} else if (err instanceof Error && err.message.includes("Failed to parse")) {
|
|
|
|
|
writeError(err.message, "PARSE_ERROR")
|
2026-07-07 17:39:47 +02:00
|
|
|
} else {
|
|
|
|
|
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
|
|
|
|
|
}
|
|
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-10 13:05:40 +07:00
|
|
|
function outputPlain(data: DetailResult): void {
|
2026-07-07 17:39:47 +02:00
|
|
|
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}`)
|
2026-07-10 13:05:40 +07:00
|
|
|
const org = data.hiringOrganization
|
2026-07-07 17:39:47 +02:00
|
|
|
console.log(`company: ${org.name}`)
|
2026-07-10 13:05:40 +07:00
|
|
|
const loc = data.jobLocation
|
2026-07-07 17:39:47 +02:00
|
|
|
console.log(`location: ${[loc.streetAddress, loc.addressLocality, loc.postalCode, loc.addressCountry].filter(Boolean).join(", ")}`)
|
2026-07-10 13:05:40 +07:00
|
|
|
if (data.applyUrl) console.log(`applyUrl: ${data.applyUrl}`)
|
2026-07-07 17:39:47 +02:00
|
|
|
console.log(`description: ${data.description}`)
|
|
|
|
|
}
|