Files
ai-job-search/.agents/skills/jobdanmark-search/cli/src/commands/detail.ts
T
Mads LorentzenandClaude Opus 5 dab215073e fix(jobdanmark-search): normalize the detail command's HTML fallback branch
The JSON-LD and rendered-HTML branches returned structurally different
records: the fallback emitted raw DD-MM-YYYY page text as datePosted,
free text (including the literal "Loebende") as validThrough, and a
hardcoded null addressLocality. Overview dates now convert to ISO,
Loebende maps to null, and the locality derives from the workplace
address via the same exported extractCity search uses. Review finding
F25 (2026-08-19).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 20:53:24 +02:00

295 lines
9.2 KiB
TypeScript

import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { parse } from "node-html-parser"
import { BASE_URL, writeError } from "../helpers.js"
import { extractCity, toContractDate } from "./search.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
}
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(/&amp;/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,
}
}
/**
* Normalize a date read from the rendered page's overview list. The page
* writes DD-MM-YYYY (sometimes with a trailing time, "02-08-2026 23.59"),
* and the deadline can be the free-text "Løbende" (rolling) - jobbank maps
* its equivalent to null, and every consumer does date arithmetic on the
* value. The JSON-LD branch gets schema.org ISO dates and needs none of this.
*/
function normalizeOverviewDate(value: string | null): string | null {
if (!value) return null
const trimmed = value.trim()
if (/^løbende$/iu.test(trimmed)) return null
const match = trimmed.match(/^(\d{2})-(\d{2})-(\d{4})/)
return match ? `${match[3]}-${match[2]}-${match[1]}` : toContractDate(trimmed)
}
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 ?? "")
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")
) {
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: normalizeOverviewDate(overviewValue(root, "Udgivet")) ?? "",
validThrough: normalizeOverviewDate(overviewValue(root, "Ansøgningsfrist")),
employmentType: employmentType ? [employmentType] : [],
hiringOrganization: {
name: companyName,
logo: normalizeUrl(logoSrc),
},
jobLocation: {
streetAddress: workplace,
addressLocality: extractCity(workplace),
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)
}
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 (compatible; jobdanmark-cli/1.0)",
},
signal: AbortSignal.timeout(15000),
})
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
const output = parseJobPostingFromHtml(html, slug, url)
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 if (err instanceof Error && err.message.includes("Failed to parse")) {
writeError(err.message, "PARSE_ERROR")
} else {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
}
process.exit(1)
}
},
})
function outputPlain(data: DetailResult): 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
console.log(`company: ${org.name}`)
const loc = data.jobLocation
console.log(`location: ${[loc.streetAddress, loc.addressLocality, loc.postalCode, loc.addressCountry].filter(Boolean).join(", ")}`)
if (data.applyUrl) console.log(`applyUrl: ${data.applyUrl}`)
console.log(`description: ${data.description}`)
}