mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
* fix(jobnet-search): fallback to search endpoint on detail 404 for external ads (#432) * fix(jobnet-search): mark external detail fallback degraded and omit unknown fields * changelog: add the #432 entry for the external-ad fallback Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fqqLgQSnwgWkv98twQhHi --------- Co-authored-by: Mads Lorentzen <madslorentzen17@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Mads Lorentzen
parent
ab5732138a
commit
f8c606fb3d
@@ -1,6 +1,7 @@
|
|||||||
import { defineCommand, option } from "@bunli/core"
|
import { defineCommand, option } from "@bunli/core"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { apiFetch, normalizeJobId, writeError, stripHtml } from "../helpers.js"
|
import { apiFetch, normalizeJobId, writeError, stripHtml } from "../helpers.js"
|
||||||
|
import type { JobAdRaw, SearchApiResponse } from "./search.js"
|
||||||
|
|
||||||
export interface DetailApiResponse {
|
export interface DetailApiResponse {
|
||||||
id: string
|
id: string
|
||||||
@@ -8,13 +9,14 @@ export interface DetailApiResponse {
|
|||||||
body: string
|
body: string
|
||||||
publicationDateTime: string
|
publicationDateTime: string
|
||||||
unpublicationDateTime: string | null
|
unpublicationDateTime: string | null
|
||||||
approvalStatus: string
|
approvalStatus: string | null
|
||||||
views: number
|
views: number | null
|
||||||
createdDateTime: string
|
createdDateTime: string
|
||||||
updatedDateTime: string
|
updatedDateTime: string
|
||||||
isAnonymousEmployer: boolean
|
isAnonymousEmployer: boolean | null
|
||||||
hasLogo: boolean
|
hasLogo: boolean
|
||||||
logoUrl: string | null
|
logoUrl: string | null
|
||||||
|
isExternal?: boolean
|
||||||
employer: {
|
employer: {
|
||||||
cvrNumber: string | null
|
cvrNumber: string | null
|
||||||
pNumber: string | null
|
pNumber: string | null
|
||||||
@@ -22,7 +24,7 @@ export interface DetailApiResponse {
|
|||||||
hasCompanyLogo: boolean
|
hasCompanyLogo: boolean
|
||||||
}
|
}
|
||||||
job: {
|
job: {
|
||||||
type: string
|
type: string | null
|
||||||
address: {
|
address: {
|
||||||
streetName: string | null
|
streetName: string | null
|
||||||
city: string | null
|
city: string | null
|
||||||
@@ -31,21 +33,21 @@ export interface DetailApiResponse {
|
|||||||
countryCode: string
|
countryCode: string
|
||||||
countryName: string
|
countryName: string
|
||||||
}
|
}
|
||||||
noFixedWorkplace: boolean
|
noFixedWorkplace: boolean | null
|
||||||
isLimitedPeriod: boolean
|
isLimitedPeriod: boolean | null
|
||||||
isDisabilityFriendly: boolean
|
isDisabilityFriendly: boolean | null
|
||||||
isPartTime: boolean
|
isPartTime: boolean | null
|
||||||
employmentDate: string | null
|
employmentDate: string | null
|
||||||
conceptUriDa: string | null
|
conceptUriDa: string | null
|
||||||
preferredLabelDa: string | null
|
preferredLabelDa: string | null
|
||||||
driversLicenses: unknown[]
|
driversLicenses: unknown[]
|
||||||
classifications: unknown[]
|
classifications: unknown[]
|
||||||
shifts: unknown[]
|
shifts: unknown[]
|
||||||
isFavorite: boolean
|
isFavorite: boolean | null
|
||||||
}
|
}
|
||||||
application: {
|
application: {
|
||||||
deadlineDate: string | null
|
deadlineDate: string | null
|
||||||
availablePositions: number
|
availablePositions: number | null
|
||||||
contactPersons: Array<{
|
contactPersons: Array<{
|
||||||
firstNames: string | null
|
firstNames: string | null
|
||||||
lastName: string | null
|
lastName: string | null
|
||||||
@@ -53,12 +55,73 @@ export interface DetailApiResponse {
|
|||||||
}>
|
}>
|
||||||
url: string | null
|
url: string | null
|
||||||
urlText: string | null
|
urlText: string | null
|
||||||
isApplicationDeadlineASAP: boolean
|
isApplicationDeadlineASAP: boolean | null
|
||||||
}
|
}
|
||||||
organisationTypeId: number | null
|
organisationTypeId: number | null
|
||||||
user: string | null
|
user: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a raw JobAd from the search endpoint to a DetailApiResponse.
|
||||||
|
* Used as a fallback when /FindJob/JobAdDetails/<id> returns 404 for external ads (#432).
|
||||||
|
*/
|
||||||
|
export function mapSearchAdToDetail(raw: JobAdRaw & { jobAdUrl?: string | null; jobAnnouncementTypeName?: string | null }): DetailApiResponse {
|
||||||
|
const street = raw.workPlaceAddress ? raw.workPlaceAddress.trim() : null
|
||||||
|
return {
|
||||||
|
id: raw.jobAdId,
|
||||||
|
title: raw.title,
|
||||||
|
body: raw.description ?? "",
|
||||||
|
publicationDateTime: raw.publicationDate ?? "",
|
||||||
|
unpublicationDateTime: null,
|
||||||
|
approvalStatus: null,
|
||||||
|
views: null,
|
||||||
|
createdDateTime: raw.publicationDate ?? "",
|
||||||
|
updatedDateTime: raw.publicationDate ?? "",
|
||||||
|
isAnonymousEmployer: null,
|
||||||
|
hasLogo: Boolean(raw.hasLogo),
|
||||||
|
logoUrl: raw.logoUrl ?? null,
|
||||||
|
isExternal: true,
|
||||||
|
employer: {
|
||||||
|
cvrNumber: raw.cvr ?? null,
|
||||||
|
pNumber: null,
|
||||||
|
name: raw.hiringOrgName ?? "",
|
||||||
|
hasCompanyLogo: Boolean(raw.hasLogo),
|
||||||
|
},
|
||||||
|
job: {
|
||||||
|
type: raw.jobAnnouncementTypeName ?? (raw.workHourPartTime != null ? (raw.workHourPartTime ? "PartTime" : "FullTime") : null),
|
||||||
|
address: {
|
||||||
|
streetName: street && street.length > 0 ? street : null,
|
||||||
|
city: raw.postalDistrictName ?? raw.municipality ?? null,
|
||||||
|
postalCode: raw.postalCode ? String(raw.postalCode) : null,
|
||||||
|
municipality: raw.municipality ?? null,
|
||||||
|
countryCode: raw.country === "Danmark" ? "DK" : (raw.country || "DK"),
|
||||||
|
countryName: raw.country || "Danmark",
|
||||||
|
},
|
||||||
|
noFixedWorkplace: null,
|
||||||
|
isLimitedPeriod: null,
|
||||||
|
isDisabilityFriendly: null,
|
||||||
|
isPartTime: raw.workHourPartTime != null ? Boolean(raw.workHourPartTime) : null,
|
||||||
|
employmentDate: null,
|
||||||
|
conceptUriDa: raw.conceptUriDa ?? null,
|
||||||
|
preferredLabelDa: raw.occupation ?? null,
|
||||||
|
driversLicenses: [],
|
||||||
|
classifications: [],
|
||||||
|
shifts: [],
|
||||||
|
isFavorite: raw.isFavorite != null ? Boolean(raw.isFavorite) : null,
|
||||||
|
},
|
||||||
|
application: {
|
||||||
|
deadlineDate: raw.applicationDeadline ?? null,
|
||||||
|
availablePositions: null,
|
||||||
|
contactPersons: [],
|
||||||
|
url: raw.jobAdUrl && raw.jobAdUrl.trim().length > 0 ? raw.jobAdUrl.trim() : null,
|
||||||
|
urlText: null,
|
||||||
|
isApplicationDeadlineASAP: raw.applicationDeadlineStatus ? raw.applicationDeadlineStatus === "NotDisclosed" : null,
|
||||||
|
},
|
||||||
|
organisationTypeId: null,
|
||||||
|
user: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize a raw detail response before any output format sees it.
|
* Normalize a raw detail response before any output format sees it.
|
||||||
*
|
*
|
||||||
@@ -99,14 +162,46 @@ export const detail = defineCommand({
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let data: DetailApiResponse | null = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = prepareDetail(
|
data = prepareDetail(
|
||||||
await apiFetch<DetailApiResponse>(`/FindJob/JobAdDetails/${id}`, {
|
await apiFetch<DetailApiResponse>(`/FindJob/JobAdDetails/${id}`, {
|
||||||
incrementViews: "false",
|
incrementViews: "false",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
if (message.includes("404") || message.includes("Not Found")) {
|
||||||
|
// Fallback for external ads: JobAdDetails returns 404 for ads with isExternal: true,
|
||||||
|
// but /FindJob/Search returns the full ad object including HTML description (#432).
|
||||||
|
try {
|
||||||
|
const searchResult = await apiFetch<SearchApiResponse>("/FindJob/Search", {
|
||||||
|
searchString: id,
|
||||||
|
resultsPerPage: "5",
|
||||||
|
pageNumber: "1",
|
||||||
|
orderType: "PublicationDate",
|
||||||
|
})
|
||||||
|
const match = searchResult.jobAds?.find((ad) => ad.jobAdId === id)
|
||||||
|
if (match) {
|
||||||
|
process.stderr.write("note: detail endpoint returned 404; retrieved external posting summary from search endpoint\n")
|
||||||
|
data = prepareDetail(mapSearchAdToDetail(match))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If fallback search fails, fall through to NOT_FOUND
|
||||||
|
}
|
||||||
|
|
||||||
if (signal.aborted) return
|
if (!data) {
|
||||||
|
writeError("Job ad not found", "NOT_FOUND")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
writeError(message, "API_ERROR")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal.aborted || !data) return
|
||||||
|
|
||||||
if (flags.format === "json") {
|
if (flags.format === "json") {
|
||||||
console.log(JSON.stringify(data, null, 2))
|
console.log(JSON.stringify(data, null, 2))
|
||||||
@@ -115,15 +210,6 @@ export const detail = defineCommand({
|
|||||||
} else {
|
} else {
|
||||||
outputPlain(data)
|
outputPlain(data)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
if (message.includes("404") || message.includes("Not Found")) {
|
|
||||||
writeError("Job ad not found", "NOT_FOUND")
|
|
||||||
} else {
|
|
||||||
writeError(message, "API_ERROR")
|
|
||||||
}
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -131,13 +217,13 @@ function outputTable(data: DetailApiResponse): void {
|
|||||||
console.log(`ID: ${data.id}`)
|
console.log(`ID: ${data.id}`)
|
||||||
console.log(`Title: ${data.title}`)
|
console.log(`Title: ${data.title}`)
|
||||||
console.log(`Employer: ${data.employer.name}`)
|
console.log(`Employer: ${data.employer.name}`)
|
||||||
console.log(`Type: ${data.job.type}`)
|
console.log(`Type: ${data.job.type ?? "-"}`)
|
||||||
console.log(`City: ${data.job.address.city ?? "-"}`)
|
console.log(`City: ${data.job.address.city ?? "-"}`)
|
||||||
console.log(`Postal: ${data.job.address.postalCode ?? "-"}`)
|
console.log(`Postal: ${data.job.address.postalCode ?? "-"}`)
|
||||||
console.log(`Country: ${data.job.address.countryName}`)
|
console.log(`Country: ${data.job.address.countryName}`)
|
||||||
console.log(`Published: ${data.publicationDateTime}`)
|
console.log(`Published: ${data.publicationDateTime}`)
|
||||||
console.log(`Deadline: ${data.application.deadlineDate ?? "-"}`)
|
console.log(`Deadline: ${data.application.deadlineDate ?? "-"}`)
|
||||||
console.log(`Positions: ${data.application.availablePositions}`)
|
console.log(`Positions: ${data.application.availablePositions ?? "-"}`)
|
||||||
console.log(`Apply URL: ${data.application.url ?? "-"}`)
|
console.log(`Apply URL: ${data.application.url ?? "-"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +238,7 @@ export function formatDetailPlain(data: DetailApiResponse): string {
|
|||||||
`Location: ${data.job.address.city ?? "-"}, ${data.job.address.countryName}`,
|
`Location: ${data.job.address.city ?? "-"}, ${data.job.address.countryName}`,
|
||||||
`Published: ${data.publicationDateTime}`,
|
`Published: ${data.publicationDateTime}`,
|
||||||
`Deadline: ${data.application.deadlineDate ?? "-"}`,
|
`Deadline: ${data.application.deadlineDate ?? "-"}`,
|
||||||
`Positions: ${data.application.availablePositions}`,
|
`Positions: ${data.application.availablePositions ?? "-"}`,
|
||||||
]
|
]
|
||||||
|
|
||||||
if (data.application.url) {
|
if (data.application.url) {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { mapSearchAdToDetail } from "../src/commands/detail"
|
||||||
|
import type { JobAdRaw } from "../src/commands/search"
|
||||||
|
|
||||||
|
describe("mapSearchAdToDetail (Issue #432 external ad fallback)", () => {
|
||||||
|
const sampleAd: JobAdRaw & { jobAdUrl?: string; jobAnnouncementTypeName?: string } = {
|
||||||
|
jobAdId: "ext-123",
|
||||||
|
title: "AI Technical Artist",
|
||||||
|
hiringOrgName: "Tactile Games",
|
||||||
|
occupation: "Programmør og systemudvikler",
|
||||||
|
conceptUriDa: "http://data.star.dk/esco/occupation/8b6456a3-ae9a-45a0-a65b-fed797521753",
|
||||||
|
jobAnnouncementTypeName: "Almindelige vilkår",
|
||||||
|
workHourPartTime: false,
|
||||||
|
jobAdUrl: "https://job-boards.eu.greenhouse.io/tactilegames/jobs/4890782101",
|
||||||
|
hasLogo: true,
|
||||||
|
logoUrl: "/bff/logo/123",
|
||||||
|
workPlaceAddress: " Trekronergade 26 ",
|
||||||
|
cvr: "32319882",
|
||||||
|
description: "<p>Great job opening at Tactile.</p>",
|
||||||
|
applicationDeadline: "2026-12-05T00:00:00+01:00",
|
||||||
|
applicationDeadlineStatus: "ExpirationDate",
|
||||||
|
country: "Danmark",
|
||||||
|
municipality: "København",
|
||||||
|
postalCode: 2500,
|
||||||
|
postalDistrictName: "Valby",
|
||||||
|
publicationDate: "2026-09-05T00:00:00+02:00",
|
||||||
|
isExternal: true,
|
||||||
|
isSeen: false,
|
||||||
|
isFavorite: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
test("maps all key fields correctly to DetailApiResponse format", () => {
|
||||||
|
const detail = mapSearchAdToDetail(sampleAd)
|
||||||
|
|
||||||
|
expect(detail.id).toBe("ext-123")
|
||||||
|
expect(detail.title).toBe("AI Technical Artist")
|
||||||
|
expect(detail.body).toBe("<p>Great job opening at Tactile.</p>")
|
||||||
|
expect(detail.publicationDateTime).toBe("2026-09-05T00:00:00+02:00")
|
||||||
|
expect(detail.isExternal).toBe(true)
|
||||||
|
expect(detail.views).toBeNull()
|
||||||
|
expect(detail.approvalStatus).toBeNull()
|
||||||
|
expect(detail.isAnonymousEmployer).toBeNull()
|
||||||
|
expect(detail.employer.name).toBe("Tactile Games")
|
||||||
|
expect(detail.employer.cvrNumber).toBe("32319882")
|
||||||
|
expect(detail.employer.hasCompanyLogo).toBe(true)
|
||||||
|
expect(detail.job.type).toBe("Almindelige vilkår")
|
||||||
|
expect(detail.job.address.streetName).toBe("Trekronergade 26")
|
||||||
|
expect(detail.job.address.city).toBe("Valby")
|
||||||
|
expect(detail.job.address.postalCode).toBe("2500")
|
||||||
|
expect(detail.job.address.municipality).toBe("København")
|
||||||
|
expect(detail.job.address.countryCode).toBe("DK")
|
||||||
|
expect(detail.job.address.countryName).toBe("Danmark")
|
||||||
|
expect(detail.job.isPartTime).toBe(false)
|
||||||
|
expect(detail.job.noFixedWorkplace).toBeNull()
|
||||||
|
expect(detail.job.isLimitedPeriod).toBeNull()
|
||||||
|
expect(detail.job.isDisabilityFriendly).toBeNull()
|
||||||
|
expect(detail.job.preferredLabelDa).toBe("Programmør og systemudvikler")
|
||||||
|
expect(detail.job.conceptUriDa).toBe("http://data.star.dk/esco/occupation/8b6456a3-ae9a-45a0-a65b-fed797521753")
|
||||||
|
expect(detail.application.deadlineDate).toBe("2026-12-05T00:00:00+01:00")
|
||||||
|
expect(detail.application.availablePositions).toBeNull()
|
||||||
|
expect(detail.application.url).toBe("https://job-boards.eu.greenhouse.io/tactilegames/jobs/4890782101")
|
||||||
|
expect(detail.application.isApplicationDeadlineASAP).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles empty or whitespace address gracefully", () => {
|
||||||
|
const detail = mapSearchAdToDetail({
|
||||||
|
...sampleAd,
|
||||||
|
workPlaceAddress: " ",
|
||||||
|
postalDistrictName: null,
|
||||||
|
municipality: null,
|
||||||
|
postalCode: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(detail.job.address.streetName).toBeNull()
|
||||||
|
expect(detail.job.address.city).toBeNull()
|
||||||
|
expect(detail.job.address.postalCode).toBeNull()
|
||||||
|
expect(detail.job.address.municipality).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("flags undisclosed deadline as ASAP", () => {
|
||||||
|
const detail = mapSearchAdToDetail({
|
||||||
|
...sampleAd,
|
||||||
|
applicationDeadlineStatus: "NotDisclosed",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(detail.application.isApplicationDeadlineASAP).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -15,6 +15,15 @@ per-file diff commands.
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **`jobnet-search detail` no longer reports an externally hosted ad as not found** (#432) -
|
||||||
|
Jobnet's `/FindJob/JobAdDetails/<id>` returns 404 for ads with `isExternal: true`, so `detail`
|
||||||
|
on an ad `search` had just listed exited 1 with `NOT_FOUND`, and `/scrape` read the posting as
|
||||||
|
gone rather than hosted elsewhere (2 of 3 ads in a fresh sample). On that 404 the command now
|
||||||
|
falls back to the search endpoint, which does carry the ad's description and the external
|
||||||
|
application URL, and returns the record marked `isExternal: true` with a stderr note; fields the
|
||||||
|
search payload does not carry (`views`, `approvalStatus`, the boolean flags) are `null`, never
|
||||||
|
guessed. Verified live on two external ads.
|
||||||
|
|
||||||
- **`09-web-research.md`'s curl snippets no longer write into the repo when `$SCRATCHPAD`
|
- **`09-web-research.md`'s curl snippets no longer write into the repo when `$SCRATCHPAD`
|
||||||
is unset** - both runnable blocks in the 403-escalation path start with `cd "$SCRATCHPAD"`,
|
is unset** - both runnable blocks in the 403-escalation path start with `cd "$SCRATCHPAD"`,
|
||||||
and nothing in the repository ever sets that variable (`git grep 'SCRATCHPAD='` returns
|
and nothing in the repository ever sets that variable (`git grep 'SCRATCHPAD='` returns
|
||||||
|
|||||||
Reference in New Issue
Block a user