mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-02 13:12:50 +00:00
fix(jobnet-search): filter search by the ESCO occupation that occupations prints (#649)
* fix(jobnet-search): filter search by the ESCO occupation that occupations prints SKILL.md and the CLI README told users to pass the conceptUriDa UUID from `occupations` to `search --occupation-area` / `--occupation-group`. Those flags take the numeric area and group codes (10000, 10060), so a UUID there returned a successful search with zero ads. The API filters one occupation through its `occupations` parameter, which returns the 830 nurse ads for the sygeplejerske UUID. Add `search --occupation`, sent as `occupations`, accepting the full conceptUriDa or its trailing UUID, and point the docs at it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * changelog: place the #649 entry under [Unreleased] Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
This commit is contained in:
5 files changed
+107
-4
No files matched your search
@@ -59,6 +59,7 @@ Key flags:
|
||||
- `--job-type <type>` — `Ordinaert`, `Efterloenner`, `Foertidspension`
|
||||
- `--occupation-area <id>` — occupation area identifier, e.g. `10000`
|
||||
- `--occupation-group <id>` — occupation group identifier, e.g. `10060`
|
||||
- `--occupation <uuid>` — one ESCO occupation: the `conceptUriDa` printed by `occupations`, or its trailing UUID
|
||||
- `--order <type>` — `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`.**
|
||||
|
||||
@@ -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 <text> [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.
|
||||
@@ -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<string, string> {
|
||||
}
|
||||
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",
|
||||
}),
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user