From f8c606fb3d609e802f6e5b7e9052ef72152ab5c9 Mon Sep 17 00:00:00 2001 From: Oscar Madera <80536682+oscarbol09@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:38:15 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_013fqqLgQSnwgWkv98twQhHi --------- Co-authored-by: Mads Lorentzen Co-authored-by: Claude Fable 5.1 --- .../jobnet-search/cli/src/commands/detail.ts | 140 ++++++++++++++---- .../cli/tests/detail-fallback.test.ts | 88 +++++++++++ CHANGELOG.md | 9 ++ 3 files changed, 210 insertions(+), 27 deletions(-) create mode 100644 .agents/skills/jobnet-search/cli/tests/detail-fallback.test.ts diff --git a/.agents/skills/jobnet-search/cli/src/commands/detail.ts b/.agents/skills/jobnet-search/cli/src/commands/detail.ts index 16bf07e..8385006 100644 --- a/.agents/skills/jobnet-search/cli/src/commands/detail.ts +++ b/.agents/skills/jobnet-search/cli/src/commands/detail.ts @@ -1,6 +1,7 @@ import { defineCommand, option } from "@bunli/core" import { z } from "zod" import { apiFetch, normalizeJobId, writeError, stripHtml } from "../helpers.js" +import type { JobAdRaw, SearchApiResponse } from "./search.js" export interface DetailApiResponse { id: string @@ -8,13 +9,14 @@ export interface DetailApiResponse { body: string publicationDateTime: string unpublicationDateTime: string | null - approvalStatus: string - views: number + approvalStatus: string | null + views: number | null createdDateTime: string updatedDateTime: string - isAnonymousEmployer: boolean + isAnonymousEmployer: boolean | null hasLogo: boolean logoUrl: string | null + isExternal?: boolean employer: { cvrNumber: string | null pNumber: string | null @@ -22,7 +24,7 @@ export interface DetailApiResponse { hasCompanyLogo: boolean } job: { - type: string + type: string | null address: { streetName: string | null city: string | null @@ -31,21 +33,21 @@ export interface DetailApiResponse { countryCode: string countryName: string } - noFixedWorkplace: boolean - isLimitedPeriod: boolean - isDisabilityFriendly: boolean - isPartTime: boolean + noFixedWorkplace: boolean | null + isLimitedPeriod: boolean | null + isDisabilityFriendly: boolean | null + isPartTime: boolean | null employmentDate: string | null conceptUriDa: string | null preferredLabelDa: string | null driversLicenses: unknown[] classifications: unknown[] shifts: unknown[] - isFavorite: boolean + isFavorite: boolean | null } application: { deadlineDate: string | null - availablePositions: number + availablePositions: number | null contactPersons: Array<{ firstNames: string | null lastName: string | null @@ -53,12 +55,73 @@ export interface DetailApiResponse { }> url: string | null urlText: string | null - isApplicationDeadlineASAP: boolean + isApplicationDeadlineASAP: boolean | null } organisationTypeId: number | null user: string | null } +/** + * Maps a raw JobAd from the search endpoint to a DetailApiResponse. + * Used as a fallback when /FindJob/JobAdDetails/ 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. * @@ -99,30 +162,53 @@ export const detail = defineCommand({ process.exit(1) } + let data: DetailApiResponse | null = null + try { - const data = prepareDetail( + data = prepareDetail( await apiFetch(`/FindJob/JobAdDetails/${id}`, { incrementViews: "false", }), ) - - if (signal.aborted) return - - if (flags.format === "json") { - console.log(JSON.stringify(data, null, 2)) - } else if (flags.format === "table") { - outputTable(data) - } else { - 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") + // 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("/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 (!data) { + writeError("Job ad not found", "NOT_FOUND") + process.exit(1) + } } else { writeError(message, "API_ERROR") + process.exit(1) } - process.exit(1) + } + + if (signal.aborted || !data) return + + if (flags.format === "json") { + console.log(JSON.stringify(data, null, 2)) + } else if (flags.format === "table") { + outputTable(data) + } else { + outputPlain(data) } }, }) @@ -131,13 +217,13 @@ function outputTable(data: DetailApiResponse): void { console.log(`ID: ${data.id}`) console.log(`Title: ${data.title}`) 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(`Postal: ${data.job.address.postalCode ?? "-"}`) console.log(`Country: ${data.job.address.countryName}`) console.log(`Published: ${data.publicationDateTime}`) 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 ?? "-"}`) } @@ -152,7 +238,7 @@ export function formatDetailPlain(data: DetailApiResponse): string { `Location: ${data.job.address.city ?? "-"}, ${data.job.address.countryName}`, `Published: ${data.publicationDateTime}`, `Deadline: ${data.application.deadlineDate ?? "-"}`, - `Positions: ${data.application.availablePositions}`, + `Positions: ${data.application.availablePositions ?? "-"}`, ] if (data.application.url) { diff --git a/.agents/skills/jobnet-search/cli/tests/detail-fallback.test.ts b/.agents/skills/jobnet-search/cli/tests/detail-fallback.test.ts new file mode 100644 index 0000000..87bb958 --- /dev/null +++ b/.agents/skills/jobnet-search/cli/tests/detail-fallback.test.ts @@ -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: "

Great job opening at Tactile.

", + 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("

Great job opening at Tactile.

") + 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) + }) +}) diff --git a/CHANGELOG.md b/CHANGELOG.md index c27811a..7dfa0de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,15 @@ per-file diff commands. ### Fixed +- **`jobnet-search detail` no longer reports an externally hosted ad as not found** (#432) - + Jobnet's `/FindJob/JobAdDetails/` 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` 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