mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 08:36:25 +00:00
Initial release: AI-powered job application framework
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
Mads Lorentzen
co-authored by
Claude Opus 4.6
commit
c66d599d75
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user