diff --git a/.agents/skills/freehire-search/SKILL.md b/.agents/skills/freehire-search/SKILL.md index e3bbaff..aa98c04 100644 --- a/.agents/skills/freehire-search/SKILL.md +++ b/.agents/skills/freehire-search/SKILL.md @@ -95,7 +95,10 @@ posting's complete text, so a search of 20 roles is 1 request rather than 1 + 20 Do **not** loop `detail` over search hits to read their descriptions — reach for `detail` only to look one posting up by slug (e.g. from the tracker, or a posting already closed and therefore absent from search). Full descriptions are verbose: -keep `--limit` modest, and pre-filter on title/company before reading bodies. +keep `--limit` modest, and pre-filter on title/company before reading bodies - +or pass `--no-description` for a cheap discovery pass that keeps every other +field and drops the bodies entirely (fetch a shortlisted job's body with +`detail`, or re-run the search without the flag). Facet filters (values come from freehire's controlled vocabularies; comma-separate for OR within a facet): - `--region ` — macro-region, e.g. `global`, `eu`, `us`, `apac`, `latam`, `cis`. `--region eu,us`. Use `none` to match jobs whose region could **not** be resolved (see "Partial data" below). diff --git a/.agents/skills/freehire-search/cli/src/cli.ts b/.agents/skills/freehire-search/cli/src/cli.ts index 4bb8baf..4766f58 100644 --- a/.agents/skills/freehire-search/cli/src/cli.ts +++ b/.agents/skills/freehire-search/cli/src/cli.ts @@ -81,6 +81,8 @@ SEARCH FLAGS --page 1-indexed page. Default 1. --limit, -n Results per page (API limit). Default 25. --format json (default) | table | plain. + --no-description Skip description hydration for a cheap discovery pass + (results keep every other field; detail fetches the body). --description-format markdown (default) | text | html — how each result's full description is rendered (json output only). @@ -118,6 +120,17 @@ function parseIntFlag(name: string, raw: string | boolean | string[]): number | return val } +// Long-form flag names each command accepts (parseFlags resolves the short +// aliases q/n to these before validation). "help"/"h" pass so `search --help` +// still prints usage. +const KNOWN_FLAGS: Record> = { + search: new Set([ + "query", "category", "city", "company", "country", "facet", "format", "jobage", "limit", + "page", "region", "remote", "seniority", "skill", "description-format", "no-description", "help", "h", + ]), + detail: new Set(["format", "description-format", "help", "h"]), +} + async function main(): Promise { const argv = process.argv.slice(2) const flags = parseFlags(argv) @@ -128,6 +141,25 @@ async function main(): Promise { return cmd ? 0 : 1 } + // Reject unknown flags instead of silently discarding them: a discarded + // filter changes what the search returns with no error (a wrong flag name + // once returned an entire portal's database as if it matched the query). + // add-portal.md's contract requires a bogus flag to exit 1 with a JSON + // error on stderr. + const knownFlags = KNOWN_FLAGS[cmd] + if (knownFlags) { + for (const key of Object.keys(flags)) { + if (key === "_" || knownFlags.has(key)) continue + process.stderr.write( + JSON.stringify({ + error: `unknown flag --${key} for '${cmd}' - flags are never silently ignored, because a discarded filter changes what the search returns; see --help for the supported flags`, + code: "UNKNOWN_FLAG", + }) + "\n", + ) + return 1 + } + } + if (cmd === "search") { const fmt = (flags.format as string) || "json" @@ -172,6 +204,7 @@ async function main(): Promise { limit: flags.limit ? Math.max(1, parseInt(flags.limit as string, 10)) : 25, format: (["json", "table", "plain"].includes(fmt) ? fmt : "json") as SearchOpts["format"], descriptionFormat: descFmt as DescriptionFormat, + includeDescription: flags["no-description"] === undefined, regions: commaList(flags.region), countries: commaList(flags.country), cities: commaList(flags.city), diff --git a/.agents/skills/freehire-search/cli/src/commands/search.ts b/.agents/skills/freehire-search/cli/src/commands/search.ts index db586b4..f2c928c 100644 --- a/.agents/skills/freehire-search/cli/src/commands/search.ts +++ b/.agents/skills/freehire-search/cli/src/commands/search.ts @@ -18,6 +18,10 @@ export interface SearchOpts { limit: number format: "json" | "table" | "plain" descriptionFormat: DescriptionFormat + // Hydrate full description bodies (the documented default). False keeps a + // discovery pass cheap: bodies are ~73% of a default search payload, and + // /scrape pre-filters by title before reading bodies anyway. + includeDescription?: boolean // Facet filters (already parsed into value lists; empty means unset). regions: string[] countries: string[] @@ -38,9 +42,11 @@ function buildQuery(opts: SearchOpts): URLSearchParams { p.set("offset", String((opts.page - 1) * opts.limit)) p.set("semantic_ratio", "0") // keyword search; the semantic index is opt-in // The agent endpoint serves the index's truncated preview unless asked to - // rehydrate each hit from the database, so both params travel together. - p.set("include_description", "true") - p.set("description_format", opts.descriptionFormat) + // rehydrate each hit from the database, so both params travel together - + // unless the caller opted out of hydration entirely (--no-description). + const hydrate = opts.includeDescription !== false + p.set("include_description", hydrate ? "true" : "false") + if (hydrate) p.set("description_format", opts.descriptionFormat) if (opts.jobage > 0 && opts.jobage < 9999) p.set("posted_within_days", String(opts.jobage)) if (opts.workMode) p.set("work_mode", opts.workMode) if (opts.company) p.set("company_slug", opts.company) @@ -117,7 +123,14 @@ export async function runSearch(opts: SearchOpts): Promise { ) return 1 } - const rows = (env.data ?? []).map(toResult) + let rows = (env.data ?? []).map(toResult) + // The API currently returns description bodies regardless of + // include_description=false (verified live 2026-08-19), and the cost this + // flag exists to avoid is the ~73% of CLI output the bodies occupy in + // agent context - so the lean mode strips them client-side either way. + if (opts.includeDescription === false) { + rows = rows.map((r) => ({ ...r, description: null })) + } const total = env.meta?.total ?? rows.length if (opts.format === "table") { diff --git a/.agents/skills/freehire-search/cli/tests/cli-flag-validation.test.ts b/.agents/skills/freehire-search/cli/tests/cli-flag-validation.test.ts index 4842916..f29637b 100644 --- a/.agents/skills/freehire-search/cli/tests/cli-flag-validation.test.ts +++ b/.agents/skills/freehire-search/cli/tests/cli-flag-validation.test.ts @@ -77,3 +77,20 @@ describe("freehire CLI flag validation", () => { }); }); }); + + +describe("unknown flag rejection", () => { + // add-portal.md's contract: "a bogus flag or missing required arg exits 1 + // with a JSON error on stderr". A silently discarded flag is worse than an + // error: on jobdanmark a wrong flag name returned the entire database + // (13,862 results) as if it matched the query (review finding F13, + // 2026-08-19). Rejection happens before dispatch, so these are network-free. + test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => { + const result = await runCLI(["search", "--query", "test", "--bogus-flag", "xyz"]); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + const error = JSON.parse(result.stderr); + expect(error.code).toBe("UNKNOWN_FLAG"); + expect(error.error).toContain("--bogus-flag"); + }); +}); diff --git a/.agents/skills/freehire-search/cli/tests/commands.test.ts b/.agents/skills/freehire-search/cli/tests/commands.test.ts index 4167b3b..6edada6 100644 --- a/.agents/skills/freehire-search/cli/tests/commands.test.ts +++ b/.agents/skills/freehire-search/cli/tests/commands.test.ts @@ -112,6 +112,24 @@ describe("runSearch (mocked fetch)", () => { expect(requestedParams(mock).get("description_format")).toBe("markdown"); }); + test("skips description hydration when includeDescription is false", async () => { + // A default search hydrates ~20k tokens of description bodies per query, + // while /scrape's Step 2 says to pre-filter by title/snippet before + // reading bodies. --no-description keeps the discovery pass cheap; + // hydration stays the default (review opportunity O1, 2026-08-19). + const mock = mockFetch(200, { data: [job()], meta: { total: 1 } }); + + const out = captureStdout(); + await runSearch({ ...searchOpts, query: "backend", includeDescription: false }); + + expect(requestedParams(mock).get("include_description")).toBe("false"); + expect(requestedParams(mock).get("description_format")).toBeNull(); + // The live API ignores include_description=false and sends bodies anyway + // (verified 2026-08-19), so the lean guarantee is enforced client-side. + const parsed = JSON.parse(out.get()); + expect(parsed.results[0].description).toBeNull(); + }); + test("asks for the requested description format", async () => { const mock = mockFetch(200, { data: [job()], meta: { total: 1 } }); captureStdout(); diff --git a/.agents/skills/jobbank-search/cli/README.md b/.agents/skills/jobbank-search/cli/README.md index 337f0aa..e504758 100644 --- a/.agents/skills/jobbank-search/cli/README.md +++ b/.agents/skills/jobbank-search/cli/README.md @@ -267,7 +267,7 @@ bun run src/cli.ts search --education 24 --suitable-for 2 --since 2026-03-01 | `url` | string | Full URL to job posting | | `posted` | string | Publication date in ISO 8601 | | `date` | string \| null | Publication date as `YYYY-MM-DD` (derived from `posted`), or `null` if absent | -| `deadline` | string \| null | Application deadline as `DD.MM.YYYY` string, or `null` if "løbende" / not present | +| `deadline` | string \| null | Application deadline as `YYYY-MM-DD` (converted from the feed's `DD.MM.YYYY`), or `null` if "løbende" / not present | > `meta.total` is fetched from the HTML page `` in a secondary request (pattern: `"{N} relevante job og karriereopslag"`). If the secondary request fails, `meta.total` is `null`. diff --git a/.agents/skills/jobbank-search/cli/src/cli.ts b/.agents/skills/jobbank-search/cli/src/cli.ts index 936b0f2..da7ab5d 100644 --- a/.agents/skills/jobbank-search/cli/src/cli.ts +++ b/.agents/skills/jobbank-search/cli/src/cli.ts @@ -1,4 +1,5 @@ import { createCLI } from "@bunli/core" +import { writeError } from "./helpers.js" import { search } from "./commands/search.js" import { detail } from "./commands/detail.js" @@ -8,7 +9,37 @@ const cli = await createCLI({ description: "CLI for Akademikernes Jobbank (jobbank.dk) — job search for highly educated candidates", }) -cli.command(search) -cli.command(detail) +const commands = [search, detail] +for (const command of commands) { + cli.command(command) +} + +// Reject unknown --flags before dispatch. bunli silently discards them, and a +// silently discarded filter changes what the search returns without any error +// (a wrong flag name once returned an entire portal's database as if it +// matched the query). add-portal.md's contract requires a bogus flag to exit 1 +// with a JSON error on stderr; this enforces it for the reference CLIs too. +const argv = process.argv.slice(2) +const invoked = commands.find((c) => (c as { name?: string }).name === argv[0]) +if (invoked) { + const known = new Set([ + ...Object.keys((invoked as { options?: Record<string, unknown> }).options ?? {}), + "help", + "version", + ]) + for (const token of argv.slice(1)) { + if (token === "--") break + if (token.startsWith("--")) { + const flag = token.slice(2).split("=")[0] + if (!known.has(flag)) { + writeError( + `unknown flag --${flag} for '${argv[0]}' - flags are never silently ignored, because a discarded filter changes what the search returns; see --help for the supported flags`, + "UNKNOWN_FLAG", + ) + process.exit(1) + } + } + } +} await cli.run() diff --git a/.agents/skills/jobbank-search/cli/src/helpers.ts b/.agents/skills/jobbank-search/cli/src/helpers.ts index 5b5ba98..d2a5089 100644 --- a/.agents/skills/jobbank-search/cli/src/helpers.ts +++ b/.agents/skills/jobbank-search/cli/src/helpers.ts @@ -135,7 +135,11 @@ export function parseRssDescription(desc: string): ParsedDescription { if (deadlineStr.toLowerCase() === "løbende" || deadlineStr.toLowerCase() === "lobende") { deadline = null } else { - deadline = deadlineStr + // The feed writes DD.MM.YYYY; the /scrape contract (and this CLI's own + // detail command) use YYYY-MM-DD. Convert the known shape; anything else + // passes through so an unexpected value stays visible downstream. + const dmy = deadlineStr.match(/^(\d{2})\.(\d{2})\.(\d{4})$/) + deadline = dmy ? `${dmy[3]}-${dmy[2]}-${dmy[1]}` : deadlineStr } // Remove the deadline portion from rest rest = rest.substring(0, deadlineMatch.index).trim() diff --git a/.agents/skills/jobbank-search/cli/tests/cli-flag-validation.test.ts b/.agents/skills/jobbank-search/cli/tests/cli-flag-validation.test.ts index 2dadadd..2bc133f 100644 --- a/.agents/skills/jobbank-search/cli/tests/cli-flag-validation.test.ts +++ b/.agents/skills/jobbank-search/cli/tests/cli-flag-validation.test.ts @@ -57,3 +57,26 @@ describe("Jobbank CLI flag validation", () => { }); }); }); + + +describe("unknown flag rejection", () => { + // add-portal.md's contract: "a bogus flag or missing required arg exits 1 + // with a JSON error on stderr". A silently discarded flag is worse than an + // error: on jobdanmark a wrong flag name returned the entire database + // (13,862 results) as if it matched the query (review finding F13, + // 2026-08-19). Rejection happens before dispatch, so these are network-free. + test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => { + const result = await runCLI(["search", "--key", "test", "--bogus-flag", "xyz"]); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + const error = JSON.parse(result.stderr); + expect(error.code).toBe("UNKNOWN_FLAG"); + expect(error.error).toContain("--bogus-flag"); + }); + + test("--query (another portal's free-text flag) is rejected, not treated as no filter", async () => { + const result = await runCLI(["search", "--query", "test"]); + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stderr).code).toBe("UNKNOWN_FLAG"); + }); +}); diff --git a/.agents/skills/jobbank-search/cli/tests/rss-parsing.test.ts b/.agents/skills/jobbank-search/cli/tests/rss-parsing.test.ts index 672019f..dc70a8e 100644 --- a/.agents/skills/jobbank-search/cli/tests/rss-parsing.test.ts +++ b/.agents/skills/jobbank-search/cli/tests/rss-parsing.test.ts @@ -56,10 +56,17 @@ describe("parseRssDescription", () => { jobType: "Fuldtidsjob, Graduate/trainee", company: "Acme A/S", location: "København", - deadline: "31.07.2026", + deadline: "2026-07-31", }); }); + test("passes an unrecognized deadline shape through for downstream defensive parsing", () => { + const parsed = parseRssDescription( + "Fuldtidsjob hos Acme A/S, Odense (Ansøgningsfrist: snarest muligt)", + ); + expect(parsed.deadline).toBe("snarest muligt"); + }); + test("normalizes a rolling deadline to null", () => { expect( parseRssDescription("Deltidsjob hos Example ApS, Aarhus (Ansøgningsfrist: løbende)"), diff --git a/.agents/skills/jobbank-search/cli/tests/search-normalization.test.ts b/.agents/skills/jobbank-search/cli/tests/search-normalization.test.ts index 494fb82..d41f42c 100644 --- a/.agents/skills/jobbank-search/cli/tests/search-normalization.test.ts +++ b/.agents/skills/jobbank-search/cli/tests/search-normalization.test.ts @@ -33,6 +33,6 @@ describe("Jobbank search normalization", () => { expect(result.company).toBe("Acme A/S"); expect(result.location).toBe("København"); expect(result.url).toBe("https://jobbank.dk/job/12345/acme/data-scientist"); - expect(result.deadline).toBe("31.07.2026"); + expect(result.deadline).toBe("2026-07-31"); }); }); \ No newline at end of file diff --git a/.agents/skills/jobdanmark-search/cli/README.md b/.agents/skills/jobdanmark-search/cli/README.md index a621ca9..5504907 100644 --- a/.agents/skills/jobdanmark-search/cli/README.md +++ b/.agents/skills/jobdanmark-search/cli/README.md @@ -129,13 +129,6 @@ bun run src/cli.ts search --text "sygeplejerske" --zip 8000 --limit 10 { "title": "IT-chef søges til RAH", "companyName": "Rah Service A/S", - "companyLogo": { - "key": "71f1c950-abcd-1234-efgh-000000000000", - "url": "https://jobdanmark.dk/media/k1epc2kk/rah-service-logo.jpg", - "focalPoint": null - }, - "companyLogoSvgMarkup": null, - "overlayColor": "#FFFFFF1F", "companyAddress": "Ndr Ringvej 4 6950 Ringkøbing", "jobTypes": ["fuldtid"], "boostJob": true, @@ -143,12 +136,6 @@ bun run src/cli.ts search --text "sygeplejerske" --zip 8000 --limit 10 "applicationDeadline": "10-04-2026", "url": "https://jobdanmark.dk/job/it-chef-soeges-til-rah", "slug": "it-chef-soeges-til-rah", - "coverImage": { - "key": "cf06eb46-abcd-1234-efgh-000000000000", - "url": "https://jobdanmark.dk/media/idvbnt4y/rah-service-as-billede.png", - "focalPoint": { "top": 0.488, "left": 0.499 } - }, - "silhouetteLogo": false, "company": "Rah Service A/S", "location": "Ringkøbing", "date": "2026-03-12", @@ -162,9 +149,8 @@ bun run src/cli.ts search --text "sygeplejerske" --zip 8000 --limit 10 > - `url` is normalized to a full URL (CLI prepends `https://jobdanmark.dk` to the relative path from the API). > - `slug` is extracted from the relative `url` field (the path segment after `/job/`). > - `applicationDeadline` can be `null`. -> - `companyLogo` can be `null`. > - `publishedDate` format: `"DD-MM-YYYY"`. -> - `coverImage` can be `null`. +> - Presentation-only keys the API sends (`coverImage`, `companyLogo`, `companyLogoSvgMarkup`, `overlayColor`, `silhouetteLogo`) are dropped from search output — they were ~40% of a live payload and an agent can never use them. > - Every result also carries the cross-portal contract fields `company`, `location`, `date` and `deadline`, derived from `companyName`, the city after the postal code in `companyAddress`, and the day-first dates converted to `YYYY-MM-DD` — `/scrape` Step 2 expects search output to include title, company, location, date, and URL. Native fields are preserved unchanged. --- @@ -454,5 +440,4 @@ All errors are written to **stderr** in JSON format and exit with code `1`: ## URL construction - Job detail pages: `https://jobdanmark.dk/job/{slug}` -- Company logo images: `https://jobdanmark.dk{companyLogo.url}` (prepend base URL to relative path) -- Cover images: `https://jobdanmark.dk{coverImage.url}` (prepend base URL to relative path) +- Image URLs from the raw API (`companyLogo.url`, `coverImage.url`) are relative; prepend `https://jobdanmark.dk` if you consume the API directly (the CLI drops these keys) diff --git a/.agents/skills/jobdanmark-search/cli/src/cli.ts b/.agents/skills/jobdanmark-search/cli/src/cli.ts index 0111a9a..be6b536 100644 --- a/.agents/skills/jobdanmark-search/cli/src/cli.ts +++ b/.agents/skills/jobdanmark-search/cli/src/cli.ts @@ -1,4 +1,5 @@ import { createCLI } from "@bunli/core" +import { writeError } from "./helpers.js" import { search } from "./commands/search.js" import { detail } from "./commands/detail.js" import { categories } from "./commands/categories.js" @@ -11,10 +12,37 @@ const cli = await createCLI({ description: "CLI for the Jobdanmark.dk public job search API", }) -cli.command(search) -cli.command(detail) -cli.command(categories) -cli.command(autocomplete) -cli.command(locations) +const commands = [search, detail, categories, autocomplete, locations] +for (const command of commands) { + cli.command(command) +} + +// Reject unknown --flags before dispatch. bunli silently discards them, and a +// silently discarded filter changes what the search returns without any error +// (a wrong flag name once returned an entire portal's database as if it +// matched the query). add-portal.md's contract requires a bogus flag to exit 1 +// with a JSON error on stderr; this enforces it for the reference CLIs too. +const argv = process.argv.slice(2) +const invoked = commands.find((c) => (c as { name?: string }).name === argv[0]) +if (invoked) { + const known = new Set([ + ...Object.keys((invoked as { options?: Record<string, unknown> }).options ?? {}), + "help", + "version", + ]) + for (const token of argv.slice(1)) { + if (token === "--") break + if (token.startsWith("--")) { + const flag = token.slice(2).split("=")[0] + if (!known.has(flag)) { + writeError( + `unknown flag --${flag} for '${argv[0]}' - flags are never silently ignored, because a discarded filter changes what the search returns; see --help for the supported flags`, + "UNKNOWN_FLAG", + ) + process.exit(1) + } + } + } +} await cli.run() diff --git a/.agents/skills/jobdanmark-search/cli/src/commands/detail.ts b/.agents/skills/jobdanmark-search/cli/src/commands/detail.ts index f4e0a29..4bca9db 100644 --- a/.agents/skills/jobdanmark-search/cli/src/commands/detail.ts +++ b/.agents/skills/jobdanmark-search/cli/src/commands/detail.ts @@ -2,6 +2,7 @@ import { defineCommand, option } from "@bunli/core" import { z } from "zod" import { parse } from "node-html-parser" import { BASE_URL, writeError } from "../helpers.js" +import { extractCity, toContractDate } from "./search.js" interface JsonLdJobPosting { "@context"?: string @@ -119,6 +120,21 @@ function fromJsonLd(jobPosting: JsonLdJobPosting, slug: string, url: string): De } } +/** + * Normalize a date read from the rendered page's overview list. The page + * writes DD-MM-YYYY (sometimes with a trailing time, "02-08-2026 23.59"), + * and the deadline can be the free-text "Løbende" (rolling) - jobbank maps + * its equivalent to null, and every consumer does date arithmetic on the + * value. The JSON-LD branch gets schema.org ISO dates and needs none of this. + */ +function normalizeOverviewDate(value: string | null): string | null { + if (!value) return null + const trimmed = value.trim() + if (/^løbende$/iu.test(trimmed)) return null + const match = trimmed.match(/^(\d{2})-(\d{2})-(\d{4})/) + return match ? `${match[3]}-${match[2]}-${match[1]}` : toContractDate(trimmed) +} + function overviewValue(root: ReturnType<typeof parse>, label: string): string | null { const normalizedLabel = label.toLowerCase() for (const item of root.querySelectorAll(".job-overview li")) { @@ -174,8 +190,8 @@ function fromRenderedHtml(root: ReturnType<typeof parse>, slug: string, url: str slug, url, title, - datePosted: overviewValue(root, "Udgivet") ?? "", - validThrough: overviewValue(root, "Ansøgningsfrist"), + datePosted: normalizeOverviewDate(overviewValue(root, "Udgivet")) ?? "", + validThrough: normalizeOverviewDate(overviewValue(root, "Ansøgningsfrist")), employmentType: employmentType ? [employmentType] : [], hiringOrganization: { name: companyName, @@ -183,7 +199,7 @@ function fromRenderedHtml(root: ReturnType<typeof parse>, slug: string, url: str }, jobLocation: { streetAddress: workplace, - addressLocality: null, + addressLocality: extractCity(workplace), addressRegion: null, postalCode: null, addressCountry: "DK", diff --git a/.agents/skills/jobdanmark-search/cli/src/commands/search.ts b/.agents/skills/jobdanmark-search/cli/src/commands/search.ts index f0faee8..07a1f33 100644 --- a/.agents/skills/jobdanmark-search/cli/src/commands/search.ts +++ b/.agents/skills/jobdanmark-search/cli/src/commands/search.ts @@ -34,11 +34,23 @@ interface ApiSearchResponse { totalPages: number } -function toContractDate(value: string | null): string | null { +export function toContractDate(value: string | null): string | null { const match = value?.match(/^(\d{2})-(\d{2})-(\d{4})$/) return match ? `${match[3]}-${match[2]}-${match[1]}` : (value ?? null) } +// Live companyAddress values put the city after the postcode either as +// "Lautruphoej 2, 2750 Ballerup" or "2670, Greve". The comma fallback +// requires a non-digit after the comma so a 4-digit street number +// ("Vejlevej 1234, 7100 Vejle") never wins over the real postcode. +export function extractCity(address: string | null): string | null { + if (!address) return null + const city = + address.match(/\d{4}\s+(.+)$/)?.[1] ?? address.match(/\d{4}\s*,\s*([^\d,].*)$/)?.[1] + const trimmed = city?.trim() + return trimmed ? trimmed : null +} + export function normalizeItem(item: ApiSearchItem): Record<string, unknown> { const relativeUrl = item.url const fullUrl = relativeUrl.startsWith("http") @@ -47,32 +59,14 @@ export function normalizeItem(item: ApiSearchItem): Record<string, unknown> { // Extract slug from url path: /job/<slug> const slug = relativeUrl.replace(/^\/job\//, "") - const companyLogo = item.companyLogo - ? { - key: item.companyLogo.key, - url: item.companyLogo.url.startsWith("http") - ? item.companyLogo.url - : `${BASE_URL}${item.companyLogo.url}`, - focalPoint: item.companyLogo.focalPoint, - } - : null - - const coverImage = item.coverImage - ? { - key: item.coverImage.key, - url: item.coverImage.url.startsWith("http") - ? item.coverImage.url - : `${BASE_URL}${item.coverImage.url}`, - focalPoint: item.coverImage.focalPoint, - } - : null - + // Presentation-only keys (coverImage, companyLogo, companyLogoSvgMarkup, + // overlayColor, silhouetteLogo) are dropped: they were ~40% of a live + // payload and the /scrape agent can never use an image or overlay colour. + // The #340 compatibility duplicates (companyName, publishedDate, + // applicationDeadline) stay. return { title: item.title, companyName: item.companyName, - companyLogo, - companyLogoSvgMarkup: item.companyLogoSvgMarkup ?? null, - overlayColor: item.overlayColor ?? null, companyAddress: item.companyAddress, jobTypes: item.jobTypes, boostJob: item.boostJob, @@ -80,10 +74,8 @@ export function normalizeItem(item: ApiSearchItem): Record<string, unknown> { applicationDeadline: item.applicationDeadline ?? null, url: fullUrl, slug, - coverImage, - silhouetteLogo: item.silhouetteLogo, company: item.companyName, - location: item.companyAddress?.match(/\d{4}\s+(.+)$/)?.[1] ?? null, + location: extractCity(item.companyAddress), date: toContractDate(item.publishedDate), deadline: toContractDate(item.applicationDeadline), } diff --git a/.agents/skills/jobdanmark-search/cli/tests/cli-flag-validation.test.ts b/.agents/skills/jobdanmark-search/cli/tests/cli-flag-validation.test.ts index 454c44b..380d9e6 100644 --- a/.agents/skills/jobdanmark-search/cli/tests/cli-flag-validation.test.ts +++ b/.agents/skills/jobdanmark-search/cli/tests/cli-flag-validation.test.ts @@ -72,3 +72,26 @@ describe("Jobdanmark CLI flag validation", () => { }); }); }); + + +describe("unknown flag rejection", () => { + // add-portal.md's contract: "a bogus flag or missing required arg exits 1 + // with a JSON error on stderr". A silently discarded flag is worse than an + // error: on jobdanmark a wrong flag name returned the entire database + // (13,862 results) as if it matched the query (review finding F13, + // 2026-08-19). Rejection happens before dispatch, so these are network-free. + test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => { + const result = await runCLI(["search", "--text", "test", "--bogus-flag", "xyz"]); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + const error = JSON.parse(result.stderr); + expect(error.code).toBe("UNKNOWN_FLAG"); + expect(error.error).toContain("--bogus-flag"); + }); + + test("--query (another portal's free-text flag) is rejected, not treated as no filter", async () => { + const result = await runCLI(["search", "--query", "test"]); + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stderr).code).toBe("UNKNOWN_FLAG"); + }); +}); diff --git a/.agents/skills/jobdanmark-search/cli/tests/detail-parsing.test.ts b/.agents/skills/jobdanmark-search/cli/tests/detail-parsing.test.ts index ed8f453..8c1fa25 100644 --- a/.agents/skills/jobdanmark-search/cli/tests/detail-parsing.test.ts +++ b/.agents/skills/jobdanmark-search/cli/tests/detail-parsing.test.ts @@ -40,16 +40,31 @@ describe("parseJobPostingFromHtml", () => { ); expect(parsed.title).toBe("Journalistisk udvikler søges"); - expect(parsed.datePosted).toBe("03-07-2026"); - expect(parsed.validThrough).toBe("02-08-2026 23.59"); + // The fallback must emit the same shapes as the JSON-LD branch: contract + // dates, not the page's raw DD-MM-YYYY text (review finding F25, 2026-08-19). + expect(parsed.datePosted).toBe("2026-07-03"); + expect(parsed.validThrough).toBe("2026-08-02"); expect(parsed.employmentType).toEqual(["Fuldtid"]); expect(parsed.hiringOrganization.name).toBe("JFM"); expect(parsed.hiringOrganization.logo).toBe("https://jobdanmark.dk/media/jfm-logo.png?width=100"); expect(parsed.jobLocation.streetAddress).toBe("Banegårdspladsen 1, 5000 Odense C"); + expect(parsed.jobLocation.addressLocality).toBe("Odense C"); expect(parsed.description).toContain("identificere relevante datasæt"); expect(parsed.applyUrl).toBe("https://jfm.career.emply.com/da/apply/example"); }); + test("maps a rolling deadline (Løbende) to null in the HTML fallback", () => { + // "Løbende" is free text meaning rolling/ongoing - jobbank's parser maps + // its equivalent to null, and a stored "Løbende" deadline would hit every + // date-arithmetic consumer (review finding F25, 2026-08-19). + const html = HTML_WITHOUT_JSON_LD.replace( + "<li><strong>Ansøgningsfrist:</strong> 02-08-2026 23.59</li>", + "<li><strong>Ansøgningsfrist:</strong> Løbende</li>", + ); + const parsed = parseJobPostingFromHtml(html, "s", "https://jobdanmark.dk/job/s"); + expect(parsed.validThrough).toBeNull(); + }); + test("does not reject titles containing '404' mid-phrase", () => { const htmlWith404InTitle = HTML_WITHOUT_JSON_LD.replace( "<title>Journalistisk udvikler søges | jobdanmark", diff --git a/.agents/skills/jobdanmark-search/cli/tests/search-normalization.test.ts b/.agents/skills/jobdanmark-search/cli/tests/search-normalization.test.ts index 6a63111..f52b538 100644 --- a/.agents/skills/jobdanmark-search/cli/tests/search-normalization.test.ts +++ b/.agents/skills/jobdanmark-search/cli/tests/search-normalization.test.ts @@ -44,6 +44,33 @@ describe("Jobdanmark search normalization", () => { expect(result.company).toBe("Statens It"); }); + test("extracts the city when a comma follows the postcode (live jobdanmark shape)", () => { + const result = normalizeItem({ + ...item(), + companyAddress: "2670, Greve", + }); + + expect(result.location).toBe("Greve"); + }); + + test("trims trailing whitespace from the extracted city", () => { + const result = normalizeItem({ + ...item(), + companyAddress: "7100, Vejle ", + }); + + expect(result.location).toBe("Vejle"); + }); + + test("does not mistake a 4-digit street number for the postcode", () => { + const result = normalizeItem({ + ...item(), + companyAddress: "Vejlevej 1234, 7100 Vejle", + }); + + expect(result.location).toBe("Vejle"); + }); + test("survives a null companyAddress from the API", () => { const result = normalizeItem({ ...item(), @@ -61,4 +88,18 @@ describe("Jobdanmark search normalization", () => { expect(result.publishedDate).toBe("27-07-2026"); expect(result.applicationDeadline).toBe("17-08-2026"); }); + + test("omits presentation-only keys the agent can never use", () => { + // coverImage/companyLogo/companyLogoSvgMarkup/overlayColor/silhouetteLogo + // were ~40% of a live search payload, fed into agent context on every + // /scrape query (review finding F3, 2026-08-19). The #340 compatibility + // duplicates (companyName, publishedDate, applicationDeadline) stay. + const result = normalizeItem(item()); + + expect(result).not.toHaveProperty("coverImage"); + expect(result).not.toHaveProperty("companyLogo"); + expect(result).not.toHaveProperty("companyLogoSvgMarkup"); + expect(result).not.toHaveProperty("overlayColor"); + expect(result).not.toHaveProperty("silhouetteLogo"); + }); }); \ No newline at end of file diff --git a/.agents/skills/jobindex-search/cli/README.md b/.agents/skills/jobindex-search/cli/README.md index 4316dd8..386ed78 100644 --- a/.agents/skills/jobindex-search/cli/README.md +++ b/.agents/skills/jobindex-search/cli/README.md @@ -169,12 +169,29 @@ bun run src/cli.ts detail h1647303 --format plain ``` **Field notes:** -- `deadline` — application deadline date string; `null` if not listed. -- `employmentType` — e.g. `"Fastansættelse"`, `"Midlertidig ansættelse"`; `null` if not listed. -- `hours` — e.g. `"Fuldtid"`, `"Deltid"`; `null` if not listed. -- `applyUrl` — the external application URL (resolved from the Jobindex redirect link `/c?t=...`); `null` if not available. -- `description` — full plain-text job description (HTML stripped). -- All fields may be `null` if not present in the HTML. + +Jobindex serves detail pages in two shapes, and field availability differs: +a **jobindex-native** page (recognisable by its `jd-*` facts blocks) carries +company, location, an ISO deadline, employment type and hours; an **external +ATS passthrough** (the employer's hosted ad, e.g. hr-manager/Talentech, served +through jobindex) has no reliable company anchor, so `company` is `null` there +rather than the ATS brand, and location/deadline come from the ad's own +widgets when present. + +- `id` / `url` — always the jobindex id and its `jobannonce` URL, never the + page's `og:url`/canonical (on passthrough pages those point at the external + ATS, not the posting). +- `deadline` — `YYYY-MM-DD` or `null`; Danish long dates ("13. september + 2026") and `DD-MM-YYYY` widget dates are converted. +- `employmentType` / `hours` — from the native facts blocks; `null` on + passthrough pages. +- `companyUrl` — currently always `null`; no page shape carries a usable + company link. +- `applyUrl` — the Jobindex redirect link (`/c?t=...`) when present; `null` + otherwise. +- `description` — plain text of the ad body (HTML stripped), falling back to + the page's meta description when the body is empty. +- All fields except `id`, `title`, and `url` may be `null`. --- diff --git a/.agents/skills/jobindex-search/cli/src/cli.ts b/.agents/skills/jobindex-search/cli/src/cli.ts index d89abf7..e816916 100644 --- a/.agents/skills/jobindex-search/cli/src/cli.ts +++ b/.agents/skills/jobindex-search/cli/src/cli.ts @@ -1,4 +1,5 @@ import { createCLI } from "@bunli/core" +import { writeError } from "./helpers.js" import { search } from "./commands/search.js" import { detail } from "./commands/detail.js" @@ -8,7 +9,37 @@ const cli = await createCLI({ description: "CLI for searching jobs on Jobindex.dk", }) -cli.command(search) -cli.command(detail) +const commands = [search, detail] +for (const command of commands) { + cli.command(command) +} + +// Reject unknown --flags before dispatch. bunli silently discards them, and a +// silently discarded filter changes what the search returns without any error +// (a wrong flag name once returned an entire portal's database as if it +// matched the query). add-portal.md's contract requires a bogus flag to exit 1 +// with a JSON error on stderr; this enforces it for the reference CLIs too. +const argv = process.argv.slice(2) +const invoked = commands.find((c) => (c as { name?: string }).name === argv[0]) +if (invoked) { + const known = new Set([ + ...Object.keys((invoked as { options?: Record }).options ?? {}), + "help", + "version", + ]) + for (const token of argv.slice(1)) { + if (token === "--") break + if (token.startsWith("--")) { + const flag = token.slice(2).split("=")[0] + if (!known.has(flag)) { + writeError( + `unknown flag --${flag} for '${argv[0]}' - flags are never silently ignored, because a discarded filter changes what the search returns; see --help for the supported flags`, + "UNKNOWN_FLAG", + ) + process.exit(1) + } + } + } +} await cli.run() diff --git a/.agents/skills/jobindex-search/cli/src/commands/detail.ts b/.agents/skills/jobindex-search/cli/src/commands/detail.ts index 0e82094..f2dab2d 100644 --- a/.agents/skills/jobindex-search/cli/src/commands/detail.ts +++ b/.agents/skills/jobindex-search/cli/src/commands/detail.ts @@ -1,6 +1,6 @@ import { defineCommand, option } from "@bunli/core" import { z } from "zod" -import { htmlFetch, writeError, extractDivContent } from "../helpers.js" +import { htmlFetch, writeError } from "../helpers.js" const BASE_URL = "https://www.jobindex.dk" @@ -39,6 +39,13 @@ function decodeHtmlEntities(text: string): string { .replace(/"/g, '"') .replace(/'/g, "'") .replace(/'/g, "'") + // Danish letters appear as named entities in employer-hosted ad markup. + .replace(/ø/g, "ø") + .replace(/Ø/g, "Ø") + .replace(/æ/g, "æ") + .replace(/Æ/g, "Æ") + .replace(/å/g, "å") + .replace(/Å/g, "Å") // Numeric character references: decimal (é) and hexadecimal (é). .replace(/&#(\d+);/g, (_, dec) => numericEntity(parseInt(dec, 10))) .replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex) => numericEntity(parseInt(hex, 16))) @@ -72,150 +79,169 @@ function buildUrl(idOrUrl: string): { url: string; id: string } { return { url, id: idOrUrl } } -/** - * Parse the detail HTML page using regex to avoid node-html-parser nesting bugs. - */ -function parseDetailPage(html: string, url: string, id: string): DetailResult { - // Title: extract from

