fix(portal-clis): accept full posting URLs in jobbank, jobdanmark, and jobnet detail commands (#430)

This commit is contained in:
Abhinav
2026-09-06 10:45:44 +02:00
committed by GitHub
parent fd89eac178
commit 71674d0220
10 changed files with 204 additions and 11 deletions
@@ -1,6 +1,6 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { fetchWithUA, parseJobPostingJsonLd, writeError, BASE_URL } from "../helpers.js"
import { fetchWithUA, normalizeJobId, parseJobPostingJsonLd, writeError, BASE_URL } from "../helpers.js"
export const detail = defineCommand({
name: "detail",
@@ -13,12 +13,18 @@ export const detail = defineCommand({
handler: async ({ positional, flags, signal }) => {
if (signal.aborted) return
const id = positional[0]
if (!id) {
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 {
@@ -159,10 +159,16 @@ export function parseRssDescription(desc: string): ParsedDescription {
return { jobType, company, location, deadline }
}
export function normalizeJobId(input: string): string | null {
const trimmed = input.trim()
if (/^\d+$/.test(trimmed)) return trimmed
const match = trimmed.match(/\/job\/(\d+)(?:\/|$|\?|#)/)
return match ? match[1] : null
}
export function extractJobIdFromUrl(url: string): string {
// URL format: https://jobbank.dk/job/{id}/{company-slug}/{title-slug}
const match = url.match(/\/job\/(\d+)\//)
return match ? match[1] : ""
return normalizeJobId(url) ?? ""
}
function findJobPosting(value: unknown): Record<string, unknown> | null {
@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test"
import { normalizeJobId } from "../src/helpers.js"
import { runCLI } from "./helpers.js"
describe("jobbank-search normalizeJobId", () => {
test("accepts bare numeric ID", () => {
expect(normalizeJobId("304212")).toBe("304212")
expect(normalizeJobId(" 12345 ")).toBe("12345")
})
test("extracts ID from full URL with trailing slash", () => {
expect(normalizeJobId("https://jobbank.dk/job/304212/")).toBe("304212")
})
test("extracts ID from full URL without trailing slash", () => {
expect(normalizeJobId("https://jobbank.dk/job/304212")).toBe("304212")
})
test("extracts ID from full URL with company/role slug segments", () => {
expect(normalizeJobId("https://jobbank.dk/job/304212/acme-corp/software-developer")).toBe("304212")
expect(normalizeJobId("https://jobbank.dk/job/304212/acme-corp/software-developer/")).toBe("304212")
})
test("extracts ID from URL with query parameters and hash fragments", () => {
expect(normalizeJobId("https://jobbank.dk/job/304212?ref=search&page=1")).toBe("304212")
expect(normalizeJobId("https://jobbank.dk/job/304212#apply")).toBe("304212")
})
test("rejects invalid non-numeric strings and unrelated URLs", () => {
expect(normalizeJobId("abc")).toBeNull()
expect(normalizeJobId("https://example.com/other/12345")).toBeNull()
expect(normalizeJobId("")).toBeNull()
})
test("CLI detail command rejects invalid ID format with BAD_ID", async () => {
const result = await runCLI(["detail", "invalid-id-format"])
expect(result.exitCode).toBe(1)
const err = JSON.parse(result.stderr)
expect(err.code).toBe("BAD_ID")
})
})
@@ -1,7 +1,7 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { parse } from "node-html-parser"
import { BASE_URL, writeError } from "../helpers.js"
import { BASE_URL, normalizeSlug, writeError } from "../helpers.js"
import { extractCity, toContractDate } from "./search.js"
interface JsonLdJobPosting {
@@ -226,12 +226,18 @@ export const detail = defineCommand({
handler: async ({ flags, positional, signal }) => {
if (signal.aborted) return
const slug = positional[0]
if (!slug) {
const rawSlug = positional[0]
if (!rawSlug) {
writeError("slug argument is required", "MISSING_REQUIRED")
process.exit(1)
}
const slug = normalizeSlug(rawSlug)
if (!slug) {
writeError(`Could not extract slug from "${rawSlug}"`, "BAD_ID")
process.exit(1)
}
const url = `${BASE_URL}/job/${slug}`
try {
@@ -71,3 +71,13 @@ export function writeError(error: string, code: string): void {
export function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim()
}
export function normalizeSlug(input: string): string | null {
const trimmed = input.trim()
if (!trimmed) return null
const match = trimmed.match(/\/job\/([^/?#]+)/)
if (match) return match[1]
if (/^[a-zA-Z0-9_-]+$/.test(trimmed)) return trimmed
return null
}
@@ -0,0 +1,45 @@
import { describe, expect, test } from "bun:test"
import { normalizeSlug } from "../src/helpers.js"
import { runCLI } from "./helpers.js"
describe("jobdanmark-search normalizeSlug", () => {
test("accepts bare slug", () => {
expect(normalizeSlug("software-udvikler-12345")).toBe("software-udvikler-12345")
expect(normalizeSlug(" senior_dev_67890 ")).toBe("senior_dev_67890")
})
test("extracts slug from full URL with trailing slash", () => {
expect(normalizeSlug("https://jobdanmark.dk/job/software-udvikler-12345/")).toBe("software-udvikler-12345")
})
test("extracts slug from full URL without trailing slash", () => {
expect(normalizeSlug("https://jobdanmark.dk/job/software-udvikler-12345")).toBe("software-udvikler-12345")
})
test("extracts slug from relative URL path", () => {
expect(normalizeSlug("/job/software-udvikler-12345")).toBe("software-udvikler-12345")
expect(normalizeSlug("/job/software-udvikler-12345/")).toBe("software-udvikler-12345")
})
test("extracts slug from URL with query parameters and hash fragments", () => {
expect(normalizeSlug("https://jobdanmark.dk/job/software-udvikler-12345?utm_source=test&ref=1")).toBe(
"software-udvikler-12345",
)
expect(normalizeSlug("https://jobdanmark.dk/job/software-udvikler-12345#apply")).toBe(
"software-udvikler-12345",
)
})
test("rejects empty string and invalid URLs", () => {
expect(normalizeSlug("")).toBeNull()
expect(normalizeSlug(" ")).toBeNull()
expect(normalizeSlug("https://example.com/other/test")).toBeNull()
})
test("CLI detail command rejects invalid slug format with BAD_ID", async () => {
const result = await runCLI(["detail", "https://invalid.com/not-a-job"])
expect(result.exitCode).toBe(1)
const err = JSON.parse(result.stderr)
expect(err.code).toBe("BAD_ID")
})
})
@@ -1,6 +1,6 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { apiFetch, writeError, stripHtml } from "../helpers.js"
import { apiFetch, normalizeJobId, writeError, stripHtml } from "../helpers.js"
export interface DetailApiResponse {
id: string
@@ -87,12 +87,18 @@ export const detail = defineCommand({
handler: async ({ positional, flags, signal }) => {
if (signal.aborted) return
const id = positional[0] as string | undefined
if (!id) {
const rawId = positional[0] as string | undefined
if (!rawId) {
writeError("Job ad ID is required", "MISSING_REQUIRED")
process.exit(1)
}
const id = normalizeJobId(rawId)
if (!id) {
writeError(`Could not parse job ad ID from "${rawId}"`, "BAD_ID")
process.exit(1)
}
try {
const data = prepareDetail(
await apiFetch<DetailApiResponse>(`/FindJob/JobAdDetails/${id}`, {
@@ -55,3 +55,13 @@ export function stripHtml(html: string): string {
.replace(/\s+/g, " ")
.trim()
}
export function normalizeJobId(input: string): string | null {
const trimmed = input.trim()
if (!trimmed) return null
if (/^[a-zA-Z0-9_-]+$/.test(trimmed)) return trimmed
const match = trimmed.match(/(?:\/find-job\/|\/JobAdDetails\/|\/Details\/)(?:detaljer\/)?([a-zA-Z0-9_-]+)(?:\/|$|\?|#)/i)
if (match) return match[1]
return null
}
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test"
import { normalizeJobId } from "../src/helpers.js"
import { runCLI } from "./helpers.js"
describe("jobnet-search normalizeJobId", () => {
test("accepts bare numeric ID", () => {
expect(normalizeJobId("6123456")).toBe("6123456")
expect(normalizeJobId(" 6123456 ")).toBe("6123456")
})
test("accepts alphanumeric ID", () => {
expect(normalizeJobId("E123456")).toBe("E123456")
expect(normalizeJobId("job_12345")).toBe("job_12345")
})
test("extracts ID from /find-job/ URL with trailing slash", () => {
expect(normalizeJobId("https://jobnet.dk/find-job/6123456/")).toBe("6123456")
})
test("extracts ID from /find-job/ URL without trailing slash", () => {
expect(normalizeJobId("https://jobnet.dk/find-job/6123456")).toBe("6123456")
})
test("extracts ID from /find-job/detaljer/ URL", () => {
expect(normalizeJobId("https://jobnet.dk/find-job/detaljer/6123456")).toBe("6123456")
expect(normalizeJobId("https://jobnet.dk/find-job/detaljer/6123456/")).toBe("6123456")
})
test("extracts ID from /FindJob/JobAdDetails/ URL", () => {
expect(normalizeJobId("https://jobnet.dk/FindJob/JobAdDetails/6123456")).toBe("6123456")
})
test("extracts ID from legacy /CV/FindWork/Details/ URL", () => {
expect(normalizeJobId("https://job.jobnet.dk/CV/FindWork/Details/6123456")).toBe("6123456")
})
test("extracts ID from URL with query parameters and hash fragments", () => {
expect(normalizeJobId("https://jobnet.dk/find-job/6123456?ref=share&utm=test")).toBe("6123456")
expect(normalizeJobId("https://jobnet.dk/find-job/6123456#main")).toBe("6123456")
})
test("rejects empty string and invalid URLs", () => {
expect(normalizeJobId("")).toBeNull()
expect(normalizeJobId(" ")).toBeNull()
expect(normalizeJobId("https://example.com/other/6123456")).toBeNull()
})
test("CLI detail command rejects invalid ID format with BAD_ID", async () => {
const result = await runCLI(["detail", "https://invalid.com/not-jobnet"])
expect(result.exitCode).toBe(1)
const err = JSON.parse(result.stderr)
expect(err.code).toBe("BAD_ID")
})
})
+9
View File
@@ -41,6 +41,15 @@ per-file diff commands.
### Fixed
- **`jobbank-search`, `jobdanmark-search`, and `jobnet-search` detail commands now accept full URLs** -
the portal contract specifies `detail <id|url>`. Passing a full posting URL (with or without
trailing slashes, slug segments, or query parameters) previously caused `jobbank-search` and
`jobdanmark-search` to construct invalid double-URL strings, and `jobnet-search` to interpolate the
full URL into the API endpoint path. All three detail handlers now extract and normalize the
underlying ID or slug via dedicated helper functions, and exit 1 with code `BAD_ID` on unparseable
inputs, matching `linkedin-search` and `freehire-search`. Pinned by 24 unit tests across the three
CLIs' `detail-url-normalization.test.ts`.
- **`/rank` now bounds each scoring batch** (#395) - a bare run scores at most 10
eligible jobs instead of attempting the entire backlog. `--limit <N>` controls
scoring independently of `--top`, and the report makes deferred work visible so