mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
fix(portal-clis): accept full posting URLs in jobbank, jobdanmark, and jobnet detail commands (#430)
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { defineCommand, option } from "@bunli/core"
|
import { defineCommand, option } from "@bunli/core"
|
||||||
import { z } from "zod"
|
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({
|
export const detail = defineCommand({
|
||||||
name: "detail",
|
name: "detail",
|
||||||
@@ -13,12 +13,18 @@ export const detail = defineCommand({
|
|||||||
handler: async ({ positional, flags, signal }) => {
|
handler: async ({ positional, flags, signal }) => {
|
||||||
if (signal.aborted) return
|
if (signal.aborted) return
|
||||||
|
|
||||||
const id = positional[0]
|
const rawId = positional[0]
|
||||||
if (!id) {
|
if (!rawId) {
|
||||||
writeError("Job ID is required", "MISSING_REQUIRED")
|
writeError("Job ID is required", "MISSING_REQUIRED")
|
||||||
process.exit(1)
|
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}/`
|
const url = `${BASE_URL}/job/${id}/`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -159,10 +159,16 @@ export function parseRssDescription(desc: string): ParsedDescription {
|
|||||||
return { jobType, company, location, deadline }
|
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 {
|
export function extractJobIdFromUrl(url: string): string {
|
||||||
// URL format: https://jobbank.dk/job/{id}/{company-slug}/{title-slug}
|
// URL format: https://jobbank.dk/job/{id}/{company-slug}/{title-slug}
|
||||||
const match = url.match(/\/job\/(\d+)\//)
|
return normalizeJobId(url) ?? ""
|
||||||
return match ? match[1] : ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function findJobPosting(value: unknown): Record<string, unknown> | null {
|
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 { defineCommand, option } from "@bunli/core"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { parse } from "node-html-parser"
|
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"
|
import { extractCity, toContractDate } from "./search.js"
|
||||||
|
|
||||||
interface JsonLdJobPosting {
|
interface JsonLdJobPosting {
|
||||||
@@ -226,12 +226,18 @@ export const detail = defineCommand({
|
|||||||
handler: async ({ flags, positional, signal }) => {
|
handler: async ({ flags, positional, signal }) => {
|
||||||
if (signal.aborted) return
|
if (signal.aborted) return
|
||||||
|
|
||||||
const slug = positional[0]
|
const rawSlug = positional[0]
|
||||||
if (!slug) {
|
if (!rawSlug) {
|
||||||
writeError("slug argument is required", "MISSING_REQUIRED")
|
writeError("slug argument is required", "MISSING_REQUIRED")
|
||||||
process.exit(1)
|
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}`
|
const url = `${BASE_URL}/job/${slug}`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -71,3 +71,13 @@ export function writeError(error: string, code: string): void {
|
|||||||
export function stripHtml(html: string): string {
|
export function stripHtml(html: string): string {
|
||||||
return html.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim()
|
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 { defineCommand, option } from "@bunli/core"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { apiFetch, writeError, stripHtml } from "../helpers.js"
|
import { apiFetch, normalizeJobId, writeError, stripHtml } from "../helpers.js"
|
||||||
|
|
||||||
export interface DetailApiResponse {
|
export interface DetailApiResponse {
|
||||||
id: string
|
id: string
|
||||||
@@ -87,12 +87,18 @@ export const detail = defineCommand({
|
|||||||
handler: async ({ positional, flags, signal }) => {
|
handler: async ({ positional, flags, signal }) => {
|
||||||
if (signal.aborted) return
|
if (signal.aborted) return
|
||||||
|
|
||||||
const id = positional[0] as string | undefined
|
const rawId = positional[0] as string | undefined
|
||||||
if (!id) {
|
if (!rawId) {
|
||||||
writeError("Job ad ID is required", "MISSING_REQUIRED")
|
writeError("Job ad ID is required", "MISSING_REQUIRED")
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const id = normalizeJobId(rawId)
|
||||||
|
if (!id) {
|
||||||
|
writeError(`Could not parse job ad ID from "${rawId}"`, "BAD_ID")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = prepareDetail(
|
const data = prepareDetail(
|
||||||
await apiFetch<DetailApiResponse>(`/FindJob/JobAdDetails/${id}`, {
|
await apiFetch<DetailApiResponse>(`/FindJob/JobAdDetails/${id}`, {
|
||||||
|
|||||||
@@ -55,3 +55,13 @@ export function stripHtml(html: string): string {
|
|||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim()
|
.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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -41,6 +41,15 @@ per-file diff commands.
|
|||||||
|
|
||||||
### Fixed
|
### 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
|
- **`/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
|
eligible jobs instead of attempting the entire backlog. `--limit <N>` controls
|
||||||
scoring independently of `--top`, and the report makes deferred work visible so
|
scoring independently of `--top`, and the report makes deferred work visible so
|
||||||
|
|||||||
Reference in New Issue
Block a user