import { defineCommand, option } from "@bunli/core" import { z } from "zod" import { htmlFetch, writeError } from "../helpers.js" const BASE_URL = "https://www.jobindex.dk" interface DetailResult { id: string title: string company: string | null companyUrl: string | null location: string | null date: string | null deadline: string | null employmentType: string | null hours: string | null applyUrl: string | null url: string description: string | null } /** * Convert a Unicode code point to a string. Uses `fromCodePoint` (not * `fromCharCode`) so supplementary-plane code points (e.g. emoji, U+1F600) * decode correctly, and drops out-of-range values instead of throwing. */ function numericEntity(cp: number): string { return cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : "" } /** * Decode HTML entities in text */ function decodeHtmlEntities(text: string): string { return text .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/'/g, "'") // Numeric character references: decimal (é) and hexadecimal (é). .replace(/&#(\d+);/g, (_, dec) => numericEntity(parseInt(dec, 10))) .replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex) => numericEntity(parseInt(hex, 16))) .replace(/ /g, " ") } /** * Strip HTML tags from text */ function stripTags(html: string): string { return html.replace(/<[^>]+>/g, "").trim() } /** * Extract job ID from URL or return as-is if already an ID */ function extractIdFromUrl(url: string): string { // Match IDs like h1647303, r13677312, etc. const match = url.match(/\/jobannonce\/([a-zA-Z]\d+)/) if (match) return match[1] return url } function buildUrl(idOrUrl: string): { url: string; id: string } { if (idOrUrl.startsWith("http")) { const id = extractIdFromUrl(idOrUrl) return { url: idOrUrl, id } } // It's a bare ID const url = `${BASE_URL}/jobannonce/${idOrUrl}` return { url, id: idOrUrl } } /** * Parse the detail HTML page using regex to avoid node-html-parser nesting bugs. */ function parseDetailPage(html: string, url: string, id: string): DetailResult { // Title: extract from

tag const h1Match = html.match(/]*>([\s\S]*?)<\/h1>/i) const title = h1Match ? decodeHtmlEntities(stripTags(h1Match[1])) : "" if (!title) { throw new Error("Failed to parse job listing HTML") } // Company and companyUrl from jix-toolbar-top__company section let company: string | null = null let companyUrl: string | null = null const companySection = html.match(/class="jix-toolbar-top__company"[^>]*>([\s\S]*?)<\/div>/i) if (companySection) { const linkMatch = companySection[1].match(/<[Aa][^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/[Aa]>/i) if (linkMatch) { company = decodeHtmlEntities(stripTags(linkMatch[2])) || null companyUrl = linkMatch[1] || null } } // Location from jix_robotjob--area span let location: string | null = null const locMatch = html.match(/]+class="jix_robotjob--area"[^>]*>([\s\S]*?)<\/span>/i) if (locMatch) { location = decodeHtmlEntities(stripTags(locMatch[1])) || null } // Date from