mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 08:36:25 +00:00
146 lines
5.1 KiB
TypeScript
146 lines
5.1 KiB
TypeScript
import { defineCommand, option } from "@bunli/core"
|
|
import { z } from "zod"
|
|
import { fetchWithUA, normalizeJobId, parseJobPostingJsonLd, writeError, BASE_URL } from "../helpers.js"
|
|
|
|
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 rawId = positional[0]
|
|
if (!rawId) {
|
|
writeError("Job ID is required", "MISSING_REQUIRED")
|
|
process.exit(1)
|
|
}
|
|
|
|
const id = normalizeJobId(rawId)
|
|
if (!id) {
|
|
writeError(`Could not extract job ID from "${rawId}"`, "BAD_ID")
|
|
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 jobPosting = parseJobPostingJsonLd(html)
|
|
|
|
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)
|
|
}
|