mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 08:36:25 +00:00
Merge pull request #347 from MadsLorentzen/fix/2026-08-19-review-fixes
Act on the 2026-08-19 deep code review: 35 findings fixed, every fix with the test that would have caught it
This commit is contained in:
@@ -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
|
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
|
`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:
|
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):
|
Facet filters (values come from freehire's controlled vocabularies; comma-separate for OR within a facet):
|
||||||
- `--region <codes>` — 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).
|
- `--region <codes>` — 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).
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ SEARCH FLAGS
|
|||||||
--page <n> 1-indexed page. Default 1.
|
--page <n> 1-indexed page. Default 1.
|
||||||
--limit, -n <n> Results per page (API limit). Default 25.
|
--limit, -n <n> Results per page (API limit). Default 25.
|
||||||
--format <fmt> json (default) | table | plain.
|
--format <fmt> 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
|
--description-format markdown (default) | text | html — how each result's
|
||||||
full description is rendered (json output only).
|
full description is rendered (json output only).
|
||||||
|
|
||||||
@@ -118,6 +120,17 @@ function parseIntFlag(name: string, raw: string | boolean | string[]): number |
|
|||||||
return val
|
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<string, Set<string>> = {
|
||||||
|
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<number> {
|
async function main(): Promise<number> {
|
||||||
const argv = process.argv.slice(2)
|
const argv = process.argv.slice(2)
|
||||||
const flags = parseFlags(argv)
|
const flags = parseFlags(argv)
|
||||||
@@ -128,6 +141,25 @@ async function main(): Promise<number> {
|
|||||||
return cmd ? 0 : 1
|
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") {
|
if (cmd === "search") {
|
||||||
const fmt = (flags.format as string) || "json"
|
const fmt = (flags.format as string) || "json"
|
||||||
|
|
||||||
@@ -172,6 +204,7 @@ async function main(): Promise<number> {
|
|||||||
limit: flags.limit ? Math.max(1, parseInt(flags.limit as string, 10)) : 25,
|
limit: flags.limit ? Math.max(1, parseInt(flags.limit as string, 10)) : 25,
|
||||||
format: (["json", "table", "plain"].includes(fmt) ? fmt : "json") as SearchOpts["format"],
|
format: (["json", "table", "plain"].includes(fmt) ? fmt : "json") as SearchOpts["format"],
|
||||||
descriptionFormat: descFmt as DescriptionFormat,
|
descriptionFormat: descFmt as DescriptionFormat,
|
||||||
|
includeDescription: flags["no-description"] === undefined,
|
||||||
regions: commaList(flags.region),
|
regions: commaList(flags.region),
|
||||||
countries: commaList(flags.country),
|
countries: commaList(flags.country),
|
||||||
cities: commaList(flags.city),
|
cities: commaList(flags.city),
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export interface SearchOpts {
|
|||||||
limit: number
|
limit: number
|
||||||
format: "json" | "table" | "plain"
|
format: "json" | "table" | "plain"
|
||||||
descriptionFormat: DescriptionFormat
|
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).
|
// Facet filters (already parsed into value lists; empty means unset).
|
||||||
regions: string[]
|
regions: string[]
|
||||||
countries: string[]
|
countries: string[]
|
||||||
@@ -38,9 +42,11 @@ function buildQuery(opts: SearchOpts): URLSearchParams {
|
|||||||
p.set("offset", String((opts.page - 1) * opts.limit))
|
p.set("offset", String((opts.page - 1) * opts.limit))
|
||||||
p.set("semantic_ratio", "0") // keyword search; the semantic index is opt-in
|
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
|
// The agent endpoint serves the index's truncated preview unless asked to
|
||||||
// rehydrate each hit from the database, so both params travel together.
|
// rehydrate each hit from the database, so both params travel together -
|
||||||
p.set("include_description", "true")
|
// unless the caller opted out of hydration entirely (--no-description).
|
||||||
p.set("description_format", opts.descriptionFormat)
|
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.jobage > 0 && opts.jobage < 9999) p.set("posted_within_days", String(opts.jobage))
|
||||||
if (opts.workMode) p.set("work_mode", opts.workMode)
|
if (opts.workMode) p.set("work_mode", opts.workMode)
|
||||||
if (opts.company) p.set("company_slug", opts.company)
|
if (opts.company) p.set("company_slug", opts.company)
|
||||||
@@ -117,7 +123,14 @@ export async function runSearch(opts: SearchOpts): Promise<number> {
|
|||||||
)
|
)
|
||||||
return 1
|
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
|
const total = env.meta?.total ?? rows.length
|
||||||
|
|
||||||
if (opts.format === "table") {
|
if (opts.format === "table") {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -112,6 +112,24 @@ describe("runSearch (mocked fetch)", () => {
|
|||||||
expect(requestedParams(mock).get("description_format")).toBe("markdown");
|
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 () => {
|
test("asks for the requested description format", async () => {
|
||||||
const mock = mockFetch(200, { data: [job()], meta: { total: 1 } });
|
const mock = mockFetch(200, { data: [job()], meta: { total: 1 } });
|
||||||
captureStdout();
|
captureStdout();
|
||||||
|
|||||||
@@ -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 |
|
| `url` | string | Full URL to job posting |
|
||||||
| `posted` | string | Publication date in ISO 8601 |
|
| `posted` | string | Publication date in ISO 8601 |
|
||||||
| `date` | string \| null | Publication date as `YYYY-MM-DD` (derived from `posted`), or `null` if absent |
|
| `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 `<title>` in a secondary request (pattern: `"{N} relevante job og karriereopslag"`). If the secondary request fails, `meta.total` is `null`.
|
> `meta.total` is fetched from the HTML page `<title>` in a secondary request (pattern: `"{N} relevante job og karriereopslag"`). If the secondary request fails, `meta.total` is `null`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createCLI } from "@bunli/core"
|
import { createCLI } from "@bunli/core"
|
||||||
|
import { writeError } from "./helpers.js"
|
||||||
import { search } from "./commands/search.js"
|
import { search } from "./commands/search.js"
|
||||||
import { detail } from "./commands/detail.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",
|
description: "CLI for Akademikernes Jobbank (jobbank.dk) — job search for highly educated candidates",
|
||||||
})
|
})
|
||||||
|
|
||||||
cli.command(search)
|
const commands = [search, detail]
|
||||||
cli.command(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()
|
await cli.run()
|
||||||
|
|||||||
@@ -135,7 +135,11 @@ export function parseRssDescription(desc: string): ParsedDescription {
|
|||||||
if (deadlineStr.toLowerCase() === "løbende" || deadlineStr.toLowerCase() === "lobende") {
|
if (deadlineStr.toLowerCase() === "løbende" || deadlineStr.toLowerCase() === "lobende") {
|
||||||
deadline = null
|
deadline = null
|
||||||
} else {
|
} 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
|
// Remove the deadline portion from rest
|
||||||
rest = rest.substring(0, deadlineMatch.index).trim()
|
rest = rest.substring(0, deadlineMatch.index).trim()
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -56,10 +56,17 @@ describe("parseRssDescription", () => {
|
|||||||
jobType: "Fuldtidsjob, Graduate/trainee",
|
jobType: "Fuldtidsjob, Graduate/trainee",
|
||||||
company: "Acme A/S",
|
company: "Acme A/S",
|
||||||
location: "København",
|
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", () => {
|
test("normalizes a rolling deadline to null", () => {
|
||||||
expect(
|
expect(
|
||||||
parseRssDescription("Deltidsjob hos Example ApS, Aarhus (Ansøgningsfrist: løbende)"),
|
parseRssDescription("Deltidsjob hos Example ApS, Aarhus (Ansøgningsfrist: løbende)"),
|
||||||
|
|||||||
@@ -33,6 +33,6 @@ describe("Jobbank search normalization", () => {
|
|||||||
expect(result.company).toBe("Acme A/S");
|
expect(result.company).toBe("Acme A/S");
|
||||||
expect(result.location).toBe("København");
|
expect(result.location).toBe("København");
|
||||||
expect(result.url).toBe("https://jobbank.dk/job/12345/acme/data-scientist");
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -129,13 +129,6 @@ bun run src/cli.ts search --text "sygeplejerske" --zip 8000 --limit 10
|
|||||||
{
|
{
|
||||||
"title": "IT-chef søges til RAH",
|
"title": "IT-chef søges til RAH",
|
||||||
"companyName": "Rah Service A/S",
|
"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",
|
"companyAddress": "Ndr Ringvej 4 6950 Ringkøbing",
|
||||||
"jobTypes": ["fuldtid"],
|
"jobTypes": ["fuldtid"],
|
||||||
"boostJob": true,
|
"boostJob": true,
|
||||||
@@ -143,12 +136,6 @@ bun run src/cli.ts search --text "sygeplejerske" --zip 8000 --limit 10
|
|||||||
"applicationDeadline": "10-04-2026",
|
"applicationDeadline": "10-04-2026",
|
||||||
"url": "https://jobdanmark.dk/job/it-chef-soeges-til-rah",
|
"url": "https://jobdanmark.dk/job/it-chef-soeges-til-rah",
|
||||||
"slug": "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",
|
"company": "Rah Service A/S",
|
||||||
"location": "Ringkøbing",
|
"location": "Ringkøbing",
|
||||||
"date": "2026-03-12",
|
"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).
|
> - `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/`).
|
> - `slug` is extracted from the relative `url` field (the path segment after `/job/`).
|
||||||
> - `applicationDeadline` can be `null`.
|
> - `applicationDeadline` can be `null`.
|
||||||
> - `companyLogo` can be `null`.
|
|
||||||
> - `publishedDate` format: `"DD-MM-YYYY"`.
|
> - `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.
|
> - 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
|
## URL construction
|
||||||
|
|
||||||
- Job detail pages: `https://jobdanmark.dk/job/{slug}`
|
- Job detail pages: `https://jobdanmark.dk/job/{slug}`
|
||||||
- Company logo images: `https://jobdanmark.dk{companyLogo.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)
|
||||||
- Cover images: `https://jobdanmark.dk{coverImage.url}` (prepend base URL to relative path)
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createCLI } from "@bunli/core"
|
import { createCLI } from "@bunli/core"
|
||||||
|
import { writeError } from "./helpers.js"
|
||||||
import { search } from "./commands/search.js"
|
import { search } from "./commands/search.js"
|
||||||
import { detail } from "./commands/detail.js"
|
import { detail } from "./commands/detail.js"
|
||||||
import { categories } from "./commands/categories.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",
|
description: "CLI for the Jobdanmark.dk public job search API",
|
||||||
})
|
})
|
||||||
|
|
||||||
cli.command(search)
|
const commands = [search, detail, categories, autocomplete, locations]
|
||||||
cli.command(detail)
|
for (const command of commands) {
|
||||||
cli.command(categories)
|
cli.command(command)
|
||||||
cli.command(autocomplete)
|
}
|
||||||
cli.command(locations)
|
|
||||||
|
// 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()
|
await cli.run()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineCommand, option } from "@bunli/core"
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { parse } from "node-html-parser"
|
import { parse } from "node-html-parser"
|
||||||
import { BASE_URL, writeError } from "../helpers.js"
|
import { BASE_URL, writeError } from "../helpers.js"
|
||||||
|
import { extractCity, toContractDate } from "./search.js"
|
||||||
|
|
||||||
interface JsonLdJobPosting {
|
interface JsonLdJobPosting {
|
||||||
"@context"?: string
|
"@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 {
|
function overviewValue(root: ReturnType<typeof parse>, label: string): string | null {
|
||||||
const normalizedLabel = label.toLowerCase()
|
const normalizedLabel = label.toLowerCase()
|
||||||
for (const item of root.querySelectorAll(".job-overview li")) {
|
for (const item of root.querySelectorAll(".job-overview li")) {
|
||||||
@@ -174,8 +190,8 @@ function fromRenderedHtml(root: ReturnType<typeof parse>, slug: string, url: str
|
|||||||
slug,
|
slug,
|
||||||
url,
|
url,
|
||||||
title,
|
title,
|
||||||
datePosted: overviewValue(root, "Udgivet") ?? "",
|
datePosted: normalizeOverviewDate(overviewValue(root, "Udgivet")) ?? "",
|
||||||
validThrough: overviewValue(root, "Ansøgningsfrist"),
|
validThrough: normalizeOverviewDate(overviewValue(root, "Ansøgningsfrist")),
|
||||||
employmentType: employmentType ? [employmentType] : [],
|
employmentType: employmentType ? [employmentType] : [],
|
||||||
hiringOrganization: {
|
hiringOrganization: {
|
||||||
name: companyName,
|
name: companyName,
|
||||||
@@ -183,7 +199,7 @@ function fromRenderedHtml(root: ReturnType<typeof parse>, slug: string, url: str
|
|||||||
},
|
},
|
||||||
jobLocation: {
|
jobLocation: {
|
||||||
streetAddress: workplace,
|
streetAddress: workplace,
|
||||||
addressLocality: null,
|
addressLocality: extractCity(workplace),
|
||||||
addressRegion: null,
|
addressRegion: null,
|
||||||
postalCode: null,
|
postalCode: null,
|
||||||
addressCountry: "DK",
|
addressCountry: "DK",
|
||||||
|
|||||||
@@ -34,11 +34,23 @@ interface ApiSearchResponse {
|
|||||||
totalPages: number
|
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})$/)
|
const match = value?.match(/^(\d{2})-(\d{2})-(\d{4})$/)
|
||||||
return match ? `${match[3]}-${match[2]}-${match[1]}` : (value ?? null)
|
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> {
|
export function normalizeItem(item: ApiSearchItem): Record<string, unknown> {
|
||||||
const relativeUrl = item.url
|
const relativeUrl = item.url
|
||||||
const fullUrl = relativeUrl.startsWith("http")
|
const fullUrl = relativeUrl.startsWith("http")
|
||||||
@@ -47,32 +59,14 @@ export function normalizeItem(item: ApiSearchItem): Record<string, unknown> {
|
|||||||
// Extract slug from url path: /job/<slug>
|
// Extract slug from url path: /job/<slug>
|
||||||
const slug = relativeUrl.replace(/^\/job\//, "")
|
const slug = relativeUrl.replace(/^\/job\//, "")
|
||||||
|
|
||||||
const companyLogo = item.companyLogo
|
// Presentation-only keys (coverImage, companyLogo, companyLogoSvgMarkup,
|
||||||
? {
|
// overlayColor, silhouetteLogo) are dropped: they were ~40% of a live
|
||||||
key: item.companyLogo.key,
|
// payload and the /scrape agent can never use an image or overlay colour.
|
||||||
url: item.companyLogo.url.startsWith("http")
|
// The #340 compatibility duplicates (companyName, publishedDate,
|
||||||
? item.companyLogo.url
|
// applicationDeadline) stay.
|
||||||
: `${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
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: item.title,
|
title: item.title,
|
||||||
companyName: item.companyName,
|
companyName: item.companyName,
|
||||||
companyLogo,
|
|
||||||
companyLogoSvgMarkup: item.companyLogoSvgMarkup ?? null,
|
|
||||||
overlayColor: item.overlayColor ?? null,
|
|
||||||
companyAddress: item.companyAddress,
|
companyAddress: item.companyAddress,
|
||||||
jobTypes: item.jobTypes,
|
jobTypes: item.jobTypes,
|
||||||
boostJob: item.boostJob,
|
boostJob: item.boostJob,
|
||||||
@@ -80,10 +74,8 @@ export function normalizeItem(item: ApiSearchItem): Record<string, unknown> {
|
|||||||
applicationDeadline: item.applicationDeadline ?? null,
|
applicationDeadline: item.applicationDeadline ?? null,
|
||||||
url: fullUrl,
|
url: fullUrl,
|
||||||
slug,
|
slug,
|
||||||
coverImage,
|
|
||||||
silhouetteLogo: item.silhouetteLogo,
|
|
||||||
company: item.companyName,
|
company: item.companyName,
|
||||||
location: item.companyAddress?.match(/\d{4}\s+(.+)$/)?.[1] ?? null,
|
location: extractCity(item.companyAddress),
|
||||||
date: toContractDate(item.publishedDate),
|
date: toContractDate(item.publishedDate),
|
||||||
deadline: toContractDate(item.applicationDeadline),
|
deadline: toContractDate(item.applicationDeadline),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -40,16 +40,31 @@ describe("parseJobPostingFromHtml", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(parsed.title).toBe("Journalistisk udvikler søges");
|
expect(parsed.title).toBe("Journalistisk udvikler søges");
|
||||||
expect(parsed.datePosted).toBe("03-07-2026");
|
// The fallback must emit the same shapes as the JSON-LD branch: contract
|
||||||
expect(parsed.validThrough).toBe("02-08-2026 23.59");
|
// 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.employmentType).toEqual(["Fuldtid"]);
|
||||||
expect(parsed.hiringOrganization.name).toBe("JFM");
|
expect(parsed.hiringOrganization.name).toBe("JFM");
|
||||||
expect(parsed.hiringOrganization.logo).toBe("https://jobdanmark.dk/media/jfm-logo.png?width=100");
|
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.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.description).toContain("identificere relevante datasæt");
|
||||||
expect(parsed.applyUrl).toBe("https://jfm.career.emply.com/da/apply/example");
|
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", () => {
|
test("does not reject titles containing '404' mid-phrase", () => {
|
||||||
const htmlWith404InTitle = HTML_WITHOUT_JSON_LD.replace(
|
const htmlWith404InTitle = HTML_WITHOUT_JSON_LD.replace(
|
||||||
"<title>Journalistisk udvikler søges | jobdanmark</title>",
|
"<title>Journalistisk udvikler søges | jobdanmark</title>",
|
||||||
|
|||||||
@@ -44,6 +44,33 @@ describe("Jobdanmark search normalization", () => {
|
|||||||
expect(result.company).toBe("Statens It");
|
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", () => {
|
test("survives a null companyAddress from the API", () => {
|
||||||
const result = normalizeItem({
|
const result = normalizeItem({
|
||||||
...item(),
|
...item(),
|
||||||
@@ -61,4 +88,18 @@ describe("Jobdanmark search normalization", () => {
|
|||||||
expect(result.publishedDate).toBe("27-07-2026");
|
expect(result.publishedDate).toBe("27-07-2026");
|
||||||
expect(result.applicationDeadline).toBe("17-08-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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -169,12 +169,29 @@ bun run src/cli.ts detail h1647303 --format plain
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Field notes:**
|
**Field notes:**
|
||||||
- `deadline` — application deadline date string; `null` if not listed.
|
|
||||||
- `employmentType` — e.g. `"Fastansættelse"`, `"Midlertidig ansættelse"`; `null` if not listed.
|
Jobindex serves detail pages in two shapes, and field availability differs:
|
||||||
- `hours` — e.g. `"Fuldtid"`, `"Deltid"`; `null` if not listed.
|
a **jobindex-native** page (recognisable by its `jd-*` facts blocks) carries
|
||||||
- `applyUrl` — the external application URL (resolved from the Jobindex redirect link `/c?t=...`); `null` if not available.
|
company, location, an ISO deadline, employment type and hours; an **external
|
||||||
- `description` — full plain-text job description (HTML stripped).
|
ATS passthrough** (the employer's hosted ad, e.g. hr-manager/Talentech, served
|
||||||
- All fields may be `null` if not present in the HTML.
|
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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createCLI } from "@bunli/core"
|
import { createCLI } from "@bunli/core"
|
||||||
|
import { writeError } from "./helpers.js"
|
||||||
import { search } from "./commands/search.js"
|
import { search } from "./commands/search.js"
|
||||||
import { detail } from "./commands/detail.js"
|
import { detail } from "./commands/detail.js"
|
||||||
|
|
||||||
@@ -8,7 +9,37 @@ const cli = await createCLI({
|
|||||||
description: "CLI for searching jobs on Jobindex.dk",
|
description: "CLI for searching jobs on Jobindex.dk",
|
||||||
})
|
})
|
||||||
|
|
||||||
cli.command(search)
|
const commands = [search, detail]
|
||||||
cli.command(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()
|
await cli.run()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineCommand, option } from "@bunli/core"
|
import { defineCommand, option } from "@bunli/core"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { htmlFetch, writeError, extractDivContent } from "../helpers.js"
|
import { htmlFetch, writeError } from "../helpers.js"
|
||||||
|
|
||||||
const BASE_URL = "https://www.jobindex.dk"
|
const BASE_URL = "https://www.jobindex.dk"
|
||||||
|
|
||||||
@@ -39,6 +39,13 @@ function decodeHtmlEntities(text: string): string {
|
|||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
.replace(/'/g, "'")
|
.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 (é).
|
// Numeric character references: decimal (é) and hexadecimal (é).
|
||||||
.replace(/&#(\d+);/g, (_, dec) => numericEntity(parseInt(dec, 10)))
|
.replace(/&#(\d+);/g, (_, dec) => numericEntity(parseInt(dec, 10)))
|
||||||
.replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex) => numericEntity(parseInt(hex, 16)))
|
.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 }
|
return { url, id: idOrUrl }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const DANISH_MONTHS: Record<string, string> = {
|
||||||
* Parse the detail HTML page using regex to avoid node-html-parser nesting bugs.
|
januar: "01", februar: "02", marts: "03", april: "04", maj: "05", juni: "06",
|
||||||
*/
|
juli: "07", august: "08", september: "09", oktober: "10", november: "11", december: "12",
|
||||||
function parseDetailPage(html: string, url: string, id: string): DetailResult {
|
}
|
||||||
// Title: extract from <h1> tag
|
|
||||||
const h1Match = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)
|
|
||||||
const title = h1Match ? decodeHtmlEntities(stripTags(h1Match[1])) : ""
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
||||||
|
`<meta[^>]+(?:property|name|itemprop)="${matcher}"[^>]+content="([^"]*)"|<meta[^>]+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 <p> 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]*?<p[^>]*>([\\s\\S]*?)</p>`, "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(/<head[\s\S]*?<\/head>/gi, "")
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||||
|
.replace(/<!--[\s\S]*?-->/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 `<title>`
|
||||||
|
* 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) {
|
if (!title) {
|
||||||
throw new Error("Failed to parse job listing HTML")
|
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 company: string | null = null
|
||||||
let companyUrl: string | null = null
|
if (isNative) {
|
||||||
|
const titleTag = html.match(/<title>([\s\S]*?)<\/title>/i)
|
||||||
const companySection = html.match(/class="jix-toolbar-top__company"[^>]*>([\s\S]*?)<\/div>/i)
|
const pageTitle = titleTag ? decodeHtmlEntities(titleTag[1]).replace(/\s+/g, " ").trim() : ""
|
||||||
if (companySection) {
|
if (pageTitle.endsWith(` - ${title}`)) {
|
||||||
const linkMatch = companySection[1].match(/<[Aa][^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/[Aa]>/i)
|
company = pageTitle.slice(0, -(title.length + 3)).trim() || null
|
||||||
if (linkMatch) {
|
|
||||||
company = decodeHtmlEntities(stripTags(linkMatch[2])) || null
|
|
||||||
companyUrl = linkMatch[1] || null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Location from jix_robotjob--area span
|
|
||||||
let location: string | null = null
|
let location: string | null = null
|
||||||
const locMatch = html.match(/<span[^>]+class="jix_robotjob--area"[^>]*>([\s\S]*?)<\/span>/i)
|
let deadline: string | null = null
|
||||||
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 employmentType: string | null = null
|
let employmentType: string | null = null
|
||||||
let hours: string | null = null
|
let hours: string | null = null
|
||||||
let deadline: string | null = null
|
let description: string | null = null
|
||||||
|
|
||||||
const jixInfoMatch = html.match(/class="jix-info"[^>]*>([\s\S]*?)<\/div>/i)
|
if (isNative) {
|
||||||
if (jixInfoMatch) {
|
location = jdBlockValue(html, "jd-location")
|
||||||
const jixInfoHtml = jixInfoMatch[1]
|
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
|
||||||
|
|
||||||
// Parse p elements with bold labels
|
// Deadline: label + a real date within range, scanned only over visible
|
||||||
const pMatches = [...jixInfoHtml.matchAll(/<p[^>]*><b>([^<]+)<\/b>\s*([\s\S]*?)<\/p>/gi)]
|
// markup - the label also appears inside a CSS comment on these pages,
|
||||||
for (const pm of pMatches) {
|
// which the previous parser captured verbatim as the deadline.
|
||||||
const label = pm[1].toLowerCase().trim()
|
const due = visibleHtml(html).match(
|
||||||
const value = stripTags(pm[2]).trim()
|
/(?: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
|
||||||
|
|
||||||
if (label.includes("ansættelsestype") || label.includes("employment type")) {
|
description = bodyText(html)
|
||||||
employmentType = decodeHtmlEntities(value) || null
|
if (!description || description.length < 100) {
|
||||||
} else if (label.includes("ugentlig arbejdstid") || label.includes("weekly working time") || label.includes("arbejdstid")) {
|
description = metaContent(html, "og:description") ?? metaContent(html, "description") ?? description
|
||||||
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 (!description) {
|
||||||
if (!employmentType) {
|
description = metaContent(html, "og:description")
|
||||||
const emtMatch = html.match(/<b>(?:Ansættelsestype|Employment\s*type):<\/b>\s*([^<\n]+)/i)
|
|
||||||
if (emtMatch) {
|
|
||||||
employmentType = decodeHtmlEntities(emtMatch[1].trim()) || null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hours) {
|
// Apply URL: jobindex's own /c?t= redirect when present.
|
||||||
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
|
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=[^"]+)"/)
|
const ctMatch = html.match(/href="(\/c\?t=[^"]+)"/)
|
||||||
if (ctMatch) {
|
if (ctMatch) {
|
||||||
applyUrl = `${BASE_URL}${decodeHtmlEntities(ctMatch[1])}`
|
applyUrl = `${BASE_URL}${decodeHtmlEntities(ctMatch[1])}`
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Description: job text section
|
const timeMatch = html.match(/<time[^>]+datetime="([^"]+)"/)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
// Extract ID from canonical URL, fall back to the provided ID
|
|
||||||
const canonicalId = extractIdFromUrl(canonicalUrl) || id
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: canonicalId,
|
id,
|
||||||
title,
|
title,
|
||||||
company: company || null,
|
company,
|
||||||
companyUrl: companyUrl || null,
|
companyUrl: null,
|
||||||
location: location || null,
|
location,
|
||||||
date: date || null,
|
date: timeMatch ? toIsoDate(timeMatch[1]) : null,
|
||||||
deadline: deadline || null,
|
deadline,
|
||||||
employmentType: employmentType || null,
|
employmentType,
|
||||||
hours: hours || null,
|
hours,
|
||||||
applyUrl: applyUrl || null,
|
applyUrl,
|
||||||
url: canonicalUrl,
|
url,
|
||||||
description: description || null,
|
description,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,13 +269,6 @@ export const detail = defineCommand({
|
|||||||
|
|
||||||
if (signal.aborted) return
|
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
|
let data: DetailResult
|
||||||
try {
|
try {
|
||||||
data = parseDetailPage(html, url, id)
|
data = parseDetailPage(html, url, id)
|
||||||
|
|||||||
@@ -160,7 +160,11 @@ export function parseSearchPage(html: string): SearchPageResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let deadline: string | null = null
|
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.apply_deadline === "string") deadline = r.apply_deadline.slice(0, 10)
|
||||||
else if (typeof r.lastdate === "string") deadline = r.lastdate
|
else if (typeof r.lastdate === "string") deadline = r.lastdate
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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</title>
|
||||||
|
<meta property="og:title" content="Udvikler til Camunda/AWS" />
|
||||||
|
<meta property="og:url" content="https://www.jobindex.dk/jobannonce/h1690934" />
|
||||||
|
<meta property="og:description" content="VELLIV" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="jd-appetizer">
|
||||||
|
<h1>Udvikler til Camunda/AWS</h1>
|
||||||
|
</div>
|
||||||
|
<div id="container" class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="twelve columns jd-details">
|
||||||
|
<div class="jd-description">
|
||||||
|
<p class="appetizer">En virksomhed med mere end 100 års historie, der samtidig er cloud-only, er ikke hverdagskost.</p>
|
||||||
|
<p>Hos Velliv får du mulighed for at arbejde med Camunda, AWS og automatisering af processer.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="four columns jd-facts">
|
||||||
|
<div class="jd-type">
|
||||||
|
<h3>Jobtype:</h3>
|
||||||
|
<p>Fast</p>
|
||||||
|
</div>
|
||||||
|
<div class="jd-workhours">
|
||||||
|
<h3>Arbejdstid:</h3>
|
||||||
|
<p>Fuldtid</p>
|
||||||
|
</div>
|
||||||
|
<div class="jd-worktime">
|
||||||
|
<h3>Arbejdsdage:</h3>
|
||||||
|
<p>Dag</p>
|
||||||
|
</div>
|
||||||
|
<div class="jd-deadline">
|
||||||
|
<h3>Ansøgningsfrist:</h3>
|
||||||
|
<p>13. september 2026</p>
|
||||||
|
</div>
|
||||||
|
<div class="jd-location">
|
||||||
|
<h3>Arbejdssted:</h3>
|
||||||
|
<p>Ballerup</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
// 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 = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>
|
||||||
|
Talentech - C#-udvikler til kritiske analyseløsninger i elnettet
|
||||||
|
</title>
|
||||||
|
<meta name="description" content="Vi søger en udvikler til elnettet" />
|
||||||
|
<meta itemprop="name" content="C#-udvikler til kritiske analyseløsninger i elnettet" />
|
||||||
|
<meta property="og:title" content="C#-udvikler til kritiske analyseløsninger i elnettet" />
|
||||||
|
<meta property="og:site_name" content="Talentech" />
|
||||||
|
<meta property="og:url" content="https://candidate.hr-manager.net/ApplicationInit.aspx?cid=316&ProjectId=188792" />
|
||||||
|
<style>
|
||||||
|
/* Defines the style of the Application Due text */
|
||||||
|
/* DK: Ansøgningsfrist */
|
||||||
|
/* BOKSTAV: K */
|
||||||
|
.frist { padding-bottom: 15px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>C#-udvikler til kritiske analyseløsninger i elnettet</h1>
|
||||||
|
<p>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.</p>
|
||||||
|
<div class="workplacelist emptyparent"><div id="workplacelist_lang" class="rowheader">Workplace</div><div class="widget-line"></div><span class="empty">Fredericia</span><br></div>
|
||||||
|
<div class="frist emptyparent"><div id="frist_lang" class="rowheader">Application due</div><div class="widget-line"></div><span class="empty">21-09-2026</span><br></div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
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(
|
||||||
|
/<div class="frist emptyparent">[\s\S]*?<br><\/div>/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const parsed = parseDetailPage(noDueWidget, `${JOBANNONCE_URL}h9`, "h9");
|
||||||
|
expect(parsed.deadline).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 `<html><head><script>var Stash = ${JSON.stringify(stash)};</script></head></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createCLI } from "@bunli/core"
|
import { createCLI } from "@bunli/core"
|
||||||
|
import { writeError } from "./helpers.js"
|
||||||
import { search } from "./commands/search.js"
|
import { search } from "./commands/search.js"
|
||||||
import { detail } from "./commands/detail.js"
|
import { detail } from "./commands/detail.js"
|
||||||
import { occupations } from "./commands/occupations.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",
|
description: "CLI for the Jobnet.dk Danish government job portal API",
|
||||||
})
|
})
|
||||||
|
|
||||||
cli.command(search)
|
const commands = [search, detail, occupations, suggestions]
|
||||||
cli.command(detail)
|
for (const command of commands) {
|
||||||
cli.command(occupations)
|
cli.command(command)
|
||||||
cli.command(suggestions)
|
}
|
||||||
|
|
||||||
|
// 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()
|
await cli.run()
|
||||||
|
|||||||
@@ -59,6 +59,23 @@ export interface DetailApiResponse {
|
|||||||
user: string | null
|
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({
|
export const detail = defineCommand({
|
||||||
name: "detail",
|
name: "detail",
|
||||||
description: "Full detail for a single job ad",
|
description: "Full detail for a single job ad",
|
||||||
@@ -77,9 +94,10 @@ export const detail = defineCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch<DetailApiResponse>(
|
const data = prepareDetail(
|
||||||
`/FindJob/JobAdDetails/${id}`,
|
await apiFetch<DetailApiResponse>(`/FindJob/JobAdDetails/${id}`, {
|
||||||
{ incrementViews: "false" }
|
incrementViews: "false",
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (signal.aborted) return
|
if (signal.aborted) return
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
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> = {}): DetailApiResponse {
|
function detail(overrides: Partial<DetailApiResponse> = {}): DetailApiResponse {
|
||||||
return {
|
return {
|
||||||
@@ -93,3 +93,29 @@ describe("formatDetailPlain", () => {
|
|||||||
expect(formatted).not.toContain("Apply:");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ bun run .agents/skills/linkedin-search/cli/src/cli.ts detail <id|url> [--format
|
|||||||
|
|
||||||
`id` is the job ID from `search` results (e.g. `4426311357`). You may also pass a full
|
`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,
|
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
|
## Usage examples
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,16 @@ EXAMPLES
|
|||||||
Personal use only — uses LinkedIn's public pages; keep volume low (LinkedIn ToS).
|
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<string, Set<string>> = {
|
||||||
|
search: new Set([
|
||||||
|
"location", "query", "jobage", "jobage-minutes", "remote", "page", "limit", "format", "help", "h",
|
||||||
|
]),
|
||||||
|
detail: new Set(["format", "help", "h"]),
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<number> {
|
async function main(): Promise<number> {
|
||||||
const argv = process.argv.slice(2)
|
const argv = process.argv.slice(2)
|
||||||
const flags = parseFlags(argv)
|
const flags = parseFlags(argv)
|
||||||
@@ -73,6 +83,25 @@ async function main(): Promise<number> {
|
|||||||
return cmd ? 0 : 1
|
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") {
|
if (cmd === "search") {
|
||||||
const location = typeof flags.location === "string" ? flags.location : undefined
|
const location = typeof flags.location === "string" ? flags.location : undefined
|
||||||
if (!location) {
|
if (!location) {
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export async function runDetail(opts: DetailOpts): Promise<number> {
|
|||||||
job.description || "(no description)",
|
job.description || "(no description)",
|
||||||
"",
|
"",
|
||||||
`URL: ${job.url}`,
|
`URL: ${job.url}`,
|
||||||
job.applyUrl ? `Apply: ${job.applyUrl}` : "",
|
|
||||||
].filter((l) => l !== "")
|
].filter((l) => l !== "")
|
||||||
process.stdout.write(lines.join("\n") + "\n")
|
process.stdout.write(lines.join("\n") + "\n")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ export interface JobDetail extends JobCard {
|
|||||||
employmentType: string | null
|
employmentType: string | null
|
||||||
jobFunction: string | null
|
jobFunction: string | null
|
||||||
industries: 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])
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
title: title ? clean(title) : "(untitled)",
|
title: title ? clean(title) : "(untitled)",
|
||||||
@@ -244,7 +240,6 @@ export function parseJobDetail(html: string, id: string): JobDetail {
|
|||||||
employmentType: criteria["employment type"] ?? null,
|
employmentType: criteria["employment type"] ?? null,
|
||||||
jobFunction: criteria["job function"] ?? null,
|
jobFunction: criteria["job function"] ?? null,
|
||||||
industries: criteria["industries"] ?? null,
|
industries: criteria["industries"] ?? null,
|
||||||
applyUrl,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,13 +68,14 @@ describe("LinkedIn CLI flag validation", () => {
|
|||||||
// parseFlags in cli.ts treats a next-token starting with "-" as absent
|
// parseFlags in cli.ts treats a next-token starting with "-" as absent
|
||||||
// (`next.startsWith("-")` → flag becomes boolean `true`), and there is no
|
// (`next.startsWith("-")` → flag becomes boolean `true`), and there is no
|
||||||
// `--flag=value` syntax. So "-5" never reaches --jobage-minutes as a value;
|
// `--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
|
// it parses as a stray flag named "5", which the unknown-flag guard now
|
||||||
// `v <= 0` guard. Negatives are unreachable through the CLI as currently parsed.
|
// 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"]);
|
const result = await runCLI(["search", "-l", LOCATION, "--jobage-minutes", "-5"]);
|
||||||
expect(result.exitCode).not.toBe(0);
|
expect(result.exitCode).not.toBe(0);
|
||||||
const err = parsedStderr(result.stderr);
|
const err = parsedStderr(result.stderr);
|
||||||
expect(err.code).toBe("BAD_ARG");
|
expect(err.code).toBe("UNKNOWN_FLAG");
|
||||||
expect(err.error).toMatch(/jobage-minutes/);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,6 +14,46 @@ function searchCard(id: string, title: string, company = "Acme"): string {
|
|||||||
</li>`;
|
</li>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The /scrape contract fields beyond title/company. The original fixture had
|
||||||
|
// no <time> or location element at all, so deleting the date extraction from
|
||||||
|
// parseJobCards left every test green (review finding F35, 2026-08-19).
|
||||||
|
function searchCardWithMeta(id: string, datetimeAttr: string, listdateClass = "job-search-card__listdate"): string {
|
||||||
|
return `<li>
|
||||||
|
<div data-entity-urn="urn:li:jobPosting:${id}">
|
||||||
|
<a class="base-card__full-link" href="https://www.linkedin.com/jobs/view/${id}"></a>
|
||||||
|
<h3 class="base-search-card__title">Data Engineer</h3>
|
||||||
|
<h4 class="base-search-card__subtitle"><a href="https://www.linkedin.com/company/acme">Acme</a></h4>
|
||||||
|
<span class="job-search-card__location">Copenhagen, Denmark</span>
|
||||||
|
<time class="${listdateClass}" datetime="${datetimeAttr}">3 days ago</time>
|
||||||
|
</div>
|
||||||
|
</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("parseJobCards contract fields", () => {
|
||||||
|
test("extracts date from the listdate <time> element", () => {
|
||||||
|
const [card] = parseJobCards(searchCardWithMeta("200", "2026-08-10"));
|
||||||
|
expect(card.date).toBe("2026-08-10");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts date from the listdate--new variant class", () => {
|
||||||
|
const [card] = parseJobCards(
|
||||||
|
searchCardWithMeta("201", "2026-08-15", "job-search-card__listdate--new"),
|
||||||
|
);
|
||||||
|
expect(card.date).toBe("2026-08-15");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts location from the location span", () => {
|
||||||
|
const [card] = parseJobCards(searchCardWithMeta("202", "2026-08-10"));
|
||||||
|
expect(card.location).toBe("Copenhagen, Denmark");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("date and location are null when the elements are absent", () => {
|
||||||
|
const [card] = parseJobCards(searchCard("203", "Bare Card"));
|
||||||
|
expect(card.date).toBeNull();
|
||||||
|
expect(card.location).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("decodeHtmlEntities (via parseJobCards)", () => {
|
describe("decodeHtmlEntities (via parseJobCards)", () => {
|
||||||
test("decodes hexadecimal numeric entities (é)", () => {
|
test("decodes hexadecimal numeric entities (é)", () => {
|
||||||
const [card] = parseJobCards(searchCard("123", "Café Manager"));
|
const [card] = parseJobCards(searchCard("123", "Café Manager"));
|
||||||
@@ -46,6 +86,19 @@ describe("decodeHtmlEntities (via parseJobCards)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseJobDetail dropped fields", () => {
|
||||||
|
test("emits no applyUrl field", () => {
|
||||||
|
// The extraction regex assumed class-before-href and never matched
|
||||||
|
// LinkedIn's real markup (null on every live posting), and a fixed
|
||||||
|
// version would only capture the job-view URL - a duplicate of `url`.
|
||||||
|
// The field is dropped rather than fixed (review finding F19,
|
||||||
|
// 2026-08-19). This test pins the removal so it does not quietly
|
||||||
|
// return as a broken or redundant field.
|
||||||
|
const job = parseJobDetail("<html></html>", "1");
|
||||||
|
expect("applyUrl" in job).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("decodeHtmlEntities (via parseJobDetail)", () => {
|
describe("decodeHtmlEntities (via parseJobDetail)", () => {
|
||||||
test("decodes hex entities inside the job title", () => {
|
test("decodes hex entities inside the job title", () => {
|
||||||
const html = `<h1 class="topcard__title">Señor Engineer</h1>`;
|
const html = `<h1 class="topcard__title">Señor Engineer</h1>`;
|
||||||
|
|||||||
@@ -254,12 +254,12 @@ Do not proceed to Step 6 until both PDFs pass inspection.
|
|||||||
|
|
||||||
An ATS parser reads the PDF's embedded **text layer**, not the rendered page — a CV that passed visual inspection can still extract as garbage (icon glyphs where the contact details should be, scrambled reading order in multi-column layouts). This step verifies what a parser actually sees. It applies to the **CV only**; cover letters rarely go through keyword screening.
|
An ATS parser reads the PDF's embedded **text layer**, not the rendered page — a CV that passed visual inspection can still extract as garbage (icon glyphs where the contact details should be, scrambled reading order in multi-column layouts). This step verifies what a parser actually sees. It applies to the **CV only**; cover letters rarely go through keyword screening.
|
||||||
|
|
||||||
**Availability check:** run `pdftotext -v`. `pdftotext` (poppler) is an optional dependency, not part of TeX distributions. If it is missing, print a one-line warning that the mechanical parse check is skipped, do the keyword-coverage check (item 3 below) against your visual Read of the PDF instead, and note the degraded mode in the Step 6 report. Same graceful-skip pattern as the salary lookup.
|
**Availability check:** run `pdftotext -v`. `pdftotext` (poppler) is an optional dependency, not part of TeX distributions. If it is missing, print a one-line warning that the mechanical parse check is skipped, do the keyword-coverage check (item 3 below) against your visual Read of the PDF instead, and note the degraded mode in the Step 6 report. Same graceful-skip pattern as the salary lookup. Keep the `-enc UTF-8` flag: Xpdf-based builds default to Latin-1 output, and without it a correct non-ASCII CV fails the replacement-character check below.
|
||||||
|
|
||||||
**1. Extract the text layer:**
|
**1. Extract the text layer:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd cv && pdftotext -layout main_<company>_<role>.pdf main_<company>_<role>.txt
|
cd cv && pdftotext -layout -enc UTF-8 main_<company>_<role>.pdf main_<company>_<role>.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Read the `.txt` file.
|
Read the `.txt` file.
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ Lookback window: `since <date>` argument if given, else `state.last_sync` if set
|
|||||||
- A quoted-name OR-group of the open applications' company names, e.g. `{"Acme Corp" "BigCo"}`
|
- A quoted-name OR-group of the open applications' company names, e.g. `{"Acme Corp" "BigCo"}`
|
||||||
- A sender-domain OR-group of common ATS platforms: `{from:greenhouse.io from:lever.co from:myworkday.com from:ashbyhq.com from:smartrecruiters.com from:icims.com from:bamboohr.com}`
|
- A sender-domain OR-group of common ATS platforms: `{from:greenhouse.io from:lever.co from:myworkday.com from:ashbyhq.com from:smartrecruiters.com from:icims.com from:bamboohr.com}`
|
||||||
- The lookback bound, e.g. `newer_than:30d` or `after:2026/06/15`
|
- The lookback bound, e.g. `newer_than:30d` or `after:2026/06/15`
|
||||||
- `in:inbox` (skip sent/drafts - status signals come from what employers send you, not what you sent them)
|
- `-in:sent -in:drafts` (status signals come from what employers send you, not what you sent them; the negative operators keep **archived** mail and label-filtered mail in scope - restricting to the Inbox instead would silently drop both, including exactly the mail matched by the job-search label from step 1, since the standard filter that applies such a label also archives it)
|
||||||
|
|
||||||
Example: `newer_than:30d in:inbox ({"Acme Corp" "BigCo"} OR {from:greenhouse.io from:lever.co from:myworkday.com from:ashbyhq.com})`
|
Example: `newer_than:30d -in:sent -in:drafts ({"Acme Corp" "BigCo"} OR {from:greenhouse.io from:lever.co from:myworkday.com from:ashbyhq.com})`
|
||||||
|
|
||||||
4. Call `search_threads` with `view: THREAD_VIEW_MINIMAL`, `pageSize: 50`, paginating via `pageToken` until exhausted or results are clearly outside the relevant window.
|
4. Call `search_threads` with `view: THREAD_VIEW_MINIMAL`, `pageSize: 50`, paginating via `pageToken` until exhausted or results are clearly outside the relevant window.
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ From the normalised data compute:
|
|||||||
- **By sector:** count per unique sector value
|
- **By sector:** count per unique sector value
|
||||||
- **By channel:** portal vs online vs referral vs other
|
- **By channel:** portal vs online vs referral vs other
|
||||||
- **By year/season:** group by the `date` field (which may be a year like `2025` or a full date)
|
- **By year/season:** group by the `date` field (which may be a year like `2025` or a full date)
|
||||||
- **Funnel rates:** what % progressed past resume screen (reached Interview or beyond)
|
- **Funnel rates:** what % progressed past resume screen (reached Interview or beyond). Compute stage-reached from history, not current status: an application counts as having reached a stage when its current status implies it **or** its merged `outcome.md` stage checkboxes (Step 1.2) show the stage was reached - a `rejected` row whose outcome file ticks an interview stage reached Interview, and a `hired` row reached every stage before Hired. Current status alone structurally undercounts every earlier stage: a finished search would read as though nobody ever interviewed.
|
||||||
- **Rejection rate:** Rejected/Closed ÷ Total with a resolved status (exclude Active)
|
- **Rejection rate:** true rejections (`rejected`, `no_response`) ÷ applications with a final outcome. `offer_declined` (the candidate turned the offer down - a success) and `withdrawn` (candidate-initiated) are not rejections and stay out of the numerator; Interview and Offer rows are still unresolved, so they stay out of the denominator along with Active. The Rejected/Closed status *bucket* still groups all closed rows for the doughnut - the rate just must not reuse the bucket blindly.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ Write a single self-contained HTML file. All CSS is inline in a `<style>` block.
|
|||||||
1. **Status doughnut** — slices for each status bucket, colours from the palette above
|
1. **Status doughnut** — slices for each status bucket, colours from the palette above
|
||||||
2. **By sector bar** (horizontal) — company count per sector, sorted descending
|
2. **By sector bar** (horizontal) — company count per sector, sorted descending
|
||||||
3. **By channel bar** — online / referral / other
|
3. **By channel bar** — online / referral / other
|
||||||
4. **Application funnel** (horizontal bar) — Applied → Interview → Offer → Hired, each bar = count reaching that stage
|
4. **Application funnel** (horizontal bar) — Applied → Interview → Offer → Hired, each bar = count reaching that stage, derived per Step 2's funnel rule (current status **plus** the merged `outcome.md` stage checkboxes), so a candidate who interviewed and was later rejected still counts in the Interview bar
|
||||||
|
|
||||||
Build each chart as a hand-written `<svg>` element: compute bar lengths/doughnut arc angles from the stats in Step 2 and emit the `<rect>`/`<path>`/`<circle>` and `<text>` elements directly — no charting library, no `<canvas>`. Each `<svg>` has `role="img"` and an `aria-label` summarizing the chart (e.g. "Status breakdown: 3 Active, 2 Interview, 1 Offer"). Wrap each in a `<div class="chart-card">` with an `<h3>` title above. Remember to escape any label/value text drawn into `<text>` nodes per the escaping rule above.
|
Build each chart as a hand-written `<svg>` element: compute bar lengths/doughnut arc angles from the stats in Step 2 and emit the `<rect>`/`<path>`/`<circle>` and `<text>` elements directly — no charting library, no `<canvas>`. Each `<svg>` has `role="img"` and an `aria-label` summarizing the chart (e.g. "Status breakdown: 3 Active, 2 Interview, 1 Offer"). Wrap each in a `<div class="chart-card">` with an `<h3>` title above. Remember to escape any label/value text drawn into `<text>` nodes per the escaping rule above.
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ Each agent returns a JSON array, one object per job:
|
|||||||
"key": "<the job's key in seen_jobs.json>",
|
"key": "<the job's key in seen_jobs.json>",
|
||||||
"status": "scored" | "expired",
|
"status": "scored" | "expired",
|
||||||
"scores": { "technical": 0-100, "experience": 0-100, "behavioral": 0-100, "career": 0-100 },
|
"scores": { "technical": 0-100, "experience": 0-100, "behavioral": 0-100, "career": 0-100 },
|
||||||
"location": "PASS" | "FAIL" | "FLAG",
|
"location_verdict": "PASS" | "FAIL" | "FLAG",
|
||||||
"language_gate": "PASS" | "FAIL" | "FLAG",
|
"language_gate": "PASS" | "FAIL" | "FLAG",
|
||||||
"language_note": "<posting requirement + declared level, only when FLAG or FAIL>",
|
"language_note": "<posting requirement + declared level, only when FLAG or FAIL>",
|
||||||
"deadline": "YYYY-MM-DD" | null,
|
"deadline": "YYYY-MM-DD" | null,
|
||||||
@@ -73,8 +73,8 @@ Back in the main context, for each scored job:
|
|||||||
2. Map to the framework's verdict bands (Strong Fit 75+, Good Fit 60-74, Moderate Fit 45-59, Weak Fit 30-44, Poor Fit <30).
|
2. Map to the framework's verdict bands (Strong Fit 75+, Good Fit 60-74, Moderate Fit 45-59, Weak Fit 30-44, Poor Fit <30).
|
||||||
3. **Location veto:** `FAIL` (e.g. requires relocation) excludes the job from the shortlist no matter the score - list it separately with the reason. `FLAG` (e.g. heavy travel) stays in the ranking but carries a visible ⚠ marker for the user to judge.
|
3. **Location veto:** `FAIL` (e.g. requires relocation) excludes the job from the shortlist no matter the score - list it separately with the reason. `FLAG` (e.g. heavy travel) stays in the ranking but carries a visible ⚠ marker for the user to judge.
|
||||||
4. **Language veto:** `language_gate: FAIL` (posting requires a language the candidate hasn't declared at all) excludes the job from the shortlist, same as a location FAIL - list it under "Excluded" with the quoted requirement from `language_note`. `language_gate: FLAG` (declared language, requirement reads above the declared level) stays in the ranking with a visible ⚠ marker and `language_note` shown alongside the score, same treatment as a location FLAG.
|
4. **Language veto:** `language_gate: FAIL` (posting requires a language the candidate hasn't declared at all) excludes the job from the shortlist, same as a location FAIL - list it under "Excluded" with the quoted requirement from `language_note`. `language_gate: FLAG` (declared language, requirement reads above the declared level) stays in the ranking with a visible ⚠ marker and `language_note` shown alongside the score, same treatment as a location FLAG.
|
||||||
5. **Deadline urgency:** a deadline within 7 days gets a 🔥 marker and wins ties. A deadline that has already passed moves the job to `expired`. Take the deadline from the scoring agent's Step 2 JSON for a job scored in this run, and from the stored `deadline` in `seen_jobs.json` for one that already carries it - a stored value costs no fetch, so urgency is re-derived on every run without re-reading the posting. When both exist and disagree, the freshly scored value wins and replaces the stored one.
|
5. **Deadline urgency:** a deadline within 7 days gets a 🔥 marker and wins ties. A deadline that has already passed moves the job to `expired`. Take the deadline from the scoring agent's Step 2 JSON for a job scored in this run, and from the stored `deadline` in `seen_jobs.json` for one that already carries it - a stored value costs no fetch, so urgency is re-derived on every run without re-reading the posting. When both exist and disagree, the freshly scored value wins and replaces the stored one. A stored value that does not parse as `YYYY-MM-DD` is skipped for urgency as well - rule 6's defensive-parse rule applies wherever a stored deadline is compared.
|
||||||
6. **Expiry sweep over already-ranked entries.** Before presenting, check the stored `deadline` of every `ranked` entry this run did not re-score. Any whose deadline has passed becomes `expired`; any within 7 days is listed under a short **Closing soon** heading in Step 5 with its 🔥 marker. This needs no fetch and no agent - it is a date comparison against values already on disk, and it is what finally enforces `/scrape`'s "only open positions" rule beyond the moment of fetching. **An entry with no stored `deadline` is left alone, never guessed at** - most entries predate the column, and inferring a deadline from `first_seen` would retire jobs on a date nobody set. `--all` re-scores entries of any status including `expired`, so a job the sweep retired can still be revived by a later `--all` that re-fetches it and finds the posting live: the sweep is reversible, which is what makes an automated status change acceptable here at all.
|
6. **Expiry sweep over already-ranked entries.** Before presenting, check the stored `deadline` of every `ranked` entry this run did not re-score. Any whose deadline has passed becomes `expired`; any within 7 days is listed under a short **Closing soon** heading in Step 5 with its 🔥 marker. This needs no fetch and no agent - it is a date comparison against values already on disk, and it is what finally enforces `/scrape`'s "only open positions" rule beyond the moment of fetching. **An entry with no stored `deadline` is left alone, never guessed at** - most entries predate the column, and inferring a deadline from `first_seen` would retire jobs on a date nobody set. **Parse stored deadlines defensively:** a stored value that is not a `YYYY-MM-DD` date is treated exactly like an absent one - left alone, never compared, never guessed at - and reported once in the Step 5 summary with its portal, so the bad value gets traced to its source instead of silently steering the sweep (portals have shipped `"ASAP"`, `DD.MM.YYYY`, and free-text deadline shapes into stored data). `--all` re-scores entries of any status including `expired`, so a job the sweep retired can still be revived by a later `--all` that re-fetches it and finds the posting live: the sweep is reversible, which is what makes an automated status change acceptable here at all.
|
||||||
|
|
||||||
Sort by overall score (descending), urgency as tiebreaker.
|
Sort by overall score (descending), urgency as tiebreaker.
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ Sort by overall score (descending), urgency as tiebreaker.
|
|||||||
|
|
||||||
Update `job_scraper/seen_jobs.json` in place - these fields are additive to the scraper's schema:
|
Update `job_scraper/seen_jobs.json` in place - these fields are additive to the scraper's schema:
|
||||||
|
|
||||||
- Ranked jobs: set `"status": "ranked"` and add `"rank_score": <overall>`, `"rank_verdict": "<band>"`, `"rank_date": "YYYY-MM-DD"`, `"location": "PASS"/"FAIL"/"FLAG"`, `"language_gate": "PASS"/"FAIL"/"FLAG"`, `"language_note"` (omit or `null` when `language_gate` is `PASS`), `"deadline": "YYYY-MM-DD" | null` from the same Step 2 JSON (replace the stored value when the agent returned a different one - a fresh fetch is the freshest source; leave it alone when the agent returned `null`, absence is not a correction - a fetch that degraded to a listing page returns no deadline, and taking that as "the posting dropped its deadline" would erase a real date and, because rule 6 leaves an entry with no stored `deadline` alone, quietly make that job immortal to the sweep), plus `"strengths": [...]` and `"gaps": [...]` copied from the scoring agent's Step 2 JSON for that job. These veto fields are as important to persist as the score itself - without them, nothing later (a re-read of `seen_jobs.json`, a debugging session, the user asking "why was this excluded") can recover why a job did or didn't make the shortlist.
|
- Ranked jobs: set `"status": "ranked"` and add `"rank_score": <overall>`, `"rank_verdict": "<band>"`, `"rank_date": "YYYY-MM-DD"`, `"location_verdict": "PASS"/"FAIL"/"FLAG"` (never the bare `location` key - that is the scraper's place field, e.g. "Aarhus, Denmark", and overwriting it with a verdict destroys the commute-filter data; an entry ranked before this rename may carry a legacy PASS/FAIL/FLAG string in `location` - read that as the verdict when `location_verdict` is absent, and move it to `location_verdict` when re-writing the entry), `"language_gate": "PASS"/"FAIL"/"FLAG"`, `"language_note"` (omit or `null` when `language_gate` is `PASS`), `"deadline": "YYYY-MM-DD" | null` from the same Step 2 JSON (replace the stored value when the agent returned a different one - a fresh fetch is the freshest source; leave it alone when the agent returned `null`, absence is not a correction - a fetch that degraded to a listing page returns no deadline, and taking that as "the posting dropped its deadline" would erase a real date and, because rule 6 leaves an entry with no stored `deadline` alone, quietly make that job immortal to the sweep), plus `"strengths": [...]` and `"gaps": [...]` copied from the scoring agent's Step 2 JSON for that job. These veto fields are as important to persist as the score itself - without them, nothing later (a re-read of `seen_jobs.json`, a debugging session, the user asking "why was this excluded") can recover why a job did or didn't make the shortlist.
|
||||||
- Dead or past-deadline jobs: set `"status": "expired"`
|
- Dead or past-deadline jobs: set `"status": "expired"`
|
||||||
- Entries retired by Step 3's rule 6 sweep: set `"status": "expired"` for those too, and leave every other field on them untouched. The sweep reasons over entries this run never scored, so without this line its conclusion would live only in the report and the same expiry would be re-derived from the same stored date on every future run.
|
- Entries retired by Step 3's rule 6 sweep: set `"status": "expired"` for those too, and leave every other field on them untouched. The sweep reasons over entries this run never scored, so without this line its conclusion would live only in the report and the same expiry would be re-derived from the same stored date on every future run.
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ If `$ARGUMENTS` is empty or does not contain a recognized scope keyword, ask:
|
|||||||
>
|
>
|
||||||
> - **`profile`** — Clears candidate data from the skill files (profile, behavioral, STAR examples, profile statements). The framework structure and writing rules are preserved. Use this to re-run `/setup` from scratch.
|
> - **`profile`** — Clears candidate data from the skill files (profile, behavioral, STAR examples, profile statements). The framework structure and writing rules are preserved. Use this to re-run `/setup` from scratch.
|
||||||
>
|
>
|
||||||
> - **`documents`** — Deletes all files you've placed in the `documents/` folder (CV PDFs, LinkedIn export, diplomas, references, past applications). The folder structure and `README.md` are preserved.
|
> - **`documents`** — Deletes all files you've placed in the `documents/` folder (CV PDFs, LinkedIn export, diplomas, references, pasted job postings, past applications). The folder structure and `README.md` are preserved.
|
||||||
>
|
>
|
||||||
> - **`all`** — Both of the above.
|
> - **`all`** — Both of the above.
|
||||||
>
|
>
|
||||||
@@ -68,7 +68,7 @@ The following files are NOT touched (they contain framework rules, not candidate
|
|||||||
|
|
||||||
### If scope includes `documents`:
|
### If scope includes `documents`:
|
||||||
|
|
||||||
Use Glob to list all files present in `documents/cv/`, `documents/linkedin/`, `documents/diplomas/`, `documents/references/`, and `documents/applications/`. Present as:
|
Use Glob to list all files present in `documents/cv/`, `documents/linkedin/`, `documents/diplomas/`, `documents/references/`, `documents/postings/`, and `documents/applications/`. Present as:
|
||||||
|
|
||||||
```
|
```
|
||||||
## Documents reset will delete:
|
## Documents reset will delete:
|
||||||
@@ -85,6 +85,9 @@ documents/diplomas/
|
|||||||
documents/references/
|
documents/references/
|
||||||
- [filename] or "(empty)"
|
- [filename] or "(empty)"
|
||||||
|
|
||||||
|
documents/postings/
|
||||||
|
- [filename] or "(empty)"
|
||||||
|
|
||||||
documents/applications/
|
documents/applications/
|
||||||
- [subfolder/filename] or "(empty)"
|
- [subfolder/filename] or "(empty)"
|
||||||
|
|
||||||
@@ -193,6 +196,7 @@ rm -f documents/cv/*
|
|||||||
rm -f documents/linkedin/*
|
rm -f documents/linkedin/*
|
||||||
rm -f documents/diplomas/*
|
rm -f documents/diplomas/*
|
||||||
rm -f documents/references/*
|
rm -f documents/references/*
|
||||||
|
rm -f documents/postings/*
|
||||||
rm -rf documents/applications/*/
|
rm -rf documents/applications/*/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
framework_version: 1.2.3
|
framework_version: 1.2.4
|
||||||
---
|
---
|
||||||
|
|
||||||
# Job Evaluation Framework
|
# Job Evaluation Framework
|
||||||
@@ -32,7 +32,7 @@ A role that fails this gate is not scored and not drafted. Everything below appl
|
|||||||
|
|
||||||
## Language Gate — run before scoring
|
## Language Gate — run before scoring
|
||||||
|
|
||||||
No dimension or gate anywhere in this framework currently checks a posting's language requirements against what the candidate actually speaks - it is not one of the five Scoring Dimensions below, not a field `/scrape` or `/rank` track, and not something `/apply`'s language detection (Step 1, which already extracts a posting's required language generically) has anywhere to report to. This gate adds that check, structured the same way as the Eligibility Gate above: read the posting, classify against profile data, and treat a hard mismatch as FAIL before scoring.
|
This gate checks a posting's language requirements against what the candidate actually speaks. It is not one of the five Scoring Dimensions below - it runs before them, structured the same way as the Eligibility Gate above: read the posting, classify against profile data, and treat a hard mismatch as FAIL before scoring. Its verdict is tracked downstream: `/rank` records the result as `language_gate` (PASS/FAIL/FLAG) with a supporting `language_note`, persists both into `seen_jobs.json`, and treats a FAIL as a shortlist veto; `/scrape` surfaces the flag in its results table and carries a language-override rule for postings whose ad language differs from the role's working language. `/apply`'s language detection (Step 1, which extracts a posting's required language generically) feeds this same check.
|
||||||
|
|
||||||
Read the posting's language requirements as stated for **the role itself** — not the language the ad happens to be written in. A posting written in a language you don't work in, for a role that only needs languages you do work in on the job, passes fine; only an explicit job-condition requirement ("fluent X required," "must communicate with the Y team in Z") triggers this check. For each language the posting requires as a job condition, compare it against your Languages table in CLAUDE.md / `01-candidate-profile.md`:
|
Read the posting's language requirements as stated for **the role itself** — not the language the ad happens to be written in. A posting written in a language you don't work in, for a role that only needs languages you do work in on the job, passes fine; only an explicit job-condition requirement ("fluent X required," "must communicate with the Y team in Z") triggers this check. For each language the posting requires as a job condition, compare it against your Languages table in CLAUDE.md / `01-candidate-profile.md`:
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
framework_version: 1.4.1
|
framework_version: 1.4.2
|
||||||
---
|
---
|
||||||
|
|
||||||
# CV Templates and Tailoring Guide
|
# CV Templates and Tailoring Guide
|
||||||
@@ -211,6 +211,27 @@ Wherever the CV names a verifiable artifact - a public project, a hackathon entr
|
|||||||
- End with: "More references are available upon request."
|
- End with: "More references are available upon request."
|
||||||
- **Do not attach reference letters** - employers typically contact references directly
|
- **Do not attach reference letters** - employers typically contact references directly
|
||||||
|
|
||||||
|
### LaTeX Special Characters (important)
|
||||||
|
|
||||||
|
Postings and profile data arrive as plain text; the CV is LaTeX. Escape these wherever they land in body text - company names, achievement bullets, skill lists:
|
||||||
|
|
||||||
|
| Character | Write | Typical trigger |
|
||||||
|
|---|---|---|
|
||||||
|
| `&` | `\&` | company names: Bang \& Olufsen, Brüel \& Kjær, H\&M |
|
||||||
|
| `%` | `\%` | quantified achievements: "cut latency by 40\%" |
|
||||||
|
| `$` | `\$` | salary and cost figures |
|
||||||
|
| `#` | `\#` | "ranked \#1", C\# |
|
||||||
|
| `_` | `\_` | file names, code identifiers |
|
||||||
|
| `~` | `\textasciitilde{}` | URLs, "approx. 5 years" tildes |
|
||||||
|
| `^` | `\textasciicircum{}` | version strings, math |
|
||||||
|
|
||||||
|
Two failure modes deserve special care:
|
||||||
|
|
||||||
|
- **`%` fails silently.** An unescaped `%` starts a LaTeX comment: the compile succeeds with zero errors, and everything after the `%` on that line vanishes from the PDF. `Cut inference latency by 40% and saved DKK 2M annually` renders as "Cut inference latency by 40" - the bullet keeps its impressive-looking fragment and loses the actual result. Quantified achievement bullets are exactly where the guidance steers you ("use numbers where possible"), so check every `%` in every bullet before compiling.
|
||||||
|
- **`&` fails loudly** inside `\cventry` (alignment-tab errors, `Missing } inserted`). The compile loop catches it, but escape employer names up front rather than debugging the compile.
|
||||||
|
|
||||||
|
Related trap: a bullet whose text begins with a literal `[` must be braced - `\item {[text]}` - or LaTeX parses the bracketed text as `\item`'s optional label and renders it clipped off the left page edge with a clean compile. The example CV's placeholder bullets are braced for exactly this reason.
|
||||||
|
|
||||||
## Compile-and-Inspect Loop (MANDATORY)
|
## Compile-and-Inspect Loop (MANDATORY)
|
||||||
|
|
||||||
After writing the CV and before presenting to the user, always compile and visually inspect the PDF. Iterate until the layout is clean. Workflow:
|
After writing the CV and before presenting to the user, always compile and visually inspect the PDF. Iterate until the layout is clean. Workflow:
|
||||||
@@ -246,10 +267,10 @@ Restore the highest-relevance item that was previously cut — a CV that ends mi
|
|||||||
Most employers run CVs through an ATS before a human sees them, and the ATS reads the PDF's embedded **text layer**, not the rendered page. A CV can pass visual inspection and still extract as garbage. After the layout passes the compile-and-inspect loop, verify the text layer:
|
Most employers run CVs through an ATS before a human sees them, and the ATS reads the PDF's embedded **text layer**, not the rendered page. A CV can pass visual inspection and still extract as garbage. After the layout passes the compile-and-inspect loop, verify the text layer:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd cv && pdftotext -layout main_<company>_<role>.pdf main_<company>_<role>.txt
|
cd cv && pdftotext -layout -enc UTF-8 main_<company>_<role>.pdf main_<company>_<role>.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
`pdftotext` comes from [poppler](https://poppler.freedesktop.org/), not the TeX distribution - it is an **optional** dependency. If it is not installed, skip the mechanical check with a warning and rely on the visual PDF read for keyword coverage.
|
`pdftotext` comes from [poppler](https://poppler.freedesktop.org/), not the TeX distribution - it is an **optional** dependency. The `-enc UTF-8` flag is not optional: Xpdf-based `pdftotext` builds default to Latin-1 output, which makes every non-ASCII character in a perfectly good CV read back as a replacement character and fail the parseability check below for no real reason. If it is not installed, skip the mechanical check with a warning and rely on the visual PDF read for keyword coverage.
|
||||||
|
|
||||||
What to check in the extraction:
|
What to check in the extraction:
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
framework_version: 1.0.1
|
framework_version: 1.0.2
|
||||||
---
|
---
|
||||||
|
|
||||||
# Cover Letter Templates and Tailoring Guide
|
# Cover Letter Templates and Tailoring Guide
|
||||||
@@ -92,9 +92,9 @@ The font wrapper is mandatory — if you just move `\begin{itemize}` outside `\l
|
|||||||
|
|
||||||
{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont
|
{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont
|
||||||
\begin{itemize}
|
\begin{itemize}
|
||||||
\item [Concrete achievement/skill 1]
|
\item {[Concrete achievement/skill 1]}
|
||||||
\item [Concrete achievement/skill 2]
|
\item {[Concrete achievement/skill 2]}
|
||||||
\item [Concrete achievement/skill 3]
|
\item {[Concrete achievement/skill 3]}
|
||||||
\end{itemize}\par}
|
\end{itemize}\par}
|
||||||
|
|
||||||
\lettercontent{[Connection to company - why this role, why this company specifically]}
|
\lettercontent{[Connection to company - why this role, why this company specifically]}
|
||||||
@@ -146,10 +146,14 @@ The font wrapper is mandatory — if you just move `\begin{itemize}` outside `\l
|
|||||||
- 3-5 bullets is ideal
|
- 3-5 bullets is ideal
|
||||||
- Start each bullet with bold label or action verb
|
- Start each bullet with bold label or action verb
|
||||||
- Use `\textbf{Label:}` for category-style bullets
|
- Use `\textbf{Label:}` for category-style bullets
|
||||||
|
- A bullet whose text begins with a literal `[` must be braced: `\item {[text]}`. Unbraced, LaTeX parses `[text]` as `\item`'s optional label and renders it off the left page edge, missing from the PDF text layer entirely
|
||||||
|
|
||||||
### LaTeX Special Characters
|
### LaTeX Special Characters
|
||||||
- Underscore: `\_`
|
Escape these wherever they appear in body text:
|
||||||
- Ampersand: `\&`
|
- Ampersand: `\&` (company names: Brüel \& Kjær, H\&M) - unescaped, the compile fails loudly
|
||||||
|
- Percent: `\%` ("grew revenue 30\%") - unescaped, it does **not** fail: everything after the `%` on that line is silently eaten as a LaTeX comment
|
||||||
|
- Dollar: `\$`, hash: `\#`, underscore: `\_`
|
||||||
|
- Tilde: `\textasciitilde{}`, caret: `\textasciicircum{}`, backslash: `\textbackslash{}`
|
||||||
|
|
||||||
### Non-English Cover Letters
|
### Non-English Cover Letters
|
||||||
- Same template structure, just write content in the posting's language
|
- Same template structure, just write content in the posting's language
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ For each **enabled** portal skill:
|
|||||||
|
|
||||||
1. Read its `SKILL.md` to find the correct `bun run …` invocation and supported flags.
|
1. Read its `SKILL.md` to find the correct `bun run …` invocation and supported flags.
|
||||||
2. Translate the query terms from `search-queries.md` into that portal's flag format (e.g. `--key`, `--search-string`, `--query`, filter codes — whatever the portal's SKILL.md specifies).
|
2. Translate the query terms from `search-queries.md` into that portal's flag format (e.g. `--key`, `--search-string`, `--query`, filter codes — whatever the portal's SKILL.md specifies).
|
||||||
3. Scope to the last 14 days using the portal's supported recency flag (`--jobage`, `--since <YYYY-MM-DD>`, `--order PublicationDate`, etc. — as documented per portal).
|
3. Scope to the last 14 days using the portal's supported recency **filter** flag (`--jobage`, `--since <YYYY-MM-DD>`, etc. — as documented per portal). A portal with **no recency flag** (jobdanmark offers none) still gets scoped: every portal's search output carries a `date` field, so filter client-side — drop results whose `date` is older than 14 days after the call returns, and never invent a flag the portal's SKILL.md does not document (the CLIs reject unknown flags). `--order PublicationDate` is a sort, and a sort is not a filter — pairing it with a `--limit` is a defensible approximation on a portal that offers nothing better (jobnet), but apply the client-side date filter on top all the same.
|
||||||
4. Cap results to ~20 per call using the portal's limit flag.
|
4. Cap results to ~20 per call using the portal's limit flag.
|
||||||
5. Use `--format json` for machine-readable output.
|
5. Use `--format json` for machine-readable output.
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ The `portal` field records which CLI skill produced the job (results are already
|
|||||||
|
|
||||||
The `source` field records which mechanism produced the entry: `cli` for Step 1b portal-CLI output, `websearch` for the Step 1c fallback. This is what keeps a ghost-job report diagnosable after the run's summary is gone: a stored entry whose URL later resolves to nothing (or to a different job) reads very differently depending on whether it came from live CLI output or from a search index that can be weeks stale - and a presented job with no entry here at all points at fabrication, which Rule 1 forbids. Entries written before this field existed lack it; never backfill it - the mechanism was not recorded.
|
The `source` field records which mechanism produced the entry: `cli` for Step 1b portal-CLI output, `websearch` for the Step 1c fallback. This is what keeps a ghost-job report diagnosable after the run's summary is gone: a stored entry whose URL later resolves to nothing (or to a different job) reads very differently depending on whether it came from live CLI output or from a search index that can be weeks stale - and a presented job with no entry here at all points at fabrication, which Rule 1 forbids. Entries written before this field existed lack it; never backfill it - the mechanism was not recorded.
|
||||||
|
|
||||||
`/rank` extends this schema additively: ranked entries also carry `rank_score` (0–100 overall score), `rank_verdict` (fit band, e.g. "strong fit"), `rank_date` (ISO date of ranking), and `strengths`/`gaps` (1-3 verbatim bullets each, copied from the scoring agent's findings). The `status` field is set to `"ranked"`. Do not drop any of these fields when re-writing entries. Entries ranked before `strengths`/`gaps` existed simply lack them; readers tolerate their absence and never backfill by guessing.
|
`/rank` extends this schema additively: ranked entries also carry `rank_score` (0–100 overall score), `rank_verdict` (fit band, e.g. "strong fit"), `rank_date` (ISO date of ranking), the veto fields `location_verdict` and `language_gate` (both PASS/FAIL/FLAG) with `language_note` (the quoted requirement explaining a non-PASS), and `strengths`/`gaps` (1-3 verbatim bullets each, copied from the scoring agent's findings). The `status` field is set to `"ranked"`. Do not drop any of these fields when re-writing entries. Entries ranked before `strengths`/`gaps` existed simply lack them; readers tolerate their absence and never backfill by guessing. Entries ranked before the verdict rename may carry a legacy PASS/FAIL/FLAG string in `location` - read that as the verdict when `location_verdict` is absent; in fresh entries `location` is always a place, never a verdict.
|
||||||
|
|
||||||
`deadline` is a base field rather than a `/rank` extension: Step 2's detail fetch already extracts the application deadline, so it is written when the job is first seen and refreshed by `/rank` Step 4 when a scoring agent returns a different value. `null` means the posting states no deadline; a missing key means the entry predates this field - **never infer a deadline** from either, and never backfill by guessing.
|
`deadline` is a base field rather than a `/rank` extension: Step 2's detail fetch already extracts the application deadline, so it is written when the job is first seen and refreshed by `/rank` Step 4 when a scoring agent returns a different value. `null` means the posting states no deadline; a missing key means the entry predates this field - **never infer a deadline** from either, and never backfill by guessing.
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ This mode now merges two sources — tracker rows (Step 2.1) and ranked postings
|
|||||||
|
|
||||||
1. **Dedupe.** Match tracker rows against ranked entries on case-insensitive company + role (casefold + strip on both fields) — the same match `/notion-sync`'s Step 2 describes. A job present in both counts once.
|
1. **Dedupe.** Match tracker rows against ranked entries on case-insensitive company + role (casefold + strip on both fields) — the same match `/notion-sync`'s Step 2 describes. A job present in both counts once.
|
||||||
2. **Recorded gaps beat inferred skills.** For any job that has a recorded `gaps` array (from a ranked entry, or from a tracker row that matched one), use those gap bullets directly as the skill list for that job instead of inferring from `role`/`sector`/`notes`. For a ranked-only job with no `gaps` (already skipped and counted in Step 2.3) or a tracker-only row, fall back to inferring likely required skills from `role`, `sector`, and `notes` — optionally WebFetch the row's `source` URL for more detail, but skip if the URL is missing or dead.
|
2. **Recorded gaps beat inferred skills.** For any job that has a recorded `gaps` array (from a ranked entry, or from a tracker row that matched one), use those gap bullets directly as the skill list for that job instead of inferring from `role`/`sector`/`notes`. For a ranked-only job with no `gaps` (already skipped and counted in Step 2.3) or a tracker-only row, fall back to inferring likely required skills from `role`, `sector`, and `notes` — optionally WebFetch the row's `source` URL for more detail, but skip if the URL is missing or dead.
|
||||||
3. **One weight per job**, both 0–100 on the same scale: `(100 - fit_rating) / 100` for tracker rows, `(100 - rank_score) / 100` for ranked-only rows. If a job is in both (Step 3.1 matched it), prefer the tracker's numeric `fit_rating` for the weight.
|
3. **One weight per job**, both 0–100 on the same scale: `(100 - fit_rating) / 100` for tracker rows, `(100 - rank_score) / 100` for ranked-only rows. If a job is in both (Step 3.1 matched it), prefer the tracker's numeric `fit_rating` for the weight. A **blank or non-numeric `fit_rating`** (rows `/outcome` creates for applications made outside the workflow never got a fit evaluation) contributes no weight: fall back to a matched ranked entry's `rank_score` when Step 3.1 found one, otherwise skip the row, count it, and report the count once in the terminal — the same treatment Step 2.3 gives a missing `gaps` field, and for the same reason. Never treat a blank as 0: that reads as weight 1.0, the maximum, and lets the one job the framework knows nothing about dominate the heatmap.
|
||||||
4. **Score.** Build a **skill frequency map**: for each extracted skill (recorded gap bullet or inferred skill), count how many jobs mention it, then multiply each job's contribution by its weight from Step 3.3. Track whether each contribution came from a recorded gap or an inferred one, for Step 5's provenance column.
|
4. **Score.** Build a **skill frequency map**: for each extracted skill (recorded gap bullet or inferred skill), count how many jobs mention it, then multiply each job's contribution by its weight from Step 3.3. Track whether each contribution came from a recorded gap or an inferred one, for Step 5's provenance column.
|
||||||
|
|
||||||
Final score for each skill: `sum of (weight × occurrence)` across all jobs.
|
Final score for each skill: `sum of (weight × occurrence)` across all jobs.
|
||||||
|
|||||||
@@ -144,7 +144,8 @@ jobs:
|
|||||||
python3 tools/verify_pdf.py cv/main_example.pdf \
|
python3 tools/verify_pdf.py cv/main_example.pdf \
|
||||||
--pages 2 \
|
--pages 2 \
|
||||||
--contains '[your.email@example.com]' \
|
--contains '[your.email@example.com]' \
|
||||||
--contains 'Professional Experience'
|
--contains 'Professional Experience' \
|
||||||
|
--contains 'Achievement'
|
||||||
python3 tools/verify_pdf.py cover_letters/cover_example.pdf \
|
python3 tools/verify_pdf.py cover_letters/cover_example.pdf \
|
||||||
--pages 1 \
|
--pages 1 \
|
||||||
--contains 'your.email@example.com' \
|
--contains 'your.email@example.com' \
|
||||||
@@ -209,8 +210,9 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
check CLAUDE.md '\[YOUR_NAME\]'
|
check CLAUDE.md '\[YOUR_NAME\]'
|
||||||
check cv/main_example.tex '\[YOUR_NAME\]'
|
check cv/main_example.tex '\\name{\[First\]}{\[Last\]}'
|
||||||
|
check cv/main_example.tex '\\email{\[your\.email@example\.com\]}'
|
||||||
check cover_letters/cover_example.tex '\[YOUR NAME\]'
|
check cover_letters/cover_example.tex '\[YOUR NAME\]'
|
||||||
check .claude/skills/job-application-assistant/01-candidate-profile.md '<!-- SETUP'
|
check .claude/skills/job-application-assistant/01-candidate-profile.md '\[YOUR_EMAIL\]'
|
||||||
check .claude/skills/job-application-assistant/04-job-evaluation.md '\[YOUR_PRIMARY_SKILLS\]'
|
check .claude/skills/job-application-assistant/04-job-evaluation.md '\[YOUR_PRIMARY_SKILLS\]'
|
||||||
exit $fail
|
exit $fail
|
||||||
|
|||||||
+212
@@ -15,6 +15,49 @@ per-file diff commands.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **`freehire-search` gains `--no-description` for cheap discovery passes** - a default
|
||||||
|
search hydrates full description bodies (~73% of the payload, ~20k tokens per query)
|
||||||
|
while `/scrape` is told to pre-filter by title before reading bodies. The new flag
|
||||||
|
drops the bodies (a live 10-result search shrinks from ~58k to ~10k chars) while
|
||||||
|
keeping every other field; hydration stays the default. The API currently returns
|
||||||
|
bodies regardless of `include_description=false`, so the lean guarantee is enforced
|
||||||
|
client-side. Pinned in `tests/commands.test.ts`.
|
||||||
|
- **Fixture coverage for linkedin's date/location and jobindex's `parseSearchPage`** -
|
||||||
|
linkedin's search-card fixture carried no `<time>` or location element, so deleting
|
||||||
|
the `date` extraction (a `/scrape` contract field on a default-ON portal) left every
|
||||||
|
test green; jobindex's Stash parser had no tests at all, so `meta.total` could stop
|
||||||
|
using `hitcount` unnoticed. Four new linkedin cases (both listdate class variants,
|
||||||
|
location, absent-element nulls) and a new jobindex `search-page.test.ts` (hitcount
|
||||||
|
vs page count, contract-field mapping, deadline fallbacks). Both mutation-verified.
|
||||||
|
- **Tests for `check_framework_version.py`** - the CI gate that stops a framework file
|
||||||
|
from being edited without a `framework_version` bump had zero tests, so the one-line
|
||||||
|
mutation `return meaningful_changes > 0` -> `return False` disabled it while the suite
|
||||||
|
stayed green. Four cases in the new `tests/test_check_framework_version.py` (clean
|
||||||
|
tree, unbumped edit, bumped edit, missing marker), each running the real script inside
|
||||||
|
an isolated git repo. Mutation-verified against that exact disable.
|
||||||
|
- **Tests for `lint_skills.py`'s skill and command checks** - only `check_settings()`
|
||||||
|
had coverage; the linter's main job (frontmatter keys, `allowed-tools` targets
|
||||||
|
existing, the `# /<name>` command title rule) was unasserted, so deleting the
|
||||||
|
missing-allowed-tools error survived the whole suite. Four new cases in
|
||||||
|
`tests/test_lint_skills.py`, with the fixture's yaml stub upgraded to parse the real
|
||||||
|
frontmatter. Mutation-verified.
|
||||||
|
- **Discriminating tests for `robots_check`'s tie-break and browser-UA fallback** - the
|
||||||
|
existing tie test put Disallow first, the one ordering that cannot detect deletion of
|
||||||
|
the tie-break clause; and the browser-readback recovery that `09-web-research.md`
|
||||||
|
claims is covered had no test at all. Three new tests in `tests/test_robots_check.py`
|
||||||
|
pin the Allow-first tie, the 403-to-honest/200-to-browser recovery, and that a
|
||||||
|
browser-fetched policy is still obeyed strictly. Each was mutation-verified: deleting
|
||||||
|
the tie-break clause or the UA fallback now fails the suite.
|
||||||
|
- **LaTeX special-character guidance for CVs** (`framework_version` 1.4.1 -> 1.4.2 in
|
||||||
|
`05-cv-templates.md`, 1.0.1 -> 1.0.2 in `06-cover-letter-templates.md`) - `05` gains a
|
||||||
|
"LaTeX Special Characters" section and `06`'s existing one is completed beyond `\_`/`\&`.
|
||||||
|
The load-bearing case is an unescaped `%` in a quantified achievement bullet: it starts a
|
||||||
|
LaTeX comment, so "cut latency by 40% and saved DKK 2M" compiles with zero errors and
|
||||||
|
renders as "cut latency by 40" - silent content loss in the deliverable, on exactly the
|
||||||
|
content the guidance steers users to write. `&` in employer names (Bang & Olufsen, H&M)
|
||||||
|
fails loudly at compile time and is now documented alongside. Pinned by
|
||||||
|
`tests/test_latex_guidance.py`.
|
||||||
|
|
||||||
- **`seen_jobs.json` entries record which mechanism produced them** - a new additive `source`
|
- **`seen_jobs.json` entries record which mechanism produced them** - a new additive `source`
|
||||||
field (`cli` for Step 1b portal-CLI output, `websearch` for the Step 1c fallback), a Step 1c
|
field (`cli` for Step 1b portal-CLI output, `websearch` for the Step 1c fallback), a Step 1c
|
||||||
rule tagging fallback results at collection time, and a `fallback (websearch):` line in the
|
rule tagging fallback results at collection time, and a `fallback (websearch):` line in the
|
||||||
@@ -29,6 +72,44 @@ per-file diff commands.
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- **BREAKING (scripts passing stray flags): all six portal CLIs reject unknown flags**
|
||||||
|
with exit 1 and `{"error", "code": "UNKNOWN_FLAG"}` on stderr, instead of silently
|
||||||
|
discarding them. A discarded filter changes what a search returns with no error - a
|
||||||
|
wrong flag name on jobdanmark returned the entire database (13,862 results, none
|
||||||
|
matching) as if it matched the query, and the six portals use four different names for
|
||||||
|
the free-text flag, so cross-portal guessing is likely. `add-portal.md` already
|
||||||
|
required contributed portals to exit 1 on a bogus flag; the reference CLIs now meet
|
||||||
|
their own bar. Pinned by nine new cases across the six `cli-flag-validation` suites.
|
||||||
|
- **`/rank` persists its location verdict as `location_verdict`** - the bare `location`
|
||||||
|
key meant two incompatible things in `seen_jobs.json`: a place (scraper search output,
|
||||||
|
driving the commute filter) and a PASS/FAIL/FLAG verdict (`/rank` Step 4), so ranking
|
||||||
|
could overwrite "Aarhus, Denmark" with "PASS" and no reader could tell which meaning a
|
||||||
|
stored value carried. Legacy entries are read compatibly (a PASS/FAIL/FLAG string in
|
||||||
|
`location` counts as the verdict when `location_verdict` is absent) and migrated on
|
||||||
|
re-write. The `seen_jobs` schema note in `job-scraper/SKILL.md` now also enumerates
|
||||||
|
`location_verdict`/`language_gate`/`language_note`, so its "do not drop any of these
|
||||||
|
fields" instruction finally covers the fields `/rank` calls as important as the score.
|
||||||
|
Pinned by two new tests in `tests/test_rank_command.py`.
|
||||||
|
- **`linkedin-search detail` drops the `applyUrl` field** - the extraction regex
|
||||||
|
assumed `class=` before `href=` and never matched LinkedIn's real markup (`null` on
|
||||||
|
every live posting since the markup ordering differs), and fixing the regex would only
|
||||||
|
capture the job-view URL, a duplicate of the record's own `url`. The field and the
|
||||||
|
SKILL.md "apply link" claim are removed; a test pins the removal.
|
||||||
|
- **`jobdanmark-search` search output drops presentation-only keys** - `coverImage`,
|
||||||
|
`companyLogo`, `companyLogoSvgMarkup`, `overlayColor`, and `silhouetteLogo` were ~40%
|
||||||
|
of a live payload (a 30-result response shrinks from ~30k to ~20k chars), fed into
|
||||||
|
agent context on every `/scrape` query, and unusable by an agent. The #340
|
||||||
|
compatibility duplicates (`companyName`, `publishedDate`, `applicationDeadline`) and
|
||||||
|
`slug` stay. Pinned in `tests/search-normalization.test.ts`.
|
||||||
|
- **BREAKING (jobbank forks): `jobbank-search` search output emits `deadline` as
|
||||||
|
`YYYY-MM-DD`** - the feed's `DD.MM.YYYY` parenthetical was passed through raw,
|
||||||
|
contradicting the `/scrape` contract, the other portals, and the same CLI's own
|
||||||
|
`detail` command (which already emits ISO for the same job). `01.09.2026` is also
|
||||||
|
ambiguous to a date parser (1 Sep vs 9 Jan). The known shape is now converted;
|
||||||
|
"løbende" still maps to `null`, and an unrecognized shape passes through for `/rank`'s
|
||||||
|
defensive handling. Anything parsing the old `DD.MM.YYYY` output must update - though
|
||||||
|
the README's own search example already showed the ISO form. Pinned in
|
||||||
|
`tests/rss-parsing.test.ts` and `tests/search-normalization.test.ts`.
|
||||||
- **Job matching reframed around function, not title** (`framework_version` 1.2.2 -> 1.2.3 in
|
- **Job matching reframed around function, not title** (`framework_version` 1.2.2 -> 1.2.3 in
|
||||||
`04-job-evaluation.md`) - title-lookalike matching throws away career capital that doesn't
|
`04-job-evaluation.md`) - title-lookalike matching throws away career capital that doesn't
|
||||||
fit one job-title box (e.g. a background spanning research leadership, platform ownership,
|
fit one job-title box (e.g. a background spanning research leadership, platform ownership,
|
||||||
@@ -46,6 +127,137 @@ per-file diff commands.
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **`jobindex-search detail` rewritten against jobindex's current markup** - every
|
||||||
|
selector the old parser used is gone from live pages, so on 4 of 5 live postings it
|
||||||
|
returned CSS-comment text as the deadline (`"K \t\t... */"`), an external ATS URL as
|
||||||
|
its own `id` and `url`, null company/location/date, and a 160-char teaser as the
|
||||||
|
description - exit 0 every time. The new parser handles both live shapes (the
|
||||||
|
jobindex-native `jd-*` layout and the external-ATS passthrough), always keeps the
|
||||||
|
jobindex id and `jobannonce` URL, requires a real date next to the deadline label and
|
||||||
|
scans only visible markup (killing the CSS-comment capture), converts Danish long
|
||||||
|
dates to ISO, and reports `company: null` honestly on passthrough pages instead of
|
||||||
|
the ATS brand. Verified live on 5/5 postings (full descriptions of 5.5-9k chars, 4/5
|
||||||
|
ISO deadlines and locations). Fixture tests for both shapes, including the
|
||||||
|
CSS-comment trap, in the new `tests/detail-parsing.test.ts`.
|
||||||
|
- **`/scrape` gains a recency fallback for portals with no recency flag** - Step 1b.3
|
||||||
|
told every portal to scope to 14 days "using the portal's supported recency flag", but
|
||||||
|
jobdanmark has none, leaving the instruction unsatisfiable there: the agent either
|
||||||
|
silently skipped the scoping or invented a flag (which the CLIs now reject). Every
|
||||||
|
portal emits a `date` field, so the instruction now says to filter client-side after
|
||||||
|
the call, and stops presenting `--order` (a sort) as interchangeable with a filter.
|
||||||
|
Pinned in `tests/test_scrape_provenance.py`.
|
||||||
|
- **`/html-report`'s funnel counts stages from history; the rejection rate stops
|
||||||
|
counting non-rejections** - the funnel was computed from current status, which is a
|
||||||
|
state, not a history: an application that interviewed and was then rejected never
|
||||||
|
counted as reaching Interview, so a finished search rendered as though nobody ever
|
||||||
|
interviewed. The funnel (Step 2 and chart 4) now derives stage-reached from current
|
||||||
|
status plus the `outcome.md` stage checkboxes Step 1.2 already merges. And the
|
||||||
|
rejection rate no longer counts `offer_declined` (a success) or `withdrawn`
|
||||||
|
(candidate-initiated) as rejections, nor unresolved Interview/Offer rows in its
|
||||||
|
denominator. Pinned by two new tests in `tests/test_html_report_command.py`.
|
||||||
|
- **`jobdanmark-search detail`'s HTML fallback emits the same shapes as its JSON-LD
|
||||||
|
branch** - a posting without JSON-LD returned `datePosted` as the page's raw
|
||||||
|
`DD-MM-YYYY` text, `validThrough` as free text (including the literal `"Løbende"`,
|
||||||
|
which would flow into stored data as a deadline), and a hardcoded `null`
|
||||||
|
`addressLocality`. The fallback now converts overview dates to `YYYY-MM-DD`, maps
|
||||||
|
`Løbende` to `null` (jobbank's precedent for the equivalent), and derives the locality
|
||||||
|
from the workplace address with the same postcode extraction search uses. Pinned in
|
||||||
|
`tests/detail-parsing.test.ts`.
|
||||||
|
- **`jobnet-search detail` no longer leaks the `1900-01-01` undisclosed-deadline
|
||||||
|
sentinel** - `search` maps the API's sentinel to `null` (with a test pinning it), but
|
||||||
|
`detail` dumped the raw response, so a posting whose deadline is simply not disclosed
|
||||||
|
contributed a deadline 126 years in the past to stored data, and `/rank`'s expiry
|
||||||
|
sweep would retire the job instantly. All three output formats now flow through a
|
||||||
|
`prepareDetail` normalization that maps the sentinel to `null`. Pinned in
|
||||||
|
`tests/detail-formatting.test.ts`.
|
||||||
|
- **CI's placeholder guard now watches the CV's actual personal-data lines** - the
|
||||||
|
sentinel for `cv/main_example.tex` was `[YOUR_NAME]`, whose only occurrences are a
|
||||||
|
header comment and the hyperref `pdftitle`; `/setup`'s documented edit replaces the
|
||||||
|
`\name{}`/`\address{}`/`\phone{}`/`\email{}` data and touches neither, so a fully
|
||||||
|
personalized CV with a real name, address, phone and email passed the check (proven
|
||||||
|
empirically in the review). The guard now asserts sentinels inside the `\name{}` and
|
||||||
|
`\email{}` lines, and `01-candidate-profile.md`'s sentinel moves from the `<!-- SETUP`
|
||||||
|
header comment onto the `[YOUR_EMAIL]` Identity field for the same reason. The new
|
||||||
|
`tests/test_placeholder_integrity.py` simulates the `/setup` edit and requires the
|
||||||
|
guard to fire on it.
|
||||||
|
- **`jobindex-search` maps ASAP postings' deadline to `null`** - the portal's
|
||||||
|
`apply_deadline_asap` flag was emitted as the literal string `"ASAP"` on roughly half
|
||||||
|
of live results, contradicting the CLI's own README ("date string; null if not
|
||||||
|
listed") and the `/scrape` schema, and breaking every consumer that does date
|
||||||
|
arithmetic (`/rank`'s urgency and expiry sweep, `/outcome`'s deadline check,
|
||||||
|
`/notion-sync`'s typed date column). ASAP means "no stated deadline", which the
|
||||||
|
contract already represents as `null`. Pinned in `tests/search-page.test.ts`.
|
||||||
|
- **`/gmail-sync` no longer restricts its search to the Inbox** - the query used
|
||||||
|
`in:inbox` to "skip sent/drafts", but that operator also excludes every archived
|
||||||
|
message, and self-defeatingly the mail matched by the very job-search label Step 3.1
|
||||||
|
hunts for (the standard filter that applies such a label also archives). The query now
|
||||||
|
uses `-in:sent -in:drafts`, which matches the stated intent exactly. The failure mode
|
||||||
|
was silent under-detection: a missed rejection or interview invite read as "no
|
||||||
|
updates". Pinned by the new `tests/test_gmail_sync_command.py`.
|
||||||
|
- **`/upskill` no longer divides by a blank `fit_rating`** - `/outcome` creates tracker
|
||||||
|
rows for applications made outside the workflow with no fit evaluation, so their
|
||||||
|
`fit_rating` is blank, and Step 3.3's `(100 - fit_rating) / 100` had no rule for that.
|
||||||
|
The naive blank-as-0 reading yields weight 1.0 (the maximum), letting the one job the
|
||||||
|
framework knows nothing about dominate the skill-gap heatmap. A blank or non-numeric
|
||||||
|
`fit_rating` now falls back to a matched ranked entry's `rank_score`, else the row is
|
||||||
|
skipped, counted, and reported once - mirroring the skill's own missing-`gaps`
|
||||||
|
handling. Pinned by `tests/test_upskill_skill.py`.
|
||||||
|
- **`/rank`'s expiry sweep parses stored deadlines defensively** - the sweep changes
|
||||||
|
status automatically from a date comparison against values on disk, but portals have
|
||||||
|
shipped non-ISO shapes into `seen_jobs.json` (`"ASAP"`, `DD.MM.YYYY`, free text), and
|
||||||
|
`/rank` had no rule for them while the display-only `/outcome` already did. A stored
|
||||||
|
deadline that is not `YYYY-MM-DD` is now treated exactly like an absent one wherever a
|
||||||
|
stored deadline is compared (urgency and sweep), and reported once with its portal.
|
||||||
|
Pinned by `tests/test_rank_command.py`.
|
||||||
|
- **Language Gate preamble no longer claims the gate is untracked** (`framework_version`
|
||||||
|
1.2.3 -> 1.2.4 in `04-job-evaluation.md`) - the paragraph still said the result "is not
|
||||||
|
a field `/scrape` or `/rank` track", written before the gate was wired into both
|
||||||
|
consumers. An agent reading the authoritative framework file learned the opposite of
|
||||||
|
what `rank.md` itself insists on ("These veto fields are as important to persist as
|
||||||
|
the score itself"). The preamble now names `language_gate`/`language_note` and how each
|
||||||
|
consumer uses them; a coupling test in `tests/test_rank_command.py` keeps the framework
|
||||||
|
text honest about the tracking.
|
||||||
|
- **`/reset documents` now clears `documents/postings/`** - the drop folder for
|
||||||
|
hand-pasted job posting text was absent from the preview, the delete block, and the
|
||||||
|
user-facing scope description, after which the command told the user "The `documents/`
|
||||||
|
folder is now empty" - false whenever postings were present, and they are exactly the
|
||||||
|
personal residue a reset exists to clear. A new `tests/test_reset_command.py` derives
|
||||||
|
the folder list from the git tree, so any future drop folder fails the test until
|
||||||
|
`/reset` covers it.
|
||||||
|
- **`convert_salary_excel.py` no longer corrupts US/UK-formatted numbers 1000x** - the
|
||||||
|
both-separators branch always assumed European locale, so a `"1,234.56"` cell was
|
||||||
|
silently converted to `1.23456` and written into `salary_data.json`. The rule is now
|
||||||
|
"the separator that appears last is the decimal separator", which also makes
|
||||||
|
multi-group values (`"1,234,567.89"`, `"1.234.567,89"`) parse instead of raising. And
|
||||||
|
`strip_type_patterns` now strips `COMPOUND_PATTERNS` words as substrings, mirroring
|
||||||
|
`header_matches`, so a Danish compound header pair ("Antal alle" / "Lønindeks alle")
|
||||||
|
pairs into one category instead of two unpaired standalones - the exact locale the
|
||||||
|
compound support was added for. Pinned by six new cases in
|
||||||
|
`tests/test_convert_salary_excel.py`.
|
||||||
|
- **`jobdanmark-search` extracts the city when a comma follows the postcode** - the
|
||||||
|
`location` regex required whitespace after the 4-digit postcode, but live
|
||||||
|
`companyAddress` values frequently read `"2670, Greve"`; those results emitted
|
||||||
|
`location: null` (7 of 30 in a live sample), so `/scrape`'s geography/commute filter
|
||||||
|
(Rule 3) had nothing to act on. The extraction now accepts an optional comma, trims the
|
||||||
|
captured city, and still refuses to mistake a 4-digit street number for the postcode.
|
||||||
|
Pinned by three new cases in `tests/search-normalization.test.ts`.
|
||||||
|
- **Example-CV bullets no longer swallowed as LaTeX optional labels** - every placeholder
|
||||||
|
bullet written as `\item [text]` (11 in `cv/main_example.tex`, 3 in
|
||||||
|
`06-cover-letter-templates.md`'s taught template) let LaTeX parse the bracketed text as
|
||||||
|
`\item`'s optional argument: the shipped example CV rendered all Professional Experience
|
||||||
|
bullets clipped off the left page edge, with the word "Achievement" appearing 9 times in
|
||||||
|
the source and 0 times in the PDF text layer - a clean compile, green CI. Bullets are now
|
||||||
|
braced (`\item {[text]}`), the cover-letter guide teaches the braced form, and CI's stock
|
||||||
|
PDF assertions additionally require `Achievement` to survive `pdftotext`. Pinned by
|
||||||
|
`tests/test_latex_guidance.py`.
|
||||||
|
- **Documented ATS extraction commands pin `-enc UTF-8`** - `pdftotext -layout` without an
|
||||||
|
encoding flag emits Latin-1 on Xpdf builds, so every non-ASCII character in a correct CV
|
||||||
|
(Rambøll, Ingeniør, København) read back as a replacement character and failed the
|
||||||
|
parseability checklist, steering the agent to "fix" a healthy document. The commands in
|
||||||
|
`apply.md`, `05-cv-templates.md`, and `CLAUDE.md`'s verification checklist now carry
|
||||||
|
`-enc UTF-8`, which is deterministic on both poppler and Xpdf. Pinned by
|
||||||
|
`tests/test_latex_guidance.py`.
|
||||||
|
|
||||||
- **`jobbank-search` search output now carries the `/scrape` contract's `date` field** (#342) -
|
- **`jobbank-search` search output now carries the `/scrape` contract's `date` field** (#342) -
|
||||||
the CLI emitted `posted` (full ISO 8601) but not the cross-portal `date` key, the one Step 2
|
the CLI emitted `posted` (full ISO 8601) but not the cross-portal `date` key, the one Step 2
|
||||||
contract field it was missing. Search results now additively emit `date` as `YYYY-MM-DD`
|
contract field it was missing. Search results now additively emit `date` as `YYYY-MM-DD`
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ Both documents MUST be compiled and visually inspected via the Read tool on the
|
|||||||
- [ ] **Cover letter bullet font matches body font** - `\lettercontent{}` must not wrap `\begin{itemize}...\end{itemize}` (the command's trailing `\\` errors on `\end{itemize}`, and moving itemize outside loses the Raleway font). Standard pattern: close `\lettercontent{}`, then wrap the list in `{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont \begin{itemize}...\end{itemize}\par}`
|
- [ ] **Cover letter bullet font matches body font** - `\lettercontent{}` must not wrap `\begin{itemize}...\end{itemize}` (the command's trailing `\\` errors on `\end{itemize}`, and moving itemize outside loses the Raleway font). Standard pattern: close `\lettercontent{}`, then wrap the list in `{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont \begin{itemize}...\end{itemize}\par}`
|
||||||
|
|
||||||
### ATS & keyword verification (CV)
|
### ATS & keyword verification (CV)
|
||||||
ATS parsers read the PDF's embedded text layer, not the rendered page. Extract it with `pdftotext -layout` and verify what a parser sees. `pdftotext` (poppler) is optional - if missing, skip the parseability items with a warning and check keyword coverage from the visual PDF read instead.
|
ATS parsers read the PDF's embedded text layer, not the rendered page. Extract it with `pdftotext -layout -enc UTF-8` and verify what a parser sees. `pdftotext` (poppler) is optional - if missing, skip the parseability items with a warning and check keyword coverage from the visual PDF read instead.
|
||||||
- [ ] CV text layer extracts cleanly - no `(cid:*)` markers, `�` replacement characters, or text visible in the PDF but absent from the extraction
|
- [ ] CV text layer extracts cleanly - no `(cid:*)` markers, `�` replacement characters, or text visible in the PDF but absent from the extraction
|
||||||
- [ ] Email and phone appear as **literal text** in the extraction (icon-glyph noise like `MOBILE-ALT`/`Envelope` is harmless, but a contact detail carried only by an icon or hyperlink is invisible to ATS)
|
- [ ] Email and phone appear as **literal text** in the extraction (icon-glyph noise like `MOBILE-ALT`/`Envelope` is harmless, but a contact detail carried only by an icon or hyperlink is invisible to ATS)
|
||||||
- [ ] Reading order of the extracted text matches the visual order (single-column stock template is safe; multi-column custom templates are where this breaks)
|
- [ ] Reading order of the extracted text matches the visual order (single-column stock template is safe; multi-column custom templates are where this breaks)
|
||||||
|
|||||||
+11
-11
@@ -93,10 +93,10 @@
|
|||||||
% --- Most Recent Role ---
|
% --- Most Recent Role ---
|
||||||
\item{\cventry{[YYYY-Present]}{[Job Title]}{[Company]}{[City, Country]}{}{\vspace{1pt}
|
\item{\cventry{[YYYY-Present]}{[Job Title]}{[Company]}{[City, Country]}{}{\vspace{1pt}
|
||||||
\begin{itemize}
|
\begin{itemize}
|
||||||
\item [Achievement or responsibility 1 - be specific, use numbers where possible]
|
\item {[Achievement or responsibility 1 - be specific, use numbers where possible]}
|
||||||
\item [Achievement or responsibility 2]
|
\item {[Achievement or responsibility 2]}
|
||||||
\item [Achievement or responsibility 3]
|
\item {[Achievement or responsibility 3]}
|
||||||
\item [Achievement or responsibility 4]
|
\item {[Achievement or responsibility 4]}
|
||||||
\end{itemize}}}
|
\end{itemize}}}
|
||||||
|
|
||||||
\vspace{3pt}
|
\vspace{3pt}
|
||||||
@@ -104,9 +104,9 @@
|
|||||||
% --- Previous Role ---
|
% --- Previous Role ---
|
||||||
\item{\cventry{[YYYY-YYYY]}{[Job Title]}{[Company]}{[City, Country]}{}{\vspace{1pt}
|
\item{\cventry{[YYYY-YYYY]}{[Job Title]}{[Company]}{[City, Country]}{}{\vspace{1pt}
|
||||||
\begin{itemize}
|
\begin{itemize}
|
||||||
\item [Achievement or responsibility 1]
|
\item {[Achievement or responsibility 1]}
|
||||||
\item [Achievement or responsibility 2]
|
\item {[Achievement or responsibility 2]}
|
||||||
\item [Achievement or responsibility 3]
|
\item {[Achievement or responsibility 3]}
|
||||||
\end{itemize}}}
|
\end{itemize}}}
|
||||||
|
|
||||||
\vspace{3pt}
|
\vspace{3pt}
|
||||||
@@ -114,8 +114,8 @@
|
|||||||
% --- Earlier Role ---
|
% --- Earlier Role ---
|
||||||
\item{\cventry{[YYYY-YYYY]}{[Job Title]}{[Company]}{[City, Country]}{}{\vspace{1pt}
|
\item{\cventry{[YYYY-YYYY]}{[Job Title]}{[Company]}{[City, Country]}{}{\vspace{1pt}
|
||||||
\begin{itemize}
|
\begin{itemize}
|
||||||
\item [Achievement or responsibility 1]
|
\item {[Achievement or responsibility 1]}
|
||||||
\item [Achievement or responsibility 2]
|
\item {[Achievement or responsibility 2]}
|
||||||
\end{itemize}}}
|
\end{itemize}}}
|
||||||
|
|
||||||
\end{itemize}
|
\end{itemize}
|
||||||
@@ -147,7 +147,7 @@ Thesis: ``[Thesis Title].'' [Brief description of research focus.]
|
|||||||
\section{Languages}
|
\section{Languages}
|
||||||
\vspace{1pt}
|
\vspace{1pt}
|
||||||
\begin{itemize}
|
\begin{itemize}
|
||||||
\item [Language 1] (native), [Language 2] (fluent), [Language 3] (intermediate).
|
\item {[Language 1] (native), [Language 2] (fluent), [Language 3] (intermediate).}
|
||||||
\end{itemize}
|
\end{itemize}
|
||||||
|
|
||||||
% ============================================================
|
% ============================================================
|
||||||
@@ -157,7 +157,7 @@ Thesis: ``[Thesis Title].'' [Brief description of research focus.]
|
|||||||
\section{Publications}
|
\section{Publications}
|
||||||
\vspace{1pt}
|
\vspace{1pt}
|
||||||
\begin{itemize}
|
\begin{itemize}
|
||||||
\item [Author(s)] ([Year]). [Title]. [Journal/Conference]. \href{[DOI_URL]}{DOI link}
|
\item {[Author(s)] ([Year]). [Title]. [Journal/Conference]. \href{[DOI_URL]}{DOI link}}
|
||||||
\end{itemize}
|
\end{itemize}
|
||||||
|
|
||||||
% ============================================================
|
% ============================================================
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Guards for tools/check_framework_version.py - the CI gate itself.
|
||||||
|
|
||||||
|
This gate is what stops a PR from editing a profile-bearing framework
|
||||||
|
file without bumping `framework_version` (the fork-rebase safety marker).
|
||||||
|
It ran in CI with zero tests, so a one-line mutation
|
||||||
|
(`return meaningful_changes > 0` -> `return False`) disabled it while
|
||||||
|
the whole suite stayed green (review finding F22, 2026-08-19). A broken
|
||||||
|
guard is silent by construction: nothing fails, it just stops catching.
|
||||||
|
|
||||||
|
Each test builds an isolated git repo with the script copied inside it
|
||||||
|
(the script resolves ROOT from __file__), so the real repo is never read
|
||||||
|
or written.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SCRIPT = REPO_ROOT / "tools" / "check_framework_version.py"
|
||||||
|
|
||||||
|
FRONTMATTER = "---\nframework_version: 1.0.0\n---\n"
|
||||||
|
BODY = "# Test framework file\n\nOriginal guidance sentence.\n"
|
||||||
|
|
||||||
|
|
||||||
|
class CheckerRepoFixture(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.root = Path(tempfile.mkdtemp())
|
||||||
|
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||||||
|
|
||||||
|
tools = self.root / "tools"
|
||||||
|
tools.mkdir()
|
||||||
|
shutil.copy(SCRIPT, tools / "check_framework_version.py")
|
||||||
|
|
||||||
|
self.skill_dir = self.root / ".claude" / "skills" / "job-application-assistant"
|
||||||
|
self.skill_dir.mkdir(parents=True)
|
||||||
|
self.framework_file = self.skill_dir / "01-test-profile.md"
|
||||||
|
self.framework_file.write_text(FRONTMATTER + BODY, encoding="utf-8")
|
||||||
|
|
||||||
|
self.git("init", "-q")
|
||||||
|
self.git("add", "-A")
|
||||||
|
self.git("commit", "-q", "-m", "base")
|
||||||
|
|
||||||
|
def git(self, *args):
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-c", "user.name=test", "-c", "user.email=test@example.com", *args],
|
||||||
|
cwd=self.root,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_checker(self):
|
||||||
|
# Strip GitHub Actions variables so get_base_commit() takes the
|
||||||
|
# local path (uncommitted changes vs HEAD) regardless of where the
|
||||||
|
# test suite itself runs.
|
||||||
|
env = {k: v for k, v in os.environ.items() if not k.startswith("GITHUB_")}
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(self.root / "tools" / "check_framework_version.py")],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FrameworkVersionGateTests(CheckerRepoFixture):
|
||||||
|
def test_clean_tree_passes(self):
|
||||||
|
result = self.run_checker()
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||||
|
self.assertIn("Framework Version Check: OK", result.stdout)
|
||||||
|
|
||||||
|
def test_unbumped_edit_fails(self):
|
||||||
|
self.framework_file.write_text(
|
||||||
|
FRONTMATTER + BODY + "\nA new sentence without a version bump.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.run_checker()
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||||
|
self.assertIn("modified without bumping 'framework_version'", result.stdout)
|
||||||
|
|
||||||
|
def test_bumped_edit_passes(self):
|
||||||
|
bumped = FRONTMATTER.replace("1.0.0", "1.0.1")
|
||||||
|
self.framework_file.write_text(
|
||||||
|
bumped + BODY + "\nA new sentence with a version bump.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.run_checker()
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||||
|
|
||||||
|
def test_file_without_version_marker_fails(self):
|
||||||
|
(self.skill_dir / "02-unmarked.md").write_text(
|
||||||
|
"# No frontmatter at all\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.run_checker()
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||||
|
self.assertIn("missing 'framework_version'", result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -5,6 +5,7 @@ from tools.convert_salary_excel import (
|
|||||||
INDEX_PATTERNS,
|
INDEX_PATTERNS,
|
||||||
detect_column_type,
|
detect_column_type,
|
||||||
header_matches,
|
header_matches,
|
||||||
|
parse_numeric_cell,
|
||||||
parse_sheet,
|
parse_sheet,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -288,3 +289,54 @@ class DetectColumnTypeTests(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class ParseNumericCellLocaleTests(unittest.TestCase):
|
||||||
|
# The separator that appears LAST is the decimal separator. Assuming
|
||||||
|
# European ("." thousands, "," decimal) for every both-separator string
|
||||||
|
# turned a US "1,234.56" into 1.23456 - a silent 1000x corruption that
|
||||||
|
# flowed into salary_data.json and negotiation advice.
|
||||||
|
|
||||||
|
def test_us_thousands_and_decimal_string(self):
|
||||||
|
self.assertEqual(parse_numeric_cell("1,234.56"), 1234.56)
|
||||||
|
|
||||||
|
def test_us_multiple_thousands_groups(self):
|
||||||
|
self.assertEqual(parse_numeric_cell("1,234,567.89"), 1234567.89)
|
||||||
|
|
||||||
|
def test_european_thousands_and_decimal_string(self):
|
||||||
|
self.assertEqual(parse_numeric_cell("1.234,56"), 1234.56)
|
||||||
|
|
||||||
|
def test_european_multiple_thousands_groups(self):
|
||||||
|
self.assertEqual(parse_numeric_cell("1.234.567,89"), 1234567.89)
|
||||||
|
|
||||||
|
|
||||||
|
class CompoundCategoryPairingTests(unittest.TestCase):
|
||||||
|
def test_parse_sheet_pairs_danish_compound_index_with_count(self):
|
||||||
|
# "Lønindeks alle" is *detected* as an index column via
|
||||||
|
# COMPOUND_PATTERNS, but the derived category name must also lose the
|
||||||
|
# compound word or it can never pair with "Antal alle" ("alle" vs
|
||||||
|
# "lønindeks alle") - exactly the locale the compound support exists for.
|
||||||
|
ws = FakeWorksheet([
|
||||||
|
("Firma", "Antal alle", "Lønindeks alle"),
|
||||||
|
("Example Corp", 12, 118.0),
|
||||||
|
])
|
||||||
|
|
||||||
|
companies = parse_sheet(ws)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
companies[0]["categories"]["alle"],
|
||||||
|
{"count": 12, "index": 118.0},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parse_sheet_sheet_level_us_locale_value(self):
|
||||||
|
ws = FakeWorksheet([
|
||||||
|
("Company", "Salary Index"),
|
||||||
|
("Example Corp", "1,234.56"),
|
||||||
|
])
|
||||||
|
|
||||||
|
companies = parse_sheet(ws)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
companies[0]["categories"]["salary_index"],
|
||||||
|
{"index": 1234.56},
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Guards for /gmail-sync's Gmail query semantics.
|
||||||
|
|
||||||
|
The command's stated intent is "skip sent/drafts - status signals come
|
||||||
|
from what employers send you". `in:inbox` does not mean that: it matches
|
||||||
|
only messages currently IN the inbox, so it also excludes every archived
|
||||||
|
message - and, self-defeatingly, the mail matched by the very
|
||||||
|
job-search label Step 3.1 hunts for, because the standard filter that
|
||||||
|
applies such a label also archives ("skip the inbox"). The correct
|
||||||
|
operators for the stated intent are `-in:sent -in:drafts` (review
|
||||||
|
finding F18, 2026-08-19). The failure mode is silent under-detection: a
|
||||||
|
missed rejection or interview invite just looks like "no updates".
|
||||||
|
"""
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
GMAIL_SYNC = REPO / ".claude" / "commands" / "gmail-sync.md"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGmailQueryOperators(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.text = GMAIL_SYNC.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_query_excludes_sent_and_drafts_explicitly(self):
|
||||||
|
self.assertIn(
|
||||||
|
"-in:sent -in:drafts",
|
||||||
|
self.text,
|
||||||
|
"the query must exclude sent/drafts with negative operators, "
|
||||||
|
"which keep archived and label-filtered mail in scope",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_query_never_restricts_to_the_inbox(self):
|
||||||
|
self.assertNotIn(
|
||||||
|
"in:inbox",
|
||||||
|
self.text.replace("-in:sent", "").replace("-in:drafts", ""),
|
||||||
|
"in:inbox silently drops archived mail and everything a "
|
||||||
|
"label-and-archive filter routed past the inbox - exactly the "
|
||||||
|
"mail the label search in Step 3.1 exists to find",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -94,6 +94,43 @@ class HtmlReportTrackerFieldTests(unittest.TestCase):
|
|||||||
class HtmlReportGitignoreTests(unittest.TestCase):
|
class HtmlReportGitignoreTests(unittest.TestCase):
|
||||||
"""reports/ must be gitignored — it holds personal generated output."""
|
"""reports/ must be gitignored — it holds personal generated output."""
|
||||||
|
|
||||||
|
def test_funnel_is_computed_from_stage_history_not_current_status(self):
|
||||||
|
"""status is a current state, not a history: an application that
|
||||||
|
interviewed and was then rejected carries status `rejected` and would
|
||||||
|
never count as having reached Interview, so a finished search reads
|
||||||
|
as though nobody ever interviewed (review finding F10, 2026-08-19).
|
||||||
|
The stage checkboxes merged from outcome.md in Step 1.2 are the
|
||||||
|
history; the funnel must be told to use them."""
|
||||||
|
text = COMMAND_FILE.read_text(encoding="utf-8")
|
||||||
|
self.assertIn(
|
||||||
|
"stage checkboxes",
|
||||||
|
text.split("## Step 3")[0].split("## Step 2")[1],
|
||||||
|
"Step 2's funnel definition must derive stage-reached from the "
|
||||||
|
"merged outcome.md stage checkboxes",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"not current status",
|
||||||
|
text,
|
||||||
|
"the funnel rule must say explicitly that current status alone undercounts",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejection_rate_excludes_declined_offers_and_withdrawals(self):
|
||||||
|
"""offer_declined is the candidate turning an offer down (a success)
|
||||||
|
and withdrawn is candidate-initiated; counting either as a rejection
|
||||||
|
inflates the rejection rate on a self-assessment dashboard (review
|
||||||
|
finding F11, 2026-08-19)."""
|
||||||
|
text = COMMAND_FILE.read_text(encoding="utf-8")
|
||||||
|
self.assertIn(
|
||||||
|
"`offer_declined`",
|
||||||
|
text.split("## Step 3")[0].split("## Step 2")[1],
|
||||||
|
"the rejection-rate definition must address offer_declined",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"not rejections",
|
||||||
|
text,
|
||||||
|
"the rate must exclude candidate-initiated outcomes explicitly",
|
||||||
|
)
|
||||||
|
|
||||||
def test_reports_folder_is_gitignored(self):
|
def test_reports_folder_is_gitignored(self):
|
||||||
rules = {line.strip() for line in GITIGNORE.read_text(encoding="utf-8").splitlines()}
|
rules = {line.strip() for line in GITIGNORE.read_text(encoding="utf-8").splitlines()}
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Guards for the LaTeX authoring guidance and the example documents.
|
||||||
|
|
||||||
|
Three silent-failure modes live here, all found by the 2026-08-19 review
|
||||||
|
(F9, F31, F34). Each one produces a clean compile and a green CI run
|
||||||
|
while the rendered document or its ATS extraction is wrong, so the spec
|
||||||
|
files and the example sources are the only place a test can catch them:
|
||||||
|
|
||||||
|
- F9: a bullet written as `\\item [text]` is parsed as moderncv's
|
||||||
|
optional label, rendered off the left page edge, and dropped from the
|
||||||
|
PDF text layer. The example CV shipped that way for months.
|
||||||
|
- F31: an unescaped `%` in body text silently truncates the rest of the
|
||||||
|
line (`&` at least fails loudly). The guidance must name the escapes.
|
||||||
|
- F34: `pdftotext` without `-enc UTF-8` emits Latin-1 on Xpdf builds,
|
||||||
|
so a correct Danish CV fails the documented "no replacement
|
||||||
|
characters" check and the agent is sent to "fix" a healthy document.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
SKILL_DIR = REPO / ".claude" / "skills" / "job-application-assistant"
|
||||||
|
CV_TEMPLATES = SKILL_DIR / "05-cv-templates.md"
|
||||||
|
COVER_TEMPLATES = SKILL_DIR / "06-cover-letter-templates.md"
|
||||||
|
APPLY = REPO / ".claude" / "commands" / "apply.md"
|
||||||
|
EXAMPLE_CV = REPO / "cv" / "main_example.tex"
|
||||||
|
EXAMPLE_COVER = REPO / "cover_letters" / "cover_example.tex"
|
||||||
|
|
||||||
|
# \item whose body starts with [ - with or without whitespace between.
|
||||||
|
# LaTeX skips spaces while scanning for the optional argument, so
|
||||||
|
# `\item [text]` and `\item[text]` both swallow the text as a label.
|
||||||
|
# The safe spelling `\item {[text]}` does not match.
|
||||||
|
UNBRACED_BRACKET_ITEM = re.compile(r"\\item\s*\[")
|
||||||
|
|
||||||
|
# The escapes both guidance files must document. `%` is the load-bearing
|
||||||
|
# one: it truncates silently. The others fail loudly or corrupt spacing.
|
||||||
|
REQUIRED_ESCAPES = ["\\&", "\\%", "\\$", "\\#", "\\_"]
|
||||||
|
|
||||||
|
|
||||||
|
def section(text, heading):
|
||||||
|
"""Return the body of a markdown section up to the next heading."""
|
||||||
|
pattern = re.compile(
|
||||||
|
rf"^#+ {re.escape(heading)}[^\n]*\n(.*?)(?=^#+ |\Z)",
|
||||||
|
re.MULTILINE | re.DOTALL,
|
||||||
|
)
|
||||||
|
match = pattern.search(text)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
class TestBulletBracketTrap(unittest.TestCase):
|
||||||
|
"""F9: no document or template doc may teach `\\item [text]`."""
|
||||||
|
|
||||||
|
def assert_no_unbraced_bracket_items(self, path):
|
||||||
|
offending = [
|
||||||
|
f"{path.name}:{lineno}: {line.strip()}"
|
||||||
|
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
|
||||||
|
if UNBRACED_BRACKET_ITEM.search(line)
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
offending,
|
||||||
|
[],
|
||||||
|
"\\item followed by [ is parsed as an optional label and the "
|
||||||
|
"text is clipped off the page; write \\item {[...]} instead:\n"
|
||||||
|
+ "\n".join(offending),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_example_cv_has_no_bracket_labelled_bullets(self):
|
||||||
|
self.assert_no_unbraced_bracket_items(EXAMPLE_CV)
|
||||||
|
|
||||||
|
def test_example_cover_letter_has_no_bracket_labelled_bullets(self):
|
||||||
|
self.assert_no_unbraced_bracket_items(EXAMPLE_COVER)
|
||||||
|
|
||||||
|
def test_cover_letter_guide_does_not_teach_the_broken_pattern(self):
|
||||||
|
self.assert_no_unbraced_bracket_items(COVER_TEMPLATES)
|
||||||
|
|
||||||
|
def test_cv_guide_does_not_teach_the_broken_pattern(self):
|
||||||
|
self.assert_no_unbraced_bracket_items(CV_TEMPLATES)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpecialCharacterGuidance(unittest.TestCase):
|
||||||
|
"""F31: both template guides must document the LaTeX escapes."""
|
||||||
|
|
||||||
|
def assert_escapes_documented(self, path):
|
||||||
|
body = section(path.read_text(encoding="utf-8"), "LaTeX Special Characters")
|
||||||
|
self.assertIsNotNone(
|
||||||
|
body, f"{path.name} has no 'LaTeX Special Characters' section"
|
||||||
|
)
|
||||||
|
missing = [esc for esc in REQUIRED_ESCAPES if esc not in body]
|
||||||
|
self.assertEqual(
|
||||||
|
missing,
|
||||||
|
[],
|
||||||
|
f"{path.name}'s special-characters section is missing: {missing}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cv_guide_documents_the_escapes(self):
|
||||||
|
self.assert_escapes_documented(CV_TEMPLATES)
|
||||||
|
|
||||||
|
def test_cover_letter_guide_documents_the_escapes(self):
|
||||||
|
self.assert_escapes_documented(COVER_TEMPLATES)
|
||||||
|
|
||||||
|
def test_cv_guide_warns_that_percent_truncates_silently(self):
|
||||||
|
body = section(
|
||||||
|
CV_TEMPLATES.read_text(encoding="utf-8"), "LaTeX Special Characters"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(body)
|
||||||
|
self.assertRegex(
|
||||||
|
body,
|
||||||
|
re.compile(r"silent", re.IGNORECASE),
|
||||||
|
"the % failure mode must be called out as silent - it is the "
|
||||||
|
"reason this section exists (a clean compile with the rest of "
|
||||||
|
"the bullet gone)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAtsExtractionEncoding(unittest.TestCase):
|
||||||
|
"""F34: every documented extraction command must pin the encoding."""
|
||||||
|
|
||||||
|
def assert_pdftotext_commands_pin_utf8(self, path):
|
||||||
|
offending = [
|
||||||
|
f"{path.name}:{lineno}: {line.strip()}"
|
||||||
|
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
|
||||||
|
if "pdftotext" in line and "-layout" in line and "-enc UTF-8" not in line
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
offending,
|
||||||
|
[],
|
||||||
|
"pdftotext without -enc UTF-8 emits Latin-1 on Xpdf builds, so "
|
||||||
|
"the ATS check reports phantom replacement characters on any "
|
||||||
|
"non-ASCII CV; add -enc UTF-8:\n" + "\n".join(offending),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_apply_extraction_command_pins_utf8(self):
|
||||||
|
self.assert_pdftotext_commands_pin_utf8(APPLY)
|
||||||
|
|
||||||
|
def test_cv_guide_extraction_command_pins_utf8(self):
|
||||||
|
self.assert_pdftotext_commands_pin_utf8(CV_TEMPLATES)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -29,11 +29,19 @@ class LinterRepoFixture(unittest.TestCase):
|
|||||||
shutil.copy(LINTER_SCRIPT, tools / "lint_skills.py")
|
shutil.copy(LINTER_SCRIPT, tools / "lint_skills.py")
|
||||||
# The Python-test CI job does not install PyYAML; the separate lint job
|
# The Python-test CI job does not install PyYAML; the separate lint job
|
||||||
# does. These settings-focused tests only need a valid frontmatter map.
|
# does. These settings-focused tests only need a valid frontmatter map.
|
||||||
|
# The stub parses simple "key: value" lines, enough for the flat
|
||||||
|
# frontmatter these fixtures write, so the checks under test see the
|
||||||
|
# actual file content instead of a canned mapping.
|
||||||
(tools / "yaml.py").write_text(
|
(tools / "yaml.py").write_text(
|
||||||
"class YAMLError(Exception):\n"
|
"class YAMLError(Exception):\n"
|
||||||
" pass\n\n"
|
" pass\n\n"
|
||||||
"def safe_load(_text):\n"
|
"def safe_load(text):\n"
|
||||||
" return {'name': 'example', 'description': 'Example skill'}\n",
|
" result = {}\n"
|
||||||
|
" for line in (text or '').splitlines():\n"
|
||||||
|
" if ':' in line:\n"
|
||||||
|
" key, _, value = line.partition(':')\n"
|
||||||
|
" result[key.strip()] = value.strip()\n"
|
||||||
|
" return result\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -103,5 +111,63 @@ class SettingsShapeTests(LinterRepoFixture):
|
|||||||
self.assertEqual(result.returncode, 1)
|
self.assertEqual(result.returncode, 1)
|
||||||
self.assertIn("expected permissions.allow to be a list", result.stdout)
|
self.assertIn("expected permissions.allow to be a list", result.stdout)
|
||||||
self.assertNotIn("Traceback", result.stderr)
|
self.assertNotIn("Traceback", result.stderr)
|
||||||
|
class SkillAndCommandCheckTests(LinterRepoFixture):
|
||||||
|
"""check_skill()/check_command() are the linter's main job and were
|
||||||
|
previously untested - only check_settings() had coverage, so deleting
|
||||||
|
e.g. the missing-allowed-tools error survived the whole suite (review
|
||||||
|
finding F23, 2026-08-19)."""
|
||||||
|
|
||||||
|
def write_skill(self, frontmatter: str):
|
||||||
|
skill = self.root / ".claude" / "skills" / "example" / "SKILL.md"
|
||||||
|
skill.write_text(frontmatter, encoding="utf-8")
|
||||||
|
|
||||||
|
def test_allowed_tools_referencing_a_missing_file_fails(self):
|
||||||
|
self.write_skill(
|
||||||
|
"---\n"
|
||||||
|
"name: example\n"
|
||||||
|
"description: Example skill\n"
|
||||||
|
"allowed-tools: Bash(bun run .claude/skills/example/DOES_NOT_EXIST.ts *)\n"
|
||||||
|
"---\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = run_linter(self.root)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||||
|
self.assertIn("allowed-tools references a missing file", result.stdout)
|
||||||
|
self.assertIn("DOES_NOT_EXIST.ts", result.stdout)
|
||||||
|
|
||||||
|
def test_allowed_tools_referencing_an_existing_file_passes(self):
|
||||||
|
target = self.root / ".claude" / "skills" / "example" / "cli.ts"
|
||||||
|
target.write_text("// present\n", encoding="utf-8")
|
||||||
|
self.write_skill(
|
||||||
|
"---\n"
|
||||||
|
"name: example\n"
|
||||||
|
"description: Example skill\n"
|
||||||
|
"allowed-tools: Bash(bun run .claude/skills/example/cli.ts *)\n"
|
||||||
|
"---\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = run_linter(self.root)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||||
|
|
||||||
|
def test_frontmatter_missing_description_fails(self):
|
||||||
|
self.write_skill("---\nname: example\ndescription:\n---\n")
|
||||||
|
|
||||||
|
result = run_linter(self.root)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 1)
|
||||||
|
self.assertIn("missing required key 'description'", result.stdout)
|
||||||
|
|
||||||
|
def test_command_without_slash_title_fails(self):
|
||||||
|
command = self.root / ".claude" / "commands" / "setup.md"
|
||||||
|
command.write_text("# setup - missing the slash\n", encoding="utf-8")
|
||||||
|
|
||||||
|
result = run_linter(self.root)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 1)
|
||||||
|
self.assertIn("must start with a '# /<name>' title", result.stdout)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Guards for CI's placeholder-integrity sentinels.
|
||||||
|
|
||||||
|
The job exists to catch personal data committed to the upstream template.
|
||||||
|
That only works when each sentinel sits IN the data /setup replaces: the
|
||||||
|
CV's old sentinel was `[YOUR_NAME]`, whose only occurrences were a header
|
||||||
|
comment and the hyperref pdftitle - /setup's documented edit ("replace
|
||||||
|
placeholder personal data with their actual name, contact info") touches
|
||||||
|
neither, so a fully personalized CV with a real name, address, phone and
|
||||||
|
email passed the check (review finding F28, 2026-08-19; proven
|
||||||
|
empirically). Same weakness for 01-candidate-profile.md's `<!-- SETUP`
|
||||||
|
comment sentinel.
|
||||||
|
|
||||||
|
These tests pin (a) that ci.yml checks data-located sentinels, (b) that
|
||||||
|
the sentinels exist in the pristine files, and (c) that simulating the
|
||||||
|
/setup edit destroys at least one checked sentinel per file - i.e. the
|
||||||
|
guard actually fires on the failure it exists to catch.
|
||||||
|
"""
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
CI = REPO / ".github" / "workflows" / "ci.yml"
|
||||||
|
EXAMPLE_CV = REPO / "cv" / "main_example.tex"
|
||||||
|
PROFILE = REPO / ".claude" / "skills" / "job-application-assistant" / "01-candidate-profile.md"
|
||||||
|
|
||||||
|
# The literal sentinel strings (unescaped) that ci.yml's grep patterns match.
|
||||||
|
CV_SENTINELS = ["\\name{[First]}{[Last]}", "\\email{[your.email@example.com]}"]
|
||||||
|
PROFILE_SENTINEL = "[YOUR_EMAIL]"
|
||||||
|
|
||||||
|
|
||||||
|
def personalize_cv(text: str) -> str:
|
||||||
|
"""Apply /setup Step 3.7's documented edit: replace placeholder personal
|
||||||
|
data with a real name and contact info. Header comments and hyperref
|
||||||
|
metadata are not personal data, so they are deliberately left alone -
|
||||||
|
that is exactly why a comment-located sentinel guards nothing."""
|
||||||
|
return (
|
||||||
|
text.replace("\\name{[First]}{[Last]}", "\\name{Jane}{Doe}")
|
||||||
|
.replace("[Your Address, City, Country]", "Some Street 1, Aarhus, Denmark")
|
||||||
|
.replace("[+XX XXXXXXXXXX]", "+45 12345678")
|
||||||
|
.replace("[your.email@example.com]", "jane.doe@example.org")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCvSentinelsAreDataLocated(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.ci = CI.read_text(encoding="utf-8")
|
||||||
|
self.cv = EXAMPLE_CV.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_ci_checks_the_name_and_email_data_lines(self):
|
||||||
|
self.assertIn(
|
||||||
|
"check cv/main_example.tex '\\\\name{\\[First\\]}{\\[Last\\]}'",
|
||||||
|
self.ci,
|
||||||
|
"ci.yml must assert the sentinel inside the \\name{} data line",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"check cv/main_example.tex '\\\\email{\\[your\\.email@example\\.com\\]}'",
|
||||||
|
self.ci,
|
||||||
|
"ci.yml must assert the sentinel inside the \\email{} data line",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pristine_cv_carries_both_sentinels(self):
|
||||||
|
for sentinel in CV_SENTINELS:
|
||||||
|
self.assertIn(sentinel, self.cv)
|
||||||
|
|
||||||
|
def test_setup_edit_destroys_the_sentinels(self):
|
||||||
|
personalized = personalize_cv(self.cv)
|
||||||
|
self.assertNotEqual(personalized, self.cv, "the simulated /setup edit must change the file")
|
||||||
|
surviving = [s for s in CV_SENTINELS if s in personalized]
|
||||||
|
self.assertEqual(
|
||||||
|
surviving,
|
||||||
|
[],
|
||||||
|
"a sentinel survived the documented /setup personalization - the "
|
||||||
|
f"guard would pass on committed personal data: {surviving}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProfileSentinelIsDataLocated(unittest.TestCase):
|
||||||
|
def test_ci_checks_a_data_placeholder_not_the_header_comment(self):
|
||||||
|
ci = CI.read_text(encoding="utf-8")
|
||||||
|
self.assertIn(
|
||||||
|
"check .claude/skills/job-application-assistant/01-candidate-profile.md '\\[YOUR_EMAIL\\]'",
|
||||||
|
ci,
|
||||||
|
"01's sentinel must sit in the Identity data /setup fills, not in "
|
||||||
|
"a header comment the model may leave untouched",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pristine_profile_carries_the_sentinel(self):
|
||||||
|
self.assertIn(PROFILE_SENTINEL, PROFILE.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -20,6 +20,9 @@ except ImportError:
|
|||||||
REPO = Path(__file__).resolve().parent.parent
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
COMMAND = REPO / ".claude" / "commands" / "rank.md"
|
COMMAND = REPO / ".claude" / "commands" / "rank.md"
|
||||||
SCRAPER_SKILL = REPO / ".claude" / "skills" / "job-scraper" / "SKILL.md"
|
SCRAPER_SKILL = REPO / ".claude" / "skills" / "job-scraper" / "SKILL.md"
|
||||||
|
EVALUATION = (
|
||||||
|
REPO / ".claude" / "skills" / "job-application-assistant" / "04-job-evaluation.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _sections(text: str) -> dict[str, str]:
|
def _sections(text: str) -> dict[str, str]:
|
||||||
@@ -102,6 +105,92 @@ class RankCommandSpec(unittest.TestCase):
|
|||||||
"not only when /rank re-scores it",
|
"not only when /rank re-scores it",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_verdict_is_written_to_location_verdict_not_bare_location(self):
|
||||||
|
"""`location` meant two incompatible things in seen_jobs.json: a place
|
||||||
|
(scraper search output, driving the commute filter) and a PASS/FAIL/FLAG
|
||||||
|
verdict (/rank Step 4), so a ranked entry could overwrite "Aarhus,
|
||||||
|
Denmark" with "PASS" and no reader could tell which meaning a stored
|
||||||
|
value carried (review finding F27B, 2026-08-19)."""
|
||||||
|
text = COMMAND.read_text(encoding="utf-8")
|
||||||
|
self.assertIn('"location_verdict"', text, "Step 2's agent JSON must use location_verdict")
|
||||||
|
self.assertIn(
|
||||||
|
'"location_verdict": "PASS"/"FAIL"/"FLAG"',
|
||||||
|
text,
|
||||||
|
"Step 4 must persist the verdict under location_verdict",
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
'"location":',
|
||||||
|
text,
|
||||||
|
"the PASS/FAIL/FLAG verdict must never be written to the bare "
|
||||||
|
"location key, which the scraper uses for a place",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"legacy",
|
||||||
|
text,
|
||||||
|
"Step 4 must carry a migration rule for entries that stored the "
|
||||||
|
"verdict under the old location key",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_job_scraper_schema_note_enumerates_the_veto_fields(self):
|
||||||
|
"""SKILL.md's "do not drop any of these fields" instruction cannot
|
||||||
|
protect fields it does not name - and it omitted exactly the three
|
||||||
|
rank.md calls as important to persist as the score itself (review
|
||||||
|
finding F27 Part A, 2026-08-19)."""
|
||||||
|
text = SCRAPER_SKILL.read_text(encoding="utf-8")
|
||||||
|
for field in ("location_verdict", "language_gate", "language_note"):
|
||||||
|
self.assertIn(
|
||||||
|
field,
|
||||||
|
text,
|
||||||
|
f"the seen_jobs schema note must enumerate {field} so the "
|
||||||
|
"do-not-drop instruction covers it",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_evaluation_framework_acknowledges_language_gate_tracking(self):
|
||||||
|
"""04-job-evaluation.md is the authoritative file /rank tells its agents
|
||||||
|
to read. Its Language Gate preamble once said the gate result "is not a
|
||||||
|
field /scrape or /rank track" - written before the gate was wired into
|
||||||
|
both consumers, and never updated. An agent reading that learns the
|
||||||
|
opposite of what rank.md itself insists on ("These veto fields are as
|
||||||
|
important to persist as the score itself"). The framework text must name
|
||||||
|
the tracked fields and must not claim they are untracked."""
|
||||||
|
text = EVALUATION.read_text(encoding="utf-8")
|
||||||
|
gate = text.partition("## Language Gate")[2].partition("\n## ")[0]
|
||||||
|
self.assertTrue(gate, "04-job-evaluation.md has no Language Gate section")
|
||||||
|
self.assertIn(
|
||||||
|
"language_gate",
|
||||||
|
gate,
|
||||||
|
"the Language Gate section must name the language_gate field /rank persists",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"language_note",
|
||||||
|
gate,
|
||||||
|
"the Language Gate section must name the language_note field /rank persists",
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"not a field",
|
||||||
|
gate,
|
||||||
|
"stale claim: the gate result IS tracked by /scrape and /rank now",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sweep_parses_stored_deadlines_defensively(self):
|
||||||
|
"""Rule 6's expiry sweep mutates status automatically from stored
|
||||||
|
deadline values, and portals have shipped non-ISO shapes into
|
||||||
|
seen_jobs.json ("ASAP" from jobindex, DD.MM.YYYY from jobbank,
|
||||||
|
free text from jobdanmark's detail fallback). /outcome carries a
|
||||||
|
defensive date-parse rule for mere display; the command that
|
||||||
|
silently changes state needs one at least as much."""
|
||||||
|
text = COMMAND.read_text(encoding="utf-8")
|
||||||
|
self.assertIn(
|
||||||
|
"Parse stored deadlines defensively",
|
||||||
|
text,
|
||||||
|
"rule 6's sweep must state the defensive-parse rule",
|
||||||
|
)
|
||||||
|
self.assertRegex(
|
||||||
|
text,
|
||||||
|
r"not a `YYYY-MM-DD` date[^.]*treated exactly like an absent one",
|
||||||
|
"a non-ISO stored deadline must be handled as absent, not compared or guessed at",
|
||||||
|
)
|
||||||
|
|
||||||
def test_step2_schema_includes_language_gate_fields(self):
|
def test_step2_schema_includes_language_gate_fields(self):
|
||||||
sections = _sections(COMMAND.read_text(encoding="utf-8"))
|
sections = _sections(COMMAND.read_text(encoding="utf-8"))
|
||||||
step2 = sections.get("Step 2: Batch-Fetch and Score", "")
|
step2 = sections.get("Step 2: Batch-Fetch and Score", "")
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Guards for /reset's documents scope.
|
||||||
|
|
||||||
|
/reset ends its documents pass by telling the user "The `documents/`
|
||||||
|
folder is now empty." That statement is only true if every personal-data
|
||||||
|
drop folder is actually covered by both the Step 1 preview and the
|
||||||
|
Step 3 delete block. `documents/postings/` was missing from both while
|
||||||
|
being documented in documents/README.md and protected as personal data
|
||||||
|
by tools/security_guards.py (review finding F26, 2026-08-19), so a reset
|
||||||
|
silently kept the user's hand-pasted job postings.
|
||||||
|
|
||||||
|
The folder list is derived from the repository tree, so adding a new
|
||||||
|
drop folder under documents/ fails this test until /reset covers it.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
RESET = REPO / ".claude" / "commands" / "reset.md"
|
||||||
|
|
||||||
|
|
||||||
|
def tracked_document_subfolders():
|
||||||
|
"""Names of documents/ subfolders tracked in git (ignores local noise)."""
|
||||||
|
out = subprocess.run(
|
||||||
|
["git", "ls-files", "documents/"],
|
||||||
|
cwd=REPO,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout
|
||||||
|
folders = set()
|
||||||
|
for line in out.splitlines():
|
||||||
|
parts = line.split("/")
|
||||||
|
if len(parts) >= 3: # documents/<subfolder>/<file...>
|
||||||
|
folders.add(parts[1])
|
||||||
|
return folders
|
||||||
|
|
||||||
|
|
||||||
|
class TestResetCoversEveryDocumentsSubfolder(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.text = RESET.read_text(encoding="utf-8")
|
||||||
|
self.folders = tracked_document_subfolders()
|
||||||
|
# The tree must actually contain the folders this test is about,
|
||||||
|
# or the assertions below would pass vacuously.
|
||||||
|
self.assertGreaterEqual(len(self.folders), 5, self.folders)
|
||||||
|
|
||||||
|
def test_preview_lists_every_subfolder(self):
|
||||||
|
missing = [
|
||||||
|
f for f in sorted(self.folders) if f"documents/{f}/" not in self.text
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
missing,
|
||||||
|
[],
|
||||||
|
"reset.md's preview never mentions these documents/ subfolders, "
|
||||||
|
f"so the user confirms a deletion list that omits them: {missing}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_delete_block_removes_every_subfolder(self):
|
||||||
|
deleted = set(re.findall(r"rm -r?f documents/(\w+)/", self.text))
|
||||||
|
missing = sorted(self.folders - deleted)
|
||||||
|
self.assertEqual(
|
||||||
|
missing,
|
||||||
|
[],
|
||||||
|
"reset.md's delete block has no rm line for these documents/ "
|
||||||
|
'subfolders, yet the command then claims "The `documents/` '
|
||||||
|
f'folder is now empty.": {missing}',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -52,6 +52,13 @@ class TestPathRules(unittest.TestCase):
|
|||||||
"""Cautious tie-break: Google resolves ties to Allow, we do not."""
|
"""Cautious tie-break: Google resolves ties to Allow, we do not."""
|
||||||
self.assertFalse(allowed("User-agent: *\nDisallow: /a\nAllow: /a\n", "*", "/a"))
|
self.assertFalse(allowed("User-agent: *\nDisallow: /a\nAllow: /a\n", "*", "/a"))
|
||||||
|
|
||||||
|
def test_equal_specificity_tie_goes_to_disallow_when_allow_listed_first(self):
|
||||||
|
"""The only ordering that exercises the tie-break clause: with Allow
|
||||||
|
first, deleting the clause makes the first rule at a given length win
|
||||||
|
and Allow would leak through. The Disallow-first sibling above cannot
|
||||||
|
detect that mutation (review finding F21, 2026-08-19)."""
|
||||||
|
self.assertFalse(allowed("User-agent: *\nAllow: /a\nDisallow: /a\n", "*", "/a"))
|
||||||
|
|
||||||
def test_api_block_and_sibling_path(self):
|
def test_api_block_and_sibling_path(self):
|
||||||
self.assertFalse(allowed(JOBUP, "*", "/api/v1/public/search"))
|
self.assertFalse(allowed(JOBUP, "*", "/api/v1/public/search"))
|
||||||
self.assertTrue(allowed(JOBUP, "*", "/en/jobs/"))
|
self.assertTrue(allowed(JOBUP, "*", "/en/jobs/"))
|
||||||
@@ -130,6 +137,49 @@ class TestSoftTwoHundred(unittest.TestCase):
|
|||||||
self.assertEqual(rc, 1)
|
self.assertEqual(rc, 1)
|
||||||
self.assertIn("not a robots.txt", msg)
|
self.assertIn("not a robots.txt", msg)
|
||||||
|
|
||||||
|
def test_gate_reads_policy_as_browser_when_honest_request_is_refused(self):
|
||||||
|
"""09-web-research.md's Barclays-class recovery: the policy file itself
|
||||||
|
returns 403 to Claude-User and 200 to a browser, and the checker must
|
||||||
|
then read it as a browser and obey it strictly. This is gate()'s UA
|
||||||
|
fallback loop, previously untested despite the doc's coverage claim
|
||||||
|
(review finding F30, 2026-08-19)."""
|
||||||
|
import robots_check
|
||||||
|
|
||||||
|
original = robots_check._fetch
|
||||||
|
|
||||||
|
def waf(url, ua):
|
||||||
|
if ua == robots_check.BROWSER:
|
||||||
|
return ("User-agent: *\nAllow: /\n", 200)
|
||||||
|
return ("<html>403 Forbidden</html>", 403)
|
||||||
|
|
||||||
|
robots_check._fetch = waf
|
||||||
|
try:
|
||||||
|
rc, msg = robots_check.gate("https://waf.example/jobs")
|
||||||
|
finally:
|
||||||
|
robots_check._fetch = original
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
self.assertIn("ALLOWED", msg)
|
||||||
|
|
||||||
|
def test_gate_obeys_a_browser_fetched_policy_strictly(self):
|
||||||
|
"""The fallback must not fail open: a policy readable only as a browser
|
||||||
|
still disallows what it disallows."""
|
||||||
|
import robots_check
|
||||||
|
|
||||||
|
original = robots_check._fetch
|
||||||
|
|
||||||
|
def waf(url, ua):
|
||||||
|
if ua == robots_check.BROWSER:
|
||||||
|
return ("User-agent: *\nDisallow: /jobs\n", 200)
|
||||||
|
return ("<html>403 Forbidden</html>", 403)
|
||||||
|
|
||||||
|
robots_check._fetch = waf
|
||||||
|
try:
|
||||||
|
rc, msg = robots_check.gate("https://waf.example/jobs")
|
||||||
|
finally:
|
||||||
|
robots_check._fetch = original
|
||||||
|
self.assertEqual(rc, 1)
|
||||||
|
self.assertIn("DISALLOWED", msg)
|
||||||
|
|
||||||
def test_a_genuinely_empty_robots_is_still_allow_all(self):
|
def test_a_genuinely_empty_robots_is_still_allow_all(self):
|
||||||
"""RFC 9309: an empty file permits everything. Do not over-correct."""
|
"""RFC 9309: an empty file permits everything. Do not over-correct."""
|
||||||
self.assertTrue(is_robots_body(""))
|
self.assertTrue(is_robots_body(""))
|
||||||
|
|||||||
@@ -78,5 +78,33 @@ class ScrapeProvenanceSpec(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecencyFallback(unittest.TestCase):
|
||||||
|
"""Step 1b.3 says to scope to the last 14 days via the portal's recency
|
||||||
|
flag - but not every portal has one (jobdanmark offers no date filter or
|
||||||
|
sort at all), which left the instruction unsatisfiable there: the agent
|
||||||
|
either silently skipped the scoping or invented a flag, and inventing a
|
||||||
|
flag is exactly what the UNKNOWN_FLAG rejection now errors on (review
|
||||||
|
finding F32, 2026-08-19). Every portal emits a `date` field, so
|
||||||
|
client-side filtering is always available as the fallback."""
|
||||||
|
|
||||||
|
def test_step1b_names_a_client_side_fallback_for_flagless_portals(self):
|
||||||
|
text = SKILL.read_text(encoding="utf-8")
|
||||||
|
self.assertIn(
|
||||||
|
"no recency flag",
|
||||||
|
text,
|
||||||
|
"Step 1b.3 must say what to do when a portal offers no recency flag",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"filter client-side",
|
||||||
|
text,
|
||||||
|
"the fallback is filtering results by their `date` field after the call",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"a sort is not a filter",
|
||||||
|
text,
|
||||||
|
"the instruction must stop presenting --order (a sort) as interchangeable with a filter",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -82,6 +82,26 @@ class UpskillSkillSpec(unittest.TestCase):
|
|||||||
self.assertIn("(100 - fit_rating) / 100", step3)
|
self.assertIn("(100 - fit_rating) / 100", step3)
|
||||||
self.assertIn("(100 - rank_score) / 100", step3)
|
self.assertIn("(100 - rank_score) / 100", step3)
|
||||||
|
|
||||||
|
def test_step3_handles_blank_fit_rating(self):
|
||||||
|
"""/outcome creates tracker rows for applications made outside the
|
||||||
|
workflow, and no rule anywhere fills fit_rating on that path - yet
|
||||||
|
Step 3.3 divides by it. A naive read of blank as 0 yields weight 1.0
|
||||||
|
(the maximum), making the one job the framework knows nothing about
|
||||||
|
dominate the heatmap. The skill already handles missing gaps with
|
||||||
|
skip+count+report; the same pattern must cover fit_rating."""
|
||||||
|
sections = _sections(SKILL.read_text(encoding="utf-8"))
|
||||||
|
step3 = sections.get("Step 3: Pass 1 — Hard Skill Diff", "")
|
||||||
|
self.assertIn(
|
||||||
|
"blank or non-numeric `fit_rating`",
|
||||||
|
step3,
|
||||||
|
"Step 3.3 must state what happens to a row whose fit_rating is blank",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"Never treat a blank as 0",
|
||||||
|
step3,
|
||||||
|
"the blank-as-0 reading (weight 1.0, maximum) is the failure mode and must be forbidden explicitly",
|
||||||
|
)
|
||||||
|
|
||||||
def test_step5_heatmap_shows_gap_provenance(self):
|
def test_step5_heatmap_shows_gap_provenance(self):
|
||||||
sections = _sections(SKILL.read_text(encoding="utf-8"))
|
sections = _sections(SKILL.read_text(encoding="utf-8"))
|
||||||
step5 = sections.get("Step 5: Build Gap Heatmap", "")
|
step5 = sections.get("Step 5: Build Gap Heatmap", "")
|
||||||
|
|||||||
@@ -65,7 +65,13 @@ def parse_numeric_cell(value):
|
|||||||
if not text:
|
if not text:
|
||||||
raise ValueError("not numeric")
|
raise ValueError("not numeric")
|
||||||
if "," in text and "." in text:
|
if "," in text and "." in text:
|
||||||
|
# The separator that appears last is the decimal separator: European
|
||||||
|
# "1.234,56" and US "1,234.56" are both unambiguous here, unlike the
|
||||||
|
# single-separator cases below.
|
||||||
|
if text.rfind(",") > text.rfind("."):
|
||||||
text = text.replace(".", "").replace(",", ".")
|
text = text.replace(".", "").replace(",", ".")
|
||||||
|
else:
|
||||||
|
text = text.replace(",", "")
|
||||||
elif "," in text:
|
elif "," in text:
|
||||||
if re.fullmatch(r"[+-]?\d+,\d{3}", text):
|
if re.fullmatch(r"[+-]?\d+,\d{3}", text):
|
||||||
raise ValueError("ambiguous comma separator")
|
raise ValueError("ambiguous comma separator")
|
||||||
@@ -95,10 +101,18 @@ def header_matches(header, patterns):
|
|||||||
|
|
||||||
|
|
||||||
def strip_type_patterns(header, patterns):
|
def strip_type_patterns(header, patterns):
|
||||||
"""Remove count/index words from a header to derive a category name."""
|
"""Remove count/index words from a header to derive a category name.
|
||||||
|
|
||||||
|
Mirrors ``header_matches``: patterns strip as whole tokens, and any
|
||||||
|
pattern also listed in ``COMPOUND_PATTERNS`` additionally strips as a
|
||||||
|
substring - otherwise a compound header like "Lønindeks alle" keeps the
|
||||||
|
type word in its category name and can never pair with "Antal alle".
|
||||||
|
"""
|
||||||
name = header.lower()
|
name = header.lower()
|
||||||
for p in patterns:
|
for p in patterns:
|
||||||
name = re.sub(rf"(?<![a-zæøåöäü0-9]){re.escape(p)}(?![a-zæøåöäü0-9])", "", name)
|
name = re.sub(rf"(?<![a-zæøåöäü0-9]){re.escape(p)}(?![a-zæøåöäü0-9])", "", name)
|
||||||
|
if p in COMPOUND_PATTERNS:
|
||||||
|
name = name.replace(p, "")
|
||||||
return name.strip(" _-")
|
return name.strip(" _-")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user