commit c66d599d7530ef6708aa107ffd2c3e0ed5af9478 Author: AI Job Search Date: Fri Mar 20 19:53:16 2026 +0100 Initial release: AI-powered job application framework Co-Authored-By: Claude Opus 4.6 diff --git a/.agents/skills/jobbank-search/SKILL.md b/.agents/skills/jobbank-search/SKILL.md new file mode 100644 index 0000000..6550aff --- /dev/null +++ b/.agents/skills/jobbank-search/SKILL.md @@ -0,0 +1,179 @@ +--- +name: jobbank-search +version: 1.0.0 +description: > + Make sure to use this skill whenever the user mentions anything related to job + searching on Akademikernes Jobbank, jobbank.dk, or looking for academic or + highly educated positions in Denmark — even if they don't mention jobbank.dk + explicitly. Also invoke this skill for questions about Danish job listings, + graduate trainee positions, Ph.d. jobs, or finding work in specific industries + or regions in Denmark. Trigger phrases include: + jobbank, akademikernes jobbank, jobs denmark, academic jobs denmark, find job + denmark, highly educated jobs, graduate job denmark, trainee position denmark, + ph.d. position denmark, postdoc denmark, studiejob, fuldtidsjob, deltidsjob, + vikariat, freelance job, praktikplads, job søgning, jobsøgning, søg job, + ledige stillinger, nye jobs, it jobs denmark, engineering jobs denmark, + marketing jobs denmark, finance jobs denmark, healthcare jobs denmark, + remote job denmark, fjernarbejde, job københavn, job aarhus, job odense, + nyuddannede job, job til nyuddannede, international job denmark, + jobbank søgning, find stilling, data scientist job, software developer job, + projektleder stilling, konsulent job, data analyse job. +context: fork +allowed-tools: Bash(bun run skills/jobbank-search/cli/src/cli.ts *) +--- + +# Jobbank Search Skill + +Search live Danish job listings from [Akademikernes Jobbank](https://jobbank.dk) — Denmark's primary job portal for highly educated candidates. No authentication needed. Uses the RSS feed for search (up to 100 results) and JSON-LD parsing for detailed job information. + +## When to use this skill + +Invoke this skill when the user wants to: + +- Search for jobs, positions, or career opportunities in Denmark +- Find academic, graduate, trainee, Ph.d., or postdoc positions +- Look for jobs by keyword, industry, location, education background, or work function +- Find remote or hybrid positions in Denmark +- Get full details for a specific job posting on jobbank.dk +- Check what positions are available at a specific company on jobbank.dk +- Browse jobs suitable for new graduates or people with international backgrounds + +## Commands + +### Search jobs + +```bash +bun run skills/jobbank-search/cli/src/cli.ts search [flags] +``` + +Key flags: +- `--key ` — keyword search (title, company, keyword) +- `--exclude ` — exclude keywords from results +- `--type ` — job type: `3`=Fuldtidsjob, `6`=Graduate/trainee, `13`=Deltidsjob, `8`=Vikariat, `12`=Ph.d. & Postdoc, `11`=Freelance, `9`=Praktikplads, `4`=Studiejob (repeatable) +- `--location ` — region: `2`=Storkøbenhavn, `8`=Østjylland (Aarhus), `7`=Midtjylland, `6`=Nordjylland, `13`=Fyn (repeatable) +- `--work-area ` — function: `31`=IT-Software, `43`=Data & Analyse, `26`=Ledelse, `29`=Marketing (repeatable) +- `--industry ` — sector: `10331`=IT & Tele, `10442`=Forskning & Uddannelse, `10358`=Finans (repeatable) +- `--education ` — education field: `24`=IT, `21`=Økonomi & Revision, `34`=Samfundsvidenskab (repeatable) +- `--remote ` — `helt` (fully remote) or `delvist` (partially remote) +- `--suitable-for ` — `2`=Nyuddannede, `4`=International baggrund, `5`=Erfarne +- `--company ` — filter by company ID +- `--since ` — jobs posted on or after this date +- `--limit ` — cap results returned by CLI +- `--format json|table|plain` + +> **RSS limitation:** The RSS feed returns max 100 items per request. No pagination is available via RSS. `meta.total` shows the true count; `results` is capped at 100. + +### Full job detail + +```bash +bun run skills/jobbank-search/cli/src/cli.ts detail [--format json|plain] +``` + +`id` is the numeric job ID from `search` results. Fetches the job page and parses the embedded Schema.org `JobPosting` JSON-LD for structured data. + +--- + +## How to use effectively + +**Start with `search`, then use `detail` for full description.** + +1. Use `search` with `--key` and/or filters to find matching jobs with IDs +2. Call `detail ` to get the full HTML job description, exact deadline, and company details + +**Use repeatable flags for multi-value filters.** Most filter flags can be repeated to match any of the values: + +```bash +# IT or Finance industry, Copenhagen or Aarhus +bun run skills/jobbank-search/cli/src/cli.ts search \ + --industry 10331 --industry 10358 \ + --location 2 --location 8 +``` + +**Filter codes are documented in the README** at `skills/jobbank-search/cli/README.md`. + +--- + +## Usage examples + +### Find data scientist jobs in Copenhagen + +```bash +bun run skills/jobbank-search/cli/src/cli.ts search \ + --key "data scientist" \ + --location 2 \ + --format table +``` + +### Graduate trainee positions for new graduates + +```bash +bun run skills/jobbank-search/cli/src/cli.ts search \ + --type 6 \ + --suitable-for 2 \ + --format table +``` + +### Remote IT software jobs + +```bash +bun run skills/jobbank-search/cli/src/cli.ts search \ + --work-area 31 \ + --remote helt \ + --format table +``` + +### Ph.d. and postdoc positions in research + +```bash +bun run skills/jobbank-search/cli/src/cli.ts search \ + --type 12 \ + --industry 10442 \ + --format table +``` + +### Recent full-time jobs posted since March 1 + +```bash +bun run skills/jobbank-search/cli/src/cli.ts search \ + --type 3 \ + --since 2026-03-01 \ + --format table +``` + +### Full details for a specific job + +```bash +bun run skills/jobbank-search/cli/src/cli.ts detail 1234567 --format plain +``` + +### IT jobs in Aarhus or Copenhagen + +```bash +bun run skills/jobbank-search/cli/src/cli.ts search \ + --key developer \ + --location 2 --location 8 \ + --work-area 31 \ + --format table +``` + +--- + +## Output formats + +| Format | Best for | +|--------|----------| +| `json` | Default — programmatic use, passing IDs to `detail` | +| `table` | Quick human-readable list of results | +| `plain` | Single-job detail views (`detail` command) | + +All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`. + +--- + +## Notes + +- Data is from the public jobbank.dk RSS feed and HTML pages — no credentials required. +- RSS feed returns max 100 results per query. For higher counts, `meta.total` shows the true total. +- The `detail` command fetches a full job page and extracts the JSON-LD structured data block. +- `location` values are region codes (e.g. `2` = Storkøbenhavn), not city names. +- All filter codes are documented in `skills/jobbank-search/cli/README.md`. diff --git a/.agents/skills/jobbank-search/cli/README.md b/.agents/skills/jobbank-search/cli/README.md new file mode 100644 index 0000000..efa22ab --- /dev/null +++ b/.agents/skills/jobbank-search/cli/README.md @@ -0,0 +1,396 @@ +# jobbank-cli + +CLI for [Akademikernes Jobbank](https://jobbank.dk) — Denmark's job portal for highly educated candidates. + +**Data sources:** +- **RSS feed**: `https://jobbank.dk/job/rss?{params}` — 100 items max, all search filters work +- **Job detail**: `https://jobbank.dk/job/{id}/` — JSON-LD (`Schema.org JobPosting`) embedded in page HTML + +**Authentication**: None required. A browser User-Agent header is required to bypass bot protection. +**Format**: RSS XML (search), HTML with embedded JSON-LD (detail). + +--- + +## Installation + +```bash +cd skills/jobbank-search/cli +bun install +``` + +--- + +## Commands + +| Command | Description | +|---------|-------------| +| `search` | Search job listings via RSS feed | +| `detail` | Full detail for a single job posting | + +All commands accept `--format json|table|plain` (default: `json`). +All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`. + +--- + +## Filter Reference Tables + +### Job Types (`--type` / `cvtype`) + +| Code | Label | +|------|-------| +| 3 | Fuldtidsjob | +| 6 | Graduate/trainee | +| 13 | Deltidsjob | +| 8 | Vikariat | +| 12 | Ph.d. & Postdoc | +| 11 | Freelance | +| 15 | Iværksætterprojekt | +| 14 | Event | +| 9 | Praktikplads | +| 4 | Studiejob | +| 5 | Studieprojekt/speciale | + +### Location / Region (`--location` / `amt`) + +| Code | Label | +|------|-------| +| 2 | Storkøbenhavn | +| 3 | Nordsjælland | +| 14 | Østsjælland | +| 4 | Vestsjælland | +| 5 | Sydsjælland & Øer | +| 13 | Fyn | +| 12 | Sønderjylland | +| 11 | Sydvestjylland | +| 9 | Vestjylland | +| 10 | Sydøstjylland | +| 7 | Midtjylland | +| 8 | Østjylland (Aarhus) | +| 6 | Nordjylland | +| 20 | Bornholm | +| 21 | Øresundsregionen | +| 22 | Grønland & Færøerne | +| 23 | Udlandet (Sverige) | +| 24 | Udlandet (Norge) | +| 19 | Udlandet (øvrige) | + +### Work Area / Function (`--work-area` / `erf`) + +| Code | Label | +|------|-------| +| 20 | Administration | +| 38 | Arkitektur & Design | +| 22 | Bank & Forsikring | +| 43 | Data & Analyse | +| 47 | Eksport | +| 41 | Forskning & Udvikling | +| 28 | Human Resources | +| 49 | Indkøb | +| 34 | Internet & Multimedia | +| 32 | IT - Hardware | +| 33 | IT - Netværk & Telekomm. | +| 31 | IT - Software | +| 24 | Jura | +| 35 | Kommunikation, Media & SoMe | +| 46 | Konstruktion & Beregning | +| 37 | Kunst & Kultur | +| 26 | Ledelse & Planlægning | +| 29 | Marketing & Reklame | +| 40 | Medicinal & Sundhed | +| 45 | Naturvidenskab | +| 23 | Organisation & Forening | +| 52 | Politik & Samfund | +| 44 | Produktion | +| 27 | Projektledelse | +| 50 | Rådgivning & Support | +| 30 | Salg | +| 39 | Socialvæsen | +| 42 | Teknik | +| 25 | Topledelse | +| 48 | Transport & Logistik | +| 36 | Undervisning | +| 21 | Økonomi & Forvaltning | + +### Education Field (`--education` / `udd`) + +| Code | Label | +|------|-------| +| 20 | Administration | +| 43 | Anlæg, Byggeri & Konstruktion | +| 29 | Arkitektur, Kunst & Design | +| 47 | Elektro & Telekommunikation | +| 32 | Fødevarer & Veterinær | +| 38 | Human Resources | +| 28 | Humaniora | +| 24 | IT | +| 23 | Jura | +| 44 | Kemi, Biotek & Materialer | +| 45 | Klima, Miljø & Energi | +| 37 | Landbrug & Natur | +| 22 | Marketing & Business | +| 48 | Maskin & Design | +| 46 | Matematik, Fysik & Nano | +| 31 | Medicinal & Sundhed | +| 30 | Naturvidenskab | +| 41 | Organisation & Ledelse | +| 35 | Produktion, Logistik & Transport | +| 34 | Samfundsvidenskab | +| 25 | Sprog, Media & Kommunikation | +| 33 | Teknik & Teknologi | +| 26 | Undervisning & Pædagogik | +| 21 | Økonomi & Revision | + +### Industry (`--industry` / `branche`) + +| Code | Label | +|------|-------| +| 10359 | Advokat & Revision | +| 11669 | Byggeri & Anlæg | +| 11634 | Elektronik & Maskin | +| 16791 | Fagforeninger, A-kasser & Pensionskasser | +| 10358 | Finans, Forsikring & Pension | +| 10442 | Forskning & Uddannelse | +| 15407 | Fødevarer & Dagligvarer | +| 10364 | Handel & Service | +| 10331 | IT & Tele | +| 17209 | Klima, Energi & Forsyning | +| 16826 | Kommuner | +| 10341 | Kultur, Medier & Underholdning | +| 10333 | Medicinal, Biotek & Kemi | +| 10363 | Papir, Møbel & Materialer | +| 11626 | Regioner, Sundhed- & Socialvæsen | +| 15586 | Rådgivning & Konsulentservice | +| 10362 | Stat, Politik & Samfund | +| 10440 | Transport | +| 12450 | Vikar & Rekruttering | + +### Remote Work (`--remote` / `fjernarbejde`) + +| Value | Label | +|-------|-------| +| `helt` | Fully remote | +| `delvist` | Partially remote | + +### Suitable For (`--suitable-for` / `andet`) + +| Code | Label | +|------|-------| +| 2 | Nyuddannede | +| 4 | International baggrund | +| 5 | Erfarne | + +--- + +## `search` — Search job listings + +**Endpoint**: `GET https://jobbank.dk/job/rss?{params}` + +```bash +bun run src/cli.ts search [flags] +``` + +### Flags + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--key` | string | — | Keyword search (title, company, keyword) | +| `--exclude` | string | — | Exclude keywords (`antikey`) | +| `--type` | number | — | Job type code (`cvtype`). Repeatable for multiple: `--type 3 --type 6` | +| `--education` | number | — | Education field code (`udd`). Repeatable. | +| `--location` | number | — | Region code (`amt`). Repeatable. | +| `--work-area` | number | — | Work area / function code (`erf`). Repeatable. | +| `--industry` | number | — | Industry code (`branche`). Repeatable. | +| `--suitable-for` | number | — | Suitable-for code (`andet`). Repeatable. | +| `--company` | number | — | Company ID (`virk`) | +| `--remote` | string | — | Remote work: `helt` or `delvist` | +| `--since` | string | — | Posted on or after date, format `YYYY-MM-DD` (`oprettet`) | +| `--limit` | number | — | Cap total results returned by CLI (client-side) | +| `--format` | string | `json` | Output format: `json`, `table`, `plain` | + +> **Important limitation:** The RSS feed returns a maximum of **100 items** per request. There is no pagination via RSS — the `page=` parameter has no effect on the RSS endpoint. If your query matches more than 100 jobs, only the first 100 are returned. The `meta.total` field reflects the true total count (fetched separately from the HTML search page title), while `results` is capped at 100. + +> **Multi-value flags**: Flags marked "Repeatable" map to params that accept multiple values in the API (repeated query params). Pass them multiple times: `--type 3 --type 6` sends `cvtype=3&cvtype=6`. + +### RSS Parsing + +The CLI fetches the RSS feed and parses each `` as follows: + +- **id**: extracted from the URL path — `/job/{id}/{company-slug}/{title-slug}` — the first numeric segment after `/job/` +- **title**: from `` (CDATA) +- **description**, **company**, **location**, **jobType**, **deadline**: parsed from the `<description>` field, which has the format: `"JobType hos Company, Location (Ansøgningsfrist: DD.MM.YYYY)"` or `"JobType hos Company, Location (Ansøgningsfrist: løbende)"` +- **url**: from `<link>` +- **posted**: from `<pubDate>`, normalized to ISO 8601 + +### Example + +```bash +bun run src/cli.ts search --key python --location 2 --type 3 --limit 10 +bun run src/cli.ts search --key "data scientist" --remote helt +bun run src/cli.ts search --industry 10331 --work-area 31 --format table +bun run src/cli.ts search --education 24 --suitable-for 2 --since 2026-03-01 +``` + +### Response shape + +```json +{ + "meta": { + "total": 457 + }, + "results": [ + { + "id": "1234567", + "title": "Senior Data Scientist", + "company": "Novo Nordisk", + "location": "Bagsværd", + "jobType": "Fuldtidsjob", + "description": "Fuldtidsjob hos Novo Nordisk, Bagsværd (Ansøgningsfrist: 12.04.2026)", + "url": "https://jobbank.dk/job/1234567/novo-nordisk/senior-data-scientist", + "posted": "2026-03-02T00:00:00+01:00", + "deadline": "2026-04-12" + } + ] +} +``` + +#### Field details + +| Field | Type | Notes | +|-------|------|-------| +| `id` | string | Numeric job ID extracted from URL | +| `title` | string | Job title | +| `company` | string | Company name, parsed from description | +| `location` | string | Location string, parsed from description | +| `jobType` | string | Employment type (e.g. "Fuldtidsjob", "Graduate/trainee"), parsed from description | +| `description` | string | Raw RSS description field (single-line summary) | +| `url` | string | Full URL to job posting | +| `posted` | string | Publication date in ISO 8601 | +| `deadline` | string \| null | Application deadline as `DD.MM.YYYY` string, 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`. + +--- + +## `detail` — Full job detail + +**Endpoint**: `GET https://jobbank.dk/job/{id}/` + +```bash +bun run src/cli.ts detail <id> [--format json|plain] +``` + +The `id` is the numeric job ID from `search` results (the `id` field). The short URL `https://jobbank.dk/job/{id}/` redirects to the full slug URL and returns HTTP 200. + +The CLI fetches the HTML page and extracts the `<script type="application/ld+json">` block containing a Schema.org `JobPosting` object. + +### Example + +```bash +bun run src/cli.ts detail 1234567 +bun run src/cli.ts detail 1234567 --format plain +``` + +### Response shape + +```json +{ + "id": "1234567", + "url": "https://jobbank.dk/job/1234567/", + "title": "Senior Data Scientist", + "description": "<p>Full HTML description of the role...</p>", + "datePosted": "2026-03-02", + "deadline": "2026-04-12", + "employmentType": ["FULL_TIME"], + "company": { + "name": "Novo Nordisk", + "logo": "https://jobbank.dk/images/dynamic/company/logo/12345/" + }, + "location": { + "streetAddress": "", + "city": "Bagsværd", + "postalCode": "", + "country": "DK" + } +} +``` + +#### Field details + +| Field | Type | Notes | +|-------|------|-------| +| `id` | string | Numeric job ID (from `identifier.value` in JSON-LD) | +| `url` | string | Canonical URL of the job posting | +| `title` | string | Job title | +| `description` | string | Full HTML job description body | +| `datePosted` | string | Publication date in ISO 8601 format (`YYYY-MM-DD`) | +| `deadline` | string \| null | Application deadline (`validThrough` in JSON-LD), ISO 8601, or `null` if absent | +| `employmentType` | string[] | Schema.org employment type values, e.g. `["FULL_TIME"]` | +| `company.name` | string | Hiring organization name | +| `company.logo` | string \| null | URL to company logo, or `null` if absent | +| `location.streetAddress` | string | Street address (may be empty) | +| `location.city` | string | City (may be empty for international jobs) | +| `location.postalCode` | string | Postal code (may be empty) | +| `location.country` | string | Country code (may be empty) | + +> `location` fields may be empty strings for international jobs or postings that do not specify a physical location. + +--- + +## Error handling + +All errors are written to **stderr** in JSON format and exit with code `1`: + +```json +{ "error": "Job not found", "code": "NOT_FOUND" } +{ "error": "Failed to fetch RSS feed: 403 Forbidden", "code": "API_ERROR" } +{ "error": "No JSON-LD found on job page", "code": "PARSE_ERROR" } +{ "error": "--key or at least one filter is required", "code": "MISSING_REQUIRED" } +``` + +--- + +## Implementation notes + +### User-Agent + +All HTTP requests must include a browser User-Agent header. Without it, Jobbank routes traffic through a bot protection layer that returns invalid responses: + +``` +Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 +``` + +### RSS description parsing + +The RSS `<description>` field follows this pattern: + +``` +Fuldtidsjob, Graduate/trainee hos Novo Nordisk, Bagsværd (Ansøgningsfrist: 12.04.2026) +Fuldtidsjob hos DTU, Lyngby (Ansøgningsfrist: løbende) +``` + +Parse strategy: +1. Split on ` hos ` — left side is job type(s), right side is `Company, Location (Ansøgningsfrist: Deadline)` +2. From the right side, extract the parenthetical `(Ansøgningsfrist: ...)` for deadline +3. Remaining text is `Company, Location` — split on first `, ` to separate company from location + +### JSON-LD extraction + +On detail pages, find `<script type="application/ld+json">` containing `"@type": "JobPosting"` and parse the JSON. Map fields: + +- `identifier.value` → `id` +- `url` → `url` +- `title` → `title` +- `description` → `description` (HTML) +- `datePosted` → `datePosted` +- `validThrough` → `deadline` (may be absent → `null`) +- `employmentType` → `employmentType` (array) +- `hiringOrganization.name` → `company.name` +- `hiringOrganization.logo` → `company.logo` +- `jobLocation.address.streetAddress` → `location.streetAddress` +- `jobLocation.address.addressLocality` → `location.city` +- `jobLocation.address.postalCode` → `location.postalCode` +- `jobLocation.address.addressCountry` → `location.country` + +### Rate limiting + +No explicit rate limits are enforced, but Cloudflare is present. Add a 300–500ms delay between sequential requests (e.g. when fetching total count from HTML in addition to the RSS feed). The `search` command makes at most 2 requests (RSS + HTML for total count). diff --git a/.agents/skills/jobbank-search/cli/package.json b/.agents/skills/jobbank-search/cli/package.json new file mode 100644 index 0000000..d9332de --- /dev/null +++ b/.agents/skills/jobbank-search/cli/package.json @@ -0,0 +1,25 @@ +{ + "name": "jobbank-cli", + "version": "1.0.0", + "description": "CLI for Akademikernes Jobbank (jobbank.dk) — job search for highly educated candidates", + "type": "module", + "main": "src/cli.ts", + "bin": { + "jobbank": "src/cli.ts" + }, + "scripts": { + "start": "bun run src/cli.ts", + "test": "bun test --timeout 30000", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@bunli/core": "latest", + "@bunli/utils": "latest", + "node-html-parser": "^6.1.13", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.4.0" + } +} diff --git a/.agents/skills/jobbank-search/cli/src/cli.ts b/.agents/skills/jobbank-search/cli/src/cli.ts new file mode 100644 index 0000000..936b0f2 --- /dev/null +++ b/.agents/skills/jobbank-search/cli/src/cli.ts @@ -0,0 +1,14 @@ +import { createCLI } from "@bunli/core" +import { search } from "./commands/search.js" +import { detail } from "./commands/detail.js" + +const cli = await createCLI({ + name: "jobbank-cli", + version: "1.0.0", + description: "CLI for Akademikernes Jobbank (jobbank.dk) — job search for highly educated candidates", +}) + +cli.command(search) +cli.command(detail) + +await cli.run() diff --git a/.agents/skills/jobbank-search/cli/src/helpers.ts b/.agents/skills/jobbank-search/cli/src/helpers.ts new file mode 100644 index 0000000..c65dcb1 --- /dev/null +++ b/.agents/skills/jobbank-search/cli/src/helpers.ts @@ -0,0 +1,154 @@ +export const BASE_URL = "https://jobbank.dk" + +export const USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + +export function writeError(error: string, code: string): void { + process.stderr.write(JSON.stringify({ error, code }) + "\n") +} + +export async function fetchWithUA(url: string): Promise<Response> { + const maxRetries = 6 + let delay = 500 + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const response = await fetch(url, { + headers: { "User-Agent": USER_AGENT }, + }) + if (response.status === 429 || response.status >= 500) { + if (attempt === maxRetries) { + throw new Error(`Request failed: ${response.status} ${response.statusText}`) + } + const jitter = Math.floor(Math.random() * 500) + await new Promise((resolve) => setTimeout(resolve, delay + jitter)) + delay = Math.min(delay * 2, 5000) + continue + } + return response + } + throw new Error("Request failed after max retries") +} + +export interface RssItem { + title: string + description: string + link: string + pubDate: string +} + +function extractCdata(xml: string, tag: string): string { + // Try CDATA first + const cdataRe = new RegExp(`<${tag}><\\!\\[CDATA\\[(.*?)\\]\\]><\\/${tag}>`, "s") + const cdataMatch = xml.match(cdataRe) + if (cdataMatch) return cdataMatch[1].trim() + // Plain content + const plainRe = new RegExp(`<${tag}>(.*?)<\\/${tag}>`, "s") + const plainMatch = xml.match(plainRe) + return plainMatch ? plainMatch[1].trim() : "" +} + +function extractLink(xml: string): string { + // <link> in RSS can conflict with atom namespace — extract text node after <link> + // Try CDATA variant first + const cdataMatch = xml.match(/<link><!\[CDATA\[(.*?)\]\]><\/link>/s) + if (cdataMatch) return cdataMatch[1].trim() + // Plain link + const plainMatch = xml.match(/<link>(.*?)<\/link>/s) + if (plainMatch) return plainMatch[1].trim() + // Some RSS feeds put the URL as text after <link> without a closing tag (self-closing style) + // Try matching href in atom:link + return "" +} + +function parseRssItems(xml: string): RssItem[] { + const items: RssItem[] = [] + // Split on <item> boundaries + const itemMatches = xml.matchAll(/<item>([\s\S]*?)<\/item>/g) + for (const match of itemMatches) { + const itemXml = match[1] + const title = extractCdata(itemXml, "title") + const description = extractCdata(itemXml, "description") + const link = extractLink(itemXml) + const pubDate = extractCdata(itemXml, "pubDate") || itemXml.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() || "" + items.push({ title, description, link, pubDate }) + } + return items +} + +export async function rssFetch(params: Record<string, string | string[]>): Promise<RssItem[]> { + const searchParams = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) { + for (const v of value) { + searchParams.append(key, v) + } + } else { + searchParams.append(key, value) + } + } + const url = `${BASE_URL}/job/rss?${searchParams.toString()}` + const response = await fetchWithUA(url) + if (!response.ok) { + throw new Error(`Failed to fetch RSS feed: ${response.status} ${response.statusText}`) + } + const xml = await response.text() + return parseRssItems(xml) +} + +export interface ParsedDescription { + jobType: string + company: string + location: string + deadline: string | null +} + +export function parseRssDescription(desc: string): ParsedDescription { + // Format: "JobType hos Company, Location (Ansøgningsfrist: DD.MM.YYYY)" + // or: "JobType hos Company, Location (Ansøgningsfrist: løbende)" + // or multiple types: "Fuldtidsjob, Graduate/trainee hos Company, Location (Ansøgningsfrist: ...)" + + let jobType = "" + let company = "" + let location = "" + let deadline: string | null = null + + const hosIdx = desc.indexOf(" hos ") + if (hosIdx === -1) { + // Can't parse — return desc as company + return { jobType: "", company: desc, location: "", deadline: null } + } + + jobType = desc.substring(0, hosIdx).trim() + let rest = desc.substring(hosIdx + 5) // skip " hos " + + // Extract deadline from parenthetical at the end + const deadlineMatch = rest.match(/\(Ans[øo]gningsfrist:\s*(.*?)\)\s*$/) + if (deadlineMatch) { + const deadlineStr = deadlineMatch[1].trim() + if (deadlineStr.toLowerCase() === "løbende" || deadlineStr.toLowerCase() === "lobende") { + deadline = null + } else { + deadline = deadlineStr + } + // Remove the deadline portion from rest + rest = rest.substring(0, deadlineMatch.index).trim() + } + + // rest is now "Company, Location" + // Split on first ", " to get company and location + const firstComma = rest.indexOf(", ") + if (firstComma !== -1) { + company = rest.substring(0, firstComma).trim() + location = rest.substring(firstComma + 2).trim() + } else { + company = rest.trim() + location = "" + } + + return { jobType, company, location, deadline } +} + +export function extractJobIdFromUrl(url: string): string { + // URL format: https://jobbank.dk/job/{id}/{company-slug}/{title-slug} + const match = url.match(/\/job\/(\d+)\//) + return match ? match[1] : "" +} diff --git a/.agents/skills/jobbank-search/cli/tests/helpers.ts b/.agents/skills/jobbank-search/cli/tests/helpers.ts new file mode 100644 index 0000000..75b82b9 --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tests/helpers.ts @@ -0,0 +1,39 @@ +import { join } from "path"; + +const CLI_PATH = join(import.meta.dir, "../src/cli.ts"); + +export interface CLIResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export async function runCLI(args: string[]): Promise<CLIResult> { + const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }; +} + +export function parseJSON<T = unknown>(result: CLIResult): T { + if (result.exitCode !== 0) { + throw new Error( + `CLI exited with code ${result.exitCode}. stderr: ${result.stderr}` + ); + } + try { + return JSON.parse(result.stdout) as T; + } catch { + throw new Error( + `Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}` + ); + } +} diff --git a/.agents/skills/jobbank-search/url-reference.md b/.agents/skills/jobbank-search/url-reference.md new file mode 100644 index 0000000..8f5cd6e --- /dev/null +++ b/.agents/skills/jobbank-search/url-reference.md @@ -0,0 +1,327 @@ +# Jobbank.dk URL Reference + +Complete reference for constructing search URLs on Akademikernes Jobbank. + +## Base URL + +``` +https://jobbank.dk/job/ +``` + +## Full URL Pattern + +``` +https://jobbank.dk/job/?key={search}&antikey={exclude}&cvtype={type_id}&udd={edu_id}&amt={location_id}&erf={workarea_id}&branche={industry_id}&andet={suitability_id}&virk={company}&fjernarbejde={remote}&oprettet={YYYY-MM-DD}&page={num} +``` + +**Multiple values:** Repeat the parameter name +``` +cvtype=3&cvtype=13&amt=2&amt=3 +``` + +## Query Parameters + +| Parameter | Description | Format | +|-----------|-------------|--------| +| `key` | Search keywords | URL-encoded string (use `+` for spaces) | +| `antikey` | Exclude keywords | URL-encoded string | +| `cvtype` | Job type ID | Numeric ID (see table below) | +| `udd` | Education area ID | Numeric ID (see table below) | +| `amt` | Location/region ID | Numeric ID (see table below) | +| `erf` | Work area ID | Numeric ID (see table below) | +| `branche` | Industry ID | Numeric ID (see table below) | +| `andet` | Suitability ID | Numeric ID (see table below) | +| `virk` | Company name | URL-encoded string | +| `fjernarbejde` | Remote work | `helt` (fully) or `delvist` (partially) | +| `oprettet` | Posted since date | `YYYY-MM-DD` format | +| `page` | Page number | Integer (1-indexed) | + +## Filter Tables + +### Job Type (cvtype) + +| Label | ID | +|-------|-----| +| Fuldtidsjob (Full-time) | 3 | +| Graduate/trainee | 6 | +| Deltidsjob (Part-time) | 13 | +| Vikariat (Temporary) | 8 | +| Ph.d. & Postdoc | 12 | +| Freelance | 11 | +| Iværksætterprojekt (Entrepreneurship) | 15 | +| Event | 14 | +| Praktikplads (Internship) | 9 | +| Studiejob (Student job) | 4 | +| Studieprojekt/speciale (Study project/thesis) | 5 | + +### Education Area (udd) + +| Label | ID | +|-------|-----| +| Administration | 20 | +| Anlæg/Byggeri/Konstruktion (Construction) | 43 | +| Arkitektur/Kunst/Design (Architecture/Art/Design) | 29 | +| Elektro/Telekommunikation (Electronics/Telecom) | 47 | +| Fødevarer/Veterinær (Food/Veterinary) | 32 | +| Human Resources | 38 | +| Humaniora (Humanities) | 28 | +| IT | 24 | +| Jura (Law) | 23 | +| Kemi/Biotek/Materialer (Chemistry/Biotech/Materials) | 44 | +| Klima/Miljø/Energi (Climate/Environment/Energy) | 45 | +| Landbrug/Natur (Agriculture/Nature) | 37 | +| Marketing/Business | 22 | +| Maskin/Design (Mechanical/Design) | 48 | +| Matematik/Fysik/Nano (Math/Physics/Nano) | 46 | +| Medicinal/Sundhed (Medicine/Health) | 31 | +| Naturvidenskab (Natural Sciences) | 30 | +| Organisation/Ledelse (Organization/Management) | 41 | +| Produktion/Logistik/Transport (Production/Logistics/Transport) | 35 | +| Samfundsvidenskab (Social Sciences) | 34 | +| Sprog/Media/Kommunikation (Language/Media/Communication) | 25 | +| Teknik/Teknologi (Engineering/Technology) | 33 | +| Undervisning/Pædagogik (Education/Pedagogy) | 26 | +| Økonomi/Revision (Economics/Accounting) | 21 | + +### Location/Region (amt) + +| Label | ID | +|-------|-----| +| Storkøbenhavn (Greater Copenhagen) | 2 | +| Nordsjælland (North Zealand) | 3 | +| Østsjælland (East Zealand) | 14 | +| Vestsjælland (West Zealand) | 4 | +| Sydsjælland & Øer (South Zealand & Islands) | 5 | +| Fyn (Funen) | 13 | +| Sønderjylland (South Jutland) | 12 | +| Sydvestjylland (Esbjerg) | 11 | +| Vestjylland (West Jutland) | 9 | +| Sydøstjylland (Southeast Jutland) | 10 | +| Midtjylland (Central Jutland) | 7 | +| Østjylland (Aarhus) | 8 | +| Nordjylland (North Jutland) | 6 | +| Bornholm | 20 | +| Øresundsregionen (Øresund Region) | 21 | +| Grønland & Færøerne (Greenland & Faroe Islands) | 22 | +| Udlandet - Sverige (Abroad - Sweden) | 23 | +| Udlandet - Norge (Abroad - Norway) | 24 | +| Udlandet - øvrige (Abroad - other) | 19 | + +### Work Area (erf) + +| Label | ID | +|-------|-----| +| Administration | 20 | +| Arkitektur/Design (Architecture/Design) | 38 | +| Bank/Forsikring (Banking/Insurance) | 22 | +| Data/Analyse (Data/Analysis) | 43 | +| Eksport (Export) | 47 | +| Forskning/Udvikling (Research/Development) | 41 | +| Human Resources | 28 | +| Indkøb (Procurement) | 49 | +| Internet/Multimedia | 34 | +| IT-Hardware | 32 | +| IT-Netværk/Telekomm. (IT-Network/Telecom) | 33 | +| IT-Software | 31 | +| Jura (Law) | 24 | +| Kommunikation/Media/SoMe (Communication/Media/Social Media) | 35 | +| Konstruktion/Beregning (Construction/Calculation) | 46 | +| Kunst/Kultur (Art/Culture) | 37 | +| Ledelse/Planlægning (Management/Planning) | 26 | +| Marketing/Reklame (Marketing/Advertising) | 29 | +| Medicinal/Sundhed (Medicine/Health) | 40 | +| Naturvidenskab (Natural Sciences) | 45 | +| Organisation/Forening (Organization/Association) | 23 | +| Politik/Samfund (Politics/Society) | 52 | +| Produktion (Production) | 44 | +| Projektledelse (Project Management) | 27 | +| Rådgivning/Support (Consulting/Support) | 50 | +| Salg (Sales) | 30 | +| Socialvæsen (Social Services) | 39 | +| Teknik (Engineering) | 42 | +| Topledelse (Executive Management) | 25 | +| Transport/Logistik (Transport/Logistics) | 48 | +| Undervisning (Education) | 36 | +| Økonomi/Forvaltning (Finance/Administration) | 21 | + +### Industry (branche) + +| Label | ID | +|-------|-----| +| Advokat/Revision (Law/Accounting) | 10359 | +| Byggeri/Anlæg (Construction) | 11669 | +| Elektronik/Maskin (Electronics/Machinery) | 11634 | +| Fagforeninger/A-kasser/Pensionskasser (Unions/Unemployment funds/Pension funds) | 16791 | +| Finans/Forsikring/Pension (Finance/Insurance/Pension) | 10358 | +| Forskning/Uddannelse (Research/Education) | 10442 | +| Fødevarer/Dagligvarer (Food/Groceries) | 15407 | +| Handel/Service (Trade/Service) | 10364 | +| IT/Tele | 10331 | +| Klima/Energi/Forsyning (Climate/Energy/Utilities) | 17209 | +| Kommuner (Municipalities) | 16826 | +| Kultur/Medier/Underholdning (Culture/Media/Entertainment) | 10341 | +| Medicinal/Biotek/Kemi (Pharmaceuticals/Biotech/Chemistry) | 10333 | +| Papir/Møbel/Materialer (Paper/Furniture/Materials) | 10363 | +| Regioner/Sundhed/Socialvæsen (Regions/Health/Social services) | 11626 | +| Rådgivning/Konsulentservice (Consulting services) | 15586 | +| Stat/Politik/Samfund (Government/Politics/Society) | 10362 | +| Transport | 10440 | +| Vikar/Rekruttering (Temp/Recruitment) | 12450 | + +### Suitability (andet) + +| Label | ID | +|-------|-----| +| Nyuddannede (New graduates) | 2 | +| Personer med international baggrund (People with international background) | 4 | +| Erfarne (Experienced) | 5 | + +### Remote Work (fjernarbejde) + +| Label | Value | +|-------|-------| +| Helt hjemmearbejde (Fully remote) | `helt` | +| Delvist hjemmearbejde (Partially remote) | `delvist` | + +## Job Detail URL Pattern + +``` +https://jobbank.dk/job/{id}/{company-slug}/{title-slug}/ +``` + +Example: `https://jobbank.dk/job/12345/novo-nordisk/senior-data-scientist/` + +## RSS Feed URL + +Every search has an RSS equivalent: + +``` +https://jobbank.dk/job/rss?{same_query_params} +``` + +Example: +``` +https://jobbank.dk/job/rss?key=python&amt=2&cvtype=3 +``` + +## Pagination + +- Pages are 1-indexed (first page is `page=1` or no page parameter) +- Approximately 20 results per page +- Use `&page=2`, `&page=3`, etc. to navigate + +## Job Card Extraction + +### CSS Selectors + +| Element | Selector | +|---------|----------| +| Job card container | `div.job-item` | +| Job ID | `div.job-item[name]` (use `getAttribute("name")`) | +| Job title | `.job-header` | +| Job type/company/location | `.job-teaser` | +| Job description excerpt | `.job-description` | +| Date updated | `.job-date-updated` | +| Application deadline | `.job-date-application` | +| Job detail link | `a[href^="/job/"]` (prepend `https://jobbank.dk`) | + +### Working Extraction Code + +```javascript +async page => { + const jobs = await page.evaluate(() => { + return Array.from(document.querySelectorAll("div.job-item")).map(item => { + const id = item.getAttribute("name"); + const link = item.querySelector("a[href^='/job/']"); + const url = link ? "https://jobbank.dk" + link.getAttribute("href") : null; + const header = item.querySelector(".job-header"); + const teaser = item.querySelector(".job-teaser"); + const desc = item.querySelector(".job-description"); + const dateUpdated = item.querySelector(".job-date-updated"); + const deadline = item.querySelector(".job-date-application"); + return { + id, + title: header ? header.textContent.trim() : null, + teaser: teaser ? teaser.textContent.trim() : null, + description: desc ? desc.textContent.trim() : null, + url, + dateUpdated: dateUpdated ? dateUpdated.textContent.trim() : null, + deadline: deadline ? deadline.textContent.trim() : null, + }; + }); + }); + return JSON.stringify(jobs, null, 2); +} +``` + +## Examples + +### Simple Keyword Search + +``` +https://jobbank.dk/job/?key=data+scientist +``` + +### Full-Time IT Jobs in Copenhagen + +``` +https://jobbank.dk/job/?key=developer&cvtype=3&udd=24&amt=2 +``` + +### Remote Software Development Positions + +``` +https://jobbank.dk/job/?erf=31&fjernarbejde=helt +``` + +### Graduate/Trainee Positions in Multiple Cities + +``` +https://jobbank.dk/job/?cvtype=6&amt=2&amt=8 +``` + +### Project Management Jobs Suitable for New Graduates + +``` +https://jobbank.dk/job/?key=project+manager&erf=27&andet=2 +``` + +### Jobs in IT/Telecom Industry, Excluding Senior Positions + +``` +https://jobbank.dk/job/?key=developer&antikey=senior&branche=10331 +``` + +### Data Analysis Jobs Posted in Last 7 Days + +``` +https://jobbank.dk/job/?key=data+analysis&erf=43&oprettet=2026-02-03 +``` + +### Second Page of Python Jobs in Aarhus + +``` +https://jobbank.dk/job/?key=python&amt=8&page=2 +``` + +### Specific Company Search (Novo Nordisk) + +``` +https://jobbank.dk/job/?virk=novo+nordisk +``` + +### Ph.D. and Postdoc Positions in Natural Sciences + +``` +https://jobbank.dk/job/?cvtype=12&udd=30 +``` + +## Notes + +- **URL encoding:** Use `+` or `%20` for spaces in text parameters (`key`, `antikey`, `virk`) +- **Multiple filters:** Repeat parameter names for multiple values in same category +- **Date format:** Use ISO format `YYYY-MM-DD` for `oprettet` parameter +- **Remote work:** Only two values: `helt` (fully) or `delvist` (partially) +- **Case sensitivity:** Filter IDs are case-sensitive, use exact values from tables +- **Empty results:** Invalid filter combinations may return zero results +- **RSS monitoring:** Use RSS feeds for automated job monitoring and alerts diff --git a/.agents/skills/jobdanmark-search/SKILL.md b/.agents/skills/jobdanmark-search/SKILL.md new file mode 100644 index 0000000..ffd348a --- /dev/null +++ b/.agents/skills/jobdanmark-search/SKILL.md @@ -0,0 +1,228 @@ +--- +name: jobdanmark-search +version: 1.0.0 +description: > + Make sure to use this skill whenever the user mentions anything related to Danish + job listings, job search in Denmark, finding work in Denmark, or job vacancies on + Jobdanmark — even if they don't explicitly mention jobdanmark.dk. Also invoke this + skill for questions about specific Danish job categories, municipalities, job types, + or salaries in a job-search context. Trigger phrases include: + danish jobs, jobs in denmark, find job denmark, job search denmark, danish job listings, + jobdanmark, job opslag, find job, jobsøgning, ledige stillinger, stillingsopslag, + job i Danmark, fuldtidsjob, deltidsjob, studiejob, praktikplads, elev, fleksjob, + IT job denmark, sygeplejersker job, håndværker job, ingeniør job, pædagog job, + kontor job, leder job, salg job, hotel job, kirke job, job aarhus, job københavn, + job odense, job aalborg, job sjælland, job jylland, job fyn, jobkategorier denmark, + ledige job, ansøgningsfrist, søg job, job opslaget, jobopslag, danish vacancies, + work in denmark, employment denmark, job denmark, jobs near me denmark, + apprentice denmark, internship denmark, part-time denmark, full-time denmark. +context: fork +allowed-tools: Bash(bun run skills/jobdanmark-search/cli/src/cli.ts *) +--- + +# Jobdanmark Search Skill + +Access live Danish job listings from the Jobdanmark.dk public API. No authentication needed. +Covers ~15,000+ active job listings across 10 categories and all Danish municipalities. + +## When to use this skill + +Invoke this skill when the user wants to: + +- Search for job listings in Denmark (by keyword, location, category, or job type) +- Browse available jobs in a specific Danish city, municipality, zip code, or region +- Filter jobs by employment type (full-time, part-time, student job, apprentice, etc.) +- Look up the full details of a specific job posting including description and deadline +- List all job categories with current live job counts +- Resolve a job search term into a job title ID or category ID for precise filtering +- Find location suggestions (municipalities, zip codes, regions) for job search filters + +## Commands + +### Search job listings + +```bash +bun run skills/jobdanmark-search/cli/src/cli.ts search [flags] +``` + +Key flags: +- `--text <keyword>` — free-text search, e.g. `elektriker`, `sygeplejerske`, `softwareudvikler` +- `--category <id>` — category ID (see Categories table below) +- `--jobtitle-id <id>` — job title ID from `autocomplete` results +- `--municipality <name>` — e.g. `Odense`, `København`, `Aarhus` +- `--zip <code>` — zip code, e.g. `5000`, `8000`, `2100` +- `--region <name>` — region name, e.g. `Midtjylland`, `Sjælland` +- `--job-type <types>` — comma-separated: `fuldtid`, `deltid`, `fleksjob`, `elev`, `studiejob`, `praktik` +- `--page <n>` — page number (30 items per page, server-enforced) +- `--limit <n>` — cap total results returned by CLI +- `--format json|table|plain` + +> Per-page is fixed at 30 by the API. Use `--page` to paginate. + +### Full job detail + +```bash +bun run skills/jobdanmark-search/cli/src/cli.ts detail <slug> [--format json|plain] +``` + +`slug` is the URL path segment returned as `slug` in `search` results (e.g. `it-chef-soeges-til-rah`). +Returns full structured job data from the job page's JSON-LD, including title, organization, location, employment type, deadline, and full HTML description. + +### List categories with live counts + +```bash +bun run skills/jobdanmark-search/cli/src/cli.ts categories [--format json|table|plain] +``` + +Returns all 10 job categories with current live job counts. Useful for giving the user an overview of the job market. + +### Autocomplete job titles and categories + +```bash +bun run skills/jobdanmark-search/cli/src/cli.ts autocomplete --query "<text>" [--limit <n>] +``` + +Use this to resolve search terms into precise job title IDs (`--jobtitle-id`) or category IDs (`--category`) for `search`. The `value` field in results is the ID to pass to `search`. + +### Suggest locations + +```bash +bun run skills/jobdanmark-search/cli/src/cli.ts locations --query "<text>" [--limit <n>] +``` + +Returns matching municipalities, zip codes, and regions. Use `value` from results as `--municipality` or `--zip` in `search`. + +--- + +## Categories + +| ID | Danish | English | +|----|--------|---------| +| 227972 | Pædagogik, Uddannelse og Forskning | Education, Research | +| 227973 | Håndværk, Industri, Transport og Landbrug | Crafts, Industry, Transport, Agriculture | +| 227974 | Salg, Kommunikation, Marketing, og Design | Sales, Communication, Marketing, Design | +| 227975 | Pleje, Social og Sundhed | Care, Social, Health | +| 227976 | Hotel, Service, Restauration og Sikkerhed | Hotel, Service, Restaurant, Security | +| 227977 | Kontor, Finans og Økonomi | Office, Finance, Economy | +| 227978 | IT, Ingeniør og Energi | IT, Engineering, Energy | +| 227979 | Ledelse, HR og projektstyring | Management, HR, Project Management | +| 543415 | Kirke, Kultur og Underholdning | Church, Culture, Entertainment | +| 227980 | Øvrige job | Other jobs | + +--- + +## How to use effectively + +**Resolve locations first.** Use `locations` to find the correct municipality name or zip code before passing them to `search`: + +```bash +bun run skills/jobdanmark-search/cli/src/cli.ts locations --query "Aarhus" --format plain +``` + +**Resolve job titles for precision.** Use `autocomplete` to get the exact job title ID when the user wants a specific role: + +```bash +bun run skills/jobdanmark-search/cli/src/cli.ts autocomplete --query "sygeplejerske" --format plain +``` + +**Natural workflow: `search` → `detail`.** +1. Use `search` to get a list of matching jobs with their `slug`. +2. Call `detail <slug>` to get the full job posting with description, deadline, and organization details. + +**Use `--format table` for comparisons**, `--format json` for data processing, and `--format plain` for single-record detail views. + +**Pagination**: The API returns 30 items per page (server-enforced). Use `--page` to navigate through results. Use `--limit` to cap CLI output regardless of page size. + +**Combine filters** for precise results — e.g. `--text` + `--job-type` + `--municipality` all work together in a single search request. + +--- + +## Usage examples + +### IT jobs in Copenhagen + +```bash +bun run skills/jobdanmark-search/cli/src/cli.ts search \ + --category 227978 \ + --municipality "København" \ + --job-type fuldtid \ + --format table +``` + +### Nursing jobs anywhere in Denmark + +```bash +bun run skills/jobdanmark-search/cli/src/cli.ts search \ + --text "sygeplejerske" \ + --category 227975 \ + --format table +``` + +### Student jobs in Aarhus + +```bash +bun run skills/jobdanmark-search/cli/src/cli.ts search \ + --municipality "Aarhus" \ + --job-type studiejob \ + --format table +``` + +### What job categories are most active right now? + +```bash +bun run skills/jobdanmark-search/cli/src/cli.ts categories --format table +``` + +### Full details for a specific job posting + +```bash +bun run skills/jobdanmark-search/cli/src/cli.ts detail it-chef-soeges-til-rah --format plain +``` + +### Find jobs in zip code 8000 + +```bash +bun run skills/jobdanmark-search/cli/src/cli.ts search --zip 8000 --format table +``` + +### What electrician jobs are available? + +```bash +bun run skills/jobdanmark-search/cli/src/cli.ts search \ + --text "elektriker" \ + --category 227973 \ + --job-type "fuldtid,deltid" \ + --format table +``` + +### Apprentice positions in all of Denmark + +```bash +bun run skills/jobdanmark-search/cli/src/cli.ts search \ + --job-type elev \ + --page 1 \ + --format table +``` + +--- + +## Output formats + +| Format | Best for | +|--------|----------| +| `json` | Default — programmatic use, data processing, passing IDs between commands | +| `table` | Quick human-readable overviews and comparisons | +| `plain` | Single-record detail views (`detail`) | + +All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`. + +--- + +## Notes + +- All data is from the public Jobdanmark.dk API — no credentials required. +- Pagination is 1-indexed (`--page 1` is the first page). 30 items per page, server-enforced. +- The `detail` command fetches the HTML job page and parses the embedded JSON-LD (schema.org/JobPosting). It does not use a separate JSON API. +- `slug` in search results is extracted from the API's relative `url` field (the path after `/job/`). +- `applicationDeadline` in search results can be `null` (no deadline set). +- Job type values for filters: `fuldtid`, `deltid`, `fleksjob`, `elev`, `studiejob`, `praktik`. diff --git a/.agents/skills/jobdanmark-search/cli/README.md b/.agents/skills/jobdanmark-search/cli/README.md new file mode 100644 index 0000000..2f8aaf3 --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/README.md @@ -0,0 +1,452 @@ +# jobdanmark-cli + +CLI for the [Jobdanmark.dk](https://www.jobdanmark.dk) public job search API. + +**Base URL**: `https://jobdanmark.dk` +**Authentication**: None required. +**Format**: All responses are JSON. + +--- + +## Installation + +```bash +cd skills/jobdanmark-search/cli +bun install +``` + +--- + +## Commands + +| Command | Description | +|---------|-------------| +| `search` | Search job listings with filters | +| `detail` | Full detail for a single job posting (by slug) | +| `categories` | List all job categories with live counts | +| `autocomplete` | Suggest job titles and categories for a query | +| `locations` | Suggest municipalities, zip codes, and regions for a query | + +All commands accept `--format json|table|plain` (default: `json`). +All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`. + +--- + +## Job Types + +| Value | Danish | English | +|-------|--------|---------| +| `fuldtid` | Fuldtid | Full-time | +| `deltid` | Deltid | Part-time | +| `fleksjob` | Fleksjob | Flex job | +| `elev` | Elev | Apprentice | +| `studiejob` | Studiejob | Student job | +| `praktik` | Praktik | Internship | + +--- + +## Categories + +| ID | Danish Title | +|----|-------------| +| 227972 | Pædagogik, Uddannelse og Forskning | +| 227973 | Håndværk, Industri, Transport og Landbrug | +| 227974 | Salg, Kommunikation, Marketing, og Design | +| 227975 | Pleje, Social og Sundhed | +| 227976 | Hotel, Service, Restauration og Sikkerhed | +| 227977 | Kontor, Finans og Økonomi | +| 227978 | IT, Ingeniør og Energi | +| 227979 | Ledelse, HR og projektstyring | +| 543415 | Kirke, Kultur og Underholdning | +| 227980 | Øvrige job | + +--- + +## `search` — Search job listings + +**Endpoint**: `POST https://jobdanmark.dk/api/jobsearch/search/{page}` + +```bash +bun run src/cli.ts search [flags] +``` + +### Flags + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--text` | string | — | Free-text keyword search (job title, keyword) | +| `--category` | number | — | Category ID (see table above) | +| `--jobtitle-id` | number | — | Job title ID from `autocomplete` results | +| `--municipality` | string | — | Municipality name, e.g. `Odense`, `København` | +| `--zip` | string | — | Zip code, e.g. `5000` | +| `--region` | string | — | Region name | +| `--job-type` | string | — | Comma-separated job types: `fuldtid,deltid,fleksjob,elev,studiejob,praktik` | +| `--page` | number | `1` | Page number (30 items per page, server-enforced) | +| `--limit` | number | — | Cap total results returned by CLI (client-side) | +| `--format` | string | `json` | Output format: `json`, `table`, `plain` | + +> **Note**: Per-page is fixed at 30 by the API. There is no `--per-page` flag. + +> **Note**: Multiple `--job-type` values are passed as a comma-separated string and split by the CLI into the `jobTypes` array in the request body. + +### Example + +```bash +bun run src/cli.ts search --text "elektriker" --job-type fuldtid --municipality "Odense" + +bun run src/cli.ts search --category 227978 --job-type "fuldtid,deltid" --page 2 --format table + +bun run src/cli.ts search --text "sygeplejerske" --zip 8000 --limit 10 +``` + +### Request body sent to API + +```json +{ + "jobTypes": ["fuldtid"], + "filters": [ + { "type": "freetext", "value": "elektriker", "displayText": "elektriker" }, + { "type": "municipality", "value": "Odense", "displayText": "Odense" } + ], + "locationMode": "Text", + "distance": 50 +} +``` + +> **Important**: All filter objects include a `displayText` field (required by the server). The `value` field is an integer for `category` and `jobtitle` filter types; a string for all others. + +### Response shape + +```json +{ + "meta": { + "currentPage": 1, + "totalItems": 15610, + "itemsPrPage": 30, + "totalPages": 521 + }, + "results": [ + { + "title": "IT-chef søges til RAH", + "companyName": "Rah Service A/S", + "companyLogo": { + "key": "71f1c950-abcd-1234-efgh-000000000000", + "url": "https://jobdanmark.dk/media/k1epc2kk/rah-service-logo.jpg", + "focalPoint": null + }, + "companyLogoSvgMarkup": null, + "overlayColor": "#FFFFFF1F", + "companyAddress": "Ndr Ringvej 4 6950 Ringkøbing", + "jobTypes": ["fuldtid"], + "boostJob": true, + "publishedDate": "12-03-2026", + "applicationDeadline": "10-04-2026", + "url": "https://jobdanmark.dk/job/it-chef-soeges-til-rah", + "slug": "it-chef-soeges-til-rah", + "coverImage": { + "key": "cf06eb46-abcd-1234-efgh-000000000000", + "url": "https://jobdanmark.dk/media/idvbnt4y/rah-service-as-billede.png", + "focalPoint": { "top": 0.488, "left": 0.499 } + }, + "silhouetteLogo": false + } + ] +} +``` + +> **Notes**: +> - `url` is normalized to a full URL (CLI prepends `https://jobdanmark.dk` to the relative path from the API). +> - `slug` is extracted from the relative `url` field (the path segment after `/job/`). +> - `applicationDeadline` can be `null`. +> - `companyLogo` can be `null`. +> - `publishedDate` format: `"DD-MM-YYYY"`. +> - `coverImage` can be `null`. + +--- + +## `detail` — Full job posting detail + +**Method**: Fetch HTML from `https://jobdanmark.dk/job/{slug}`, extract `<script type="application/ld+json">` block. + +```bash +bun run src/cli.ts detail <slug> [--format json|plain] +``` + +The `slug` is the URL path segment returned as `slug` in `search` results (e.g. `it-chef-soeges-til-rah`). + +### Example + +```bash +bun run src/cli.ts detail it-chef-soeges-til-rah +bun run src/cli.ts detail it-chef-soeges-til-rah --format plain +``` + +### Response shape + +```json +{ + "slug": "it-chef-soeges-til-rah", + "url": "https://jobdanmark.dk/job/it-chef-soeges-til-rah", + "title": "IT-chef søges til RAH", + "datePosted": "2026-03-12", + "validThrough": "2026-04-10", + "employmentType": ["FULL_TIME"], + "hiringOrganization": { + "name": "Rah Service A/S", + "logo": "https://jobdanmark.dk/media/k1epc2kk/rah-service-logo.jpg?width=50" + }, + "jobLocation": { + "streetAddress": "Ndr Ringvej 4 6950 Ringkøbing", + "addressLocality": "Ringkøbing", + "addressRegion": "Vestjylland", + "postalCode": "6950", + "addressCountry": "DK" + }, + "description": "<p>Full HTML description...</p>" +} +``` + +> **Note**: The `hiringOrganization.logo` and `validThrough` fields may be `null` if not present in the JSON-LD. `jobLocation` fields may be `null` if the location data is absent. + +--- + +## `categories` — List categories with live job counts + +**Endpoint**: `GET https://jobdanmark.dk/api/categorycount/getcounts` + +```bash +bun run src/cli.ts categories [flags] +``` + +### Flags + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--limit` | number | — | Cap number of categories returned | +| `--format` | string | `json` | Output format: `json`, `table`, `plain` | + +### Example + +```bash +bun run src/cli.ts categories +bun run src/cli.ts categories --format table +``` + +### Response shape + +```json +[ + { + "id": 227972, + "title": "Pædagogik, Uddannelse og Forskning", + "helpText": "Pædagog, lærer, forsker og meget mere...", + "count": 1856 + }, + { + "id": 227973, + "title": "Håndværk, Industri, Transport og Landbrug", + "helpText": "Elektriker, VVS, maler og meget mere...", + "count": 2816 + }, + { + "id": 227974, + "title": "Salg, Kommunikation, Marketing, og Design", + "helpText": "Sælger, marketingkoordinator og meget mere...", + "count": 1423 + }, + { + "id": 227975, + "title": "Pleje, Social og Sundhed", + "helpText": "Sygeplejerske, social- og sundhedsassistent og meget mere...", + "count": 2104 + }, + { + "id": 227976, + "title": "Hotel, Service, Restauration og Sikkerhed", + "helpText": "Tjener, kok, receptionist og meget mere...", + "count": 987 + }, + { + "id": 227977, + "title": "Kontor, Finans og Økonomi", + "helpText": "Bogholder, revisor, kontorassistent og meget mere...", + "count": 1234 + }, + { + "id": 227978, + "title": "IT, Ingeniør og Energi", + "helpText": "Programmør, datamatiker, ingeniør og meget mere...", + "count": 1567 + }, + { + "id": 227979, + "title": "Ledelse, HR og projektstyring", + "helpText": "Leder, HR-konsulent, projektleder og meget mere...", + "count": 876 + }, + { + "id": 543415, + "title": "Kirke, Kultur og Underholdning", + "helpText": "Præst, musiker, skuespiller og meget mere...", + "count": 234 + }, + { + "id": 227980, + "title": "Øvrige job", + "helpText": "Andre job der ikke passer i de øvrige kategorier...", + "count": 2329 + } +] +``` + +--- + +## `autocomplete` — Suggest job titles and categories + +**Endpoint**: `GET https://jobdanmark.dk/api/search/autocomplete?q={query}` + +```bash +bun run src/cli.ts autocomplete --query <text> [flags] +``` + +### Flags + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--query` | string | **required** | Search text to autocomplete | +| `--limit` | number | — | Cap total suggestions returned | +| `--format` | string | `json` | Output format: `json`, `table`, `plain` | + +### Example + +```bash +bun run src/cli.ts autocomplete --query "it" +bun run src/cli.ts autocomplete --query "sygeplejerske" --format table +``` + +### Response shape + +```json +[ + { + "title": "Jobtitler", + "items": [ + { + "id": "title__454", + "text": "IT-konsulent", + "value": 454, + "category": "jobtitle", + "slug": "it-konsulent" + }, + { + "id": "title__123", + "text": "IT-supporter", + "value": 123, + "category": "jobtitle", + "slug": "it-supporter" + } + ] + }, + { + "title": "Kategorier", + "items": [ + { + "id": "category__227978", + "text": "IT, Ingeniør og Energi", + "value": 227978, + "category": "category", + "slug": "it-ingenioer-og-energi" + } + ] + } +] +``` + +> **Note**: Returns an empty array `[]` when no suggestions match. `value` is a numeric ID usable as `--jobtitle-id` or `--category` in `search`. Groups with no matching items are omitted from the response. + +--- + +## `locations` — Suggest location filters + +**Endpoint**: `GET https://jobdanmark.dk/api/search/locations?q={query}` + +```bash +bun run src/cli.ts locations --query <text> [flags] +``` + +### Flags + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--query` | string | **required** | Location text to search (city, zip code, region) | +| `--limit` | number | — | Cap total suggestions returned | +| `--format` | string | `json` | Output format: `json`, `table`, `plain` | + +### Example + +```bash +bun run src/cli.ts locations --query "Odense" +bun run src/cli.ts locations --query "8000" --format table +bun run src/cli.ts locations --query "Sjælland" +``` + +### Response shape + +```json +[ + { + "title": "Kommune", + "items": [ + { + "id": "municipality__0461", + "text": "Odense", + "value": "Odense", + "category": "municipality", + "slug": "odense" + } + ] + }, + { + "title": "Postnummer", + "items": [ + { + "id": "zip__5000", + "text": "5000 Odense C", + "value": "5000", + "category": "zip", + "slug": "5000-odense-c" + }, + { + "id": "zip__5200", + "text": "5200 Odense V", + "value": "5200", + "category": "zip", + "slug": "5200-odense-v" + } + ] + } +] +``` + +> **Note**: Returns `[]` when no locations match. The `value` field for municipalities is the display name (string); for zip codes it is the numeric string. Use `value` directly as `--municipality` or `--zip` in `search`. + +--- + +## Error handling + +All errors are written to **stderr** in JSON format and exit with code `1`: + +```json +{ "error": "Job not found", "code": "NOT_FOUND" } +{ "error": "API request failed: 400 Bad Request", "code": "API_ERROR" } +{ "error": "--query is required", "code": "MISSING_REQUIRED" } +{ "error": "Failed to parse JSON-LD from job page", "code": "PARSE_ERROR" } +``` + +--- + +## URL construction + +- Job detail pages: `https://jobdanmark.dk/job/{slug}` +- Company logo images: `https://jobdanmark.dk{companyLogo.url}` (prepend base URL to relative path) +- Cover images: `https://jobdanmark.dk{coverImage.url}` (prepend base URL to relative path) diff --git a/.agents/skills/jobdanmark-search/cli/package.json b/.agents/skills/jobdanmark-search/cli/package.json new file mode 100644 index 0000000..f3f9af1 --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/package.json @@ -0,0 +1,25 @@ +{ + "name": "jobdanmark-cli", + "version": "1.0.0", + "description": "CLI for the Jobdanmark.dk public job search API", + "type": "module", + "main": "src/cli.ts", + "bin": { + "jobdanmark": "src/cli.ts" + }, + "scripts": { + "start": "bun run src/cli.ts", + "test": "bun test --timeout 30000", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@bunli/core": "latest", + "@bunli/utils": "latest", + "node-html-parser": "^6.1.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.4.0", + "@types/bun": "latest" + } +} diff --git a/.agents/skills/jobdanmark-search/cli/src/cli.ts b/.agents/skills/jobdanmark-search/cli/src/cli.ts new file mode 100644 index 0000000..0111a9a --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/src/cli.ts @@ -0,0 +1,20 @@ +import { createCLI } from "@bunli/core" +import { search } from "./commands/search.js" +import { detail } from "./commands/detail.js" +import { categories } from "./commands/categories.js" +import { autocomplete } from "./commands/autocomplete.js" +import { locations } from "./commands/locations.js" + +const cli = await createCLI({ + name: "jobdanmark-cli", + version: "1.0.0", + description: "CLI for the Jobdanmark.dk public job search API", +}) + +cli.command(search) +cli.command(detail) +cli.command(categories) +cli.command(autocomplete) +cli.command(locations) + +await cli.run() diff --git a/.agents/skills/jobdanmark-search/cli/src/helpers.ts b/.agents/skills/jobdanmark-search/cli/src/helpers.ts new file mode 100644 index 0000000..212bbc3 --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/src/helpers.ts @@ -0,0 +1,67 @@ +export const BASE_URL = "https://jobdanmark.dk" + +export async function apiFetch<T>(path: string, params?: Record<string, string>): Promise<T> { + let url = `${BASE_URL}${path}` + if (params && Object.keys(params).length > 0) { + const qs = new URLSearchParams(params) + url += `?${qs.toString()}` + } + + const maxRetries = 6 + let delay = 500 + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const response = await fetch(url) + if (response.status === 429 || response.status >= 500) { + if (attempt === maxRetries) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`) + } + const jitter = Math.floor(Math.random() * 500) + await new Promise((resolve) => setTimeout(resolve, delay + jitter)) + delay = Math.min(delay * 2, 5000) + continue + } + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`) + } + return response.json() as Promise<T> + } + throw new Error("API request failed after max retries") +} + +export async function apiPost<T>(path: string, body: unknown): Promise<T> { + const url = `${BASE_URL}${path}` + + const maxRetries = 6 + let delay = 500 + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }) + if (response.status === 429 || response.status >= 500) { + if (attempt === maxRetries) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`) + } + const jitter = Math.floor(Math.random() * 500) + await new Promise((resolve) => setTimeout(resolve, delay + jitter)) + delay = Math.min(delay * 2, 5000) + continue + } + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`) + } + return response.json() as Promise<T> + } + throw new Error("API request failed after max retries") +} + +export function writeError(error: string, code: string): void { + process.stderr.write(JSON.stringify({ error, code }) + "\n") +} + +export function stripHtml(html: string): string { + return html.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim() +} diff --git a/.agents/skills/jobdanmark-search/cli/tests/helpers.ts b/.agents/skills/jobdanmark-search/cli/tests/helpers.ts new file mode 100644 index 0000000..75b82b9 --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/tests/helpers.ts @@ -0,0 +1,39 @@ +import { join } from "path"; + +const CLI_PATH = join(import.meta.dir, "../src/cli.ts"); + +export interface CLIResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export async function runCLI(args: string[]): Promise<CLIResult> { + const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }; +} + +export function parseJSON<T = unknown>(result: CLIResult): T { + if (result.exitCode !== 0) { + throw new Error( + `CLI exited with code ${result.exitCode}. stderr: ${result.stderr}` + ); + } + try { + return JSON.parse(result.stdout) as T; + } catch { + throw new Error( + `Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}` + ); + } +} diff --git a/.agents/skills/jobindex-search/SKILL.md b/.agents/skills/jobindex-search/SKILL.md new file mode 100644 index 0000000..57846ad --- /dev/null +++ b/.agents/skills/jobindex-search/SKILL.md @@ -0,0 +1,170 @@ +--- +name: jobindex-search +version: 1.0.0 +description: > + Make sure to use this skill whenever the user wants to search for jobs in Denmark, + find Danish job listings, look up a specific job posting, or asks anything about + the Danish job market — even if they don't mention jobindex.dk explicitly. Invoke + this skill for questions about open positions, job vacancies, hiring in Denmark, + job opportunities in Danish cities or sectors, or when the user wants to find work + in Denmark. Also trigger for phrases like "find me a job", "are there any jobs for + X in Copenhagen", or "what jobs are available in Aarhus" when the context is Denmark. + Trigger phrases include: jobindex, jobsøgning, job i Danmark, ledige stillinger, + job opslag, find job, stillingopslag, jobannonce, job vacancy denmark, danish jobs, + jobs in denmark, job search denmark, work in denmark, find work denmark, IT jobs + denmark, engineer jobs denmark, developer jobs copenhagen, marketing jobs aarhus, + jobs aarhus, jobs copenhagen, jobs odense, jobs aalborg, job openings denmark, + hiring denmark, job listings denmark, python jobs denmark, grafisk designer job, + data engineer job, softwareudvikler job, full stack developer job danmark. +context: fork +allowed-tools: Bash(bun run skills/jobindex-search/cli/src/cli.ts *) +--- + +# Jobindex Search Skill + +Search live Danish job listings from Jobindex.dk. No authentication needed. +Covers thousands of job postings across all sectors, updated in real time. + +## When to use this skill + +Invoke this skill when the user wants to: + +- Search for job openings in Denmark by keyword, job title, or technology +- Find jobs in a specific Danish city (use keyword with city name, e.g. `python aarhus`) +- Filter jobs by recency (posted today, last 7 days, last 30 days) +- Get the full description of a specific job listing +- Explore the Danish job market for a given profession or skill set + +## Commands + +### Search job listings + +```bash +bun run skills/jobindex-search/cli/src/cli.ts search [flags] +``` + +Key flags: +- `--query <text>` / `-q <text>` — keyword search (job title, skill, company, city). **Required** for meaningful results. +- `--jobage <days>` — filter by posting age: `1` (today), `7`, `14`, `30`, or `9999` (all, default) +- `--sort <order>` — `score` (relevance, default) or `date` (newest first) +- `--page <n>` — page number (1-indexed, 20 results per page, fixed) +- `--limit <n>` — cap total results the CLI outputs (client-side) +- `--format json|table|plain` + +> **Area note**: The Jobindex API does not support area filtering via params. To find jobs in a specific city, include the city in `--query` (e.g. `--query "data engineer københavn"` or `--query "python aarhus"`). + +### Fetch full job detail + +```bash +bun run skills/jobindex-search/cli/src/cli.ts detail <id> [--format json|plain] +``` + +`id` is the job ID from `search` results (e.g. `h1647303`). You may also pass the full Jobindex URL. Returns the full job description, deadline, employment type, hours, and apply link. + +--- + +## How to use effectively + +**Always start with `search`.** Pass the job title, skill, or profession as `--query`. Combine with a city name in the query to narrow by location (e.g. `--query "frontend developer odense"`). + +**Use `--jobage 7` or `--jobage 1` for fresh listings.** Without it, results include all historical postings. + +**Use `--sort date` to see the most recently posted jobs first.** Default `score` sorts by relevance. + +**Natural workflow: `search` → `detail`.** +1. Use `search` to find matching jobs and their `id` values. +2. Call `detail <id>` to get the full description, deadline, and apply link. + +**Use `--format table` for quick scanning**, `--format json` for data processing, and `--format plain` for reading a single job's full details. + +**Pagination**: The API always returns 20 results per page. Use `--page` to navigate pages. Use `--limit` to cap results across one page fetch. + +--- + +## Usage examples + +### Find Python jobs posted in the last 7 days + +```bash +bun run skills/jobindex-search/cli/src/cli.ts search \ + --query python \ + --jobage 7 \ + --sort date \ + --format table +``` + +### Data engineer jobs in Copenhagen + +```bash +bun run skills/jobindex-search/cli/src/cli.ts search \ + --query "data engineer københavn" \ + --sort score \ + --format table +``` + +### Graphic designer jobs — all time, by relevance + +```bash +bun run skills/jobindex-search/cli/src/cli.ts search \ + --query "grafisk designer" \ + --limit 10 \ + --format table +``` + +### Full-stack developer jobs, page 2 + +```bash +bun run skills/jobindex-search/cli/src/cli.ts search \ + --query "full stack developer" \ + --page 2 \ + --format json +``` + +### Jobs posted today across all sectors + +```bash +bun run skills/jobindex-search/cli/src/cli.ts search \ + --jobage 1 \ + --sort date \ + --limit 20 \ + --format table +``` + +### Get full details for a specific job + +```bash +bun run skills/jobindex-search/cli/src/cli.ts detail h1647303 --format plain +``` + +### Marketing jobs in Aarhus + +```bash +bun run skills/jobindex-search/cli/src/cli.ts search \ + --query "marketing aarhus" \ + --jobage 30 \ + --sort date \ + --format table +``` + +--- + +## Output formats + +| Format | Best for | +|--------|----------| +| `json` | Default — programmatic use, data processing, passing IDs to `detail` | +| `table` | Quick human-readable overview and scanning | +| `plain` | Reading a single job's full detail (`detail` command) | + +All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`. + +--- + +## Notes + +- All data is from the public `jobindex.dk` API — no credentials required. +- Page size is fixed at 20 results per page (Jobindex API limitation). +- Area/region filtering via API params does not work — include city names in `--query` instead. +- `--jobage 9999` is the default and includes all postings regardless of age. +- Total count in `meta.total` uses Danish dot-thousands notation internally (e.g. `18.903`) — the CLI normalizes this to a plain integer. +- Job IDs are string-prefixed (e.g. `h1647303`) — pass them as-is to `detail`. diff --git a/.agents/skills/jobindex-search/cli/README.md b/.agents/skills/jobindex-search/cli/README.md new file mode 100644 index 0000000..4316dd8 --- /dev/null +++ b/.agents/skills/jobindex-search/cli/README.md @@ -0,0 +1,230 @@ +# jobindex-cli + +CLI for searching jobs on [Jobindex.dk](https://www.jobindex.dk). + +**Base URL**: `https://www.jobindex.dk/` +**Authentication**: None required. +**Format**: The API returns JSON with embedded HTML blobs. The CLI parses the HTML internally and emits clean JSON. + +--- + +## Installation + +```bash +cd skills/jobindex-search/cli +bun install +``` + +--- + +## Commands + +| Command | Description | +|---------|-------------| +| `search` | Search for job listings | +| `detail` | Fetch full detail for a single job listing | + +All commands accept `--format json|table|plain` (default: `json`). +All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`. + +--- + +## `search` — Search for job listings + +**Endpoint**: `GET https://www.jobindex.dk/jobsoegning.json` + +```bash +bun run src/cli.ts search [flags] +``` + +The API always returns 20 results per page (fixed — no `--per-page` flag). The CLI parses the `result_list_box_html` HTML blob from the response to extract structured job records. + +### Flags + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--query` / `-q` | string | — | Keyword search query (e.g. `python`, `grafisk designer`) | +| `--page` | number | `1` | Page number (1-indexed) | +| `--jobage` | number | `9999` | Max age of posting in days: `1`, `7`, `14`, `30`, or `9999` (all) | +| `--sort` | string | `score` | Sort order: `score` (relevance) or `date` (newest first) | +| `--limit` | number | — | Cap total results returned by the CLI (client-side) | +| `--format` | string | `json` | Output format: `json`, `table`, `plain` | + +### Sort options + +| Value | Description | +|-------|-------------| +| `score` | Relevance / best match (default) | +| `date` | Newest postings first | + +### jobage options + +| Value | Description | +|-------|-------------| +| `1` | Posted today | +| `7` | Last 7 days | +| `14` | Last 14 days | +| `30` | Last 30 days | +| `9999` | All time (default) | + +### Example + +```bash +# Search for Python jobs posted in the last 7 days, sorted by date +bun run src/cli.ts search --query python --jobage 7 --sort date + +# Search for "grafisk designer" jobs — show first 5 results +bun run src/cli.ts search --query "grafisk designer" --limit 5 + +# Page 2 of results for data engineer +bun run src/cli.ts search --query "data engineer" --page 2 --format table +``` + +### Response shape + +```json +{ + "meta": { + "total": 237, + "page": 1, + "perPage": 20 + }, + "results": [ + { + "id": "h1647303", + "title": "Data Engineer til opbygning af Gavefabrikkens dataplatform", + "company": "Gavefabrikken", + "companyUrl": "https://www.gavefabrikken.dk/", + "location": "Valby", + "date": "2026-03-12", + "url": "https://www.jobindex.dk/jobannonce/h1647303/data-engineer-til-opbygning-af-gavefabrikkens-dataplatform", + "description": "Vi søger en dygtig Data Engineer til at opbygge og vedligeholde vores dataplatform..." + } + ] +} +``` + +**Field notes:** +- `id` — string ID prefixed with `h` (e.g. `h1647303`). Use this with the `detail` command. +- `company` — company name; may be `null` for some aggregated listings. +- `companyUrl` — company homepage URL; may be `null` if not present. +- `location` — city or area; may be `null` if not listed. +- `date` — ISO date string (`YYYY-MM-DD`) from the `datetime` attribute on the `<time>` element; may be `null`. +- `description` — short excerpt from the listing; may be `null` or empty. +- `url` — full Jobindex.dk URL for the listing. +- `total` in `meta` — parsed from `hitcount_html` (Danish thousands separator `.` is stripped before parsing, e.g. `18.903` → `18903`). + +> **Note on area filtering**: The Jobindex API does not reliably support area/region filtering via query parameters. `area` and `geoareaid` params are silently ignored. To filter by location, use `--query` with a city name (e.g. `--query "python aarhus"`) or apply `--limit` and filter the JSON output externally. + +--- + +## `detail` — Fetch full job listing detail + +**URL**: `https://www.jobindex.dk/jobannonce/{id}/{slug}` + +```bash +bun run src/cli.ts detail <id> [--format json|plain] +``` + +The `id` is the job ID from `search` results (e.g. `h1647303`). The slug is optional — the CLI fetches the canonical URL by first constructing `https://www.jobindex.dk/jobannonce/{id}` and following any redirect, or by using the full URL from the `url` field in `search` results. + +You may also pass the full URL directly as the `id` argument. + +### Flags + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--format` | string | `json` | Output format: `json`, `plain` | + +### Example + +```bash +# Using ID from search results +bun run src/cli.ts detail h1647303 + +# Using full URL +bun run src/cli.ts detail "https://www.jobindex.dk/jobannonce/h1647303/data-engineer-til-opbygning-af-gavefabrikkens-dataplatform" + +# Plain text output +bun run src/cli.ts detail h1647303 --format plain +``` + +### Response shape + +```json +{ + "id": "h1647303", + "title": "Data Engineer til opbygning af Gavefabrikkens dataplatform", + "company": "Gavefabrikken", + "companyUrl": "https://www.gavefabrikken.dk/", + "location": "Valby, København", + "date": "2026-03-12", + "deadline": "2026-04-01", + "employmentType": "Fastansættelse", + "hours": "Fuldtid", + "applyUrl": "https://www.gavefabrikken.dk/jobs/apply/123", + "url": "https://www.jobindex.dk/jobannonce/h1647303/data-engineer-til-opbygning-af-gavefabrikkens-dataplatform", + "description": "Full job description text here..." +} +``` + +**Field notes:** +- `deadline` — application deadline date string; `null` if not listed. +- `employmentType` — e.g. `"Fastansættelse"`, `"Midlertidig ansættelse"`; `null` if not listed. +- `hours` — e.g. `"Fuldtid"`, `"Deltid"`; `null` if not listed. +- `applyUrl` — the external application URL (resolved from the Jobindex redirect link `/c?t=...`); `null` if not available. +- `description` — full plain-text job description (HTML stripped). +- All fields may be `null` if not present in the HTML. + +--- + +## Error handling + +All errors are written to **stderr** in JSON format and exit with code `1`: + +```json +{ "error": "Job not found", "code": "NOT_FOUND" } +{ "error": "API request failed: 500 Internal Server Error", "code": "API_ERROR" } +{ "error": "Failed to parse job listing HTML", "code": "PARSE_ERROR" } +{ "error": "--query is required", "code": "MISSING_REQUIRED" } +``` + +--- + +## URL construction + +Job detail pages on jobindex.dk: +- `https://www.jobindex.dk/jobannonce/{id}/{slug}` + +The slug is part of the `url` returned by `search`. When calling `detail` with just an ID, the CLI fetches `https://www.jobindex.dk/jobannonce/{id}` which redirects to the full URL. + +--- + +## Parsing notes + +### Total count from `hitcount_html` + +The API returns pagination info as an HTML string like: + +```html +<div class="jix_pagination_total"><strong>1</strong> til <strong>20</strong> af <strong>18.903</strong> resultater.</div> +``` + +Parse total with: `/af <strong>([\d.]+)<\/strong>/` and strip `.` before converting to integer. + +### Job card selectors + +Each job card is wrapped in `[data-beacon-tid]`. Inside, select: + +| Field | Selector | +|-------|----------| +| `id` | `[data-beacon-tid]` attribute value | +| `title` | `h4 > a` text content | +| `url` | `h4 > a[href]` | +| `company` | `.jix-toolbar-top__company a` text | +| `companyUrl` | `.jix-toolbar-top__company a[href]` | +| `location` | `span.jix_robotjob--area` text | +| `date` | `time[datetime]` attribute value | +| `description` | `p` text content (first `<p>` in card) | + +Two card types exist: `div.PaidJob` (sponsored) and `div.jix_robotjob` (aggregated). Both use the same selector pattern. diff --git a/.agents/skills/jobindex-search/cli/package.json b/.agents/skills/jobindex-search/cli/package.json new file mode 100644 index 0000000..b2e7817 --- /dev/null +++ b/.agents/skills/jobindex-search/cli/package.json @@ -0,0 +1,25 @@ +{ + "name": "jobindex-cli", + "version": "1.0.0", + "description": "CLI for searching jobs on Jobindex.dk", + "type": "module", + "main": "src/cli.ts", + "bin": { + "jobindex": "src/cli.ts" + }, + "scripts": { + "start": "bun run src/cli.ts", + "test": "bun test --timeout 30000", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@bunli/core": "latest", + "@bunli/utils": "latest", + "node-html-parser": "^6.1.13", + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.4.0", + "@types/bun": "latest" + } +} diff --git a/.agents/skills/jobindex-search/cli/src/cli.ts b/.agents/skills/jobindex-search/cli/src/cli.ts new file mode 100644 index 0000000..d89abf7 --- /dev/null +++ b/.agents/skills/jobindex-search/cli/src/cli.ts @@ -0,0 +1,14 @@ +import { createCLI } from "@bunli/core" +import { search } from "./commands/search.js" +import { detail } from "./commands/detail.js" + +const cli = await createCLI({ + name: "jobindex-cli", + version: "0.1.0", + description: "CLI for searching jobs on Jobindex.dk", +}) + +cli.command(search) +cli.command(detail) + +await cli.run() diff --git a/.agents/skills/jobindex-search/cli/src/helpers.ts b/.agents/skills/jobindex-search/cli/src/helpers.ts new file mode 100644 index 0000000..14fb585 --- /dev/null +++ b/.agents/skills/jobindex-search/cli/src/helpers.ts @@ -0,0 +1,197 @@ +export const BASE_URL = "https://www.jobindex.dk" + +export function writeError(error: string, code: string): void { + process.stderr.write(JSON.stringify({ error, code }) + "\n") +} + +export async function apiFetch<T>(path: string, params?: Record<string, string>): Promise<T> { + let url = `${BASE_URL}${path}` + if (params && Object.keys(params).length > 0) { + const qs = new URLSearchParams(params) + url += `?${qs.toString()}` + } + + const maxRetries = 6 + let delay = 500 + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const response = await fetch(url) + if (response.status === 429 || response.status >= 500) { + if (attempt === maxRetries) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`) + } + const jitter = Math.floor(Math.random() * 500) + await new Promise((resolve) => setTimeout(resolve, delay + jitter)) + delay = Math.min(delay * 2, 5000) + continue + } + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`) + } + return response.json() as Promise<T> + } + throw new Error("API request failed after max retries") +} + +export async function htmlFetch(url: string): Promise<string> { + const maxRetries = 6 + let delay = 500 + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const response = await fetch(url, { + headers: { + "User-Agent": "Mozilla/5.0 (compatible; jobindex-cli/1.0)", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "da,en;q=0.9", + }, + redirect: "follow", + }) + if (response.status === 429 || response.status >= 500) { + if (attempt === maxRetries) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`) + } + const jitter = Math.floor(Math.random() * 500) + await new Promise((resolve) => setTimeout(resolve, delay + jitter)) + delay = Math.min(delay * 2, 5000) + continue + } + if (response.status === 404) { + throw new Error(`Job not found`) + } + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`) + } + return response.text() + } + throw new Error("Request failed after max retries") +} + +export interface JobCard { + id: string + title: string + company: string | null + companyUrl: string | null + location: string | null + date: string | null + url: string + description: string | null +} + +/** + * Decode HTML entities in text + */ +function decodeHtmlEntities(text: string): string { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10))) + .replace(/ /g, " ") +} + +/** + * Strip HTML tags from text + */ +function stripTags(html: string): string { + return html.replace(/<[^>]+>/g, "").trim() +} + +/** + * Parse job cards from result_list_box_html using regex. + * node-html-parser has nesting bugs with this specific HTML structure + * (unclosed tags inside buttons cause incorrect DOM tree). + * Regex parsing is more reliable for this specific HTML format. + */ +export function parseJobCards(html: string): JobCard[] { + const results: JobCard[] = [] + + // Split HTML by jobad-wrapper to get individual card HTML chunks + const wrapperPattern = /<div[^>]+id="jobad-wrapper-(h\d+|r\d+)"[^>]*>([\s\S]*?)(?=<div[^>]+id="jobad-wrapper-|$)/g + + let match: RegExpExecArray | null + while ((match = wrapperPattern.exec(html)) !== null) { + const id = match[1] + const cardHtml = match[2] + + // Extract title: look for <h4>...<a|A href="...">Title</a>...</h4> + const titleMatch = cardHtml.match(/<h4[^>]*>[\s\S]*?<[Aa][^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/[Aa]>/i) + if (!titleMatch) continue + const rawTitle = stripTags(titleMatch[2]) + const title = decodeHtmlEntities(rawTitle) + if (!title) continue + + // Determine URL: prefer jobindex.dk /jobannonce/ URL, fallback to constructed URL + let url: string + const jobannonce = cardHtml.match(/href="(https:\/\/www\.jobindex\.dk\/jobannonce\/[^"]+)"/) + if (jobannonce) { + url = jobannonce[1] + } else { + // Construct canonical URL from ID + url = `${BASE_URL}/jobannonce/${id}` + } + + // Extract company: <a ...> inside jix-toolbar-top__company + let company: string | null = null + let companyUrl: string | null = null + const companySection = cardHtml.match(/class="jix-toolbar-top__company"[^>]*>([\s\S]*?)<\/div>/i) + if (companySection) { + const companyLinkMatch = companySection[1].match(/<[Aa][^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/[Aa]>/i) + if (companyLinkMatch) { + company = decodeHtmlEntities(stripTags(companyLinkMatch[2])) || null + companyUrl = companyLinkMatch[1] || null + } + } + + // Extract location: <span class="jix_robotjob--area">Location</span> + const locMatch = cardHtml.match(/<span[^>]+class="jix_robotjob--area"[^>]*>([\s\S]*?)<\/span>/i) + const location = locMatch ? decodeHtmlEntities(stripTags(locMatch[1])) || null : null + + // Extract date: <time datetime="YYYY-MM-DD"> + const dateMatch = cardHtml.match(/<time[^>]+datetime="([^"]+)"/) + const date = dateMatch ? dateMatch[1] : null + + // Extract description: first <p class="..."> or first standalone <p> (not in toolbar) + // Skip the toolbar/menu section and look for the description paragraph + let description: string | null = null + const innerSection = cardHtml.match(/class="PaidJob-inner"[^>]*>([\s\S]*?)(?:<\/div>\s*<\/div>|$)/i) || + cardHtml.match(/class="jix_robotjob-inner"[^>]*>([\s\S]*?)(?:<\/div>\s*<\/div>|$)/i) + if (innerSection) { + const pMatch = innerSection[1].match(/<p[^>]*>([\s\S]*?)<\/p>/i) + if (pMatch) { + const text = decodeHtmlEntities(stripTags(pMatch[1])) + description = text.length > 0 ? text.substring(0, 300) : null + } + } else { + // Fallback: look for p after the jobannonce link + const pMatches = [...cardHtml.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)] + for (const pm of pMatches) { + const text = decodeHtmlEntities(stripTags(pm[1])) + if (text.length > 20) { + description = text.substring(0, 300) + break + } + } + } + + results.push({ + id, + title, + company: company || null, + companyUrl: companyUrl || null, + location: location || null, + date: date || null, + url, + description: description || null, + }) + } + + return results +} + +export function parseHitCount(html: string): number { + const match = html.match(/af <strong>([\d.]+)<\/strong>/) + if (!match) return 0 + const numStr = match[1].replace(/\./g, "") + return parseInt(numStr, 10) || 0 +} diff --git a/.agents/skills/jobindex-search/cli/tests/helpers.ts b/.agents/skills/jobindex-search/cli/tests/helpers.ts new file mode 100644 index 0000000..75b82b9 --- /dev/null +++ b/.agents/skills/jobindex-search/cli/tests/helpers.ts @@ -0,0 +1,39 @@ +import { join } from "path"; + +const CLI_PATH = join(import.meta.dir, "../src/cli.ts"); + +export interface CLIResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export async function runCLI(args: string[]): Promise<CLIResult> { + const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }; +} + +export function parseJSON<T = unknown>(result: CLIResult): T { + if (result.exitCode !== 0) { + throw new Error( + `CLI exited with code ${result.exitCode}. stderr: ${result.stderr}` + ); + } + try { + return JSON.parse(result.stdout) as T; + } catch { + throw new Error( + `Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}` + ); + } +} diff --git a/.agents/skills/jobindex-search/url-reference.md b/.agents/skills/jobindex-search/url-reference.md new file mode 100644 index 0000000..ac39c65 --- /dev/null +++ b/.agents/skills/jobindex-search/url-reference.md @@ -0,0 +1,118 @@ +# Jobindex URL Reference + +## Base URL + +``` +https://www.jobindex.dk/jobsoegning +``` + +## Full URL Pattern + +``` +https://www.jobindex.dk/jobsoegning/{category-group}/{category-slug}/{area-slug}?q={query}&jobage={days}&page={num} +``` + +All path segments and query params are optional. Category MUST come before area in the path. + +## Path Segments + +### Areas (geography) + +Area slug goes at the end of the path: + +| Area | Slug | +|------|------| +| Storkøbenhavn | `storkoebenhavn` | +| Københavnsområdet | `storkoebenhavn` | +| Nordsjælland | `nordsjaelland` | +| Sjælland | `sjaelland` | +| Fyn | `fyn` | +| Nordjylland | `nordjylland` | +| Midtjylland | `midtjylland` | +| Sydjylland | `sydjylland` | +| Bornholm | `bornholm` | + +**Note:** If the exact slug is unknown, use the Geografi filter UI instead: +1. Click the "Geografi" button +2. Click "Tilføj område" +3. Type city/region name in the textbox +4. Select from autocomplete (treeitem) +5. Click "Vis X job" button +6. Note the URL path that results + +### Categories + +Category uses a two-part path: `{group}/{slug}`: + +| Category | Path | +|----------|------| +| IT-drift og support | `it/itdrift` | + +**Note:** Category slugs are not fully mapped. To discover a category slug: +1. Click the "Kategorier" button +2. Type category name in the search field +3. Select from autocomplete (treeitem) +4. Click "Vis X job" button +5. Note the URL path that results + +## Query Parameters + +| Parameter | Description | Examples | +|-----------|-------------|----------| +| `q` | Search query | `q=data+engineer` (broad), `q=%27data+engineer%27` (exact match) | +| `jobage` | Max age in days | `jobage=1` (today), `jobage=3`, `jobage=7`, `jobage=30` | +| `page` | Page number (1-indexed) | `page=1`, `page=2` | + +## Examples + +```bash +# Basic keyword search +playwright-cli goto "https://www.jobindex.dk/jobsoegning?q=python+developer" + +# Exact match search (single quotes around query) +playwright-cli goto "https://www.jobindex.dk/jobsoegning?q=%27python+developer%27" + +# Search in Storkøbenhavn area +playwright-cli goto "https://www.jobindex.dk/jobsoegning/storkoebenhavn?q=python+developer" + +# Search with category + area +playwright-cli goto "https://www.jobindex.dk/jobsoegning/it/itdrift/storkoebenhavn?q=data+engineer" + +# Last 7 days only +playwright-cli goto "https://www.jobindex.dk/jobsoegning?q=data+engineer&jobage=7" + +# Page 2 of results +playwright-cli goto "https://www.jobindex.dk/jobsoegning?q=data+engineer&page=2" + +# Everything combined +playwright-cli goto "https://www.jobindex.dk/jobsoegning/storkoebenhavn?q=data+engineer&jobage=7&page=1" +``` + +## Filters Available via UI Only + +These filters require clicking through the filter panel (not URL-constructable): + +**Ansættelsestype (Employment type):** +Fastansættelse, Tidsbegrænset, Studiejob, Graduate/trainee, Freelance, etc. + +**Arbejdstid (Working hours):** +Fuldtid, Deltid + +**Hjemmearbejde (Remote work):** +Muligt, Tilbydes ikke, 100% hjemmearbejde + +To use these: click "Filtre" button → check desired options → click "Vis X job". + +## Job Card Extraction + +CSS selectors for job cards: `div.PaidJob, div.jix_robotjob` + +Each card contains: +- Title: `h4 a` (text + href) +- Company: first `a` link text +- Location: div after h4 (clean by removing "Se rejsetid") +- Posted date: `time` element (format: DD-MM-YYYY) +- Description: `p` elements +- Job URL: `h4 a` href (may be external or jobindex-hosted) + +~20 results per page. diff --git a/.agents/skills/jobnet-search/SKILL.md b/.agents/skills/jobnet-search/SKILL.md new file mode 100644 index 0000000..5ea698b --- /dev/null +++ b/.agents/skills/jobnet-search/SKILL.md @@ -0,0 +1,205 @@ +--- +name: jobnet-search +version: 1.0.0 +description: > + Make sure to use this skill whenever the user mentions anything related to Danish + job searching, job listings, job vacancies, employment opportunities in Denmark, or + the Danish government job portal — even if they don't mention jobnet.dk explicitly. + Also invoke this skill for questions about specific job titles, occupations, employers, + or regions in a Danish employment context. This skill covers the official Danish + public job portal operated by STAR (Styrelsen for Arbejdsmarked og Rekruttering). + Trigger phrases include: danish jobs, danish job search, jobnet, jobnet.dk, find job + denmark, danish employment, job i danmark, job på jobnet, offentlige job, stillinger + i det offentlige, public sector jobs denmark, government jobs denmark, STAR jobs, + job ledige stillinger, ledig stilling, søg job, job opslag, job vacancy denmark, + stillingopslag, jobopslag, sygepleje job, ingeniør job, lærer job, pædagog job, + it-job denmark, jobs in copenhagen, jobs in aarhus, jobs in odense, deltidsjob, + fuldtidsjob, fastansættelse, tidsbegrænset ansættelse, fleksjob, sygeplejerske job, + social worker job denmark, occupation search denmark, esco occupation, job deadline, + ansøgningsfrist, søg efter job, full time job denmark, part time job denmark. +context: fork +allowed-tools: Bash(bun run skills/jobnet-search/cli/src/cli.ts *) +--- + +# Jobnet-Search Skill + +Access live Danish job listings from the Jobnet.dk public API. No authentication needed. +Jobnet is operated by STAR (Styrelsen for Arbejdsmarked og Rekruttering) and is Denmark's +official government job portal — covering public sector positions as well as many private +sector listings. Approximately 21,000+ active jobs at any time. + +## When to use this skill + +Invoke this skill when the user wants to: + +- Search for job openings in Denmark by keyword, title, or employer +- Filter jobs by region, work hours (full/part time), or employment duration (permanent/temporary) +- Find jobs near a specific postal code within a given radius +- Get full details for a specific job ad including description, contact persons, and application URL +- Discover occupation types and ESCO categories for more precise job filtering +- Explore autocomplete suggestions for Danish job titles or keywords +- Find jobs in the public sector, healthcare, IT, education, or any other Danish industry + +## Commands + +### Search for job ads + +```bash +bun run skills/jobnet-search/cli/src/cli.ts search [flags] +``` + +Key flags: +- `--search-string <text>` — keyword search, e.g. `sygeplejerske`, `ingeniør`, `pædagog` +- `--region <region>` — `HovedstadenOgBornholm`, `Midtjylland`, `Syddanmark`, `OevrigeSjaelland`, `Nordjylland` +- `--postal-code <code>` — postal code for radius-based search, e.g. `2100` +- `--radius <km>` — radius in km from postal code (default: `50`) +- `--work-hours <type>` — `FullTime` or `PartTime` +- `--duration <type>` — `Permanent` or `Temporary` +- `--job-type <type>` — `Ordinaert`, `Efterloenner`, `Foertidspension` +- `--occupation-area <id>` — occupation area identifier, e.g. `10000` +- `--occupation-group <id>` — occupation group identifier, e.g. `10060` +- `--order <type>` — `PublicationDate` (default), `BestMatch`, `ApplicationDate` +- `--page / --per-page / --limit` +- `--format json|table|plain` + +### Full job ad detail + +```bash +bun run skills/jobnet-search/cli/src/cli.ts detail <jobAdId> [--format json|plain] +``` + +`jobAdId` is the UUID from `search` results (the `jobAdId` field). Returns the complete job +description, contact persons, application deadline, employer details, and direct application URL. + +### Search occupation types + +```bash +bun run skills/jobnet-search/cli/src/cli.ts occupations --search-string <text> [--per-page <n>] +``` + +Use this to discover ESCO occupation identifiers before passing them to `search` with +`--occupation-area` or `--occupation-group`. + +### Typeahead suggestions + +```bash +bun run skills/jobnet-search/cli/src/cli.ts suggestions --query <text> [--limit <n>] +``` + +Returns Danish job title autocomplete strings. Useful for exploring valid Danish +job titles before constructing a `search` query. + +--- + +## How to use effectively + +**Discover occupations first.** Use `occupations` or `suggestions` to find the right Danish +term or ESCO identifier before running a `search`: + +```bash +bun run skills/jobnet-search/cli/src/cli.ts suggestions --query "syge" +bun run skills/jobnet-search/cli/src/cli.ts occupations --search-string "sygeplejerske" +``` + +**Natural workflow: `search` → `detail`.** +1. Use `search` to get a list of matching jobs with their `jobAdId`. +2. Call `detail <jobAdId>` to get the full job description, contact persons, and direct application link. + +**Use `--format table` for comparisons**, `--format json` for data processing, and +`--format plain` for single-record detail views (strips HTML from job body). + +**Pagination**: `--per-page` controls server-side results per page. `--limit` caps what the +CLI outputs. Use `--page` + `--per-page` to iterate through large result sets. + +**Geographic search modes:** +- Use `--region` for broad regional filtering (e.g. all of Midtjylland) +- Use `--postal-code` + `--radius` for jobs near a specific location +- Do not combine `--region` and `--postal-code` in the same query + +**Order matters for intent:** +- `PublicationDate` — newest postings first (default, good for "what's new") +- `BestMatch` — relevance score (best when `--search-string` is provided) +- `ApplicationDate` — earliest deadline first (good for urgent applications) + +--- + +## Usage examples + +### Jobs in Copenhagen area + +```bash +bun run skills/jobnet-search/cli/src/cli.ts search \ + --region HovedstadenOgBornholm \ + --per-page 10 \ + --format table +``` + +### Nurse jobs nationwide + +```bash +bun run skills/jobnet-search/cli/src/cli.ts search \ + --search-string "sygeplejerske" \ + --work-hours FullTime \ + --duration Permanent \ + --order BestMatch \ + --per-page 10 \ + --format table +``` + +### IT jobs near Aarhus within 30km + +```bash +bun run skills/jobnet-search/cli/src/cli.ts search \ + --search-string "udvikler" \ + --postal-code 8000 \ + --radius 30 \ + --work-hours FullTime \ + --format table +``` + +### Full details of a job ad + +```bash +bun run skills/jobnet-search/cli/src/cli.ts detail 9ef43bce-d82b-4ea1-a098-7ff6520f99be --format plain +``` + +### Jobs sorted by application deadline (urgent first) + +```bash +bun run skills/jobnet-search/cli/src/cli.ts search \ + --search-string "pædagog" \ + --region OevrigeSjaelland \ + --order ApplicationDate \ + --per-page 10 +``` + +### Discover occupation terms + +```bash +bun run skills/jobnet-search/cli/src/cli.ts suggestions --query "ingeniør" --limit 5 +bun run skills/jobnet-search/cli/src/cli.ts occupations --search-string "lærer" --per-page 5 +``` + +--- + +## Output formats + +| Format | Best for | +|--------|----------| +| `json` | Default — programmatic use, data processing, passing IDs between commands | +| `table` | Quick human-readable overviews and comparisons | +| `plain` | Single-record detail views (`detail`), strips HTML from job descriptions | + +All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`. + +--- + +## Notes + +- All data is from the public `jobnet.dk/bff` REST API — no credentials required. +- The API requires the `x-csrf: 1` header; the CLI adds this automatically. +- Pagination is 1-indexed (`--page 1` is the first page). +- `search` results omit the HTML job description — use `detail` to get it. +- `detail --format plain` strips HTML tags for readable text output. +- Job ad detail pages on jobnet.dk: `https://jobnet.dk/job/{jobAdId}` +- `suggestions` is tuned for Danish job titles — English terms may return empty results. diff --git a/.agents/skills/jobnet-search/cli/README.md b/.agents/skills/jobnet-search/cli/README.md new file mode 100644 index 0000000..3578c27 --- /dev/null +++ b/.agents/skills/jobnet-search/cli/README.md @@ -0,0 +1,373 @@ +# jobnet-cli + +CLI for the [Jobnet.dk](https://jobnet.dk) Danish government job portal API. + +**Base URL**: `https://jobnet.dk/bff` +**Authentication**: No credentials required. All public endpoints only need the `x-csrf: 1` request header. +**Format**: All responses are JSON. + +--- + +## Installation + +```bash +cd skills/jobnet-search/cli +bun install +``` + +--- + +## Commands + +| Command | Description | +|---------|-------------| +| `search` | Search for job ads with filters | +| `detail` | Full detail for a single job ad | +| `occupations` | Search occupation types (for building filters) | +| `suggestions` | Typeahead suggestions for job title / keyword search | + +All commands accept `--format json|table|plain` (default: `json`). +All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`. + +--- + +## Regions + +| Value | Danish region | +|-------|--------------| +| `HovedstadenOgBornholm` | Hovedstaden og Bornholm | +| `Midtjylland` | Midtjylland | +| `Syddanmark` | Syddanmark | +| `OevrigeSjaelland` | Øvrige Sjælland | +| `Nordjylland` | Nordjylland | + +--- + +## Order types + +| Value | Description | +|-------|-------------| +| `PublicationDate` | Newest postings first (default) | +| `BestMatch` | Relevance score (requires `--search-string`) | +| `ApplicationDate` | Earliest deadline first | + +--- + +## `search` — Search for job ads + +**Endpoint**: `GET /FindJob/Search` + +```bash +bun run src/cli.ts search [flags] +``` + +### Flags + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--search-string` | string | — | Free-text keyword search (job title, skills, employer) | +| `--region` | string | — | One region value (see Regions table) | +| `--postal-code` | string | — | Postal code for radius search, e.g. `2100` | +| `--radius` | number | `50` | Radius in km from postal code (requires `--postal-code`) | +| `--work-hours` | string | — | `FullTime` or `PartTime` | +| `--duration` | string | — | `Permanent` or `Temporary` | +| `--job-type` | string | — | Announcement type: `Ordinaert`, `Efterloenner`, `Foertidspension` | +| `--occupation-area` | string | — | Occupation area identifier, e.g. `10000` | +| `--occupation-group` | string | — | Occupation group identifier, e.g. `10060` | +| `--page` | number | `1` | Page number (1-indexed) | +| `--per-page` | number | `10` | Results per page | +| `--limit` | number | — | Cap total results returned by CLI | +| `--order` | string | `PublicationDate` | Sort order (see Order types table) | +| `--format` | string | `json` | Output format: `json`, `table`, `plain` | + +### Example + +```bash +bun run src/cli.ts search \ + --search-string "sygeplejerske" \ + --region HovedstadenOgBornholm \ + --work-hours FullTime \ + --duration Permanent \ + --per-page 5 \ + --format table + +bun run src/cli.ts search \ + --postal-code 8000 \ + --radius 25 \ + --per-page 10 +``` + +### Response shape + +```json +{ + "meta": { + "totalJobAdCount": 21452, + "pageNumber": 1, + "resultsPerPage": 10, + "searchString": "developer" + }, + "facets": { + "regions": [ + { "type": "HovedstadenOgBornholm", "jobAdCount": 6642 } + ], + "workHours": [ + { "type": "FullTime", "jobAdCount": 17919 }, + { "type": "PartTime", "jobAdCount": 3533 } + ], + "employmentDurations": [ + { "type": "Permanent", "jobAdCount": 18533 }, + { "type": "Temporary", "jobAdCount": 2919 } + ], + "occupationAreas": [ + { "identifier": "10000", "jobAdCount": 3235 } + ], + "countries": [ + { "label": "Danmark", "identifier": "DK", "jobAdCount": 21164 } + ] + }, + "results": [ + { + "jobAdId": "9ef43bce-d82b-4ea1-a098-7ff6520f99be", + "title": "Akademisk medarbejder med interesse for arbejdsmiljø og uddannelse", + "hiringOrgName": "Region Midtjylland", + "occupation": "Personalekonsulent", + "municipality": "Viborg", + "postalCode": 8800, + "postalDistrictName": "Viborg", + "country": "Danmark", + "publicationDate": "2026-03-13T00:00:00+01:00", + "applicationDeadline": "2026-04-05T21:59:00+02:00", + "applicationDeadlineStatus": "ExpirationDate", + "workHourPartTime": false, + "isExternal": false, + "hasLogo": true, + "logoUrl": "/bff/SharedComponents/JobAdCard/CompanyLogo/ByJobAdId/9ef43bce-d82b-4ea1-a098-7ff6520f99be", + "cvr": "29190925", + "workPlaceAddress": "", + "conceptUriDa": "http://data.star.dk/esco/occupation/426e017f-ebe5-4bea-b1eb-7d2d5ab3c6db", + "isSeen": false, + "isFavorite": false + } + ] +} +``` + +> **Note**: The `description` field (raw HTML) is intentionally omitted from `search` results for brevity. Use `detail` to retrieve the full job description. + +> **Note**: `resultsPerPage` and `pageNumber` must always be provided — omitting them while also providing `searchString` causes the API to return error 1014 ("Fejl i formatering af inputs"). + +--- + +## `detail` — Full job ad detail + +**Endpoint**: `GET /FindJob/JobAdDetails/{id}` + +```bash +bun run src/cli.ts detail <id> [--format json|plain] +``` + +The `id` is the `jobAdId` UUID from `search` results. + +By default the CLI passes `incrementViews=false` to avoid polluting view counts. + +### Example + +```bash +bun run src/cli.ts detail 9ef43bce-d82b-4ea1-a098-7ff6520f99be +bun run src/cli.ts detail 9ef43bce-d82b-4ea1-a098-7ff6520f99be --format plain +``` + +### Response shape + +```json +{ + "id": "9ef43bce-d82b-4ea1-a098-7ff6520f99be", + "title": "Akademisk medarbejder med interesse for arbejdsmiljø og uddannelse", + "body": "<p>Full HTML job description...</p>", + "publicationDateTime": "2026-03-13T08:06:58.0578635+01:00", + "unpublicationDateTime": "2026-04-05T21:59:00+02:00", + "approvalStatus": "Godkendt", + "views": 2, + "createdDateTime": "2026-03-13T08:06:58.3936145+01:00", + "updatedDateTime": "2026-03-13T08:06:58.3936145+01:00", + "isAnonymousEmployer": false, + "hasLogo": true, + "logoUrl": "/bff/SharedComponents/JobAdCard/CompanyLogo/ByJobAdId/9ef43bce-d82b-4ea1-a098-7ff6520f99be", + "employer": { + "cvrNumber": "29190925", + "pNumber": "1003367314", + "name": "Region Midtjylland", + "hasCompanyLogo": true + }, + "job": { + "type": "Ordinaert", + "address": { + "streetName": "Heibergs Alle 5A", + "city": "Viborg", + "postalCode": "8800", + "municipality": "Viborg", + "countryCode": "DK", + "countryName": "Danmark" + }, + "noFixedWorkplace": false, + "isLimitedPeriod": false, + "isDisabilityFriendly": false, + "isPartTime": false, + "employmentDate": "2026-06-01T00:00:00+02:00", + "conceptUriDa": "http://data.star.dk/esco/occupation/426e017f-ebe5-4bea-b1eb-7d2d5ab3c6db", + "preferredLabelDa": "Personalekonsulent", + "driversLicenses": [], + "classifications": [], + "shifts": [], + "isFavorite": false + }, + "application": { + "deadlineDate": "2026-04-05T21:59:00+02:00", + "availablePositions": 1, + "contactPersons": [ + { + "firstNames": "Jane", + "lastName": "Doe", + "phoneNumber": "+4512345678" + } + ], + "url": "https://midtjob.dk/ad/...", + "urlText": "", + "isApplicationDeadlineASAP": false + }, + "organisationTypeId": 24, + "user": "Emply 31430747" +} +``` + +> **Note**: `body` contains raw HTML. In `--format plain` output the CLI strips HTML tags to produce readable text. + +> **Note**: `application.url` may be empty for some job ads — the employer may only accept applications through Jobnet's internal system. + +--- + +## `occupations` — Search occupation types + +**Endpoint**: `GET /OccupationSearch` + +```bash +bun run src/cli.ts occupations --search-string <text> [flags] +``` + +Use this command to find occupation identifiers (ESCO concept URIs) to pass as filters to `search`. + +### Flags + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--search-string` | string | **required** | Search term for occupation, e.g. `sygeplejerske` | +| `--per-page` | number | `10` | Max results to return | +| `--format` | string | `json` | Output format: `json`, `table`, `plain` | + +### Example + +```bash +bun run src/cli.ts occupations --search-string "sygeplejerske" --per-page 5 +``` + +### Response shape + +```json +[ + { + "conceptUriDa": "http://data.star.dk/esco/occupation/56f5d45c-1234-4321-abcd-000000000000", + "preferredLabelDa": "Sygeplejerske", + "aliases": [ + { + "aliasIdentifier": "some-uuid", + "conceptUriDa": "http://data.star.dk/esco/occupation/56f5d45c-1234-4321-abcd-000000000000", + "alternativeLabelDa": "Operationssygeplejerske" + } + ] + } +] +``` + +> **Note**: `conceptUriDa` is the full ESCO concept URI. The UUID portion (last path segment) can be used to build occupation filters for `search`. + +--- + +## `suggestions` — Typeahead suggestions + +**Endpoint**: `GET /FindJob/GetTypeaheadSuggestions` + +```bash +bun run src/cli.ts suggestions --query <text> [flags] +``` + +Returns autocomplete strings for the search box — useful for exploring valid Danish job titles before running a `search`. + +### Flags + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--query` | string | **required** | Partial search string to complete | +| `--limit` | number | — | Cap number of suggestions returned | +| `--format` | string | `json` | Output format: `json`, `table`, `plain` | + +### Example + +```bash +bun run src/cli.ts suggestions --query "syge" +bun run src/cli.ts suggestions --query "ingeniør" --limit 5 +``` + +### Response shape + +```json +[ + "sygepleje", + "Sygeplejerske", + "Sygeplejerske \"Lægeassistent\"" +] +``` + +> **Note**: Suggestions are tuned for Danish job titles. English terms like "developer" may return an empty array. + +--- + +## Error handling + +All errors are written to **stderr** in JSON format and exit with code `1`: + +```json +{ "error": "Job ad not found", "code": "NOT_FOUND" } +{ "error": "API request failed: 500 Internal Server Error", "code": "API_ERROR" } +{ "error": "--query is required", "code": "MISSING_REQUIRED" } +{ "error": "--search-string is required", "code": "MISSING_REQUIRED" } +``` + +--- + +## URL construction + +Job ad detail pages on jobnet.dk: + +``` +https://jobnet.dk/job/{jobAdId} +``` + +Company logo images (prefix relative logoUrl from API): + +``` +https://jobnet.dk{logoUrl} +``` + +Example: `https://jobnet.dk/bff/SharedComponents/JobAdCard/CompanyLogo/ByJobAdId/9ef43bce-d82b-4ea1-a098-7ff6520f99be` + +--- + +## Notes + +- All data is from the public `jobnet.dk/bff` REST API — no credentials required. +- The `x-csrf: 1` header must be sent with every request. +- Pagination is 1-indexed (`--page 1` is the first page). +- `search` results intentionally omit the HTML `description` field — use `detail` to fetch it. +- `body` in `detail` responses is raw HTML; use `--format plain` to get stripped text. +- The `occupations` command helps discover ESCO occupation URIs usable as `--occupation-area` / `--occupation-group` seeds for narrowing search results. diff --git a/.agents/skills/jobnet-search/cli/package.json b/.agents/skills/jobnet-search/cli/package.json new file mode 100644 index 0000000..26c888a --- /dev/null +++ b/.agents/skills/jobnet-search/cli/package.json @@ -0,0 +1,24 @@ +{ + "name": "jobnet-cli", + "version": "1.0.0", + "description": "CLI for the Jobnet.dk Danish government job portal API", + "type": "module", + "main": "src/cli.ts", + "bin": { + "jobnet": "src/cli.ts" + }, + "scripts": { + "start": "bun run src/cli.ts", + "test": "bun test --timeout 30000", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@bunli/core": "latest", + "@bunli/utils": "latest", + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.4.0", + "@types/bun": "latest" + } +} diff --git a/.agents/skills/jobnet-search/cli/src/cli.ts b/.agents/skills/jobnet-search/cli/src/cli.ts new file mode 100644 index 0000000..644ee9e --- /dev/null +++ b/.agents/skills/jobnet-search/cli/src/cli.ts @@ -0,0 +1,18 @@ +import { createCLI } from "@bunli/core" +import { search } from "./commands/search.js" +import { detail } from "./commands/detail.js" +import { occupations } from "./commands/occupations.js" +import { suggestions } from "./commands/suggestions.js" + +const cli = await createCLI({ + name: "jobnet-cli", + version: "0.1.0", + description: "CLI for the Jobnet.dk Danish government job portal API", +}) + +cli.command(search) +cli.command(detail) +cli.command(occupations) +cli.command(suggestions) + +await cli.run() diff --git a/.agents/skills/jobnet-search/cli/src/helpers.ts b/.agents/skills/jobnet-search/cli/src/helpers.ts new file mode 100644 index 0000000..866b3aa --- /dev/null +++ b/.agents/skills/jobnet-search/cli/src/helpers.ts @@ -0,0 +1,54 @@ +export const BASE_URL = "https://jobnet.dk/bff" + +export async function apiFetch<T>(path: string, params?: Record<string, string>): Promise<T> { + let url = `${BASE_URL}${path}` + if (params && Object.keys(params).length > 0) { + const qs = new URLSearchParams(params) + url += `?${qs.toString()}` + } + + const maxRetries = 6 + let delay = 500 + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const response = await fetch(url, { + headers: { + "x-csrf": "1", + }, + }) + + if (response.status === 429 || response.status >= 500) { + if (attempt === maxRetries) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`) + } + // Add jitter to spread out retries: base delay + random 0-500ms + const jitter = Math.floor(Math.random() * 500) + await new Promise((resolve) => setTimeout(resolve, delay + jitter)) + delay = Math.min(delay * 2, 5000) + continue + } + + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`) + } + + return response.json() as Promise<T> + } + throw new Error("API request failed after max retries") +} + +export function writeError(error: string, code: string): void { + process.stderr.write(JSON.stringify({ error, code }) + "\n") +} + +export function stripHtml(html: string): string { + return html + .replace(/<[^>]*>/g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim() +} diff --git a/.agents/skills/jobnet-search/cli/tests/helpers.ts b/.agents/skills/jobnet-search/cli/tests/helpers.ts new file mode 100644 index 0000000..75b82b9 --- /dev/null +++ b/.agents/skills/jobnet-search/cli/tests/helpers.ts @@ -0,0 +1,39 @@ +import { join } from "path"; + +const CLI_PATH = join(import.meta.dir, "../src/cli.ts"); + +export interface CLIResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export async function runCLI(args: string[]): Promise<CLIResult> { + const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }; +} + +export function parseJSON<T = unknown>(result: CLIResult): T { + if (result.exitCode !== 0) { + throw new Error( + `CLI exited with code ${result.exitCode}. stderr: ${result.stderr}` + ); + } + try { + return JSON.parse(result.stdout) as T; + } catch { + throw new Error( + `Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}` + ); + } +} diff --git a/.claude/agents/gemini-research-expert.md b/.claude/agents/gemini-research-expert.md new file mode 100644 index 0000000..27ddfc5 --- /dev/null +++ b/.claude/agents/gemini-research-expert.md @@ -0,0 +1,57 @@ +--- +name: gemini-research-expert +description: Use this agent when the user needs to perform research tasks, gather information from external sources, or investigate topics that require web searches and synthesis of information. +model: sonnet +--- + +You are an elite Research Expert specializing in leveraging the Gemini AI model in headless mode to conduct thorough, accurate research on any topic. Your core strength lies in formulating precise research prompts and executing them efficiently using the command-line interface. + +## Your Primary Tool + +You execute research using Gemini in headless mode with this exact syntax: +``` +gemini -p "your research prompt here" +``` + +## Your Research Methodology + +1. **Prompt Formulation**: Before executing any research command, carefully craft your Gemini prompt to: + - Be specific and focused on the exact information needed + - Include context about the domain + - Specify the desired output format (summary, bullet points, comparison, etc.) + - Request citations or sources when factual accuracy is critical + - Set clear boundaries on scope to avoid overly broad results + +2. **Research Execution**: Always use the exact command format `gemini -p "prompt"` with: + - Clear, well-structured questions + - Specific criteria for the information you're seeking + - Any relevant constraints (time period, geographic focus, technical level) + +3. **Information Synthesis**: After receiving Gemini's output: + - Verify the relevance of the information to the user's original request + - Identify key findings and organize them logically + - Note any gaps or areas requiring follow-up research + - Highlight important caveats or limitations in the findings + +4. **Quality Assurance**: + - Cross-reference critical facts when possible + - Distinguish between established facts and emerging trends + - Note the recency of information, especially for fast-moving fields + - Flag any potential biases or incomplete information + +## Operational Guidelines + +- **Always explain your research strategy**: Before executing the gemini command, briefly describe what you're researching and why your prompt is structured as it is +- **Use multiple searches when needed**: Complex questions may require several targeted gemini queries rather than one broad search +- **Adapt prompts based on results**: If initial research is insufficient, refine your approach and execute follow-up queries +- **Provide context with findings**: Don't just relay raw information - interpret it in light of the user's needs +- **Be transparent about limitations**: If Gemini cannot provide certain information or if results are uncertain, clearly communicate this + +## Your Communication Style + +- Be proactive: Anticipate follow-up questions and suggest related areas of research +- Be systematic: Present findings in a clear, organized structure +- Be critical: Evaluate the quality and reliability of information +- Be efficient: Execute focused research rather than broad, unfocused queries + +Your ultimate goal is to transform user questions into actionable research commands and deliver synthesized, reliable information that directly addresses their needs. diff --git a/.claude/commands/apply.md b/.claude/commands/apply.md new file mode 100644 index 0000000..bc46560 --- /dev/null +++ b/.claude/commands/apply.md @@ -0,0 +1,191 @@ +# /apply - Drafter-Reviewer Job Application Workflow + +You are orchestrating a two-agent job application workflow. The job posting is provided below as `$ARGUMENTS` (either a URL or pasted text). + +Follow these steps **exactly in order**. Do not skip steps. + +--- + +## Step 0: Parse Input + +- If `$ARGUMENTS` looks like a URL, use `WebFetch` to retrieve the job posting content. +- If it is pasted text, use it directly. +- Extract: **company name**, **role title**, **department** (if mentioned), **location**, and **language** of the posting (Danish or English). +- Store these for use throughout the workflow. + +--- + +## Step 1: DRAFTER - Evaluate Fit + +Read the evaluation framework: +- `.claude/skills/job-application-assistant/04-job-evaluation.md` +- `.claude/skills/job-application-assistant/01-candidate-profile.md` + +Using the framework from `04-job-evaluation.md`, evaluate the job posting against the candidate's profile. If the salary lookup tool is configured, run: + +```bash +python salary_lookup.py "<Company Name>" --json +``` + +If the posting specifies a city, add `--city "<City>"` to narrow results. Parse the JSON output and include the salary benchmark in the evaluation. If the tool is not configured or returns an error, skip the salary benchmark. + +Present the evaluation to the user with: + +1. **Skills match** - which required/preferred skills match vs. gaps +2. **Experience match** - how work history maps to the role +3. **Behavioral/culture match** - how behavioral profile fits the role/company culture +4. **Salary benchmark** - salary index for the company (if available) +5. **Overall fit score** and recommendation (strong fit / moderate fit / weak fit) + +After presenting the evaluation, ask the user: +> "Should I proceed with drafting the CV and cover letter for this role?" + +**If the user says no, stop here.** If yes, continue to Step 2. + +--- + +## Step 2: DRAFTER - Draft CV + Cover Letter + +Read the following reference files: +- `.claude/skills/job-application-assistant/01-candidate-profile.md` +- `.claude/skills/job-application-assistant/03-writing-style.md` +- `.claude/skills/job-application-assistant/05-cv-templates.md` +- `.claude/skills/job-application-assistant/06-cover-letter-templates.md` + +Also read the most recent existing CV and cover letter files for structural reference: +- Read any existing `cv/main_*.tex` file as a LaTeX template reference +- Read any existing `cover_letters/cover_*.tex` or `cover_letters/Cover_*.tex` file as a template reference + +### CV (`cv/main_<company>.tex`) +- Always in **English** +- Follow the moderncv/banking format from `05-cv-templates.md` +- Tailor the profile statement and experience bullets to the specific role +- Reframe skills and achievements to match job requirements +- Keep to 2 pages + +### Cover Letter (`cover_letters/cover_<company>_<role>.tex`) +- **Match the language of the job posting** (Danish posting -> Danish cover letter, English posting -> English cover letter) +- Follow the structure from `06-cover-letter-templates.md` +- Use the `cover.cls` template +- Tailor the opening paragraph to the specific role and company +- Address to a named person if available in the posting, otherwise "Dear Hiring Manager" (or equivalent in posting language) +- Keep to approximately one page +- Any mention of agentic coding or AI tooling must reference **Claude Code** by name + +Write both files to disk. + +--- + +## Step 3: REVIEWER - Research & Critique + +Use the **Agent tool** to spawn a `general-purpose` reviewer agent with the following prompt: + +``` +You are a hiring manager proxy reviewing a job application. Your job is to make the application as targeted and compelling as possible. + +## Your Tasks + +### 1. Research the Company +Use WebSearch and WebFetch to research: +- The company's website, mission, and recent news +- The specific department or team (if mentioned in the posting) +- Any recent projects, press releases, or strategic initiatives relevant to the role +- Company culture and values + +### 2. Read All Reference Materials +Read these files to understand the candidate and quality standards: +- `.claude/skills/job-application-assistant/01-candidate-profile.md` +- `.claude/skills/job-application-assistant/02-behavioral-profile.md` +- `.claude/skills/job-application-assistant/03-writing-style.md` +- `.claude/skills/job-application-assistant/04-job-evaluation.md` +- `.claude/skills/job-application-assistant/05-cv-templates.md` +- `.claude/skills/job-application-assistant/06-cover-letter-templates.md` + +### 3. Read the Drafts +Read the drafted CV and cover letter: +- `cv/main_<COMPANY>.tex` +- `cover_letters/cover_<COMPANY>_<ROLE>.tex` + +### 4. Read the Job Posting +<JOB_POSTING> +<INSERT_JOB_POSTING_TEXT_HERE> +</JOB_POSTING> + +### 5. Produce Feedback +Return a structured critique with **specific, actionable suggestions** in these categories: + +**a) Missed keywords/requirements** +- List any requirements or keywords from the posting that are not addressed in the CV or cover letter +- For each, suggest where and how to add them (with specific text suggestions) + +**b) Company/department-specific angles** +- Based on your research, suggest specific angles to add +- Suggest how to connect experience to the company's strategic priorities + +**c) Action-oriented reframing** +- Identify passive or generic statements and suggest action-oriented rewrites + +**d) Tone and style issues** +- Check against the writing style guide (03-writing-style.md) +- Flag any issues with tone, formality, or voice + +**e) Verification checklist** +Run this checklist and report pass/fail for each: +- [ ] All claims match actual profile - no fabricated skills, experience, or achievements +- [ ] Job titles, dates, company names, and locations are correct +- [ ] Contact details are correct +- [ ] Profile statement is tailored to the specific role +- [ ] Key job requirements are addressed +- [ ] No LaTeX syntax errors (balanced braces, correct commands) +- [ ] No spelling or grammar errors +- [ ] Agentic coding / AI tooling references mention Claude Code by name +- [ ] Cover letter addressed correctly +- [ ] Cover letter fits approximately one page +- [ ] CV follows 2-page moderncv/banking format + +**CRITICAL RULE:** All suggestions must be grounded in actual profile data. Do NOT suggest fabricating skills, experience, or achievements. If a requirement is a gap, say so honestly and suggest how to frame adjacent experience instead. + +Return your full feedback as a single structured message. +``` + +**Important:** Before spawning the agent, replace `<COMPANY>`, `<ROLE>`, and `<INSERT_JOB_POSTING_TEXT_HERE>` with the actual values from Steps 0-2. + +--- + +## Step 4: DRAFTER - Revise Based on Feedback + +Once the reviewer agent returns its feedback: + +1. Read the reviewer's suggestions carefully +2. Read both draft files again +3. Incorporate the suggestions that improve the application: + - Add missed keywords where they fit naturally + - Add company-specific angles from the reviewer's research + - Reframe passive statements to be more action-oriented + - Fix any tone/style issues + - Fix any verification checklist failures +4. Update both files **in place** (edit, don't recreate) +5. Do NOT incorporate suggestions that would fabricate skills or experience + +--- + +## Step 5: Present Final Output + +After revision, present to the user: + +### Verification Checklist +Re-run the full verification checklist from CLAUDE.md and report pass/fail for each item. + +### Key Tailoring Decisions +Summarize 3-5 key decisions made to tailor the application: +- What was emphasized and why +- What company-specific angles were incorporated +- What the reviewer suggested that was most impactful +- Any gaps that were acknowledged or reframed + +### Files Created +List the files written: +- `cv/main_<company>.tex` +- `cover_letters/cover_<company>_<role>.tex` + +Tell the user: "Both files are ready for your review. Open them to check the final output before compiling." diff --git a/.claude/commands/setup.md b/.claude/commands/setup.md new file mode 100644 index 0000000..46b0024 --- /dev/null +++ b/.claude/commands/setup.md @@ -0,0 +1,198 @@ +# /setup - Profile Onboarding Interview + +You are running the onboarding setup for the AI Job Search framework. Your goal is to collect the user's professional information and populate all profile files so the `/apply` workflow works out of the box. + +--- + +## Step 0: Welcome & Choose Path + +Welcome the user and explain what this setup does. Then offer two paths: + +> **Welcome to the AI Job Search setup!** +> +> I'll help you set up your professional profile so Claude can evaluate job postings, tailor CVs, write cover letters, and prepare you for interviews. +> +> **Two ways to get started:** +> +> **Path A: Import from CV (recommended)** - Share your existing CV or resume (mention the file with @ or paste the text). I'll extract your information automatically and ask follow-up questions for anything missing. +> +> **Path B: Interview mode** - I'll walk you through structured questions section by section. Great if you're starting from scratch. +> +> Which do you prefer? + +If the user specifies `$ARGUMENTS` containing `--section <name>`, skip to that section only for updating. + +--- + +## Path A: Document Import + +If the user provides a CV/resume: + +1. Read the document thoroughly +2. Extract all structured information: name, contact, education, experience, skills, publications, awards +3. Present a summary of what was extracted +4. Ask follow-up questions for gaps (behavioral profile, career goals, deal-breakers, salary expectations, references) +5. Proceed to file generation (Step 3) + +--- + +## Path B: Interview Mode + +Walk through each section conversationally. Ask questions naturally, not as a form. Let the user answer in their own words and you'll structure the data. + +### Section 1: Identity & Contact +Ask about: +- Full name +- Location (city, country) +- Phone, email, LinkedIn, GitHub +- Languages spoken (with proficiency levels) +- Current employment status +- Family/commute constraints (if any) + +### Section 2: Education +For each degree: +- Level (PhD, MSc, BSc, etc.), field, institution, years +- Thesis topic (if applicable) +- Key coursework or topics + +Also ask about certifications (online courses, professional certs). + +### Section 3: Professional Experience +For each role (most recent first): +- Job title, company, dates, location +- Key responsibilities (3-5 bullets) +- Key achievements or projects +- Technologies/tools used + +Also ask about independent projects, freelance work, or side projects. + +### Section 4: Technical Skills +- Programming languages + proficiency level +- ML/AI frameworks and tools +- Domain expertise +- Software tools and platforms +- Any other technical skills + +### Section 5: Publications & Awards (optional) +- Peer-reviewed papers, conference presentations +- Hackathons, competitions, awards +- Skip if not applicable + +### Section 6: Behavioral Profile (optional) +If they have a formal assessment (PI, DISC, Myers-Briggs, StrengthsFinder): +- Ask them to describe or share the results + +If not, ask behavioral questions: +- "What work environments do you thrive in?" +- "What drains your energy at work?" +- "How do you prefer to work in teams?" +- "How do you make decisions - quickly or deliberately?" +- "What's your communication style?" +- Synthesize answers into a behavioral profile + +### Section 7: Career Goals & Preferences +- Target roles and industries +- What excites you in work +- Deal-breakers and must-haves +- Salary expectations/baseline (optional) +- What environments to avoid +- Commute/location constraints + +### Section 8: References (optional) +For each reference: +- Name, title, company, email, phone +- Relationship to the user + +### Section 9: Job Search Configuration +This section generates the search queries that power `/scrape`. Use the information from Sections 1, 4, and 7 to build targeted queries. + +Ask about: +- **Role titles to search for:** "What job titles should I search for? For example: Data Scientist, ML Engineer, Geophysicist." Collect 3-8 specific titles. +- **Key skills as search terms:** "Which of your skills are most likely to appear in job postings?" Pick 3-5 that are distinctive and searchable. +- **Target companies (optional):** "Are there specific companies you'd like to monitor for openings?" +- **Geographic scope:** "Which cities or regions should I search in? How far are you willing to commute?" Use this to define the location filter tiers (ideal, acceptable, borderline, too far). +- **Job portals:** "The framework includes tools for Danish job portals (Jobindex, Jobbank, Jobdanmark, Jobnet). Are these the right ones for you, or do you use other sites?" Note: if the user is outside Denmark, acknowledge that the built-in CLI tools are Denmark-specific and suggest they can add their own portal integrations or rely on LinkedIn/Google site-searches. + +**Important:** Also suggest role types the user may not have considered, based on their skill profile. For example: +- If they have strong Python + domain expertise: "Have you considered roles like 'Technical Consultant' or 'Solutions Engineer' in your domain?" +- If they have ML + a specific industry: "Companies in adjacent industries also hire for these skills. Should I include searches for [adjacent sector]?" +- If they have project management experience alongside technical skills: "Would you also want to search for 'Technical Project Manager' or 'Team Lead' roles?" + +This proactive suggestion step helps users discover career paths they might not have considered. + +--- + +## Step 3: Generate Profile Files + +Once all information is collected (via either path), generate the following files: + +### 1. Update `CLAUDE.md` +Replace all `[PLACEHOLDER]` tokens with the user's actual information. Keep the structure, workflow, and verification checklist intact. + +### 2. Populate `01-candidate-profile.md` +Write the full candidate profile with structured sections: Identity, Education, Professional Experience, Independent Projects, Technical Skills, Publications, Awards, References. + +### 3. Populate `02-behavioral-profile.md` +Write the behavioral profile based on assessment results or synthesized answers. + +### 4. Update `04-job-evaluation.md` +Replace skill match areas with the user's actual skills: +- Strong match areas: [their primary skills] +- Moderate match areas: [their secondary skills] +- Weak match areas: [skills they lack] + +Update career goals and motivation filters with their actual preferences. + +### 5. Update `05-cv-templates.md` +Add role-specific profile statement templates based on their background. + +### 6. Update `07-interview-prep.md` +Create STAR examples from their actual experience (at least 3-4 examples). + +### 7. Update `cv/main_example.tex` +Replace placeholder personal data with their actual name, contact info, and add their education and most recent experience entries. + +### 8. Generate `.claude/skills/job-scraper/search-queries.md` +Replace all placeholder tokens in the search queries file with the user's actual information from Section 9: +- Replace `[YOUR_PRIMARY_ROLE_TYPE]`, `[YOUR_PRIMARY_JOB_TITLE]`, etc. with actual role titles +- Replace `[YOUR_KEY_SKILL]`, `[YOUR_DOMAIN_KEYWORD_1]`, etc. with actual skills and domain terms +- Replace `[YOUR_CITY]`, `[YOUR_COUNTRY]`, `[YOUR_REGION]` with actual location +- Fill in the location filter tiers (ideal, acceptable, borderline, too far) based on commute constraints +- Organize queries into priority categories matching the user's career direction: + - Priority 1: Their strongest/most desired role direction + - Priority 2: Their domain expertise + - Priority 3: Adjacent roles they could pivot into + - Priority 4: Broader roles (wider net) + +--- + +## Step 4: Confirm & Next Steps + +Present a summary: + +> **Setup complete!** Here's what was generated: +> +> - `CLAUDE.md` - Your full candidate profile +> - `.claude/skills/job-application-assistant/01-candidate-profile.md` - Structured profile +> - `.claude/skills/job-application-assistant/02-behavioral-profile.md` - Behavioral assessment +> - `.claude/skills/job-application-assistant/04-job-evaluation.md` - Personalized evaluation framework +> - `.claude/skills/job-application-assistant/05-cv-templates.md` - CV templates with your profile statements +> - `.claude/skills/job-application-assistant/07-interview-prep.md` - STAR examples from your experience +> - `cv/main_example.tex` - Your LaTeX CV template +> - `.claude/skills/job-scraper/search-queries.md` - Job search queries for `/scrape` +> +> **Try it out:** +> - Run `/scrape` to search for matching jobs right now +> - Run `/apply` with a job posting URL to see the full application workflow +> - Run `/setup --section search` later to update your search queries as your priorities evolve + +--- + +## Design Principles + +- Each section is a natural conversation, not a form +- The user can skip optional sections +- Synthesize answers into structured formats (the user doesn't need to know markdown or LaTeX) +- Can be re-run with `--section <name>` to update specific sections (e.g., `/setup --section search` to reconfigure job search queries without re-doing the full profile) +- Section 9 (search) proactively suggests role types the user may not have considered +- At the end, suggest running `/scrape` and `/apply` with a test job posting diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..388e737 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Skill(job-application-assistant)", + "Bash(python3:*)", + "Bash(python:*)", + "Bash(curl:*)", + "Bash(bun:*)" + ] + } +} diff --git a/.claude/skills/job-application-assistant/01-candidate-profile.md b/.claude/skills/job-application-assistant/01-candidate-profile.md new file mode 100644 index 0000000..a8a681a --- /dev/null +++ b/.claude/skills/job-application-assistant/01-candidate-profile.md @@ -0,0 +1,60 @@ +# Candidate Profile + +<!-- SETUP: This file is populated by running /setup --> +<!-- After running /setup, all sections will be filled with your actual information --> + +## Identity +- **Name:** [YOUR_NAME] +- **Location:** [YOUR_ADDRESS] +- **Phone:** [YOUR_PHONE] +- **Email:** [YOUR_EMAIL] +- **LinkedIn:** [YOUR_LINKEDIN_URL] +- **GitHub:** [YOUR_GITHUB_URL] +- **Languages:** [YOUR_LANGUAGES with proficiency levels] +- **Status:** [YOUR_EMPLOYMENT_STATUS] +- **Constraints:** [YOUR_COMMUTE_OR_LOCATION_CONSTRAINTS] + +## Education + +| Degree | Period | Institution | Key Topics | +|--------|--------|-------------|------------| +| [DEGREE] | [YEARS] | [INSTITUTION] | [TOPICS] | + +## Professional Experience + +### [JOB_TITLE] - [COMPANY] ([START] - [END]) +[LOCATION] +- [RESPONSIBILITY_OR_ACHIEVEMENT_1] +- [RESPONSIBILITY_OR_ACHIEVEMENT_2] +- [RESPONSIBILITY_OR_ACHIEVEMENT_3] + +<!-- Add more roles as needed --> + +## Independent Projects +<!-- Projects outside of employment: freelance, open source, personal --> +- **[PROJECT_NAME]**: [DESCRIPTION] + +## Technical Skills + +### Programming & ML +- **[LANGUAGE]** ([PROFICIENCY]): [FRAMEWORKS_AND_LIBRARIES] +- [OTHER_SKILLS] + +### Domain Expertise +- [DOMAIN_1] +- [DOMAIN_2] + +### Software & Tools +- [TOOL_LIST] + +## Publications +<!-- List peer-reviewed publications, if any --> +1. [AUTHOR_LIST] ([YEAR]). [TITLE]. [JOURNAL]. [DOI_LINK] + +## Awards +- [AWARD] - [EVENT] ([YEAR]) + +## References +- [NAME], [TITLE], [COMPANY] ([EMAIL], [PHONE]) + +More references available upon request. diff --git a/.claude/skills/job-application-assistant/02-behavioral-profile.md b/.claude/skills/job-application-assistant/02-behavioral-profile.md new file mode 100644 index 0000000..a52f4ad --- /dev/null +++ b/.claude/skills/job-application-assistant/02-behavioral-profile.md @@ -0,0 +1,50 @@ +# Behavioral Profile + +<!-- SETUP: This file is populated by running /setup --> +<!-- You can use results from PI, DISC, Myers-Briggs, StrengthsFinder, or a self-assessment --> + +## Overview +[YOUR_NAME]'s behavioral assessment identifies them as a **[PROFILE_TYPE]** pattern. [1-2 SENTENCE_SUMMARY]. + +## Core Behavioral Drives + +| Drive | Level | Meaning | +|-------|-------|---------| +| [DRIVE_1] | [LEVEL] | [DESCRIPTION] | +| [DRIVE_2] | [LEVEL] | [DESCRIPTION] | +| [DRIVE_3] | [LEVEL] | [DESCRIPTION] | +| [DRIVE_4] | [LEVEL] | [DESCRIPTION] | + +## Strongest Behaviors +- **[BEHAVIOR_1]:** [DESCRIPTION] +- **[BEHAVIOR_2]:** [DESCRIPTION] +- **[BEHAVIOR_3]:** [DESCRIPTION] + +## How You Work Best +- [ENVIRONMENT_PREFERENCE_1] +- [ENVIRONMENT_PREFERENCE_2] +- [ENVIRONMENT_PREFERENCE_3] + +## Growth Areas (frame positively in applications) +- **[AREA_1]:** [HOW_TO_FRAME_IT_POSITIVELY] +- **[AREA_2]:** [HOW_TO_FRAME_IT_POSITIVELY] + +## Mapping to Job Posting Language + +When a job posting mentions these keywords, it's a **strong behavioral fit**: +- [KEYWORD_OR_PHRASE_THAT_MATCHES_YOUR_STYLE] +- [ANOTHER_KEYWORD] + +When a job posting mentions these, flag as **potential friction** (not deal-breaker): +- [KEYWORD_OR_PHRASE_THAT_MIGHT_CLASH] +- [ANOTHER_KEYWORD] + +## Management Style Preferences +- [WHAT_MANAGEMENT_STYLE_WORKS_FOR_YOU] +- [WHAT_DOESN'T_WORK] + +## Using This in Applications +- **Cover letters:** [HOW_TO_WEAVE_IN_BEHAVIORAL_STRENGTHS] +- **CV:** [WHAT_TO_EMPHASIZE] +- **Interviews:** [WHAT_STAR_EXAMPLES_TO_USE] +- **Don't overstate:** [WHAT_NOT_TO_CLAIM] diff --git a/.claude/skills/job-application-assistant/03-writing-style.md b/.claude/skills/job-application-assistant/03-writing-style.md new file mode 100644 index 0000000..cf0764f --- /dev/null +++ b/.claude/skills/job-application-assistant/03-writing-style.md @@ -0,0 +1,106 @@ +# Writing Style Guide + +## Critical Rules + +1. **NO em-dashes (--).** Use commas, periods, or restructure the sentence instead. +2. **NO cliches or filler phrases.** Cut: "I am passionate about", "I believe I would be a great fit", "leverage my skills", "hit the ground running", "drive results", "synergies". +3. **NO generic buzzwords** without concrete backing. Every claim must be supported by a specific example or fact. +4. **NO apologetic or overly humble language.** Not "I think I could contribute" but "I bring X, demonstrated by Y." +5. **NO unverified company claims.** Every company-specific statement in a cover letter (partnerships, product names, technology descriptions, expansions) must be independently verified via WebFetch or WebSearch before inclusion. Do not trust reviewer agent research at face value. If a claim cannot be verified, rephrase it in general terms or omit it. +6. **Reframe emphasis, not substance.** Some framing of experience toward the target role is expected. But apply the **interview backtrack test**: could the candidate comfortably explain this bullet in an interview without backtracking? If they'd have to say "well, what I actually meant was..." then it's too far. Specifically: + - **OK:** Reordering experience to lead with what's most relevant; using natural synonyms for the target domain; emphasizing one aspect of a broad role. + - **Flag it:** Combining academic + industry experience into a single claim that implies it was all industry; describing work using the posting's specific terminology when the actual work was adjacent but not the same. + - **Never:** Claiming experience the candidate doesn't have; implying they worked in a domain they haven't. + When a bullet falls in the "flag it" zone, present it to the user after drafting with: "This bullet is a stretch because X. Keep, soften, or drop?" If the evaluation experience match score is below 50, warn before proceeding to drafting that extensive reframing would be needed. + +## Tone +- **Warm but direct.** Friendly and approachable, but confident without arrogance. +- **Conversational professional.** Not stiff corporate-speak, not casual chat. Think: how a confident person talks in a good job interview. +- **First person, active voice.** "I built" not "a system was developed by the candidate." +- **Demonstrate, don't state.** Instead of "I am a team player", write a specific example of teamwork and its outcome. + +## Application Headline (Best Practice) + +The subject line / headline of the application should be engaging and specific, not generic. + +**Bad:** "Application for Sales Engineer Position" / "Ansogning til stilling som ingeniør" +**Good:** "[Your specialty] specializing in [relevant keyword from posting]" + +Formula: **[Title/education] + [relevant keyword from the job posting]** + +## Scannable Structure (Best Practice) + +Employers scan applications quickly. Structure for easy reading: +- Use descriptive subheadings that reflect content (not just "Introduction" / "Body") +- Include industry-specific keywords in headings where natural +- Write concisely - eliminate filler language +- One page maximum (hard rule) + +## Forward-Looking Framing (Best Practice) + +The cover letter is **not a CV repetition**. It should be forward-looking: +- Focus on **tasks you can solve for the employer**, not just what you've done before +- Describe your approach: methods, tools, knowledge you'll bring +- Explain what positive outcomes the employer can expect from hiring you +- Use 1-2 brief past examples only to back up forward-looking claims + +## Cover Letter Structure + +### Opening Paragraph +- State the role and why you're writing (1 sentence) +- Immediately connect your background to the role (1-2 sentences) +- Make it specific to this company/role, not a template opener + +### Body Paragraphs - Task-Solving Focus +- Lead with the most relevant experience for this specific role +- Frame content around **which of their tasks you can solve and how** +- Describe your approach: methods, tools, and knowledge you'll bring +- Use bullet lists for concrete skills/achievements when appropriate (3-5 bullets) +- Each bullet should be specific and outcome-oriented +- Include at least one example that shows initiative +- Include 1-2 brief examples of past success, but keep the focus forward-looking + +### Motivation / Why This Company (place early) +- The **first section** after the opening should explain why you're applying to *this specific company* +- Use language and themes from the job posting and company website +- Focus on how you'll contribute to their goals, not what you gain from employment +- If you spoke with someone at the company, reference the conversation naturally + +### Company-Specific Paragraph +- Show you've researched the company (mention specific projects, values, or market position) +- Explain why this company specifically, not just "a company like yours" +- Connect domain knowledge to their business context + +### Closing +- Brief, confident, forward-looking +- "I look forward to hearing from you" or "I would welcome the opportunity to discuss..." +- No begging or over-enthusiasm + +## Bullet Point Style +- Start with action verb or bold category label +- Be specific: numbers, tools, outcomes +- Vary the structure (not every bullet starts the same way) + +## Language for Different Role Types + +### Technical/ML roles +- Lead with programming languages, ML frameworks, specific model architectures +- Mention datasets, data volumes, pipeline complexity +- Include independent projects + +### Domain-specific roles +- Lead with domain expertise and specific methods +- Frame technical skills as tools that enhance domain analysis + +### Consulting/Advisory roles +- Lead with stakeholder communication, project coordination, client interaction +- Emphasize ability to bridge technical and business perspectives + +### Leadership/Senior roles +- Lead with project management, mentoring, course development +- Frame advanced degrees as evidence of independent project delivery + +## Multi-language Applications +- Default to the language of the job posting +- Cover letters in the posting's language should feel natural, not translated +- Slightly warmer, more personal tone may be acceptable in some languages diff --git a/.claude/skills/job-application-assistant/04-job-evaluation.md b/.claude/skills/job-application-assistant/04-job-evaluation.md new file mode 100644 index 0000000..9852338 --- /dev/null +++ b/.claude/skills/job-application-assistant/04-job-evaluation.md @@ -0,0 +1,173 @@ +# Job Evaluation Framework + +<!-- SETUP: Skill match areas and career goals are personalized by running /setup --> + +## Scoring Dimensions + +Evaluate each job posting against these five dimensions: + +### 1. Technical Skills Match (0-100) +How well do the required/preferred skills align with the candidate's capabilities? + +| Score | Meaning | +|-------|---------| +| 80-100 | Core requirements are primary skills | +| 60-79 | Most requirements match, 1-2 gaps that are learnable | +| 40-59 | Partial match, significant upskilling needed | +| 0-39 | Fundamental mismatch | + +**Strong match areas:** [YOUR_PRIMARY_SKILLS] +**Moderate match areas:** [YOUR_SECONDARY_SKILLS] +**Weak match areas:** [SKILLS_YOU_LACK] + +### 2. Experience Match (0-100) +Does work history align with what they're looking for? + +| Score | Meaning | +|-------|---------| +| 80-100 | Direct experience in the same domain and role type | +| 60-79 | Related experience, transferable skills clear | +| 40-59 | Adjacent experience, would need to make the case | +| 0-39 | Unrelated experience | + +**Strong:** [YOUR_DIRECT_EXPERIENCE_DOMAINS] +**Moderate:** [YOUR_ADJACENT_EXPERIENCE] +**Entry-level:** [ROLES_WITH_LIMITED_EXPERIENCE] + +### 3. Behavioral/Culture Fit (0-100) +Does the role and company culture match the behavioral profile? + +| Score | Meaning | +|-------|---------| +| 80-100 | Culture strongly matches behavioral preferences | +| 60-79 | Mixed signals but mostly compatible | +| 40-59 | Some friction areas | +| 0-39 | Significant culture mismatch | + +**Red flags to research:** Department disorganization, work dominated by maintenance over development, poor chemistry with leadership, culture mismatches. Check reviews, media coverage, LinkedIn connections, and network contacts for insider perspective. + +### 4. Location & Logistics (Pass/Fail + Notes) +- Within commute range: PASS +- Remote with occasional office: PASS +- Requires relocation: FAIL (deal-breaker) +- Frequent international travel: FLAG (discuss with user) + +### 5. Career Alignment & Motivation (0-100) +Does this role advance career goals and contain tasks that energize? + +| Score | Meaning | +|-------|---------| +| 80-100 | Strongly aligned with career direction, clear growth path | +| 60-79 | Good role but only partially aligned with long-term goals | +| 40-59 | Decent job but doesn't build toward career goals | +| 0-39 | Dead end or backwards step | + +**Career goals:** +- [YOUR_CAREER_GOAL_1] +- [YOUR_CAREER_GOAL_2] +- [YOUR_CAREER_GOAL_3] + +**Motivation filter:** Evaluate not just whether you *can* do the tasks, but whether the tasks will *energize* you. Consider: +- Tasks that energize: [YOUR_ENERGIZING_TASKS] +- Tasks that drain: [YOUR_DRAINING_TASKS] +- Non-task factors: leadership style, department culture, company values, degree of autonomy + +**Life situation alignment:** Consider personal constraints: +- **Security**: [YOUR_FINANCIAL_SITUATION_CONTEXT] +- **Flexibility**: [YOUR_SCHEDULE_CONSTRAINTS] +- **Professional development**: [YOUR_GROWTH_PRIORITIES] + +### 6. Salary Benchmark (Optional) + +If the salary lookup tool is configured (`salary_data.json` exists), look up the company: +``` +python salary_lookup.py "<Company Name>" --json +``` + +If a city is known from the posting, add `--city "<City>"` to narrow results. + +Present findings as: +``` +### Salary Benchmark +| Metric | Value | +|--------|-------| +| [Category] index | XX.X (+/-X.X% vs baseline) | +| Overall index | XX.X (+/-X.X% vs baseline) | +``` + +Interpret results relative to the baseline defined in the data file's metadata. For index-based data, higher typically means above-market compensation. + +If the salary tool is not configured, skip this section. + +## Output Format + +Present the evaluation as: + +``` +## Job Fit Evaluation: [Role] at [Company] + +| Dimension | Score | Notes | +|-----------|-------|-------| +| Technical Skills | XX/100 | [brief note] | +| Experience Match | XX/100 | [brief note] | +| Behavioral Fit | XX/100 | [brief note] | +| Location | PASS/FAIL | [brief note] | +| Career Alignment | XX/100 | [brief note] | + +**Overall Score: XX/100** (weighted average of scored dimensions) + +### Verdict: [Strong Fit / Good Fit / Moderate Fit / Weak Fit / Poor Fit] + +### Key Strengths for This Role +- [bullet points] + +### Gaps to Address +- [bullet points] + +### Recommendation +[1-2 sentences: apply/skip/apply with caveats] + +### Company Research Checklist +- [ ] Checked company website (mission, values, recent news) +- [ ] Checked review sites (Glassdoor, Jobindex, etc.) +- [ ] Checked LinkedIn for team size, recent hires, connections +- [ ] Checked media for restructuring, growth, or workplace issues +- [ ] Identified network contacts who may know the team/manager +``` + +## Weighting +- Technical Skills: 30% +- Experience Match: 25% +- Behavioral Fit: 15% +- Career Alignment: 30% + +(Location is pass/fail, not weighted) + +## Thresholds +- **Strong Fit** (75+): Definitely apply, tailor everything +- **Good Fit** (60-74): Apply, address gaps in cover letter +- **Moderate Fit** (45-59): Consider carefully, discuss with user +- **Weak Fit** (30-44): Probably skip unless strategic reasons +- **Poor Fit** (<30): Skip + +## Pre-Application: Call the Employer (Best Practice) + +Before writing the application, consider whether the candidate should call the contact person listed in the posting. **Only call if there are substantive questions** - never call just to "be remembered." + +### When to Suggest Calling +- The posting has unclear or ambiguous requirements +- It's unclear which competencies are essential vs. nice-to-have +- The role description is vague about day-to-day tasks +- There's a named contact person who invites questions + +### Good Questions to Ask +- "What are the primary challenges in this role?" +- "How is time typically divided across the listed responsibilities?" +- "Which competencies are most critical for success in this position?" +- "What does success look like in the first 6-12 months?" + +### Rules for the Call +- Prepare a 30-second "elevator pitch" about your background in case they ask +- The call's purpose is **gathering information**, not delivering a pitch +- Take notes - use what you learn to tailor the application +- Reference the conversation naturally in the cover letter ("After speaking with [name], I was especially drawn to...") diff --git a/.claude/skills/job-application-assistant/05-cv-templates.md b/.claude/skills/job-application-assistant/05-cv-templates.md new file mode 100644 index 0000000..f9aef7b --- /dev/null +++ b/.claude/skills/job-application-assistant/05-cv-templates.md @@ -0,0 +1,141 @@ +# CV Templates and Tailoring Guide + +<!-- SETUP: Profile statements and section ordering are personalized by running /setup --> + +## Template: LaTeX moderncv (Banking Style) + +All CVs use the moderncv LaTeX package with the "banking" style and "blue" color scheme. + +**Output file:** `cv/main_<company>.tex` +**Compile with:** pdflatex (not xelatex) +**Master reference:** `cv/main_example.tex` (comprehensive CV with all competencies, experience, and achievements - use as source when building targeted CVs) + +## Document Structure + +```latex +\documentclass[11pt,a4paper,sans]{moderncv} +\moderncvstyle{banking} +\moderncvcolor{blue} + +\usepackage[utf8]{inputenc} +\usepackage{hyperref} +\hypersetup{ + colorlinks=true, + linkcolor=blue, + filecolor=magenta, + urlcolor=blue, + pdftitle={[YOUR_NAME] - CV}, + pdfpagemode=FullScreen, +} +\usepackage[scale=0.77]{geometry} +\usepackage{import} + +% Personal data +\name{[FIRST_NAME]}{[LAST_NAME]} +\address{[YOUR_ADDRESS]}{}{} +\phone[mobile]{[YOUR_PHONE]} +\email{[YOUR_EMAIL]} +\extrainfo{\href{[YOUR_LINKEDIN_URL]}{LinkedIn}, \href{[YOUR_GITHUB_URL]}{GitHub}} + +\begin{document} +\makecvtitle + +% 1. Profile statement (1-3 sentences, tailored per role) +% 2. Skills section +% 3. Education section +% 4. Professional Experience section +% 5. Selected Publications (if applicable) +% 6. Honors and Awards (if applicable) +% 7. References + +\end{document} +``` + +## Section-by-Section Tailoring + +### Profile Statement / Elevator Pitch (Best Practice) +This is the most important section to customize. It appears right after `\makecvtitle`. + +Write 5-7 lines that function as an "elevator pitch": a concise, compelling introduction explaining why you're qualified for *this specific role*. Focus on what the employer gains from hiring you. + +**Create 2-3 profile statement templates for your main role types:** + +<!-- SETUP: These are populated based on your background --> +**For [YOUR_PRIMARY_ROLE_TYPE] roles:** +> [YOUR_PROFILE_STATEMENT_TEMPLATE_1] + +**For [YOUR_SECONDARY_ROLE_TYPE] roles:** +> [YOUR_PROFILE_STATEMENT_TEMPLATE_2] + +### Core Competencies / Skills Section (Best Practice) +Reorder and emphasize based on the role. Use bold category labels. + +List **5-7 key competencies** in bullet format, tailored to the specific job. For each competency, briefly explain how it adds value to the position. + +### Education +- Always include your highest degrees +- For senior roles, keep education brief (dates and titles only) +- Include thesis topics when relevant to the target role + +### Professional Experience +- Rewrite bullet points to emphasize aspects most relevant to the target role +- Use 4-6 bullets for most recent role, 3-4 for previous, 2-3 for older +- **Emphasize measurable results** where possible: "Reduced processing time by X%", "Model adopted by the team" + +### Handling Employment Gaps (Best Practice) +If there is a gap in your employment history: +- The gap should be explained matter-of-factly if needed +- Describe how professional development continued during the gap +- Frame as deliberate skill-building and career repositioning + +### Publications +- Include Google Scholar link if applicable +- Select 3-4 most relevant publications (not always all of them) +- For non-academic roles, keep brief + +### Honors and Awards +- Keep format brief, one line each + +### References +- List 2-4 references with name, title, company, and contact +- End with: "More references are available upon request." +- **Do not attach reference letters** - employers typically contact references directly + +## Page Budget - Hard 2-Page Limit + +The CV **must** fit on exactly 2 pages when compiled. Use these content limits as a guide: + +| Section | Max budget | +|---------|-----------| +| Profile statement | 3-4 lines | +| Skills | 5 items, each 1-2 lines | +| Most recent role | 4-5 bullets | +| Previous role | 2-3 bullets | +| Older roles | 2 bullets (1 line each) | +| Education | 2-3 entries | +| Publications | 2-3 entries | +| Awards | 3 entries, single line each | +| References | "Available upon request." (single line) | + +**If in doubt, cut rather than squeeze.** Reducing `\vspace` or geometry scale to force-fit content makes the CV look cramped. + +## Recommended Section Order + +The section order varies by role type: + +**For technical / data science / ML roles:** +1. Profile statement / elevator pitch +2. Core competencies / Skills +3. Professional Experience (reverse chronological) +4. Education (reverse chronological) +5. Languages +6. Publications & Awards +7. References + +**For domain-specific / specialist roles:** +1. Profile statement / elevator pitch +2. Core competencies / Skills +3. Education (reverse chronological) - credentials are a key qualifier +4. Professional Experience (reverse chronological) +5. Publications & Awards +6. References diff --git a/.claude/skills/job-application-assistant/06-cover-letter-templates.md b/.claude/skills/job-application-assistant/06-cover-letter-templates.md new file mode 100644 index 0000000..9bea2d0 --- /dev/null +++ b/.claude/skills/job-application-assistant/06-cover-letter-templates.md @@ -0,0 +1,126 @@ +# Cover Letter Templates and Tailoring Guide + +## Template: Custom cover.cls (XeLaTeX) + +Cover letters use a custom LaTeX document class (`cover.cls`) with Lato/Raleway fonts. + +**Output file:** `cover_letters/cover_<company>_<role>.tex` +**Compile with:** XeLaTeX (not pdflatex) +**Font directory:** `cover_letters/OpenFonts/fonts/` + +## Document Structure + +```latex +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +% Cover Letter - [Company], [Role] +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +\documentclass[]{cover} +\usepackage{fancyhdr} + +\pagestyle{fancy} +\fancyhf{} + +\rfoot{Page \thepage \hspace{0pt}} +\thispagestyle{empty} +\renewcommand{\headrulewidth}{0pt} +\begin{document} + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +% TITLE NAME +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +\namesection{}{\Huge{[YOUR_NAME]}}{ \href{mailto:[YOUR_EMAIL]}{[YOUR_EMAIL]} | [YOUR_PHONE] | \urlstyle{same}\href{[YOUR_LINKEDIN_URL]}{LinkedIn} +} + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +% MAIN COVER LETTER CONTENT +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +\currentdate{\today} +\lettercontent{Dear [Name/Team],} + +\lettercontent{[Opening paragraph - role, connection to background, 2-3 sentences]} + +\lettercontent{[Body paragraph - most relevant experience, then bullet list] + +\begin{itemize} + \item [Concrete achievement/skill 1] + \item [Concrete achievement/skill 2] + \item [Concrete achievement/skill 3] +\end{itemize} + +[Connection to company - why this role, why this company specifically]} + +\lettercontent{[Personal fit paragraph - behavioral strengths, team contribution, 2-3 sentences]} + +\lettercontent{I look forward to hearing from you.} + +\begin{flushright} +\closing{Kind regards,\\} + +\signature{[YOUR_NAME]} +\end{flushright} +\end{document} +``` + +## Key Commands Reference + +| Command | Purpose | +|---------|---------| +| `\namesection{}{Name}{contact info}` | Header with name and contact | +| `\currentdate{date}` | Date field (use `\today` or explicit date) | +| `\lettercontent{text}` | Body paragraph (adds spacing after) | +| `\closing{text}` | Closing line | +| `\signature{name}` | Printed name below signature | + +## Tailoring Guidelines + +### Salutation +- If you know the hiring manager's name: "Dear [First Last]," +- If you know the team: "Dear [Company] hiring team," +- Generic: "Dear [Company]," (avoid "To whom it may concern") + +### Length - Hard 1-Page Limit +- Target: 1 page including signature block +- Maximum: **never exceed 1 page** +- **Word budget: 250-300 words** of body text (not counting LaTeX markup). This is the safe maximum. 350 words will overflow. +- **Always count**: opening paragraph + bullet list paragraph + closing paragraph = 3 blocks. Add a 4th only if the others are short. +- When adding company-specific content, trim other content to compensate rather than adding net length + +### Line Spacing +- Add `\usepackage{setspace}` and `\setstretch{1.0}` if the letter is long and needs to fit on one page +- Use `\vspace{.5cm}` between major sections for readability (only if space permits) + +### Bullet Lists +- Use `\begin{itemize}...\end{itemize}` inside a `\lettercontent{}` block +- 3-5 bullets is ideal +- Start each bullet with bold label or action verb +- Use `\textbf{Label:}` for category-style bullets + +### LaTeX Special Characters +- Underscore: `\_` +- Ampersand: `\&` + +### Non-English Cover Letters +- Same template structure, just write content in the posting's language +- Adjust date format to local convention +- Adjust closing to local convention (e.g. "Med venlig hilsen," for Danish) + +## Checklist Before Finalizing +- [ ] No em-dashes (use commas or periods instead) +- [ ] No cliches or empty filler +- [ ] Every claim backed by specific example +- [ ] Forward-looking framing: focuses on tasks you'll solve, not just past duties +- [ ] Motivation section references this specific company's mission/values +- [ ] Company name and role are correct throughout +- [ ] Date is current +- [ ] Fits on one page +- [ ] Language matches the job posting language +- [ ] Salutation is appropriate (named person if possible) +- [ ] Headline is engaging and specific, not generic + +## Submission Guidelines (Best Practice) +- Submit only the documents the employer requests +- Export as PDF to preserve formatting +- Name files clearly: "[Your Name] CV" and "[Your Name] Cover Letter" +- Follow all employer instructions regarding anonymity or specific materials diff --git a/.claude/skills/job-application-assistant/07-interview-prep.md b/.claude/skills/job-application-assistant/07-interview-prep.md new file mode 100644 index 0000000..1c68adf --- /dev/null +++ b/.claude/skills/job-application-assistant/07-interview-prep.md @@ -0,0 +1,109 @@ +# Interview Preparation Guide + +<!-- SETUP: STAR examples are personalized by running /setup based on your actual experience --> + +## STAR Format + +Structure answers as: **Situation** (context), **Task** (your responsibility), **Action** (what you did), **Result** (outcome). + +Keep answers to 1-2 minutes. Be specific. End with what you learned or would do differently. + +## Ready-Made STAR Examples + +<!-- These are populated by /setup from your actual experience. Below are templates showing the format. --> + +### 1. [PROJECT_NAME] ([SKILL_DEMONSTRATED]) +**S:** [CONTEXT - what was happening, what was the problem] +**T:** [YOUR RESPONSIBILITY - what you specifically needed to do] +**A:** [WHAT YOU DID - specific actions, tools, methods] +**R:** [OUTCOME - measurable results, adoption, impact] +**Use for:** "[QUESTION_TYPE_1]", "[QUESTION_TYPE_2]" + +### 2. [PROJECT_NAME] ([SKILL_DEMONSTRATED]) +**S:** [CONTEXT] +**T:** [YOUR RESPONSIBILITY] +**A:** [WHAT YOU DID] +**R:** [OUTCOME] +**Use for:** "[QUESTION_TYPE_1]", "[QUESTION_TYPE_2]" + +### 3. [PROJECT_NAME] ([SKILL_DEMONSTRATED]) +**S:** [CONTEXT] +**T:** [YOUR RESPONSIBILITY] +**A:** [WHAT YOU DID] +**R:** [OUTCOME] +**Use for:** "[QUESTION_TYPE_1]", "[QUESTION_TYPE_2]" + +<!-- Add more STAR examples as needed. Aim for 4-6 covering different competencies. --> + +## Common Tough Questions + +### "Why did you leave [previous company]?" +> [PREPARE YOUR ANSWER - be honest, forward-looking, no negativity about former employer] + +### "You don't have [specific skill/experience]." +> [PREPARE YOUR ANSWER - acknowledge the gap, bridge to adjacent experience, show willingness to learn] + +### "Where do you see yourself in 5 years?" +> [PREPARE YOUR ANSWER - show ambition aligned with the role's growth path] + +### "What's your biggest weakness?" +> [PREPARE YOUR ANSWER - genuine weakness with concrete mitigation strategy] + +### "Why this company specifically?" +> Customize per company. Must reference: specific projects, company values, market position, or team structure. Never give a generic answer. + +## Questions You Should Ask Interviewers + +### About the Role +- "What does a typical week look like in this role?" +- "What would success look like in the first 6 months?" +- "What's the biggest challenge the team is facing right now?" + +### About the Team +- "How big is the team, and how do you divide work?" +- "What does the development/project lifecycle look like, from idea to production?" +- "How do you onboard new team members?" + +### About Tech & Growth +- "What's your current tech stack for [relevant area]?" +- "Is there room to grow into more architectural or strategic decisions?" +- "How does the team stay current with new tools and methods?" + +### About Culture (use these to prevent disappointment) +- "How would you describe the team culture?" +- "What does professional development look like here?" +- "Is there flexibility for remote/hybrid work?" +- "What's the balance between development/new projects and maintenance work?" +- "How would you describe the leadership style in this team?" +- "What do people who thrive here have in common?" + +## Phone/Video Interview Tips +- Have STAR examples written out (use this file) +- Keep a glass of water nearby +- Smile when speaking (it changes your tone) +- Ask for clarification if a question is vague +- It's OK to take 5 seconds to think before answering +- End with: "Is there anything else you'd like to know about my background?" + +## After the Application (Best Practice) + +### Follow-Up Etiquette +- **Don't call to "stand out"** or to learn more about the role post-submission - this risks a negative impression +- If the employer specified a timeline, respect it and wait +- If no timeline was given and significant time has passed (2+ weeks), a brief call to ask about status is acceptable +- If you have genuinely new, relevant information to share, a short follow-up is fine + +### Thank-You Notes +- When you receive any update (interview invitation, rejection, or status update), send a brief thank-you message +- Express appreciation for their time and the process +- Keep it short (2-3 sentences) + +## Roleplay Guidelines +When the user asks for interview practice: +1. Ask which role/company to simulate +2. Start with easy warm-up questions ("Tell me about yourself") +3. Progress to role-specific technical questions +4. Include 1-2 behavioral questions using the competencies from the job posting +5. End with a tough question or curveball +6. After each answer, give brief feedback: what worked, what to sharpen +7. Suggest which STAR example would work best for each question diff --git a/.claude/skills/job-application-assistant/SKILL.md b/.claude/skills/job-application-assistant/SKILL.md new file mode 100644 index 0000000..93a6962 --- /dev/null +++ b/.claude/skills/job-application-assistant/SKILL.md @@ -0,0 +1,63 @@ +# Job Application Assistant + +**name:** job-application-assistant +**description:** Assists with job applications: evaluating job postings, tailoring CVs, writing cover letters, and preparing for interviews. Triggers on keywords like: job posting, job application, CV, cover letter, resume, interview prep, job fit, career, application, apply, ansøgning, stilling +**allowed-tools:** Read, Glob, Grep, WebFetch, WebSearch, Edit, Write, AskUserQuestion + +--- + +## Workflow + +When the user provides a job posting (URL or text), follow this workflow: + +### Step 1: Research & Evaluate Fit +- Fetch the job posting content (use WebFetch for URLs) +- Analyze the posting for required competencies, keywords, and priorities +- Research the company (website, LinkedIn, mission, recent news) +- Score the posting against the candidate's profile using the framework in `04-job-evaluation.md` +- Present the evaluation table and verdict +- Suggest whether the candidate should call the employer before applying (see `04-job-evaluation.md` for guidance) +- Ask the user if they want to proceed with an application + +### Step 2: Tailor CV +- Read the most relevant existing CV variant from `cv/` as a starting point +- Follow the guidelines in `05-cv-templates.md` +- Create `cv/main_<company>.tex` with tailored content +- Adjust: profile statement, skills section, experience bullet emphasis, section order + +### Step 3: Write Cover Letter +- Follow the writing style rules in `03-writing-style.md` (critical: no em-dashes, no cliches) +- Follow the template structure in `06-cover-letter-templates.md` +- Create `cover_letters/cover_<company>_<role>.tex` +- Ensure the letter connects specific experience to the role requirements + +### Step 4: Interview Preparation +- Follow the framework in `07-interview-prep.md` +- Prepare STAR-format answers for likely questions +- Identify role-specific talking points +- Draft questions the candidate should ask the interviewer + +--- + +## Reference Files + +| File | Purpose | +|------|---------| +| `01-candidate-profile.md` | Education, experience, skills, publications, awards | +| `02-behavioral-profile.md` | Behavioral assessment, strengths, ideal environments | +| `03-writing-style.md` | Tone, structure, do's and don'ts | +| `04-job-evaluation.md` | Scoring framework for job fit | +| `05-cv-templates.md` | LaTeX CV structure and tailoring rules | +| `06-cover-letter-templates.md` | LaTeX cover letter structure and tailoring rules | +| `07-interview-prep.md` | STAR examples, tough questions, roleplay guidelines | + +--- + +## Quick Commands + +The user may also ask for individual steps without the full workflow: +- "Evaluate this job posting" - Step 1 only +- "Write a CV for [company]" - Step 2 only +- "Write a cover letter for [role] at [company]" - Step 3 only +- "Help me prepare for an interview at [company]" - Step 4 only +- "What jobs should I look for?" - Career strategy discussion using profile + evaluation framework diff --git a/.claude/skills/job-scraper/SKILL.md b/.claude/skills/job-scraper/SKILL.md new file mode 100644 index 0000000..f158805 --- /dev/null +++ b/.claude/skills/job-scraper/SKILL.md @@ -0,0 +1,119 @@ +# Job Scraper + +**name:** job-scraper +**description:** Scrapes Danish job sites for new positions matching your profile. Deduplicates across runs. Triggers on: job scrape, find jobs, search jobs, new jobs, job search, scrape jobs, /scrape +**allowed-tools:** Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, Agent, AskUserQuestion + +--- + +## How It Works + +This skill searches multiple Danish job sites using targeted queries based on your profile, deduplicates against previously seen jobs and the application tracker, and presents new matches with a quick fit assessment. + +## Invocation + +The user triggers this skill by saying things like: +- "Find new jobs" +- "Scrape for jobs" +- "Any new positions?" +- "/scrape" + +Optional arguments: +- A focus area, e.g. "/scrape data science" or "/scrape geophysics" +- "broad" to run all search categories, e.g. "/scrape broad" + +--- + +## Execution Steps + +### Step 0: Load State + +1. Read `job_scraper/seen_jobs.json` (create if missing - start with `{"seen": {}}`) +2. Read `job_search_tracker.csv` to extract already-applied companies+roles +3. Read `search-queries.md` (this directory) for the search strategy + +### Step 1: Search + +Run **WebSearch** queries from `search-queries.md`. By default, run the top 3 priority categories. If the user said "broad", run all categories. + +If the user specified a focus area (e.g. "data science"), prioritize queries from that category. + +For each search: +- Use `WebSearch` with site-specific queries (jobindex.dk, linkedin.com/jobs, karriere.dk, etc.) +- Target your configured geographic area +- Look for postings from the last 14 days + +### Step 2: Fetch & Parse + +For each promising result from Step 1: +- Use `WebFetch` to retrieve the job posting page +- Extract: **job title**, **company**, **location**, **posting date** (or "recent"), **URL**, **key requirements** (brief), **application deadline** (if listed) +- Skip if the URL or company+title combo already exists in `seen_jobs.json` +- Skip if the company+role already appears in `job_search_tracker.csv` + +### Step 3: Quick Fit Assessment + +For each new job, do a rapid fit check (NOT the full evaluation from `04-job-evaluation.md` - just a quick signal): + +- **High match**: Role directly involves your core skills +- **Medium match**: Role is adjacent to your experience +- **Low match**: Role requires significant skills you lack + +### Step 4: Deduplicate & Store + +1. Add ALL fetched jobs (new and skipped) to `seen_jobs.json` with structure: +```json +{ + "seen": { + "<url_or_company_title_key>": { + "title": "...", + "company": "...", + "url": "...", + "first_seen": "YYYY-MM-DD", + "fit": "high/medium/low", + "status": "new/skipped/evaluated" + } + } +} +``` +2. Only present jobs NOT already in the seen list or tracker. + +### Step 5: Present Results + +Present new jobs in a table sorted by fit (high first): + +``` +## New Job Matches - YYYY-MM-DD + +Found X new positions (Y high, Z medium, W low match). + +| # | Fit | Title | Company | Location | Deadline | URL | +|---|-----|-------|---------|----------|----------|-----| +| 1 | High | ... | ... | ... | ... | [Link](...) | + +### High-Match Highlights +For each high-match job, add 2-3 bullet points: +- Why it matches your profile +- Key requirements to check +- Any red flags +``` + +After presenting, ask: +> "Want me to evaluate any of these in detail? Just give me the number(s)." + +If the user picks a number, invoke the **job-application-assistant** skill workflow (fit evaluation first, then CV + cover letter if approved). + +### Step 6: Update Tracker (Optional) + +If the user decides to apply to any job, add a row to `job_search_tracker.csv`. + +--- + +## Important Rules + +1. **Never fabricate job postings.** Only present jobs found via actual WebSearch/WebFetch results. +2. **Respect deduplication.** Always check seen_jobs.json AND job_search_tracker.csv before presenting. +3. **Focus on configured geographic area.** Skip jobs that require relocation or are clearly outside commute range. +4. **Only open positions.** Skip postings with expired deadlines or those marked as closed. +5. **Be efficient with WebFetch.** Don't fetch every search result - use titles and snippets to pre-filter before fetching. +6. **Parallel searches.** Use the Agent tool or parallel WebSearch calls to speed up the search phase. diff --git a/.claude/skills/job-scraper/search-queries.md b/.claude/skills/job-scraper/search-queries.md new file mode 100644 index 0000000..825c23a --- /dev/null +++ b/.claude/skills/job-scraper/search-queries.md @@ -0,0 +1,76 @@ +# Search Queries for Job Scraper + +<!-- SETUP: Customize these queries based on your skills, target roles, and location --> + +## Search Sites + +Primary (Danish job market): +- **jobindex.dk** - largest Danish job board +- **linkedin.com/jobs** - LinkedIn job listings (filter: Denmark / your city) +- **karriere.dk** - IDA's job board (engineering/science roles) +- **jobfinder.dk** - another major Danish job board +- **akademikernes.dk** - academic union job board + +Secondary (company career pages via Google): +- Direct Google searches with `site:` filters for known target companies + +## Query Categories + +Queries are grouped by priority. Each query should be combined with your location terms (e.g. "Copenhagen", "Sjælland", "Hovedstaden") where the site supports it. + +### Priority 1: [YOUR_PRIMARY_ROLE_TYPE] + +These match your strongest and most desired career direction. + +``` +site:jobindex.dk "[YOUR_PRIMARY_JOB_TITLE]" [YOUR_CITY] +site:jobindex.dk "[YOUR_KEY_SKILL]" [YOUR_CITY] +site:linkedin.com/jobs "[YOUR_PRIMARY_JOB_TITLE]" [YOUR_COUNTRY] +``` + +### Priority 2: [YOUR_DOMAIN_EXPERTISE] + +These match your domain expertise. + +``` +site:jobindex.dk [YOUR_DOMAIN_KEYWORD_1] [YOUR_CITY] OR [YOUR_REGION] +site:jobindex.dk [YOUR_DOMAIN_KEYWORD_2] [YOUR_COUNTRY] +site:linkedin.com/jobs [YOUR_DOMAIN_KEYWORD_1] [YOUR_CITY] [YOUR_COUNTRY] +``` + +### Priority 3: [YOUR_ADJACENT_ROLE_TYPE] + +Adjacent roles you could pivot into. + +``` +site:jobindex.dk "[YOUR_ADJACENT_TITLE_1]" [YOUR_KEY_SKILL] [YOUR_CITY] +site:jobindex.dk "[YOUR_ADJACENT_TITLE_2]" [YOUR_KEY_SKILL] [YOUR_CITY] +``` + +### Priority 4: Broader Technical / Consulting + +Wider net for general technical roles. + +``` +site:jobindex.dk [YOUR_KEY_SKILL] developer [YOUR_CITY] +site:linkedin.com/jobs "[YOUR_KEY_SKILL] developer" [YOUR_CITY] +site:jobindex.dk "technical consultant" [YOUR_DOMAIN] [YOUR_CITY] +``` + +## Location Filter + +When evaluating results, verify the job location is within reasonable commute distance from your home. Define acceptable areas: +- [YOUR_CITY] and surrounding areas +- [ACCEPTABLE_AREA_1] +- [ACCEPTABLE_AREA_2] +- [BORDERLINE_AREA] (borderline - ~X min by transit) +- [TOO_FAR_AREA] (too far) + +## Date Filter + +Only include jobs posted within the last 14 days, or with an application deadline that has not yet passed. If a posting date cannot be determined, include it but flag as "date unknown". + +## Adapting Queries + +If the user specifies a focus area, select queries from the matching category and also generate 2-3 custom queries for that focus. For example: +- "/scrape [focus_area]" -> relevant category queries + custom focus-specific queries diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..066e9d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Dependencies +node_modules/ +bun.lock + +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ + +# Compiled documents +*.pdf +*.aux +*.log +*.out +*.synctex.gz +*.fls +*.fdb_latexmk + +# Personal data (never commit these) +salary_data.json +job_scraper/seen_jobs.json +job_scraper/*.md +*_BehavioralReport.pdf +linkedin_Profile.pdf + +# Personal photos and signatures +*.jpg +*.jpeg +*.png +!cover_letters/OpenFonts/fonts/** + +# OS files +.DS_Store +Thumbs.db + +# Editor +.vscode/ +.idea/ + +# Memory files (Claude Code user-specific) +.claude/projects/ + +# Skills lock (auto-generated) +skills-lock.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f61885f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,120 @@ +# Job Application Assistant for [YOUR_NAME] + +<!-- SETUP: This file is populated by running /setup --> +<!-- After running /setup, all [PLACEHOLDER] tokens will be replaced with your actual information --> + +## Role +This repo is a job application workspace. Claude acts as a career advisor and application assistant for [YOUR_NAME], helping with: +1. **Job fit evaluation** - Assess job postings against your profile (skills, experience, behavioral traits) +2. **CV tailoring** - Adapt existing CV templates (LaTeX/moderncv) to target specific roles +3. **Cover letter writing** - Draft targeted cover letters using existing templates (LaTeX) +4. **Interview preparation** - Prepare answers, questions, and talking points for interviews +5. **Career strategy** - Advise on positioning and personal branding + +## Candidate Profile + +<!-- This section is auto-populated by /setup. You can also fill it in manually. --> + +### Identity +- **Name:** [YOUR_NAME] +- **Location:** [YOUR_CITY], [YOUR_COUNTRY] ([YOUR_COMMUTE_CONSTRAINTS]) +- **Languages:** [YOUR_LANGUAGES] +- **Status:** [YOUR_EMPLOYMENT_STATUS] +- **LinkedIn headline:** "[YOUR_LINKEDIN_HEADLINE]" + +### Education +<!-- List your degrees, most recent first --> +- **[DEGREE_LEVEL] in [FIELD]** ([YEAR_START]-[YEAR_END]) - [INSTITUTION] + - Thesis: "[THESIS_TITLE]" + - Topics: [KEY_TOPICS] + +### Professional Experience +<!-- List your roles, most recent first --> +- **[JOB_TITLE]** ([START_DATE] - [END_DATE]) - **[COMPANY]** ([LOCATION]) + - [KEY_RESPONSIBILITY_1] + - [KEY_RESPONSIBILITY_2] + - [KEY_ACHIEVEMENT] + +### Technical Skills +- **Primary:** [YOUR_PRIMARY_SKILLS] +- **Secondary:** [YOUR_SECONDARY_SKILLS] +- **Domain:** [YOUR_DOMAIN_EXPERTISE] +- **Software:** [YOUR_TOOLS_AND_SOFTWARE] + +### Certifications +<!-- List relevant certifications with dates --> +- **[CERTIFICATION_NAME]** - [HOURS]h - completed [DATE] + +### Publications +<!-- List peer-reviewed publications, if any --> +- [AUTHOR_LIST] ([YEAR]). [TITLE]. [JOURNAL]. + +### Awards +<!-- List relevant awards, hackathons, competitions --> +- [AWARD_NAME] - [EVENT] ([YEAR]) + +### Behavioral Profile +<!-- Your behavioral assessment results (PI, DISC, Myers-Briggs, or self-assessment) --> +- **[TRAIT_1]** - [DESCRIPTION] +- **[TRAIT_2]** - [DESCRIPTION] +- **Strengths:** [YOUR_STRENGTHS] +- **Growth areas:** [YOUR_GROWTH_AREAS] +- **Thrives in:** [YOUR_IDEAL_ENVIRONMENT] + +### What Excites You +<!-- What motivates you professionally --> +- [PASSION_1] +- [PASSION_2] + +### Target Sectors +<!-- Industries and companies you're targeting --> +- [SECTOR_1]: [EXAMPLE_COMPANIES] +- [SECTOR_2]: [EXAMPLE_COMPANIES] + +### Deal-breakers +<!-- Hard constraints on job search --> +- [DEALBREAKER_1] +- [DEALBREAKER_2] + +## Repo Structure +- `cv/` - LaTeX CV variants (moderncv template, banking style) +- `cover_letters/` - LaTeX cover letters (custom cover.cls template) +- `.claude/skills/` - AI skill definitions for the application workflow +- `.agents/skills/` - Job search CLI tools + +## Workflow for New Job Applications +1. User provides a job posting (URL or text) +2. **Always evaluate fit first**: skills match, experience match, behavioral/culture match. Present this assessment to the user before proceeding. +3. If good fit: create targeted CV (`cv/main_<company>.tex`) and cover letter (`cover_letters/cover_<company>_<role>.tex`) +4. **Verify both documents** (see Verification Checklist below) +5. Prepare interview talking points based on the role requirements and your strengths + +**Important:** When mentioning agentic coding or AI tooling in CVs/cover letters, explicitly reference **Claude Code** by name. + +## Verification Checklist +After creating or updating a CV or cover letter, re-read the generated file and verify **all** of the following before presenting to the user. Report the results as a pass/fail checklist. + +### Factual accuracy +- [ ] All claims match actual profile (CLAUDE.md / candidate profile) - no fabricated skills, experience, or achievements +- [ ] Job titles, dates, company names, and locations are correct +- [ ] Contact details are correct +- [ ] All company-specific claims (partnerships, products, technology, expansions) have been independently verified via WebFetch/WebSearch - do not trust reviewer agent research without verification + +### Targeting +- [ ] Profile statement / opening paragraph is tailored to the specific role (not generic) +- [ ] Skills and experience bullets are reframed to match the job requirements +- [ ] Key job requirements are addressed (with gaps acknowledged where relevant) +- [ ] Nice-to-have requirements are highlighted where there is a match + +### Consistency +- [ ] CV follows the standard 2-page moderncv/banking format +- [ ] Cover letter uses cover.cls template and established structure +- [ ] Tone is consistent across CV and cover letter +- [ ] No contradictions between CV and cover letter content + +### Quality +- [ ] No LaTeX syntax errors (balanced braces, correct commands) +- [ ] No spelling or grammar errors +- [ ] Agentic coding / AI tooling references mention **Claude Code** by name +- [ ] Cover letter is addressed to the correct person (or "Dear Hiring Manager" if unknown) +- [ ] Cover letter fits approximately one page diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dd86a45 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Mads Lorentzen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e4908d --- /dev/null +++ b/README.md @@ -0,0 +1,205 @@ +<p align="center"> + <img src="claude_animation.gif" alt="Claude Job Search Assistant" width="200"> +</p> + +# AI Job Search + +An AI-powered job application framework built on [Claude Code](https://claude.com/claude-code). Fork it, fill in your profile, and let Claude evaluate job postings, tailor your CV, write cover letters, and prepare you for interviews. + +## What this is + +A structured workflow that turns Claude Code into a full-stack job application assistant. The core workflow (self-profiling, fit evaluation, and the drafter-reviewer application pipeline) is **language- and country-agnostic**. The job portal search skills are built for the Danish market (Jobindex, Jobnet, Akademikernes Jobbank, etc.), but the pattern is designed to be swapped for your local job boards. + +``` +/setup /scrape /apply <url> + | | | + v v v +Fill in Search job Evaluate fit +your profile portals Score & recommend + | | | + v v v +Profile Present matches Draft CV + Cover Letter +files ready with fit ratings (LaTeX, tailored) + | | + v v + Pick a match Reviewer agent critiques + -> /apply -> Revise -> Final output +``` + +The framework encodes career guidance best practices, including structured evaluation criteria, forward-looking cover letter framing, and optional salary benchmarking. + +## Prerequisites + +- [Claude Code](https://claude.com/claude-code) (CLI) +- Python 3.10+ +- [Bun](https://bun.sh) (for Danish job search CLI tools) +- LaTeX distribution (for compiling CVs and cover letters): [TeX Live](https://tug.org/texlive/) or [MiKTeX](https://miktex.org/) + +## Quick start + +### 1. Fork and clone + +```bash +gh repo fork MadsLorentzen/ai-job-search --clone +cd ai-job-search +``` + +### 2. Install job search tools + +```bash +cd .agents/skills/jobbank-search/cli && bun install && cd ../../../.. +cd .agents/skills/jobdanmark-search/cli && bun install && cd ../../../.. +cd .agents/skills/jobindex-search/cli && bun install && cd ../../../.. +cd .agents/skills/jobnet-search/cli && bun install && cd ../../../.. +``` + +### 3. Set up your profile + +```bash +claude +# Then inside Claude Code: +/setup +``` + +Claude will ask about your background, skills, and career goals, then populate all profile files automatically. You can import from an existing CV or answer questions interactively. The setup also configures your job search queries so `/scrape` works immediately. + +### 4. Search for jobs + +```bash +/scrape +``` + +This searches multiple job portals for positions matching your profile, deduplicates results, and presents them sorted by fit. Pick a match to run `/apply` on it directly. + +### 5. Apply to a job + +```bash +/apply https://jobindex.dk/job/1234567 +``` + +If the URL can't be fetched (some job portals block automated access), you can paste the job description directly instead: + +```bash +/apply <paste the full job description here> +``` + +This runs the full workflow: evaluate fit, draft CV + cover letter, review with a second agent, revise, and present the final output. + +## File structure + +``` +ai-job-search/ +├── CLAUDE.md # Main candidate profile + workflow rules +├── .claude/ +│ ├── commands/ +│ │ ├── apply.md # /apply workflow (drafter-reviewer) +│ │ └── setup.md # /setup onboarding interview +│ ├── skills/ +│ │ ├── job-application-assistant/ # Core application skill +│ │ │ ├── SKILL.md # Skill definition +│ │ │ ├── 01-candidate-profile.md # Your education, experience, skills +│ │ │ ├── 02-behavioral-profile.md# PI/DISC/personality assessment +│ │ │ ├── 03-writing-style.md # Tone, structure, do's and don'ts +│ │ │ ├── 04-job-evaluation.md # Scoring framework for job fit +│ │ │ ├── 05-cv-templates.md # LaTeX CV structure + tailoring rules +│ │ │ ├── 06-cover-letter-templates.md # LaTeX cover letter templates +│ │ │ └── 07-interview-prep.md # STAR examples + interview framework +│ │ └── job-scraper/ # Job search orchestration +│ └── settings.local.json # Claude Code permissions +├── .agents/skills/ # Job portal CLI tools (Denmark) +│ ├── jobbank-search/ # Akademikernes Jobbank +│ ├── jobdanmark-search/ # Jobdanmark.dk +│ ├── jobindex-search/ # Jobindex.dk +│ └── jobnet-search/ # Jobnet.dk (government portal) +├── cv/ +│ └── main_example.tex # moderncv LaTeX template +├── cover_letters/ +│ ├── cover.cls # Custom cover letter LaTeX class +│ └── OpenFonts/ # Lato + Raleway fonts +├── salary_lookup.py # Salary benchmarking tool (BYO data) +├── tools/ +│ ├── convert_salary_excel.py # Convert salary Excel to JSON +│ └── README_SALARY_TOOL.md # Salary tool setup instructions +├── job_scraper/ # Scraper state (seen jobs, results) +├── job_search_tracker.csv # Application tracking spreadsheet +└── SETUP.md # Detailed setup guide +``` + +## How `/apply` works + +The `/apply` command runs a **drafter-reviewer workflow**: + +1. **Parse** the job posting (URL or text) +2. **Evaluate fit** against your profile (skills, experience, culture, location, career alignment) +3. **Draft** a tailored CV and cover letter in LaTeX +4. **Spawn a reviewer agent** that researches the company and critiques the drafts +5. **Revise** based on the reviewer's feedback +6. **Present** the final output with a verification checklist + +All claims in the CV and cover letter are verified against your actual profile. The system never fabricates skills or experience. + +## Customization + +### Which files to edit manually + +If you prefer editing files directly instead of using `/setup`: + +| File | What to change | +|------|---------------| +| `CLAUDE.md` | Your full profile (name, education, experience, skills, goals) | +| `01-candidate-profile.md` | Structured version of your CV data | +| `02-behavioral-profile.md` | Your behavioral assessment or self-assessment | +| `04-job-evaluation.md` | Skill match areas, career goals, motivation filters | +| `05-cv-templates.md` | Profile statement templates for different role types | +| `07-interview-prep.md` | Your STAR examples from actual experience | +| `search-queries.md` | Job search queries for your skills and location | + +### Updating your search queries + +As your priorities evolve, you can reconfigure just the job search without re-running the full profile setup: + +``` +/setup --section search +``` + +This re-runs the search configuration interview: which roles to target, which skills to search for, which locations, and which portals. It also suggests role types you may not have considered based on your profile. + +### LaTeX templates + +The CV uses [moderncv](https://ctan.org/pkg/moderncv) (banking style). The cover letter uses a custom `cover.cls` with Lato/Raleway fonts. You can replace these with your own templates; just update the guidance in `05-cv-templates.md` and `06-cover-letter-templates.md`. + +### Job search tools + +The four CLI tools in `.agents/skills/` are specific to the **Danish job market** (Jobbank, Jobdanmark, Jobindex, Jobnet). They demonstrate the pattern for building job portal integrations. If you're in a different country, you can build equivalent tools for your local job portals using the same structure. + +### Salary benchmarking + +The salary tool works with any salary data you provide (union statistics, Glassdoor exports, personal research, etc.). See `tools/README_SALARY_TOOL.md` for the expected format and setup. If you don't have salary data, the salary step is simply skipped. + +## Tips for better results + +### Profile depth matters + +The single biggest factor in output quality is how much detail you put into your profile. A thin profile produces generic applications; a detailed one enables genuinely tailored results. + +- **Role descriptions:** Don't just list job titles. Describe what you actually did in each position: specific projects, tools used, responsibilities, and measurable achievements. The more material you provide, the more precisely the system can reframe your experience for different roles. +- **Skills in context:** Instead of listing "Python" or "project management," describe how and where you applied them. "Built ML pipelines for customer churn prediction in Python using scikit-learn" gives the system far more to work with than "Python, machine learning." +- **Either onboarding path works:** Whether you import an existing CV or answer questions interactively via `/setup`, the principle is the same: richer input produces sharper output. + +### Career path discovery + +The framework supports two distinct modes of job searching: + +- **Explicit targeting:** You know which roles or sectors you want. The system helps refine and prioritize based on fit. +- **Latent opportunity discovery:** By analyzing your full history (not just job titles, but the actual work you did), the system can surface career paths you haven't considered. Transferable skills that map to unexpected industries, patterns in what you enjoyed or excelled at, or emerging roles that combine your domain expertise with new technology. + +To get the most from this, invest time during `/setup` in describing not just your experience, but what energized you, what drained you, and what you'd want more of. This context directly shapes how the system evaluates fit and which roles it surfaces during `/scrape`. + +## Acknowledgements + +- [Mikkel Krogholm](https://github.com/mikkelkrogsholm) ([skills repo](https://github.com/mikkelkrogsholm/skills)) for the job search CLI skills +- Built with [Claude Code](https://claude.com/claude-code) by [Anthropic](https://anthropic.com) + +## License + +MIT diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..c3bb4fd --- /dev/null +++ b/SETUP.md @@ -0,0 +1,166 @@ +# Setup Guide + +Step-by-step instructions for getting the AI Job Search framework running. + +## 1. Prerequisites + +### Claude Code + +Install Claude Code (Anthropic's CLI for Claude): + +```bash +npm install -g @anthropic-ai/claude-code +``` + +You'll need an Anthropic API key or a Claude Pro/Team subscription. See the [Claude Code docs](https://docs.anthropic.com/en/docs/claude-code) for details. + +### Python + +Python 3.10+ is required for the salary lookup tool. Check with: + +```bash +python --version +``` + +### Bun (for job search tools) + +The Danish job portal CLIs are written in TypeScript and run with Bun: + +```bash +curl -fsSL https://bun.sh/install | bash +``` + +### LaTeX (for compiling CVs and cover letters) + +Install a LaTeX distribution to compile the generated `.tex` files to PDF: + +- **Windows:** [MiKTeX](https://miktex.org/download) +- **macOS:** [MacTeX](https://tug.org/mactex/) +- **Linux:** `sudo apt install texlive-full` or `sudo dnf install texlive-scheme-full` + +The CV compiles with `pdflatex`. The cover letter compiles with `xelatex` (for custom fonts). + +## 2. Fork and clone + +```bash +gh repo fork MadsLorentzen/ai-job-search --clone +cd ai-job-search +``` + +Or manually: fork on GitHub, then clone your fork. + +## 3. Install job search CLI dependencies + +```bash +for tool in jobbank-search jobdanmark-search jobindex-search jobnet-search; do + cd .agents/skills/$tool/cli && bun install && cd ../../../.. +done +``` + +## 4. Run the setup interview + +Start Claude Code in the repository: + +```bash +claude +``` + +Then run the onboarding: + +``` +/setup +``` + +Claude will offer two paths: + +- **Path A (recommended):** Share your existing CV (mention the file with `@` or paste the text). Claude extracts your information and asks follow-up questions for anything missing. +- **Path B:** Answer structured interview questions section by section. + +Both paths produce the same result: fully populated profile files. + +### What gets populated + +| File | Content | +|------|---------| +| `CLAUDE.md` | Your full candidate profile | +| `01-candidate-profile.md` | Structured education, experience, skills | +| `02-behavioral-profile.md` | Behavioral assessment | +| `04-job-evaluation.md` | Personalized skill match areas and career goals | +| `05-cv-templates.md` | Profile statement templates for your background | +| `07-interview-prep.md` | STAR examples from your experience | +| `cv/main_example.tex` | Your LaTeX CV with actual details | +| `search-queries.md` | Job search queries for `/scrape` | + +### Re-running setup + +You can update specific sections later: + +``` +/setup --section skills +/setup --section experience +/setup --section search +``` + +The `--section search` option is especially useful as your priorities evolve. It re-runs the search configuration interview and suggests role types you may not have considered based on your full profile. + +## 5. Optional: Set up salary benchmarking + +If you have salary data (from a union, salary survey, Glassdoor, or personal research): + +1. **Option A:** Create `salary_data.json` manually in the repo root (see `tools/README_SALARY_TOOL.md` for the format) +2. **Option B:** Convert from Excel: + ```bash + pip install openpyxl + python tools/convert_salary_excel.py path/to/salary-data.xlsx --source "My Salary Data 2025" + ``` + +This creates `salary_data.json` which the `/apply` workflow uses for salary benchmarking. If you skip this step, salary lookup is simply omitted. + +## 6. Test the workflow + +Find a job posting you're interested in, then: + +``` +/apply https://jobindex.dk/job/1234567 +``` + +Or paste the job description directly: + +``` +/apply [paste job posting text here] +``` + +Claude will: +1. Evaluate the fit against your profile +2. Ask if you want to proceed +3. Draft a tailored CV and cover letter +4. Have a reviewer agent critique the drafts +5. Revise and present the final output + +## 7. Compile your documents + +After `/apply` creates the LaTeX files: + +```bash +# Compile CV +cd cv && pdflatex main_<company>.tex && cd .. + +# Compile cover letter +cd cover_letters && xelatex cover_<company>_<role>.tex && cd .. +``` + +## Troubleshooting + +### "salary_data.json not found" +This is expected if you haven't set up salary benchmarking. The `/apply` workflow skips this step automatically. + +### Job search CLI tools not working +Make sure Bun is installed and you ran `bun install` in each CLI directory. The tools require network access to fetch job listings. + +### LaTeX compilation errors +- CV: uses `pdflatex` (standard LaTeX) +- Cover letter: uses `xelatex` (for custom fonts in `OpenFonts/fonts/`) +- Make sure your LaTeX distribution includes the `moderncv` package + +### Fonts not found in cover letter +The cover letter template expects fonts in `cover_letters/OpenFonts/fonts/`. Make sure this directory exists and contains the Lato and Raleway font files. diff --git a/claude_animation.gif b/claude_animation.gif new file mode 100644 index 0000000..0a1823d Binary files /dev/null and b/claude_animation.gif differ diff --git a/cover_letters/OpenFonts/cover.cls b/cover_letters/OpenFonts/cover.cls new file mode 100644 index 0000000..9333b7c --- /dev/null +++ b/cover_letters/OpenFonts/cover.cls @@ -0,0 +1,101 @@ +% Intro Options +\ProvidesClass{deedy-resume-openfont}[2014/04/30 CV class] +\NeedsTeXFormat{LaTeX2e} +\DeclareOption{print}{\def\@cv@print{}} +\DeclareOption*{% + \PassOptionsToClass{\CurrentOption}{article} +} +\ProcessOptions\relax +\LoadClass{article} + +% Package Imports +\usepackage[hmargin=2.54cm, vmargin=2.54cm]{geometry} +\usepackage[hidelinks]{hyperref} +\usepackage[usenames,dvipsnames]{xcolor} +\usepackage{titlesec} +\usepackage[absolute]{textpos} +\usepackage{fontspec,xltxtra,xunicode} + +% Publications +\usepackage{cite} +\renewcommand\refname{\vskip -1.5cm} + +% Color definitions +\usepackage[usenames,dvipsnames]{xcolor} +\definecolor{date}{HTML}{666666} +\definecolor{primary}{HTML}{2b2b2b} +\definecolor{headings}{HTML}{6A6A6A} +\definecolor{subheadings}{HTML}{333333} + +% Set main fonts +\usepackage{fontspec} +\setmainfont[Color=primary, Path = OpenFonts/fonts/lato/,BoldItalicFont=Lato-RegIta,BoldFont=Lato-Reg,ItalicFont=Lato-LigIta]{Lato-Lig} +\setsansfont[Scale=MatchLowercase,Mapping=tex-text, Path = OpenFonts/fonts/raleway/]{Raleway-ExtraLight} + +% Date command +\usepackage[absolute]{textpos} +% \usepackage[UKenglish]{isodate} +\setlength{\TPHorizModule}{1mm} +\setlength{\TPVertModule}{1mm} +\newcommand{\lastupdated}{\begin{textblock}{60}(155,5) +\color{date}\fontspec[Path = fonts/raleway/]{Raleway-ExtraLight}\fontsize{8pt}{10pt}\selectfont +Last Updated on \today +\end{textblock}} + +% Name command +\newcommand{\namesection}[3]{ +\centering{ +\fontsize{40pt}{60pt} +\fontspec[Path = fonts/lato/]{Lato-Hai}\selectfont #1 +\fontspec[Path = fonts/lato/]{Lato-Lig}\selectfont #2 +} \\[5pt] +\centering{ +\color{headings} +\fontspec[Path = fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{14pt}\selectfont #3} +\noindent\makebox[\linewidth]{\color{headings}\rule{\paperwidth}{0.0pt}} +\vspace{0pt} +} + +% Section seperators +\usepackage{titlesec} +\titlespacing{\section}{0pt}{0pt}{0pt} +\titlespacing{\subsection}{0pt}{0pt}{0pt} +\newcommand{\sectionsep}{\vspace{8pt}} + +% Headings command +\titleformat{\section}{\color{headings} +\scshape\fontspec[Path = fonts/lato/]{Lato-Lig}\fontsize{16pt}{24pt}\selectfont \raggedright\uppercase}{}{0em}{} + +% Subeadings command +\titleformat{\subsection}{ +\color{subheadings}\fontspec[Path = fonts/lato/]{Lato-Bol}\fontsize{12pt}{12pt}\selectfont\bfseries\uppercase}{}{0em}{} + +\newcommand{\runsubsection}[1]{ +\color{subheadings}\fontspec[Path = fonts/lato/]{Lato-Bol}\fontsize{12pt}{12pt}\selectfont\bfseries\uppercase {#1} \normalfont} + +% Descriptors command +\newcommand{\descript}[1]{ +\color{subheadings}\raggedright\scshape\fontspec[Path = fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont {#1 \\} \normalfont} + +% Location command +\newcommand{\location}[1]{ +\color{headings}\raggedright\fontspec[Path = fonts/raleway/]{Raleway-Medium}\fontsize{10pt}{12pt}\selectfont {#1\\} \normalfont} + +% Bullet Lists with fewer gaps command +\newenvironment{tightemize}{ +\vspace{-\topsep}\begin{itemize}\itemsep1pt \parskip0pt \parsep0pt} +{\end{itemize}\vspace{-\topsep}} + +% Cover Letter +\newcommand{\companyname}[1]{\raggedright\fontspec[Path = fonts/lato/]{Lato-Bol}\fontsize{12pt}{14pt}\selectfont {#1 \\} \normalfont} + +\newcommand{\companyaddress}[1]{\raggedright\fontspec[Path = fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont {#1 \\}\mbox{}\\\mbox{}\\ \normalfont} + +\newcommand{\currentdate}[1]{\raggedleft\fontspec[Path = fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont {#1 \\} \normalfont} + +% Letter content command +\newcommand{\lettercontent}[1]{\raggedright\fontspec[Path = fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont {#1 \\}\mbox{}\\ \normalfont} + +\newcommand{\closing}[1]{\raggedright\fontspec[Path = fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont {#1 \\}\mbox{}\\\mbox{}\\ \normalfont} + +\newcommand{\signature}[1]{\raggedright\fontspec[Path = fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont {#1 \\} \normalfont} \ No newline at end of file diff --git a/cover_letters/OpenFonts/fonts/lato/Lato-Bla.ttf b/cover_letters/OpenFonts/fonts/lato/Lato-Bla.ttf new file mode 100644 index 0000000..6848db0 Binary files /dev/null and b/cover_letters/OpenFonts/fonts/lato/Lato-Bla.ttf differ diff --git a/cover_letters/OpenFonts/fonts/lato/Lato-BlaIta.ttf b/cover_letters/OpenFonts/fonts/lato/Lato-BlaIta.ttf new file mode 100644 index 0000000..5decf12 Binary files /dev/null and b/cover_letters/OpenFonts/fonts/lato/Lato-BlaIta.ttf differ diff --git a/cover_letters/OpenFonts/fonts/lato/Lato-Bol.ttf b/cover_letters/OpenFonts/fonts/lato/Lato-Bol.ttf new file mode 100644 index 0000000..7434369 Binary files /dev/null and b/cover_letters/OpenFonts/fonts/lato/Lato-Bol.ttf differ diff --git a/cover_letters/OpenFonts/fonts/lato/Lato-BolIta.ttf b/cover_letters/OpenFonts/fonts/lato/Lato-BolIta.ttf new file mode 100644 index 0000000..684aacf Binary files /dev/null and b/cover_letters/OpenFonts/fonts/lato/Lato-BolIta.ttf differ diff --git a/cover_letters/OpenFonts/fonts/lato/Lato-Hai.ttf b/cover_letters/OpenFonts/fonts/lato/Lato-Hai.ttf new file mode 100644 index 0000000..288be29 Binary files /dev/null and b/cover_letters/OpenFonts/fonts/lato/Lato-Hai.ttf differ diff --git a/cover_letters/OpenFonts/fonts/lato/Lato-HaiIta.ttf b/cover_letters/OpenFonts/fonts/lato/Lato-HaiIta.ttf new file mode 100644 index 0000000..c2bfd33 Binary files /dev/null and b/cover_letters/OpenFonts/fonts/lato/Lato-HaiIta.ttf differ diff --git a/cover_letters/OpenFonts/fonts/lato/Lato-Lig.ttf b/cover_letters/OpenFonts/fonts/lato/Lato-Lig.ttf new file mode 100644 index 0000000..a958067 Binary files /dev/null and b/cover_letters/OpenFonts/fonts/lato/Lato-Lig.ttf differ diff --git a/cover_letters/OpenFonts/fonts/lato/Lato-LigIta.ttf b/cover_letters/OpenFonts/fonts/lato/Lato-LigIta.ttf new file mode 100644 index 0000000..5e45ad9 Binary files /dev/null and b/cover_letters/OpenFonts/fonts/lato/Lato-LigIta.ttf differ diff --git a/cover_letters/OpenFonts/fonts/lato/Lato-Reg.ttf b/cover_letters/OpenFonts/fonts/lato/Lato-Reg.ttf new file mode 100644 index 0000000..04ea8ef Binary files /dev/null and b/cover_letters/OpenFonts/fonts/lato/Lato-Reg.ttf differ diff --git a/cover_letters/OpenFonts/fonts/lato/Lato-RegIta.ttf b/cover_letters/OpenFonts/fonts/lato/Lato-RegIta.ttf new file mode 100644 index 0000000..3d3b7a2 Binary files /dev/null and b/cover_letters/OpenFonts/fonts/lato/Lato-RegIta.ttf differ diff --git a/cover_letters/OpenFonts/fonts/raleway/Raleway-Bold.otf b/cover_letters/OpenFonts/fonts/raleway/Raleway-Bold.otf new file mode 100644 index 0000000..2f840b7 Binary files /dev/null and b/cover_letters/OpenFonts/fonts/raleway/Raleway-Bold.otf differ diff --git a/cover_letters/OpenFonts/fonts/raleway/Raleway-ExtraBold.otf b/cover_letters/OpenFonts/fonts/raleway/Raleway-ExtraBold.otf new file mode 100644 index 0000000..133552c Binary files /dev/null and b/cover_letters/OpenFonts/fonts/raleway/Raleway-ExtraBold.otf differ diff --git a/cover_letters/OpenFonts/fonts/raleway/Raleway-ExtraLight.otf b/cover_letters/OpenFonts/fonts/raleway/Raleway-ExtraLight.otf new file mode 100644 index 0000000..4ab05bb Binary files /dev/null and b/cover_letters/OpenFonts/fonts/raleway/Raleway-ExtraLight.otf differ diff --git a/cover_letters/OpenFonts/fonts/raleway/Raleway-Heavy.otf b/cover_letters/OpenFonts/fonts/raleway/Raleway-Heavy.otf new file mode 100644 index 0000000..63c952a Binary files /dev/null and b/cover_letters/OpenFonts/fonts/raleway/Raleway-Heavy.otf differ diff --git a/cover_letters/OpenFonts/fonts/raleway/Raleway-Light.otf b/cover_letters/OpenFonts/fonts/raleway/Raleway-Light.otf new file mode 100644 index 0000000..cb4ff4e Binary files /dev/null and b/cover_letters/OpenFonts/fonts/raleway/Raleway-Light.otf differ diff --git a/cover_letters/OpenFonts/fonts/raleway/Raleway-Medium.otf b/cover_letters/OpenFonts/fonts/raleway/Raleway-Medium.otf new file mode 100644 index 0000000..79786fe Binary files /dev/null and b/cover_letters/OpenFonts/fonts/raleway/Raleway-Medium.otf differ diff --git a/cover_letters/OpenFonts/fonts/raleway/Raleway-Regular.otf b/cover_letters/OpenFonts/fonts/raleway/Raleway-Regular.otf new file mode 100644 index 0000000..871e0f4 Binary files /dev/null and b/cover_letters/OpenFonts/fonts/raleway/Raleway-Regular.otf differ diff --git a/cover_letters/OpenFonts/fonts/raleway/Raleway-SemiBold.otf b/cover_letters/OpenFonts/fonts/raleway/Raleway-SemiBold.otf new file mode 100644 index 0000000..33d19f6 Binary files /dev/null and b/cover_letters/OpenFonts/fonts/raleway/Raleway-SemiBold.otf differ diff --git a/cover_letters/OpenFonts/fonts/raleway/Raleway-Thin.otf b/cover_letters/OpenFonts/fonts/raleway/Raleway-Thin.otf new file mode 100644 index 0000000..13a581d Binary files /dev/null and b/cover_letters/OpenFonts/fonts/raleway/Raleway-Thin.otf differ diff --git a/cover_letters/cover.cls b/cover_letters/cover.cls new file mode 100644 index 0000000..52a4195 --- /dev/null +++ b/cover_letters/cover.cls @@ -0,0 +1,101 @@ +% Intro Options +\ProvidesClass{deedy-resume-openfont}[2014/04/30 CV class] +\NeedsTeXFormat{LaTeX2e} +\DeclareOption{print}{\def\@cv@print{}} +\DeclareOption*{% + \PassOptionsToClass{\CurrentOption}{article} +} +\ProcessOptions\relax +\LoadClass{article} + +% Package Imports +\usepackage[hmargin=2.3cm, vmargin=2.3cm]{geometry} +\usepackage[hidelinks]{hyperref} +\usepackage[usenames,dvipsnames]{xcolor} +\usepackage{titlesec} +\usepackage[absolute]{textpos} +\usepackage{fontspec,xltxtra,xunicode} + +% Publications +\usepackage{cite} +\renewcommand\refname{\vskip -1.5cm} + +% Color definitions +\usepackage[usenames,dvipsnames]{xcolor} +\definecolor{date}{HTML}{666666} +\definecolor{primary}{HTML}{2b2b2b} +\definecolor{headings}{HTML}{6A6A6A} +\definecolor{subheadings}{HTML}{333333} + +% Set main fonts +\usepackage{fontspec} +\setmainfont[Color=primary, Path = OpenFonts/fonts/lato/,BoldItalicFont=Lato-RegIta,BoldFont=Lato-Reg,ItalicFont=Lato-LigIta]{Lato-Lig} +\setsansfont[Scale=MatchLowercase,Mapping=tex-text, Path = OpenFonts/fonts/raleway/]{Raleway-ExtraLight} + +% Date command +\usepackage[absolute]{textpos} +% \usepackage[UKenglish]{isodate} +\setlength{\TPHorizModule}{1mm} +\setlength{\TPVertModule}{1mm} +\newcommand{\lastupdated}{\begin{textblock}{60}(155,5) +\color{date}\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-ExtraLight}\fontsize{8pt}{10pt}\selectfont +Last Updated on \today +\end{textblock}} + +% Name command +\newcommand{\namesection}[3]{ +\centering{ +\fontsize{40pt}{60pt} +\fontspec[Path = OpenFonts/fonts/lato/]{Lato-Hai}\selectfont #1 +\fontspec[Path = OpenFonts/fonts/lato/]{Lato-Lig}\selectfont #2 +} \\[5pt] +\centering{ +\color{headings} +\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{14pt}\selectfont #3} +\noindent\makebox[\linewidth]{\color{headings}\rule{\paperwidth}{0.0pt}} +\vspace{0pt} +} + +% Section seperators +\usepackage{titlesec} +\titlespacing{\section}{0pt}{0pt}{0pt} +\titlespacing{\subsection}{0pt}{0pt}{0pt} +\newcommand{\sectionsep}{\vspace{8pt}} + +% Headings command +\titleformat{\section}{\color{headings} +\scshape\fontspec[Path = OpenFonts/fonts/lato/]{Lato-Lig}\fontsize{16pt}{24pt}\selectfont \raggedright\uppercase}{}{0em}{} + +% Subeadings command +\titleformat{\subsection}{ +\color{subheadings}\fontspec[Path = OpenFonts/fonts/lato/]{Lato-Bol}\fontsize{12pt}{12pt}\selectfont\bfseries\uppercase}{}{0em}{} + +\newcommand{\runsubsection}[1]{ +\color{subheadings}\fontspec[Path = OpenFonts/fonts/lato/]{Lato-Bol}\fontsize{12pt}{12pt}\selectfont\bfseries\uppercase {#1} \normalfont} + +% Descriptors command +\newcommand{\descript}[1]{ +\color{subheadings}\raggedright\scshape\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont {#1 \\} \normalfont} + +% Location command +\newcommand{\location}[1]{ +\color{headings}\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{10pt}{12pt}\selectfont {#1\\} \normalfont} + +% Bullet Lists with fewer gaps command +\newenvironment{tightemize}{ +\vspace{-\topsep}\begin{itemize}\itemsep1pt \parskip0pt \parsep0pt} +{\end{itemize}\vspace{-\topsep}} + +% Cover Letter +\newcommand{\companyname}[1]{\raggedright\fontspec[Path = OpenFonts/fonts/lato/]{Lato-Bol}\fontsize{12pt}{14pt}\selectfont {#1 \\} \normalfont} + +\newcommand{\companyaddress}[1]{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont {#1 \\}\mbox{}\\\mbox{}\\ \normalfont} + +\newcommand{\currentdate}[1]{\raggedleft\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont {#1 \\} \normalfont} + +% Letter content command +\newcommand{\lettercontent}[1]{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont {#1 \\}\mbox{}\\ \normalfont} + +\newcommand{\closing}[1]{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont {#1 \\}\mbox{}\\\mbox{}\\ \normalfont} + +\newcommand{\signature}[1]{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont {#1 \\} \normalfont} \ No newline at end of file diff --git a/cv/main_example.tex b/cv/main_example.tex new file mode 100644 index 0000000..7601be8 --- /dev/null +++ b/cv/main_example.tex @@ -0,0 +1,171 @@ +%% Example CV - [YOUR_NAME] +%% Comprehensive overview of all competencies, experience, and achievements. +%% Use as a master reference when tailoring role-specific CVs. +%% +%% SETUP: Run /setup to populate this with your actual information. +%% Compile with: pdflatex main_example.tex + +\documentclass[11pt,a4paper,sans]{moderncv} +\moderncvstyle{banking} +\moderncvcolor{blue} + +\usepackage[utf8]{inputenc} +\usepackage{hyperref} +\hypersetup{ + colorlinks=true, + linkcolor=blue, + filecolor=magenta, + urlcolor=blue, + pdftitle={[YOUR_NAME] - CV}, + pdfpagemode=FullScreen, +} +\usepackage[scale=0.80]{geometry} +\usepackage{import} + +% personal data +\name{[First]}{[Last]} +\address{[Your Address, City, Country]}{}{} +\phone[mobile]{[+XX XXXXXXXXXX]} +\email{[your.email@example.com]} +\extrainfo{\href{[https://linkedin.com/in/your-profile]}{LinkedIn}, \href{[https://github.com/your-username]}{GitHub}} + +\begin{document} + +\makecvtitle + +% ============================================================ +% PROFILE STATEMENT +% ============================================================ + +\vspace{6pt} +\small{[Write a 3-5 line profile statement tailored to the role you're applying for. Focus on what makes you uniquely qualified. Example: "Data scientist with a PhD in [field] and X years of industry experience. Combines deep domain expertise in [domain] with strong applied machine learning and Python development skills. Experienced in developing end-to-end ML pipelines, from data ingestion to stakeholder-facing dashboards."]} + +% ============================================================ +% CORE COMPETENCIES +% ============================================================ + +\section{Core Competencies} +\vspace{1pt} +\begin{itemize} + +\item \textbf{[Skill Category 1]}: [Specific skills, frameworks, tools. Be concrete.] +\vspace{1pt} + +\item \textbf{[Skill Category 2]}: [Specific skills, frameworks, tools.] +\vspace{1pt} + +\item \textbf{[Skill Category 3]}: [Specific skills, frameworks, tools.] +\vspace{1pt} + +\item \textbf{[Skill Category 4]}: [Domain expertise, methods, approaches.] +\vspace{1pt} + +\item \textbf{[Skill Category 5]}: [Tools, platforms, software.] + +\end{itemize} + +% ============================================================ +% PROFESSIONAL EXPERIENCE +% ============================================================ + +\section{Professional Experience} +\vspace{3pt} +\begin{itemize} + +% --- Most Recent Role --- +\item{\cventry{[YYYY--Present]}{[Job Title]}{[Company]}{[City, Country]}{}{\vspace{1pt} +\begin{itemize} + \item [Achievement or responsibility 1 - be specific, use numbers where possible] + \vspace{1pt} + \item [Achievement or responsibility 2] + \vspace{1pt} + \item [Achievement or responsibility 3] + \vspace{1pt} + \item [Achievement or responsibility 4] +\end{itemize}}} + +\vspace{3pt} + +% --- Previous Role --- +\item{\cventry{[YYYY--YYYY]}{[Job Title]}{[Company]}{[City, Country]}{}{\vspace{1pt} +\begin{itemize} + \item [Achievement or responsibility 1] + \vspace{1pt} + \item [Achievement or responsibility 2] + \vspace{1pt} + \item [Achievement or responsibility 3] +\end{itemize}}} + +\vspace{3pt} + +% --- Earlier Role --- +\item{\cventry{[YYYY--YYYY]}{[Job Title]}{[Company]}{[City, Country]}{}{\vspace{1pt} +\begin{itemize} + \item [Achievement or responsibility 1] + \vspace{1pt} + \item [Achievement or responsibility 2] +\end{itemize}}} + +\end{itemize} + +% ============================================================ +% EDUCATION +% ============================================================ + +\section{Education} +\vspace{1pt} +\begin{itemize} + +\item{\cventry{[YYYY--YYYY]}{[Degree] in [Field]}{[Institution]}{[City, Country]}{}{\vspace{1pt} +Thesis: ``[Thesis Title].'' [Brief description of research focus.] +}} + +\vspace{3pt} + +\item{\cventry{[YYYY--YYYY]}{[Degree] in [Field]}{[Institution]}{[City, Country]}{}{\vspace{1pt} +[Brief description or key topics.] +}} + +\end{itemize} + +% ============================================================ +% LANGUAGES +% ============================================================ + +\section{Languages} +\vspace{1pt} +\begin{itemize} +\item [Language 1] (native), [Language 2] (fluent), [Language 3] (intermediate). +\end{itemize} + +% ============================================================ +% PUBLICATIONS (optional) +% ============================================================ + +\section{Publications} +\vspace{1pt} +\begin{itemize} +\item [Author(s)] ([Year]). [Title]. [Journal/Conference]. \href{[DOI_URL]}{DOI link} +\end{itemize} + +% ============================================================ +% HONORS AND AWARDS (optional) +% ============================================================ + +\section{Honors and Awards} +\vspace{1pt} +\begin{itemize} +\item{\textbf{[Award Name]} -- [Event/Organization] ([Year]).} +\end{itemize} + +% ============================================================ +% REFERENCES +% ============================================================ + +\section{References} +\vspace{1pt} +\begin{itemize} +\item{Available upon request.} +\end{itemize} + +\end{document} diff --git a/job_scraper/.gitkeep b/job_scraper/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/job_search_tracker.csv b/job_search_tracker.csv new file mode 100644 index 0000000..c92b725 --- /dev/null +++ b/job_search_tracker.csv @@ -0,0 +1 @@ +date,company,sector,role,role_type,channel,status,contact_person,fit_rating,notes,cv_file,cover_letter_file,source diff --git a/salary_lookup.py b/salary_lookup.py new file mode 100644 index 0000000..f01ecd3 --- /dev/null +++ b/salary_lookup.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +""" +Salary Benchmark Lookup Tool + +Looks up company salary data from a user-provided dataset. +Supports any salary data source — union statistics, Glassdoor exports, +manually collected benchmarks, etc. + +This tool requires a data file (salary_data.json) that you create +from your own salary data. See tools/README_SALARY_TOOL.md for +instructions on the expected format and how to convert from Excel. + +Usage: + python salary_lookup.py "Company Name" + python salary_lookup.py "Company Name" --city "København" + python salary_lookup.py "Company Name" --json + python salary_lookup.py --list-all +""" + +import json +import sys +import re +import argparse +import unicodedata +from pathlib import Path + +DATA_FILE = Path(__file__).parent / "salary_data.json" + +# Common Danish <-> anglicized spelling variants +SPELLING_VARIANTS = { + "ø": "o", "æ": "ae", "å": "aa", + "ö": "o", "ä": "ae", "ü": "u", +} + +# Legal suffixes and noise to strip when matching company names +STRIP_PATTERNS = [ + r"\ba/s\b", r"\baps\b", r"\bi/s\b", r"\bp/s\b", r"\bk/s\b", + r"\bivs\b", r"\bamba\b", r"\ba\.m\.b\.a\.\b", + r"\(vg\)", r"\(.*?\)", # (VG) and other parentheticals + r"\bdanmark\b", r"\bdenmark\b", r"\bscandinavia\b", r"\bnordic\b", + r"\bgroup\b", r"\bholding\b", + r",\s*.*$", # everything after comma (sub-entities) +] + + +def load_data(): + if not DATA_FILE.exists(): + print("Error: salary_data.json not found.", file=sys.stderr) + print("", file=sys.stderr) + print("This tool requires a salary data file.", file=sys.stderr) + print("See tools/README_SALARY_TOOL.md for setup instructions.", file=sys.stderr) + print("", file=sys.stderr) + print("If you don't have salary data, the salary lookup", file=sys.stderr) + print("step will be skipped during /apply.", file=sys.stderr) + sys.exit(1) + with open(DATA_FILE, "r", encoding="utf-8") as f: + return json.load(f) + + +def normalize(s): + """Normalize string for robust fuzzy matching.""" + s = s.lower().strip() + for pat in STRIP_PATTERNS: + s = re.sub(pat, "", s) + s = re.sub(r"[^a-zæøåöäü0-9]", "", s) + return s.strip() + + +def anglicize(s): + """Convert Danish/Nordic characters to anglicized equivalents.""" + s = s.lower() + for danish, english in SPELLING_VARIANTS.items(): + s = s.replace(danish, english) + return s + + +def extract_core_words(s): + """Extract meaningful words from a company name, ignoring noise.""" + s = s.lower() + for pat in STRIP_PATTERNS: + s = re.sub(pat, "", s) + words = re.findall(r"[a-zæøåöäü0-9]+", s) + return [w for w in words if len(w) > 1] + + +def match_score(query, entry_name): + """Compute a match score between 0 and 100 for ranking results.""" + q_norm = normalize(query) + n_norm = normalize(entry_name) + + if not q_norm or not n_norm: + return 0 + + if q_norm == n_norm: + return 100 + + if q_norm in n_norm: + ratio = len(q_norm) / len(n_norm) + if len(q_norm) <= 4 and ratio < 0.5: + q_words = set(extract_core_words(query)) + n_words = set(extract_core_words(entry_name)) + if not q_words & n_words: + pass + else: + return 80 + int(ratio * 10) + else: + return 80 + int(ratio * 10) + if n_norm in q_norm: + ratio = len(n_norm) / len(q_norm) + if len(n_norm) <= 4 and ratio < 0.5: + pass + else: + return 80 + int(ratio * 10) + + q_ang = anglicize(q_norm) + n_ang = anglicize(n_norm) + if q_ang == n_ang: + return 85 + if q_ang in n_ang or n_ang in q_ang: + shorter = min(len(q_ang), len(n_ang)) + longer = max(len(q_ang), len(n_ang)) + if shorter <= 4 and shorter / longer < 0.5: + q_words_ang = {anglicize(w) for w in extract_core_words(query)} + n_words_ang = {anglicize(w) for w in extract_core_words(entry_name)} + if q_words_ang & n_words_ang: + return 75 + else: + return 75 + + q_words = set(extract_core_words(query)) + n_words = set(extract_core_words(entry_name)) + if not q_words or not n_words: + return 0 + + overlap = q_words & n_words + if not overlap: + q_words_ang = {anglicize(w) for w in q_words} + n_words_ang = {anglicize(w) for w in n_words} + overlap = q_words_ang & n_words_ang + + if overlap: + if len(q_words) == 1: + q_word = list(q_words)[0] + if q_word in n_words or anglicize(q_word) in {anglicize(w) for w in n_words}: + return 70 + else: + return 0 + + coverage = len(overlap) / len(q_words) + return int(30 + coverage * 40) + + return 0 + + +def search_company(data, query, city=None): + """Search for a company by name. Returns matching entries sorted by relevance.""" + companies = data.get("companies", []) + scored = [] + + for entry in companies: + if city: + city_lower = city.lower() + entry_city = entry.get("city", "").lower() + if city_lower not in entry_city and anglicize(city_lower) not in anglicize(entry_city): + continue + + score = match_score(query, entry["company"]) + if score > 0: + scored.append((score, entry)) + + scored.sort(key=lambda x: (-x[0], x[1]["company"])) + + min_score = 30 + return [entry for score, entry in scored if score >= min_score] + + +def format_entry(entry, metadata): + """Format a single company entry for display.""" + lines = [] + lines.append(f"\n{'='*60}") + lines.append(f" {entry['company']}") + if entry.get("city"): + lines.append(f" Location: {entry['city']}") + lines.append(f"{'='*60}") + + # Get category data (everything except company/city fields) + categories = entry.get("categories", {}) + if not categories: + # Fallback: treat any numeric fields as categories + skip_keys = {"company", "city", "categories"} + for key, value in entry.items(): + if key not in skip_keys and isinstance(value, dict): + categories[key] = value + + if categories: + index_label = metadata.get("index_label", "Index") + baseline = metadata.get("index_baseline", 100) + + lines.append(f" {'Category':<22} {'Count':>6} {index_label:>8} {'vs Baseline':>10}") + lines.append(f" {'-'*50}") + + for label, data in categories.items(): + display_label = label.replace("_", " ").title() + count = data.get("count") + index = data.get("index") + if count is not None or index is not None: + count_str = str(count) if count else "-" + if index is not None: + diff = index - baseline + sign = "+" if diff >= 0 else "" + index_str = f"{index:.1f}" + diff_str = f"{sign}{diff:.1f}%" + else: + index_str = "N/A*" + diff_str = "" + lines.append(f" {display_label:<22} {count_str:>6} {index_str:>8} {diff_str:>10}") + + lines.append(f"\n * N/A = Too few employees to publish (privacy)") + if metadata.get("baseline_description"): + lines.append(f" {metadata['baseline_description']}") + else: + lines.append(f" {index_label} {baseline} = baseline") + else: + # Simple format: just show all non-standard fields + skip_keys = {"company", "city", "categories"} + for key, value in entry.items(): + if key not in skip_keys: + display_key = key.replace("_", " ").title() + lines.append(f" {display_key}: {value}") + + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser(description="Salary Benchmark Lookup") + parser.add_argument("company", nargs="?", help="Company name to search for") + parser.add_argument("--city", help="Filter by city name") + parser.add_argument("--json", action="store_true", help="Output as JSON") + parser.add_argument("--list-all", action="store_true", help="List all companies") + args = parser.parse_args() + + data = load_data() + metadata = data.get("metadata", {}) + companies = data.get("companies", []) + + if args.list_all: + for entry in companies: + city = entry.get("city", "") + city_str = f" ({city})" if city else "" + print(f"{entry['company']}{city_str}") + return + + if not args.company: + parser.print_help() + sys.exit(1) + + results = search_company(data, args.company, args.city) + + if not results: + print(f"No results found for '{args.company}'") + if args.city: + print(f" (filtered by city: {args.city})") + print("\nTry a shorter or different name. Company names in the dataset") + print("may include legal suffixes like 'A/S' or 'ApS'.") + sys.exit(1) + + if args.json: + print(json.dumps(results, ensure_ascii=False, indent=2)) + else: + print(f"\nFound {len(results)} match(es) for '{args.company}':") + for entry in results: + print(format_entry(entry, metadata)) + print() + + +if __name__ == "__main__": + main() diff --git a/tools/README_SALARY_TOOL.md b/tools/README_SALARY_TOOL.md new file mode 100644 index 0000000..4b023ec --- /dev/null +++ b/tools/README_SALARY_TOOL.md @@ -0,0 +1,120 @@ +# Salary Benchmark Tool + +## What is this? + +The salary lookup tool (`salary_lookup.py`) lets you benchmark company salaries against a baseline from your own data. It's used during the `/apply` workflow to show how a company's compensation compares to market rates. + +**This tool is optional.** If you don't have salary data, the salary step is simply skipped during `/apply`. + +## How it works + +The tool reads a `salary_data.json` file in the repo root containing company salary benchmarks. It uses fuzzy matching to find companies by name, handling Danish/Nordic characters, legal suffixes (A/S, ApS), and common spelling variations. + +The data format supports any index-based or absolute salary data. For example: +- Index 100 = median salary, higher is better +- Absolute salary values in your currency +- Any custom metric you want to track + +## Data format + +The tool expects `salary_data.json` with this structure: + +```json +{ + "metadata": { + "source": "My Union Statistics 2025", + "index_baseline": 100, + "index_label": "Index", + "baseline_description": "Index 100 = median salary for private sector" + }, + "companies": [ + { + "company": "Novo Nordisk A/S", + "city": "Bagsværd", + "categories": { + "all_employees": { "count": 500, "index": 108.5 }, + "engineering": { "count": 120, "index": 112.3 } + } + }, + { + "company": "Ørsted A/S", + "city": "Fredericia", + "categories": { + "all_employees": { "count": 200, "index": 105.2 } + } + } + ] +} +``` + +### Fields + +- **metadata.source**: Where the data comes from (for reference) +- **metadata.index_baseline**: The baseline value (e.g., 100 for index-based data) +- **metadata.index_label**: Label for the index column in output +- **metadata.baseline_description**: Human-readable explanation of the baseline +- **companies[].company**: Company name (required) +- **companies[].city**: City/location (optional, used for filtering) +- **companies[].categories**: Named salary categories, each with `count` and/or `index` + +## Setup options + +### Option A: Create salary_data.json manually + +Create the file by hand with data from any source: union statistics, Glassdoor, salary surveys, networking, or personal research. + +### Option B: Convert from Excel + +If you have salary data in an Excel file: + +```bash +pip install openpyxl +python tools/convert_salary_excel.py path/to/salary-data.xlsx \ + --source "My Salary Data 2025" \ + --baseline 100 \ + --baseline-desc "Index 100 = median salary" +``` + +The converter auto-detects the Excel layout: +- Looks for a "Company"/"Firma" column and an optional "City"/"By" column +- Treats remaining columns as salary data (auto-pairs count/index columns) + +### Option C: Build from research + +Start with an empty template and add companies as you research them: + +```json +{ + "metadata": { + "source": "Personal research", + "index_baseline": 0, + "index_label": "Monthly salary (DKK)", + "baseline_description": "Approximate monthly salary before tax" + }, + "companies": [ + { + "company": "Example Corp", + "city": "Copenhagen", + "categories": { + "entry_level": { "index": 42000 }, + "senior": { "index": 55000 } + } + } + ] +} +``` + +## Usage + +```bash +python salary_lookup.py "Novo Nordisk" +python salary_lookup.py "Ørsted" --city "Fredericia" +python salary_lookup.py "COWI" --json +python salary_lookup.py --list-all +``` + +## Important notes + +- The data file (`salary_data.json`) is **excluded from git** (see `.gitignore`). Your salary data may be proprietary or confidential. +- If the data file is missing, `salary_lookup.py` exits with a helpful error message and the `/apply` workflow skips the salary benchmark step. +- The fuzzy matcher handles Danish company name variations: legal suffixes, Nordic characters, anglicized spellings, and partial matches. diff --git a/tools/convert_salary_excel.py b/tools/convert_salary_excel.py new file mode 100644 index 0000000..4e8b8fd --- /dev/null +++ b/tools/convert_salary_excel.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +""" +Convert salary data from Excel to JSON format. + +This script converts an Excel file containing company salary data +into the JSON format expected by salary_lookup.py. + +Prerequisites: + pip install openpyxl + +Usage: + python tools/convert_salary_excel.py <path-to-excel-file> + python tools/convert_salary_excel.py <path-to-excel-file> --source "My Union Stats 2025" + python tools/convert_salary_excel.py <path-to-excel-file> --baseline 100 --baseline-desc "Index 100 = median salary" + +The output file (salary_data.json) will be written to the repository root. + +Expected Excel format: + - A header row with column names + - A "Company" or "Firma" column (required) + - An optional "City" or "By" column + - Any number of numeric data columns (salary index, count, etc.) + +The script auto-detects the header row and column layout. For Excel files +with paired count/index columns per category, it groups them automatically. +""" + +import json +import sys +import argparse +from pathlib import Path + +try: + import openpyxl +except ImportError: + print("Error: openpyxl is required. Install it with: pip install openpyxl", file=sys.stderr) + sys.exit(1) + + +# Column name patterns for auto-detection +COMPANY_PATTERNS = {"firma", "company", "virksomhed", "employer", "arbejdsgiver"} +CITY_PATTERNS = {"by", "city", "kommune", "location", "lokation", "sted"} +COUNT_PATTERNS = {"antal", "count", "number", "n", "employees", "medarbejdere"} +INDEX_PATTERNS = {"indeks", "index", "idx", "salary", "løn", "median", "average", "gennemsnit"} + + +def detect_column_type(header): + """Detect whether a column header refers to count or index data.""" + h = header.lower().strip() + for p in COUNT_PATTERNS: + if p in h: + return "count" + for p in INDEX_PATTERNS: + if p in h: + return "index" + return None + + +def parse_sheet(ws, sheet_label=None): + """Parse a single worksheet into a list of company entries and detected categories.""" + # Find header row + header_row = None + for row_idx, row in enumerate(ws.iter_rows(min_row=1, max_row=10, values_only=False), start=1): + for cell in row: + if cell.value and str(cell.value).strip().lower() in COMPANY_PATTERNS: + header_row = row_idx + break + if header_row: + break + + if header_row is None: + print(f"Warning: Could not find header row in sheet '{ws.title}'. Skipping.", file=sys.stderr) + return [] + + # Read headers + headers = [] + for cell in ws[header_row]: + headers.append(str(cell.value).strip() if cell.value else "") + + # Find company and city columns + company_col = None + city_col = None + for i, h in enumerate(headers): + h_lower = h.lower() + if h_lower in COMPANY_PATTERNS: + company_col = i + elif h_lower in CITY_PATTERNS: + city_col = i + + if company_col is None: + print(f"Warning: Could not find company column in sheet '{ws.title}'.", file=sys.stderr) + return [] + + # Identify data columns (everything that's not company/city) + data_cols = [] + for i, h in enumerate(headers): + if i == company_col or i == city_col or not h: + continue + data_cols.append((i, h)) + + # Try to detect paired count/index columns per category + # Heuristic: if columns come in pairs and alternate count/index, group them + categories = [] + i = 0 + while i < len(data_cols): + col_idx, col_header = data_cols[i] + col_type = detect_column_type(col_header) + + if i + 1 < len(data_cols): + next_col_idx, next_col_header = data_cols[i + 1] + next_col_type = detect_column_type(next_col_header) + + # If we have a count/index pair, group them + if col_type == "count" and next_col_type == "index": + # Use the header minus the count/index suffix as category name + cat_name = col_header + for p in COUNT_PATTERNS: + cat_name = cat_name.lower().replace(p, "").strip(" _-") + if not cat_name: + cat_name = f"category_{len(categories)+1}" + categories.append({ + "name": cat_name, + "count_col": col_idx, + "index_col": next_col_idx, + }) + i += 2 + continue + elif col_type == "index" and next_col_type == "count": + cat_name = col_header + for p in INDEX_PATTERNS: + cat_name = cat_name.lower().replace(p, "").strip(" _-") + if not cat_name: + cat_name = f"category_{len(categories)+1}" + categories.append({ + "name": cat_name, + "index_col": col_idx, + "count_col": next_col_idx, + }) + i += 2 + continue + + # Single column - treat as a standalone value + categories.append({ + "name": col_header.lower().replace(" ", "_"), + "value_col": col_idx, + }) + i += 1 + + # Parse data rows + companies = [] + for row in ws.iter_rows(min_row=header_row + 1, values_only=True): + if not row[company_col]: + continue + + company_name = str(row[company_col]).strip() + city_name = str(row[city_col]).strip() if city_col is not None and row[city_col] else "" + + entry = { + "company": company_name, + "city": city_name, + "categories": {}, + } + + for cat in categories: + cat_name = cat["name"] + if "count_col" in cat and "index_col" in cat: + count_val = None + index_val = None + if cat["count_col"] < len(row) and row[cat["count_col"]] is not None: + try: + count_val = int(row[cat["count_col"]]) + except (ValueError, TypeError): + pass + if cat["index_col"] < len(row) and row[cat["index_col"]] is not None: + try: + index_val = float(row[cat["index_col"]]) + except (ValueError, TypeError): + pass + entry["categories"][cat_name] = {"count": count_val, "index": index_val} + elif "value_col" in cat: + if cat["value_col"] < len(row) and row[cat["value_col"]] is not None: + val = row[cat["value_col"]] + try: + val = float(val) + except (ValueError, TypeError): + val = str(val) + entry["categories"][cat_name] = {"index": val} + + companies.append(entry) + + return companies + + +def main(): + parser = argparse.ArgumentParser( + description="Convert salary Excel data to JSON" + ) + parser.add_argument("excel_file", help="Path to the Excel file with salary data") + parser.add_argument( + "--output", default=None, + help="Output JSON file path (default: salary_data.json in repo root)", + ) + parser.add_argument( + "--source", default=None, + help="Name of the data source (e.g., 'Union Statistics 2025')", + ) + parser.add_argument( + "--baseline", type=float, default=100, + help="Baseline value for index comparison (default: 100)", + ) + parser.add_argument( + "--baseline-desc", default=None, + help="Description of what the baseline means (e.g., 'Index 100 = median salary')", + ) + args = parser.parse_args() + + excel_path = Path(args.excel_file) + if not excel_path.exists(): + print(f"Error: File not found: {excel_path}", file=sys.stderr) + sys.exit(1) + + output_path = Path(args.output) if args.output else Path(__file__).parent.parent / "salary_data.json" + + print(f"Reading: {excel_path}") + wb = openpyxl.load_workbook(excel_path, read_only=True, data_only=True) + + all_companies = [] + for sheet_name in wb.sheetnames: + print(f" Parsing sheet: {sheet_name}") + ws = wb[sheet_name] + companies = parse_sheet(ws, sheet_label=sheet_name) + all_companies.extend(companies) + + wb.close() + + if not all_companies: + print("Error: No data could be parsed from the Excel file.", file=sys.stderr) + print("Make sure the Excel file has a header row with a 'Company'/'Firma' column.", file=sys.stderr) + sys.exit(1) + + # Build output + output = { + "metadata": { + "source": args.source or excel_path.stem, + "index_baseline": args.baseline, + "index_label": "Index", + "baseline_description": args.baseline_desc or f"Index {args.baseline} = baseline", + }, + "companies": all_companies, + } + + with open(output_path, "w", encoding="utf-8") as f: + json.dump(output, f, ensure_ascii=False, indent=2) + + print(f"\nDone! Wrote {len(all_companies)} company entries to {output_path}") + + +if __name__ == "__main__": + main()