diff --git a/.agents/skills/jobbank-search/cli/src/commands/detail.ts b/.agents/skills/jobbank-search/cli/src/commands/detail.ts index 5da2d6e..4d5aa72 100644 --- a/.agents/skills/jobbank-search/cli/src/commands/detail.ts +++ b/.agents/skills/jobbank-search/cli/src/commands/detail.ts @@ -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 { diff --git a/.agents/skills/jobbank-search/cli/src/helpers.ts b/.agents/skills/jobbank-search/cli/src/helpers.ts index d2a5089..249458e 100644 --- a/.agents/skills/jobbank-search/cli/src/helpers.ts +++ b/.agents/skills/jobbank-search/cli/src/helpers.ts @@ -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 | null { diff --git a/.agents/skills/jobbank-search/cli/tests/detail-url-normalization.test.ts b/.agents/skills/jobbank-search/cli/tests/detail-url-normalization.test.ts new file mode 100644 index 0000000..55e5a6b --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tests/detail-url-normalization.test.ts @@ -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") + }) +}) diff --git a/.agents/skills/jobdanmark-search/cli/src/commands/detail.ts b/.agents/skills/jobdanmark-search/cli/src/commands/detail.ts index 4bca9db..b3e7648 100644 --- a/.agents/skills/jobdanmark-search/cli/src/commands/detail.ts +++ b/.agents/skills/jobdanmark-search/cli/src/commands/detail.ts @@ -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 { diff --git a/.agents/skills/jobdanmark-search/cli/src/helpers.ts b/.agents/skills/jobdanmark-search/cli/src/helpers.ts index f190cf8..4a65e08 100644 --- a/.agents/skills/jobdanmark-search/cli/src/helpers.ts +++ b/.agents/skills/jobdanmark-search/cli/src/helpers.ts @@ -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 +} + diff --git a/.agents/skills/jobdanmark-search/cli/tests/detail-url-normalization.test.ts b/.agents/skills/jobdanmark-search/cli/tests/detail-url-normalization.test.ts new file mode 100644 index 0000000..6277856 --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/tests/detail-url-normalization.test.ts @@ -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") + }) +}) diff --git a/.agents/skills/jobnet-search/cli/src/commands/detail.ts b/.agents/skills/jobnet-search/cli/src/commands/detail.ts index 08ed460..16bf07e 100644 --- a/.agents/skills/jobnet-search/cli/src/commands/detail.ts +++ b/.agents/skills/jobnet-search/cli/src/commands/detail.ts @@ -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(`/FindJob/JobAdDetails/${id}`, { diff --git a/.agents/skills/jobnet-search/cli/src/helpers.ts b/.agents/skills/jobnet-search/cli/src/helpers.ts index 6b448bd..f7147da 100644 --- a/.agents/skills/jobnet-search/cli/src/helpers.ts +++ b/.agents/skills/jobnet-search/cli/src/helpers.ts @@ -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 +} + diff --git a/.agents/skills/jobnet-search/cli/tests/detail-url-normalization.test.ts b/.agents/skills/jobnet-search/cli/tests/detail-url-normalization.test.ts new file mode 100644 index 0000000..4af2711 --- /dev/null +++ b/.agents/skills/jobnet-search/cli/tests/detail-url-normalization.test.ts @@ -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") + }) +}) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a32c78..74be7a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `. 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 ` controls scoring independently of `--top`, and the report makes deferred work visible so