diff --git a/.agents/skills/jobnet-search/SKILL.md b/.agents/skills/jobnet-search/SKILL.md index f37abe94..f177d6b9 100644 --- a/.agents/skills/jobnet-search/SKILL.md +++ b/.agents/skills/jobnet-search/SKILL.md @@ -59,6 +59,7 @@ Key flags: - `--job-type ` — `Ordinaert`, `Efterloenner`, `Foertidspension` - `--occupation-area ` — occupation area identifier, e.g. `10000` - `--occupation-group ` — occupation group identifier, e.g. `10060` +- `--occupation ` — one ESCO occupation: the `conceptUriDa` printed by `occupations`, or its trailing UUID - `--order ` — `PublicationDate` (default), `BestMatch`, `ApplicationDate` - `--page / --per-page / --limit` - `--format json|table|plain` @@ -79,7 +80,8 @@ bun run .agents/skills/jobnet-search/cli/src/cli.ts occupations --search-string ``` Use this to discover ESCO occupation identifiers before passing them to `search` with -`--occupation-area` or `--occupation-group`. +`--occupation`. The numeric `--occupation-area` / `--occupation-group` codes are a different +taxonomy: an occupation UUID passed there matches no ads. ### Typeahead suggestions @@ -100,6 +102,7 @@ term or ESCO identifier before running a `search`: ```bash bun run .agents/skills/jobnet-search/cli/src/cli.ts suggestions --query "syge" bun run .agents/skills/jobnet-search/cli/src/cli.ts occupations --search-string "sygeplejerske" +bun run .agents/skills/jobnet-search/cli/src/cli.ts search --occupation http://data.star.dk/esco/occupation/56f5d45c-ce48-43ac-9720-34831a1c8302 ``` **Natural workflow: `search` → `detail`.** diff --git a/.agents/skills/jobnet-search/cli/README.md b/.agents/skills/jobnet-search/cli/README.md index bad6e351..cabf8473 100644 --- a/.agents/skills/jobnet-search/cli/README.md +++ b/.agents/skills/jobnet-search/cli/README.md @@ -74,6 +74,7 @@ bun run src/cli.ts search [flags] | `--job-type` | string | — | Announcement type: `Ordinaert`, `Efterloenner`, `Foertidspension` | | `--occupation-area` | string | — | Occupation area identifier, e.g. `10000` | | `--occupation-group` | string | — | Occupation group identifier, e.g. `10060` | +| `--occupation` | string | — | One ESCO occupation: the `conceptUriDa` from `occupations`, or its trailing UUID (sent as `occupations`) | | `--page` | number | `1` | Page number (1-indexed) | | `--per-page` | number | `10` | Results per page | | `--limit` | number | — | Cap total results returned by CLI | @@ -263,7 +264,7 @@ bun run src/cli.ts detail 9ef43bce-d82b-4ea1-a098-7ff6520f99be --format plain bun run src/cli.ts occupations --search-string [flags] ``` -Use this command to find occupation identifiers (ESCO concept URIs) to pass as filters to `search`. +Use this command to find occupation identifiers (ESCO concept URIs) to pass to `search --occupation`. ### Flags @@ -297,7 +298,7 @@ bun run src/cli.ts occupations --search-string "sygeplejerske" --per-page 5 ] ``` -> **Note**: `conceptUriDa` is the full ESCO concept URI. The UUID portion (last path segment) can be used to build occupation filters for `search`. +> **Note**: `conceptUriDa` is the full ESCO concept URI. Pass it, or its UUID (last path segment), to `search --occupation`. The numeric `--occupation-area` / `--occupation-group` codes are a different taxonomy and match no ads for a UUID. --- @@ -384,4 +385,4 @@ Example: `https://jobnet.dk/bff/SharedComponents/JobAdCard/CompanyLogo/ByJobAdId - Pagination is 1-indexed (`--page 1` is the first page). - `search` results intentionally omit the HTML `description` field — use `detail` to fetch it. - `body` in `detail` responses is raw HTML; use `--format plain` to get stripped text. -- The `occupations` command helps discover ESCO occupation URIs usable as `--occupation-area` / `--occupation-group` seeds for narrowing search results. +- The `occupations` command helps discover ESCO occupation URIs to pass to `search --occupation` for narrowing search results. diff --git a/.agents/skills/jobnet-search/cli/src/commands/search.ts b/.agents/skills/jobnet-search/cli/src/commands/search.ts index 849125a9..818386d4 100644 --- a/.agents/skills/jobnet-search/cli/src/commands/search.ts +++ b/.agents/skills/jobnet-search/cli/src/commands/search.ts @@ -59,6 +59,7 @@ export interface SearchFlags { radius: number "occupation-area"?: string "occupation-group"?: string + occupation?: string limit?: number } @@ -80,10 +81,21 @@ export function buildSearchParams(flags: SearchFlags): Record { } if (flags["occupation-area"]) params["occupationAreas"] = flags["occupation-area"] if (flags["occupation-group"]) params["occupationGroups"] = flags["occupation-group"] + if (flags.occupation) params["occupations"] = occupationId(flags.occupation) return params } +/** + * The search API filters one ESCO occupation by the UUID that ends the + * `conceptUriDa` printed by `occupations`. Accept that full URI as well as the + * bare UUID, so the value can be copied from either output unchanged. + */ +export function occupationId(value: string): string { + const trimmed = value.trim().replace(/\/+$/, "") + return trimmed.slice(trimmed.lastIndexOf("/") + 1) +} + /** * Return the destination URL for an external ad, if it has a valid http(s) * jobAdUrl. Only an absolute http(s) URL is used: the value is employer-supplied @@ -201,6 +213,9 @@ export const search = defineCommand({ "occupation-group": option(z.string().optional(), { description: "Occupation group identifier, e.g. 10060", }), + occupation: option(z.string().optional(), { + description: "ESCO occupation UUID or conceptUriDa from the occupations command", + }), limit: option(z.coerce.number().int().min(1).optional(), { description: "Cap total results returned by CLI", }), diff --git a/.agents/skills/jobnet-search/cli/tests/occupation-filter-cli.test.ts b/.agents/skills/jobnet-search/cli/tests/occupation-filter-cli.test.ts new file mode 100644 index 00000000..f03bf0e4 --- /dev/null +++ b/.agents/skills/jobnet-search/cli/tests/occupation-filter-cli.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildSearchParams, occupationId } from "../src/commands/search"; + +// The identifier `occupations` documents and prints, copied unchanged. +async function documentedConceptUri(): Promise { + const readme = (await Bun.file(join(import.meta.dir, "../README.md")).text()).replace(/\r\n/g, "\n"); + const section = readme.split("## `occupations`")[1].split("\n---")[0]; + return JSON.parse(section.match(/```json\n([\s\S]*?)```/)![1])[0].conceptUriDa; +} + +async function searchRequest(args: string[]) { + const requests: URL[] = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + requests.push(new URL(request.url)); + return Response.json({ jobAds: [], searchFacets: {}, totalJobAdCount: 0, searchString: null }); + }, + }); + const dir = mkdtempSync(join(tmpdir(), "jobnet-occupation-filter-")); + const preload = join(dir, "fetch.ts"); + writeFileSync(preload, ` + const originalFetch = globalThis.fetch; + globalThis.fetch = (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.protocol === "data:") return originalFetch(input, init); + if (url.origin !== "https://jobnet.dk" || url.pathname !== "/bff/FindJob/Search") { + throw new Error("Unexpected search fixture request"); + } + return originalFetch(${JSON.stringify(server.url.origin)} + url.pathname + url.search, init); + }; + `); + try { + const proc = Bun.spawn([ + process.execPath, "run", "--preload", preload, + join(import.meta.dir, "../src/cli.ts"), "search", ...args, + ], { stdout: "pipe", stderr: "pipe" }); + const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(requests).toHaveLength(1); + return requests[0].searchParams; + } finally { + server.stop(true); + rmSync(dir, { recursive: true, force: true }); + } +} + +test("search --occupation sends the conceptUriDa printed by occupations as the occupations filter", async () => { + const uri = await documentedConceptUri(); + const uuid = uri.split("/").at(-1)!; + const params = await searchRequest(["--occupation", uri]); + expect(params.get("occupations")).toBe(uuid); + // Never the area/group filters, which take numeric codes and match no ad for a UUID. + expect(params.get("occupationAreas")).toBeNull(); + expect(params.get("occupationGroups")).toBeNull(); +}); + +test("search --occupation accepts the bare UUID", async () => { + const uuid = (await documentedConceptUri()).split("/").at(-1)!; + expect((await searchRequest(["--occupation", uuid])).get("occupations")).toBe(uuid); +}); + +test("occupationId keeps a UUID and takes the last segment of a URI", () => { + const uuid = "56f5d45c-ce48-43ac-9720-34831a1c8302"; + expect(occupationId(uuid)).toBe(uuid); + expect(occupationId(`http://data.star.dk/esco/occupation/${uuid}`)).toBe(uuid); + expect(occupationId(` http://data.star.dk/esco/occupation/${uuid}/ `)).toBe(uuid); + expect( + buildSearchParams({ page: 1, "per-page": 10, order: "PublicationDate", radius: 50, occupation: uuid }).occupations, + ).toBe(uuid); +}); diff --git a/CHANGELOG.md b/CHANGELOG.md index 5efea887..3cdb4a74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -138,6 +138,14 @@ per-file diff commands. so the requested newest-first sort was silently not applied. The option is now an enum like `--format`, and any other value exits 1 with the CLI's validation error before any request. +- **`jobnet-search search --occupation` filters by the ESCO occupation that `occupations` prints** + (#649, `.agents/skills/jobnet-search/cli/src/commands/search.ts`, `tests/occupation-filter-cli.test.ts`, + `SKILL.md`, `cli/README.md`) - the skill and README said to pass the `conceptUriDa` UUID from + `occupations` to `--occupation-area` / `--occupation-group`, but those take the numeric area and + group codes (`10000`, `10060`); a UUID there returned a successful search with zero ads, while the + API's `occupations` parameter returns the 830 nurse ads for the same UUID. The new flag sends that + parameter and accepts the full URI or the bare UUID; the docs stop pointing at the numeric flags. + ## [1.7.3] - 2026-10-19 ### Added