tag - const h1Match = html.match(/]*>([\s\S]*?)<\/h1>/i) - const title = h1Match ? decodeHtmlEntities(stripTags(h1Match[1])) : "" +const DANISH_MONTHS: Record = { + januar: "01", februar: "02", marts: "03", april: "04", maj: "05", juni: "06", + juli: "07", august: "08", september: "09", oktober: "10", november: "11", december: "12", +} +/** + * Normalize a date found on a detail page to YYYY-MM-DD, or null. + * Live pages carry three shapes: ISO, DD-MM-YYYY (the hr-manager widget), + * and Danish long form ("13. september 2026", the jobindex-native facts box). + */ +export function toIsoDate(value: string | null | undefined): string | null { + if (!value) return null + const text = value.trim() + let m = text.match(/^(\d{4})-(\d{2})-(\d{2})/) + if (m) return `${m[1]}-${m[2]}-${m[3]}` + m = text.match(/^(\d{2})-(\d{2})-(\d{4})/) + if (m) return `${m[3]}-${m[2]}-${m[1]}` + m = text.match(/^(\d{1,2})\.?\s+([a-zæøå]+)\s+(\d{4})/i) + if (m) { + const month = DANISH_MONTHS[m[2].toLowerCase()] + if (month) return `${m[3]}-${month}-${m[1].padStart(2, "0")}` + } + return null +} + +function metaContent(html: string, matcher: string): string | null { + const re = new RegExp( + `]+(?:property|name|itemprop)="${matcher}"[^>]+content="([^"]*)"|]+content="([^"]*)"[^>]+(?:property|name|itemprop)="${matcher}"`, + "i", + ) + const m = html.match(re) + const value = m ? (m[1] ?? m[2]) : null + return value ? decodeHtmlEntities(value).trim() || null : null +} + +/** The text of the

inside a jobindex-native jd-* facts block. */ +function jdBlockValue(html: string, cls: string): string | null { + const m = html.match(new RegExp(`class="${cls}"[^>]*>[\\s\\S]*?]*>([\\s\\S]*?)

`, "i")) + return m ? decodeHtmlEntities(stripTags(m[1])).replace(/\s+/g, " ").trim() || null : null +} + +/** Drop head/script/style content so text scans never read CSS or JS. */ +function visibleHtml(html: string): string { + return html + .replace(//gi, "") + .replace(//gi, "") + .replace(//gi, "") + .replace(//g, "") +} + +function bodyText(html: string): string | null { + const text = decodeHtmlEntities(stripTags(visibleHtml(html).replace(/<(br|\/p|\/div|\/li|\/h[1-6])[^>]*>/gi, "\n"))) + .split("\n") + .map((line) => line.replace(/\s+/g, " ").trim()) + .filter(Boolean) + .join("\n") + return text || null +} + +/** + * Parse a live detail page. Jobindex serves two shapes (verified live + * 2026-08-19; the selectors the previous parser used exist in neither): + * + * - the jobindex-native shape, recognisable by its `jd-*` facts blocks + * (jd-deadline, jd-location, ...), with the company as the `` + * prefix ("COMPANY - Job title"); + * - an external ATS passthrough (hr-manager/Talentech and similar), where + * the page IS the employer's hosted ad: `og:url` points at the ATS, + * `og:site_name` is the ATS brand, and there is no reliable company + * anchor at all - so `company` is honestly null there, never the ATS. + * + * `id` and `url` are always the caller's jobindex id and its jobannonce + * URL: the canonical/og:url on these pages is the external ATS, and + * storing that broke /scrape's "store a URL that resolves to the posting". + */ +export function parseDetailPage(html: string, url: string, id: string): DetailResult { + const isNative = html.includes('class="jd-') + + const ogTitle = metaContent(html, "og:title") + const itempropName = metaContent(html, "name") + const h1 = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i) + const h1Title = h1 ? decodeHtmlEntities(stripTags(h1[1])).replace(/\s+/g, " ").trim() : null + const title = ogTitle ?? itempropName ?? h1Title ?? "" if (!title) { throw new Error("Failed to parse job listing HTML") } - // Company and companyUrl from jix-toolbar-top__company section + // Company: only the native shape carries one - as the <title> prefix, + // "VELLIV - Udvikler til Camunda/AWS". Require the suffix to be the job + // title so an unrelated <title> never becomes a company name. let company: string | null = null - let companyUrl: string | null = null - - const companySection = html.match(/class="jix-toolbar-top__company"[^>]*>([\s\S]*?)<\/div>/i) - if (companySection) { - const linkMatch = companySection[1].match(/<[Aa][^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/[Aa]>/i) - if (linkMatch) { - company = decodeHtmlEntities(stripTags(linkMatch[2])) || null - companyUrl = linkMatch[1] || null + if (isNative) { + const titleTag = html.match(/<title>([\s\S]*?)<\/title>/i) + const pageTitle = titleTag ? decodeHtmlEntities(titleTag[1]).replace(/\s+/g, " ").trim() : "" + if (pageTitle.endsWith(` - ${title}`)) { + company = pageTitle.slice(0, -(title.length + 3)).trim() || null } } - // Location from jix_robotjob--area span let location: string | null = null - const locMatch = html.match(/<span[^>]+class="jix_robotjob--area"[^>]*>([\s\S]*?)<\/span>/i) - if (locMatch) { - location = decodeHtmlEntities(stripTags(locMatch[1])) || null - } - - // Date from <time datetime="..."> element - let date: string | null = null - const timeMatch = html.match(/<time[^>]+datetime="([^"]+)"/) - if (timeMatch) { - date = timeMatch[1] || null - } - - // Employment type and hours from jix-info section + let deadline: string | null = null let employmentType: string | null = null let hours: string | null = null - let deadline: string | null = null - - const jixInfoMatch = html.match(/class="jix-info"[^>]*>([\s\S]*?)<\/div>/i) - if (jixInfoMatch) { - const jixInfoHtml = jixInfoMatch[1] - - // Parse p elements with bold labels - const pMatches = [...jixInfoHtml.matchAll(/<p[^>]*><b>([^<]+)<\/b>\s*([\s\S]*?)<\/p>/gi)] - for (const pm of pMatches) { - const label = pm[1].toLowerCase().trim() - const value = stripTags(pm[2]).trim() - - if (label.includes("ansættelsestype") || label.includes("employment type")) { - employmentType = decodeHtmlEntities(value) || null - } else if (label.includes("ugentlig arbejdstid") || label.includes("weekly working time") || label.includes("arbejdstid")) { - hours = decodeHtmlEntities(value) || null - } else if (label.includes("ansøgningsfrist") || label.includes("deadline") || label.includes("application deadline")) { - deadline = decodeHtmlEntities(value) || null - } - } - } - - // If not found in jix-info, try broader text patterns - if (!employmentType) { - const emtMatch = html.match(/<b>(?:Ansættelsestype|Employment\s*type):<\/b>\s*([^<\n]+)/i) - if (emtMatch) { - employmentType = decodeHtmlEntities(emtMatch[1].trim()) || null - } - } - - if (!hours) { - const hoursMatch = html.match(/<b>(?:Ugentlig\s*arbejdstid|Weekly\s*working\s*time):<\/b>\s*([^<\n]+)/i) - if (hoursMatch) { - hours = decodeHtmlEntities(hoursMatch[1].trim()) || null - } - } - - // Deadline from application section - if (!deadline) { - // Look for "senest den" or "Ansøgningsfrist" patterns in text - const deadlineMatch = html.match(/Ansøgningsfrist[^:]*:\s*([^<\n,]+)/i) - if (deadlineMatch) { - deadline = decodeHtmlEntities(deadlineMatch[1].trim()) || null - } - } - - // Apply URL: look for /c?t= redirect links in jix_onlineapplication_button - let applyUrl: string | null = null - const applySection = html.match(/class="jix_onlineapplication_button"[^>]*>[\s\S]*?href="([^"]+)"/i) - if (applySection) { - const href = decodeHtmlEntities(applySection[1]) - applyUrl = href.startsWith("http") ? href : `${BASE_URL}${href}` - } - - // If not found, look for any /c?t= link - if (!applyUrl) { - const ctMatch = html.match(/href="(\/c\?t=[^"]+)"/) - if (ctMatch) { - applyUrl = `${BASE_URL}${decodeHtmlEntities(ctMatch[1])}` - } - } - - // Description: job text section let description: string | null = null - // Try job-text class first - const jobTextHtml = extractDivContent(html, "job-text") - if (jobTextHtml) { - description = decodeHtmlEntities(stripTags(jobTextHtml)).replace(/\s+/g, " ").trim() || null - } + if (isNative) { + location = jdBlockValue(html, "jd-location") + deadline = toIsoDate(jdBlockValue(html, "jd-deadline")) + employmentType = jdBlockValue(html, "jd-type") + hours = jdBlockValue(html, "jd-workhours") + const desc = html.match(/class="jd-description"[^>]*>([\s\S]*?)<\/div>/i) + description = desc + ? decodeHtmlEntities(stripTags(desc[1])).replace(/\s+/g, " ").trim() || null + : null + } else { + // hr-manager-style widget: a rowheader label followed by the value span. + const workplace = visibleHtml(html).match( + /class="workplace[^"]*"[\s\S]*?<span class="empty">([\s\S]*?)<\/span>/i, + ) + location = workplace + ? decodeHtmlEntities(stripTags(workplace[1])).replace(/\s+/g, " ").trim() || null + : null - // Fallback: try og:description meta tag for a brief description - if (!description) { - const ogDescMatch = html.match(/property="og:description"[^>]+content="([^"]+)"/i) || - html.match(/content="([^"]+)"[^>]+property="og:description"/i) - if (ogDescMatch) { - description = decodeHtmlEntities(ogDescMatch[1]) || null + // Deadline: label + a real date within range, scanned only over visible + // markup - the label also appears inside a CSS comment on these pages, + // which the previous parser captured verbatim as the deadline. + const due = visibleHtml(html).match( + /(?:Ansøgningsfrist|Application\s*due|Frist)[\s\S]{0,300}?(\d{2}-\d{2}-\d{4}|\d{4}-\d{2}-\d{2}|\d{1,2}\.?\s+[a-zæøå]+\s+\d{4})/i, + ) + deadline = due ? toIsoDate(due[1]) : null + + description = bodyText(html) + if (!description || description.length < 100) { + description = metaContent(html, "og:description") ?? metaContent(html, "description") ?? description } } - // Get canonical URL or use the fetched URL - const canonicalMatch = html.match(/<link[^>]+rel="canonical"[^>]+href="([^"]+)"/i) || - html.match(/property="og:url"[^>]+content="([^"]+)"/i) || - html.match(/content="([^"]+)"[^>]+property="og:url"/i) - const canonicalUrl = canonicalMatch ? canonicalMatch[1] : url + if (!description) { + description = metaContent(html, "og:description") + } - // Extract ID from canonical URL, fall back to the provided ID - const canonicalId = extractIdFromUrl(canonicalUrl) || id + // Apply URL: jobindex's own /c?t= redirect when present. + let applyUrl: string | null = null + const ctMatch = html.match(/href="(\/c\?t=[^"]+)"/) + if (ctMatch) { + applyUrl = `${BASE_URL}${decodeHtmlEntities(ctMatch[1])}` + } + + const timeMatch = html.match(/<time[^>]+datetime="([^"]+)"/) return { - id: canonicalId, + id, title, - company: company || null, - companyUrl: companyUrl || null, - location: location || null, - date: date || null, - deadline: deadline || null, - employmentType: employmentType || null, - hours: hours || null, - applyUrl: applyUrl || null, - url: canonicalUrl, - description: description || null, + company, + companyUrl: null, + location, + date: timeMatch ? toIsoDate(timeMatch[1]) : null, + deadline, + employmentType, + hours, + applyUrl, + url, + description, } } @@ -243,13 +269,6 @@ export const detail = defineCommand({ if (signal.aborted) return - // Check if page is not a valid job listing - // A valid job listing has an <h1> tag - if (!html.includes("<h1>") && !html.includes("<h1 ")) { - writeError("Job not found", "NOT_FOUND") - process.exit(1) - } - let data: DetailResult try { data = parseDetailPage(html, url, id) diff --git a/.agents/skills/jobindex-search/cli/src/helpers.ts b/.agents/skills/jobindex-search/cli/src/helpers.ts index 3852921..e6db4aa 100644 --- a/.agents/skills/jobindex-search/cli/src/helpers.ts +++ b/.agents/skills/jobindex-search/cli/src/helpers.ts @@ -160,7 +160,11 @@ export function parseSearchPage(html: string): SearchPageResult { } } let deadline: string | null = null - if (r.apply_deadline_asap) deadline = "ASAP" + // apply_deadline_asap means the posting states no fixed deadline ("apply + // now"). The /scrape contract represents that as null, and consumers do + // date arithmetic on this field - so the flag maps to null, and wins over + // any date field that happens to be present. + if (r.apply_deadline_asap) deadline = null else if (typeof r.apply_deadline === "string") deadline = r.apply_deadline.slice(0, 10) else if (typeof r.lastdate === "string") deadline = r.lastdate diff --git a/.agents/skills/jobindex-search/cli/tests/cli-flag-validation.test.ts b/.agents/skills/jobindex-search/cli/tests/cli-flag-validation.test.ts index 3cf10e3..bedb368 100644 --- a/.agents/skills/jobindex-search/cli/tests/cli-flag-validation.test.ts +++ b/.agents/skills/jobindex-search/cli/tests/cli-flag-validation.test.ts @@ -61,3 +61,20 @@ describe("Jobindex CLI flag validation", () => { }); }); }); + + +describe("unknown flag rejection", () => { + // add-portal.md's contract: "a bogus flag or missing required arg exits 1 + // with a JSON error on stderr". A silently discarded flag is worse than an + // error: on jobdanmark a wrong flag name returned the entire database + // (13,862 results) as if it matched the query (review finding F13, + // 2026-08-19). Rejection happens before dispatch, so these are network-free. + test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => { + const result = await runCLI(["search", "--query", "test", "--bogus-flag", "xyz"]); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + const error = JSON.parse(result.stderr); + expect(error.code).toBe("UNKNOWN_FLAG"); + expect(error.error).toContain("--bogus-flag"); + }); +}); diff --git a/.agents/skills/jobindex-search/cli/tests/detail-parsing.test.ts b/.agents/skills/jobindex-search/cli/tests/detail-parsing.test.ts new file mode 100644 index 0000000..280c6a6 --- /dev/null +++ b/.agents/skills/jobindex-search/cli/tests/detail-parsing.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test } from "bun:test"; +import { parseDetailPage } from "../src/commands/detail"; + +// Jobindex redesigned its detail pages; every selector the old parser used +// (job-text, jix-info, jix_robotjob--area, jix-toolbar-top__company) is gone +// from live pages, so detail returned null company/location/date, CSS-comment +// text as the deadline, and an external ATS URL as its own id/url - exit 0, +// nothing signalling breakage (review finding F14, 2026-08-19, measured on +// 5/5 live postings). These fixtures are trimmed from live pages captured +// 2026-08-19: the jobindex-native "jd-*" shape and the external-ATS +// (hr-manager/Talentech) passthrough shape. + +const NATIVE_PAGE = `<!DOCTYPE html> +<html lang="da"> +<head> + <title>VELLIV - Udvikler til Camunda/AWS + + + + + +
+

Udvikler til Camunda/AWS

+
+
+
+
+
+

En virksomhed med mere end 100 års historie, der samtidig er cloud-only, er ikke hverdagskost.

+

Hos Velliv får du mulighed for at arbejde med Camunda, AWS og automatisering af processer.

+
+
+
+
+

Jobtype:

+

Fast

+
+
+

Arbejdstid:

+

Fuldtid

+
+
+

Arbejdsdage:

+

Dag

+
+
+

Ansøgningsfrist:

+

13. september 2026

+
+
+

Arbejdssted:

+

Ballerup

+
+
+
+
+ +`; + +// The external shape: an employer's ATS-hosted ad served through jobindex. +// og:url points at the ATS (NOT the posting), og:site_name is the ATS brand, +// and the only occurrence of the deadline label outside the widget is inside +// a CSS comment - the exact text the old regex captured as the deadline. +const EXTERNAL_PAGE = ` + + + + Talentech - C#-udvikler til kritiske analyseløsninger i elnettet + + + + + + + + + +

C#-udvikler til kritiske analyseløsninger i elnettet

+

Vil du være med til at udvikle og drifte de systemer, der understøtter udbygningen af Danmarks kommende elnet? Vi arbejder i krydsfeltet mellem IT og energi.

+
Workplace
Fredericia
+
Application due
21-09-2026
+ +`; + +const JOBANNONCE_URL = "https://www.jobindex.dk/jobannonce/"; + +describe("parseDetailPage - jobindex-native (jd-*) shape", () => { + const job = parseDetailPage(NATIVE_PAGE, `${JOBANNONCE_URL}h1690934`, "h1690934"); + + test("extracts the contract fields", () => { + expect(job).toMatchObject({ + id: "h1690934", + title: "Udvikler til Camunda/AWS", + company: "VELLIV", + location: "Ballerup", + deadline: "2026-09-13", + url: `${JOBANNONCE_URL}h1690934`, + }); + }); + + test("converts the Danish long date to ISO", () => { + expect(job.deadline).toBe("2026-09-13"); + }); + + test("extracts employment metadata and the description body", () => { + expect(job.employmentType).toBe("Fast"); + expect(job.hours).toBe("Fuldtid"); + expect(job.description).toContain("Camunda, AWS og automatisering"); + }); +}); + +describe("parseDetailPage - external ATS passthrough shape", () => { + const job = parseDetailPage(EXTERNAL_PAGE, `${JOBANNONCE_URL}h1690445`, "h1690445"); + + test("keeps the jobindex id and jobannonce URL, never the ATS og:url", () => { + expect(job.id).toBe("h1690445"); + expect(job.url).toBe(`${JOBANNONCE_URL}h1690445`); + expect(job.url).not.toContain("hr-manager.net"); + }); + + test("extracts the title and never reports the ATS brand as the company", () => { + expect(job.title).toBe("C#-udvikler til kritiske analyseløsninger i elnettet"); + expect(job.company).toBeNull(); + }); + + test("extracts the workplace widget location", () => { + expect(job.location).toBe("Fredericia"); + }); + + test("finds the real deadline, not the CSS comment", () => { + expect(job.deadline).toBe("2026-09-21"); + }); + + test("description is readable body text, not a stylesheet", () => { + expect(job.description).toContain("udbygningen af Danmarks kommende elnet"); + expect(job.description).not.toContain("padding-bottom"); + }); + + test("deadline is null when only the CSS comment mentions the label", () => { + const noDueWidget = EXTERNAL_PAGE.replace( + /
[\s\S]*?
<\/div>/, + "", + ); + const parsed = parseDetailPage(noDueWidget, `${JOBANNONCE_URL}h9`, "h9"); + expect(parsed.deadline).toBeNull(); + }); +}); diff --git a/.agents/skills/jobindex-search/cli/tests/search-page.test.ts b/.agents/skills/jobindex-search/cli/tests/search-page.test.ts new file mode 100644 index 0000000..6891db3 --- /dev/null +++ b/.agents/skills/jobindex-search/cli/tests/search-page.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { parseSearchPage } from "../src/helpers"; + +// parseSearchPage had no tests at all: mutating total to stop using +// sr.hitcount survived the whole suite (review finding F35, 2026-08-19). +// The fixture mirrors the real Stash nesting documented in helpers.ts: +// jobsearch/result_app -> storeData -> searchResponse -> { hitcount, results[] }. +function stashPage(searchResponse: object): string { + const stash = { jobsearch: { result_app: { storeData: { searchResponse } } } }; + return ``; +} + +const RESULT = { + tid: "h1689961", + headline: "Softwareudvikler", + company: { name: "Acme A/S", homeurl: "https://acme.example" }, + area: "Aarhus", + firstdate: "2026-08-10", + apply_deadline: "2026-09-11T00:00:00", +}; + +describe("parseSearchPage", () => { + test("total comes from hitcount, not the page's result count", () => { + const page = stashPage({ hitcount: 435, results: [RESULT] }); + const parsed = parseSearchPage(page); + expect(parsed.total).toBe(435); + expect(parsed.results).toHaveLength(1); + }); + + test("total falls back to the result count when hitcount is absent", () => { + const page = stashPage({ results: [RESULT, { ...RESULT, tid: "h2" }] }); + expect(parseSearchPage(page).total).toBe(2); + }); + + test("maps the contract fields from a Stash result", () => { + const [job] = parseSearchPage(stashPage({ hitcount: 1, results: [RESULT] })).results; + expect(job).toMatchObject({ + id: "h1689961", + title: "Softwareudvikler", + company: "Acme A/S", + location: "Aarhus", + date: "2026-08-10", + deadline: "2026-09-11", + url: "https://www.jobindex.dk/jobannonce/h1689961", + }); + }); + + test("maps an ASAP posting's deadline to null (no stated deadline)", () => { + // apply_deadline_asap means "no fixed deadline, apply now". The /scrape + // schema defines null as exactly that, and every consumer (rank's expiry + // sweep, notion-sync's typed date column) does date arithmetic on this + // field - a bare "ASAP" string broke all of them on half of live results + // (review finding F12, 2026-08-19). lastdate present too: the flag wins. + const [job] = parseSearchPage( + stashPage({ + hitcount: 1, + results: [{ ...RESULT, apply_deadline: undefined, apply_deadline_asap: true, lastdate: "2026-09-30" }], + }), + ).results; + expect(job.deadline).toBeNull(); + }); + + test("falls back to lastdate when apply_deadline is absent", () => { + const [job] = parseSearchPage( + stashPage({ hitcount: 1, results: [{ ...RESULT, apply_deadline: undefined, lastdate: "2026-09-30" }] }), + ).results; + expect(job.deadline).toBe("2026-09-30"); + }); +}); diff --git a/.agents/skills/jobnet-search/cli/src/cli.ts b/.agents/skills/jobnet-search/cli/src/cli.ts index 644ee9e..4436d81 100644 --- a/.agents/skills/jobnet-search/cli/src/cli.ts +++ b/.agents/skills/jobnet-search/cli/src/cli.ts @@ -1,4 +1,5 @@ import { createCLI } from "@bunli/core" +import { writeError } from "./helpers.js" import { search } from "./commands/search.js" import { detail } from "./commands/detail.js" import { occupations } from "./commands/occupations.js" @@ -10,9 +11,37 @@ const cli = await createCLI({ description: "CLI for the Jobnet.dk Danish government job portal API", }) -cli.command(search) -cli.command(detail) -cli.command(occupations) -cli.command(suggestions) +const commands = [search, detail, occupations, suggestions] +for (const command of commands) { + cli.command(command) +} + +// Reject unknown --flags before dispatch. bunli silently discards them, and a +// silently discarded filter changes what the search returns without any error +// (a wrong flag name once returned an entire portal's database as if it +// matched the query). add-portal.md's contract requires a bogus flag to exit 1 +// with a JSON error on stderr; this enforces it for the reference CLIs too. +const argv = process.argv.slice(2) +const invoked = commands.find((c) => (c as { name?: string }).name === argv[0]) +if (invoked) { + const known = new Set([ + ...Object.keys((invoked as { options?: Record }).options ?? {}), + "help", + "version", + ]) + for (const token of argv.slice(1)) { + if (token === "--") break + if (token.startsWith("--")) { + const flag = token.slice(2).split("=")[0] + if (!known.has(flag)) { + writeError( + `unknown flag --${flag} for '${argv[0]}' - flags are never silently ignored, because a discarded filter changes what the search returns; see --help for the supported flags`, + "UNKNOWN_FLAG", + ) + process.exit(1) + } + } + } +} await cli.run() diff --git a/.agents/skills/jobnet-search/cli/src/commands/detail.ts b/.agents/skills/jobnet-search/cli/src/commands/detail.ts index c3cd25a..08ed460 100644 --- a/.agents/skills/jobnet-search/cli/src/commands/detail.ts +++ b/.agents/skills/jobnet-search/cli/src/commands/detail.ts @@ -59,6 +59,23 @@ export interface DetailApiResponse { user: string | null } +/** + * Normalize a raw detail response before any output format sees it. + * + * The API's "deadline not disclosed" sentinel is 1900-01-01 (it arrives with + * isApplicationDeadlineASAP / an applicationDeadlineStatus of NotDisclosed). + * The search command already maps that sentinel to null; detail must agree, + * or an undisclosed deadline reads as 126 years expired and /rank's expiry + * sweep retires the job the moment it is stored. + */ +export function prepareDetail(data: DetailApiResponse): DetailApiResponse { + const deadline = data.application.deadlineDate + if (deadline && deadline.startsWith("1900-01-01")) { + data.application.deadlineDate = null + } + return data +} + export const detail = defineCommand({ name: "detail", description: "Full detail for a single job ad", @@ -77,9 +94,10 @@ export const detail = defineCommand({ } try { - const data = await apiFetch( - `/FindJob/JobAdDetails/${id}`, - { incrementViews: "false" } + const data = prepareDetail( + await apiFetch(`/FindJob/JobAdDetails/${id}`, { + incrementViews: "false", + }), ) if (signal.aborted) return diff --git a/.agents/skills/jobnet-search/cli/tests/cli-flag-validation.test.ts b/.agents/skills/jobnet-search/cli/tests/cli-flag-validation.test.ts index 40f5c74..76bc1b3 100644 --- a/.agents/skills/jobnet-search/cli/tests/cli-flag-validation.test.ts +++ b/.agents/skills/jobnet-search/cli/tests/cli-flag-validation.test.ts @@ -72,3 +72,26 @@ describe("Jobnet CLI flag validation", () => { }); }); }); + + +describe("unknown flag rejection", () => { + // add-portal.md's contract: "a bogus flag or missing required arg exits 1 + // with a JSON error on stderr". A silently discarded flag is worse than an + // error: on jobdanmark a wrong flag name returned the entire database + // (13,862 results) as if it matched the query (review finding F13, + // 2026-08-19). Rejection happens before dispatch, so these are network-free. + test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => { + const result = await runCLI(["search", "--search-string", "test", "--bogus-flag", "xyz"]); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + const error = JSON.parse(result.stderr); + expect(error.code).toBe("UNKNOWN_FLAG"); + expect(error.error).toContain("--bogus-flag"); + }); + + test("--query (another portal's free-text flag) is rejected, not treated as no filter", async () => { + const result = await runCLI(["search", "--query", "test"]); + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stderr).code).toBe("UNKNOWN_FLAG"); + }); +}); diff --git a/.agents/skills/jobnet-search/cli/tests/detail-formatting.test.ts b/.agents/skills/jobnet-search/cli/tests/detail-formatting.test.ts index 28dde67..ce3799e 100644 --- a/.agents/skills/jobnet-search/cli/tests/detail-formatting.test.ts +++ b/.agents/skills/jobnet-search/cli/tests/detail-formatting.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { formatDetailPlain, type DetailApiResponse } from "../src/commands/detail"; +import { formatDetailPlain, prepareDetail, type DetailApiResponse } from "../src/commands/detail"; function detail(overrides: Partial = {}): DetailApiResponse { return { @@ -93,3 +93,29 @@ describe("formatDetailPlain", () => { expect(formatted).not.toContain("Apply:"); }); }); + + +describe("prepareDetail deadline sentinel", () => { + // The API's "deadline not disclosed" sentinel is 1900-01-01 (paired with + // isApplicationDeadlineASAP / applicationDeadlineStatus). search maps it to + // null and has a test pinning that; detail dumped the raw response, so an + // undisclosed deadline read as 126 years expired and /rank's sweep would + // retire the job instantly (review finding F33, 2026-08-19). + test("maps the 1900-01-01 undisclosed sentinel to null", () => { + const data = detail(); + data.application.deadlineDate = "1900-01-01T00:00:00+01:00"; + expect(prepareDetail(data).application.deadlineDate).toBeNull(); + }); + + test("keeps a real deadline unchanged", () => { + const data = detail(); + data.application.deadlineDate = "2026-09-01T00:00:00+02:00"; + expect(prepareDetail(data).application.deadlineDate).toBe("2026-09-01T00:00:00+02:00"); + }); + + test("keeps a null deadline null", () => { + const data = detail(); + data.application.deadlineDate = null; + expect(prepareDetail(data).application.deadlineDate).toBeNull(); + }); +}); diff --git a/.agents/skills/linkedin-search/SKILL.md b/.agents/skills/linkedin-search/SKILL.md index 2a9b7bd..f533f1f 100644 --- a/.agents/skills/linkedin-search/SKILL.md +++ b/.agents/skills/linkedin-search/SKILL.md @@ -64,7 +64,7 @@ bun run .agents/skills/linkedin-search/cli/src/cli.ts detail [--format `id` is the job ID from `search` results (e.g. `4426311357`). You may also pass a full LinkedIn `jobs/view/...` URL or a `urn:li:jobPosting:...` URN. Returns the full description, -seniority, employment type, job function, industries, and apply link. +seniority, employment type, job function, and industries. ## Usage examples diff --git a/.agents/skills/linkedin-search/cli/src/cli.ts b/.agents/skills/linkedin-search/cli/src/cli.ts index 52ada1a..f697398 100644 --- a/.agents/skills/linkedin-search/cli/src/cli.ts +++ b/.agents/skills/linkedin-search/cli/src/cli.ts @@ -63,6 +63,16 @@ EXAMPLES Personal use only — uses LinkedIn's public pages; keep volume low (LinkedIn ToS). ` +// Long-form flag names each command accepts (parseFlags resolves the short +// aliases q/l/n to these before validation). "help"/"h" pass so `search --help` +// still prints usage. +const KNOWN_FLAGS: Record> = { + search: new Set([ + "location", "query", "jobage", "jobage-minutes", "remote", "page", "limit", "format", "help", "h", + ]), + detail: new Set(["format", "help", "h"]), +} + async function main(): Promise { const argv = process.argv.slice(2) const flags = parseFlags(argv) @@ -73,6 +83,25 @@ async function main(): Promise { return cmd ? 0 : 1 } + // Reject unknown flags instead of silently discarding them: a discarded + // filter changes what the search returns with no error (a wrong flag name + // once returned an entire portal's database as if it matched the query). + // add-portal.md's contract requires a bogus flag to exit 1 with a JSON + // error on stderr. + const knownFlags = KNOWN_FLAGS[cmd] + if (knownFlags) { + for (const key of Object.keys(flags)) { + if (key === "_" || knownFlags.has(key)) continue + process.stderr.write( + JSON.stringify({ + error: `unknown flag --${key} for '${cmd}' - flags are never silently ignored, because a discarded filter changes what the search returns; see --help for the supported flags`, + code: "UNKNOWN_FLAG", + }) + "\n", + ) + return 1 + } + } + if (cmd === "search") { const location = typeof flags.location === "string" ? flags.location : undefined if (!location) { diff --git a/.agents/skills/linkedin-search/cli/src/commands/detail.ts b/.agents/skills/linkedin-search/cli/src/commands/detail.ts index c07a8ed..ed710b8 100644 --- a/.agents/skills/linkedin-search/cli/src/commands/detail.ts +++ b/.agents/skills/linkedin-search/cli/src/commands/detail.ts @@ -43,7 +43,6 @@ export async function runDetail(opts: DetailOpts): Promise { job.description || "(no description)", "", `URL: ${job.url}`, - job.applyUrl ? `Apply: ${job.applyUrl}` : "", ].filter((l) => l !== "") process.stdout.write(lines.join("\n") + "\n") } else { diff --git a/.agents/skills/linkedin-search/cli/src/helpers.ts b/.agents/skills/linkedin-search/cli/src/helpers.ts index 1d6da1f..8b58e1f 100644 --- a/.agents/skills/linkedin-search/cli/src/helpers.ts +++ b/.agents/skills/linkedin-search/cli/src/helpers.ts @@ -63,7 +63,6 @@ export interface JobDetail extends JobCard { employmentType: string | null jobFunction: string | null industries: string | null - applyUrl: string | null } /** @@ -228,9 +227,6 @@ export function parseJobDetail(html: string, id: string): JobDetail { criteria[clean(cm[1]).toLowerCase()] = clean(cm[2]) } - const applyMatch = html.match(/class="topcard__link[^"]*"[^>]*href="([^"]+)"/i) - const applyUrl = applyMatch ? decodeHtmlEntities(applyMatch[1]).split("?")[0] : null - return { id, title: title ? clean(title) : "(untitled)", @@ -244,7 +240,6 @@ export function parseJobDetail(html: string, id: string): JobDetail { employmentType: criteria["employment type"] ?? null, jobFunction: criteria["job function"] ?? null, industries: criteria["industries"] ?? null, - applyUrl, } } diff --git a/.agents/skills/linkedin-search/cli/tests/cli-flag-validation.test.ts b/.agents/skills/linkedin-search/cli/tests/cli-flag-validation.test.ts index 87c1123..1fa7747 100644 --- a/.agents/skills/linkedin-search/cli/tests/cli-flag-validation.test.ts +++ b/.agents/skills/linkedin-search/cli/tests/cli-flag-validation.test.ts @@ -68,13 +68,14 @@ describe("LinkedIn CLI flag validation", () => { // parseFlags in cli.ts treats a next-token starting with "-" as absent // (`next.startsWith("-")` → flag becomes boolean `true`), and there is no // `--flag=value` syntax. So "-5" never reaches --jobage-minutes as a value; - // parseInt("true") is NaN, and BAD_ARG comes from the NaN branch, not the - // `v <= 0` guard. Negatives are unreachable through the CLI as currently parsed. + // it parses as a stray flag named "5", which the unknown-flag guard now + // rejects before the NaN branch can. Either way the invariant holds: a + // negative value fails loudly with exit 1 and a JSON error, never a + // silent unfiltered search. const result = await runCLI(["search", "-l", LOCATION, "--jobage-minutes", "-5"]); expect(result.exitCode).not.toBe(0); const err = parsedStderr(result.stderr); - expect(err.code).toBe("BAD_ARG"); - expect(err.error).toMatch(/jobage-minutes/); + expect(err.code).toBe("UNKNOWN_FLAG"); }); }); @@ -126,3 +127,20 @@ describe("LinkedIn CLI flag validation", () => { }); }); }); + + +describe("unknown flag rejection", () => { + // add-portal.md's contract: "a bogus flag or missing required arg exits 1 + // with a JSON error on stderr". A silently discarded flag is worse than an + // error: on jobdanmark a wrong flag name returned the entire database + // (13,862 results) as if it matched the query (review finding F13, + // 2026-08-19). Rejection happens before dispatch, so these are network-free. + test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => { + const result = await runCLI(["search", "-l", "Denmark", "-q", "test", "--bogus-flag", "xyz"]); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + const error = JSON.parse(result.stderr); + expect(error.code).toBe("UNKNOWN_FLAG"); + expect(error.error).toContain("--bogus-flag"); + }); +}); diff --git a/.agents/skills/linkedin-search/cli/tests/parsing.test.ts b/.agents/skills/linkedin-search/cli/tests/parsing.test.ts index 93b44d1..28edb70 100644 --- a/.agents/skills/linkedin-search/cli/tests/parsing.test.ts +++ b/.agents/skills/linkedin-search/cli/tests/parsing.test.ts @@ -14,6 +14,46 @@ function searchCard(id: string, title: string, company = "Acme"): string { `; } +// The /scrape contract fields beyond title/company. The original fixture had +// no