fix(jobnet-search): fallback to search endpoint on detail 404 for external ads (#432) (#435)

* 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:
Oscar Madera
2026-09-07 18:38:15 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Mads Lorentzen
parent ab5732138a
commit f8c606fb3d
3 changed files with 210 additions and 27 deletions
@@ -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)
})
})
+9
View File
@@ -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