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,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()
|
||||
@@ -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] : ""
|
||||
}
|
||||
Reference in New Issue
Block a user