export const BASE_URL = "https://jobdanmark.dk" export const USER_AGENT = "Mozilla/5.0 (compatible; jobdanmark-cli/1.0)" export async function apiFetch(path: string, params?: Record): Promise { 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: { "User-Agent": USER_AGENT }, signal: AbortSignal.timeout(15000), }) 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 } throw new Error("API request failed after max retries") } export async function apiPost(path: string, body: unknown): Promise { 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", "User-Agent": USER_AGENT, }, body: JSON.stringify(body), signal: AbortSignal.timeout(15000), }) 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 } throw new Error("API request failed after max retries") } /** * Fetch a rendered jobdanmark.dk page as text, with the same 429/5xx backoff, * request timeout, and User-Agent as apiFetch/apiPost. `detail` reads HTML * rather than the JSON API; it used to call fetch() directly with none of the * three, so a rate-limited detail page failed on the first 429 while every * other portal's detail command retried. Returns null on 404 so the caller * keeps its own NOT_FOUND contract. */ export async function htmlFetch(url: string): Promise { const maxRetries = 6 let delay = 500 for (let attempt = 0; attempt <= maxRetries; attempt++) { const response = await fetch(url, { headers: { "Accept": "text/html,application/xhtml+xml", "User-Agent": USER_AGENT, }, signal: AbortSignal.timeout(15000), }) 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) { return null } if (!response.ok) { throw new Error(`API request failed: ${response.status} ${response.statusText}`) } return response.text() } 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() } export function normalizeSlug(input: string): string | null { const trimmed = input.trim() if (!trimmed) return null const match = trimmed.match(/\/job\/([^/?#]+)/) if (match) return match[1] if (/^[a-zA-Z0-9_-]+$/.test(trimmed)) return trimmed return null }